Commit 24c714dba2
Verified · cmc
Layout: unified · split
.gitbay/wiki/CI.org +6 −5
| @@ -67,11 +67,12 @@ than holding the drain; follow again once the daemon is back. | ||
| 67 | 67 | * What a build can reach |
| 68 | 68 | |
| 69 | 69 | Builds have outbound internet access, trusted and untrusted alike. On |
| 70 | the runner's host they reach only the forge's public ports 22, 80 and | |
| 71 | 443: not the host's loopback, not the operator's sshd. The forge is | |
| 72 | reached at its public address, the one in =GITBAY_SSH=. See the | |
| 73 | Threat-Model page, "What a build can reach", for how and why | |
| 74 | (krz/gitbay#260). | |
| 70 | the runner's host an nftables table limits them to the forge's public | |
| 71 | ports 22, 80 and 443, which closes the operator's sshd. The forge is | |
| 72 | reached at the address in =GITBAY_SSH=: on a runner that polls the | |
| 73 | daemon over loopback, =169.254.1.2=, which pasta translates to the | |
| 74 | host's public address. See the Threat-Model page, "What a build can | |
| 75 | reach", for how and why (krz/gitbay#260). | |
| 75 | 76 | |
| 76 | 77 | * The table |
| 77 | 78 | |
.gitbay/wiki/Threat-Model.org +20 −17
| @@ -195,26 +195,29 @@ runner, polling over SSH, clones the commit and runs its steps. | ||
| 195 | 195 | curated set and arbitrary code from a registry nobody vetted. |
| 196 | 196 | - *What a build can reach.* Outbound internet, trusted or not: a fork's |
| 197 | 197 | merge request to a Go repository has to fetch its modules. On the |
| 198 | runner's host, only the forge's public ports 22, 80 and 443, exactly | |
| 199 | as anyone on the internet reaches them. Two layers keep it there. A | |
| 200 | runner that polls the daemon over loopback starts its containers with | |
| 201 | pasta's gateway mapping off, so a build does not reach the host's | |
| 202 | loopback, and =GITBAY_SSH= names the public address; that keeps the | |
| 203 | runner's source address, =127.0.0.1=, one no build connects from. And | |
| 204 | an nftables table (=deploy/gitbay-runner-egress.nft=) rejects every | |
| 205 | connection the runner's user makes to the host's own addresses except | |
| 198 | runner's host, the rules below limit it to the forge's public ports | |
| 199 | 22, 80 and 443. Under pasta a build's container holds the host's own | |
| 200 | public address, and pasta translates =169.254.1.2= (its | |
| 201 | =--map-guest-addr=) to that address. A runner that polls the daemon | |
| 202 | over loopback starts its containers with =--network | |
| 203 | pasta:--no-map-gw=, which is podman's default stated explicitly, and | |
| 204 | gives them =GITBAY_SSH= at =169.254.1.2=, with the forge's port when | |
| 205 | it is not 22. The runner's source address is =127.0.0.1=. An nftables | |
| 206 | table (=deploy/gitbay-runner-egress.nft=) rejects every connection | |
| 207 | the runner's user makes to the host's own addresses except | |
| 206 | 208 | =127.0.0.1:22=, DNS on loopback, and 22, 80 and 443 on the public |
| 207 | 209 | address: the operator's sshd on 2222 and anything bound to loopback |
| 208 | are closed to builds. Under rootless podman a build's connections are | |
| 210 | are closed to it. Under rootless podman a build's connections are | |
| 209 | 211 | made by pasta as the runner's user, so the table cannot tell a build |
| 210 | from its runner and leaves =127.0.0.1:22= open; the gateway mapping | |
| 211 | is what closes it to builds. The runner does not start without the | |
| 212 | table. The SSH auth limiter counts failures per source address and, | |
| 213 | once an address is over the limit, refuses every key from it until | |
| 214 | the window passes, the runner's included; with registration open or | |
| 215 | by invite an unknown key never counts (krz/gitbay#260). Under | |
| 216 | =-isolation none= a build runs on the host and shares its loopback; | |
| 217 | the table still applies, since it runs as the same user. | |
| 212 | from its runner and leaves =127.0.0.1:22= open; that no build reaches | |
| 213 | the host's loopback is measured from inside a build by runbook R3. | |
| 214 | The runner does not start without the table. The SSH auth limiter | |
| 215 | counts failures per source address and, once an address is over the | |
| 216 | limit, refuses every key from it until the window passes, the | |
| 217 | runner's included; with registration open or by invite an unknown | |
| 218 | key never counts (krz/gitbay#260). Under =-isolation none= a build | |
| 219 | runs on the host and shares its loopback; the table still applies, | |
| 220 | since it runs as the same user. | |
| 218 | 221 | |
| 219 | 222 | Under =-isolation none=, anything a step can do as the runner's user a |
| 220 | 223 | pushed =ci.yml= can do. Under podman a step is confined to its |
.gitbay/wiki/Users.org +6 −5
| @@ -563,11 +563,12 @@ with =sh -c= on the instance's runner, stopping at the first failure; | ||
| 563 | 563 | a broken config surfaces as a failed =ci/config= status. Environment: |
| 564 | 564 | =GITBAY_REPO=, |
| 565 | 565 | =GITBAY_SHA=, =GITBAY_REF=, =GITBAY_JOB=, =CI=true=, and =GITBAY_SSH=, |
| 566 | the instance's ssh destination as the build reaches it (=git@gitbay.org=, | |
| 567 | the instance's public address, from a runner elsewhere and from a | |
| 568 | container on the server's own runner alike; =git@host:port= on an | |
| 569 | instance whose ssh is not on 22, so use it as =ssh://$GITBAY_SSH/owner/name.git= | |
| 570 | or =ssh ssh://$GITBAY_SSH …=, which work in both forms). A job that | |
| 566 | the instance's ssh destination as the build reaches it: on the forge's | |
| 567 | own runner, =git@169.254.1.2=, pasta's address for the host; from a | |
| 568 | runner elsewhere, the instance's public address (=git@gitbay.org=). | |
| 569 | Either carries =:port= when the instance's ssh is not on 22, so use it | |
| 570 | as =ssh://$GITBAY_SSH/owner/name.git= or =ssh ssh://$GITBAY_SSH …=, | |
| 571 | which work in both forms. A job that | |
| 571 | 572 | talks back to the instance — a release asset, a comment, a push to a |
| 572 | 573 | pages branch — uses =$GITBAY_SSH= with a key it holds as a secret; |
| 573 | 574 | the build's container has no key of its own. Two things about that |
CHANGELOG.org +9 −3
| @@ -133,9 +133,15 @@ for the eighteen commands whose CLI path differs from the registry's | ||
| 133 | 133 | same image. =repo settings require-contexts= names status contexts |
| 134 | 134 | that must report green; setting any turns require-checks on, and one |
| 135 | 135 | not yet reported counts as pending. (#258) |
| 136 | - Builds lose host loopback and reach only the forge's public 22, 80 | |
| 137 | and 443 on the host. Deploy gitbayd, then the runner, after | |
| 138 | validating on a scratch repository per the CI page. (#260) | |
| 136 | - A runner polling over loopback gives its podman builds =GITBAY_SSH= | |
| 137 | at =169.254.1.2=, pasta's address for the host, with the port when | |
| 138 | it is not 22, since under pasta the container holds the host's | |
| 139 | public address. An nftables table limits what the runner's user | |
| 140 | reaches on the host to =127.0.0.1:22=, DNS on loopback, and public | |
| 141 | 22, 80 and 443. The runner unit now requires the egress unit: run | |
| 142 | =deploy/runner-podman-setup.sh= (it installs nftables) before =make | |
| 143 | deploy-runner=. Deploy gitbayd, then the runner, after validating on | |
| 144 | a scratch repository per the CI page. (#260) | |
| 139 | 145 | |
| 140 | 146 | * v1.36.0 — 2026-09-23 |
| 141 | 147 | |
cmd/gitbay-runner/env_test.go +21 −12
| @@ -191,23 +191,31 @@ func TestSplitEnvKeepsMultilineOutOfTheFile(t *testing.T) { | ||
| 191 | 191 | } |
| 192 | 192 | |
| 193 | 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). | |
| 194 | // from where it runs. Under pasta a podman build holds the host's public | |
| 195 | // address, so a runner polling over loopback gives its podman builds | |
| 196 | // 169.254.1.2, pasta's address for the host, with the claim's port when | |
| 197 | // it is not 22, and never the loopback address it polls. Any other runner | |
| 198 | // passes on the claim's destination, or its own remote without one (#260). | |
| 198 | 199 | func TestStepEnvCarriesInstanceAddress(t *testing.T) { |
| 199 | 200 | env := stepEnv(job{}, "/tmp/buildhome", "git@gitbay.org") |
| 200 | 201 | if !containsEnv(env, "GITBAY_SSH=git@gitbay.org") { |
| 201 | 202 | t.Errorf("GITBAY_SSH missing: %q", env) |
| 202 | 203 | } |
| 203 | 204 | 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"}, | |
| 205 | {"git@127.0.0.1", isolationPodman, "git@gitbay.org", "git@169.254.1.2"}, | |
| 206 | {"git@127.0.0.1", isolationPodman, "git@gitbay.test:22", "git@169.254.1.2"}, | |
| 207 | {"git@127.0.0.1", isolationPodman, "git@gitbay.test:2022", "git@169.254.1.2:2022"}, | |
| 208 | {"git@127.0.0.1", isolationPodman, "git@[2001:db8::1]:2022", "git@169.254.1.2:2022"}, | |
| 209 | {"git@127.0.0.1", isolationPodman, "git@2001:db8::1", "git@169.254.1.2"}, | |
| 210 | {"git@localhost", isolationPodman, "git@gitbay.org", "git@169.254.1.2"}, | |
| 211 | {"forge@::1", isolationPodman, "git@gitbay.org", "forge@169.254.1.2"}, | |
| 212 | {"127.0.0.1", isolationPodman, "git@gitbay.org", "git@169.254.1.2"}, | |
| 213 | {"git@127.0.0.1", isolationPodman, "", "git@169.254.1.2"}, | |
| 214 | {"git@127.0.0.1", isolationNone, "git@gitbay.org", "git@gitbay.org"}, | |
| 215 | {"git@127.0.0.1", isolationNone, "", "git@127.0.0.1"}, | |
| 216 | {"git@gitbay.org", isolationPodman, "git@other.test", "git@other.test"}, | |
| 217 | {"git@gitbay.org", isolationPodman, "", "git@gitbay.org"}, | |
| 218 | {"gitbay.org", isolationPodman, "", "gitbay.org"}, | |
| 211 | 219 | } { |
| 212 | 220 | r := &runner{remote: tc.remote, isolation: tc.isolation} |
| 213 | 221 | if got := r.buildSSH(tc.public); got != tc.want { |
| @@ -217,7 +225,8 @@ func TestStepEnvCarriesInstanceAddress(t *testing.T) { | ||
| 217 | 225 | } |
| 218 | 226 | |
| 219 | 227 | // 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). | |
| 228 | // connect from, so only its builds state --no-map-gw rather than rely on | |
| 229 | // podman's default (#260). | |
| 221 | 230 | func TestBuildNetworkKeepsLoopbackRunnersBuildsOff(t *testing.T) { |
| 222 | 231 | for _, tc := range []struct { |
| 223 | 232 | remote string |
cmd/gitbay-runner/main.go +38 −17
| @@ -16,6 +16,7 @@ import ( | ||
| 16 | 16 | "io" |
| 17 | 17 | "io/fs" |
| 18 | 18 | "log" |
| 19 | "net" | |
| 19 | 20 | "os" |
| 20 | 21 | "os/exec" |
| 21 | 22 | "os/signal" |
| @@ -496,29 +497,49 @@ func (r *runner) loopbackRemote() bool { | ||
| 496 | 497 | return host == "127.0.0.1" || host == "localhost" || host == "::1" |
| 497 | 498 | } |
| 498 | 499 | |
| 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. | |
| 500 | // hostAddr is the address a podman build under pasta reaches the host | |
| 501 | // at: pasta's --map-guest-addr, which podman sets to this address and | |
| 502 | // which pasta translates to the host's public address. | |
| 503 | const hostAddr = "169.254.1.2" | |
| 504 | ||
| 505 | // buildSSH is the instance's ssh destination as a build reaches it. | |
| 506 | // Under pasta a podman build holds the host's own public address, so the | |
| 507 | // instance's public name resolves to the container itself. A runner | |
| 508 | // polling over loopback runs on the daemon's host, and its podman builds | |
| 509 | // get hostAddr with the user from -remote and the port from the claim's | |
| 510 | // destination when it is not 22. Otherwise a build uses the claim's | |
| 511 | // destination, or -remote when the claim carries none. | |
| 504 | 512 | func (r *runner) buildSSH(public string) string { |
| 505 | if r.isolation == isolationPodman && r.loopbackRemote() && public != "" { | |
| 513 | if r.isolation == isolationPodman && r.loopbackRemote() { | |
| 514 | user, _, ok := strings.Cut(r.remote, "@") | |
| 515 | if !ok || user == "" { | |
| 516 | user = "git" | |
| 517 | } | |
| 518 | dest := hostAddr | |
| 519 | _, hostport, ok := strings.Cut(public, "@") | |
| 520 | if !ok { | |
| 521 | hostport = public | |
| 522 | } | |
| 523 | if _, port, err := net.SplitHostPort(hostport); err == nil && port != "" && port != "22" { | |
| 524 | dest = net.JoinHostPort(hostAddr, port) | |
| 525 | } | |
| 526 | return user + "@" + dest | |
| 527 | } | |
| 528 | if public != "" { | |
| 506 | 529 | return public |
| 507 | 530 | } |
| 508 | 531 | return r.remote |
| 509 | 532 | } |
| 510 | 533 | |
| 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. | |
| 534 | // buildNetwork is the podman network option for a build on the daemon's | |
| 535 | // host. A build reaches the host at hostAddr, which pasta translates to | |
| 536 | // the host's public address, and cannot reach the host's loopback, so | |
| 537 | // none of its connections arrive from 127.0.0.1, the address the runner | |
| 538 | // polls from; the SSH auth limiter counts failures per source address | |
| 539 | // (#260). podman passes --no-map-gw to pasta by default; it is stated | |
| 540 | // here so the build's view of the host does not depend on that default. | |
| 541 | // The host's nftables table (deploy/gitbay-runner-egress.nft) limits | |
| 542 | // what a build reaches on the host to 22, 80 and 443. | |
| 522 | 543 | func (r *runner) buildNetwork() []string { |
| 523 | 544 | if !r.loopbackRemote() { |
| 524 | 545 | return nil |
deploy/gitbay-runner-egress.nft +10 −4
| @@ -17,14 +17,20 @@ | ||
| 17 | 17 | # outbound internet access, trusted or not (go mod download needs it). |
| 18 | 18 | # |
| 19 | 19 | # What ci-runner may reach on this host: |
| 20 | # 127.0.0.1:22 the forge over loopback, for the runner. Builds do | |
| 21 | # not reach loopback at all: the runner starts them | |
| 22 | # with pasta's gateway mapping off (--no-map-gw). | |
| 20 | # 127.0.0.1:22 the forge over loopback, for the runner. This | |
| 21 | # table cannot close it to builds. A build reaches | |
| 22 | # the host at 169.254.1.2, pasta's --map-guest-addr, | |
| 23 | # which pasta translates to the host's public | |
| 24 | # address; --no-map-gw (podman's default, which the | |
| 25 | # runner also states) adds no mapping to loopback. | |
| 26 | # Runbook R3 checks from inside a build whether | |
| 27 | # 127.0.0.1:22 answers. | |
| 23 | 28 | # loopback :53 the host's resolver, for when the host's nameserver |
| 24 | 29 | # is a loopback address. That pasta forwards a |
| 25 | 30 | # build's DNS there is to be confirmed from inside a |
| 26 | 31 | # build by runbook R3, not assumed. |
| 27 | # public 22/80/443 the forge, as anyone on the internet reaches it. | |
| 32 | # public 22/80/443 the forge, as anyone on the internet reaches it, | |
| 33 | # and as a build reaches it through 169.254.1.2. | |
| 28 | 34 | # Everything else is rejected: the admin sshd on 2222 on every address, |
| 29 | 35 | # and any service bound to loopback. -isolation none builds run as the |
| 30 | 36 | # same user and get the same rule. |