Commit ae2edb026f
Verified · cmc
Layout: unified · split
internal/mirror/mirror.go +7 −83
| @@ -10,19 +10,16 @@ import ( | |||
| 10 | "fmt" | 10 | "fmt" |
| 11 | "log/slog" | 11 | "log/slog" |
| 12 | "net" | 12 | "net" |
| 13 | "net/url" | ||
| 14 | "os" | 13 | "os" |
| 15 | "os/exec" | 14 | "os/exec" |
| 16 | "path/filepath" | 15 | "path/filepath" |
| 17 | "strconv" | ||
| 18 | "strings" | ||
| 19 | "time" | 16 | "time" |
| 20 | 17 | ||
| 21 | "gitbay.org/gitbay/internal/config" | 18 | "gitbay.org/gitbay/internal/config" |
| 22 | "gitbay.org/gitbay/internal/control" | 19 | "gitbay.org/gitbay/internal/control" |
| 20 | "gitbay.org/gitbay/internal/gitpin" | ||
| 23 | "gitbay.org/gitbay/internal/store" | 21 | "gitbay.org/gitbay/internal/store" |
| 24 | "gitbay.org/gitbay/internal/toolpath" | 22 | "gitbay.org/gitbay/internal/toolpath" |
| 25 | "gitbay.org/gitbay/internal/webhook" | ||
| 26 | ) | 23 | ) |
| 27 | 24 | ||
| 28 | const askpassScript = `#!/bin/sh | 25 | const askpassScript = `#!/bin/sh |
| @@ -50,20 +47,12 @@ func New(st *store.Store, cfg config.Config) *Worker { | |||
| 50 | tick = d | 47 | tick = d |
| 51 | } | 48 | } |
| 52 | } | 49 | } |
| 53 | return &Worker{St: st, Cfg: cfg, Tick: tick, | 50 | return &Worker{St: st, Cfg: cfg, Tick: tick, Lookup: gitpin.LookupIP} |
| 54 | Lookup: func(ctx context.Context, host string) ([]net.IP, error) { | ||
| 55 | return net.DefaultResolver.LookupIP(ctx, "ip", host) | ||
| 56 | }} | ||
| 57 | } | 51 | } |
| 58 | 52 | ||
| 59 | func (w *Worker) Run(ctx context.Context) { | 53 | func (w *Worker) Run(ctx context.Context) { |
| 60 | out, err := exec.CommandContext(ctx, toolpath.Look("git"), "version").Output() | 54 | if err := gitpin.CheckGit(ctx); err != nil { |
| 61 | if err != nil { | 55 | w.gitErr = fmt.Errorf("mirrors disabled: %w", err) |
| 62 | w.gitErr = fmt.Errorf("mirrors disabled: running git version: %v", err) | ||
| 63 | } else { | ||
| 64 | w.gitErr = gitVersionOK(string(out)) | ||
| 65 | } | ||
| 66 | if w.gitErr != nil { | ||
| 67 | slog.Error("mirror: not syncing", "err", w.gitErr) | 56 | slog.Error("mirror: not syncing", "err", w.gitErr) |
| 68 | } | 57 | } |
| 69 | t := time.NewTicker(w.Tick) | 58 | t := time.NewTicker(w.Tick) |
| @@ -105,35 +94,18 @@ func (w *Worker) sync(m store.Mirror) error { | |||
| 105 | return err | 94 | return err |
| 106 | } | 95 | } |
| 107 | dir := control.RepoDir(w.Cfg.Server.Root, repo.OwnerName, repo.Name) | 96 | dir := control.RepoDir(w.Cfg.Server.Root, repo.OwnerName, repo.Name) |
| 108 | u, err := url.Parse(m.URL) | ||
| 109 | if err != nil { | ||
| 110 | return err | ||
| 111 | } | ||
| 112 | if u.Scheme != "https" && u.Scheme != "http" { | ||
| 113 | return fmt.Errorf("mirror URL scheme %q is not http or https", u.Scheme) | ||
| 114 | } | ||
| 115 | 97 | ||
| 116 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) | 98 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) |
| 117 | defer cancel() | 99 | defer cancel() |
| 118 | // The URL was checked when saved, but DNS can answer differently | 100 | // The URL was checked when saved, but DNS can answer differently |
| 119 | // now. Check what it resolves to at sync time, then let git connect | 101 | // now. Check what it resolves to at sync time, then let git connect |
| 120 | // to exactly those addresses. | 102 | // to exactly those addresses. |
| 121 | ips, err := w.Lookup(ctx, u.Hostname()) | 103 | remote, err := gitpin.Resolve(ctx, w.Lookup, m.URL, w.Cfg.Webhooks.AllowLocal) |
| 122 | if err != nil { | 104 | if err != nil { |
| 123 | return fmt.Errorf("resolving %s: %w", u.Hostname(), err) | ||
| 124 | } | ||
| 125 | if len(ips) == 0 { | ||
| 126 | // An empty resolve list would leave curl to resolve the host itself. | ||
| 127 | return fmt.Errorf("%s resolves to no address", u.Hostname()) | ||
| 128 | } | ||
| 129 | if err := webhook.CheckAddrs(u.Hostname(), ips, w.Cfg.Webhooks.AllowLocal); err != nil { | ||
| 130 | return err | 105 | return err |
| 131 | } | 106 | } |
| 132 | 107 | ||
| 133 | // No system or global gitconfig: a proxy, URL rewrite or redirect | 108 | env := gitpin.Env(w.Cfg.Server.Root) |
| 134 | // setting there would take git around the pin. | ||
| 135 | env := []string{"GIT_TERMINAL_PROMPT=0", "HOME=" + w.Cfg.Server.Root, | ||
| 136 | "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null"} | ||
| 137 | if m.Token != "" { | 109 | if m.Token != "" { |
| 138 | askpass := filepath.Join(w.Cfg.Server.Root, "mirror-askpass.sh") | 110 | askpass := filepath.Join(w.Cfg.Server.Root, "mirror-askpass.sh") |
| 139 | if err := os.WriteFile(askpass, []byte(askpassScript), 0o700); err != nil { | 111 | if err := os.WriteFile(askpass, []byte(askpassScript), 0o700); err != nil { |
| @@ -149,7 +121,7 @@ func (w *Worker) sync(m store.Mirror) error { | |||
| 149 | "GITBAY_MIRROR_TOKEN="+m.Token) | 121 | "GITBAY_MIRROR_TOKEN="+m.Token) |
| 150 | } | 122 | } |
| 151 | 123 | ||
| 152 | args := append(pinArgs(u, ips), "-C", dir) | 124 | args := append(remote.Args(), "-C", dir) |
| 153 | if m.Direction == "push" { | 125 | if m.Direction == "push" { |
| 154 | // Branches and tags only: internal refs (merge-requests) stay home. | 126 | // Branches and tags only: internal refs (merge-requests) stay home. |
| 155 | args = append(args, "push", "--prune", m.URL, | 127 | args = append(args, "push", "--prune", m.URL, |
| @@ -165,51 +137,3 @@ func (w *Worker) sync(m store.Mirror) error { | |||
| 165 | } | 137 | } |
| 166 | return nil | 138 | return nil |
| 167 | } | 139 | } |
| 168 | |||
| 169 | // pinArgs keeps git on the addresses just checked: curl's resolve list | ||
| 170 | // pins the host, and with redirects off a server cannot send git on to | ||
| 171 | // a host nobody checked. An address literal needs no pin. | ||
| 172 | func pinArgs(u *url.URL, ips []net.IP) []string { | ||
| 173 | args := []string{"-c", "http.followRedirects=false"} | ||
| 174 | host := u.Hostname() | ||
| 175 | if net.ParseIP(host) != nil { | ||
| 176 | return args | ||
| 177 | } | ||
| 178 | port := u.Port() | ||
| 179 | if port == "" { | ||
| 180 | port = "443" | ||
| 181 | if u.Scheme == "http" { | ||
| 182 | port = "80" | ||
| 183 | } | ||
| 184 | } | ||
| 185 | addrs := make([]string, len(ips)) | ||
| 186 | for i, ip := range ips { | ||
| 187 | if ip.To4() == nil { | ||
| 188 | addrs[i] = "[" + ip.String() + "]" | ||
| 189 | } else { | ||
| 190 | addrs[i] = ip.String() | ||
| 191 | } | ||
| 192 | } | ||
| 193 | return append(args, "-c", "http.curloptResolve="+host+":"+port+":"+strings.Join(addrs, ",")) | ||
| 194 | } | ||
| 195 | |||
| 196 | // gitVersionOK accepts the output of `git version` for git 2.37 or | ||
| 197 | // later, the first release with http.curloptResolve. An older git | ||
| 198 | // ignores the setting and would resolve the host itself. | ||
| 199 | func gitVersionOK(out string) error { | ||
| 200 | fields := strings.Fields(out) | ||
| 201 | if len(fields) >= 3 && fields[0] == "git" && fields[1] == "version" { | ||
| 202 | parts := strings.Split(fields[2], ".") | ||
| 203 | if len(parts) >= 2 { | ||
| 204 | major, err1 := strconv.Atoi(parts[0]) | ||
| 205 | minor, err2 := strconv.Atoi(parts[1]) | ||
| 206 | if err1 == nil && err2 == nil { | ||
| 207 | if major > 2 || major == 2 && minor >= 37 { | ||
| 208 | return nil | ||
| 209 | } | ||
| 210 | return fmt.Errorf("mirrors disabled: git %s is older than 2.37 and cannot pin mirror addresses", fields[2]) | ||
| 211 | } | ||
| 212 | } | ||
| 213 | } | ||
| 214 | return fmt.Errorf("mirrors disabled: cannot read git version from %q", strings.TrimSpace(out)) | ||
| 215 | } | ||
internal/mirror/mirror_test.go +2 −34
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | 15 | ||
| 16 | "gitbay.org/gitbay/internal/config" | 16 | "gitbay.org/gitbay/internal/config" |
| 17 | "gitbay.org/gitbay/internal/control" | 17 | "gitbay.org/gitbay/internal/control" |
| 18 | "gitbay.org/gitbay/internal/gitpin" | ||
| 18 | "gitbay.org/gitbay/internal/store" | 19 | "gitbay.org/gitbay/internal/store" |
| 19 | ) | 20 | ) |
| 20 | 21 | ||
| @@ -148,7 +149,7 @@ func TestSweepRefusesWithAnOldGit(t *testing.T) { | |||
| 148 | t.Fatal("looked up a host with an old git") | 149 | t.Fatal("looked up a host with an old git") |
| 149 | return nil, nil | 150 | return nil, nil |
| 150 | }} | 151 | }} |
| 151 | w.gitErr = gitVersionOK("git version 2.36.1") | 152 | w.gitErr = gitpin.VersionOK("git version 2.36.1") |
| 152 | w.sweep() | 153 | w.sweep() |
| 153 | ms, err := st.ListMirrors(m.RepoID) | 154 | ms, err := st.ListMirrors(m.RepoID) |
| 154 | if err != nil || len(ms) != 1 { | 155 | if err != nil || len(ms) != 1 { |
| @@ -159,20 +160,6 @@ func TestSweepRefusesWithAnOldGit(t *testing.T) { | |||
| 159 | } | 160 | } |
| 160 | } | 161 | } |
| 161 | 162 | ||
| 162 | func TestGitVersionOK(t *testing.T) { | ||
| 163 | for _, s := range []string{"git version 2.37.0", "git version 2.47.3", "git version 2.39.5 (Apple Git-154)", | ||
| 164 | "git version 2.45.2.windows.1", "git version 3.0.0\n"} { | ||
| 165 | if err := gitVersionOK(s); err != nil { | ||
| 166 | t.Errorf("%q: %v", s, err) | ||
| 167 | } | ||
| 168 | } | ||
| 169 | for _, s := range []string{"git version 2.36.9", "git version 1.99.0", "git version 2", "nonsense", ""} { | ||
| 170 | if err := gitVersionOK(s); err == nil { | ||
| 171 | t.Errorf("%q accepted", s) | ||
| 172 | } | ||
| 173 | } | ||
| 174 | } | ||
| 175 | |||
| 176 | // The URL passed the check when it was saved; the answer at sync time | 163 | // The URL passed the check when it was saved; the answer at sync time |
| 177 | // is what counts. | 164 | // is what counts. |
| 178 | func TestSyncRefusesAPrivateAddressAtSyncTime(t *testing.T) { | 165 | func TestSyncRefusesAPrivateAddressAtSyncTime(t *testing.T) { |
| @@ -238,22 +225,3 @@ func TestSyncRefusesANonHTTPScheme(t *testing.T) { | |||
| 238 | t.Fatalf("sync = %v, want a refusal", err) | 225 | t.Fatalf("sync = %v, want a refusal", err) |
| 239 | } | 226 | } |
| 240 | } | 227 | } |
| 241 | |||
| 242 | func TestPinArgs(t *testing.T) { | ||
| 243 | u, _ := url.Parse("https://git.example/x.git") | ||
| 244 | got := pinArgs(u, []net.IP{net.ParseIP("203.0.113.5"), net.ParseIP("2001:db8::1")}) | ||
| 245 | want := []string{"-c", "http.followRedirects=false", | ||
| 246 | "-c", "http.curloptResolve=git.example:443:203.0.113.5,[2001:db8::1]"} | ||
| 247 | if !slices.Equal(got, want) { | ||
| 248 | t.Fatalf("https: %q", got) | ||
| 249 | } | ||
| 250 | u, _ = url.Parse("http://git.example:8080/x.git") | ||
| 251 | if got := pinArgs(u, []net.IP{net.ParseIP("203.0.113.5")}); got[3] != "http.curloptResolve=git.example:8080:203.0.113.5" { | ||
| 252 | t.Fatalf("http with port: %q", got) | ||
| 253 | } | ||
| 254 | // An address literal is its own resolution; there is nothing to pin. | ||
| 255 | u, _ = url.Parse("https://203.0.113.5/x.git") | ||
| 256 | if got := pinArgs(u, []net.IP{net.ParseIP("203.0.113.5")}); !slices.Equal(got, []string{"-c", "http.followRedirects=false"}) { | ||
| 257 | t.Fatalf("literal: %q", got) | ||
| 258 | } | ||
| 259 | } | ||