name: Test Matrix on: push: branches: [main] pull_request: workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read jobs: # ------------------------------------------------------------------- # 1. POSIX lint with shellcheck — runs once, NOT in matrix. # ------------------------------------------------------------------- shellcheck: name: ShellCheck (POSIX sh) runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@v4 - name: Run shellcheck run: | sudo apt-get update -qq sudo apt-get install -y shellcheck shellcheck --version # -s sh : POSIX shell mode (catches bashisms) # SC1091 : "not following sourced file" — script is self-contained # SC2317 : "unreachable" — shellcheck doesn't follow trap callbacks # SC2015 : A && B || C info — used intentionally throughout # SC2012 : ls for parsing — we only use ls -ln to extract uid (POSIX-safe) shellcheck -s sh -e SC1091,SC2317,SC2015,SC2012 init.sh # ------------------------------------------------------------------- # 2. Per-distro install test with --delay-restart (no live sshd churn) # ------------------------------------------------------------------- test-distros: name: Install test (delay-restart) — ${{ matrix.container }} runs-on: ubuntu-latest timeout-minutes: 15 strategy: fail-fast: false matrix: container: - 'debian:11' - 'debian:12' - 'ubuntu:22.04' - 'ubuntu:24.04' - 'alpine:latest' - 'almalinux:9' - 'rockylinux:9' - 'quay.io/centos/centos:stream9' include: # CentOS 7 is EOL (2024-06-30) — kept for legacy users but allowed to fail. - container: 'centos:7' allow_failure: true continue-on-error: ${{ matrix.allow_failure == true }} steps: - uses: actions/checkout@v4 - name: Run init.sh in ${{ matrix.container }} run: | echo "🚀 Testing on: ${{ matrix.container }}" chmod +x init.sh docker run --rm --privileged -v "$(pwd)":/mnt:ro -w /mnt "${{ matrix.container }}" /bin/sh -s <<'CONTAINER' set -e # ----------------------------------------------------------------- # CentOS 7 vault patch — only needed because mirror.centos.org is down # ----------------------------------------------------------------- if [ -f /etc/redhat-release ] && grep -q "release 7" /etc/redhat-release; then echo "💀 CentOS 7 (EOL) — repointing repos to vault.centos.org" sed -i "s/^mirrorlist/#mirrorlist/g" /etc/yum.repos.d/*.repo sed -i "s|^#baseurl=http://mirror.centos.org|baseurl=http://vault.centos.org|g" /etc/yum.repos.d/*.repo sed -i "s|^baseurl=http://mirror.centos.org|baseurl=http://vault.centos.org|g" /etc/yum.repos.d/*.repo yum clean all rm -rf /var/cache/yum fi # ----------------------------------------------------------------- # Dependencies # ----------------------------------------------------------------- echo "📦 Installing dependencies..." if [ -f /etc/debian_version ]; then apt-get update -qq && apt-get install -y -qq curl sudo openssh-server iproute2 ssh-keygen -A mkdir -p /run/sshd elif [ -f /etc/alpine-release ]; then apk add --no-cache curl sudo openssh-server bash ssh-keygen -A elif [ -f /etc/redhat-release ]; then if command -v dnf >/dev/null; then dnf install -y --allowerasing curl sudo openssh-server iproute else yum install -y curl sudo openssh-server iproute fi ssh-keygen -A mkdir -p /run/sshd fi # ----------------------------------------------------------------- # Make /bin/sh point to dash where available so dash-isms surface. # ----------------------------------------------------------------- if [ -x /bin/dash ]; then ln -sf /bin/dash /bin/sh echo "ℹ️ /bin/sh now resolves to: $(readlink -f /bin/sh)" fi # ----------------------------------------------------------------- # Run init.sh # ----------------------------------------------------------------- echo "🚀 Running init.sh..." cp init.sh /tmp/init.sh chmod +x /tmp/init.sh /tmp/init.sh \ --user=_247like \ --port=2222 \ --key-raw="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPQQDejyTCPJO3Jyw5UX9jmojb/zjgcqVuRO28fmpWU5 ci@test" \ --no-update \ --bbr \ --delay-restart \ --no-ip-probe \ --yes \ --strict # ----------------------------------------------------------------- # Assertions # ----------------------------------------------------------------- echo "🔍 Verifying outcome..." grep -q "BEGIN SERVER-INIT MANAGED BLOCK" /etc/ssh/sshd_config \ || { echo "❌ managed block missing"; exit 1; } first_line=$(sed -n '1p' /etc/ssh/sshd_config) test "$first_line" = "# BEGIN SERVER-INIT MANAGED BLOCK" \ || { echo "❌ managed block is not the first line: $first_line"; exit 1; } grep -Eq "^[[:space:]]*Port[[:space:]]+2222([[:space:]]|$)" /etc/ssh/sshd_config \ || { echo "❌ Port 2222 not in config"; exit 1; } id _247like >/dev/null \ || { echo "❌ user _247like not created"; exit 1; } test -s /home/_247like/.ssh/authorized_keys \ || { echo "❌ authorized_keys missing or empty"; exit 1; } grep -q "ssh-ed25519 AAAA" /home/_247like/.ssh/authorized_keys \ || { echo "❌ deployed key content missing"; exit 1; } test -f /etc/sudoers.d/server-init-_247like \ || { echo "❌ stable sudoers file missing"; exit 1; } test -f /etc/profile.d/z99-ssh-init-banner.sh \ || { echo "❌ login banner missing"; exit 1; } # sshd -t against the rewritten config must pass. sshd -t -f /etc/ssh/sshd_config \ || { echo "❌ sshd -t rejected the new config"; exit 1; } # Audit log must exist and record the DONE phase. test -f /var/log/server-init-audit.log \ || { echo "❌ audit log missing"; exit 1; } grep -q "ACTION: DONE" /var/log/server-init-audit.log \ || { echo "❌ audit log missing DONE entry"; exit 1; } # [v4.7.8] Verify the new v4.7.4 audit entries are emitted on the # happy path so silent regressions are caught. grep -q "ACTION: USER_CREATED" /var/log/server-init-audit.log \ || { echo "❌ audit log missing USER_CREATED entry"; exit 1; } grep -q "ACTION: KEYS_DEPLOYED" /var/log/server-init-audit.log \ || { echo "❌ audit log missing KEYS_DEPLOYED entry"; exit 1; } grep -q "ACTION: PASSWORD_CLEARED" /var/log/server-init-audit.log \ || { echo "❌ audit log missing PASSWORD_CLEARED entry"; exit 1; } grep -q "ACTION: MANAGED_BLOCK_INSTALLED" /var/log/server-init-audit.log \ || { echo "❌ audit log missing MANAGED_BLOCK_INSTALLED entry"; exit 1; } # Health report must exist. test -f /var/log/server-init-health.log \ || { echo "❌ health report missing"; exit 1; } # Backup directory must have a verifiable restore.sh. backup_dir=$(ls -dt /var/backups/ssh-config/*/ 2>/dev/null | head -n 1) test -n "$backup_dir" \ || { echo "❌ backup directory missing"; exit 1; } test -x "$backup_dir/restore.sh" \ || { echo "❌ restore.sh missing or not executable"; exit 1; } test -f "$backup_dir/checksums.sha256" \ || { echo "❌ checksums.sha256 missing"; exit 1; } ( cd "$backup_dir" && sha256sum -c checksums.sha256 ) \ || { echo "❌ backup checksums do not verify"; exit 1; } # Re-run idempotency: second invocation must not duplicate the managed block. echo "🔁 Re-running to confirm idempotency..." /tmp/init.sh \ --user=_247like \ --port=2222 \ --key-raw="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPQQDejyTCPJO3Jyw5UX9jmojb/zjgcqVuRO28fmpWU5 ci@test" \ --no-update --no-bbr --delay-restart --no-ip-probe --yes --strict block_count=$(grep -c "BEGIN SERVER-INIT MANAGED BLOCK" /etc/ssh/sshd_config || true) test "$block_count" = "1" \ || { echo "❌ managed block duplicated on re-run (count=$block_count)"; exit 1; } sudoers_count=$(ls /etc/sudoers.d/server-init-_247like* 2>/dev/null | wc -l) test "$sudoers_count" = "1" \ || { echo "❌ sudoers files multiplied on re-run (count=$sudoers_count)"; exit 1; } for lock in /run/server-init/script.lock /var/lib/server-init/script.lock; do test ! -d "$lock" \ || { echo "❌ script lock left behind after successful run: $lock"; exit 1; } done echo "✅ ALL ASSERTIONS PASSED" CONTAINER # ------------------------------------------------------------------- # 3. Live restart + listen + connection test on one representative distro # Uses the v4.7.1 direct-spawn fallback in restart_sshd, so this works # in a plain (non-systemd) container. # ------------------------------------------------------------------- test-live-restart: name: Live restart & connection (ubuntu:22.04) runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v4 - name: Live test run: | chmod +x init.sh docker run --rm --privileged -v "$(pwd)":/mnt:ro -w /mnt ubuntu:22.04 /bin/sh -s <<'CONTAINER' set -e apt-get update -qq apt-get install -y -qq curl sudo openssh-server iproute2 netcat-openbsd procps ssh-keygen -A mkdir -p /run/sshd # Pre-start sshd manually (no systemd in this container). /usr/sbin/sshd # Wait for sshd on 22. for i in 1 2 3 4 5; do ss -ltn | grep -q ":22 " && break sleep 1 done cp init.sh /tmp/init.sh && chmod +x /tmp/init.sh # Live invocation — no --delay-restart. The v4.7.1 direct-spawn # fallback in restart_sshd kicks in because systemd is absent. /tmp/init.sh \ --user=_247like \ --port=2222 \ --key-raw="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPQQDejyTCPJO3Jyw5UX9jmojb/zjgcqVuRO28fmpWU5 ci@test" \ --no-update --no-bbr --no-ip-probe --yes --strict # After live restart, sshd must be listening on 2222. # Give the freshly-spawned daemon a moment to bind. for i in 1 2 3 4 5; do ss -ltn | grep -q ":2222 " && break sleep 1 done ss -ltn | grep -q ":2222 " || { echo "❌ sshd not listening on 2222"; ss -ltn; exit 1; } # SSH banner on the new port. banner=$(printf "SSH-2.0-TEST\r\n" | nc -w 3 127.0.0.1 2222 | head -1 || true) echo "Banner: $banner" echo "$banner" | grep -q "SSH-2.0" || { echo "❌ no SSH banner on 2222"; exit 1; } for lock in /run/server-init/script.lock /var/lib/server-init/script.lock; do test ! -d "$lock" \ || { echo "❌ script lock left behind after live restart: $lock"; exit 1; } done echo "✅ Live restart + listen + banner OK" CONTAINER # ------------------------------------------------------------------- # 4. Pre-existing-user safety test — ensure passwd -d does NOT clobber an # existing admin password (the v4.7.0 → v4.7.1 R1 regression test). # ------------------------------------------------------------------- test-existing-user-safe: name: Pre-existing user password preserved runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@v4 - name: Verify pre-existing user is left alone run: | chmod +x init.sh docker run --rm --privileged -v "$(pwd)":/mnt:ro -w /mnt ubuntu:22.04 /bin/sh -s <<'CONTAINER' set -e apt-get update -qq apt-get install -y -qq curl sudo openssh-server iproute2 ssh-keygen -A mkdir -p /run/sshd if [ -x /bin/dash ]; then ln -sf /bin/dash /bin/sh; fi # Create an existing admin with a known password hash. useradd -m -s /bin/bash existing_admin echo "existing_admin:hunter2_super_secret" | chpasswd # Capture the shadow line BEFORE the script runs. shadow_before=$(getent shadow existing_admin | cut -d: -f2) echo "shadow_before: $shadow_before" test -n "$shadow_before" || { echo "no shadow entry"; exit 1; } test "$shadow_before" != "" || { echo "empty shadow"; exit 1; } test "$shadow_before" != "!" || { echo "locked unexpectedly"; exit 1; } cp init.sh /tmp/init.sh && chmod +x /tmp/init.sh /tmp/init.sh \ --user=existing_admin \ --port=2222 \ --key-raw="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPQQDejyTCPJO3Jyw5UX9jmojb/zjgcqVuRO28fmpWU5 ci@test" \ --no-update --no-bbr --delay-restart --no-ip-probe --yes --strict shadow_after=$(getent shadow existing_admin | cut -d: -f2) echo "shadow_after: $shadow_after" test "$shadow_before" = "$shadow_after" || { echo "❌ REGRESSION: existing_admin password was changed!" echo " before: $shadow_before" echo " after: $shadow_after" exit 1 } # [v4.7.3] Positive assertion: passwd -S should report `P` (password # set) — not `NP` (no password) or `L` (locked). ps_out=$(passwd -S existing_admin 2>/dev/null || true) echo "passwd -S: $ps_out" echo "$ps_out" | awk "{print \$2}" | grep -qE "^P\$" || { echo "❌ REGRESSION: existing_admin lost its password set state (expected P, got: $ps_out)" exit 1 } # [v4.7.2] safe_configure_sudo is only invoked from the # new-user branch of safe_ensure_user. For PRE-EXISTING accounts, # safe_ensure_user returns early and never touches sudoers. The # absence of the sudoers file is the correct, expected behavior. if [ -f /etc/sudoers.d/server-init-existing_admin ]; then echo "❌ REGRESSION: script wrote sudoers for a pre-existing account" exit 1 fi echo "✅ Pre-existing user password preserved and sudoers untouched" CONTAINER # ------------------------------------------------------------------- # 5. Cross-user sudoers cleanup safety — verify the legacy-glob cleanup # cannot delete another user'\''s stable sudoers file when usernames # share a hyphen-prefix (e.g. running script for "admin" must not # remove "/etc/sudoers.d/server-init-admin-bot"). # ------------------------------------------------------------------- test-cross-user-sudoers-safe: name: Cross-user sudoers cleanup glob safety runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@v4 - name: Verify glob does not match sibling usernames run: | chmod +x init.sh docker run --rm --privileged -v "$(pwd)":/mnt:ro -w /mnt ubuntu:22.04 /bin/sh -s <<'CONTAINER' set -e apt-get update -qq apt-get install -y -qq curl sudo openssh-server iproute2 ssh-keygen -A mkdir -p /run/sshd # Pre-create a sibling user with a HYPHENATED name that shares a # prefix with the script target. Also create an operator-managed # sudoers file with a non-numeric suffix. The seed content uses a # Defaults-only line (NOT a user/group rule) so it does NOT match # safe_configure_sudo's principal-prefix check `^[[:space:]]*admin # [[:space:]]` — otherwise the script would believe `admin` already # has sudo and would NOT write the new stable file, breaking the # third assertion below. useradd -m admin-bot mkdir -p /etc/sudoers.d echo "admin-bot ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/server-init-admin-bot chmod 440 /etc/sudoers.d/server-init-admin-bot # Operator-managed file: Defaults block, no user/group rule that # could match the "admin" principal regex in safe_configure_sudo. # NOTE: cannot use printf with single-quoted format because the # outer `sh -c '\''...'\''` wrap would terminate at any internal # single quote — use double quotes with escaped inner quotes. printf "%s\n" "Defaults env_keep += \"PROXY_URL\"" > /etc/sudoers.d/server-init-admin-special-policy chmod 440 /etc/sudoers.d/server-init-admin-special-policy cp init.sh /tmp/init.sh && chmod +x /tmp/init.sh # Run for user "admin" — the legacy-cleanup glob would naively # match server-init-admin-* including admin-bot (HIGH bug fixed in # v4.7.2 by requiring digits-only suffix). /tmp/init.sh \ --user=admin \ --port=2222 \ --key-raw="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPQQDejyTCPJO3Jyw5UX9jmojb/zjgcqVuRO28fmpWU5 ci@test" \ --no-update --no-bbr --delay-restart --no-ip-probe --yes --strict # Sibling sudoers MUST survive. test -f /etc/sudoers.d/server-init-admin-bot \ || { echo "❌ REGRESSION: sibling stable sudoers file was deleted"; ls /etc/sudoers.d/; exit 1; } # Operator-managed non-numeric-suffix file MUST survive. test -f /etc/sudoers.d/server-init-admin-special-policy \ || { echo "❌ REGRESSION: operator-managed sudoers file was deleted"; ls /etc/sudoers.d/; exit 1; } # New stable file for admin SHOULD exist. test -f /etc/sudoers.d/server-init-admin \ || { echo "❌ admin stable sudoers missing"; ls /etc/sudoers.d/; exit 1; } echo "✅ Cross-user sudoers glob safety + operator-file safety verified" CONTAINER # ------------------------------------------------------------------- # 6. Legacy v4.6.x timestamped sudoers SHOULD be cleaned on re-run. # ------------------------------------------------------------------- test-legacy-sudoers-cleanup: name: Legacy timestamped sudoers auto-cleanup runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@v4 - name: Verify legacy-pattern file is removed run: | chmod +x init.sh docker run --rm --privileged -v "$(pwd)":/mnt:ro -w /mnt ubuntu:22.04 /bin/sh -s <<'CONTAINER' set -e apt-get update -qq apt-get install -y -qq curl sudo openssh-server iproute2 ssh-keygen -A mkdir -p /run/sshd # Simulate a v4.6.x leftover for user "deploy". mkdir -p /etc/sudoers.d echo "deploy ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/server-init-deploy-20250520123045 chmod 440 /etc/sudoers.d/server-init-deploy-20250520123045 ls -l /etc/sudoers.d/ cp init.sh /tmp/init.sh && chmod +x /tmp/init.sh /tmp/init.sh \ --user=deploy \ --port=2222 \ --key-raw="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPQQDejyTCPJO3Jyw5UX9jmojb/zjgcqVuRO28fmpWU5 ci@test" \ --no-update --no-bbr --delay-restart --no-ip-probe --yes --strict # Legacy file must be gone. test ! -f /etc/sudoers.d/server-init-deploy-20250520123045 \ || { echo "❌ legacy timestamped sudoers was NOT cleaned"; ls -l /etc/sudoers.d/; exit 1; } # Stable file must exist. test -f /etc/sudoers.d/server-init-deploy \ || { echo "❌ stable sudoers missing"; ls -l /etc/sudoers.d/; exit 1; } # Audit log must record the removal. grep -q "ACTION: LEGACY_SUDOERS_REMOVED" /var/log/server-init-audit.log \ || { echo "❌ audit log missing LEGACY_SUDOERS_REMOVED entry"; exit 1; } grep -q "server-init-deploy-20250520123045" /var/log/server-init-audit.log \ || { echo "❌ audit log missing specific path"; exit 1; } echo "✅ Legacy sudoers cleanup verified" CONTAINER # ------------------------------------------------------------------- # 7. CLI flag smoke — --version / --help / --no-ip-probe # ------------------------------------------------------------------- test-cli-flags: name: CLI flag smoke (--version / --help / --no-ip-probe) runs-on: ubuntu-latest timeout-minutes: 3 steps: - uses: actions/checkout@v4 - name: Smoke-test CLI flags run: | chmod +x init.sh docker run --rm -v "$(pwd)":/mnt:ro -w /mnt ubuntu:22.04 /bin/sh -s <<'CONTAINER' set -e # We need root for the script to run past the preflight check. # --version / --help exit BEFORE the root check, so they work # for any user. # --version ver=$(./init.sh --version) echo "version: $ver" echo "$ver" | grep -q "v4.7.8" \ || { echo "❌ --version did not report v4.7.8: $ver"; exit 1; } # --help should list all known flags ./init.sh --help | grep -q -- "--no-ip-probe" \ || { echo "❌ --help missing --no-ip-probe entry"; exit 1; } ./init.sh --help | grep -q -- "--strict" \ || { echo "❌ --help missing --strict entry"; exit 1; } # Short forms ./init.sh -V | grep -q "v4.7.8" \ || { echo "❌ -V failed"; exit 1; } ./init.sh -h | grep -q -- "--help" \ || { echo "❌ -h failed"; exit 1; } check_no_lock() { label="$1" for lock in /run/server-init/script.lock /var/lib/server-init/script.lock; do test ! -d "$lock" \ || { echo "❌ script lock leaked after $label: $lock"; exit 1; } done } check_no_lock "--version/--help" if ./init.sh --user=a --user=b --yes >/tmp/dup-user.out 2>&1; then echo "❌ duplicate --user unexpectedly succeeded" cat /tmp/dup-user.out exit 1 fi check_no_lock "duplicate --user" if ./init.sh --port= --yes >/tmp/empty-port.out 2>&1; then echo "❌ empty --port unexpectedly succeeded" cat /tmp/empty-port.out exit 1 fi check_no_lock "empty --port" if ./init.sh \ --user=deploy \ --port=03306 \ --key-raw="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPQQDejyTCPJO3Jyw5UX9jmojb/zjgcqVuRO28fmpWU5 ci@test" \ --no-update --no-bbr --delay-restart --no-ip-probe --yes --strict \ >/tmp/leading-zero-port.out 2>&1; then echo "❌ leading-zero --port unexpectedly succeeded" cat /tmp/leading-zero-port.out exit 1 fi check_no_lock "leading-zero --port" echo "✅ CLI flag smoke OK" CONTAINER # ------------------------------------------------------------------- # 8. --no-ip-probe actually skips the ipify lookup # ------------------------------------------------------------------- test-no-ip-probe: name: --no-ip-probe skips external IP lookup runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@v4 - name: Verify --no-ip-probe skips ipify call run: | chmod +x init.sh docker run --rm --privileged -v "$(pwd)":/mnt:ro -w /mnt ubuntu:22.04 /bin/sh -s <<'CONTAINER' set -e apt-get update -qq apt-get install -y -qq curl sudo openssh-server iproute2 ssh-keygen -A mkdir -p /run/sshd cp init.sh /tmp/init.sh && chmod +x /tmp/init.sh /tmp/init.sh \ --user=deploy \ --port=2222 \ --key-raw="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPQQDejyTCPJO3Jyw5UX9jmojb/zjgcqVuRO28fmpWU5 ci@test" \ --no-update --no-bbr --delay-restart --no-ip-probe --yes --strict # Log should NOT contain ipify lookup. if grep -q "api.ipify.org" /var/log/server-init.log 2>/dev/null; then echo "❌ --no-ip-probe failed: ipify URL leaked into log" grep "api.ipify.org" /var/log/server-init.log exit 1 fi echo "✅ --no-ip-probe verified" CONTAINER # ------------------------------------------------------------------- # 9. ACCOUNT_LOCKED warning fires when key deployment fails (non-strict) # ------------------------------------------------------------------- test-account-locked-path: name: ACCOUNT_LOCKED fires when key deploy fails (non-strict) runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@v4 - name: Verify ACCOUNT_LOCKED_REASON path run: | chmod +x init.sh docker run --rm --privileged -v "$(pwd)":/mnt:ro -w /mnt ubuntu:22.04 /bin/sh -s <<'CONTAINER' set -e apt-get update -qq apt-get install -y -qq curl sudo openssh-server iproute2 ssh-keygen -A mkdir -p /run/sshd cp init.sh /tmp/init.sh && chmod +x /tmp/init.sh # Deliberately use an unreachable URL so key deploy FAILS but in # non-strict mode the script continues. /tmp/init.sh \ --user=deploy_locked \ --port=2222 \ --key-url="https://127.0.0.1:1/nope" \ --no-update --no-bbr --delay-restart --no-ip-probe --yes \ || { echo "non-strict run should not exit non-zero on key deploy failure"; exit 1; } # Audit log should record the locked state. grep -q "ACTION: ACCOUNT_KEPT_LOCKED" /var/log/server-init-audit.log \ || { echo "❌ audit missing ACCOUNT_KEPT_LOCKED"; cat /var/log/server-init-audit.log; exit 1; } # User should exist but be locked. passwd -S deploy_locked | awk "{print \$2}" | grep -qE "^L\$|^LK\$" \ || { echo "❌ deploy_locked is not locked"; passwd -S deploy_locked; exit 1; } for lock in /run/server-init/script.lock /var/lib/server-init/script.lock; do test ! -d "$lock" \ || { echo "❌ script lock left behind after non-strict key failure: $lock"; exit 1; } done echo "✅ ACCOUNT_LOCKED path verified" CONTAINER # ------------------------------------------------------------------- # 10. restore.sh refuses when checksums.sha256 is missing; FORCE=1 overrides # ------------------------------------------------------------------- test-restore-checksum-gate: name: restore.sh refuses missing checksums; FORCE=1 overrides runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@v4 - name: Verify checksum gate + FORCE=1 run: | chmod +x init.sh docker run --rm --privileged -v "$(pwd)":/mnt:ro -w /mnt ubuntu:22.04 /bin/sh -s <<'CONTAINER' set -e apt-get update -qq apt-get install -y -qq curl sudo openssh-server iproute2 ssh-keygen -A mkdir -p /run/sshd cp init.sh /tmp/init.sh && chmod +x /tmp/init.sh /tmp/init.sh \ --user=deploy \ --port=2222 \ --key-raw="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPQQDejyTCPJO3Jyw5UX9jmojb/zjgcqVuRO28fmpWU5 ci@test" \ --no-update --no-bbr --delay-restart --no-ip-probe --yes --strict backup_dir=$(ls -dt /var/backups/ssh-config/*/ 2>/dev/null | head -n 1) test -n "$backup_dir" || { echo "❌ no backup dir"; exit 1; } test -x "$backup_dir/restore.sh" || { echo "❌ no restore.sh"; exit 1; } # 1. Delete checksum file → restore.sh should REFUSE. rm -f "$backup_dir/checksums.sha256" if sh "$backup_dir/restore.sh" 2>&1; then echo "❌ restore.sh did NOT refuse when checksums.sha256 was missing" exit 1 fi echo "✅ restore.sh refused missing checksums" # 2. FORCE=1 should bypass. if ! FORCE=1 sh "$backup_dir/restore.sh" 2>&1; then echo "❌ FORCE=1 did NOT bypass the checksum gate" exit 1 fi echo "✅ FORCE=1 successfully bypassed" CONTAINER