mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-01 17:38:15 +00:00
Report build provenance
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 — 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 — 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 — 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}}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user