Files
orca/internal/emitter/drift_path.go
T
Jon Chery 03f3585f16 feat(P10b): drift detection (R-018/R-019/R-020, REQ-103..113)
internal/drift/drift.go: Detector (Watch via iter.Seq2, Aggregate,
Remediate with cooldown-on-success, Acknowledge), Config with tiered
cadence (critical 5s + Path units, standard 30s, default 60s).
internal/cli/drift.go: orca drift {show,watch,acknowledge,remediate,
config}. internal/emitter/drift_path.go: systemd Path+service unit
emitter (User=orca, ProtectSystem=strict). scripts/orca-drift-notify.sh
(sha256 event JSON), orca-remediate.sh (cooldown-on-success, transient
retry). Pre-flight gate (R-020, --force + per-ns scoping). orca
system user (REQ-111), NFS detection (D-233), orca job restart for
EnvironmentFile drift (D-235).

---ci---
project: orca
phase: 10b
milestone: v0.11
status: execute
---/ci---
2026-08-07 07:17:41 +00:00

148 lines
5.1 KiB
Go

// Package emitter: drift_path.go implements the systemd Path-unit
// emitter for critical drift paths (P10b, v0.11, REQ-105, R-018).
//
// For each critical path two units are emitted:
//
// - <orca-drift-<name>.path>: PathChanged=<path>,
// RateLimitIntervalSec=1s, RateLimitBurst=5 — systemd watches the
// path for changes and triggers the matching .service.
// - <orca-drift-<name>.service>: Type=oneshot,
// ExecStart=/usr/local/bin/orca-drift-notify.sh %f, User=orca,
// security-hardened (NoNewPrivileges=yes, ProtectSystem=strict,
// ReadWritePaths=/etc/orca/state/drift-events, ProtectHome=yes).
//
// The emitter takes a list of critical PathSpecs (from drift.Config)
// and produces the unit File artifacts. Unit names are derived from a
// slug of the path pattern: non-alphanumerics collapse to '-' so the
// name is systemd-friendly (no slashes, spaces, or brace chars).
package emitter
import (
"fmt"
"strings"
)
// DriftPathUnit is the systemd Path + service unit emitter for critical
// drift paths (REQ-105).
type DriftPathUnit struct{}
// driftEventsDir is the directory the oneshot services write to
// (ReadWritePaths).
const driftEventsDir = "/etc/orca/state/drift-events"
// driftNotifyBin is the oneshot ExecStart binary.
const driftNotifyBin = "/usr/local/bin/orca-drift-notify.sh"
// RenderDriftUnits renders a pair of systemd units (a .path and a
// .service) for each critical path in specs. Returns the File slice
// ready for SSH-push to the peer.
func (DriftPathUnit) RenderDriftUnits(specs []DriftPathSpec) ([]File, error) {
if len(specs) == 0 {
return nil, nil
}
seen := make(map[string]bool, len(specs))
var files []File
for _, s := range specs {
if s.Pattern == "" {
return nil, fmt.Errorf("emitter/drift_path: empty pattern")
}
name := driftUnitName(s.Pattern)
if seen[name] {
continue
}
seen[name] = true
files = append(files, File{
Path: fmt.Sprintf("/etc/systemd/system/%s.path", name),
Content: renderDriftPathUnit(name, s.Pattern),
Mode: "0644",
})
files = append(files, File{
Path: fmt.Sprintf("/etc/systemd/system/%s.service", name),
Content: renderDriftServiceUnit(name),
Mode: "0644",
})
}
return files, nil
}
// DriftPathSpec is the minimal description the emitter needs: the
// pattern to watch and whether systemd Path units are requested (when
// false, no units are emitted — the path falls back to polling).
type DriftPathSpec struct {
Pattern string
SystemdPathUnit bool
}
// driftUnitName derives a systemd-friendly unit-name slug from a path
// pattern. Non-alphanumeric runes collapse to '-'. The result carries
// the orca-drift- prefix so the units are identifiable as orca-owned.
func driftUnitName(pattern string) string {
s := pattern
s = strings.TrimPrefix(s, "/")
var b strings.Builder
prevDash := false
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
prevDash = false
} else {
if !prevDash {
b.WriteByte('-')
prevDash = true
}
}
}
name := strings.Trim(b.String(), "-")
if name == "" {
name = "root"
}
return "orca-drift-" + name
}
// renderDriftPathUnit renders the .path unit. PathChanged re-fires on
// every modification (inotify IN_MODIFY), RateLimitIntervalSec +
// RateLimitBurst bound the burst so a thrashing file does not spawn
// thousands of services (R-018).
func renderDriftPathUnit(name, path string) string {
var b strings.Builder
b.WriteString("[Unit]\n")
b.WriteString(fmt.Sprintf("Description=orca drift watch for %s\n", path))
b.WriteString("\n[Path]\n")
b.WriteString(fmt.Sprintf("PathChanged=%s\n", path))
b.WriteString("RateLimitIntervalSec=1s\n")
b.WriteString("RateLimitBurst=5\n")
b.WriteString("\n[Install]\n")
b.WriteString("WantedBy=multi-user.target\n")
return b.String()
}
// renderDriftServiceUnit renders the oneshot .service triggered by
// the .path unit. ExecStart receives the changed path via %f. Security
// hardening runs the service as User=orca with NoNewPrivileges=yes,
// ProtectSystem=strict (read-only /), and ReadWritePaths scoped to the
// drift-events dir so the script can write its event JSON. ProtectHome
// hides /root and /home (the orca user has no business there).
func renderDriftServiceUnit(name string) string {
var b strings.Builder
b.WriteString("[Unit]\n")
b.WriteString(fmt.Sprintf("Description=orca drift notify for %s\n", name))
b.WriteString(fmt.Sprintf("After=%s.path\n", name))
b.WriteString("\n[Service]\n")
b.WriteString("Type=oneshot\n")
b.WriteString(fmt.Sprintf("ExecStart=%s %%f\n", driftNotifyBin))
b.WriteString("User=orca\n")
b.WriteString("Group=orca\n")
b.WriteString("NoNewPrivileges=yes\n")
b.WriteString("ProtectSystem=strict\n")
b.WriteString(fmt.Sprintf("ReadWritePaths=%s\n", driftEventsDir))
b.WriteString("ProtectHome=yes\n")
b.WriteString("PrivateTmp=yes\n")
b.WriteString("ProtectKernelTunables=yes\n")
b.WriteString("ProtectKernelModules=yes\n")
b.WriteString("ProtectControlGroups=yes\n")
b.WriteString("RestrictSUIDSGID=yes\n")
b.WriteString("\n[Install]\n")
b.WriteString("WantedBy=multi-user.target\n")
return b.String()
}