v0.329.0: each app reaches only its own appdata folder; empty allow-list; drive retirement asks the agent first; plain no-durable-id refusal; :latest only for a release
gates / gates (push) Successful in 45s
gates / gates (push) Successful in 45s
R-1142: the HDD_PATH root is appdata/<own folder>, with a compiled-in folder map (paperless-ngx -> paperless). R-981: the allow-list is empty (homepage's socket served no Felhom feature). R-1063: finalizeDecommissionWith asks the agent before it marks the drive or moves the default; the 403/409 come back typed and reach the household as a plain 409. R-1069: the agent's no-durable-id format refusal is ErrNoDurableID and a plain sentence. R-1143: build.sh carries :latest only for x.y.z. Each pinned by a red-proofed test. MinAgent 0.158.1.
This commit is contained in:
@@ -429,6 +429,13 @@ var ErrFormatRefused = fmt.Errorf("agentapi: format refused — system/backup de
|
||||
// The UI surfaces the type-to-confirm flow, then re-submits with confirmed=true + that durable id.
|
||||
var ErrNeedsConfirmation = fmt.Errorf("agentapi: format needs customer confirmation — user-data device")
|
||||
|
||||
// ErrNoDurableID is the agent's anti-retarget refusal of a blank format (R-1069): the device has no durable id to
|
||||
// bind the format to — a virtual disk passed on a virtio bus without a serial has no /dev/disk/by-id link. The agent's
|
||||
// rule is a safety rule and stays; this only lets the dashboard say it in plain words. Matched on the agent's own
|
||||
// refusal text (felhom-agent internal/localapi/disks.go „format refused: device has no durable id"); if that text
|
||||
// ever changes, the refusal degrades to the raw message it was before. Pinned by TestFormatDisk_NoDurableIDIsTyped.
|
||||
var ErrNoDurableID = fmt.Errorf("agentapi: format refused — the device has no durable id (path-only formats are not permitted)")
|
||||
|
||||
// SameDiskResult is the agent's answer to POST /disks/same-disk (v0.157.0). Act on Proven first:
|
||||
// Proven+Different = a counted second copy; Proven+!Different = the same disk; !Proven = unknown
|
||||
// (Reason vm|network|usb_bridge|weak_wwn|no_serial|unmapped).
|
||||
@@ -779,6 +786,11 @@ func (c *Client) Decommission(ctx context.Context, where string) (DecommissionRe
|
||||
return out, err
|
||||
}
|
||||
if err := refusalError("/disks/decommission", status, env); err != nil {
|
||||
if status == http.StatusForbidden || status == http.StatusConflict {
|
||||
// R-1063: the agent's two policy refusals (403 a protected role, 409 the whole-guest backup target) are
|
||||
// typed, so the dashboard can say which in plain words. The text is refusalError's, unchanged.
|
||||
return out, &DecommissionRefusedError{Status: status, Reason: refusalReason(env)}
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &out); err != nil {
|
||||
@@ -787,6 +799,17 @@ func (c *Client) Decommission(ctx context.Context, where string) (DecommissionRe
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DecommissionRefusedError is the agent saying no to a decommission: Status 403 (the mount is system/backup by its
|
||||
// role) or 409 (the mount holds the whole-guest backup target). Nothing was done on the host.
|
||||
type DecommissionRefusedError struct {
|
||||
Status int
|
||||
Reason string // the agent's own words, truncated (never a secret: the agent's refusal text)
|
||||
}
|
||||
|
||||
func (e *DecommissionRefusedError) Error() string {
|
||||
return fmt.Sprintf("agentapi: POST /disks/decommission: HTTP %d: %s", e.Status, e.Reason)
|
||||
}
|
||||
|
||||
// refusalError converts a non-2xx status or an ok:false envelope into an error that CARRIES the
|
||||
// agent's reason (truncated; never request bodies or secrets). nil on an accepted 200/202+ok=true.
|
||||
func refusalError(path string, status int, env apiResponse) error {
|
||||
@@ -794,16 +817,21 @@ func refusalError(path string, status int, env apiResponse) error {
|
||||
if accepted && env.OK {
|
||||
return nil
|
||||
}
|
||||
reason := truncateErr(env.Error, 300)
|
||||
if reason == "" {
|
||||
reason = "(no reason in agent response)"
|
||||
}
|
||||
reason := refusalReason(env)
|
||||
if accepted { // 2xx but ok:false — business refusal without an HTTP error code
|
||||
return fmt.Errorf("agentapi: POST %s: %s", path, reason)
|
||||
}
|
||||
return fmt.Errorf("agentapi: POST %s: HTTP %d: %s", path, status, reason)
|
||||
}
|
||||
|
||||
// refusalReason is the agent's refusal text as refusalError carries it.
|
||||
func refusalReason(env apiResponse) string {
|
||||
if r := truncateErr(env.Error, 300); r != "" {
|
||||
return r
|
||||
}
|
||||
return "(no reason in agent response)"
|
||||
}
|
||||
|
||||
// truncateErr mirrors stacks.truncateStr for agent refusal reasons.
|
||||
func truncateErr(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
@@ -850,6 +878,9 @@ func (c *Client) FormatDisk(ctx context.Context, device, fstype string, confirme
|
||||
// real failure (e.g. the agent's 502 on a mkfs error: "device is mounted"). Returning the zero-value
|
||||
// result with a nil error here made a failed destructive format read as a silent SUCCESS in the web
|
||||
// layer. Surface it as an error so the caller (and the dashboard) report the failure.
|
||||
if status == http.StatusConflict && strings.Contains(env.Error, "has no durable id") {
|
||||
return out, ErrNoDurableID
|
||||
}
|
||||
if status < 200 || status >= 300 || !env.OK {
|
||||
msg := strings.TrimSpace(env.Error)
|
||||
if msg == "" {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -35,3 +36,52 @@ func TestR856_CrashGuardDecodesAndAnOlderAgentIs404(t *testing.T) {
|
||||
t.Fatalf("an older agent must answer a typed 404, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// R-1063: the agent's two decommission refusals come back TYPED (403 a protected role, 409 the whole-guest backup
|
||||
// target), with the same text as before; any other failure is not that type.
|
||||
func TestDecommission_PolicyRefusalsAreTyped(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
code int
|
||||
typed bool
|
||||
}{{http.StatusForbidden, true}, {http.StatusConflict, true}, {http.StatusBadGateway, false}} {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /disks/decommission", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(c.code)
|
||||
_, _ = w.Write([]byte(`{"ok":false,"error":"refused for the test"}`))
|
||||
})
|
||||
s := httptest.NewTLSServer(mux)
|
||||
_, err := clientFor(t, s, strings.TrimPrefix(s.URL, "https://")).Decommission(context.Background(), "/mnt/x")
|
||||
s.Close()
|
||||
var dr *DecommissionRefusedError
|
||||
if got := errors.As(err, &dr); got != c.typed {
|
||||
t.Fatalf("HTTP %d: typed=%v, want %v (err %v)", c.code, got, c.typed, err)
|
||||
}
|
||||
if c.typed && (dr.Status != c.code || !strings.Contains(err.Error(), "HTTP "+strconv.Itoa(c.code)+": refused for the test")) {
|
||||
t.Fatalf("HTTP %d: %+v / %q", c.code, dr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// R-1069: the agent's anti-retarget refusal of a blank format (its exact text, a 409) comes back as ErrNoDurableID;
|
||||
// another 409 does not.
|
||||
func TestFormatDisk_NoDurableIDIsTyped(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
body string
|
||||
typed bool
|
||||
}{
|
||||
{`{"ok":false,"error":"format refused: device has no durable id to bind the format to (path-only formats are not permitted)","data":{"formatted":false,"data_bearing":false}}`, true},
|
||||
{`{"ok":false,"error":"format refused (device may have changed since inspection): gone","data":{"formatted":false}}`, false},
|
||||
} {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /disks/format", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
_, _ = w.Write([]byte(c.body))
|
||||
})
|
||||
s := httptest.NewTLSServer(mux)
|
||||
_, err := clientFor(t, s, strings.TrimPrefix(s.URL, "https://")).FormatDisk(context.Background(), "/dev/vdb", "ext4", false, "")
|
||||
s.Close()
|
||||
if got := errors.Is(err, ErrNoDurableID); got != c.typed || err == nil {
|
||||
t.Fatalf("body %s: ErrNoDurableID=%v want %v (err %v)", c.body[:60], got, c.typed, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1235,6 +1235,7 @@
|
||||
"disk.err.device_required": "a device is required",
|
||||
"disk.err.init_in_progress": "a drive is already being initialised",
|
||||
"disk.err.name_mismatch": "the name you typed does not match the mount name",
|
||||
"disk.err.no_durable_id": "This disk cannot be formatted from the dashboard: the virtual machine gives it no fixed name (serial number), so Felhom cannot identify it for certain. The disk was not changed. The operator, or the person who set up the virtual machine, can give the disk a serial number (or attach it on a SATA bus); then try again.",
|
||||
"disk.err.protected": "this drive is protected (system / backup) — erasing it needs an operator signature",
|
||||
"disk.err.target_required": "a target storage is required for the move",
|
||||
"disk.err.unknown_mode": "unknown mode (migrate or anyway)",
|
||||
@@ -1427,6 +1428,8 @@
|
||||
"err.web.a_formazas_nem_indult_el_az": "the format did not start on the device (%s)",
|
||||
"err.web.a_kivalasztott_meghajtohoz_nem_talalhato_fajlrendszer": "no file-system id was found for the drive you picked (only a drive that already has a file system can be attached)",
|
||||
"err.web.a_meghajto_leszerelese_sikertelen": "decommissioning the drive failed: %s",
|
||||
"err.web.decommission_backup_target": "This drive holds the system backup, so it cannot be retired now. First choose another drive as the place for the system backup, then try again. Nothing was changed.",
|
||||
"err.web.decommission_protected": "The system protects this drive (it is a system or backup disk), so it cannot be retired from here. Nothing was changed.",
|
||||
"err.web.a_meghajto_nem_talalhato": "the drive was not found",
|
||||
"err.web.a_meghajtok_listaja_nem_olvashato": "the list of drives cannot be read",
|
||||
"err.web.csatlakoztatas_sikertelen": "mounting failed: %s",
|
||||
|
||||
@@ -1230,6 +1230,7 @@
|
||||
"disk.err.device_required": "eszköz kötelező",
|
||||
"disk.err.init_in_progress": "már folyamatban van egy meghajtó-inicializálás",
|
||||
"disk.err.name_mismatch": "a beírt név nem egyezik a csatlakoztatási névvel",
|
||||
"disk.err.no_durable_id": "Ezt a lemezt a vezérlőpultról nem lehet formázni: a virtuális gép nem ad neki állandó nevet (sorozatszámot), ezért a Felhom nem tudja biztosan azonosítani. A lemez nem változott. Az üzemeltető vagy az, aki a virtuális gépet beállította, adhat a lemeznek sorozatszámot (vagy SATA-buszon csatolhatja), utána próbáld újra.",
|
||||
"disk.err.protected": "a meghajtó védett (rendszer/biztonsági mentés) — törlés csak operátori aláírással",
|
||||
"disk.err.target_required": "céltároló kötelező az áthelyezéshez",
|
||||
"disk.err.unknown_mode": "ismeretlen mód (migrate vagy anyway)",
|
||||
@@ -1418,6 +1419,8 @@
|
||||
"err.web.a_formazas_nem_indult_el_az": "a formázás nem indult el az eszközön (%s)",
|
||||
"err.web.a_kivalasztott_meghajtohoz_nem_talalhato_fajlrendszer": "a kiválasztott meghajtóhoz nem található fájlrendszer-azonosító (csak fájlrendszerrel rendelkező meghajtó csatolható)",
|
||||
"err.web.a_meghajto_leszerelese_sikertelen": "a meghajtó leszerelése sikertelen: %s",
|
||||
"err.web.decommission_backup_target": "Ezen a meghajtón van a rendszermentés, ezért most nem szerelhető le. Előbb jelölj ki egy másik meghajtót a rendszermentés helyéül, utána próbáld újra. Semmi nem változott.",
|
||||
"err.web.decommission_protected": "Ezt a meghajtót a rendszer védi (rendszer- vagy mentési lemez), ezért innen nem szerelhető le. Semmi nem változott.",
|
||||
"err.web.a_meghajto_nem_talalhato": "a meghajtó nem található",
|
||||
"err.web.a_meghajtok_listaja_nem_olvashato": "a meghajtók listája nem olvasható",
|
||||
"err.web.csatlakoztatas_sikertelen": "csatlakoztatás sikertelen: %s",
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
{
|
||||
"version": 1,
|
||||
"note": "R-971 (operator decision D2 = A, 2026-10-17). The box's catalog allow-list, compiled into the controller. Each entry allows ONE exact setting for ONE service of ONE app, only while that service runs the entry's image repository with the image's own entrypoint and command and the exact healthcheck named; a changed setting is a different one and is refused. The catalog's scripts/privilege-allowlist.json is a byte-identical copy for its CI gate. Survey: felhom.eu documentation/audits/agent-allowlist-rotation-2026-10-17/P2/survey.md",
|
||||
"entries": [
|
||||
{
|
||||
"app": "homepage",
|
||||
"service": "homepage",
|
||||
"setting": "bind /var/run/docker.sock -> /var/run/docker.sock ro",
|
||||
"image": "ghcr.io/gethomepage/homepage",
|
||||
"healthcheck": "[\"CMD\",\"wget\",\"--spider\",\"-q\",\"http://127.0.0.1:3000\"]",
|
||||
"why": "the Docker widget and the docker service discovery list the household's containers and their state",
|
||||
"doc": "https://gethomepage.dev/configs/docker/"
|
||||
}
|
||||
]
|
||||
"version": 2,
|
||||
"note": "R-971 (operator decision D2 = A, 2026-10-17). The box's catalog allow-list, compiled into the controller. Each entry allows ONE exact setting for ONE service of ONE app, only while that service runs the entry's image repository with the image's own entrypoint and command and the exact healthcheck named; a changed setting is a different one and is refused. EMPTY since R-981 (2026-10-18): homepage's Docker socket was used by no Felhom feature and is gone, so no app has an exception. appdata_folders (R-1142): an app's bind root under ${HDD_PATH} is appdata/<its own folder>/, the folder being its name unless listed here. The catalog's scripts/privilege-allowlist.json is a byte-identical copy for its CI gate. Survey: felhom.eu documentation/audits/agent-allowlist-rotation-2026-10-17/P2/survey.md and audits/night-2026-10-18/P1/",
|
||||
"entries": [],
|
||||
"appdata_folders": {
|
||||
"paperless-ngx": "paperless"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,9 @@
|
||||
//
|
||||
// The data roots are the values the controller itself gives the app: HDD_PATH, USERDATA_PATH and IMPORT_PATH, each
|
||||
// an absolute clean path under /mnt/ or under the system data path. A bind source may use only those three variables,
|
||||
// and only as its leading token.
|
||||
// and only as its leading token. Under HDD_PATH the root is the app's OWN folder, ${HDD_PATH}/appdata/<folder>/ (R-1142):
|
||||
// <folder> is the app's name, or its entry in the list's appdata_folders (paperless-ngx keeps appdata/paperless) — so a
|
||||
// catalog push cannot hand one app another app's files.
|
||||
//
|
||||
// The controller's own infra stacks (traefik, cloudflared, filebrowser, samba) and its override files (setup gate,
|
||||
// family gate, install hold, media library) are rendered by the controller, not the catalog: out of scope.
|
||||
@@ -69,11 +71,14 @@ type Entry struct {
|
||||
type allowList struct {
|
||||
Version int `json:"version"`
|
||||
Entries []Entry `json:"entries"`
|
||||
// AppdataFolders names an app's folder under ${HDD_PATH}/appdata/ when it is not the app's own name (R-1142). It is
|
||||
// compiled in with the entries for the same reason: the catalog — the threat — must not be able to name the folder.
|
||||
AppdataFolders map[string]string `json:"appdata_folders"`
|
||||
}
|
||||
|
||||
var allowed = mustLoad(allowListJSON)
|
||||
var allowed, appdataFolders = mustLoad(allowListJSON)
|
||||
|
||||
func mustLoad(b []byte) map[string]Entry {
|
||||
func mustLoad(b []byte) (map[string]Entry, map[string]string) {
|
||||
var l allowList
|
||||
if err := json.Unmarshal(b, &l); err != nil {
|
||||
panic("privpolicy: allowlist.json does not parse: " + err.Error())
|
||||
@@ -85,7 +90,29 @@ func mustLoad(b []byte) map[string]Entry {
|
||||
}
|
||||
m[key(e.App, e.Service, e.Setting)] = e
|
||||
}
|
||||
return m
|
||||
for app, f := range l.AppdataFolders {
|
||||
if !oneSegment(f) {
|
||||
panic("privpolicy: allowlist.json appdata folder of " + app + " is not one folder name: " + f)
|
||||
}
|
||||
}
|
||||
return m, l.AppdataFolders
|
||||
}
|
||||
|
||||
// oneSegment: a single folder name — no separator, not "." or "..", not empty.
|
||||
func oneSegment(s string) bool {
|
||||
return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, "/\\$")
|
||||
}
|
||||
|
||||
// AppdataFolder is the app's own folder under ${HDD_PATH}/appdata/ (R-1142): its appdata_folders entry, else its name.
|
||||
// "" when the app has no usable name — then HDD_PATH gives it no root at all (fail closed).
|
||||
func AppdataFolder(app string) string {
|
||||
if f, ok := appdataFolders[app]; ok {
|
||||
return f
|
||||
}
|
||||
if !oneSegment(app) {
|
||||
return ""
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
func key(app, svc, setting string) string { return app + "\x00" + svc + "\x00" + setting }
|
||||
@@ -156,7 +183,7 @@ func PlaceholderEnv() map[string]string {
|
||||
// error = the compose could not be read, which refuses too (fail closed: a definition the check cannot read is not a
|
||||
// definition it has checked).
|
||||
func Admit(app string, compose []byte, env map[string]string, p Policy) error {
|
||||
vs, err := Check(compose, env, p)
|
||||
vs, err := Check(app, compose, env, p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the catalog definition of %s could not be checked: %w", app, err)
|
||||
}
|
||||
@@ -230,13 +257,13 @@ func imageRepo(ref string) string {
|
||||
return ref
|
||||
}
|
||||
|
||||
// Check lists every setting in a class, allowed or not, sorted.
|
||||
func Check(compose []byte, env map[string]string, p Policy) ([]Violation, error) {
|
||||
// Check lists every setting in a class, allowed or not, sorted. app decides the HDD_PATH root (R-1142).
|
||||
func Check(app string, compose []byte, env map[string]string, p Policy) ([]Violation, error) {
|
||||
var doc map[string]any
|
||||
if err := yaml.Unmarshal(compose, &doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := &checker{env: env, p: p}
|
||||
c := &checker{env: env, p: p, app: app}
|
||||
c.roots = c.validRoots()
|
||||
if _, ok := doc["include"]; ok {
|
||||
c.add("", "include")
|
||||
@@ -276,6 +303,7 @@ func Check(compose []byte, env map[string]string, p Policy) ([]Violation, error)
|
||||
}
|
||||
|
||||
type checker struct {
|
||||
app string
|
||||
env map[string]string
|
||||
p Policy
|
||||
roots []string
|
||||
@@ -516,7 +544,12 @@ func (c *checker) validRoots() []string {
|
||||
// round 3: HDD_PATH is the drive's whole namespace — its backups/ (recovery units carry app secrets)
|
||||
// and every other top-level folder are siblings an app has no business binding. Only appdata/ is a
|
||||
// root; the household's folders come through USERDATA_PATH.
|
||||
v = filepath.Join(v, "appdata")
|
||||
// R-1142: and only the app's OWN folder in it — appdata/<other app>/ is another app's files.
|
||||
f := AppdataFolder(c.app)
|
||||
if f == "" {
|
||||
continue
|
||||
}
|
||||
v = filepath.Join(v, "appdata", f)
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
|
||||
@@ -176,6 +176,21 @@ func TestAdmit_TopLevelAliases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// testAllowList is the shape the box had until R-981 (one entry, homepage's read-only socket). The compiled list is
|
||||
// empty since then; the entry-binding rules are still pinned on this test list, so a future entry is held to them.
|
||||
const testAllowList = `{"version":2,"entries":[{"app":"homepage","service":"homepage",
|
||||
"setting":"bind /var/run/docker.sock -> /var/run/docker.sock ro","image":"ghcr.io/gethomepage/homepage",
|
||||
"healthcheck":"[\"CMD\",\"wget\",\"--spider\",\"-q\",\"http://127.0.0.1:3000\"]"}],
|
||||
"appdata_folders":{"paperless-ngx":"paperless"}}`
|
||||
|
||||
// withTestAllowList swaps the compiled list for testAllowList for one test.
|
||||
func withTestAllowList(t *testing.T) {
|
||||
t.Helper()
|
||||
oldA, oldF := allowed, appdataFolders
|
||||
allowed, appdataFolders = mustLoad([]byte(testAllowList))
|
||||
t.Cleanup(func() { allowed, appdataFolders = oldA, oldF })
|
||||
}
|
||||
|
||||
const homepage = `services:
|
||||
homepage:
|
||||
image: ghcr.io/gethomepage/homepage:v1.13.2
|
||||
@@ -191,6 +206,7 @@ volumes:
|
||||
// The allow-list is EXACT: homepage's read-only socket is allowed for homepage's service only; the same socket
|
||||
// read-write, under another service, or in another app is refused.
|
||||
func TestAdmit_TheAllowListIsExact(t *testing.T) {
|
||||
withTestAllowList(t)
|
||||
if err := admit(t, "homepage", homepage, env()); err != nil {
|
||||
t.Fatalf("the allowed homepage socket was refused: %v", err)
|
||||
}
|
||||
@@ -273,6 +289,7 @@ func TestAllowList_CatalogCopyIsIdentical(t *testing.T) {
|
||||
// Security review 2026-10-17, finding 1: an allow-list entry holds only for its image and the image's own start. The
|
||||
// same allowed socket under a swapped image, or with an entrypoint or command added, is refused.
|
||||
func TestAdmit_TheAllowedSettingIsBoundToItsImageAndStart(t *testing.T) {
|
||||
withTestAllowList(t)
|
||||
for name, compose := range map[string]string{
|
||||
"another image": strings.Replace(homepage, "ghcr.io/gethomepage/homepage:v1.13.2", "docker.io/evil/homepage:v1.13.2", 1),
|
||||
"added entrypoint": strings.Replace(homepage, " volumes:", " entrypoint: [\"sh\", \"-c\", \"id\"]\n volumes:", 1),
|
||||
@@ -353,6 +370,7 @@ func TestAdmit_ARootElsewhereUnderMntIsNoRoot(t *testing.T) {
|
||||
|
||||
// Round 2 of the security review: the gaps found in the fixes.
|
||||
func TestAdmit_Round2(t *testing.T) {
|
||||
withTestAllowList(t)
|
||||
refuse := map[string]struct{ app, compose string }{
|
||||
"a second service extends the allowed one": {"homepage", strings.Replace(homepage, "volumes:\n homepage_config:", " evil:\n image: evil/x:1\n extends:\n service: homepage\nvolumes:\n homepage_config:", 1)},
|
||||
"the allowed service's healthcheck runs a shell": {"homepage", strings.Replace(homepage, `["CMD", "wget", "--spider", "-q", "http://127.0.0.1:3000"]`, `["CMD-SHELL", "id"]`, 1)},
|
||||
@@ -438,3 +456,81 @@ func TestAdmit_RootsThatDoNotExistYet(t *testing.T) {
|
||||
t.Fatalf("binds under roots that do not exist yet were refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// R-981 (2026-10-18): homepage's Docker socket served no Felhom feature (its rendered docker.yaml is empty —
|
||||
// audits/night-2026-10-18/P1/r981-homepage-before.txt), so the compiled list has NO exception: the socket is refused
|
||||
// for homepage too, read-only or not. The catalog template no longer mounts it (TheWholeCatalogPasses).
|
||||
// RED-PROOF: with the old one-entry allowlist.json compiled in, this test fails („homepage's socket was admitted").
|
||||
func TestAllowList_HasNoExceptionAndRefusesHomepagesSocket(t *testing.T) {
|
||||
if n := len(Entries()); n != 0 {
|
||||
t.Fatalf("the compiled allow-list has %d entries; R-981 left it empty", n)
|
||||
}
|
||||
if err := admit(t, "homepage", homepage, env()); err == nil {
|
||||
t.Fatal("homepage's socket was admitted")
|
||||
}
|
||||
without := strings.Replace(homepage, " - /var/run/docker.sock:/var/run/docker.sock:ro\n", "", 1)
|
||||
if err := admit(t, "homepage", without, env()); err != nil {
|
||||
t.Fatalf("homepage without the socket was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// R-1142: under ${HDD_PATH} an app reaches its OWN folder only — appdata/<its name>/, or the folder the list names
|
||||
// (paperless-ngx keeps appdata/paperless). Another app's folder, a name that only starts like its own, and a bind
|
||||
// for an app with no usable name are refused.
|
||||
// RED-PROOF: with validRoots' root back at ${HDD_PATH}/appdata (the v0.328.0 shape), the three „another app" cases
|
||||
// are admitted and the test fails.
|
||||
func TestAdmit_AnotherAppsAppdataFolderIsRefused(t *testing.T) {
|
||||
bind := func(line string) string {
|
||||
return "services:\n app:\n image: x/y:1\n volumes:\n - " + line + "\n"
|
||||
}
|
||||
refuse := map[string]struct{ app, line string }{
|
||||
"another app's folder": {"immich", "${HDD_PATH}/appdata/nextcloud:/data"},
|
||||
"another app's sub-folder": {"romm", "${HDD_PATH}/appdata/paperless/media:/m"},
|
||||
"the whole appdata": {"immich", "${HDD_PATH}/appdata:/all"},
|
||||
"a name that starts like its own": {"immich", "${HDD_PATH}/appdata/immich-other:/x"},
|
||||
"the slug where a folder is set": {"paperless-ngx", "${HDD_PATH}/appdata/paperless-ngx:/x"},
|
||||
"a literal path to another app": {"immich", "/mnt/felhom-drives/adatlemez/felhom-data/appdata/nextcloud:/x"},
|
||||
"no app name": {"", "${HDD_PATH}/appdata/x:/x"},
|
||||
"an app name with a slash": {"a/b", "${HDD_PATH}/appdata/a/b:/x"},
|
||||
}
|
||||
for name, c := range refuse {
|
||||
t.Run("refused/"+name, func(t *testing.T) {
|
||||
if err := admit(t, c.app, bind(c.line), env()); err == nil {
|
||||
t.Fatalf("%s binding %s was admitted", c.app, c.line)
|
||||
}
|
||||
})
|
||||
}
|
||||
for name, c := range map[string]struct{ app, line string }{
|
||||
"its own folder": {"immich", "${HDD_PATH}/appdata/immich:/usr/src/app/upload"},
|
||||
"its own sub-folder": {"romm", "${HDD_PATH}/appdata/romm/resources:/romm/resources"},
|
||||
"the listed folder": {"paperless-ngx", "${HDD_PATH}/appdata/paperless/media:/usr/src/paperless/media"},
|
||||
"a literal path to its own": {"nextcloud", "/mnt/felhom-drives/adatlemez/felhom-data/appdata/nextcloud:/d"},
|
||||
"USERDATA is still shared": {"immich", "${USERDATA_PATH}/media/photos:/external/photos:ro"},
|
||||
} {
|
||||
t.Run("admitted/"+name, func(t *testing.T) {
|
||||
if err := admit(t, c.app, bind(c.line), env()); err != nil {
|
||||
t.Fatalf("%s binding %s was refused: %v", c.app, c.line, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The folder map loads only single folder names (a list entry naming a path would hand out a wider root).
|
||||
func TestAllowList_AppdataFolderMustBeOneName(t *testing.T) {
|
||||
for _, bad := range []string{"", ".", "..", "a/b", "../x", "${X}"} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("appdata folder %q was loaded", bad)
|
||||
}
|
||||
}()
|
||||
mustLoad([]byte(`{"version":2,"entries":[],"appdata_folders":{"app":"` + bad + `"}}`))
|
||||
}()
|
||||
}
|
||||
if got := AppdataFolder("paperless-ngx"); got != "paperless" {
|
||||
t.Errorf("AppdataFolder(paperless-ngx) = %q", got)
|
||||
}
|
||||
if got := AppdataFolder("immich"); got != "immich" {
|
||||
t.Errorf("AppdataFolder(immich) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,6 +401,11 @@ func (s *Server) agentDiskFormatHandler(w http.ResponseWriter, r *http.Request)
|
||||
writeDiskJSON(w, http.StatusConflict, false, "customer confirmation required", resp)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, agentapi.ErrNoDurableID) {
|
||||
s.logger.Printf("[INFO] [web] disk format refused by agent: %s has no durable id (a virtual disk without a serial, R-1069)", req.Device)
|
||||
writeDiskJSON(w, http.StatusConflict, false, s.msg(r, "disk.err.no_durable_id"), nil)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, agentapi.ErrFormatRefused) {
|
||||
s.logger.Printf("[WARN] [web] disk format refused by agent (system/backup-protected): %s", req.Device)
|
||||
writeDiskJSON(w, http.StatusConflict, false, "operator authorization required", resp)
|
||||
|
||||
@@ -2,8 +2,15 @@ package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
@@ -115,3 +122,57 @@ func TestMissingStorageLabel(t *testing.T) {
|
||||
t.Errorf("unregistered path: missing=%v label=%q, want true/non-empty", missing, label)
|
||||
}
|
||||
}
|
||||
|
||||
// R-1063: the agent is asked FIRST. When it refuses — 403 (a protected role) or 409 (the whole-guest backup target) —
|
||||
// nothing changes on the dashboard: the drive is not marked decommissioned, the default does not move, and the
|
||||
// household reads a plain reason in its language (a 409 to the browser, like the M1 refusal).
|
||||
// RED-PROOF: with SetDecommissioned + the default move back BEFORE agent.Decommission (the v0.328.0 order), the
|
||||
// drive reads decommissioned and the default moved, and this test fails.
|
||||
func TestFinalizeDecommission_AgentRefusalChangesNothing(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
status int
|
||||
want error
|
||||
huFrag, enFrag string
|
||||
}{
|
||||
{http.StatusForbidden, errDecommissionProtected, "rendszer v", "system protects this drive"},
|
||||
{http.StatusConflict, errDecommissionBackupTarget, "rendszermentés helyéül", "place for the system backup"},
|
||||
} {
|
||||
t.Run(strconv.Itoa(c.status), func(t *testing.T) {
|
||||
s := testServer(t)
|
||||
_ = s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/old", Schedulable: true, IsDefault: true})
|
||||
_ = s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/new", Schedulable: true})
|
||||
agent := &mockAgent{decommissionErr: &agentapi.DecommissionRefusedError{Status: c.status, Reason: "refused"}}
|
||||
|
||||
err := s.finalizeDecommissionWith(context.Background(), agent, "/mnt/old", "")
|
||||
if !errors.Is(err, c.want) {
|
||||
t.Fatalf("err = %v, want the plain refusal %v", err, c.want)
|
||||
}
|
||||
if len(agent.decommissionCalls) != 1 {
|
||||
t.Fatalf("agent asked %d times, want 1", len(agent.decommissionCalls))
|
||||
}
|
||||
if s.settings.IsDecommissioned("/mnt/old") {
|
||||
t.Error("the drive reads decommissioned although the agent refused")
|
||||
}
|
||||
if got := s.settings.GetDefaultStoragePath(); got != "/mnt/old" {
|
||||
t.Errorf("default moved to %q although the agent refused", got)
|
||||
}
|
||||
if !isDecommissionPolicyRefusal(err) {
|
||||
t.Error("the refusal is not mapped to 409 (the CF edge would swallow a 502's body)")
|
||||
}
|
||||
if hu := util.ErrText("hu", err); !strings.Contains(hu, c.huFrag) || !strings.Contains(hu, "Semmi nem v") {
|
||||
t.Errorf("HU text = %q", hu)
|
||||
}
|
||||
if en := util.ErrText("en", err); !strings.Contains(en, c.enFrag) || !strings.Contains(en, "Nothing was changed") {
|
||||
t.Errorf("EN text = %q", en)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Any other agent failure is still the old „failed" message, and changes nothing either.
|
||||
s := testServer(t)
|
||||
_ = s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/old", Schedulable: true, IsDefault: true})
|
||||
_ = s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/new", Schedulable: true})
|
||||
err := s.finalizeDecommissionWith(context.Background(), &mockAgent{decommissionErr: errors.New("agentapi: dial: refused")}, "/mnt/old", "")
|
||||
if err == nil || isDecommissionPolicyRefusal(err) || s.settings.IsDecommissioned("/mnt/old") {
|
||||
t.Fatalf("a transport failure: err=%v policy=%v marked=%v", err, isDecommissionPolicyRefusal(err), s.settings.IsDecommissioned("/mnt/old"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
)
|
||||
|
||||
// R-1069: a disk the agent cannot bind a format to (a virtio disk without a serial) is refused with a plain
|
||||
// sentence in the household's language — what happened, that nothing changed, who can fix it — and the init stops
|
||||
// before any mount or registration. The agent's refusal itself is unchanged (a safety rule).
|
||||
// RED-PROOF: with the ErrNoDurableID branch removed from runStorageInit, the error is „formázás sikertelen:
|
||||
// agentapi: format refused — …" and the HU/EN assertions fail.
|
||||
func TestR1069_NoDurableIDIsAPlainRefusal(t *testing.T) {
|
||||
s := testServer(t)
|
||||
agent := &mockAgent{formatErr: agentapi.ErrNoDurableID}
|
||||
_, err := s.runStorageInit(context.Background(), agent, "/dev/vdb", "ext4", "/mnt/felhom-drives/adat", "Adat", false, false, "", nil)
|
||||
if err == nil {
|
||||
t.Fatal("the init went on although the agent refused the format")
|
||||
}
|
||||
if len(agent.assignCalls) != 0 {
|
||||
t.Fatalf("a mount was attempted after the refusal: %v", agent.assignCalls)
|
||||
}
|
||||
hu, en := util.ErrText("hu", err), util.ErrText("en", err)
|
||||
for _, frag := range []string{"virtu", "sorozatsz", "nem v", "zemeltet"} {
|
||||
if !strings.Contains(hu, frag) {
|
||||
t.Errorf("HU text lacks %q: %q", frag, hu)
|
||||
}
|
||||
}
|
||||
for _, frag := range []string{"virtual machine", "serial number", "was not changed", "operator"} {
|
||||
if !strings.Contains(en, frag) {
|
||||
t.Errorf("EN text lacks %q: %q", frag, en)
|
||||
}
|
||||
}
|
||||
if strings.Contains(hu, "agentapi") || strings.Contains(en, "agentapi") {
|
||||
t.Errorf("the raw agent text reached the household: %q / %q", hu, en)
|
||||
}
|
||||
}
|
||||
@@ -142,6 +142,9 @@ func (s *Server) runStorageInit(ctx context.Context, agent diskAgent, device, fs
|
||||
}
|
||||
return res, nil // STOP — no bypass; the UI surfaces Opsign.
|
||||
}
|
||||
if errors.Is(err, agentapi.ErrNoDurableID) {
|
||||
return storageInitResult{}, util.MsgError("disk.err.no_durable_id") // R-1069: plain words, nothing changed
|
||||
}
|
||||
if err != nil && !isAgentTimeout(err) {
|
||||
return storageInitResult{}, util.MsgError("err.web.formazas_sikertelen", err)
|
||||
}
|
||||
@@ -562,7 +565,7 @@ func (s *Server) handleStorageDecommission(w http.ResponseWriter, r *http.Reques
|
||||
// The M1 policy refusal is a 409 (CONFLICT with current state), not a gateway
|
||||
// failure — and 409 passes the CF edge with its JSON body intact.
|
||||
status := http.StatusBadGateway
|
||||
if errors.Is(err, errLastUsableDrive) {
|
||||
if isDecommissionPolicyRefusal(err) {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
writeDiskJSON(w, status, false, s.errText(r, err), nil)
|
||||
@@ -580,6 +583,19 @@ func (s *Server) handleStorageDecommission(w http.ResponseWriter, r *http.Reques
|
||||
// browser intact (a 502 would be swallowed by the Cloudflare edge error page).
|
||||
var errLastUsableDrive = util.MsgError("err.web.ez_az_egyetlen_hasznalhato_tarhely_a")
|
||||
|
||||
// errDecommissionProtected / errDecommissionBackupTarget are the agent's two policy refusals (R-1063), in plain
|
||||
// words. Both are 409s like the M1 refusal, and in both NOTHING changed: the agent is asked first.
|
||||
var (
|
||||
errDecommissionProtected = util.MsgError("err.web.decommission_protected")
|
||||
errDecommissionBackupTarget = util.MsgError("err.web.decommission_backup_target")
|
||||
)
|
||||
|
||||
// isDecommissionPolicyRefusal: a refusal the household can act on (409 with its JSON), not a gateway failure.
|
||||
func isDecommissionPolicyRefusal(err error) bool {
|
||||
return errors.Is(err, errLastUsableDrive) || errors.Is(err, errDecommissionProtected) ||
|
||||
errors.Is(err, errDecommissionBackupTarget)
|
||||
}
|
||||
|
||||
// finalizeDecommission resolves the agent client then soft-marks + decommissions (see *With).
|
||||
func (s *Server) finalizeDecommission(ctx context.Context, where, migratedTo string) error {
|
||||
agent, err := s.agentClient()
|
||||
@@ -589,10 +605,15 @@ func (s *Server) finalizeDecommission(ctx context.Context, where, migratedTo str
|
||||
return s.finalizeDecommissionWith(ctx, agent, where, migratedTo)
|
||||
}
|
||||
|
||||
// finalizeDecommissionWith soft-marks the registry path (keeping the entry — blocks A1 resurrection)
|
||||
// and tells the agent to decommission the drive (intent + unmount; never formats). migratedTo is the
|
||||
// target path for a migrate-then-decommission, or "" for decommission-anyway. The agent is injected so
|
||||
// the orchestration is testable without a live agent.
|
||||
// finalizeDecommissionWith tells the agent to decommission the drive (intent + unmount; never formats) and, only
|
||||
// once the agent said yes, soft-marks the registry path (keeping the entry — blocks A1 resurrection) and moves the
|
||||
// default. migratedTo is the target path for a migrate-then-decommission, or "" for decommission-anyway. The agent
|
||||
// is injected so the orchestration is testable without a live agent.
|
||||
//
|
||||
// R-1063 (2026-10-18): the AGENT IS ASKED FIRST. Until v0.328.0 the mark and the default moved before the agent was
|
||||
// asked, and its 403 (protected role) or 409 (whole-guest backup target) left the dashboard showing a retired drive
|
||||
// the agent kept serving. On a refusal nothing changes now, and the household reads which refusal it was.
|
||||
// Pinned by TestFinalizeDecommission_AgentRefusalChangesNothing.
|
||||
func (s *Server) finalizeDecommissionWith(ctx context.Context, agent diskAgent, where, migratedTo string) error {
|
||||
// M1: never leave zero default. If `where` is the default, promote another usable drive; if none
|
||||
// exists, BLOCK before any side-effect (compute against the registry BEFORE SetDecommissioned, which
|
||||
@@ -601,7 +622,22 @@ func (s *Server) finalizeDecommissionWith(ctx context.Context, agent diskAgent,
|
||||
if mustBlock {
|
||||
return errLastUsableDrive
|
||||
}
|
||||
// Registered path is the STABLE /mnt/felhom-drives/<name>; the agent decommissions the raw mount.
|
||||
if _, err := agent.Decommission(ctx, agentWhere(where)); err != nil {
|
||||
var refused *agentapi.DecommissionRefusedError
|
||||
if errors.As(err, &refused) {
|
||||
s.logger.Printf("[INFO] [web] decommission of %s refused by the agent (HTTP %d) — nothing changed", where, refused.Status)
|
||||
if refused.Status == http.StatusConflict {
|
||||
return errDecommissionBackupTarget
|
||||
}
|
||||
return errDecommissionProtected
|
||||
}
|
||||
return util.MsgError("err.web.a_meghajto_leszerelese_sikertelen", err)
|
||||
}
|
||||
if err := s.settings.SetDecommissioned(where, migratedTo); err != nil {
|
||||
// The agent has let go of the drive; only the dashboard's record failed. Said loudly — the next
|
||||
// decommission press is idempotent on the agent side and writes the mark.
|
||||
s.logger.Printf("[ERROR] [web] decommission of %s: the agent decommissioned it but the registry mark failed: %v", where, err)
|
||||
return util.MsgError("err.web.nyilvantartas_frissitese_sikertelen", err)
|
||||
}
|
||||
if promote != "" {
|
||||
@@ -611,10 +647,6 @@ func (s *Server) finalizeDecommissionWith(ctx context.Context, agent diskAgent,
|
||||
s.logger.Printf("[INFO] [web] default drive reassigned %s → %s (M1)", where, promote)
|
||||
}
|
||||
}
|
||||
// Registered path is the STABLE /mnt/felhom-drives/<name>; the agent decommissions the raw mount.
|
||||
if _, err := agent.Decommission(ctx, agentWhere(where)); err != nil {
|
||||
return util.MsgError("err.web.a_meghajto_leszerelese_sikertelen", err)
|
||||
}
|
||||
if s.stackMgr != nil {
|
||||
go s.SyncFileBrowserMounts()
|
||||
}
|
||||
@@ -818,6 +850,10 @@ func (s *Server) handleStorageWipe(w http.ResponseWriter, r *http.Request) {
|
||||
writeDiskJSON(w, http.StatusConflict, false, s.msg(r, "disk.err.protected"), probe)
|
||||
return
|
||||
}
|
||||
if errors.Is(perr, agentapi.ErrNoDurableID) {
|
||||
writeDiskJSON(w, http.StatusConflict, false, s.msg(r, "disk.err.no_durable_id"), nil)
|
||||
return
|
||||
}
|
||||
if !errors.Is(perr, agentapi.ErrNeedsConfirmation) {
|
||||
if perr != nil {
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, s.msg(r, "disk.err.wipe_failed", s.errText(r, perr)), nil)
|
||||
|
||||
@@ -57,6 +57,7 @@ type mockAgent struct {
|
||||
formatStatusCalls int
|
||||
guestAttachCalls []string
|
||||
decommissionCalls []string
|
||||
decommissionErr error
|
||||
guestRebootCalls int
|
||||
guestRebootErr error
|
||||
candidates agentapi.CandidatesResult
|
||||
@@ -99,6 +100,9 @@ func (m *mockAgent) EjectDisk(_ context.Context, where string) (agentapi.EjectRe
|
||||
}
|
||||
func (m *mockAgent) Decommission(_ context.Context, where string) (agentapi.DecommissionResult, error) {
|
||||
m.decommissionCalls = append(m.decommissionCalls, where)
|
||||
if m.decommissionErr != nil {
|
||||
return agentapi.DecommissionResult{}, m.decommissionErr
|
||||
}
|
||||
return agentapi.DecommissionResult{Decommissioned: where}, nil
|
||||
}
|
||||
func (m *mockAgent) GuestAttach(_ context.Context, where string) error {
|
||||
|
||||
Reference in New Issue
Block a user