From 811157b4ce88dccad65d9fc37b3b93798aeac737 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Fri, 1 May 2026 11:20:41 +0100 Subject: [PATCH] =?UTF-8?q?server:=20drop=20in-process=20TLS=20=E2=80=94?= =?UTF-8?q?=20HTTP-only=20behind=20reverse=20proxy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-hosted deployments already terminate TLS at Caddy/Traefik/nginx; making the server do TLS too means double cert config, dual ACME plumbing, and an untested code path. Drop RM_TLS_CERT/RM_TLS_KEY, remove TLSEnabled() and the ListenAndServeTLS branch. Replace the cookie's "Secure if TLS-in-process" check with a new RM_COOKIE_SECURE flag (default true). Local HTTP-only testing sets RM_COOKIE_SECURE=false; production is always behind a TLS proxy and the cookie stays Secure. Default port :8443 → :8080. docker-compose binds 127.0.0.1 only and populates RM_TRUSTED_PROXY. spec.md §4.1/§10.1 rewritten with a Caddyfile snippet and a hard "do not expose RM_LISTEN publicly" warning. enrollResponse keeps cert_pin_sha256 in the shape but the server can't introspect a cert it doesn't terminate — operator pastes the proxy's hash into -cert-pin at install time. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/server/main.go | 2 +- deploy/docker-compose.yml | 13 +++++--- internal/server/config/config.go | 37 +++++++++++---------- internal/server/config/config_test.go | 31 +++++++++++------ internal/server/http/auth.go | 4 +-- internal/server/http/enrollment.go | 17 ++++++---- internal/server/http/server.go | 14 +++----- spec.md | 48 +++++++++++++++++++-------- 8 files changed, 102 insertions(+), 64 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index a004afb..e2bc71c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -47,7 +47,7 @@ func run() error { return fmt.Errorf("config: %w", err) } slog.Info("config resolved", "listen", cfg.Listen, "data_dir", cfg.DataDir, - "tls", cfg.TLSEnabled(), "trusted_proxies", cfg.TrustedProxies) + "cookie_secure", cfg.CookieSecure, "trusted_proxies", cfg.TrustedProxies) if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil { return fmt.Errorf("ensure data dir: %w", err) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index f754741..a0a2dcc 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -1,18 +1,21 @@ # Reference deployment for the restic-manager control plane. # Mirrors spec.md §10.1. Adjust image tag and RM_BASE_URL for your env. +# +# The server speaks plain HTTP. Front it with a TLS-terminating +# reverse proxy (Caddy/Traefik/nginx). RM_TRUSTED_PROXY must contain +# the proxy's IP/CIDR so X-Forwarded-* headers are honoured. services: restic-manager: image: ghcr.io/dcglab/restic-manager:latest restart: unless-stopped + # Bind to localhost only — the proxy is what the public reaches. ports: - - "8443:8443" + - "127.0.0.1:8080:8080" volumes: - ./data:/data - - ./certs:/certs:ro environment: - RM_DATA_DIR=/data - - RM_LISTEN=:8443 + - RM_LISTEN=:8080 - RM_BASE_URL=https://restic.lab.example - - RM_TLS_CERT=/certs/fullchain.pem - - RM_TLS_KEY=/certs/privkey.pem - RM_SECRET_KEY_FILE=/data/secret.key + - RM_TRUSTED_PROXY=172.16.0.0/12 diff --git a/internal/server/config/config.go b/internal/server/config/config.go index c53df77..9e09ecd 100644 --- a/internal/server/config/config.go +++ b/internal/server/config/config.go @@ -16,14 +16,21 @@ import ( // Config holds runtime parameters resolved from env + (optionally) a // YAML file. Env wins over YAML so operators can tweak a single var // without rewriting the file. +// +// The server is HTTP-only by design: the expected deployment fronts it +// with a TLS-terminating reverse proxy (Caddy/Traefik/nginx). See +// spec.md §11 for the rationale. type Config struct { Listen string `yaml:"listen"` DataDir string `yaml:"data_dir"` BaseURL string `yaml:"base_url"` - TLSCert string `yaml:"tls_cert"` - TLSKey string `yaml:"tls_key"` SecretKeyFile string `yaml:"secret_key_file"` TrustedProxies []string `yaml:"trusted_proxies"` + // CookieSecure controls the Secure attribute on session cookies. + // Defaults to true. Set RM_COOKIE_SECURE=false only for local HTTP + // testing — production deployments are always behind a TLS proxy + // and the cookie must be Secure. + CookieSecure bool `yaml:"cookie_secure"` } // Load resolves config in this order: @@ -35,8 +42,9 @@ type Config struct { // safe to start. func Load(yamlPath string) (Config, error) { c := Config{ - Listen: ":8443", - DataDir: "/data", + Listen: ":8080", + DataDir: "/data", + CookieSecure: true, } if yamlPath != "" { @@ -60,15 +68,17 @@ func Load(yamlPath string) (Config, error) { if v, ok := os.LookupEnv("RM_BASE_URL"); ok { c.BaseURL = v } - if v, ok := os.LookupEnv("RM_TLS_CERT"); ok { - c.TLSCert = v - } - if v, ok := os.LookupEnv("RM_TLS_KEY"); ok { - c.TLSKey = v - } if v, ok := os.LookupEnv("RM_SECRET_KEY_FILE"); ok { c.SecretKeyFile = v } + if v, ok := os.LookupEnv("RM_COOKIE_SECURE"); ok { + // Anything other than "false"/"0" leaves the safe default. + if v == "false" || v == "0" { + c.CookieSecure = false + } else { + c.CookieSecure = true + } + } if v, ok := os.LookupEnv("RM_TRUSTED_PROXY"); ok { // Comma-separated CIDRs; allow whitespace for readability. parts := strings.Split(v, ",") @@ -103,12 +113,5 @@ func (c *Config) validate() error { return fmt.Errorf("config: RM_TRUSTED_PROXY entry %q is not a valid CIDR: %w", cidr, err) } } - // TLS pair: either both set or both unset (HTTP-only mode for dev). - if (c.TLSCert == "") != (c.TLSKey == "") { - return fmt.Errorf("config: RM_TLS_CERT and RM_TLS_KEY must be set together (or both unset)") - } return nil } - -// TLSEnabled is true when both cert and key are configured. -func (c Config) TLSEnabled() bool { return c.TLSCert != "" && c.TLSKey != "" } diff --git a/internal/server/config/config_test.go b/internal/server/config/config_test.go index 857c73e..ba264f5 100644 --- a/internal/server/config/config_test.go +++ b/internal/server/config/config_test.go @@ -6,14 +6,14 @@ import ( ) func TestDefaultsValid(t *testing.T) { - t.Setenv("RM_LISTEN", ":8443") + t.Setenv("RM_LISTEN", ":8080") t.Setenv("RM_DATA_DIR", "/tmp/rm-test") c, err := Load("") if err != nil { t.Fatalf("load: %v", err) } - if c.Listen != ":8443" { + if c.Listen != ":8080" { t.Errorf("listen: %q", c.Listen) } if c.SecretKeyFile != "/tmp/rm-test/secret.key" { @@ -50,7 +50,7 @@ func TestEnvOverridesYAML(t *testing.T) { } func TestTrustedProxyParsing(t *testing.T) { - t.Setenv("RM_LISTEN", ":8443") + t.Setenv("RM_LISTEN", ":8080") t.Setenv("RM_DATA_DIR", "/tmp/x") t.Setenv("RM_TRUSTED_PROXY", "10.0.0.0/8, 192.168.1.0/24") @@ -67,7 +67,7 @@ func TestTrustedProxyParsing(t *testing.T) { } func TestTrustedProxyRejectsGarbage(t *testing.T) { - t.Setenv("RM_LISTEN", ":8443") + t.Setenv("RM_LISTEN", ":8080") t.Setenv("RM_DATA_DIR", "/tmp/x") t.Setenv("RM_TRUSTED_PROXY", "not-a-cidr") @@ -76,14 +76,25 @@ func TestTrustedProxyRejectsGarbage(t *testing.T) { } } -func TestTLSPairConsistency(t *testing.T) { - t.Setenv("RM_LISTEN", ":8443") +func TestCookieSecureDefaultAndOverride(t *testing.T) { + t.Setenv("RM_LISTEN", ":8080") t.Setenv("RM_DATA_DIR", "/tmp/x") - t.Setenv("RM_TLS_CERT", "/some/cert.pem") - // key intentionally unset - if _, err := Load(""); err == nil { - t.Fatal("expected error: cert without key") + c, err := Load("") + if err != nil { + t.Fatalf("load: %v", err) + } + if !c.CookieSecure { + t.Errorf("CookieSecure should default to true") + } + + t.Setenv("RM_COOKIE_SECURE", "false") + c, err = Load("") + if err != nil { + t.Fatalf("load: %v", err) + } + if c.CookieSecure { + t.Errorf("CookieSecure should be false when RM_COOKIE_SECURE=false") } } diff --git a/internal/server/http/auth.go b/internal/server/http/auth.go index f7d93e6..981a556 100644 --- a/internal/server/http/auth.go +++ b/internal/server/http/auth.go @@ -71,7 +71,7 @@ func (s *Server) handleLogin(w stdhttp.ResponseWriter, r *stdhttp.Request) { Value: token, Path: "/", HttpOnly: true, - Secure: s.deps.Cfg.TLSEnabled(), + Secure: s.deps.Cfg.CookieSecure, SameSite: stdhttp.SameSiteLaxMode, Expires: sess.ExpiresAt, }) @@ -97,7 +97,7 @@ func (s *Server) handleLogout(w stdhttp.ResponseWriter, r *stdhttp.Request) { Path: "/", MaxAge: -1, HttpOnly: true, - Secure: s.deps.Cfg.TLSEnabled(), + Secure: s.deps.Cfg.CookieSecure, SameSite: stdhttp.SameSiteLaxMode, }) w.WriteHeader(stdhttp.StatusNoContent) diff --git a/internal/server/http/enrollment.go b/internal/server/http/enrollment.go index 0ea6ce7..fbf961e 100644 --- a/internal/server/http/enrollment.go +++ b/internal/server/http/enrollment.go @@ -27,9 +27,12 @@ type enrollRequest struct { // enrollResponse hands the agent the credentials it'll use forever. // AgentToken is shown exactly once; the server stores its hash. -// CertPinSHA256 is the SHA-256 of the server's certificate, base64; -// the agent pins this on every reconnect so a stolen DB at the -// control plane can't be replayed against an attacker's TLS endpoint. +// +// CertPinSHA256 is reserved for future use. The server is HTTP-only +// and sits behind a reverse proxy that owns the TLS cert; pinning is +// configured at the agent install step (`-cert-pin`) by the operator +// pasting in the proxy's cert hash. The field stays in the response +// shape so we can populate it later if the topology changes. type enrollResponse struct { HostID string `json:"host_id"` AgentToken string `json:"agent_token"` @@ -109,9 +112,11 @@ func (s *Server) handleAgentEnroll(w stdhttp.ResponseWriter, r *stdhttp.Request) writeJSON(w, stdhttp.StatusCreated, enrollResponse{ HostID: hostID, AgentToken: agentToken, - // CertPinSHA256 is populated by a TLS-aware future revision. - // For now (HTTP-or-TLS-by-Caddy) we leave it empty and rely - // on the agent trusting its OS root store. + // CertPinSHA256: the server is HTTP-only and sits behind a + // reverse proxy that owns the cert. The operator pastes the + // proxy's cert hash into the install command (`-cert-pin`) + // when they want pinning; the server cannot introspect a + // cert it doesn't terminate. }) } diff --git a/internal/server/http/server.go b/internal/server/http/server.go index aabc9cc..c415b85 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -86,6 +86,9 @@ func (s *Server) routes(r chi.Router) { // Run-now: dispatch a job to a host's agent. r.Post("/hosts/{id}/jobs", s.handleRunNow) + + // Snapshot projection (refreshed by the agent after each backup). + r.Get("/hosts/{id}/snapshots", s.handleListHostSnapshots) }) // Agent ↔ server WebSocket. Bearer-authenticated inside the handler. @@ -109,16 +112,9 @@ func (s *Server) routes(r chi.Router) { } // Start begins listening. Blocks until ListenAndServe returns -// (typically only on Shutdown). Pass the result to errgroup.Group.Go. +// (typically only on Shutdown). The server is HTTP-only by design; +// production deployments terminate TLS at a reverse proxy in front. func (s *Server) Start() error { - cfg := s.deps.Cfg - if cfg.TLSEnabled() { - err := s.srv.ListenAndServeTLS(cfg.TLSCert, cfg.TLSKey) - if errors.Is(err, stdhttp.ErrServerClosed) { - return nil - } - return err - } err := s.srv.ListenAndServe() if errors.Is(err, stdhttp.ErrServerClosed) { return nil diff --git a/spec.md b/spec.md index edf2752..f1f3609 100644 --- a/spec.md +++ b/spec.md @@ -99,14 +99,20 @@ It is built for small-to-medium fleets (initial target: ~12 endpoints) and is in - **UI:** HTMX + Tailwind, server-rendered Go templates, no Node build step - **Distribution:** single static binary, packaged in a Docker image; published `docker-compose.yml` - **Config:** YAML or env vars: - - `RM_LISTEN` — bind address, e.g. `:8443` (source of truth for the port; - the `8443` in the reference compose is just a default mapping) - - `RM_DATA_DIR`, `RM_BASE_URL`, `RM_TLS_CERT`, `RM_TLS_KEY`, - `RM_SECRET_KEY_FILE` + - `RM_LISTEN` — bind address, e.g. `:8080` (source of truth for the port; + the `8080` in the reference compose is just a default mapping). Bind to + `127.0.0.1:8080` when running behind a same-host proxy. + - `RM_DATA_DIR`, `RM_BASE_URL`, `RM_SECRET_KEY_FILE` - `RM_TRUSTED_PROXY` — comma-separated CIDR list of reverse proxies whose `X-Forwarded-For` / `X-Forwarded-Proto` we honour. Empty (the default) = trust no one. Set this when fronted by Caddy/Traefik. -- **TLS:** terminate TLS in-process (cert from Caddy/Traefik sidecar acceptable; agents require HTTPS) + - `RM_COOKIE_SECURE` — `true` (default) marks session cookies `Secure`. + Only set to `false` for local HTTP-only testing. +- **TLS:** the server speaks plain HTTP and is **always** expected to sit + behind a TLS-terminating reverse proxy (Caddy / Traefik / nginx). This + keeps cert renewal, ACME, and SNI in the proxy where operators already + manage it. Agents must reach the server over HTTPS; the cert pin + (`cert_pin_sha256`) pins whatever cert the proxy serves. ### 4.2 Agent @@ -371,6 +377,10 @@ Stack: HTMX + Tailwind + Go html/templates. No SPA framework. Server-rendered, p ### 10.1 Control plane (Proxmox host or LXC) +The server is HTTP-only by design — operators front it with their own +TLS-terminating reverse proxy (Caddy / Traefik / nginx). Bind the +container to localhost so the only public path is through the proxy. + `docker-compose.yml`: ```yaml services: @@ -378,24 +388,34 @@ services: image: ghcr.io//restic-manager:latest restart: unless-stopped ports: - - "8443:8443" + - "127.0.0.1:8080:8080" volumes: - ./data:/data - - ./certs:/certs:ro environment: - RM_DATA_DIR=/data - - RM_LISTEN=:8443 + - RM_LISTEN=:8080 - RM_BASE_URL=https://restic.lab.example - - RM_TLS_CERT=/certs/fullchain.pem - - RM_TLS_KEY=/certs/privkey.pem - RM_SECRET_KEY_FILE=/data/secret.key - # - RM_TRUSTED_PROXY=10.0.0.0/8 # set when fronted by a reverse proxy + - RM_TRUSTED_PROXY=172.16.0.0/12 # CIDR of your reverse proxy ``` +Reference Caddy snippet (operator's own Caddyfile, outside this repo): +``` +restic.lab.example { + encode zstd gzip + reverse_proxy 127.0.0.1:8080 +} +``` +Caddy provisions and renews the cert; the agent's `cert_pin_sha256` +pins **Caddy's** leaf cert (that's what the agent actually sees). + `RM_LISTEN` is the source of truth for the server's bind address. The -`8443:8443` mapping above is just the matching default; if you change -`RM_LISTEN` to e.g. `:9443`, change the right-hand side of the port -mapping to match. +`8080:8080` mapping above is just the matching default; change both +sides together if you pick a different port. + +> ⚠️ Never expose `RM_LISTEN` directly on a public interface — the +> server has no TLS, no rate limiting, and no DDoS protection. That +> all belongs in the proxy. ### 10.2 Restic REST server (Unraid)