runner: builds off the runner's address; host egress limited to public ports !484
20 files changed, +502 −46
Layout: unified · split
.gitbay/wiki/Admin.org +11
| @@ -716,6 +716,17 @@ The script installs podman, delegates a subuid/subgid range to | ||
| 716 | 716 | assuming, enables lingering, and verifies rootless podman actually runs |
| 717 | 717 | as that user. It is idempotent. |
| 718 | 718 | |
| 719 | It also installs nftables. =make deploy-runner= ships | |
| 720 | =deploy/gitbay-runner-egress.nft= to =/etc/gitbay-runner/egress.nft= | |
| 721 | with =gitbay-runner-egress.service=, which loads it and which the | |
| 722 | runner's unit requires; it checks the file with =nft -c=, reloads the | |
| 723 | unit, and runs =deploy/runner-egress-check.sh= as =ci-runner= before | |
| 724 | restarting the runner: =127.0.0.1:22= and the public 22 must answer, | |
| 725 | 2222 must not. The table limits the runner's user to =127.0.0.1:22=, | |
| 726 | DNS on loopback, and 22, 80 and 443 on the host's public address; the | |
| 727 | Threat-Model page says why. A restart of =nftables.service= flushes it; | |
| 728 | =systemctl reload gitbay-runner-egress= restores it. | |
| 729 | ||
| 719 | 730 | The drop-in sets =NoNewPrivileges=no=, without which rootless podman |
| 720 | 731 | cannot call =newuidmap= and the runner refuses to start. That is a |
| 721 | 732 | considered trade, explained in the file and in the Threat-Model; if you |
.gitbay/wiki/Architecture/04-Trust-Boundaries.org +1 −1
| @@ -24,7 +24,7 @@ | ||
| 24 | 24 | | TB4 | Z1 → Z3 git | argv, repository path, stdin packs | argv built by code, never a shell; repository path from the database, not the request (=internal/gitutil=) | |
| 25 | 25 | | TB5 | Z3 → Z1 hook socket | ref updates, repository id, user id, key scope, push token, commit objects | the socket is mode 0600 and, on Linux, refuses a peer whose uid is not the daemon's; a request must carry the token sshd minted for its receive-pack (stored hashed in =push_tokens=) and name the same repository, account and scope. The daemon then decides with =policy.CheckPush= and =sig.VerifyCommit= (=internal/hookd/hookd.go=) | |
| 26 | 26 | | TB6 | Z4 ↔ Z1 runner channel | build claims (with secrets for trusted builds), logs, results | runner-scoped SSH key; claims limited to attached repositories; secrets only when the build is trusted (=internal/control/build.go=) | |
| 27 | | TB7 | Z5 → Z4 container | build steps, workspace, build home | rootless podman, operator-provisioned image, cgroup limits; a trusted build's home is its repository's, an untrusted build's is discarded with it; the network is open (#260) | | |
| 27 | | TB7 | Z5 → Z4 container | build steps, workspace, build home | rootless podman, operator-provisioned image, cgroup limits; a trusted build's home is its repository's, an untrusted build's is discarded with it; outbound is open; on the host only the forge's public ports (#260) | | |
| 28 | 28 | | TB8 | Z1 → Z0 outbound | webhooks, mirrors, mail, push | address checks on user-supplied URLs; HMAC on webhooks; no redirects ([[file:03-Deployment.org][3]]) | |
| 29 | 29 | | TB9 | user content → browser | Markdown and Org bodies, READMEs, filenames | HTML sanitised (=ugcHTML=, =internal/httpd/web.go=, bluemonday); CSP =script-src 'none'= | |
| 30 | 30 | | TB10| Z6 → everything | host shell | operator SSH on 2222, keys only, fail2ban; append-only offsite backup credentials | |
.gitbay/wiki/Architecture/07-CI-and-Supply-Chain.org +1 −1
| @@ -68,7 +68,7 @@ Who may do what: | ||
| 68 | 68 | | Build home | trusted: =<workdir>/trusted-home/<owner>/<name>=, one per repository, persistent; untrusted: =<workdir>/build-<id>-home=, removed with the build (=main.go=) | |
| 69 | 69 | | Secrets | env file 0600 outside the workspace, or =--env NAME= for multi-line values | |
| 70 | 70 | | Resources | per-build cgroup with =memory.max= and =cpu.max= written by the runner; unit-level =MemoryMax=6G=, =CPUQuota=300%= | |
| 71 | | Network | podman default (pasta); outbound unrestricted (#260) | | |
| 71 | | Network | pasta; outbound open; a loopback runner's builds run with =--no-map-gw= (=main.go=); on the host only public 22/80/443 (=gitbay-runner-egress.nft=, #260) | | |
| 72 | 72 | | Shutdown | SIGTERM stops claiming and drains in-flight builds; the unit uses =KillMode=mixed= | |
| 73 | 73 | |
| 74 | 74 | * Integrations |
.gitbay/wiki/Architecture/09-Controls.org +1 −1
| @@ -88,7 +88,7 @@ chapter names of OWASP ASVS 4.0 where one fits. | ||
| 88 | 88 | | No secrets for untrusted builds | in place | =internal/control/build.go= | |
| 89 | 89 | | Runner limited to attached repositories | in place | =runnerMayBuild= (=build.go=) | |
| 90 | 90 | | Build images fixed by the operator | in place | =--pull=never= | |
| 91 | | Build network egress restricted | gap | #260 | | |
| 91 | | Build network egress restricted | partial | host: loopback closed, public 22/80/443 only (=gitbay-runner-egress.nft=); internet outbound open by decision (#260) | | |
| 92 | 92 | | Build results reused only across equal trust | in place | =SuccessBuildForTree=, =SuccessBuildFor= (=internal/store/builds.go=) | |
| 93 | 93 | |
| 94 | 94 | ** Availability and operations |
.gitbay/wiki/CI.org +10
| @@ -64,6 +64,16 @@ seconds: a follower who loses it is told the repository is not found. | ||
| 64 | 64 | A restart ends every open follow with a message saying so, rather |
| 65 | 65 | than holding the drain; follow again once the daemon is back. |
| 66 | 66 | |
| 67 | * What a build can reach | |
| 68 | ||
| 69 | Builds have outbound internet access, trusted and untrusted alike. On | |
| 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). | |
| 76 | ||
| 67 | 77 | * The table |
| 68 | 78 | |
| 69 | 79 | One =ci.yml= with five jobs, each isolating one rule: |
.gitbay/wiki/Threat-Model.org +25
| @@ -193,6 +193,31 @@ runner, polling over SSH, clones the commit and runs its steps. | ||
| 193 | 193 | already has rather than naming anything on the internet. On an |
| 194 | 194 | instance with open registration that is the difference between a |
| 195 | 195 | curated set and arbitrary code from a registry nobody vetted. |
| 196 | - *What a build can reach.* Outbound internet, trusted or not: a fork's | |
| 197 | merge request to a Go repository has to fetch its modules. On the | |
| 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 | |
| 208 | =127.0.0.1:22=, DNS on loopback, and 22, 80 and 443 on the public | |
| 209 | address: the operator's sshd on 2222 and anything bound to loopback | |
| 210 | are closed to it. Under rootless podman a build's connections are | |
| 211 | made by pasta as the runner's user, so the table cannot tell a build | |
| 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. | |
| 196 | 221 | |
| 197 | 222 | Under =-isolation none=, anything a step can do as the runner's user a |
| 198 | 223 | pushed =ci.yml= can do. Under podman a step is confined to its |
.gitbay/wiki/Users.org +6 −3
| @@ -563,9 +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 | from a runner elsewhere; inside a container on the server's own runner | |
| 568 | the host is at a private address the runner fills in). 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, with | |
| 568 | =:port= when the instance's ssh is not on 22; from any other runner, | |
| 569 | the destination that runner polls (its =-remote=, such as | |
| 570 | =git@gitbay.org=). Use it as =ssh://$GITBAY_SSH/owner/name.git= or | |
| 571 | =ssh ssh://$GITBAY_SSH …=, which work in either form. A job that | |
| 569 | 572 | talks back to the instance — a release asset, a comment, a push to a |
| 570 | 573 | pages branch — uses =$GITBAY_SSH= with a key it holds as a secret; |
| 571 | 574 | the build's container has no key of its own. Two things about that |
CHANGELOG.org +9
| @@ -133,6 +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 | - 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) | |
| 136 | 145 | |
| 137 | 146 | * v1.36.0 — 2026-09-23 |
| 138 | 147 | |
Makefile +10 −2
| @@ -71,14 +71,22 @@ deploy-runner: preflight | ||
| 71 | 71 | $(CROSS) go build -trimpath -ldflags='$(LDFLAGS)' -o $(RUNNER_BIN) ./cmd/gitbay-runner |
| 72 | 72 | @echo "==> pushing runner to $(HOST)" |
| 73 | 73 | ./deploy/copy.sh $(HOST) $(PORT) $(RUNNER_BIN) /usr/local/bin/gitbay-runner.new |
| 74 | ssh -p $(PORT) root@$(HOST) 'mkdir -p /etc/systemd/system/gitbay-runner.service.d' | |
| 74 | ssh -p $(PORT) root@$(HOST) 'mkdir -p /etc/systemd/system/gitbay-runner.service.d /etc/gitbay-runner' | |
| 75 | 75 | ./deploy/copy.sh $(HOST) $(PORT) deploy/gitbay-runner.override.conf /etc/systemd/system/gitbay-runner.service.d/override.conf |
| 76 | 76 | ./deploy/copy.sh $(HOST) $(PORT) deploy/gitbay-runner-prune.service /etc/systemd/system/gitbay-runner-prune.service |
| 77 | 77 | ./deploy/copy.sh $(HOST) $(PORT) deploy/gitbay-runner-prune.timer /etc/systemd/system/gitbay-runner-prune.timer |
| 78 | ./deploy/copy.sh $(HOST) $(PORT) deploy/gitbay-runner-egress.nft /etc/gitbay-runner/egress.nft | |
| 79 | ./deploy/copy.sh $(HOST) $(PORT) deploy/gitbay-runner-egress.service /etc/systemd/system/gitbay-runner-egress.service | |
| 80 | @echo "==> loading the egress rule" | |
| 81 | ssh -p $(PORT) root@$(HOST) 'set -eu; \ | |
| 82 | nft -c -f /etc/gitbay-runner/egress.nft; \ | |
| 83 | systemctl daemon-reload; \ | |
| 84 | systemctl enable gitbay-runner-egress.service; \ | |
| 85 | systemctl reload-or-restart gitbay-runner-egress.service' | |
| 86 | ssh -p $(PORT) root@$(HOST) 'sh -s' < deploy/runner-egress-check.sh | |
| 78 | 87 | ssh -p $(PORT) root@$(HOST) 'set -eu; \ |
| 79 | 88 | chmod 755 /usr/local/bin/gitbay-runner.new; \ |
| 80 | 89 | mv /usr/local/bin/gitbay-runner.new /usr/local/bin/gitbay-runner; \ |
| 81 | systemctl daemon-reload; \ | |
| 82 | 90 | systemctl enable --now gitbay-runner-prune.timer; \ |
| 83 | 91 | systemctl restart gitbay-runner; \ |
| 84 | 92 | systemctl --no-pager --lines=3 status gitbay-runner; \ |
cmd/gitbay-runner/env_test.go +44 −13
| @@ -190,26 +190,57 @@ func TestSplitEnvKeepsMultilineOutOfTheFile(t *testing.T) { | ||
| 190 | 190 | } |
| 191 | 191 | } |
| 192 | 192 | |
| 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. 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 its own remote (#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 | 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"}, | |
| 204 | for _, tc := range []struct{ remote, isolation, public, want string }{ | |
| 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@127.0.0.1"}, | |
| 215 | {"git@127.0.0.1", isolationNone, "", "git@127.0.0.1"}, | |
| 216 | {"git@gitbay.org", isolationPodman, "git@other.test", "git@gitbay.org"}, | |
| 217 | {"git@gitbay.org", isolationPodman, "", "git@gitbay.org"}, | |
| 218 | {"gitbay.org", isolationPodman, "", "gitbay.org"}, | |
| 219 | {"ci@forge.internal", isolationNone, "git@gitbay.org", "ci@forge.internal"}, | |
| 209 | 220 | } { |
| 210 | 221 | 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) | |
| 222 | if got := r.buildSSH(tc.public); got != tc.want { | |
| 223 | t.Errorf("remote %s under %s, public %q: got %s want %s", tc.remote, tc.isolation, tc.public, got, tc.want) | |
| 224 | } | |
| 225 | } | |
| 226 | } | |
| 227 | ||
| 228 | // Only a runner that polls over loopback shares an address a build could | |
| 229 | // connect from, so only its builds state --no-map-gw rather than rely on | |
| 230 | // podman's default (#260). | |
| 231 | func TestBuildNetworkKeepsLoopbackRunnersBuildsOff(t *testing.T) { | |
| 232 | for _, tc := range []struct { | |
| 233 | remote string | |
| 234 | want []string | |
| 235 | }{ | |
| 236 | {"git@127.0.0.1", []string{"--network", "pasta:--no-map-gw"}}, | |
| 237 | {"localhost", []string{"--network", "pasta:--no-map-gw"}}, | |
| 238 | {"git@::1", []string{"--network", "pasta:--no-map-gw"}}, | |
| 239 | {"git@gitbay.org", nil}, | |
| 240 | } { | |
| 241 | r := &runner{remote: tc.remote, isolation: isolationPodman} | |
| 242 | if got := r.buildNetwork(); strings.Join(got, " ") != strings.Join(tc.want, " ") { | |
| 243 | t.Errorf("remote %s: %q, want %q", tc.remote, got, tc.want) | |
| 213 | 244 | } |
| 214 | 245 | } |
| 215 | 246 | } |
cmd/gitbay-runner/isolate.go +1
| @@ -151,6 +151,7 @@ func (r *runner) runStepsPodman(j job, dir string, env []string, sink io.Writer, | ||
| 151 | 151 | args = append(args, |
| 152 | 152 | "--name", name, |
| 153 | 153 | "--env-file", envFile) |
| 154 | args = append(args, r.buildNetwork()...) | |
| 154 | 155 | args = append(args, inheritArgs(inherit)...) |
| 155 | 156 | args = append(args, |
| 156 | 157 | "--volume", dir+":/workspace:rw", |
cmd/gitbay-runner/main.go +61 −23
| @@ -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" |
| @@ -42,7 +43,10 @@ type job struct { | ||
| 42 | 43 | // Trusted is false for a merge request head from a fork, and when the |
| 43 | 44 | // server did not say: such a build gets no secrets and a home of its |
| 44 | 45 | // own (#255). |
| 45 | Trusted bool `json:"trusted"` | |
| 46 | Trusted bool `json:"trusted"` | |
| 47 | // SSH is the instance's public ssh destination; a runner polling | |
| 48 | // over loopback takes its port for its builds (#260). | |
| 49 | SSH string `json:"ssh"` | |
| 46 | 50 | Secrets map[string]string `json:"secrets"` |
| 47 | 51 | } |
| 48 | 52 | |
| @@ -425,7 +429,7 @@ func (r *runner) run(j job) bool { | ||
| 425 | 429 | } |
| 426 | 430 | } |
| 427 | 431 | |
| 428 | env := stepEnv(j, home, r.buildSSH()) | |
| 432 | env := stepEnv(j, home, r.buildSSH(j.SSH)) | |
| 429 | 433 | return r.runSteps(j, dir, env, sink, deadline, runStep) |
| 430 | 434 | } |
| 431 | 435 | |
| @@ -483,27 +487,61 @@ func removeTree(dir string) error { | ||
| 483 | 487 | return os.RemoveAll(dir) |
| 484 | 488 | } |
| 485 | 489 | |
| 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. | |
| 492 | func (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" | |
| 490 | // loopbackRemote reports whether the runner polls the daemon on its own | |
| 491 | // host over loopback. | |
| 492 | func (r *runner) loopbackRemote() bool { | |
| 493 | _, host, ok := strings.Cut(r.remote, "@") | |
| 494 | if !ok { | |
| 495 | host = r.remote | |
| 496 | } | |
| 497 | return host == "127.0.0.1" || host == "localhost" || host == "::1" | |
| 498 | } | |
| 499 | ||
| 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. Any other runner's -remote is the path | |
| 511 | // that reaches the forge from where it runs, so its builds get that. | |
| 512 | func (r *runner) buildSSH(public string) string { | |
| 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 | return r.remote | |
| 529 | } | |
| 530 | ||
| 531 | // buildNetwork is the podman network option for a build on the daemon's | |
| 532 | // host. A build reaches the host at hostAddr, which pasta translates to | |
| 533 | // the host's public address, and cannot reach the host's loopback, so | |
| 534 | // none of its connections arrive from 127.0.0.1, the address the runner | |
| 535 | // polls from; the SSH auth limiter counts failures per source address | |
| 536 | // (#260). podman passes --no-map-gw to pasta by default; it is stated | |
| 537 | // here so the build's view of the host does not depend on that default. | |
| 538 | // The host's nftables table (deploy/gitbay-runner-egress.nft) limits | |
| 539 | // what a build reaches on the host to 22, 80 and 443. | |
| 540 | func (r *runner) buildNetwork() []string { | |
| 541 | if !r.loopbackRemote() { | |
| 542 | return nil | |
| 543 | } | |
| 544 | return []string{"--network", "pasta:--no-map-gw"} | |
| 507 | 545 | } |
| 508 | 546 | |
| 509 | 547 | // stepEnv builds the environment a build step runs with. It is |
deploy/gitbay-runner-egress.nft added +66
| @@ -0,0 +1,66 @@ | ||
| 1 | #!/usr/sbin/nft -f | |
| 2 | # Host egress for CI builds (#260). Loaded by gitbay-runner-egress.service, | |
| 3 | # which gitbay-runner.service requires, so the runner does not start | |
| 4 | # without it. `make deploy-runner` installs it as | |
| 5 | # /etc/gitbay-runner/egress.nft. | |
| 6 | # | |
| 7 | # Under rootless podman with pasta, a build's connections are made by | |
| 8 | # pasta on the host, from sockets owned by the runner's user, ci-runner. | |
| 9 | # nftables sees them exactly as it sees the runner's own ssh, so this | |
| 10 | # table cannot tell a build from its runner. It limits what that user | |
| 11 | # reaches on this host, and the runner needs little: 127.0.0.1:22, to | |
| 12 | # poll, clone and stream logs. | |
| 13 | # | |
| 14 | # Every packet to one of the host's own addresses, loopback or public, | |
| 15 | # leaves through lo, so the output hook sees host-bound traffic as | |
| 16 | # oifname "lo". Traffic to other hosts is not matched: builds keep | |
| 17 | # outbound internet access, trusted or not (go mod download needs it). | |
| 18 | # | |
| 19 | # What ci-runner may reach on this host: | |
| 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. | |
| 28 | # loopback :53 the host's resolver, for when the host's nameserver | |
| 29 | # is a loopback address. That pasta forwards a | |
| 30 | # build's DNS there is to be confirmed from inside a | |
| 31 | # build by runbook R3, not assumed. | |
| 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. | |
| 34 | # Everything else is rejected: the admin sshd on 2222 on every address, | |
| 35 | # and any service bound to loopback. -isolation none builds run as the | |
| 36 | # same user and get the same rule. | |
| 37 | # | |
| 38 | # The account name is resolved when the file is loaded. A restart of | |
| 39 | # nftables.service (flush ruleset) removes this table; `systemctl | |
| 40 | # reload gitbay-runner-egress` puts it back. | |
| 41 | # | |
| 42 | # The first line creates the table if it is missing, so the delete never | |
| 43 | # fails; the file then replaces it in one transaction, and a reload never | |
| 44 | # leaves a moment without the rule. The uid match sits in the base | |
| 45 | # chain's one rule rather than in a `!=` accept, because a packet with no | |
| 46 | # socket (a reset the kernel sends) matches neither `==` nor `!=` on | |
| 47 | # skuid and would otherwise fall through to the reject. | |
| 48 | ||
| 49 | table inet gitbay_runner | |
| 50 | delete table inet gitbay_runner | |
| 51 | ||
| 52 | table inet gitbay_runner { | |
| 53 | chain output { | |
| 54 | type filter hook output priority filter; policy accept; | |
| 55 | oifname "lo" meta skuid "ci-runner" jump host | |
| 56 | } | |
| 57 | ||
| 58 | chain host { | |
| 59 | ip daddr 127.0.0.1 tcp dport 22 accept | |
| 60 | ip daddr 127.0.0.0/8 meta l4proto { tcp, udp } th dport 53 accept | |
| 61 | ip6 daddr ::1 meta l4proto { tcp, udp } th dport 53 accept | |
| 62 | ip daddr != 127.0.0.0/8 tcp dport { 22, 80, 443 } accept | |
| 63 | ip6 daddr != ::1 tcp dport { 22, 80, 443 } accept | |
| 64 | counter reject | |
| 65 | } | |
| 66 | } | |
deploy/gitbay-runner-egress.service added +29
| @@ -0,0 +1,29 @@ | ||
| 1 | # Loads the CI runner's host egress rule (#260, | |
| 2 | # deploy/gitbay-runner-egress.nft). gitbay-runner.service requires this | |
| 3 | # unit, so the runner starts only with the rule in force; stopping this | |
| 4 | # unit removes the table and stops the runner with it. | |
| 5 | # | |
| 6 | # Ordered after nftables.service and ufw.service: either may rewrite the | |
| 7 | # ruleset at boot, and nftables.service's default config starts with | |
| 8 | # flush ruleset. A missing unit in After= is ignored. | |
| 9 | # | |
| 10 | # Reload re-reads the file and replaces the table in one transaction; it | |
| 11 | # does not restart the runner, which a restart of this unit would | |
| 12 | # (Requires= propagates restarts). `make deploy-runner` reloads. | |
| 13 | # | |
| 14 | # Stop uses destroy, which succeeds when the table is already gone (a | |
| 15 | # flush ruleset removes it); delete would fail and leave the unit failed. | |
| 16 | [Unit] | |
| 17 | Description=Host egress rule for CI builds | |
| 18 | After=nftables.service ufw.service | |
| 19 | Before=gitbay-runner.service | |
| 20 | ||
| 21 | [Service] | |
| 22 | Type=oneshot | |
| 23 | RemainAfterExit=yes | |
| 24 | ExecStart=/usr/sbin/nft -f /etc/gitbay-runner/egress.nft | |
| 25 | ExecReload=/usr/sbin/nft -f /etc/gitbay-runner/egress.nft | |
| 26 | ExecStop=/usr/sbin/nft destroy table inet gitbay_runner | |
| 27 | ||
| 28 | [Install] | |
| 29 | WantedBy=multi-user.target | |
deploy/gitbay-runner.override.conf +9
| @@ -22,6 +22,15 @@ | ||
| 22 | 22 | # container store lives under the runner's home, which ProtectSystem |
| 23 | 23 | # would otherwise make read-only. Prepare the host with |
| 24 | 24 | # deploy/runner-podman-setup.sh before deploying a runner that isolates. |
| 25 | [Unit] | |
| 26 | # The host egress rule (#260, gitbay-runner-egress.nft) limits what this | |
| 27 | # unit's user reaches on the host: 127.0.0.1:22 for the runner, the | |
| 28 | # forge's public 22, 80 and 443 for builds, nothing else. Required, so | |
| 29 | # the runner does not start without it: a table that failed to load must | |
| 30 | # not mean builds reach the admin sshd. | |
| 31 | Requires=gitbay-runner-egress.service | |
| 32 | After=gitbay-runner-egress.service | |
| 33 | ||
| 25 | 34 | [Service] |
| 26 | 35 | # The runner polls as a non-admin account with a runner-scoped key, and |
| 27 | 36 | # claims only the repositories that key is attached to (`repo runner |
deploy/runner-egress-check.sh added +41
| @@ -0,0 +1,41 @@ | ||
| 1 | #!/bin/sh | |
| 2 | # Check the CI runner's host egress rule (#260) as the runner's user: | |
| 3 | # the forge over loopback on 22 must answer (the runner polls there), | |
| 4 | # and the admin sshd on 2222 must not, on loopback or the public | |
| 5 | # address. `make deploy-runner` runs this after loading the rule and | |
| 6 | # before restarting the runner, and stops on a failure. | |
| 7 | # | |
| 8 | # ssh -p 2222 root@bay1 'sh -s' < deploy/runner-egress-check.sh | |
| 9 | set -eu | |
| 10 | ||
| 11 | RUNNER_USER="${RUNNER_USER:-ci-runner}" | |
| 12 | # hostname -I lists the host's addresses, IPv4 first on bay1; the first | |
| 13 | # is the public one there. | |
| 14 | public=$(hostname -I | awk '{print $1}') | |
| 15 | ||
| 16 | probe() { | |
| 17 | su -s /bin/bash "$RUNNER_USER" -c "timeout 5 bash -c 'exec 3<>/dev/tcp/$1/$2'" </dev/null 2>/dev/null | |
| 18 | } | |
| 19 | ||
| 20 | nft list table inet gitbay_runner >/dev/null | |
| 21 | ||
| 22 | # The runner polls 127.0.0.1:22. If the rule blocks that, the running | |
| 23 | # runner is already cut off, so remove the table: CI keeps polling as it | |
| 24 | # did before the deploy, and the exit still stops make before the restart. | |
| 25 | if ! probe 127.0.0.1 22; then | |
| 26 | nft destroy table inet gitbay_runner | |
| 27 | echo "$RUNNER_USER cannot reach 127.0.0.1:22 with the egress rule loaded;" >&2 | |
| 28 | echo "removed table inet gitbay_runner so the runner keeps polling. Fix the rule and deploy again." >&2 | |
| 29 | exit 1 | |
| 30 | fi | |
| 31 | if ! probe "$public" 22; then | |
| 32 | echo "$RUNNER_USER cannot reach $public:22: builds would not reach the forge" >&2 | |
| 33 | exit 1 | |
| 34 | fi | |
| 35 | for dest in 127.0.0.1:2222 "$public:2222"; do | |
| 36 | if probe "${dest%:*}" "${dest##*:}"; then | |
| 37 | echo "$RUNNER_USER reaches $dest: the egress rule is not in force" >&2 | |
| 38 | exit 1 | |
| 39 | fi | |
| 40 | done | |
| 41 | echo "egress for $RUNNER_USER: 127.0.0.1:22 and $public:22 open, 2222 refused" | |
deploy/runner-podman-setup.sh +10
| @@ -28,6 +28,16 @@ if ! command -v podman >/dev/null 2>&1; then | ||
| 28 | 28 | fi |
| 29 | 29 | podman --version |
| 30 | 30 | |
| 31 | # nft loads the runner's host egress rule (#260, | |
| 32 | # deploy/gitbay-runner-egress.nft), which `make deploy-runner` ships and | |
| 33 | # the runner's unit requires. Without nft the runner does not start. | |
| 34 | echo "==> installing nftables" | |
| 35 | if ! command -v nft >/dev/null 2>&1; then | |
| 36 | apt-get update | |
| 37 | DEBIAN_FRONTEND=noninteractive apt-get install -y nftables | |
| 38 | fi | |
| 39 | nft --version | |
| 40 | ||
| 31 | 41 | # Rootless podman maps container uids into a range delegated to the user. |
| 32 | 42 | # Without these the runner's `podman run` fails with a mapping error. |
| 33 | 43 | echo "==> subuid/subgid for $RUNNER_USER" |
internal/control/build.go +23 −2
| @@ -6,6 +6,7 @@ import ( | ||
| 6 | 6 | "fmt" |
| 7 | 7 | "io" |
| 8 | 8 | "log/slog" |
| 9 | "net" | |
| 9 | 10 | "regexp" |
| 10 | 11 | "slices" |
| 11 | 12 | "strconv" |
| @@ -460,6 +461,23 @@ func runnerMayBuild(c *Ctx, key store.SSHKey, repoID int64) (bool, error) { | ||
| 460 | 461 | // walking it forever. |
| 461 | 462 | const maxOrphanSkip = 50 |
| 462 | 463 | |
| 464 | // publicSSH is the instance's ssh destination as anyone outside reaches | |
| 465 | // it. A runner on the daemon's own host polls over loopback and takes | |
| 466 | // the port from it for its builds' GITBAY_SSH, which names pasta's | |
| 467 | // address for the host (#260). The port is added only when it is not | |
| 468 | // 22: hutch and orgo build ssh://$GITBAY_SSH/... URLs, valid in both | |
| 469 | // forms. Empty when site_url is not set. | |
| 470 | func publicSSH(c *Ctx) string { | |
| 471 | host := c.Cfg.SiteHost() | |
| 472 | if host == "" { | |
| 473 | return "" | |
| 474 | } | |
| 475 | if p := c.Cfg.SSH.Port; p != 0 && p != 22 { | |
| 476 | return "git@" + net.JoinHostPort(host, strconv.Itoa(p)) | |
| 477 | } | |
| 478 | return "git@" + host | |
| 479 | } | |
| 480 | ||
| 463 | 481 | func runRunnerNext(c *Ctx, args []string) int { |
| 464 | 482 | key, code := runnerSession(c) |
| 465 | 483 | if code >= 0 { |
| @@ -562,10 +580,13 @@ func runRunnerNext(c *Ctx, args []string) int { | ||
| 562 | 580 | Image string `json:"image,omitempty"` |
| 563 | 581 | // Trusted is always sent: a runner decides a build's home and |
| 564 | 582 | // secrets from it, and reads a missing field as untrusted (#255). |
| 565 | Trusted bool `json:"trusted"` | |
| 583 | Trusted bool `json:"trusted"` | |
| 584 | // SSH is the instance's public destination; a runner polling | |
| 585 | // over loopback takes its port for the build's GITBAY_SSH (#260). | |
| 586 | SSH string `json:"ssh,omitempty"` | |
| 566 | 587 | Secrets map[string]string `json:"secrets,omitempty"` |
| 567 | 588 | }{ID: b.ID, Repo: repo.Path(), Number: b.Number, Job: b.Job, SHA: b.SHA, Ref: b.Ref, |
| 568 | Steps: steps, Image: b.Image, Trusted: b.Trusted, Secrets: secrets} | |
| 589 | Steps: steps, Image: b.Image, Trusted: b.Trusted, SSH: publicSSH(c), Secrets: secrets} | |
| 569 | 590 | return c.emit(d, func(w io.Writer) { |
| 570 | 591 | fmt.Fprintf(w, "build %d: %s %s @ %.10s\n", d.ID, d.Repo, d.Job, d.SHA) |
| 571 | 592 | }) |
internal/control/runnernext_test.go +29
| @@ -266,3 +266,32 @@ func TestRunnerNextSaysWhetherTrusted(t *testing.T) { | ||
| 266 | 266 | } |
| 267 | 267 | } |
| 268 | 268 | } |
| 269 | ||
| 270 | // A build on the daemon's own host is given the public destination, not | |
| 271 | // the loopback address its runner polls. The port rides along only when | |
| 272 | // it is not 22, so ssh://$GITBAY_SSH/<owner>/<name>.git is a valid URL | |
| 273 | // either way (#260). | |
| 274 | func TestRunnerNextCarriesPublicSSH(t *testing.T) { | |
| 275 | st, repo, uid, root, baseSHA, _ := setupOrphanRepo(t) | |
| 276 | for _, tc := range []struct { | |
| 277 | port int | |
| 278 | want string | |
| 279 | }{ | |
| 280 | {0, `"ssh":"git@x.test"`}, | |
| 281 | {22, `"ssh":"git@x.test"`}, | |
| 282 | {2022, `"ssh":"git@x.test:2022"`}, | |
| 283 | } { | |
| 284 | if _, err := st.CreateBuild(repo.ID, "unit", baseSHA, "main", "[]", "", "", true); err != nil { | |
| 285 | t.Fatal(err) | |
| 286 | } | |
| 287 | c, out := runnerCtx(st, uid, root) // site_url https://x.test | |
| 288 | c.Cfg.SSH.Port = tc.port | |
| 289 | c.JSON = true | |
| 290 | if code := runRunnerNext(c, nil); code != protocol.ExitOK { | |
| 291 | t.Fatalf("port %d: runner next: exit %d, output:\n%s", tc.port, code, out.String()) | |
| 292 | } | |
| 293 | if !strings.Contains(out.String(), tc.want) { | |
| 294 | t.Fatalf("port %d: claim lacks %s:\n%s", tc.port, tc.want, out.String()) | |
| 295 | } | |
| 296 | } | |
| 297 | } | |
internal/sshd/sshd_test.go +115
| @@ -296,3 +296,118 @@ func TestUnregisteredKeyMessageNamesFingerprintAndHost(t *testing.T) { | ||
| 296 | 296 | } |
| 297 | 297 | } |
| 298 | 298 | } |
| 299 | ||
| 300 | // authMeta is the connection metadata authenticate reads: only the | |
| 301 | // remote address. | |
| 302 | type authMeta struct { | |
| 303 | ssh.ConnMetadata | |
| 304 | addr net.Addr | |
| 305 | } | |
| 306 | ||
| 307 | func (m authMeta) RemoteAddr() net.Addr { return m.addr } | |
| 308 | ||
| 309 | func authKey(t *testing.T) ssh.PublicKey { | |
| 310 | t.Helper() | |
| 311 | pub, _, err := ed25519.GenerateKey(rand.Reader) | |
| 312 | if err != nil { | |
| 313 | t.Fatal(err) | |
| 314 | } | |
| 315 | k, err := ssh.NewPublicKey(pub) | |
| 316 | if err != nil { | |
| 317 | t.Fatal(err) | |
| 318 | } | |
| 319 | return k | |
| 320 | } | |
| 321 | ||
| 322 | // authServer is a Server holding what authenticate uses: a store with a | |
| 323 | // runner account's key, the registration mode, and a limiter of three | |
| 324 | // failures a minute. | |
| 325 | func authServer(t *testing.T, mode string) (*Server, ssh.PublicKey) { | |
| 326 | t.Helper() | |
| 327 | st, err := store.Open(filepath.Join(t.TempDir(), "gitbay.db")) | |
| 328 | if err != nil { | |
| 329 | t.Fatal(err) | |
| 330 | } | |
| 331 | t.Cleanup(func() { st.Close() }) | |
| 332 | if err := st.MigrateUp(); err != nil { | |
| 333 | t.Fatal(err) | |
| 334 | } | |
| 335 | uid, err := st.CreateUser("ci", false) | |
| 336 | if err != nil { | |
| 337 | t.Fatal(err) | |
| 338 | } | |
| 339 | runner := authKey(t) | |
| 340 | if err := st.AddSSHKey(uid, ssh.FingerprintSHA256(runner), runner.Type(), runner.Marshal(), "runner", ""); err != nil { | |
| 341 | t.Fatal(err) | |
| 342 | } | |
| 343 | cfg := config.Default() | |
| 344 | cfg.Registration.Mode = mode | |
| 345 | return &Server{cfg: cfg, st: st, authLimiter: newRateLimiter(3, time.Minute)}, runner | |
| 346 | } | |
| 347 | ||
| 348 | // With registration closed an unknown key counts against its address. | |
| 349 | // Below the limit a known key's success clears the count. At the limit | |
| 350 | // authenticate refuses before it looks at the key, so the runner's own | |
| 351 | // key from that address is refused too and its success never runs to | |
| 352 | // clear anything, until the window passes. Another address is not | |
| 353 | // affected. This is why a build must not share the runner's source | |
| 354 | // address (#260). | |
| 355 | func TestAuthLockoutHoldsAgainstTheRunnersKey(t *testing.T) { | |
| 356 | s, runner := authServer(t, "closed") | |
| 357 | stranger := authKey(t) | |
| 358 | failTimes := func(n int) { | |
| 359 | t.Helper() | |
| 360 | for i := 0; i < n; i++ { | |
| 361 | if _, err := s.authenticate(fromLoopback, stranger); err == nil { | |
| 362 | t.Fatal("unknown key admitted with registration closed") | |
| 363 | } | |
| 364 | } | |
| 365 | } | |
| 366 | ||
| 367 | failTimes(2) | |
| 368 | if _, err := s.authenticate(fromLoopback, runner); err != nil { | |
| 369 | t.Fatalf("runner below the limit: %v", err) | |
| 370 | } | |
| 371 | failTimes(2) | |
| 372 | if _, err := s.authenticate(fromLoopback, runner); err != nil { | |
| 373 | t.Fatalf("runner after its success cleared the count: %v", err) | |
| 374 | } | |
| 375 | ||
| 376 | failTimes(3) | |
| 377 | for i := 0; i < 2; i++ { | |
| 378 | if _, err := s.authenticate(fromLoopback, runner); err == nil || !strings.Contains(err.Error(), "too many") { | |
| 379 | t.Fatalf("attempt %d from a locked-out address: %v, want refused", i+1, err) | |
| 380 | } | |
| 381 | } | |
| 382 | if _, err := s.authenticate(fromPublic, runner); err != nil { | |
| 383 | t.Fatalf("another address was locked out too: %v", err) | |
| 384 | } | |
| 385 | ||
| 386 | s.authLimiter.seen["127.0.0.1"].start = time.Now().Add(-2 * time.Minute) | |
| 387 | if _, err := s.authenticate(fromLoopback, runner); err != nil { | |
| 388 | t.Fatalf("runner after the window passed: %v", err) | |
| 389 | } | |
| 390 | } | |
| 391 | ||
| 392 | // With registration open or by invite, an unknown key is admitted to run | |
| 393 | // register and never counts, so no number of unknown-key attempts locks | |
| 394 | // the runner's address out. gitbay.org runs open registration (#260). | |
| 395 | func TestAuthUnknownKeyCountsOnlyWhenClosed(t *testing.T) { | |
| 396 | for _, mode := range []string{"open", "invite"} { | |
| 397 | s, runner := authServer(t, mode) | |
| 398 | for i := 0; i < 10; i++ { | |
| 399 | p, err := s.authenticate(fromLoopback, authKey(t)) | |
| 400 | if err != nil || p.Extensions["anon-key"] == "" { | |
| 401 | t.Fatalf("%s: unknown key %d: %v %+v", mode, i+1, err, p) | |
| 402 | } | |
| 403 | } | |
| 404 | if _, err := s.authenticate(fromLoopback, runner); err != nil { | |
| 405 | t.Fatalf("%s: runner refused after unknown keys: %v", mode, err) | |
| 406 | } | |
| 407 | } | |
| 408 | } | |
| 409 | ||
| 410 | var ( | |
| 411 | fromLoopback = authMeta{addr: &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 40000}} | |
| 412 | fromPublic = authMeta{addr: &net.TCPAddr{IP: net.IPv4(203, 0, 113, 7), Port: 40000}} | |
| 413 | ) | |