Commit ae2edb026f

ae2edb026f0bdf61389b2db4423d7b2d432a4d3e

parent: 7e3afe5f90

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 00:12 UTC

mirror: sync through gitpin

Ref #298

Layout: unified · split

internal/mirror/mirror.go +7 −83
@@ -10,19 +10,16 @@ import (
1010 "fmt"
1111 "log/slog"
1212 "net"
13 "net/url"
1413 "os"
1514 "os/exec"
1615 "path/filepath"
17 "strconv"
18 "strings"
1916 "time"
2017
2118 "gitbay.org/gitbay/internal/config"
2219 "gitbay.org/gitbay/internal/control"
20 "gitbay.org/gitbay/internal/gitpin"
2321 "gitbay.org/gitbay/internal/store"
2422 "gitbay.org/gitbay/internal/toolpath"
25 "gitbay.org/gitbay/internal/webhook"
2623)
2724
2825const askpassScript = `#!/bin/sh
@@ -50,20 +47,12 @@ func New(st *store.Store, cfg config.Config) *Worker {
5047 tick = d
5148 }
5249 }
53 return &Worker{St: st, Cfg: cfg, Tick: tick,
54 Lookup: func(ctx context.Context, host string) ([]net.IP, error) {
55 return net.DefaultResolver.LookupIP(ctx, "ip", host)
56 }}
50 return &Worker{St: st, Cfg: cfg, Tick: tick, Lookup: gitpin.LookupIP}
5751}
5852
5953func (w *Worker) Run(ctx context.Context) {
60 out, err := exec.CommandContext(ctx, toolpath.Look("git"), "version").Output()
61 if err != nil {
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 {
54 if err := gitpin.CheckGit(ctx); err != nil {
55 w.gitErr = fmt.Errorf("mirrors disabled: %w", err)
6756 slog.Error("mirror: not syncing", "err", w.gitErr)
6857 }
6958 t := time.NewTicker(w.Tick)
@@ -105,35 +94,18 @@ func (w *Worker) sync(m store.Mirror) error {
10594 return err
10695 }
10796 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 }
11597
11698 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
11799 defer cancel()
118100 // The URL was checked when saved, but DNS can answer differently
119101 // now. Check what it resolves to at sync time, then let git connect
120102 // 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)
122104 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 {
130105 return err
131106 }
132107
133 // No system or global gitconfig: a proxy, URL rewrite or redirect
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"}
108 env := gitpin.Env(w.Cfg.Server.Root)
137109 if m.Token != "" {
138110 askpass := filepath.Join(w.Cfg.Server.Root, "mirror-askpass.sh")
139111 if err := os.WriteFile(askpass, []byte(askpassScript), 0o700); err != nil {
@@ -149,7 +121,7 @@ func (w *Worker) sync(m store.Mirror) error {
149121 "GITBAY_MIRROR_TOKEN="+m.Token)
150122 }
151123
152 args := append(pinArgs(u, ips), "-C", dir)
124 args := append(remote.Args(), "-C", dir)
153125 if m.Direction == "push" {
154126 // Branches and tags only: internal refs (merge-requests) stay home.
155127 args = append(args, "push", "--prune", m.URL,
@@ -165,51 +137,3 @@ func (w *Worker) sync(m store.Mirror) error {
165137 }
166138 return nil
167139}
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.
172func 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.
199func 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 (
1515
1616 "gitbay.org/gitbay/internal/config"
1717 "gitbay.org/gitbay/internal/control"
18 "gitbay.org/gitbay/internal/gitpin"
1819 "gitbay.org/gitbay/internal/store"
1920)
2021
@@ -148,7 +149,7 @@ func TestSweepRefusesWithAnOldGit(t *testing.T) {
148149 t.Fatal("looked up a host with an old git")
149150 return nil, nil
150151 }}
151 w.gitErr = gitVersionOK("git version 2.36.1")
152 w.gitErr = gitpin.VersionOK("git version 2.36.1")
152153 w.sweep()
153154 ms, err := st.ListMirrors(m.RepoID)
154155 if err != nil || len(ms) != 1 {
@@ -159,20 +160,6 @@ func TestSweepRefusesWithAnOldGit(t *testing.T) {
159160 }
160161}
161162
162func 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
176163// The URL passed the check when it was saved; the answer at sync time
177164// is what counts.
178165func TestSyncRefusesAPrivateAddressAtSyncTime(t *testing.T) {
@@ -238,22 +225,3 @@ func TestSyncRefusesANonHTTPScheme(t *testing.T) {
238225 t.Fatalf("sync = %v, want a refusal", err)
239226 }
240227}
241
242func 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}