R-970: the box's hub key opens no route; Data tab leftovers; R-1103: the install form's dead branches
gates / gates (push) Successful in 48s
gates / gates (push) Successful in 48s
R-970: /api/config, /api/config/hash, /api/config/apply removed (they read and replaced controller.yaml with every secret, reachable with the box's own hub API key, unsigned; nothing called them). The hub-key Bearer door on /api/selfupdate/ and /api/geo/ and the matching CSRF exemption are gone; the dashboard keeps using those with a session. The route table is buildMux, so a test drives the real one. Data tab: the export password field is autocomplete=new-password with a non-login name; the 2nd copy's status rows that repeated the form are gone (the no-target reason moves into the form). R-1103: app_fields.html loses its installed-only branches (no page serves them since R-1095); the tests that rendered that dead path are retired, their rules asserted on the settings tab (R-694 moved there). Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user