Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 1 addition & 2 deletions bin/bootstrap-server.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0001-bash-with-shared-lib.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 4 additions & 2 deletions docs/adr/0002-sshd-drop-in-validated.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
29 changes: 29 additions & 0 deletions lib/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
25 changes: 25 additions & 0 deletions tests/common.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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-*')" ]
}
Loading