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, `