Commit 28f6758983

28f67589837a6ad3a8b6df9090288fd4cc44404a

parent: c21c7ea735

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 07:14 UTC

runner: builds off the host's loopback when the runner polls over it

Ref #260

Layout: unified · split

cmd/gitbay-runner/env_test.go +34 −13
@@ -190,26 +190,47 @@ func TestSplitEnvKeepsMultilineOutOfTheFile(t *testing.T) {
190190 }
191191}
192192
193// A build that talks back to the instance — releases, comments — needs an
194// address that works from where it runs. GITBAY_SSH carries the runner's
195// remote; under podman a loopback remote is rewritten to the address at
196// which pasta exposes the host, since the host's own addresses belong to
197// the container inside it.
193// A build that talks back to the instance needs an address that works
194// from where it runs. A runner polling over loopback keeps its podman
195// builds off the host's loopback, so they get the instance's public
196// destination from the claim, port included when it is not 22; any other
197// remote is used as it is (#260).
198198func TestStepEnvCarriesInstanceAddress(t *testing.T) {
199199 env := stepEnv(job{}, "/tmp/buildhome", "git@gitbay.org")
200200 if !containsEnv(env, "GITBAY_SSH=git@gitbay.org") {
201201 t.Errorf("GITBAY_SSH missing: %q", env)
202202 }
203 for _, tc := range []struct{ remote, isolation, want string }{
204 {"git@127.0.0.1", isolationNone, "git@127.0.0.1"},
205 {"git@127.0.0.1", isolationPodman, "git@169.254.1.2"},
206 {"git@localhost", isolationPodman, "git@169.254.1.2"},
207 {"git@gitbay.org", isolationPodman, "git@gitbay.org"},
208 {"gitbay.org", isolationPodman, "gitbay.org"},
203 for _, tc := range []struct{ remote, isolation, public, want string }{
204 {"git@127.0.0.1", isolationNone, "git@gitbay.org", "git@127.0.0.1"},
205 {"git@127.0.0.1", isolationPodman, "git@gitbay.org", "git@gitbay.org"},
206 {"git@127.0.0.1", isolationPodman, "git@gitbay.test:2022", "git@gitbay.test:2022"},
207 {"git@localhost", isolationPodman, "git@gitbay.org", "git@gitbay.org"},
208 {"git@127.0.0.1", isolationPodman, "", "git@127.0.0.1"},
209 {"git@gitbay.org", isolationPodman, "git@other.test", "git@gitbay.org"},
210 {"gitbay.org", isolationPodman, "git@gitbay.org", "gitbay.org"},
209211 } {
210212 r := &runner{remote: tc.remote, isolation: tc.isolation}
211 if got := r.buildSSH(); got != tc.want {
212 t.Errorf("remote %s under %s: got %s want %s", tc.remote, tc.isolation, got, tc.want)
213 if got := r.buildSSH(tc.public); got != tc.want {
214 t.Errorf("remote %s under %s, public %q: got %s want %s", tc.remote, tc.isolation, tc.public, got, tc.want)
215 }
216 }
217}
218
219// Only a runner that polls over loopback shares an address a build could
220// connect from, so only its builds lose the host-loopback mapping (#260).
221func TestBuildNetworkKeepsLoopbackRunnersBuildsOff(t *testing.T) {
222 for _, tc := range []struct {
223 remote string
224 want []string
225 }{
226 {"git@127.0.0.1", []string{"--network", "pasta:--no-map-gw"}},
227 {"localhost", []string{"--network", "pasta:--no-map-gw"}},
228 {"git@::1", []string{"--network", "pasta:--no-map-gw"}},
229 {"git@gitbay.org", nil},
230 } {
231 r := &runner{remote: tc.remote, isolation: isolationPodman}
232 if got := r.buildNetwork(); strings.Join(got, " ") != strings.Join(tc.want, " ") {
233 t.Errorf("remote %s: %q, want %q", tc.remote, got, tc.want)
213234 }
214235 }
215236}
cmd/gitbay-runner/isolate.go +1
@@ -151,6 +151,7 @@ func (r *runner) runStepsPodman(j job, dir string, env []string, sink io.Writer,
151151 args = append(args,
152152 "--name", name,
153153 "--env-file", envFile)
154 args = append(args, r.buildNetwork()...)
154155 args = append(args, inheritArgs(inherit)...)
155156 args = append(args,
156157 "--volume", dir+":/workspace:rw",
cmd/gitbay-runner/main.go +43 −23
@@ -42,7 +42,10 @@ type job struct {
4242 // Trusted is false for a merge request head from a fork, and when the
4343 // server did not say: such a build gets no secrets and a home of its
4444 // own (#255).
45 Trusted bool `json:"trusted"`
45 Trusted bool `json:"trusted"`
46 // SSH is the instance's public ssh destination, for a build whose
47 // runner polls over loopback (#260).
48 SSH string `json:"ssh"`
4649 Secrets map[string]string `json:"secrets"`
4750}
4851
@@ -425,7 +428,7 @@ func (r *runner) run(j job) bool {
425428 }
426429 }
427430
428 env := stepEnv(j, home, r.buildSSH())
431 env := stepEnv(j, home, r.buildSSH(j.SSH))
429432 return r.runSteps(j, dir, env, sink, deadline, runStep)
430433}
431434
@@ -483,27 +486,44 @@ func removeTree(dir string) error {
483486 return os.RemoveAll(dir)
484487}
485488
486// buildSSH is the instance's ssh destination as a build reaches it. Under
487// podman, pasta gives the container the host's own addresses, so a
488// loopback remote — the runner on the server itself — is unreachable by
489// that name; pasta exposes the host at 169.254.1.2, its
490// --map-host-loopback default. Any other remote is a real host elsewhere
491// and works as it is.
492func (r *runner) buildSSH() string {
493 if r.isolation != isolationPodman {
494 return r.remote
495 }
496 user, host, hasUser := strings.Cut(r.remote, "@")
497 if !hasUser {
498 user, host = "", user
499 }
500 if host != "127.0.0.1" && host != "localhost" && host != "::1" {
501 return r.remote
502 }
503 if hasUser {
504 return user + "@169.254.1.2"
505 }
506 return "169.254.1.2"
489// loopbackRemote reports whether the runner polls the daemon on its own
490// host over loopback.
491func (r *runner) loopbackRemote() bool {
492 _, host, ok := strings.Cut(r.remote, "@")
493 if !ok {
494 host = r.remote
495 }
496 return host == "127.0.0.1" || host == "localhost" || host == "::1"
497}
498
499// buildSSH is the instance's ssh destination as a build reaches it. A
500// runner polling over loopback keeps its podman builds off the host's
501// loopback (buildNetwork), so they get the instance's public destination
502// from the claim. Any other remote is a real host elsewhere and works as
503// it is, and under -isolation none a build runs on the host itself.
504func (r *runner) buildSSH(public string) string {
505 if r.isolation == isolationPodman && r.loopbackRemote() && public != "" {
506 return public
507 }
508 return r.remote
509}
510
511// buildNetwork is the podman network option for a build. pasta maps the
512// container's gateway address to the host's loopback, and a build's
513// connection through it arrives from 127.0.0.1 — the address a runner on
514// the daemon's host polls from. The SSH auth limiter counts failures per
515// source address, so a build sharing the runner's could throttle its
516// polling (#260). --no-map-gw removes the mapping: the build reaches the
517// host only at its public address, as any client on the internet does,
518// and keeps its outbound access. The host's nftables table
519// (deploy/gitbay-runner-egress.nft) then limits it to 22, 80 and 443
520// there; it cannot tell a build from the runner by uid, so it leaves
521// 127.0.0.1:22 open, and this flag is what keeps builds off it.
522func (r *runner) buildNetwork() []string {
523 if !r.loopbackRemote() {
524 return nil
525 }
526 return []string{"--network", "pasta:--no-map-gw"}
507527}
508528
509529// stepEnv builds the environment a build step runs with. It is