664 lines
30 KiB
YAML
664 lines
30 KiB
YAML
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
|