diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go
index e4f4b9d..5dd97dd 100644
--- a/controller/cmd/controller/main.go
+++ b/controller/cmd/controller/main.go
@@ -18,7 +18,6 @@ import (
"syscall"
"time"
- "crypto/subtle"
"strconv"
"strings"
"sync"
@@ -2190,56 +2189,7 @@ func main() {
// the controller no longer runs a DriveMigrator.
// --- Build HTTP mux ---
- mux := http.NewServeMux()
-
- // API routes (no auth for health endpoint, auth for everything else)
- mux.HandleFunc("/api/health", apiRouter.HealthHandler)
- // Disk management API — thin proxy to the host agent (slice 8C). The agent owns
- // disk execution; the controller forwards list/assign/eject/format.
- mux.Handle("/api/disks", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeDiskAPI))))
- mux.Handle("/api/disks/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeDiskAPI))))
- // Guided storage provisioning (init/attach/eject orchestration over the agent disk API + registry).
- mux.Handle("/api/storage/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeStorageAPI))))
- // LAN network-sharing folder picker (R-7 slice 1). Must live here, not in the web ServeHTTP
- // switch: the /api/ subtree is routed on this mux, so a case there is shadowed and 401s.
- mux.Handle("/api/sharing/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeSharingAPI))))
- // Guest RAM resize (v0.143.0, R-24): read current allocation/bounds + apply a bounded resize.
- mux.Handle("/api/system/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeSystemAPI))))
- // R-490 (v0.242.0): the web layer's /api/system/ prefix knows only the memory routes and answered
- // 404 to /api/system/info, so the monitoring page's memory-distribution card never rendered. An
- // exact pattern wins over the prefix in ServeMux. Pinned by TestR490_SystemInfoIsMountedAheadOfTheWebPrefix.
- mux.Handle("/api/system/info", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(apiRouter.ServeHTTP))))
- // Standalone full-server (guest) restart — the "Kiszolgáló újraindítása" maintenance affordance,
- // a sibling to the controller-only /api/selfrestart. Reuses the agent GuestReboot primitive.
- mux.Handle("/api/server/reboot", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.HandleServerReboot))))
- // R-998: „A gép kikapcsolása" — the household turns the whole machine off (agent POST /host/poweroff, doc 14).
- // Dashboard login only (RequireAuth): family members' app logins never reach the dashboard.
- mux.Handle("/api/server/poweroff", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.HandleServerPowerOff))))
- // Whole-guest (appliance) backup visibility + manual trigger. Distinct prefix from apiRouter's
- // app-data /api/backup/{run,status} (DB dumps) to avoid shadowing the /api/ catch-all subtree.
- mux.Handle("/api/guest-backup/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeBackupAPI))))
- // Host metrics API — thin proxy to the host agent (slice 9). Read-only host-wide health +
- // per-storage capacity for the monitoring view; the de-privileged controller can't read the
- // host itself. GET only, so no CSRF wrapper needed.
- mux.Handle("/api/host-metrics", webServer.RequireAuth(http.HandlerFunc(webServer.ServeHostMetricsAPI)))
- // App export/import API routes handled by web server
- mux.Handle("/api/export/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeExportAPI))))
- // Debug API routes handled by web server (debug-mode gating inside handler)
- mux.Handle("/api/debug/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeDebugAPI))))
- // Escrow ceremony wizard API (v0.127.0) — session auth + CSRF on POSTs; the claim response is
- // the ONLY surface the recovery code R ever crosses (no-store, never logged).
- mux.Handle("/api/escrow/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeEscrowAPI))))
- // Self-update API — accepts session auth OR hub API key (for external triggering)
- // CsrfProtect exempts Bearer-token requests automatically.
- mux.Handle("/api/selfupdate/", selfUpdateAuthMiddleware(cfg, webServer, webServer.CsrfProtect(http.HandlerFunc(apiRouter.ServeHTTP))))
- // Config API — accepts session auth OR hub API key (for Hub config push)
- mux.Handle("/api/config/", selfUpdateAuthMiddleware(cfg, webServer, webServer.CsrfProtect(http.HandlerFunc(apiRouter.ServeHTTP))))
- // Geo API — accepts session auth OR hub API key (for Hub geo-disable)
- mux.Handle("/api/geo/", selfUpdateAuthMiddleware(cfg, webServer, webServer.CsrfProtect(http.HandlerFunc(apiRouter.ServeHTTP))))
- mux.Handle("/api/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(apiRouter.ServeHTTP))))
-
- // Web UI routes (auth required)
- mux.Handle("/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeHTTP))))
+ mux := buildMux(apiRouter, webServer)
// --- Start HTTP server ---
server := &http.Server{
@@ -2282,7 +2232,6 @@ func main() {
logger.Println("[INFO] felhom-controller stopped")
}
-// selfUpdateAuthMiddleware allows access via session auth (normal UI) OR hub API key bearer token (external).
// deadAppBootGrace is the startup settle window before fix-3 evaluates deployed-app run states — apps
// legitimately take 30–60 s to come up, so a shorter grace would false-alarm during the controller's
// own boot. After the grace, an app that still isn't running alerts (the F11 dead-at-boot case).
@@ -3011,21 +2960,6 @@ func classifyRunStates(sts []stacks.Stack, quiesced map[string]bool, failedResta
return dead, states
}
-func selfUpdateAuthMiddleware(cfg *config.Config, webServer *web.Server, next http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- // Check bearer token first (for external API calls: hub, build scripts)
- if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") {
- token := strings.TrimPrefix(auth, "Bearer ")
- if token != "" && cfg.Hub.APIKey != "" && subtle.ConstantTimeCompare([]byte(token), []byte(cfg.Hub.APIKey)) == 1 {
- next.ServeHTTP(w, r)
- return
- }
- }
- // Fall back to session auth
- webServer.RequireAuth(next).ServeHTTP(w, r)
- })
-}
-
// setupLogger builds the v0.116.0 capture layer: the LogBuffer ring ALWAYS exists
// and captures every line (DEBUG included — remote diagnostics need the detail to
// exist without a config flip), while stdout (docker logs) keeps respecting
@@ -4168,3 +4102,55 @@ func keptSpaceSentence(sm *stacks.Manager, drive, lang string) string {
}
return util.Text(lang, "kept.diskwarn", strings.Join(parts, ", "))
}
+
+// buildMux is the controller's HTTP route table. One function so a test can drive the REAL table
+// (cmd/controller r970_no_hub_key_door_test.go) rather than a copy of it.
+func buildMux(apiRouter *api.Router, webServer *web.Server) *http.ServeMux {
+ mux := http.NewServeMux()
+
+ // API routes (no auth for health endpoint, auth for everything else)
+ mux.HandleFunc("/api/health", apiRouter.HealthHandler)
+ // Disk management API — thin proxy to the host agent (slice 8C). The agent owns
+ // disk execution; the controller forwards list/assign/eject/format.
+ mux.Handle("/api/disks", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeDiskAPI))))
+ mux.Handle("/api/disks/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeDiskAPI))))
+ // Guided storage provisioning (init/attach/eject orchestration over the agent disk API + registry).
+ mux.Handle("/api/storage/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeStorageAPI))))
+ // LAN network-sharing folder picker (R-7 slice 1). Must live here, not in the web ServeHTTP
+ // switch: the /api/ subtree is routed on this mux, so a case there is shadowed and 401s.
+ mux.Handle("/api/sharing/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeSharingAPI))))
+ // Guest RAM resize (v0.143.0, R-24): read current allocation/bounds + apply a bounded resize.
+ mux.Handle("/api/system/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeSystemAPI))))
+ // R-490 (v0.242.0): the web layer's /api/system/ prefix knows only the memory routes and answered
+ // 404 to /api/system/info, so the monitoring page's memory-distribution card never rendered. An
+ // exact pattern wins over the prefix in ServeMux. Pinned by TestR490_SystemInfoIsMountedAheadOfTheWebPrefix.
+ mux.Handle("/api/system/info", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(apiRouter.ServeHTTP))))
+ // Standalone full-server (guest) restart — the "Kiszolgáló újraindítása" maintenance affordance,
+ // a sibling to the controller-only /api/selfrestart. Reuses the agent GuestReboot primitive.
+ mux.Handle("/api/server/reboot", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.HandleServerReboot))))
+ // R-998: „A gép kikapcsolása" — the household turns the whole machine off (agent POST /host/poweroff, doc 14).
+ // Dashboard login only (RequireAuth): family members' app logins never reach the dashboard.
+ mux.Handle("/api/server/poweroff", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.HandleServerPowerOff))))
+ // Whole-guest (appliance) backup visibility + manual trigger. Distinct prefix from apiRouter's
+ // app-data /api/backup/{run,status} (DB dumps) to avoid shadowing the /api/ catch-all subtree.
+ mux.Handle("/api/guest-backup/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeBackupAPI))))
+ // Host metrics API — thin proxy to the host agent (slice 9). Read-only host-wide health +
+ // per-storage capacity for the monitoring view; the de-privileged controller can't read the
+ // host itself. GET only, so no CSRF wrapper needed.
+ mux.Handle("/api/host-metrics", webServer.RequireAuth(http.HandlerFunc(webServer.ServeHostMetricsAPI)))
+ // App export/import API routes handled by web server
+ mux.Handle("/api/export/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeExportAPI))))
+ // Debug API routes handled by web server (debug-mode gating inside handler)
+ mux.Handle("/api/debug/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeDebugAPI))))
+ // Escrow ceremony wizard API (v0.127.0) — session auth + CSRF on POSTs; the claim response is
+ // the ONLY surface the recovery code R ever crosses (no-store, never logged).
+ mux.Handle("/api/escrow/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeEscrowAPI))))
+ // v0.322.0 (R-970): no route accepts the box's hub API key any more. /api/selfupdate/ and /api/geo/ were open to
+ // it as a Bearer token (and /api/config/, now removed); the hub never calls into a box (it disables geo through
+ // Cloudflare directly), so they are served like every other /api/ route below: a dashboard session + CSRF.
+ mux.Handle("/api/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(apiRouter.ServeHTTP))))
+
+ // Web UI routes (auth required)
+ mux.Handle("/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeHTTP))))
+ return mux
+}
diff --git a/controller/cmd/controller/r970_no_hub_key_door_test.go b/controller/cmd/controller/r970_no_hub_key_door_test.go
new file mode 100644
index 0000000..2afd29e
--- /dev/null
+++ b/controller/cmd/controller/r970_no_hub_key_door_test.go
@@ -0,0 +1,84 @@
+package main
+
+import (
+ "io"
+ "log"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "gitea.dooplex.hu/admin/felhom-controller/internal/api"
+ "gitea.dooplex.hu/admin/felhom-controller/internal/config"
+ "gitea.dooplex.hu/admin/felhom-controller/internal/settings"
+ "gitea.dooplex.hu/admin/felhom-controller/internal/web"
+)
+
+// R-970 (v0.322.0) — the box's own hub API key opens NO route on the box.
+//
+// Until v0.322.0 a Bearer equal to the hub key reached /api/config/ (read the whole controller.yaml with every secret,
+// and REPLACE it), /api/selfupdate/ and /api/geo/ from the internet, unsigned — against `01` §4 („no inbound control
+// endpoint") and §10. The test drives the REAL route table (buildMux) with real web.Server and api.Router: a password
+// is set (so auth is on), the request carries the key and no session, and the planted controller.yaml must be neither
+// served nor changed.
+//
+// RED-PROOF (2026-10-16, REPORT): with an /api/geo/ route mounted without the session check, this failed at
+// „GET /api/geo/countries with the hub key answered 200".
+func TestR970_HubKeyOpensNoDoor(t *testing.T) {
+ const hubKey = "TESTONLY-hub-key-5d1b"
+ const secret = "TESTONLY-tunnel-token-q9"
+ dir := t.TempDir()
+ lg := log.New(io.Discard, "", 0)
+ cfgPath := filepath.Join(dir, "controller.yaml")
+ planted := "customer:\n id: t\n domain: example.hu\ninfrastructure:\n cf_tunnel_token: " + secret + "\n"
+ if err := os.WriteFile(cfgPath, []byte(planted), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ cfg := config.Default()
+ cfg.Customer.Domain = "example.hu"
+ cfg.Hub.APIKey = hubKey
+ cfg.Paths.DataDir = filepath.Join(dir, "data")
+ sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := sett.SetPasswordHash("$2a$10$abcdefghijklmnopqrstuuJ1A9pDq6s9F2x1H3bQb0v8Y3wq6z2Om"); err != nil {
+ t.Fatal(err)
+ }
+ ws := web.NewServer(cfg, nil, nil, nil, nil, sett, nil, nil, nil, lg, "test")
+ ar := api.NewRouter(cfg, cfgPath, sett, nil, nil, nil, nil, nil, nil, nil, lg)
+ mux := buildMux(ar, ws)
+
+ // Control: auth is on — a request with no key and no session is refused, so a refusal below means something.
+ ctl := httptest.NewRequest(http.MethodGet, "/api/stacks", nil)
+ ctl.Host = "felhom.example.hu"
+ cw := httptest.NewRecorder()
+ mux.ServeHTTP(cw, ctl)
+ if cw.Code != http.StatusUnauthorized {
+ t.Fatalf("control: an anonymous /api/stacks answered %d, want 401 — auth is not on in this harness", cw.Code)
+ }
+
+ paths := []string{"/api/config", "/api/config/", "/api/config/apply", "/api/config/hash",
+ "/api/selfupdate/check", "/api/selfupdate/update", "/api/geo/settings", "/api/geo/countries", "/api/geo/sync"}
+ methods := []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch}
+ for _, p := range paths {
+ for _, m := range methods {
+ req := httptest.NewRequest(m, p, strings.NewReader("customer:\n id: evil\n domain: evil.example\n"))
+ req.Host = "felhom.example.hu"
+ req.Header.Set("Authorization", "Bearer "+hubKey)
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, req)
+ if w.Code/100 == 2 {
+ t.Errorf("%s %s with the hub key answered %d — the key opens a door", m, p, w.Code)
+ }
+ if strings.Contains(w.Body.String(), secret) {
+ t.Fatalf("%s %s with the hub key served the settings file", m, p)
+ }
+ }
+ }
+ if got, _ := os.ReadFile(cfgPath); string(got) != planted {
+ t.Fatal("controller.yaml was replaced through a hub-key request")
+ }
+}
diff --git a/controller/internal/api/config_perms_test.go b/controller/internal/api/config_perms_test.go
deleted file mode 100644
index 7b39a6c..0000000
--- a/controller/internal/api/config_perms_test.go
+++ /dev/null
@@ -1,39 +0,0 @@
-package api
-
-import (
- "os"
- "path/filepath"
- "runtime"
- "testing"
-)
-
-// TestWriteConfig0600 asserts F8: controller.yaml is persisted 0600 (it holds infra secrets), even when
-// the target file already existed with looser (0644) permissions. POSIX modes only — skipped on Windows.
-func TestWriteConfig0600(t *testing.T) {
- if runtime.GOOS == "windows" {
- t.Skip("POSIX file modes not represented on Windows")
- }
- dir := t.TempDir()
- path := filepath.Join(dir, "controller.yaml")
-
- // Pre-create with world-readable 0644 to prove the helper tightens an existing file.
- if err := os.WriteFile(path, []byte("old: true\n"), 0o644); err != nil {
- t.Fatal(err)
- }
-
- if err := writeConfig0600(path, []byte("hub:\n api_key: redacted\n")); err != nil {
- t.Fatalf("writeConfig0600: %v", err)
- }
-
- fi, err := os.Stat(path)
- if err != nil {
- t.Fatal(err)
- }
- if mode := fi.Mode().Perm(); mode != 0o600 {
- t.Fatalf("config mode = %o, want 0600", mode)
- }
- // No leftover temp file.
- if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
- t.Fatalf("temp file not cleaned up")
- }
-}
diff --git a/controller/internal/api/r970_config_routes_test.go b/controller/internal/api/r970_config_routes_test.go
new file mode 100644
index 0000000..43faaa1
--- /dev/null
+++ b/controller/internal/api/r970_config_routes_test.go
@@ -0,0 +1,41 @@
+package api
+
+import (
+ "io"
+ "log"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// R-970 (v0.322.0): the router has no /config routes at all — even a SIGNED-IN request gets the router's 404 for every
+// method, and the settings file is neither served nor written.
+// RED-PROOF (2026-10-16, REPORT): with the `/config` GET case put back, this failed at „GET /api/config answered 200".
+func TestR970_ConfigRoutesAreGone(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "controller.yaml")
+ planted := "customer:\n id: t\n domain: example.hu\nhub:\n api_key: TESTONLY-key-r970\n"
+ if err := os.WriteFile(path, []byte(planted), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ r := &Router{configPath: path, logger: log.New(io.Discard, "", 0)}
+ r.restart = func() { t.Fatal("a /config request restarted the controller") }
+ for _, p := range []string{"/api/config", "/api/config/", "/api/config/apply", "/api/config/hash"} {
+ for _, m := range []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} {
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, httptest.NewRequest(m, p, strings.NewReader("customer:\n id: evil\n domain: e.example\n")))
+ if w.Code != http.StatusNotFound {
+ t.Errorf("%s %s answered %d, want the router's 404", m, p, w.Code)
+ }
+ if strings.Contains(w.Body.String(), "TESTONLY-key-r970") {
+ t.Fatalf("%s %s served the settings file", m, p)
+ }
+ }
+ }
+ if got, _ := os.ReadFile(path); string(got) != planted {
+ t.Fatal("the settings file was written")
+ }
+}
diff --git a/controller/internal/api/router.go b/controller/internal/api/router.go
index f909786..317fe89 100644
--- a/controller/internal/api/router.go
+++ b/controller/internal/api/router.go
@@ -1,15 +1,12 @@
package api
import (
- "bytes"
"context"
"encoding/json"
"errors"
"fmt"
- "io"
"log"
"net/http"
- "os"
"path/filepath"
"strconv"
"strings"
@@ -180,17 +177,11 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
case path == "/selfupdate/update" && req.Method == http.MethodPost:
r.selfupdateTrigger(w, req)
- // POST /api/config/apply — Hub pushes generated YAML to update controller.yaml
- case path == "/config/apply" && req.Method == http.MethodPost:
- r.configApply(w, req)
-
- // GET /api/config/hash — return current config file hash
- case path == "/config/hash" && req.Method == http.MethodGet:
- r.configHash(w, req)
-
- // GET /api/config — return raw controller.yaml content
- case path == "/config" && req.Method == http.MethodGet:
- r.configContent(w, req)
+ // v0.322.0 (R-970): /api/config, /api/config/hash and /api/config/apply are GONE. They read the box's
+ // controller.yaml with every secret in it and replaced it — reachable with the box's own hub key, unsigned,
+ // which contradicted `01-topology-and-trust.md` §4/§10. Nothing called them (the hub never connects to a box;
+ // the agent injects config at provision; the signed config bundle writes root files). Pinned by
+ // TestR970_ConfigRoutesAreGone and cmd/controller TestR970_HubKeyOpensNoDoor.
// POST /api/selfrestart — customer-facing graceful self-restart (auth + CSRF via /api/ mount)
case path == "/selfrestart" && req.Method == http.MethodPost:
@@ -1531,97 +1522,6 @@ func (r *Router) selfupdateTrigger(w http.ResponseWriter, req *http.Request) {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.update.kicked_off")})
}
-// --- Config apply handler ---
-
-func (r *Router) configApply(w http.ResponseWriter, req *http.Request) {
- r.dbg("configApply: contentLength=%d remoteAddr=%s", req.ContentLength, req.RemoteAddr)
- // Read YAML body (limit to 1MB)
- body, err := io.ReadAll(io.LimitReader(req.Body, 1<<20))
- if err != nil {
- writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "failed to read request body"})
- return
- }
- if len(body) == 0 {
- writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "empty request body"})
- return
- }
-
- // Validate it's parseable YAML by attempting to load it
- if _, err := config.LoadFromBytes(body); err != nil {
- r.logger.Printf("[WARN] [api] Config apply rejected: invalid YAML: %v", err)
- writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: fmt.Sprintf("invalid config YAML: %v", err)})
- return
- }
-
- // No-op guard: if the pushed config is byte-identical to what is already on disk, do
- // nothing — don't rewrite, don't restart. The hub may re-push the same config
- // idempotently, and a self-restart on every push would be a needless flap.
- if prior, rerr := os.ReadFile(r.configPath); rerr == nil && bytes.Equal(prior, body) {
- r.logger.Printf("[INFO] [api] Config apply: identical to current config (%d bytes) — no change, no restart", len(body))
- writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.config.unchanged")})
- return
- }
-
- // Write config 0600: it holds infra credentials (cf_api_token, cf_tunnel_token, hub api_key) in
- // plaintext (F8), so it must not be world-readable. writeConfig0600 enforces the mode even when the
- // target file already existed with looser perms (os.WriteFile does not chmod an existing file).
- if err := writeConfig0600(r.configPath, body); err != nil {
- r.logger.Printf("[ERROR] [api] Config apply: failed to write config: %v", err)
- writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to apply config"})
- return
- }
-
- // Respond to the hub FIRST (and flush), THEN self-restart. The new config only takes
- // effect on restart — singletons such as the Cloudflare client are built once at
- // startup, so an in-process write alone would leave e.g. a rotated CF token unused.
- r.logger.Printf("[INFO] [api] Config applied from Hub (%d bytes) — self-restarting to take effect", len(body))
- writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.config.applied")})
- flushResponse(w)
- if r.restart != nil {
- r.restart()
- }
-}
-
-// writeConfig0600 writes config bytes to path with mode 0600, atomically when possible (tmp+rename),
-// falling back to a direct write for Docker bind mounts (where os.Rename returns EBUSY). It ALWAYS
-// enforces 0600 on the final file — even if it already existed with looser perms — because controller.yaml
-// holds infra credentials in plaintext (F8); os.WriteFile only applies the mode when creating a new file.
-func writeConfig0600(path string, body []byte) error {
- tmpPath := path + ".tmp"
- if err := os.WriteFile(tmpPath, body, 0600); err != nil {
- return fmt.Errorf("writing temp config: %w", err)
- }
- if err := os.Rename(tmpPath, path); err != nil {
- os.Remove(tmpPath)
- if err := os.WriteFile(path, body, 0600); err != nil { // bind-mount fallback
- return fmt.Errorf("writing config: %w", err)
- }
- }
- if err := os.Chmod(path, 0600); err != nil {
- return fmt.Errorf("chmod config 0600: %w", err)
- }
- return nil
-}
-
-func (r *Router) configHash(w http.ResponseWriter, _ *http.Request) {
- hash, err := config.FileHash(r.configPath)
- if err != nil {
- writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to read config"})
- return
- }
- writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]string{"hash": hash, "path": filepath.Base(r.configPath)}})
-}
-
-func (r *Router) configContent(w http.ResponseWriter, _ *http.Request) {
- data, err := os.ReadFile(r.configPath)
- if err != nil {
- writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to read config"})
- return
- }
- w.Header().Set("Content-Type", "text/yaml; charset=utf-8")
- w.Write(data)
-}
-
// --- Asset sync handlers ---
func (r *Router) triggerAssetSync(w http.ResponseWriter, req *http.Request) {
diff --git a/controller/internal/api/selfrestart_test.go b/controller/internal/api/selfrestart_test.go
index 51f00fb..ff0dd4e 100644
--- a/controller/internal/api/selfrestart_test.go
+++ b/controller/internal/api/selfrestart_test.go
@@ -1,7 +1,6 @@
package api
import (
- "bytes"
"io"
"log"
"net/http"
@@ -28,61 +27,6 @@ func newRestartTestRouter(t *testing.T, prior []byte) (*Router, *int, string) {
return r, &calls, path
}
-// a minimal config that passes config.LoadFromBytes (customer.id + customer.domain required).
-var validConfig = []byte("customer:\n id: test\n domain: test.example\n")
-
-func TestConfigApply_ChangedConfig_Restarts(t *testing.T) {
- prior := []byte("customer:\n id: old\n domain: old.example\n")
- r, calls, path := newRestartTestRouter(t, prior)
-
- req := httptest.NewRequest(http.MethodPost, "/api/config/apply", bytes.NewReader(validConfig))
- rec := httptest.NewRecorder()
- r.configApply(rec, req)
-
- if rec.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
- }
- if *calls != 1 {
- t.Fatalf("restart called %d times, want 1 (config changed)", *calls)
- }
- got, _ := os.ReadFile(path)
- if !bytes.Equal(got, validConfig) {
- t.Fatalf("config not written: got %q", got)
- }
-}
-
-// COMPANION: an identical re-push must NOT restart (the old handler always restarted /
-// always ran its post-apply hook regardless of whether anything changed).
-func TestConfigApply_IdenticalConfig_NoRestart(t *testing.T) {
- r, calls, _ := newRestartTestRouter(t, validConfig) // prior == body
-
- req := httptest.NewRequest(http.MethodPost, "/api/config/apply", bytes.NewReader(validConfig))
- rec := httptest.NewRecorder()
- r.configApply(rec, req)
-
- if rec.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
- }
- if *calls != 0 {
- t.Fatalf("restart called %d times, want 0 (config byte-identical)", *calls)
- }
-}
-
-func TestConfigApply_InvalidYAML_NoRestart(t *testing.T) {
- r, calls, _ := newRestartTestRouter(t, validConfig)
-
- req := httptest.NewRequest(http.MethodPost, "/api/config/apply", bytes.NewReader([]byte("customer:\n id: only-id-no-domain\n")))
- rec := httptest.NewRecorder()
- r.configApply(rec, req)
-
- if rec.Code != http.StatusBadRequest {
- t.Fatalf("status = %d, want 400 (missing customer.domain)", rec.Code)
- }
- if *calls != 0 {
- t.Fatalf("restart called %d times, want 0 (validation failed)", *calls)
- }
-}
-
func TestSelfRestart_InvokesRestarter(t *testing.T) {
r, calls, _ := newRestartTestRouter(t, nil)
diff --git a/controller/internal/web/csrf.go b/controller/internal/web/csrf.go
index bffb267..f3a9a5e 100644
--- a/controller/internal/web/csrf.go
+++ b/controller/internal/web/csrf.go
@@ -16,7 +16,6 @@ const csrfHeaderName = "X-CSRF-Token"
//
// Exempt cases:
// - Auth is disabled (no password configured)
-// - Request has a valid Authorization: Bearer header (API key / hub auth)
func (s *Server) CsrfProtect(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Safe methods: no CSRF check needed
@@ -53,17 +52,9 @@ func (s *Server) CsrfProtect(next http.Handler) http.Handler {
return
}
- // Skip CSRF for Bearer-token authenticated requests.
- // Validate the token against the configured API key before skipping.
- if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") {
- token := strings.TrimPrefix(auth, "Bearer ")
- apiKey := s.cfg.Hub.APIKey
- if apiKey != "" && subtle.ConstantTimeCompare([]byte(token), []byte(apiKey)) == 1 {
- next.ServeHTTP(w, r)
- return
- }
- // Invalid Bearer token — fall through to CSRF validation
- }
+ // v0.322.0 (R-970): no Bearer exemption. It existed for the routes that accepted the box's hub API key
+ // (/api/config/, /api/selfupdate/, /api/geo/); none does now, so a request carrying that key is a dashboard
+ // request like any other and needs the session's CSRF token. Pinned by TestR970_HubKeyIsNoCSRFExemption.
// Get the session's CSRF token
cookie, err := r.Cookie(sessionCookieName)
diff --git a/controller/internal/web/deploy_secret_exposure_test.go b/controller/internal/web/deploy_secret_exposure_test.go
index 46de6f3..ffe9c22 100644
--- a/controller/internal/web/deploy_secret_exposure_test.go
+++ b/controller/internal/web/deploy_secret_exposure_test.go
@@ -45,28 +45,10 @@ func renderDeployPage(t *testing.T, alreadyDeployed bool) string {
return buf.String()
}
-// RED-PROOF: drop the `{{if $isDeployed}}` branch so the deployed page renders `value="{{$val}}"`
-// again — this fails, showing the secret returning to the body of a page with nothing to submit.
-func TestDeployPage_DeployedApp_DoesNotCarryTheSecret(t *testing.T) {
- html := renderDeployPage(t, true)
-
- if strings.Contains(html, testDeploySecret) {
- t.Error("R-254 site two: an already-deployed app's generated secret is in the response body " +
- "of its settings page — nothing there submits it, so there is no form reason for it to " +
- "be in the page at all")
- }
- // Assert the CONTROL, not the URL: the revealAutoField() function ships in the page script on
- // both variants, so a substring match on the endpoint path matches the script and would report a
- // control that is not there. (This test caught exactly that on itself.)
- if !strings.Contains(html, `onclick="revealAutoField('vaultwarden','DB_PASSWORD'`) {
- t.Error("no reveal control rendered, so the customer cannot see their own generated secret")
- }
- // The hidden input must NOT appear on a deployed app — it never did, and that is the asymmetry
- // that makes the readonly input indefensible there.
- if strings.Contains(html, ` put back, at „2 sections".
+// v0.322.0: with the status rows put back, at „status rows repeat the form"; without autocomplete, at „lacks".
func TestAppData_OneBackupSection(t *testing.T) {
if !lfOffDriveOrPBS.MatchString("a teljes rendszermentés/PBS — off-drive") {
t.Fatal("control: the matcher does not find the words it looks for")
@@ -407,9 +408,19 @@ func TestAppData_OneBackupSection(t *testing.T) {
if n := strings.Count(html, `data-backup-section="1"`); n != 1 {
t.Errorf("%s: %d backup sections, want 1", lang, n)
}
- if !strings.Contains(html, `data-backup-row="nightly"`) || !strings.Contains(html, `data-backup-row="second"`) ||
- !strings.Contains(html, `data-second-backup-form="1"`) {
- t.Errorf("%s: the nightly row, the 2nd-copy row or its form is missing", lang)
+ if !strings.Contains(html, `data-backup-row="nightly"`) || !strings.Contains(html, `data-second-backup-form="1"`) {
+ t.Errorf("%s: the nightly row or the 2nd-copy form is missing", lang)
+ }
+ // v0.322.0: the 2nd copy's state is said once — by the form; no status rows repeat it.
+ if strings.Contains(html, `data-backup-row="second"`) || strings.Contains(html, `data-backup-row="target"`) {
+ t.Errorf("%s: the 2nd copy's status rows repeat the form", lang)
+ }
+ if n := strings.Count(html, `