From 943d933b143a86b2086d451c0bb5d076cd6db54c Mon Sep 17 00:00:00 2001 From: Jonathon Leight Date: Sat, 8 Aug 2026 19:52:28 -0400 Subject: [PATCH] Report build provenance --- .env.example | 8 + .woodpecker/build.yaml | 64 ++++++- .woodpecker/deploy.yaml | 31 ---- README.md | 52 ++++-- internal/buildinfo/buildinfo.go | 146 +++++++++++++++ internal/buildinfo/buildinfo_test.go | 160 +++++++++++++++++ internal/config/config.go | 18 ++ internal/core/admin_build.go | 39 ++++ internal/core/build_provenance_test.go | 218 +++++++++++++++++++++++ internal/core/handoff_test.go | 11 +- internal/core/templates/admin.html | 8 + internal/core/templates/admin_build.html | 136 ++++++++++++++ internal/core/web.go | 9 + internal/licenses/reproducible_test.go | 81 ++++++++- internal/marketing/marketing.go | 5 + internal/web/env.go | 9 +- internal/web/version.go | 34 ++++ internal/web/version_test.go | 89 +++++++++ 18 files changed, 1065 insertions(+), 53 deletions(-) delete mode 100644 .woodpecker/deploy.yaml create mode 100644 internal/buildinfo/buildinfo.go create mode 100644 internal/buildinfo/buildinfo_test.go create mode 100644 internal/core/admin_build.go create mode 100644 internal/core/build_provenance_test.go create mode 100644 internal/core/templates/admin_build.html create mode 100644 internal/web/version.go create mode 100644 internal/web/version_test.go diff --git a/.env.example b/.env.example index 20b3c1d..d9d8c40 100644 --- a/.env.example +++ b/.env.example @@ -66,3 +66,11 @@ MESHTENDER_MAIL_FROM=MeshTender # per IP and per address partly to stay inside that. # MESHTENDER_RESEND_API_KEY= # MESHTENDER_MAIL_REPLY_TO= + +# The OCI digest of the image this server runs as — reported by GET /version and +# the admin build page, so an auditor can rebuild the commit and compare digests. +# A binary can't derive its own image digest (the digest is computed over the +# binary), so CI resolves it at publish time and the deployment passes it in; see +# .woodpecker/build.yaml. Leave unset when running from source: the field is then +# simply absent rather than wrong. A malformed value is a startup error. +# MESHTENDER_IMAGE_DIGEST=sha256:<64 hex> diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml index 863ff94..b16647d 100644 --- a/.woodpecker/build.yaml +++ b/.woodpecker/build.yaml @@ -1,5 +1,12 @@ -# Builds the OCI image with ko and pushes it to the Forgejo registry. Runs only -# after tests and lint pass, and only on the main branch and on tags (not PRs). +# Builds the OCI image with ko, pushes it to the Forgejo registry, and (on main) +# rolls it out. Runs only after tests and lint pass, and only on the main branch +# and on tags (not PRs). +# +# Building and deploying share one workflow because the deploy needs the image +# DIGEST, and only the build step can know it: ko reports it with --image-refs +# after the push, and Woodpecker workflows have separate workspaces. Deploying by +# digest also means the running Deployment names an immutable artifact rather than +# a tag that could later be moved. # # There is no Dockerfile. MeshTender is a pure-Go single binary with migrations, # templates, and static assets embedded via go:embed, so ko builds it and lays @@ -65,8 +72,14 @@ steps: commands: - go install github.com/google/ko@$KO_VERSION # --bare: publish to exactly $KO_DOCKER_REPO rather than appending a - # package-name/hash suffix, matching the tag deploy.yaml expects. - - ko build --bare --tags "$CI_COMMIT_SHA" --sbom=spdx ./cmd/meshtender + # package-name/hash suffix, matching the reference the deploy step reads. + # + # --image-refs records the published reference INCLUDING its digest + # (repo@sha256:…). That file is why deploying lives in this workflow: the + # digest exists only after the push, workflows don't share a workspace, and + # a binary can't derive its own image digest — so this is the one place the + # value can be captured and handed to the deployment. + - ko build --bare --tags "$CI_COMMIT_SHA" --image-refs image-ref --sbom=spdx ./cmd/meshtender backend_options: *registry_secret when: - event: push @@ -82,3 +95,46 @@ steps: backend_options: *registry_secret when: - event: tag + + # Deploys by DIGEST rather than by the commit-SHA tag, and passes that same + # digest to the app as MESHTENDER_IMAGE_DIGEST so /version can report what is + # actually running (see "Verifying a build" in README.md). + # + # One strategic-merge patch sets the image and the env var together, so the + # rollout that picks up the new image is the same one that picks up its digest — + # `set image` followed by `set env` would trigger two rollouts and leave a + # window where the reported digest belongs to the previous release. Strategic + # merge keys containers by name and env by name, so unrelated env vars on the + # Deployment are preserved. + deploy: + image: alpine/kubectl:1.35.3 + commands: + # No ${...} around shell variables anywhere in this step: Woodpecker does + # its own ${VAR} substitution before the shell runs, so "${IMAGE##*@}" + # would be replaced with an empty string rather than reaching bash. $(…) + # and bare $VAR are untouched, so the digest is cut with a command instead. + - IMAGE="$(cat image-ref)" + - DIGEST="$(cut -d@ -f2 image-ref)" + # Fail loudly rather than deploying with an empty/garbled digest: the app + # rejects a malformed MESHTENDER_IMAGE_DIGEST at startup, so a bad value + # here would take the deployment down rather than merely misreport. + - | + case "$DIGEST" in + sha256:*) ;; + *) echo "no digest in image ref: $IMAGE" >&2; exit 1 ;; + esac + - | + kubectl patch deployment/meshtender -n mesh --type=strategic -p "{ + \"spec\": {\"template\": {\"spec\": {\"containers\": [{ + \"name\": \"meshtender\", + \"image\": \"$IMAGE\", + \"env\": [{\"name\": \"MESHTENDER_IMAGE_DIGEST\", \"value\": \"$DIGEST\"}] + }]}}} + }" + - kubectl rollout status -n mesh deployment/meshtender --timeout=120s + backend_options: + kubernetes: + serviceAccountName: woodpecker-ci-deployer + when: + - event: push + branch: main diff --git a/.woodpecker/deploy.yaml b/.woodpecker/deploy.yaml deleted file mode 100644 index c70d67f..0000000 --- a/.woodpecker/deploy.yaml +++ /dev/null @@ -1,31 +0,0 @@ -# Deploys to the cluster after a successful image build on main, by bumping the -# running Deployment's image to the freshly-pushed SHA tag and waiting for the -# rollout to finish. -when: - - event: push - branch: main - -depends_on: - - build - -# This workflow only runs kubectl against the commit SHA (provided as an env -# var) — it never reads the repo, so skip the automatic clone step. -skip_clone: true - -steps: - deploy: - image: alpine/kubectl:1.35.3 - commands: - - | - kubectl set image \ - -n mesh \ - deployment/meshtender \ - meshtender=git.leightha.us/ci/jleight/meshtender:${CI_COMMIT_SHA} - - | - kubectl rollout status \ - -n mesh \ - deployment/meshtender \ - --timeout=120s - backend_options: - kubernetes: - serviceAccountName: woodpecker-ci-deployer diff --git a/README.md b/README.md index 30112b5..511aeb7 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ passkeys (WebAuthn via `go-webauthn`) with a bcrypt password fallback, sessions optional and used only for recovery (verification + password reset) via Resend; without a `MAIL_FROM` the whole feature is hidden, and without an API key messages are logged instead of sent. -**Admin.** Instance capabilities (`cap_manage_users`, `cap_manage_catalog`) — the first registered +**Admin.** Site-wide capabilities (`cap_manage_users`, `cap_manage_catalog`) — the first registered account is bootstrapped with both — plus first-party traffic analytics (no third party, no PII; visitors counted by a daily-rotating salted hash), CSP violation reports, a reverse-proxy test page, and encrypted export/restore of the server identity. @@ -135,8 +135,8 @@ mise run dev # migrates on boot; serves HTTPS on :8080 ``` Then open (dashboard) or (public -root). WebSerial requires a secure context, which the mkcert HTTPS provides — a real deployment must -likewise serve the console pages over HTTPS. +root). WebSerial requires a secure context, which the mkcert HTTPS provides — the same reason +meshtender.com is served over HTTPS. `mise run seed` fills the database with realistic fake data; `mise run reset` truncates everything except users with credentials, passkeys, sessions, and the server identity. (Both are `go run @@ -167,6 +167,7 @@ except users with credentials, passkeys, sessions, and the server identity. (Bot | `MESHTENDER_TRUSTED_PROXIES` | proxies whose `X-Forwarded-For`/`X-Real-IP` are trusted when resolving the client IP — comma-separated CIDRs/IPs, or `private` for the RFC1918/link-local/ULA ranges. Loopback is always trusted. Verify with the admin **Reverse proxy test** page. | | `MESHTENDER_MAIL_FROM` / `_MAIL_REPLY_TO` | enables the optional recovery-email feature; unset ⇒ no email UI at all | | `MESHTENDER_RESEND_API_KEY` | enables real delivery; unset ⇒ messages are logged to stderr instead (the dev default) | +| `MESHTENDER_IMAGE_DIGEST` | the image digest this server runs as, reported by `/version` (see [Verifying a build](#verifying-a-build)). Set by the deploy; unset when running from source. A malformed value is a startup error | > **Note:** `MESHTENDER_MASTER_KEY` is coupled to the stored identity — changing it makes the > existing `server_identity` row undecryptable. Keep it stable, and keep a copy of the admin @@ -233,15 +234,46 @@ base image *by digest* rather than by its floating `:nonroot` tag — and ko zer `TestReleasePinsAreConsistent` and `TestBaseImageIsPinnedByDigest` (`internal/licenses/reproducible_test.go`) fail the build if any of those pins drift apart. -```sh -git clone https://github.com/jleight/meshtender && cd meshtender -git checkout v1.2.3 # the tag you are verifying -mise install # installs the pinned Go and ko -mise run image +To check what's actually running, start from **`GET /version`** — unauthenticated, because the +people best placed to check our work are the ones without an account: + +```console +$ curl -s https://meshtender.com/version +{ + "commit": "62e30036ee0bfb28f6c1a4a3f5ac5f4a52e4b1c9", + "commitTime": "2026-08-06T17:47:49-04:00", + "modified": false, + "go": "go1.26.5", + "os": "linux", + "arch": "amd64", + "executableSHA256": "9f2c…", + "imageDigest": "sha256:a41b…" +} ``` -Compare the printed `sha256:…` against the digest of the published image. The registry name is not -part of the digest, so this works without any access to our registry. +Then rebuild that commit for that platform and compare digests: + +```sh +git clone https://github.com/jleight/meshtender && cd meshtender +git checkout 62e30036ee0bfb28f6c1a4a3f5ac5f4a52e4b1c9 # the commit /version reported +mise install # installs the pinned Go and ko +mise run image --platform linux/amd64 # the os/arch /version reported +``` + +The printed `sha256:…` should equal `imageDigest`. The registry name is not part of a digest, so +this works without any access to our registry — you never have to pull anything of ours. + +What each field is worth is deliberately different, and worth knowing when you audit: + +| Field | Attested by | +|---|---| +| `commit`, `commitTime`, `modified`, `go`, `os`, `arch` | The Go toolchain, stamped at compile time. Our code doesn't choose these. | +| `executableSHA256` | Measured at runtime, by the process itself, over the file it is running from. The only field about the *running* process rather than about a build. To check it, extract `/ko-app/meshtender` from your own build and hash it. | +| `imageDigest` | Our pipeline. A binary can't derive its own image digest — the digest is computed over the binary — so CI captures it at publish time (`ko build --image-refs`) and the deploy passes it in as `MESHTENDER_IMAGE_DIGEST`, deploying by digest in the same patch. Treat it as a claim to check, not as proof. | + +A build from a modified tree reports `"modified": true`, and its `commit` does **not** describe the +source it was built from — such a build can't be reproduced from that commit, by anyone. Admins see +the same data plus copy-paste reproduction commands at `/admin/build`. Two things change the digest, and both are intentional: diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go new file mode 100644 index 0000000..bf50fa9 --- /dev/null +++ b/internal/buildinfo/buildinfo.go @@ -0,0 +1,146 @@ +// Package buildinfo reports what this running binary was built from, so anyone +// can rebuild it and check they get the same artifact. +// +// The published image is reproducible (see "Verifying a build" in README.md): +// every build input is pinned, so a clean checkout of a given commit produces a +// byte-identical image. That property is only useful if a third party can find +// out WHICH commit a running server was built from — otherwise they can +// reproduce a build, but not the one in front of them. This package is that +// missing half. +// +// Three kinds of claim, deliberately kept distinct, because they are worth +// different amounts to someone auditing us: +// +// - Commit/CommitTime/Modified/Go/OS/Arch come from the Go toolchain's own VCS +// stamps, recorded at compile time. Nothing in our code chooses them. +// - ExecutableSHA256 is computed here, at runtime, over the file this process +// is running from. It is the only field that attests to the actual running +// process rather than to a build that happened elsewhere. +// - ImageDigest is supplied by the deployment (MESHTENDER_IMAGE_DIGEST). A +// binary cannot know its own image digest — the digest is computed over the +// binary, so a binary containing it would have to contain its own hash — so +// CI resolves it at publish time and hands it to the deployment. It is a +// claim by our pipeline, not something the server can verify. +// +// Nothing here is secret: the repository is public, and the whole point is that +// an outsider can check our work. +package buildinfo + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "regexp" + "runtime" + "runtime/debug" + "sync" +) + +// Info is what a running server reports about its own build. JSON tags are part +// of the public /version contract — renaming one is a breaking change for anyone +// scripting against it. +type Info struct { + // Commit is the git revision the binary was built from, or "" for a build + // made outside a repository (a bare `go build` of an exported tree). + Commit string `json:"commit,omitempty"` + // CommitTime is that commit's timestamp, RFC 3339, as stamped by the + // toolchain. Go stamps it into the binary, so it is a build input: it is + // part of why a given commit reproduces a given digest. + CommitTime string `json:"commitTime,omitempty"` + // Modified reports that the working tree had uncommitted changes at build + // time. Such a build is NOT reproducible from the commit alone — Commit + // names a tree the binary was not actually built from. + Modified bool `json:"modified"` + // Go is the toolchain version (e.g. "go1.26.5"). A different compiler + // produces a different binary, so a verifier needs it to match. + Go string `json:"go"` + // OS and Arch are the build target. `mise run image` defaults to + // linux/amd64, so a verifier reproducing an arm64 deployment has to pass + // --platform to match. + OS string `json:"os"` + Arch string `json:"arch"` + // ExecutableSHA256 is a hash of the file this process is running from, + // computed at runtime. Empty if the executable could not be read (it was + // replaced or unlinked under us), which is reported rather than guessed. + ExecutableSHA256 string `json:"executableSHA256,omitempty"` + // ImageDigest is the OCI digest of the image this server is running as, as + // reported by the deployment. Empty when unset — a from-source run, or a + // deployment that doesn't supply it. + ImageDigest string `json:"imageDigest,omitempty"` +} + +// Reproducible reports whether Info names a build another party could actually +// reproduce: it must identify a commit, and that commit must describe the tree +// the binary was built from. +func (i Info) Reproducible() bool { return i.Commit != "" && !i.Modified } + +// digestRE matches an OCI digest: "sha256:" plus exactly 64 lowercase hex. +var digestRE = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + +// ValidateDigest checks an image digest supplied by the environment. Empty is +// allowed (no digest reported). Anything else must be a well-formed sha256 +// digest: a truncated or mistyped value would be published as though it were an +// attestation, and a verifier comparing against it would conclude the running +// server didn't match when the real problem was a typo in a manifest. +func ValidateDigest(s string) error { + if s == "" || digestRE.MatchString(s) { + return nil + } + return fmt.Errorf("must be a digest of the form sha256:<64 lowercase hex>, got %q", s) +} + +// exeHash caches the executable hash. Hashing reads the whole binary (tens of +// MB), and the answer cannot change while the process runs, so it is computed at +// most once — on first use, so a test binary or a `--seed` run never pays for it. +var exeHash = sync.OnceValue(func() string { + path, err := os.Executable() + if err != nil { + return "" + } + // G304: the path is os.Executable() — this process's own binary — not + // anything a request or config can influence. + f, err := os.Open(path) //nolint:gosec // G304: path is os.Executable(), not caller-controlled + if err != nil { + return "" + } + defer func() { _ = f.Close() }() // read-only; a close error can't affect the hash + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "" + } + return hex.EncodeToString(h.Sum(nil)) +}) + +// Read assembles the running binary's build info. imageDigest comes from the +// deployment (validate it with ValidateDigest first); pass "" when none is +// configured. +// +// Fields the toolchain didn't stamp are left empty rather than filled with a +// placeholder: "" reads as "this build carries no such claim", where "unknown" +// would look like a value and could be compared against. +func Read(imageDigest string) Info { + i := Info{ + Go: runtime.Version(), + OS: runtime.GOOS, + Arch: runtime.GOARCH, + ExecutableSHA256: exeHash(), + ImageDigest: imageDigest, + } + bi, ok := debug.ReadBuildInfo() + if !ok { + return i + } + for _, s := range bi.Settings { + switch s.Key { + case "vcs.revision": + i.Commit = s.Value + case "vcs.time": + i.CommitTime = s.Value + case "vcs.modified": + i.Modified = s.Value == "true" + } + } + return i +} diff --git a/internal/buildinfo/buildinfo_test.go b/internal/buildinfo/buildinfo_test.go new file mode 100644 index 0000000..5f83cc5 --- /dev/null +++ b/internal/buildinfo/buildinfo_test.go @@ -0,0 +1,160 @@ +package buildinfo + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "runtime" + "strings" + "testing" +) + +func TestValidateDigest(t *testing.T) { + t.Parallel() + good := "sha256:" + strings.Repeat("a", 64) + cases := []struct { + name string + in string + ok bool + }{ + {"empty is allowed", "", true}, + {"well formed", good, true}, + {"real looking", "sha256:f5b485ea962d9bd1186b2f6b3a061191539b905b82ec395de78cbfae51f20e35", true}, + {"no algorithm prefix", strings.Repeat("a", 64), false}, + {"wrong algorithm", "sha512:" + strings.Repeat("a", 64), false}, + {"too short", "sha256:" + strings.Repeat("a", 63), false}, + {"too long", "sha256:" + strings.Repeat("a", 65), false}, + {"uppercase hex", "sha256:" + strings.Repeat("A", 64), false}, + {"non hex", "sha256:" + strings.Repeat("g", 64), false}, + {"leading space", " " + good, false}, + {"trailing newline", good + "\n", false}, + {"tagged reference rather than digest", "repo/meshtender:abc123", false}, + {"full reference including digest", "repo/meshtender@" + good, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := ValidateDigest(tc.in) + if tc.ok && err != nil { + t.Fatalf("ValidateDigest(%q) = %v, want nil", tc.in, err) + } + if !tc.ok && err == nil { + t.Fatalf("ValidateDigest(%q) = nil, want an error", tc.in) + } + }) + } +} + +// TestReadReportsRuntimeFacts checks the fields that come from the toolchain +// rather than from a build stamp, so they hold under `go test` too. +func TestReadReportsRuntimeFacts(t *testing.T) { + t.Parallel() + got := Read("") + if got.Go != runtime.Version() { + t.Errorf("Go = %q, want %q", got.Go, runtime.Version()) + } + if got.OS != runtime.GOOS || got.Arch != runtime.GOARCH { + t.Errorf("OS/Arch = %s/%s, want %s/%s", got.OS, got.Arch, runtime.GOOS, runtime.GOARCH) + } + if got.ImageDigest != "" { + t.Errorf("ImageDigest = %q, want empty when none is supplied", got.ImageDigest) + } +} + +// TestReadHashesTheRunningExecutable is the check that matters for the +// self-attestation claim: the reported hash must be of the file this process is +// actually running from, not of some other path. +func TestReadHashesTheRunningExecutable(t *testing.T) { + t.Parallel() + exe, err := os.Executable() + if err != nil { + t.Skipf("os.Executable unavailable on this platform: %v", err) + } + b, err := os.ReadFile(exe) + if err != nil { + t.Skipf("cannot read the test binary: %v", err) + } + sum := sha256.Sum256(b) + want := hex.EncodeToString(sum[:]) + + if got := Read("").ExecutableSHA256; got != want { + t.Errorf("ExecutableSHA256 = %q, want %q (the running test binary)", got, want) + } +} + +// TestReadPassesThroughImageDigest documents that Read does not validate — the +// caller does, at startup, so a bad value fails the server rather than being +// silently dropped from an endpoint someone is auditing. +func TestReadPassesThroughImageDigest(t *testing.T) { + t.Parallel() + want := "sha256:" + strings.Repeat("b", 64) + if got := Read(want).ImageDigest; got != want { + t.Errorf("ImageDigest = %q, want %q", got, want) + } +} + +func TestReproducible(t *testing.T) { + t.Parallel() + cases := []struct { + name string + in Info + want bool + }{ + {"clean build of a known commit", Info{Commit: "abc"}, true}, + {"dirty tree", Info{Commit: "abc", Modified: true}, false}, + {"no commit stamp", Info{}, false}, + {"dirty and unstamped", Info{Modified: true}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := tc.in.Reproducible(); got != tc.want { + t.Errorf("Reproducible() = %v, want %v", got, tc.want) + } + }) + } +} + +// TestJSONContract pins the wire names. They are the public /version contract, +// so a rename has to be a deliberate edit to this test, not a silent side effect +// of renaming a Go field. +func TestJSONContract(t *testing.T) { + t.Parallel() + full := Info{ + Commit: "c0ffee", CommitTime: "2026-08-06T17:47:49-04:00", Modified: true, + Go: "go1.26.5", OS: "linux", Arch: "amd64", + ExecutableSHA256: strings.Repeat("a", 64), + ImageDigest: "sha256:" + strings.Repeat("b", 64), + } + b, err := json.Marshal(full) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + for _, k := range []string{"commit", "commitTime", "modified", "go", "os", "arch", "executableSHA256", "imageDigest"} { + if _, ok := m[k]; !ok { + t.Errorf("marshaled Info has no %q key; the /version contract changed", k) + } + } + + // A from-source run omits the empty claims rather than reporting "" for + // them, so a consumer can tell "not stamped" from "stamped as empty". + b, err = json.Marshal(Info{Go: "go1.26.5", OS: "linux", Arch: "amd64"}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + for _, k := range []string{"commit", "commitTime", "executableSHA256", "imageDigest"} { + if strings.Contains(string(b), `"`+k+`"`) { + t.Errorf("empty %s should be omitted, got %s", k, b) + } + } + // modified is NOT omitempty: false is a meaningful claim (a clean tree), + // and omitting it would read the same as "we didn't say". + if !strings.Contains(string(b), `"modified":false`) { + t.Errorf("modified must always be reported, got %s", b) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index d299d5b..e26b12c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,8 @@ import ( "net" "os" "strings" + + "github.com/jleight/meshtender/internal/buildinfo" ) // Config holds all runtime configuration for the server. @@ -73,6 +75,13 @@ type Config struct { MailReplyTo string MailEnabled bool + // ImageDigest is the OCI digest of the image this server runs as, reported by + // /version so an auditor can rebuild the commit and compare. A binary can't + // derive its own image digest (the digest is taken over the binary), so CI + // resolves it at publish time and the deployment passes it in via + // MESHTENDER_IMAGE_DIGEST. Empty when running from source. + ImageDigest string + // TrustedProxies are CIDR ranges whose X-Forwarded-For / X-Real-IP headers are // trusted when resolving a request's client IP. Loopback is always trusted (a // same-host reverse proxy). The client IP is the rightmost X-Forwarded-For @@ -115,9 +124,18 @@ func Load() (*Config, error) { ResendAPIKey: os.Getenv("MESHTENDER_RESEND_API_KEY"), MailFrom: os.Getenv("MESHTENDER_MAIL_FROM"), MailReplyTo: os.Getenv("MESHTENDER_MAIL_REPLY_TO"), + ImageDigest: strings.TrimSpace(os.Getenv("MESHTENDER_IMAGE_DIGEST")), TrustedProxies: trustedProxies, } + // A malformed digest is worse than none: /version would publish it as an + // attestation, and an auditor comparing their rebuild against it would read a + // typo in a deployment manifest as evidence the running server doesn't match + // its source. Fail closed at startup instead. + if err := buildinfo.ValidateDigest(c.ImageDigest); err != nil { + return nil, fmt.Errorf("MESHTENDER_IMAGE_DIGEST: %w", err) + } + // A configured API key with no From address can never deliver anything, and the // failure would only show up as recovery mail silently not arriving. Fail closed // at startup instead, the same treatment a malformed proxy range gets. diff --git a/internal/core/admin_build.go b/internal/core/admin_build.go new file mode 100644 index 0000000..bcd319c --- /dev/null +++ b/internal/core/admin_build.go @@ -0,0 +1,39 @@ +package core + +import ( + "net/http" + + "github.com/jleight/meshtender/internal/web" +) + +// The admin view of build provenance. It renders the same facts as the public +// web.VersionPath endpoint — deliberately, so an operator reads exactly what an +// outside auditor reads — plus the commands to reproduce this build. +// +// The reproduction steps are derived from the running build rather than written +// out in the template: a hardcoded `--platform linux/amd64` would quietly be +// wrong on an arm64 deployment, and that is the kind of error that makes a +// verifier conclude the artifact doesn't match when the instructions were simply +// aimed at the wrong target. + +// pageBuild renders the build-provenance page. +func (s *Handlers) pageBuild(w http.ResponseWriter, r *http.Request) { + b := s.Build + // Only offer a checkout command when there is a commit to check out; a + // from-source run has no VCS stamps, and `git checkout ""` is worse than + // showing nothing. + var checkout string + if b.Commit != "" { + checkout = "git checkout " + b.Commit + } + s.Render(w, r, "admin_build.html", map[string]any{ + "Build": b, + "Checkout": checkout, + "ImageCmd": "mise run image --platform " + b.OS + "/" + b.Arch, + "VersionPath": web.VersionPath, + // Reproducible drives the page's headline: a dirty or unstamped build + // can't be reproduced from a commit, and saying so plainly beats + // printing steps that will not produce a matching digest. + "Reproducible": b.Reproducible(), + }) +} diff --git a/internal/core/build_provenance_test.go b/internal/core/build_provenance_test.go new file mode 100644 index 0000000..3aa3e59 --- /dev/null +++ b/internal/core/build_provenance_test.go @@ -0,0 +1,218 @@ +package core + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "runtime" + "strings" + "testing" + + "github.com/jleight/meshtender/internal/buildinfo" + "github.com/jleight/meshtender/internal/web" +) + +// testDigest is a syntactically valid image digest for the fixture deployments. +var testDigest = "sha256:" + strings.Repeat("b", 64) + +// TestVersionIsPublicOnRoot: the endpoint exists for people who cannot sign in, +// so it must answer an anonymous request on the public host. +func TestVersionIsPublicOnRoot(t *testing.T) { + t.Parallel() + _, _, ts, h := splitServer(t) + + resp := do(t, ts, h.root, web.VersionPath) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET %s on root = %d, want 200", web.VersionPath, resp.StatusCode) + } + + var got map[string]any + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + // The toolchain fields hold for a test binary too; the VCS stamps don't, so + // asserting on them here would only test the build harness. + for _, k := range []string{"go", "os", "arch"} { + if _, ok := got[k]; !ok { + t.Errorf("payload has no %q: %v", k, got) + } + } +} + +// TestVersionSkipsSessionMiddleware: the payload is per-process, not per-user, so +// the endpoint is mounted ahead of the session middleware. scs's LoadAndSave adds +// "Vary: Cookie", so its absence is the observable proof (same argument as +// TestStaticSkipsSessionMiddleware). +func TestVersionSkipsSessionMiddleware(t *testing.T) { + t.Parallel() + _, _, ts, h := splitServer(t) + + resp := do(t, ts, h.root, web.VersionPath) + resp.Body.Close() + for _, v := range resp.Header.Values("Vary") { + for _, part := range strings.Split(v, ",") { + if strings.EqualFold(strings.TrimSpace(part), "Cookie") { + t.Fatalf("%s runs the session middleware (Vary: Cookie); it needs no session", web.VersionPath) + } + } + } +} + +// TestVersionReportsTheDeploymentDigest: the digest is the value an auditor +// compares their rebuild against, and it reaches the endpoint only by being +// threaded from config through to the surfaces. That path is easy to break +// silently — the field just goes missing — so assert it end to end. +func TestVersionReportsTheDeploymentDigest(t *testing.T) { + t.Parallel() + cfg := testConfig() + cfg.ImageDigest = testDigest + _, _, ts, h, _ := splitServerWith(t, true, cfg) + + resp := do(t, ts, h.root, web.VersionPath) + defer resp.Body.Close() + var got map[string]any + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if got["imageDigest"] != testDigest { + t.Errorf("imageDigest = %v, want %s", got["imageDigest"], testDigest) + } +} + +// TestVersionIsRootHostOnly pins the surface: the app and auth hosts are for +// signed-in users and serve no public discovery, so build provenance lives on the +// one host an outsider is meant to read. +func TestVersionIsRootHostOnly(t *testing.T) { + t.Parallel() + _, _, ts, h := splitServer(t) + + for _, host := range []string{h.app, h.auth} { + resp := do(t, ts, host, web.VersionPath) + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + t.Errorf("%s%s = 200, want it served only on the root host", host, web.VersionPath) + } + } +} + +// TestAdminBuildPageRequiresCapability: it's an admin page, so it 404s (rather +// than 403s) for everyone else, matching the rest of /admin. +func TestAdminBuildPageRequiresCapability(t *testing.T) { + t.Parallel() + st, ctx, ts, h := splitServer(t) + u, plain := appLogin(t, ts, st, ctx, h.app, "nobody") + // CreateUser bootstraps the FIRST account in a database to full capabilities, + // so this has to be cleared explicitly — otherwise the fixture is an admin and + // the test passes for the wrong reason (see TestIdentityBackupRequiresAdmin). + if err := st.SetCapabilities(ctx, u.ID, false, false); err != nil { + t.Fatalf("clear capabilities: %v", err) + } + + resp := do(t, ts, h.app, "/admin/build", plain) + resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("/admin/build without a capability = %d, want 404", resp.StatusCode) + } +} + +// TestAdminBuildPageShowsDigest covers what the page adds over the public JSON: +// the digest an auditor compares their rebuild against. +// +// It deliberately does NOT assert the reproduction commands — the test binary +// carries no VCS stamps, so the page correctly withholds steps that couldn't +// reproduce anything. TestBuildPageOffersStepsForAStampedBuild covers that branch +// with a stamped build instead. +func TestAdminBuildPageShowsDigest(t *testing.T) { + t.Parallel() + cfg := testConfig() + cfg.ImageDigest = testDigest + st, ctx, ts, h, _ := splitServerWith(t, true, cfg) + + admin, sess := appLogin(t, ts, st, ctx, h.app, "buildadmin") + if err := st.SetCapabilities(ctx, admin.ID, true, true); err != nil { + t.Fatalf("set caps: %v", err) + } + + body := readBody(t, do(t, ts, h.app, "/admin/build", sess)) + if !strings.Contains(body, testDigest) { + t.Errorf("page does not show the image digest:\n%s", body) + } + // The admin page must not contradict the public endpoint — same source, so + // the toolchain it names is the one the JSON reports. + if !strings.Contains(body, runtime.Version()) { + t.Errorf("page does not name the Go toolchain %q", runtime.Version()) + } +} + +// TestBuildPageOffersStepsForAStampedBuild covers the branch a test binary can +// never reach: a build WITH version-control stamps, where the page offers +// reproduction commands. It renders the handler directly against a synthesized +// build rather than through the server, since the stamps are fixed at compile +// time and no fixture can change them. +func TestBuildPageOffersStepsForAStampedBuild(t *testing.T) { + t.Parallel() + const commit = "62e30036ee0bfb28f6c1a4a3f5ac5f4a52e4b1c9" + env, err := web.NewEnv(web.Deps{ + Cfg: testConfig(), + Build: buildinfo.Info{ + Commit: commit, CommitTime: "2026-08-06T17:47:49-04:00", + Go: "go1.26.5", OS: "linux", Arch: "arm64", + ImageDigest: testDigest, + }, + }, templatesFS) + if err != nil { + t.Fatalf("env: %v", err) + } + + rec := httptest.NewRecorder() + (&Handlers{Env: env}).pageBuild(rec, httptest.NewRequest(http.MethodGet, "/admin/build", nil)) + body := rec.Body.String() + + if !strings.Contains(body, "git checkout "+commit) { + t.Errorf("page does not offer the checkout command:\n%s", body) + } + // The platform must come from the build, not a hardcoded linux/amd64 — that + // is the error that would send a verifier off to reproduce the wrong variant. + if !strings.Contains(body, "mise run image --platform linux/arm64") { + t.Errorf("page does not offer the rebuild command for the built platform:\n%s", body) + } + if !strings.Contains(body, testDigest) { + t.Errorf("page does not show the image digest:\n%s", body) + } +} + +// TestAdminBuildPageIsLinkedFromAdmin: an admin page nothing links to is one +// nobody finds. +func TestAdminBuildPageIsLinkedFromAdmin(t *testing.T) { + t.Parallel() + st, ctx, ts, h := splitServer(t) + admin, sess := appLogin(t, ts, st, ctx, h.app, "adminlinks") + if err := st.SetCapabilities(ctx, admin.ID, true, true); err != nil { + t.Fatalf("set caps: %v", err) + } + + if body := readBody(t, do(t, ts, h.app, "/admin", sess)); !strings.Contains(body, `href="/admin/build"`) { + t.Errorf("the admin index does not link to the build page:\n%s", body) + } +} + +// TestAdminBuildPageHandlesUnstampedBuild: a `go test`/`go run` binary carries no +// VCS stamps, and the page must say so rather than offering steps that cannot +// reproduce anything. (The test binary is itself such a build.) +func TestAdminBuildPageHandlesUnstampedBuild(t *testing.T) { + t.Parallel() + st, ctx, ts, h := splitServer(t) + admin, sess := appLogin(t, ts, st, ctx, h.app, "adminunstamped") + if err := st.SetCapabilities(ctx, admin.ID, true, true); err != nil { + t.Fatalf("set caps: %v", err) + } + + body := readBody(t, do(t, ts, h.app, "/admin/build", sess)) + if strings.Contains(body, "git checkout \"\"") || strings.Contains(body, "git checkout <") { + t.Errorf("page offers a checkout command with no commit:\n%s", body) + } + if !strings.Contains(body, "no version-control stamps") && !strings.Contains(body, "modified working tree") { + t.Errorf("page does not explain that this build isn't reproducible:\n%s", body) + } +} diff --git a/internal/core/handoff_test.go b/internal/core/handoff_test.go index 841c07e..db77c63 100644 --- a/internal/core/handoff_test.go +++ b/internal/core/handoff_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/jleight/meshtender/internal/auth" + "github.com/jleight/meshtender/internal/config" "github.com/jleight/meshtender/internal/identity" "github.com/jleight/meshtender/internal/store" ) @@ -64,6 +65,14 @@ func splitServerNoMail(t *testing.T) (*store.Store, context.Context, *httptest.S } func splitServerWithMail(t *testing.T, mailEnabled bool) (*store.Store, context.Context, *httptest.Server, hostEnv, *fakeSender) { + t.Helper() + return splitServerWith(t, mailEnabled, testConfig()) +} + +// splitServerWith is the base builder, taking the runtime config so a test can +// exercise a setting the deployment supplies (e.g. MESHTENDER_IMAGE_DIGEST) +// rather than only the defaults in testConfig. +func splitServerWith(t *testing.T, mailEnabled bool, cfg *config.Config) (*store.Store, context.Context, *httptest.Server, hostEnv, *fakeSender) { t.Helper() st, ctx := coreStore(t) @@ -79,7 +88,7 @@ func splitServerWithMail(t *testing.T, mailEnabled bool) (*store.Store, context. if err != nil { t.Fatalf("auth: %v", err) } - srv, err := NewServer(st, authSvc, idSvc, testConfig()) + srv, err := NewServer(st, authSvc, idSvc, cfg) if err != nil { t.Fatalf("server: %v", err) } diff --git a/internal/core/templates/admin.html b/internal/core/templates/admin.html index 27dc83c..2fcbee6 100644 --- a/internal/core/templates/admin.html +++ b/internal/core/templates/admin.html @@ -53,6 +53,14 @@ +
diff --git a/internal/core/templates/admin_build.html b/internal/core/templates/admin_build.html new file mode 100644 index 0000000..cce5a28 --- /dev/null +++ b/internal/core/templates/admin_build.html @@ -0,0 +1,136 @@ +{{define "title"}}Build provenance · MeshTender{{end}} +{{define "header"}} +
+
+
Administration
+

Build provenance

+
+
+{{end}} +{{define "content"}} + +{{if .Reproducible}} +
+{{else}} + +{{end}} + +
+

What this binary was built from

+
+
+
+
Commit
+
+ {{if .Build.Commit}}{{.Build.Commit}}{{else}}not stamped{{end}} +
+
+
+
Commit time
+
+ {{if .Build.CommitTime}}{{.Build.CommitTime}}{{else}}not stamped{{end}} +
+
+
+
Working tree
+
+ {{if .Build.Modified}}modified{{else}}clean{{end}} +
+
+
+
Go toolchain
+
{{.Build.Go}}
+
+
+
Platform
+
{{.Build.OS}}/{{.Build.Arch}}
+
+
+
+
+ +
+

Artifact hashes

+
+
+
Image digest (reported by the deployment)
+ {{if .Build.ImageDigest}} +
+ + +
+ Compare this against the digest mise run image prints. A binary can't + derive its own image digest, so this value is supplied by the deployment (MESHTENDER_IMAGE_DIGEST), + which CI sets from the digest it published. + {{else}} + Not reported. + Set when the deployment passes MESHTENDER_IMAGE_DIGEST; unset for a run from source. + {{end}} +
+
+
Executable SHA-256 (measured here, at runtime)
+ {{if .Build.ExecutableSHA256}} +
+ + +
+ The only value on this page measured from the running process rather than reported + by the pipeline. To check it, extract /ko-app/meshtender from a locally built image and hash it. + {{else}} + Unavailable — the executable could not be read. + {{end}} +
+
+
+ +{{if .Reproducible}} +
+

Reproduce this build

+
+

+ Run these from a clean checkout. Go stamps the commit, its time, and a dirty-tree flag into the binary, so + the tree has to be exactly this commit with no local edits or the digest will differ. +

+
+
+ + +
+
+
+
+ + +
+ Installs the pinned Go toolchain and ko — a different compiler produces a different binary. +
+
+
+ + +
+ Builds for this deployment's platform and prints a digest. It pushes nothing and needs no registry access. +
+
+
+{{end}} + +{{template "icon-arrow-left" "me-1"}}Back to admin +{{end}} diff --git a/internal/core/web.go b/internal/core/web.go index e1b03b3..1645abf 100644 --- a/internal/core/web.go +++ b/internal/core/web.go @@ -14,6 +14,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/jleight/meshtender/internal/auth" + "github.com/jleight/meshtender/internal/buildinfo" "github.com/jleight/meshtender/internal/config" "github.com/jleight/meshtender/internal/identity" "github.com/jleight/meshtender/internal/marketing" @@ -108,6 +109,10 @@ func NewServer(st *store.Store, authSvc *auth.Service, idSvc *identity.Service, return u.Name(), u.CapManageUsers || u.CapManageCatalog, u.Timezone, true }, LookupTXT: net.LookupTXT, + // Read once here rather than per request: it hashes the executable, and + // the answer can't change while the process runs. Sharing one value also + // guarantees the public endpoint and the admin page can never disagree. + Build: buildinfo.Read(cfg.ImageDigest), } coreEnv, err := web.NewEnv(deps, templatesFS) @@ -272,6 +277,10 @@ func (s *Handlers) appRouter() chi.Router { r.With(s.requireCap(capAny)).Get("/", s.pageAdmin) r.With(s.requireCap(capAny)).Get("/analytics", s.pageAnalytics) r.With(s.requireCap(capAny)).Get("/proxy-test", s.pageProxyTest) + // Build provenance. capAny, and the same facts are public at + // web.VersionPath — the page adds only the reproduction commands, + // so gating it higher would protect nothing. + r.With(s.requireCap(capAny)).Get("/build", s.pageBuild) // CSP violation reports. Viewing is capAny, matching traffic analytics // — it's diagnostic data about our own pages. Clearing deletes records, // so it takes capUsers, the higher bar; the page hides the button diff --git a/internal/licenses/reproducible_test.go b/internal/licenses/reproducible_test.go index d1797a2..297522e 100644 --- a/internal/licenses/reproducible_test.go +++ b/internal/licenses/reproducible_test.go @@ -144,13 +144,13 @@ func TestReleasePinsAreConsistent(t *testing.T) { } // nonGatingWorkflows are the .woodpecker workflows that are deliberately not -// prerequisites of the build: build and deploy are the thing being gated, and -// e2e is non-gating on purpose (it skips when no browser container is up, so -// requiring it would make releases depend on an advisory check). +// prerequisites of the build: build is the thing being gated (and now carries +// the deploy step too), and e2e is non-gating on purpose (it skips when no +// browser container is up, so requiring it would make releases depend on an +// advisory check). var nonGatingWorkflows = map[string]bool{ - "build": true, - "deploy": true, - "e2e": true, + "build": true, + "e2e": true, } // TestBuildDependsOnEveryGatingWorkflow catches a whole class of quiet failure: @@ -204,3 +204,72 @@ func TestBuildDependsOnEveryGatingWorkflow(t *testing.T) { } } } + +// TestDeployReportsTheImageDigest guards the plumbing behind /version's +// imageDigest field, which is the value an auditor compares their own rebuild +// against. +// +// It is a chain with no runtime signal when it breaks: ko has to be asked for +// the published reference (--image-refs), the deploy has to read that file, and +// it has to pass the digest to the app as MESHTENDER_IMAGE_DIGEST. Drop any link +// and nothing fails — the field simply stops appearing, and the endpoint quietly +// answers with less than it claims to. So assert the wiring rather than trust it. +func TestDeployReportsTheImageDigest(t *testing.T) { + // Every check below reads the COMMENT-STRIPPED file. The step documents this + // plumbing at length — including quoting the ${...} form that must not appear + // — so matching against the prose would let a check pass on its own + // explanation while the command it describes was gone. + build := stripYAMLComments(readRepoFile(t, ".woodpecker/build.yaml")) + + if !strings.Contains(build, "--image-refs") { + t.Error(".woodpecker/build.yaml: ko is not run with --image-refs, so the published " + + "digest is never captured and the deploy has nothing to report") + } + if !strings.Contains(build, "MESHTENDER_IMAGE_DIGEST") { + t.Error(".woodpecker/build.yaml: the deploy does not set MESHTENDER_IMAGE_DIGEST, " + + "so /version cannot report the digest it is running") + } + // Deploying by tag would leave the Deployment naming a mutable reference, + // and the reported digest could then describe a different artifact than the + // one that actually got pulled. The patch is a JSON string inside YAML, so + // its quotes are backslash-escaped. + if !regexp.MustCompile(`\\?"image\\?":\s*\\?"\$IMAGE\\?"`).MatchString(build) { + t.Error(".woodpecker/build.yaml: the deploy no longer sets the image from the " + + "digest-bearing reference ko reported") + } + + // Woodpecker substitutes ${VAR} itself, before the shell sees it, so a shell + // variable written that way silently becomes empty. That would deploy an + // image with an empty digest env var — which the app rejects at startup. + if regexp.MustCompile(`\$\{(IMAGE|DIGEST)\b`).MatchString(build) { + t.Error(".woodpecker/build.yaml: a shell variable is written as ${...}, which " + + "Woodpecker expands away before bash runs. Use $VAR or $(...) instead.") + } +} + +// stripYAMLComments drops whole-line # comments. Crude on purpose — it is used +// only to keep prose in .woodpecker files out of checks that look for code. +func stripYAMLComments(s string) string { + var b strings.Builder + for _, line := range strings.Split(s, "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "#") { + continue + } + b.WriteString(line) + b.WriteString("\n") + } + return b.String() +} + +// TestImageDigestEnvVarMatchesConfig keeps the deploy and the app agreeing on the +// variable's name. They are two files with no compiler between them, and a +// mismatch is invisible: the app just never sees a digest. +func TestImageDigestEnvVarMatchesConfig(t *testing.T) { + cfg := readRepoFile(t, "internal/config/config.go") + name := findSubmatch(t, cfg, "internal/config/config.go", `os\.Getenv\("(MESHTENDER_IMAGE_DIGEST)"\)`) + + build := stripYAMLComments(readRepoFile(t, ".woodpecker/build.yaml")) + if !strings.Contains(build, name) { + t.Errorf(".woodpecker/build.yaml does not set %s, the variable internal/config reads", name) + } +} diff --git a/internal/marketing/marketing.go b/internal/marketing/marketing.go index 175a5a6..8e6f271 100644 --- a/internal/marketing/marketing.go +++ b/internal/marketing/marketing.go @@ -43,6 +43,11 @@ func (s *Handlers) Routes() chi.Router { // Static assets and health don't need a session (and static is hit often), so // register them ahead of the session middleware, which does per-request DB work. s.SharedRoutes(r) + // Build provenance: public, so a third party can check a running server + // against a build they reproduced themselves. Registered here, ahead of the + // session middleware, because it reads nothing per-request — the payload is + // fixed for the life of the process. + r.Get(web.VersionPath, s.VersionJSON) // Branded 404 for unrouted paths, run through the session middleware so the // renderer can read the (host-only) identity for the page chrome. r.NotFound(s.Auth.Sessions.LoadAndSave(s.Auth.ValidateSession(http.HandlerFunc(s.NotFound))).ServeHTTP) diff --git a/internal/web/env.go b/internal/web/env.go index 3b2357d..0681389 100644 --- a/internal/web/env.go +++ b/internal/web/env.go @@ -21,6 +21,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" + "github.com/jleight/meshtender/internal/buildinfo" "github.com/jleight/meshtender/internal/config" "github.com/jleight/meshtender/internal/identity" "github.com/jleight/meshtender/internal/store" @@ -56,6 +57,10 @@ type Deps struct { // endpoint and the reporting directives entirely. CSP *CSPCollector LookupTXT func(name string) ([]string, error) + // Build is what this binary was built from, reported by /version and the + // admin build page. Assembled once by the assembler (hashing the executable + // is not free) and shared, so every surface reports the same thing. + Build buildinfo.Info } // Env is the shared environment a surface's Handlers embeds. It carries the @@ -68,6 +73,8 @@ type Env struct { // LookupTXT resolves DNS TXT records; injectable so domain verification is // testable. Defaults to net.LookupTXT. LookupTXT func(name string) ([]string, error) + // Build is what this binary was built from (see Deps.Build). + Build buildinfo.Info // csp is the shared violation-report collector, or nil when reporting is off. // Unexported: surfaces only need it to exist, not to reach into it. csp *CSPCollector @@ -85,7 +92,7 @@ func NewEnv(d Deps, surfaceTemplates fs.FS) (*Env, error) { if lookup == nil { lookup = net.LookupTXT } - return &Env{Store: d.Store, Identity: d.Identity, Cfg: d.Cfg, Renderer: r, LookupTXT: lookup, csp: d.CSP}, nil + return &Env{Store: d.Store, Identity: d.Identity, Cfg: d.Cfg, Renderer: r, LookupTXT: lookup, Build: d.Build, csp: d.CSP}, nil } // Render delegates to the shared renderer (convenience for handlers via Env). diff --git a/internal/web/version.go b/internal/web/version.go new file mode 100644 index 0000000..f701ff4 --- /dev/null +++ b/internal/web/version.go @@ -0,0 +1,34 @@ +package web + +import ( + "net/http" + "time" +) + +// VersionPath is the public build-provenance endpoint. It lives on the root +// host, which is the surface an outsider can reach without an account — the +// people this endpoint exists for are exactly the ones who can't sign in. +const VersionPath = "/version" + +// versionMaxAge is how long a client may cache the answer. The payload only +// changes when the process is replaced, so this is purely about repeat requests; +// it is short enough that a verifier polling across a deploy sees the new build +// promptly. +const versionMaxAge = time.Minute + +// VersionJSON reports what this binary was built from, so anyone can rebuild the +// named commit and check that they get the same artifact (see "Verifying a +// build" in README.md). +// +// Public and unauthenticated on purpose: a reproducible build that only its +// operator can check against a running server verifies nothing to a third party. +// Nothing here is sensitive — it names a public repository, the toolchain, and +// hashes of artifacts we publish. +// +// A side-effect-free GET, so it satisfies the root host's rule (see +// docs/auth-cross-host.md) that no state-changing request lives there. +func (e *Env) VersionJSON(w http.ResponseWriter, r *http.Request) { + if err := ServeJSONCached(w, r, versionMaxAge, e.Build); err != nil { + e.ServerError(w, r, "could not report build information", err) + } +} diff --git a/internal/web/version_test.go b/internal/web/version_test.go new file mode 100644 index 0000000..05d1666 --- /dev/null +++ b/internal/web/version_test.go @@ -0,0 +1,89 @@ +package web + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/jleight/meshtender/internal/buildinfo" +) + +func testBuild() buildinfo.Info { + return buildinfo.Info{ + Commit: "c0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ff", CommitTime: "2026-08-06T17:47:49-04:00", + Go: "go1.26.5", OS: "linux", Arch: "amd64", + ExecutableSHA256: strings.Repeat("a", 64), + ImageDigest: "sha256:" + strings.Repeat("b", 64), + } +} + +// TestVersionJSONReportsTheBuild is the contract an auditor scripts against: +// the endpoint answers with the build info as JSON, unauthenticated. +func TestVersionJSONReportsTheBuild(t *testing.T) { + t.Parallel() + e := &Env{Build: testBuild()} + rec := httptest.NewRecorder() + e.VersionJSON(rec, httptest.NewRequest(http.MethodGet, VersionPath, nil)) + + res := rec.Result() + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", res.StatusCode) + } + if ct := res.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { + t.Errorf("Content-Type = %q, want JSON", ct) + } + + var got buildinfo.Info + if err := json.NewDecoder(res.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if got != testBuild() { + t.Errorf("reported %+v, want %+v", got, testBuild()) + } +} + +// TestVersionJSONRevalidatesCheaply: the payload is fixed for the life of the +// process, so a repeat request should be answerable with a 304 rather than +// re-sending it. +func TestVersionJSONRevalidatesCheaply(t *testing.T) { + t.Parallel() + e := &Env{Build: testBuild()} + + first := httptest.NewRecorder() + e.VersionJSON(first, httptest.NewRequest(http.MethodGet, VersionPath, nil)) + etag := first.Result().Header.Get("ETag") + if etag == "" { + t.Fatal("no ETag on the first response") + } + + req := httptest.NewRequest(http.MethodGet, VersionPath, nil) + req.Header.Set("If-None-Match", etag) + second := httptest.NewRecorder() + e.VersionJSON(second, req) + if second.Code != http.StatusNotModified { + t.Errorf("revalidated request = %d, want 304", second.Code) + } +} + +// TestVersionJSONOmitsUnstampedFields: a from-source build must not report a +// commit it doesn't have. An auditor reading "commit": "" could otherwise take +// it as a claim rather than as an absence. +func TestVersionJSONOmitsUnstampedFields(t *testing.T) { + t.Parallel() + e := &Env{Build: buildinfo.Info{Go: "go1.26.5", OS: "darwin", Arch: "arm64"}} + rec := httptest.NewRecorder() + e.VersionJSON(rec, httptest.NewRequest(http.MethodGet, VersionPath, nil)) + + body := rec.Body.String() + for _, k := range []string{"commit", "commitTime", "executableSHA256", "imageDigest"} { + if strings.Contains(body, `"`+k+`"`) { + t.Errorf("unstamped %s should be omitted, got %s", k, body) + } + } + if !strings.Contains(body, `"go":"go1.26.5"`) { + t.Errorf("toolchain should always be reported, got %s", body) + } +}