Skip to main content

sysknife_daemon/
state.rs

1use crate::actions::ExclusiveResource;
2use crate::audit_chain::RequestHashKey;
3use crate::audit_forward::AuditForwarder;
4use crate::policy::PolicyTable;
5use crate::store::{AuditStore, SqliteStore};
6use crate::transactions::{TransactionStore, TransactionStoreError};
7use crate::transport::listen::{bind_unix_listener, ListenTarget, ListenTargetError};
8use std::collections::HashMap;
9use std::os::unix::net::UnixListener;
10use std::path::PathBuf;
11use std::sync::Arc;
12use tokio::sync::Mutex;
13
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct DaemonConfig {
16    pub listen_target: ListenTarget,
17    pub database_path: PathBuf,
18}
19
20impl DaemonConfig {
21    pub fn new(listen_target: ListenTarget, database_path: impl Into<PathBuf>) -> Self {
22        Self {
23            listen_target,
24            database_path: database_path.into(),
25        }
26    }
27}
28
29#[derive(Clone, Debug)]
30pub struct DaemonState {
31    pub config: DaemonConfig,
32    /// Polymorphic audit store. Concrete impls: `SqliteStore` (default) and
33    /// `PostgresStore` (selected via `[storage] backend = "postgres"`).
34    /// Both implement [`AuditStore`] async trait.
35    pub audit: Arc<dyn AuditStore>,
36    /// HMAC key for the `request_hash` column, derived from the same audit key
37    /// material that signs the chain (#268). Preview hashes and execute-time
38    /// re-hashes both go through it, so the binding check needs the key and an
39    /// exported row is no longer offline-crackable without it.
40    pub request_hash_key: Arc<RequestHashKey>,
41    pub policy: PolicyTable,
42    /// Strict host identity used to enforce the documented support matrix at
43    /// preview and execution boundaries.
44    pub host_distro: Option<sysknife_core::distro::DistroId>,
45    /// Optional external audit-log forwarder. `None` when no `[audit.forward]`
46    /// sink is configured; events recorded by the dispatcher are then only
47    /// written to the local hash-chained store.
48    pub forwarder: Option<AuditForwarder>,
49    /// Concurrency guard keyed by the system lock each in-flight mutating
50    /// action holds (ME4).
51    ///
52    /// Maps an [`ExclusiveResource`] to the `request_hash` of the action
53    /// currently holding it. A mutating action is rejected with a
54    /// `ConflictResponse` when either
55    ///
56    /// * [`ExclusiveResource::System`] is held — a reboot-required action
57    ///   (release upgrade, rebase, layered install) is in flight and conflicts
58    ///   with everything mutating; or
59    /// * its own resource is held — e.g. a second `AptUpgrade` while the first
60    ///   still owns [`ExclusiveResource::Dpkg`].
61    ///
62    /// This replaced a single `Option<String>` slot that only High-risk
63    /// *reboot-required* actions ever claimed. Under that rule `AptUpgrade`
64    /// (High risk, `reboot_required: false`) claimed nothing, so two
65    /// concurrent `apt-get dist-upgrade` runs both passed the gate and then
66    /// collided on `/var/lib/dpkg/lock-frontend` — the exact contention the
67    /// gate exists to prevent, and one that can leave dpkg needing
68    /// `--configure -a`.
69    ///
70    /// Actions with no shared lock (`exclusive_resource` returns `None`, e.g.
71    /// `StartService`) still respect a held `System` but never block each
72    /// other, so unrelated work is not serialised behind a long `apt` run.
73    ///
74    /// The map is `Arc<Mutex<…>>` so cloned `DaemonState` values — one per IPC
75    /// connection — all share the same guard. `Mutex::lock` is held for at
76    /// most a few microseconds; read-only actions skip the check entirely.
77    ///
78    /// On daemon crash the in-memory guard is lost. That is correct: the
79    /// daemon's SQLite store will show the orphaned `Running` row; the
80    /// operator can inspect it via `ListJobHistory`.
81    pub running_exclusive: Arc<Mutex<HashMap<ExclusiveResource, String>>>,
82}
83
84#[derive(Debug)]
85pub struct DaemonRuntime {
86    pub state: DaemonState,
87    pub listener: UnixListener,
88}
89
90#[derive(Debug, thiserror::Error)]
91pub enum DaemonStateError {
92    #[error(transparent)]
93    Transactions(#[from] TransactionStoreError),
94
95    #[error(transparent)]
96    AuditKey(#[from] crate::audit_chain::AuditKeyError),
97
98    #[error(transparent)]
99    Listen(#[from] ListenTargetError),
100}
101
102impl DaemonState {
103    /// Open the daemon state with no policy overrides and no forwarding.
104    /// Suitable for tests and dev runs.
105    pub fn open(config: DaemonConfig) -> Result<Self, DaemonStateError> {
106        Self::open_with_policy(config, PolicyTable::empty())
107    }
108
109    /// Open the daemon state with an explicit policy table and no forwarding.
110    pub fn open_with_policy(
111        config: DaemonConfig,
112        policy: PolicyTable,
113    ) -> Result<Self, DaemonStateError> {
114        Self::open_full(config, policy, None)
115    }
116
117    /// Open the daemon state with full configuration. Production callers
118    /// (`main.rs`) build the policy table from `[policy.risk_overrides]` and
119    /// the forwarder from `[audit.forward]`. The audit store defaults to
120    /// SQLite at `config.database_path`; pass an explicit `audit` to use
121    /// Postgres or any other [`AuditStore`] impl.
122    pub fn open_full(
123        config: DaemonConfig,
124        policy: PolicyTable,
125        forwarder: Option<AuditForwarder>,
126    ) -> Result<Self, DaemonStateError> {
127        // Load the audit key once: it both signs the chain (inside
128        // `TransactionStore`) and derives the `request_hash` HMAC key (#268).
129        // Loading twice would read the same file and, on a fresh host,
130        // generate-then-reread — one load keeps the two uses provably the
131        // same key material.
132        let key_path = crate::audit_chain::resolve_audit_key_path(&config.database_path);
133        let audit_key = Arc::new(crate::audit_chain::AuditKey::load_or_generate(&key_path)?);
134        let request_hash_key = Arc::new(audit_key.request_hash_key());
135        let store = TransactionStore::open_with_key(&config.database_path, audit_key)?;
136        let audit: Arc<dyn AuditStore> = Arc::new(SqliteStore::new(store));
137        Ok(Self {
138            config,
139            audit,
140            request_hash_key,
141            policy,
142            host_distro: sysknife_core::distro::detect().ok(),
143            forwarder,
144            running_exclusive: Arc::new(Mutex::new(HashMap::new())),
145        })
146    }
147
148    /// Open the daemon state with an explicit audit store. Used by `main.rs`
149    /// when `[storage] backend = "postgres"` is configured: the Postgres
150    /// `AuditStore` is constructed via `PostgresStore::connect` and passed
151    /// here as the `audit` argument, together with the `AuditKey` it was
152    /// connected with — the `request_hash` HMAC key is derived from that same
153    /// key, so the two backends cannot drift.
154    pub fn open_with_audit(
155        config: DaemonConfig,
156        policy: PolicyTable,
157        forwarder: Option<AuditForwarder>,
158        audit: Arc<dyn AuditStore>,
159        audit_key: &crate::audit_chain::AuditKey,
160    ) -> Self {
161        Self {
162            config,
163            audit,
164            request_hash_key: Arc::new(audit_key.request_hash_key()),
165            policy,
166            host_distro: sysknife_core::distro::detect().ok(),
167            forwarder,
168            running_exclusive: Arc::new(Mutex::new(HashMap::new())),
169        }
170    }
171
172    pub fn bootstrap(config: DaemonConfig) -> Result<DaemonRuntime, DaemonStateError> {
173        let state = Self::open(config)?;
174        let listener = bind_unix_listener(&state.config.listen_target)?;
175        Ok(DaemonRuntime { state, listener })
176    }
177}
178
179// ---------------------------------------------------------------------------
180// T18 — `state.rs` constructor and field-default tests
181//
182// Before this batch the only coverage of `DaemonState` was transitive
183// (every dispatcher integration test happened to construct one).  Pin
184// the constructors directly so a regression that swaps `open` to skip
185// the policy default, or that flips the `forwarder: None` short-circuit
186// into a panic, is caught by a unit test rather than a full integration
187// suite that may still pass for unrelated reasons.
188// ---------------------------------------------------------------------------
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use tempfile::tempdir;
194
195    fn unix_target(dir: &tempfile::TempDir) -> ListenTarget {
196        ListenTarget::Unix(dir.path().join("daemon.sock"))
197    }
198
199    #[test]
200    fn open_creates_state_with_empty_policy_and_no_forwarder() {
201        let dir = tempdir().unwrap();
202        let cfg = DaemonConfig::new(unix_target(&dir), dir.path().join("audit.sqlite"));
203        let state = DaemonState::open(cfg.clone()).expect("open should succeed");
204
205        assert_eq!(state.config, cfg);
206        assert!(
207            state.forwarder.is_none(),
208            "open() must not configure a forwarder by default"
209        );
210        assert_eq!(
211            state.policy.override_count(),
212            0,
213            "open() must use an empty policy table — no overrides without explicit opt-in"
214        );
215    }
216
217    #[test]
218    fn open_full_threads_the_provided_forwarder_through() {
219        let dir = tempdir().unwrap();
220        let cfg = DaemonConfig::new(unix_target(&dir), dir.path().join("audit.sqlite"));
221        let state = DaemonState::open_full(cfg, PolicyTable::empty(), None)
222            .expect("open_full should succeed with no forwarder");
223        assert!(state.forwarder.is_none());
224
225        // Smoke-test that `open_full` accepts a non-None forwarder.  We
226        // don't have a constructor that builds an AuditForwarder from a
227        // socket-less spec without spawning a tokio task, so we use the
228        // canonical UDP loopback constructor and immediately drop the
229        // returned state — the goal is to verify the field is wired.
230        let dir2 = tempdir().unwrap();
231        let cfg2 = DaemonConfig::new(unix_target(&dir2), dir2.path().join("audit.sqlite"));
232        let runtime = tokio::runtime::Builder::new_current_thread()
233            .enable_all()
234            .build()
235            .unwrap();
236        let forwarder = runtime.block_on(async {
237            crate::audit_forward::spawn(crate::audit_forward::AuditSinkSpec::SyslogUdp {
238                host: "127.0.0.1:65000".parse().unwrap(),
239                facility: 1,
240                enterprise_number: crate::audit_forward::DOCUMENTATION_PEN,
241            })
242        });
243        let state = DaemonState::open_full(cfg2, PolicyTable::empty(), Some(forwarder))
244            .expect("open_full with forwarder should succeed");
245        assert!(state.forwarder.is_some());
246    }
247
248    #[test]
249    fn bootstrap_returns_a_runtime_with_a_bound_listener() {
250        let dir = tempdir().unwrap();
251        let cfg = DaemonConfig::new(unix_target(&dir), dir.path().join("audit.sqlite"));
252        let runtime = DaemonState::bootstrap(cfg.clone()).expect("bootstrap should succeed");
253
254        assert_eq!(runtime.state.config, cfg);
255        // The listener should be a real bound socket — calling local_addr
256        // proves the bind succeeded and the path is what we asked for.
257        let addr = runtime
258            .listener
259            .local_addr()
260            .expect("listener has a local addr");
261        let bound = addr.as_pathname().expect("Unix listener is path-bound");
262        assert_eq!(bound, dir.path().join("daemon.sock"));
263    }
264
265    #[test]
266    fn open_with_audit_uses_the_provided_audit_store_verbatim() {
267        // The contract: open_with_audit doesn't open a SQLite file — it
268        // accepts whatever AuditStore the caller supplies.  We supply a
269        // SqliteStore pointing at a path that doesn't exist as a database
270        // and assert that open_with_audit returns Ok regardless (no I/O
271        // happens during construction; the caller is responsible).
272        let dir = tempdir().unwrap();
273        let cfg = DaemonConfig::new(unix_target(&dir), dir.path().join("audit.sqlite"));
274
275        // A real SqliteStore over a fresh temp file — proves the
276        // injected store is used as-is, not replaced by a re-opened one.
277        let store_dir = tempdir().unwrap();
278        let store_path = store_dir.path().join("custom-audit.sqlite");
279        let inner = TransactionStore::open(&store_path).unwrap();
280        let custom_audit: Arc<dyn AuditStore> = Arc::new(SqliteStore::new(inner));
281
282        let state = DaemonState::open_with_audit(
283            cfg.clone(),
284            PolicyTable::empty(),
285            None,
286            custom_audit,
287            &crate::audit_chain::AuditKey::from_bytes(vec![0x42; 32]),
288        );
289
290        assert_eq!(state.config, cfg);
291        assert!(state.forwarder.is_none());
292        assert_eq!(state.policy.override_count(), 0);
293        // The SqliteStore is opaque from outside the module, but the
294        // fact that we constructed it pointing at `store_path` (different
295        // from `cfg.database_path`) and the test compiled and ran is the
296        // proof that `open_with_audit` doesn't open `cfg.database_path`
297        // a second time.
298    }
299}