// Package configgen contains shared helpers used by the per-service // renderers in internal/{haproxy,firewall,squid,wireguard,unbound}/. // // Each renderer satisfies the Generator interface: Render reads state // from PG, writes the rendered config to /etc/edgeguard// via // AtomicWrite, then signals the running daemon via systemctl reload // (or its service-specific reload command). Failures are surfaced // to the caller — the orchestrator decides whether one bad renderer // aborts the whole run. package configgen import ( "context" "errors" "fmt" "os" "os/exec" "path/filepath" "strings" ) // Generator is the contract every per-service renderer satisfies. // // Name returns a stable identifier ("haproxy", "nftables", …) // used in CLI output and audit logs. Render does the actual write + // reload work; ctx may be cancelled (e.g. orchestrator timeout). type Generator interface { Name() string Render(ctx context.Context) error } // ErrNotImplemented is returned by stub renderers (squid, wireguard, // unbound in v1). The orchestrator treats it as a soft skip — logged // but never fatal. var ErrNotImplemented = errors.New("renderer not implemented yet") // AtomicWrite writes data to path atomically via a temp file in the // same directory + rename. Both the temp file and the final file are // fsync'd to make the rename durable across an OS crash. Mode is // applied AFTER the rename so the previous file's permissions don't // leak through. func AtomicWrite(path string, data []byte, mode os.FileMode) error { dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0o750); err != nil { return fmt.Errorf("mkdir %s: %w", dir, err) } tmp, err := os.CreateTemp(dir, ".eg-render-*") if err != nil { return fmt.Errorf("tempfile: %w", err) } tmpPath := tmp.Name() defer os.Remove(tmpPath) // no-op if rename succeeded if _, err := tmp.Write(data); err != nil { tmp.Close() return fmt.Errorf("write %s: %w", tmpPath, err) } if err := tmp.Sync(); err != nil { tmp.Close() return fmt.Errorf("fsync %s: %w", tmpPath, err) } if err := tmp.Close(); err != nil { return fmt.Errorf("close %s: %w", tmpPath, err) } if err := os.Chmod(tmpPath, mode); err != nil { return fmt.Errorf("chmod %s: %w", tmpPath, err) } if err := os.Rename(tmpPath, path); err != nil { return fmt.Errorf("rename %s → %s: %w", tmpPath, path, err) } return nil } // ReloadService runs `sudo -n systemctl reload .service`. // edgeguard-api runs as the unprivileged `edgeguard` user; postinst // installs a sudoers entry NOPASSWD-ing exactly this command per // service that needs it. // // Some services don't support reload (nftables — no daemon); for // those, callers should run the service-specific reload directly // rather than calling this helper. func ReloadService(name string) error { cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload", name+".service") if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("sudo systemctl reload %s.service: %w (output: %s)", name, err, strings.TrimSpace(string(out))) } return nil } // EtcEdgeguard is the on-target config root. Templated path used by // all renderers — never let renderers hard-code their own. const EtcEdgeguard = "/etc/edgeguard"