diff --git a/README.md b/README.md index 468260c..7bc9625 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ![ShellCheck](https://img.shields.io/badge/shellcheck-clean-brightgreen) ![License: MIT](https://img.shields.io/badge/license-MIT-green) -Six Bash scripts for running small Linux fleets: bootstrap a fresh VPS, install Docker, take encrypted off-site backups, reclaim disk space, and watch TLS expiry and uptime. Every script is idempotent, validates its arguments, runs under `set -euo pipefail`, supports a `DRY_RUN=1` preview, and is ShellCheck-clean. 21 [bats](https://github.com/bats-core/bats-core) tests run in CI. +Six Bash scripts for running small Linux fleets: bootstrap a fresh VPS, install Docker, take encrypted off-site backups, reclaim disk space, and watch TLS expiry and uptime. Every script is idempotent, validates its arguments, runs under `set -euo pipefail`, supports a `DRY_RUN=1` preview, and is ShellCheck-clean. 24 [bats](https://github.com/bats-core/bats-core) tests run in CI. The trade-off: Bash keeps the scripts dependency-free and readable on any server, but they target Debian and Ubuntu only and are not a replacement for Ansible once you manage more than a handful of hosts. diff --git a/bin/bootstrap-server.sh b/bin/bootstrap-server.sh index 21d7c9c..6589021 100755 --- a/bin/bootstrap-server.sh +++ b/bin/bootstrap-server.sh @@ -67,8 +67,7 @@ AllowUsers $USER_NAME" if [[ "${DRY_RUN:-0}" == "1" ]]; then printf '[dry-run] write %s:\n%s\n' "$SSHD_DROPIN" "$SSHD_CONF" else - printf '%s\n' "$SSHD_CONF" > "$SSHD_DROPIN" - sshd -t || die "sshd config test failed — not reloading" + install_sshd_dropin "$SSHD_DROPIN" "$SSHD_CONF" || die "sshd config test failed, not reloading" systemctl reload ssh 2>/dev/null || systemctl reload sshd fi diff --git a/docs/adr/0001-bash-with-shared-lib.md b/docs/adr/0001-bash-with-shared-lib.md index 3d88f10..5b29db1 100644 --- a/docs/adr/0001-bash-with-shared-lib.md +++ b/docs/adr/0001-bash-with-shared-lib.md @@ -22,7 +22,7 @@ real language. The discipline is enforced by tooling, not by hope. ## Consequences - Every script starts with `set -euo pipefail` and validates its arguments. - `run_cmd` in `common.sh` gives every script the same `DRY_RUN=1` preview. -- ShellCheck runs in CI on `bin/` and `lib/`, and 21 bats tests cover argument +- ShellCheck runs in CI on `bin/` and `lib/`, and 24 bats tests cover argument handling, dry-run output and the checkers against local listeners. - The bats suite cannot test the bootstrap or Docker install against a real VM. - Revisit, and move to Ansible, once there are more than a handful of hosts to keep in sync. diff --git a/docs/adr/0002-sshd-drop-in-validated.md b/docs/adr/0002-sshd-drop-in-validated.md index d54d2a7..8137076 100644 --- a/docs/adr/0002-sshd-drop-in-validated.md +++ b/docs/adr/0002-sshd-drop-in-validated.md @@ -24,8 +24,10 @@ invalid config is never loaded. ## Consequences - Depends on the `Include sshd_config.d/*.conf` line that current Debian and Ubuntu ship. Older releases without it would ignore the drop-in. -- If `sshd -t` fails, the script stops but leaves the drop-in on disk. The next sshd - restart would then fail too. Removing the file on failure is an open improvement. +- If `sshd -t` fails, the previous drop-in is restored, or the new one removed when + there was none, so a bad config never waits on disk for the next sshd restart. + (Resolved 2026-10-17: `install_sshd_dropin` in `lib/common.sh`, covered by bats tests. + The first version of this ADR listed it as an open gap.) - UFW is configured right after the reload and opens the new port with `ufw limit`. If UFW was already active with other rules, there is a short window where the new port is closed. The README asks to keep a second session open for that reason. diff --git a/lib/common.sh b/lib/common.sh index 2327d59..12f8ae2 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -49,3 +49,32 @@ notify() { printf 'url = "https://api.telegram.org/bot%s/sendMessage"\n' "$TG_TOKEN" \ | curl -fsS -m 10 -K - -d chat_id="$TG_CHAT" --data-urlencode text="$1" >/dev/null || warn "notify failed" } + +# install_sshd_dropin PATH CONTENT -> writes an sshd drop-in and validates the full config. +# On a failed `sshd -t` the previous file is restored (or the new one removed), so a bad +# config never stays on disk for the next sshd restart. SSHD_BIN overrides sshd for tests. +install_sshd_dropin() { + local path="$1" content="$2" sshd_bin="${SSHD_BIN:-sshd}" dir backup="" tmp + dir="$(dirname "$path")" + [[ -d "$dir" ]] || die "drop-in directory does not exist: $dir" + if [[ -e "$path" ]]; then + backup="$(mktemp "$dir/.sshd-dropin-backup.XXXXXX")" + cp -p -- "$path" "$backup" + fi + tmp="$(mktemp "$dir/.sshd-dropin-new.XXXXXX")" + printf '%s\n' "$content" > "$tmp" + chmod 644 "$tmp" + mv -f -- "$tmp" "$path" + if "$sshd_bin" -t; then + [[ -n "$backup" ]] && rm -f -- "$backup" + return 0 + fi + if [[ -n "$backup" ]]; then + mv -f -- "$backup" "$path" + err "sshd -t failed; restored previous $path" + else + rm -f -- "$path" + err "sshd -t failed; removed $path" + fi + return 1 +} diff --git a/tests/common.bats b/tests/common.bats index aab50f2..fa40c1d 100644 --- a/tests/common.bats +++ b/tests/common.bats @@ -44,3 +44,28 @@ setup() { run notify "hello" [ "$status" -eq 0 ] } + +@test "install_sshd_dropin keeps the new file when sshd -t passes" { + d="$BATS_TEST_TMPDIR/sshd.d"; mkdir -p "$d" + SSHD_BIN=true run install_sshd_dropin "$d/10-hardening.conf" "Port 2222" + [ "$status" -eq 0 ] + [ "$(cat "$d/10-hardening.conf")" = "Port 2222" ] + [ -z "$(find "$d" -name '.sshd-dropin-*')" ] +} + +@test "install_sshd_dropin removes a new file when sshd -t fails" { + d="$BATS_TEST_TMPDIR/sshd.d"; mkdir -p "$d" + SSHD_BIN=false run install_sshd_dropin "$d/10-hardening.conf" "Port bogus" + [ "$status" -eq 1 ] + [ ! -e "$d/10-hardening.conf" ] + [ -z "$(find "$d" -name '.sshd-dropin-*')" ] +} + +@test "install_sshd_dropin restores the previous file when sshd -t fails" { + d="$BATS_TEST_TMPDIR/sshd.d"; mkdir -p "$d" + printf 'Port 22\n' > "$d/10-hardening.conf" + SSHD_BIN=false run install_sshd_dropin "$d/10-hardening.conf" "Port bogus" + [ "$status" -eq 1 ] + [ "$(cat "$d/10-hardening.conf")" = "Port 22" ] + [ -z "$(find "$d" -name '.sshd-dropin-*')" ] +}