24d937d6e4
gates / gates (push) Successful in 42s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
134 lines
4.8 KiB
Go
134 lines
4.8 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()
|
|
if env != nil {
|
|
e = m.policyEnv(env)
|
|
}
|
|
// Resolve: a bind source that is a symlink out of its data root is outside (it is judged where it points).
|
|
err := privpolicy.Admit(name, data, e, privpolicy.Policy{SystemDataPath: m.sysDataPath, Resolve: filepath.EvalSymlinks})
|
|
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
|
|
}
|