Skip to main content

sysknife_daemon/actions/
journald.rs

1//! journald query + maintenance actions.
2//!
3//! `GetJournalLog` is a read-only, filtered read of the systemd journal in
4//! structured JSON (`journalctl --output=json`); `VacuumJournal` reclaims disk
5//! by trimming old journal files (`journalctl --vacuum-size` / `--vacuum-time`).
6//!
7//! Both run the `journalctl` binary directly (no `sudo`): the daemon runs as
8//! root, so it can already read every journal and write the journal files.
9//! Every filter is passed in **attached** `--flag=value` form so a value that
10//! begins with `-` can never be reparsed as a separate option (option
11//! injection); there is no shell involved, so spaces/regex metacharacters in a
12//! value are inert.
13
14use super::{command_mechanism, ActionSpec};
15use sysknife_types::RiskLevel;
16
17pub fn specs() -> Vec<ActionSpec> {
18    vec![
19        get_journal_log(&JournalQuery {
20            lines: 100,
21            unit: Some("ssh.service"),
22            priority: Some("err"),
23            boot: true,
24            ..JournalQuery::default()
25        }),
26        vacuum_journal_by_size(500),
27    ]
28}
29
30/// A validated journal query. All string fields are assumed to have passed the
31/// executor's validators before reaching here.
32#[derive(Default)]
33pub struct JournalQuery<'a> {
34    /// Number of most-recent lines to return (`--lines=`). Always set.
35    pub lines: u32,
36    /// Restrict to a single systemd unit (`--unit=`).
37    pub unit: Option<&'a str>,
38    /// Restrict by priority level or range (`--priority=`), e.g. `err`, `3`, `0..3`.
39    pub priority: Option<&'a str>,
40    /// Restrict to the current boot (`--boot`).
41    pub boot: bool,
42    /// Kernel ring-buffer messages only (`--dmesg`).
43    pub kernel: bool,
44    /// Entries at or after this time (`--since=`).
45    pub since: Option<&'a str>,
46    /// Entries at or before this time (`--until=`).
47    pub until: Option<&'a str>,
48    /// Regex filter on the MESSAGE field (`--grep=`).
49    pub grep: Option<&'a str>,
50}
51
52/// Read filtered journal entries as JSON (`journalctl --output=json …`).
53///
54/// Read-only. JSON gives the caller structured fields (`__REALTIME_TIMESTAMP`,
55/// `MESSAGE`, `_TRANSPORT`, `PRIORITY`, …) rather than a preformatted line, so a
56/// model can reason over the entries.
57pub fn get_journal_log(q: &JournalQuery) -> ActionSpec {
58    let mut args = vec![
59        "--output=json".to_string(),
60        "--no-pager".to_string(),
61        format!("--lines={}", q.lines),
62    ];
63    if let Some(unit) = q.unit {
64        args.push(format!("--unit={unit}"));
65    }
66    if let Some(priority) = q.priority {
67        args.push(format!("--priority={priority}"));
68    }
69    if q.boot {
70        args.push("--boot".to_string());
71    }
72    if q.kernel {
73        args.push("--dmesg".to_string());
74    }
75    if let Some(since) = q.since {
76        args.push(format!("--since={since}"));
77    }
78    if let Some(until) = q.until {
79        args.push(format!("--until={until}"));
80    }
81    if let Some(grep) = q.grep {
82        args.push(format!("--grep={grep}"));
83    }
84    ActionSpec {
85        action_name: "GetJournalLog",
86        mechanism: command_mechanism("journalctl", args),
87        risk_level: RiskLevel::Low,
88        reboot_required: false,
89        rollback_available: false,
90    }
91}
92
93/// Trim the journal to at most `megabytes` MB on disk (`--vacuum-size=<n>M`).
94pub fn vacuum_journal_by_size(megabytes: u32) -> ActionSpec {
95    vacuum_journal(format!("--vacuum-size={megabytes}M"))
96}
97
98/// Trim journal files older than `days` days (`--vacuum-time=<n>d`).
99pub fn vacuum_journal_by_time(days: u32) -> ActionSpec {
100    vacuum_journal(format!("--vacuum-time={days}d"))
101}
102
103fn vacuum_journal(vacuum_flag: String) -> ActionSpec {
104    ActionSpec {
105        action_name: "VacuumJournal",
106        mechanism: command_mechanism("journalctl", [vacuum_flag]),
107        risk_level: RiskLevel::High,
108        reboot_required: false,
109        rollback_available: false,
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::actions::ActionMechanism;
117
118    fn args_of(spec: &ActionSpec) -> (&'static str, Vec<String>) {
119        match &spec.mechanism {
120            ActionMechanism::Command { program, args } => (program, args.clone()),
121            other => panic!("expected Command, got {other:?}"),
122        }
123    }
124
125    #[test]
126    fn get_journal_log_builds_attached_filters() {
127        let spec = get_journal_log(&JournalQuery {
128            lines: 50,
129            unit: Some("nginx.service"),
130            priority: Some("err"),
131            boot: true,
132            kernel: false,
133            since: Some("2026-07-22 10:00:00"),
134            until: None,
135            grep: Some("timeout"),
136        });
137        let (program, args) = args_of(&spec);
138        assert_eq!(program, "journalctl");
139        assert_eq!(args[0], "--output=json");
140        assert!(args.contains(&"--lines=50".to_string()));
141        assert!(args.contains(&"--unit=nginx.service".to_string()));
142        assert!(args.contains(&"--priority=err".to_string()));
143        assert!(args.contains(&"--boot".to_string()));
144        assert!(args.contains(&"--since=2026-07-22 10:00:00".to_string()));
145        assert!(args.contains(&"--grep=timeout".to_string()));
146        // omitted filters must not appear
147        assert!(!args.iter().any(|a| a.starts_with("--until=")));
148        assert!(!args.iter().any(|a| a == "--dmesg"));
149        assert_eq!(spec.risk_level, RiskLevel::Low);
150    }
151
152    #[test]
153    fn kernel_query_uses_dmesg() {
154        let spec = get_journal_log(&JournalQuery {
155            lines: 10,
156            kernel: true,
157            ..JournalQuery::default()
158        });
159        let (_, args) = args_of(&spec);
160        assert!(args.contains(&"--dmesg".to_string()));
161    }
162
163    #[test]
164    fn vacuum_by_size_and_time() {
165        let (_, size_args) = args_of(&vacuum_journal_by_size(200));
166        assert_eq!(size_args, vec!["--vacuum-size=200M"]);
167        let (_, time_args) = args_of(&vacuum_journal_by_time(7));
168        assert_eq!(time_args, vec!["--vacuum-time=7d"]);
169        assert_eq!(vacuum_journal_by_size(1).risk_level, RiskLevel::High);
170    }
171}