Report build provenance

This commit is contained in:
Jonathon Leight
2026-08-08 19:52:28 -04:00
parent b8dc5c01a5
commit 943d933b14
18 changed files with 1065 additions and 53 deletions
+8
View File
@@ -66,3 +66,11 @@ MESHTENDER_MAIL_FROM=MeshTender <noreply@leighthaus.dev>
# 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>
+60 -4
View File
@@ -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
-31
View File
@@ -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
+42 -10
View File
@@ -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 <https://app.leighthaus.dev:8080> (dashboard) or <https://leighthaus.dev:8080> (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:
+146
View File
@@ -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
}
+160
View File
@@ -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)
}
}
+18
View File
@@ -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.
+39
View File
@@ -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(),
})
}
+218
View File
@@ -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)
}
}
+10 -1
View File
@@ -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)
}
+8
View File
@@ -53,6 +53,14 @@
</div>
</a>
</div>
<div class="col-md-6">
<a class="card card-link" href="/admin/build">
<div class="card-body">
<h2 class="card-title">{{template "icon-check" "me-1"}}Build provenance</h2>
<p class="text-secondary mb-0">What this server was built from, and how to reproduce it.</p>
</div>
</a>
</div>
<div class="col-md-6">
<a class="card card-link" href="/admin/proxy-test">
<div class="card-body">
+136
View File
@@ -0,0 +1,136 @@
{{define "title"}}Build provenance · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Administration</div>
<h1 class="page-title fs-1">Build provenance</h1>
</div>
</div>
{{end}}
{{define "content"}}
{{if .Reproducible}}
<div class="alert alert-info" role="alert">
<div>
This build can be reproduced from source. Anyone can run the steps below and check that they get the same
image digest &mdash; without trusting us, and without access to our registry. The same facts are published
at <a href="{{.VersionPath}}"><code>{{.VersionPath}}</code></a> on the public site, so an outside auditor
can check a running server too.
</div>
</div>
{{else}}
<div class="alert alert-warning" role="alert">
<div>
{{if .Build.Modified}}
This binary was built from a <strong>modified working tree</strong>, so the commit below does not describe
the source it was actually built from and the digest cannot be reproduced from it.
{{else}}
This binary carries <strong>no version-control stamps</strong>, so there is no commit to reproduce it from.
That is normal for a build run straight from a source tree (<code>go run ./cmd/meshtender</code>).
{{end}}
</div>
</div>
{{end}}
<div class="card">
<div class="card-header"><h2 class="card-title">What this binary was built from</h2></div>
<div class="card-body">
<div class="datagrid">
<div class="datagrid-item">
<div class="datagrid-title">Commit</div>
<div class="datagrid-content font-monospace" style="word-break:break-all">
{{if .Build.Commit}}{{.Build.Commit}}{{else}}<span class="text-secondary">not stamped</span>{{end}}
</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">Commit time</div>
<div class="datagrid-content font-monospace">
{{if .Build.CommitTime}}{{.Build.CommitTime}}{{else}}<span class="text-secondary">not stamped</span>{{end}}
</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">Working tree</div>
<div class="datagrid-content">
{{if .Build.Modified}}<span class="badge bg-yellow-lt">modified</span>{{else}}<span class="badge bg-success-lt">clean</span>{{end}}
</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">Go toolchain</div>
<div class="datagrid-content font-monospace">{{.Build.Go}}</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">Platform</div>
<div class="datagrid-content font-monospace">{{.Build.OS}}/{{.Build.Arch}}</div>
</div>
</div>
</div>
</div>
<div class="card mt-3">
<div class="card-header"><h2 class="card-title">Artifact hashes</h2></div>
<div class="card-body">
<div class="mb-3">
<div class="form-label">Image digest <span class="text-secondary">(reported by the deployment)</span></div>
{{if .Build.ImageDigest}}
<div class="input-group">
<input type="text" class="form-control font-monospace" id="image-digest" aria-label="Image digest" value="{{.Build.ImageDigest}}" readonly>
<button type="button" class="btn" data-copy-target="#image-digest">{{template "icon-copy" ""}}Copy</button>
</div>
<small class="form-hint">Compare this against the digest <code>mise run image</code> prints. A binary can't
derive its own image digest, so this value is supplied by the deployment (<code>MESHTENDER_IMAGE_DIGEST</code>),
which CI sets from the digest it published.</small>
{{else}}
<span class="text-secondary">Not reported.</span>
<small class="form-hint">Set when the deployment passes <code>MESHTENDER_IMAGE_DIGEST</code>; unset for a run from source.</small>
{{end}}
</div>
<div>
<div class="form-label">Executable SHA-256 <span class="text-secondary">(measured here, at runtime)</span></div>
{{if .Build.ExecutableSHA256}}
<div class="input-group">
<input type="text" class="form-control font-monospace" id="exe-hash" aria-label="Executable SHA-256" value="{{.Build.ExecutableSHA256}}" readonly>
<button type="button" class="btn" data-copy-target="#exe-hash">{{template "icon-copy" ""}}Copy</button>
</div>
<small class="form-hint">The only value on this page measured from the running process rather than reported
by the pipeline. To check it, extract <code>/ko-app/meshtender</code> from a locally built image and hash it.</small>
{{else}}
<span class="text-secondary">Unavailable &mdash; the executable could not be read.</span>
{{end}}
</div>
</div>
</div>
{{if .Reproducible}}
<div class="card mt-3">
<div class="card-header"><h2 class="card-title">Reproduce this build</h2></div>
<div class="card-body">
<p class="text-secondary">
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.
</p>
<div class="mb-2">
<div class="input-group">
<input type="text" class="form-control font-monospace" id="cmd-checkout" aria-label="Check out this commit" value="{{.Checkout}}" readonly>
<button type="button" class="btn" data-copy-target="#cmd-checkout">{{template "icon-copy" ""}}Copy</button>
</div>
</div>
<div class="mb-2">
<div class="input-group">
<input type="text" class="form-control font-monospace" id="cmd-install" aria-label="Install the pinned toolchain" value="mise install" readonly>
<button type="button" class="btn" data-copy-target="#cmd-install">{{template "icon-copy" ""}}Copy</button>
</div>
<small class="form-hint">Installs the pinned Go toolchain and ko &mdash; a different compiler produces a different binary.</small>
</div>
<div>
<div class="input-group">
<input type="text" class="form-control font-monospace" id="cmd-image" aria-label="Rebuild the image" value="{{.ImageCmd}}" readonly>
<button type="button" class="btn" data-copy-target="#cmd-image">{{template "icon-copy" ""}}Copy</button>
</div>
<small class="form-hint">Builds for this deployment's platform and prints a digest. It pushes nothing and needs no registry access.</small>
</div>
</div>
</div>
{{end}}
<a class="back-link mt-3" href="/admin">{{template "icon-arrow-left" "me-1"}}Back to admin</a>
{{end}}
+9
View File
@@ -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
+75 -6
View File
@@ -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)
}
}
+5
View File
@@ -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)
+8 -1
View File
@@ -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).
+34
View File
@@ -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)
}
}
+89
View File
@@ -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)
}
}