Commit babb6e8da2
Verified · cmc
cmd/gitbay/main.go +7
| @@ -243,6 +243,13 @@ func repoCmd() *cobra.Command { | ||
| 243 | 243 | pass("list", "list deploy keys", passOpts{server: []string{"repo", "deploy-key", "list"}, needsRepo: true}), |
| 244 | 244 | pass("remove", "remove a deploy key: <fingerprint>", passOpts{server: []string{"repo", "deploy-key", "remove"}, needsRepo: true}), |
| 245 | 245 | ), |
| 246 | group("mirror", "sync with a foreign remote", | |
| 247 | pass("add", "add a mirror: <https-url> --direction push|pull [--username <u>] [--token-stdin]", | |
| 248 | passOpts{server: []string{"repo", "mirror", "add"}, needsRepo: true, stdinOK: true}), | |
| 249 | pass("list", "list mirrors with sync status", passOpts{server: []string{"repo", "mirror", "list"}, needsRepo: true}), | |
| 250 | pass("remove", "remove a mirror: <id>", passOpts{server: []string{"repo", "mirror", "remove"}, needsRepo: true}), | |
| 251 | pass("sync", "schedule an immediate sync", passOpts{server: []string{"repo", "mirror", "sync"}, needsRepo: true}), | |
| 252 | ), | |
| 246 | 253 | group("topics", "free-form repository tags", |
| 247 | 254 | pass("list", "list topics", passOpts{server: []string{"repo", "topics"}, needsRepo: true}), |
| 248 | 255 | pass("add", "add topics: <topic>...", passOpts{server: []string{"repo", "topics", "add"}, needsRepo: true}), |
cmd/gitbayd/main.go +2
| @@ -21,6 +21,7 @@ import ( | ||
| 21 | 21 | "gitbay.org/gitbay/internal/config" |
| 22 | 22 | "gitbay.org/gitbay/internal/control" |
| 23 | 23 | "gitbay.org/gitbay/internal/mail" |
| 24 | "gitbay.org/gitbay/internal/mirror" | |
| 24 | 25 | "gitbay.org/gitbay/internal/notify" |
| 25 | 26 | "gitbay.org/gitbay/internal/gitd" |
| 26 | 27 | "gitbay.org/gitbay/internal/hookd" |
| @@ -137,6 +138,7 @@ func serveCmd() *cobra.Command { | ||
| 137 | 138 | if cfg.Mail.SMTPHost != "" { |
| 138 | 139 | go notify.New(st, cfg, retryBase).Run(whCtx) |
| 139 | 140 | } |
| 141 | go mirror.New(st, cfg).Run(whCtx) | |
| 140 | 142 | |
| 141 | 143 | errCh := make(chan error, 3) |
| 142 | 144 | if cfg.SSH.Mode == "embedded" { |
docs/admin.org +5
| @@ -103,6 +103,11 @@ contradiction; =--no-host-checks= skips port/path probes. | ||
| 103 | 103 | Serves only public repositories that additionally ran |
| 104 | 104 | =repo settings git-daemon <repo> on=. |
| 105 | 105 | |
| 106 | ** [mirrors] | |
| 107 | =pull_interval_minutes= (15) — how often pull mirrors fetch their | |
| 108 | upstream. Push mirrors sync shortly after each local ref update. | |
| 109 | Mirror URLs pass the same SSRF rules as webhook targets. | |
| 110 | ||
| 106 | 111 | ** [go_import] |
| 107 | 112 | Vanity Go module paths, one per line: ="host/module" = "owner/repo"=. |
| 108 | 113 | Requests with =?go-get=1= at or under the module path answer with the |
docs/users.org +13
| @@ -127,6 +127,19 @@ gitbay repo import you/mirror --from https://github.com/you/repo.git \ | ||
| 127 | 127 | gitbay repo import-issues you/mirror --from you/repo --token-stdin |
| 128 | 128 | #+end_src |
| 129 | 129 | |
| 130 | Mirroring keeps a foreign remote in sync during a gradual migration | |
| 131 | (repo admin; https remotes; the token is stored server-side for the | |
| 132 | recurring sync and never echoed back): | |
| 133 | ||
| 134 | #+begin_src sh | |
| 135 | gitbay repo mirror add you/project https://github.com/you/project.git \ | |
| 136 | --direction push --token-stdin # propagate after every local push | |
| 137 | gitbay repo mirror add you/copy https://github.com/them/theirs.git \ | |
| 138 | --direction pull # follow upstream; local pushes refused | |
| 139 | gitbay repo mirror list # sync status and last error, per mirror | |
| 140 | gitbay repo mirror sync / remove <id> | |
| 141 | #+end_src | |
| 142 | ||
| 130 | 143 | * Organizations |
| 131 | 144 | |
| 132 | 145 | Orgs share the owner namespace with users and own repositories at |
e2e/mirror_test.go added +161
| @@ -0,0 +1,161 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "net/http/cgi" | |
| 5 | "net/http/httptest" | |
| 6 | "os" | |
| 7 | "os/exec" | |
| 8 | "path/filepath" | |
| 9 | "strings" | |
| 10 | "testing" | |
| 11 | "time" | |
| 12 | ) | |
| 13 | ||
| 14 | // gitHTTPRemote serves a bare repository over smart HTTP (push enabled), | |
| 15 | // standing in for GitHub in mirror tests. | |
| 16 | func gitHTTPRemote(t *testing.T) (url, bareDir string) { | |
| 17 | t.Helper() | |
| 18 | parent := t.TempDir() | |
| 19 | bareDir = filepath.Join(parent, "remote.git") | |
| 20 | for _, args := range [][]string{ | |
| 21 | {"init", "--bare", "--initial-branch=main", bareDir}, | |
| 22 | {"-C", bareDir, "config", "http.receivepack", "true"}, | |
| 23 | } { | |
| 24 | if out, err := exec.Command("git", args...).CombinedOutput(); err != nil { | |
| 25 | t.Fatalf("git %v: %v\n%s", args, err, out) | |
| 26 | } | |
| 27 | } | |
| 28 | execPath, err := exec.Command("git", "--exec-path").Output() | |
| 29 | if err != nil { | |
| 30 | t.Fatal(err) | |
| 31 | } | |
| 32 | h := &cgi.Handler{ | |
| 33 | Path: filepath.Join(strings.TrimSpace(string(execPath)), "git-http-backend"), | |
| 34 | Env: []string{"GIT_PROJECT_ROOT=" + parent, "GIT_HTTP_EXPORT_ALL=1"}, | |
| 35 | } | |
| 36 | srv := httptest.NewServer(h) | |
| 37 | t.Cleanup(srv.Close) | |
| 38 | return srv.URL + "/remote.git", bareDir | |
| 39 | } | |
| 40 | ||
| 41 | func waitFor(t *testing.T, what string, cond func() bool) { | |
| 42 | t.Helper() | |
| 43 | deadline := time.Now().Add(15 * time.Second) | |
| 44 | for time.Now().Before(deadline) { | |
| 45 | if cond() { | |
| 46 | return | |
| 47 | } | |
| 48 | time.Sleep(150 * time.Millisecond) | |
| 49 | } | |
| 50 | t.Fatalf("timed out waiting for %s", what) | |
| 51 | } | |
| 52 | ||
| 53 | func TestMirrors(t *testing.T) { | |
| 54 | t.Setenv("GITBAY_MIRROR_TICK", "200ms") | |
| 55 | inst := startInstanceWith(t, "[webhooks]\nallow_local = true\n") | |
| 56 | aliceKey := inst.newKey(t, "alice") | |
| 57 | bobKey := inst.newKey(t, "bob") | |
| 58 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | |
| 59 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") | |
| 60 | ||
| 61 | // ---- push mirror: local pushes propagate to the remote. | |
| 62 | remoteURL, remoteBare := gitHTTPRemote(t) | |
| 63 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | |
| 64 | t.Fatalf("repo create: %s", errOut) | |
| 65 | } | |
| 66 | if _, _, code := inst.ssh(t, bobKey, "", "repo", "mirror", "add", "alice/app", remoteURL, "--direction", "push"); code != 4 { | |
| 67 | t.Fatal("non-admin added a mirror") | |
| 68 | } | |
| 69 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "mirror", "add", "alice/app", remoteURL, "--direction", "push"); code != 0 { | |
| 70 | t.Fatalf("mirror add: %s", errOut) | |
| 71 | } | |
| 72 | ||
| 73 | work := t.TempDir() | |
| 74 | env := inst.gitEnv(aliceKey) | |
| 75 | mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w") | |
| 76 | dir := filepath.Join(work, "w") | |
| 77 | os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644) | |
| 78 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | |
| 79 | mustGit(t, dir, env, "add", ".") | |
| 80 | mustGit(t, dir, env, "commit", "-q", "-m", "base") | |
| 81 | mustGit(t, dir, env, "push", "-q", "origin", "main") | |
| 82 | head := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD")) | |
| 83 | ||
| 84 | waitFor(t, "push mirror sync", func() bool { | |
| 85 | out, _ := exec.Command("git", "-C", remoteBare, "rev-parse", "refs/heads/main").Output() | |
| 86 | return strings.TrimSpace(string(out)) == head | |
| 87 | }) | |
| 88 | out, _, _ := inst.ssh(t, aliceKey, "", "repo", "mirror", "list", "alice/app", "--json") | |
| 89 | if !strings.Contains(out, `"last_sync":"`) || strings.Contains(out, "token") || | |
| 90 | strings.Contains(out, `"last_error":"`) { | |
| 91 | t.Fatalf("mirror list after sync: %s", out) | |
| 92 | } | |
| 93 | ||
| 94 | // ---- pull mirror: local repo follows the remote and refuses pushes. | |
| 95 | srcURL, srcBare := gitHTTPRemote(t) | |
| 96 | seed := t.TempDir() | |
| 97 | mustGit(t, seed, env, "clone", "-q", srcBare, "s") | |
| 98 | sdir := filepath.Join(seed, "s") | |
| 99 | os.WriteFile(filepath.Join(sdir, "up.txt"), []byte("upstream\n"), 0o644) | |
| 100 | mustGit(t, sdir, env, "checkout", "-q", "-b", "main") | |
| 101 | mustGit(t, sdir, env, "add", ".") | |
| 102 | mustGit(t, sdir, env, "commit", "-q", "-m", "upstream commit") | |
| 103 | mustGit(t, sdir, env, "push", "-q", "origin", "main") | |
| 104 | upstreamHead := strings.TrimSpace(mustGit(t, sdir, env, "rev-parse", "HEAD")) | |
| 105 | ||
| 106 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/follow"); code != 0 { | |
| 107 | t.Fatal("repo create failed") | |
| 108 | } | |
| 109 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "mirror", "add", "alice/follow", srcURL, "--direction", "pull"); code != 0 { | |
| 110 | t.Fatalf("pull mirror add: %s", errOut) | |
| 111 | } | |
| 112 | waitFor(t, "pull mirror sync", func() bool { | |
| 113 | out, _, _ := inst.ssh(t, aliceKey, "", "repo", "log", "alice/follow", "--json") | |
| 114 | return strings.Contains(out, upstreamHead) | |
| 115 | }) | |
| 116 | // Local pushes are refused while the pull mirror exists. | |
| 117 | work2 := t.TempDir() | |
| 118 | mustGit(t, work2, env, "clone", "-q", inst.sshURL("alice/follow"), "f") | |
| 119 | fdir := filepath.Join(work2, "f") | |
| 120 | os.WriteFile(filepath.Join(fdir, "no.txt"), []byte("n\n"), 0o644) | |
| 121 | mustGit(t, fdir, env, "add", ".") | |
| 122 | mustGit(t, fdir, env, "commit", "-q", "-m", "local change") | |
| 123 | if out, code := gitRun(t, fdir, env, "push", "origin", "HEAD:main"); code == 0 || !strings.Contains(out, "pull mirror") { | |
| 124 | t.Fatalf("push to pull mirror: exit %d\n%s", code, out) | |
| 125 | } | |
| 126 | // Removing the mirror restores pushes. | |
| 127 | out, _, _ = inst.ssh(t, aliceKey, "", "repo", "mirror", "list", "alice/follow", "--json") | |
| 128 | id := out[strings.Index(out, `"id":`)+5:] | |
| 129 | id = id[:strings.IndexAny(id, ",}")] | |
| 130 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "mirror", "remove", "alice/follow", strings.TrimSpace(id)); code != 0 { | |
| 131 | t.Fatal("mirror remove failed") | |
| 132 | } | |
| 133 | mustGit(t, fdir, env, "push", "-q", "origin", "HEAD:main") | |
| 134 | ||
| 135 | // ---- failure visibility: a dead remote records an error. | |
| 136 | deadURL := remoteURL + "-gone" | |
| 137 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "mirror", "add", "alice/follow", deadURL, "--direction", "push"); code != 0 { | |
| 138 | t.Fatal("dead mirror add failed") | |
| 139 | } | |
| 140 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "mirror", "sync", "alice/follow"); code != 0 { | |
| 141 | t.Fatal("mirror sync failed") | |
| 142 | } | |
| 143 | waitFor(t, "failure recorded", func() bool { | |
| 144 | out, _, _ := inst.ssh(t, aliceKey, "", "repo", "mirror", "list", "alice/follow", "--json") | |
| 145 | return strings.Contains(out, `"last_error":"git push`) | |
| 146 | }) | |
| 147 | } | |
| 148 | ||
| 149 | func TestMirrorSSRFGuard(t *testing.T) { | |
| 150 | inst := startInstance(t) // default posture: allow_local off | |
| 151 | aliceKey := inst.newKey(t, "alice") | |
| 152 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | |
| 153 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | |
| 154 | t.Fatal("repo create failed") | |
| 155 | } | |
| 156 | _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "mirror", "add", "alice/app", | |
| 157 | "http://127.0.0.1:9999/x.git", "--direction", "push") | |
| 158 | if code != 2 || !strings.Contains(errOut, "SSRF") { | |
| 159 | t.Fatalf("local mirror allowed: exit %d, %s", code, errOut) | |
| 160 | } | |
| 161 | } | |
internal/config/config.go +6
| @@ -23,6 +23,7 @@ type Config struct { | ||
| 23 | 23 | Webhooks Webhooks `toml:"webhooks"` |
| 24 | 24 | Limits Limits `toml:"limits"` |
| 25 | 25 | Mail Mail `toml:"mail"` |
| 26 | Mirrors Mirrors `toml:"mirrors"` | |
| 26 | 27 | // GoImport maps vanity Go module paths to repositories, e.g. |
| 27 | 28 | // "gitbay.org/gitbay" = "krz/gitbay". Requests carrying ?go-get=1 |
| 28 | 29 | // under a mapped path get a go-import meta tag. |
| @@ -80,6 +81,10 @@ type Webhooks struct { | ||
| 80 | 81 | AllowLocal bool `toml:"allow_local"` |
| 81 | 82 | } |
| 82 | 83 | |
| 84 | type Mirrors struct { | |
| 85 | PullIntervalMinutes int `toml:"pull_interval_minutes"` | |
| 86 | } | |
| 87 | ||
| 83 | 88 | type Limits struct { |
| 84 | 89 | MaxPackBytes int64 `toml:"max_pack_bytes"` |
| 85 | 90 | MaxBlobBytes int64 `toml:"max_blob_bytes"` |
| @@ -106,6 +111,7 @@ func Default() Config { | ||
| 106 | 111 | Mode: "closed", |
| 107 | 112 | }, |
| 108 | 113 | GitDaemon: GitDaemon{Port: 9418}, |
| 114 | Mirrors: Mirrors{PullIntervalMinutes: 15}, | |
| 109 | 115 | Limits: Limits{ |
| 110 | 116 | MaxPackBytes: 2 << 30, // 2 GiB |
| 111 | 117 | MaxBlobBytes: 100 << 20, |
internal/control/mirrorcmd.go added +176
| @@ -0,0 +1,176 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "bufio" | |
| 5 | "errors" | |
| 6 | "fmt" | |
| 7 | "io" | |
| 8 | "strconv" | |
| 9 | "strings" | |
| 10 | ||
| 11 | "gitbay.org/gitbay/internal/policy" | |
| 12 | "gitbay.org/gitbay/internal/protocol" | |
| 13 | "gitbay.org/gitbay/internal/store" | |
| 14 | "gitbay.org/gitbay/internal/webhook" | |
| 15 | ) | |
| 16 | ||
| 17 | func init() { | |
| 18 | register(Command{Path: []string{"repo", "mirror", "add"}, | |
| 19 | Summary: "mirror to or from a remote: repo mirror add <owner/name> <https-url> --direction push|pull [--username <u>] [--token-stdin]", | |
| 20 | ReadsStdin: true, SSHOnly: true, Run: runMirrorAdd}) | |
| 21 | register(Command{Path: []string{"repo", "mirror", "list"}, | |
| 22 | Summary: "list mirrors with sync status: repo mirror list <owner/name>", ReadOnly: true, Run: runMirrorList}) | |
| 23 | register(Command{Path: []string{"repo", "mirror", "remove"}, | |
| 24 | Summary: "remove a mirror: repo mirror remove <owner/name> <id>", Run: runMirrorRemove}) | |
| 25 | register(Command{Path: []string{"repo", "mirror", "sync"}, | |
| 26 | Summary: "schedule an immediate sync: repo mirror sync <owner/name>", Run: runMirrorSync}) | |
| 27 | } | |
| 28 | ||
| 29 | func runMirrorAdd(c *Ctx, args []string) int { | |
| 30 | var path, urlArg, direction, username string | |
| 31 | tokenStdin := false | |
| 32 | for i := 0; i < len(args); i++ { | |
| 33 | switch args[i] { | |
| 34 | case "--direction", "--username": | |
| 35 | if i+1 >= len(args) { | |
| 36 | return c.fail(protocol.ExitUsage, "%s requires a value", args[i]) | |
| 37 | } | |
| 38 | if args[i] == "--direction" { | |
| 39 | direction = args[i+1] | |
| 40 | } else { | |
| 41 | username = args[i+1] | |
| 42 | } | |
| 43 | i++ | |
| 44 | case "--token-stdin": | |
| 45 | tokenStdin = true | |
| 46 | default: | |
| 47 | if path == "" { | |
| 48 | path = args[i] | |
| 49 | } else if urlArg == "" { | |
| 50 | urlArg = args[i] | |
| 51 | } else { | |
| 52 | return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i]) | |
| 53 | } | |
| 54 | } | |
| 55 | } | |
| 56 | if path == "" || urlArg == "" || (direction != "push" && direction != "pull") { | |
| 57 | return c.fail(protocol.ExitUsage, "usage: repo mirror add <owner/name> <https-url> --direction push|pull [--username <u>] [--token-stdin]") | |
| 58 | } | |
| 59 | // The worker's git process dials this URL from the server: same SSRF | |
| 60 | // surface as a webhook target, same rules. | |
| 61 | if err := webhook.ValidateURL(urlArg, c.Cfg.Webhooks.AllowLocal); err != nil { | |
| 62 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 63 | } | |
| 64 | repo, code := resolveRepo(c, path, policy.CanAdmin) | |
| 65 | if code >= 0 { | |
| 66 | return code | |
| 67 | } | |
| 68 | token := "" | |
| 69 | if tokenStdin { | |
| 70 | line, err := bufio.NewReader(io.LimitReader(c.Stdin, 4096)).ReadString('\n') | |
| 71 | if err != nil && line == "" { | |
| 72 | return c.fail(protocol.ExitUsage, "--token-stdin given but stdin held no token") | |
| 73 | } | |
| 74 | token = strings.TrimSpace(line) | |
| 75 | } | |
| 76 | id, err := c.Store.AddMirror(repo.ID, direction, urlArg, username, token) | |
| 77 | if err != nil { | |
| 78 | if errors.Is(err, store.ErrExists) { | |
| 79 | return c.fail(protocol.ExitUsage, "that mirror already exists") | |
| 80 | } | |
| 81 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 82 | } | |
| 83 | note := "" | |
| 84 | if direction == "pull" { | |
| 85 | note = "; local pushes are now refused — refs come from the upstream" | |
| 86 | } | |
| 87 | return c.emit(map[string]any{"id": id, "direction": direction, "url": urlArg}, func(w io.Writer) { | |
| 88 | fmt.Fprintf(w, "mirror %d added (%s %s)%s\n", id, direction, urlArg, note) | |
| 89 | }) | |
| 90 | } | |
| 91 | ||
| 92 | func runMirrorList(c *Ctx, args []string) int { | |
| 93 | if len(args) != 1 { | |
| 94 | return c.fail(protocol.ExitUsage, "usage: repo mirror list <owner/name>") | |
| 95 | } | |
| 96 | repo, code := resolveRepo(c, args[0], policy.CanAdmin) | |
| 97 | if code >= 0 { | |
| 98 | return code | |
| 99 | } | |
| 100 | ms, err := c.Store.ListMirrors(repo.ID) | |
| 101 | if err != nil { | |
| 102 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 103 | } | |
| 104 | type out struct { | |
| 105 | ID int64 `json:"id"` | |
| 106 | Direction string `json:"direction"` | |
| 107 | URL string `json:"url"` | |
| 108 | Username string `json:"username,omitempty"` | |
| 109 | Pending bool `json:"pending"` | |
| 110 | LastSync string `json:"last_sync,omitempty"` | |
| 111 | LastError string `json:"last_error,omitempty"` | |
| 112 | } | |
| 113 | var ds []out | |
| 114 | for _, m := range ms { | |
| 115 | // The token never leaves the server, in any encoding. | |
| 116 | ds = append(ds, out{m.ID, m.Direction, m.URL, m.Username, m.Dirty, m.LastSync, m.LastError}) | |
| 117 | } | |
| 118 | return c.emit(ds, func(w io.Writer) { | |
| 119 | for _, d := range ds { | |
| 120 | status := "ok" | |
| 121 | if d.Pending { | |
| 122 | status = "pending" | |
| 123 | } | |
| 124 | if d.LastError != "" { | |
| 125 | status = "error: " + d.LastError | |
| 126 | } | |
| 127 | fmt.Fprintf(w, "%d\t%s\t%s\tlast %s\t%s\n", d.ID, d.Direction, d.URL, orDash(d.LastSync), status) | |
| 128 | } | |
| 129 | }) | |
| 130 | } | |
| 131 | ||
| 132 | func orDash(s string) string { | |
| 133 | if s == "" { | |
| 134 | return "-" | |
| 135 | } | |
| 136 | return s | |
| 137 | } | |
| 138 | ||
| 139 | func runMirrorRemove(c *Ctx, args []string) int { | |
| 140 | if len(args) != 2 { | |
| 141 | return c.fail(protocol.ExitUsage, "usage: repo mirror remove <owner/name> <id>") | |
| 142 | } | |
| 143 | repo, code := resolveRepo(c, args[0], policy.CanAdmin) | |
| 144 | if code >= 0 { | |
| 145 | return code | |
| 146 | } | |
| 147 | id, err := strconv.ParseInt(args[1], 10, 64) | |
| 148 | if err != nil { | |
| 149 | return c.fail(protocol.ExitUsage, "bad mirror id %q", args[1]) | |
| 150 | } | |
| 151 | if err := c.Store.RemoveMirror(repo.ID, id); err != nil { | |
| 152 | if errors.Is(err, store.ErrNotFound) { | |
| 153 | return c.fail(protocol.ExitNotFound, "no mirror %d on %s", id, repo.Path()) | |
| 154 | } | |
| 155 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 156 | } | |
| 157 | return c.emit(map[string]any{"removed": id}, func(w io.Writer) { | |
| 158 | fmt.Fprintf(w, "removed mirror %d\n", id) | |
| 159 | }) | |
| 160 | } | |
| 161 | ||
| 162 | func runMirrorSync(c *Ctx, args []string) int { | |
| 163 | if len(args) != 1 { | |
| 164 | return c.fail(protocol.ExitUsage, "usage: repo mirror sync <owner/name>") | |
| 165 | } | |
| 166 | repo, code := resolveRepo(c, args[0], policy.CanAdmin) | |
| 167 | if code >= 0 { | |
| 168 | return code | |
| 169 | } | |
| 170 | if err := c.Store.MarkMirrorsDirty(repo.ID, ""); err != nil { | |
| 171 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 172 | } | |
| 173 | return c.emit(map[string]string{"sync": "scheduled"}, func(w io.Writer) { | |
| 174 | fmt.Fprintln(w, "sync scheduled; check repo mirror list for the outcome") | |
| 175 | }) | |
| 176 | } | |
internal/control/mr.go +1
| @@ -822,6 +822,7 @@ func runMRMerge(c *Ctx, args []string) int { | ||
| 822 | 822 | if mr.TargetRef == repo.DefaultBranch { |
| 823 | 823 | ProcessCommitMessages(c.Store, dir, repo, c.User.ID, targetSHA, newSHA) |
| 824 | 824 | } |
| 825 | c.Store.MarkMirrorsDirty(repo.ID, "push") | |
| 825 | 826 | if parts, err := c.Store.MRParticipants(mr.ID); err == nil { |
| 826 | 827 | notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title), |
| 827 | 828 | notifyBody(c, fmt.Sprintf("merged !%d into %s (%s)", mr.Number, mr.TargetRef, strategy), "", fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number))) |
internal/hookd/hookd.go +2
| @@ -183,6 +183,8 @@ func (s *Server) postReceive(req Request) { | ||
| 183 | 183 | dir := control.RepoDir(s.cfg.Server.Root, pushedRepo.OwnerName, pushedRepo.Name) |
| 184 | 184 | control.ProcessCommitMessages(s.st, dir, pushedRepo, req.UserID, u.Old, u.New) |
| 185 | 185 | } |
| 186 | // Any branch/tag update schedules the push mirrors. | |
| 187 | s.st.MarkMirrorsDirty(req.RepoID, "push") | |
| 186 | 188 | mrs, err := s.st.OpenMRsBySource(req.RepoID, branch) |
| 187 | 189 | if err != nil { |
| 188 | 190 | slog.Error("post-receive: listing MRs", "err", err) |
internal/httpd/accounts.go +1
| @@ -332,5 +332,6 @@ func (s *Server) editSubmit(w http.ResponseWriter, r *http.Request, u store.User | ||
| 332 | 332 | fail(err.Error()) |
| 333 | 333 | return |
| 334 | 334 | } |
| 335 | s.st.MarkMirrorsDirty(repo.ID, "push") | |
| 335 | 336 | http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", repo.Path(), ref, filePath), http.StatusSeeOther) |
| 336 | 337 | } |
internal/mirror/mirror.go added +115
| @@ -0,0 +1,115 @@ | ||
| 1 | // Package mirror synchronizes repositories with foreign remotes: push | |
| 2 | // mirrors propagate local refs outward after each receive, pull mirrors | |
| 3 | // keep a local copy fresh from an upstream. Sync runs in a background | |
| 4 | // worker, never in the push path; outcomes are recorded per mirror so | |
| 5 | // `repo mirror list` shows failure states like webhook deliveries do. | |
| 6 | package mirror | |
| 7 | ||
| 8 | import ( | |
| 9 | "context" | |
| 10 | "fmt" | |
| 11 | "log/slog" | |
| 12 | "os" | |
| 13 | "os/exec" | |
| 14 | "path/filepath" | |
| 15 | "time" | |
| 16 | ||
| 17 | "gitbay.org/gitbay/internal/config" | |
| 18 | "gitbay.org/gitbay/internal/control" | |
| 19 | "gitbay.org/gitbay/internal/store" | |
| 20 | ) | |
| 21 | ||
| 22 | const askpassScript = `#!/bin/sh | |
| 23 | case "$1" in | |
| 24 | Username*) echo "${GITBAY_MIRROR_USER}" ;; | |
| 25 | *) echo "${GITBAY_MIRROR_TOKEN}" ;; | |
| 26 | esac | |
| 27 | ` | |
| 28 | ||
| 29 | type Worker struct { | |
| 30 | St *store.Store | |
| 31 | Cfg config.Config | |
| 32 | Tick time.Duration | |
| 33 | } | |
| 34 | ||
| 35 | func New(st *store.Store, cfg config.Config) *Worker { | |
| 36 | tick := 10 * time.Second | |
| 37 | if v := os.Getenv("GITBAY_MIRROR_TICK"); v != "" { | |
| 38 | if d, err := time.ParseDuration(v); err == nil { | |
| 39 | tick = d | |
| 40 | } | |
| 41 | } | |
| 42 | return &Worker{St: st, Cfg: cfg, Tick: tick} | |
| 43 | } | |
| 44 | ||
| 45 | func (w *Worker) Run(ctx context.Context) { | |
| 46 | t := time.NewTicker(w.Tick) | |
| 47 | defer t.Stop() | |
| 48 | for { | |
| 49 | select { | |
| 50 | case <-ctx.Done(): | |
| 51 | return | |
| 52 | case <-t.C: | |
| 53 | w.sweep() | |
| 54 | } | |
| 55 | } | |
| 56 | } | |
| 57 | ||
| 58 | func (w *Worker) sweep() { | |
| 59 | interval := w.Cfg.Mirrors.PullIntervalMinutes * 60 | |
| 60 | due, err := w.St.DueMirrors(interval) | |
| 61 | if err != nil { | |
| 62 | slog.Error("mirror: listing due", "err", err) | |
| 63 | return | |
| 64 | } | |
| 65 | for _, m := range due { | |
| 66 | if err := w.sync(m); err != nil { | |
| 67 | slog.Warn("mirror sync failed", "mirror", m.ID, "url", m.URL, "err", err) | |
| 68 | w.St.SetMirrorResult(m.ID, err.Error()) | |
| 69 | } else { | |
| 70 | w.St.SetMirrorResult(m.ID, "") | |
| 71 | } | |
| 72 | } | |
| 73 | } | |
| 74 | ||
| 75 | func (w *Worker) sync(m store.Mirror) error { | |
| 76 | repo, err := w.St.RepoByID(m.RepoID) | |
| 77 | if err != nil { | |
| 78 | return err | |
| 79 | } | |
| 80 | dir := control.RepoDir(w.Cfg.Server.Root, repo.OwnerName, repo.Name) | |
| 81 | ||
| 82 | env := []string{"GIT_TERMINAL_PROMPT=0", "HOME=" + w.Cfg.Server.Root} | |
| 83 | if m.Token != "" { | |
| 84 | askpass := filepath.Join(w.Cfg.Server.Root, "mirror-askpass.sh") | |
| 85 | if err := os.WriteFile(askpass, []byte(askpassScript), 0o700); err != nil { | |
| 86 | return err | |
| 87 | } | |
| 88 | user := m.Username | |
| 89 | if user == "" { | |
| 90 | user = "x-access-token" | |
| 91 | } | |
| 92 | env = append(env, | |
| 93 | "GIT_ASKPASS="+askpass, | |
| 94 | "GITBAY_MIRROR_USER="+user, | |
| 95 | "GITBAY_MIRROR_TOKEN="+m.Token) | |
| 96 | } | |
| 97 | ||
| 98 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) | |
| 99 | defer cancel() | |
| 100 | var args []string | |
| 101 | if m.Direction == "push" { | |
| 102 | // Branches and tags only: internal refs (merge-requests) stay home. | |
| 103 | args = []string{"-C", dir, "push", "--prune", m.URL, | |
| 104 | "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"} | |
| 105 | } else { | |
| 106 | args = []string{"-C", dir, "fetch", "--prune", m.URL, | |
| 107 | "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"} | |
| 108 | } | |
| 109 | cmd := exec.CommandContext(ctx, "git", args...) | |
| 110 | cmd.Env = env | |
| 111 | if out, err := cmd.CombinedOutput(); err != nil { | |
| 112 | return fmt.Errorf("git %s: %v: %.300s", m.Direction, err, out) | |
| 113 | } | |
| 114 | return nil | |
| 115 | } | |
internal/sshd/sshd.go +6
| @@ -302,6 +302,12 @@ func runGit(cfg config.Config, st *store.Store, user store.User, scope string, a | ||
| 302 | 302 | fmt.Fprintf(stderr, "%s is archived and read-only\n", repo.Path()) |
| 303 | 303 | return protocol.ExitDenied |
| 304 | 304 | } |
| 305 | if write { | |
| 306 | if mirrored, err := st.PullMirrored(repo.ID); err == nil && mirrored { | |
| 307 | fmt.Fprintf(stderr, "%s is a pull mirror: its refs come from the upstream; push there instead\n", repo.Path()) | |
| 308 | return protocol.ExitDenied | |
| 309 | } | |
| 310 | } | |
| 305 | 311 | |
| 306 | 312 | dir := control.RepoDir(cfg.Server.Root, repo.OwnerName, repo.Name) |
| 307 | 313 | env := []string{ |
internal/store/migrations/0017_mirrors.down.sql added +1
| @@ -0,0 +1 @@ | ||
| 1 | DROP TABLE mirrors; | |
internal/store/migrations/0017_mirrors.up.sql added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | CREATE TABLE mirrors ( | |
| 2 | id INTEGER PRIMARY KEY, | |
| 3 | repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 4 | direction TEXT NOT NULL CHECK (direction IN ('push','pull')), | |
| 5 | url TEXT NOT NULL, | |
| 6 | username TEXT NOT NULL DEFAULT '', | |
| 7 | token TEXT NOT NULL DEFAULT '', | |
| 8 | dirty INTEGER NOT NULL DEFAULT 1, | |
| 9 | last_sync TEXT NOT NULL DEFAULT '', | |
| 10 | last_error TEXT NOT NULL DEFAULT '', | |
| 11 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 12 | UNIQUE (repo_id, direction, url) | |
| 13 | ); | |
internal/store/mirrors.go added +117
| @@ -0,0 +1,117 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import "errors" | |
| 4 | ||
| 5 | // ErrExists marks unique-constraint refusals callers turn into messages. | |
| 6 | var ErrExists = errors.New("already exists") | |
| 7 | ||
| 8 | // Mirror propagates refs to (push) or from (pull) a foreign remote. The | |
| 9 | // token is stored server-side — unlike import, mirroring is recurring — | |
| 10 | // and must never be echoed back in listings. | |
| 11 | type Mirror struct { | |
| 12 | ID int64 | |
| 13 | RepoID int64 | |
| 14 | Direction string // push | pull | |
| 15 | URL string | |
| 16 | Username string | |
| 17 | Token string | |
| 18 | Dirty bool | |
| 19 | LastSync string | |
| 20 | LastError string | |
| 21 | } | |
| 22 | ||
| 23 | func (s *Store) AddMirror(repoID int64, direction, url, username, token string) (int64, error) { | |
| 24 | res, err := s.DB.Exec( | |
| 25 | "INSERT INTO mirrors (repo_id, direction, url, username, token) VALUES (?, ?, ?, ?, ?)", | |
| 26 | repoID, direction, url, username, token) | |
| 27 | if err != nil { | |
| 28 | if isUniqueErr(err) { | |
| 29 | return 0, ErrExists | |
| 30 | } | |
| 31 | return 0, err | |
| 32 | } | |
| 33 | return res.LastInsertId() | |
| 34 | } | |
| 35 | ||
| 36 | const mirrorSelect = ` | |
| 37 | SELECT id, repo_id, direction, url, username, token, dirty, last_sync, last_error | |
| 38 | FROM mirrors` | |
| 39 | ||
| 40 | func scanMirror(row interface{ Scan(...any) error }) (Mirror, error) { | |
| 41 | var m Mirror | |
| 42 | err := row.Scan(&m.ID, &m.RepoID, &m.Direction, &m.URL, &m.Username, &m.Token, | |
| 43 | &m.Dirty, &m.LastSync, &m.LastError) | |
| 44 | return m, err | |
| 45 | } | |
| 46 | ||
| 47 | func (s *Store) mirrorQuery(q string, args ...any) ([]Mirror, error) { | |
| 48 | rows, err := s.DB.Query(q, args...) | |
| 49 | if err != nil { | |
| 50 | return nil, err | |
| 51 | } | |
| 52 | defer rows.Close() | |
| 53 | var out []Mirror | |
| 54 | for rows.Next() { | |
| 55 | m, err := scanMirror(rows) | |
| 56 | if err != nil { | |
| 57 | return nil, err | |
| 58 | } | |
| 59 | out = append(out, m) | |
| 60 | } | |
| 61 | return out, rows.Err() | |
| 62 | } | |
| 63 | ||
| 64 | func (s *Store) ListMirrors(repoID int64) ([]Mirror, error) { | |
| 65 | return s.mirrorQuery(mirrorSelect+" WHERE repo_id = ? ORDER BY id", repoID) | |
| 66 | } | |
| 67 | ||
| 68 | // DueMirrors returns mirrors needing a sync: anything dirty, plus pull | |
| 69 | // mirrors whose last sync is older than intervalSeconds. | |
| 70 | func (s *Store) DueMirrors(intervalSeconds int) ([]Mirror, error) { | |
| 71 | return s.mirrorQuery(mirrorSelect+` | |
| 72 | WHERE dirty = 1 | |
| 73 | OR (direction = 'pull' AND (last_sync = '' | |
| 74 | OR strftime('%s','now') - strftime('%s', last_sync) > ?)) | |
| 75 | ORDER BY id`, intervalSeconds) | |
| 76 | } | |
| 77 | ||
| 78 | func (s *Store) RemoveMirror(repoID, id int64) error { | |
| 79 | res, err := s.DB.Exec("DELETE FROM mirrors WHERE repo_id = ? AND id = ?", repoID, id) | |
| 80 | if err != nil { | |
| 81 | return err | |
| 82 | } | |
| 83 | if n, _ := res.RowsAffected(); n == 0 { | |
| 84 | return ErrNotFound | |
| 85 | } | |
| 86 | return nil | |
| 87 | } | |
| 88 | ||
| 89 | // MarkMirrorsDirty schedules a sync. An empty direction marks both. | |
| 90 | func (s *Store) MarkMirrorsDirty(repoID int64, direction string) error { | |
| 91 | q := "UPDATE mirrors SET dirty = 1 WHERE repo_id = ?" | |
| 92 | args := []any{repoID} | |
| 93 | if direction != "" { | |
| 94 | q += " AND direction = ?" | |
| 95 | args = append(args, direction) | |
| 96 | } | |
| 97 | _, err := s.DB.Exec(q, args...) | |
| 98 | return err | |
| 99 | } | |
| 100 | ||
| 101 | // SetMirrorResult records a sync outcome and clears the dirty flag. | |
| 102 | func (s *Store) SetMirrorResult(id int64, syncErr string) error { | |
| 103 | _, err := s.DB.Exec(` | |
| 104 | UPDATE mirrors SET dirty = 0, last_error = ?, | |
| 105 | last_sync = strftime('%Y-%m-%dT%H:%M:%fZ','now') | |
| 106 | WHERE id = ?`, syncErr, id) | |
| 107 | return err | |
| 108 | } | |
| 109 | ||
| 110 | // PullMirrored reports whether the repo has a pull mirror, which makes it | |
| 111 | // read-only locally: its refs belong to the upstream. | |
| 112 | func (s *Store) PullMirrored(repoID int64) (bool, error) { | |
| 113 | var n int | |
| 114 | err := s.DB.QueryRow( | |
| 115 | "SELECT COUNT(*) FROM mirrors WHERE repo_id = ? AND direction = 'pull'", repoID).Scan(&n) | |
| 116 | return n > 0, err | |
| 117 | } | |