diff --git a/CLAUDE.md b/CLAUDE.md index 9b0153b..8c526cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,6 +11,8 @@ See `README.md` for user-facing docs. This file is for working ON the code. go test ./... # all tests, fast (no docker required) go test -run TestFoo ./internal/api # single test go build ./cmd/server # produces ./server +go build ./cmd/master ./cmd/agent # new master/agent binaries (phase 1: inventory) +DATABASE_URL=postgres://... go test -tags integration ./internal/master/store/ # Postgres tests docker build -t package-updater:dev . # multi-stage, builds golang:1.26-alpine # Local smoke test (builds from Dockerfile, runs against /var/run/docker.sock): @@ -32,6 +34,13 @@ curl -sH "Authorization: Bearer $UPDATER_API_KEY" \ - `internal/selfupdate` — flush-then-exec wrapper for updating ourselves (NOT wired in live, see gotcha) - `internal/metrics` — Prometheus collectors - `internal/logging` — slog JSON, request-id context propagation +- `cmd/master`, `cmd/agent` — master/agent architecture (spec `2026-10-12-master-agent-design.md`), phase 1 = inventory only. `cmd/server` keeps doing updates until phase 3. +- `internal/protocol` — agent↔master WebSocket messages +- `internal/pin` — master key pair, self-signed cert, fingerprint-pinned TLS client +- `internal/agenttoken`— per-agent tokens (`pua_` prefix, only the SHA-256 hash is stored) +- `internal/imageref` — split `repo:tag@digest` +- `internal/agent/...` — inventory collection, Docker event debounce, connection loop +- `internal/master/...`— Postgres store, agent hub, change broker, OIDC auth, htmx UI ## Design intent (do not break without discussion) @@ -73,12 +82,19 @@ curl -sH "Authorization: Bearer $UPDATER_API_KEY" \ with a permission error against a worker-only node. This is an operator/deployment concern (point `DOCKER_HOST` at a manager, or schedule the updater on a manager), not something the code can detect or work around. +- **WebSocket read limit is raised to 1 MiB on both sides.** coder/websocket defaults to 32 KiB, which a full snapshot from a busy machine exceeds — symptom is an agent stuck in a reconnect loop. +- **The master's key lives in Postgres (`master_identity`).** Wiping the database means every agent needs a new `MASTER_KEY`. +- **SSE responses must flush through every wrapper.** `web.events` uses `http.ResponseController`; a middleware that wraps `ResponseWriter` without `Unwrap()` silently breaks live updates. Nginx needs `X-Accel-Buffering: no` (set by the handler). +- **The "stateless" and "label gate" design intents above describe `cmd/server` only.** The master is stateful by design and gates updates per service in the UI (spec 2026-10-12). +- **The master does OIDC discovery once at startup and exits if the issuer is unreachable.** It does not retry; Swarm restarts the container, so a short Authentik outage during deploy resolves itself. ## References - Design spec: `docs/superpowers/specs/2026-05-22-package-updater-design.md` (489 lines, authoritative) - Implementation plan: `docs/superpowers/plans/2026-05-22-package-updater-implementation.md` - Consumer-side CI integration: `gitea-action/` +- Master/agent spec: `docs/superpowers/specs/2026-10-12-master-agent-design.md` +- Phase 1 plan: `docs/superpowers/plans/2026-10-12-master-agent-phase-1-inventory.md` ## Conventions diff --git a/Dockerfile b/Dockerfile index c6c52c6..8305278 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,15 +8,17 @@ COPY . . ARG VERSION=dev ARG COMMIT=unknown ARG BUILD_TIME=unknown -RUN CGO_ENABLED=0 go build \ - -ldflags="-s -w \ - -X main.version=${VERSION} \ - -X main.commit=${COMMIT} \ - -X main.buildTime=${BUILD_TIME}" \ - -o /out/package-updater ./cmd/server +RUN for cmd in server:package-updater master:updater-master agent:updater-agent; do \ + CGO_ENABLED=0 go build \ + -ldflags="-s -w \ + -X main.version=${VERSION} \ + -X main.commit=${COMMIT} \ + -X main.buildTime=${BUILD_TIME}" \ + -o /out/${cmd#*:} ./cmd/${cmd%%:*} || exit 1; \ + done FROM alpine:3.20 RUN apk add --no-cache docker-cli docker-cli-compose ca-certificates wget -COPY --from=build /out/package-updater /usr/local/bin/ +COPY --from=build /out/ /usr/local/bin/ EXPOSE 8080 ENTRYPOINT ["/usr/local/bin/package-updater"] diff --git a/README.md b/README.md index 8a41057..d967afd 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,21 @@ Two things to get right when running in Swarm mode: mounted. This is a deployment concern the service cannot detect or work around. +## Master/agent (in progress) + +A master with one agent per machine is replacing the single-instance webhook +model; see the [design spec](docs/superpowers/specs/2026-10-12-master-agent-design.md). +Phase 1 delivers a live inventory: every Compose stack, Swarm service and +container on every machine, behind Authentik login. Updates still go through +the existing `/update` webhook until phase 3. + +- Master: `updater-master` in the same image, example stack in `deploy/master.stack.yml`. + At startup it does OIDC discovery against `OIDC_ISSUER` and exits if the issuer + is unreachable; Swarm then restarts it. +- Agents: add one per machine on the master's **Agents** page; it shows a + ready-to-run compose file with the agent's token and the master's key + fingerprint. + ## Quick start 1. Build and push the image (e.g. via your own CI). diff --git a/cmd/agent/main.go b/cmd/agent/main.go new file mode 100644 index 0000000..0833e42 --- /dev/null +++ b/cmd/agent/main.go @@ -0,0 +1,89 @@ +// Command updater-agent reports this machine's Docker services to the +// package-updater master. +package main + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/signal" + "syscall" + "time" + + "github.com/docker/docker/client" + + "github.com/shcizo/package-updater/internal/agent/config" + "github.com/shcizo/package-updater/internal/agent/conn" + "github.com/shcizo/package-updater/internal/agent/inventory" + "github.com/shcizo/package-updater/internal/logging" +) + +// Build info is injected at link time via -ldflags="-X main.version=...". +var version = "dev" + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "fatal:", err) + os.Exit(1) + } +} + +func run() error { + cfg, err := config.Load() + if err != nil { + return err + } + logger := logging.New(cfg.LogLevel) + + dockerCli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + if err != nil { + return fmt.Errorf("docker client: %w", err) + } + defer dockerCli.Close() + + collector := inventory.New(dockerCli, cfg.SelfID) + agent := conn.New(conn.Config{ + URL: cfg.MasterURL, Token: cfg.Token, Fingerprint: cfg.MasterKey, Version: version, + }, collector, logger) + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + go watchLoop(ctx, dockerCli, agent, logger) + logger.Info("agent_starting", "version", version, "master_url", cfg.MasterURL) + return agent.Run(ctx) +} + +// watchLoop keeps a Docker event subscription alive. When the stream breaks +// (daemon restart), it resubscribes and asks for a refresh, because events +// may have been missed in between. It also asks for a full re-inventory every +// 60 seconds, since a Swarm replica dying on another node emits no event here. +func watchLoop(ctx context.Context, src inventory.EventSource, agent *conn.Agent, log *slog.Logger) { + go func() { + ticker := time.NewTicker(60 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + agent.Notify() + } + } + }() + + for { + err := inventory.Watch(ctx, src, 2*time.Second, agent.Notify) + if ctx.Err() != nil { + return + } + log.Warn("docker_events_lost", "error", err) + select { + case <-ctx.Done(): + return + case <-time.After(5 * time.Second): + } + agent.Notify() + } +} diff --git a/cmd/master/main.go b/cmd/master/main.go new file mode 100644 index 0000000..34e0de7 --- /dev/null +++ b/cmd/master/main.go @@ -0,0 +1,137 @@ +// Command updater-master serves the package-updater UI and accepts agent +// connections. +package main + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/shcizo/package-updater/internal/logging" + "github.com/shcizo/package-updater/internal/master/config" + "github.com/shcizo/package-updater/internal/master/events" + "github.com/shcizo/package-updater/internal/master/hub" + "github.com/shcizo/package-updater/internal/master/store" + "github.com/shcizo/package-updater/internal/master/web" + "github.com/shcizo/package-updater/internal/master/webauth" + "github.com/shcizo/package-updater/internal/pin" +) + +// Build info is injected at link time via -ldflags="-X main.version=...". +var version = "dev" + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "fatal:", err) + os.Exit(1) + } +} + +func run() error { + cfg, err := config.Load() + if err != nil { + return err + } + logger := logging.New(cfg.LogLevel) + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + st, err := store.Open(ctx, cfg.DatabaseURL) + if err != nil { + return err + } + defer st.Close() + + key, err := st.LoadOrCreateIdentity(ctx, pin.GenerateKey) + if err != nil { + return err + } + cert, err := pin.Certificate(key) + if err != nil { + return err + } + fingerprint, err := pin.Fingerprint(key.Public()) + if err != nil { + return err + } + logger.Info("master_starting", "version", version, "master_key", fingerprint) + + broker := events.NewBroker() + agentHub := hub.New(st, broker, logger) + + provider, err := webauth.NewOIDCProvider(ctx, webauth.OIDCConfig{ + Issuer: cfg.OIDCIssuer, ClientID: cfg.OIDCClientID, ClientSecret: cfg.OIDCClientSecret, + RedirectURL: cfg.PublicURL + "/auth/callback", + }) + if err != nil { + return err + } + auth := webauth.New(provider, st, cfg.AllowedGroup, strings.HasPrefix(cfg.PublicURL, "https://"), logger) + + ui, err := web.New(st, agentHub, broker, web.Config{ + AgentURL: cfg.AgentURL, MasterKey: fingerprint, AgentImage: cfg.AgentImage, + }, logger) + if err != nil { + return err + } + + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { + if err := st.Ping(r.Context()); err != nil { + http.Error(w, "database unreachable", http.StatusServiceUnavailable) + return + } + _, _ = w.Write([]byte("ok")) + }) + mux.HandleFunc("GET /auth/login", auth.Login) + mux.HandleFunc("GET /auth/callback", auth.Callback) + mux.Handle("POST /auth/logout", auth.Require(http.HandlerFunc(auth.Logout))) + ui.Register(mux, auth.Require) + + // BaseContext ties every request context to the signal context, so on + // shutdown the long-lived SSE handlers and agent connections return + // instead of holding Shutdown until its timeout. + baseContext := func(net.Listener) context.Context { return ctx } + + // No WriteTimeout on either server: SSE streams and agent connections + // are long-lived by design. + webSrv := &http.Server{ + Addr: ":" + cfg.Port, Handler: mux, ReadHeaderTimeout: 10 * time.Second, + BaseContext: baseContext, + } + + agentMux := http.NewServeMux() + agentMux.Handle("GET /agent", agentHub) + agentSrv := &http.Server{ + Addr: ":" + cfg.AgentPort, Handler: agentMux, ReadHeaderTimeout: 10 * time.Second, + TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS13}, + BaseContext: baseContext, + } + + errCh := make(chan error, 2) + go func() { errCh <- webSrv.ListenAndServe() }() + go func() { errCh <- agentSrv.ListenAndServeTLS("", "") }() + logger.Info("listening", "web", webSrv.Addr, "agents", agentSrv.Addr) + + select { + case <-ctx.Done(): + logger.Info("shutdown_signal") + case err := <-errCh: + if !errors.Is(err, http.ErrServerClosed) { + return err + } + } + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = webSrv.Shutdown(shutdownCtx) + _ = agentSrv.Shutdown(shutdownCtx) + return nil +} diff --git a/deploy/master.stack.yml b/deploy/master.stack.yml new file mode 100644 index 0000000..e4aaa63 --- /dev/null +++ b/deploy/master.stack.yml @@ -0,0 +1,41 @@ +# Example Swarm stack for the package-updater master (phase 1: inventory only). +# Deploy on the manager: docker stack deploy -c master.stack.yml package-updater-master +# +# Before deploying: +# 1. In Postgres: CREATE USER package_updater ...; CREATE DATABASE package_updater OWNER package_updater; +# 2. In Authentik: create an OAuth2/OpenID provider + application with redirect URI +# https://updater.example.com/auth/callback, and a group "updater-admins". +# 3. docker secret create pu_oidc_client_secret - ; docker secret create pu_database_url - +services: + master: + image: gitea.shcizo.se/shcizo/package-updater:latest + entrypoint: ["/bin/sh", "-c", "DATABASE_URL=$$(cat /run/secrets/pu_database_url) OIDC_CLIENT_SECRET=$$(cat /run/secrets/pu_oidc_client_secret) exec /usr/local/bin/updater-master"] + environment: + PUBLIC_URL: https://updater.example.com + AGENT_URL: wss://10.0.0.5:8090/agent + OIDC_ISSUER: https://auth.example.com/application/o/package-updater/ + OIDC_CLIENT_ID: package-updater + ALLOWED_GROUP: updater-admins + secrets: + - pu_database_url + - pu_oidc_client_secret + ports: + # Agents connect here directly over the internal network, not via the proxy. + - target: 8090 + published: 8090 + mode: host + networks: + - proxy + deploy: + placement: + constraints: [node.role == manager] + +secrets: + pu_database_url: + external: true + pu_oidc_client_secret: + external: true + +networks: + proxy: + external: true diff --git a/docs/superpowers/plans/2026-10-12-master-agent-phase-1-inventory.md b/docs/superpowers/plans/2026-10-12-master-agent-phase-1-inventory.md index 3bed171..42199c4 100644 --- a/docs/superpowers/plans/2026-10-12-master-agent-phase-1-inventory.md +++ b/docs/superpowers/plans/2026-10-12-master-agent-phase-1-inventory.md @@ -29,6 +29,13 @@ - The agent detects changes by re-running the full inventory after Docker events settle and diffing it against the last one sent, rather than re-inspecting individual items. Same messages on the wire, much less code. - The envelope is `{type, payload}`. The `id` field arrives in phase 2 together with jobs. - Only the tables phase 1 needs are created. `desired_ref`, `available_*`, `last_poll_error`, `image_versions` and `update_jobs` arrive in later phases' migrations. +- The agents column is named `swarm_manager` (spec: `is_swarm_manager`), and services carry a single unique `location_key` column instead of one unique index per kind. +- Templates and static assets live under `internal/master/web/` (embedded there), not a top-level `web/` directory. +- The master has no `/metrics` or `/version` endpoint in phase 1; only `/healthz`. +- The overview shows all Swarm services in one "Swarm" group, not one group per cluster. +- The session cookie gets the `Secure` flag only when `PUBLIC_URL` starts with `https://`, so a local http setup works. +- The agent runs a full re-inventory every 60 seconds in addition to Docker events, because a Swarm replica dying on another node emits no event on the manager running the agent. +- Known limitation: the master files Swarm services under the cluster ID the agent reports in its hello. An agent whose token leaks can therefore overwrite or remove the overview rows of any Swarm cluster. This matches the spec's statement that a leaked token can feed false inventory but not command machines; phase 2 must not route Swarm jobs based only on the cluster ID an agent claims. ## Review Focus diff --git a/go.mod b/go.mod index e916ed0..8ac9846 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,14 @@ module github.com/shcizo/package-updater go 1.26.3 require ( + github.com/coder/websocket v1.8.15 + github.com/coreos/go-oidc/v3 v3.21.0 github.com/docker/docker v28.5.2+incompatible + github.com/jackc/pgx/v5 v5.10.0 github.com/prometheus/client_golang v1.23.2 + github.com/prometheus/client_model v0.6.2 github.com/stretchr/testify v1.11.1 + golang.org/x/oauth2 v0.36.0 ) require ( @@ -20,8 +25,12 @@ require ( github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect @@ -31,7 +40,6 @@ require ( github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect @@ -41,7 +49,9 @@ require ( go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.15.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index db66166..f933059 100644 --- a/go.sum +++ b/go.sum @@ -8,12 +8,17 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM= +github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= @@ -26,6 +31,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -37,6 +44,14 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -77,6 +92,9 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -105,6 +123,10 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= @@ -122,6 +144,7 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= diff --git a/internal/agent/config/config.go b/internal/agent/config/config.go new file mode 100644 index 0000000..1b918d9 --- /dev/null +++ b/internal/agent/config/config.go @@ -0,0 +1,51 @@ +// Package config loads the agent's configuration from environment variables. +package config + +import ( + "errors" + "os" + "regexp" + "strings" +) + +var masterKeyPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + +// Config holds the agent's runtime configuration. +type Config struct { + MasterURL string // MASTER_URL, wss://host:port/agent + MasterKey string // MASTER_KEY, fingerprint shown in the master UI + Token string // AGENT_TOKEN, shown once when the agent was added + SelfID string // SELF_ID, defaults to the hostname (= container ID prefix) + LogLevel string // LOG_LEVEL, default info +} + +// Load reads and validates the agent configuration. It fails fast so a +// misconfigured agent never starts dialling. +func Load() (*Config, error) { + cfg := &Config{ + MasterURL: os.Getenv("MASTER_URL"), + MasterKey: os.Getenv("MASTER_KEY"), + Token: os.Getenv("AGENT_TOKEN"), + SelfID: os.Getenv("SELF_ID"), + LogLevel: os.Getenv("LOG_LEVEL"), + } + switch { + case cfg.MasterURL == "": + return nil, errors.New("MASTER_URL is required") + case !strings.HasPrefix(cfg.MasterURL, "wss://"): + return nil, errors.New("MASTER_URL must start with wss://") + case cfg.MasterKey == "": + return nil, errors.New("MASTER_KEY is required") + case !masterKeyPattern.MatchString(cfg.MasterKey): + return nil, errors.New("MASTER_KEY must look like sha256:<64 hex>") + case cfg.Token == "": + return nil, errors.New("AGENT_TOKEN is required") + } + if cfg.SelfID == "" { + cfg.SelfID, _ = os.Hostname() + } + if cfg.LogLevel == "" { + cfg.LogLevel = "info" + } + return cfg, nil +} diff --git a/internal/agent/config/config_test.go b/internal/agent/config/config_test.go new file mode 100644 index 0000000..606018e --- /dev/null +++ b/internal/agent/config/config_test.go @@ -0,0 +1,50 @@ +package config + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const validKey = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +func setValid(t *testing.T) { + t.Setenv("MASTER_URL", "wss://10.0.0.5:8090/agent") + t.Setenv("MASTER_KEY", validKey) + t.Setenv("AGENT_TOKEN", "pua_abc") + t.Setenv("SELF_ID", "") + t.Setenv("LOG_LEVEL", "") +} + +func TestLoad_Valid(t *testing.T) { + setValid(t) + cfg, err := Load() + require.NoError(t, err) + host, _ := os.Hostname() + assert.Equal(t, &Config{ + MasterURL: "wss://10.0.0.5:8090/agent", MasterKey: validKey, Token: "pua_abc", + SelfID: host, LogLevel: "info", + }, cfg) +} + +func TestLoad_Invalid(t *testing.T) { + cases := []struct { + name, key, value, wantErr string + }{ + {"missing url", "MASTER_URL", "", "MASTER_URL is required"}, + {"plain ws", "MASTER_URL", "ws://10.0.0.5:8090/agent", "MASTER_URL must start with wss://"}, + {"missing key", "MASTER_KEY", "", "MASTER_KEY is required"}, + {"malformed key", "MASTER_KEY", "abc", "MASTER_KEY must look like sha256:<64 hex>"}, + {"missing token", "AGENT_TOKEN", "", "AGENT_TOKEN is required"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + setValid(t) + t.Setenv(c.key, c.value) + _, err := Load() + assert.ErrorContains(t, err, c.wantErr) + }) + } +} diff --git a/internal/agent/conn/conn.go b/internal/agent/conn/conn.go new file mode 100644 index 0000000..dff5318 --- /dev/null +++ b/internal/agent/conn/conn.go @@ -0,0 +1,179 @@ +// Package conn keeps an agent connected to the master: it dials over pinned +// TLS, announces itself, sends its inventory, and streams changes until the +// connection drops, then reconnects with backoff. +package conn + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" + + "github.com/shcizo/package-updater/internal/agent/inventory" + "github.com/shcizo/package-updater/internal/pin" + "github.com/shcizo/package-updater/internal/protocol" +) + +// readLimit caps a single incoming message. The library default (32 KiB) +// is too small for the master's future job messages; matches the master. +const readLimit = 1 << 20 + +// dialTimeout bounds the TLS + WebSocket handshake so a master that accepts +// TCP but never answers does not hang the agent forever. +const dialTimeout = 30 * time.Second + +// Source supplies what the agent reports. *inventory.Collector satisfies it. +type Source interface { + Hello(ctx context.Context) (protocol.Hello, error) + Snapshot(ctx context.Context) ([]protocol.Service, error) +} + +// Config holds connection settings. Token is sent as a bearer header and must +// never be logged. +type Config struct { + URL string // wss://host:port/agent + Token string // AGENT_TOKEN + Fingerprint string // MASTER_KEY + Version string + MinBackoff time.Duration // default 1s + MaxBackoff time.Duration // default 30s +} + +// Agent is the agent's side of the master connection. +type Agent struct { + cfg Config + src Source + log *slog.Logger + changes chan struct{} +} + +// New returns an Agent. Call Run to connect. +func New(cfg Config, src Source, log *slog.Logger) *Agent { + if cfg.MinBackoff == 0 { + cfg.MinBackoff = time.Second + } + if cfg.MaxBackoff == 0 { + cfg.MaxBackoff = 30 * time.Second + } + return &Agent{cfg: cfg, src: src, log: log, changes: make(chan struct{}, 1)} +} + +// Notify tells the agent its inventory may have changed. It never blocks; +// several calls before the agent gets to them collapse into one refresh. +func (a *Agent) Notify() { + select { + case a.changes <- struct{}{}: + default: + } +} + +// Run keeps the agent connected until ctx ends, then returns nil. +func (a *Agent) Run(ctx context.Context) error { + backoff := a.cfg.MinBackoff + for { + connected, err := a.session(ctx) + if ctx.Err() != nil { + return nil + } + if connected { + backoff = a.cfg.MinBackoff + } + a.log.Warn("master_connection_lost", "error", err, "retry_in", backoff.String()) + select { + case <-ctx.Done(): + return nil + case <-time.After(backoff): + } + backoff = min(backoff*2, a.cfg.MaxBackoff) + } +} + +// session runs one connection until it fails. connected reports whether the +// hello was delivered, so Run resets its backoff only after real progress. +func (a *Agent) session(parent context.Context) (connected bool, err error) { + httpClient := &http.Client{Transport: &http.Transport{TLSClientConfig: pin.ClientConfig(a.cfg.Fingerprint)}} + // The dial context only bounds the handshake; coder/websocket does not + // tie the established connection to it, so cancelling afterwards is safe. + dialCtx, cancelDial := context.WithTimeout(parent, dialTimeout) + c, _, err := websocket.Dial(dialCtx, a.cfg.URL, &websocket.DialOptions{ + HTTPClient: httpClient, + HTTPHeader: http.Header{"Authorization": []string{"Bearer " + a.cfg.Token}}, + }) + cancelDial() + if err != nil { + return false, fmt.Errorf("dial master: %w", err) + } + defer c.CloseNow() + c.SetReadLimit(readLimit) + + ctx, cancel := context.WithCancel(parent) + defer cancel() + // The master sends nothing yet in this phase, but reading is how a + // dropped connection is noticed, so a reader runs for the whole session. + go func() { + defer cancel() + for { + if _, _, err := c.Read(ctx); err != nil { + return + } + } + }() + + hello, err := a.src.Hello(ctx) + if err != nil { + return false, err + } + hello.AgentVersion = a.cfg.Version + if err := send(ctx, c, protocol.TypeHello, hello); err != nil { + return false, err + } + + last, err := a.src.Snapshot(ctx) + if err != nil { + return true, err + } + if err := send(ctx, c, protocol.TypeSnapshot, protocol.Snapshot{Services: last}); err != nil { + return true, err + } + a.log.Info("master_connected", "services", len(last)) + + for { + select { + case <-ctx.Done(): + _ = c.Close(websocket.StatusNormalClosure, "") + return true, ctx.Err() + case <-a.changes: + next, err := a.src.Snapshot(ctx) + if err != nil { + return true, err + } + upserts, removes := inventory.Diff(last, next) + for _, s := range upserts { + if err := send(ctx, c, protocol.TypeUpsert, protocol.Upsert{Service: s}); err != nil { + return true, err + } + } + for _, k := range removes { + if err := send(ctx, c, protocol.TypeRemove, protocol.Remove{Key: k}); err != nil { + return true, err + } + } + last = next + } + } +} + +func send(ctx context.Context, c *websocket.Conn, typ string, payload any) error { + env, err := protocol.Encode(typ, payload) + if err != nil { + return err + } + if err := wsjson.Write(ctx, c, env); err != nil { + return fmt.Errorf("send %s: %w", typ, err) + } + return nil +} diff --git a/internal/agent/conn/conn_test.go b/internal/agent/conn/conn_test.go new file mode 100644 index 0000000..ac72afb --- /dev/null +++ b/internal/agent/conn/conn_test.go @@ -0,0 +1,200 @@ +package conn + +import ( + "context" + "crypto/tls" + "io" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/shcizo/package-updater/internal/pin" + "github.com/shcizo/package-updater/internal/protocol" +) + +type fakeMaster struct { + srv *httptest.Server + fingerprint string + envs chan protocol.Envelope + auth chan string + conns chan *websocket.Conn + attempts atomic.Int64 +} + +func newFakeMaster(t *testing.T) *fakeMaster { + t.Helper() + key, err := pin.GenerateKey() + require.NoError(t, err) + cert, err := pin.Certificate(key) + require.NoError(t, err) + fp, err := pin.Fingerprint(key.Public()) + require.NoError(t, err) + + fm := &fakeMaster{ + fingerprint: fp, + envs: make(chan protocol.Envelope, 100), + auth: make(chan string, 10), + conns: make(chan *websocket.Conn, 10), + } + fm.srv = httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fm.auth <- r.Header.Get("Authorization") + c, err := websocket.Accept(w, r, nil) + if err != nil { + return + } + c.SetReadLimit(1 << 20) + fm.conns <- c + for { + var env protocol.Envelope + if err := wsjson.Read(r.Context(), c, &env); err != nil { + return + } + fm.envs <- env + } + })) + fm.srv.Config.ConnState = func(_ net.Conn, state http.ConnState) { + if state == http.StateNew { + fm.attempts.Add(1) + } + } + fm.srv.TLS = &tls.Config{Certificates: []tls.Certificate{cert}} + fm.srv.StartTLS() + t.Cleanup(fm.srv.Close) + return fm +} + +func (fm *fakeMaster) url() string { return "wss" + strings.TrimPrefix(fm.srv.URL, "https") } + +func (fm *fakeMaster) next(t *testing.T) protocol.Envelope { + t.Helper() + select { + case e := <-fm.envs: + return e + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for a message from the agent") + return protocol.Envelope{} + } +} + +type fakeSource struct { + mu sync.Mutex + services []protocol.Service +} + +func (f *fakeSource) set(s ...protocol.Service) { + f.mu.Lock() + defer f.mu.Unlock() + f.services = s +} + +func (f *fakeSource) Hello(context.Context) (protocol.Hello, error) { + return protocol.Hello{Hostname: "node-2"}, nil +} + +func (f *fakeSource) Snapshot(context.Context) ([]protocol.Service, error) { + f.mu.Lock() + defer f.mu.Unlock() + return append([]protocol.Service(nil), f.services...), nil +} + +var ( + svcA = protocol.Service{Kind: protocol.KindContainer, ContainerName: "a", Image: "a:1", Status: "running"} + svcB = protocol.Service{Kind: protocol.KindContainer, ContainerName: "b", Image: "b:1", Status: "running"} +) + +func startAgent(t *testing.T, fm *fakeMaster, fingerprint string, src Source) *Agent { + t.Helper() + a := New(Config{ + URL: fm.url(), Token: "pua_test", Fingerprint: fingerprint, Version: "v-test", + MinBackoff: 10 * time.Millisecond, MaxBackoff: 50 * time.Millisecond, + }, src, slog.New(slog.NewTextHandler(io.Discard, nil))) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { _ = a.Run(ctx); close(done) }() + t.Cleanup(func() { cancel(); <-done }) + return a +} + +func TestAgent_SendsHelloThenSnapshotWithToken(t *testing.T) { + fm := newFakeMaster(t) + src := &fakeSource{} + src.set(svcA) + startAgent(t, fm, fm.fingerprint, src) + + assert.Equal(t, "Bearer pua_test", <-fm.auth) + hello := fm.next(t) + require.Equal(t, protocol.TypeHello, hello.Type) + h, err := protocol.Decode[protocol.Hello](hello) + require.NoError(t, err) + assert.Equal(t, protocol.Hello{Hostname: "node-2", AgentVersion: "v-test"}, h) + + snap := fm.next(t) + require.Equal(t, protocol.TypeSnapshot, snap.Type) + s, err := protocol.Decode[protocol.Snapshot](snap) + require.NoError(t, err) + assert.Equal(t, []protocol.Service{svcA}, s.Services) +} + +func TestAgent_NotifySendsDiff(t *testing.T) { + fm := newFakeMaster(t) + src := &fakeSource{} + src.set(svcA) + a := startAgent(t, fm, fm.fingerprint, src) + fm.next(t) // hello + fm.next(t) // snapshot + + src.set(svcB) + a.Notify() + + up := fm.next(t) + require.Equal(t, protocol.TypeUpsert, up.Type) + u, err := protocol.Decode[protocol.Upsert](up) + require.NoError(t, err) + assert.Equal(t, svcB, u.Service) + + rm := fm.next(t) + require.Equal(t, protocol.TypeRemove, rm.Type) + r, err := protocol.Decode[protocol.Remove](rm) + require.NoError(t, err) + assert.Equal(t, "container|a", r.Key) +} + +func TestAgent_ReconnectsAndResendsSnapshot(t *testing.T) { + fm := newFakeMaster(t) + src := &fakeSource{} + src.set(svcA) + startAgent(t, fm, fm.fingerprint, src) + fm.next(t) + fm.next(t) + + first := <-fm.conns + first.CloseNow() + + assert.Equal(t, protocol.TypeHello, fm.next(t).Type) + assert.Equal(t, protocol.TypeSnapshot, fm.next(t).Type) +} + +func TestAgent_RefusesMasterWithWrongKey(t *testing.T) { + fm := newFakeMaster(t) + other, err := pin.GenerateKey() + require.NoError(t, err) + wrong, err := pin.Fingerprint(other.Public()) + require.NoError(t, err) + + startAgent(t, fm, wrong, &fakeSource{}) + require.Eventually(t, func() bool { return fm.attempts.Load() >= 1 }, + 2*time.Second, 10*time.Millisecond, "the agent must actually try to connect") + time.Sleep(200 * time.Millisecond) + assert.Empty(t, fm.auth, "the token must never be sent to a server with the wrong key") +} diff --git a/internal/agent/inventory/collector.go b/internal/agent/inventory/collector.go new file mode 100644 index 0000000..2ee81d1 --- /dev/null +++ b/internal/agent/inventory/collector.go @@ -0,0 +1,216 @@ +// Package inventory turns what the local Docker daemon runs into the +// services an agent reports to the master. +package inventory + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/swarm" + "github.com/docker/docker/api/types/system" + + "github.com/shcizo/package-updater/internal/imageref" + "github.com/shcizo/package-updater/internal/protocol" +) + +const ( + labelProject = "com.docker.compose.project" + labelService = "com.docker.compose.service" + labelWorkingDir = "com.docker.compose.project.working_dir" + labelSwarmTask = "com.docker.swarm.service.id" +) + +// DockerClient is the subset of the Docker SDK the collector needs. +// *client.Client satisfies it. +type DockerClient interface { + ContainerList(ctx context.Context, opts container.ListOptions) ([]container.Summary, error) + ContainerInspect(ctx context.Context, containerID string) (container.InspectResponse, error) + ImageList(ctx context.Context, opts image.ListOptions) ([]image.Summary, error) + ServiceList(ctx context.Context, opts swarm.ServiceListOptions) ([]swarm.Service, error) + Info(ctx context.Context) (system.Info, error) +} + +// Collector reads the local daemon's inventory. +type Collector struct { + cli DockerClient + selfID string +} + +// New returns a Collector. selfID is a prefix of the agent's own container +// ID (inside a container, the hostname); that container is marked Self. +func New(cli DockerClient, selfID string) *Collector { + return &Collector{cli: cli, selfID: selfID} +} + +// Hello returns the machine facts sent on connect. AgentVersion is left for +// the caller to fill in. +func (c *Collector) Hello(ctx context.Context) (protocol.Hello, error) { + info, err := c.cli.Info(ctx) + if err != nil { + return protocol.Hello{}, fmt.Errorf("docker info: %w", err) + } + h := protocol.Hello{Hostname: info.Name, SwarmManager: info.Swarm.ControlAvailable} + if info.Swarm.Cluster != nil { + h.SwarmClusterID = info.Swarm.Cluster.ID + } + return h, nil +} + +// Snapshot returns every service on this machine, sorted by Key. +func (c *Collector) Snapshot(ctx context.Context) ([]protocol.Service, error) { + info, err := c.cli.Info(ctx) + if err != nil { + return nil, fmt.Errorf("docker info: %w", err) + } + containers, err := c.cli.ContainerList(ctx, container.ListOptions{All: true}) + if err != nil { + return nil, fmt.Errorf("docker container list: %w", err) + } + images, err := c.cli.ImageList(ctx, image.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("docker image list: %w", err) + } + repoDigests := make(map[string][]string, len(images)) + for _, img := range images { + repoDigests[img.ID] = img.RepoDigests + } + + byKey := make(map[string]protocol.Service) + for _, ct := range containers { + if _, isTask := ct.Labels[labelSwarmTask]; isTask { + continue + } + ref, err := c.imageRef(ctx, ct) + if err != nil { + return nil, err + } + svc := c.fromContainer(ct, ref, repoDigests[ct.ImageID]) + if prev, ok := byKey[svc.Key()]; ok { + svc = mergeReplicas(prev, svc) + } + byKey[svc.Key()] = svc + } + + if info.Swarm.ControlAvailable { + services, err := c.cli.ServiceList(ctx, swarm.ServiceListOptions{Status: true}) + if err != nil { + return nil, fmt.Errorf("docker service list: %w", err) + } + for _, s := range services { + if s.Spec.TaskTemplate.ContainerSpec == nil { + continue + } + svc := fromSwarmService(s) + byKey[svc.Key()] = svc + } + } + + out := make([]protocol.Service, 0, len(byKey)) + for _, s := range byKey { + out = append(out, s) + } + sort.Slice(out, func(i, j int) bool { return out[i].Key() < out[j].Key() }) + return out, nil +} + +// imageRef returns the image reference the container was created from. +// When the tag has since moved to another image, ContainerList reports the +// image ID ("sha256:...") instead, so the reference is read from the +// container's config. +func (c *Collector) imageRef(ctx context.Context, ct container.Summary) (string, error) { + if !strings.HasPrefix(ct.Image, "sha256:") { + return ct.Image, nil + } + insp, err := c.cli.ContainerInspect(ctx, ct.ID) + if err != nil { + return "", fmt.Errorf("docker container inspect %s: %w", ct.ID, err) + } + if insp.Config == nil { + return ct.Image, nil + } + return insp.Config.Image, nil +} + +func (c *Collector) fromContainer(ct container.Summary, ref string, repoDigests []string) protocol.Service { + svc := protocol.Service{ + Image: ref, + Digest: digestFor(ref, repoDigests), + Status: containerStatus(ct.State), + Self: c.selfID != "" && strings.HasPrefix(ct.ID, c.selfID), + } + project, service := ct.Labels[labelProject], ct.Labels[labelService] + if project != "" && service != "" { + svc.Kind = protocol.KindCompose + svc.ComposeProject = project + svc.ComposeService = service + svc.WorkingDir = ct.Labels[labelWorkingDir] + return svc + } + svc.Kind = protocol.KindContainer + svc.ContainerName = ct.ID + if len(ct.Names) > 0 { + svc.ContainerName = strings.TrimPrefix(ct.Names[0], "/") + } + return svc +} + +func fromSwarmService(s swarm.Service) protocol.Service { + ref := s.Spec.TaskTemplate.ContainerSpec.Image + return protocol.Service{ + Kind: protocol.KindSwarm, + SwarmService: s.Spec.Name, + Image: ref, + Digest: imageref.Parse(ref).Digest, + Status: swarmStatus(s.ServiceStatus), + } +} + +// mergeReplicas folds a second container of the same Compose service into +// the first: the service counts as running if any replica runs. +func mergeReplicas(a, b protocol.Service) protocol.Service { + if b.Status == "running" { + a.Status = "running" + } + a.Self = a.Self || b.Self + return a +} + +// digestFor returns the registry digest the container's image was pulled +// as. A reference that already pins a digest wins; otherwise the image's +// RepoDigests entry for the same repository is used. +func digestFor(ref string, repoDigests []string) string { + r := imageref.Parse(ref) + if r.Digest != "" { + return r.Digest + } + for _, rd := range repoDigests { + if p := imageref.Parse(rd); p.Repo == r.Repo { + return p.Digest + } + } + return "" +} + +func containerStatus(state string) string { + if state == "running" { + return "running" + } + return "stopped" +} + +func swarmStatus(st *swarm.ServiceStatus) string { + switch { + case st == nil: + return "unknown" + case st.DesiredTasks == 0: + return "stopped" + case st.RunningTasks >= st.DesiredTasks: + return "running" + default: + return "degraded" + } +} diff --git a/internal/agent/inventory/collector_test.go b/internal/agent/inventory/collector_test.go new file mode 100644 index 0000000..b300de5 --- /dev/null +++ b/internal/agent/inventory/collector_test.go @@ -0,0 +1,202 @@ +package inventory + +import ( + "context" + "errors" + "testing" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/swarm" + "github.com/docker/docker/api/types/system" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/shcizo/package-updater/internal/protocol" +) + +type fakeDocker struct { + containers []container.Summary + images []image.Summary + services []swarm.Service + info system.Info + containerErr error + serviceListCalls int + inspect map[string]container.InspectResponse + inspectErr error +} + +func (f *fakeDocker) ContainerList(context.Context, container.ListOptions) ([]container.Summary, error) { + return f.containers, f.containerErr +} + +func (f *fakeDocker) ContainerInspect(_ context.Context, id string) (container.InspectResponse, error) { + return f.inspect[id], f.inspectErr +} + +func (f *fakeDocker) ImageList(context.Context, image.ListOptions) ([]image.Summary, error) { + return f.images, nil +} + +func (f *fakeDocker) ServiceList(context.Context, swarm.ServiceListOptions) ([]swarm.Service, error) { + f.serviceListCalls++ + return f.services, nil +} + +func (f *fakeDocker) Info(context.Context) (system.Info, error) { return f.info, nil } + +func composeContainer(id, project, service, img, imageID, state string) container.Summary { + return container.Summary{ + ID: id, Image: img, ImageID: imageID, State: state, Names: []string{"/" + project + "-" + service + "-1"}, + Labels: map[string]string{ + labelProject: project, + labelService: service, + labelWorkingDir: "/home/shcizo/self-hosted/" + project, + }, + } +} + +func managerInfo() system.Info { + var info system.Info + info.Name = "swarm-mgr" + info.Swarm.ControlAvailable = true + info.Swarm.Cluster = &swarm.ClusterInfo{ID: "cluster-1"} + return info +} + +func TestSnapshot_ComposeContainerWithRepoDigest(t *testing.T) { + cli := &fakeDocker{ + containers: []container.Summary{composeContainer("abc123", "slusk", "web", "gitea.shcizo.se/shcizo/slusk:1.4.2", "sha256:img1", "running")}, + images: []image.Summary{{ID: "sha256:img1", RepoDigests: []string{"gitea.shcizo.se/shcizo/slusk@sha256:d1"}}}, + } + got, err := New(cli, "").Snapshot(context.Background()) + require.NoError(t, err) + assert.Equal(t, []protocol.Service{{ + Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web", + WorkingDir: "/home/shcizo/self-hosted/slusk", + Image: "gitea.shcizo.se/shcizo/slusk:1.4.2", Digest: "sha256:d1", Status: "running", + }}, got) +} + +func TestSnapshot_MovedTagUsesConfiguredReference(t *testing.T) { + ct := composeContainer("abc123", "slusk", "web", "sha256:img1", "sha256:img1", "running") + cli := &fakeDocker{ + containers: []container.Summary{ct}, + images: []image.Summary{{ID: "sha256:img1", RepoDigests: []string{"gitea.shcizo.se/shcizo/slusk@sha256:d1"}}}, + inspect: map[string]container.InspectResponse{ + "abc123": {Config: &container.Config{Image: "gitea.shcizo.se/shcizo/slusk:1.4.2"}}, + }, + } + got, err := New(cli, "").Snapshot(context.Background()) + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, "gitea.shcizo.se/shcizo/slusk:1.4.2", got[0].Image) + assert.Equal(t, "sha256:d1", got[0].Digest) +} + +func TestSnapshot_InspectErrorPropagates(t *testing.T) { + cli := &fakeDocker{ + containers: []container.Summary{composeContainer("abc123", "slusk", "web", "sha256:img1", "sha256:img1", "running")}, + inspectErr: errors.New("gone"), + } + _, err := New(cli, "").Snapshot(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "docker container inspect") +} + +func TestSnapshot_ReplicasMergeAndRunningWins(t *testing.T) { + cli := &fakeDocker{containers: []container.Summary{ + composeContainer("a1", "slusk", "worker", "slusk:1", "sha256:i", "exited"), + composeContainer("a2", "slusk", "worker", "slusk:1", "sha256:i", "running"), + }} + got, err := New(cli, "").Snapshot(context.Background()) + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, "running", got[0].Status) +} + +func TestSnapshot_PlainContainerAndSwarmTaskSkipped(t *testing.T) { + cli := &fakeDocker{containers: []container.Summary{ + {ID: "p1", Names: []string{"/adhoc"}, Image: "redis:alpine", State: "exited"}, + {ID: "t1", Names: []string{"/gitea_gitea.1.x"}, Image: "gitea/gitea:1.27.3", State: "running", + Labels: map[string]string{labelSwarmTask: "svc1"}}, + }} + got, err := New(cli, "").Snapshot(context.Background()) + require.NoError(t, err) + assert.Equal(t, []protocol.Service{{ + Kind: protocol.KindContainer, ContainerName: "adhoc", Image: "redis:alpine", Status: "stopped", + }}, got) +} + +func TestSnapshot_SelfFlag(t *testing.T) { + cli := &fakeDocker{containers: []container.Summary{ + composeContainer("0123456789abcdef", "updater-agent", "agent", "pu:1", "sha256:i", "running"), + }} + got, err := New(cli, "0123456789ab").Snapshot(context.Background()) + require.NoError(t, err) + require.Len(t, got, 1) + assert.True(t, got[0].Self) +} + +func TestSnapshot_SwarmServicesOnlyOnManager(t *testing.T) { + svc := swarm.Service{ + Spec: swarm.ServiceSpec{ + Annotations: swarm.Annotations{Name: "gitea_gitea"}, + TaskTemplate: swarm.TaskSpec{ContainerSpec: &swarm.ContainerSpec{Image: "gitea/gitea:1.27.3@sha256:87a6"}}, + }, + ServiceStatus: &swarm.ServiceStatus{RunningTasks: 1, DesiredTasks: 1}, + } + + worker := &fakeDocker{services: []swarm.Service{svc}} + got, err := New(worker, "").Snapshot(context.Background()) + require.NoError(t, err) + assert.Empty(t, got) + assert.Equal(t, 0, worker.serviceListCalls, "workers must not call the manager-only API") + + manager := &fakeDocker{services: []swarm.Service{svc}, info: managerInfo()} + got, err = New(manager, "").Snapshot(context.Background()) + require.NoError(t, err) + assert.Equal(t, []protocol.Service{{ + Kind: protocol.KindSwarm, SwarmService: "gitea_gitea", + Image: "gitea/gitea:1.27.3@sha256:87a6", Digest: "sha256:87a6", Status: "running", + }}, got) +} + +func TestSwarmStatus(t *testing.T) { + cases := []struct { + name string + in *swarm.ServiceStatus + want string + }{ + {"no status", nil, "unknown"}, + {"scaled to zero", &swarm.ServiceStatus{DesiredTasks: 0}, "stopped"}, + {"all running", &swarm.ServiceStatus{RunningTasks: 2, DesiredTasks: 2}, "running"}, + {"some running", &swarm.ServiceStatus{RunningTasks: 1, DesiredTasks: 2}, "degraded"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { assert.Equal(t, c.want, swarmStatus(c.in)) }) + } +} + +func TestSnapshot_SortedByKey(t *testing.T) { + cli := &fakeDocker{containers: []container.Summary{ + {ID: "2", Names: []string{"/zeta"}, Image: "z", State: "running"}, + {ID: "1", Names: []string{"/alpha"}, Image: "a", State: "running"}, + }} + got, err := New(cli, "").Snapshot(context.Background()) + require.NoError(t, err) + require.Len(t, got, 2) + assert.Equal(t, "alpha", got[0].ContainerName) +} + +func TestSnapshot_PropagatesDockerErrors(t *testing.T) { + cli := &fakeDocker{containerErr: errors.New("daemon down")} + _, err := New(cli, "").Snapshot(context.Background()) + assert.ErrorContains(t, err, "daemon down") +} + +func TestHello(t *testing.T) { + got, err := New(&fakeDocker{info: managerInfo()}, "").Hello(context.Background()) + require.NoError(t, err) + assert.Equal(t, protocol.Hello{Hostname: "swarm-mgr", SwarmManager: true, SwarmClusterID: "cluster-1"}, got) +} diff --git a/internal/agent/inventory/diff.go b/internal/agent/inventory/diff.go new file mode 100644 index 0000000..d3fb08a --- /dev/null +++ b/internal/agent/inventory/diff.go @@ -0,0 +1,25 @@ +package inventory + +import "github.com/shcizo/package-updater/internal/protocol" + +// Diff compares two snapshots. upserts holds services that are new or +// changed in next; removes holds the keys of services missing from next. +func Diff(prev, next []protocol.Service) (upserts []protocol.Service, removes []string) { + old := make(map[string]protocol.Service, len(prev)) + for _, s := range prev { + old[s.Key()] = s + } + seen := make(map[string]struct{}, len(next)) + for _, s := range next { + seen[s.Key()] = struct{}{} + if o, ok := old[s.Key()]; !ok || o != s { + upserts = append(upserts, s) + } + } + for _, s := range prev { + if _, ok := seen[s.Key()]; !ok { + removes = append(removes, s.Key()) + } + } + return upserts, removes +} diff --git a/internal/agent/inventory/diff_test.go b/internal/agent/inventory/diff_test.go new file mode 100644 index 0000000..a71140c --- /dev/null +++ b/internal/agent/inventory/diff_test.go @@ -0,0 +1,37 @@ +package inventory + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/shcizo/package-updater/internal/protocol" +) + +func TestDiff(t *testing.T) { + a := protocol.Service{Kind: protocol.KindContainer, ContainerName: "a", Image: "a:1", Status: "running"} + b := protocol.Service{Kind: protocol.KindContainer, ContainerName: "b", Image: "b:1", Status: "running"} + bStopped := b + bStopped.Status = "stopped" + c := protocol.Service{Kind: protocol.KindContainer, ContainerName: "c", Image: "c:1", Status: "running"} + + cases := []struct { + name string + prev, next []protocol.Service + wantUpserts []protocol.Service + wantRemoves []string + }{ + {"unchanged", []protocol.Service{a, b}, []protocol.Service{a, b}, nil, nil}, + {"added", []protocol.Service{a}, []protocol.Service{a, c}, []protocol.Service{c}, nil}, + {"changed", []protocol.Service{a, b}, []protocol.Service{a, bStopped}, []protocol.Service{bStopped}, nil}, + {"removed", []protocol.Service{a, b}, []protocol.Service{a}, nil, []string{"container|b"}}, + {"from empty", nil, []protocol.Service{a}, []protocol.Service{a}, nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ups, rms := Diff(tc.prev, tc.next) + assert.Equal(t, tc.wantUpserts, ups) + assert.Equal(t, tc.wantRemoves, rms) + }) + } +} diff --git a/internal/agent/inventory/watch.go b/internal/agent/inventory/watch.go new file mode 100644 index 0000000..4a4f5d4 --- /dev/null +++ b/internal/agent/inventory/watch.go @@ -0,0 +1,52 @@ +package inventory + +import ( + "context" + "fmt" + "time" + + "github.com/docker/docker/api/types/events" + "github.com/docker/docker/api/types/filters" +) + +// EventSource is the subset of the Docker SDK Watch needs. +// *client.Client satisfies it. +type EventSource interface { + Events(ctx context.Context, opts events.ListOptions) (<-chan events.Message, <-chan error) +} + +// Watch subscribes to container and service lifecycle events and calls +// onChange once after each burst has been quiet for the given duration. +// A Compose restart emits a dozen events; this turns them into one +// inventory refresh. Returns nil when ctx ends, or an error if the event +// stream breaks (for example when the daemon restarts) — the caller should +// resubscribe and refresh, since events may have been missed. +func Watch(ctx context.Context, src EventSource, quiet time.Duration, onChange func()) error { + args := filters.NewArgs( + filters.Arg("type", string(events.ContainerEventType)), + filters.Arg("type", string(events.ServiceEventType)), + ) + for _, action := range []string{"create", "start", "stop", "die", "destroy", "rename", "update", "remove"} { + args.Add("event", action) + } + msgs, errs := src.Events(ctx, events.ListOptions{Filters: args}) + + timer := time.NewTimer(quiet) + timer.Stop() + defer timer.Stop() + for { + select { + case <-ctx.Done(): + return nil + case err := <-errs: + if ctx.Err() != nil { + return nil + } + return fmt.Errorf("docker events: %w", err) + case <-msgs: + timer.Reset(quiet) + case <-timer.C: + onChange() + } + } +} diff --git a/internal/agent/inventory/watch_test.go b/internal/agent/inventory/watch_test.go new file mode 100644 index 0000000..df7c9f1 --- /dev/null +++ b/internal/agent/inventory/watch_test.go @@ -0,0 +1,52 @@ +package inventory + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" + + "github.com/docker/docker/api/types/events" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeEvents struct { + msgs chan events.Message + errs chan error +} + +func newFakeEvents() *fakeEvents { + return &fakeEvents{msgs: make(chan events.Message, 10), errs: make(chan error, 1)} +} + +func (f *fakeEvents) Events(context.Context, events.ListOptions) (<-chan events.Message, <-chan error) { + return f.msgs, f.errs +} + +func TestWatch_DebouncesBursts(t *testing.T) { + src := newFakeEvents() + var calls atomic.Int32 + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- Watch(ctx, src, 30*time.Millisecond, func() { calls.Add(1) }) }() + + for range 3 { + src.msgs <- events.Message{Type: events.ContainerEventType, Action: "start"} + } + require.Eventually(t, func() bool { return calls.Load() == 1 }, time.Second, 5*time.Millisecond) + + src.msgs <- events.Message{Type: events.ServiceEventType, Action: "update"} + require.Eventually(t, func() bool { return calls.Load() == 2 }, time.Second, 5*time.Millisecond) + + cancel() + assert.NoError(t, <-done) +} + +func TestWatch_ReturnsStreamError(t *testing.T) { + src := newFakeEvents() + src.errs <- errors.New("daemon restarted") + err := Watch(context.Background(), src, time.Millisecond, func() {}) + assert.ErrorContains(t, err, "daemon restarted") +} diff --git a/internal/agenttoken/agenttoken.go b/internal/agenttoken/agenttoken.go new file mode 100644 index 0000000..42ae710 --- /dev/null +++ b/internal/agenttoken/agenttoken.go @@ -0,0 +1,31 @@ +// Package agenttoken creates and hashes the per-agent tokens agents present +// to the master. +package agenttoken + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" +) + +const prefix = "pua_" + +// Generate returns a new random agent token. Show it to the operator once; +// store only Hash(token). +func Generate() (string, error) { + var b [32]byte + if _, err := rand.Read(b[:]); err != nil { + return "", fmt.Errorf("generate agent token: %w", err) + } + return prefix + base64.RawURLEncoding.EncodeToString(b[:]), nil +} + +// Hash returns the value stored for token. A plain SHA-256 is sufficient +// because tokens carry 256 bits of randomness; salting protects low-entropy +// passwords, which these are not. +func Hash(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/agenttoken/agenttoken_test.go b/internal/agenttoken/agenttoken_test.go new file mode 100644 index 0000000..3819bb7 --- /dev/null +++ b/internal/agenttoken/agenttoken_test.go @@ -0,0 +1,25 @@ +package agenttoken + +import ( + "regexp" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerate(t *testing.T) { + a, err := Generate() + require.NoError(t, err) + b, err := Generate() + require.NoError(t, err) + + assert.Regexp(t, regexp.MustCompile(`^pua_[A-Za-z0-9_-]{43}$`), a) + assert.NotEqual(t, a, b) +} + +func TestHash(t *testing.T) { + assert.Equal(t, Hash("pua_x"), Hash("pua_x")) + assert.NotEqual(t, Hash("pua_x"), Hash("pua_y")) + assert.Regexp(t, regexp.MustCompile(`^[0-9a-f]{64}$`), Hash("pua_x")) +} diff --git a/internal/e2e/agent_master_test.go b/internal/e2e/agent_master_test.go new file mode 100644 index 0000000..36a071b --- /dev/null +++ b/internal/e2e/agent_master_test.go @@ -0,0 +1,230 @@ +// Package e2e runs a real agent connection against a real hub over TLS with +// a pinned key, in one process, with in-memory storage and fake Docker. +package e2e + +import ( + "context" + "crypto/tls" + "io" + "log" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/shcizo/package-updater/internal/agent/conn" + "github.com/shcizo/package-updater/internal/agenttoken" + "github.com/shcizo/package-updater/internal/master/events" + "github.com/shcizo/package-updater/internal/master/hub" + "github.com/shcizo/package-updater/internal/master/model" + "github.com/shcizo/package-updater/internal/pin" + "github.com/shcizo/package-updater/internal/protocol" +) + +type memStore struct { + mu sync.Mutex + agents map[string]model.Agent + lookups int + snapshots int + services map[string]protocol.Service +} + +func (m *memStore) AgentByTokenHash(_ context.Context, hash string) (model.Agent, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.lookups++ + a, ok := m.agents[hash] + if !ok { + return model.Agent{}, model.ErrNotFound + } + return a, nil +} + +func (m *memStore) RecordHello(context.Context, int64, protocol.Hello, time.Time) error { return nil } + +func (m *memStore) ApplySnapshot(_ context.Context, _ int64, _ protocol.Hello, s []protocol.Service, _ time.Time) error { + m.mu.Lock() + defer m.mu.Unlock() + m.snapshots++ + m.services = map[string]protocol.Service{} + for _, x := range s { + m.services[x.Key()] = x + } + return nil +} + +func (m *memStore) UpsertService(_ context.Context, _ int64, _ string, s protocol.Service, _ time.Time) error { + m.mu.Lock() + defer m.mu.Unlock() + m.services[s.Key()] = s + return nil +} + +func (m *memStore) RemoveService(_ context.Context, _ int64, _, key string, _ time.Time) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.services, key) + return nil +} + +func (m *memStore) MarkSeen(context.Context, int64, time.Time) error { return nil } + +func (m *memStore) keys() []string { + m.mu.Lock() + defer m.mu.Unlock() + var out []string + for k := range m.services { + out = append(out, k) + } + return out +} + +type staticSource struct { + mu sync.Mutex + services []protocol.Service +} + +func (s *staticSource) Hello(context.Context) (protocol.Hello, error) { + return protocol.Hello{Hostname: "node-2"}, nil +} + +func (s *staticSource) Snapshot(context.Context) ([]protocol.Service, error) { + s.mu.Lock() + defer s.mu.Unlock() + return append([]protocol.Service(nil), s.services...), nil +} + +func (s *staticSource) set(v ...protocol.Service) { + s.mu.Lock() + defer s.mu.Unlock() + s.services = v +} + +// testMaster is a running TLS test server with the hub mounted on /agent. +type testMaster struct { + url string + fingerprint string + hub *hub.Hub + attempts *atomic.Int64 // TCP connections accepted by the server +} + +func startMaster(t *testing.T, store *memStore) testMaster { + t.Helper() + key, err := pin.GenerateKey() + require.NoError(t, err) + cert, err := pin.Certificate(key) + require.NoError(t, err) + fingerprint, err := pin.Fingerprint(key.Public()) + require.NoError(t, err) + + h := hub.New(store, events.NewBroker(), slog.New(slog.NewTextHandler(io.Discard, nil))) + mux := http.NewServeMux() + mux.Handle("GET /agent", h) + attempts := &atomic.Int64{} + srv := httptest.NewUnstartedServer(mux) + srv.Config.ErrorLog = log.New(io.Discard, "", 0) + srv.Config.ConnState = func(_ net.Conn, s http.ConnState) { + if s == http.StateNew { + attempts.Add(1) + } + } + srv.TLS = &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS13} + srv.StartTLS() + t.Cleanup(srv.Close) + return testMaster{url: "wss" + strings.TrimPrefix(srv.URL, "https") + "/agent", fingerprint: fingerprint, hub: h, attempts: attempts} +} + +func runAgent(t *testing.T, url, fingerprint, token string, src conn.Source) *conn.Agent { + t.Helper() + a := conn.New(conn.Config{URL: url, Token: token, Fingerprint: fingerprint, Version: "e2e", + MinBackoff: 10 * time.Millisecond, MaxBackoff: 50 * time.Millisecond, + }, src, slog.New(slog.NewTextHandler(io.Discard, nil))) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { _ = a.Run(ctx); close(done) }() + t.Cleanup(func() { cancel(); <-done }) + return a +} + +var ( + web = protocol.Service{Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web", Image: "slusk:1", Status: "running"} + cache = protocol.Service{Kind: protocol.KindContainer, ContainerName: "cache", Image: "redis:alpine", Status: "running"} +) + +func TestInventoryFlowsFromAgentToStore(t *testing.T) { + store := &memStore{agents: map[string]model.Agent{agenttoken.Hash("pua_ok"): {ID: 2, Name: "node-2"}}} + m := startMaster(t, store) + src := &staticSource{} + src.set(web) + agent := runAgent(t, m.url, m.fingerprint, "pua_ok", src) + + require.Eventually(t, func() bool { return len(store.keys()) == 1 }, 2*time.Second, 10*time.Millisecond) + + src.set(cache) + agent.Notify() + require.Eventually(t, func() bool { + k := store.keys() + return len(k) == 1 && k[0] == "container|cache" + }, 2*time.Second, 10*time.Millisecond) +} + +func TestAgentWithWrongKeyNeverReachesHub(t *testing.T) { + store := &memStore{agents: map[string]model.Agent{agenttoken.Hash("pua_ok"): {ID: 2, Name: "node-2"}}} + m := startMaster(t, store) + other, err := pin.GenerateKey() + require.NoError(t, err) + wrong, err := pin.Fingerprint(other.Public()) + require.NoError(t, err) + + runAgent(t, m.url, wrong, "pua_ok", &staticSource{}) + require.Eventually(t, func() bool { return m.attempts.Load() >= 1 }, 2*time.Second, 10*time.Millisecond, "the agent must actually try to connect") + time.Sleep(200 * time.Millisecond) + store.mu.Lock() + defer store.mu.Unlock() + assert.Zero(t, store.lookups, "TLS must fail before the token is ever presented") +} + +func TestRevokedAgentIsRejected(t *testing.T) { + store := &memStore{agents: map[string]model.Agent{ + agenttoken.Hash("pua_old"): {ID: 3, Name: "old", RevokedAt: func() *time.Time { n := time.Now(); return &n }()}, + }} + m := startMaster(t, store) + src := &staticSource{} + src.set(web) + runAgent(t, m.url, m.fingerprint, "pua_old", src) + + require.Eventually(t, func() bool { + store.mu.Lock() + defer store.mu.Unlock() + return store.lookups >= 2 + }, 2*time.Second, 10*time.Millisecond, "the agent keeps retrying") + assert.Empty(t, store.keys()) +} + +func TestAgentReconnectsAndResendsSnapshot(t *testing.T) { + store := &memStore{agents: map[string]model.Agent{agenttoken.Hash("pua_ok"): {ID: 2, Name: "node-2"}}} + m := startMaster(t, store) + src := &staticSource{} + src.set(web) + runAgent(t, m.url, m.fingerprint, "pua_ok", src) + + snapshots := func() int { + store.mu.Lock() + defer store.mu.Unlock() + return store.snapshots + } + require.Eventually(t, func() bool { return snapshots() >= 1 }, 2*time.Second, 10*time.Millisecond) + + m.hub.Disconnect(2) + + require.Eventually(t, func() bool { return snapshots() >= 2 }, 2*time.Second, 10*time.Millisecond, "agent reconnects and sends a fresh snapshot") + assert.Len(t, store.keys(), 1) +} diff --git a/internal/imageref/imageref.go b/internal/imageref/imageref.go new file mode 100644 index 0000000..cd268b1 --- /dev/null +++ b/internal/imageref/imageref.go @@ -0,0 +1,43 @@ +// Package imageref splits container image references into repository, tag +// and digest. +package imageref + +import "strings" + +// Ref is an image reference split into its parts. Tag and Digest are empty +// when the reference has none. +type Ref struct { + Repo string + Tag string + Digest string +} + +// Parse splits ref into repository, tag and digest. A colon before the last +// "/" belongs to a registry host and port ("localhost:5000/foo"), not a tag. +func Parse(ref string) Ref { + var r Ref + if at := strings.Index(ref, "@"); at >= 0 { + r.Digest = ref[at+1:] + ref = ref[:at] + } + slash := strings.LastIndex(ref, "/") + last := ref[slash+1:] + if colon := strings.Index(last, ":"); colon >= 0 { + r.Tag = last[colon+1:] + ref = ref[:slash+1+colon] + } + r.Repo = ref + return r +} + +// String reassembles the reference as repo[:tag][@digest]. +func (r Ref) String() string { + s := r.Repo + if r.Tag != "" { + s += ":" + r.Tag + } + if r.Digest != "" { + s += "@" + r.Digest + } + return s +} diff --git a/internal/imageref/imageref_test.go b/internal/imageref/imageref_test.go new file mode 100644 index 0000000..c86414b --- /dev/null +++ b/internal/imageref/imageref_test.go @@ -0,0 +1,29 @@ +package imageref + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParse(t *testing.T) { + cases := []struct { + in string + want Ref + }{ + {"postgres", Ref{Repo: "postgres"}}, + {"postgres:17.10", Ref{Repo: "postgres", Tag: "17.10"}}, + {"gitea/gitea:1.27.3@sha256:87a6", Ref{Repo: "gitea/gitea", Tag: "1.27.3", Digest: "sha256:87a6"}}, + {"lscr.io/linuxserver/jellyfin@sha256:0f42", Ref{Repo: "lscr.io/linuxserver/jellyfin", Digest: "sha256:0f42"}}, + {"localhost:5000/foo", Ref{Repo: "localhost:5000/foo"}}, + {"localhost:5000/foo:v1", Ref{Repo: "localhost:5000/foo", Tag: "v1"}}, + {"localhost:5000/foo:v1@sha256:ab", Ref{Repo: "localhost:5000/foo", Tag: "v1", Digest: "sha256:ab"}}, + } + for _, c := range cases { + t.Run(c.in, func(t *testing.T) { + got := Parse(c.in) + assert.Equal(t, c.want, got) + assert.Equal(t, c.in, got.String(), "String must round-trip") + }) + } +} diff --git a/internal/master/config/config.go b/internal/master/config/config.go new file mode 100644 index 0000000..0739ec1 --- /dev/null +++ b/internal/master/config/config.go @@ -0,0 +1,68 @@ +// Package config loads the master's configuration from environment variables. +package config + +import ( + "fmt" + "os" + "strings" +) + +// Config holds the master's runtime configuration. +type Config struct { + DatabaseURL string + PublicURL string // where people reach the UI, without trailing slash + AgentURL string // what agents dial, shown in the add-agent snippet + AgentImage string + OIDCIssuer string + OIDCClientID string + OIDCClientSecret string + AllowedGroup string + Port string + AgentPort string + LogLevel string +} + +// Load reads and validates the master configuration, failing fast on +// anything missing so a half-configured master never starts. +func Load() (*Config, error) { + cfg := &Config{ + DatabaseURL: os.Getenv("DATABASE_URL"), + PublicURL: strings.TrimSuffix(os.Getenv("PUBLIC_URL"), "/"), + AgentURL: os.Getenv("AGENT_URL"), + AgentImage: getenvDefault("AGENT_IMAGE", "gitea.shcizo.se/shcizo/package-updater:latest"), + OIDCIssuer: os.Getenv("OIDC_ISSUER"), + OIDCClientID: os.Getenv("OIDC_CLIENT_ID"), + OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"), + AllowedGroup: os.Getenv("ALLOWED_GROUP"), + Port: getenvDefault("PORT", "8080"), + AgentPort: getenvDefault("AGENT_PORT", "8090"), + LogLevel: getenvDefault("LOG_LEVEL", "info"), + } + for _, r := range []struct{ name, value string }{ + {"DATABASE_URL", cfg.DatabaseURL}, + {"PUBLIC_URL", cfg.PublicURL}, + {"AGENT_URL", cfg.AgentURL}, + {"OIDC_ISSUER", cfg.OIDCIssuer}, + {"OIDC_CLIENT_ID", cfg.OIDCClientID}, + {"OIDC_CLIENT_SECRET", cfg.OIDCClientSecret}, + {"ALLOWED_GROUP", cfg.AllowedGroup}, + } { + if r.value == "" { + return nil, fmt.Errorf("%s is required", r.name) + } + } + if !strings.HasPrefix(cfg.PublicURL, "http://") && !strings.HasPrefix(cfg.PublicURL, "https://") { + return nil, fmt.Errorf("PUBLIC_URL must start with http:// or https://") + } + if !strings.HasPrefix(cfg.AgentURL, "wss://") { + return nil, fmt.Errorf("AGENT_URL must start with wss://") + } + return cfg, nil +} + +func getenvDefault(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/internal/master/config/config_test.go b/internal/master/config/config_test.go new file mode 100644 index 0000000..3ef5614 --- /dev/null +++ b/internal/master/config/config_test.go @@ -0,0 +1,64 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var required = map[string]string{ + "DATABASE_URL": "postgres://u:p@db/pu", + "PUBLIC_URL": "https://updater.example.com", + "AGENT_URL": "wss://10.0.0.5:8090/agent", + "OIDC_ISSUER": "https://auth.example.com/application/o/pu/", + "OIDC_CLIENT_ID": "id", + "OIDC_CLIENT_SECRET": "secret", + "ALLOWED_GROUP": "updater-admins", +} + +func setAll(t *testing.T) { + for k, v := range required { + t.Setenv(k, v) + } + for _, k := range []string{"PORT", "AGENT_PORT", "AGENT_IMAGE", "LOG_LEVEL"} { + t.Setenv(k, "") + } +} + +func TestLoad_Defaults(t *testing.T) { + setAll(t) + cfg, err := Load() + require.NoError(t, err) + assert.Equal(t, "8080", cfg.Port) + assert.Equal(t, "8090", cfg.AgentPort) + assert.Equal(t, "gitea.shcizo.se/shcizo/package-updater:latest", cfg.AgentImage) + assert.Equal(t, "info", cfg.LogLevel) + assert.Equal(t, "updater-admins", cfg.AllowedGroup) +} + +func TestLoad_EachRequiredVariable(t *testing.T) { + for key := range required { + t.Run(key, func(t *testing.T) { + setAll(t) + t.Setenv(key, "") + _, err := Load() + assert.ErrorContains(t, err, key+" is required") + }) + } +} + +func TestLoad_URLFormats(t *testing.T) { + cases := []struct{ key, value, wantErr string }{ + {"PUBLIC_URL", "updater.example.com", "PUBLIC_URL must start with http:// or https://"}, + {"AGENT_URL", "ws://10.0.0.5:8090/agent", "AGENT_URL must start with wss://"}, + } + for _, c := range cases { + t.Run(c.key, func(t *testing.T) { + setAll(t) + t.Setenv(c.key, c.value) + _, err := Load() + assert.ErrorContains(t, err, c.wantErr) + }) + } +} diff --git a/internal/master/events/broker.go b/internal/master/events/broker.go new file mode 100644 index 0000000..994fe51 --- /dev/null +++ b/internal/master/events/broker.go @@ -0,0 +1,56 @@ +// Package events fans out "something changed" signals from the agent hub to +// open browser streams. +package events + +import "sync" + +// Topic names what changed. Subscribers refetch that part of the UI. +type Topic string + +// Topics. +const ( + Services Topic = "services" + Agents Topic = "agents" +) + +// Broker is an in-process publish/subscribe hub. +type Broker struct { + mu sync.Mutex + subs map[chan Topic]struct{} +} + +// NewBroker returns an empty Broker. +func NewBroker() *Broker { + return &Broker{subs: make(map[chan Topic]struct{})} +} + +// Subscribe returns a channel of topics and a cancel function that must be +// called when the subscriber goes away. Cancel is safe to call twice. +func (b *Broker) Subscribe() (<-chan Topic, func()) { + ch := make(chan Topic, 16) + b.mu.Lock() + b.subs[ch] = struct{}{} + b.mu.Unlock() + var once sync.Once + return ch, func() { + once.Do(func() { + b.mu.Lock() + delete(b.subs, ch) + b.mu.Unlock() + }) + } +} + +// Publish signals t to every subscriber. A subscriber whose buffer is full is +// skipped rather than blocking the agent hub; it already has signals queued +// and will refetch current state when it processes them. +func (b *Broker) Publish(t Topic) { + b.mu.Lock() + defer b.mu.Unlock() + for ch := range b.subs { + select { + case ch <- t: + default: + } + } +} diff --git a/internal/master/events/broker_test.go b/internal/master/events/broker_test.go new file mode 100644 index 0000000..7613051 --- /dev/null +++ b/internal/master/events/broker_test.go @@ -0,0 +1,48 @@ +package events + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestBroker_DeliversToAllSubscribers(t *testing.T) { + b := NewBroker() + a, cancelA := b.Subscribe() + defer cancelA() + c, cancelC := b.Subscribe() + defer cancelC() + + b.Publish(Services) + assert.Equal(t, Services, <-a) + assert.Equal(t, Services, <-c) +} + +func TestBroker_CancelStopsDelivery(t *testing.T) { + b := NewBroker() + ch, cancel := b.Subscribe() + cancel() + cancel() // safe to call twice + b.Publish(Agents) + assert.Empty(t, ch) +} + +func TestBroker_SlowSubscriberDoesNotBlockPublish(t *testing.T) { + b := NewBroker() + _, cancel := b.Subscribe() + defer cancel() + + done := make(chan struct{}) + go func() { + defer close(done) + for range 1000 { + b.Publish(Services) // must return even though nobody reads + } + }() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("Publish blocked on a slow subscriber") + } +} diff --git a/internal/master/hub/hub.go b/internal/master/hub/hub.go new file mode 100644 index 0000000..8270976 --- /dev/null +++ b/internal/master/hub/hub.go @@ -0,0 +1,231 @@ +// Package hub accepts agent connections, authenticates them, and turns their +// messages into stored inventory and live UI signals. +package hub + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "strings" + "sync" + "time" + + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" + + "github.com/shcizo/package-updater/internal/agenttoken" + "github.com/shcizo/package-updater/internal/master/events" + "github.com/shcizo/package-updater/internal/master/model" + "github.com/shcizo/package-updater/internal/protocol" +) + +// readLimit caps one incoming message. The library default of 32 KiB is too +// small for a full snapshot from a busy machine. +const readLimit = 1 << 20 + +// Store is the storage the hub writes to. *store.Store satisfies it. +type Store interface { + AgentByTokenHash(ctx context.Context, hash string) (model.Agent, error) + RecordHello(ctx context.Context, agentID int64, h protocol.Hello, at time.Time) error + ApplySnapshot(ctx context.Context, agentID int64, h protocol.Hello, services []protocol.Service, at time.Time) error + UpsertService(ctx context.Context, agentID int64, clusterID string, svc protocol.Service, at time.Time) error + RemoveService(ctx context.Context, agentID int64, clusterID, agentKey string, at time.Time) error + MarkSeen(ctx context.Context, agentID int64, at time.Time) error +} + +// Publisher receives "something changed" signals. *events.Broker satisfies it. +type Publisher interface { + Publish(t events.Topic) +} + +// Hub is the master's endpoint for agents. +type Hub struct { + store Store + pub Publisher + log *slog.Logger + now func() time.Time + + mu sync.Mutex + conns map[int64]*websocket.Conn +} + +// New returns a Hub. Mount it at GET /agent on the TLS listener. +func New(store Store, pub Publisher, log *slog.Logger) *Hub { + return &Hub{store: store, pub: pub, log: log, now: time.Now, conns: make(map[int64]*websocket.Conn)} +} + +// ServeHTTP authenticates the agent's token and runs its connection. The token +// is only ever hashed; it is never logged or stored. +func (h *Hub) ServeHTTP(w http.ResponseWriter, r *http.Request) { + token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") + if !ok || token == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + agent, err := h.store.AgentByTokenHash(r.Context(), agenttoken.Hash(token)) + if err != nil && !errors.Is(err, model.ErrNotFound) { + h.log.Error("agent_lookup_failed", "error", err) + } + if err != nil || agent.RevokedAt != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + c, err := websocket.Accept(w, r, nil) + if err != nil { + return + } + c.SetReadLimit(readLimit) + h.register(agent, r.RemoteAddr, c) + defer h.unregister(agent.ID, c) + + // Revoking commits revoked_at before calling Disconnect. Re-checking after + // register closes the window where a revoke lands between the first lookup + // and register, when Disconnect would find nothing to cut. + again, err := h.store.AgentByTokenHash(r.Context(), agenttoken.Hash(token)) + if err != nil || again.ID != agent.ID || again.RevokedAt != nil { + _ = c.Close(websocket.StatusPolicyViolation, "revoked") + return + } + + s := &session{hub: h, agent: agent} + err = s.run(r.Context(), c) + h.log.Info("agent_disconnected", "agent", agent.Name, "reason", err) +} + +// Online reports whether the agent has a live connection. +func (h *Hub) Online(agentID int64) bool { + h.mu.Lock() + defer h.mu.Unlock() + _, ok := h.conns[agentID] + return ok +} + +// Disconnect closes the agent's live connection, if any. Call it after +// revoking the agent so the revocation takes effect immediately. +func (h *Hub) Disconnect(agentID int64) { + h.mu.Lock() + c := h.conns[agentID] + h.mu.Unlock() + if c != nil { + c.CloseNow() + } +} + +// register makes c the agent's live connection, closing any older one. A +// replacement is logged: it is normal after a network blip, but repeated +// replacements mean two agents share a token. +func (h *Hub) register(agent model.Agent, remoteAddr string, c *websocket.Conn) { + h.mu.Lock() + old := h.conns[agent.ID] + h.conns[agent.ID] = c + h.mu.Unlock() + if old != nil { + h.log.Warn("agent_replaced", "agent_id", agent.ID, "agent", agent.Name, "remote_addr", remoteAddr) + old.CloseNow() + } +} + +func (h *Hub) unregister(id int64, c *websocket.Conn) { + h.mu.Lock() + current := h.conns[id] == c + if current { + delete(h.conns, id) + } + h.mu.Unlock() + if !current { + return // replaced by a newer connection; that one owns the state + } + if err := h.store.MarkSeen(context.Background(), id, h.now()); err != nil { + h.log.Error("mark_seen_failed", "agent_id", id, "error", err) + } + h.pub.Publish(events.Agents) +} + +// session is one agent connection's state. +type session struct { + hub *Hub + agent model.Agent + hello *protocol.Hello +} + +// swarmManager reports whether this agent may report Swarm services. The same +// rule gates snapshots, live upserts and live removes. +func (s *session) swarmManager() bool { + return s.hello.SwarmManager && s.hello.SwarmClusterID != "" +} + +func (s *session) run(ctx context.Context, c *websocket.Conn) error { + for { + var env protocol.Envelope + if err := wsjson.Read(ctx, c, &env); err != nil { + return err + } + if s.hello == nil && env.Type != protocol.TypeHello { + _ = c.Close(websocket.StatusPolicyViolation, "hello required first") + return fmt.Errorf("%q before hello", env.Type) + } + if err := s.handle(ctx, env); err != nil { + _ = c.Close(websocket.StatusPolicyViolation, "bad message") + return err + } + } +} + +func (s *session) handle(ctx context.Context, env protocol.Envelope) error { + h := s.hub + switch env.Type { + case protocol.TypeHello: + if s.hello != nil { + return errors.New("duplicate hello") + } + v, err := protocol.Decode[protocol.Hello](env) + if err != nil { + return err + } + if err := h.store.RecordHello(ctx, s.agent.ID, v, h.now()); err != nil { + return err + } + s.hello = &v + h.log.Info("agent_connected", "agent", s.agent.Name, "hostname", v.Hostname, "swarm_manager", v.SwarmManager) + h.pub.Publish(events.Agents) + case protocol.TypeSnapshot: + v, err := protocol.Decode[protocol.Snapshot](env) + if err != nil { + return err + } + if err := h.store.ApplySnapshot(ctx, s.agent.ID, *s.hello, v.Services, h.now()); err != nil { + return err + } + h.pub.Publish(events.Services) + case protocol.TypeUpsert: + v, err := protocol.Decode[protocol.Upsert](env) + if err != nil { + return err + } + if v.Service.Kind == protocol.KindSwarm && !s.swarmManager() { + return nil + } + if err := h.store.UpsertService(ctx, s.agent.ID, s.hello.SwarmClusterID, v.Service, h.now()); err != nil { + return err + } + h.pub.Publish(events.Services) + case protocol.TypeRemove: + v, err := protocol.Decode[protocol.Remove](env) + if err != nil { + return err + } + if strings.HasPrefix(v.Key, string(protocol.KindSwarm)+"|") && !s.swarmManager() { + return nil + } + if err := h.store.RemoveService(ctx, s.agent.ID, s.hello.SwarmClusterID, v.Key, h.now()); err != nil { + return err + } + h.pub.Publish(events.Services) + default: + return fmt.Errorf("unknown message type %q", env.Type) + } + return nil +} diff --git a/internal/master/hub/hub_test.go b/internal/master/hub/hub_test.go new file mode 100644 index 0000000..756a986 --- /dev/null +++ b/internal/master/hub/hub_test.go @@ -0,0 +1,416 @@ +package hub + +import ( + "bytes" + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/shcizo/package-updater/internal/agenttoken" + "github.com/shcizo/package-updater/internal/master/events" + "github.com/shcizo/package-updater/internal/master/model" + "github.com/shcizo/package-updater/internal/protocol" +) + +type fakeStore struct { + mu sync.Mutex + agents map[string]model.Agent // by token hash + hellos []protocol.Hello + snapshots [][]protocol.Service + upserts []string // "clusterID|key" + removes []string // "clusterID|key" + seen []int64 + lookups int + // revokeFromLookup, when > 0, makes AgentByTokenHash return the agent as + // revoked from that (1-based) lookup onwards. + revokeFromLookup int +} + +func newFakeStore(agents ...model.Agent) *fakeStore { + f := &fakeStore{agents: map[string]model.Agent{}} + for _, a := range agents { + f.agents[agenttoken.Hash("pua_"+a.Name)] = a + } + return f +} + +func (f *fakeStore) AgentByTokenHash(_ context.Context, hash string) (model.Agent, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.lookups++ + a, ok := f.agents[hash] + if !ok { + return model.Agent{}, model.ErrNotFound + } + if f.revokeFromLookup > 0 && f.lookups >= f.revokeFromLookup { + a.RevokedAt = ptr(time.Now()) + } + return a, nil +} + +func (f *fakeStore) RecordHello(_ context.Context, _ int64, h protocol.Hello, _ time.Time) error { + f.mu.Lock() + defer f.mu.Unlock() + f.hellos = append(f.hellos, h) + return nil +} + +func (f *fakeStore) ApplySnapshot(_ context.Context, _ int64, _ protocol.Hello, s []protocol.Service, _ time.Time) error { + f.mu.Lock() + defer f.mu.Unlock() + f.snapshots = append(f.snapshots, s) + return nil +} + +func (f *fakeStore) UpsertService(_ context.Context, _ int64, cluster string, s protocol.Service, _ time.Time) error { + f.mu.Lock() + defer f.mu.Unlock() + f.upserts = append(f.upserts, cluster+"|"+s.Key()) + return nil +} + +func (f *fakeStore) RemoveService(_ context.Context, _ int64, cluster, key string, _ time.Time) error { + f.mu.Lock() + defer f.mu.Unlock() + f.removes = append(f.removes, cluster+"|"+key) + return nil +} + +func (f *fakeStore) MarkSeen(_ context.Context, id int64, _ time.Time) error { + f.mu.Lock() + defer f.mu.Unlock() + f.seen = append(f.seen, id) + return nil +} + +func (f *fakeStore) count(get func(*fakeStore) int) func() int { + return func() int { + f.mu.Lock() + defer f.mu.Unlock() + return get(f) + } +} + +type fakePub struct { + mu sync.Mutex + topics []events.Topic +} + +func (p *fakePub) Publish(t events.Topic) { + p.mu.Lock() + defer p.mu.Unlock() + p.topics = append(p.topics, t) +} + +func (p *fakePub) has(t events.Topic) bool { + p.mu.Lock() + defer p.mu.Unlock() + for _, x := range p.topics { + if x == t { + return true + } + } + return false +} + +// reset forgets every topic published so far. +func (p *fakePub) reset() { + p.mu.Lock() + defer p.mu.Unlock() + p.topics = nil +} + +var ( + node2 = model.Agent{ID: 2, Name: "node-2"} + mgr = model.Agent{ID: 1, Name: "swarm-mgr"} + revoked = model.Agent{ID: 3, Name: "old", RevokedAt: ptr(time.Now())} +) + +func ptr[T any](v T) *T { return &v } + +func newHub(t *testing.T, store *fakeStore) (*Hub, *fakePub, *httptest.Server) { + t.Helper() + pub := &fakePub{} + h := New(store, pub, slog.New(slog.NewTextHandler(io.Discard, nil))) + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + return h, pub, srv +} + +func dial(t *testing.T, srv *httptest.Server, token string) (*websocket.Conn, *http.Response, error) { + t.Helper() + header := http.Header{} + if token != "" { + header.Set("Authorization", "Bearer "+token) + } + return websocket.Dial(context.Background(), "ws"+strings.TrimPrefix(srv.URL, "http"), + &websocket.DialOptions{HTTPHeader: header}) +} + +func send(t *testing.T, c *websocket.Conn, typ string, payload any) { + t.Helper() + env, err := protocol.Encode(typ, payload) + require.NoError(t, err) + require.NoError(t, wsjson.Write(context.Background(), c, env)) +} + +func TestHub_RejectsBadTokens(t *testing.T) { + _, _, srv := newHub(t, newFakeStore(node2, revoked)) + for name, token := range map[string]string{"missing": "", "unknown": "pua_nobody", "revoked": "pua_old"} { + t.Run(name, func(t *testing.T) { + _, resp, err := dial(t, srv, token) + require.Error(t, err) + require.NotNil(t, resp) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + } +} + +func TestHub_HelloAndSnapshotReachStore(t *testing.T) { + store := newFakeStore(node2) + h, pub, srv := newHub(t, store) + c, _, err := dial(t, srv, "pua_node-2") + require.NoError(t, err) + defer c.CloseNow() + + send(t, c, protocol.TypeHello, protocol.Hello{Hostname: "node-2"}) + send(t, c, protocol.TypeSnapshot, protocol.Snapshot{Services: []protocol.Service{ + {Kind: protocol.KindContainer, ContainerName: "a", Image: "a:1", Status: "running"}, + }}) + + require.Eventually(t, func() bool { return store.count(func(f *fakeStore) int { return len(f.snapshots) })() == 1 }, time.Second, 5*time.Millisecond) + assert.True(t, h.Online(node2.ID)) + assert.True(t, pub.has(events.Agents)) + assert.True(t, pub.has(events.Services)) +} + +func TestHub_LargeSnapshotArrivesIntact(t *testing.T) { + store := newFakeStore(node2) + _, _, srv := newHub(t, store) + c, _, err := dial(t, srv, "pua_node-2") + require.NoError(t, err) + defer c.CloseNow() + + services := make([]protocol.Service, 500) + for i := range services { + services[i] = protocol.Service{ + Kind: protocol.KindContainer, ContainerName: strings.Repeat("x", 20) + string(rune('a'+i%26)) + strings.Repeat("y", i%7), + Image: "registry.example.com/some/long/image/name:v1.2.3@sha256:" + strings.Repeat("0", 64), Status: "running", + } + } + send(t, c, protocol.TypeHello, protocol.Hello{Hostname: "node-2"}) + send(t, c, protocol.TypeSnapshot, protocol.Snapshot{Services: services}) + + require.Eventually(t, func() bool { return store.count(func(f *fakeStore) int { return len(f.snapshots) })() == 1 }, time.Second, 5*time.Millisecond) + store.mu.Lock() + defer store.mu.Unlock() + assert.Len(t, store.snapshots[0], 500) +} + +func TestHub_UpsertAndRemoveCarryClusterID(t *testing.T) { + store := newFakeStore(mgr) + _, _, srv := newHub(t, store) + c, _, err := dial(t, srv, "pua_swarm-mgr") + require.NoError(t, err) + defer c.CloseNow() + + gitea := protocol.Service{Kind: protocol.KindSwarm, SwarmService: "gitea_gitea", Image: "gitea/gitea:1", Status: "running"} + send(t, c, protocol.TypeHello, protocol.Hello{Hostname: "swarm-mgr", SwarmManager: true, SwarmClusterID: "c1"}) + send(t, c, protocol.TypeUpsert, protocol.Upsert{Service: gitea}) + send(t, c, protocol.TypeRemove, protocol.Remove{Key: gitea.Key()}) + + require.Eventually(t, func() bool { return store.count(func(f *fakeStore) int { return len(f.removes) })() == 1 }, time.Second, 5*time.Millisecond) + store.mu.Lock() + defer store.mu.Unlock() + assert.Equal(t, []string{"c1|swarm|gitea_gitea"}, store.upserts) + assert.Equal(t, []string{"c1|swarm|gitea_gitea"}, store.removes) +} + +func TestHub_IgnoresSwarmUpsertFromWorker(t *testing.T) { + store := newFakeStore(node2) + _, _, srv := newHub(t, store) + c, _, err := dial(t, srv, "pua_node-2") + require.NoError(t, err) + defer c.CloseNow() + + send(t, c, protocol.TypeHello, protocol.Hello{Hostname: "node-2"}) + send(t, c, protocol.TypeUpsert, protocol.Upsert{Service: protocol.Service{Kind: protocol.KindSwarm, SwarmService: "x"}}) + send(t, c, protocol.TypeUpsert, protocol.Upsert{Service: protocol.Service{Kind: protocol.KindContainer, ContainerName: "y"}}) + + require.Eventually(t, func() bool { return store.count(func(f *fakeStore) int { return len(f.upserts) })() == 1 }, time.Second, 5*time.Millisecond) + store.mu.Lock() + defer store.mu.Unlock() + assert.Equal(t, []string{"|container|y"}, store.upserts) +} + +// Live swarm messages are gated exactly like snapshots: only a swarm manager +// with a cluster ID may upsert or remove swarm services. +func TestHub_SwarmLiveMessagesAreGatedOnManagerWithClusterID(t *testing.T) { + tests := []struct { + name string + hello protocol.Hello + wantReaches bool + }{ + {"non-manager", protocol.Hello{Hostname: "swarm-mgr"}, false}, + {"non-manager with cluster id", protocol.Hello{Hostname: "swarm-mgr", SwarmClusterID: "c1"}, false}, + {"manager with empty cluster id", protocol.Hello{Hostname: "swarm-mgr", SwarmManager: true}, false}, + {"manager with cluster id", protocol.Hello{Hostname: "swarm-mgr", SwarmManager: true, SwarmClusterID: "c1"}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := newFakeStore(mgr) + _, _, srv := newHub(t, store) + c, _, err := dial(t, srv, "pua_swarm-mgr") + require.NoError(t, err) + defer c.CloseNow() + + svc := protocol.Service{Kind: protocol.KindSwarm, SwarmService: "gitea_gitea"} + sentinel := protocol.Service{Kind: protocol.KindContainer, ContainerName: "sentinel"} + send(t, c, protocol.TypeHello, tt.hello) + send(t, c, protocol.TypeUpsert, protocol.Upsert{Service: svc}) + send(t, c, protocol.TypeRemove, protocol.Remove{Key: svc.Key()}) + // Messages are handled in order, so once the sentinel's remove has + // arrived, the swarm messages before it have been handled too. + send(t, c, protocol.TypeRemove, protocol.Remove{Key: sentinel.Key()}) + + sentinelRemoved := func() bool { + store.mu.Lock() + defer store.mu.Unlock() + for _, r := range store.removes { + if strings.HasSuffix(r, "|container|sentinel") { + return true + } + } + return false + } + require.Eventually(t, sentinelRemoved, time.Second, 5*time.Millisecond) + + store.mu.Lock() + defer store.mu.Unlock() + if tt.wantReaches { + assert.Equal(t, []string{"c1|swarm|gitea_gitea"}, store.upserts) + assert.Contains(t, store.removes, "c1|swarm|gitea_gitea") + } else { + assert.Empty(t, store.upserts) + assert.Equal(t, []string{tt.hello.SwarmClusterID + "|container|sentinel"}, store.removes) + } + }) + } +} + +func TestHub_ClosesConnectionThatSkipsHello(t *testing.T) { + store := newFakeStore(node2) + _, _, srv := newHub(t, store) + c, _, err := dial(t, srv, "pua_node-2") + require.NoError(t, err) + defer c.CloseNow() + + send(t, c, protocol.TypeSnapshot, protocol.Snapshot{}) + _, _, err = c.Read(context.Background()) + assert.Equal(t, websocket.StatusPolicyViolation, websocket.CloseStatus(err)) +} + +// lockedBuffer is a bytes.Buffer safe for the hub's handler goroutines to +// write while the test reads it. +type lockedBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *lockedBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *lockedBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +func TestHub_NewConnectionReplacesOld(t *testing.T) { + store := newFakeStore(node2) + logs := &lockedBuffer{} + h := New(store, &fakePub{}, slog.New(slog.NewTextHandler(logs, nil))) + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + first, _, err := dial(t, srv, "pua_node-2") + require.NoError(t, err) + defer first.CloseNow() + send(t, first, protocol.TypeHello, protocol.Hello{Hostname: "node-2"}) + require.Eventually(t, func() bool { return h.Online(node2.ID) }, time.Second, 5*time.Millisecond) + + second, _, err := dial(t, srv, "pua_node-2") + require.NoError(t, err) + defer second.CloseNow() + + _, _, err = first.Read(context.Background()) + assert.Error(t, err, "the replaced connection must be closed") + assert.True(t, h.Online(node2.ID), "the agent stays online on its new connection") + assert.Contains(t, logs.String(), "msg=agent_replaced") + assert.Contains(t, logs.String(), "agent=node-2") + assert.NotContains(t, logs.String(), "pua_", "tokens are never logged") +} + +func TestHub_DisconnectCutsLiveConnection(t *testing.T) { + store := newFakeStore(node2) + h, pub, srv := newHub(t, store) + c, _, err := dial(t, srv, "pua_node-2") + require.NoError(t, err) + defer c.CloseNow() + send(t, c, protocol.TypeHello, protocol.Hello{Hostname: "node-2"}) + require.Eventually(t, func() bool { return h.Online(node2.ID) }, time.Second, 5*time.Millisecond) + // Online turns true before the hello is handled, so wait for the hello's + // own Agents signal, then forget it; the assertion below can then only be + // satisfied by the disconnect. + require.Eventually(t, func() bool { return pub.has(events.Agents) }, time.Second, 5*time.Millisecond) + pub.reset() + + h.Disconnect(node2.ID) + + _, _, err = c.Read(context.Background()) + assert.Error(t, err) + require.Eventually(t, func() bool { return !h.Online(node2.ID) }, time.Second, 5*time.Millisecond) + require.Eventually(t, func() bool { return store.count(func(f *fakeStore) int { return len(f.seen) })() == 1 }, time.Second, 5*time.Millisecond) + require.Eventually(t, func() bool { return pub.has(events.Agents) }, time.Second, 5*time.Millisecond) +} + +func TestHub_RevokeBetweenLookupAndRegisterCutsConnection(t *testing.T) { + store := newFakeStore(node2) + store.revokeFromLookup = 2 // first lookup passes, the re-check sees the revocation + h, _, srv := newHub(t, store) + c, _, err := dial(t, srv, "pua_node-2") + require.NoError(t, err) + defer c.CloseNow() + + _, _, err = c.Read(context.Background()) + assert.Equal(t, websocket.StatusPolicyViolation, websocket.CloseStatus(err)) + require.Eventually(t, func() bool { return !h.Online(node2.ID) }, time.Second, 5*time.Millisecond) +} + +func TestHub_ClosesConnectionOnSecondHello(t *testing.T) { + store := newFakeStore(mgr) + _, _, srv := newHub(t, store) + c, _, err := dial(t, srv, "pua_swarm-mgr") + require.NoError(t, err) + defer c.CloseNow() + + send(t, c, protocol.TypeHello, protocol.Hello{Hostname: "swarm-mgr"}) + send(t, c, protocol.TypeHello, protocol.Hello{Hostname: "swarm-mgr", SwarmManager: true, SwarmClusterID: "c1"}) + + _, _, err = c.Read(context.Background()) + assert.Equal(t, websocket.StatusPolicyViolation, websocket.CloseStatus(err)) + assert.Equal(t, 1, store.count(func(f *fakeStore) int { return len(f.hellos) })()) +} diff --git a/internal/master/model/model.go b/internal/master/model/model.go new file mode 100644 index 0000000..e09bc4a --- /dev/null +++ b/internal/master/model/model.go @@ -0,0 +1,89 @@ +// Package model holds the master's domain types shared by storage, the agent +// hub and the web UI. +package model + +import ( + "errors" + "strconv" + "strings" + "time" + + "github.com/shcizo/package-updater/internal/protocol" +) + +// Sentinel errors returned by storage. +var ( + ErrNotFound = errors.New("not found") + ErrConflict = errors.New("already exists") + ErrRevoked = errors.New("agent revoked") +) + +// Agent is a registered agent. Whether it is connected right now is held in +// memory by the hub, not here. +type Agent struct { + ID int64 + Name string + Hostname string + AgentVersion string + SwarmManager bool + LastSeenAt *time.Time + RevokedAt *time.Time + CreatedAt time.Time +} + +// Service is one service as last reported by an agent. +type Service struct { + ID int64 + Kind protocol.Kind + LocationKey string + AgentID *int64 + AgentName string + ComposeProject string + ComposeService string + WorkingDir string + SwarmClusterID string + SwarmService string + ContainerName string + ImageRepo string + ActualRef string + ActualDigest string + Status string + Self bool + UpdateMode string + FirstSeenAt time.Time + UpdatedAt time.Time +} + +// DisplayName is the human-facing name of the service. +func (s Service) DisplayName() string { + switch s.Kind { + case protocol.KindCompose: + return s.ComposeProject + "/" + s.ComposeService + case protocol.KindSwarm: + return s.SwarmService + default: + return s.ContainerName + } +} + +// Session is a logged-in browser session. +type Session struct { + ID string + Subject string + Name string + Email string + CSRFToken string + ExpiresAt time.Time +} + +// LocationKey turns an agent-relative protocol key into the fleet-wide key +// stored in services.location_key. Compose services and containers belong to +// the agent's machine; Swarm services belong to the cluster, so two manager +// agents in one cluster report the same row. +func LocationKey(agentID int64, clusterID, agentKey string) string { + kind, rest, _ := strings.Cut(agentKey, "|") + if kind == string(protocol.KindSwarm) { + return kind + "|" + clusterID + "|" + rest + } + return kind + "|" + strconv.FormatInt(agentID, 10) + "|" + rest +} diff --git a/internal/master/model/model_test.go b/internal/master/model/model_test.go new file mode 100644 index 0000000..0705f69 --- /dev/null +++ b/internal/master/model/model_test.go @@ -0,0 +1,41 @@ +package model + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/shcizo/package-updater/internal/protocol" +) + +func TestLocationKey(t *testing.T) { + cases := []struct { + name, agentKey, want string + }{ + {"compose is per agent", "compose|slusk|web", "compose|7|slusk|web"}, + {"container is per agent", "container|adhoc", "container|7|adhoc"}, + {"swarm is per cluster", "swarm|gitea_gitea", "swarm|cluster-1|gitea_gitea"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + assert.Equal(t, c.want, LocationKey(7, "cluster-1", c.agentKey)) + }) + } +} + +func TestDisplayName(t *testing.T) { + cases := []struct { + name string + svc Service + want string + }{ + {"compose", Service{Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web"}, "slusk/web"}, + {"swarm", Service{Kind: protocol.KindSwarm, SwarmService: "gitea_gitea"}, "gitea_gitea"}, + {"container", Service{Kind: protocol.KindContainer, ContainerName: "adhoc"}, "adhoc"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + assert.Equal(t, c.want, c.svc.DisplayName()) + }) + } +} diff --git a/internal/master/store/agents.go b/internal/master/store/agents.go new file mode 100644 index 0000000..c477665 --- /dev/null +++ b/internal/master/store/agents.go @@ -0,0 +1,108 @@ +package store + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + + "github.com/shcizo/package-updater/internal/master/model" + "github.com/shcizo/package-updater/internal/protocol" +) + +const agentColumns = `id, name, hostname, agent_version, swarm_manager, last_seen_at, revoked_at, created_at` + +func scanAgent(row pgx.Row) (model.Agent, error) { + var a model.Agent + err := row.Scan(&a.ID, &a.Name, &a.Hostname, &a.AgentVersion, &a.SwarmManager, &a.LastSeenAt, &a.RevokedAt, &a.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return model.Agent{}, model.ErrNotFound + } + return a, err +} + +// CreateAgent registers an agent. Returns model.ErrConflict if an active +// (non-revoked) agent already has the name. +func (s *Store) CreateAgent(ctx context.Context, name, tokenHash string) (model.Agent, error) { + a, err := scanAgent(s.pool.QueryRow(ctx, + `INSERT INTO agents (name, token_hash) VALUES ($1, $2) RETURNING `+agentColumns, name, tokenHash)) + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + return model.Agent{}, model.ErrConflict + } + if err != nil { + return model.Agent{}, fmt.Errorf("create agent: %w", err) + } + return a, nil +} + +// ListAgents returns all agents, including revoked ones, ordered by name. +func (s *Store) ListAgents(ctx context.Context) ([]model.Agent, error) { + rows, err := s.pool.Query(ctx, `SELECT `+agentColumns+` FROM agents ORDER BY name`) + if err != nil { + return nil, fmt.Errorf("list agents: %w", err) + } + defer rows.Close() + var out []model.Agent + for rows.Next() { + a, err := scanAgent(rows) + if err != nil { + return nil, fmt.Errorf("scan agent: %w", err) + } + out = append(out, a) + } + return out, rows.Err() +} + +// AgentByTokenHash finds the agent a token belongs to. Revoked agents are +// returned too; the caller decides. Returns model.ErrNotFound if none. +func (s *Store) AgentByTokenHash(ctx context.Context, hash string) (model.Agent, error) { + return scanAgent(s.pool.QueryRow(ctx, `SELECT `+agentColumns+` FROM agents WHERE token_hash = $1`, hash)) +} + +// RevokeAgent marks the agent revoked and its Compose and plain-container +// services removed, in one transaction. Swarm rows are shared with other +// managers and left alone. Its token stops working immediately; closing a +// live connection is the hub's job. +func (s *Store) RevokeAgent(ctx context.Context, id int64, at time.Time) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("revoke agent: begin: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + if _, err := tx.Exec(ctx, + `UPDATE agents SET revoked_at = $2 WHERE id = $1 AND revoked_at IS NULL`, id, at); err != nil { + return fmt.Errorf("revoke agent: %w", err) + } + if _, err := tx.Exec(ctx, + `UPDATE services SET removed_at = $2 + WHERE agent_id = $1 AND kind IN ('compose', 'container') AND removed_at IS NULL`, id, at); err != nil { + return fmt.Errorf("revoke agent: remove services: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("revoke agent: commit: %w", err) + } + return nil +} + +// RecordHello stores what the agent reported about itself on connect. +func (s *Store) RecordHello(ctx context.Context, id int64, h protocol.Hello, at time.Time) error { + if _, err := s.pool.Exec(ctx, + `UPDATE agents SET hostname = $2, agent_version = $3, swarm_manager = $4, last_seen_at = $5 WHERE id = $1`, + id, h.Hostname, h.AgentVersion, h.SwarmManager, at); err != nil { + return fmt.Errorf("record hello: %w", err) + } + return nil +} + +// MarkSeen updates last_seen_at, called when an agent disconnects. +func (s *Store) MarkSeen(ctx context.Context, id int64, at time.Time) error { + if _, err := s.pool.Exec(ctx, `UPDATE agents SET last_seen_at = $2 WHERE id = $1`, id, at); err != nil { + return fmt.Errorf("mark agent seen: %w", err) + } + return nil +} diff --git a/internal/master/store/agents_integration_test.go b/internal/master/store/agents_integration_test.go new file mode 100644 index 0000000..568ffc1 --- /dev/null +++ b/internal/master/store/agents_integration_test.go @@ -0,0 +1,140 @@ +//go:build integration + +package store + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/shcizo/package-updater/internal/master/model" + "github.com/shcizo/package-updater/internal/pin" + "github.com/shcizo/package-updater/internal/protocol" +) + +func TestIdentity_CreatedOnceThenReused(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + k1, err := s.LoadOrCreateIdentity(ctx, pin.GenerateKey) + require.NoError(t, err) + k2, err := s.LoadOrCreateIdentity(ctx, pin.GenerateKey) + require.NoError(t, err) + assert.True(t, k1.Equal(k2), "a restarted master must keep its key or every agent breaks") +} + +func TestMigrationsAreIdempotent(t *testing.T) { + s := openTestStore(t) + require.NoError(t, migrate(context.Background(), s.pool)) +} + +func TestAgents_CreateLookupRevoke(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + created, err := s.CreateAgent(ctx, "node-2", "hash-2") + require.NoError(t, err) + assert.Equal(t, "node-2", created.Name) + assert.Nil(t, created.RevokedAt) + + found, err := s.AgentByTokenHash(ctx, "hash-2") + require.NoError(t, err) + assert.Equal(t, created.ID, found.ID) + + _, err = s.AgentByTokenHash(ctx, "nope") + assert.ErrorIs(t, err, model.ErrNotFound) + + require.NoError(t, s.RevokeAgent(ctx, created.ID, time.Now())) + found, err = s.AgentByTokenHash(ctx, "hash-2") + require.NoError(t, err) + assert.NotNil(t, found.RevokedAt) +} + +func TestAgents_DuplicateNameIsConflict(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + _, err := s.CreateAgent(ctx, "node-2", "h1") + require.NoError(t, err) + _, err = s.CreateAgent(ctx, "node-2", "h2") + assert.ErrorIs(t, err, model.ErrConflict) +} + +func TestAgents_RecordHelloAndMarkSeen(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + a, err := s.CreateAgent(ctx, "swarm-mgr", "h") + require.NoError(t, err) + + at := time.Date(2026, 10, 12, 12, 0, 0, 0, time.UTC) + require.NoError(t, s.RecordHello(ctx, a.ID, protocol.Hello{ + Hostname: "swarm-mgr", AgentVersion: "v1", SwarmManager: true, SwarmClusterID: "c1", + }, at)) + require.NoError(t, s.MarkSeen(ctx, a.ID, at.Add(time.Minute))) + + agents, err := s.ListAgents(ctx) + require.NoError(t, err) + require.Len(t, agents, 1) + assert.Equal(t, "swarm-mgr", agents[0].Hostname) + assert.Equal(t, "v1", agents[0].AgentVersion) + assert.True(t, agents[0].SwarmManager) + require.NotNil(t, agents[0].LastSeenAt) + assert.True(t, agents[0].LastSeenAt.Equal(at.Add(time.Minute))) +} + +func TestAgents_RevokedNameCanBeReused(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + old, err := s.CreateAgent(ctx, "node-2", "h1") + require.NoError(t, err) + require.NoError(t, s.RevokeAgent(ctx, old.ID, time.Now())) + + fresh, err := s.CreateAgent(ctx, "node-2", "h2") + require.NoError(t, err) + assert.NotEqual(t, old.ID, fresh.ID) + + _, err = s.CreateAgent(ctx, "node-2", "h3") + assert.ErrorIs(t, err, model.ErrConflict, "a second active agent with the name is still a conflict") +} + +func TestAgents_RevokeRemovesOwnComposeAndContainerServices(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + id := newAgent(t, s, "swarm-mgr") + require.NoError(t, s.ApplySnapshot(ctx, id, manager, []protocol.Service{slusk, adhoc, gitea}, t0)) + + require.NoError(t, s.RevokeAgent(ctx, id, t0.Add(time.Hour))) + + got, err := s.ListServices(ctx) + require.NoError(t, err) + assert.Equal(t, []string{"gitea_gitea"}, names(got), "swarm rows are shared and must survive a revoke") +} + +func TestRevokedAgent_InventoryWritesRefused(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + id := newAgent(t, s, "node-2") + require.NoError(t, s.RevokeAgent(ctx, id, t0)) + + assert.ErrorIs(t, s.ApplySnapshot(ctx, id, worker, []protocol.Service{slusk}, t0.Add(time.Minute)), model.ErrRevoked) + assert.ErrorIs(t, s.UpsertService(ctx, id, "", adhoc, t0.Add(time.Minute)), model.ErrRevoked) + + got, err := s.ListServices(ctx) + require.NoError(t, err) + assert.Empty(t, got, "a revoked agent must not write inventory") +} + +func TestRevokedAgent_UpsertDoesNotResurrectRemovedRow(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + id := newAgent(t, s, "node-2") + require.NoError(t, s.ApplySnapshot(ctx, id, worker, []protocol.Service{slusk}, t0)) + require.NoError(t, s.RevokeAgent(ctx, id, t0.Add(time.Hour))) + + assert.ErrorIs(t, s.UpsertService(ctx, id, "", slusk, t0.Add(2*time.Hour)), model.ErrRevoked) + + got, err := s.ListServices(ctx) + require.NoError(t, err) + assert.Empty(t, got, "the revoked agent's removed row must stay removed") +} diff --git a/internal/master/store/helpers_integration_test.go b/internal/master/store/helpers_integration_test.go new file mode 100644 index 0000000..3cd08a4 --- /dev/null +++ b/internal/master/store/helpers_integration_test.go @@ -0,0 +1,43 @@ +//go:build integration + +package store + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/require" +) + +// openTestStore returns a Store migrated into a fresh schema that is dropped +// when the test ends, so tests never see each other's rows. +func openTestStore(t *testing.T) *Store { + t.Helper() + url := os.Getenv("DATABASE_URL") + if url == "" { + t.Skip("DATABASE_URL not set") + } + ctx := context.Background() + admin, err := pgx.Connect(ctx, url) + require.NoError(t, err) + schema := fmt.Sprintf("test_%d", time.Now().UnixNano()) + _, err = admin.Exec(ctx, "CREATE SCHEMA "+schema) + require.NoError(t, err) + t.Cleanup(func() { + _, _ = admin.Exec(ctx, "DROP SCHEMA "+schema+" CASCADE") + _ = admin.Close(ctx) + }) + + cfg, err := pgxpool.ParseConfig(url) + require.NoError(t, err) + cfg.ConnConfig.RuntimeParams["search_path"] = schema + s, err := openWithConfig(ctx, cfg) + require.NoError(t, err) + t.Cleanup(s.Close) + return s +} diff --git a/internal/master/store/identity.go b/internal/master/store/identity.go new file mode 100644 index 0000000..fb4e28d --- /dev/null +++ b/internal/master/store/identity.go @@ -0,0 +1,42 @@ +package store + +import ( + "context" + "crypto/ed25519" + "crypto/x509" + "errors" + "fmt" +) + +// LoadOrCreateIdentity returns the master's private key, creating and +// storing one with generate on first start. Keeping it in Postgres means it +// survives container recreation and is covered by database backups; losing +// it would force every agent to be reconfigured with a new MASTER_KEY. +func (s *Store) LoadOrCreateIdentity(ctx context.Context, generate func() (ed25519.PrivateKey, error)) (ed25519.PrivateKey, error) { + fresh, err := generate() + if err != nil { + return nil, err + } + der, err := x509.MarshalPKCS8PrivateKey(fresh) + if err != nil { + return nil, fmt.Errorf("marshal master key: %w", err) + } + if _, err := s.pool.Exec(ctx, + `INSERT INTO master_identity (id, private_key) VALUES (1, $1) ON CONFLICT (id) DO NOTHING`, der, + ); err != nil { + return nil, fmt.Errorf("store master key: %w", err) + } + var stored []byte + if err := s.pool.QueryRow(ctx, `SELECT private_key FROM master_identity WHERE id = 1`).Scan(&stored); err != nil { + return nil, fmt.Errorf("load master key: %w", err) + } + parsed, err := x509.ParsePKCS8PrivateKey(stored) + if err != nil { + return nil, fmt.Errorf("parse master key: %w", err) + } + key, ok := parsed.(ed25519.PrivateKey) + if !ok { + return nil, errors.New("stored master key is not ed25519") + } + return key, nil +} diff --git a/internal/master/store/migrate.go b/internal/master/store/migrate.go new file mode 100644 index 0000000..4be77e8 --- /dev/null +++ b/internal/master/store/migrate.go @@ -0,0 +1,68 @@ +package store + +import ( + "context" + "embed" + "fmt" + "io/fs" + + "github.com/jackc/pgx/v5/pgxpool" +) + +//go:embed migrations/*.sql +var migrationsFS embed.FS + +// migrate applies every embedded migration not yet recorded in +// schema_migrations, in file-name order, each in its own transaction. +func migrate(ctx context.Context, pool *pgxpool.Pool) error { + if _, err := pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations ( + version text PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT now() + )`); err != nil { + return fmt.Errorf("create schema_migrations: %w", err) + } + entries, err := fs.ReadDir(migrationsFS, "migrations") + if err != nil { + return fmt.Errorf("read migrations: %w", err) + } + for _, e := range entries { + name := e.Name() + var applied bool + if err := pool.QueryRow(ctx, + `SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE version = $1)`, name, + ).Scan(&applied); err != nil { + return fmt.Errorf("check migration %s: %w", name, err) + } + if applied { + continue + } + sql, err := migrationsFS.ReadFile("migrations/" + name) + if err != nil { + return fmt.Errorf("read migration %s: %w", name, err) + } + if err := applyMigration(ctx, pool, name, string(sql)); err != nil { + return err + } + } + return nil +} + +func applyMigration(ctx context.Context, pool *pgxpool.Pool, name, sql string) error { + tx, err := pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin migration %s: %w", name, err) + } + defer func() { _ = tx.Rollback(ctx) }() + // Exec without arguments uses the simple protocol, which allows the + // several statements a migration file contains. + if _, err := tx.Exec(ctx, sql); err != nil { + return fmt.Errorf("migration %s: %w", name, err) + } + if _, err := tx.Exec(ctx, `INSERT INTO schema_migrations (version) VALUES ($1)`, name); err != nil { + return fmt.Errorf("record migration %s: %w", name, err) + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit migration %s: %w", name, err) + } + return nil +} diff --git a/internal/master/store/migrations/0001_init.sql b/internal/master/store/migrations/0001_init.sql new file mode 100644 index 0000000..0ceba6b --- /dev/null +++ b/internal/master/store/migrations/0001_init.sql @@ -0,0 +1,51 @@ +CREATE TABLE master_identity ( + id int PRIMARY KEY CHECK (id = 1), + private_key bytea NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE agents ( + id bigserial PRIMARY KEY, + name text NOT NULL, + token_hash text NOT NULL UNIQUE, + hostname text NOT NULL DEFAULT '', + agent_version text NOT NULL DEFAULT '', + swarm_manager boolean NOT NULL DEFAULT false, + last_seen_at timestamptz, + revoked_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- A revoked agent's name can be reused by a new agent. +CREATE UNIQUE INDEX agents_name_active ON agents (name) WHERE revoked_at IS NULL; + +CREATE TABLE services ( + id bigserial PRIMARY KEY, + kind text NOT NULL CHECK (kind IN ('compose', 'swarm', 'container')), + location_key text NOT NULL UNIQUE, + agent_id bigint REFERENCES agents (id), + compose_project text NOT NULL DEFAULT '', + compose_service text NOT NULL DEFAULT '', + working_dir text NOT NULL DEFAULT '', + swarm_cluster_id text NOT NULL DEFAULT '', + swarm_service text NOT NULL DEFAULT '', + container_name text NOT NULL DEFAULT '', + image_repo text NOT NULL, + actual_ref text NOT NULL, + actual_digest text NOT NULL DEFAULT '', + status text NOT NULL, + self boolean NOT NULL DEFAULT false, + update_mode text NOT NULL DEFAULT 'manual' CHECK (update_mode IN ('manual', 'auto')), + first_seen_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + removed_at timestamptz +); + +CREATE TABLE sessions ( + id text PRIMARY KEY, + subject text NOT NULL, + name text NOT NULL DEFAULT '', + email text NOT NULL DEFAULT '', + csrf_token text NOT NULL, + expires_at timestamptz NOT NULL +); diff --git a/internal/master/store/services.go b/internal/master/store/services.go new file mode 100644 index 0000000..6f858eb --- /dev/null +++ b/internal/master/store/services.go @@ -0,0 +1,176 @@ +package store + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + + "github.com/shcizo/package-updater/internal/imageref" + "github.com/shcizo/package-updater/internal/master/model" + "github.com/shcizo/package-updater/internal/protocol" +) + +type execer interface { + Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) +} + +// lockActiveAgent takes a share lock on the agent's row inside tx and fails +// with model.ErrRevoked if the agent is revoked or gone. RevokeAgent's +// UPDATE of the row waits for the lock, so an inventory write either commits +// before the revoke (whose service cleanup then sees and removes its rows) +// or runs after it and is refused. Without this, an in-flight write could +// bring a revoked agent's services back. +func lockActiveAgent(ctx context.Context, tx pgx.Tx, agentID int64) error { + var one int + err := tx.QueryRow(ctx, + `SELECT 1 FROM agents WHERE id = $1 AND revoked_at IS NULL FOR SHARE`, agentID).Scan(&one) + if errors.Is(err, pgx.ErrNoRows) { + return model.ErrRevoked + } + if err != nil { + return fmt.Errorf("lock agent: %w", err) + } + return nil +} + +const upsertServiceSQL = ` +INSERT INTO services ( + kind, location_key, agent_id, compose_project, compose_service, working_dir, + swarm_cluster_id, swarm_service, container_name, image_repo, actual_ref, actual_digest, + status, self, first_seen_at, updated_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $15) +ON CONFLICT (location_key) DO UPDATE SET + agent_id = EXCLUDED.agent_id, + working_dir = EXCLUDED.working_dir, + image_repo = EXCLUDED.image_repo, + actual_ref = EXCLUDED.actual_ref, + actual_digest = EXCLUDED.actual_digest, + status = EXCLUDED.status, + self = EXCLUDED.self, + updated_at = EXCLUDED.updated_at, + removed_at = NULL` + +func upsertService(ctx context.Context, db execer, agentID int64, clusterID string, svc protocol.Service, at time.Time) error { + clusterCol := "" + if svc.Kind == protocol.KindSwarm { + clusterCol = clusterID + } + _, err := db.Exec(ctx, upsertServiceSQL, + string(svc.Kind), model.LocationKey(agentID, clusterID, svc.Key()), agentID, + svc.ComposeProject, svc.ComposeService, svc.WorkingDir, + clusterCol, svc.SwarmService, svc.ContainerName, + imageref.Parse(svc.Image).Repo, svc.Image, svc.Digest, + svc.Status, svc.Self, at) + if err != nil { + return fmt.Errorf("upsert service %s: %w", svc.Key(), err) + } + return nil +} + +// ApplySnapshot makes the stored inventory for this agent's scope equal to +// services: everything listed is upserted, everything in scope but not +// listed is marked removed. Returns model.ErrRevoked without writing if the +// agent is revoked. Scope is the agent's own Compose services and +// containers, plus the cluster's Swarm services if the agent is a manager. +// A manager without a cluster ID is treated as a non-manager, since its +// Swarm rows could not be told apart from other agents' rows. +func (s *Store) ApplySnapshot(ctx context.Context, agentID int64, h protocol.Hello, services []protocol.Service, at time.Time) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin snapshot: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + if err := lockActiveAgent(ctx, tx, agentID); err != nil { + return err + } + + isManager := h.SwarmManager && h.SwarmClusterID != "" + keys := make([]string, 0, len(services)) + for _, svc := range services { + if svc.Kind == protocol.KindSwarm && !isManager { + continue + } + if err := upsertService(ctx, tx, agentID, h.SwarmClusterID, svc, at); err != nil { + return err + } + keys = append(keys, model.LocationKey(agentID, h.SwarmClusterID, svc.Key())) + } + if _, err := tx.Exec(ctx, ` + UPDATE services SET removed_at = $1 + WHERE removed_at IS NULL + AND NOT (location_key = ANY($2)) + AND ((kind IN ('compose', 'container') AND agent_id = $3) + OR (kind = 'swarm' AND $4 AND swarm_cluster_id = $5))`, + at, keys, agentID, isManager, h.SwarmClusterID); err != nil { + return fmt.Errorf("mark removed services: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit snapshot: %w", err) + } + return nil +} + +// UpsertService stores one changed or new service. Returns +// model.ErrRevoked without writing if the agent is revoked. +func (s *Store) UpsertService(ctx context.Context, agentID int64, clusterID string, svc protocol.Service, at time.Time) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin upsert: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + if err := lockActiveAgent(ctx, tx, agentID); err != nil { + return err + } + if err := upsertService(ctx, tx, agentID, clusterID, svc, at); err != nil { + return err + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit upsert: %w", err) + } + return nil +} + +// RemoveService marks the service with the agent-relative key as removed. +func (s *Store) RemoveService(ctx context.Context, agentID int64, clusterID, agentKey string, at time.Time) error { + if _, err := s.pool.Exec(ctx, + `UPDATE services SET removed_at = $2 WHERE location_key = $1 AND removed_at IS NULL`, + model.LocationKey(agentID, clusterID, agentKey), at); err != nil { + return fmt.Errorf("remove service: %w", err) + } + return nil +} + +// ListServices returns all services not marked removed: machine-bound +// services grouped by agent name first, Swarm services last. +func (s *Store) ListServices(ctx context.Context) ([]model.Service, error) { + rows, err := s.pool.Query(ctx, ` + SELECT s.id, s.kind, s.location_key, s.agent_id, COALESCE(a.name, ''), + s.compose_project, s.compose_service, s.working_dir, s.swarm_cluster_id, + s.swarm_service, s.container_name, s.image_repo, s.actual_ref, s.actual_digest, + s.status, s.self, s.update_mode, s.first_seen_at, s.updated_at + FROM services s LEFT JOIN agents a ON a.id = s.agent_id + WHERE s.removed_at IS NULL + ORDER BY s.kind = 'swarm', COALESCE(a.name, ''), s.location_key`) + if err != nil { + return nil, fmt.Errorf("list services: %w", err) + } + defer rows.Close() + var out []model.Service + for rows.Next() { + var m model.Service + var kind string + if err := rows.Scan(&m.ID, &kind, &m.LocationKey, &m.AgentID, &m.AgentName, + &m.ComposeProject, &m.ComposeService, &m.WorkingDir, &m.SwarmClusterID, + &m.SwarmService, &m.ContainerName, &m.ImageRepo, &m.ActualRef, &m.ActualDigest, + &m.Status, &m.Self, &m.UpdateMode, &m.FirstSeenAt, &m.UpdatedAt); err != nil { + return nil, fmt.Errorf("scan service: %w", err) + } + m.Kind = protocol.Kind(kind) + out = append(out, m) + } + return out, rows.Err() +} diff --git a/internal/master/store/services_integration_test.go b/internal/master/store/services_integration_test.go new file mode 100644 index 0000000..87d8013 --- /dev/null +++ b/internal/master/store/services_integration_test.go @@ -0,0 +1,162 @@ +//go:build integration + +package store + +import ( + "context" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/shcizo/package-updater/internal/master/model" + "github.com/shcizo/package-updater/internal/protocol" +) + +var ( + t0 = time.Date(2026, 10, 12, 12, 0, 0, 0, time.UTC) + slusk = protocol.Service{Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web", WorkingDir: "/srv/slusk", Image: "gitea.shcizo.se/shcizo/slusk:1.4.2", Digest: "sha256:d1", Status: "running"} + adhoc = protocol.Service{Kind: protocol.KindContainer, ContainerName: "adhoc", Image: "redis:alpine", Status: "stopped"} + gitea = protocol.Service{Kind: protocol.KindSwarm, SwarmService: "gitea_gitea", Image: "gitea/gitea:1.27.3@sha256:87a6", Digest: "sha256:87a6", Status: "running"} + worker = protocol.Hello{Hostname: "node-2"} + manager = protocol.Hello{Hostname: "swarm-mgr", SwarmManager: true, SwarmClusterID: "c1"} +) + +func names(svcs []model.Service) []string { + var out []string + for _, s := range svcs { + out = append(out, s.DisplayName()) + } + return out +} + +func newAgent(t *testing.T, s *Store, name string) int64 { + t.Helper() + a, err := s.CreateAgent(context.Background(), name, "hash-"+name) + require.NoError(t, err) + return a.ID +} + +func TestApplySnapshot_InsertsAsManualWithAgentName(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + id := newAgent(t, s, "node-2") + + require.NoError(t, s.ApplySnapshot(ctx, id, worker, []protocol.Service{slusk}, t0)) + + got, err := s.ListServices(ctx) + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, "manual", got[0].UpdateMode) + assert.Equal(t, "node-2", got[0].AgentName) + assert.Equal(t, "gitea.shcizo.se/shcizo/slusk", got[0].ImageRepo) + assert.Equal(t, "gitea.shcizo.se/shcizo/slusk:1.4.2", got[0].ActualRef) + assert.Equal(t, "sha256:d1", got[0].ActualDigest) + assert.Equal(t, "compose|"+itoa(id)+"|slusk|web", got[0].LocationKey) +} + +func TestApplySnapshot_RemovedWhileOfflineThenBack(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + id := newAgent(t, s, "node-2") + + require.NoError(t, s.ApplySnapshot(ctx, id, worker, []protocol.Service{slusk, adhoc}, t0)) + before, err := s.ListServices(ctx) + require.NoError(t, err) + + require.NoError(t, s.ApplySnapshot(ctx, id, worker, []protocol.Service{slusk}, t0.Add(time.Hour))) + got, err := s.ListServices(ctx) + require.NoError(t, err) + assert.Equal(t, []string{"slusk/web"}, names(got)) + + require.NoError(t, s.ApplySnapshot(ctx, id, worker, []protocol.Service{slusk, adhoc}, t0.Add(2*time.Hour))) + after, err := s.ListServices(ctx) + require.NoError(t, err) + require.Len(t, after, 2) + for i := range before { + assert.Equal(t, before[i].ID, after[i].ID, "a returning service keeps its row") + assert.True(t, before[i].FirstSeenAt.Equal(after[i].FirstSeenAt)) + } +} + +func TestApplySnapshot_TwoManagersShareSwarmRowsAndKeepOwnCompose(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + a := newAgent(t, s, "swarm-mgr") + b := newAgent(t, s, "swarm-mgr-2") + + require.NoError(t, s.ApplySnapshot(ctx, a, manager, []protocol.Service{gitea, slusk}, t0)) + require.NoError(t, s.ApplySnapshot(ctx, b, manager, []protocol.Service{gitea}, t0)) + + got, err := s.ListServices(ctx) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"slusk/web", "gitea_gitea"}, names(got), + "one swarm row, and b's snapshot must not remove a's compose service") +} + +func TestApplySnapshot_IgnoresSwarmFromWorker(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + id := newAgent(t, s, "node-2") + require.NoError(t, s.ApplySnapshot(ctx, id, worker, []protocol.Service{gitea}, t0)) + got, err := s.ListServices(ctx) + require.NoError(t, err) + assert.Empty(t, got) +} + +func TestApplySnapshot_IgnoresSwarmFromManagerWithoutClusterID(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + a := newAgent(t, s, "mgr-a") + b := newAgent(t, s, "mgr-b") + noCluster := protocol.Hello{Hostname: "mgr", SwarmManager: true} + + require.NoError(t, s.ApplySnapshot(ctx, a, manager, []protocol.Service{gitea}, t0)) + require.NoError(t, s.ApplySnapshot(ctx, b, noCluster, []protocol.Service{gitea}, t0)) + + got, err := s.ListServices(ctx) + require.NoError(t, err) + require.Len(t, got, 1, "the cluster-less manager adds no swarm row") + assert.Equal(t, "swarm|c1|gitea_gitea", got[0].LocationKey) + + require.NoError(t, s.ApplySnapshot(ctx, b, noCluster, nil, t0.Add(time.Hour))) + got, err = s.ListServices(ctx) + require.NoError(t, err) + assert.Len(t, got, 1, "the cluster-less manager removes no swarm rows") +} + +func TestUpsertAndRemoveService(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + id := newAgent(t, s, "swarm-mgr") + + require.NoError(t, s.UpsertService(ctx, id, "c1", gitea, t0)) + changed := gitea + changed.Status = "degraded" + require.NoError(t, s.UpsertService(ctx, id, "c1", changed, t0.Add(time.Minute))) + + got, err := s.ListServices(ctx) + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, "degraded", got[0].Status) + assert.Equal(t, "swarm|c1|gitea_gitea", got[0].LocationKey) + + require.NoError(t, s.RemoveService(ctx, id, "c1", gitea.Key(), t0.Add(2*time.Minute))) + got, err = s.ListServices(ctx) + require.NoError(t, err) + assert.Empty(t, got) +} + +func TestListServices_SwarmLast(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + id := newAgent(t, s, "swarm-mgr") + require.NoError(t, s.ApplySnapshot(ctx, id, manager, []protocol.Service{gitea, slusk}, t0)) + got, err := s.ListServices(ctx) + require.NoError(t, err) + assert.Equal(t, []string{"slusk/web", "gitea_gitea"}, names(got)) +} + +func itoa(n int64) string { return strconv.FormatInt(n, 10) } diff --git a/internal/master/store/sessions.go b/internal/master/store/sessions.go new file mode 100644 index 0000000..90e7ef3 --- /dev/null +++ b/internal/master/store/sessions.go @@ -0,0 +1,50 @@ +package store + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/shcizo/package-updater/internal/master/model" +) + +// CreateSession stores a new session and deletes expired ones, so the table +// never grows without bound. +func (s *Store) CreateSession(ctx context.Context, sess model.Session) error { + if _, err := s.pool.Exec(ctx, `DELETE FROM sessions WHERE expires_at < now()`); err != nil { + return fmt.Errorf("delete expired sessions: %w", err) + } + if _, err := s.pool.Exec(ctx, + `INSERT INTO sessions (id, subject, name, email, csrf_token, expires_at) VALUES ($1, $2, $3, $4, $5, $6)`, + sess.ID, sess.Subject, sess.Name, sess.Email, sess.CSRFToken, sess.ExpiresAt); err != nil { + return fmt.Errorf("create session: %w", err) + } + return nil +} + +// GetSession returns the session unless it is missing or expired at now, +// in which case it returns model.ErrNotFound. +func (s *Store) GetSession(ctx context.Context, id string, now time.Time) (model.Session, error) { + var m model.Session + err := s.pool.QueryRow(ctx, + `SELECT id, subject, name, email, csrf_token, expires_at FROM sessions WHERE id = $1 AND expires_at > $2`, + id, now).Scan(&m.ID, &m.Subject, &m.Name, &m.Email, &m.CSRFToken, &m.ExpiresAt) + if errors.Is(err, pgx.ErrNoRows) { + return model.Session{}, model.ErrNotFound + } + if err != nil { + return model.Session{}, fmt.Errorf("get session: %w", err) + } + return m, nil +} + +// DeleteSession removes a session (logout). +func (s *Store) DeleteSession(ctx context.Context, id string) error { + if _, err := s.pool.Exec(ctx, `DELETE FROM sessions WHERE id = $1`, id); err != nil { + return fmt.Errorf("delete session: %w", err) + } + return nil +} diff --git a/internal/master/store/sessions_integration_test.go b/internal/master/store/sessions_integration_test.go new file mode 100644 index 0000000..b8d8a15 --- /dev/null +++ b/internal/master/store/sessions_integration_test.go @@ -0,0 +1,34 @@ +//go:build integration + +package store + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/shcizo/package-updater/internal/master/model" +) + +func TestSessions(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + now := time.Date(2026, 10, 12, 12, 0, 0, 0, time.UTC) + sess := model.Session{ID: "s1", Subject: "sub", Name: "Samuel", Email: "s@example.com", CSRFToken: "c1", ExpiresAt: now.Add(time.Hour)} + + require.NoError(t, s.CreateSession(ctx, sess)) + got, err := s.GetSession(ctx, "s1", now) + require.NoError(t, err) + assert.Equal(t, "Samuel", got.Name) + assert.Equal(t, "c1", got.CSRFToken) + + _, err = s.GetSession(ctx, "s1", now.Add(2*time.Hour)) + assert.ErrorIs(t, err, model.ErrNotFound, "expired sessions are not returned") + + require.NoError(t, s.DeleteSession(ctx, "s1")) + _, err = s.GetSession(ctx, "s1", now) + assert.ErrorIs(t, err, model.ErrNotFound) +} diff --git a/internal/master/store/store.go b/internal/master/store/store.go new file mode 100644 index 0000000..6ab2329 --- /dev/null +++ b/internal/master/store/store.go @@ -0,0 +1,41 @@ +// Package store persists the master's state in Postgres. +package store + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Store is the Postgres-backed storage for the master. +type Store struct { + pool *pgxpool.Pool +} + +// Open connects to Postgres and applies pending migrations. +func Open(ctx context.Context, url string) (*Store, error) { + cfg, err := pgxpool.ParseConfig(url) + if err != nil { + return nil, fmt.Errorf("parse DATABASE_URL: %w", err) + } + return openWithConfig(ctx, cfg) +} + +func openWithConfig(ctx context.Context, cfg *pgxpool.Config) (*Store, error) { + pool, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + return nil, fmt.Errorf("connect postgres: %w", err) + } + if err := migrate(ctx, pool); err != nil { + pool.Close() + return nil, err + } + return &Store{pool: pool}, nil +} + +// Close releases the connection pool. +func (s *Store) Close() { s.pool.Close() } + +// Ping checks that Postgres answers. Used by /healthz. +func (s *Store) Ping(ctx context.Context) error { return s.pool.Ping(ctx) } diff --git a/internal/master/web/server.go b/internal/master/web/server.go new file mode 100644 index 0000000..bf81f1d --- /dev/null +++ b/internal/master/web/server.go @@ -0,0 +1,288 @@ +// Package web serves the master's htmx UI. +package web + +import ( + "context" + "embed" + "errors" + "fmt" + "html/template" + "log/slog" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "github.com/shcizo/package-updater/internal/agenttoken" + "github.com/shcizo/package-updater/internal/master/events" + "github.com/shcizo/package-updater/internal/master/model" + "github.com/shcizo/package-updater/internal/master/webauth" +) + +//go:embed templates/*.html static/* +var assets embed.FS + +var agentNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`) + +// Store is the storage the UI reads and writes. *store.Store satisfies it. +type Store interface { + ListServices(ctx context.Context) ([]model.Service, error) + ListAgents(ctx context.Context) ([]model.Agent, error) + CreateAgent(ctx context.Context, name, tokenHash string) (model.Agent, error) + RevokeAgent(ctx context.Context, id int64, at time.Time) error +} + +// Hub reports and controls live agent connections. *hub.Hub satisfies it. +type Hub interface { + Online(agentID int64) bool + Disconnect(agentID int64) +} + +// Broker delivers change signals to open streams and accepts signals from +// UI actions. *events.Broker satisfies it. +type Broker interface { + Subscribe() (<-chan events.Topic, func()) + Publish(t events.Topic) +} + +// Config holds values shown in the "add agent" snippet. +type Config struct { + AgentURL string // what agents dial, e.g. wss://10.0.0.5:8090/agent + MasterKey string // the master's fingerprint + AgentImage string +} + +// Server renders the UI. +type Server struct { + store Store + hub Hub + broker Broker + cfg Config + log *slog.Logger + now func() time.Time + pages map[string]*template.Template +} + +// New parses the templates. Each page is its own template set so every page +// can define its own "content" block. +func New(store Store, hub Hub, broker Broker, cfg Config, log *slog.Logger) (*Server, error) { + funcs := template.FuncMap{"shortRef": shortRef} + pages := map[string]*template.Template{} + for _, page := range []string{"overview.html", "agents.html", "agent_created.html"} { + t, err := template.New(page).Funcs(funcs).ParseFS(assets, "templates/layout.html", "templates/"+page) + if err != nil { + return nil, fmt.Errorf("parse %s: %w", page, err) + } + pages[page] = t + } + return &Server{store: store, hub: hub, broker: broker, cfg: cfg, log: log, now: time.Now, pages: pages}, nil +} + +// Register adds the UI routes to mux. protect wraps every route except +// static assets, normally with webauth's Require. +func (s *Server) Register(mux *http.ServeMux, protect func(http.Handler) http.Handler) { + mux.Handle("GET /static/", http.FileServerFS(assets)) + route := func(pattern string, h http.HandlerFunc) { mux.Handle(pattern, protect(h)) } + route("GET /{$}", s.overview) + route("GET /fragments/services", s.servicesFragment) + route("GET /agents", s.agents) + route("GET /fragments/agents", s.agentsFragment) + route("POST /agents", s.createAgent) + route("POST /agents/{id}/revoke", s.revokeAgent) + route("GET /events", s.events) +} + +type pageData struct { + Title string + CSRF string + Groups []serviceGroup + Agents []agentRow + Error string + Created model.Agent + Snippet string +} + +func (s *Server) base(r *http.Request, title string) pageData { + sess, _ := webauth.SessionFrom(r.Context()) + return pageData{Title: title, CSRF: sess.CSRFToken} +} + +func (s *Server) render(w http.ResponseWriter, status int, page, block string, data pageData) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + if err := s.pages[page].ExecuteTemplate(w, block, data); err != nil { + s.log.Error("render_failed", "page", page, "block", block, "error", err) + } +} + +func (s *Server) serviceData(r *http.Request) (pageData, error) { + data := s.base(r, "Services") + services, err := s.store.ListServices(r.Context()) + if err != nil { + return data, err + } + data.Groups = groupServices(services) + return data, nil +} + +func (s *Server) overview(w http.ResponseWriter, r *http.Request) { + data, err := s.serviceData(r) + if err != nil { + s.fail(w, err) + return + } + s.render(w, http.StatusOK, "overview.html", "layout", data) +} + +func (s *Server) servicesFragment(w http.ResponseWriter, r *http.Request) { + data, err := s.serviceData(r) + if err != nil { + s.fail(w, err) + return + } + s.render(w, http.StatusOK, "overview.html", "services", data) +} + +func (s *Server) agentData(r *http.Request) (pageData, error) { + data := s.base(r, "Agents") + agents, err := s.store.ListAgents(r.Context()) + if err != nil { + return data, err + } + for _, a := range agents { + data.Agents = append(data.Agents, agentRow{Agent: a, Online: s.hub.Online(a.ID)}) + } + return data, nil +} + +func (s *Server) agents(w http.ResponseWriter, r *http.Request) { + data, err := s.agentData(r) + if err != nil { + s.fail(w, err) + return + } + s.render(w, http.StatusOK, "agents.html", "layout", data) +} + +func (s *Server) agentsFragment(w http.ResponseWriter, r *http.Request) { + data, err := s.agentData(r) + if err != nil { + s.fail(w, err) + return + } + s.render(w, http.StatusOK, "agents.html", "agents", data) +} + +func (s *Server) createAgent(w http.ResponseWriter, r *http.Request) { + name := strings.TrimSpace(r.FormValue("name")) + if !agentNamePattern.MatchString(name) { + s.agentsWithError(w, r, http.StatusBadRequest, "Agent names use lowercase letters, digits and dashes.") + return + } + token, err := agenttoken.Generate() + if err != nil { + s.fail(w, err) + return + } + agent, err := s.store.CreateAgent(r.Context(), name, agenttoken.Hash(token)) + if errors.Is(err, model.ErrConflict) { + s.agentsWithError(w, r, http.StatusConflict, fmt.Sprintf("An agent named %q already exists.", name)) + return + } + if err != nil { + s.fail(w, err) + return + } + data := s.base(r, "Agent created") + data.Created = agent + data.Snippet = agentSnippet(s.cfg, token) + w.Header().Set("Cache-Control", "no-store") // the page shows the one-time token + s.render(w, http.StatusOK, "agent_created.html", "layout", data) +} + +func (s *Server) agentsWithError(w http.ResponseWriter, r *http.Request, status int, msg string) { + data, err := s.agentData(r) + if err != nil { + s.fail(w, err) + return + } + data.Error = msg + s.render(w, status, "agents.html", "layout", data) +} + +func (s *Server) revokeAgent(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.Error(w, "invalid agent id", http.StatusBadRequest) + return + } + if err := s.store.RevokeAgent(r.Context(), id, s.now()); err != nil { + s.fail(w, err) + return + } + s.hub.Disconnect(id) + s.broker.Publish(events.Services) // the agent's services were marked removed + http.Redirect(w, r, "/agents", http.StatusSeeOther) +} + +// events streams change signals as Server-Sent Events. htmx's SSE extension +// turns "event: services" into a refetch of the services fragment. The event +// carries no state, so a dropped signal is recovered by the next one. +func (s *Server) events(w http.ResponseWriter, r *http.Request) { + rc := http.NewResponseController(w) + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Accel-Buffering", "no") // stop nginx from buffering the stream + + ch, cancel := s.broker.Subscribe() + defer cancel() + + if _, err := fmt.Fprint(w, ": connected\n\n"); err != nil { + return + } + if err := rc.Flush(); err != nil { + s.log.Error("sse_flush_unsupported", "error", err) + return + } + keepalive := time.NewTicker(25 * time.Second) + defer keepalive.Stop() + for { + var msg string + select { + case <-r.Context().Done(): + return + case t := <-ch: + msg = fmt.Sprintf("event: %s\ndata: changed\n\n", t) + case <-keepalive.C: + msg = ": keepalive\n\n" + } + if _, err := fmt.Fprint(w, msg); err != nil { + return + } + if err := rc.Flush(); err != nil { + return + } + } +} + +func (s *Server) fail(w http.ResponseWriter, err error) { + s.log.Error("request_failed", "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) +} + +// agentSnippet is the compose file shown once when an agent is added. +func agentSnippet(cfg Config, token string) string { + return fmt.Sprintf(`services: + updater-agent: + image: %s + entrypoint: ["/usr/local/bin/updater-agent"] + restart: unless-stopped + environment: + MASTER_URL: %s + MASTER_KEY: %s + AGENT_TOKEN: %s + volumes: + - /var/run/docker.sock:/var/run/docker.sock +`, cfg.AgentImage, cfg.AgentURL, cfg.MasterKey, token) +} diff --git a/internal/master/web/server_test.go b/internal/master/web/server_test.go new file mode 100644 index 0000000..8cec09d --- /dev/null +++ b/internal/master/web/server_test.go @@ -0,0 +1,265 @@ +package web + +import ( + "bufio" + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/shcizo/package-updater/internal/agenttoken" + "github.com/shcizo/package-updater/internal/master/events" + "github.com/shcizo/package-updater/internal/master/model" + "github.com/shcizo/package-updater/internal/protocol" +) + +// fakeStore is written from the HTTP handler goroutine and read by the test +// goroutine, so every access goes through mu. +type fakeStore struct { + mu sync.Mutex + services []model.Service + agents []model.Agent + created map[string]string // name → token hash + revokedID int64 +} + +func (f *fakeStore) ListServices(context.Context) ([]model.Service, error) { return f.services, nil } +func (f *fakeStore) ListAgents(context.Context) ([]model.Agent, error) { return f.agents, nil } + +func (f *fakeStore) CreateAgent(_ context.Context, name, hash string) (model.Agent, error) { + f.mu.Lock() + defer f.mu.Unlock() + if _, taken := f.created[name]; taken { + return model.Agent{}, model.ErrConflict + } + f.created[name] = hash + return model.Agent{ID: 9, Name: name}, nil +} + +func (f *fakeStore) RevokeAgent(_ context.Context, id int64, _ time.Time) error { + f.mu.Lock() + defer f.mu.Unlock() + f.revokedID = id + return nil +} + +func (f *fakeStore) createdHash(name string) string { + f.mu.Lock() + defer f.mu.Unlock() + return f.created[name] +} + +func (f *fakeStore) revoked() int64 { + f.mu.Lock() + defer f.mu.Unlock() + return f.revokedID +} + +type fakeHub struct { + mu sync.Mutex + online map[int64]bool + disconnected int64 +} + +func (h *fakeHub) Online(id int64) bool { + h.mu.Lock() + defer h.mu.Unlock() + return h.online[id] +} + +func (h *fakeHub) Disconnect(id int64) { + h.mu.Lock() + defer h.mu.Unlock() + h.disconnected = id +} + +func (h *fakeHub) lastDisconnected() int64 { + h.mu.Lock() + defer h.mu.Unlock() + return h.disconnected +} + +func newTestServer(t *testing.T) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker) { + t.Helper() + agentID := int64(2) + store := &fakeStore{ + created: map[string]string{"node-2": "x"}, + services: []model.Service{ + {Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web", AgentID: &agentID, AgentName: "node-2", + ActualRef: "gitea.shcizo.se/shcizo/slusk:1.4.2", Status: "running", UpdateMode: "manual"}, + {Kind: protocol.KindSwarm, SwarmService: "jellyfin_jellyfin", + ActualRef: "lscr.io/linuxserver/jellyfin@sha256:0f42497a69fa0441bfd5f9d6bba8694f2a656ec984e571d0ac04c6dd91250039", Status: "degraded", UpdateMode: "manual"}, + {Kind: protocol.KindContainer, ContainerName: "updater-agent", AgentID: &agentID, AgentName: "node-2", + ActualRef: "pu:1", Status: "running", UpdateMode: "manual", Self: true}, + }, + agents: []model.Agent{ + {ID: 2, Name: "node-2", Hostname: "node-2"}, + {ID: 3, Name: "node-3"}, + {ID: 4, Name: "old", RevokedAt: ptr(time.Now())}, + }, + } + hub := &fakeHub{online: map[int64]bool{2: true}} + broker := events.NewBroker() + s, err := New(store, hub, broker, Config{ + AgentURL: "wss://10.0.0.5:8090/agent", MasterKey: "sha256:abc", AgentImage: "gitea.shcizo.se/shcizo/package-updater:latest", + }, slog.New(slog.NewTextHandler(io.Discard, nil))) + require.NoError(t, err) + mux := http.NewServeMux() + s.Register(mux, func(h http.Handler) http.Handler { return h }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv, store, hub, broker +} + +func ptr[T any](v T) *T { return &v } + +func get(t *testing.T, srv *httptest.Server, path string) (int, string) { + t.Helper() + resp, err := http.Get(srv.URL + path) + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + return resp.StatusCode, string(body) +} + +func noRedirectClient() *http.Client { + return &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} +} + +func TestOverview_GroupsServicesPerMachineAndSwarm(t *testing.T) { + srv, _, _, _ := newTestServer(t) + code, body := get(t, srv, "/") + assert.Equal(t, http.StatusOK, code) + assert.Contains(t, body, " 0) { + const childrenToFix = elt.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]") + for (let i = 0; i < childrenToFix.length; i++) { + registerSSE(childrenToFix[i]) + } + // We want to increase the reconnection delay for consecutive failed attempts only + retryCount = 0 + } + } + + api.getInternalData(elt).sseEventSource = source + + + var closeAttribute = api.getAttributeValue(elt, "sse-close"); + if (closeAttribute) { + // close eventsource when this message is received + source.addEventListener(closeAttribute, function() { + api.triggerEvent(elt, 'htmx:sseClose', { + source, + type: 'message', + }) + source.close() + }); + } + } + + /** + * maybeCloseSSESource confirms that the parent element still exists. + * If not, then any associated SSE source is closed and the function returns true. + * + * @param {HTMLElement} elt + * @returns boolean + */ + function maybeCloseSSESource(elt) { + if (!api.bodyContains(elt)) { + var source = api.getInternalData(elt).sseEventSource + if (source != undefined) { + api.triggerEvent(elt, 'htmx:sseClose', { + source, + type: 'nodeMissing', + }) + source.close() + // source = null + return true + } + } + return false + } + + + /** + * @param {HTMLElement} elt + * @param {string} content + */ + function swap(elt, content) { + api.withExtensions(elt, function(extension) { + content = extension.transformResponse(content, null, elt) + }) + + var swapSpec = api.getSwapSpecification(elt) + var target = api.getTarget(elt) + api.swap(target, content, swapSpec) + } + + + function hasEventSource(node) { + return api.getInternalData(node).sseEventSource != null + } +})() diff --git a/internal/master/web/static/htmx.min.js b/internal/master/web/static/htmx.min.js new file mode 100644 index 0000000..59937d7 --- /dev/null +++ b/internal/master/web/static/htmx.min.js @@ -0,0 +1 @@ +var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.4"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=he;Q.ajax=Rn;Q.find=u;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:hn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:o,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:ht,triggerEvent:he,triggerErrorEvent:fe,withExtensions:Ft};const r=["get","post","put","delete","patch"];const H=r.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function o(e,t){while(e&&!t(e)){e=c(e)}return e||null}function i(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;o(t,function(e){return!!(r=i(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function A(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function N(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(N(e)){const t=A(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){O(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/
]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q(''+t+"");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function D(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function M(t){const n=[];if(t){for(let e=0;eThe token is shown only once. Save this as docker-compose.yml on the machine and run docker compose up -d.
{{.Snippet}}
+
+{{end}}
diff --git a/internal/master/web/templates/agents.html b/internal/master/web/templates/agents.html
new file mode 100644
index 0000000..b0ead31
--- /dev/null
+++ b/internal/master/web/templates/agents.html
@@ -0,0 +1,38 @@
+{{define "content"}}
+{{.Error}}
{{end}} +| Name | Hostname | State | Version | Swarm manager | Last seen | |
|---|---|---|---|---|---|---|
| {{.Name}} | +{{.Hostname}} | +{{if .RevokedAt}}revoked{{else if .Online}}online{{else}}offline{{end}} | +{{.AgentVersion}} | +{{if .SwarmManager}}yes{{end}} | +{{with .LastSeenAt}}{{.Format "2006-01-02 15:04"}}{{end}} | +{{if not .RevokedAt}} + + {{end}} | +
| No agents yet. | ||||||
| Service | Image | Status | Mode |
|---|---|---|---|
| {{.DisplayName}}{{if .Self}} managed outside{{end}} | +{{shortRef .ActualRef}} |
+ {{.Status}} | +{{.UpdateMode}} | +
No services reported yet. Add an agent to get started.
+{{end}} +{{end}} diff --git a/internal/master/web/views.go b/internal/master/web/views.go new file mode 100644 index 0000000..0d585a0 --- /dev/null +++ b/internal/master/web/views.go @@ -0,0 +1,51 @@ +package web + +import ( + "strings" + + "github.com/shcizo/package-updater/internal/imageref" + "github.com/shcizo/package-updater/internal/master/model" + "github.com/shcizo/package-updater/internal/protocol" +) + +type serviceGroup struct { + Title string + Services []model.Service +} + +// groupServices groups machine-bound services per agent and Swarm services +// in one group, keeping the store's order (machines first, Swarm last). +func groupServices(services []model.Service) []serviceGroup { + var groups []serviceGroup + index := map[string]int{} + for _, s := range services { + title := s.AgentName + if s.Kind == protocol.KindSwarm { + title = "Swarm" + } else if title == "" { + title = "Unknown agent" + } + i, ok := index[title] + if !ok { + i = len(groups) + index[title] = i + groups = append(groups, serviceGroup{Title: title}) + } + groups[i].Services = append(groups[i].Services, s) + } + return groups +} + +// shortRef shortens a digest in an image reference to 12 hex characters. +func shortRef(ref string) string { + r := imageref.Parse(ref) + if hex, ok := strings.CutPrefix(r.Digest, "sha256:"); ok && len(hex) > 12 { + r.Digest = "sha256:" + hex[:12] + "…" + } + return r.String() +} + +type agentRow struct { + model.Agent + Online bool +} diff --git a/internal/master/webauth/auth.go b/internal/master/webauth/auth.go new file mode 100644 index 0000000..031d846 --- /dev/null +++ b/internal/master/webauth/auth.go @@ -0,0 +1,184 @@ +// Package webauth logs people into the master UI through OIDC (Authentik), +// keeps their sessions, and protects state-changing requests with a CSRF +// token. +package webauth + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "log/slog" + "net/http" + "slices" + "strings" + "time" + + "golang.org/x/oauth2" + + "github.com/shcizo/package-updater/internal/master/model" +) + +const ( + sessionCookieName = "pu_session" + flowCookieName = "pu_oidc_flow" + sessionTTL = 12 * time.Hour +) + +// Store keeps sessions. *store.Store satisfies it. +type Store interface { + CreateSession(ctx context.Context, s model.Session) error + GetSession(ctx context.Context, id string, now time.Time) (model.Session, error) + DeleteSession(ctx context.Context, id string) error +} + +// Auth holds the login handlers and the session middleware. +type Auth struct { + provider Provider + store Store + allowedGroup string + secure bool + log *slog.Logger + now func() time.Time +} + +// New returns an Auth. Only members of allowedGroup may log in. secureCookies +// should be true whenever the UI is served over HTTPS. +func New(p Provider, s Store, allowedGroup string, secureCookies bool, log *slog.Logger) *Auth { + return &Auth{provider: p, store: s, allowedGroup: allowedGroup, secure: secureCookies, log: log, now: time.Now} +} + +// Login starts the OIDC flow. State and PKCE verifier travel in a short-lived +// cookie scoped to /auth/; tampering with it can only break the user's own +// login. +func (a *Auth) Login(w http.ResponseWriter, r *http.Request) { + state := randomString() + verifier := oauth2.GenerateVerifier() + http.SetCookie(w, &http.Cookie{ + Name: flowCookieName, Value: state + "." + verifier, Path: "/auth/", + MaxAge: 600, HttpOnly: true, Secure: a.secure, SameSite: http.SameSiteLaxMode, + }) + http.Redirect(w, r, a.provider.AuthCodeURL(state, verifier), http.StatusFound) +} + +// Callback finishes the OIDC flow, checks group membership and creates the +// session. +func (a *Auth) Callback(w http.ResponseWriter, r *http.Request) { + flow, err := r.Cookie(flowCookieName) + if err != nil { + http.Error(w, "login flow expired, start again", http.StatusBadRequest) + return + } + state, verifier, ok := strings.Cut(flow.Value, ".") + if !ok || state == "" || subtle.ConstantTimeCompare([]byte(r.URL.Query().Get("state")), []byte(state)) != 1 { + http.Error(w, "invalid login state", http.StatusBadRequest) + return + } + http.SetCookie(w, &http.Cookie{Name: flowCookieName, Path: "/auth/", MaxAge: -1}) + + claims, err := a.provider.Exchange(r.Context(), r.URL.Query().Get("code"), verifier) + if err != nil { + a.log.Warn("login_failed", "error", err) + http.Error(w, "login failed", http.StatusUnauthorized) + return + } + if !slices.Contains(claims.Groups, a.allowedGroup) { + a.log.Warn("login_forbidden", "subject", claims.Subject, "required_group", a.allowedGroup) + http.Error(w, "forbidden: you are not a member of the group allowed to use package-updater", http.StatusForbidden) + return + } + + sess := model.Session{ + ID: randomString(), Subject: claims.Subject, Name: claims.Name, Email: claims.Email, + CSRFToken: randomString(), ExpiresAt: a.now().Add(sessionTTL), + } + if err := a.store.CreateSession(r.Context(), sess); err != nil { + a.log.Error("create_session_failed", "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, Value: sess.ID, Path: "/", Expires: sess.ExpiresAt, + HttpOnly: true, Secure: a.secure, SameSite: http.SameSiteLaxMode, + }) + a.log.Info("login", "subject", claims.Subject, "name", claims.Name) + http.Redirect(w, r, "/", http.StatusFound) +} + +// Logout ends the session. Mount behind Require so it is CSRF-protected. +func (a *Auth) Logout(w http.ResponseWriter, r *http.Request) { + if s, ok := SessionFrom(r.Context()); ok { + if err := a.store.DeleteSession(r.Context(), s.ID); err != nil { + a.log.Error("delete_session_failed", "error", err) + } + } + http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Path: "/", MaxAge: -1}) + _, _ = w.Write([]byte("Logged out.")) +} + +// Require lets a request through only with a valid session, and for +// anything but GET/HEAD only with the session's CSRF token in the +// X-CSRF-Token header or the csrf_token form field. htmx requests without a +// session get 401 + HX-Redirect so the whole page navigates to the login, +// instead of the login page being swapped into a fragment. Event-stream +// (SSE) requests without a session get a plain 401: a redirect would make +// the browser's EventSource retry /auth/login repeatedly and overwrite the +// login flow cookie of a login in progress in another tab. +func (a *Auth) Require(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sess, err := a.session(r) + if err != nil && !errors.Is(err, model.ErrNotFound) { + a.log.Error("session_lookup_failed", "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if err != nil { + switch { + case r.Header.Get("HX-Request") == "true": + w.Header().Set("HX-Redirect", "/auth/login") + w.WriteHeader(http.StatusUnauthorized) + case strings.Contains(r.Header.Get("Accept"), "text/event-stream"): + w.WriteHeader(http.StatusUnauthorized) + case r.Method == http.MethodGet: + http.Redirect(w, r, "/auth/login", http.StatusFound) + default: + http.Error(w, "unauthorized", http.StatusUnauthorized) + } + return + } + if r.Method != http.MethodGet && r.Method != http.MethodHead { + token := r.Header.Get("X-CSRF-Token") + if token == "" { + token = r.PostFormValue("csrf_token") // body only: a token in the URL leaks via logs and Referer + } + if subtle.ConstantTimeCompare([]byte(token), []byte(sess.CSRFToken)) != 1 { + http.Error(w, "invalid CSRF token", http.StatusForbidden) + return + } + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), sessionKey{}, sess))) + }) +} + +func (a *Auth) session(r *http.Request) (model.Session, error) { + c, err := r.Cookie(sessionCookieName) + if err != nil { + return model.Session{}, model.ErrNotFound + } + return a.store.GetSession(r.Context(), c.Value, a.now()) +} + +type sessionKey struct{} + +// SessionFrom returns the session Require stored in ctx. +func SessionFrom(ctx context.Context) (model.Session, bool) { + s, ok := ctx.Value(sessionKey{}).(model.Session) + return s, ok +} + +func randomString() string { + var b [32]byte + _, _ = rand.Read(b[:]) + return base64.RawURLEncoding.EncodeToString(b[:]) +} diff --git a/internal/master/webauth/auth_test.go b/internal/master/webauth/auth_test.go new file mode 100644 index 0000000..30f6cb8 --- /dev/null +++ b/internal/master/webauth/auth_test.go @@ -0,0 +1,249 @@ +package webauth + +import ( + "context" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/shcizo/package-updater/internal/master/model" +) + +type fakeProvider struct { + claims Claims + err error + gotVer string +} + +func (p *fakeProvider) AuthCodeURL(state, _ string) string { + return "https://auth.example/authorize?state=" + url.QueryEscape(state) +} + +func (p *fakeProvider) Exchange(_ context.Context, _ string, verifier string) (Claims, error) { + p.gotVer = verifier + return p.claims, p.err +} + +type fakeStore struct { + mu sync.Mutex + sessions map[string]model.Session +} + +func newFakeStore() *fakeStore { return &fakeStore{sessions: map[string]model.Session{}} } + +func (f *fakeStore) CreateSession(_ context.Context, s model.Session) error { + f.mu.Lock() + defer f.mu.Unlock() + f.sessions[s.ID] = s + return nil +} + +func (f *fakeStore) GetSession(_ context.Context, id string, now time.Time) (model.Session, error) { + f.mu.Lock() + defer f.mu.Unlock() + s, ok := f.sessions[id] + if !ok || !s.ExpiresAt.After(now) { + return model.Session{}, model.ErrNotFound + } + return s, nil +} + +func (f *fakeStore) DeleteSession(_ context.Context, id string) error { + f.mu.Lock() + defer f.mu.Unlock() + delete(f.sessions, id) + return nil +} + +func newAuth(p *fakeProvider, s *fakeStore) *Auth { + return New(p, s, "updater-admins", true, slog.New(slog.NewTextHandler(io.Discard, nil))) +} + +// startLogin runs /auth/login and returns the flow cookie and the state. +func startLogin(t *testing.T, a *Auth) (*http.Cookie, string) { + t.Helper() + rec := httptest.NewRecorder() + a.Login(rec, httptest.NewRequest(http.MethodGet, "/auth/login", nil)) + require.Equal(t, http.StatusFound, rec.Code) + loc, err := url.Parse(rec.Header().Get("Location")) + require.NoError(t, err) + cookies := rec.Result().Cookies() + require.Len(t, cookies, 1) + return cookies[0], loc.Query().Get("state") +} + +func callback(a *Auth, flow *http.Cookie, state string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=c&state="+url.QueryEscape(state), nil) + if flow != nil { + req.AddCookie(flow) + } + rec := httptest.NewRecorder() + a.Callback(rec, req) + return rec +} + +func TestLogin_SetsFlowCookieAndRedirects(t *testing.T) { + a := newAuth(&fakeProvider{}, newFakeStore()) + flow, state := startLogin(t, a) + assert.True(t, flow.HttpOnly) + assert.True(t, flow.Secure) + assert.NotEmpty(t, state) +} + +func TestCallback_InvalidStateRejected(t *testing.T) { + a := newAuth(&fakeProvider{claims: Claims{Subject: "u", Groups: []string{"updater-admins"}}}, newFakeStore()) + flow, _ := startLogin(t, a) + emptyStateFlow := &http.Cookie{Name: flowCookieName, Value: ".verifier"} + cases := []struct { + name string + flow *http.Cookie + state string + }{ + {"state mismatch", flow, "forged"}, + {"empty state in cookie and query", emptyStateFlow, ""}, + {"missing flow cookie", nil, ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + rec := callback(a, c.flow, c.state) + assert.Equal(t, http.StatusBadRequest, rec.Code) + }) + } +} + +func TestCallback_UserOutsideGroupForbidden(t *testing.T) { + store := newFakeStore() + a := newAuth(&fakeProvider{claims: Claims{Subject: "u", Groups: []string{"family"}}}, store) + flow, state := startLogin(t, a) + rec := callback(a, flow, state) + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.Empty(t, store.sessions) +} + +func TestCallback_ExchangeFailure(t *testing.T) { + a := newAuth(&fakeProvider{err: errors.New("bad code")}, newFakeStore()) + flow, state := startLogin(t, a) + assert.Equal(t, http.StatusUnauthorized, callback(a, flow, state).Code) +} + +func TestCallback_CreatesSession(t *testing.T) { + store := newFakeStore() + p := &fakeProvider{claims: Claims{Subject: "u1", Name: "Samuel", Groups: []string{"updater-admins"}}} + a := newAuth(p, store) + flow, state := startLogin(t, a) + rec := callback(a, flow, state) + + require.Equal(t, http.StatusFound, rec.Code) + assert.Equal(t, "/", rec.Header().Get("Location")) + assert.NotEmpty(t, p.gotVer, "the PKCE verifier from the flow cookie must reach the exchange") + var sessionCookie *http.Cookie + for _, c := range rec.Result().Cookies() { + if c.Name == sessionCookieName { + sessionCookie = c + } + } + require.NotNil(t, sessionCookie) + assert.True(t, sessionCookie.HttpOnly) + assert.Equal(t, http.SameSiteLaxMode, sessionCookie.SameSite) + s, ok := store.sessions[sessionCookie.Value] + require.True(t, ok) + assert.Equal(t, "Samuel", s.Name) + assert.NotEmpty(t, s.CSRFToken) +} + +func protectedRequest(method, target string, sessionID string) *http.Request { + req := httptest.NewRequest(method, target, nil) + if sessionID != "" { + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: sessionID}) + } + return req +} + +func TestRequire(t *testing.T) { + store := newFakeStore() + store.sessions["live"] = model.Session{ID: "live", Name: "Samuel", CSRFToken: "tok", ExpiresAt: time.Now().Add(time.Hour)} + store.sessions["old"] = model.Session{ID: "old", CSRFToken: "tok", ExpiresAt: time.Now().Add(-time.Hour)} + a := newAuth(&fakeProvider{}, store) + + var gotName string + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s, _ := SessionFrom(r.Context()) + gotName = s.Name + w.WriteHeader(http.StatusNoContent) + }) + h := a.Require(next) + + cases := []struct { + name string + req *http.Request + wantCode int + wantHeader [2]string + }{ + {"no session GET redirects to login", protectedRequest(http.MethodGet, "/", ""), http.StatusFound, [2]string{"Location", "/auth/login"}}, + {"expired session on htmx fragment", func() *http.Request { + r := protectedRequest(http.MethodGet, "/fragments/services", "old") + r.Header.Set("HX-Request", "true") + return r + }(), http.StatusUnauthorized, [2]string{"HX-Redirect", "/auth/login"}}, + {"event-stream without session gets plain 401", func() *http.Request { + r := protectedRequest(http.MethodGet, "/events", "") + r.Header.Set("Accept", "text/event-stream") + return r + }(), http.StatusUnauthorized, [2]string{"Location", ""}}, + {"no session POST", protectedRequest(http.MethodPost, "/agents", ""), http.StatusUnauthorized, [2]string{}}, + {"POST without CSRF", protectedRequest(http.MethodPost, "/agents", "live"), http.StatusForbidden, [2]string{}}, + {"POST with CSRF header", func() *http.Request { + r := protectedRequest(http.MethodPost, "/agents", "live") + r.Header.Set("X-CSRF-Token", "tok") + return r + }(), http.StatusNoContent, [2]string{}}, + {"POST with CSRF form field", func() *http.Request { + r := httptest.NewRequest(http.MethodPost, "/agents", strings.NewReader("csrf_token=tok")) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "live"}) + return r + }(), http.StatusNoContent, [2]string{}}, + {"POST with CSRF only in query string", func() *http.Request { + r := httptest.NewRequest(http.MethodPost, "/agents?csrf_token=tok", strings.NewReader("name=x")) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "live"}) + return r + }(), http.StatusForbidden, [2]string{}}, + {"GET with session", protectedRequest(http.MethodGet, "/", "live"), http.StatusNoContent, [2]string{}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, c.req) + assert.Equal(t, c.wantCode, rec.Code) + if c.wantHeader[0] != "" { + assert.Equal(t, c.wantHeader[1], rec.Header().Get(c.wantHeader[0])) + } + }) + } + assert.Equal(t, "Samuel", gotName) +} + +func TestLogout_DeletesSession(t *testing.T) { + store := newFakeStore() + store.sessions["live"] = model.Session{ID: "live", CSRFToken: "tok", ExpiresAt: time.Now().Add(time.Hour)} + a := newAuth(&fakeProvider{}, store) + + req := protectedRequest(http.MethodPost, "/auth/logout", "live") + req.Header.Set("X-CSRF-Token", "tok") + rec := httptest.NewRecorder() + a.Require(http.HandlerFunc(a.Logout)).ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Empty(t, store.sessions) +} diff --git a/internal/master/webauth/provider.go b/internal/master/webauth/provider.go new file mode 100644 index 0000000..442b675 --- /dev/null +++ b/internal/master/webauth/provider.go @@ -0,0 +1,85 @@ +package webauth + +import ( + "context" + "errors" + "fmt" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +// Claims is what the master needs from the identity provider. +type Claims struct { + Subject string + Name string + Email string + Groups []string +} + +// Provider performs the OIDC authorization code flow with PKCE. Declared as +// an interface so handlers can be tested without a real identity provider. +type Provider interface { + AuthCodeURL(state, verifier string) string + Exchange(ctx context.Context, code, verifier string) (Claims, error) +} + +// OIDCConfig configures the real provider. +type OIDCConfig struct { + Issuer string // e.g. https://auth.example.com/application/o/package-updater/ + ClientID string + ClientSecret string + RedirectURL string // PUBLIC_URL + /auth/callback +} + +type oidcProvider struct { + oauth *oauth2.Config + verifier *oidc.IDTokenVerifier +} + +// NewOIDCProvider discovers the issuer's endpoints. It makes a network call, +// so the master fails at startup if Authentik is unreachable. +func NewOIDCProvider(ctx context.Context, cfg OIDCConfig) (Provider, error) { + p, err := oidc.NewProvider(ctx, cfg.Issuer) + if err != nil { + return nil, fmt.Errorf("oidc discovery: %w", err) + } + return &oidcProvider{ + oauth: &oauth2.Config{ + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + Endpoint: p.Endpoint(), + RedirectURL: cfg.RedirectURL, + Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, + }, + verifier: p.Verifier(&oidc.Config{ClientID: cfg.ClientID}), + }, nil +} + +func (p *oidcProvider) AuthCodeURL(state, verifier string) string { + return p.oauth.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier)) +} + +func (p *oidcProvider) Exchange(ctx context.Context, code, verifier string) (Claims, error) { + tok, err := p.oauth.Exchange(ctx, code, oauth2.VerifierOption(verifier)) + if err != nil { + return Claims{}, fmt.Errorf("exchange code: %w", err) + } + raw, ok := tok.Extra("id_token").(string) + if !ok { + return Claims{}, errors.New("token response has no id_token") + } + idt, err := p.verifier.Verify(ctx, raw) + if err != nil { + return Claims{}, fmt.Errorf("verify id_token: %w", err) + } + var c struct { + Name string `json:"name"` + Email string `json:"email"` + Groups []string `json:"groups"` + } + if err := idt.Claims(&c); err != nil { + return Claims{}, fmt.Errorf("read claims: %w", err) + } + return Claims{Subject: idt.Subject, Name: c.Name, Email: c.Email, Groups: c.Groups}, nil +} diff --git a/internal/pin/pin.go b/internal/pin/pin.go new file mode 100644 index 0000000..b5f0bb0 --- /dev/null +++ b/internal/pin/pin.go @@ -0,0 +1,91 @@ +// Package pin lets agents trust the master without a certificate authority. +// The master serves a self-signed certificate for its own key pair; agents +// are configured with the fingerprint of that public key and accept the +// connection only if it matches. The TLS handshake proves the master holds +// the private key, the same way SSH host keys work. +package pin + +import ( + "crypto" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "errors" + "fmt" + "math/big" + "time" +) + +// GenerateKey creates a new master key pair. +func GenerateKey() (ed25519.PrivateKey, error) { + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("generate key: %w", err) + } + return priv, nil +} + +// Certificate returns a self-signed TLS certificate for key. Its validity is +// effectively unlimited because trust comes from the pinned fingerprint, not +// from the certificate's dates or issuer. +func Certificate(key ed25519.PrivateKey) (tls.Certificate, error) { + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "package-updater-master"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().AddDate(100, 0, 0), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key) + if err != nil { + return tls.Certificate{}, fmt.Errorf("create certificate: %w", err) + } + return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}, nil +} + +// Fingerprint returns "sha256:" + hex SHA-256 of pub's PKIX DER encoding. +// This is the MASTER_KEY value agents are configured with. +func Fingerprint(pub crypto.PublicKey) (string, error) { + der, err := x509.MarshalPKIXPublicKey(pub) + if err != nil { + return "", fmt.Errorf("marshal public key: %w", err) + } + sum := sha256.Sum256(der) + return "sha256:" + hex.EncodeToString(sum[:]), nil +} + +// ClientConfig returns a TLS config that accepts only a server whose +// certificate carries the public key with the given fingerprint. +func ClientConfig(fingerprint string) *tls.Config { + return &tls.Config{ + MinVersion: tls.VersionTLS13, + // Chain and hostname verification are replaced by the fingerprint + // check below: the master's certificate is self-signed by design. + // The handshake still verifies the server's signature with the + // certificate's key, so presenting a copied certificate fails. + InsecureSkipVerify: true, //nolint:gosec // see comment above + VerifyPeerCertificate: func(raw [][]byte, _ [][]*x509.Certificate) error { + if len(raw) == 0 { + return errors.New("pin: server sent no certificate") + } + cert, err := x509.ParseCertificate(raw[0]) + if err != nil { + return fmt.Errorf("pin: parse certificate: %w", err) + } + got, err := Fingerprint(cert.PublicKey) + if err != nil { + return err + } + if subtle.ConstantTimeCompare([]byte(got), []byte(fingerprint)) != 1 { + return fmt.Errorf("pin: fingerprint mismatch: server presented %s", got) + } + return nil + }, + } +} diff --git a/internal/pin/pin_test.go b/internal/pin/pin_test.go new file mode 100644 index 0000000..9280bdb --- /dev/null +++ b/internal/pin/pin_test.go @@ -0,0 +1,61 @@ +package pin + +import ( + "crypto/tls" + "net/http" + "net/http/httptest" + "regexp" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newPinnedServer(t *testing.T) (*httptest.Server, string) { + t.Helper() + key, err := GenerateKey() + require.NoError(t, err) + cert, err := Certificate(key) + require.NoError(t, err) + fp, err := Fingerprint(key.Public()) + require.NoError(t, err) + + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("ok")) + })) + srv.TLS = &tls.Config{Certificates: []tls.Certificate{cert}} + srv.StartTLS() + t.Cleanup(srv.Close) + return srv, fp +} + +func clientFor(fp string) *http.Client { + return &http.Client{Transport: &http.Transport{TLSClientConfig: ClientConfig(fp)}} +} + +func TestFingerprintFormat(t *testing.T) { + key, err := GenerateKey() + require.NoError(t, err) + fp, err := Fingerprint(key.Public()) + require.NoError(t, err) + assert.Regexp(t, regexp.MustCompile(`^sha256:[0-9a-f]{64}$`), fp) +} + +func TestClientConfigAcceptsPinnedKey(t *testing.T) { + srv, fp := newPinnedServer(t) + resp, err := clientFor(fp).Get(srv.URL) + require.NoError(t, err) + _ = resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestClientConfigRejectsOtherKey(t *testing.T) { + srv, _ := newPinnedServer(t) + other, err := GenerateKey() + require.NoError(t, err) + otherFP, err := Fingerprint(other.Public()) + require.NoError(t, err) + + _, err = clientFor(otherFP).Get(srv.URL) + assert.ErrorContains(t, err, "fingerprint mismatch") +} diff --git a/internal/protocol/protocol.go b/internal/protocol/protocol.go new file mode 100644 index 0000000..a4aa9bb --- /dev/null +++ b/internal/protocol/protocol.go @@ -0,0 +1,102 @@ +// Package protocol defines the messages agents and the master exchange over +// their WebSocket connection. Every message is an Envelope whose Payload is +// one of the payload types below, selected by Type. +package protocol + +import ( + "encoding/json" + "fmt" +) + +// Message types sent from agent to master. +const ( + TypeHello = "hello" + TypeSnapshot = "snapshot" + TypeUpsert = "upsert" + TypeRemove = "remove" +) + +// Envelope wraps every message on the wire. +type Envelope struct { + Type string `json:"type"` + Payload json.RawMessage `json:"payload,omitempty"` +} + +// Hello is the first message an agent sends on every connection. +type Hello struct { + AgentVersion string `json:"agent_version"` + Hostname string `json:"hostname"` + SwarmManager bool `json:"swarm_manager"` + SwarmClusterID string `json:"swarm_cluster_id,omitempty"` +} + +// Kind says how a service is deployed. +type Kind string + +// Service kinds. +const ( + KindCompose Kind = "compose" + KindSwarm Kind = "swarm" + KindContainer Kind = "container" +) + +// Service is one deployable unit as an agent sees it. Which location fields +// are set depends on Kind. +type Service struct { + Kind Kind `json:"kind"` + ComposeProject string `json:"compose_project,omitempty"` + ComposeService string `json:"compose_service,omitempty"` + WorkingDir string `json:"working_dir,omitempty"` + SwarmService string `json:"swarm_service,omitempty"` + ContainerName string `json:"container_name,omitempty"` + Image string `json:"image"` + Digest string `json:"digest,omitempty"` + Status string `json:"status"` + Self bool `json:"self,omitempty"` +} + +// Key identifies the service within one agent's report. It is stable across +// restarts and is what Remove refers to. +func (s Service) Key() string { + switch s.Kind { + case KindCompose: + return "compose|" + s.ComposeProject + "|" + s.ComposeService + case KindSwarm: + return "swarm|" + s.SwarmService + default: + return "container|" + s.ContainerName + } +} + +// Snapshot is the agent's complete inventory. Sent after every Hello. +type Snapshot struct { + Services []Service `json:"services"` +} + +// Upsert reports a service that appeared or changed. +type Upsert struct { + Service Service `json:"service"` +} + +// Remove reports that the service with Key is gone. +type Remove struct { + Key string `json:"key"` +} + +// Encode wraps payload in an Envelope of the given type. +func Encode(typ string, payload any) (Envelope, error) { + raw, err := json.Marshal(payload) + if err != nil { + return Envelope{}, fmt.Errorf("encode %s: %w", typ, err) + } + return Envelope{Type: typ, Payload: raw}, nil +} + +// Decode unpacks env's payload into T. +func Decode[T any](env Envelope) (T, error) { + var v T + if err := json.Unmarshal(env.Payload, &v); err != nil { + return v, fmt.Errorf("decode %s: %w", env.Type, err) + } + return v, nil +} diff --git a/internal/protocol/protocol_test.go b/internal/protocol/protocol_test.go new file mode 100644 index 0000000..4c288cb --- /dev/null +++ b/internal/protocol/protocol_test.go @@ -0,0 +1,49 @@ +package protocol + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestServiceKey(t *testing.T) { + cases := []struct { + name string + svc Service + want string + }{ + {"compose", Service{Kind: KindCompose, ComposeProject: "slusk", ComposeService: "web"}, "compose|slusk|web"}, + {"swarm", Service{Kind: KindSwarm, SwarmService: "gitea_gitea"}, "swarm|gitea_gitea"}, + {"container", Service{Kind: KindContainer, ContainerName: "adhoc"}, "container|adhoc"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + assert.Equal(t, c.want, c.svc.Key()) + }) + } +} + +func TestEncodeDecodeRoundTrip(t *testing.T) { + in := Snapshot{Services: []Service{ + {Kind: KindCompose, ComposeProject: "slusk", ComposeService: "web", Image: "slusk:1.4.2", Status: "running"}, + }} + env, err := Encode(TypeSnapshot, in) + require.NoError(t, err) + + raw, err := json.Marshal(env) + require.NoError(t, err) + var back Envelope + require.NoError(t, json.Unmarshal(raw, &back)) + + assert.Equal(t, TypeSnapshot, back.Type) + out, err := Decode[Snapshot](back) + require.NoError(t, err) + assert.Equal(t, in, out) +} + +func TestDecodeRejectsMalformedPayload(t *testing.T) { + _, err := Decode[Hello](Envelope{Type: TypeHello, Payload: json.RawMessage(`{"hostname": 5}`)}) + assert.ErrorContains(t, err, "decode hello") +}