Skip to main content

sysknife_daemon/
state.rs

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