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}