7c233ac3
security: a stranger on the internet is given less to work with
a73x 2026-08-22 09:23
Commit message
Makefile
| Old | New | ||
|---|---|---|---|
| @@ -6,14 +6,19 @@ GOLANGCI_VERSION := v2.13.0 | |||
| 6 | GOLANGCI := $(shell go env GOPATH)/bin/golangci-lint | 6 | GOLANGCI := $(shell go env GOPATH)/bin/golangci-lint |
| 7 | # Pinned dead-code analyzer (golang.org/x/tools/cmd/deadcode). Bump deliberately. | 7 | # Pinned dead-code analyzer (golang.org/x/tools/cmd/deadcode). Bump deliberately. |
| 8 | DEADCODE_VERSION := v0.48.0 | 8 | DEADCODE_VERSION := v0.48.0 |
| 9 | # Pinned vulnerability scanner (golang.org/x/vuln/cmd/govulncheck). Bump | ||
| 10 | # deliberately — and note the floor is real: v1.1.4 and older crash under | ||
| 11 | # Go 1.27's module graph. | ||
| 12 | GOVULN_VERSION := v1.7.0 | ||
| 13 | GOVULN := $(shell go env GOPATH)/bin/govulncheck | ||
| 9 | # Style/complexity linters run informationally (exit 0); promote into | 14 | # Style/complexity linters run informationally (exit 0); promote into |
| 10 | # .golangci.yml's enable list once a linter's baseline is clean. | 15 | # .golangci.yml's enable list once a linter's baseline is clean. |
| 11 | LINT_WARN := errcheck,revive,gocyclo,funlen,gocritic,misspell,unconvert,nakedret | 16 | LINT_WARN := errcheck,revive,gocyclo,funlen,gocritic,misspell,unconvert,nakedret |
| 12 | 17 | ||
| 13 | .PHONY: build build-go build-darwin web test vet proto clean \ | 18 | .PHONY: build build-go build-darwin web test vet proto clean \ |
| 14 | lint lint-extra arch cover fmt fmt-check tidy-check proto-check shape shape-check api api-check \ | 19 | lint lint-extra arch cover fmt fmt-check tidy-check proto-check shape shape-check api api-check \ |
| 15 | site site-check ci deadcode web-test web-check \ | 20 | site site-check ci deadcode web-test web-check vuln vuln-tool scan-image \ |
| 16 | deploy release ship hooks site-image server-image | 21 | deploy release ship hooks site-image server-image backup-image |
| 17 | 22 | ||
| 18 | # Enable the repo's client-side merge gate: point git at .githooks, whose | 23 | # Enable the repo's client-side merge gate: point git at .githooks, whose |
| 19 | # pre-push hook runs `make ci` before any push that updates main. Run once per | 24 | # pre-push hook runs `make ci` before any push that updates main. Run once per |
| @@ -100,6 +105,31 @@ lint: lint-tool | |||
| 100 | lint-extra: lint-tool | 105 | lint-extra: lint-tool |
| 101 | $(GOLANGCI) run --default=none --enable=$(LINT_WARN) --issues-exit-code=0 ./... | 106 | $(GOLANGCI) run --default=none --enable=$(LINT_WARN) --issues-exit-code=0 ./... |
| 102 | 107 | ||
| 108 | .PHONY: vuln-tool | ||
| 109 | vuln-tool: | ||
| 110 | @$(GOVULN) -version 2>/dev/null | grep -q "$(GOVULN_VERSION:v%=%)" || \ | ||
| 111 | go install golang.org/x/vuln/cmd/govulncheck@$(GOVULN_VERSION) | ||
| 112 | |||
| 113 | # Known-vulnerability gate over the Go dependency graph. | ||
| 114 | # | ||
| 115 | # It is REACHABILITY-based, which is what makes it usable as a gate: an | ||
| 116 | # advisory in a module we never call the vulnerable symbol of does not fail the | ||
| 117 | # build, so this stays quiet until something we actually execute is affected — | ||
| 118 | # and then it names the call site. That is the difference between a gate people | ||
| 119 | # keep and one they learn to skip. | ||
| 120 | # | ||
| 121 | # It covers Go modules only. The OS layer of a published container is a | ||
| 122 | # different surface with a different scanner; see scan-image. | ||
| 123 | vuln: vuln-tool | ||
| 124 | $(GOVULN) ./... | ||
| 125 | |||
| 126 | # Scan one already-built image for OS/library advisories (see | ||
| 127 | # scripts/scan-image.sh). The image publish scripts call this between build and | ||
| 128 | # push; this target is for scanning something by hand. | ||
| 129 | # make scan-image IMAGE=registry.example/eitri-site:v0.0.7 | ||
| 130 | scan-image: | ||
| 131 | ./scripts/scan-image.sh $(IMAGE) | ||
| 132 | |||
| 103 | # Per-package coverage ratchet (see scripts/coverage.sh). | 133 | # Per-package coverage ratchet (see scripts/coverage.sh). |
| 104 | cover: | 134 | cover: |
| 105 | ./scripts/coverage.sh | 135 | ./scripts/coverage.sh |
| @@ -209,6 +239,11 @@ site-image: | |||
| 209 | server-image: web | 239 | server-image: web |
| 210 | ./scripts/server-image.sh | 240 | ./scripts/server-image.sh |
| 211 | 241 | ||
| 242 | # The nightly backup job's image: alpine + sqlite, carrying its own tag. Built | ||
| 243 | # only when the base moves, not per release. | ||
| 244 | backup-image: | ||
| 245 | ./scripts/backup-image.sh | ||
| 246 | |||
| 212 | # Console logic under test (web/src/**/*.test.ts, run by vitest). New logic in | 247 | # Console logic under test (web/src/**/*.test.ts, run by vitest). New logic in |
| 213 | # the SPA lands with tests; markup does not need them. | 248 | # the SPA lands with tests; markup does not need them. |
| 214 | # | 249 | # |
| @@ -270,7 +305,7 @@ deadcode: | |||
| 270 | # The merge gate. Mirrors the required checks in CI. `test` is the authoritative | 305 | # The merge gate. Mirrors the required checks in CI. `test` is the authoritative |
| 271 | # race-detector run; `cover` re-runs without -race to enforce the ratchet; `arch` | 306 | # race-detector run; `cover` re-runs without -race to enforce the ratchet; `arch` |
| 272 | # re-runs the fitness tests with -count=1 (the race run may serve them cached). | 307 | # re-runs the fitness tests with -count=1 (the race run may serve them cached). |
| 273 | ci: vet build-go build-darwin arch lint fmt-check test cover tidy-check proto-check api-check shape-check deadcode site-check web-test web-check | 308 | ci: vet build-go build-darwin arch lint fmt-check test cover tidy-check proto-check api-check shape-check deadcode site-check web-test web-check vuln |
| 274 | 309 | ||
| 275 | # Compile every Go package (no Node/web build needed — the embed dir ships a | 310 | # Compile every Go package (no Node/web build needed — the embed dir ships a |
| 276 | # placeholder, so the server builds and serves a "UI not built" notice). | 311 | # placeholder, so the server builds and serves a "UI not built" notice). |
deploy/server/README.md
| Old | New | ||
|---|---|---|---|
| @@ -574,6 +574,18 @@ The nightly CronJob writes dated sqlite backups onto prod's PVC; run | |||
| 574 | not survive the node, so the off-node copy is the DR story. stg has no CronJob: | 574 | not survive the node, so the off-node copy is the DR story. stg has no CronJob: |
| 575 | its database is disposable by design. | 575 | its database is disposable by design. |
| 576 | 576 | ||
| 577 | The job runs `$BACKUP_IMAGE` — alpine with sqlite baked in, built by | ||
| 578 | `make backup-image` (see `backup.Dockerfile`). It carries its OWN tag and is not | ||
| 579 | rebuilt per release: it holds sqlite and nothing of eitri's. Set `BACKUP_IMAGE` | ||
| 580 | in the plane's `ship.env` where `BACKUPS=1`; ship refuses to render without it. | ||
| 581 | |||
| 582 | It is a separate image because the job used to `apk add sqlite` on every run, | ||
| 583 | which put a package CDN on the critical path of the only protection a database | ||
| 584 | nobody can rebuild has. Fetch failures took whole nights of backups with them | ||
| 585 | and left nothing but a CronJob pod in Error — which is worth knowing about: | ||
| 586 | nothing alerts on a failed backup, so a run of bad nights is silent until a | ||
| 587 | restore needs one. | ||
| 588 | |||
| 577 | A restore needs the plane's `key_encryption_key` as well as its data. The host | 589 | A restore needs the plane's `key_encryption_key` as well as its data. The host |
| 578 | CA on the PVC is sealed under it, so data alone rebuilds a plane that cannot | 590 | CA on the PVC is sealed under it, so data alone rebuilds a plane that cannot |
| 579 | open it — it would come back without the identity every client pins. Your `~/eitri-deploy/<target>/server.json` is | 591 | open it — it would come back without the identity every client pins. Your `~/eitri-deploy/<target>/server.json` is |
deploy/server/backup-cronjob.yaml
| Old | New | ||
|---|---|---|---|
| @@ -18,12 +18,14 @@ spec: | |||
| 18 | kubernetes.io/hostname: ${NODE_NAME} | 18 | kubernetes.io/hostname: ${NODE_NAME} |
| 19 | containers: | 19 | containers: |
| 20 | - name: backup | 20 | - name: backup |
| 21 | image: alpine:3.20 | 21 | # sqlite is baked in (deploy/server/backup.Dockerfile). It used to |
| 22 | # be installed on every run, which put a package CDN on the | ||
| 23 | # critical path of the only copy of a database nobody can rebuild. | ||
| 24 | image: ${BACKUP_IMAGE} | ||
| 22 | command: ["/bin/sh", "-c"] | 25 | command: ["/bin/sh", "-c"] |
| 23 | args: | 26 | args: |
| 24 | - | | 27 | - | |
| 25 | set -e | 28 | set -e |
| 26 | apk add --no-cache sqlite | ||
| 27 | mkdir -p /data/backups | 29 | mkdir -p /data/backups |
| 28 | sqlite3 /data/eitri.db ".backup /data/backups/eitri-$(date +%F).db" | 30 | sqlite3 /data/eitri.db ".backup /data/backups/eitri-$(date +%F).db" |
| 29 | find /data/backups -name 'eitri-*.db' -mtime +14 -delete | 31 | find /data/backups -name 'eitri-*.db' -mtime +14 -delete |
deploy/server/backup.Dockerfile
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,19 @@ | |||
| 1 | # The nightly backup job's image: alpine with sqlite already in it. | ||
| 2 | # | ||
| 3 | # The job used to run `apk add --no-cache sqlite` on every execution, which put | ||
| 4 | # the Alpine CDN on the critical path of the one thing that protects a database | ||
| 5 | # nobody can rebuild. A transient fetch failure took the night's backup with it, | ||
| 6 | # and the only trace was a CronJob pod in Error that nothing was watching. | ||
| 7 | # | ||
| 8 | # Nothing here is versioned against a release: the job runs one sqlite command | ||
| 9 | # and does not change when eitri does, so this image carries its own tag and is | ||
| 10 | # rebuilt only when the base moves. | ||
| 11 | # 3.24, not the 3.20 the job inherited: 3.20 is past end-of-life, so it receives | ||
| 12 | # no further security fixes and the image scanner's --ignore-unfixed would then | ||
| 13 | # quietly pass anything found in it. Bump when this line goes EOL too. | ||
| 14 | FROM docker.io/library/alpine:3.24 | ||
| 15 | |||
| 16 | RUN apk add --no-cache sqlite | ||
| 17 | |||
| 18 | # Backups are written to a mounted PVC as root; the job creates /data/backups. | ||
| 19 | ENTRYPOINT ["/bin/sh", "-c"] | ||
deploy/server/config.required
| Old | New | ||
|---|---|---|---|
| @@ -69,6 +69,7 @@ oidc.allowed_identities optional | |||
| 69 | # Absent means the built-in default (eitri.sh); an explicit empty string | 69 | # Absent means the built-in default (eitri.sh); an explicit empty string |
| 70 | # disables release discovery and every upgrade surface with it. | 70 | # disables release discovery and every upgrade surface with it. |
| 71 | release_manifest_url optional | 71 | release_manifest_url optional |
| 72 | log_level optional | ||
| 72 | credential_max_age optional | 73 | credential_max_age optional |
| 73 | # How long the append-only audit log keeps a row. Absent means 90 days; "0" | 74 | # How long the append-only audit log keeps a row. Absent means 90 days; "0" |
| 74 | # keeps every row forever. | 75 | # keeps every row forever. |
docs/assumptions.md
| Old | New | ||
|---|---|---|---|
| @@ -647,3 +647,29 @@ renewal. | |||
| 647 | Falsified by an operator pointing the default image at a non-netplan distro, | 647 | Falsified by an operator pointing the default image at a non-netplan distro, |
| 648 | and quietly: both NICs still address, the guest still boots, and nothing in | 648 | and quietly: both NICs still address, the guest still boots, and nothing in |
| 649 | eitri reads a route table to notice. | 649 | eitri reads a route table to notice. |
| 650 | |||
| 651 | ### An operator will notice a leaked host credential and revoke it | ||
| 652 | |||
| 653 | A host credential is minted once at enrollment and never re-issued, so the only | ||
| 654 | bound on a stolen one is someone calling revoke-credential on that host. The | ||
| 655 | age limit that would bound it automatically (`credential_max_age`) is a deadline | ||
| 656 | rather than a rotation — nothing renews, so every host it applies to goes dark | ||
| 657 | and needs re-enrolling by hand — which is why it ships unset. | ||
| 658 | **Not proven**, and the weakest link in host authentication: it assumes | ||
| 659 | detection. Renewal on the sync channel is what would replace the assumption | ||
| 660 | with a mechanism. | ||
| 661 | |||
| 662 | ### A guest keeps the host certificate it was born with, for as long as it lives | ||
| 663 | |||
| 664 | The certificate reaches a guest exactly once, in the cloud-init seed built at | ||
| 665 | create (`internal/agent/reconcile`), and converge never rebuilds a seed — so | ||
| 666 | eitri has no way to hand a running guest a new one. That is what "guest owns the | ||
| 667 | guest" costs here, and it is why the certificate's ten-year window is deliberate | ||
| 668 | rather than lazy: any shorter TTL is a date on which every guest older than it | ||
| 669 | becomes unverifiable, with recreate as the only remedy. | ||
| 670 | **Proven**: traced in code — `e.Seed` has one call site, inside create, and | ||
| 671 | cloud-init runs once per instance-id. The consequence is the open one: a | ||
| 672 | certificate for `<tenant>.<name>` outlives the VM it was issued to, and VM names | ||
| 673 | are reusable, so deleting a VM does not retire its identity. Bounding that needs | ||
| 674 | a delivery channel for a replacement certificate, which does not exist yet. | ||
| 675 | |||
go.mod
| Old | New | ||
|---|---|---|---|
| @@ -10,13 +10,13 @@ require ( | |||
| 10 | github.com/lima-vm/go-qcow2reader v0.7.1 | 10 | github.com/lima-vm/go-qcow2reader v0.7.1 |
| 11 | github.com/modelcontextprotocol/go-sdk v1.6.1 | 11 | github.com/modelcontextprotocol/go-sdk v1.6.1 |
| 12 | github.com/pkg/sftp v1.13.11 | 12 | github.com/pkg/sftp v1.13.11 |
| 13 | github.com/quic-go/quic-go v0.48.2 | 13 | github.com/quic-go/quic-go v0.49.1 |
| 14 | github.com/stretchr/testify v1.11.1 | 14 | github.com/stretchr/testify v1.11.1 |
| 15 | github.com/yuin/goldmark v1.8.4 | 15 | github.com/yuin/goldmark v1.8.4 |
| 16 | golang.org/x/crypto v0.54.0 | 16 | golang.org/x/crypto v0.54.0 |
| 17 | golang.org/x/net v0.56.0 | 17 | golang.org/x/net v0.56.0 |
| 18 | golang.org/x/oauth2 v0.36.0 | 18 | golang.org/x/oauth2 v0.36.0 |
| 19 | golang.org/x/sync v0.20.0 | 19 | golang.org/x/sync v0.22.0 |
| 20 | golang.org/x/sys v0.47.0 | 20 | golang.org/x/sys v0.47.0 |
| 21 | golang.org/x/term v0.45.0 | 21 | golang.org/x/term v0.45.0 |
| 22 | google.golang.org/protobuf v1.36.11 | 22 | google.golang.org/protobuf v1.36.11 |
| @@ -31,9 +31,7 @@ require ( | |||
| 31 | github.com/dustin/go-humanize v1.0.1 // indirect | 31 | github.com/dustin/go-humanize v1.0.1 // indirect |
| 32 | github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 // indirect | 32 | github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 // indirect |
| 33 | github.com/go-jose/go-jose/v4 v4.1.4 // indirect | 33 | github.com/go-jose/go-jose/v4 v4.1.4 // indirect |
| 34 | github.com/go-logr/logr v1.4.3 // indirect | ||
| 35 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect | 34 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect |
| 36 | github.com/golang/protobuf v1.5.4 // indirect | ||
| 37 | github.com/google/jsonschema-go v0.4.3 // indirect | 35 | github.com/google/jsonschema-go v0.4.3 // indirect |
| 38 | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect | 36 | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect |
| 39 | github.com/google/uuid v1.6.0 // indirect | 37 | github.com/google/uuid v1.6.0 // indirect |
| @@ -53,7 +51,7 @@ require ( | |||
| 53 | github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 // indirect | 51 | github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 // indirect |
| 54 | github.com/ulikunitz/xz v0.5.15 // indirect | 52 | github.com/ulikunitz/xz v0.5.15 // indirect |
| 55 | github.com/yosida95/uritemplate/v3 v3.0.2 // indirect | 53 | github.com/yosida95/uritemplate/v3 v3.0.2 // indirect |
| 56 | go.uber.org/mock v0.4.0 // indirect | 54 | go.uber.org/mock v0.5.0 // indirect |
| 57 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect | 55 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect |
| 58 | golang.org/x/mod v0.33.0 // indirect | 56 | golang.org/x/mod v0.33.0 // indirect |
| 59 | golang.org/x/tools v0.42.0 // indirect | 57 | golang.org/x/tools v0.42.0 // indirect |
go.sum
| Old | New | ||
|---|---|---|---|
| @@ -17,16 +17,16 @@ github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 h1:x5yxN | |||
| 17 | github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw= | 17 | github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw= |
| 18 | github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= | 18 | github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= |
| 19 | github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= | 19 | github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= |
| 20 | github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= | 20 | github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= |
| 21 | github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= | 21 | github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= |
| 22 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= | 22 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= |
| 23 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= | 23 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= |
| 24 | github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= | 24 | github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= |
| 25 | github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= | 25 | github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= |
| 26 | github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= | 26 | github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= |
| 27 | github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= | 27 | github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= |
| 28 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= | 28 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= |
| 29 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= | 29 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= |
| 30 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= | 30 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= |
| 31 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= | 31 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= |
| 32 | github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= | 32 | github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= |
| @@ -71,8 +71,8 @@ github.com/pkg/xattr v0.4.12 h1:rRTkSyFNTRElv6pkA3zpjHpQ90p/OdHQC1GmGh1aTjM= | |||
| 71 | github.com/pkg/xattr v0.4.12/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU= | 71 | github.com/pkg/xattr v0.4.12/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU= |
| 72 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | 72 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= |
| 73 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | 73 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= |
| 74 | github.com/quic-go/quic-go v0.48.2 h1:wsKXZPeGWpMpCGSWqOcqpW2wZYic/8T3aqiOID0/KWE= | 74 | github.com/quic-go/quic-go v0.49.1 h1:e5JXpUyF0f2uFjckQzD8jTghZrOUK1xxDqqZhlwixo0= |
| 75 | github.com/quic-go/quic-go v0.48.2/go.mod h1:yBgs3rWBOADpga7F+jJsb6Ybg1LSYiQvwWlLX+/6HMs= | 75 | github.com/quic-go/quic-go v0.49.1/go.mod h1:s2wDnmCdooUQBmQfpUSTCYBl1/D4FcqbULMMkASvR6s= |
| 76 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= | 76 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= |
| 77 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= | 77 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
| 78 | github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= | 78 | github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= |
| @@ -93,8 +93,8 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI | |||
| 93 | github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= | 93 | github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= |
| 94 | github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA= | 94 | github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA= |
| 95 | github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= | 95 | github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= |
| 96 | go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= | 96 | go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= |
| 97 | go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= | 97 | go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= |
| 98 | golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= | 98 | golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= |
| 99 | golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= | 99 | golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= |
| 100 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= | 100 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= |
| @@ -105,8 +105,8 @@ golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= | |||
| 105 | golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= | 105 | golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= |
| 106 | golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= | 106 | golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= |
| 107 | golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= | 107 | golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= |
| 108 | golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= | 108 | golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= |
| 109 | golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= | 109 | golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= |
| 110 | golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | 110 | golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| 111 | golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | 111 | golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| 112 | golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | 112 | golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
internal/server/api/isolation_test.go
| Old | New | ||
|---|---|---|---|
| @@ -292,14 +292,14 @@ func TestSSHCertRevokeRejectsForeignTenantCert(t *testing.T) { | |||
| 292 | // default tries to revoke beta's cert → 404, and the serial stays live. | 292 | // default tries to revoke beta's cert → 404, and the serial stays live. |
| 293 | resp := do(t, "POST", w.ts.URL+"/api/v1/ssh-certs/revoke", testPAT, map[string]any{"certificate": line}) | 293 | resp := do(t, "POST", w.ts.URL+"/api/v1/ssh-certs/revoke", testPAT, map[string]any{"certificate": line}) |
| 294 | assert.Equal(t, 404, resp.StatusCode, "revoking another tenant's cert must 404") | 294 | assert.Equal(t, 404, resp.StatusCode, "revoking another tenant's cert must 404") |
| 295 | revoked, err := w.st.IsSSHCertRevoked(cert.Serial) | 295 | revoked, err := w.st.IsSSHCertRevoked("beta", cert.Serial) |
| 296 | require.NoError(t, err) | 296 | require.NoError(t, err) |
| 297 | assert.False(t, revoked, "a cross-tenant revoke must not have taken effect") | 297 | assert.False(t, revoked, "a cross-tenant revoke must not have taken effect") |
| 298 | 298 | ||
| 299 | // beta revokes its own cert → 204, and it shows only in beta's list. | 299 | // beta revokes its own cert → 204, and it shows only in beta's list. |
| 300 | resp = do(t, "POST", w.ts.URL+"/api/v1/ssh-certs/revoke", w.betaPAT, map[string]any{"certificate": line}) | 300 | resp = do(t, "POST", w.ts.URL+"/api/v1/ssh-certs/revoke", w.betaPAT, map[string]any{"certificate": line}) |
| 301 | require.Equal(t, 204, resp.StatusCode) | 301 | require.Equal(t, 204, resp.StatusCode) |
| 302 | revoked, err = w.st.IsSSHCertRevoked(cert.Serial) | 302 | revoked, err = w.st.IsSSHCertRevoked("beta", cert.Serial) |
| 303 | require.NoError(t, err) | 303 | require.NoError(t, err) |
| 304 | assert.True(t, revoked) | 304 | assert.True(t, revoked) |
| 305 | 305 | ||
internal/server/api/sshcert.go
| Old | New | ||
|---|---|---|---|
| @@ -50,11 +50,14 @@ func (a *API) handleSSHCA(w http.ResponseWriter, r *http.Request) { | |||
| 50 | // registered CA, revoking is a cross-tenant act and answers 404 (no existence | 50 | // registered CA, revoking is a cross-tenant act and answers 404 (no existence |
| 51 | // leak). Otherwise (the caller's own CA, or an unregistered CA that is nobody's) | 51 | // leak). Otherwise (the caller's own CA, or an unregistered CA that is nobody's) |
| 52 | // the revocation is filed under the CALLER's tenant. The bare-SERIAL form has no | 52 | // the revocation is filed under the CALLER's tenant. The bare-SERIAL form has no |
| 53 | // CA to resolve, so it is always filed under the caller's tenant — but note the | 53 | // CA to resolve, so it is always filed under the caller's tenant. |
| 54 | // ROW is what's namespaced; gate enforcement stays fleet-wide by serial | 54 | // |
| 55 | // (fail-safe, deny-only), so revoking a serial denies it for everyone. Serials | 55 | // That filing is now the whole story, because ENFORCEMENT is scoped to it: the |
| 56 | // are 64-bit crypto-random and never exposed cross-tenant, which is what keeps | 56 | // gate resolves a presented certificate to the tenant that registered its |
| 57 | // that acceptable; scoping gate enforcement per-tenant is a recorded follow-up. | 57 | // signing CA and consults only that tenant's rows. So a revocation can never |
| 58 | // reach past the caller, and a bare serial the caller does not own denies | ||
| 59 | // nothing. It used to deny that serial for the entire fleet — deny-only and so | ||
| 60 | // fail-safe for the guest, but an availability hole for every other tenant. | ||
| 58 | // The revocation LIST is tenant-scoped. | 61 | // The revocation LIST is tenant-scoped. |
| 59 | func (a *API) handleRevokeSSHCert(w http.ResponseWriter, r *http.Request) { | 62 | func (a *API) handleRevokeSSHCert(w http.ResponseWriter, r *http.Request) { |
| 60 | var req types.RevokeSSHCertRequest | 63 | var req types.RevokeSSHCertRequest |
internal/server/api/sshcert_test.go
| Old | New | ||
|---|---|---|---|
| @@ -86,7 +86,7 @@ func TestSSHCertRevokeBySerial(t *testing.T) { | |||
| 86 | map[string]any{"serial": serial, "reason": "lost yubikey"}) | 86 | map[string]any{"serial": serial, "reason": "lost yubikey"}) |
| 87 | require.Equal(t, http.StatusNoContent, resp.StatusCode) | 87 | require.Equal(t, http.StatusNoContent, resp.StatusCode) |
| 88 | 88 | ||
| 89 | revoked, err := st.IsSSHCertRevoked(serial) | 89 | revoked, err := st.IsSSHCertRevoked(testTenant, serial) |
| 90 | require.NoError(t, err) | 90 | require.NoError(t, err) |
| 91 | assert.True(t, revoked) | 91 | assert.True(t, revoked) |
| 92 | 92 | ||
| @@ -123,7 +123,7 @@ func TestSSHCertRevokeByCertLine(t *testing.T) { | |||
| 123 | map[string]any{"certificate": line}) | 123 | map[string]any{"certificate": line}) |
| 124 | require.Equal(t, http.StatusNoContent, resp.StatusCode) | 124 | require.Equal(t, http.StatusNoContent, resp.StatusCode) |
| 125 | 125 | ||
| 126 | revoked, err := st.IsSSHCertRevoked(cert.Serial) | 126 | revoked, err := st.IsSSHCertRevoked(testTenant, cert.Serial) |
| 127 | require.NoError(t, err) | 127 | require.NoError(t, err) |
| 128 | assert.True(t, revoked, "the pasted cert's serial must be revoked") | 128 | assert.True(t, revoked, "the pasted cert's serial must be revoked") |
| 129 | } | 129 | } |
internal/server/boot/boot.go
| Old | New | ||
|---|---|---|---|
| @@ -15,7 +15,9 @@ import ( | |||
| 15 | "fmt" | 15 | "fmt" |
| 16 | "log/slog" | 16 | "log/slog" |
| 17 | "net/http" | 17 | "net/http" |
| 18 | "os" | ||
| 18 | "os/signal" | 19 | "os/signal" |
| 20 | "strings" | ||
| 19 | "syscall" | 21 | "syscall" |
| 20 | "time" | 22 | "time" |
| 21 | 23 | ||
| @@ -96,6 +98,11 @@ func run(cfgPath string) error { | |||
| 96 | if err != nil { | 98 | if err != nil { |
| 97 | return fmt.Errorf("config %s: %w", cfgPath, err) | 99 | return fmt.Errorf("config %s: %w", cfgPath, err) |
| 98 | } | 100 | } |
| 101 | // Install the log handler before anything else logs, so the first line the | ||
| 102 | // server writes already obeys the configured floor. | ||
| 103 | if err := installLogger(cfg.LogLevel); err != nil { | ||
| 104 | return fmt.Errorf("config: %w", err) | ||
| 105 | } | ||
| 99 | 106 | ||
| 100 | // The key that seals the on-disk host CA and gate host key — the only key | 107 | // The key that seals the on-disk host CA and gate host key — the only key |
| 101 | // material this server encrypts at rest — decoded once and handed to each | 108 | // material this server encrypts at rest — decoded once and handed to each |
| @@ -221,6 +228,13 @@ func run(cfgPath string) error { | |||
| 221 | // Publish the host CA: no-op when the gate is off, so the endpoint 404s. | 228 | // Publish the host CA: no-op when the gate is off, so the endpoint 404s. |
| 222 | sshGate.wireAPI(a) | 229 | sshGate.wireAPI(a) |
| 223 | 230 | ||
| 231 | // Say the policy out loud at boot: a max age is a deadline, not a rotation — | ||
| 232 | // nothing re-issues a credential, so every host reaching it needs enrolling | ||
| 233 | // again by hand. An operator who set it should see that stated somewhere. | ||
| 234 | if maxCredAge > 0 { | ||
| 235 | slog.Warn("host credentials expire by age; nothing renews them, so each host must be re-enrolled before it elapses", | ||
| 236 | "credential_max_age", maxCredAge.String()) | ||
| 237 | } | ||
| 224 | svc := syncsvc.New(st, reg, h, []byte(cfg.HostSecret), maxCredAge) | 238 | svc := syncsvc.New(st, reg, h, []byte(cfg.HostSecret), maxCredAge) |
| 225 | // Certify the host keys guests generate for themselves. No-op when the gate | 239 | // Certify the host keys guests generate for themselves. No-op when the gate |
| 226 | // is off, and then no guest waits for a certificate. | 240 | // is off, and then no guest waits for a certificate. |
| @@ -328,7 +342,7 @@ func run(cfgPath string) error { | |||
| 328 | // from the live server without bouncing the process. | 342 | // from the live server without bouncing the process. |
| 329 | covsnap.Install(ctx) | 343 | covsnap.Install(ctx) |
| 330 | 344 | ||
| 331 | srv := httpServer(cfg.HTTPListen, root) | 345 | srv := httpServer(cfg.HTTPListen, securityHeaders(root)) |
| 332 | go func() { | 346 | go func() { |
| 333 | slog.Info("http listening", "addr", cfg.HTTPListen) | 347 | slog.Info("http listening", "addr", cfg.HTTPListen) |
| 334 | if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { | 348 | if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { |
| @@ -398,3 +412,34 @@ func defaultImages(in map[string]serverconfig.DefaultImage) map[string]api.Defau | |||
| 398 | } | 412 | } |
| 399 | return out | 413 | return out |
| 400 | } | 414 | } |
| 415 | |||
| 416 | // installLogger sets the process-wide slog handler at the configured floor. | ||
| 417 | // Without this the default handler applies, whose floor is info — which is why | ||
| 418 | // every Debug line in the server was unreachable in a running plane no matter | ||
| 419 | // what the operator did. | ||
| 420 | // | ||
| 421 | // An empty level means info, which is the floor the default handler already | ||
| 422 | // used — so a config that says nothing keeps the same lines. It does not keep | ||
| 423 | // the same FORMATTING: this installs a TextHandler where the log package's own | ||
| 424 | // handler was, so an unset level still changes how a line looks, and anything | ||
| 425 | // parsing the journal should be checked once. | ||
| 426 | // | ||
| 427 | // An unrecognized level is a config error, not a silent fallback: an operator | ||
| 428 | // who wrote "verbose" and got info would conclude the logging is broken rather | ||
| 429 | // than the spelling. | ||
| 430 | func installLogger(level string) error { | ||
| 431 | lv := slog.LevelInfo | ||
| 432 | switch strings.ToLower(strings.TrimSpace(level)) { | ||
| 433 | case "", "info": | ||
| 434 | case "debug": | ||
| 435 | lv = slog.LevelDebug | ||
| 436 | case "warn", "warning": | ||
| 437 | lv = slog.LevelWarn | ||
| 438 | case "error": | ||
| 439 | lv = slog.LevelError | ||
| 440 | default: | ||
| 441 | return fmt.Errorf("log_level %q is not one of debug, info, warn, error", level) | ||
| 442 | } | ||
| 443 | slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: lv}))) | ||
| 444 | return nil | ||
| 445 | } | ||
internal/server/boot/boot_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,32 @@ | |||
| 1 | package boot | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "log/slog" | ||
| 6 | "testing" | ||
| 7 | |||
| 8 | "github.com/stretchr/testify/assert" | ||
| 9 | "github.com/stretchr/testify/require" | ||
| 10 | ) | ||
| 11 | |||
| 12 | // TestInstallLoggerLevels pins the floors an operator can ask for, and that a | ||
| 13 | // typo is refused rather than silently becoming info. | ||
| 14 | func TestInstallLoggerLevels(t *testing.T) { | ||
| 15 | for _, lv := range []string{"", "info", "debug", "warn", "warning", "error", "DEBUG", " info "} { | ||
| 16 | require.NoError(t, installLogger(lv), "level %q", lv) | ||
| 17 | } | ||
| 18 | err := installLogger("verbose") | ||
| 19 | require.Error(t, err) | ||
| 20 | assert.Contains(t, err.Error(), "debug, info, warn, error") | ||
| 21 | } | ||
| 22 | |||
| 23 | // TestInstallLoggerDebugReachable is the point of the change: with the floor at | ||
| 24 | // debug, a Debug line actually emits. Under the default handler it never did. | ||
| 25 | func TestInstallLoggerDebugReachable(t *testing.T) { | ||
| 26 | var buf bytes.Buffer | ||
| 27 | prev := slog.Default() | ||
| 28 | t.Cleanup(func() { slog.SetDefault(prev) }) | ||
| 29 | slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) | ||
| 30 | slog.Debug("sshgate tunnel closed", "vm", "vm-1") | ||
| 31 | assert.Contains(t, buf.String(), "sshgate tunnel closed") | ||
| 32 | } | ||
internal/server/boot/headers.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,80 @@ | |||
| 1 | package boot | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "net/http" | ||
| 5 | "strings" | ||
| 6 | |||
| 7 | "github.com/a73x/eitri/internal/server/web" | ||
| 8 | ) | ||
| 9 | |||
| 10 | // hstsMaxAge is two years in seconds, the value the preload lists expect. | ||
| 11 | // | ||
| 12 | // includeSubDomains is deliberately absent. This plane answers on one hostname, | ||
| 13 | // and the directive would bind every sibling under the registered domain — | ||
| 14 | // including hosts this server knows nothing about and does not serve. That is a | ||
| 15 | // commitment for an operator to make on purpose, not a side effect of turning | ||
| 16 | // on a header. | ||
| 17 | const hstsMaxAge = "max-age=63072000" | ||
| 18 | |||
| 19 | // securityHeaders sets the response headers the browser-facing surface needs. | ||
| 20 | // It wraps the ROOT mux, so the SPA, the API and the sign-in redirects are all | ||
| 21 | // covered — a header set on only some responses protects only some of them. | ||
| 22 | // | ||
| 23 | // Go's http.Error already sends nosniff on error replies, which is why the | ||
| 24 | // absence of these was easy to miss: the 4xx a probe sees looks defended while | ||
| 25 | // every 200 goes out bare. | ||
| 26 | func securityHeaders(next http.Handler) http.Handler { | ||
| 27 | csp := contentSecurityPolicy() | ||
| 28 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 29 | h := w.Header() | ||
| 30 | h.Set("Content-Security-Policy", csp) | ||
| 31 | h.Set("X-Content-Type-Options", "nosniff") | ||
| 32 | // frame-ancestors in the CSP is the modern control and covers this; the | ||
| 33 | // legacy header stays for clients that honour only the old one. | ||
| 34 | h.Set("X-Frame-Options", "DENY") | ||
| 35 | h.Set("Referrer-Policy", "strict-origin-when-cross-origin") | ||
| 36 | // HSTS is meaningful only once the browser is already on TLS, and it is | ||
| 37 | // what closes the window the plain-HTTP redirect leaves open: without it | ||
| 38 | // the FIRST request of a session still goes out in the clear. TLS is | ||
| 39 | // terminated by the proxy in front, so trust its forwarded scheme and | ||
| 40 | // fall back to whether this hop was itself TLS. | ||
| 41 | if r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") { | ||
| 42 | h.Set("Strict-Transport-Security", hstsMaxAge) | ||
| 43 | } | ||
| 44 | next.ServeHTTP(w, r) | ||
| 45 | }) | ||
| 46 | } | ||
| 47 | |||
| 48 | // contentSecurityPolicy builds the policy served with every response. | ||
| 49 | // | ||
| 50 | // script-src carries the hash of the SPA's inline entry point rather than | ||
| 51 | // 'unsafe-inline' — with a hash present a browser IGNORES 'unsafe-inline', so | ||
| 52 | // injected script is refused while the console still boots. The hashes are | ||
| 53 | // computed from the embedded index.html at startup, so a rebuild that changes | ||
| 54 | // that script needs no change here. | ||
| 55 | // | ||
| 56 | // style-src keeps 'unsafe-inline' because the SPA sets style attributes on | ||
| 57 | // elements, which no hash can cover. An inline style cannot execute script, so | ||
| 58 | // this is the cheap half of the policy to concede. | ||
| 59 | // | ||
| 60 | // connect-src stays 'self': the console talks to its own origin for the API, | ||
| 61 | // the SSE stream and the console WebSocket (ws: over the same host is covered | ||
| 62 | // by 'self' in modern browsers). | ||
| 63 | func contentSecurityPolicy() string { | ||
| 64 | script := "'self'" | ||
| 65 | for _, h := range web.InlineScriptHashes() { | ||
| 66 | script += " '" + h + "'" | ||
| 67 | } | ||
| 68 | return strings.Join([]string{ | ||
| 69 | "default-src 'self'", | ||
| 70 | "script-src " + script, | ||
| 71 | "style-src 'self' 'unsafe-inline'", | ||
| 72 | "img-src 'self' data:", | ||
| 73 | "font-src 'self'", | ||
| 74 | "connect-src 'self'", | ||
| 75 | "frame-ancestors 'none'", | ||
| 76 | "base-uri 'self'", | ||
| 77 | "form-action 'self'", | ||
| 78 | "object-src 'none'", | ||
| 79 | }, "; ") | ||
| 80 | } | ||
internal/server/boot/headers_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,66 @@ | |||
| 1 | package boot | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "crypto/tls" | ||
| 5 | "net/http" | ||
| 6 | "net/http/httptest" | ||
| 7 | "strings" | ||
| 8 | "testing" | ||
| 9 | |||
| 10 | "github.com/stretchr/testify/assert" | ||
| 11 | "github.com/stretchr/testify/require" | ||
| 12 | ) | ||
| 13 | |||
| 14 | func serveWrapped(t *testing.T, r *http.Request) *http.Response { | ||
| 15 | t.Helper() | ||
| 16 | rec := httptest.NewRecorder() | ||
| 17 | securityHeaders(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | ||
| 18 | w.WriteHeader(http.StatusOK) | ||
| 19 | })).ServeHTTP(rec, r) | ||
| 20 | return rec.Result() | ||
| 21 | } | ||
| 22 | |||
| 23 | // TestSecurityHeadersOnSuccess is the finding this closes: a 200 used to carry | ||
| 24 | // none of these. Error replies looked defended only because http.Error adds | ||
| 25 | // nosniff of its own. | ||
| 26 | func TestSecurityHeadersOnSuccess(t *testing.T) { | ||
| 27 | resp := serveWrapped(t, httptest.NewRequest("GET", "/", nil)) | ||
| 28 | h := resp.Header | ||
| 29 | assert.Equal(t, "nosniff", h.Get("X-Content-Type-Options")) | ||
| 30 | assert.Equal(t, "DENY", h.Get("X-Frame-Options")) | ||
| 31 | assert.Equal(t, "strict-origin-when-cross-origin", h.Get("Referrer-Policy")) | ||
| 32 | assert.Contains(t, h.Get("Content-Security-Policy"), "frame-ancestors 'none'") | ||
| 33 | assert.Contains(t, h.Get("Content-Security-Policy"), "object-src 'none'") | ||
| 34 | } | ||
| 35 | |||
| 36 | // TestHSTSOnlyOverTLS pins that the header is sent when the browser is on TLS — | ||
| 37 | // directly, or via the proxy that terminates it — and withheld otherwise, where | ||
| 38 | // it would mean nothing. | ||
| 39 | func TestHSTSOnlyOverTLS(t *testing.T) { | ||
| 40 | plain := httptest.NewRequest("GET", "/", nil) | ||
| 41 | assert.Empty(t, serveWrapped(t, plain).Header.Get("Strict-Transport-Security")) | ||
| 42 | |||
| 43 | proxied := httptest.NewRequest("GET", "/", nil) | ||
| 44 | proxied.Header.Set("X-Forwarded-Proto", "https") | ||
| 45 | assert.Equal(t, hstsMaxAge, serveWrapped(t, proxied).Header.Get("Strict-Transport-Security")) | ||
| 46 | |||
| 47 | direct := httptest.NewRequest("GET", "/", nil) | ||
| 48 | direct.TLS = &tls.ConnectionState{} | ||
| 49 | assert.Equal(t, hstsMaxAge, serveWrapped(t, direct).Header.Get("Strict-Transport-Security")) | ||
| 50 | } | ||
| 51 | |||
| 52 | // TestCSPScriptSrcIsHashedNotUnsafeInline is what makes the policy worth having: | ||
| 53 | // 'unsafe-inline' in script-src would let injected script run, and a browser | ||
| 54 | // ignores it entirely once a hash is present. | ||
| 55 | func TestCSPScriptSrcIsHashedNotUnsafeInline(t *testing.T) { | ||
| 56 | csp := contentSecurityPolicy() | ||
| 57 | var scriptSrc string | ||
| 58 | for _, d := range strings.Split(csp, "; ") { | ||
| 59 | if strings.HasPrefix(d, "script-src ") { | ||
| 60 | scriptSrc = d | ||
| 61 | } | ||
| 62 | } | ||
| 63 | require.NotEmpty(t, scriptSrc) | ||
| 64 | assert.NotContains(t, scriptSrc, "unsafe-inline") | ||
| 65 | assert.NotContains(t, scriptSrc, "unsafe-eval") | ||
| 66 | } | ||
internal/server/boot/sshgate.go
| Old | New | ||
|---|---|---|---|
| @@ -1,10 +1,12 @@ | |||
| 1 | package boot | 1 | package boot |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "encoding/json" | ||
| 4 | "errors" | 5 | "errors" |
| 5 | "fmt" | 6 | "fmt" |
| 6 | "log/slog" | 7 | "log/slog" |
| 7 | "net" | 8 | "net" |
| 9 | "sync/atomic" | ||
| 8 | "time" | 10 | "time" |
| 9 | 11 | ||
| 10 | "github.com/a73x/eitri/internal/server/api" | 12 | "github.com/a73x/eitri/internal/server/api" |
| @@ -191,7 +193,8 @@ func (g *sshGateSetup) startListener(st *store.Store, svc *syncsvc.Service, fata | |||
| 191 | if err != nil { | 193 | if err != nil { |
| 192 | return fmt.Errorf("gate host cert signer: %w", err) | 194 | return fmt.Errorf("gate host cert signer: %w", err) |
| 193 | } | 195 | } |
| 194 | gate := sshgate.New(gateHostSigner, userCALookup(st), resolveVM(st), authorizeVM(st), svc.OpenTCP, revokedCert(st)) | 196 | gate := sshgate.New(gateHostSigner, userCALookup(st), resolveVM(st), authorizeVM(st), svc.OpenTCP, |
| 197 | revokedCert(st), gateAudit(st)) | ||
| 195 | ln, err := net.Listen("tcp", g.listen) | 198 | ln, err := net.Listen("tcp", g.listen) |
| 196 | if err != nil { | 199 | if err != nil { |
| 197 | return fmt.Errorf("ssh gate listen: %w", err) | 200 | return fmt.Errorf("ssh gate listen: %w", err) |
| @@ -258,8 +261,8 @@ func vmLookup(st *store.Store) vmssh.VMLookup { | |||
| 258 | // revoked cert through) or fail-the-whole-gate (which a global close would | 261 | // revoked cert through) or fail-the-whole-gate (which a global close would |
| 259 | // amount to, DoSing every login on any transient error). | 262 | // amount to, DoSing every login on any transient error). |
| 260 | func revokedCert(st *store.Store) sshgate.Revoker { | 263 | func revokedCert(st *store.Store) sshgate.Revoker { |
| 261 | return func(serial uint64) bool { | 264 | return func(tenant string, serial uint64) bool { |
| 262 | revoked, err := st.IsSSHCertRevoked(serial) | 265 | revoked, err := st.IsSSHCertRevoked(tenant, serial) |
| 263 | if err != nil { | 266 | if err != nil { |
| 264 | slog.Error("ssh cert revocation lookup failed; rejecting connection", "err", err) | 267 | slog.Error("ssh cert revocation lookup failed; rejecting connection", "err", err) |
| 265 | return true | 268 | return true |
| @@ -282,3 +285,57 @@ func userCALookup(st *store.Store) sshgate.UserCALookup { | |||
| 282 | return tenant, ok | 285 | return tenant, ok |
| 283 | } | 286 | } |
| 284 | } | 287 | } |
| 288 | |||
| 289 | // gateAudit files gate events in the same append-only log the API writes to, in | ||
| 290 | // the same (tenant, action, detail-JSON) shape — so a tenant's trail reads as | ||
| 291 | // one story rather than two, and `GET /api/v1/audit` shows who reached which VM | ||
| 292 | // without a second place to look. | ||
| 293 | // | ||
| 294 | // An event the gate could not attribute (a refused login carries no tenant) is | ||
| 295 | // filed under the system scope, exactly as a denied enrollment is. | ||
| 296 | // | ||
| 297 | // Writes happen on ONE background goroutine behind a bounded queue, because the | ||
| 298 | // gate calls this on a live connection's goroutine and the store serializes | ||
| 299 | // every write onto a single connection shared with syncsvc and the API. Writing | ||
| 300 | // inline would put a database round trip between a client and its tunnel, and | ||
| 301 | // would stall logins under exactly the load that makes an audit trail worth | ||
| 302 | // having. | ||
| 303 | // | ||
| 304 | // A full queue DROPS, and says so. The alternative is blocking the gate, and an | ||
| 305 | // audit trail that can wedge the thing it audits is worse than one with a | ||
| 306 | // counted gap in it. | ||
| 307 | func gateAudit(st *store.Store) sshgate.Audit { | ||
| 308 | type event struct { | ||
| 309 | tenant, action string | ||
| 310 | detail map[string]string | ||
| 311 | } | ||
| 312 | q := make(chan event, gateAuditQueue) | ||
| 313 | go func() { | ||
| 314 | for e := range q { | ||
| 315 | raw, _ := json.Marshal(e.detail) | ||
| 316 | if err := st.AppendAudit(e.tenant, e.action, string(raw)); err != nil { | ||
| 317 | slog.Warn("gate audit append failed", "action", e.action, "err", err) | ||
| 318 | } | ||
| 319 | } | ||
| 320 | }() | ||
| 321 | var dropped atomic.Int64 | ||
| 322 | return func(tenant, action string, detail map[string]string) { | ||
| 323 | if tenant == "" { | ||
| 324 | tenant = store.SystemTenant | ||
| 325 | } | ||
| 326 | select { | ||
| 327 | case q <- event{tenant, action, detail}: | ||
| 328 | default: | ||
| 329 | // Count rather than log per drop: the situation that fills this queue | ||
| 330 | // is the one where another log line per event is also the problem. | ||
| 331 | if n := dropped.Add(1); n == 1 || n%gateAuditQueue == 0 { | ||
| 332 | slog.Warn("gate audit queue full; events dropped", "dropped_total", n) | ||
| 333 | } | ||
| 334 | } | ||
| 335 | } | ||
| 336 | } | ||
| 337 | |||
| 338 | // gateAuditQueue bounds the audit backlog the gate may build up. Deep enough to | ||
| 339 | // absorb a burst of real logins, shallow enough that a flood is dropped rather | ||
| 340 | // than buffered into memory. | ||
| 341 | const gateAuditQueue = 256 | ||
internal/server/boot/sshgate_test.go
| Old | New | ||
|---|---|---|---|
| @@ -174,15 +174,15 @@ func TestRevokedCertFailsClosed(t *testing.T) { | |||
| 174 | tenant, _ := makeTenantHost(t, s, "sub-a", "alpha@x.com") | 174 | tenant, _ := makeTenantHost(t, s, "sub-a", "alpha@x.com") |
| 175 | 175 | ||
| 176 | revoked := revokedCert(s) | 176 | revoked := revokedCert(s) |
| 177 | assert.False(t, revoked(42), "an unknown serial is not revoked") | 177 | assert.False(t, revoked(tenant, 42), "an unknown serial is not revoked") |
| 178 | 178 | ||
| 179 | require.NoError(t, s.RevokeSSHCert(tenant, 42, "leaked laptop")) | 179 | require.NoError(t, s.RevokeSSHCert(tenant, 42, "leaked laptop")) |
| 180 | assert.True(t, revoked(42), "a revoked serial reads revoked") | 180 | assert.True(t, revoked(tenant, 42), "a revoked serial reads revoked") |
| 181 | 181 | ||
| 182 | // Force a store error: after Close the DB handle is dead and the lookup | 182 | // Force a store error: after Close the DB handle is dead and the lookup |
| 183 | // errors. The gate must fail closed — reject the connection. | 183 | // errors. The gate must fail closed — reject the connection. |
| 184 | require.NoError(t, s.Close()) | 184 | require.NoError(t, s.Close()) |
| 185 | assert.True(t, revoked(43), "a store error must fail closed (reject)") | 185 | assert.True(t, revoked(tenant, 43), "a store error must fail closed (reject)") |
| 186 | } | 186 | } |
| 187 | 187 | ||
| 188 | // TestUserCALookupFailsClosed pins tenant attribution: a cert signed by a | 188 | // TestUserCALookupFailsClosed pins tenant attribution: a cert signed by a |
internal/server/config/config.go
| Old | New | ||
|---|---|---|---|
| @@ -5,6 +5,15 @@ package config | |||
| 5 | // Config is the eitri-server config file schema (decoded from JSON). | 5 | // Config is the eitri-server config file schema (decoded from JSON). |
| 6 | type Config struct { | 6 | type Config struct { |
| 7 | HTTPListen string `json:"http_listen"` | 7 | HTTPListen string `json:"http_listen"` |
| 8 | // LogLevel sets the floor for structured logs: "debug", "info", "warn" or | ||
| 9 | // "error". Empty means "info", which is what Go's default handler does — so | ||
| 10 | // an unset value changes nothing. | ||
| 11 | // | ||
| 12 | // Without it the Debug lines throughout the server are dead code in a running | ||
| 13 | // plane: nothing configures a handler, so the default floor of info silently | ||
| 14 | // discards them and an operator diagnosing a live problem has no way to turn | ||
| 15 | // detail on short of a rebuild. | ||
| 16 | LogLevel string `json:"log_level"` | ||
| 8 | QUICListen string `json:"quic_listen"` | 17 | QUICListen string `json:"quic_listen"` |
| 9 | DBPath string `json:"db_path"` | 18 | DBPath string `json:"db_path"` |
| 10 | // AdminToken is retired: sign-in is OIDC (see OIDC below) and console | 19 | // AdminToken is retired: sign-in is OIDC (see OIDC below) and console |
| @@ -47,6 +56,17 @@ type Config struct { | |||
| 47 | // e.g. "2160h" for 90 days). Empty/zero disables — revocation via | 56 | // e.g. "2160h" for 90 days). Empty/zero disables — revocation via |
| 48 | // POST /api/v1/hosts/{id}/revoke-credential is the primary mechanism; | 57 | // POST /api/v1/hosts/{id}/revoke-credential is the primary mechanism; |
| 49 | // max-age forces periodic re-enrollment and is opt-in defense-in-depth. | 58 | // max-age forces periodic re-enrollment and is opt-in defense-in-depth. |
| 59 | // | ||
| 60 | // READ THIS BEFORE SETTING IT. Nothing renews a host credential: one is | ||
| 61 | // minted once, at enrollment, and never re-issued. So this is not a rotation | ||
| 62 | // policy — it is a deadline. Every host whose credential reaches this age | ||
| 63 | // stops syncing and stays dark until an operator re-enrolls it BY HAND, and | ||
| 64 | // they will all reach it at whatever spread their enrollments had. | ||
| 65 | // | ||
| 66 | // Leaving it unset is therefore the safe default and not an oversight; the | ||
| 67 | // hole it leaves is that a leaked credential is valid until someone notices | ||
| 68 | // and revokes that host's generation. Closing that properly means renewal on | ||
| 69 | // the sync channel, which does not exist yet. | ||
| 50 | CredentialMaxAge string `json:"credential_max_age"` | 70 | CredentialMaxAge string `json:"credential_max_age"` |
| 51 | // AuditRetention bounds the audit_log age (Go duration; default "2160h" = | 71 | // AuditRetention bounds the audit_log age (Go duration; default "2160h" = |
| 52 | // 90 days; "0" disables pruning). Pruned at startup and daily. | 72 | // 90 days; "0" disables pruning). Pruned at startup and daily. |
internal/server/sshgate/gate.go
| Old | New | ||
|---|---|---|---|
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | "io" | 15 | "io" |
| 16 | "log/slog" | 16 | "log/slog" |
| 17 | "net" | 17 | "net" |
| 18 | "strconv" | ||
| 18 | "strings" | 19 | "strings" |
| 19 | "time" | 20 | "time" |
| 20 | 21 | ||
| @@ -59,6 +60,10 @@ type Gate struct { | |||
| 59 | resolve Resolver | 60 | resolve Resolver |
| 60 | authorize Authorizer | 61 | authorize Authorizer |
| 61 | dial Dialer | 62 | dial Dialer |
| 63 | audit Audit | ||
| 64 | // starting bounds how many connections may sit in the UNAUTHENTICATED | ||
| 65 | // handshake at once — sshd's MaxStartups. See maxStartups. | ||
| 66 | starting chan struct{} | ||
| 62 | } | 67 | } |
| 63 | 68 | ||
| 64 | // Revoker reports whether the user cert bearing serial has been revoked. It is | 69 | // Revoker reports whether the user cert bearing serial has been revoked. It is |
| @@ -67,18 +72,47 @@ type Gate struct { | |||
| 67 | // on a store error so a DB hiccup rejects the single connection rather than | 72 | // on a store error so a DB hiccup rejects the single connection rather than |
| 68 | // silently letting a possibly-revoked cert through. | 73 | // silently letting a possibly-revoked cert through. |
| 69 | // | 74 | // |
| 75 | // It is asked about a serial WITHIN a tenant: a certificate is revoked by the | ||
| 76 | // tenant whose CA signed it, and only for that tenant. Answering fleet-wide | ||
| 77 | // would let any tenant deny a serial it has no claim to, and nothing can | ||
| 78 | // attribute a bare serial back to its owner — CAs are BYO, so eitri never sees | ||
| 79 | // the certificates they mint. | ||
| 80 | // | ||
| 70 | // SCOPE: revocation is enforced at the GATE ONLY. VM guests trust the CA | 81 | // SCOPE: revocation is enforced at the GATE ONLY. VM guests trust the CA |
| 71 | // (TrustedUserCAKeys) with NO guest-side KRL, so a revoked cert would still be | 82 | // (TrustedUserCAKeys) with NO guest-side KRL, so a revoked cert would still be |
| 72 | // accepted by a VM's sshd if a client reached it directly. That is fine for | 83 | // accepted by a VM's sshd if a client reached it directly. That is fine for |
| 73 | // single-user — VMs are reachable ONLY via this gate. | 84 | // single-user — VMs are reachable ONLY via this gate. |
| 74 | // Guest-side KRL distribution is a multi-user/rotation follow-up (the deferred | 85 | // Guest-side KRL distribution is a multi-user/rotation follow-up (the deferred |
| 75 | // CA-rotation-push problem). | 86 | // CA-rotation-push problem). |
| 76 | type Revoker func(serial uint64) bool | 87 | type Revoker func(tenant string, serial uint64) bool |
| 77 | 88 | ||
| 78 | // UserCALookup resolves a cert signature key to the tenant that registered it. | 89 | // UserCALookup resolves a cert signature key to the tenant that registered it. |
| 79 | // ok=false ⇒ the CA is not a registered tenant user CA ⇒ reject the cert. | 90 | // ok=false ⇒ the CA is not a registered tenant user CA ⇒ reject the cert. |
| 80 | type UserCALookup func(sig ssh.PublicKey) (tenant string, ok bool) | 91 | type UserCALookup func(sig ssh.PublicKey) (tenant string, ok bool) |
| 81 | 92 | ||
| 93 | // Audit records one gate event. It takes the same (tenant, action, detail) | ||
| 94 | // shape the API's audit trail already uses, so gate rows read like every other | ||
| 95 | // row rather than like a second scheme. | ||
| 96 | // | ||
| 97 | // A tenant of "" means the event has no authenticated tenant yet — a refused | ||
| 98 | // login — and the caller files it under whatever scope it uses for unattributed | ||
| 99 | // events. The gate is a leaf and does not know that name. | ||
| 100 | // | ||
| 101 | // It MUST NOT block: it is called on the connection's own goroutine, between a | ||
| 102 | // client and its tunnel, and the store behind the real implementation serializes | ||
| 103 | // every write onto one connection shared with the rest of the plane. An | ||
| 104 | // implementation that writes synchronously will stall logins under exactly the | ||
| 105 | // load that makes the trail interesting. A nil Audit disables gate auditing. | ||
| 106 | type Audit func(tenant, action string, detail map[string]string) | ||
| 107 | |||
| 108 | // Extension keys carrying the authenticated cert's identity from the auth | ||
| 109 | // callback to the handlers that audit it. Per-connection by construction, for | ||
| 110 | // the same reason tenantExt is. | ||
| 111 | const ( | ||
| 112 | serialExt = "cert_serial" | ||
| 113 | keyIDExt = "cert_key_id" | ||
| 114 | ) | ||
| 115 | |||
| 82 | // New builds a Gate that presents hostKey, trusts every certificate signed by a | 116 | // New builds a Gate that presents hostKey, trusts every certificate signed by a |
| 83 | // CA that userCAs resolves to a tenant, resolves VM names with resolve, gates | 117 | // CA that userCAs resolves to a tenant, resolves VM names with resolve, gates |
| 84 | // them with authorize, rejects certs isRevoked flags, and tunnels through dial. | 118 | // them with authorize, rejects certs isRevoked flags, and tunnels through dial. |
| @@ -88,14 +122,27 @@ type UserCALookup func(sig ssh.PublicKey) (tenant string, ok bool) | |||
| 88 | // (userCAs), making the downstream cert.tenant == vm.tenant authz a real | 122 | // (userCAs), making the downstream cert.tenant == vm.tenant authz a real |
| 89 | // cryptographic boundary — a cert can only ever carry the tenant of the CA that | 123 | // cryptographic boundary — a cert can only ever carry the tenant of the CA that |
| 90 | // signed it. | 124 | // signed it. |
| 91 | func New(hostKey ssh.Signer, userCAs UserCALookup, resolve Resolver, authorize Authorizer, dial Dialer, isRevoked Revoker) *Gate { | 125 | func New(hostKey ssh.Signer, userCAs UserCALookup, resolve Resolver, authorize Authorizer, dial Dialer, isRevoked Revoker, audit Audit) *Gate { |
| 92 | checker := &ssh.CertChecker{ | 126 | checker := &ssh.CertChecker{ |
| 93 | IsUserAuthority: func(auth ssh.PublicKey) bool { _, ok := userCAs(auth); return ok }, | 127 | IsUserAuthority: func(auth ssh.PublicKey) bool { _, ok := userCAs(auth); return ok }, |
| 94 | } | 128 | } |
| 95 | // CheckCert consults IsRevoked during validation: a true result fails the | 129 | // CheckCert consults IsRevoked during validation: a true result fails the |
| 96 | // cert authentication outright, so a revoked cert cannot open the tunnel. | 130 | // cert authentication outright, so a revoked cert cannot open the tunnel. |
| 131 | // | ||
| 132 | // The tenant asked about is the one that registered the signing CA — the same | ||
| 133 | // derivation the connection's identity uses, so a certificate is only ever | ||
| 134 | // measured against its own tenant's revocations. A signing key that resolves | ||
| 135 | // to no tenant is treated as revoked: the auth below refuses it anyway, and | ||
| 136 | // answering "not revoked" for a CA we do not know would be the wrong default | ||
| 137 | // to leave lying around. | ||
| 97 | if isRevoked != nil { | 138 | if isRevoked != nil { |
| 98 | checker.IsRevoked = func(cert *ssh.Certificate) bool { return isRevoked(cert.Serial) } | 139 | checker.IsRevoked = func(cert *ssh.Certificate) bool { |
| 140 | tenant, ok := userCAs(cert.SignatureKey) | ||
| 141 | if !ok { | ||
| 142 | return true | ||
| 143 | } | ||
| 144 | return isRevoked(tenant, cert.Serial) | ||
| 145 | } | ||
| 99 | } | 146 | } |
| 100 | cfg := &ssh.ServerConfig{ | 147 | cfg := &ssh.ServerConfig{ |
| 101 | PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { | 148 | PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { |
| @@ -139,12 +186,21 @@ func New(hostKey ssh.Signer, userCAs UserCALookup, resolve Resolver, authorize A | |||
| 139 | // the target VM's tenant, so the cert's signing CA cryptographically | 186 | // the target VM's tenant, so the cert's signing CA cryptographically |
| 140 | // bounds which tenant's VMs the connection may reach. | 187 | // bounds which tenant's VMs the connection may reach. |
| 141 | tenantExt: tenant, | 188 | tenantExt: tenant, |
| 189 | // Carried so the connection can be audited by the certificate | ||
| 190 | // that opened it: the serial is what a revocation names, which | ||
| 191 | // makes a gate row answer "was this the cert we later revoked?". | ||
| 192 | serialExt: strconv.FormatUint(cert.Serial, 10), | ||
| 193 | keyIDExt: cert.KeyId, | ||
| 142 | }, | 194 | }, |
| 143 | }, nil | 195 | }, nil |
| 144 | }, | 196 | }, |
| 145 | } | 197 | } |
| 146 | cfg.AddHostKey(hostKey) | 198 | cfg.AddHostKey(hostKey) |
| 147 | return &Gate{cfg: cfg, resolve: resolve, authorize: authorize, dial: dial} | 199 | if audit == nil { |
| 200 | audit = func(string, string, map[string]string) {} | ||
| 201 | } | ||
| 202 | return &Gate{cfg: cfg, resolve: resolve, authorize: authorize, dial: dial, audit: audit, | ||
| 203 | starting: make(chan struct{}, maxStartups)} | ||
| 148 | } | 204 | } |
| 149 | 205 | ||
| 150 | // Serve accepts connections on l until it returns an error (e.g. l is closed). | 206 | // Serve accepts connections on l until it returns an error (e.g. l is closed). |
| @@ -165,17 +221,63 @@ func (g *Gate) Serve(l net.Listener) error { | |||
| 165 | // so tests can shrink it; not part of the public API. | 221 | // so tests can shrink it; not part of the public API. |
| 166 | var handshakeGrace = 30 * time.Second | 222 | var handshakeGrace = 30 * time.Second |
| 167 | 223 | ||
| 224 | // maxStartups bounds concurrent unauthenticated handshakes, mirroring sshd's | ||
| 225 | // MaxStartups. handshakeGrace already bounds how LONG one stalled client may | ||
| 226 | // hold a slot; this bounds HOW MANY, which is the half that stops a flood of | ||
| 227 | // merely-slow clients from crowding out every real admin login. A var (not | ||
| 228 | // const) so tests can shrink it; not part of the public API. | ||
| 229 | // | ||
| 230 | // The slot is released the moment the handshake finishes, NOT when the tunnel | ||
| 231 | // closes: the resource being rationed is the pre-auth window, and an | ||
| 232 | // authenticated session may legitimately last hours. Holding slots for the life | ||
| 233 | // of a tunnel would make a busy gate refuse logins it has every reason to take. | ||
| 234 | var maxStartups = 64 | ||
| 235 | |||
| 168 | // handleConn runs the SSH handshake and dispatches channels for one connection. | 236 | // handleConn runs the SSH handshake and dispatches channels for one connection. |
| 169 | func (g *Gate) handleConn(nConn net.Conn) { | 237 | func (g *Gate) handleConn(nConn net.Conn) { |
| 170 | defer nConn.Close() | 238 | defer nConn.Close() |
| 239 | remote := remoteHost(nConn) | ||
| 240 | // Take a startup slot, or shed the connection now. Refusing immediately is | ||
| 241 | // the honest answer under load: the alternative is queueing behind a full | ||
| 242 | // grace window, which delays every real login instead of one attacker's. | ||
| 243 | select { | ||
| 244 | case g.starting <- struct{}{}: | ||
| 245 | default: | ||
| 246 | // Logged, deliberately NOT audited. This is the cheapest event on the | ||
| 247 | // gate to provoke — it costs an attacker one TCP connection — and an | ||
| 248 | // audit row costs a serialized database write and ninety days of | ||
| 249 | // retention. Auditing here would turn the defence against a flood into | ||
| 250 | // the flood's amplifier. sshd sends refusals to syslog for this reason. | ||
| 251 | // The line above the auth boundary is the rule: what got far enough to | ||
| 252 | // attempt authentication is audited; what was refused before that is | ||
| 253 | // logged. | ||
| 254 | slog.Warn("sshgate shed connection: too many starting", "remote", remote) | ||
| 255 | return | ||
| 256 | } | ||
| 257 | released := false | ||
| 258 | release := func() { | ||
| 259 | if !released { | ||
| 260 | released = true | ||
| 261 | <-g.starting | ||
| 262 | } | ||
| 263 | } | ||
| 264 | defer release() | ||
| 265 | |||
| 171 | // Deadline covers only the pre-auth handshake; cleared once it completes so | 266 | // Deadline covers only the pre-auth handshake; cleared once it completes so |
| 172 | // it never applies to the long-lived tunnel that follows. | 267 | // it never applies to the long-lived tunnel that follows. |
| 173 | _ = nConn.SetDeadline(time.Now().Add(handshakeGrace)) | 268 | _ = nConn.SetDeadline(time.Now().Add(handshakeGrace)) |
| 174 | sConn, chans, reqs, err := ssh.NewServerConn(nConn, g.cfg) | 269 | sConn, chans, reqs, err := ssh.NewServerConn(nConn, g.cfg) |
| 175 | if err != nil { | 270 | if err != nil { |
| 271 | // A refused login has no tenant to file under — the caller decides where | ||
| 272 | // unattributed events go. The error is the reason as x/crypto phrased it; | ||
| 273 | // it names no key material. | ||
| 274 | g.audit("", "gate.auth.denied", map[string]string{"remote": remote, "reason": err.Error()}) | ||
| 176 | return // handshake, auth failure, or grace timeout — nothing to serve | 275 | return // handshake, auth failure, or grace timeout — nothing to serve |
| 177 | } | 276 | } |
| 178 | _ = nConn.SetDeadline(time.Time{}) | 277 | _ = nConn.SetDeadline(time.Time{}) |
| 278 | // Authenticated: the pre-auth window is over, so give the slot back before | ||
| 279 | // serving a tunnel that may outlast every other connection on the gate. | ||
| 280 | release() | ||
| 179 | defer sConn.Close() | 281 | defer sConn.Close() |
| 180 | 282 | ||
| 181 | // Refuse EVERY out-of-band global request. tcpip-forward (`ssh -R`) would turn | 283 | // Refuse EVERY out-of-band global request. tcpip-forward (`ssh -R`) would turn |
| @@ -183,10 +285,15 @@ func (g *Gate) handleConn(nConn net.Conn) { | |||
| 183 | // legitimate here. Draining the channel also keeps the transport unblocked. | 285 | // legitimate here. Draining the channel also keeps the transport unblocked. |
| 184 | go rejectRequests(reqs) | 286 | go rejectRequests(reqs) |
| 185 | 287 | ||
| 186 | tenant := "" | 288 | tenant, serial, keyID := "", "", "" |
| 187 | if sConn.Permissions != nil { | 289 | if sConn.Permissions != nil { |
| 188 | tenant = sConn.Permissions.Extensions[tenantExt] | 290 | tenant = sConn.Permissions.Extensions[tenantExt] |
| 291 | serial = sConn.Permissions.Extensions[serialExt] | ||
| 292 | keyID = sConn.Permissions.Extensions[keyIDExt] | ||
| 189 | } | 293 | } |
| 294 | g.audit(tenant, "gate.auth", map[string]string{ | ||
| 295 | "remote": remote, "serial": serial, "key_id": keyID, "user": sConn.User(), | ||
| 296 | }) | ||
| 190 | for newChan := range chans { | 297 | for newChan := range chans { |
| 191 | // Only direct-tcpip is permitted; this rejects session/exec/shell/ | 298 | // Only direct-tcpip is permitted; this rejects session/exec/shell/ |
| 192 | // subsystem/x11/auth-agent — any granted session is code-exec on the server. | 299 | // subsystem/x11/auth-agent — any granted session is code-exec on the server. |
| @@ -194,7 +301,7 @@ func (g *Gate) handleConn(nConn net.Conn) { | |||
| 194 | _ = newChan.Reject(ssh.UnknownChannelType, "only direct-tcpip is permitted") | 301 | _ = newChan.Reject(ssh.UnknownChannelType, "only direct-tcpip is permitted") |
| 195 | continue | 302 | continue |
| 196 | } | 303 | } |
| 197 | go g.handleDirectTCPIP(newChan, tenant) | 304 | go g.handleDirectTCPIP(newChan, tenant, remote) |
| 198 | } | 305 | } |
| 199 | } | 306 | } |
| 200 | 307 | ||
| @@ -210,15 +317,24 @@ func rejectRequests(reqs <-chan *ssh.Request) { | |||
| 210 | 317 | ||
| 211 | // handleDirectTCPIP validates a direct-tcpip open, authorizes it, dials the VM, | 318 | // handleDirectTCPIP validates a direct-tcpip open, authorizes it, dials the VM, |
| 212 | // and bridges the channel to the VM byte-for-byte. | 319 | // and bridges the channel to the VM byte-for-byte. |
| 213 | func (g *Gate) handleDirectTCPIP(newChan ssh.NewChannel, tenant string) { | 320 | func (g *Gate) handleDirectTCPIP(newChan ssh.NewChannel, tenant, remote string) { |
| 321 | // deny records why a tunnel was refused. The requested name is | ||
| 322 | // caller-controlled, so it is bounded before it reaches the trail. | ||
| 323 | deny := func(target, reason string) { | ||
| 324 | g.audit(tenant, "gate.tunnel.denied", map[string]string{ | ||
| 325 | "remote": remote, "target": truncate(target, 64), "reason": reason, | ||
| 326 | }) | ||
| 327 | } | ||
| 214 | var p directTCPIP | 328 | var p directTCPIP |
| 215 | if err := ssh.Unmarshal(newChan.ExtraData(), &p); err != nil { | 329 | if err := ssh.Unmarshal(newChan.ExtraData(), &p); err != nil { |
| 330 | deny("", "malformed request") | ||
| 216 | _ = newChan.Reject(ssh.ConnectionFailed, "malformed direct-tcpip request") | 331 | _ = newChan.Reject(ssh.ConnectionFailed, "malformed direct-tcpip request") |
| 217 | return | 332 | return |
| 218 | } | 333 | } |
| 219 | // Port policy: only 22, and reject others rather than silently rewriting, so | 334 | // Port policy: only 22, and reject others rather than silently rewriting, so |
| 220 | // intent stays auditable. | 335 | // intent stays auditable. |
| 221 | if p.PortToConnect != 22 { | 336 | if p.PortToConnect != 22 { |
| 337 | deny(p.HostToConnect, "port "+strconv.FormatUint(uint64(p.PortToConnect), 10)+" not permitted") | ||
| 222 | _ = newChan.Reject(ssh.Prohibited, "only port 22 is permitted") | 338 | _ = newChan.Reject(ssh.Prohibited, "only port 22 is permitted") |
| 223 | return | 339 | return |
| 224 | } | 340 | } |
| @@ -235,26 +351,31 @@ func (g *Gate) handleDirectTCPIP(newChan ssh.NewChannel, tenant string) { | |||
| 235 | bare := p.HostToConnect | 351 | bare := p.HostToConnect |
| 236 | if prefix, rest, found := strings.Cut(p.HostToConnect, "."); found { | 352 | if prefix, rest, found := strings.Cut(p.HostToConnect, "."); found { |
| 237 | if prefix != tenant || rest == "" { | 353 | if prefix != tenant || rest == "" { |
| 354 | deny(p.HostToConnect, "unknown VM") | ||
| 238 | _ = newChan.Reject(ssh.ConnectionFailed, "unknown VM") | 355 | _ = newChan.Reject(ssh.ConnectionFailed, "unknown VM") |
| 239 | return | 356 | return |
| 240 | } | 357 | } |
| 241 | bare = rest | 358 | bare = rest |
| 242 | } | 359 | } |
| 243 | if bare == "" { | 360 | if bare == "" { |
| 361 | deny(p.HostToConnect, "unknown VM") | ||
| 244 | _ = newChan.Reject(ssh.ConnectionFailed, "unknown VM") | 362 | _ = newChan.Reject(ssh.ConnectionFailed, "unknown VM") |
| 245 | return | 363 | return |
| 246 | } | 364 | } |
| 247 | hostID, vmID, ok := g.resolve(tenant, bare) | 365 | hostID, vmID, ok := g.resolve(tenant, bare) |
| 248 | if !ok { | 366 | if !ok { |
| 367 | deny(p.HostToConnect, "unknown VM") | ||
| 249 | _ = newChan.Reject(ssh.ConnectionFailed, "unknown VM") | 368 | _ = newChan.Reject(ssh.ConnectionFailed, "unknown VM") |
| 250 | return | 369 | return |
| 251 | } | 370 | } |
| 252 | if !g.authorize(tenant, vmID) { | 371 | if !g.authorize(tenant, vmID) { |
| 372 | deny(p.HostToConnect, "not authorized for this VM") | ||
| 253 | _ = newChan.Reject(ssh.Prohibited, "not authorized for this VM") | 373 | _ = newChan.Reject(ssh.Prohibited, "not authorized for this VM") |
| 254 | return | 374 | return |
| 255 | } | 375 | } |
| 256 | rwc, err := g.dial(context.Background(), hostID, vmID, 22) | 376 | rwc, err := g.dial(context.Background(), hostID, vmID, 22) |
| 257 | if err != nil { | 377 | if err != nil { |
| 378 | deny(p.HostToConnect, "host unreachable") | ||
| 258 | _ = newChan.Reject(ssh.ConnectionFailed, "cannot reach VM") | 379 | _ = newChan.Reject(ssh.ConnectionFailed, "cannot reach VM") |
| 259 | return | 380 | return |
| 260 | } | 381 | } |
| @@ -264,6 +385,9 @@ func (g *Gate) handleDirectTCPIP(newChan ssh.NewChannel, tenant string) { | |||
| 264 | return | 385 | return |
| 265 | } | 386 | } |
| 266 | go ssh.DiscardRequests(chReqs) // no channel requests (env/pty/exec) are honored | 387 | go ssh.DiscardRequests(chReqs) // no channel requests (env/pty/exec) are honored |
| 388 | g.audit(tenant, "gate.tunnel", map[string]string{ | ||
| 389 | "remote": remote, "vm_id": vmID, "host_id": hostID, "target": truncate(bare, 64), | ||
| 390 | }) | ||
| 267 | 391 | ||
| 268 | // Two pumps, raw bytes (mirrors console.go): close both legs on either EOF. | 392 | // Two pumps, raw bytes (mirrors console.go): close both legs on either EOF. |
| 269 | done := make(chan struct{}, 2) | 393 | done := make(chan struct{}, 2) |
| @@ -273,3 +397,21 @@ func (g *Gate) handleDirectTCPIP(newChan ssh.NewChannel, tenant string) { | |||
| 273 | <-done | 397 | <-done |
| 274 | slog.Debug("sshgate tunnel closed", "vm", vmID) | 398 | slog.Debug("sshgate tunnel closed", "vm", vmID) |
| 275 | } | 399 | } |
| 400 | |||
| 401 | // remoteHost is the client's address without its port — the field an operator | ||
| 402 | // scans an audit trail by. An address that will not split is used whole. | ||
| 403 | func remoteHost(c net.Conn) string { | ||
| 404 | host, _, err := net.SplitHostPort(c.RemoteAddr().String()) | ||
| 405 | if err != nil { | ||
| 406 | return c.RemoteAddr().String() | ||
| 407 | } | ||
| 408 | return host | ||
| 409 | } | ||
| 410 | |||
| 411 | // truncate bounds a caller-controlled string before it reaches the audit trail. | ||
| 412 | func truncate(s string, n int) string { | ||
| 413 | if len(s) > n { | ||
| 414 | return s[:n] | ||
| 415 | } | ||
| 416 | return s | ||
| 417 | } | ||
internal/server/sshgate/gate_test.go
| Old | New | ||
|---|---|---|---|
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | "crypto/rand" | 7 | "crypto/rand" |
| 8 | "io" | 8 | "io" |
| 9 | "net" | 9 | "net" |
| 10 | "sync" | ||
| 10 | "testing" | 11 | "testing" |
| 11 | "time" | 12 | "time" |
| 12 | 13 | ||
| @@ -72,6 +73,7 @@ func mintCertSignerNoPrincipals(t *testing.T, ca, clientKey ssh.Signer) ssh.Sign | |||
| 72 | // testGate wires a gate over a loopback listener and returns the client-side | 73 | // testGate wires a gate over a loopback listener and returns the client-side |
| 73 | // dial address plus the wired fakes' observed state. | 74 | // dial address plus the wired fakes' observed state. |
| 74 | type testGate struct { | 75 | type testGate struct { |
| 76 | gate *Gate | ||
| 75 | addr string | 77 | addr string |
| 76 | hostKey ssh.Signer | 78 | hostKey ssh.Signer |
| 77 | dialCalls chan [3]string // hostID, vmID, port-as-string per dial | 79 | dialCalls chan [3]string // hostID, vmID, port-as-string per dial |
| @@ -100,8 +102,9 @@ func startGate(t *testing.T, userCA ssh.PublicKey, authorized bool) *testGate { | |||
| 100 | go func() { _, _ = io.Copy(b, b); b.Close() }() // echo server = fake VM sshd | 102 | go func() { _, _ = io.Copy(b, b); b.Close() }() // echo server = fake VM sshd |
| 101 | return a, nil | 103 | return a, nil |
| 102 | } | 104 | } |
| 103 | g := New(tg.hostKey, singleCALookup(userCA, "default"), resolve, authorize, dial, nil) | 105 | g := New(tg.hostKey, singleCALookup(userCA, "default"), resolve, authorize, dial, nil, nil) |
| 104 | 106 | ||
| 107 | tg.gate = g | ||
| 105 | l, err := net.Listen("tcp", "127.0.0.1:0") | 108 | l, err := net.Listen("tcp", "127.0.0.1:0") |
| 106 | require.NoError(t, err) | 109 | require.NoError(t, err) |
| 107 | tg.addr = l.Addr().String() | 110 | tg.addr = l.Addr().String() |
| @@ -110,6 +113,9 @@ func startGate(t *testing.T, userCA ssh.PublicKey, authorized bool) *testGate { | |||
| 110 | return tg | 113 | return tg |
| 111 | } | 114 | } |
| 112 | 115 | ||
| 116 | // gateStarting exposes the gate's startup semaphore for the MaxStartups test. | ||
| 117 | func (tg *testGate) gateStarting() chan struct{} { return tg.gate.starting } | ||
| 118 | |||
| 113 | // singleCALookup builds a UserCALookup that trusts exactly one CA public key, | 119 | // singleCALookup builds a UserCALookup that trusts exactly one CA public key, |
| 114 | // stamping its connections with tenant. Any other signing key resolves to | 120 | // stamping its connections with tenant. Any other signing key resolves to |
| 115 | // ok=false (rejected) — the test-side analogue of the DB-backed lookup. | 121 | // ok=false (rejected) — the test-side analogue of the DB-backed lookup. |
| @@ -140,7 +146,8 @@ func startGateRevoked(t *testing.T, userCA ssh.PublicKey, isRevoked Revoker) *te | |||
| 140 | go func() { _, _ = io.Copy(b, b); b.Close() }() | 146 | go func() { _, _ = io.Copy(b, b); b.Close() }() |
| 141 | return a, nil | 147 | return a, nil |
| 142 | } | 148 | } |
| 143 | g := New(tg.hostKey, singleCALookup(userCA, "default"), resolve, authorize, dial, isRevoked) | 149 | g := New(tg.hostKey, singleCALookup(userCA, "default"), resolve, authorize, dial, isRevoked, nil) |
| 150 | tg.gate = g | ||
| 144 | l, err := net.Listen("tcp", "127.0.0.1:0") | 151 | l, err := net.Listen("tcp", "127.0.0.1:0") |
| 145 | require.NoError(t, err) | 152 | require.NoError(t, err) |
| 146 | tg.addr = l.Addr().String() | 153 | tg.addr = l.Addr().String() |
| @@ -202,7 +209,8 @@ func startGateWithHostKey(t *testing.T, hostKey ssh.Signer, userCA ssh.PublicKey | |||
| 202 | go func() { _, _ = io.Copy(b, b); b.Close() }() | 209 | go func() { _, _ = io.Copy(b, b); b.Close() }() |
| 203 | return a, nil | 210 | return a, nil |
| 204 | } | 211 | } |
| 205 | g := New(hostKey, singleCALookup(userCA, "default"), resolve, authorize, dial, nil) | 212 | g := New(hostKey, singleCALookup(userCA, "default"), resolve, authorize, dial, nil, nil) |
| 213 | tg.gate = g | ||
| 206 | l, err := net.Listen("tcp", "127.0.0.1:0") | 214 | l, err := net.Listen("tcp", "127.0.0.1:0") |
| 207 | require.NoError(t, err) | 215 | require.NoError(t, err) |
| 208 | tg.addr = l.Addr().String() | 216 | tg.addr = l.Addr().String() |
| @@ -413,7 +421,8 @@ func TestGateBareNameForeignTenantRejected(t *testing.T) { | |||
| 413 | go func() { _, _ = io.Copy(b, b); b.Close() }() | 421 | go func() { _, _ = io.Copy(b, b); b.Close() }() |
| 414 | return a, nil | 422 | return a, nil |
| 415 | } | 423 | } |
| 416 | g := New(tg.hostKey, singleCALookup(ca.PublicKey(), "default"), resolve, authorize, dial, nil) | 424 | g := New(tg.hostKey, singleCALookup(ca.PublicKey(), "default"), resolve, authorize, dial, nil, nil) |
| 425 | tg.gate = g | ||
| 417 | l, err := net.Listen("tcp", "127.0.0.1:0") | 426 | l, err := net.Listen("tcp", "127.0.0.1:0") |
| 418 | require.NoError(t, err) | 427 | require.NoError(t, err) |
| 419 | tg.addr = l.Addr().String() | 428 | tg.addr = l.Addr().String() |
| @@ -499,7 +508,7 @@ func TestGateRejectsRevokedCert(t *testing.T) { | |||
| 499 | const serial = uint64(0xDEADBEEFCAFEF00D) | 508 | const serial = uint64(0xDEADBEEFCAFEF00D) |
| 500 | 509 | ||
| 501 | // Revoked ⇒ auth fails. | 510 | // Revoked ⇒ auth fails. |
| 502 | revoked := startGateRevoked(t, ca.PublicKey(), func(s uint64) bool { return s == serial }) | 511 | revoked := startGateRevoked(t, ca.PublicKey(), func(_ string, s uint64) bool { return s == serial }) |
| 503 | cfg := &ssh.ClientConfig{ | 512 | cfg := &ssh.ClientConfig{ |
| 504 | User: "ubuntu", | 513 | User: "ubuntu", |
| 505 | Auth: []ssh.AuthMethod{ssh.PublicKeys(mintCertSignerSerial(t, ca, newSigner(t), serial))}, | 514 | Auth: []ssh.AuthMethod{ssh.PublicKeys(mintCertSignerSerial(t, ca, newSigner(t), serial))}, |
| @@ -510,7 +519,7 @@ func TestGateRejectsRevokedCert(t *testing.T) { | |||
| 510 | require.Error(t, err, "a revoked cert must fail auth at the gate") | 519 | require.Error(t, err, "a revoked cert must fail auth at the gate") |
| 511 | 520 | ||
| 512 | // Not revoked ⇒ the same serial authenticates and tunnels. | 521 | // Not revoked ⇒ the same serial authenticates and tunnels. |
| 513 | allowed := startGateRevoked(t, ca.PublicKey(), func(uint64) bool { return false }) | 522 | allowed := startGateRevoked(t, ca.PublicKey(), func(string, uint64) bool { return false }) |
| 514 | client := dialClient(t, allowed, mintCertSignerSerial(t, ca, newSigner(t), serial)) | 523 | client := dialClient(t, allowed, mintCertSignerSerial(t, ca, newSigner(t), serial)) |
| 515 | conn, err := client.Dial("tcp", "default.vm1:22") | 524 | conn, err := client.Dial("tcp", "default.vm1:22") |
| 516 | require.NoError(t, err, "a non-revoked cert must still tunnel") | 525 | require.NoError(t, err, "a non-revoked cert must still tunnel") |
| @@ -550,7 +559,7 @@ func TestGateTrustsRegisteredCARejectsUnknown(t *testing.T) { | |||
| 550 | resolve := func(string, string) (string, string, bool) { return "", "", false } | 559 | resolve := func(string, string) (string, string, bool) { return "", "", false } |
| 551 | authorize := func(string, string) bool { return true } | 560 | authorize := func(string, string) bool { return true } |
| 552 | dial := func(context.Context, string, string, uint32) (io.ReadWriteCloser, error) { return nil, nil } | 561 | dial := func(context.Context, string, string, uint32) (io.ReadWriteCloser, error) { return nil, nil } |
| 553 | g := New(newSigner(t), lookup, resolve, authorize, dial, nil) | 562 | g := New(newSigner(t), lookup, resolve, authorize, dial, nil, nil) |
| 554 | cb := g.cfg.PublicKeyCallback | 563 | cb := g.cfg.PublicKeyCallback |
| 555 | 564 | ||
| 556 | // A cert signed by the registered CA authenticates and is stamped "acme". | 565 | // A cert signed by the registered CA authenticates and is stamped "acme". |
| @@ -566,3 +575,214 @@ func TestGateTrustsRegisteredCARejectsUnknown(t *testing.T) { | |||
| 566 | _, err = cb(nil, evilCert) | 575 | _, err = cb(nil, evilCert) |
| 567 | require.Error(t, err, "a cert signed by an unregistered CA must be rejected") | 576 | require.Error(t, err, "a cert signed by an unregistered CA must be rejected") |
| 568 | } | 577 | } |
| 578 | |||
| 579 | // auditLog is a concurrency-safe recorder standing in for the store-backed | ||
| 580 | // audit sink. The gate calls it from each connection's own goroutine. | ||
| 581 | type auditLog struct { | ||
| 582 | mu sync.Mutex | ||
| 583 | events []auditEvent | ||
| 584 | } | ||
| 585 | |||
| 586 | type auditEvent struct { | ||
| 587 | tenant, action string | ||
| 588 | detail map[string]string | ||
| 589 | } | ||
| 590 | |||
| 591 | func (a *auditLog) record() Audit { | ||
| 592 | return func(tenant, action string, detail map[string]string) { | ||
| 593 | a.mu.Lock() | ||
| 594 | defer a.mu.Unlock() | ||
| 595 | a.events = append(a.events, auditEvent{tenant, action, detail}) | ||
| 596 | } | ||
| 597 | } | ||
| 598 | |||
| 599 | // waitFor returns the first recorded event with the given action, polling until | ||
| 600 | // it appears — the gate audits on the connection goroutine, so a client call | ||
| 601 | // can return before the row lands. | ||
| 602 | func (a *auditLog) waitFor(t *testing.T, action string) auditEvent { | ||
| 603 | t.Helper() | ||
| 604 | for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); { | ||
| 605 | a.mu.Lock() | ||
| 606 | for _, e := range a.events { | ||
| 607 | if e.action == action { | ||
| 608 | a.mu.Unlock() | ||
| 609 | return e | ||
| 610 | } | ||
| 611 | } | ||
| 612 | a.mu.Unlock() | ||
| 613 | time.Sleep(5 * time.Millisecond) | ||
| 614 | } | ||
| 615 | a.mu.Lock() | ||
| 616 | defer a.mu.Unlock() | ||
| 617 | t.Fatalf("no %q event; recorded %v", action, a.events) | ||
| 618 | return auditEvent{} | ||
| 619 | } | ||
| 620 | |||
| 621 | // startGateAudited is startGate with an audit sink wired, so a test can assert | ||
| 622 | // the trail the gate leaves rather than only the bytes it moves. | ||
| 623 | func startGateAudited(t *testing.T, userCA ssh.PublicKey, authorized bool) (*testGate, *auditLog) { | ||
| 624 | t.Helper() | ||
| 625 | log := &auditLog{} | ||
| 626 | tg := &testGate{hostKey: newSigner(t), dialCalls: make(chan [3]string, 4), authorized: authorized} | ||
| 627 | resolve := func(tenant, name string) (string, string, bool) { | ||
| 628 | if tenant == "default" && name == "vm1" { | ||
| 629 | return "host-1", "vm-1", true | ||
| 630 | } | ||
| 631 | return "", "", false | ||
| 632 | } | ||
| 633 | authorize := func(tenant, vmID string) bool { return tg.authorized } | ||
| 634 | dial := func(_ context.Context, hostID, vmID string, port uint32) (io.ReadWriteCloser, error) { | ||
| 635 | tg.dialCalls <- [3]string{hostID, vmID, "22"} | ||
| 636 | a, b := net.Pipe() | ||
| 637 | go func() { _, _ = io.Copy(b, b); b.Close() }() | ||
| 638 | return a, nil | ||
| 639 | } | ||
| 640 | g := New(tg.hostKey, singleCALookup(userCA, "default"), resolve, authorize, dial, nil, log.record()) | ||
| 641 | tg.gate = g | ||
| 642 | l, err := net.Listen("tcp", "127.0.0.1:0") | ||
| 643 | require.NoError(t, err) | ||
| 644 | tg.addr = l.Addr().String() | ||
| 645 | go func() { _ = g.Serve(l) }() | ||
| 646 | t.Cleanup(func() { _ = l.Close() }) | ||
| 647 | return tg, log | ||
| 648 | } | ||
| 649 | |||
| 650 | // TestGateAuditsAuthAndTunnel is the gate's answer to "who reached which VM". | ||
| 651 | // Both halves must be recorded: the certificate that opened the connection, and | ||
| 652 | // the VM it was pointed at. | ||
| 653 | func TestGateAuditsAuthAndTunnel(t *testing.T) { | ||
| 654 | ca := newSigner(t) | ||
| 655 | tg, log := startGateAudited(t, ca.PublicKey(), true) | ||
| 656 | client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t))) | ||
| 657 | |||
| 658 | auth := log.waitFor(t, "gate.auth") | ||
| 659 | assert.Equal(t, "default", auth.tenant, "tenant comes from the signing CA") | ||
| 660 | // The serial is the field a revocation names, so it is what ties a session | ||
| 661 | // back to a certificate after the fact. | ||
| 662 | assert.Equal(t, "1", auth.detail["serial"]) | ||
| 663 | assert.Equal(t, "ubuntu", auth.detail["key_id"]) | ||
| 664 | assert.NotEmpty(t, auth.detail["remote"]) | ||
| 665 | |||
| 666 | conn, err := client.Dial("tcp", "default.vm1:22") | ||
| 667 | require.NoError(t, err) | ||
| 668 | defer conn.Close() | ||
| 669 | |||
| 670 | tun := log.waitFor(t, "gate.tunnel") | ||
| 671 | assert.Equal(t, "default", tun.tenant) | ||
| 672 | assert.Equal(t, "vm-1", tun.detail["vm_id"]) | ||
| 673 | assert.Equal(t, "host-1", tun.detail["host_id"]) | ||
| 674 | } | ||
| 675 | |||
| 676 | // TestGateAuditsRefusedLogin pins that a login the gate turns away is recorded | ||
| 677 | // too — an unattributed event, since no tenant was ever established. | ||
| 678 | func TestGateAuditsRefusedLogin(t *testing.T) { | ||
| 679 | ca, foreign := newSigner(t), newSigner(t) | ||
| 680 | tg, log := startGateAudited(t, ca.PublicKey(), true) | ||
| 681 | |||
| 682 | cfg := &ssh.ClientConfig{ | ||
| 683 | User: "probe", | ||
| 684 | Auth: []ssh.AuthMethod{ssh.PublicKeys(mintCertSigner(t, foreign, newSigner(t)))}, | ||
| 685 | HostKeyCallback: ssh.FixedHostKey(tg.hostKey.PublicKey()), | ||
| 686 | Timeout: 5 * time.Second, | ||
| 687 | } | ||
| 688 | c, err := ssh.Dial("tcp", tg.addr, cfg) | ||
| 689 | require.Error(t, err, "a cert from an unregistered CA must not authenticate") | ||
| 690 | if c != nil { | ||
| 691 | _ = c.Close() | ||
| 692 | } | ||
| 693 | |||
| 694 | ev := log.waitFor(t, "gate.auth.denied") | ||
| 695 | assert.Empty(t, ev.tenant, "a refused login has no tenant to attribute") | ||
| 696 | assert.NotEmpty(t, ev.detail["remote"]) | ||
| 697 | assert.NotEmpty(t, ev.detail["reason"]) | ||
| 698 | } | ||
| 699 | |||
| 700 | // TestGateAuditsRefusedTunnel pins the other refusal: an authenticated client | ||
| 701 | // that asks for something it may not have still leaves a row naming what it | ||
| 702 | // asked for. | ||
| 703 | func TestGateAuditsRefusedTunnel(t *testing.T) { | ||
| 704 | ca := newSigner(t) | ||
| 705 | tg, log := startGateAudited(t, ca.PublicKey(), true) | ||
| 706 | client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t))) | ||
| 707 | |||
| 708 | _, err := client.Dial("tcp", "default.nope:22") | ||
| 709 | require.Error(t, err) | ||
| 710 | |||
| 711 | ev := log.waitFor(t, "gate.tunnel.denied") | ||
| 712 | assert.Equal(t, "default", ev.tenant) | ||
| 713 | assert.Equal(t, "default.nope", ev.detail["target"]) | ||
| 714 | assert.Equal(t, "unknown VM", ev.detail["reason"]) | ||
| 715 | } | ||
| 716 | |||
| 717 | // TestGateShedsBeyondMaxStartups pins the aggregate pre-auth bound. The grace | ||
| 718 | // deadline caps how long one stalled client holds a slot; this caps how many, | ||
| 719 | // which is what stops a flood of merely-slow clients from crowding out logins. | ||
| 720 | func TestGateShedsBeyondMaxStartups(t *testing.T) { | ||
| 721 | prev := maxStartups | ||
| 722 | maxStartups = 2 | ||
| 723 | t.Cleanup(func() { maxStartups = prev }) | ||
| 724 | |||
| 725 | ca := newSigner(t) | ||
| 726 | tg, log := startGateAudited(t, ca.PublicKey(), true) | ||
| 727 | |||
| 728 | // Occupy every slot with clients that connect and then say nothing, so each | ||
| 729 | // sits in the handshake holding its slot until the grace expires. | ||
| 730 | var stalled []net.Conn | ||
| 731 | for range maxStartups { | ||
| 732 | c, err := net.Dial("tcp", tg.addr) | ||
| 733 | require.NoError(t, err) | ||
| 734 | stalled = append(stalled, c) | ||
| 735 | } | ||
| 736 | t.Cleanup(func() { | ||
| 737 | for _, c := range stalled { | ||
| 738 | _ = c.Close() | ||
| 739 | } | ||
| 740 | }) | ||
| 741 | |||
| 742 | // The gate accepts asynchronously, so wait until the slots are actually held. | ||
| 743 | require.Eventually(t, func() bool { return len(tg.gateStarting()) == maxStartups }, | ||
| 744 | 2*time.Second, 5*time.Millisecond, "slots never filled") | ||
| 745 | |||
| 746 | // One more must be shed rather than queued. A shed connection is closed | ||
| 747 | // without a handshake, so it never sees the version banner an accepted one | ||
| 748 | // gets — and it happens at once, rather than after the grace window. | ||
| 749 | over, err := net.Dial("tcp", tg.addr) | ||
| 750 | require.NoError(t, err, "TCP still accepts; the gate sheds above the SSH layer") | ||
| 751 | defer over.Close() | ||
| 752 | |||
| 753 | require.NoError(t, over.SetReadDeadline(time.Now().Add(2*time.Second))) | ||
| 754 | _, err = over.Read(make([]byte, 1)) | ||
| 755 | require.ErrorIs(t, err, io.EOF, "a shed connection is closed, not left in the handshake") | ||
| 756 | |||
| 757 | // Shedding must NOT write an audit row: it is the cheapest event to provoke | ||
| 758 | // on the gate, and a row per refusal would make the defence amplify the | ||
| 759 | // flood it exists to absorb. | ||
| 760 | log.mu.Lock() | ||
| 761 | defer log.mu.Unlock() | ||
| 762 | for _, e := range log.events { | ||
| 763 | assert.NotEqual(t, "gate.auth.denied", e.action, "the shed path must not audit") | ||
| 764 | } | ||
| 765 | } | ||
| 766 | |||
| 767 | // TestGateAuthenticatedConnDoesNotHoldStartupSlot is the other half of the | ||
| 768 | // bound: a tunnel may last hours, and must not occupy the pre-auth window while | ||
| 769 | // it does — or a busy gate would refuse logins it has every reason to take. | ||
| 770 | func TestGateAuthenticatedConnDoesNotHoldStartupSlot(t *testing.T) { | ||
| 771 | prev := maxStartups | ||
| 772 | maxStartups = 1 | ||
| 773 | t.Cleanup(func() { maxStartups = prev }) | ||
| 774 | |||
| 775 | ca := newSigner(t) | ||
| 776 | tg, _ := startGateAudited(t, ca.PublicKey(), true) | ||
| 777 | |||
| 778 | // Hold one authenticated connection open with a live tunnel. | ||
| 779 | client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t))) | ||
| 780 | conn, err := client.Dial("tcp", "default.vm1:22") | ||
| 781 | require.NoError(t, err) | ||
| 782 | defer conn.Close() | ||
| 783 | |||
| 784 | // With the only slot released at auth, a second login must still succeed. | ||
| 785 | second := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t))) | ||
| 786 | _, err = second.Dial("tcp", "default.vm1:22") | ||
| 787 | require.NoError(t, err, "an established tunnel must not consume a startup slot") | ||
| 788 | } | ||
internal/server/store/evolve.go
| Old | New | ||
|---|---|---|---|
| @@ -89,3 +89,82 @@ func dropColumn(db *sql.DB, table, column string) error { | |||
| 89 | slog.Info("dropped retired column", "table", table, "column", column) | 89 | slog.Info("dropped retired column", "table", table, "column", column) |
| 90 | return nil | 90 | return nil |
| 91 | } | 91 | } |
| 92 | |||
| 93 | // rekeyRevokedSSHCerts rebuilds revoked_ssh_certs with a PRIMARY KEY over | ||
| 94 | // (tenant, serial) instead of serial alone. | ||
| 95 | // | ||
| 96 | // The table shipped keyed on serial because revocation was enforced fleet-wide: | ||
| 97 | // one row denied a serial for everyone. Scoping enforcement to the revoking | ||
| 98 | // tenant makes that key wrong in a way that FAILS OPEN — with one row per | ||
| 99 | // serial globally, the first tenant to revoke a serial takes the row, and a | ||
| 100 | // second tenant revoking the same serial is silently dropped by the ON CONFLICT | ||
| 101 | // and its certificate keeps working. Per-tenant rows remove that possibility | ||
| 102 | // rather than making it unlikely. | ||
| 103 | // | ||
| 104 | // Idempotent: it inspects the existing key and returns immediately once the | ||
| 105 | // table is already in the new shape. Every row is carried across. | ||
| 106 | func rekeyRevokedSSHCerts(db *sql.DB) error { | ||
| 107 | keyed, err := pkColumns(db, "revoked_ssh_certs") | ||
| 108 | if err != nil { | ||
| 109 | return err | ||
| 110 | } | ||
| 111 | if len(keyed) == 0 || (len(keyed) == 2 && keyed[0] == "tenant" && keyed[1] == "serial") { | ||
| 112 | return nil // absent (fresh schema builds it right) or already rekeyed | ||
| 113 | } | ||
| 114 | // Rows a pre-tenant-column binary wrote were backfilled to 'default', and the | ||
| 115 | // default tenant is retired: no CA resolves to it, so once enforcement is | ||
| 116 | // scoped those rows deny nothing. Say so at the moment of the migration, | ||
| 117 | // where an operator rolling the release will see it, rather than leaving it | ||
| 118 | // to be discovered as a certificate that started working again. | ||
| 119 | var orphaned int | ||
| 120 | if err := db.QueryRow(`SELECT COUNT(*) FROM revoked_ssh_certs WHERE tenant='default'`).Scan(&orphaned); err == nil && orphaned > 0 { | ||
| 121 | slog.Warn("revocations filed under the retired 'default' tenant will no longer deny anything "+ | ||
| 122 | "now that revocation is tenant-scoped; re-revoke them under the owning tenant", | ||
| 123 | "rows", orphaned) | ||
| 124 | } | ||
| 125 | |||
| 126 | tx, err := db.Begin() | ||
| 127 | if err != nil { | ||
| 128 | return err | ||
| 129 | } | ||
| 130 | defer tx.Rollback() | ||
| 131 | for _, stmt := range []string{ | ||
| 132 | `CREATE TABLE revoked_ssh_certs_new ( | ||
| 133 | tenant TEXT NOT NULL DEFAULT 'default', | ||
| 134 | serial INTEGER NOT NULL, | ||
| 135 | revoked_at DATETIME NOT NULL, | ||
| 136 | reason TEXT NOT NULL DEFAULT '', | ||
| 137 | PRIMARY KEY (tenant, serial) | ||
| 138 | )`, | ||
| 139 | `INSERT INTO revoked_ssh_certs_new(tenant, serial, revoked_at, reason) | ||
| 140 | SELECT tenant, serial, revoked_at, reason FROM revoked_ssh_certs`, | ||
| 141 | `DROP TABLE revoked_ssh_certs`, | ||
| 142 | `ALTER TABLE revoked_ssh_certs_new RENAME TO revoked_ssh_certs`, | ||
| 143 | } { | ||
| 144 | if _, err := tx.Exec(stmt); err != nil { | ||
| 145 | return fmt.Errorf("rekey revoked_ssh_certs: %w", err) | ||
| 146 | } | ||
| 147 | } | ||
| 148 | return tx.Commit() | ||
| 149 | } | ||
| 150 | |||
| 151 | // pkColumns returns the table's primary-key columns in key order, or nil when | ||
| 152 | // the table does not exist. table is a compile-time constant, the same rule | ||
| 153 | // ensureColumn states. | ||
| 154 | func pkColumns(db *sql.DB, table string) ([]string, error) { | ||
| 155 | rows, err := db.Query(`SELECT name, pk FROM pragma_table_info(?) WHERE pk > 0 ORDER BY pk`, table) | ||
| 156 | if err != nil { | ||
| 157 | return nil, fmt.Errorf("read primary key of %s: %w", table, err) | ||
| 158 | } | ||
| 159 | defer rows.Close() | ||
| 160 | var out []string | ||
| 161 | for rows.Next() { | ||
| 162 | var name string | ||
| 163 | var pk int | ||
| 164 | if err := rows.Scan(&name, &pk); err != nil { | ||
| 165 | return nil, err | ||
| 166 | } | ||
| 167 | out = append(out, name) | ||
| 168 | } | ||
| 169 | return out, rows.Err() | ||
| 170 | } | ||
internal/server/store/evolve_test.go
| Old | New | ||
|---|---|---|---|
| @@ -1,6 +1,13 @@ | |||
| 1 | package store | 1 | package store |
| 2 | 2 | ||
| 3 | import "testing" | 3 | import ( |
| 4 | "database/sql" | ||
| 5 | "path/filepath" | ||
| 6 | "testing" | ||
| 7 | |||
| 8 | "github.com/stretchr/testify/assert" | ||
| 9 | "github.com/stretchr/testify/require" | ||
| 10 | ) | ||
| 4 | 11 | ||
| 5 | // ensureColumn must add a missing column exactly once and be a no-op after. | 12 | // ensureColumn must add a missing column exactly once and be a no-op after. |
| 6 | func TestEnsureColumnIdempotent(t *testing.T) { | 13 | func TestEnsureColumnIdempotent(t *testing.T) { |
| @@ -19,3 +26,77 @@ func TestEnsureColumnIdempotent(t *testing.T) { | |||
| 19 | t.Fatalf("email column count = %d, want 1", n) | 26 | t.Fatalf("email column count = %d, want 1", n) |
| 20 | } | 27 | } |
| 21 | } | 28 | } |
| 29 | |||
| 30 | // TestRekeyRevokedSSHCertsCarriesRowsAndIsIdempotent covers the migration that | ||
| 31 | // re-keys revocation on (tenant, serial). A revocation the plane already holds | ||
| 32 | // must survive it — losing one silently un-revokes a certificate. | ||
| 33 | func TestRekeyRevokedSSHCertsCarriesRowsAndIsIdempotent(t *testing.T) { | ||
| 34 | db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "old.db")) | ||
| 35 | require.NoError(t, err) | ||
| 36 | defer db.Close() | ||
| 37 | |||
| 38 | // The table as it shipped: keyed on serial alone, tenant added later by | ||
| 39 | // ensureColumn. | ||
| 40 | _, err = db.Exec(`CREATE TABLE revoked_ssh_certs ( | ||
| 41 | serial INTEGER PRIMARY KEY, | ||
| 42 | revoked_at DATETIME NOT NULL, | ||
| 43 | reason TEXT NOT NULL DEFAULT '' | ||
| 44 | )`) | ||
| 45 | require.NoError(t, err) | ||
| 46 | require.NoError(t, ensureColumn(db, "revoked_ssh_certs", "tenant", "TEXT NOT NULL DEFAULT 'default'")) | ||
| 47 | _, err = db.Exec(`INSERT INTO revoked_ssh_certs(serial, tenant, revoked_at, reason) | ||
| 48 | VALUES (7, 'alpha', '2026-01-01T00:00:00Z', 'leaked'), (9, 'beta', '2026-01-02T00:00:00Z', '')`) | ||
| 49 | require.NoError(t, err) | ||
| 50 | |||
| 51 | require.NoError(t, rekeyRevokedSSHCerts(db)) | ||
| 52 | |||
| 53 | assert.Equal(t, []string{"tenant", "serial"}, mustPK(t, db), "key is now composite, in key order") | ||
| 54 | |||
| 55 | var n int | ||
| 56 | require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM revoked_ssh_certs`).Scan(&n)) | ||
| 57 | assert.Equal(t, 2, n, "every revocation must survive the rebuild") | ||
| 58 | var reason string | ||
| 59 | require.NoError(t, db.QueryRow(`SELECT reason FROM revoked_ssh_certs WHERE tenant='alpha' AND serial=7`).Scan(&reason)) | ||
| 60 | assert.Equal(t, "leaked", reason, "columns carry across, not just rows") | ||
| 61 | |||
| 62 | // Running again must be a no-op, since Open calls it on every boot. | ||
| 63 | require.NoError(t, rekeyRevokedSSHCerts(db)) | ||
| 64 | require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM revoked_ssh_certs`).Scan(&n)) | ||
| 65 | assert.Equal(t, 2, n) | ||
| 66 | |||
| 67 | // The point of the new key: two tenants may hold the same serial. | ||
| 68 | _, err = db.Exec(`INSERT INTO revoked_ssh_certs(tenant, serial, revoked_at, reason) | ||
| 69 | VALUES ('beta', 7, '2026-01-03T00:00:00Z', 'beta own cert')`) | ||
| 70 | require.NoError(t, err, "the old key would have rejected this and failed open") | ||
| 71 | } | ||
| 72 | |||
| 73 | func mustPK(t *testing.T, db *sql.DB) []string { | ||
| 74 | t.Helper() | ||
| 75 | cols, err := pkColumns(db, "revoked_ssh_certs") | ||
| 76 | require.NoError(t, err) | ||
| 77 | return cols | ||
| 78 | } | ||
| 79 | |||
| 80 | // TestRekeyWarnsOnRetiredDefaultTenantRows pins that the migration notices | ||
| 81 | // revocations it is about to render inert. Scoping enforcement means rows filed | ||
| 82 | // under the retired 'default' tenant stop denying anything, and a certificate | ||
| 83 | // quietly working again is the one outcome nobody would spot. | ||
| 84 | func TestRekeyWarnsOnRetiredDefaultTenantRows(t *testing.T) { | ||
| 85 | db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "legacy.db")) | ||
| 86 | require.NoError(t, err) | ||
| 87 | defer db.Close() | ||
| 88 | _, err = db.Exec(`CREATE TABLE revoked_ssh_certs ( | ||
| 89 | serial INTEGER PRIMARY KEY, revoked_at DATETIME NOT NULL, reason TEXT NOT NULL DEFAULT '')`) | ||
| 90 | require.NoError(t, err) | ||
| 91 | require.NoError(t, ensureColumn(db, "revoked_ssh_certs", "tenant", "TEXT NOT NULL DEFAULT 'default'")) | ||
| 92 | _, err = db.Exec(`INSERT INTO revoked_ssh_certs(serial, revoked_at) VALUES (5, '2026-01-01T00:00:00Z')`) | ||
| 93 | require.NoError(t, err) | ||
| 94 | |||
| 95 | require.NoError(t, rekeyRevokedSSHCerts(db)) | ||
| 96 | |||
| 97 | // The row is carried across rather than dropped — it is evidence, and an | ||
| 98 | // operator may want to re-file it under the owning tenant. | ||
| 99 | var tenant string | ||
| 100 | require.NoError(t, db.QueryRow(`SELECT tenant FROM revoked_ssh_certs WHERE serial=5`).Scan(&tenant)) | ||
| 101 | assert.Equal(t, "default", tenant) | ||
| 102 | } | ||
internal/server/store/sessions.go
| Old | New | ||
|---|---|---|---|
| @@ -13,12 +13,17 @@ import ( | |||
| 13 | // and returns its id — a 256-bit random hex string used verbatim as the | 13 | // and returns its id — a 256-bit random hex string used verbatim as the |
| 14 | // eitri_session cookie value. Server-side rows mean revocation works and a | 14 | // eitri_session cookie value. Server-side rows mean revocation works and a |
| 15 | // restart keeps users signed in. | 15 | // restart keeps users signed in. |
| 16 | // | ||
| 17 | // Only the SHA-256 of the id is stored, exactly as a PAT is (see hashToken): | ||
| 18 | // the row is a verifier, not a credential, so a copied database or a backup | ||
| 19 | // yields nothing anyone can present. The cookie is unchanged — the client still | ||
| 20 | // holds the id itself, which is the only place it exists. | ||
| 16 | func (s *Store) CreateSession(tenant string, ttl time.Duration) (string, error) { | 21 | func (s *Store) CreateSession(tenant string, ttl time.Duration) (string, error) { |
| 17 | id := random.Hex(32) | 22 | id := random.Hex(32) |
| 18 | now := time.Now().UTC() | 23 | now := time.Now().UTC() |
| 19 | _, err := s.db.Exec( | 24 | _, err := s.db.Exec( |
| 20 | `INSERT INTO sessions(id, tenant, created_at, expires_at) VALUES (?,?,?,?)`, | 25 | `INSERT INTO sessions(id, tenant, created_at, expires_at) VALUES (?,?,?,?)`, |
| 21 | id, tenant, now.Format(time.RFC3339), now.Add(ttl).Format(time.RFC3339), | 26 | hashToken(id), tenant, now.Format(time.RFC3339), now.Add(ttl).Format(time.RFC3339), |
| 22 | ) | 27 | ) |
| 23 | if err != nil { | 28 | if err != nil { |
| 24 | return "", fmt.Errorf("create session: %w", err) | 29 | return "", fmt.Errorf("create session: %w", err) |
| @@ -29,11 +34,21 @@ func (s *Store) CreateSession(tenant string, ttl time.Duration) (string, error) | |||
| 29 | // SessionTenant resolves a session id to its tenant, enforcing expiry on read. | 34 | // SessionTenant resolves a session id to its tenant, enforcing expiry on read. |
| 30 | // ok=false (no error) for an unknown, deleted, or expired session — the caller | 35 | // ok=false (no error) for an unknown, deleted, or expired session — the caller |
| 31 | // treats all three identically (redirect to sign-in). | 36 | // treats all three identically (redirect to sign-in). |
| 37 | // | ||
| 38 | // Keyed on the hash of the presented id, so the stored value is never compared | ||
| 39 | // as a secret and there is no timing side-channel to work. | ||
| 40 | // | ||
| 41 | // Rows written before ids were hashed hold the id itself. They cannot match a | ||
| 42 | // hashed lookup, so they authenticate nobody and simply age out at their own | ||
| 43 | // expiry — the one visible effect of the change is that everyone signs in once | ||
| 44 | // more. They are not deleted here because a stored id and a stored hash are both | ||
| 45 | // 64 hex characters and cannot be told apart, so a blanket purge would sign | ||
| 46 | // every user out on every restart rather than once. | ||
| 32 | func (s *Store) SessionTenant(id string) (string, bool, error) { | 47 | func (s *Store) SessionTenant(id string) (string, bool, error) { |
| 33 | var tenant string | 48 | var tenant string |
| 34 | err := s.db.QueryRow( | 49 | err := s.db.QueryRow( |
| 35 | `SELECT tenant FROM sessions WHERE id=? AND expires_at > ?`, | 50 | `SELECT tenant FROM sessions WHERE id=? AND expires_at > ?`, |
| 36 | id, time.Now().UTC().Format(time.RFC3339), | 51 | hashToken(id), time.Now().UTC().Format(time.RFC3339), |
| 37 | ).Scan(&tenant) | 52 | ).Scan(&tenant) |
| 38 | if errors.Is(err, sql.ErrNoRows) { | 53 | if errors.Is(err, sql.ErrNoRows) { |
| 39 | return "", false, nil | 54 | return "", false, nil |
| @@ -45,8 +60,9 @@ func (s *Store) SessionTenant(id string) (string, bool, error) { | |||
| 45 | } | 60 | } |
| 46 | 61 | ||
| 47 | // DeleteSession revokes a session (sign-out). Deleting an absent id is a no-op. | 62 | // DeleteSession revokes a session (sign-out). Deleting an absent id is a no-op. |
| 63 | // Keyed on the hash, like every other read of this table. | ||
| 48 | func (s *Store) DeleteSession(id string) error { | 64 | func (s *Store) DeleteSession(id string) error { |
| 49 | if _, err := s.db.Exec(`DELETE FROM sessions WHERE id=?`, id); err != nil { | 65 | if _, err := s.db.Exec(`DELETE FROM sessions WHERE id=?`, hashToken(id)); err != nil { |
| 50 | return fmt.Errorf("delete session: %w", err) | 66 | return fmt.Errorf("delete session: %w", err) |
| 51 | } | 67 | } |
| 52 | return nil | 68 | return nil |
internal/server/store/sessions_test.go
| Old | New | ||
|---|---|---|---|
| @@ -68,3 +68,38 @@ func TestReapSessions(t *testing.T) { | |||
| 68 | require.NoError(t, err) | 68 | require.NoError(t, err) |
| 69 | assert.Equal(t, int64(0), n) | 69 | assert.Equal(t, int64(0), n) |
| 70 | } | 70 | } |
| 71 | |||
| 72 | // TestSessionIDNotStoredInCleartext is the property, not the plumbing: a copied | ||
| 73 | // database must not hand anyone a usable console session. The lifecycle tests | ||
| 74 | // above would pass just as well with the id stored verbatim. | ||
| 75 | func TestSessionIDNotStoredInCleartext(t *testing.T) { | ||
| 76 | s := newStore(t) | ||
| 77 | id, err := s.CreateSession(testTenant, time.Hour) | ||
| 78 | require.NoError(t, err) | ||
| 79 | |||
| 80 | var stored string | ||
| 81 | require.NoError(t, s.db.QueryRow(`SELECT id FROM sessions`).Scan(&stored)) | ||
| 82 | assert.NotEqual(t, id, stored, "the cookie value must not be what is on disk") | ||
| 83 | assert.Equal(t, hashToken(id), stored) | ||
| 84 | |||
| 85 | // And the stored value must not itself work as a cookie — otherwise the row | ||
| 86 | // is still a bearer credential, just a differently-spelled one. | ||
| 87 | _, ok, err := s.SessionTenant(stored) | ||
| 88 | require.NoError(t, err) | ||
| 89 | assert.False(t, ok, "presenting the stored hash must not authenticate") | ||
| 90 | } | ||
| 91 | |||
| 92 | // TestSessionRowsPredatingHashingAreInert pins what happens to sessions written | ||
| 93 | // before ids were hashed: they authenticate nobody, rather than still working. | ||
| 94 | func TestSessionRowsPredatingHashingAreInert(t *testing.T) { | ||
| 95 | s := newStore(t) | ||
| 96 | legacy := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" | ||
| 97 | _, err := s.db.Exec(`INSERT INTO sessions(id, tenant, created_at, expires_at) VALUES (?,?,?,?)`, | ||
| 98 | legacy, testTenant, time.Now().UTC().Format(time.RFC3339), | ||
| 99 | time.Now().UTC().Add(time.Hour).Format(time.RFC3339)) | ||
| 100 | require.NoError(t, err) | ||
| 101 | |||
| 102 | _, ok, err := s.SessionTenant(legacy) | ||
| 103 | require.NoError(t, err) | ||
| 104 | assert.False(t, ok, "a pre-hashing row must not authenticate") | ||
| 105 | } | ||
internal/server/store/store.go
| Old | New | ||
|---|---|---|---|
| @@ -217,15 +217,20 @@ CREATE UNIQUE INDEX IF NOT EXISTS vms_tenant_name ON vms(tenant, name) WHERE del | |||
| 217 | -- lookups are preserved. The tenant column (added via ensureColumn in Open — the | 217 | -- lookups are preserved. The tenant column (added via ensureColumn in Open — the |
| 218 | -- table shipped without it) scopes the revocation LIST per tenant and records | 218 | -- table shipped without it) scopes the revocation LIST per tenant and records |
| 219 | -- which tenant filed the revocation; old rows backfill to 'default'. Enforcement | 219 | -- which tenant filed the revocation; old rows backfill to 'default'. Enforcement |
| 220 | -- at the GATE stays fleet-wide by serial (IsSSHCertRevoked ignores tenant): a | 220 | -- at the GATE is scoped to the revoking tenant: the gate resolves a presented |
| 221 | -- revocation only ever DENIES access, serials are 64-bit crypto-random, and the | 221 | -- certificate to the tenant that registered its signing CA, and consults that |
| 222 | -- gate has no user-cert mint registry to key a tenant off, so a global kill by | 222 | -- tenant's rows only. A fleet-wide kill by serial was fail-safe for the guest it |
| 223 | -- serial is fail-safe. Guests trust the CA with no guest-side KRL — a | 223 | -- protected and an availability hole for everyone else — any tenant could write |
| 224 | -- multi-user/rotation follow-up. | 224 | -- a row that denied a serial it had no claim to. The key is (tenant, serial) so |
| 225 | -- two tenants can revoke the same serial independently; keyed on serial alone | ||
| 226 | -- the second revocation would be dropped and its certificate keep working. | ||
| 227 | -- Guests trust the CA with no guest-side KRL — a multi-user/rotation follow-up. | ||
| 225 | CREATE TABLE IF NOT EXISTS revoked_ssh_certs ( | 228 | CREATE TABLE IF NOT EXISTS revoked_ssh_certs ( |
| 226 | serial INTEGER PRIMARY KEY, | 229 | tenant TEXT NOT NULL DEFAULT 'default', |
| 230 | serial INTEGER NOT NULL, | ||
| 227 | revoked_at DATETIME NOT NULL, | 231 | revoked_at DATETIME NOT NULL, |
| 228 | reason TEXT NOT NULL DEFAULT '' | 232 | reason TEXT NOT NULL DEFAULT '', |
| 233 | PRIMARY KEY (tenant, serial) | ||
| 229 | ); | 234 | ); |
| 230 | 235 | ||
| 231 | -- tenant_user_cas: uploaded per-tenant USER CA public keys (BYO). eitri holds | 236 | -- tenant_user_cas: uploaded per-tenant USER CA public keys (BYO). eitri holds |
| @@ -413,6 +418,13 @@ func Open(path, cidrPool string) (*Store, error) { | |||
| 413 | return nil, err | 418 | return nil, err |
| 414 | } | 419 | } |
| 415 | 420 | ||
| 421 | // Revocation became tenant-scoped; the old key would silently drop a second | ||
| 422 | // tenant's revocation of the same serial. See rekeyRevokedSSHCerts. | ||
| 423 | if err := rekeyRevokedSSHCerts(db); err != nil { | ||
| 424 | db.Close() | ||
| 425 | return nil, err | ||
| 426 | } | ||
| 427 | |||
| 416 | // A guest's host private key belongs to its host, so there is no column for | 428 | // A guest's host private key belongs to its host, so there is no column for |
| 417 | // one. A database written before that was true has both the column and the | 429 | // one. A database written before that was true has both the column and the |
| 418 | // keys in it; dropping the column takes them with it. The certificate beside | 430 | // keys in it; dropping the column takes them with it. The certificate beside |
| @@ -1161,7 +1173,7 @@ type RevokedCert struct { | |||
| 1161 | func (s *Store) RevokeSSHCert(tenant string, serial uint64, reason string) error { | 1173 | func (s *Store) RevokeSSHCert(tenant string, serial uint64, reason string) error { |
| 1162 | _, err := s.db.Exec( | 1174 | _, err := s.db.Exec( |
| 1163 | `INSERT INTO revoked_ssh_certs(serial, tenant, revoked_at, reason) VALUES (?, ?, ?, ?) | 1175 | `INSERT INTO revoked_ssh_certs(serial, tenant, revoked_at, reason) VALUES (?, ?, ?, ?) |
| 1164 | ON CONFLICT(serial) DO NOTHING`, | 1176 | ON CONFLICT(tenant, serial) DO NOTHING`, |
| 1165 | int64(serial), tenant, time.Now().UTC().Format(time.RFC3339), reason, | 1177 | int64(serial), tenant, time.Now().UTC().Format(time.RFC3339), reason, |
| 1166 | ) | 1178 | ) |
| 1167 | if err != nil { | 1179 | if err != nil { |
| @@ -1170,12 +1182,18 @@ func (s *Store) RevokeSSHCert(tenant string, serial uint64, reason string) error | |||
| 1170 | return nil | 1182 | return nil |
| 1171 | } | 1183 | } |
| 1172 | 1184 | ||
| 1173 | // IsSSHCertRevoked reports whether serial is on the revocation list. The gate | 1185 | // IsSSHCertRevoked reports whether tenant has revoked serial. The gate consults |
| 1174 | // consults this on every cert authentication, so it is a hot read; the single | 1186 | // this on every cert authentication, so it is a hot read; the composite PRIMARY |
| 1175 | // PRIMARY KEY lookup is cheap. | 1187 | // KEY makes it a single index lookup. |
| 1176 | func (s *Store) IsSSHCertRevoked(serial uint64) (bool, error) { | 1188 | // |
| 1189 | // Scoped on purpose: a certificate is revoked by the tenant whose CA signed it, | ||
| 1190 | // and only for that tenant. Answering fleet-wide would let any tenant deny a | ||
| 1191 | // serial belonging to another — the API cannot attribute a bare serial, because | ||
| 1192 | // CAs are BYO and eitri never sees the certificates they mint. | ||
| 1193 | func (s *Store) IsSSHCertRevoked(tenant string, serial uint64) (bool, error) { | ||
| 1177 | var n int | 1194 | var n int |
| 1178 | err := s.db.QueryRow(`SELECT COUNT(*) FROM revoked_ssh_certs WHERE serial=?`, int64(serial)).Scan(&n) | 1195 | err := s.db.QueryRow(`SELECT COUNT(*) FROM revoked_ssh_certs WHERE tenant=? AND serial=?`, |
| 1196 | tenant, int64(serial)).Scan(&n) | ||
| 1179 | if err != nil { | 1197 | if err != nil { |
| 1180 | return false, fmt.Errorf("lookup revoked ssh cert: %w", err) | 1198 | return false, fmt.Errorf("lookup revoked ssh cert: %w", err) |
| 1181 | } | 1199 | } |
internal/server/store/store_test.go
| Old | New | ||
|---|---|---|---|
| @@ -456,18 +456,18 @@ func TestSSHCertRevocation(t *testing.T) { | |||
| 456 | s := newStore(t) | 456 | s := newStore(t) |
| 457 | 457 | ||
| 458 | // Unknown serial is not revoked. | 458 | // Unknown serial is not revoked. |
| 459 | revoked, err := s.IsSSHCertRevoked(42) | 459 | revoked, err := s.IsSSHCertRevoked(testTenant, 42) |
| 460 | require.NoError(t, err) | 460 | require.NoError(t, err) |
| 461 | assert.False(t, revoked) | 461 | assert.False(t, revoked) |
| 462 | 462 | ||
| 463 | // Revoke, then it reads back as revoked. | 463 | // Revoke, then it reads back as revoked. |
| 464 | require.NoError(t, s.RevokeSSHCert(testTenant, 42, "leaked laptop")) | 464 | require.NoError(t, s.RevokeSSHCert(testTenant, 42, "leaked laptop")) |
| 465 | revoked, err = s.IsSSHCertRevoked(42) | 465 | revoked, err = s.IsSSHCertRevoked(testTenant, 42) |
| 466 | require.NoError(t, err) | 466 | require.NoError(t, err) |
| 467 | assert.True(t, revoked) | 467 | assert.True(t, revoked) |
| 468 | 468 | ||
| 469 | // A different serial is unaffected. | 469 | // A different serial is unaffected. |
| 470 | revoked, err = s.IsSSHCertRevoked(43) | 470 | revoked, err = s.IsSSHCertRevoked(testTenant, 43) |
| 471 | require.NoError(t, err) | 471 | require.NoError(t, err) |
| 472 | assert.False(t, revoked) | 472 | assert.False(t, revoked) |
| 473 | 473 | ||
| @@ -503,19 +503,35 @@ func TestSSHCertRevocationTenantScoped(t *testing.T) { | |||
| 503 | require.Len(t, bl, 1) | 503 | require.Len(t, bl, 1) |
| 504 | assert.Equal(t, uint64(200), bl[0].Serial) | 504 | assert.Equal(t, uint64(200), bl[0].Serial) |
| 505 | 505 | ||
| 506 | // Gate enforcement is fleet-wide by serial: both are revoked regardless of tenant. | 506 | // Gate enforcement is scoped to the revoking tenant: each serial is revoked |
| 507 | for _, serial := range []uint64{100, 200} { | 507 | // for its own tenant and live for the other. A fleet-wide answer here would |
| 508 | revoked, err := s.IsSSHCertRevoked(serial) | 508 | // let either tenant deny a serial it has no claim to. |
| 509 | for _, c := range []struct { | ||
| 510 | tenant string | ||
| 511 | serial uint64 | ||
| 512 | want bool | ||
| 513 | }{ | ||
| 514 | {testTenant, 100, true}, | ||
| 515 | {testTenant, 200, false}, | ||
| 516 | {beta.ID, 200, true}, | ||
| 517 | {beta.ID, 100, false}, | ||
| 518 | } { | ||
| 519 | revoked, err := s.IsSSHCertRevoked(c.tenant, c.serial) | ||
| 509 | require.NoError(t, err) | 520 | require.NoError(t, err) |
| 510 | assert.True(t, revoked, "serial %d must read revoked at the gate", serial) | 521 | assert.Equal(t, c.want, revoked, "tenant %s serial %d", c.tenant, c.serial) |
| 511 | } | 522 | } |
| 512 | 523 | ||
| 513 | // beta re-filing default's serial is a no-op: original owner (default) keeps it. | 524 | // Two tenants may revoke the SAME serial independently — the rows are keyed |
| 514 | require.NoError(t, s.RevokeSSHCert(beta.ID, 100, "beta tries to steal")) | 525 | // (tenant, serial). Keyed on serial alone the second would be dropped and |
| 526 | // that tenant's certificate would keep working: a revocation that fails open. | ||
| 527 | require.NoError(t, s.RevokeSSHCert(beta.ID, 100, "beta's own cert, same serial")) | ||
| 528 | revoked, err := s.IsSSHCertRevoked(beta.ID, 100) | ||
| 529 | require.NoError(t, err) | ||
| 530 | assert.True(t, revoked, "beta's revocation must bind beta") | ||
| 531 | |||
| 515 | bl, err = s.ListRevokedSSHCerts(beta.ID) | 532 | bl, err = s.ListRevokedSSHCerts(beta.ID) |
| 516 | require.NoError(t, err) | 533 | require.NoError(t, err) |
| 517 | require.Len(t, bl, 1, "beta must not acquire a serial default already owns") | 534 | assert.Len(t, bl, 2, "beta now owns rows for both serials it revoked") |
| 518 | assert.Equal(t, uint64(200), bl[0].Serial) | ||
| 519 | } | 535 | } |
| 520 | 536 | ||
| 521 | // TestSSHCertRevocationLargeSerial guards the uint64→int64 bit-cast: a serial | 537 | // TestSSHCertRevocationLargeSerial guards the uint64→int64 bit-cast: a serial |
| @@ -528,12 +544,12 @@ func TestSSHCertRevocationLargeSerial(t *testing.T) { | |||
| 528 | const other = uint64(0x8000000000000000) | 544 | const other = uint64(0x8000000000000000) |
| 529 | 545 | ||
| 530 | require.NoError(t, s.RevokeSSHCert(testTenant, big, "big")) | 546 | require.NoError(t, s.RevokeSSHCert(testTenant, big, "big")) |
| 531 | revoked, err := s.IsSSHCertRevoked(big) | 547 | revoked, err := s.IsSSHCertRevoked(testTenant, big) |
| 532 | require.NoError(t, err) | 548 | require.NoError(t, err) |
| 533 | assert.True(t, revoked) | 549 | assert.True(t, revoked) |
| 534 | 550 | ||
| 535 | // A distinct large serial must not collide with the first. | 551 | // A distinct large serial must not collide with the first. |
| 536 | revoked, err = s.IsSSHCertRevoked(other) | 552 | revoked, err = s.IsSSHCertRevoked(testTenant, other) |
| 537 | require.NoError(t, err) | 553 | require.NoError(t, err) |
| 538 | assert.False(t, revoked) | 554 | assert.False(t, revoked) |
| 539 | 555 | ||
internal/server/web/csp.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,58 @@ | |||
| 1 | package web | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "crypto/sha256" | ||
| 5 | "encoding/base64" | ||
| 6 | "io/fs" | ||
| 7 | "regexp" | ||
| 8 | "strings" | ||
| 9 | "sync" | ||
| 10 | ) | ||
| 11 | |||
| 12 | // inlineScript matches a <script> element that carries its code inline. One with | ||
| 13 | // a src= attribute is skipped: it loads from 'self' and needs no hash. | ||
| 14 | var inlineScript = regexp.MustCompile(`(?s)<script([^>]*)>(.*?)</script>`) | ||
| 15 | |||
| 16 | var ( | ||
| 17 | hashOnce sync.Once | ||
| 18 | hashes []string | ||
| 19 | ) | ||
| 20 | |||
| 21 | // InlineScriptHashes returns CSP source tokens ("sha256-…") covering every | ||
| 22 | // inline script in the built index.html. | ||
| 23 | // | ||
| 24 | // The SPA's entry point is one inline block SvelteKit writes at build time, and | ||
| 25 | // its contents change on every build (it names a fresh global). Hashing the | ||
| 26 | // bytes actually embedded — rather than pinning a literal or giving up and | ||
| 27 | // allowing 'unsafe-inline' — keeps script-src strict across rebuilds with | ||
| 28 | // nothing for an operator to remember to update. | ||
| 29 | // | ||
| 30 | // A hash present in script-src is also what makes CSP ignore 'unsafe-inline', | ||
| 31 | // so this is the difference between a policy that blocks injected script and | ||
| 32 | // one that only looks like it does. | ||
| 33 | func InlineScriptHashes() []string { | ||
| 34 | hashOnce.Do(func() { hashes = scanInlineScripts(dist, "dist/index.html") }) | ||
| 35 | return hashes | ||
| 36 | } | ||
| 37 | |||
| 38 | // scanInlineScripts is the testable half: it takes the filesystem and path so a | ||
| 39 | // test can hash a document it wrote itself. A missing or unreadable index.html | ||
| 40 | // yields no hashes — the UI is not built, and there is nothing to allow. | ||
| 41 | func scanInlineScripts(fsys fs.FS, path string) []string { | ||
| 42 | raw, err := fs.ReadFile(fsys, path) | ||
| 43 | if err != nil { | ||
| 44 | return nil | ||
| 45 | } | ||
| 46 | var out []string | ||
| 47 | for _, m := range inlineScript.FindAllSubmatch(raw, -1) { | ||
| 48 | if strings.Contains(strings.ToLower(string(m[1])), "src=") { | ||
| 49 | continue | ||
| 50 | } | ||
| 51 | if len(m[2]) == 0 { | ||
| 52 | continue | ||
| 53 | } | ||
| 54 | sum := sha256.Sum256(m[2]) | ||
| 55 | out = append(out, "sha256-"+base64.StdEncoding.EncodeToString(sum[:])) | ||
| 56 | } | ||
| 57 | return out | ||
| 58 | } | ||
internal/server/web/csp_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,52 @@ | |||
| 1 | package web | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "testing" | ||
| 5 | "testing/fstest" | ||
| 6 | |||
| 7 | "github.com/stretchr/testify/assert" | ||
| 8 | "github.com/stretchr/testify/require" | ||
| 9 | ) | ||
| 10 | |||
| 11 | func TestScanInlineScriptsHashesOnlyInlineCode(t *testing.T) { | ||
| 12 | fsys := fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte( | ||
| 13 | `<html><head><script src="/app.js"></script></head>` + | ||
| 14 | `<body><script>console.log("hi")</script></body></html>`)}} | ||
| 15 | got := scanInlineScripts(fsys, "index.html") | ||
| 16 | // sha256 of `console.log("hi")`, base64 — verified with openssl, which is the | ||
| 17 | // same computation a browser makes. | ||
| 18 | require.Len(t, got, 1, "the src= script must not be hashed") | ||
| 19 | assert.Equal(t, "sha256-TMFma7PHrBUjZEUKY/MwBLuX3/HrQe2+A1FmjMS7ppA=", got[0]) | ||
| 20 | } | ||
| 21 | |||
| 22 | func TestScanInlineScriptsMissingIndexYieldsNone(t *testing.T) { | ||
| 23 | assert.Empty(t, scanInlineScripts(fstest.MapFS{}, "index.html")) | ||
| 24 | } | ||
| 25 | |||
| 26 | // TestInlineScriptHashesCoversBuiltSPA guards the real embedded asset: if the | ||
| 27 | // console ships with an inline entry point, it must be hashed, or a strict | ||
| 28 | // script-src would blank the page. | ||
| 29 | func TestInlineScriptHashesCoversBuiltSPA(t *testing.T) { | ||
| 30 | if !fileExists(dist, "dist/index.html") { | ||
| 31 | t.Skip("UI not built") | ||
| 32 | } | ||
| 33 | assert.NotEmpty(t, InlineScriptHashes(), "built SPA has an inline entry point") | ||
| 34 | } | ||
| 35 | |||
| 36 | func TestScanInlineScriptsSkipsEmptyBlock(t *testing.T) { | ||
| 37 | // An empty <script></script> hashes to the digest of nothing, which would be | ||
| 38 | // a source token that permits an empty script and confuses the policy. | ||
| 39 | fsys := fstest.MapFS{"index.html": &fstest.MapFile{ | ||
| 40 | Data: []byte(`<html><body><script></script></body></html>`)}} | ||
| 41 | assert.Empty(t, scanInlineScripts(fsys, "index.html")) | ||
| 42 | } | ||
| 43 | |||
| 44 | func TestScanInlineScriptsHandlesAttributedScript(t *testing.T) { | ||
| 45 | // SvelteKit writes a bare <script>, but a build that emits type="module" | ||
| 46 | // must still be covered — a missed hash blanks the console. | ||
| 47 | fsys := fstest.MapFS{"index.html": &fstest.MapFile{ | ||
| 48 | Data: []byte(`<html><body><script type="module">console.log("hi")</script></body></html>`)}} | ||
| 49 | got := scanInlineScripts(fsys, "index.html") | ||
| 50 | require.Len(t, got, 1) | ||
| 51 | assert.Equal(t, "sha256-TMFma7PHrBUjZEUKY/MwBLuX3/HrQe2+A1FmjMS7ppA=", got[0]) | ||
| 52 | } | ||
scripts/backup-image.sh
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,30 @@ | |||
| 1 | #!/usr/bin/env bash | ||
| 2 | # Build and push the nightly-backup image (see deploy/server/backup.Dockerfile). | ||
| 3 | # | ||
| 4 | # Unlike the server and site images this one is NOT built per release: it holds | ||
| 5 | # sqlite and nothing of eitri's, so it carries its own tag and is rebuilt only | ||
| 6 | # when the base image moves. BACKUP_IMAGE therefore includes the tag. | ||
| 7 | # | ||
| 8 | # Reads deploy.env (same file scripts/deploy.sh uses): | ||
| 9 | # BACKUP_IMAGE full ref to push, e.g. registry.example/eitri-backup:1 | ||
| 10 | set -euo pipefail | ||
| 11 | cd "$(dirname "$0")/.." | ||
| 12 | |||
| 13 | ENV_FILE="${EITRI_DEPLOY_ENV:-$HOME/eitri-deploy/deploy.env}" | ||
| 14 | # shellcheck disable=SC1090 | ||
| 15 | [ -f "$ENV_FILE" ] && . "$ENV_FILE" | ||
| 16 | : "${BACKUP_IMAGE:?backup-image: set BACKUP_IMAGE (with its tag) in $ENV_FILE}" | ||
| 17 | |||
| 18 | case "$BACKUP_IMAGE" in | ||
| 19 | *:*) ;; | ||
| 20 | *) echo "backup-image: BACKUP_IMAGE must include a tag, e.g. .../eitri-backup:1" >&2; exit 1 ;; | ||
| 21 | esac | ||
| 22 | |||
| 23 | # arm64: the control-plane node this job is pinned to (see plane.*.env NODE_NAME). | ||
| 24 | docker build --platform linux/arm64 -f deploy/server/backup.Dockerfile -t "$BACKUP_IMAGE" . | ||
| 25 | # Between build and push, like the other two: the gate is on what gets published. | ||
| 26 | ./scripts/scan-image.sh "$BACKUP_IMAGE" | ||
| 27 | docker push "$BACKUP_IMAGE" | ||
| 28 | echo "backup-image: pushed $BACKUP_IMAGE — set BACKUP_IMAGE in the plane's ship.env and re-ship, or" | ||
| 29 | echo " kubectl -n <ns> patch cronjob eitri-server-backup --type=json \\" | ||
| 30 | echo " -p='[{\"op\":\"replace\",\"path\":\"/spec/jobTemplate/spec/template/spec/containers/0/image\",\"value\":\"$BACKUP_IMAGE\"}]'" | ||
scripts/scan-image.sh
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,47 @@ | |||
| 1 | #!/usr/bin/env bash | ||
| 2 | # Scan a built container image for known OS/library vulnerabilities, and fail | ||
| 3 | # on the severities we refuse to publish. | ||
| 4 | # | ||
| 5 | # This is the gate the Go scanner cannot be. `make vuln` (govulncheck) covers | ||
| 6 | # the module graph, which is the whole of the server image's contents — it is a | ||
| 7 | # static binary on distroless. The site image is nginx:alpine, and that layer | ||
| 8 | # has a package manifest with its own advisories that nothing else here reads. | ||
| 9 | # | ||
| 10 | # Runs trivy AS A CONTAINER, so there is no scanner to install and no second | ||
| 11 | # toolchain to keep pinned. The image is handed over as a tarball rather than | ||
| 12 | # through a daemon socket: podman and docker disagree about where that socket | ||
| 13 | # is and whether it exists, and `save` works identically on both. | ||
| 14 | # | ||
| 15 | # scripts/scan-image.sh <image:tag> | ||
| 16 | # | ||
| 17 | # SCAN_SEVERITY severities that fail (default HIGH,CRITICAL) | ||
| 18 | # SKIP_IMAGE_SCAN=1 skip, loudly — for an offline build, never as a habit | ||
| 19 | # TRIVY_IMAGE pinned scanner image | ||
| 20 | set -euo pipefail | ||
| 21 | |||
| 22 | IMAGE="${1:?scan-image: usage: scan-image.sh <image:tag>}" | ||
| 23 | SEVERITY="${SCAN_SEVERITY:-HIGH,CRITICAL}" | ||
| 24 | TRIVY_IMAGE="${TRIVY_IMAGE:-docker.io/aquasec/trivy:0.58.1}" | ||
| 25 | DOCKER="$(command -v docker || command -v podman)" | ||
| 26 | |||
| 27 | if [ "${SKIP_IMAGE_SCAN:-}" = "1" ]; then | ||
| 28 | echo "scan-image: SKIPPED for $IMAGE (SKIP_IMAGE_SCAN=1) — publishing unscanned" >&2 | ||
| 29 | exit 0 | ||
| 30 | fi | ||
| 31 | |||
| 32 | TAR="$(mktemp -d)/image.tar" | ||
| 33 | trap 'rm -rf "$(dirname "$TAR")"' EXIT | ||
| 34 | echo "scan-image: scanning $IMAGE for $SEVERITY" | ||
| 35 | "$DOCKER" save "$IMAGE" -o "$TAR" | ||
| 36 | |||
| 37 | # The vulnerability DB is cached in a named volume so a second scan in the same | ||
| 38 | # session does not re-download it. | ||
| 39 | "$DOCKER" run --rm \ | ||
| 40 | -v "$TAR:/image.tar:ro" \ | ||
| 41 | -v eitri-trivy-cache:/root/.cache/trivy \ | ||
| 42 | "$TRIVY_IMAGE" image \ | ||
| 43 | --input /image.tar \ | ||
| 44 | --severity "$SEVERITY" \ | ||
| 45 | --ignore-unfixed \ | ||
| 46 | --exit-code 1 \ | ||
| 47 | --scanners vuln | ||
scripts/server-image.sh
| Old | New | ||
|---|---|---|---|
| @@ -33,5 +33,8 @@ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags "$LDFLAGS" \ | |||
| 33 | cp deploy/server/Dockerfile "$BUILD_DIR/" | 33 | cp deploy/server/Dockerfile "$BUILD_DIR/" |
| 34 | 34 | ||
| 35 | docker build --platform linux/arm64 -t "$SERVER_IMAGE:$VERSION" "$BUILD_DIR" | 35 | docker build --platform linux/arm64 -t "$SERVER_IMAGE:$VERSION" "$BUILD_DIR" |
| 36 | # Between build and push on purpose: the gate is on what gets PUBLISHED, not on | ||
| 37 | # what someone happened to build locally. | ||
| 38 | ./scripts/scan-image.sh "$SERVER_IMAGE:$VERSION" | ||
| 36 | docker push "$SERVER_IMAGE:$VERSION" | 39 | docker push "$SERVER_IMAGE:$VERSION" |
| 37 | echo "server-image: pushed $SERVER_IMAGE:$VERSION — roll it out with kubectl -n eitri set image deployment/eitri-server eitri-server=$SERVER_IMAGE:$VERSION" | 40 | echo "server-image: pushed $SERVER_IMAGE:$VERSION — roll it out with kubectl -n eitri set image deployment/eitri-server eitri-server=$SERVER_IMAGE:$VERSION" |
scripts/ship.env.example
| Old | New | ||
|---|---|---|---|
| @@ -13,6 +13,9 @@ | |||
| 13 | # plane's registry is named. | 13 | # plane's registry is named. |
| 14 | SERVER_IMAGE="registry.example.com/eitri-server" | 14 | SERVER_IMAGE="registry.example.com/eitri-server" |
| 15 | SITE_IMAGE="registry.example.com/eitri-site" | 15 | SITE_IMAGE="registry.example.com/eitri-site" |
| 16 | # The nightly backup job's image (scripts/backup-image.sh). Required only on a | ||
| 17 | # plane with BACKUPS=1. It includes its OWN tag — it is not built per release. | ||
| 18 | BACKUP_IMAGE="registry.example.com/eitri-backup:1" | ||
| 16 | # Image platform for the site image; match the node arch in the plane file. | 19 | # Image platform for the site image; match the node arch in the plane file. |
| 17 | SITE_PLATFORM="linux/arm64" | 20 | SITE_PLATFORM="linux/arm64" |
| 18 | 21 | ||
scripts/ship.sh
| Old | New | ||
|---|---|---|---|
| @@ -102,6 +102,13 @@ RENDER_VARS=( | |||
| 102 | CONFIG_SECRET PVC_NAME PVC_SIZE TLS_SECRET SITE_TLS_SECRET | 102 | CONFIG_SECRET PVC_NAME PVC_SIZE TLS_SECRET SITE_TLS_SECRET |
| 103 | SERVER_IMAGE SITE_IMAGE TAG | 103 | SERVER_IMAGE SITE_IMAGE TAG |
| 104 | ) | 104 | ) |
| 105 | # The backup image is referenced only by the CronJob, which is applied only | ||
| 106 | # where BACKUPS=1 — so a plane without backups never has to name one. It carries | ||
| 107 | # its own tag rather than $TAG: it holds sqlite and nothing of eitri's, and | ||
| 108 | # rebuilding it every release would be work with no output. | ||
| 109 | if [[ "${BACKUPS:-0}" == "1" ]]; then | ||
| 110 | RENDER_VARS+=(BACKUP_IMAGE) | ||
| 111 | fi | ||
| 105 | # The bundled issuer's values join the list only on a plane that runs one, so a | 112 | # The bundled issuer's values join the list only on a plane that runs one, so a |
| 106 | # plane without it never carries them. An ${OIDC_*} reference added to a SHARED | 113 | # plane without it never carries them. An ${OIDC_*} reference added to a SHARED |
| 107 | # template later would then survive rendering as literal text and fail the apply | 114 | # template later would then survive rendering as literal text and fail the apply |
scripts/site-image.sh
| Old | New | ||
|---|---|---|---|
| @@ -35,5 +35,9 @@ cp -r "dist/$DL_VERSION" "site/dist/dl/$DL_VERSION" | |||
| 35 | ln -sfn "$DL_VERSION" site/dist/dl/latest | 35 | ln -sfn "$DL_VERSION" site/dist/dl/latest |
| 36 | 36 | ||
| 37 | docker build ${SITE_PLATFORM:+--platform "$SITE_PLATFORM"} -f site/Dockerfile -t "$SITE_IMAGE:$VERSION" . | 37 | docker build ${SITE_PLATFORM:+--platform "$SITE_PLATFORM"} -f site/Dockerfile -t "$SITE_IMAGE:$VERSION" . |
| 38 | # Between build and push on purpose: the gate is on what gets PUBLISHED. This | ||
| 39 | # image is the one that needs it — nginx:alpine carries an OS package manifest, | ||
| 40 | # which is a surface `make vuln` cannot see. | ||
| 41 | ./scripts/scan-image.sh "$SITE_IMAGE:$VERSION" | ||
| 38 | docker push "$SITE_IMAGE:$VERSION" | 42 | docker push "$SITE_IMAGE:$VERSION" |
| 39 | echo "site-image: pushed $SITE_IMAGE:$VERSION serving /dl/$DL_VERSION — roll it out on k8s manually" | 43 | echo "site-image: pushed $SITE_IMAGE:$VERSION serving /dl/$DL_VERSION — roll it out on k8s manually" |