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

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:
2026-10-16 18:24:00 +02:00
parent 8fe46a1313
commit f037be7a6a
25 changed files with 331 additions and 2987 deletions
@@ -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")
}
}
@@ -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")
}
}
+5 -105
View File
@@ -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) {
@@ -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)