max / makenotwork
7 files changed,
+312 insertions,
-54 deletions
| @@ -54,6 +54,12 @@ | |||
| 54 | 54 | ENV_FILE="${ENV_FILE:-$ETC_DIR/makenotwork.env}" | |
| 55 | 55 | STATE_DIR="${STATE_DIR:-/var/lib/mnw}" | |
| 56 | 56 | GIT_REPOS_PATH="${GIT_REPOS_PATH:-$STATE_DIR/git}" | |
| 57 | + | # The git user's home, where `mnw-admin rebuild-keys` writes | |
| 58 | + | # `.ssh/authorized_keys`. The server reads it from `GIT_HOME` independently of | |
| 59 | + | # `GIT_REPOS_PATH`, so it gets its own ReadWritePaths treatment below: a node | |
| 60 | + | # where the two differ and one falls outside STATE_DIR would otherwise get an | |
| 61 | + | # unwritable authorized_keys with no warning. | |
| 62 | + | GIT_HOME="${GIT_HOME:-$GIT_REPOS_PATH}" | |
| 57 | 63 | BIN_NAME="${BIN_NAME:-makenotwork}" | |
| 58 | 64 | SERVICE_NAME="${SERVICE_NAME:-makenotwork.service}" | |
| 59 | 65 | SERVICE_USER="${SERVICE_USER:-deploy}" | |
| @@ -181,10 +187,18 @@ | |||
| 181 | 187 | # above. Emit a second path only when the operator has put them elsewhere, | |
| 182 | 188 | # so a sandbox that lists only STATE_DIR cannot silently break repository | |
| 183 | 189 | # creation. | |
| 184 | - | case "$GIT_REPOS_PATH/" in | |
| 185 | - | "$STATE_DIR"/*) ;; | |
| 186 | - | *) printf '\nReadWritePaths=%s' "$GIT_REPOS_PATH" ;; | |
| 187 | - | esac | |
| 190 | + | emitted="" | |
| 191 | + | # GIT_HOME defaults to GIT_REPOS_PATH, so dedupe rather than emit twice. | |
| 192 | + | for extra in "$GIT_REPOS_PATH" "$GIT_HOME"; do | |
| 193 | + | case "$extra/" in | |
| 194 | + | "$STATE_DIR"/*) continue ;; | |
| 195 | + | esac | |
| 196 | + | case " $emitted " in | |
| 197 | + | *" $extra "*) continue ;; | |
| 198 | + | esac | |
| 199 | + | emitted="$emitted $extra" | |
| 200 | + | printf '\nReadWritePaths=%s' "$extra" | |
| 201 | + | done | |
| 188 | 202 | ) | |
| 189 | 203 | Restart=on-failure | |
| 190 | 204 | RestartSec=30 | |
| @@ -196,7 +210,11 @@ | |||
| 196 | 210 | # adds it to ReadWritePaths. The path is mirrored in | |
| 197 | 211 | # \`makenotwork::constants::SCAN_SPOOL_DIR\`, so it is /var/lib/makenotwork | |
| 198 | 212 | # rather than \$STATE_DIR and the two names are not interchangeable. | |
| 199 | - | StateDirectory=makenotwork/scan-spool | |
| 213 | + | # The second entry is the drop-box for authorized_keys rebuild requests; see the | |
| 214 | + | # .path unit below. Named explicitly rather than relying on the parent of the | |
| 215 | + | # spool being writable. Both paths are mirrored in | |
| 216 | + | # \`makenotwork::constants\` (\`SCAN_SPOOL_DIR\`, \`KEYS_REBUILD_MARKER\`). | |
| 217 | + | StateDirectory=makenotwork/scan-spool makenotwork/keys | |
| 200 | 218 | StateDirectoryMode=0700 | |
| 201 | 219 | # The ceilings server/docs/troubleshooting.md documents. Both are measured | |
| 202 | 220 | # rather than chosen: prod's own cgroup reported 392M anonymous (unreclaimable) | |
| @@ -243,8 +261,57 @@ | |||
| 243 | 261 | [Install] | |
| 244 | 262 | WantedBy=multi-user.target | |
| 245 | 263 | EOF | |
| 264 | + | ||
| 265 | + | # The authorized_keys rebuild, out of the service's process tree. | |
| 266 | + | # | |
| 267 | + | # sshd's StrictModes refuses an authorized_keys whose file or .ssh directory is | |
| 268 | + | # group-writable, so the service user cannot be given write access to it, and | |
| 269 | + | # the sandbox above implies NoNewPrivileges (PrivateDevices, ProtectClock, | |
| 270 | + | # MemoryDenyWriteExecute, RestrictNamespaces, RestrictSUIDSGID, LockPersonality | |
| 271 | + | # and SystemCallFilter each imply it, and NoNewPrivileges=no does not undo the | |
| 272 | + | # implication), so sudo cannot raise privilege from inside the unit either. | |
| 273 | + | # Measured 2026-09-01: sudo under any one of those options exits 1 with "the | |
| 274 | + | # no new privileges flag is set". The service therefore writes a marker into | |
| 275 | + | # its StateDirectory and this pair does the write as root. | |
| 276 | + | # | |
| 277 | + | # The marker path is mirrored in `makenotwork::constants::KEYS_REBUILD_MARKER` | |
| 278 | + | # and is fixed at /var/lib/makenotwork, not $STATE_DIR, because systemd derives | |
| 279 | + | # it from StateDirectory. | |
| 280 | + | KEYS_MARKER="/var/lib/makenotwork/keys/rebuild" | |
| 281 | + | cat > /etc/systemd/system/makenotwork-rebuild-keys.path <<EOF | |
| 282 | + | [Unit] | |
| 283 | + | Description=Watch for makenotwork authorized_keys rebuild requests | |
| 284 | + | ||
| 285 | + | [Path] | |
| 286 | + | PathExists=$KEYS_MARKER | |
| 287 | + | PathModified=$KEYS_MARKER | |
| 288 | + | Unit=makenotwork-rebuild-keys.service | |
| 289 | + | ||
| 290 | + | [Install] | |
| 291 | + | WantedBy=multi-user.target | |
| 292 | + | EOF | |
| 293 | + | ||
| 294 | + | # Deletes the marker before rebuilding, so a rebuild that outlives its request | |
| 295 | + | # cannot re-trigger itself. A failed rebuild loses its marker and shows up as a | |
| 296 | + | # failed unit rather than a retry loop; the server's own warn line covers the | |
| 297 | + | # case where the request never lands. | |
| 298 | + | cat > /etc/systemd/system/makenotwork-rebuild-keys.service <<EOF | |
| 299 | + | [Unit] | |
| 300 | + | Description=Rebuild the git user's authorized_keys from the database | |
| 301 | + | ||
| 302 | + | [Service] | |
| 303 | + | Type=oneshot | |
| 304 | + | EnvironmentFile=$ENV_FILE | |
| 305 | + | ExecStartPre=-/bin/rm -f $KEYS_MARKER | |
| 306 | + | ExecStart=$DEPLOY_ROOT/current/mnw-admin rebuild-keys | |
| 307 | + | StandardOutput=journal | |
| 308 | + | StandardError=journal | |
| 309 | + | SyslogIdentifier=makenotwork-rebuild-keys | |
| 310 | + | EOF | |
| 311 | + | ||
| 246 | 312 | systemctl daemon-reload | |
| 247 | 313 | systemctl enable "$SERVICE_NAME" >/dev/null 2>&1 || true | |
| 314 | + | systemctl enable --now makenotwork-rebuild-keys.path >/dev/null 2>&1 || true | |
| 248 | 315 | ||
| 249 | 316 | if [[ "$ENABLE_FIREWALL" == "1" ]]; then | |
| 250 | 317 | log "8/8 firewall (UFW: 22/80/443 in, all else deny)" |
| @@ -193,9 +193,33 @@ | |||
| 193 | 193 | while `systemctl show` reports the 524288 hard limit. | |
| 194 | 194 | ||
| 195 | 195 | Under the sandbox the only writable paths are `$STATE_DIR` (`/var/lib/mnw`, | |
| 196 | - | covering backups and the bare git repositories), the `StateDirectory` scan spool | |
| 197 | - | at `/var/lib/makenotwork/scan-spool`, and the unit's private `/tmp`. A permission | |
| 198 | - | error writing anywhere else is the sandbox, not a broken chown. `GIT_REPOS_PATH` | |
| 199 | - | in the env file has to stay inside `$STATE_DIR` or be listed as its own | |
| 200 | - | `ReadWritePaths`; the binary's own default is `/opt/git`, which the sandbox makes | |
| 201 | - | read-only. | |
| 196 | + | covering backups and the bare git repositories), the two `StateDirectory` | |
| 197 | + | entries (`/var/lib/makenotwork/scan-spool` and `/var/lib/makenotwork/keys`), and | |
| 198 | + | the unit's private `/tmp`. A permission error writing anywhere else is the | |
| 199 | + | sandbox, not a broken chown. `GIT_REPOS_PATH` and `GIT_HOME` in the env file | |
| 200 | + | each have to stay inside `$STATE_DIR` or be listed as their own | |
| 201 | + | `ReadWritePaths`; bootstrap emits a line for either one that falls outside, and | |
| 202 | + | the binary's own defaults are both `/opt/git`, which the sandbox makes | |
| 203 | + | read-only. `tests/unit_paths_seal.rs` holds the constants and the script to the | |
| 204 | + | same paths. | |
| 205 | + | ||
| 206 | + | ### The sandbox implies NoNewPrivileges, so nothing under the unit can sudo | |
| 207 | + | ||
| 208 | + | `PrivateDevices`, `ProtectClock`, `ProtectKernelTunables`, `RestrictNamespaces`, | |
| 209 | + | `RestrictSUIDSGID`, `MemoryDenyWriteExecute`, `LockPersonality` and | |
| 210 | + | `SystemCallFilter` each imply `NoNewPrivileges`, and writing | |
| 211 | + | `NoNewPrivileges=no` does not undo the implication. Any `sudo` in a child of the | |
| 212 | + | service exits 1 with "the no new privileges flag is set". Two consequences: | |
| 213 | + | ||
| 214 | + | - **`authorized_keys` is rebuilt out of process.** Adding or removing an SSH key | |
| 215 | + | writes `/var/lib/makenotwork/keys/rebuild`; `makenotwork-rebuild-keys.path` | |
| 216 | + | starts a root oneshot that deletes the marker and runs `mnw-admin | |
| 217 | + | rebuild-keys`. A key that does not work over SSH is either a marker that never | |
| 218 | + | landed (`journalctl -u makenotwork -p warning` names the path) or a failed | |
| 219 | + | rebuild (`systemctl status makenotwork-rebuild-keys.service`). The old | |
| 220 | + | `/etc/sudoers.d/mnw-git-ssh` rule is dead; `mnw-admin setup-git` removes it. | |
| 221 | + | - **Build hosts need a provisioned identity.** `ProtectHome=yes` presents | |
| 222 | + | `/home` as empty, so ssh finds no `~/.ssh`. `run_ssh_command` and | |
| 223 | + | `run_scp_download` pass `-i /etc/mnw/build_ssh_key` when that file exists and | |
| 224 | + | log a warning naming it when it does not. Pair it with | |
| 225 | + | `/etc/mnw/known_hosts`, which pins the build hosts' keys. |
| @@ -812,6 +812,37 @@ | |||
| 812 | 812 | Ok((s3_key.into_string(), signature)) | |
| 813 | 813 | } | |
| 814 | 814 | ||
| 815 | + | /// Private key for build SSH connections. | |
| 816 | + | /// | |
| 817 | + | /// Passed with an explicit `-i` when present. Without it ssh falls back to the | |
| 818 | + | /// service user's `~/.ssh`, which the unit's `ProtectHome=yes` presents as | |
| 819 | + | /// empty, so an unprovisioned identity is a build pipeline that authenticates | |
| 820 | + | /// against nothing. Kept beside [`BUILD_SSH_KNOWN_HOSTS`] under `/etc/mnw`, | |
| 821 | + | /// which the sandbox leaves readable. | |
| 822 | + | const BUILD_SSH_IDENTITY: &str = "/etc/mnw/build_ssh_key"; | |
| 823 | + | ||
| 824 | + | /// SSH identity options. Returned owned so callers can push them straight into | |
| 825 | + | /// an argv vector. Shared by [`run_ssh_command`] and [`run_scp_download`] so | |
| 826 | + | /// the two can't drift. | |
| 827 | + | fn ssh_identity_args() -> Vec<String> { | |
| 828 | + | if std::path::Path::new(BUILD_SSH_IDENTITY).exists() { | |
| 829 | + | vec![ | |
| 830 | + | "-o".into(), | |
| 831 | + | "IdentitiesOnly=yes".into(), | |
| 832 | + | "-i".into(), | |
| 833 | + | BUILD_SSH_IDENTITY.into(), | |
| 834 | + | ] | |
| 835 | + | } else { | |
| 836 | + | tracing::warn!( | |
| 837 | + | identity = BUILD_SSH_IDENTITY, | |
| 838 | + | "build SSH identity absent; falling back to the service user's ~/.ssh, which \ | |
| 839 | + | ProtectHome=yes hides. Provision {BUILD_SSH_IDENTITY} to authenticate builds \ | |
| 840 | + | under the sandbox", | |
| 841 | + | ); | |
| 842 | + | Vec::new() | |
| 843 | + | } | |
| 844 | + | } | |
| 845 | + | ||
| 815 | 846 | /// Path to a known_hosts file for build SSH connections. | |
| 816 | 847 | /// When present, StrictHostKeyChecking=yes is used (pinned keys). | |
| 817 | 848 | /// When absent, StrictHostKeyChecking=accept-new (trust on first use). | |
| @@ -850,6 +881,7 @@ | |||
| 850 | 881 | "BatchMode=yes".into(), | |
| 851 | 882 | ]; | |
| 852 | 883 | args.extend(ssh_host_key_args()); | |
| 884 | + | args.extend(ssh_identity_args()); | |
| 853 | 885 | args.push(host.to_string()); | |
| 854 | 886 | args.push(command.to_string()); | |
| 855 | 887 | let mut child = tokio::process::Command::new("ssh") | |
| @@ -929,6 +961,7 @@ | |||
| 929 | 961 | "BatchMode=yes".into(), | |
| 930 | 962 | ]; | |
| 931 | 963 | args.extend(ssh_host_key_args()); | |
| 964 | + | args.extend(ssh_identity_args()); | |
| 932 | 965 | args.push(remote); | |
| 933 | 966 | args.push(local_path.to_string()); | |
| 934 | 967 | let scp = tokio::process::Command::new("scp") |
| @@ -293,6 +293,19 @@ | |||
| 293 | 293 | /// resolves to `/var/lib/makenotwork/scan-spool`. Override with | |
| 294 | 294 | /// `MNW_SCAN_SPOOL_DIR` for dev. | |
| 295 | 295 | pub const SCAN_SPOOL_DIR: &str = "/var/lib/makenotwork/scan-spool"; | |
| 296 | + | /// Marker file the server touches to ask for an `authorized_keys` rebuild. | |
| 297 | + | /// | |
| 298 | + | /// The rebuild has to run as root: sshd's `StrictModes` refuses an | |
| 299 | + | /// `authorized_keys` whose file or `.ssh` directory is group-writable, so the | |
| 300 | + | /// service user cannot be given write access to it, and `sudo` cannot raise | |
| 301 | + | /// privilege from inside the unit (`NoNewPrivileges` is implied by | |
| 302 | + | /// `PrivateDevices`, `MemoryDenyWriteExecute`, `RestrictNamespaces` and the | |
| 303 | + | /// rest of the sandbox, and setting it back to `no` does not undo the | |
| 304 | + | /// implication). So the service writes here and a systemd `.path` unit runs | |
| 305 | + | /// `mnw-admin rebuild-keys` out of process. Both names are written by | |
| 306 | + | /// `sando/deploy/bootstrap-node.sh`; changing one means changing the other. | |
| 307 | + | /// Override with `MNW_KEYS_REBUILD_MARKER` for dev. | |
| 308 | + | pub const KEYS_REBUILD_MARKER: &str = "/var/lib/makenotwork/keys/rebuild"; | |
| 296 | 309 | /// Files in `SCAN_SPOOL_DIR` older than this are considered orphaned | |
| 297 | 310 | /// (a panic, OOM, or hard kill left them behind) and reaped on the | |
| 298 | 311 | /// next sweep. RAII drop in `SpoolHandle` covers the live path; this |
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | //! mnw-admin git-auth <key_id> Authenticate an SSH git operation | |
| 21 | 21 | //! mnw-admin install-hooks Install post-receive hooks on all repos | |
| 22 | 22 | //! mnw-admin backfill-git-config Backfill resource limits into bare repos | |
| 23 | - | //! mnw-admin setup-git Set up SSH directories, permissions, sudoers | |
| 23 | + | //! mnw-admin setup-git Set up SSH directories and permissions | |
| 24 | 24 | //! mnw-admin reindex-notes [user] Rebuild the git-notes index from the repos | |
| 25 | 25 | //! | |
| 26 | 26 | //! The SSH management verbs (`repo list`, `key rm`, ...) are not here: they | |
| @@ -139,7 +139,7 @@ | |||
| 139 | 139 | }, | |
| 140 | 140 | /// Backfill resource limits (receive.maxInputSize) into all existing bare repos | |
| 141 | 141 | BackfillGitConfig, | |
| 142 | - | /// Set up SSH infrastructure for git access (directories, permissions, sudoers) | |
| 142 | + | /// Set up SSH infrastructure for git access (directories, permissions) | |
| 143 | 143 | SetupGit, | |
| 144 | 144 | } | |
| 145 | 145 | ||
| @@ -868,31 +868,27 @@ | |||
| 868 | 868 | ); | |
| 869 | 869 | } | |
| 870 | 870 | ||
| 871 | - | // 4. Install sudoers rule | |
| 871 | + | // 4. Retire the sudoers rule, if this node still carries one. | |
| 872 | + | // | |
| 873 | + | // The rule let the service run `sudo -u git mnw-admin rebuild-keys`. The | |
| 874 | + | // sandbox `sando/deploy/bootstrap-node.sh` writes implies NoNewPrivileges, | |
| 875 | + | // so sudo cannot raise privilege from inside the unit at all; the rebuild | |
| 876 | + | // now runs from `makenotwork-rebuild-keys.path`. Leaving the rule in place | |
| 877 | + | // grants a privilege nothing uses. | |
| 872 | 878 | if sudoers_file.exists() { | |
| 879 | + | fs::remove_file(sudoers_file)?; | |
| 873 | 880 | println!( | |
| 874 | - | "[setup] Sudoers rule already exists: {}", | |
| 881 | + | "[setup] Removed obsolete sudoers rule: {}", | |
| 875 | 882 | sudoers_file.display() | |
| 876 | 883 | ); | |
| 884 | + | } | |
| 885 | + | if Path::new("/etc/systemd/system/makenotwork-rebuild-keys.path").exists() { | |
| 886 | + | println!("[setup] authorized_keys rebuild: makenotwork-rebuild-keys.path"); | |
| 877 | 887 | } else { | |
| 878 | - | let rule = format!( | |
| 879 | - | "makenotwork ALL=(git) NOPASSWD: {} rebuild-keys\n", | |
| 880 | - | mnw_admin.display(), | |
| 888 | + | println!( | |
| 889 | + | "[setup] WARNING: makenotwork-rebuild-keys.path not installed. \ | |
| 890 | + | Adding or removing an SSH key will not reach sshd. Re-run bootstrap-node.sh." | |
| 881 | 891 | ); | |
| 882 | - | fs::write(sudoers_file, &rule)?; | |
| 883 | - | fs::set_permissions(sudoers_file, fs::Permissions::from_mode(0o440))?; | |
| 884 | - | println!("[setup] Created sudoers rule: {}", sudoers_file.display()); | |
| 885 | - | ||
| 886 | - | // Verify syntax | |
| 887 | - | let status = std::process::Command::new("visudo") | |
| 888 | - | .args(["-cf", &sudoers_file.to_string_lossy()]) | |
| 889 | - | .status()?; | |
| 890 | - | if !status.success() { | |
| 891 | - | anyhow::bail!( | |
| 892 | - | "sudoers syntax check failed, fix {} manually", | |
| 893 | - | sudoers_file.display() | |
| 894 | - | ); | |
| 895 | - | } | |
| 896 | 892 | } | |
| 897 | 893 | ||
| 898 | 894 | println!("[setup] Git SSH infrastructure ready."); |
| @@ -159,31 +159,49 @@ | |||
| 159 | 159 | Ok(StatusCode::NO_CONTENT.into_response()) | |
| 160 | 160 | } | |
| 161 | 161 | ||
| 162 | - | /// Trigger authorized_keys rebuild via mnw-admin. | |
| 162 | + | /// Ask for an `authorized_keys` rebuild. | |
| 163 | 163 | /// | |
| 164 | - | /// Spawns the rebuild as a background process and does not wait for it. | |
| 165 | - | /// If sudo/mnw-admin is not available (e.g., dev environment), logs a warning. | |
| 164 | + | /// Writes the marker file `makenotwork-rebuild-keys.path` watches; the oneshot | |
| 165 | + | /// it starts runs `mnw-admin rebuild-keys` as root, deletes the marker and | |
| 166 | + | /// writes the file sshd reads. The rebuild cannot happen in this process or in | |
| 167 | + | /// a child of it: sshd's `StrictModes` refuses a group-writable | |
| 168 | + | /// `authorized_keys`, so the service user cannot own the write, and the unit's | |
| 169 | + | /// sandbox implies `NoNewPrivileges`, so `sudo` cannot raise privilege either. | |
| 170 | + | /// | |
| 171 | + | /// A key that is in the database but not yet in `authorized_keys` is a key the | |
| 172 | + | /// user believes works and does not, so every failure here logs at `warn` and | |
| 173 | + | /// names the marker path. Silence in `journalctl -u makenotwork -p warning` | |
| 174 | + | /// means the request was filed, not that the rebuild succeeded: that is | |
| 175 | + | /// `systemctl status makenotwork-rebuild-keys.service`. | |
| 166 | 176 | fn rebuild_authorized_keys() { | |
| 167 | - | std::thread::spawn(|| { | |
| 168 | - | let result = std::process::Command::new("sudo") | |
| 169 | - | .args(["-u", "git", "/opt/mnw/current/mnw-admin", "rebuild-keys"]) | |
| 170 | - | .output(); | |
| 177 | + | let marker = std::env::var("MNW_KEYS_REBUILD_MARKER") | |
| 178 | + | .unwrap_or_else(|_| crate::constants::KEYS_REBUILD_MARKER.to_string()); | |
| 179 | + | let path = std::path::Path::new(&marker); | |
| 171 | 180 | ||
| 172 | - | match result { | |
| 173 | - | Ok(output) if !output.status.success() => { | |
| 174 | - | let stderr = String::from_utf8_lossy(&output.stderr); | |
| 175 | - | tracing::warn!( | |
| 176 | - | status = %output.status, | |
| 177 | - | stderr = %stderr, | |
| 178 | - | "authorized_keys rebuild failed" | |
| 179 | - | ); | |
| 180 | - | } | |
| 181 | - | Err(e) => { | |
| 182 | - | tracing::debug!(error = %e, "authorized_keys rebuild skipped (mnw-admin not available)"); | |
| 183 | - | } | |
| 184 | - | _ => {} | |
| 185 | - | } | |
| 186 | - | }); | |
| 181 | + | if let Some(parent) = path.parent() | |
| 182 | + | && !parent.exists() | |
| 183 | + | { | |
| 184 | + | tracing::warn!( | |
| 185 | + | marker = %marker, | |
| 186 | + | "authorized_keys rebuild not requested: marker directory missing. \ | |
| 187 | + | The node predates makenotwork-rebuild-keys.path; re-run bootstrap-node.sh" | |
| 188 | + | ); | |
| 189 | + | return; | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // Content is a timestamp rather than an empty file so a `.path` unit using | |
| 193 | + | // PathModified sees a change even when the marker outlives one rebuild. | |
| 194 | + | let stamp = std::time::SystemTime::now() | |
| 195 | + | .duration_since(std::time::UNIX_EPOCH) | |
| 196 | + | .map(|d| d.as_secs()) | |
| 197 | + | .unwrap_or_default(); | |
| 198 | + | if let Err(error) = std::fs::write(path, format!("{stamp}\n")) { | |
| 199 | + | tracing::warn!( | |
| 200 | + | error = %error, | |
| 201 | + | marker = %marker, | |
| 202 | + | "authorized_keys rebuild not requested: marker unwritable" | |
| 203 | + | ); | |
| 204 | + | } | |
| 187 | 205 | } | |
| 188 | 206 | ||
| 189 | 207 | /// View type for SSH key display in templates. |
| @@ -1,0 +1,107 @@ | |||
| 1 | + | //! Seal: the paths the systemd unit provisions are the paths the binary writes. | |
| 2 | + | //! | |
| 3 | + | //! `sando/deploy/bootstrap-node.sh` is the only writer of the makenotwork unit, | |
| 4 | + | //! and the unit's sandbox makes every path outside `ReadWritePaths` and the | |
| 5 | + | //! `StateDirectory` set read-only. So a constant in `makenotwork::constants` | |
| 6 | + | //! that names a directory the unit does not provision is not a compile error | |
| 7 | + | //! and not a failing request: it is an EPERM on a live box, at the moment a | |
| 8 | + | //! creator adds an SSH key or the scanner spools an upload. | |
| 9 | + | //! | |
| 10 | + | //! Both directions are checked. A path the code names must appear in the | |
| 11 | + | //! script, and every `StateDirectory` entry the script declares must be named | |
| 12 | + | //! by a constant, so a directory provisioned for nothing is caught too. | |
| 13 | + | //! | |
| 14 | + | //! Run with: cargo test --test unit_paths_seal | |
| 15 | + | ||
| 16 | + | use std::path::PathBuf; | |
| 17 | + | ||
| 18 | + | use makenotwork::constants::{KEYS_REBUILD_MARKER, SCAN_SPOOL_DIR}; | |
| 19 | + | ||
| 20 | + | /// systemd puts every `StateDirectory=` entry under this root. | |
| 21 | + | const STATE_DIRECTORY_ROOT: &str = "/var/lib/"; | |
| 22 | + | ||
| 23 | + | fn bootstrap_script() -> Option<String> { | |
| 24 | + | let path: PathBuf = PathBuf::from(env!("CARGO_MANIFEST_DIR")) | |
| 25 | + | .join("../sando/deploy/bootstrap-node.sh") | |
| 26 | + | .components() | |
| 27 | + | .collect(); | |
| 28 | + | std::fs::read_to_string(path).ok() | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | /// The `StateDirectory=` entries the script declares, as absolute paths. | |
| 32 | + | fn declared_state_directories(script: &str) -> Vec<String> { | |
| 33 | + | script | |
| 34 | + | .lines() | |
| 35 | + | .filter_map(|line| line.trim().strip_prefix("StateDirectory=")) | |
| 36 | + | .flat_map(|value| value.split_whitespace()) | |
| 37 | + | .map(|entry| format!("{STATE_DIRECTORY_ROOT}{entry}")) | |
| 38 | + | .collect() | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | #[test] | |
| 42 | + | fn every_provisioned_path_the_code_names_is_written_by_bootstrap() { | |
| 43 | + | let Some(script) = bootstrap_script() else { | |
| 44 | + | // A checkout of the server crate alone has no sibling sando. Nothing to | |
| 45 | + | // seal against, and failing here would only punish that layout. | |
| 46 | + | return; | |
| 47 | + | }; | |
| 48 | + | ||
| 49 | + | let declared = declared_state_directories(&script); | |
| 50 | + | assert!( | |
| 51 | + | !declared.is_empty(), | |
| 52 | + | "bootstrap-node.sh declares no StateDirectory; the unit template moved" | |
| 53 | + | ); | |
| 54 | + | ||
| 55 | + | for (name, path) in [ | |
| 56 | + | ("SCAN_SPOOL_DIR", SCAN_SPOOL_DIR), | |
| 57 | + | ("KEYS_REBUILD_MARKER", KEYS_REBUILD_MARKER), | |
| 58 | + | ] { | |
| 59 | + | assert!( | |
| 60 | + | declared.iter().any(|dir| path.starts_with(dir)), | |
| 61 | + | "{name} = {path} is under no StateDirectory bootstrap-node.sh declares \ | |
| 62 | + | ({declared:?}). The sandbox makes it read-only, so the first write EPERMs." | |
| 63 | + | ); | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | #[test] | |
| 68 | + | fn the_rebuild_marker_the_unit_watches_is_the_one_the_server_writes() { | |
| 69 | + | let Some(script) = bootstrap_script() else { | |
| 70 | + | return; | |
| 71 | + | }; | |
| 72 | + | ||
| 73 | + | assert!( | |
| 74 | + | script.contains(&format!("KEYS_MARKER=\"{KEYS_REBUILD_MARKER}\"")), | |
| 75 | + | "the .path unit watches a different file than KEYS_REBUILD_MARKER ({KEYS_REBUILD_MARKER}); \ | |
| 76 | + | adding an SSH key would never reach sshd" | |
| 77 | + | ); | |
| 78 | + | assert!( | |
| 79 | + | script.contains("Unit=makenotwork-rebuild-keys.service"), | |
| 80 | + | "the marker is watched by no unit, so nothing rebuilds authorized_keys" | |
| 81 | + | ); | |
| 82 | + | let sudo_rebuild = script | |
| 83 | + | .lines() | |
| 84 | + | .find(|line| line.contains("NOPASSWD") && line.contains("rebuild-keys")); | |
| 85 | + | assert!( | |
| 86 | + | sudo_rebuild.is_none(), | |
| 87 | + | "bootstrap installs a sudoers rule for rebuild-keys ({sudo_rebuild:?}); the unit's \ | |
| 88 | + | sandbox implies NoNewPrivileges, so sudo cannot run from inside it" | |
| 89 | + | ); | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | #[test] | |
| 93 | + | fn every_declared_state_directory_has_a_constant_that_names_it() { | |
| 94 | + | let Some(script) = bootstrap_script() else { | |
| 95 | + | return; | |
| 96 | + | }; | |
| 97 | + | ||
| 98 | + | for dir in declared_state_directories(&script) { | |
| 99 | + | assert!( | |
| 100 | + | [SCAN_SPOOL_DIR, KEYS_REBUILD_MARKER] | |
| 101 | + | .iter() | |
| 102 | + | .any(|path| path.starts_with(&dir)), | |
| 103 | + | "bootstrap-node.sh provisions {dir} and no constant names it. Either the code \ | |
| 104 | + | that used it was deleted, or a constant drifted off the provisioned path." | |
| 105 | + | ); | |
| 106 | + | } | |
| 107 | + | } |