Files
felhom-controller/controller/internal/stacks/allowlist.go
T

137 lines
4.9 KiB
Go

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()
pol := privpolicy.Policy{SystemDataPath: m.sysDataPath}
if env != nil {
e = m.policyEnv(env)
// Resolve (real paths only — the placeholder env names no real folder): a bind source that is a symlink out
// of its data root is outside; it is judged where it points.
pol.Resolve = filepath.EvalSymlinks
}
err := privpolicy.Admit(name, data, e, pol)
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
}