Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,5 +65,15 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Build identity stamped into the binary via -ldflags. These come from
# the same metadata-action step that names the image tag, so
# GET /api/v1/version matches the tag the image was published under:
# outputs.version is "1.2.3" for a v1.2.3 tag and "manual-<sha>" for a
# dispatch run. org.opencontainers.image.created is metadata-action's
# own build timestamp, reused so the label and the API agree.
build-args: |
VERSION=${{ steps.meta.outputs.version }}
COMMIT=${{ github.sha }}
BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}
cache-from: type=gha
cache-to: type=gha,mode=max
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -574,12 +574,27 @@ npm run build # production build
## API Endpoints

All routes are under `/api/v1` and require authentication (unless
`AUTH_DISABLED=true`), except `/health`. The web UI is the primary interface;
these are the main REST routes.
`AUTH_DISABLED=true`), except `/health` and `/api/v1/version`. The web UI is the
primary interface; these are the main REST routes.

### Health
- `GET /health` — health check (restricted to localhost)

### Version
- `GET /api/v1/version` — build identity of the running binary. Public, so an
uptime check or a support conversation can answer "what is running here?"
without a session.

```console
$ curl -s localhost:5001/api/v1/version
{"version":"0.9.0","commit":"6fb9879...","buildDate":"2026-07-31T09:12:44Z"}
```

The same values appear on the first startup log line and in the UI under
**Settings → About**. A published image reports the tag it was built under; a
local `docker build` with no `--build-arg` reports `version: dev`, which is the
honest answer rather than a blank.

### Authentication
- `POST /api/v1/auth/setup` — create the first admin (only when no user exists)
- `POST /api/v1/auth/login` — log in
Expand Down
12 changes: 12 additions & 0 deletions backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"github.com/thinkbig1979/capstan/backend/internal/logging"
"github.com/thinkbig1979/capstan/backend/internal/middleware"
"github.com/thinkbig1979/capstan/backend/internal/services"
"github.com/thinkbig1979/capstan/backend/internal/version"
)

// buildConnectSrc constructs the CSP connect-src directive from the configured
Expand Down Expand Up @@ -106,7 +107,13 @@ func main() {
log.Fatal("Failed to configure logging:", err)
}

// Build identity goes on the very first line, so it is present even if the
// process dies later in startup (agent-os-r7e).
build := version.Get()
slog.Info("Starting Capstan backend",
"version", build.Version,
"commit", build.Commit,
"built", build.BuildDate,
"log_level", cfg.LogLevel,
"log_format", cfg.LogFormat,
)
Expand Down Expand Up @@ -246,6 +253,11 @@ func main() {

api := r.Group("/api/v1")

// Public by choice: build identity answers "what is running here?" for
// monitoring and support without a session. Mirrored in
// middleware.PublicPaths, which both auth and CSRF consult (agent-os-r7e).
handlers.NewVersionHandler().RegisterVersionRoutes(api)

authHandler := handlers.NewAuthHandler(db, cfg.JWTSecret, cfg.AuthDisabled)
authGroup := api.Group("/auth")
authHandler.RegisterPublicRoutes(authGroup)
Expand Down
36 changes: 36 additions & 0 deletions backend/internal/handlers/version.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package handlers

import (
"net/http"

"github.com/gin-gonic/gin"
"github.com/thinkbig1979/capstan/backend/internal/version"
)

// VersionHandler serves the build identity of the running binary.
//
// The route is deliberately public (see RegisterVersionRoutes). "What is
// actually running on this host?" is the first question of every incident, and
// an uptime check or a support conversation should be able to answer it without
// a session. The trade is that an unauthenticated caller learns the exact
// version; for a self-hosted admin tool on a trusted network that is worth the
// operability, and the same information is already visible in the image tag.
type VersionHandler struct{}

func NewVersionHandler() *VersionHandler {
return &VersionHandler{}
}

// RegisterVersionRoutes mounts GET /version on the given group.
//
// It must be mounted on the *unauthenticated* /api/v1 group, and the resulting
// path must appear in middleware.PublicPaths — that list is consulted by both
// the auth middleware and the CSRF middleware, so the two stay consistent if the
// route is ever moved under the protected group.
func (h *VersionHandler) RegisterVersionRoutes(group *gin.RouterGroup) {
group.GET("/version", h.Get)
}

func (h *VersionHandler) Get(c *gin.Context) {
c.JSON(http.StatusOK, version.Get())
}
64 changes: 64 additions & 0 deletions backend/internal/handlers/version_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package handlers

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/gin-gonic/gin"
"github.com/thinkbig1979/capstan/backend/internal/middleware"
"github.com/thinkbig1979/capstan/backend/internal/version"
)

func versionRouter() *gin.Engine {
r := gin.New()
NewVersionHandler().RegisterVersionRoutes(r.Group("/api/v1"))
return r
}

func TestVersionEndpointReturnsBuildIdentity(t *testing.T) {
origV, origC, origD := version.Version, version.Commit, version.BuildDate
t.Cleanup(func() { version.Version, version.Commit, version.BuildDate = origV, origC, origD })
version.Version, version.Commit, version.BuildDate = "9.9.9", "deadbeef", "2026-07-31T00:00:00Z"

w := httptest.NewRecorder()
versionRouter().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/version", nil))

if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
}

var got version.Info
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decoding %s: %v", w.Body.String(), err)
}
if got.Version != "9.9.9" || got.Commit != "deadbeef" || got.BuildDate != "2026-07-31T00:00:00Z" {
t.Errorf("body = %+v, want the stamped build identity", got)
}
}

// TestVersionEndpointServesUnstampedDefaults covers the local-build path: no
// ldflags, so the response must carry "dev" rather than an empty version string.
func TestVersionEndpointServesUnstampedDefaults(t *testing.T) {
w := httptest.NewRecorder()
versionRouter().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/version", nil))

var got version.Info
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decoding %s: %v", w.Body.String(), err)
}
if got.Version != "dev" {
t.Errorf("version = %q, want %q", got.Version, "dev")
}
}

// TestVersionPathIsPublic pins the deliberate public choice. middleware.PublicPaths
// is consulted by the auth middleware *and* the CSRF middleware, so a route that
// is mounted publicly but missing from the list would start 401ing the moment it
// is moved under the protected group.
func TestVersionPathIsPublic(t *testing.T) {
if !middleware.IsPublicPath("/api/v1/version") {
t.Error("/api/v1/version is served outside the protected group but is not in middleware.PublicPaths")
}
}
4 changes: 4 additions & 0 deletions backend/internal/middleware/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ var PublicPaths = []string{
"/api/v1/auth/login",
"/api/v1/auth/setup",
"/api/v1/auth/status",
// Build identity is deliberately public: an uptime check or a support
// conversation needs to answer "what is running here?" without a session.
// This list is consulted by the CSRF middleware too, so keep the two in step.
"/api/v1/version",
"/health",
}

Expand Down
89 changes: 89 additions & 0 deletions backend/internal/version/ldflags_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package version

import (
"os"
"reflect"
"regexp"
"testing"
)

// dockerfilePath is relative to this package directory (backend/internal/version).
const dockerfilePath = "../../../docker/Dockerfile"

// ldflagsX matches a single `-X <import/path>.<Symbol>=<value>` entry.
var ldflagsX = regexp.MustCompile(`-X\s+'?([^\s'"=]+)\.([A-Za-z_][A-Za-z0-9_]*)=`)

// TestDockerfileLdflagsResolveToThisPackage is the guard against the failure mode
// that makes this whole feature untrustworthy: a typo in an -X symbol path is
// accepted silently by the linker, the default value survives, and the build
// succeeds. Nothing at build time complains, so the image ships reporting "dev".
//
// The test pins the Dockerfile's -X paths to the symbols actually declared here.
// Renaming a symbol breaks compilation of the reference block below; moving the
// package or mistyping the path breaks this comparison.
func TestDockerfileLdflagsResolveToThisPackage(t *testing.T) {
// Compile-time proof that these symbols exist with these names and are
// settable strings. -X only works on string vars.
var _ string = Version
var _ string = Commit
var _ string = BuildDate

pkgPath := reflect.TypeFor[Info]().PkgPath()
if pkgPath == "" {
t.Fatal("could not determine this package's import path")
}

content, err := os.ReadFile(dockerfilePath)
if err != nil {
t.Fatalf("reading %s: %v", dockerfilePath, err)
}

matches := ldflagsX.FindAllStringSubmatch(string(content), -1)
if len(matches) == 0 {
t.Fatalf("no -X ldflags entries found in %s; the build no longer stamps build identity", dockerfilePath)
}

want := map[string]bool{"Version": false, "Commit": false, "BuildDate": false}

for _, m := range matches {
gotPath, gotSymbol := m[1], m[2]
if gotPath != pkgPath {
t.Errorf("-X %s.%s targets package %q, but the version package is %q; "+
"the linker accepts this silently and the default value survives",
gotPath, gotSymbol, gotPath, pkgPath)
continue
}
seen, known := want[gotSymbol]
if !known {
t.Errorf("-X %s.%s targets a symbol not declared in this package", gotPath, gotSymbol)
continue
}
if seen {
t.Errorf("-X %s.%s appears more than once", gotPath, gotSymbol)
}
want[gotSymbol] = true
}

for symbol, seen := range want {
if !seen {
t.Errorf("%s is never stamped: no `-X %s.%s=` entry in %s", symbol, pkgPath, symbol, dockerfilePath)
}
}
}

// TestDockerfileArgsDefaultToDev asserts the Dockerfile declares defaults for the
// build args, so a plain `docker build` with no --build-arg yields "dev" rather
// than an empty string. An undeclared-default ARG expands to "" and the -X then
// stamps an empty value, which reads as a bug in the UI and the logs.
func TestDockerfileArgsDefaultToDev(t *testing.T) {
content, err := os.ReadFile(dockerfilePath)
if err != nil {
t.Fatalf("reading %s: %v", dockerfilePath, err)
}

for _, want := range []string{"ARG VERSION=dev", "ARG COMMIT=unknown", "ARG BUILD_DATE=unknown"} {
if !regexp.MustCompile(`(?m)^` + regexp.QuoteMeta(want) + `\s*$`).Match(content) {
t.Errorf("%s not found in %s; a plain docker build would stamp an empty string", want, dockerfilePath)
}
}
}
35 changes: 35 additions & 0 deletions backend/internal/version/version.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Package version carries the build identity stamped into the binary at link
// time.
//
// The values below are overridden by -ldflags "-X <import path>.Version=..." in
// docker/Dockerfile's backend build stage. The defaults are deliberately
// non-empty: an unstamped binary (a plain `go build`, or a local developer run)
// reports "dev" rather than an empty string, which reads as a bug rather than as
// "this was not built from a release tag".
//
// A typo in an -X symbol path fails silently — the linker neither errors nor
// warns, and the default survives. ldflags_test.go guards against that by
// asserting the Dockerfile's -X paths match the symbols declared here.
package version

// These are var, not const, precisely so -X can rewrite them at link time.
var (
// Version is the release version, e.g. "1.4.0". "dev" when unstamped.
Version = "dev"
// Commit is the full git SHA the image was built from.
Commit = "unknown"
// BuildDate is the RFC3339 build timestamp.
BuildDate = "unknown"
)

// Info is the build identity as served by GET /api/v1/version.
type Info struct {
Version string `json:"version"`
Commit string `json:"commit"`
BuildDate string `json:"buildDate"`
}

// Get returns the current build identity.
func Get() Info {
return Info{Version: Version, Commit: Commit, BuildDate: BuildDate}
}
31 changes: 31 additions & 0 deletions backend/internal/version/version_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package version

import "testing"

// TestDefaultsAreHonest guards the "empty string reads as a bug" requirement: an
// unstamped binary must say something, and that something must be "dev".
func TestDefaultsAreHonest(t *testing.T) {
info := Get()

if info.Version != "dev" {
t.Errorf("Version = %q, want %q for an unstamped build", info.Version, "dev")
}
if info.Commit == "" {
t.Error("Commit is empty; an unstamped build must still report something")
}
if info.BuildDate == "" {
t.Error("BuildDate is empty; an unstamped build must still report something")
}
}

func TestGetReflectsStampedValues(t *testing.T) {
origV, origC, origD := Version, Commit, BuildDate
t.Cleanup(func() { Version, Commit, BuildDate = origV, origC, origD })

Version, Commit, BuildDate = "1.2.3", "abc1234", "2026-07-31T12:00:00Z"

info := Get()
if info.Version != "1.2.3" || info.Commit != "abc1234" || info.BuildDate != "2026-07-31T12:00:00Z" {
t.Errorf("Get() = %+v, want the stamped values", info)
}
}
7 changes: 6 additions & 1 deletion docker-compose.prod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ services:
app:
container_name: capstan
# Published by .github/workflows/docker-publish.yml. Pin to a version tag
# (e.g. :0.7.0) in production; :latest tracks the most recent release.
# (e.g. :0.9.0) in production; :latest tracks the most recent release.
# The default stays :latest deliberately — a pinned tag in a template file
# goes stale on every release and nothing in the repo keeps it current. Since
# :latest is mutable and the watchtower label below lets an agent move it,
# identify what is actually running with `curl localhost:5001/api/v1/version`
# or Settings → About.
image: ghcr.io/thinkbig1979/capstan:latest
restart: unless-stopped
ports:
Expand Down
25 changes: 24 additions & 1 deletion docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,30 @@ RUN go mod download
COPY backend/ ./
ARG TARGETOS
ARG TARGETARCH
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a -o server ./cmd/server

# Build identity, stamped into internal/version at link time. The defaults keep
# a plain `docker build` (no --build-arg) honest: it reports "dev", not an empty
# string that reads as a bug. docker-publish.yml supplies the real values from
# the same docker/metadata-action output that names the image tag.
#
# A typo in an -X symbol path is accepted silently by the linker and leaves the
# default in place, so backend/internal/version/ldflags_test.go asserts these
# paths against the symbols actually declared in that package. Keep them in step.
ARG VERSION=dev
ARG COMMIT=unknown
ARG BUILD_DATE=unknown

# The ${VAR:-default} rewrites cover the case the ARG defaults do not: an ARG
# default only applies when the arg is *absent*, so a --build-arg carrying an
# empty value (a CI expression that resolved to nothing) would stamp an empty
# string — the exact silent failure this feature exists to prevent.
RUN set -eu; \
VERSION="${VERSION:-dev}"; \
COMMIT="${COMMIT:-unknown}"; \
BUILD_DATE="${BUILD_DATE:-unknown}"; \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a \
-ldflags "-X github.com/thinkbig1979/capstan/backend/internal/version.Version=${VERSION} -X github.com/thinkbig1979/capstan/backend/internal/version.Commit=${COMMIT} -X github.com/thinkbig1979/capstan/backend/internal/version.BuildDate=${BUILD_DATE}" \
-o server ./cmd/server

# Backup-tools fetch stage — downloads restic and rclone static binaries for the
# target architecture on the build host. Pinned to exact versions for
Expand Down
Loading
Loading