v0.328.0: the catalog allow-list — the box refuses container rights a template asks for and the box does not give (R-971, D2 = A)
gates / gates (push) Successful in 45s
gates / gates (push) Successful in 45s
internal/privpolicy checks the RENDERED compose (privileged, cap_add, devices, host namespaces, security_opt,
sysctls, volumes_from, build/include/extends, env_file/config files outside the stack dir, aliasing named volumes,
binds outside the box's data roots, the Docker socket, propagation, ${VAR} in a privileged key) against an
allow-list compiled into the controller (one entry: homepage's read-only socket). Asked before every compose
write: the 15-minute sync, the guarded update/ladder step, the install, both settings changes, both restores
(before the stop). A refusal writes nothing, keeps the app on its last good compose, raises the operator-only
event app_definition_refused once per definition and puts one line on the app page. The sync never copies a
template named like a controller infra stack. Structural guard TestAllowList_IsWiredIntoEveryComposeWriter.
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -1,3 +1,38 @@
|
||||
## v0.328.0 — the catalog allow-list: the box refuses container rights a template asks for and the box does not give (R-971, operator decision D2 = A) (2026-10-17)
|
||||
|
||||
**MinAgent: 0.158.1** — the floor's, carried. Nothing here calls a new agent route. No golden (`09` §3 decision 250).
|
||||
Task: „agent release, the catalog allow-list (D2 = A), break-glass rotation (D1 = A), off-site history check" (felhom.eu
|
||||
`REPORT-agent-allowlist-rotation-2026-10-17.md`). **HUB DEPENDENCY:** the operator event `app_definition_refused` needs
|
||||
hub ≥ v0.157.0 (`allowedEventTypes` + `operatorOnlyEvents`); an older hub answers 400 and drops the event — the box
|
||||
still refuses and the app page still says so.
|
||||
|
||||
- **`internal/privpolicy`** — the check, on the RENDERED compose: `privileged`, `cap_add`, `devices`,
|
||||
`device_cgroup_rules`, `network_mode`/`pid`/`ipc`/`uts`/`userns_mode`/`cgroup`, `security_opt` (all but
|
||||
`no-new-privileges`), `sysctls`, `volumes_from`, `build`, `include`, `extends.file`, an `env_file` / `configs` /
|
||||
`secrets` file outside the stack dir, a named volume with `driver`/`driver_opts`/`external`/`name`, binds outside the
|
||||
data roots the box gives (a bind source may use only `${HDD_PATH}`, `${USERDATA_PATH}`, `${IMPORT_PATH}`, as its
|
||||
leading token; each root must be under `/mnt/` or the system data path), the Docker socket anywhere, mount
|
||||
propagation, and `interpolated <key>` for a `${VAR}` in any privileged key (a catalog deploy-field default could
|
||||
otherwise change it later). A refusal never prints an env value. Unreadable compose → refused (fail closed).
|
||||
- **The allow-list** (`internal/privpolicy/allowlist.json`, go:embed — a change in Gitea cannot add to it): app +
|
||||
service + EXACT setting. One entry: homepage's `bind /var/run/docker.sock -> /var/run/docker.sock ro`. The survey of
|
||||
all 123 catalog files (templates + ladder steps) found nothing else in any class.
|
||||
- **Wired into every compose writer:** the 15-minute sync (`sync.copyTemplates` — a refused app keeps its compose AND
|
||||
its `.felhom.yml`, and is not reported updated), the guarded update / ladder step (`stacks.advancePinTo`), the install
|
||||
(`DeployStack`), the two settings changes (`UpdateStackConfig`, `UpdateOptionalConfig`), the unit and off-site
|
||||
restores (asked BEFORE the app is stopped, and again by the writer `RecreateStackDefinitionFromUnit`). Structural
|
||||
guard `TestAllowList_IsWiredIntoEveryComposeWriter` (AST, comments dropped): every production function that names the
|
||||
compose file and writes a file is gated or exempt with its reason; the decision points ask before their first
|
||||
mutation.
|
||||
- **A catalog template named like a controller infra stack is never copied** (traefik, cloudflared, filebrowser, samba,
|
||||
felhom-controller) — the sync used to copy one into the infra stack's directory.
|
||||
- **The refusal is visible:** the operator-only event `app_definition_refused` (once per refused definition), one line
|
||||
on the app page (`app_info.definition_refused`, both languages; new parity fixture
|
||||
`app_settings_definition_refused`, every other fixture byte-identical), and the plain message
|
||||
`err.stacks.definition_refused` for a refused install / settings change / restore.
|
||||
- Red-proofs: felhom.eu `audits/agent-allowlist-rotation-2026-10-17/P2/redproof-allowlist.txt` (six mutations, each
|
||||
convicted by its test).
|
||||
|
||||
## v0.327.1 — files.<domain> reaches FileBrowser again: a file route by name, not Docker labels (2026-10-17)
|
||||
|
||||
**MinAgent: 0.158.1** — the floor's, carried. No golden. Task: as v0.327.0.
|
||||
|
||||
@@ -136,6 +136,7 @@
|
||||
|
||||
| Symbol | File | Short signature | Use for | Gotchas |
|
||||
|---|---|---|---|---|
|
||||
| `stacks.Manager.admitCompose` / `privpolicy.Admit` (v0.328.0, R-971) | controller/internal/stacks/allowlist.go, controller/internal/privpolicy/privpolicy.go | `admitCompose(name, data, env) error` | THE question before ANY write of an app compose (and before a settings change or restore commits an env): the box's catalog allow-list on the RENDERED compose | A new compose writer must call it first — `TestAllowList_IsWiredIntoEveryComposeWriter` fails otherwise; a refusal records the page line + one hub event per definition; the allow-list is `privpolicy/allowlist.json` (exact app+service+setting) and the catalog's `scripts/privilege-allowlist.json` must stay byte-identical |
|
||||
| `Manager.DeployStack` | controller/internal/stacks/deploy.go | `(req DeployRequest) (string, error)` | Full deploy flow | Sets in-memory `Deployed` BEFORE compose up (slow-pull race), reverts on failure |
|
||||
| `DeclaredVolumeNames` (R-658, v0.268.0) | controller/internal/stacks/undo.go | `(composePath) (own, external []string, err)` | THE app's named volumes as Docker names them: `name:` else `<project>_<key>`, project = top-level `name:` else the stack dir | **Never select an app's volumes by the `com.docker.compose.project` label** — a restore before v0.268.0 made volumes without it, and the undo then copied nothing (R-658). The label is a cross-check only |
|
||||
| `renderDigests` / `RenderWithLadderDigests` / `CarryDigests` / `StripDigest` (v0.269.0/.1) | controller/internal/stacks/digest.go | compose bytes → the same with `ref@sha256` from the ladder entry for its refs | writing a compose that RUNS (update; the sync for an app NOT yet installed) — an INSTALLED app's sync uses `CarryDigests` (v0.269.1), which keeps the digest the app runs | Pins and records are digest-free by construction (`parseComposeImagesBytes` and the installed record strip) — never compare a raw `.Config.Image` against a pin |
|
||||
|
||||
@@ -1182,6 +1182,26 @@ Multiple checks per app are supported (all must pass). The probe scheduler runs
|
||||
|
||||
---
|
||||
|
||||
### 1a. The catalog allow-list — container rights the box gives (v0.328.0, R-971)
|
||||
|
||||
A push to the catalog's `main` reaches every installed app within about 15 minutes, so the box no longer trusts a
|
||||
template's container rights. Every path that writes an app compose — the install, a settings change, the 15-minute
|
||||
correction, an update or ladder step, a unit or off-site restore — first renders the compose with the app's own values
|
||||
(as `compose up` will) and asks `internal/privpolicy`: `privileged`, `cap_add`, `devices`, host namespaces
|
||||
(`network_mode`/`pid`/`ipc`/`uts`/`userns_mode`/`cgroup`), `security_opt` other than `no-new-privileges`, `sysctls`,
|
||||
`volumes_from`, `build`/`include`/`extends` files, an `env_file` or config file outside the stack directory, a named
|
||||
volume with a driver/`driver_opts`/`external`/`name`, a `${VAR}` inside any of those, and any bind outside the data
|
||||
roots the box itself gives (`${HDD_PATH}`, `${USERDATA_PATH}`, `${IMPORT_PATH}`) or of the Docker socket. Such a setting
|
||||
is refused unless this app's service has EXACTLY that setting on the list compiled into the controller
|
||||
(`internal/privpolicy/allowlist.json` — today one entry: homepage's read-only Docker socket). A refusal writes nothing:
|
||||
a refused correction or update leaves the app running on its last good compose, raises the operator-only hub event
|
||||
`app_definition_refused` once per refused definition, and puts one line on the app page; a refused install, settings
|
||||
change or restore is refused with a plain message („Semmi nem változott"). A catalog template named like a controller
|
||||
infra stack (traefik, cloudflared, filebrowser, samba, felhom-controller) is never copied. The controller's own infra
|
||||
stacks and override files are rendered by the controller and are out of scope. The catalog's `check-privileges.py`
|
||||
mirrors the check in CI (a byte-identical copy of the list). The structural guard
|
||||
`TestAllowList_IsWiredIntoEveryComposeWriter` fails on any new compose writer that does not ask first.
|
||||
|
||||
### 2. App Export/Import (.fab bundles)
|
||||
|
||||
Per-app export creates a self-contained `.fab` file (tar.gz, optionally encrypted) that can be stored externally or used to restore the app on the same server. Distinct from the automatic backup system — user-initiated, per-app, produces a single portable file.
|
||||
|
||||
@@ -418,6 +418,7 @@ func main() {
|
||||
// the pre-v0.235.0 product; the freeze is INERT unless it is wired here, which is why a test
|
||||
// walks this file's AST for the call rather than trusting the package to be correct alone.
|
||||
syncer.SetRenderPlanFn(stackMgr.RenderPlanFor)
|
||||
syncer.SetAdmitFn(stackMgr.AdmitSyncedCompose) // R-971: the catalog allow-list, before any compose is written
|
||||
// Start() is deliberately NOT called here — see the comment beside the call further down, after
|
||||
// the pin adoption pass.
|
||||
defer syncer.Stop()
|
||||
@@ -2001,6 +2002,12 @@ func main() {
|
||||
// `app_deploy_started`, emitted by the API router beside its 202.
|
||||
// v0.279.0: the hook is set on EVERY box — after_install (decision 45) must run on a box with no hub too;
|
||||
// only the events need the notifier.
|
||||
// R-971: a definition the catalog allow-list refused raises the operator-only event (once per definition).
|
||||
stackMgr.SetDefinitionRefusedHook(func(name, detail string) {
|
||||
if notifier != nil {
|
||||
notifier.NotifyAppDefinitionRefused(name, detail)
|
||||
}
|
||||
})
|
||||
stackMgr.SetDeployDoneHook(func(name string, ok bool, detail string) {
|
||||
display := name
|
||||
if s, found := stackMgr.GetStack(name); found && s.Meta.DisplayName != "" {
|
||||
@@ -3228,6 +3235,10 @@ func (a *stackAdapter) RecreateStackDefinitionFromUnit(name, composeSrcDir strin
|
||||
return fmt.Errorf("stack %q not found", name)
|
||||
}
|
||||
stackDir := filepath.Dir(s.ComposePath)
|
||||
// R-971: the writer asks too (the restores asked before they stopped the app; this is the last word).
|
||||
if err := a.AdmitDefinitionFromUnit(name, composeSrcDir, fullEnv); err != nil {
|
||||
return err
|
||||
}
|
||||
// Recover the app definition from the unit (compose + .felhom.yml) into the stack dir.
|
||||
for _, fname := range []string{"docker-compose.yml", ".felhom.yml"} {
|
||||
data, err := os.ReadFile(filepath.Join(composeSrcDir, fname))
|
||||
@@ -3265,6 +3276,16 @@ func (a *stackAdapter) RecreateStackDefinitionFromUnit(name, composeSrcDir strin
|
||||
return nil
|
||||
}
|
||||
|
||||
// AdmitDefinitionFromUnit (R-971) checks a unit's compose, rendered with the restore's env, against the box's catalog
|
||||
// allow-list. No compose in the unit → nothing would be written → nil.
|
||||
func (a *stackAdapter) AdmitDefinitionFromUnit(name, composeSrcDir string, fullEnv map[string]string) error {
|
||||
data, err := os.ReadFile(filepath.Join(composeSrcDir, "docker-compose.yml"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return a.mgr.AdmitCompose(name, data, fullEnv)
|
||||
}
|
||||
|
||||
// SetStackSecretValues writes the named values into the app's app.yaml (R-1106: the off-site restore's database
|
||||
// password). The optional backup.secretValueSetter.
|
||||
func (a *stackAdapter) SetStackSecretValues(name string, values map[string]string) error {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package backup
|
||||
|
||||
// definitionAdmitter is the R-971 question a restore asks BEFORE it stops the app: would the unit's (or the snapshot's)
|
||||
// compose, rendered with the restore's env, pass the box's catalog allow-list? Implemented by main.go's stackAdapter;
|
||||
// a provider without it (the test fakes) asks nothing. A refusal leaves the live app running and untouched.
|
||||
type definitionAdmitter interface {
|
||||
AdmitDefinitionFromUnit(name, composeSrcDir string, fullEnv map[string]string) error
|
||||
}
|
||||
|
||||
// admitRestoredDefinition asks the provider when it can answer; nil when it cannot.
|
||||
func (m *Manager) admitRestoredDefinition(name, composeSrcDir string, env map[string]string) error {
|
||||
if a, ok := m.stackProvider.(definitionAdmitter); ok {
|
||||
return a.AdmitDefinitionFromUnit(name, composeSrcDir, env)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -905,6 +905,15 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
|
||||
}
|
||||
}
|
||||
|
||||
// R-971: the snapshot's compose must pass the box's allow-list — asked before the stop, so a refusal leaves the
|
||||
// live app untouched.
|
||||
if defineFromSnapshot {
|
||||
if err := m.admitRestoredDefinition(stack, scratchCompose, snapEnv); err != nil {
|
||||
m.logger.Printf("[ERROR] [offbox] Restore REFUSED for %s: %v", stack, err)
|
||||
return res, util.MsgError("err.stacks.definition_refused", stack, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// --- FILES ----------------------------------------------------------------------------------
|
||||
// R-166: mark the stop→restore→start window BEFORE stopping. A controller killed anywhere inside
|
||||
// it used to leave the app down with nothing on disk recording that it was owed a restart — and a
|
||||
|
||||
@@ -415,6 +415,13 @@ func (m *Manager) RestoreFromRecoveryUnitAtWith(stackName, unitDir string, opt U
|
||||
return res, util.MsgError("err.backup.adatbazis_masolat_csonka_nem_indult", stackName)
|
||||
}
|
||||
|
||||
// R-971: the unit's compose must pass the box's allow-list — asked HERE, before the stop, so a refusal leaves the
|
||||
// live app running and untouched.
|
||||
if err := m.admitRestoredDefinition(stackName, composeDir, fullEnv); err != nil {
|
||||
m.logger.Printf("[ERROR] [backup] Restore REFUSED for %s: %v", stackName, err)
|
||||
return res, util.MsgError("err.stacks.definition_refused", stackName, err.Error())
|
||||
}
|
||||
|
||||
// Stop, restore named-volume data, recreate the definition, replay the DB with ONLY the database
|
||||
// service running, and only then start the whole stack.
|
||||
// F17: surface a data-restore failure instead of swallowing it (we still bring the app back up).
|
||||
|
||||
@@ -194,6 +194,7 @@
|
||||
"app_info.mire_hasznalhato": "What is it for?",
|
||||
"app_info.naplo": "Log",
|
||||
"app_info.hold_log_kept": "The app's own log from before it was stopped was kept — it shows why the new version did not start.",
|
||||
"app_info.definition_refused": "The box did not accept the catalog's newer definition of this app: it asks for rights the box does not give. The app keeps running on its last good definition; the operator has been told.",
|
||||
"app_info.nincs_folyamatban_athelyezes": "No move in progress.",
|
||||
"app_info.regi_adatok_torlese": "Deleting old data",
|
||||
"app_info.telepites": "Install",
|
||||
@@ -2695,6 +2696,7 @@
|
||||
"login.msg.wrong_password": "Wrong password.",
|
||||
"err.stacks.family_gate_failed": "The family gate could not be prepared, so the app was not installed: %s",
|
||||
"err.stacks.needs_newer_controller": "This app needs newer box software, so it was not installed: %s",
|
||||
"err.stacks.definition_refused": "The box does not allow this: the definition of %s asks for rights on the machine that the box does not give (%s). Nothing was changed.",
|
||||
"family_gate.page_title": "Family sign-in",
|
||||
"family_gate.page_body": "Open this app with your family name and password.",
|
||||
"family_gate.name": "Your name",
|
||||
|
||||
@@ -190,6 +190,7 @@
|
||||
"app_info.mire_hasznalhato": "Mire használható?",
|
||||
"app_info.naplo": "Napló",
|
||||
"app_info.hold_log_kept": "Az alkalmazás saját naplója megmaradt a leállítás előttről — ebből látszik, miért nem indult el az új változat.",
|
||||
"app_info.definition_refused": "A katalógus újabb leírását ehhez az alkalmazáshoz a doboz nem fogadta el, mert olyan jogot kér, amit a doboz nem ad meg. Az alkalmazás a legutóbbi jó beállításaival fut tovább; az üzemeltetőt értesítettük.",
|
||||
"app_info.nincs_folyamatban_athelyezes": "Nincs folyamatban áthelyezés.",
|
||||
"app_info.regi_adatok_torlese": "Régi adatok törlése",
|
||||
"app_info.telepites": "Telepítés",
|
||||
@@ -2683,6 +2684,7 @@
|
||||
"login.msg.wrong_password": "Hibás jelszó.",
|
||||
"err.stacks.family_gate_failed": "A családi kapu nem készült el, ezért nem telepítettük az alkalmazást: %s",
|
||||
"err.stacks.needs_newer_controller": "Ehhez az alkalmazáshoz újabb doboz-szoftver kell, ezért most nem telepítettük: %s",
|
||||
"err.stacks.definition_refused": "A doboz ezt nem engedi: a(z) %s leírása olyan jogot kér a géptől, amit a doboz nem ad meg (%s). Semmi nem változott.",
|
||||
"family_gate.page_title": "Családi belépés",
|
||||
"family_gate.page_body": "Ezt az alkalmazást a családi neveddel és jelszavaddal nyithatod meg.",
|
||||
"family_gate.name": "Neved",
|
||||
|
||||
@@ -1468,6 +1468,25 @@ func (n *Notifier) NotifyAppHoldNoWholeCopy(d AppHoldNoWholeCopyDetails) {
|
||||
n.pushEventBoth("app_hold_no_whole_copy", "critical", msg, "", d)
|
||||
}
|
||||
|
||||
// AppDefinitionRefusedDetails is the R-971 event's structured part.
|
||||
type AppDefinitionRefusedDetails struct {
|
||||
App string `json:"app"`
|
||||
Refused string `json:"refused"`
|
||||
}
|
||||
|
||||
// NotifyAppDefinitionRefused (v0.328.0, R-971; operator decision D2 = A): the box's catalog allow-list refused an
|
||||
// app's definition — a catalog correction or update, a restore, a settings change. Nothing was written; the app keeps
|
||||
// its last good compose. OPERATOR-ONLY on the hub (allowedEventTypes AND operatorOnlyEvents, hub >= v0.157.0): the
|
||||
// household can do nothing about it — someone pushed a catalog definition the box does not trust. One event per
|
||||
// distinct refused definition (stacks.Manager.noteAdmission), not one every 15 minutes.
|
||||
// HUB DEPENDENCY: a hub without the type answers 400 and the event is lost; the box still refuses and the app page
|
||||
// still says so.
|
||||
func (n *Notifier) NotifyAppDefinitionRefused(app, detail string) {
|
||||
msg := fmt.Sprintf("%s: the box REFUSED a catalog definition — it asks for container rights not on the box's allow-list. Nothing was written; the app keeps its last good compose. Check who pushed to the catalog. %s",
|
||||
app, detail)
|
||||
n.pushEventBoth("app_definition_refused", "warning", msg, "", AppDefinitionRefusedDetails{App: app, Refused: detail})
|
||||
}
|
||||
|
||||
// healthSeverity is the event severity a health status would be sent at (R-647): the disabled path
|
||||
// names what it drops, and "warn" is a health STATUS, not a severity the hub knows.
|
||||
func healthSeverity(status string) string {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"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; 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",
|
||||
"why": "the Docker widget and the docker service discovery list the household's containers and their state",
|
||||
"doc": "https://gethomepage.dev/configs/docker/"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
// Package privpolicy is the box-side catalog allow-list (R-971, operator decision D2 = A, 2026-10-17;
|
||||
// felhom.eu `02-controller-module-map.md` „The catalog allow-list").
|
||||
//
|
||||
// A push to the catalog's `main` reaches every installed app within about 15 minutes. Before this package nothing on
|
||||
// the box refused a dangerous container setting, so whoever could push to the catalog could take every guest. Now
|
||||
// every path that writes an app's compose asks Admit first, on the RENDERED compose (after variable substitution),
|
||||
// and writes nothing when it refuses. A setting in one of the classes below is refused unless THIS app's service has
|
||||
// exactly that setting on the allow-list compiled into the controller (allowlist.json, go:embed) — a change in Gitea
|
||||
// cannot add to it. An allowed setting that changes (a read-only socket becoming read-write) is a different setting,
|
||||
// so it is refused too.
|
||||
//
|
||||
// The classes (each a Violation.Setting prefix):
|
||||
//
|
||||
// privileged, cap_add, device, device_cgroup_rule, network_mode, pid, ipc, uts, userns_mode, cgroup,
|
||||
// security_opt (every value except no-new-privileges), sysctl, volumes_from,
|
||||
// bind (a host path outside the box's data roots, the Docker socket anywhere, mount propagation),
|
||||
// volume (a named volume with a driver, driver_opts, external or an explicit name — each can alias a host path or
|
||||
// another stack's volume), config_file / secret_file / env_file (a host file outside the stack directory),
|
||||
// build, include, extends_file,
|
||||
// interpolated (a ${VAR} inside any of the keys above — a value the catalog's own deploy fields could later change)
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// Pinned by privpolicy_test.go (one refused case per class, a positive control per class, the allow-list's exact-
|
||||
// match rule) and stacks.TestAllowList_IsWiredIntoEveryComposeWriter (the structural guard).
|
||||
package privpolicy
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
//go:embed allowlist.json
|
||||
var allowListJSON []byte
|
||||
|
||||
// AllowListJSON is the compiled-in allow-list, byte for byte (the catalog keeps an identical copy for its CI gate;
|
||||
// TestAllowList_CatalogCopyIsIdentical pins the two).
|
||||
func AllowListJSON() []byte { return append([]byte(nil), allowListJSON...) }
|
||||
|
||||
// Entry is one allowed setting.
|
||||
type Entry struct {
|
||||
App string `json:"app"`
|
||||
Service string `json:"service"`
|
||||
Setting string `json:"setting"`
|
||||
Why string `json:"why"`
|
||||
Doc string `json:"doc"`
|
||||
}
|
||||
|
||||
type allowList struct {
|
||||
Version int `json:"version"`
|
||||
Entries []Entry `json:"entries"`
|
||||
}
|
||||
|
||||
var allowed = mustLoad(allowListJSON)
|
||||
|
||||
func mustLoad(b []byte) map[string]bool {
|
||||
var l allowList
|
||||
if err := json.Unmarshal(b, &l); err != nil {
|
||||
panic("privpolicy: allowlist.json does not parse: " + err.Error())
|
||||
}
|
||||
m := map[string]bool{}
|
||||
for _, e := range l.Entries {
|
||||
m[key(e.App, e.Service, e.Setting)] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func key(app, svc, setting string) string { return app + "\x00" + svc + "\x00" + setting }
|
||||
|
||||
// Entries returns the allow-list (for the doc and the app page).
|
||||
func Entries() []Entry {
|
||||
var l allowList
|
||||
_ = json.Unmarshal(allowListJSON, &l)
|
||||
return l.Entries
|
||||
}
|
||||
|
||||
// Violation is one setting in a class. Service is "" for a top-level definition (a volume, include).
|
||||
type Violation struct {
|
||||
Service string
|
||||
Setting string
|
||||
}
|
||||
|
||||
func (v Violation) String() string {
|
||||
if v.Service == "" {
|
||||
return v.Setting
|
||||
}
|
||||
return v.Service + ": " + v.Setting
|
||||
}
|
||||
|
||||
// RefusedError lists what was refused, in plain words. The app keeps its last good compose.
|
||||
type RefusedError struct {
|
||||
App string
|
||||
Violations []Violation
|
||||
}
|
||||
|
||||
func (e *RefusedError) Error() string {
|
||||
parts := make([]string, len(e.Violations))
|
||||
for i, v := range e.Violations {
|
||||
parts[i] = v.String()
|
||||
}
|
||||
return fmt.Sprintf("the catalog definition of %s asks for container rights the box does not allow: %s", e.App, strings.Join(parts, "; "))
|
||||
}
|
||||
|
||||
// Policy carries what the box knows about its own paths.
|
||||
type Policy struct {
|
||||
SystemDataPath string // paths.system_data_path — a data root may live under it (or under /mnt/)
|
||||
}
|
||||
|
||||
// RootVars are the only variables a bind source may use.
|
||||
var RootVars = []string{"HDD_PATH", "USERDATA_PATH", "IMPORT_PATH"}
|
||||
|
||||
// PlaceholderEnv is the env a template is checked with when no app env exists yet (the sync of an app that is not
|
||||
// installed, the catalog CI). Same shape as the controller gives; the catalog gate uses the same three values.
|
||||
func PlaceholderEnv() map[string]string {
|
||||
return map[string]string{
|
||||
"HDD_PATH": "/mnt/felhom-drives/check/felhom-data",
|
||||
"USERDATA_PATH": "/mnt/felhom-drives/check/felhom-data/userdata",
|
||||
"IMPORT_PATH": "/mnt/felhom-drives/check/felhom-data/userdata/import",
|
||||
"DOMAIN": "example.invalid",
|
||||
"SUBDOMAIN": "check",
|
||||
}
|
||||
}
|
||||
|
||||
// Admit is the one question every compose writer asks. nil = write it; *RefusedError = write nothing; any other
|
||||
// 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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the catalog definition of %s could not be checked: %w", app, err)
|
||||
}
|
||||
var refused []Violation
|
||||
for _, v := range vs {
|
||||
if !allowed[key(app, v.Service, v.Setting)] {
|
||||
refused = append(refused, v)
|
||||
}
|
||||
}
|
||||
if len(refused) > 0 {
|
||||
return &RefusedError{App: app, Violations: refused}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check lists every setting in a class, allowed or not, sorted.
|
||||
func Check(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.roots = c.validRoots()
|
||||
if _, ok := doc["include"]; ok {
|
||||
c.add("", "include")
|
||||
}
|
||||
if vols, ok := doc["volumes"].(map[string]any); ok {
|
||||
names := sortedKeys(vols)
|
||||
for _, n := range names {
|
||||
def, _ := vols[n].(map[string]any)
|
||||
for _, k := range []string{"driver", "driver_opts", "external", "name"} {
|
||||
if v, has := def[k]; has && !(k == "driver" && c.render(v) == "local") {
|
||||
c.add("", fmt.Sprintf("volume %s %s %s", n, k, c.canon(v)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, top := range []string{"configs", "secrets"} {
|
||||
defs, _ := doc[top].(map[string]any)
|
||||
for _, n := range sortedKeys(defs) {
|
||||
def, _ := defs[n].(map[string]any)
|
||||
if f, has := def["file"]; has && !c.insideStack(c.render(f)) {
|
||||
c.add("", fmt.Sprintf("%s_file %s %s", strings.TrimSuffix(top, "s"), n, c.shown(f)))
|
||||
}
|
||||
}
|
||||
}
|
||||
svcs, _ := doc["services"].(map[string]any)
|
||||
for _, name := range sortedKeys(svcs) {
|
||||
sd, _ := svcs[name].(map[string]any)
|
||||
c.service(name, sd)
|
||||
}
|
||||
sort.Slice(c.out, func(i, j int) bool {
|
||||
if c.out[i].Service != c.out[j].Service {
|
||||
return c.out[i].Service < c.out[j].Service
|
||||
}
|
||||
return c.out[i].Setting < c.out[j].Setting
|
||||
})
|
||||
return c.out, nil
|
||||
}
|
||||
|
||||
type checker struct {
|
||||
env map[string]string
|
||||
p Policy
|
||||
roots []string
|
||||
out []Violation
|
||||
svc string
|
||||
}
|
||||
|
||||
func (c *checker) add(svc, setting string) {
|
||||
c.out = append(c.out, Violation{Service: svc, Setting: setting})
|
||||
}
|
||||
|
||||
// scalarKeys are the classes whose whole value is the setting.
|
||||
var scalarKeys = []string{"network_mode", "pid", "ipc", "uts", "userns_mode", "cgroup"}
|
||||
|
||||
// privilegedKeys are every key whose value must be literal (no ${VAR}).
|
||||
var privilegedKeys = []string{"privileged", "cap_add", "devices", "device_cgroup_rules", "network_mode", "pid", "ipc",
|
||||
"uts", "userns_mode", "cgroup", "security_opt", "sysctls", "volumes_from"}
|
||||
|
||||
func (c *checker) service(name string, sd map[string]any) {
|
||||
c.svc = name
|
||||
// A ${VAR} in a privileged key is refused as such, and the key is NOT rendered: its value could be a secret, and a
|
||||
// refusal names what it refused (logs, the operator event).
|
||||
interp := map[string]bool{}
|
||||
for _, k := range privilegedKeys {
|
||||
if v, has := sd[k]; has && hasInterp(v) {
|
||||
c.add(name, "interpolated "+k)
|
||||
interp[k] = true
|
||||
}
|
||||
}
|
||||
sd = withoutKeys(sd, interp)
|
||||
if v, has := sd["privileged"]; has && truthy(c.render(v)) {
|
||||
c.add(name, "privileged true")
|
||||
}
|
||||
for _, v := range list(sd["cap_add"]) {
|
||||
c.add(name, "cap_add "+strings.TrimPrefix(strings.ToUpper(c.render(v)), "CAP_"))
|
||||
}
|
||||
for _, v := range list(sd["devices"]) {
|
||||
c.add(name, "device "+c.canon(v))
|
||||
}
|
||||
for _, v := range list(sd["device_cgroup_rules"]) {
|
||||
c.add(name, "device_cgroup_rule "+c.render(v))
|
||||
}
|
||||
for _, k := range scalarKeys {
|
||||
if v, has := sd[k]; has {
|
||||
c.add(name, k+" "+c.render(v))
|
||||
}
|
||||
}
|
||||
for _, v := range list(sd["security_opt"]) {
|
||||
s := c.render(v)
|
||||
if noNewPrivileges(s) {
|
||||
continue // a hardening, not a privilege
|
||||
}
|
||||
c.add(name, "security_opt "+s)
|
||||
}
|
||||
switch sy := sd["sysctls"].(type) {
|
||||
case map[string]any:
|
||||
for _, k := range sortedKeys(sy) {
|
||||
c.add(name, fmt.Sprintf("sysctl %s=%s", k, c.render(sy[k])))
|
||||
}
|
||||
case []any:
|
||||
for _, v := range sy {
|
||||
c.add(name, "sysctl "+c.render(v))
|
||||
}
|
||||
}
|
||||
for _, v := range list(sd["volumes_from"]) {
|
||||
c.add(name, "volumes_from "+c.render(v))
|
||||
}
|
||||
if _, has := sd["build"]; has {
|
||||
c.add(name, "build")
|
||||
}
|
||||
if ex, ok := sd["extends"].(map[string]any); ok {
|
||||
if f, has := ex["file"]; has {
|
||||
c.add(name, "extends_file "+c.shown(f))
|
||||
}
|
||||
}
|
||||
for _, v := range list(sd["env_file"]) {
|
||||
raw := v
|
||||
if m, ok := v.(map[string]any); ok {
|
||||
raw = m["path"]
|
||||
}
|
||||
if !c.insideStack(c.render(raw)) {
|
||||
c.add(name, "env_file "+c.shown(raw))
|
||||
}
|
||||
}
|
||||
for _, v := range list(sd["volumes"]) {
|
||||
c.volume(name, v)
|
||||
}
|
||||
}
|
||||
|
||||
// volume classifies one service volume entry (short or long syntax).
|
||||
// withoutKeys returns sd without the named keys (a shallow copy; sd itself is not changed).
|
||||
func withoutKeys(sd map[string]any, drop map[string]bool) map[string]any {
|
||||
if len(drop) == 0 {
|
||||
return sd
|
||||
}
|
||||
out := make(map[string]any, len(sd))
|
||||
for k, v := range sd {
|
||||
if !drop[k] {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *checker) volume(svc string, v any) {
|
||||
var rawSrc, src, dst, mode, prop string
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
parts := strings.Split(t, ":")
|
||||
if len(parts) < 2 {
|
||||
return // an anonymous volume: a container path only
|
||||
}
|
||||
rawSrc = parts[0]
|
||||
src, dst = c.render(parts[0]), c.render(parts[1])
|
||||
mode = "rw"
|
||||
if len(parts) > 2 {
|
||||
for _, o := range strings.Split(parts[2], ",") {
|
||||
switch o {
|
||||
case "ro":
|
||||
mode = "ro"
|
||||
case "shared", "rshared", "slave", "rslave":
|
||||
prop = o
|
||||
}
|
||||
}
|
||||
}
|
||||
if !isPathSource(src) && !isPathSource(rawSrc) {
|
||||
return // a named volume (its definition is checked at the top level)
|
||||
}
|
||||
case map[string]any:
|
||||
typ := c.render(t["type"])
|
||||
rawSrc = fmt.Sprint(t["source"])
|
||||
src, dst = c.render(t["source"]), c.render(t["target"])
|
||||
mode = "rw"
|
||||
if truthy(c.render(t["read_only"])) {
|
||||
mode = "ro"
|
||||
}
|
||||
if b, ok := t["bind"].(map[string]any); ok {
|
||||
if p := c.render(b["propagation"]); p == "shared" || p == "rshared" || p == "slave" || p == "rslave" {
|
||||
prop = p
|
||||
}
|
||||
}
|
||||
switch typ {
|
||||
case "volume", "tmpfs", "image":
|
||||
return
|
||||
case "bind":
|
||||
default:
|
||||
c.add(svc, "volume type "+typ)
|
||||
return
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
shown := src
|
||||
if !rootLed(rawSrc) {
|
||||
shown = rawSrc // the template text: a rendered non-root variable could be a secret
|
||||
}
|
||||
setting := fmt.Sprintf("bind %s -> %s %s", shown, dst, mode)
|
||||
if prop != "" {
|
||||
setting += " " + prop
|
||||
}
|
||||
if !rootLed(rawSrc) || prop != "" || isSocket(src) || !c.underRoot(src) {
|
||||
c.add(svc, setting)
|
||||
}
|
||||
}
|
||||
|
||||
// rootLed reports whether a TEMPLATE bind source uses no variable, or exactly one root variable as its leading token.
|
||||
func rootLed(raw string) bool {
|
||||
if !strings.Contains(strings.ReplaceAll(raw, "$$", ""), "$") {
|
||||
return true // a literal path (judged by underRoot)
|
||||
}
|
||||
for _, r := range RootVars {
|
||||
for _, form := range []string{"${" + r + "}", "$" + r} {
|
||||
if rest, ok := strings.CutPrefix(raw, form); ok && (rest == "" || strings.HasPrefix(rest, "/")) &&
|
||||
!strings.Contains(strings.ReplaceAll(rest, "$$", ""), "$") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isPathSource(s string) bool {
|
||||
return strings.HasPrefix(s, "/") || strings.HasPrefix(s, ".") || strings.HasPrefix(s, "~") || strings.HasPrefix(s, "$")
|
||||
}
|
||||
|
||||
func isSocket(p string) bool {
|
||||
return strings.HasSuffix(p, ".sock") || strings.Contains(p, "docker.sock")
|
||||
}
|
||||
|
||||
// validRoots are the root values that are absolute, clean, not "/" and under /mnt/ or the system data path.
|
||||
func (c *checker) validRoots() []string {
|
||||
var out []string
|
||||
for _, r := range RootVars {
|
||||
v := c.env[r]
|
||||
if v == "" || !strings.HasPrefix(v, "/") || filepath.Clean(v) != v || v == "/" || hasDotDot(v) {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(v, "/mnt/") || (c.p.SystemDataPath != "" && within(v, filepath.Clean(c.p.SystemDataPath))) {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *checker) underRoot(p string) bool {
|
||||
if !strings.HasPrefix(p, "/") || hasDotDot(p) {
|
||||
return false
|
||||
}
|
||||
p = filepath.Clean(p)
|
||||
for _, r := range c.roots {
|
||||
if within(p, r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// insideStack: a relative path that stays inside the stack directory.
|
||||
func (c *checker) insideStack(p string) bool {
|
||||
return p != "" && !strings.HasPrefix(p, "/") && !strings.HasPrefix(p, "~") && !hasDotDot(p)
|
||||
}
|
||||
|
||||
func within(p, root string) bool { return p == root || strings.HasPrefix(p, root+"/") }
|
||||
|
||||
func hasDotDot(p string) bool {
|
||||
for _, s := range strings.Split(p, "/") {
|
||||
if s == ".." {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func noNewPrivileges(s string) bool {
|
||||
s = strings.ReplaceAll(strings.ReplaceAll(s, "=", ":"), " ", "")
|
||||
return s == "no-new-privileges" || s == "no-new-privileges:true"
|
||||
}
|
||||
|
||||
func truthy(s string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "true", "yes", "on", "1":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func list(v any) []any {
|
||||
switch t := v.(type) {
|
||||
case []any:
|
||||
return t
|
||||
case nil:
|
||||
return nil
|
||||
default:
|
||||
return []any{t}
|
||||
}
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string]any) []string {
|
||||
ks := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
ks = append(ks, k)
|
||||
}
|
||||
sort.Strings(ks)
|
||||
return ks
|
||||
}
|
||||
|
||||
// canon renders a scalar, or a structure as compact JSON with rendered strings (stable key order).
|
||||
func (c *checker) canon(v any) string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return c.render(t)
|
||||
case map[string]any, []any:
|
||||
b, _ := json.Marshal(c.renderTree(t))
|
||||
return string(b)
|
||||
default:
|
||||
return fmt.Sprint(t)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *checker) renderTree(v any) any {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return c.render(t)
|
||||
case map[string]any:
|
||||
m := map[string]any{}
|
||||
for k, x := range t {
|
||||
m[k] = c.renderTree(x)
|
||||
}
|
||||
return m
|
||||
case []any:
|
||||
out := make([]any, len(t))
|
||||
for i, x := range t {
|
||||
out[i] = c.renderTree(x)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return t
|
||||
}
|
||||
}
|
||||
|
||||
// shown is a value as a refusal may print it: rendered when it holds no variable, the template text when it does.
|
||||
func (c *checker) shown(v any) string {
|
||||
if hasInterp(v) {
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
return c.render(v)
|
||||
}
|
||||
|
||||
func hasInterp(v any) bool {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return strings.Contains(strings.ReplaceAll(t, "$$", ""), "$")
|
||||
case map[string]any:
|
||||
for k, x := range t {
|
||||
if hasInterp(k) || hasInterp(x) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, x := range t {
|
||||
if hasInterp(x) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var interpRe = regexp.MustCompile(`\$\$|\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:?[-?+])([^}]*))?\}|\$([A-Za-z_][A-Za-z0-9_]*)`)
|
||||
|
||||
// render substitutes compose's variable forms ($$, ${V}, ${V:-d}, ${V-d}, ${V:?e}, ${V?e}, ${V:+a}, ${V+a}, $V) from
|
||||
// the env. A non-string scalar is printed as YAML would read it.
|
||||
func (c *checker) render(v any) string {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
return interpRe.ReplaceAllStringFunc(s, func(m string) string {
|
||||
if m == "$$" {
|
||||
return "$"
|
||||
}
|
||||
g := interpRe.FindStringSubmatch(m)
|
||||
name, op, arg := g[1], g[2], g[3]
|
||||
if name == "" {
|
||||
name = g[4]
|
||||
}
|
||||
val, set := c.env[name]
|
||||
switch op {
|
||||
case ":-":
|
||||
if val == "" {
|
||||
return arg
|
||||
}
|
||||
case "-":
|
||||
if !set {
|
||||
return arg
|
||||
}
|
||||
case ":+":
|
||||
if val != "" {
|
||||
return arg
|
||||
}
|
||||
return ""
|
||||
case "+":
|
||||
if set {
|
||||
return arg
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return val
|
||||
})
|
||||
}
|
||||
|
||||
// controllerInfra are the stacks the controller renders itself (out of this check's scope — and never a catalog
|
||||
// template's to overwrite).
|
||||
var controllerInfra = map[string]bool{"traefik": true, "cloudflared": true, "filebrowser": true, "samba": true, "felhom-controller": true}
|
||||
|
||||
// IsControllerInfra reports whether name is one of the controller's own infra stacks.
|
||||
func IsControllerInfra(name string) bool { return controllerInfra[strings.ToLower(name)] }
|
||||
@@ -0,0 +1,269 @@
|
||||
package privpolicy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var pol = Policy{SystemDataPath: "/var/lib/felhom"}
|
||||
|
||||
func env() map[string]string {
|
||||
return map[string]string{
|
||||
"HDD_PATH": "/mnt/felhom-drives/adatlemez/felhom-data",
|
||||
"USERDATA_PATH": "/mnt/felhom-drives/adatlemez/felhom-data/userdata",
|
||||
"IMPORT_PATH": "/var/lib/felhom/data/userdata/import",
|
||||
"SUBDOMAIN": "x", "DOMAIN": "example.hu",
|
||||
}
|
||||
}
|
||||
|
||||
// base is a real-shaped app: a root-led bind, a named volume, a hardening security_opt.
|
||||
const base = `services:
|
||||
app:
|
||||
image: example/app:1.0
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
volumes:
|
||||
- app_data:/data
|
||||
- ${USERDATA_PATH}/media:/media:ro
|
||||
- ${HDD_PATH}/appdata/app:/config
|
||||
- ${IMPORT_PATH}/app:/consume
|
||||
volumes:
|
||||
app_data:
|
||||
`
|
||||
|
||||
func admit(t *testing.T, app, compose string, e map[string]string) error {
|
||||
t.Helper()
|
||||
return Admit(app, []byte(compose), e, pol)
|
||||
}
|
||||
|
||||
func TestAdmit_TheBaseShapeIsAdmitted(t *testing.T) {
|
||||
if err := admit(t, "app", base, env()); err != nil {
|
||||
t.Fatalf("a normal app was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// One ATTACK per class, each added to the admitted base: refused, and the refusal names the setting.
|
||||
// RED-PROOF (P2/redproof-privpolicy.txt): with Admit returning nil, every case fails.
|
||||
func TestAdmit_EveryClassIsRefused(t *testing.T) {
|
||||
cases := map[string]struct{ add, want string }{
|
||||
"privileged": {" privileged: true\n", "privileged true"},
|
||||
"cap_add": {" cap_add: [SYS_ADMIN]\n", "cap_add SYS_ADMIN"},
|
||||
"devices": {" devices: [\"/dev/sda:/dev/sda\"]\n", "device /dev/sda:/dev/sda"},
|
||||
"device_cgroup_rule": {" device_cgroup_rules: [\"b 8:* rmw\"]\n", "device_cgroup_rule b 8:* rmw"},
|
||||
"network_mode host": {" network_mode: host\n", "network_mode host"},
|
||||
"network_mode ctr": {" network_mode: \"container:felhom-controller\"\n", "network_mode container:felhom-controller"},
|
||||
"pid": {" pid: host\n", "pid host"},
|
||||
"ipc": {" ipc: host\n", "ipc host"},
|
||||
"uts": {" uts: host\n", "uts host"},
|
||||
"userns_mode": {" userns_mode: host\n", "userns_mode host"},
|
||||
"cgroup": {" cgroup: host\n", "cgroup host"},
|
||||
"seccomp off": {" security_opt: [\"seccomp=unconfined\"]\n", "security_opt seccomp=unconfined"},
|
||||
"apparmor off": {" security_opt: [\"apparmor:unconfined\"]\n", "security_opt apparmor:unconfined"},
|
||||
"sysctls": {" sysctls:\n kernel.core_pattern: x\n", "sysctl kernel.core_pattern=x"},
|
||||
"volumes_from": {" volumes_from: [\"container:felhom-controller\"]\n", "volumes_from container:felhom-controller"},
|
||||
"build": {" build: /\n", "build"},
|
||||
"env_file outside": {" env_file: /opt/docker/felhom-controller/controller.yaml\n", "env_file /opt/docker/felhom-controller/controller.yaml"},
|
||||
"extends file": {" extends:\n file: /etc/x.yml\n service: y\n", "extends_file /etc/x.yml"},
|
||||
"interpolated": {" privileged: ${PRIV}\n", "interpolated privileged"},
|
||||
}
|
||||
for name, c := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
compose := strings.Replace(base, " image: example/app:1.0\n", " image: example/app:1.0\n"+c.add, 1)
|
||||
// security_opt is already in base: replace it rather than duplicate the key
|
||||
if strings.HasPrefix(c.add, " security_opt") {
|
||||
compose = strings.Replace(base, " security_opt:\n - no-new-privileges:true\n", c.add, 1)
|
||||
}
|
||||
err := admit(t, "app", compose, env())
|
||||
var re *RefusedError
|
||||
if !errors.As(err, &re) {
|
||||
t.Fatalf("not refused: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.want) {
|
||||
t.Fatalf("refusal %q does not name %q", err, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Bind sources: the root rule, read on the TEMPLATE and on the RENDERED value.
|
||||
func TestAdmit_Binds(t *testing.T) {
|
||||
refuse := map[string]string{
|
||||
"host root": " - /:/host",
|
||||
"docker socket": " - /var/run/docker.sock:/var/run/docker.sock",
|
||||
"socket under a root": " - ${HDD_PATH}/docker.sock:/s",
|
||||
"etc": " - /etc:/etc:ro",
|
||||
"relative stack dir": " - ./:/stack",
|
||||
"dotdot after a root": " - ${HDD_PATH}/../../../etc:/x",
|
||||
"another variable": " - ${OTHER}/x:/x",
|
||||
"root then a variable": " - ${HDD_PATH}${OTHER}:/x",
|
||||
"rshared propagation": " - ${HDD_PATH}/x:/x:rshared",
|
||||
"long syntax socket": " - type: bind\n source: /run/docker.sock\n target: /s",
|
||||
"long syntax npipe": " - type: npipe\n source: x\n target: /s",
|
||||
"literal outside roots": " - /mnt/other-drive/x:/x",
|
||||
}
|
||||
for name, line := range refuse {
|
||||
t.Run("refused/"+name, func(t *testing.T) {
|
||||
compose := strings.Replace(base, " - app_data:/data\n", " - app_data:/data\n"+line+"\n", 1)
|
||||
if err := admit(t, "app", compose, env()); err == nil {
|
||||
t.Fatalf("%s was admitted", line)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Positive controls: a literal path INSIDE a root, a long-syntax root bind, a tmpfs.
|
||||
for name, line := range map[string]string{
|
||||
"literal inside a root": " - /mnt/felhom-drives/adatlemez/felhom-data/userdata/x:/x",
|
||||
"long syntax root bind": " - type: bind\n source: ${USERDATA_PATH}/media\n target: /m\n read_only: true",
|
||||
"tmpfs": " - type: tmpfs\n target: /tmp",
|
||||
} {
|
||||
t.Run("admitted/"+name, func(t *testing.T) {
|
||||
compose := strings.Replace(base, " - app_data:/data\n", " - app_data:/data\n"+line+"\n", 1)
|
||||
if err := admit(t, "app", compose, env()); err != nil {
|
||||
t.Fatalf("%s was refused: %v", line, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The ROOTS are checked too: a HDD_PATH of "/" (or anything not under /mnt/ or the system data path) is no root, so
|
||||
// every bind under it is refused — env cannot widen the box's data roots.
|
||||
func TestAdmit_ARootValueOutsideTheBoxIsNoRoot(t *testing.T) {
|
||||
for _, bad := range []string{"/", "/etc", "/mnt/../etc", "relative/x", "/var/lib/felhomX"} {
|
||||
e := env()
|
||||
e["HDD_PATH"] = bad
|
||||
if err := admit(t, "app", base, e); err == nil {
|
||||
t.Fatalf("HDD_PATH=%q was accepted as a data root", bad)
|
||||
}
|
||||
}
|
||||
e := env()
|
||||
e["HDD_PATH"] = "/var/lib/felhom/data" // the system-data fallback is a root
|
||||
if err := admit(t, "app", base, e); err != nil {
|
||||
t.Fatalf("the system-data root was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Top-level definitions that can alias a host path or another stack's volume.
|
||||
func TestAdmit_TopLevelAliases(t *testing.T) {
|
||||
for name, tail := range map[string]string{
|
||||
"driver_opts bind": " app_data:\n driver_opts:\n type: none\n o: bind\n device: /\n",
|
||||
"external": " app_data:\n external: true\n",
|
||||
"explicit name": " app_data:\n name: felhom-controller_data\n",
|
||||
"plugin driver": " app_data:\n driver: rexray\n",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
compose := strings.Replace(base, "volumes:\n app_data:\n", "volumes:\n"+tail, 1)
|
||||
if err := admit(t, "app", compose, env()); err == nil {
|
||||
t.Fatal("admitted")
|
||||
}
|
||||
})
|
||||
}
|
||||
for name, add := range map[string]string{
|
||||
"include": "include:\n - /etc/other.yml\n",
|
||||
"config file": "configs:\n c:\n file: /etc/shadow\n",
|
||||
"secret file": "secrets:\n s:\n file: ../../x\n",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := admit(t, "app", base+add, env()); err == nil {
|
||||
t.Fatal("admitted")
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := admit(t, "app", base+"configs:\n c:\n file: ./conf/app.ini\n", env()); err != nil {
|
||||
t.Fatalf("a config file inside the stack dir was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
const homepage = `services:
|
||||
homepage:
|
||||
image: ghcr.io/gethomepage/homepage:v1.13.2
|
||||
volumes:
|
||||
- homepage_config:/app/config
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
volumes:
|
||||
homepage_config:
|
||||
`
|
||||
|
||||
// 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) {
|
||||
if err := admit(t, "homepage", homepage, env()); err != nil {
|
||||
t.Fatalf("the allowed homepage socket was refused: %v", err)
|
||||
}
|
||||
for name, c := range map[string]struct{ app, compose string }{
|
||||
"read-write": {"homepage", strings.Replace(homepage, "docker.sock:ro", "docker.sock", 1)},
|
||||
"another service": {"homepage", strings.Replace(homepage, " homepage:\n image", " sidecar:\n image", 1)},
|
||||
"another app": {"glance", homepage},
|
||||
"plus privileged": {"homepage", strings.Replace(homepage, " volumes:", " privileged: true\n volumes:", 1)},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := admit(t, c.app, c.compose, env()); err == nil {
|
||||
t.Fatal("admitted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The check fails CLOSED: a compose it cannot read is refused, not admitted.
|
||||
func TestAdmit_UnreadableIsRefused(t *testing.T) {
|
||||
if err := admit(t, "app", "services: [::", env()); err == nil {
|
||||
t.Fatal("an unparseable compose was admitted")
|
||||
}
|
||||
}
|
||||
|
||||
// The renderer follows compose's forms.
|
||||
func TestRender_ComposeForms(t *testing.T) {
|
||||
c := &checker{env: map[string]string{"A": "a", "E": ""}}
|
||||
for in, want := range map[string]string{
|
||||
"${A}": "a", "$A/x": "a/x", "${B:-d}": "d", "${E:-d}": "d", "${E-d}": "", "${B-d}": "d",
|
||||
"${A:+y}": "y", "${E:+y}": "", "${E+y}": "y", "$$A": "$A", "${A:?boom}": "a",
|
||||
} {
|
||||
if got := c.render(in); got != want {
|
||||
t.Errorf("render(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every template and ladder step in the catalog passes on a placeholder env — the catalog as it is today needs
|
||||
// exactly the allow-list it has. Runs where the sibling clone is present (the workspace); CI has none.
|
||||
func TestAllowList_TheWholeCatalogPasses(t *testing.T) {
|
||||
root := filepath.Join("..", "..", "..", "..", "app-catalog-felhom.eu", "templates")
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
t.Skipf("no sibling catalog clone at %s — checked in the workspace, not in CI", root)
|
||||
}
|
||||
n := 0
|
||||
_ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if info.Name() != "docker-compose.yml" && !(filepath.Base(filepath.Dir(p)) == "steps" && strings.HasSuffix(p, ".yml")) {
|
||||
return nil
|
||||
}
|
||||
n++
|
||||
rel, _ := filepath.Rel(root, p)
|
||||
app := strings.Split(rel, string(filepath.Separator))[0]
|
||||
b, _ := os.ReadFile(p)
|
||||
if err := Admit(app, b, PlaceholderEnv(), pol); err != nil {
|
||||
t.Errorf("%s: %v", rel, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if n < 100 {
|
||||
t.Fatalf("only %d catalog files found — the walk did not see the catalog", n)
|
||||
}
|
||||
}
|
||||
|
||||
// The catalog's CI copy of the list is byte-identical (the box's copy is the authority; the catalog's is an early
|
||||
// warning). Where the sibling is absent (CI), the catalog's own gate runs the same comparison when it can.
|
||||
func TestAllowList_CatalogCopyIsIdentical(t *testing.T) {
|
||||
p := filepath.Join("..", "..", "..", "..", "app-catalog-felhom.eu", "scripts", "privilege-allowlist.json")
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
t.Skipf("no sibling catalog clone (%v) — checked in the workspace, not in CI", err)
|
||||
}
|
||||
if !bytes.Equal(b, AllowListJSON()) {
|
||||
t.Fatalf("%s differs from internal/privpolicy/allowlist.json — change both in the same session", p)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/privpolicy"
|
||||
)
|
||||
|
||||
// R-971 (operator decision D2 = A): every path that writes an app's compose asks admitCompose first, on the RENDERED
|
||||
// compose, and writes nothing when it refuses. The check itself is internal/privpolicy; this file gives it the app's
|
||||
// env exactly as `compose up` will see it, and makes a refusal visible — one operator event per distinct refused
|
||||
// definition, and one line on the app page (Stack.DefinitionRefused) until a later definition of the app is admitted.
|
||||
// The structural guard that every writer asks first: TestAllowList_IsWiredIntoEveryComposeWriter.
|
||||
|
||||
// defRefusal is the last refusal of one app.
|
||||
type defRefusal struct {
|
||||
sha string
|
||||
msg string
|
||||
}
|
||||
|
||||
// SetDefinitionRefusedHook sets what a NEW refusal raises (main.go: the operator-only hub event
|
||||
// `app_definition_refused`). Called once per distinct refused definition of an app, not on every 15-minute cycle.
|
||||
func (m *Manager) SetDefinitionRefusedHook(fn func(name, detail string)) { m.defRefusedHook = fn }
|
||||
|
||||
// policyEnv is env as `compose up` sees it: the app's values, DOMAIN, and the two derived path variables the
|
||||
// controller adds itself (withPathVars — the derived values win, as they do at exec).
|
||||
func (m *Manager) policyEnv(env map[string]string) map[string]string {
|
||||
out := make(map[string]string, len(env)+3)
|
||||
for k, v := range env {
|
||||
out[k] = v
|
||||
}
|
||||
out["DOMAIN"] = m.cfg.Customer.Domain
|
||||
if hdd := env["HDD_PATH"]; hdd != "" {
|
||||
out["USERDATA_PATH"] = appbackup.UserdataDir(appbackup.NamespaceRootFor(hdd, m.sysDataPath))
|
||||
}
|
||||
if ir := m.GetImportRoot(); ir != "" {
|
||||
out["IMPORT_PATH"] = ir
|
||||
} else {
|
||||
delete(out, "IMPORT_PATH")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// admitCompose is the one question every compose writer asks. env is the app's env (nil → the placeholder env: a
|
||||
// template checked before the app has values). A refusal is returned AND recorded; an admitted definition clears the
|
||||
// app's refusal line.
|
||||
func (m *Manager) admitCompose(name string, data []byte, env map[string]string) error {
|
||||
e := privpolicy.PlaceholderEnv()
|
||||
if env != nil {
|
||||
e = m.policyEnv(env)
|
||||
}
|
||||
err := privpolicy.Admit(name, data, e, privpolicy.Policy{SystemDataPath: m.sysDataPath})
|
||||
m.noteAdmission(name, data, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// AdmitCompose is admitCompose for the callers outside this package (main.go's restore adapter).
|
||||
func (m *Manager) AdmitCompose(name string, data []byte, env map[string]string) error {
|
||||
return m.admitCompose(name, data, env)
|
||||
}
|
||||
|
||||
// admitWithNewValues is the settings change's question: the live compose, rendered with the app's decrypted env and
|
||||
// the changed values on top.
|
||||
func (m *Manager) admitWithNewValues(name, stackDir string, values map[string]string) error {
|
||||
data, err := os.ReadFile(ComposePathIn(stackDir))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env := map[string]string{}
|
||||
if dec := LoadAppConfigDecrypted(stackDir, m.encKey); dec != nil {
|
||||
for k, v := range dec.Env {
|
||||
env[k] = v
|
||||
}
|
||||
}
|
||||
for k, v := range values {
|
||||
env[k] = v
|
||||
}
|
||||
return m.admitCompose(name, data, env)
|
||||
}
|
||||
|
||||
// AdmitSyncedCompose is the sync's question (sync.Syncer.SetAdmitFn): a deployed app is checked with its own env, a
|
||||
// template of an app not installed with the placeholder env (the install checks again with the real values).
|
||||
func (m *Manager) AdmitSyncedCompose(name string, data []byte) error {
|
||||
var env map[string]string
|
||||
if st, ok := m.GetStack(name); ok && st.Deployed {
|
||||
if cfg := LoadAppConfigDecrypted(filepath.Dir(st.ComposePath), m.encKey); cfg != nil {
|
||||
env = cfg.Env
|
||||
}
|
||||
}
|
||||
return m.admitCompose(name, data, env)
|
||||
}
|
||||
|
||||
func (m *Manager) noteAdmission(name string, data []byte, err error) {
|
||||
m.defRefusedMu.Lock()
|
||||
if m.defRefused == nil {
|
||||
m.defRefused = map[string]defRefusal{}
|
||||
}
|
||||
byApp := m.defRefused
|
||||
if err == nil {
|
||||
delete(byApp, name)
|
||||
m.defRefusedMu.Unlock()
|
||||
return
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
sha := hex.EncodeToString(sum[:])
|
||||
prev, seen := byApp[name]
|
||||
hook := m.defRefusedHook
|
||||
told := sha
|
||||
if hook == nil {
|
||||
told = "" // not wired yet (a sync before main's hook): the first wired sight still raises the event
|
||||
}
|
||||
byApp[name] = defRefusal{sha: told, msg: err.Error()}
|
||||
m.defRefusedMu.Unlock()
|
||||
m.logger.Printf("[WARN] [stacks] %s: definition REFUSED by the allow-list (R-971), nothing written: %v", name, err)
|
||||
if seen && prev.sha == sha {
|
||||
return // the same definition again (the next 15-minute cycle): one event per definition
|
||||
}
|
||||
if hook != nil {
|
||||
hook(name, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// definitionRefused is the app page's line ("" when none).
|
||||
func (m *Manager) definitionRefused(name string) string {
|
||||
m.defRefusedMu.Lock()
|
||||
defer m.defRefusedMu.Unlock()
|
||||
return m.defRefused[name].msg
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
)
|
||||
|
||||
func allowlistMgr(t *testing.T) *Manager {
|
||||
t.Helper()
|
||||
m := &Manager{cfg: &config.Config{}, logger: log.New(io.Discard, "", 0), sysDataPath: "/mnt/sys_drive", stacks: map[string]*Stack{}}
|
||||
return m
|
||||
}
|
||||
|
||||
const refusedCompose = "services:\n web:\n image: x/y:1\n privileged: true\n"
|
||||
|
||||
// One operator event per DISTINCT refused definition — the same definition on the next 15-minute cycle raises none;
|
||||
// a different one raises again; an admitted definition clears the page line. RED-PROOF: with the sha comparison
|
||||
// removed, the second identical refusal raises a second event and this test fails.
|
||||
func TestAllowList_OneEventPerRefusedDefinition(t *testing.T) {
|
||||
m := allowlistMgr(t)
|
||||
var events []string
|
||||
m.SetDefinitionRefusedHook(func(name, detail string) { events = append(events, name+"|"+detail) })
|
||||
env := map[string]string{"HDD_PATH": "/mnt/felhom-drives/a/felhom-data"}
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := m.admitCompose("web", []byte(refusedCompose), env); err == nil {
|
||||
t.Fatal("privileged was admitted")
|
||||
}
|
||||
}
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("events = %d, want 1 for one definition seen three times: %v", len(events), events)
|
||||
}
|
||||
if !strings.Contains(events[0], "privileged true") {
|
||||
t.Fatalf("the event does not name the refused setting: %v", events)
|
||||
}
|
||||
if m.definitionRefused("web") == "" {
|
||||
t.Fatal("the app page line is empty while a refusal stands")
|
||||
}
|
||||
if err := m.admitCompose("web", []byte(strings.Replace(refusedCompose, "privileged: true", "cap_add: [NET_ADMIN]", 1)), env); err == nil {
|
||||
t.Fatal("cap_add was admitted")
|
||||
}
|
||||
if len(events) != 2 {
|
||||
t.Fatalf("a different refused definition raised %d events in all, want 2", len(events))
|
||||
}
|
||||
if err := m.admitCompose("web", []byte("services:\n web:\n image: x/y:2\n"), env); err != nil {
|
||||
t.Fatalf("a clean definition was refused: %v", err)
|
||||
}
|
||||
if m.definitionRefused("web") != "" {
|
||||
t.Fatal("an admitted definition did not clear the page line")
|
||||
}
|
||||
}
|
||||
|
||||
// A refusal before the hook is wired (a sync at start-up) still raises the event at the first wired sight.
|
||||
func TestAllowList_UnwiredFirstSightIsNotSwallowed(t *testing.T) {
|
||||
m := allowlistMgr(t)
|
||||
_ = m.admitCompose("web", []byte(refusedCompose), nil)
|
||||
n := 0
|
||||
m.SetDefinitionRefusedHook(func(string, string) { n++ })
|
||||
_ = m.admitCompose("web", []byte(refusedCompose), nil)
|
||||
if n != 1 {
|
||||
t.Fatalf("events after wiring = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// The refusal text (logged, mailed to the operator) never carries an env VALUE: a secret in a privileged key or a
|
||||
// non-root bind variable is shown as the template text.
|
||||
func TestAllowList_RefusalNeverPrintsASecret(t *testing.T) {
|
||||
m := allowlistMgr(t)
|
||||
const secret = "S3cr3t-value-never-shown"
|
||||
env := map[string]string{"HDD_PATH": "/mnt/felhom-drives/a/felhom-data", "DB_PASSWORD": secret}
|
||||
compose := "services:\n web:\n image: x/y:1\n sysctls:\n net.x: ${DB_PASSWORD}\n volumes:\n - ${DB_PASSWORD}:/x\n env_file: /etc/${DB_PASSWORD}\n"
|
||||
err := m.admitCompose("web", []byte(compose), env)
|
||||
if err == nil {
|
||||
t.Fatal("admitted")
|
||||
}
|
||||
if strings.Contains(err.Error(), secret) || strings.Contains(m.definitionRefused("web"), secret) {
|
||||
t.Fatalf("the refusal printed a secret value: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The env the check sees is the env compose sees: the controller-derived USERDATA_PATH wins over an app value.
|
||||
func TestAllowList_PolicyEnvDerivesTheRootsLikeComposeExec(t *testing.T) {
|
||||
m := allowlistMgr(t)
|
||||
e := m.policyEnv(map[string]string{"HDD_PATH": "/mnt/felhom-drives/a/felhom-data", "USERDATA_PATH": "/"})
|
||||
if e["USERDATA_PATH"] == "/" || !strings.HasPrefix(e["USERDATA_PATH"], "/mnt/felhom-drives/a/") {
|
||||
t.Fatalf("USERDATA_PATH = %q — the app's own value must not become a data root", e["USERDATA_PATH"])
|
||||
}
|
||||
}
|
||||
|
||||
// The install / settings / restore refusal is a bundle key: the reader gets it in their language, with the app and
|
||||
// the refused setting named, and the sentence says nothing changed.
|
||||
func TestDefinitionRefused_MessagesFollowTheReader(t *testing.T) {
|
||||
err := util.MsgError("err.stacks.definition_refused", "homepage", "homepage: privileged true")
|
||||
msg, ok := util.AsMsg(err)
|
||||
if !ok {
|
||||
t.Fatal("not a message error")
|
||||
}
|
||||
for lang, want := range map[string]string{"hu": "Semmi nem valtozott", "en": "Nothing was changed"} {
|
||||
got := msg.Text(lang)
|
||||
ascii := strings.NewReplacer("á", "a", "é", "e", "í", "i", "ó", "o", "ö", "o", "ő", "o", "ú", "u", "ü", "u", "ű", "u").Replace(got)
|
||||
if !strings.Contains(ascii, want) || !strings.Contains(got, "homepage: privileged true") {
|
||||
t.Errorf("[%s] %q", lang, got)
|
||||
}
|
||||
}
|
||||
if msg.Text("hu") == msg.Text("en") {
|
||||
t.Fatal("the two languages render the same text — a key is missing from one bundle")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAllowList_IsWiredIntoEveryComposeWriter (R-971) — the catalog allow-list is only a control if EVERY path that
|
||||
// writes an app's compose asks it first. Walked as an AST (comments dropped, so a commented-out call does not count),
|
||||
// the shape of TestAdmission_IsWiredIntoEveryProductionWriteLeg.
|
||||
//
|
||||
// 1. DISCOVERY: every production function that names the compose file (the literal "docker-compose.yml", a
|
||||
// ComposePathIn / AppliedComposePath call, a .ComposePath field) AND calls a file write must be in `writers` —
|
||||
// either GATED (its first call of the gate comes before its first write) or EXEMPT with the reason. A new writer
|
||||
// fails this test until someone decides which it is.
|
||||
// 2. DECISION POINTS: the paths that choose an app's env (install, the two settings changes, the two restores) ask
|
||||
// the gate before their first mutation.
|
||||
//
|
||||
// RED-PROOF (felhom.eu audits/agent-allowlist-rotation-2026-10-17/P2/redproof-allowlist.txt): remove the admit call
|
||||
// from copyTemplates → step 1 fails naming it; add a new function that writes ComposePathIn(dir) → step 1 fails as
|
||||
// an unknown writer.
|
||||
func TestAllowList_IsWiredIntoEveryComposeWriter(t *testing.T) {
|
||||
type rule struct {
|
||||
gate string // "" = exempt
|
||||
why string // exempt: the reason
|
||||
before string // gated: the write the gate must precede (the first call of this name)
|
||||
}
|
||||
writers := map[string]rule{
|
||||
"internal/sync/sync.go:copyTemplates": {gate: "admit", before: "writeIfChanged"},
|
||||
"internal/stacks/pin.go:advancePinTo": {gate: "admitCompose", before: "StoreAppliedDefinition"},
|
||||
"cmd/controller/main.go:RecreateStackDefinitionFromUnit": {gate: "AdmitDefinitionFromUnit", before: "WriteFile"},
|
||||
"internal/stacks/infra.go:ensureFileBrowser": {why: "controller-rendered infra stack — out of scope"},
|
||||
"internal/stacks/infra.go:reconcileFileBrowserImage": {why: "controller-rendered infra stack — out of scope"},
|
||||
"internal/stacks/install_interrupted.go:RecoverInterruptedInstalls": {why: "writes the interrupted-install marker, no compose"},
|
||||
"internal/stacks/pin.go:StoreAppliedDefinition": {why: "the STORED copy; it becomes live only through copyTemplates' frozen branch, which admits it"},
|
||||
"internal/stacks/update.go:runGuardedUpdate": {why: "copies the PREVIOUS compose aside (the undo); the new one is written by advancePinTo"},
|
||||
"internal/stacks/update.go:restoreDefinition": {why: "puts back the bytes the app ran on before the update — an undo adds nothing"},
|
||||
"internal/backup/offbox_reconstitute.go:captureLiveDefinition": {why: "a scratch copy in a temp dir, never brought up"},
|
||||
"internal/backup/recovery_unit.go:captureRecoveryUnit": {why: "the backup's copy; its way back in is RecreateStackDefinitionFromUnit (gated)"},
|
||||
}
|
||||
decisions := []struct{ fn, gate, before string }{
|
||||
{"internal/stacks/deploy.go:DeployStack", "admitCompose", "SaveAppConfig"},
|
||||
{"internal/stacks/deploy.go:UpdateStackConfig", "admitWithNewValues", "SaveAppConfig"},
|
||||
{"internal/stacks/deploy.go:UpdateOptionalConfig", "admitWithNewValues", "SaveAppConfig"},
|
||||
{"internal/backup/restore_unit.go:RestoreFromRecoveryUnitAtWith", "admitRestoredDefinition", "StopStack"},
|
||||
{"internal/backup/offbox_reconstitute.go:ReconstituteFromOffsite", "admitRestoredDefinition", "StopStack"},
|
||||
}
|
||||
|
||||
writes := map[string]bool{"WriteFile": true, "writeIfChanged": true, "copyIfChanged": true, "atomicWrite": true,
|
||||
"Rename": true, "Create": true, "OpenFile": true, "StoreAppliedDefinition": true, "copyFile": true, "CopyFile": true}
|
||||
root := filepath.Join("..", "..")
|
||||
calls := map[string][]string{} // "dir/file.go:Func" → called names in source order
|
||||
var found []string
|
||||
for _, dir := range []string{"internal/stacks", "internal/sync", "internal/backup", "internal/appexport",
|
||||
"internal/web", "internal/api", "cmd/controller"} {
|
||||
files, _ := filepath.Glob(filepath.Join(root, dir, "*.go"))
|
||||
if len(files) == 0 {
|
||||
t.Fatalf("no Go files under %s — the walk cannot see the code it guards", dir)
|
||||
}
|
||||
for _, f := range files {
|
||||
if strings.HasSuffix(f, "_test.go") {
|
||||
continue
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, f, nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, d := range file.Decls {
|
||||
fd, ok := d.(*ast.FuncDecl)
|
||||
if !ok || fd.Body == nil {
|
||||
continue
|
||||
}
|
||||
id := dir + "/" + filepath.Base(f) + ":" + fd.Name.Name
|
||||
names, compose, writesFile := []string{}, false, false
|
||||
ast.Inspect(fd.Body, func(n ast.Node) bool {
|
||||
switch v := n.(type) {
|
||||
case *ast.SelectorExpr:
|
||||
if v.Sel.Name == "ComposePath" {
|
||||
compose = true
|
||||
}
|
||||
case *ast.BasicLit:
|
||||
if v.Value == `"docker-compose.yml"` {
|
||||
compose = true
|
||||
}
|
||||
case *ast.CallExpr:
|
||||
name := ""
|
||||
switch fn := v.Fun.(type) {
|
||||
case *ast.Ident:
|
||||
name = fn.Name
|
||||
case *ast.SelectorExpr:
|
||||
name = fn.Sel.Name
|
||||
}
|
||||
if name == "ComposePathIn" || name == "AppliedComposePath" {
|
||||
compose = true
|
||||
}
|
||||
if writes[name] {
|
||||
writesFile = true
|
||||
}
|
||||
if name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
calls[id] = names
|
||||
if compose && writesFile {
|
||||
found = append(found, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Discovery.
|
||||
sort.Strings(found)
|
||||
for _, id := range found {
|
||||
r, known := writers[id]
|
||||
if !known {
|
||||
t.Errorf("%s writes a file and names the compose file, and the allow-list guard does not know it — gate it "+
|
||||
"(admitCompose before the write) or add it to `writers` as exempt with the reason", id)
|
||||
continue
|
||||
}
|
||||
if r.gate == "" {
|
||||
continue
|
||||
}
|
||||
assertCalledBefore(t, id, calls[id], r.gate, r.before)
|
||||
}
|
||||
for id := range writers {
|
||||
if _, ok := calls[id]; !ok {
|
||||
t.Errorf("fixture drift: %s no longer exists — this guard can no longer see that writer", id)
|
||||
}
|
||||
}
|
||||
// 2. Decision points.
|
||||
for _, d := range decisions {
|
||||
c, ok := calls[d.fn]
|
||||
if !ok {
|
||||
t.Errorf("fixture drift: %s no longer exists", d.fn)
|
||||
continue
|
||||
}
|
||||
assertCalledBefore(t, d.fn, c, d.gate, d.before)
|
||||
}
|
||||
}
|
||||
|
||||
func assertCalledBefore(t *testing.T, id string, calls []string, gate, act string) {
|
||||
t.Helper()
|
||||
gi, ai := -1, -1
|
||||
for i, c := range calls {
|
||||
if c == gate && gi < 0 {
|
||||
gi = i
|
||||
}
|
||||
if c == act && ai < 0 {
|
||||
ai = i
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case gi < 0:
|
||||
t.Errorf("%s: the allow-list gate %q is never called", id, gate)
|
||||
case ai < 0:
|
||||
t.Errorf("fixture drift: %s no longer calls %q — the guard cannot order the gate against it", id, act)
|
||||
case gi > ai:
|
||||
t.Errorf("%s: %q first appears after %q — the check must come before the write", id, gate, act)
|
||||
}
|
||||
}
|
||||
@@ -464,6 +464,16 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
|
||||
m.logger.Printf("[ERROR] [stacks] Deploy %s REFUSED: %v", req.StackName, err)
|
||||
return "", util.MsgError("err.stacks.needs_newer_controller", err.Error())
|
||||
}
|
||||
// R-971: the compose, rendered with THIS install's values, must pass the box's catalog allow-list — refused before
|
||||
// anything is written.
|
||||
if data, rerr := os.ReadFile(stack.ComposePath); rerr != nil {
|
||||
clearDeploying()
|
||||
return "", fmt.Errorf("reading the compose file of %s: %w", req.StackName, rerr)
|
||||
} else if aerr := m.admitCompose(req.StackName, data, env); aerr != nil {
|
||||
clearDeploying()
|
||||
m.logger.Printf("[ERROR] [stacks] Deploy %s REFUSED by the allow-list: %v", req.StackName, aerr)
|
||||
return "", util.MsgError("err.stacks.definition_refused", req.StackName, aerr.Error())
|
||||
}
|
||||
// `09` §3 decision 46: a gated template is installed CLOSED, and the gate's traefik file is written BEFORE
|
||||
// the first start (spike F2). Cannot write it → the install is refused: never published open.
|
||||
var gate *SetupGateRecord
|
||||
@@ -746,6 +756,10 @@ func (m *Manager) UpdateStackConfig(name string, values map[string]string) error
|
||||
if m.isDebug() {
|
||||
m.logger.Printf("[DEBUG] [stacks] UpdateStackConfig %s: changed keys: [%s], locked keys: %d", name, strings.Join(changedKeys, ", "), len(lockedSet))
|
||||
}
|
||||
// R-971: the compose, rendered with the NEW values, must still pass the box's allow-list — refused before the save.
|
||||
if err := m.admitWithNewValues(name, stackDir, values); err != nil {
|
||||
return util.MsgError("err.stacks.definition_refused", name, err.Error())
|
||||
}
|
||||
|
||||
if err := SaveAppConfig(stackDir, appCfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
|
||||
return fmt.Errorf("saving updated config: %w", err)
|
||||
@@ -989,6 +1003,10 @@ func (m *Manager) UpdateOptionalConfig(stackName string, values map[string]strin
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
// R-971: the compose, rendered with the NEW values, must still pass the box's allow-list — refused before the save.
|
||||
if err := m.admitWithNewValues(stackName, stackDir, values); err != nil {
|
||||
return util.MsgError("err.stacks.definition_refused", stackName, err.Error())
|
||||
}
|
||||
|
||||
// Save app.yaml
|
||||
meta := LoadMetadata(stackDir)
|
||||
|
||||
@@ -189,9 +189,12 @@ type Stack struct {
|
||||
HoldNoWholeCopy bool `json:"hold_no_whole_copy,omitempty"`
|
||||
// HoldKind (v0.269.0): which hold — "update_failed", "unhealthy_stop" (decision 28: the page offers
|
||||
// Start, not a restore), "restore"; "" when none.
|
||||
HoldKind string `json:"hold_kind,omitempty"`
|
||||
HealthProbe *HealthProbeResult `json:"health_probe,omitempty"` // controller-side probe result
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
HoldKind string `json:"hold_kind,omitempty"`
|
||||
// DefinitionRefused (R-971) is the box's refusal of the app's newest catalog definition — the app keeps its last
|
||||
// good compose. Filled on every read (allowlist.go); "" when none.
|
||||
DefinitionRefused string `json:"definition_refused,omitempty"`
|
||||
HealthProbe *HealthProbeResult `json:"health_probe,omitempty"` // controller-side probe result
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
// RestartingSince (C9-F2) is when this stack was FIRST observed in StateRestarting during the
|
||||
// current restarting run; zero whenever the stack is in any other state. It is what turns a brief
|
||||
// restart (normal: deploy, update, quiesce restart) into a distinguishable crash loop — see
|
||||
@@ -267,6 +270,10 @@ type Manager struct {
|
||||
// this and nothing else: it used to be sent beside the 202 that merely accepted the request, so
|
||||
// an install interrupted five seconds later stayed on the timeline as a completed one.
|
||||
deployDoneHook func(name string, ok bool, detail string)
|
||||
// defRefusedHook (R-971) raises the operator event for a definition the allow-list refused (allowlist.go).
|
||||
defRefusedHook func(name, detail string)
|
||||
defRefusedMu sync.Mutex
|
||||
defRefused map[string]defRefusal
|
||||
testSeams *migSeams // nil in production; tests inject fakes
|
||||
// R-51: docker restart policies for DOWN members of mixed stacks. Keyed by
|
||||
// containerName+"|"+state so a transitioned or recreated container re-reads rather than
|
||||
@@ -1123,6 +1130,7 @@ func (m *Manager) GetStacks() []Stack {
|
||||
m.mu.RUnlock()
|
||||
for i := range result {
|
||||
fillHoldReason(g, &result[i])
|
||||
result[i].DefinitionRefused = m.definitionRefused(result[i].Name)
|
||||
}
|
||||
|
||||
// Sort alphabetically by display name for consistent UI ordering
|
||||
@@ -1143,6 +1151,7 @@ func (m *Manager) GetStack(name string) (*Stack, bool) {
|
||||
}
|
||||
cp := deepCopyStack(s)
|
||||
fillHoldReason(m.updateGuards, &cp)
|
||||
cp.DefinitionRefused = m.definitionRefused(name)
|
||||
return &cp, true
|
||||
}
|
||||
|
||||
|
||||
@@ -348,6 +348,18 @@ func (m *Manager) advancePinTo(name, stackDir, src, metaSrc string) error {
|
||||
|
||||
// v0.269.0 (`09` §6.4 part 6): run EXACTLY the tested images — the ladder entry's digests for these refs.
|
||||
data = RenderWithLadderDigests(filepath.Dir(m.CatalogTemplatePath(name, "docker-compose.yml")), data)
|
||||
// R-971: the new definition must pass the box's allow-list before anything is stored — a refused update leaves
|
||||
// the app on its last good compose, running.
|
||||
var env map[string]string
|
||||
if dec := LoadAppConfigDecrypted(stackDir, m.encKey); dec != nil {
|
||||
env = dec.Env
|
||||
}
|
||||
if env == nil {
|
||||
env = map[string]string{}
|
||||
}
|
||||
if err := m.admitCompose(name, data, env); err != nil {
|
||||
return err
|
||||
}
|
||||
live := ComposePathIn(stackDir)
|
||||
if err := StoreAppliedDefinition(stackDir, data); err != nil {
|
||||
return fmt.Errorf("storing the new applied definition for %s: %w", name, err)
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// R-971 — the 15-minute correction refuses a catalog definition the box's allow-list does not admit: the deployed
|
||||
// app keeps its last good compose AND its .felhom.yml (nothing of the app moves this cycle), read back off disk.
|
||||
|
||||
const tplOldFixedPrivileged = `services:
|
||||
web:
|
||||
image: nextcloud:31.0.14-apache
|
||||
privileged: true
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:80/status.php"]
|
||||
`
|
||||
|
||||
// RED-PROOF (P2/redproof-allowlist.txt): with the admit call removed from copyTemplates, the live compose gains
|
||||
// `privileged: true` and this test fails.
|
||||
func TestR971_RefusedCorrectionWritesNothing(t *testing.T) {
|
||||
for _, wired := range []bool{true, false} { // the manager's check, and the syncer's own placeholder-env check
|
||||
s, stackDir, _ := renderFixture(t, tplOldFixedPrivileged)
|
||||
live := filepath.Join(stackDir, "docker-compose.yml")
|
||||
meta := filepath.Join(stackDir, ".felhom.yml")
|
||||
write(t, live, tplOld)
|
||||
write(t, meta, "OLD META\n")
|
||||
write(t, stacks.AppliedComposePath(stackDir), tplOld)
|
||||
s.SetRenderPlanFn(pinnedPlan(stackDir, map[string]string{"web": "nextcloud:31.0.14-apache"}, true))
|
||||
asked := 0
|
||||
if wired {
|
||||
s.SetAdmitFn(func(app string, data []byte) error {
|
||||
asked++
|
||||
return s.admit2(app, data)
|
||||
})
|
||||
}
|
||||
_, updated, err := s.copyTemplates()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := readFile(t, live); got != tplOld {
|
||||
t.Fatalf("[wired=%v] the refused definition reached the live compose:\n%s", wired, got)
|
||||
}
|
||||
// The app is not reported updated, so the post-sync hook does not redeploy it: it keeps running as it was.
|
||||
for _, u := range updated {
|
||||
if u == "nextcloud" {
|
||||
t.Fatalf("[wired=%v] a refused app was reported updated — the post-sync redeploy would touch it", wired)
|
||||
}
|
||||
}
|
||||
if got := readFile(t, meta); got != "OLD META\n" {
|
||||
t.Fatalf("[wired=%v] the .felhom.yml of a refused app moved: %q", wired, got)
|
||||
}
|
||||
if wired && asked == 0 {
|
||||
t.Fatal("the manager's check was never asked")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// admit2 is the placeholder-env check (what an unwired syncer uses), callable from the wired case.
|
||||
func (s *Syncer) admit2(app string, data []byte) error {
|
||||
saved := s.admitFn
|
||||
s.admitFn = nil
|
||||
defer func() { s.admitFn = saved }()
|
||||
return s.admit(app, data)
|
||||
}
|
||||
|
||||
// Positive control: the same fix without the privilege flows exactly as before (Group A's assertion).
|
||||
func TestR971_AdmittedCorrectionStillFlows(t *testing.T) {
|
||||
s, stackDir, _ := renderFixture(t, tplOldFixed)
|
||||
live := filepath.Join(stackDir, "docker-compose.yml")
|
||||
write(t, live, tplOld)
|
||||
write(t, stacks.AppliedComposePath(stackDir), tplOld)
|
||||
s.SetRenderPlanFn(pinnedPlan(stackDir, map[string]string{"web": "nextcloud:31.0.14-apache"}, true))
|
||||
if _, _, err := s.copyTemplates(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := readFile(t, live); !strings.Contains(got, "status.php") {
|
||||
t.Fatalf("an admitted fix did not flow:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A catalog template named like a controller infra stack never lands in that stack's directory.
|
||||
func TestR971_CatalogNeverWritesAnInfraStack(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cfg := &config.Config{}
|
||||
cfg.Paths.DataDir = filepath.Join(root, "data")
|
||||
cfg.Paths.StacksDir = filepath.Join(root, "stacks")
|
||||
for _, name := range []string{"traefik", "cloudflared", "filebrowser", "samba", "felhom-controller"} {
|
||||
cat := filepath.Join(cfg.Paths.DataDir, "catalog-cache", "templates", name)
|
||||
dst := filepath.Join(cfg.Paths.StacksDir, name)
|
||||
for _, d := range []string{cat, dst} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write(t, filepath.Join(cat, "docker-compose.yml"), "services:\n x:\n image: evil/x:1\n")
|
||||
write(t, filepath.Join(dst, "docker-compose.yml"), "CONTROLLER RENDERED\n")
|
||||
}
|
||||
s := New(cfg, log.New(io.Discard, "", 0), func() error { return nil }, nil)
|
||||
if _, _, err := s.copyTemplates(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"traefik", "cloudflared", "filebrowser", "samba", "felhom-controller"} {
|
||||
if got := readFile(t, filepath.Join(cfg.Paths.StacksDir, name, "docker-compose.yml")); got != "CONTROLLER RENDERED\n" {
|
||||
t.Errorf("%s: the catalog overwrote the controller's own compose: %q", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An app that is not installed: its refused template is not written either (the install would refuse it again).
|
||||
func TestR971_RefusedTemplateOfAnAppNotInstalled(t *testing.T) {
|
||||
s, stackDir, _ := renderFixture(t, tplOldFixedPrivileged)
|
||||
live := filepath.Join(stackDir, "docker-compose.yml")
|
||||
write(t, live, tplOld)
|
||||
if _, _, err := s.copyTemplates(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := readFile(t, live); got != tplOld {
|
||||
t.Fatalf("a refused template was written for an app not installed:\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
// must not reach for the Manager through it: everything it needs about an app arrives through
|
||||
// renderPlanFn. Importing the package rather than re-writing a compose parser is deliberate;
|
||||
// a second image parser is exactly the duplication REUSE.md exists to prevent.
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/privpolicy"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
@@ -48,12 +49,16 @@ type Syncer struct {
|
||||
// behaviour. Every existing test that constructs a Syncer without one keeps passing, and a
|
||||
// wiring mistake degrades to the old product rather than to a broken one.
|
||||
renderPlanFn func(appName string) stacks.RenderPlan
|
||||
mu sync.Mutex
|
||||
lastSync time.Time
|
||||
lastErr error
|
||||
syncing bool
|
||||
stopCh chan struct{}
|
||||
stopOnce sync.Once
|
||||
// admitFn (R-971) is the box's catalog allow-list check for one app's compose, asked BEFORE the file is written.
|
||||
// The stack manager's (stacks.Manager.AdmitSyncedCompose) renders a deployed app with its own env and raises the
|
||||
// operator event; nil → the same check on the placeholder env (privpolicy.PlaceholderEnv), never "no check".
|
||||
admitFn func(appName string, data []byte) error
|
||||
mu sync.Mutex
|
||||
lastSync time.Time
|
||||
lastErr error
|
||||
syncing bool
|
||||
stopCh chan struct{}
|
||||
stopOnce sync.Once
|
||||
}
|
||||
|
||||
// SyncStatus holds information about the last sync operation.
|
||||
@@ -94,6 +99,17 @@ func New(cfg *config.Config, logger *log.Logger, rescanFn func() error, postSync
|
||||
// seam has to be optional so a Syncer without one keeps the pre-v0.235.0 behaviour exactly.
|
||||
func (s *Syncer) SetRenderPlanFn(fn func(appName string) stacks.RenderPlan) { s.renderPlanFn = fn }
|
||||
|
||||
// SetAdmitFn injects the R-971 check (see Syncer.admitFn).
|
||||
func (s *Syncer) SetAdmitFn(fn func(appName string, data []byte) error) { s.admitFn = fn }
|
||||
|
||||
// admit runs the R-971 check: the injected one, or the placeholder-env check when none is wired.
|
||||
func (s *Syncer) admit(appName string, data []byte) error {
|
||||
if s.admitFn != nil {
|
||||
return s.admitFn(appName, data)
|
||||
}
|
||||
return privpolicy.Admit(appName, data, privpolicy.PlaceholderEnv(), privpolicy.Policy{SystemDataPath: s.cfg.Paths.SystemDataPath})
|
||||
}
|
||||
|
||||
// isDebug returns true if the logging level is set to "debug".
|
||||
func (s *Syncer) isDebug() bool { return s.cfg.Logging.Level == "debug" }
|
||||
|
||||
@@ -430,6 +446,12 @@ func (s *Syncer) copyTemplates() (newApps []string, updated []string, err error)
|
||||
}
|
||||
|
||||
appName := entry.Name()
|
||||
if privpolicy.IsControllerInfra(appName) {
|
||||
// R-971: the controller renders its own infra stacks; a catalog template with one of their names must never
|
||||
// land in their directory (traefik holds the Docker socket at the internet edge).
|
||||
s.logger.Printf("[WARN] [sync] catalog template %q has the name of a controller infra stack — not copied", appName)
|
||||
continue
|
||||
}
|
||||
srcDir := filepath.Join(templatesDir, appName)
|
||||
dstDir := filepath.Join(s.cfg.Paths.StacksDir, appName)
|
||||
|
||||
@@ -485,7 +507,22 @@ func (s *Syncer) copyTemplates() (newApps []string, updated []string, err error)
|
||||
} else {
|
||||
rendered = stacks.RenderWithLadderDigests(srcDir, raw)
|
||||
}
|
||||
if aerr := s.admit(appName, rendered); aerr != nil {
|
||||
s.logger.Printf("[WARN] [sync] %s: catalog definition REFUSED by the allow-list — the app keeps its last good compose, nothing written: %v", appName, aerr)
|
||||
break // neither the compose nor the .felhom.yml of this app moves this cycle
|
||||
}
|
||||
changed, err = writeIfChanged(rendered, dst)
|
||||
} else if filename == "docker-compose.yml" {
|
||||
data, rerr := os.ReadFile(src)
|
||||
if rerr != nil {
|
||||
s.logger.Printf("[WARN] [sync] Failed to read %s for %s: %v", src, appName, rerr)
|
||||
continue
|
||||
}
|
||||
if aerr := s.admit(appName, data); aerr != nil {
|
||||
s.logger.Printf("[WARN] [sync] %s: stored definition REFUSED by the allow-list — the app keeps its last good compose, nothing written: %v", appName, aerr)
|
||||
break
|
||||
}
|
||||
changed, err = writeIfChanged(data, dst)
|
||||
} else {
|
||||
changed, err = copyIfChanged(src, dst)
|
||||
}
|
||||
|
||||
@@ -508,6 +508,18 @@ func i18nCases() []i18nCase {
|
||||
d["HoldLogKept"] = true
|
||||
return d
|
||||
}})
|
||||
// R-971 (v0.328.0): the box refused the catalog's newer definition of an installed app — the app page's line,
|
||||
// captured when born.
|
||||
base = append(base, i18nCase{"app_settings_definition_refused", "app_settings", func() map[string]interface{} {
|
||||
d := i18nLayoutData("stacks", "Homepage")
|
||||
st := stacks.Stack{Name: "homepage", Deployed: true, State: stacks.StateRunning,
|
||||
DefinitionRefused: "the catalog definition of homepage asks for container rights the box does not allow: homepage: privileged true"}
|
||||
st.Meta = stacks.Metadata{DisplayName: "Homepage", Slug: "homepage"}
|
||||
d["Stack"] = st
|
||||
d["Meta"] = st.Meta
|
||||
d["AppInfo"] = st.Meta.AppInfo
|
||||
return d
|
||||
}})
|
||||
// v0.320.0 — logins and folders: the infra app page (FileBrowser), the two login sentences a catalog key
|
||||
// chooses, and the „Fajlok" button with a media folder and a folder whose drive is away. Captured when born.
|
||||
infraFB := func() map[string]interface{} {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// R-971 (v0.328.0) — the box refused the catalog's newer definition of an installed app: the app page says so in the
|
||||
// reader's language, and says nothing when there is no refusal. A render test PER BRANCH of the gate (seam-wiring).
|
||||
// COMPANION RED-PROOF (controller REPORT.md): drop the `{{if .Stack.DefinitionRefused}}` block — the refused branch
|
||||
// fails in both languages.
|
||||
func TestR971_AppPage_SaysTheDefinitionWasRefused(t *testing.T) {
|
||||
for _, refused := range []bool{true, false} {
|
||||
st := ubStack(nil, map[string]string{"homepage": "ghcr.io/gethomepage/homepage:v1.13.2"}, "2026-07-18")
|
||||
st.State = stacks.StateRunning
|
||||
if refused {
|
||||
st.DefinitionRefused = "the catalog definition of homepage asks for container rights the box does not allow: homepage: privileged true"
|
||||
}
|
||||
for lang, want := range map[string]string{"hu": "nem fogadta el", "en": "did not accept"} {
|
||||
h := renderBackupPageLang(t, lang, "app_settings", appInfoData(st))
|
||||
has := strings.Contains(h, `data-definition-refused="true"`)
|
||||
if refused && (!has || !strings.Contains(h, want)) {
|
||||
t.Errorf("[%s] a refused definition is not on the page (marker %v, sentence %q)", lang, has, want)
|
||||
}
|
||||
if !refused && has {
|
||||
t.Errorf("[%s] positive control: no refusal, yet the line is shown", lang)
|
||||
}
|
||||
if strings.Contains(h, "privileged true") {
|
||||
t.Errorf("[%s] the household page shows the operator detail", lang)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,8 @@
|
||||
<div class="alert alert-warning" style="margin-top:1rem" data-update-error="true">{{updateErrorText .Stack}}</div>
|
||||
{{else if .UpdateUndoneLine}}
|
||||
<div class="alert alert-info" style="margin-top:1rem" data-update-undone="true">{{.UpdateUndoneLine}}</div>
|
||||
{{end}}{{if .Stack.DefinitionRefused}}{{/* R-971: the box refused the catalog's newer definition; the app runs on its last good one. */}}
|
||||
<div class="alert alert-warning" style="margin-top:1rem" data-definition-refused="true">{{T "app_info.definition_refused"}}</div>
|
||||
{{end}}{{if and .Stack.HealthProbe .Stack.HealthProbe.NotChecked}}{{/* R-772: the declared health check could not run — said, never shown as healthy. */}}
|
||||
<p class="form-hint" style="margin-top:0.5rem" data-health-not-checked="true">{{T "health.no_probe_container"}}</p>{{end}}
|
||||
{{if .MissingStorageLabel}}
|
||||
|
||||
+738
@@ -0,0 +1,738 @@
|
||||
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="hu" class="no-js">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Homepage — Felhom.eu</title>
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
|
||||
<link rel="stylesheet" href="/static/style.css?v=ASSET">
|
||||
<meta name="csrf-token" content="tok">
|
||||
<script>
|
||||
|
||||
|
||||
document.documentElement.className=document.documentElement.className.replace('no-js','js');
|
||||
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
|
||||
var fhEN=document.documentElement.lang==='en';
|
||||
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
|
||||
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
|
||||
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
|
||||
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
function felhomConfirm(el,question,onYes){
|
||||
if(!el||el.dataset.fcOpen)return;
|
||||
el.dataset.fcOpen='1';
|
||||
var wrap=document.createElement('span');wrap.className='inline-confirm';
|
||||
var q=document.createElement('span');q.className='inline-confirm-q';q.textContent=question;
|
||||
var yes=document.createElement('button');yes.type='button';yes.className='btn btn-xs btn-danger';yes.textContent='Igen';
|
||||
var no=document.createElement('button');no.type='button';no.className='btn btn-xs btn-outline';no.textContent='Mégse';
|
||||
wrap.appendChild(q);wrap.appendChild(yes);wrap.appendChild(no);
|
||||
el.style.display='none';el.parentNode.insertBefore(wrap,el.nextSibling);
|
||||
function close(){wrap.remove();el.style.display='';delete el.dataset.fcOpen;}
|
||||
no.addEventListener('click',close);
|
||||
yes.addEventListener('click',function(){close();onYes();});
|
||||
}
|
||||
document.addEventListener('click',function(e){
|
||||
var btn=e.target.closest?e.target.closest('[data-confirm]'):null;
|
||||
if(!btn)return;
|
||||
e.preventDefault();
|
||||
felhomConfirm(btn,btn.getAttribute('data-confirm'),function(){
|
||||
var form=btn.closest('form');
|
||||
if(form){if(form.requestSubmit)form.requestSubmit(btn);else form.submit();}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
document.addEventListener('click',function(e){
|
||||
var btn=e.target.closest?e.target.closest('.nav-group-toggle'):null;
|
||||
if(!btn)return;
|
||||
var group=btn.closest('.nav-group');
|
||||
if(!group)return;
|
||||
var willOpen=!group.classList.contains('is-open');
|
||||
document.querySelectorAll('.nav-group.is-open').forEach(function(g){
|
||||
g.classList.remove('is-open');
|
||||
var t=g.querySelector('.nav-group-toggle');
|
||||
if(t)t.setAttribute('aria-expanded','false');
|
||||
});
|
||||
if(willOpen){group.classList.add('is-open');btn.setAttribute('aria-expanded','true');}
|
||||
});
|
||||
function showAlert(msg){var o=document.createElement('div');o.className='modal-overlay';o.id='alert-modal';o.addEventListener('click',function(e){if(e.target===o)o.remove();});var c=document.createElement('div');c.className='modal-card';c.innerHTML='<h3>Üzenet</h3><pre style="white-space:pre-wrap;word-break:break-word;background:var(--bg-2);padding:.75rem;border-radius:.375rem;font-size:.85rem;max-height:60vh;overflow-y:auto;user-select:text;cursor:text">'+msg.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')+'</pre><div class="modal-actions"><button class="btn btn-primary" onclick="document.getElementById(\'alert-modal\').remove()">OK</button></div>';o.appendChild(c);document.body.appendChild(o);}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">
|
||||
<symbol id="i-hard-drive" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 16h.01" /> <path d="M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" /> <path d="M21.946 12.013H2.054" /> <path d="M6 16h.01" /></symbol>
|
||||
<symbol id="i-cpu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20v2" /> <path d="M12 2v2" /> <path d="M17 20v2" /> <path d="M17 2v2" /> <path d="M2 12h2" /> <path d="M2 17h2" /> <path d="M2 7h2" /> <path d="M20 12h2" /> <path d="M20 17h2" /> <path d="M20 7h2" /> <path d="M7 20v2" /> <path d="M7 2v2" /> <rect x="4" y="4" width="16" height="16" rx="2" /> <rect x="8" y="8" width="8" height="8" rx="1" /></symbol>
|
||||
<symbol id="i-memory-stick" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 12v-2" /> <path d="M12 18v-2" /> <path d="M16 12v-2" /> <path d="M16 18v-2" /> <path d="M2 11h1.5" /> <path d="M20 18v-2" /> <path d="M20.5 11H22" /> <path d="M4 18v-2" /> <path d="M8 12v-2" /> <path d="M8 18v-2" /> <rect x="2" y="6" width="20" height="10" rx="2" /></symbol>
|
||||
<symbol id="i-thermometer" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z" /></symbol>
|
||||
<symbol id="i-triangle-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" /> <path d="M12 9v4" /> <path d="M12 17h.01" /></symbol>
|
||||
<symbol id="i-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2" /> <path d="M7 11V7a5 5 0 0 1 10 0v4" /></symbol>
|
||||
<symbol id="i-cloud" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" /></symbol>
|
||||
<symbol id="i-square" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" /></symbol>
|
||||
<symbol id="i-rotate-cw" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" /> <path d="M21 3v5h-5" /></symbol>
|
||||
<symbol id="i-file-text" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" /> <path d="M14 2v5a1 1 0 0 0 1 1h5" /> <path d="M10 9H8" /> <path d="M16 13H8" /> <path d="M16 17H8" /></symbol>
|
||||
<symbol id="i-external-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6" /> <path d="M10 14 21 3" /> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></symbol>
|
||||
<symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /></symbol>
|
||||
<symbol id="i-server" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="8" x="2" y="2" rx="2" ry="2" /> <rect width="20" height="8" x="2" y="14" rx="2" ry="2" /> <line x1="6" x2="6.01" y1="6" y2="6" /> <line x1="6" x2="6.01" y1="18" y2="18" /></symbol>
|
||||
<symbol id="i-share" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3" /> <circle cx="6" cy="12" r="3" /> <circle cx="18" cy="19" r="3" /> <line x1="8.59" x2="15.42" y1="13.51" y2="17.49" /> <line x1="15.41" x2="8.59" y1="6.51" y2="10.49" /></symbol>
|
||||
<symbol id="i-layout-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="7" x="3" y="3" rx="1" /> <rect width="7" height="7" x="14" y="3" rx="1" /> <rect width="7" height="7" x="14" y="14" rx="1" /> <rect width="7" height="7" x="3" y="14" rx="1" /></symbol>
|
||||
<symbol id="i-rocket" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91 0z" /> <path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" /> <path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" /> <path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" /></symbol>
|
||||
<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" /> <circle cx="12" cy="12" r="3" /></symbol>
|
||||
<symbol id="i-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.268 21a2 2 0 0 0 3.464 0" /> <path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></symbol>
|
||||
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" /> <path d="m6 6 12 12" /></symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5" /></symbol>
|
||||
<symbol id="i-pencil" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" /> <path d="m15 5 4 4" /></symbol>
|
||||
<symbol id="i-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /> <path d="M12 16v-4" /> <path d="M12 8h.01" /></symbol>
|
||||
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> <path d="m7 10 5 5 5-5" /></symbol>
|
||||
<symbol id="i-upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12" /> <path d="m17 8-5-5-5 5" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /></symbol>
|
||||
<symbol id="i-trash-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 11v6" /> <path d="M14 11v6" /> <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /> <path d="M3 6h18" /> <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></symbol>
|
||||
<symbol id="i-wrench" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z" /></symbol>
|
||||
<symbol id="i-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /> <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /></symbol>
|
||||
<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 21-4.34-4.34" /> <circle cx="11" cy="11" r="8" /></symbol>
|
||||
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14" /> <path d="M12 5v14" /></symbol>
|
||||
<symbol id="i-chevron-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6" /></symbol>
|
||||
<symbol id="i-play" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z" /></symbol>
|
||||
<symbol id="i-pause" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="14" y="3" width="5" height="18" rx="1" /> <rect x="5" y="3" width="5" height="18" rx="1" /></symbol>
|
||||
<symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h16" /> <path d="M4 6h16" /> <path d="M4 18h16" /></symbol>
|
||||
</svg>
|
||||
<header class="mobile-topbar">
|
||||
<a href="/" class="mobile-topbar-logo"><img src="/static/felhom-logo.svg?v=0.247.0" alt="Felhom.eu"></a>
|
||||
<button type="button" class="nav-burger" aria-expanded="false" aria-controls="sidebar" aria-label="Menü">
|
||||
<svg class="ico"><use href="#i-menu"/></svg>
|
||||
</button>
|
||||
</header>
|
||||
<div class="nav-backdrop" hidden></div>
|
||||
<nav class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<img src="/static/felhom-logo.svg?v=0.247.0" alt="Felhom.eu" class="sidebar-logo">
|
||||
</div>
|
||||
<ul class="nav-links">
|
||||
<li><a href="/launcher" class=""><svg class="ico"><use href="#i-rocket"/></svg>Indítópult</a></li>
|
||||
<li><a href="/dashboard" class=""><svg class="ico"><use href="#i-layout-grid"/></svg>Vezérlőpult</a></li>
|
||||
<li><a href="/stacks" class="active"><svg class="ico"><use href="#i-cloud"/></svg>Alkalmazások</a></li>
|
||||
|
||||
<li class="nav-group">
|
||||
<button type="button" class="nav-group-toggle" aria-expanded="false" aria-controls="nav-group-storage"><svg class="ico"><use href="#i-hard-drive"/></svg>Tárhely<svg class="ico nav-chevron"><use href="#i-chevron-down"/></svg></button>
|
||||
<ul id="nav-group-storage" class="nav-links nav-links-sub nav-links-nested">
|
||||
<li><a href="/storage" class="">Meghajtók</a></li>
|
||||
<li><a href="/storage/network" class="">Hálózati tárhely</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li class="nav-group">
|
||||
<button type="button" class="nav-group-toggle" aria-expanded="false" aria-controls="nav-group-backups"><svg class="ico"><use href="#i-shield"/></svg>Biztonsági mentés<svg class="ico nav-chevron"><use href="#i-chevron-down"/></svg></button>
|
||||
<ul id="nav-group-backups" class="nav-links nav-links-sub nav-links-nested">
|
||||
<li><a href="/backups" class="">Áttekintés</a></li>
|
||||
<li><a href="/backups/remote" class="">Távoli mentés</a></li>
|
||||
<li><a href="/backups/apps" class="">Alkalmazások</a></li>
|
||||
<li><a href="/backups/restore" class="">Visszaállítás</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li class="nav-group">
|
||||
<button type="button" class="nav-group-toggle" aria-expanded="false" aria-controls="nav-group-sharing"><svg class="ico"><use href="#i-share"/></svg>Megosztás<svg class="ico nav-chevron"><use href="#i-chevron-down"/></svg></button>
|
||||
<ul id="nav-group-sharing" class="nav-links nav-links-sub nav-links-nested">
|
||||
<li><a href="/sharing" class="">Hálózati megosztás</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a href="/monitoring" class=""><svg class="ico"><use href="#i-cpu"/></svg>Rendszermonitor</a></li>
|
||||
<li><a href="/debug" class=""><svg class="ico"><use href="#i-wrench"/></svg>Hibakeresés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-bottom">
|
||||
<div class="nav-group-label">Beállítások</div>
|
||||
<ul class="nav-links nav-links-sub">
|
||||
<li><a href="/settings" class=""><svg class="ico"><use href="#i-settings"/></svg>Rendszer</a></li>
|
||||
<li><a href="/settings/notifications" class=""><svg class="ico"><use href="#i-bell"/></svg>Értesítések</a></li>
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="content">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="app-page">
|
||||
|
||||
<a href="/stacks" class="app-back">← Alkalmazások</a>
|
||||
|
||||
<div class="app-hero">
|
||||
<img class="app-hero-logo" src="/static/assets/homepage-logo.svg"
|
||||
alt="Homepage" data-fallback="/static/app-placeholder.svg"
|
||||
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='\/static\/assets\/homepage-logo.png';}else if(this.dataset.fallback&&this.dataset.step==='1'){this.dataset.step='2';this.src=this.dataset.fallback;}else{this.onerror=null;this.style.visibility='hidden';}">
|
||||
<div class="app-hero-text">
|
||||
<h2 class="app-hero-name">Homepage</h2>
|
||||
|
||||
<div class="app-chips">
|
||||
|
||||
<span class="app-chip" data-state-chip="running">● Fut</span>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="alert alert-warning" style="margin-top:1rem" data-definition-refused="true">A katalógus újabb leírását ehhez az alkalmazáshoz a doboz nem fogadta el, mert olyan jogot kér, amit a doboz nem ad meg. Az alkalmazás a legutóbbi jó beállításaival fut tovább; az üzemeltetőt értesítettük.</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<nav class="app-tabs" aria-label="Alkalmazás lapjai">
|
||||
<a class="app-tab app-tab-active" href="/apps/homepage/settings" data-tab="settings"
|
||||
aria-current="page" aria-selected="true">Beállítások</a>
|
||||
<a class="app-tab" href="/apps/homepage/data" data-tab="data"
|
||||
aria-selected="false">Adatok és mentés</a>
|
||||
<a class="app-tab" href="/apps/homepage/guide" data-tab="guide"
|
||||
aria-selected="false">Útmutató</a>
|
||||
<a class="app-tab" href="/apps/homepage/logs" data-tab="logs"
|
||||
aria-selected="false">Napló</a>
|
||||
</nav>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="app-settings-tab">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="app-section" id="app-run-card" data-run-card="1">
|
||||
<h3>Működés</h3>
|
||||
<p class="form-hint" data-run-state="running">Fut</p>
|
||||
<div style="display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.5rem">
|
||||
|
||||
<button class="btn btn-sm btn-warning" data-run-action="restart" onclick="stackAction(event, 'homepage', 'restart')">Újraindítás</button>
|
||||
<button class="btn btn-sm btn-danger" data-run-action="stop" onclick="var b=this; felhomConfirm(b, 'Leállítod az alkalmazást? Amíg áll, a házban senki nem tudja használni.', function(){ stackAction(event, 'homepage', 'stop'); })">Leállítás</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="app-sections">
|
||||
|
||||
|
||||
<div class="app-section" id="app-login-panel" data-login-panel="1" data-login-answer="first_visit">
|
||||
<h3>Belépés az alkalmazásba</h3>
|
||||
<p data-login-kind="first_visit">Az admin fiókot az első megnyitáskor te hozod létre. A jelszót a doboz nem tudja — írd fel a jelszókezelődbe.</p>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="app-section" data-address-keys="1">
|
||||
<h3>Cím és kulcsok</h3>
|
||||
<p class="app-field-hint" data-readonly-hint="1">Ezek telepítéskor jöttek létre, és nem módosíthatók.</p>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<script>
|
||||
function appPress(btn, url) {
|
||||
var err = btn.parentNode.querySelector('.app-press-err');
|
||||
if (err) { err.style.display = 'none'; }
|
||||
btn.disabled = true;
|
||||
fetch(url, {method: 'POST', headers: csrfHeaders(), credentials: 'same-origin'})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (j) {
|
||||
if (!j.ok) { throw new Error(j.error || ''); }
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(function (e) { btn.disabled = false; if (err) { err.textContent = e.message; err.style.display = 'inline'; } });
|
||||
}
|
||||
|
||||
|
||||
function appAfterStart(btn, slug, envVar) {
|
||||
var box = btn.closest('.after-start-card'), err = box.querySelector('.app-press-err');
|
||||
var input = box.querySelector('input');
|
||||
err.style.display = 'none'; btn.disabled = true;
|
||||
fetch('/apps/' + slug + '/after-start/' + envVar, {
|
||||
method: 'POST',
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({value: (input ? input.value : '').trim()})
|
||||
}).then(function (r) { return r.json(); })
|
||||
.then(function (j) { if (!j.ok) { throw new Error(j.error || ''); } window.location.reload(); })
|
||||
.catch(function (e) { btn.disabled = false; err.textContent = e.message; err.style.display = 'inline'; });
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
|
||||
function revealAutoField(stackName, envVar, btn, elId) {
|
||||
var el = document.getElementById(elId || ('auto-field-' + envVar));
|
||||
if (!el) return;
|
||||
if (btn.dataset.shown === '1') {
|
||||
el.value = '';
|
||||
el.type = 'password';
|
||||
el.dataset.revealed = '';
|
||||
btn.dataset.shown = '';
|
||||
btn.textContent = 'Megjelenítés';
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
fetch('/stacks/' + encodeURIComponent(stackName) + '/auto-field/reveal', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({'Content-Type': 'application/x-www-form-urlencoded'}, csrfHeaders()),
|
||||
credentials: 'same-origin',
|
||||
body: 'env_var=' + encodeURIComponent(envVar)
|
||||
}).then(function (r) { return r.json(); }).then(function (j) {
|
||||
btn.disabled = false;
|
||||
if (!j.ok) { showAlert(j.error || 'A lekérés nem sikerült.'); return; }
|
||||
el.value = j.data.value;
|
||||
el.type = 'text';
|
||||
btn.dataset.shown = '1';
|
||||
btn.textContent = 'Elrejtés';
|
||||
}).catch(function () { btn.disabled = false; showAlert('A lekérés nem sikerült.'); });
|
||||
}
|
||||
function deleteStaleData(stackName, stalePath, btn) {
|
||||
|
||||
felhomConfirm(btn, 'Biztosan törölni szeretnéd a korábbi adatokat? Tárhely: ' + stalePath + ' — FIGYELEM: a művelet visszavonhatatlan! Előtte győződj meg róla, hogy az alkalmazás az új tárolóról megfelelően működik.', function () {
|
||||
felhomConfirm(btn, 'UTOLSÓ FIGYELMEZTETÉS! A törlés visszavonhatatlan. Biztosan folytatod?', function () {
|
||||
deleteStaleDataRun(stackName, stalePath, btn);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function deleteStaleDataRun(stackName, stalePath, btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Törlés folyamatban...';
|
||||
|
||||
fetch('/api/storage/stale-cleanup', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||
body: JSON.stringify({stack_name: stackName, stale_path: stalePath})
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.ok) {
|
||||
showAlert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Korábbi adatok törlése';
|
||||
return;
|
||||
}
|
||||
var msg = 'Korábbi adatok törölve!\n\nFelszabadított hely: ' + (data.freed_human || '?');
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
msg += '\n\nNéhány hiba történt:\n' + data.errors.join('\n');
|
||||
}
|
||||
showAlert(msg);
|
||||
|
||||
var item = btn.closest('.stale-data-item');
|
||||
if (item) item.remove();
|
||||
|
||||
var container = document.querySelector('.deploy-stale-data');
|
||||
if (container && container.querySelectorAll('.stale-data-item').length === 0) {
|
||||
container.remove();
|
||||
}
|
||||
})
|
||||
.catch(function(e) {
|
||||
showAlert('Hálózati hiba: ' + e.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Korábbi adatok törlése';
|
||||
});
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
</div>
|
||||
|
||||
</main>
|
||||
<script>
|
||||
|
||||
|
||||
|
||||
(function(){
|
||||
var burger=document.querySelector('.nav-burger');
|
||||
var sidebar=document.getElementById('sidebar');
|
||||
var backdrop=document.querySelector('.nav-backdrop');
|
||||
if(!burger||!sidebar||!backdrop)return;
|
||||
function setOpen(open){
|
||||
sidebar.classList.toggle('is-open',open);
|
||||
document.body.classList.toggle('nav-open',open);
|
||||
backdrop.hidden=!open;
|
||||
burger.setAttribute('aria-expanded',open?'true':'false');
|
||||
}
|
||||
burger.addEventListener('click',function(){setOpen(!sidebar.classList.contains('is-open'));});
|
||||
backdrop.addEventListener('click',function(){setOpen(false);});
|
||||
document.addEventListener('keydown',function(e){
|
||||
if(e.key==='Escape'&&sidebar.classList.contains('is-open'))setOpen(false);
|
||||
});
|
||||
})();
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.closest('a, button, .btn, input, select, textarea, .app-row-actions, .stack-detail-actions')) return;
|
||||
var card = e.target.closest('[data-href]');
|
||||
if (card) window.location.href = card.dataset.href;
|
||||
});
|
||||
async function checkBeforeDeploy(e, name) {
|
||||
try {
|
||||
var resp = await fetch('/api/stacks/' + name);
|
||||
var data = await resp.json();
|
||||
if (data.ok && data.data && data.data.deployed) {
|
||||
e.preventDefault();
|
||||
showAlert('Ez az alkalmazás már telepítve van.');
|
||||
window.location.reload();
|
||||
return false;
|
||||
}
|
||||
} catch(err) {}
|
||||
return true;
|
||||
}
|
||||
async function syncTemplates() {
|
||||
const btn = document.getElementById('sync-btn');
|
||||
const toast = document.getElementById('sync-toast');
|
||||
if (!btn) return;
|
||||
const origText = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '↻ Frissítés...';
|
||||
btn.classList.add('loading');
|
||||
try {
|
||||
const resp = await fetch('/api/sync', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders())
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (toast) {
|
||||
toast.textContent = data.ok ? (data.message || 'Sablonok frissítve') : ('Hiba: ' + (data.error || 'Ismeretlen hiba'));
|
||||
toast.className = 'sync-toast ' + (data.ok ? 'sync-toast-ok' : 'sync-toast-err');
|
||||
toast.style.display = 'block';
|
||||
setTimeout(function() { toast.style.display = 'none'; }, 5000);
|
||||
}
|
||||
if (data.ok && data.data && (data.data.new_apps && data.data.new_apps.length > 0 || data.data.updated && data.data.updated.length > 0)) {
|
||||
setTimeout(function() { window.location.reload(); }, 1500);
|
||||
}
|
||||
} catch (err) {
|
||||
if (toast) {
|
||||
toast.textContent = 'Hálózati hiba: ' + err.message;
|
||||
toast.className = 'sync-toast sync-toast-err';
|
||||
toast.style.display = 'block';
|
||||
setTimeout(function() { toast.style.display = 'none'; }, 5000);
|
||||
}
|
||||
}
|
||||
btn.innerHTML = origText;
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('loading');
|
||||
}
|
||||
async function stackAction(event, name, action) {
|
||||
const btn = event.currentTarget;
|
||||
const origText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Folyamatban...';
|
||||
btn.classList.add('loading');
|
||||
try {
|
||||
const resp = await fetch('/api/stacks/' + name + '/' + action, {
|
||||
method: 'POST',
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders())
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!data.ok) {
|
||||
showAlert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
|
||||
btn.textContent = origText;
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('loading');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (action === 'update') {
|
||||
followUpdate(name, btn);
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
showAlert('Hálózati hiba: ' + err.message);
|
||||
btn.textContent = origText;
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
function followUpdate(name, btn) {
|
||||
var started = Date.now();
|
||||
var timer = setInterval(async function() {
|
||||
if (Date.now() - started > 30 * 60 * 1000) { clearInterval(timer); window.location.reload(); return; }
|
||||
try {
|
||||
var r = await fetch('/api/stacks/' + name);
|
||||
var d = await r.json();
|
||||
if (!d.ok || !d.data) return;
|
||||
if (d.data.update_phase_label) btn.textContent = d.data.update_phase_label;
|
||||
if (!d.data.updating) { clearInterval(timer); window.location.reload(); }
|
||||
} catch (e) {}
|
||||
}, 3000);
|
||||
}
|
||||
async function deleteOrphanStack(name) {
|
||||
var modal = document.createElement('div');
|
||||
modal.className = 'modal-overlay';
|
||||
modal.id = 'delete-modal';
|
||||
modal.innerHTML = '<div class="modal-card"><h3>Betöltés...</h3></div>';
|
||||
modal.addEventListener('click', function(e) { if (e.target === modal) closeDeleteModal(); });
|
||||
document.body.appendChild(modal);
|
||||
try {
|
||||
var resp = await fetch('/api/stacks/' + name + '/hdd-data');
|
||||
var data = await resp.json();
|
||||
var hddInfo = '';
|
||||
var checkboxHTML = '';
|
||||
if (data.ok && data.data && data.data.has_hdd_data) {
|
||||
hddInfo = '<div class="modal-hdd-info"><strong>Felhasználói adatok a merevlemezen:</strong>';
|
||||
data.data.hdd_paths.forEach(function(p) {
|
||||
hddInfo += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
});
|
||||
hddInfo += '</div>';
|
||||
checkboxHTML = '<label class="modal-checkbox"><input type="checkbox" id="delete-hdd-check"> Felhasználói adatok törlése a merevlemezről</label>';
|
||||
}
|
||||
|
||||
if (data.ok && data.data && data.data.userdata_kept && data.data.userdata_kept.length > 0) {
|
||||
var udPaths = '';
|
||||
data.data.userdata_kept.forEach(function(p) { udPaths += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>'; });
|
||||
hddInfo += '<div class="modal-section" data-userdata-kept="true"><strong>A fájljaid ezekben a mappákban megmaradnak — a fájlböngészőben látod őket:</strong>' + udPaths + '</div>';
|
||||
}
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Alkalmazás törlése: ' + name + '</h3>' +
|
||||
'<p style="color:var(--text-2);font-size:.9rem;margin-bottom:.75rem">Ez a művelet eltávolítja a konténereket, a köteteket és a konfigurációs fájlokat.</p>' +
|
||||
'<div class="alert alert-warning" style="margin-bottom:.75rem">Ez a művelet nem visszavonható!</div>' +
|
||||
hddInfo + checkboxHTML +
|
||||
'<div class="modal-actions">' +
|
||||
'<button class="btn btn-outline" onclick="closeDeleteModal()">Mégsem</button>' +
|
||||
'<button class="btn btn-danger" id="confirm-delete-btn" onclick="confirmDelete(\'' + name + '\')">Törlés</button>' +
|
||||
'</div>';
|
||||
} catch (err) {
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Hiba</h3><p style="color:var(--text-2)">Nem sikerült lekérni az adatokat: ' + err.message + '</p>' +
|
||||
'<div class="modal-actions"><button class="btn btn-outline" onclick="closeDeleteModal()">Bezárás</button></div>';
|
||||
}
|
||||
}
|
||||
function closeDeleteModal() {
|
||||
var modal = document.getElementById('delete-modal');
|
||||
if (modal) modal.remove();
|
||||
}
|
||||
async function confirmDelete(name) {
|
||||
var btn = document.getElementById('confirm-delete-btn');
|
||||
var checkbox = document.getElementById('delete-hdd-check');
|
||||
var removeHDD = checkbox ? checkbox.checked : false;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Törlés folyamatban...';
|
||||
try {
|
||||
var resp = await fetch('/api/stacks/' + name, {
|
||||
method: 'DELETE',
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||
body: JSON.stringify({remove_hdd_data: removeHDD})
|
||||
});
|
||||
var data = await resp.json();
|
||||
if (data.ok) {
|
||||
var modal = document.getElementById('delete-modal');
|
||||
var removedInfo = '';
|
||||
if (data.data && data.data.hdd_paths_removed && data.data.hdd_paths_removed.length > 0) {
|
||||
removedInfo = '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Törölt adatok: ' + data.data.hdd_paths_removed.join(', ') + '</p>';
|
||||
}
|
||||
var preservedInfo = '';
|
||||
if (data.data && data.data.hdd_paths_preserved && data.data.hdd_paths_preserved.length > 0) {
|
||||
preservedInfo = '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Megőrzött adatok: ' + data.data.hdd_paths_preserved.join(', ') + '</p>';
|
||||
}
|
||||
if (data.data && data.data.hdd_note) {
|
||||
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">' + data.data.hdd_note + '</p>';
|
||||
}
|
||||
if (data.data && data.data.userdata_kept && data.data.userdata_kept.length > 0) {
|
||||
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem" data-userdata-kept="true">A fájljaid ezekben a mappákban megmaradnak — a fájlböngészőben látod őket: ' + data.data.userdata_kept.join(', ') + '</p>';
|
||||
}
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Sikeresen törölve!</h3>' +
|
||||
'<p style="color:var(--text-2)">Az alkalmazás (' + name + ') törölve lett.</p>' +
|
||||
removedInfo + preservedInfo +
|
||||
'<div class="modal-actions"><button class="btn btn-primary" onclick="window.location.href=\'/stacks\'">Bezárás</button></div>';
|
||||
} else {
|
||||
showAlert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Törlés';
|
||||
}
|
||||
} catch (err) {
|
||||
showAlert('Hálózati hiba: ' + err.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Törlés';
|
||||
}
|
||||
}
|
||||
async function removeStack(name) {
|
||||
var modal = document.createElement('div');
|
||||
modal.className = 'modal-overlay';
|
||||
modal.id = 'remove-modal';
|
||||
modal.innerHTML = '<div class="modal-card"><h3>Betöltés...</h3></div>';
|
||||
modal.addEventListener('click', function(e) { if (e.target === modal) closeRemoveModal(); });
|
||||
document.body.appendChild(modal);
|
||||
try {
|
||||
var [hddResp, backupResp] = await Promise.all([
|
||||
fetch('/api/stacks/' + name + '/hdd-data').then(function(r) { return r.json(); }),
|
||||
fetch('/api/stacks/' + name + '/backup-data').then(function(r) { return r.json(); })
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
'<li>Docker kötetek (adatbázis, alkalmazás konfiguráció)</li>' +
|
||||
'<li>Telepítési konfiguráció (app.yaml)</li>' +
|
||||
'<li>Másodlagos mentés ütemezése</li>' +
|
||||
'</ul></div>';
|
||||
|
||||
var hddCheckbox = '';
|
||||
if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) {
|
||||
var hddPaths = '';
|
||||
hddResp.data.hdd_paths.forEach(function(p) {
|
||||
hddPaths += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
});
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Felhasználói adatok a merevlemezen:</strong>' + hddPaths +
|
||||
'<label class="modal-checkbox"><input type="checkbox" id="remove-hdd-check"> Felhasználói adatok törlése</label>' +
|
||||
'<div class="alert alert-info" style="margin-top:.5rem;font-size:.8rem" id="remove-hdd-keep-warning">' +
|
||||
'Ha újratelepíted az alkalmazást, az adatokat újra importálnod kell, mivel az adatbázis törlődik. A megtartott adatok a továbbiakban NEM lesznek automatikusan mentve.' +
|
||||
'</div></div>';
|
||||
hddCheckbox = '\x3Cscript>document.getElementById("remove-hdd-check").addEventListener("change",function(){document.getElementById("remove-hdd-keep-warning").style.display=this.checked?"none":"";});<\/script>';
|
||||
}
|
||||
|
||||
var backupCheckbox = '';
|
||||
if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) {
|
||||
var bkPaths = '';
|
||||
backupResp.data.backup_paths.forEach(function(p) {
|
||||
if (p.exists) bkPaths += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
});
|
||||
if (bkPaths) {
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mentési adatok:</strong>' + bkPaths +
|
||||
'<label class="modal-checkbox"><input type="checkbox" id="remove-backup-check"> Mentési adatok törlése</label>' +
|
||||
'<div class="alert alert-info" style="margin-top:.5rem;font-size:.8rem">' +
|
||||
'Az éjszakai restic pillanatképek nem törölhetők egyenként — a megőrzési szabályok szerint automatikusan elavulnak.' +
|
||||
'</div></div>';
|
||||
}
|
||||
}
|
||||
|
||||
if (hddResp.ok && hddResp.data && hddResp.data.userdata_kept && hddResp.data.userdata_kept.length > 0) {
|
||||
var udPaths = '';
|
||||
hddResp.data.userdata_kept.forEach(function(p) { udPaths += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>'; });
|
||||
sections += '<div class="modal-section" data-userdata-kept="true"><strong>A fájljaid ezekben a mappákban megmaradnak — a fájlböngészőben látod őket:</strong>' + udPaths + '</div>';
|
||||
}
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Alkalmazás eltávolítása: ' + name + '</h3>' +
|
||||
'<p style="color:var(--text-2);font-size:.9rem;margin-bottom:.75rem">Az alkalmazás visszaáll "Nincs telepítve" állapotba. A sablon megmarad, újratelepíthető.</p>' +
|
||||
'<div class="alert alert-warning" style="margin-bottom:.75rem">Ez a művelet nem visszavonható!</div>' +
|
||||
sections +
|
||||
'<div class="modal-actions">' +
|
||||
'<button class="btn btn-outline" onclick="closeRemoveModal()">Mégsem</button>' +
|
||||
'<button class="btn btn-danger" id="confirm-remove-btn" onclick="confirmRemoveStack(\'' + name + '\')">Eltávolítás</button>' +
|
||||
'</div>' + hddCheckbox;
|
||||
} catch (err) {
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Hiba</h3><p style="color:var(--text-2)">Nem sikerült lekérni az adatokat: ' + err.message + '</p>' +
|
||||
'<div class="modal-actions"><button class="btn btn-outline" onclick="closeRemoveModal()">Bezárás</button></div>';
|
||||
}
|
||||
}
|
||||
function closeRemoveModal() {
|
||||
var modal = document.getElementById('remove-modal');
|
||||
if (modal) modal.remove();
|
||||
}
|
||||
async function confirmRemoveStack(name) {
|
||||
var btn = document.getElementById('confirm-remove-btn');
|
||||
var hddCheck = document.getElementById('remove-hdd-check');
|
||||
var backupCheck = document.getElementById('remove-backup-check');
|
||||
var removeHDD = hddCheck ? hddCheck.checked : false;
|
||||
var removeBackups = backupCheck ? backupCheck.checked : false;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Eltávolítás folyamatban...';
|
||||
try {
|
||||
var resp = await fetch('/api/stacks/' + name + '/remove', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||
body: JSON.stringify({remove_hdd_data: removeHDD, remove_backups: removeBackups})
|
||||
});
|
||||
var data = await resp.json();
|
||||
if (data.ok) {
|
||||
var modal = document.getElementById('remove-modal');
|
||||
var removedInfo = '';
|
||||
if (data.data && data.data.hdd_paths_removed && data.data.hdd_paths_removed.length > 0) {
|
||||
removedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Törölt adatok: ' + data.data.hdd_paths_removed.join(', ') + '</p>';
|
||||
}
|
||||
if (data.data && data.data.backup_paths_removed && data.data.backup_paths_removed.length > 0) {
|
||||
removedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Törölt mentések: ' + data.data.backup_paths_removed.join(', ') + '</p>';
|
||||
}
|
||||
var preservedInfo = '';
|
||||
if (data.data && data.data.hdd_paths_preserved && data.data.hdd_paths_preserved.length > 0) {
|
||||
preservedInfo = '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Megőrzött adatok: ' + data.data.hdd_paths_preserved.join(', ') + '</p>';
|
||||
}
|
||||
if (data.data && data.data.hdd_note) {
|
||||
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">' + data.data.hdd_note + '</p>';
|
||||
}
|
||||
if (data.data && data.data.userdata_kept && data.data.userdata_kept.length > 0) {
|
||||
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem" data-userdata-kept="true">A fájljaid ezekben a mappákban megmaradnak — a fájlböngészőben látod őket: ' + data.data.userdata_kept.join(', ') + '</p>';
|
||||
}
|
||||
if (data.data && data.data.backup_paths_refused && data.data.backup_paths_refused.length > 0) {
|
||||
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Nem törölt mentések: ' + data.data.backup_paths_refused.join('; ') + '</p>';
|
||||
}
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Sikeresen eltávolítva!</h3>' +
|
||||
'<p style="color:var(--text-2)">Az alkalmazás (' + name + ') eltávolítva. Újratelepíthető a Telepítés gombbal.</p>' +
|
||||
removedInfo + preservedInfo +
|
||||
'<div class="modal-actions"><button class="btn btn-primary" onclick="window.location.href=\'/stacks\'">Bezárás</button></div>';
|
||||
} else {
|
||||
showAlert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Eltávolítás';
|
||||
}
|
||||
} catch (err) {
|
||||
showAlert('Hálózati hiba: ' + err.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Eltávolítás';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -157,6 +157,7 @@
|
||||
"login.msg.wrong_password": "R-753 (v0.286.0) -- the dashboard login's messages moved to the informal voice on purpose (brief 2026-10-01: formal „Kérjük adja meg …\" → informal), so byte parity with the base literal cannot hold. Pinned in both languages by TestLoginMessagesFollowTheReader.",
|
||||
"err.stacks.family_gate_failed": "BORN AS A KEY, v0.287.0 (the family gate, decisions 63/64) -- a NEW sentence, never a Go literal. Pinned in both languages by TestFamilyGate_MessagesFollowTheReader.",
|
||||
"err.stacks.needs_newer_controller": "BORN AS A KEY, v0.287.0 (the family gate, decisions 63/64) -- a NEW sentence, never a Go literal. Pinned in both languages by TestFamilyGate_MessagesFollowTheReader.",
|
||||
"err.stacks.definition_refused": "BORN AS A KEY, v0.328.0 (R-971, the catalog allow-list) -- a NEW sentence, never a Go literal. Pinned in both languages by TestDefinitionRefused_MessagesFollowTheReader.",
|
||||
"family_gate.msg.bad_name": "BORN AS A KEY, v0.287.0 (the family gate, decisions 63/64) -- a NEW sentence, never a Go literal. Pinned in both languages by TestFamilyGate_MessagesFollowTheReader.",
|
||||
"family_gate.msg.exists": "BORN AS A KEY, v0.287.0 (the family gate, decisions 63/64) -- a NEW sentence, never a Go literal. Pinned in both languages by TestFamilyGate_MessagesFollowTheReader.",
|
||||
"family_gate.msg.form_expired": "BORN AS A KEY, v0.287.0 (the family gate, decisions 63/64) -- a NEW sentence, never a Go literal. Pinned in both languages by TestFamilyGate_MessagesFollowTheReader.",
|
||||
|
||||
Reference in New Issue
Block a user