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 pub audit: Arc<dyn AuditStore>,
33 pub policy: PolicyTable,
34 pub host_distro: Option<sysknife_core::distro::DistroId>,
37 pub forwarder: Option<AuditForwarder>,
41 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 pub fn open(config: DaemonConfig) -> Result<Self, DaemonStateError> {
83 Self::open_with_policy(config, PolicyTable::empty())
84 }
85
86 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 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 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#[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 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 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 let dir = tempdir().unwrap();
236 let cfg = DaemonConfig::new(unix_target(&dir), dir.path().join("audit.sqlite"));
237
238 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 }
257}