sysknife_daemon/actions/ssh.rs
1use super::{command_mechanism, ActionMechanism, ActionSpec};
2use sysknife_types::RiskLevel;
3
4pub fn specs() -> Vec<ActionSpec> {
5 vec![
6 get_authorized_keys("alice"),
7 add_authorized_key("alice", "ssh-ed25519 AAAA..."),
8 remove_authorized_key("alice", "ssh-ed25519 AAAA..."),
9 set_sshd_option("PermitRootLogin", "prohibit-password"),
10 ]
11}
12
13/// Installed path of the privileged sshd-option helper script.
14/// See `packaging/sysknife-sshd-option-edit` and the matching NOPASSWD grant in
15/// `packaging/sysknife-sudoers`.
16const SSHD_OPTION_HELPER: &str = "/usr/lib/sysknife/sshd-option-edit";
17
18/// Set an allowlisted sshd option via a drop-in under
19/// `/etc/ssh/sshd_config.d/`, validated with `sshd -t` and applied by reloading
20/// the ssh service.
21///
22/// Risk: High. A misconfigured sshd can lock out remote access; the helper
23/// gates every change on `sshd -t` and rolls back on failure. `option` and
24/// `value` are checked against a fixed allowlist by both the daemon and the
25/// helper — this is deliberately NOT an arbitrary `sshd_config` editor.
26pub fn set_sshd_option(option: &str, value: &str) -> ActionSpec {
27 ActionSpec {
28 action_name: "SetSshdOption",
29 mechanism: command_mechanism(
30 "sudo",
31 [SSHD_OPTION_HELPER, "--option", option, "--value", value],
32 ),
33 risk_level: RiskLevel::High,
34 reboot_required: false,
35 rollback_available: false,
36 }
37}
38
39pub fn get_authorized_keys(username: &str) -> ActionSpec {
40 ActionSpec {
41 action_name: "GetAuthorizedKeys",
42 mechanism: command_mechanism("cat", [&format!("/home/{username}/.ssh/authorized_keys")]),
43 risk_level: RiskLevel::Low,
44 reboot_required: false,
45 rollback_available: false,
46 }
47}
48
49/// Shell body for `AddAuthorizedKey`.
50///
51/// The key and path arrive as positional arguments (`$1`, `$2`) rather than
52/// being interpolated into the script text, so no value the caller supplies is
53/// ever parsed as shell syntax. `printf '%s\n'` is used instead of `echo`
54/// because `echo` mangles values beginning with `-` and interprets backslash
55/// escapes on some shells.
56const ADD_KEY_SCRIPT: &str =
57 "key=$1; path=$2; grep -Fxq -- \"$key\" \"$path\" 2>/dev/null || printf '%s\\n' \"$key\" >> \"$path\"";
58
59pub fn add_authorized_key(username: &str, public_key: &str) -> ActionSpec {
60 let keys_path = format!("/home/{username}/.ssh/authorized_keys");
61 // sudo is required because the daemon runs as the sysknife system user, which has
62 // no write permission to user home directories (files are 600 owned by the target user).
63 ActionSpec {
64 action_name: "AddAuthorizedKey",
65 mechanism: ActionMechanism::Command {
66 program: "sudo",
67 args: vec![
68 "sh".to_string(),
69 "-c".to_string(),
70 ADD_KEY_SCRIPT.to_string(),
71 "sh".to_string(),
72 public_key.to_string(),
73 keys_path,
74 ],
75 },
76 risk_level: RiskLevel::High,
77 reboot_required: false,
78 rollback_available: false,
79 }
80}
81
82/// Shell body for `RemoveAuthorizedKey`.
83///
84/// **The key must never reach a regex.** This action previously deleted the
85/// line with `sed -i '\|^KEY$|d'`, which made the caller-supplied key a *basic
86/// regular expression*: a value like `ssh-ed25519 .*` passes every check in
87/// `validated_public_key` (allowed prefix, printable ASCII, no shell
88/// metacharacters) and then matched — and deleted — every ed25519 key in the
89/// file. `sed` exits 0, so the job was recorded `Succeeded` and the signed
90/// audit summary read as a routine single-key removal: the executed effect
91/// silently diverged from the approved preview on a lockout-capable action.
92///
93/// Blocklisting regex metacharacters is NOT a viable fix: `.` is legal inside
94/// a key comment (`alice@example.com`), so rejecting it would refuse valid
95/// keys. The fix is structural — `grep -Fxv` compares fixed whole lines, so no
96/// metacharacter can widen the match, and the key travels as `$1` rather than
97/// being interpolated into the script.
98///
99/// The result is streamed to a temp file and copied back with `cat >` rather
100/// than moved: that preserves the original inode, owner, and mode, which
101/// matters because `authorized_keys` must stay owned by the target user.
102/// `grep` exits 1 when it selects no lines (the file is now empty) — that is a
103/// success here, so only an exit status above 1 is treated as an error.
104const REMOVE_KEY_SCRIPT: &str = "key=$1; path=$2; \
105tmp=$(mktemp) || exit 1; \
106grep -Fxv -- \"$key\" \"$path\" > \"$tmp\"; rc=$?; \
107if [ $rc -gt 1 ]; then rm -f \"$tmp\"; exit $rc; fi; \
108cat \"$tmp\" > \"$path\"; rm -f \"$tmp\"";
109
110pub fn remove_authorized_key(username: &str, public_key: &str) -> ActionSpec {
111 let keys_path = format!("/home/{username}/.ssh/authorized_keys");
112 // sudo is required for the same reason as add_authorized_key.
113 ActionSpec {
114 action_name: "RemoveAuthorizedKey",
115 mechanism: ActionMechanism::Command {
116 program: "sudo",
117 args: vec![
118 "sh".to_string(),
119 "-c".to_string(),
120 REMOVE_KEY_SCRIPT.to_string(),
121 "sh".to_string(),
122 public_key.to_string(),
123 keys_path,
124 ],
125 },
126 // Revoking an authorized key is access-control + lockout-capable (remove
127 // the wrong/only key and you lose SSH access) and cannot be rolled back
128 // → High, symmetric with AddAuthorizedKey.
129 risk_level: RiskLevel::High,
130 reboot_required: false,
131 rollback_available: false,
132 }
133}