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