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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user