feat: v4.7.8 Enterprise Hardening (18 audit rounds, 11 fix iterations)

Aggregates v4.7.0 through v4.7.8. Roughly 1850 lines added to init.sh
(2206 -> 4059); 10 CI jobs (was 1); README +113/+115 lines (zh/en).

CRIT/HIGH fixes across iterations:
  - existing-user passwd-d regression (v4.7.1)
  - rollback artifact tracking with provenance (v4.7.0/4/5)
  - restart_sshd direct-spawn fallback for no-init envs (v4.7.1)
  - chroot pkill defense (v4.7.6/8: both direct-spawn and systemctl paths)
  - PATH inheritance hardening (v4.7.8): /usr/local/sbin:/usr/local/bin:
    /usr/sbin:/usr/bin:/sbin:/bin set at script top
  - audit_log newline-injection defense (v4.7.6)
  - script-level mutex with stale-PID + kill -0 liveness (v4.7.6/7)
  - ARTIFACT_FILE mirror to RUNTIME_DIR for post-SIGKILL recovery (v4.7.6)
  - SELinux NOOP-vs-ADD provenance (v4.7.5) + cross-run cleanup (v4.7.4)
  - leading-zero port bypass of is_hard_reserved (v4.7.6, e.g. --port=03306
    silently shadowing MySQL)
  - setup_rollback ENOSPC verification (v4.7.7): empty snapshot would
    blank live sshd_config on rollback
  - record_last_applied atomic write via tmp+mv (v4.7.7)
  - audit_log stderr fallback when /var/log is unwritable (v4.7.7)
  - duplicate-argv hard-fail (v4.7.6): --user/--port/--key-* duplicates
    now refused instead of silent last-wins
  - validate_username + --key-gh per-line grep bypass (v4.7.6): newline-
    containing values now rejected via case-match before grep
  - leading-zero port (v4.7.6): rejected to prevent sshd parsing 03306
    as decimal 3306, shadowing MySQL

41 audit_log actions for SIEM ingestion. README documents all of them
with bold-marked high-priority alerts (ROLLBACK_RM_FAILED /
STRANDED_MUTATIONS_DETECTED / SUDO_FAIL / etc).

10 CI jobs cover: shellcheck (POSIX -s sh), 9-distro matrix with 18
assertions + idempotency re-run, live restart with banner check,
existing-user safety (passwd -S = P), cross-user sudoers glob safety,
legacy v4.6.x sudoers cleanup, CLI flag smoke (--version/-V/-h/
--no-ip-probe/--strict), --no-ip-probe verification, ACCOUNT_KEPT_LOCKED
path, restore.sh checksum gate + FORCE=1 bypass.

Branch convention: dev/v4.7.8 — not yet promoted to main. Awaiting
real-machine deployment validation before tag + release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
c-ujungandClaude Opus 4.7 committed 2026-05-23 15:03:22 +08:00
1 parent a231e51b3b
commit 4982f727cf
4 files changed
+2882 -338

No files matched your search

+562 -61
View File
@@ -1,12 +1,48 @@
name: Test Matrix
on: [push, pull_request]
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:
test-distros:
# 关键点 1: 在 Ubuntu 宿主机上运行 Job,而不是直接在容器里跑
# -------------------------------------------------------------------
# 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:
@@ -14,94 +50,559 @@ jobs:
- 'debian:11'
- 'debian:12'
- 'ubuntu:22.04'
- 'ubuntu:24.04'
- 'alpine:latest'
- 'almalinux:9'
- 'rockylinux:9'
- 'centos:7' # 这里的 CentOS 7 现在可以跑了!
- '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:
- name: Checkout code
uses: actions/checkout@v4
- uses: actions/checkout@v4
- name: Run Test in Container
- name: Run init.sh in ${{ matrix.container }}
run: |
echo "🚀 Testing on image: ${{ matrix.container }}"
echo "🚀 Testing on: ${{ matrix.container }}"
chmod +x init.sh
# 启动容器
docker run --rm --privileged -v $(pwd):/mnt -w /mnt ${{ matrix.container }} /bin/sh -c '
# 调试信息:打印当前系统版本
if [ -f /etc/redhat-release ]; then
echo "📝 System detected: $(cat /etc/redhat-release)"
fi
docker run --rm --privileged -v "$(pwd)":/mnt:ro -w /mnt ${{ matrix.container }} /bin/sh -c '
set -e
# ============================================
# 1. 针对 CentOS 7 的“起死回生”补丁 (正则修正版)
# ============================================
# [FIX] 改为匹配 "release 7",兼容 "CentOS Linux release 7.x.x"
# -----------------------------------------------------------------
# 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 "💀 Detected CentOS 7 (EOL). Fixing repositories to Vault..."
# 暴力替换所有 repo 文件 (base, updates, extras 等)
# 1. 注释掉失效的 mirrorlist
echo "💀 CentOS 7 (EOL) — repointing repos to vault.centos.org"
sed -i "s/^mirrorlist/#mirrorlist/g" /etc/yum.repos.d/*.repo
# 2. 启用 baseurl 并指向 vault
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
# 3. 强制清理缓存
yum clean all
rm -rf /var/cache/yum
fi
# ============================================
# 2. 环境补全
# ============================================
# -----------------------------------------------------------------
# Dependencies
# -----------------------------------------------------------------
echo "📦 Installing dependencies..."
# --- Debian / Ubuntu ---
if [ -f /etc/debian_version ]; then
apt-get update -y && apt-get install -y curl sudo openssh-server
apt-get update -qq && apt-get install -y -qq curl sudo openssh-server iproute2
ssh-keygen -A
mkdir -p /run/sshd
fi
# --- Alpine ---
if [ -f /etc/alpine-release ]; then
elif [ -f /etc/alpine-release ]; then
apk add --no-cache curl sudo openssh-server bash
ssh-keygen -A
fi
# --- RHEL / CentOS / Alma / Rocky ---
if [ -f /etc/redhat-release ]; then
# AlmaLinux 9 解决 curl-minimal 冲突
elif [ -f /etc/redhat-release ]; then
if command -v dnf >/dev/null; then
dnf install -y --allowerasing curl sudo openssh-server
dnf install -y --allowerasing curl sudo openssh-server iproute
else
# CentOS 7 会走这里,现在源修好了,安装应该没问题
yum install -y curl sudo openssh-server
yum install -y curl sudo openssh-server iproute
fi
ssh-keygen -A
mkdir -p /run/sshd
fi
# ============================================
# 3. 运行脚本
# ============================================
echo "🚀 Running init script..."
./init.sh --user=_247like --port=2222 --key-raw="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPQQDejyTCPJO3Jyw5UX9jmojb/zjgcqVuRO28fmpWU5" --no-update --bbr --delay-restart --yes --strict
# ============================================
# 4. 验证结果
# ============================================
if grep -q "BEGIN SERVER-INIT MANAGED BLOCK" /etc/ssh/sshd_config; then
echo "✅ SUCCESS: Config block injected."
else
echo "❌ FAIL: Config block missing."
exit 1
# -----------------------------------------------------------------
# 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; }
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; }
echo "✅ ALL ASSERTIONS PASSED"
'
# -------------------------------------------------------------------
# 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 -c '
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; }
echo "✅ Live restart + listen + banner OK"
'
# -------------------------------------------------------------------
# 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 -c '
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"
'
# -------------------------------------------------------------------
# 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 -c '
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"
'
# -------------------------------------------------------------------
# 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 -c '
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"
'
# -------------------------------------------------------------------
# 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 -c '
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; }
echo "✅ CLI flag smoke OK"
'
# -------------------------------------------------------------------
# 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 -c '
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"
'
# -------------------------------------------------------------------
# 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 -c '
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; }
echo "✅ ACCOUNT_LOCKED path verified"
'
# -------------------------------------------------------------------
# 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 -c '
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"
'
+104 -9
View File
@@ -36,6 +36,8 @@
curl -fsSL https://raw.githubusercontent.com/247like/linux-ssh-init-sh/main/init.sh -o init.sh && chmod +x init.sh && ./init.sh
```
> 🛡️ **审计建议**:脚本会修改 SSH 配置,建议先下载到本地、查看内容、再执行 (上面这条命令就是 "先下载、再执行" 的写法,请勿改为 `curl ... | sh`)。
#### 2. 强制使用英文界面
```bash
./init.sh --lang=en
@@ -76,6 +78,9 @@ curl -fsSL https://raw.githubusercontent.com/247like/linux-ssh-init-sh/main/init
| | `--yes` | **自动确认**:跳过脚本最后的 "确认执行?" 询问 |
| | `--strict` | **严格模式**:遇到任何错误立即退出 (详见下方) |
| | `--delay-restart` | **延迟重启**:修改配置但不重启 SSH 服务 (适用于特殊环境) |
| | `--no-ip-probe` | 跳过外网 IP 探测 (`api.ipify.org`),适用于离线/隔离环境 |
| | `-V / --version` | 打印版本并退出 |
| | `-h / --help` | 显示完整帮助 |
| **用户与端口** | `--user=root` | 指定登录用户 (root 或普通用户名) |
| | `--port=22` | 保持默认 22 端口 |
| | `--port=random` | 生成随机高位端口 (49152-65535) |
@@ -93,15 +98,97 @@ curl -fsSL https://raw.githubusercontent.com/247like/linux-ssh-init-sh/main/init
| 场景 | 普通模式 (默认) | 严格模式 (`--strict`) |
| :--- | :--- | :--- |
| **设计理念** | **"优先保命"** (尽力而为) | **"优先合规"** (零容忍) |
| **公钥失败** | 若下载失败,脚本**保留密码登录**并警告。<br>👉 *结果:服务器不安全,但能登录修补。* | 脚本**立即报错退出**,不修改任何配置。<br>👉 *结果:部署中断,保持原样。* |
| **公钥失败** | 托管块写入 `PasswordAuthentication yes`(保留密码登录通道),新建用户**保持锁定**(不会成为无密码可登录账号)。<br>👉 *结果:原有 root/密码用户仍能登录修补;新建用户因为账号锁定无法登录,操作员看到明确告警提示。* | 脚本**立即报错退出**,不修改任何配置。 |
| **既有用户密码** | v4.7.1 起:脚本**不再清除已存在用户的密码**,仅对新建账号执行 `passwd -d` | 同左 |
| **端口失败** | 若随机端口失败,回退使用 **端口 22**。 | 脚本**立即报错退出**。 |
| **HTTP 密钥 URL** | 仅警告并继续 | 拒绝 `http://`,要求 `https://` |
| **防火墙规则添加失败** | 仅警告 | 警告并打日志(非 22 端口时) |
### 📦 系统要求
* **权限**:必须以 `root` 身份运行
* **必备命令**:`cat / grep / awk / sed / cp / mv / chmod / chown / mkdir / rm / id`
* **建议命令** (脚本会自动补装):`curl` 或 `wget`、`sudo`、`openssh-server`、`ss` 或 `netstat`、`nc`、`base64`、`ssh-keygen`
* **支持发行版** (CI 全量验证):
* Debian 11 / 12
* Ubuntu 22.04 / 24.04
* Alpine (latest)
* AlmaLinux 9 / Rocky Linux 9 / CentOS Stream 9
* CentOS 7 (EOL,仅尽力而为)
### 📁 脚本修改/创建的文件
| 路径 | 说明 |
| :--- | :--- |
| `/etc/ssh/sshd_config` | 注入托管配置块;旧值改名为 `*.bak_server_init` |
| `/etc/ssh/sshd_config.d/*.conf` | 冲突的 drop-in 自动备份重命名 |
| `/etc/sudoers.d/server-init-<user>` | 稳定文件名(v4.7.0 起),可直接删除以撤销 |
| `/etc/systemd/system/sshd.service.d/override.conf` | 防杀/自动重启 |
| `/etc/profile.d/z99-ssh-init-banner.sh` | 登录横幅 |
| `/etc/motd` + `/etc/motd.bak` | 清理旧的 server-init 行;首次创建 `.bak` 真备份 |
| `/etc/sysctl.conf` | 启用 BBR 时追加 `net.core.default_qdisc=fq` 与 `tcp_congestion_control=bbr` |
| **防火墙规则** (ufw/firewalld/iptables/ip6tables) | 仅在 `--port != 22` 时打开 TCP;rollback 仅撤销当前 backend 写入的规则 |
| **SELinux 端口标签** | 当系统处于 Enforcing 模式时通过 `semanage port -a` 加 `ssh_port_t` 标签;`-m`(modify)路径无法清晰回滚 |
| `/home/<user>/.ssh/authorized_keys` | 已部署的公钥;拒绝符号链接路径;校验家目录所有者 (v4.7.3) |
| `/var/log/server-init.log` | 运行日志 |
| `/var/log/server-init-audit.log` | 审计日志(已脱敏 `--key-raw=` / `--key-url=`) |
| `/var/log/server-init-health.log` | 健康快照 |
| `/var/backups/ssh-config/<TS>/` | 持久备份 + `restore.sh` + `checksums.sha256` |
| `/var/lib/server-init/last-applied` | (v4.7.3+) 记录上一次成功使用的端口/防火墙后端;v4.7.4 起额外记录 `SELINUX_PORT=N`;v4.7.5 起记录 `SELINUX_OWNED=y/n` 区分"本脚本安装的 label" 与"管理员预先存在的 label",跨次运行只清理前者 |
| `/run/server-init/script.lock/` 或 `/var/lib/server-init/script.lock/` | (v4.7.6+) 脚本级互斥锁目录(含 PID 文件)。运行结束后自动清除;如果脚本被 `kill -9` 等异常终止可能遗留,下次启动会自动检测 PID 存活并回收 |
| `/run/server-init/last-artifacts-<UID>` 或 `/var/lib/server-init/last-artifacts-<UID>` | (v4.7.6+) artifact mirror。运行中 append;正常退出会清除;异常崩溃时遗留,下次启动会展示"遗留改动"清单提示操作员手动清理 |
### 🔁 幂等性 / 重复执行
* 脚本采用**托管块**机制:再次执行时**先删除旧块再写入新块**,不会重复堆积
* sudoers 文件名固定为 `server-init-<user>`,再次执行会覆盖而非新建
* **从 < v4.7.0 升级**:旧版本会留下时间戳形式的 sudoers 文件 (`server-init-<user>-YYYYMMDD...`)。v4.7.1 起在下次运行时**自动清理**这些遗留文件;v4.7.2 起**严格匹配纯数字后缀**,确保管理员自建的 `server-init-<user>-<非数字>` 文件(如 `server-init-admin-special-policy`)不会被误删,且不会跨用户误删兄弟账号的稳定文件。每次清理会在 audit log 记录 `LEGACY_SUDOERS_REMOVED`
* 备份目录每次按时间戳新建;最多保留 **10** 个最近备份,更旧的自动清理
### 🚪 退出码
| 退出码 | 含义 |
| :---: | :--- |
| 0 | 成功 |
| 1 | 通用错误 / 用户取消 / 配置失败 |
| 130 | `SIGINT` (Ctrl-C) |
| 143 | `SIGTERM` |
| 129 | `SIGHUP` |
| ≠0 (执行中途) | 触发自动回滚;查看 `/var/log/server-init.log` 与 `audit.log` |
> 注意:Ctrl-C 在 **确认提示** 阶段触发时,此时尚未发生破坏性变更,**不会**进入回滚路径。
### 📂 日志与审计
脚本执行后会生成以下重要文件,用于排查问题或审计合规:
> 📋 **日志轮转建议**:以下三个日志文件由脚本以**追加**方式写入,没有自带轮转机制。在频繁运行(CI/批量装机)的场景下建议加 `logrotate` 配置:
> ```
> # /etc/logrotate.d/server-init
> /var/log/server-init*.log {
> weekly
> rotate 12
> compress
> delaycompress
> missingok
> notifempty
> create 0600 root root
> }
> ```
* **运行日志**: `/var/log/server-init.log` (包含详细的 debug 信息)
* **审计日志**: `/var/log/server-init-audit.log` (记录关键操作 Action、时间戳及操作人)
* 主要 Action 类别(**应监控加粗项**):
* **生命周期**: `START` / `DONE` / **`ROLLBACK`** / **`RESTART_REFUSED`**
* **账号**: `USER_CREATED` / `PASSWORD_CLEARED` / `KEYS_DEPLOYED` / **`ACCOUNT_KEPT_LOCKED`** / **`USER_REMOVE_FAILED`**
* **sudo**: `SUDOERS_WRITTEN` / **`SUDO_FAIL`** / `LEGACY_SUDOERS_REMOVED`
* **系统配置**: `MANAGED_BLOCK_INSTALLED` / `DROPIN_RENAMED` / `SYSTEMD_OVERRIDE_WRITTEN` / `MOTD_BANNER_WRITTEN` / `BBR_ENABLED` / `SYSTEM_UPDATE_START` / `SYSTEM_UPDATE_DONE`
* **防火墙**: `FIREWALL_OPENED` / `FIREWALL_NOOP` / `STALE_FIREWALL_REMOVED` / `LAST_APPLIED_CLEARED`
* **SELinux**: `SELINUX_PORT_ADDED` / `SELINUX_PORT_NOOP` / `SELINUX_PORT_MODIFIED` / **`SELINUX_PORT_FAIL`** / `STALE_SELINUX_REMOVED` / **`STALE_SELINUX_REMOVE_FAILED`** / `STALE_SELINUX_KEPT` / `STALE_SELINUX_SKIP`
* **回滚不完整告警**(重点告警): **`ROLLBACK_RM_FAILED`** / **`ROLLBACK_MOTD_FAILED`** / **`ROLLBACK_MOTD_BAK_MISSING`** / **`STRANDED_MUTATIONS_DETECTED`**
* **测试 / 安全相关**: `LOGIN_TEST_PASSED` / `LOGIN_TEST_INCONCLUSIVE` / `INSECURE_KEY_URL` / `KEY_FETCH_FAILED` / `DIRECT_SPAWN` / `DIRECT_SPAWN_REFUSED`
* stderr 后备格式(当 `/var/log` 不可写时):`AUDIT-FALLBACK <timestamp> ACTION=<name> DETAILS=<...>`
* 适合接入 SIEM 进行集中审计
* **健康报告**: `/var/log/server-init-health.log` (最终的系统配置状态快照)
### 🆘 灾难恢复与配置还原
@@ -112,7 +199,7 @@ curl -fsSL https://raw.githubusercontent.com/247like/linux-ssh-init-sh/main/init
#### 方法 A:使用一键恢复脚本 (推荐)
脚本在修改前会自动创建备份,并生成恢复脚本。
脚本在修改前会自动创建带 **SHA256 校验** 的备份。`restore.sh` 在覆盖前会校验 `checksums.sha256`,被篡改的备份将被拒绝。
1. 找到最近的备份目录:
```bash
@@ -120,23 +207,31 @@ curl -fsSL https://raw.githubusercontent.com/247like/linux-ssh-init-sh/main/init
```
2. 进入目录并运行恢复脚本:
```bash
# 进入最新的备份目录 (例如 20250520_120000)
cd /var/backups/ssh-config/<TIMESTAMP>/
# 执行恢复
sh restore.sh
```
*该脚本会自动覆盖 sshd_config 并尝试重启 SSH 服务。*
`restore.sh` 会:① 校验校验和 → ② 覆盖 `sshd_config` 及 `sshd_config.d/` → ③ 运行 `sshd -t` → ④ 重启 sshd。任一步骤失败都会以非 0 退出码停止。
**覆盖校验**:若 `checksums.sha256` 缺失或系统无 `sha256sum`,`restore.sh` 会**拒绝执行**(防止恢复被篡改的备份)。如果你确信备份完整且必须强制恢复(例如手动删除了 checksums 文件),可以:
```bash
FORCE=1 sh restore.sh
```
仅在你**信任备份内容**时使用。
> 注意:`restore.sh` 仅还原 **`sshd_config` 与 drop-in**;运行期创建的用户、sudoers、systemd override、防火墙规则等需依靠**运行时自动回滚**或手动撤销(见上方的"脚本修改/创建的文件"清单)。
#### 方法 B:手动恢复
如果无法运行脚本,可手动复制文件:
如果 `restore.sh` 不可用,可手动复制文件:
```bash
# 1. 覆盖配置文件
# 1. 校验 (可选但强烈推荐)
cd /var/backups/ssh-config/<TIMESTAMP>/ && sha256sum -c checksums.sha256
# 2. 覆盖配置文件
cp /var/backups/ssh-config/<TIMESTAMP>/sshd_config /etc/ssh/sshd_config
# 2. 重启服务
# 3. 重启服务
systemctl restart sshd || service sshd restart
```
+105 -10
View File
@@ -36,6 +36,8 @@ Run the following command as **root**.
curl -fsSL https://raw.githubusercontent.com/247like/linux-ssh-init-sh/main/init.sh -o init.sh && chmod +x init.sh && ./init.sh
```
> 🛡️ **Audit tip**: this script modifies SSH config — prefer the "download then run" form above so you can inspect `init.sh` before executing. Avoid `curl … | sh`.
#### 2. Force English UI
```bash
./init.sh --lang=en
@@ -76,6 +78,9 @@ The script supports rich command-line arguments to control its behavior:
| | `--yes` | **Auto Confirm**: Skip the final "Proceed?" prompt |
| | `--strict` | **Strict Mode**: Exit immediately on error (see below) |
| | `--delay-restart` | **Delay Restart**: Apply config but do not restart SSHD immediately |
| | `--no-ip-probe` | Skip the external IP lookup (`api.ipify.org`) — for offline/air-gapped hosts |
| | `-V / --version` | Print version and exit |
| | `-h / --help` | Show full help |
| **User/Port** | `--user=root` | Specify login user (root or username) |
| | `--port=22` | Keep default port 22 |
| | `--port=random` | Generate random high port (49152-65535) |
@@ -93,15 +98,97 @@ The script supports rich command-line arguments to control its behavior:
| Feature | Normal Mode (Default) | Strict Mode (`--strict`) |
| :--- | :--- | :--- |
| **Philosophy** | **"Don't Lockout"** (Best Effort) | **"Compliance First"** (Zero Tolerance) |
| **Key Failure** | If key download fails, script **keeps Password Auth enabled** and warns you.<br>👉 *Result: Server reachable but less secure.* | Script **exits immediately**. No changes applied.<br>👉 *Result: Deployment aborted, state unchanged.* |
| **Port Failure** | If random port fails, it falls back to **Port 22**. | Script **exits immediately**. |
| **Key Failure** | Password auth remains enabled with a warning, and any newly-created user **stays locked** (v4.7.0+).<br>👉 *Existing root/password users can still log in to repair; the new account never becomes passwordless+open.* | Script **exits immediately**. |
| **Existing-user password** | v4.7.1+: the script **does NOT clear** an existing user's password; `passwd -d` runs only for accounts newly created in this run | Same |
| **Port Failure** | Falls back to **port 22**. | Script **exits immediately**. |
| **HTTP key URL** | Warn-and-continue | Reject `http://`, require `https://` |
| **Firewall rule add failed** | Warn only | Warn + log (non-22 ports) |
### 📦 Requirements
* **Privileges**: must run as `root`
* **Mandatory commands**: `cat / grep / awk / sed / cp / mv / chmod / chown / mkdir / rm / id`
* **Recommended commands** (auto-installed if missing): `curl` or `wget`, `sudo`, `openssh-server`, `ss` or `netstat`, `nc`, `base64`, `ssh-keygen`
* **Verified distros** (full CI matrix):
* Debian 11 / 12
* Ubuntu 22.04 / 24.04
* Alpine (latest)
* AlmaLinux 9 / Rocky Linux 9 / CentOS Stream 9
* CentOS 7 (EOL — best effort, allow-failure in CI)
### 📁 Files the script creates / modifies
| Path | Purpose |
| :--- | :--- |
| `/etc/ssh/sshd_config` | Managed block injected; conflicting drop-ins renamed `*.bak_server_init` |
| `/etc/ssh/sshd_config.d/*.conf` | Conflicting drop-ins backed up/disabled |
| `/etc/sudoers.d/server-init-<user>` | Stable filename (v4.7.0+) — delete to revoke |
| `/etc/systemd/system/sshd.service.d/override.conf` | Anti-kill / restart policy |
| `/etc/profile.d/z99-ssh-init-banner.sh` | Login banner |
| `/etc/motd` + `/etc/motd.bak` | Strips stale server-init lines; first run preserves real `.bak` |
| `/etc/sysctl.conf` | BBR lines appended when `--bbr` is enabled |
| **Firewall rules** (ufw/firewalld/iptables/ip6tables) | Opens TCP port when `--port != 22`; rollback only undoes the backend the script used |
| **SELinux port labels** | When system is Enforcing, applies `semanage port -a -t ssh_port_t`; `-m` (modify) path cannot be cleanly rolled back |
| `/home/<user>/.ssh/authorized_keys` | Deployed keys; symlinks rejected; home owner verified (v4.7.3) |
| `/var/log/server-init.log` | Runtime log |
| `/var/log/server-init-audit.log` | Audit trail (redacts `--key-raw` / `--key-url`) |
| `/var/log/server-init-health.log` | Post-run snapshot |
| `/var/backups/ssh-config/<TS>/` | Persistent backup + `restore.sh` + `checksums.sha256` |
| `/var/lib/server-init/last-applied` | (v4.7.3+) records the previous successful port + firewall backend; v4.7.4 also records `SELINUX_PORT=N` so port-change runs clean the prior SELinux label; v4.7.5 adds `SELINUX_OWNED=y/n` distinguishing "label this script installed" from "label that pre-existed" — only the former is cleaned across runs |
| `/run/server-init/script.lock/` or `/var/lib/server-init/script.lock/` | (v4.7.6+) Script-level mutex directory (holds PID file). Auto-removed on normal exit; if the script was `kill -9`'d the dir may linger — the next start auto-detects whether the holder PID is still alive and reclaims if not |
| `/run/server-init/last-artifacts-<UID>` or `/var/lib/server-init/last-artifacts-<UID>` | (v4.7.6+) Artifact mirror appended during run; cleared on normal exit. If the script crashed, the next start surfaces this list to alert the operator to stranded mutations |
### 🔁 Idempotency / re-runs
* The managed block is **removed and re-inserted** on every run — no duplicate stacking
* Sudoers file uses a stable per-user name; re-runs overwrite instead of accumulating
* **Upgrading from < v4.7.0**: older versions left timestamped sudoers files (`server-init-<user>-YYYYMMDD...`). v4.7.1 added auto-cleanup on the next run; **v4.7.2 narrows the match to digits-only suffixes** so admin-created files like `server-init-<user>-special-policy` are preserved and sibling accounts that share a hyphen-prefix (e.g. `admin` vs `admin-bot`) cannot be collaterally cleaned. Each removal is recorded in audit log as `LEGACY_SUDOERS_REMOVED`
* Backups rotate by timestamp; the **10 most recent** are retained, older ones pruned automatically
### 🚪 Exit codes
| Code | Meaning |
| :---: | :--- |
| 0 | Success |
| 1 | Generic error / user cancel / config failure |
| 130 | `SIGINT` (Ctrl-C) |
| 143 | `SIGTERM` |
| 129 | `SIGHUP` |
| ≠ 0 (mid-deploy) | Triggers automatic rollback — inspect `/var/log/server-init.log` + `audit.log` |
> Note: Ctrl-C during the **confirmation prompt** does NOT enter the rollback path (no destructive changes have happened yet).
### 📂 Logs & Audit
After execution, the following files are generated for troubleshooting and auditing:
> 📋 **Log-rotation recommendation**: the three log files below are append-only with no built-in rotation. For frequent runs (CI / fleet installs) drop in a logrotate config:
> ```
> # /etc/logrotate.d/server-init
> /var/log/server-init*.log {
> weekly
> rotate 12
> compress
> delaycompress
> missingok
> notifempty
> create 0600 root root
> }
> ```
* **Run Log**: `/var/log/server-init.log` (Detailed debug information)
* **Audit Log**: `/var/log/server-init-audit.log` (Records key actions, timestamps, and operators)
* Action categories (**recommended-to-alert in bold**):
* **Lifecycle**: `START` / `DONE` / **`ROLLBACK`** / **`RESTART_REFUSED`**
* **Accounts**: `USER_CREATED` / `PASSWORD_CLEARED` / `KEYS_DEPLOYED` / **`ACCOUNT_KEPT_LOCKED`** / **`USER_REMOVE_FAILED`**
* **Sudo**: `SUDOERS_WRITTEN` / **`SUDO_FAIL`** / `LEGACY_SUDOERS_REMOVED`
* **System config**: `MANAGED_BLOCK_INSTALLED` / `DROPIN_RENAMED` / `SYSTEMD_OVERRIDE_WRITTEN` / `MOTD_BANNER_WRITTEN` / `BBR_ENABLED` / `SYSTEM_UPDATE_START` / `SYSTEM_UPDATE_DONE`
* **Firewall**: `FIREWALL_OPENED` / `FIREWALL_NOOP` / `STALE_FIREWALL_REMOVED` / `LAST_APPLIED_CLEARED`
* **SELinux**: `SELINUX_PORT_ADDED` / `SELINUX_PORT_NOOP` / `SELINUX_PORT_MODIFIED` / **`SELINUX_PORT_FAIL`** / `STALE_SELINUX_REMOVED` / **`STALE_SELINUX_REMOVE_FAILED`** / `STALE_SELINUX_KEPT` / `STALE_SELINUX_SKIP`
* **Incomplete-rollback alerts** (high-priority): **`ROLLBACK_RM_FAILED`** / **`ROLLBACK_MOTD_FAILED`** / **`ROLLBACK_MOTD_BAK_MISSING`** / **`STRANDED_MUTATIONS_DETECTED`**
* **Test / security**: `LOGIN_TEST_PASSED` / `LOGIN_TEST_INCONCLUSIVE` / `INSECURE_KEY_URL` / `KEY_FETCH_FAILED` / `DIRECT_SPAWN` / `DIRECT_SPAWN_REFUSED`
* Stderr fallback format (when `/var/log` is unwritable): `AUDIT-FALLBACK <timestamp> ACTION=<name> DETAILS=<...>`
* Designed for SIEM ingestion
* **Health Report**: `/var/log/server-init-health.log` (Snapshot of the final system configuration state)
### 🆘 Disaster Recovery & Restore
@@ -112,7 +199,7 @@ If you cannot connect to your server via SSH after the script finishes (after se
#### Method A: One-Click Restore Script (Recommended)
The script automatically creates a backup and generates a restore script before applying changes.
Each run creates a backup with a **SHA256 checksums file**. `restore.sh` verifies `checksums.sha256` before applying anything — a tampered backup is rejected.
1. Find the latest backup directory:
```bash
@@ -120,23 +207,31 @@ The script automatically creates a backup and generates a restore script before
```
2. Enter the directory and run the restore script:
```bash
# Enter the latest timestamp directory (e.g., 20250520_120000)
cd /var/backups/ssh-config/<TIMESTAMP>/
# Run the restore script
sh restore.sh
```
*This will automatically overwrite `sshd_config` and attempt to restart the SSH service.*
`restore.sh` will: ① verify checksums → ② restore `sshd_config` + `sshd_config.d/` → ③ run `sshd -t` → ④ restart sshd. Any failure aborts with a non-zero exit code.
**Override**: if `checksums.sha256` is missing or `sha256sum` is unavailable, `restore.sh` **REFUSES** to run (defends against restoring a tampered backup). If you're certain the backup is good and need to bypass:
```bash
FORCE=1 sh restore.sh
```
Use only when you **trust the backup contents** (e.g. you manually pruned `checksums.sha256` yourself).
> ⚠ `restore.sh` only restores **`sshd_config` and its drop-ins**. Users / sudoers / systemd overrides / firewall rules created during the run are best undone by the **runtime auto-rollback** (which fires on any failure during the script run); see the "Files the script creates / modifies" table above for manual cleanup.
#### Method B: Manual Restore
If the restore script is unavailable, manually copy the files:
If `restore.sh` is unavailable, copy files by hand:
```bash
# 1. Overwrite configuration
# 1. Verify (recommended)
cd /var/backups/ssh-config/<TIMESTAMP>/ && sha256sum -c checksums.sha256
# 2. Overwrite configuration
cp /var/backups/ssh-config/<TIMESTAMP>/sshd_config /etc/ssh/sshd_config
# 2. Restart Service
# 3. Restart service
systemctl restart sshd || service sshd restart
```
+2111 -258
View File
File diff suppressed because it is too large. Load diff