diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 2b3d831..3b98530 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -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"
'
diff --git a/README.md b/README.md
index 65339e7..f554bbf 100644
--- a/README.md
+++ b/README.md
@@ -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`) |
| :--- | :--- | :--- |
| **设计理念** | **"优先保命"** (尽力而为) | **"优先合规"** (零容忍) |
-| **公钥失败** | 若下载失败,脚本**保留密码登录**并警告。
👉 *结果:服务器不安全,但能登录修补。* | 脚本**立即报错退出**,不修改任何配置。
👉 *结果:部署中断,保持原样。* |
+| **公钥失败** | 托管块写入 `PasswordAuthentication yes`(保留密码登录通道),新建用户**保持锁定**(不会成为无密码可登录账号)。
👉 *结果:原有 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-` | 稳定文件名(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//.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//` | 持久备份 + `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-` 或 `/var/lib/server-init/last-artifacts-` | (v4.7.6+) artifact mirror。运行中 append;正常退出会清除;异常崩溃时遗留,下次启动会展示"遗留改动"清单提示操作员手动清理 |
+
+### 🔁 幂等性 / 重复执行
+
+* 脚本采用**托管块**机制:再次执行时**先删除旧块再写入新块**,不会重复堆积
+* sudoers 文件名固定为 `server-init-`,再次执行会覆盖而非新建
+* **从 < v4.7.0 升级**:旧版本会留下时间戳形式的 sudoers 文件 (`server-init--YYYYMMDD...`)。v4.7.1 起在下次运行时**自动清理**这些遗留文件;v4.7.2 起**严格匹配纯数字后缀**,确保管理员自建的 `server-init--<非数字>` 文件(如 `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 ACTION= 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//
-
- # 执行恢复
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// && sha256sum -c checksums.sha256
+
+# 2. 覆盖配置文件
cp /var/backups/ssh-config//sshd_config /etc/ssh/sshd_config
-# 2. 重启服务
+# 3. 重启服务
systemctl restart sshd || service sshd restart
```
diff --git a/README_EN.md b/README_EN.md
index 1de2c70..751292a 100644
--- a/README_EN.md
+++ b/README_EN.md
@@ -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.
👉 *Result: Server reachable but less secure.* | Script **exits immediately**. No changes applied.
👉 *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+).
👉 *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-` | 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//.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//` | 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-` or `/var/lib/server-init/last-artifacts-` | (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--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--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 ACTION= 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//
-
- # 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// && sha256sum -c checksums.sha256
+
+# 2. Overwrite configuration
cp /var/backups/ssh-config//sshd_config /etc/ssh/sshd_config
-# 2. Restart Service
+# 3. Restart service
systemctl restart sshd || service sshd restart
```
diff --git a/init.sh b/init.sh
index 1669222..7e1eb48 100644
--- a/init.sh
+++ b/init.sh
@@ -7,25 +7,372 @@
# GitHub: https://github.com/247like/linux-ssh-init-sh
# License: MIT
#
-# Release: v4.6.2 (Fortress Pro - Final Audit Fix)
+# Release: v4.7.8 (Enterprise Hardening — 18th-round red-team / scenario fixes)
#
-# POSIX sh compatible (Debian dash / CentOS / Alpine / Ubuntu)
+# POSIX sh compatible (Debian dash / CentOS / Alpine / Ubuntu / RHEL)
#
-# Changelog v4.6.2:
-# - [SEC] Fix: deploy_keys() now refuses symlinks (local privilege escalation)
-# - [SEC] Fix: Removed chown -R (symlink traversal risk)
-# - [SEC] Fix: Added KbdInteractiveAuthentication no for true key-only auth
-# - Fix: cleanup_old_backups() awk logic bug (was deleting all backups)
-# - Fix: Use 127.0.0.1 instead of localhost in tests (IPv6 mismatch)
-# - Fix: Removed rsa-sha2-256/512 from key type regex
-# - Fix: --delay-restart now skips listen/connection tests
-# - Fix: safe_configure_sudo grep prefix match issue
-# - Fix: Case-insensitive sshd_config directive matching
-# - Fix: Replaced awk IGNORECASE with tolower() for mawk/BusyBox compatibility
-# - Fix: Changed chown user:user to chown user: for default group handling
+# Changelog v4.7.8 (post-v4.7.7 18th-round red-team + scenario walk-through):
+# - [HIGH] PATH inheritance hardening: previously, $PATH was inherited
+# unsanitized from the caller. In CI / sudo-with-env_keep+=PATH
+# scenarios an attacker controlling the calling shell's env could
+# plant fake passwd/useradd/stat/whoami/etc binaries that the
+# script then executes as root. Now sets a fixed PATH at the top.
+# - [HIGH] restart_sshd refuses early when in a chroot. v4.7.7 only blocked
+# the direct-spawn fallback; the systemctl path could still cross
+# the chroot boundary via /run/dbus and restart the HOST's sshd
+# with the CHROOT's modified config. Now bails at the function
+# entry via in_chroot_or_unknown.
+# - [MED W1-1] update_system: SYSTEM_UPDATE_START + SYSTEM_UPDATE_DONE
+# audit_log entries (apt/dnf/yum/apk upgrade is a major mutation).
+# - [MED W2-2] FIREWALL_OPENED audit only fires when rule was ACTUALLY
+# installed; pre-existing rules emit FIREWALL_NOOP. Stops every
+# idempotent re-run from polluting the audit log.
+# - [MED W6-1] KEY_FETCH_FAILED audit entry after 3 retries exhausted
+# (forensic breadcrumb for why STRICT mode aborted).
+# - [MED W8-1] STRANDED_MUTATIONS_DETECTED audit when --yes + non-empty
+# prev-run artifact mirror. Cron-driven runs no longer silently
+# accumulate detected-but-unreconciled mutations.
+# - [LOW] --key-gh=USERNAME value redacted in START audit entry (parity
+# with --key-url and --key-raw).
+# - [doc] README "公钥失败" / "Key Failure" table row clarified: managed
+# block writes PasswordAuthentication=yes (preserves existing
+# password-login path) AND new user is kept locked.
+# - [doc] README audit-log enumeration completed — all 37+ ACTION names
+# now listed, with high-priority alert candidates bold-marked
+# (ROLLBACK_RM_FAILED / STRANDED_MUTATIONS_DETECTED / etc).
+# - [doc] README "Files modified" table now lists script.lock and
+# last-artifacts-UID paths (v4.7.6 additions previously omitted).
+#
+# Changelog v4.7.7 (post-v4.7.6 16th-round perf/FS/container/exotic-shell audit):
+# - [HIGH] Direct-spawn sshd fallback now refuses ALSO when in a chroot
+# without separate PID namespace (e.g. operator did
+# `chroot /mnt/target` from host console). Previously, only the
+# SSH-into-host scenario was guarded; chroot-from-console would
+# let `pkill -x sshd` reach the HOST's sshd and drop every SSH
+# session on the machine. New in_chroot_or_unknown helper does
+# this via /proc/1/root inode comparison.
+# - [MED] Script-level mutex liveness check now uses both /proc/$pid AND
+# kill -0 as a fallback, and REFUSES when the holder's status is
+# ambiguous (chroot without /proc, etc) rather than reclaiming
+# and letting two concurrent runs race-corrupt sshd_config.
+# - [MED] setup_rollback's snapshot cp now fails LOUDLY on ENOSPC / NFS
+# errors AND verifies non-empty result. Previously a silent cp
+# failure left an empty snapshot — and the eventual rollback would
+# cp that empty snapshot OVER /etc/ssh/sshd_config, bricking the
+# box. Refuses to proceed without a valid snapshot.
+# - [MED] backup_config_persistent's snapshot cp same hardening; on
+# failure removes the partial file (restore.sh would otherwise
+# install a 0-byte sshd_config) and warns.
+# - [MED] record_last_applied now writes atomically via tmp+mv. Previous
+# direct truncate-then-write left partial state on signal/ENOSPC.
+# - [LOW] audit_log falls back to stderr (`AUDIT-FALLBACK ...`) when
+# /var/log is unwritable. The ROLLBACK record is the most
+# important forensic event; losing it silently would leave the
+# operator with no idea why rollback fired.
+# - [INFO] README adds a logrotate(8) snippet for /var/log/server-init*.log
+# (zh+en). Operators of high-frequency installs (CI / fleet) need
+# a hint that there's no built-in rotation.
+# - [CLEAN] Exotic shells (yash, ksh93, mksh, posh, OpenBSD pdksh) and old
+# tool versions verified: no new portability issues found.
+#
+# Changelog v4.7.6 (post-v4.7.5 13th-round adversarial audit):
+# - [CRIT] handle_selinux now initializes __selinux_done="n" at function
+# entry. Previously, the "all 3 semanage attempts failed" path
+# left it unset; under `set -u` the subsequent test crashed the
+# script and triggered a full rollback — defeating the non-strict
+# graceful-degrade design.
+# - [HIGH N1] Script-level mutex at $RUNTIME_DIR/script.lock prevents
+# concurrent invocations from interleave-corrupting sshd_config.
+# Stale-PID detection allows recovery from crashed prior runs.
+# - [HIGH N2] artifact_track now ALSO mirrors to $RUNTIME_DIR/last-artifacts-UID
+# (RUNTIME_DIR survives across script invocations; TMP_DIR doesn't).
+# On SIGKILL/OOM/power-loss, the next script run surfaces the
+# stranded artifacts so the operator knows to manually clean.
+# - [HIGH N3] audit_log strips newlines from $action and $details. Without
+# this, a $details derived from a tampered /etc/passwd field 6
+# could inject forged "ACTION: FAKE" entries into the audit log.
+# - [MED] validate_port rejects leading-zero ports. `--port=03306` used to
+# decimal-pass to 3306, bypass is_hard_reserved's string-only
+# case match, and write `Port 03306` to sshd_config — silently
+# shadowing MySQL on that port. Same vector for `--port=0022`
+# that script-treated as non-standard while sshd parsed as 22,
+# racing firewall/SELinux state vs the daemon's reality.
+# - [MED N5] Rollback rm-failure now audit-logged (ROLLBACK_RM_FAILED) and
+# sets rollback_clean=0. Read-only /etc previously left stale
+# sudoers entries after user deletion — a privilege primitive
+# for any future re-created user with the same name.
+# - [MED N6] MOTD_BAK-missing during rollback now surfaces as ROLLBACK_
+# MOTD_BAK_MISSING audit + non-clean rollback (was silent).
+# - [MED] Strip block for SELINUX_* lines: previous `grep -v ... && mv ||
+# rm` broke when grep matched zero lines (file was all SELINUX_*).
+# Now uses `|| true` after grep so mv always runs.
+# - [MED] Duplicate `--user=` / `--port=` / `--key-*` now hard-fails instead
+# of silently last-wins. Empty `--port=` and `--key-X=` values
+# are also rejected (previously caused interactive-prompt EOF →
+# silent port-22 downgrade).
+# - [LOW] validate_username + --key-gh now reject embedded newlines BEFORE
+# grep (POSIX grep is per-line-anchored — multi-line values
+# silently bypassed). Closes the log-disclosure / future-refactor
+# footgun.
+# - [LOW] public_ip from ipify is sanitized to digits/dots only (≤15 char).
+# Prevents a MITM'd response from injecting ANSI CSI sequences
+# into the operator's terminal scrollback via the summary box.
+#
+# Changelog v4.7.5 (post-v4.7.4 11th-round audit):
+# - [MED] SELinux NOOP-vs-ADD provenance: v4.7.4 tracked NOOP labels the
+# same as ADDED labels, which let rollback and stale-cleanup
+# `semanage port -d` labels we never installed (sysadmin-set or
+# cross-tool-set). NOOP path no longer artifact_tracks; persisted
+# state gains `SELINUX_OWNED=y/n` so stale-cleanup only deletes
+# OWNED labels and emits STALE_SELINUX_KEPT for the rest.
+# - [MED] Stale SELinux residue across mid-state crash: after a successful
+# cross-run `semanage port -d`, the SELINUX_PORT= and SELINUX_OWNED=
+# lines are STRIPPED from LAST_APPLIED_FILE immediately (not just
+# overwritten by the post-handle_selinux record_last_applied call).
+# Eliminates the brief window where the file claimed ownership of a
+# label that had just been deleted.
+# - [MED] CI: test-distros now asserts the v4.7.4 audit entries
+# (USER_CREATED / PASSWORD_CLEARED / KEYS_DEPLOYED /
+# MANAGED_BLOCK_INSTALLED) actually fire on the happy path.
+# - [MED] CI: new test-restore-checksum-gate job verifies restore.sh
+# refuses missing checksums.sha256 AND that FORCE=1 bypasses.
+# - [LOW] Forensic gap: `INSECURE_KEY_URL` audit entry fires when a
+# plaintext http:// key URL is fetched in non-strict mode.
+# - [LOW] STALE_SELINUX_KEPT / STALE_SELINUX_REMOVE_FAILED /
+# STALE_SELINUX_SKIP audit entries for full state coverage.
+# - [INFO] Both READMEs now document the audit-log action names (for SIEM
+# integration) and the v4.7.5 SELinux ownership semantics.
+#
+# Changelog v4.7.4 (post-v4.7.3 9th-round audit):
+# - [LOW] --key-raw \c/\0 check now uses raw printf+exit (the previous
+# `die` call was unreachable: die() is defined AFTER the argv loop,
+# so an undefined-function "command not found" would silently
+# proceed, bypassing the defense-in-depth check).
+# - [MED] SELinux port label leak across runs: LAST_APPLIED_FILE now
+# records SELINUX_PORT=N; remove_stale_firewall_port also calls
+# `semanage port -d` on the OLD port. No more cruft accumulation.
+# - [MED] Idempotent same-port SELinux re-run: handle_selinux now
+# pre-checks `semanage port -l` and emits SELINUX_PORT_NOOP
+# instead of the misleading "previous SELinux type was
+# overwritten" warning.
+# - [MED] Forensic audit gap: USER_CREATED, PASSWORD_CLEARED, KEYS_DEPLOYED
+# now in the audit trail (previously only artifact_track + info).
+# - [MED] README documents `FORCE=1 sh restore.sh` for the case where
+# checksums.sha256 was lost and operator must bypass verification.
+# - [LOW] Symlink-defense hardening: 5 sites now call unlink_if_symlink
+# before write (init_log_files / record_last_applied / sudoers /
+# systemd override / motd banner). BBR sysctl path refuses to
+# append when /etc/sysctl.conf is a symlink. All require prior
+# root-equiv compromise to weaponize but tightens defense.
+#
+# Changelog v4.7.3 (post-v4.7.2 fresh-eyes audit):
+# - [HIGH H2] finalize_user_password_policy now surfaces double-failure
+# (passwd -d AND usermod -U both failing) via ACCOUNT_LOCKED_REASON
+# + audit_log instead of silently leaving account broken.
+# - [HIGH H3] deploy_keys defensively rejects home directories that are
+# not owned by the target user (defends against tampered
+# /etc/passwd → privilege-escalation primitive).
+# - [HIGH H4] cleanup_sshd_config_d will not overwrite a pre-existing
+# .bak_server_init from a prior crashed run — uses timestamped
+# name instead.
+# - [HIGH H5] Persist last-applied port/backend in /var/lib/server-init/last-applied;
+# on next run with different --port, clean the stale firewall
+# rule (no more "every prior port stays open").
+# - [HIGH H6] setup_rollback skips .bak_server_init files when snapshotting
+# so stage-3 rollback restore cannot reintroduce stale data.
+# - [HIGH H7] SELinux `semanage port -m` path audit-logs the overwrite +
+# warns loudly; STRICT mode refuses to silently overwrite.
+# - [HIGH H8/H9] CI: existing-user-safe now asserts `passwd -S` reports P;
+# cross-user comment corrected.
+# - [INFO C1] enhanced_ssh_test ssh-client login test is INHERENTLY
+# inconclusive (root has no client privkey) — kept warn+return-0
+# behavior but added LOGIN_TEST_PASSED / LOGIN_TEST_INCONCLUSIVE
+# audit_log entries.
+# - [MED] --key-raw rejects literal \\c and \\0 escapes (printf %b truncation
+# hazard); interactive raw paste uses real newline (no %b at all).
+# - [MED] cleanup_old_backups grep -c fix — empty backup_list no longer
+# produces "0\\n0" that broke numeric -gt compare.
+# - [MED] ensure_port_tools tries multiple nc package names (netcat-openbsd,
+# ncat, nmap-ncat, netcat) for cross-distro coverage.
+# - [MED] restore.sh REFUSES when checksums.sha256 is missing or sha256sum
+# unavailable; set FORCE=1 to override.
+# - [MED] fetch_keys via wget uses --quota=$KEY_MAX_BYTES (wget has no
+# --max-filesize) to bound on-disk damage.
+# - [MED] sanitize_sshd_config stops at first `Match` line — operator's
+# Match-block-scoped directives are no longer silently commented out.
+# - [MED] update_motd grep anchored to start-of-line; operator's MOTD lines
+# containing "Login User:" etc. are no longer accidentally stripped.
+# - [MED] print_final_summary suppresses ssh command when ACCOUNT_LOCKED.
+# - [MED] --delay-restart explicitly tells the operator about the
+# systemd override.conf that will apply on next manual restart.
+# - [MED] restart_sshd direct-spawn fallback REFUSES when SSH_CONNECTION
+# is set (would kill operator's own session in a chroot/container).
+# - [MED] audit_log added for BBR_ENABLED, FIREWALL_OPENED, SUDOERS_WRITTEN,
+# DROPIN_RENAMED, MANAGED_BLOCK_INSTALLED, SYSTEMD_OVERRIDE_WRITTEN,
+# MOTD_BANNER_WRITTEN, SELINUX_PORT_ADDED/MODIFIED/FAIL.
+# - [LOW] STALE_FIREWALL_REMOVED audit entry on port-change cleanup.
+# - [LOW] DIRECT_SPAWN / DIRECT_SPAWN_REFUSED audit entries.
+#
+# Changelog v4.7.2 (post-v4.7.1 audit fixes):
+# - [HIGH] safe_configure_sudo: validate + write the NEW stable file BEFORE
+# touching any legacy files (prevents lockout-of-admin when visudo
+# fails on the new template).
+# - [HIGH] Legacy-sudoers cleanup glob now requires digits-only suffix,
+# preventing cross-user deletion (running script for "admin" no
+# longer deletes "server-init-admin-bot"). Operator-managed files
+# like "server-init--special-policy" are preserved.
+# - [MED] rollback_handler skips restart_sshd when restored sshd_config
+# fails `sshd -t` — pushing a broken config to the live daemon
+# could crash sshd and leave the machine unreachable.
+# - [MED] finalize_user_password_policy: keep new account LOCKED when
+# sudo deployment also failed (avoid passwordless+no-sudo combo).
+# - [LOW] CI test-existing-user-safe comment corrected (sudoers is NEVER
+# written for pre-existing accounts — that's by design).
+# - [LOW] CI: new test-cross-user-sudoers-safe job (regression test for
+# the HIGH glob bug).
+# - [LOW] CI: new test-legacy-sudoers-cleanup job (verifies v4.6.x
+# timestamped files are removed AND audit-logged).
+#
+# Changelog v4.7.1 (post-v4.7.0 audit fixes):
+# - [CRIT R1] passwd -d regression: only clear password for NEW accounts;
+# pre-existing admin passwords are preserved
+# - [CRIT R2] rollback now RENAMES .bak_server_init back instead of deleting
+# (prevents permanent loss of original drop-in content)
+# - [CRIT R3] update_motd modifications tracked via MOTD_BAK artifact;
+# rollback restores from .bak
+# - [CRIT R4] restart_sshd grew a direct-spawn fallback (pkill+sshd) for
+# minimal/no-init environments; CI live-restart job now passes
+# - [HIGH H1] safe_configure_sudo failure is no longer silent (audit_log +
+# STRICT abort)
+# - [HIGH H2] FIREWALL_PORT artifact tagged with backend (ufw/firewalld/
+# iptables) — rollback only undoes that backend
+# - [HIGH H3] sshd -T fallback warns loudly + STRICT mode refuses approximate
+# grep-based validation
+# - [HIGH H4] Rollback path forces sshd restart even under --delay-restart
+# - [HIGH H6] safe_configure_sudo cleans up pre-v4.7.0 timestamped sudoers
+# variants (one stable file per user)
+# - [MED M1] Stdout AUTO_SKIP message redacts --key-raw / --key-url values
+# - [MED M2] --key-gh validates GitHub-username regex before URL substitution
+# - [MED M3] fetch_keys downloads via temp file (max-filesize errors no
+# longer masked by SIGPIPE → curl exit 0)
+# - [MED M4] rollback while-read tolerates torn last-line writes (no LF)
+# - [MED M5] pkill -KILL -u $user before userdel in rollback
+# - [MED M6] iptables-restore explicit warning about wiping other rules
+# - [MED M8] ipify probe uses --proto '=https' --max-redirs 2
+# - [LOW] --version / --help clean up TMP_DIR before exit
+# - [LOW] dropped GNU-only `xargs -r` (BusyBox compat)
+#
+# Changelog v4.7.0 (Enterprise Hardening):
+# - [CRIT] Defer `passwd -d` until key deployment confirmed (avoids passwordless+password-auth window)
+# - [CRIT] Rollback now tracks all created artifacts (sudoers, systemd override, motd, BBR, user)
+# - [CRIT] Rollback restores iptables.backup + re-validates sshd -t + listening port
+# - [CRIT] Clean `.bak_server_init` drop-ins on rollback
+# - [CRIT] All color output via printf '%b' (fixes dash literal-escape output)
+# - [CRIT] `--key-raw=` argv: apply printf %b for literal \n support
+# - [HIGH] restore.sh now verifies checksums.sha256 before applying
+# - [HIGH] Backup dir umask 077 + explicit chmod 700 (no more world-readable history)
+# - [HIGH] /tmp backup-dir fallback hardened (refuses pre-existing dir with wrong owner)
+# - [HIGH] fetch_keys: --max-filesize, --max-redirs, strict mode requires https
+# - [HIGH] validate_ssh_config uses sshd -T (Match-block aware) instead of grep|tail
+# - [HIGH] safe_configure_sudo checks wheel/sudo group membership
+# - [HIGH] Trap handler blocks re-entrant signals + explicit INT/TERM handlers
+# - [HIGH] update_motd preserves a real backup (.bak retained for one cycle)
+# - [MED] Stable sudoers.d filename per user (no more orphan files on re-run)
+# - [MED] Port lock cleanup on success
+# - [MED] `protect_sshd_service` runs even with --delay-restart
+# - [MED] chown verification post-deploy_keys (catches NFS root_squash silent failures)
+# - [MED] install_managed_block: mv guarded with die
+# - [MED] remove_managed_block tolerant of CRLF/whitespace markers
+# - [MED] Replace `echo "$user_input" | ...` with printf '%s\n' (bash/dash echo divergence)
+# - [MED] Health report uses if/then/else (no &&...|| precedence trap)
+# - [LOW] iptables.backup / backup.info / checksums.sha256 explicit chmod 600
+# - [LOW] LC_ALL=C exported (deterministic sort/awk regex)
+# - [LOW] Optional external IP lookup gated by --no-ip-probe
# =========================================================
set -u
+# [v4.7.0] Deterministic locale for sort/awk/sed/grep semantics
+LC_ALL=C
+LANGUAGE=C
+export LC_ALL LANGUAGE
+
+# [v4.7.8 FIX-HIGH] PATH hardening. The script invokes ~245 binaries; many
+# without `command -v` guards. If PATH is attacker-controlled (e.g. CI runner
+# env, sudo with env_keep+=PATH, a poisoned .bashrc sourced before sudo), an
+# attacker can plant fake `passwd`/`useradd`/`stat`/`whoami`/etc in $PATH
+# and the script will run them as root. Reset PATH to a known-safe default
+# BEFORE any external command runs. /usr/local/* first so legitimate local
+# installs (e.g. AlmaLinux's curl) still work. PATH=. is NEVER included.
+PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+export PATH
+
+print_usage() {
+ cat </ (restore.sh per-run)
+EOF
+}
+
+# --help / --version should not require root and should not acquire locks.
+for __early_arg in "$@"; do
+ case "$__early_arg" in
+ --version|-V)
+ echo "linux-ssh-init-sh v4.7.8 Enterprise Hardening"
+ exit 0 ;;
+ --help|-h)
+ print_usage
+ exit 0 ;;
+ esac
+done
+unset __early_arg
+
+if [ "$(id -u)" -ne 0 ]; then
+ __early_lang="zh"
+ for __early_arg in "$@"; do
+ case "$__early_arg" in
+ --lang=en) __early_lang="en" ;;
+ --lang=zh) __early_lang="zh" ;;
+ esac
+ done
+ if [ "$__early_lang" = "en" ]; then
+ echo "Must be run as root"
+ else
+ echo "必须以 root 权限运行此脚本"
+ fi
+ exit 1
+fi
+unset __early_lang __early_arg
+
SCRIPT_START_TIME=$(date +%s)
# ---------------- Configuration ----------------
@@ -38,6 +385,11 @@ SSH_CONF_D="/etc/ssh/sshd_config.d"
DEFAULT_USER="deploy"
BLOCK_BEGIN="# BEGIN SERVER-INIT MANAGED BLOCK"
BLOCK_END="# END SERVER-INIT MANAGED BLOCK"
+# [v4.7.3 FIX-H5] Persist the last-applied SSH port across script runs so
+# that on the next run with a DIFFERENT port we can clean the firewall rule
+# we previously installed. The file lives under /var/lib/server-init (root-
+# only, persists across reboots).
+LAST_APPLIED_FILE="/var/lib/server-init/last-applied"
# ---------------- [SEC] Atomic Secure Temp Directory ----------------
old_umask=$(umask)
@@ -91,12 +443,85 @@ chmod 700 "$RUNTIME_DIR" 2>/dev/null || true
STATE_FILE="$RUNTIME_DIR/state-$(id -u)"
LOCK_DIR="$RUNTIME_DIR/locks-$(id -u)"
+# [v4.7.6 FIX-HIGH-N1] Script-level mutex. Two concurrent invocations of
+# this script can interleave-corrupt /etc/ssh/sshd_config (both rename
+# drop-ins, both install managed blocks, one's rollback wipes the other's
+# work). The lock is a directory (mkdir is atomic) holding the active PID.
+# Cleared by cleanup_locks() on normal exit. Stale entries (PID gone) are
+# auto-reclaimed by the next start.
+SCRIPT_LOCK_DIR="$RUNTIME_DIR/script.lock"
[ -L "$STATE_FILE" ] && rm -f "$STATE_FILE" 2>/dev/null || true
if [ -e "$LOCK_DIR" ] && [ ! -d "$LOCK_DIR" ]; then
rm -f "$LOCK_DIR" 2>/dev/null || true
fi
+# [v4.7.6] Acquire script-level mutex. We do this BEFORE any state mutation
+# (well before setup_rollback). If we fail to acquire, refuse to start.
+if mkdir "$SCRIPT_LOCK_DIR" 2>/dev/null; then
+ echo "$$" > "$SCRIPT_LOCK_DIR/pid" 2>/dev/null || true
+else
+ # Lock exists. Check if the holder is still alive.
+ holder_pid=""
+ if [ -r "$SCRIPT_LOCK_DIR/pid" ]; then
+ holder_pid=$(head -1 "$SCRIPT_LOCK_DIR/pid" 2>/dev/null | tr -d '\n\r ')
+ fi
+ # [v4.7.7 FIX-MED] Liveness detection uses BOTH /proc/$pid AND kill -0.
+ # The kill -0 fallback works in chroots without /proc (where the previous
+ # form falsely declared "stale" → reclaim → two scripts both proceed →
+ # sshd_config corruption). The unknown-result third branch refuses rather
+ # than racing.
+ holder_alive="unknown"
+ if [ -n "$holder_pid" ]; then
+ if [ -d "/proc/$holder_pid" ]; then
+ holder_alive="yes"
+ elif kill -0 "$holder_pid" 2>/dev/null; then
+ holder_alive="yes"
+ elif [ -d "/proc/1" ] || command -v kill >/dev/null 2>&1; then
+ # /proc is available (or we have kill) AND neither method found the
+ # PID → genuinely stale, safe to reclaim.
+ holder_alive="no"
+ fi
+ fi
+ case "$holder_alive" in
+ yes)
+ echo "FATAL: Another linux-ssh-init-sh run is in progress (PID $holder_pid)" >&2
+ echo " If you're sure it crashed, remove $SCRIPT_LOCK_DIR and retry." >&2
+ exit 1 ;;
+ no)
+ rm -rf "$SCRIPT_LOCK_DIR" 2>/dev/null || true
+ if ! mkdir "$SCRIPT_LOCK_DIR" 2>/dev/null; then
+ echo "FATAL: Cannot acquire script lock at $SCRIPT_LOCK_DIR" >&2
+ exit 1
+ fi
+ echo "$$" > "$SCRIPT_LOCK_DIR/pid" 2>/dev/null || true ;;
+ unknown)
+ # Cannot determine liveness (no /proc, no kill, or empty pid file).
+ # Refuse rather than risk a concurrent-corruption race.
+ echo "FATAL: Lock $SCRIPT_LOCK_DIR exists but liveness check is ambiguous" >&2
+ echo " (no /proc and/or empty PID file). Verify no other run is in" >&2
+ echo " progress, then remove $SCRIPT_LOCK_DIR manually and retry." >&2
+ exit 1 ;;
+ esac
+fi
+
+# [v4.7.8 FIX] The script-level mutex is acquired before argv parsing so
+# concurrent invocations are blocked early. That also means every pre-rollback
+# exit path (--help/--version, bad flags, root/preflight/input failures) must
+# release it. Once setup_rollback() installs the real rollback trap, this
+# lightweight cleanup trap is replaced.
+early_cleanup_before_rollback() {
+ [ -n "${STATE_FILE:-}" ] && rm -f "$STATE_FILE" 2>/dev/null || true
+ [ -n "${ARTIFACT_MIRROR:-}" ] && rm -f "$ARTIFACT_MIRROR" 2>/dev/null || true
+ [ -n "${LOCK_DIR:-}" ] && [ -d "$LOCK_DIR" ] && rm -rf "$LOCK_DIR" 2>/dev/null || true
+ [ -n "${SCRIPT_LOCK_DIR:-}" ] && [ -d "$SCRIPT_LOCK_DIR" ] && rm -rf "$SCRIPT_LOCK_DIR" 2>/dev/null || true
+ [ -n "${TMP_DIR:-}" ] && [ -d "$TMP_DIR" ] && rm -rf "$TMP_DIR" 2>/dev/null || true
+ case "${RUNTIME_DIR:-}" in
+ /tmp/*) [ -d "$RUNTIME_DIR" ] && rm -rf "$RUNTIME_DIR" 2>/dev/null || true ;;
+ esac
+}
+trap 'early_cleanup_before_rollback' EXIT
+
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
@@ -114,6 +539,31 @@ KEY_TYPE=""
KEY_VAL=""
DO_UPDATE="n"
DO_BBR="n"
+# [v4.7.1] Track whether THIS script run created the target user. Used so that
+# finalize_user_password_policy does not wipe an existing admin's password.
+USER_WAS_CREATED="n"
+# [v4.7.2] Track whether sudo configuration succeeded for the (new) user.
+# Used so that finalize_user_password_policy does not clear the password on
+# a brand-new account that ALSO lacks sudo — that combo gives a key-only
+# account with no admin privileges, which is rarely what the operator wanted.
+SUDO_DEPLOYED="n"
+# [v4.7.2] Surfaces "the target account is locked and cannot SSH right now"
+# in print_final_summary + generate_health_report + audit_log. Otherwise the
+# operator sees the success banner with the ssh command line and only later
+# discovers the account is unusable.
+ACCOUNT_LOCKED_REASON=""
+# [v4.7.4] "Should record_last_applied persist SELINUX_PORT=N for this run?"
+# Set "y" by handle_selinux if NOOP/ADDED/MODIFIED (anything that means our
+# port is currently labeled ssh_port_t).
+SELINUX_LABEL_APPLIED="n"
+# [v4.7.5] "Did THIS run install or retype the label, i.e. may rollback
+# `semanage port -d` it without destroying pre-existing state?" "y" only on
+# ADDED or MODIFIED paths; "n" on NOOP (label pre-existed independently).
+SELINUX_LABEL_OWNED_BY_RUN="n"
+# Note: a previous v4.7.5 draft introduced SELINUX_LABEL_PREEXISTED here, but
+# the actual provenance signal that downstream code consumes is
+# SELINUX_LABEL_OWNED_BY_RUN above (which is the inverse). The PREEXISTED
+# flag is therefore intentionally NOT defined to avoid SC2034 unused-var.
OPENSSH_VER_MAJOR=0
OPENSSH_VER_MINOR=0
@@ -135,35 +585,140 @@ ARG_BBR=""
AUTO_CONFIRM="n"
STRICT_MODE="n"
ARG_DELAY_RESTART="n"
+ARG_NO_IP_PROBE="n"
# Parse Arguments
+# [v4.7.6 FIX-MED] Track first-set state so duplicate --user / --port /
+# --key-* don't silently last-win. Operators copy-pasting CI templates can
+# unintentionally specify both --key-gh and --key-raw; refuse instead.
+__seen_user=""; __seen_port=""; __seen_key=""
for a in "$@"; do
case "$a" in
--lang=zh) LANG_CUR="zh" ;;
--lang=en) LANG_CUR="en" ;;
--strict) STRICT_MODE="y" ;;
--yes) AUTO_CONFIRM="y" ;;
- --user=*) ARG_USER="${a#*=}" ;;
- --port=random) ARG_PORT="random" ;;
- --port=*) ARG_PORT="${a#*=}" ;;
- --key-gh=*) ARG_KEY_TYPE="gh"; ARG_KEY_VAL="${a#*=}" ;;
- --key-url=*) ARG_KEY_TYPE="url"; ARG_KEY_VAL="${a#*=}" ;;
- --key-raw=*) ARG_KEY_TYPE="raw"; ARG_KEY_VAL="${a#*=}" ;;
+ --user=*)
+ if [ -n "$__seen_user" ]; then
+ printf '[FATAL] --user specified more than once\n' >&2; exit 1
+ fi
+ __seen_user="y"; ARG_USER="${a#*=}" ;;
+ --port=random)
+ if [ -n "$__seen_port" ]; then
+ printf '[FATAL] --port specified more than once\n' >&2; exit 1
+ fi
+ __seen_port="y"; ARG_PORT="random" ;;
+ --port=*)
+ if [ -n "$__seen_port" ]; then
+ printf '[FATAL] --port specified more than once\n' >&2; exit 1
+ fi
+ __seen_port="y"; ARG_PORT="${a#*=}" ;;
+ --key-gh=*)
+ if [ -n "$__seen_key" ]; then
+ printf '[FATAL] only one of --key-gh / --key-url / --key-raw may be given\n' >&2; exit 1
+ fi
+ __seen_key="y"; ARG_KEY_TYPE="gh"; ARG_KEY_VAL="${a#*=}" ;;
+ --key-url=*)
+ if [ -n "$__seen_key" ]; then
+ printf '[FATAL] only one of --key-gh / --key-url / --key-raw may be given\n' >&2; exit 1
+ fi
+ __seen_key="y"; ARG_KEY_TYPE="url"; ARG_KEY_VAL="${a#*=}" ;;
+ --key-raw=*)
+ if [ -n "$__seen_key" ]; then
+ printf '[FATAL] only one of --key-gh / --key-url / --key-raw may be given\n' >&2; exit 1
+ fi
+ __seen_key="y"
+ ARG_KEY_TYPE="raw"
+ # [v4.7.0] Interpret literal \n / \t in argv so multi-line keys can be
+ # passed as a single shell-quoted argument.
+ # [v4.7.3 FIX-MED] Reject \c and \0 escapes BEFORE printf %b — `\c`
+ # would silently truncate the rest of the key material. Real SSH keys
+ # never contain these, so refusal is safe.
+ # [v4.7.4 FIX-LOW] Cannot use die() here — argv parsing runs BEFORE
+ # function definitions; an undefined die call would "command not found"
+ # silently and proceed (bypassing the check). Use raw printf+exit.
+ __raw_in="${a#*=}"
+ case "$__raw_in" in
+ *'\c'*|*'\0'*)
+ printf '[FATAL] Refusing --key-raw containing \\c or \\0 escape (truncation hazard)\n' >&2
+ exit 1 ;;
+ esac
+ ARG_KEY_VAL="$(printf '%b' "$__raw_in")"
+ unset __raw_in
+ ;;
--update) ARG_UPDATE="y" ;;
--no-update) ARG_UPDATE="n" ;;
--bbr) ARG_BBR="y" ;;
--no-bbr) ARG_BBR="n" ;;
--delay-restart) ARG_DELAY_RESTART="y" ;;
+ --no-ip-probe) ARG_NO_IP_PROBE="y" ;;
+ --version|-V)
+ echo "linux-ssh-init-sh v4.7.8 Enterprise Hardening"
+ # [v4.7.1] Clean up tmp/runtime dirs created at script entry so that
+ # scripted `--version` invocations don't accumulate empty directories.
+ [ -n "${TMP_DIR:-}" ] && [ -d "$TMP_DIR" ] && rm -rf "$TMP_DIR" 2>/dev/null
+ [ -n "${RUNTIME_DIR:-}" ] && [ -d "$RUNTIME_DIR" ] && case "$RUNTIME_DIR" in /tmp/*) rm -rf "$RUNTIME_DIR" 2>/dev/null ;; esac
+ exit 0 ;;
+ --help|-h)
+ cat </ (restore.sh per-run)
+EOF
+ # [v4.7.1] Same tmp-dir cleanup as --version.
+ [ -n "${TMP_DIR:-}" ] && [ -d "$TMP_DIR" ] && rm -rf "$TMP_DIR" 2>/dev/null
+ [ -n "${RUNTIME_DIR:-}" ] && [ -d "$RUNTIME_DIR" ] && case "$RUNTIME_DIR" in /tmp/*) rm -rf "$RUNTIME_DIR" 2>/dev/null ;; esac
+ exit 0 ;;
esac
done
+# [v4.7.6 FIX-LOW] Reject empty --port=, --key-* values explicitly.
+# Without this, `--port= --yes` falls through to interactive prompt → EOF →
+# default to port 22 → silent hardening downgrade.
+if [ "${__seen_port:-}" = "y" ] && [ -z "$ARG_PORT" ]; then
+ printf '[FATAL] --port= requires a value (22, random, or 1024-65535)\n' >&2; exit 1
+fi
+if [ -n "$ARG_KEY_TYPE" ] && [ -z "$ARG_KEY_VAL" ]; then
+ printf '[FATAL] --key-%s= requires a value\n' "$ARG_KEY_TYPE" >&2; exit 1
+fi
+unset __seen_user __seen_port __seen_key
+
# ---------------- Internationalization ----------------
msg() {
key="$1"
if [ "$LANG_CUR" = "zh" ]; then
case "$key" in
MUST_ROOT) echo "必须以 root 权限运行此脚本" ;;
- BANNER) echo "服务器初始化 & SSH 安全加固 (v4.6.2 Fortress Pro)" ;;
+ BANNER) echo "服务器初始化 & SSH 安全加固 (v4.7.8 Enterprise Hardening)" ;;
STRICT_ON) echo "STRICT 模式已开启:任何关键错误将直接退出" ;;
ASK_USER) echo "SSH 登录用户 (默认 " ;;
ERR_USER_INV) echo "❌ 用户名无效 (仅限小写字母/数字/下划线,且避开系统保留名)" ;;
@@ -301,7 +856,7 @@ msg() {
else
case "$key" in
MUST_ROOT) echo "Must be run as root" ;;
- BANNER) echo "Server Init & SSH Hardening (v4.6.2 Fortress Pro)" ;;
+ BANNER) echo "Server Init & SSH Hardening (v4.7.8 Enterprise Hardening)" ;;
STRICT_ON) echo "STRICT mode ON: Critical errors will abort" ;;
ASK_USER) echo "SSH Login User (default " ;;
ERR_USER_INV) echo "❌ Invalid username (lowercase/digits/underscore only, no reserved words)" ;;
@@ -442,6 +997,13 @@ msg() {
# ---------------- Logging & Audit ----------------
init_log_files() {
for logfile in "$LOG_FILE" "$AUDIT_FILE"; do
+ # [v4.7.4 FIX-LOW] Reject pre-planted symlinks at log paths. A symlink
+ # in /var/log (root-owned 755) requires prior root-equiv write, but as
+ # defense-in-depth we remove it before touch follows it to e.g.
+ # /etc/shadow. Helper unlink_if_symlink is defined later, so inline here.
+ if [ -L "$logfile" ]; then
+ rm -f "$logfile" 2>/dev/null || true
+ fi
if [ -f "$logfile" ]; then
if [ ! -w "$logfile" ]; then
logfile_new="${logfile}.$(date +%s)"
@@ -468,22 +1030,62 @@ log() { echo "$(date '+%F %T') $*" >>"$LOG_FILE" 2>/dev/null || true; }
audit_log() {
action="$1"
details="$2"
+ # [v4.7.6 FIX-HIGH-N3] Strip newlines from action/details before writing.
+ # Without this, a $details value containing "\n---\nACTION: FAKE" could
+ # inject forged audit entries. $details can transitively come from
+ # untrusted sources (e.g. paths derived from /etc/passwd field 6 which
+ # ends up in KEYS_DEPLOYED "auth=..." details). Replace LF/CR with space.
+ action=$(printf '%s' "$action" | tr -d '\n\r')
+ details=$(printf '%s' "$details" | tr '\n\r' ' ')
{
echo "=== $(date '+%F %T') ==="
echo "ACTION: $action"
echo "USER: $(whoami 2>/dev/null || echo root)"
echo "DETAILS: $details"
echo "---"
- } >> "$AUDIT_FILE" 2>/dev/null || true
+ } >> "$AUDIT_FILE" 2>/dev/null || {
+ # [v4.7.7 FIX-LOW] Audit-log fallback to stderr when /var/log is full,
+ # read-only, or otherwise unwritable. The ROLLBACK entry itself is the
+ # most important forensic record — losing it silently would leave the
+ # operator with no idea why the rollback fired.
+ printf 'AUDIT-FALLBACK %s ACTION=%s DETAILS=%s\n' \
+ "$(date '+%F %T' 2>/dev/null || echo unknown)" "$action" "$details" >&2
+ }
log "[AUDIT] $action - $details"
}
-info() { printf "${BLUE}[INFO]${NC} %s\n" "$*"; log "[INFO] $*"; }
-warn() { printf "${YELLOW}[WARN]${NC} %s\n" "$*"; log "[WARN] $*"; }
-err() { printf "${RED}[ERR ]${NC} %s\n" "$*"; log "[ERR ] $*"; }
-ok() { printf "${GREEN}[ OK ]${NC} %s\n" "$*"; log "[OK] $*"; }
+# [v4.7.0] Color via printf %b (dash-safe: '\033[...' is interpreted, not literal)
+# IMPORTANT: never put $COLOR into the format-string slot — keep it as %b argument.
+info() { printf '%b[INFO]%b %s\n' "$BLUE" "$NC" "$*"; log "[INFO] $*"; }
+warn() { printf '%b[WARN]%b %s\n' "$YELLOW" "$NC" "$*"; log "[WARN] $*"; }
+err() { printf '%b[ERR ]%b %s\n' "$RED" "$NC" "$*"; log "[ERR ] $*"; }
+ok() { printf '%b[ OK ]%b %s\n' "$GREEN" "$NC" "$*"; log "[OK] $*"; }
die() { err "$*"; exit 1; }
+# [v4.7.4 FIX-LOW] Symlink-defense helper: if $1 is a symlink, remove it.
+# Prevents a planted symlink (planted by a previous root-equiv compromise)
+# from making `cat > FILE` or `echo >> FILE` write into the symlink target
+# (e.g. /etc/shadow). All write-targets in root-only directories use this.
+unlink_if_symlink() {
+ [ -L "$1" ] || return 0
+ rm -f "$1" 2>/dev/null || true
+}
+
+# [v4.7.7 FIX-HIGH] Detect chroot-without-PID-namespace. Used by:
+# - direct-spawn sshd refusal (avoid `pkill -x sshd` killing host sshd)
+# - script-level mutex stale-PID detection (without /proc we can't tell
+# "stale lock" from "live peer", so refuse rather than reclaim)
+# Returns 0 if chroot/ambiguous, 1 if confirmed real root.
+in_chroot_or_unknown() {
+ # If /proc is unavailable, we can't determine — treat as chroot for safety.
+ [ -d /proc/1/root ] || return 0
+ # Compare root inode of the script's view vs PID 1's root.
+ root_now=$(stat -c '%d:%i' / 2>/dev/null)
+ root_init=$(stat -c '%d:%i' /proc/1/root/ 2>/dev/null)
+ [ -z "$root_now" ] || [ -z "$root_init" ] && return 0
+ [ "$root_now" != "$root_init" ]
+}
+
# =========================================================
# Core Logic Functions
# =========================================================
@@ -568,6 +1170,11 @@ install_pkg_try() {
}
update_system() {
+ # [v4.7.8 FIX-MED W1-1] Forensic record: a system-wide package upgrade is a
+ # significant mutation that prior versions did not audit. Operators
+ # investigating post-incident need to know whether the run touched
+ # packages.
+ audit_log "SYSTEM_UPDATE_START" "pm=$PM"
case "$PM" in
apt) pm_prepare_once; DEBIAN_FRONTEND=noninteractive apt-get upgrade -y >>"$LOG_FILE" 2>&1 ;;
yum) pm_prepare_once;
@@ -575,34 +1182,121 @@ update_system() {
else yum update -y >>"$LOG_FILE" 2>&1; fi ;;
apk) pm_prepare_once; apk upgrade >>"$LOG_FILE" 2>&1 ;;
esac
+ audit_log "SYSTEM_UPDATE_DONE" "pm=$PM rc=$?"
}
# ---------------- SSHD Restart ----------------
+# [v4.7.1] Args:
+# $1 (optional) — "force" bypasses the --delay-restart no-op (used by
+# rollback_handler, which must always relaunch sshd).
restart_sshd() {
- if [ "$ARG_DELAY_RESTART" = "y" ]; then
+ force_mode="${1:-}"
+
+ if [ "$ARG_DELAY_RESTART" = "y" ] && [ "$force_mode" != "force" ]; then
warn "DELAY RESTART: Please manually restart sshd later."
return 0
fi
+ # [v4.7.8 FIX-HIGH-W4] Detect chroot BEFORE the systemctl path. With /run/dbus
+ # bind-mounted into the chroot (common), `systemctl restart sshd` would
+ # cross the chroot boundary via D-Bus and restart the HOST's sshd using the
+ # CHROOT's modified config — silently breaking the host. Previously the
+ # chroot guard only fired inside the direct-spawn fallback (after systemctl
+ # had already run). Move the guard to the top of the function.
+ if in_chroot_or_unknown; then
+ err "Refusing to restart sshd: this process appears to be in a chroot"
+ err " (or environment without /proc). Any restart path could reach the"
+ err " HOST's sshd via D-Bus/PID-NS leakage and apply this chroot's config"
+ err " to the host. Use --delay-restart and restart sshd manually outside"
+ err " the chroot."
+ audit_log "RESTART_REFUSED" "reason=chroot_or_no_proc"
+ return 1
+ fi
+
res=1
+
+ # ----- 1. systemd -----
if command -v systemctl >/dev/null 2>&1; then
- # [兼容性优化] 尝试停止 socket。
- # 如果系统没有 ssh.socket (如 CentOS),命令会失败,但 || true 会让脚本继续执行,不会报错退出。
systemctl stop ssh.socket >/dev/null 2>&1 || true
systemctl disable ssh.socket >/dev/null 2>&1 || true
-
- # 原有的重启逻辑
- systemctl restart sshd >>"$LOG_FILE" 2>&1 || systemctl restart ssh >>"$LOG_FILE" 2>&1
+ if systemctl restart sshd >>"$LOG_FILE" 2>&1 || systemctl restart ssh >>"$LOG_FILE" 2>&1; then
+ return 0
+ fi
res=$?
- elif command -v rc-service >/dev/null 2>&1; then
- rc-service sshd restart >>"$LOG_FILE" 2>&1
+ fi
+
+ # ----- 2. OpenRC -----
+ if command -v rc-service >/dev/null 2>&1; then
+ rc-service sshd restart >>"$LOG_FILE" 2>&1 && return 0
res=$?
- elif command -v service >/dev/null 2>&1; then
- service sshd restart >>"$LOG_FILE" 2>&1 || service ssh restart >>"$LOG_FILE" 2>&1
+ fi
+
+ # ----- 3. SysV service -----
+ if command -v service >/dev/null 2>&1; then
+ service sshd restart >>"$LOG_FILE" 2>&1 && return 0
+ service ssh restart >>"$LOG_FILE" 2>&1 && return 0
+ res=$?
+ fi
+
+ # ----- 4. init.d -----
+ if [ -x /etc/init.d/sshd ]; then
+ /etc/init.d/sshd restart >>"$LOG_FILE" 2>&1 && return 0
+ fi
+ if [ -x /etc/init.d/ssh ]; then
+ /etc/init.d/ssh restart >>"$LOG_FILE" 2>&1 && return 0
+ fi
+
+ # ----- 5. [v4.7.1 FIX-R4/H5] Direct-spawn fallback for minimal containers,
+ # chroot environments, or any host where no init system is reachable
+ # but the sshd binary is present. This is what makes the script
+ # actually work inside e.g. a plain `docker run ubuntu:22.04` (which
+ # has no PID 1 systemd) and inside test containers in CI.
+ if command -v sshd >/dev/null 2>&1; then
+ # [v4.7.3 FIX-MED] If the operator is running over SSH, the direct-spawn
+ # fallback would kill the operator's own session. Detect via SSH_CONNECTION
+ # env var (present in interactive ssh sessions) and refuse unconditionally
+ # — there is no flag to override this. The correct workflow is: reconnect
+ # via console/VNC, then re-run.
+ if [ -n "${SSH_CONNECTION:-}" ]; then
+ err "Direct-spawn fallback would kill sshd — but you appear to be running over SSH:"
+ err " SSH_CONNECTION=$SSH_CONNECTION"
+ err " Killing all sshd processes would drop your own session."
+ err " Refusing. Reconnect via console/VNC, then re-run the script."
+ audit_log "DIRECT_SPAWN_REFUSED" "SSH_CONNECTION=$SSH_CONNECTION"
+ return 1
+ fi
+ # [v4.7.7 FIX-HIGH] Even without SSH_CONNECTION, running in a chroot
+ # WITHOUT a separate PID namespace means `pkill -x sshd` reaches the
+ # HOST's sshd processes — including any SSH sessions terminating to the
+ # host. This is exactly the "operator opened console, then chroot
+ # /mnt/target, then ran script" scenario. Refuse.
+ if in_chroot_or_unknown; then
+ err "Direct-spawn fallback would run pkill -x sshd, but this process appears to be"
+ err " in a chroot (or environment where /proc is missing). pkill would target the"
+ err " HOST's sshd (different PID namespace not detected). Refusing to avoid mass"
+ err " SSH disconnect on the host. Run from a proper container/VM or skip --delay-restart."
+ audit_log "DIRECT_SPAWN_REFUSED" "reason=chroot_or_no_proc"
+ return 1
+ fi
+ info "No init system available — performing direct sshd spawn"
+ audit_log "DIRECT_SPAWN" "config=$SSH_CONF"
+ # Validate the file before killing the live instance — if syntax is bad
+ # we'd otherwise lock ourselves out.
+ if ! sshd -t -f "$SSH_CONF" >>"$LOG_FILE" 2>&1; then
+ err "Direct-spawn: sshd -t failed against $SSH_CONF"
+ return 1
+ fi
+ # Try graceful first.
+ pkill -TERM -x sshd >>"$LOG_FILE" 2>&1 || true
+ # Brief settle.
+ sleep 1
+ # If sshd is still around, escalate.
+ pkill -KILL -x sshd >>"$LOG_FILE" 2>&1 || true
+ # Re-launch (daemon mode by default).
+ if sshd -f "$SSH_CONF" >>"$LOG_FILE" 2>&1; then
+ return 0
+ fi
res=$?
- else
- if [ -x /etc/init.d/sshd ]; then /etc/init.d/sshd restart >>"$LOG_FILE" 2>&1 && res=0; fi
- if [ -x /etc/init.d/ssh ]; then /etc/init.d/ssh restart >>"$LOG_FILE" 2>&1 && res=0; fi
fi
return "$res"
@@ -650,11 +1344,35 @@ check_previous_state() {
fi
warn "$(msg WARN_RESUME)"
-
+
+ # [v4.7.0] Surface previous phase in the warning instead of just discarding it
prev_phase=$(parse_state_value "PHASE" "$STATE_FILE")
prev_user=$(parse_state_value "USER" "$STATE_FILE")
prev_port=$(parse_state_value "PORT" "$STATE_FILE")
-
+ [ -n "$prev_phase" ] && warn " - previous phase: $prev_phase (user=$prev_user port=$prev_port)"
+
+ # [v4.7.6 FIX-HIGH-N2] If a previous run crashed mid-way, its artifact
+ # mirror in RUNTIME_DIR will be non-empty. Surface the artifacts so the
+ # operator knows what mutations are stranded.
+ prev_mirror="$RUNTIME_DIR/last-artifacts-$(id -u)"
+ if [ -s "$prev_mirror" ]; then
+ warn " ⚠ Previous run left STRANDED mutations the new run cannot roll back:"
+ __stranded_count=0
+ while IFS= read -r __fact; do
+ if [ -n "$__fact" ]; then
+ warn " - $__fact"
+ __stranded_count=$((__stranded_count + 1))
+ fi
+ done < "$prev_mirror"
+ warn " Manual cleanup may be required (delete users / sudoers / firewall rules)."
+ # [v4.7.8 FIX-MED W8-1] Audit-log the stranded state so SIEM / scripted
+ # cron `--yes` runs don't silently accumulate detected-but-ignored
+ # mutations. The detailed list is in $LOG_FILE; the audit entry just
+ # records the count so an alert can fire.
+ audit_log "STRANDED_MUTATIONS_DETECTED" "count=$__stranded_count mirror=$prev_mirror"
+ unset __stranded_count __fact
+ fi
+
if [ "$AUTO_CONFIRM" != "y" ]; then
printf "%s" "$(msg ASK_RESUME)"
read -r continue_resume
@@ -663,43 +1381,298 @@ check_previous_state() {
exit 1
fi
fi
-
+
[ -n "$prev_user" ] && [ -z "$ARG_USER" ] && TARGET_USER="$prev_user"
[ -n "$prev_port" ] && [ -z "$ARG_PORT" ] && SSH_PORT="$prev_port"
fi
}
-cleanup_state() { rm -f "$STATE_FILE" 2>/dev/null || true; }
+cleanup_state() {
+ rm -f "$STATE_FILE" 2>/dev/null || true
+ # [v4.7.6 FIX-HIGH-N2] Clear the artifact mirror on success; the prev_mirror
+ # detection above only fires when a previous run left a NON-EMPTY mirror.
+ [ -n "${ARTIFACT_MIRROR:-}" ] && rm -f "$ARTIFACT_MIRROR" 2>/dev/null || true
+}
cleanup_locks() {
if [ -n "${LOCK_DIR:-}" ] && [ -d "$LOCK_DIR" ]; then
rm -rf "$LOCK_DIR" 2>/dev/null || true
fi
+ # [v4.7.6 FIX-HIGH-N1] Release script-level mutex on every exit path.
+ if [ -n "${SCRIPT_LOCK_DIR:-}" ] && [ -d "$SCRIPT_LOCK_DIR" ]; then
+ rm -rf "$SCRIPT_LOCK_DIR" 2>/dev/null || true
+ fi
+}
+
+# [v4.7.0 / v4.7.6] Artifact tracking. Each line in $ROLLBACK_DIR/artifacts
+# is a fact the rollback handler can undo. Append-only, line-oriented,
+# parsed in reverse.
+# [v4.7.6 FIX-HIGH-N2] Also mirror appends into $RUNTIME_DIR/last-artifacts-UID
+# so a script killed by SIGKILL / OOM / power loss can be recovered by the
+# next run (TMP_DIR is per-mktemp and lost on hard-kill; RUNTIME_DIR survives).
+ARTIFACT_FILE=""
+ARTIFACT_MIRROR=""
+
+artifact_track() {
+ fact="$1"
+ if [ -n "$ARTIFACT_FILE" ]; then
+ printf '%s\n' "$fact" >> "$ARTIFACT_FILE" 2>/dev/null || true
+ fi
+ if [ -n "$ARTIFACT_MIRROR" ]; then
+ printf '%s\n' "$fact" >> "$ARTIFACT_MIRROR" 2>/dev/null || true
+ fi
+}
+
+# Reverse a file line-by-line in pure POSIX awk (no `tac` dependency).
+__reverse_lines() {
+ awk '{ a[NR] = $0 } END { for (i = NR; i >= 1; i--) print a[i] }' "$1" 2>/dev/null
}
rollback_handler() {
RET=$?
- trap - INT TERM EXIT HUP
+ # [v4.7.0] Disarm signals immediately so a SIGINT mid-rollback can't re-enter.
+ trap '' INT TERM HUP
+ trap - EXIT
if [ "$RET" -ne 0 ]; then
warn ""
warn "$(msg RB_START)"
- if [ -f "$ROLLBACK_DIR/sshd_config" ]; then
- cp -p "$ROLLBACK_DIR/sshd_config" "$SSH_CONF" 2>/dev/null || true
- chmod 600 "$SSH_CONF" 2>/dev/null || true
- fi
-
- if [ -d "$ROLLBACK_DIR/sshd_config.d" ] && [ -d "$SSH_CONF_D" ]; then
- for f in "$ROLLBACK_DIR/sshd_config.d"/*; do
- [ -f "$f" ] || continue
- cp -p "$f" "$SSH_CONF_D"/ 2>/dev/null || true
+ # 1. Undo recorded artifacts in REVERSE chronological order.
+ # [v4.7.1 FIX-M4] `|| [ -n "$fact" ]` ensures the loop also processes
+ # the LAST line even if a torn write (disk full) left it without LF.
+ if [ -n "$ARTIFACT_FILE" ] && [ -f "$ARTIFACT_FILE" ]; then
+ __reverse_lines "$ARTIFACT_FILE" | while IFS= read -r fact || [ -n "$fact" ]; do
+ case "$fact" in
+ USER_CREATED=*)
+ u="${fact#USER_CREATED=}"
+ if id "$u" >/dev/null 2>&1; then
+ warn " - removing user $u (and home dir)"
+ # [v4.7.1 FIX-M5] Kill any processes owned by the user first,
+ # otherwise userdel refuses to remove "user is currently used".
+ if command -v pkill >/dev/null 2>&1; then
+ pkill -KILL -u "$u" >>"$LOG_FILE" 2>&1 || true
+ sleep 1
+ fi
+ userdel_ok=0
+ if command -v userdel >/dev/null 2>&1; then
+ userdel -r "$u" >>"$LOG_FILE" 2>&1 && userdel_ok=1 || userdel "$u" >>"$LOG_FILE" 2>&1 && userdel_ok=1
+ elif command -v deluser >/dev/null 2>&1; then
+ deluser --remove-home "$u" >>"$LOG_FILE" 2>&1 && userdel_ok=1 || deluser "$u" >>"$LOG_FILE" 2>&1 && userdel_ok=1
+ fi
+ if [ "$userdel_ok" != "1" ]; then
+ audit_log "USER_REMOVE_FAILED" "Rollback could not remove user $u"
+ err " - userdel failed for $u (check $LOG_FILE)"
+ fi
+ fi
+ ;;
+ SUDOERS_FILE=*)
+ # [v4.7.6 FIX-MED-N5] Verify rm actually removed the file. On
+ # read-only /etc the rm silently fails; a stale sudoers entry
+ # plus a deleted user creates a "passwordless sudo for any
+ # future re-created user with the same name" privilege primitive.
+ f="${fact#SUDOERS_FILE=}"
+ if [ -f "$f" ]; then
+ warn " - removing $f"
+ rm -f "$f" 2>/dev/null || true
+ if [ -f "$f" ]; then
+ err " - FAILED to remove $f (read-only /etc?)"
+ audit_log "ROLLBACK_RM_FAILED" "$f"
+ rollback_clean=0
+ fi
+ fi
+ ;;
+ SYSTEMD_OVERRIDE=*)
+ f="${fact#SYSTEMD_OVERRIDE=}"
+ if [ -f "$f" ]; then
+ warn " - removing $f"
+ rm -f "$f" 2>/dev/null || true
+ if [ -f "$f" ]; then
+ err " - FAILED to remove $f"
+ audit_log "ROLLBACK_RM_FAILED" "$f"
+ rollback_clean=0
+ fi
+ fi
+ command -v systemctl >/dev/null 2>&1 && systemctl daemon-reload >/dev/null 2>&1 || true
+ ;;
+ PROFILE_D_BANNER=*)
+ f="${fact#PROFILE_D_BANNER=}"
+ if [ -f "$f" ]; then
+ warn " - removing $f"
+ rm -f "$f" 2>/dev/null || true
+ if [ -f "$f" ]; then
+ err " - FAILED to remove $f"
+ audit_log "ROLLBACK_RM_FAILED" "$f"
+ rollback_clean=0
+ fi
+ fi
+ ;;
+ FIREWALL_PORT=*)
+ # [v4.7.1 FIX-H2] Format is "backend:port" (e.g. "ufw:2222").
+ # Backwards-compatible: if a pre-v4.7.1 artifact file has just the
+ # port number, treat it as "iptables:port" (most likely).
+ spec="${fact#FIREWALL_PORT=}"
+ case "$spec" in
+ *:*) fw_backend="${spec%%:*}"; fw_p="${spec#*:}" ;;
+ *) fw_backend="iptables"; fw_p="$spec" ;;
+ esac
+ warn " - removing firewall rule (${fw_backend}) for tcp/$fw_p"
+ case "$fw_backend" in
+ ufw)
+ command -v ufw >/dev/null 2>&1 && ufw delete allow "${fw_p}/tcp" >>"$LOG_FILE" 2>&1 || true
+ ;;
+ firewalld)
+ if command -v firewall-cmd >/dev/null 2>&1; then
+ firewall-cmd --permanent --remove-port="${fw_p}/tcp" >>"$LOG_FILE" 2>&1 || true
+ firewall-cmd --reload >>"$LOG_FILE" 2>&1 || true
+ fi
+ ;;
+ iptables)
+ command -v iptables >/dev/null 2>&1 && iptables -D INPUT -p tcp --dport "$fw_p" -j ACCEPT >>"$LOG_FILE" 2>&1 || true
+ ;;
+ esac
+ # IPv6 companion — try to remove if it exists; harmless if not.
+ command -v ip6tables >/dev/null 2>&1 && ip6tables -D INPUT -p tcp --dport "$fw_p" -j ACCEPT >>"$LOG_FILE" 2>&1 || true
+ ;;
+ SELINUX_PORT=*)
+ # [v4.7.5] Safe to delete: artifact_track for SELINUX_PORT only
+ # fires when handle_selinux ADDED or MODIFIED (i.e. this run owns
+ # the label). The NOOP path no longer tracks, so rollback cannot
+ # delete a sysadmin-installed label by accident.
+ p="${fact#SELINUX_PORT=}"
+ if command -v semanage >/dev/null 2>&1; then
+ warn " - removing SELinux port label for $p"
+ semanage port -d -t ssh_port_t -p tcp "$p" >>"$LOG_FILE" 2>&1 || true
+ fi
+ ;;
+ BBR_SYSCTL=*)
+ f="${fact#BBR_SYSCTL=}"
+ [ -f "$f" ] || continue
+ warn " - reverting BBR sysctl lines from $f"
+ tmpf="${f}.rb.$$"
+ grep -vE '^(net\.core\.default_qdisc=fq|net\.ipv4\.tcp_congestion_control=bbr)$' "$f" > "$tmpf" 2>/dev/null && mv "$tmpf" "$f" 2>/dev/null || rm -f "$tmpf" 2>/dev/null
+ ;;
+ MOTD_BAK=*)
+ # [v4.7.1 FIX-R3 / v4.7.6 FIX-MED-N6] Restore /etc/motd from .bak.
+ # If .bak is missing (rare, e.g. it was manually removed), motd
+ # stays in its modified state — surface this as a non-clean
+ # rollback so the operator knows to manually clean up.
+ f="${fact#MOTD_BAK=}"
+ if [ -f "${f}.bak" ]; then
+ warn " - restoring $f from ${f}.bak"
+ if ! mv "${f}.bak" "$f" 2>/dev/null; then
+ err " failed to restore motd from ${f}.bak"
+ audit_log "ROLLBACK_MOTD_FAILED" "$f"
+ rollback_clean=0
+ fi
+ else
+ err " - MOTD .bak missing; $f stays in modified state"
+ audit_log "ROLLBACK_MOTD_BAK_MISSING" "$f"
+ rollback_clean=0
+ fi
+ ;;
+ esac
done
fi
- restart_sshd >/dev/null 2>&1 || true
- warn "$(msg RB_DONE)"
- audit_log "ROLLBACK" "System rolled back due to error code $RET"
+ # 2. [v4.7.1 FIX-R2] Rename `.bak_server_init` files BACK to their originals.
+ # Previously these were rm -f'd, which permanently lost user data when
+ # the snapshot directory was empty or partial. The original drop-in
+ # content lives in this renamed file — restore it by renaming back.
+ if [ -d "$SSH_CONF_D" ]; then
+ for f in "$SSH_CONF_D"/*.bak_server_init; do
+ [ -f "$f" ] || continue
+ orig="${f%.bak_server_init}"
+ warn " - restoring renamed drop-in: $f → $orig"
+ # If the snapshot has a fresher copy, prefer it; otherwise this rename
+ # is the only source of truth.
+ if [ -f "$ROLLBACK_DIR/sshd_config.d/$(basename "$orig")" ]; then
+ rm -f "$f" 2>/dev/null || true
+ else
+ mv "$f" "$orig" 2>/dev/null || warn " failed to rename"
+ fi
+ done
+ fi
+
+ # 3. Restore /etc/ssh/sshd_config.d/* — drop any files we ADDED during the
+ # run (not present in snapshot), then re-populate from snapshot.
+ if [ -d "$ROLLBACK_DIR/sshd_config.d" ] && [ -d "$SSH_CONF_D" ]; then
+ for f in "$SSH_CONF_D"/*; do
+ [ -f "$f" ] || continue
+ base=$(basename "$f")
+ case "$base" in
+ *.bak_server_init) continue ;; # already handled in stage 2
+ esac
+ # Only delete if NOT present in the snapshot.
+ if [ ! -f "$ROLLBACK_DIR/sshd_config.d/$base" ]; then
+ rm -f "$f" 2>/dev/null || true
+ fi
+ done
+ for f in "$ROLLBACK_DIR/sshd_config.d"/*; do
+ [ -f "$f" ] || continue
+ cp -p "$f" "$SSH_CONF_D"/ 2>/dev/null || warn " - failed to restore $f"
+ done
+ fi
+
+ # 4. Restore the main sshd_config from snapshot.
+ if [ -f "$ROLLBACK_DIR/sshd_config" ]; then
+ if ! cp -p "$ROLLBACK_DIR/sshd_config" "$SSH_CONF" 2>/dev/null; then
+ err " - FAILED to restore $SSH_CONF from snapshot"
+ fi
+ chmod 600 "$SSH_CONF" 2>/dev/null || true
+ fi
+
+ # 5. Restore iptables (now actually used — v4.6.x had captured but never applied).
+ if [ -f "$ROLLBACK_DIR/iptables.backup" ] && command -v iptables-restore >/dev/null 2>&1; then
+ # [v4.7.1 FIX-M6] iptables-restore replaces the entire ruleset with the
+ # snapshot. Rules added by OTHER services since setup_rollback (e.g.
+ # fail2ban triggers) will be wiped. Warn loudly.
+ warn " - iptables-restore: replacing live ruleset with snapshot taken at script start"
+ warn " (rules added by other services since then will be lost)"
+ iptables-restore < "$ROLLBACK_DIR/iptables.backup" 2>>"$LOG_FILE" || warn " - iptables-restore failed"
+ fi
+
+ # 6. Validate restored config & relaunch sshd. If either fails we MUST shout.
+ # [v4.7.2 FIX] Only relaunch sshd when the restored config passes
+ # `sshd -t`. Previously we restarted regardless, which could push a
+ # broken config to the live daemon (worst-case: sshd crashes and the
+ # machine is unreachable). When validation fails, we skip the restart
+ # so the LIVE sshd keeps running its current (pre-rollback) in-memory
+ # config — buying the operator time to reach VNC/console.
+ rollback_clean=1
+ config_ok=1
+ if command -v sshd >/dev/null 2>&1; then
+ if ! sshd -t -f "$SSH_CONF" 2>>"$LOG_FILE"; then
+ rollback_clean=0
+ config_ok=0
+ err " - restored sshd_config failed sshd -t — NOT restarting sshd to avoid pushing a broken config"
+ fi
+ fi
+ if [ "$config_ok" = "1" ]; then
+ # [v4.7.1 FIX-H4] Force-restart so --delay-restart runs still relaunch
+ # the daemon during rollback.
+ if ! restart_sshd force >/dev/null 2>&1; then
+ rollback_clean=0
+ err " - restart_sshd failed during rollback"
+ fi
+ fi
+
+ if [ "$rollback_clean" = "1" ]; then
+ ok "$(msg RB_DONE)"
+ else
+ err "================================================================"
+ err " ⚠ ROLLBACK COULD NOT FULLY RESTORE THE SYSTEM."
+ err " ⚠ Open a VNC/console session and manually verify:"
+ err " - $SSH_CONF syntax (sshd -t)"
+ err " - sshd is listening on port 22"
+ err " - $LOG_FILE for the failure trail"
+ err "================================================================"
+ fi
+ # [v4.7.3 FIX-H5] After a rollback, the LAST_APPLIED_FILE may point at a
+ # firewall rule that no longer exists (we just removed it). Clear it so
+ # the next run starts from a clean state.
+ rm -f "$LAST_APPLIED_FILE" 2>/dev/null || true
+ audit_log "ROLLBACK" "rc=$RET clean=$rollback_clean"
else
rm -rf "$TMP_DIR" 2>/dev/null || true
fi
@@ -710,24 +1683,73 @@ rollback_handler() {
setup_rollback() {
mkdir -p "$ROLLBACK_DIR" 2>/dev/null || true
+ chmod 700 "$ROLLBACK_DIR" 2>/dev/null || true
+
+ ARTIFACT_FILE="$ROLLBACK_DIR/artifacts"
+ : > "$ARTIFACT_FILE" 2>/dev/null || true
+ chmod 600 "$ARTIFACT_FILE" 2>/dev/null || true
+
+ # [v4.7.6 FIX-HIGH-N2] Also persist the artifact file path AND mirror its
+ # content into RUNTIME_DIR (which survives across script invocations,
+ # unlike TMP_DIR which is per-mktemp). If this run is killed by SIGKILL /
+ # OOM / power loss, the next script invocation can find the mirrored
+ # artifact list and offer recovery. The mirror is updated by artifact_track
+ # on every append.
+ # [v4.7.6 FIX-followup] Order matters: assign the path FIRST, then let
+ # check_previous_state read any orphaned content from a prior crashed run,
+ # THEN truncate for our run. The earlier v4.7.6 draft truncated BEFORE
+ # check_previous_state, making the orphan-surface logic dead code.
+ ARTIFACT_MIRROR="$RUNTIME_DIR/last-artifacts-$(id -u)"
check_previous_state
+
+ : > "$ARTIFACT_MIRROR" 2>/dev/null || true
+ chmod 600 "$ARTIFACT_MIRROR" 2>/dev/null || true
+
update_state "setup" "Init"
- [ -f "$SSH_CONF" ] && cp -p "$SSH_CONF" "$ROLLBACK_DIR/sshd_config" 2>/dev/null || true
+ # [v4.7.7 FIX-MED] Verify the snapshot cp actually produced a non-empty
+ # file. Previously a silent cp failure (ENOSPC, NFS hiccup, ENOTDIR) would
+ # leave a 0-byte snapshot; later rollback would cp it back over the live
+ # /etc/ssh/sshd_config, blanking it. sshd refuses to start with an empty
+ # config → operator locked out. Refuse to proceed when the snapshot is
+ # missing or empty.
+ if [ -f "$SSH_CONF" ]; then
+ if ! cp -p "$SSH_CONF" "$ROLLBACK_DIR/sshd_config" 2>>"$LOG_FILE"; then
+ die "setup_rollback: cp $SSH_CONF → $ROLLBACK_DIR/sshd_config FAILED (disk full?). Refusing to proceed without a rollback snapshot."
+ fi
+ if [ ! -s "$ROLLBACK_DIR/sshd_config" ]; then
+ die "setup_rollback: snapshot of $SSH_CONF is empty after cp (likely ENOSPC mid-write). Refusing to proceed."
+ fi
+ fi
+ chmod 600 "$ROLLBACK_DIR/sshd_config" 2>/dev/null || true
if [ -d "$SSH_CONF_D" ]; then
mkdir -p "$ROLLBACK_DIR/sshd_config.d" 2>/dev/null || true
+ chmod 700 "$ROLLBACK_DIR/sshd_config.d" 2>/dev/null || true
for f in "$SSH_CONF_D"/*; do
[ -f "$f" ] || continue
+ # [v4.7.3 FIX-H6] Skip .bak_server_init files when snapshotting —
+ # they're already-renamed originals from a prior crashed run and
+ # capturing them would let stage-3 of rollback restore stale content.
+ case "$f" in
+ *.bak_server_init|*.bak_server_init.[0-9]*) continue ;;
+ esac
cp -p "$f" "$ROLLBACK_DIR/sshd_config.d/" 2>/dev/null || true
done
fi
if command -v iptables-save >/dev/null 2>&1; then
iptables-save > "$ROLLBACK_DIR/iptables.backup" 2>/dev/null || true
+ chmod 600 "$ROLLBACK_DIR/iptables.backup" 2>/dev/null || true
fi
- trap 'rollback_handler' INT TERM EXIT HUP
+ # [v4.7.0] Explicit per-signal handling. EXIT trap fires unconditionally; the
+ # signal traps just `exit` with the standard 128+N code so EXIT sees a real
+ # non-zero $? (dash sets $? = 0 after a bare signal otherwise).
+ trap 'rollback_handler' EXIT
+ trap 'exit 130' INT
+ trap 'exit 143' TERM
+ trap 'exit 129' HUP
}
# ---------------- Persistent Backup ----------------
@@ -736,8 +1758,17 @@ cleanup_old_backups() {
keep_count=10
if [ -d "$BACKUP_REPO" ]; then
backup_list=$(ls -dt "$BACKUP_REPO"/*/ 2>/dev/null) || return 0
- count=$(echo "$backup_list" | grep -c . 2>/dev/null || echo 0)
- if [ -n "$count" ] && [ "$count" -gt "$keep_count" ] 2>/dev/null; then
+ # [v4.7.3 FIX-MED] Use printf+wc instead of `grep -c . || echo 0`.
+ # Previous form produced "0\n0" on empty input (grep -c outputs 0 AND
+ # exits 1 → trailing `|| echo 0` appended a second 0), breaking the
+ # numeric `-gt` compare.
+ if [ -z "$backup_list" ]; then
+ count=0
+ else
+ count=$(printf '%s\n' "$backup_list" | grep -c . 2>/dev/null)
+ [ -z "$count" ] && count=0
+ fi
+ if [ "$count" -gt "$keep_count" ] 2>/dev/null; then
to_rm=$((count - keep_count))
info "$(msg INFO_CLEANING_BACKUPS) $to_rm $(msg INFO_OLD_BACKUPS)"
# [SEC-FIX] Use tail -n to get oldest backups (last in sorted list)
@@ -749,6 +1780,11 @@ cleanup_old_backups() {
}
backup_config_persistent() {
+ # [v4.7.0] All file creation inside this function happens with umask 077 so
+ # backup.info / restore.sh / checksums.sha256 are 0600/0700 instead of 0644.
+ __saved_umask=$(umask)
+ umask 077
+
timestamp=$(date +%Y%m%d_%H%M%S)
if command -v date >/dev/null 2>&1 && date --version 2>&1 | grep -q GNU; then
timestamp=$(date +%Y%m%d_%H%M%S%N 2>/dev/null || echo "$timestamp")
@@ -758,53 +1794,167 @@ backup_config_persistent() {
if ! mkdir -p "$BACKUP_REPO" 2>/dev/null; then
warn "$(msg ERR_BACKUP_DIR) $BACKUP_REPO"
- BACKUP_REPO="/tmp/server-init-backups"
- backup_dir="$BACKUP_REPO/$timestamp"
- mkdir -p "$BACKUP_REPO" 2>/dev/null || {
- warn "$(msg ERR_BACKUP_DIR_ALT)"
+ # [v4.7.0 / HIGH] /tmp fallback only succeeds if the path doesn't already
+ # exist with the wrong owner (defeats predictable-path squatting).
+ alt="/tmp/server-init-backups"
+ if [ -e "$alt" ] && [ ! -d "$alt" ]; then
+ warn "$(msg ERR_BACKUP_DIR_ALT) ($alt exists and is not a directory)"
+ umask "$__saved_umask"
return 1
- }
+ fi
+ if [ -d "$alt" ]; then
+ owner=$(stat -c "%u" "$alt" 2>/dev/null || ls -ld "$alt" 2>/dev/null | awk '{print $3}')
+ if [ -n "$owner" ] && [ "$owner" != "0" ] && [ "$owner" != "root" ]; then
+ warn "$(msg ERR_BACKUP_DIR_ALT) (refusing to use $alt — owned by $owner)"
+ umask "$__saved_umask"
+ return 1
+ fi
+ fi
+ BACKUP_REPO="$alt"
+ backup_dir="$BACKUP_REPO/$timestamp"
+ if ! mkdir -p "$BACKUP_REPO" 2>/dev/null; then
+ warn "$(msg ERR_BACKUP_DIR_ALT)"
+ umask "$__saved_umask"
+ return 1
+ fi
fi
+ chmod 700 "$BACKUP_REPO" 2>/dev/null || true
+
if ! mkdir -p "$backup_dir" 2>/dev/null; then
warn "$(msg ERR_BACKUP_SUBDIR) $backup_dir"
+ umask "$__saved_umask"
return 1
fi
chmod 700 "$backup_dir" 2>/dev/null || true
if [ -f "$SSH_CONF" ]; then
- cp -p "$SSH_CONF" "$backup_dir/sshd_config" 2>/dev/null || true
- chmod 600 "$backup_dir/sshd_config" 2>/dev/null || true
+ # [v4.7.7 FIX-MED] Don't silently swallow cp failure — the persistent
+ # backup is what `restore.sh` uses, so an incomplete cp would mean
+ # operator's manual recovery gets a 0-byte sshd_config and bricks
+ # the box harder. Failing the backup here just skips this persistent
+ # snapshot (script continues — backup is opportunistic, not load-bearing).
+ if ! cp -p "$SSH_CONF" "$backup_dir/sshd_config" 2>>"$LOG_FILE"; then
+ warn "Persistent backup cp failed (disk full?); restore.sh from this backup will NOT work"
+ rm -f "$backup_dir/sshd_config" 2>/dev/null || true
+ elif [ ! -s "$backup_dir/sshd_config" ]; then
+ warn "Persistent backup snapshot is empty (truncated mid-write); removing"
+ rm -f "$backup_dir/sshd_config" 2>/dev/null || true
+ else
+ chmod 600 "$backup_dir/sshd_config" 2>/dev/null || true
+ fi
+ fi
+
+ if [ -d "$SSH_CONF_D" ]; then
+ mkdir -p "$backup_dir/sshd_config.d" 2>/dev/null && chmod 700 "$backup_dir/sshd_config.d" 2>/dev/null || true
+ for f in "$SSH_CONF_D"/*; do
+ [ -f "$f" ] || continue
+ cp -p "$f" "$backup_dir/sshd_config.d/" 2>/dev/null || true
+ done
fi
{
echo "=== Server Init Backup ==="
echo "Time: $(date)"
- echo "Version: 4.6.1"
+ echo "Version: 4.7.8"
echo "User: ${TARGET_USER:-unknown}"
echo "Port: ${SSH_PORT:-unknown}"
echo "OpenSSH: ${OPENSSH_VER_MAJOR}.${OPENSSH_VER_MINOR}"
echo "--- System ---"
uname -a 2>/dev/null || true
+ echo ""
+ echo "Note: restore.sh restores ONLY $SSH_CONF and sshd_config.d/."
+ echo "User accounts, sudoers files, systemd overrides, firewall rules,"
+ echo "SELinux port labels and BBR sysctl entries created by the run are"
+ echo "NOT undone by restore.sh — use runtime auto-rollback instead."
} > "$backup_dir/backup.info" 2>/dev/null || true
+ chmod 600 "$backup_dir/backup.info" 2>/dev/null || true
+ # [v4.7.0 / HIGH] restore.sh now verifies checksums before applying.
cat > "$backup_dir/restore.sh" <<'EOF'
#!/bin/sh
-set -e
-BACKUP_DIR=$(dirname "$0")
+# Auto-generated by linux-ssh-init-sh v4.7.8
+# Restores /etc/ssh/sshd_config (and sshd_config.d/) from this backup directory
+# after verifying that the files match checksums.sha256.
+set -eu
+BACKUP_DIR=$(cd "$(dirname "$0")" && pwd)
SSH_CONFIG="/etc/ssh/sshd_config"
-echo "Restoring SSH Config..."
-[ -f "$BACKUP_DIR/sshd_config" ] || exit 1
-cp -p "$SSH_CONFIG" "$SSH_CONFIG.bak-$(date +%s)" 2>/dev/null || true
+SSH_CONFIG_D="/etc/ssh/sshd_config.d"
+
+if [ "$(id -u)" -ne 0 ]; then
+ echo "Must be run as root" >&2
+ exit 1
+fi
+
+echo "[restore] Verifying integrity..."
+# [v4.7.3 FIX-MED] Default to STRICT (refuse missing checksum). An attacker
+# who can write to the backup dir could otherwise delete checksums.sha256 to
+# neutralize verification. Set FORCE=1 in the environment to override (e.g.
+# when restoring a backup created by an older version that lacked sha256s).
+if [ ! -f "$BACKUP_DIR/checksums.sha256" ]; then
+ if [ "${FORCE:-0}" != "1" ]; then
+ echo "[restore] REFUSING: checksums.sha256 missing. Re-run with FORCE=1 to bypass." >&2
+ exit 5
+ fi
+ echo "[restore] WARNING: FORCE=1 — proceeding without checksum verification." >&2
+elif ! command -v sha256sum >/dev/null 2>&1; then
+ if [ "${FORCE:-0}" != "1" ]; then
+ echo "[restore] REFUSING: sha256sum not available. Re-run with FORCE=1 to bypass." >&2
+ exit 6
+ fi
+ echo "[restore] WARNING: FORCE=1 — sha256sum unavailable, skipping verification." >&2
+else
+ ( cd "$BACKUP_DIR" && sha256sum -c checksums.sha256 ) || {
+ echo "[restore] CHECKSUM MISMATCH — refusing to restore tampered backup." >&2
+ exit 2
+ }
+fi
+
+if [ ! -f "$BACKUP_DIR/sshd_config" ]; then
+ echo "[restore] No sshd_config snapshot found." >&2
+ exit 3
+fi
+
+ts=$(date +%s)
+cp -p "$SSH_CONFIG" "$SSH_CONFIG.bak-$ts" 2>/dev/null || true
cp -p "$BACKUP_DIR/sshd_config" "$SSH_CONFIG"
chmod 600 "$SSH_CONFIG" 2>/dev/null || true
-systemctl restart sshd 2>/dev/null || service sshd restart 2>/dev/null || true
-echo "Done."
-EOF
- chmod +x "$backup_dir/restore.sh" 2>/dev/null || true
- (cd "$backup_dir" && sha256sum * 2>/dev/null > checksums.sha256) || true
+if [ -d "$BACKUP_DIR/sshd_config.d" ] && [ -d "$SSH_CONFIG_D" ]; then
+ for f in "$BACKUP_DIR/sshd_config.d"/*; do
+ [ -f "$f" ] || continue
+ cp -p "$f" "$SSH_CONFIG_D/" 2>/dev/null || true
+ done
+fi
+
+echo "[restore] Validating restored config..."
+if command -v sshd >/dev/null 2>&1; then
+ sshd -t || { echo "[restore] sshd -t failed against restored config!" >&2; exit 4; }
+fi
+
+echo "[restore] Restarting sshd..."
+systemctl restart sshd 2>/dev/null || service sshd restart 2>/dev/null || /etc/init.d/sshd restart 2>/dev/null || true
+echo "[restore] Done. Test connectivity in a NEW terminal before closing this one."
+EOF
+ chmod 700 "$backup_dir/restore.sh" 2>/dev/null || true
+
+ # Generate checksums LAST so they cover every other file in the dir.
+ (
+ cd "$backup_dir" || exit 0
+ # exclude any pre-existing checksums file from being self-referential
+ rm -f checksums.sha256 2>/dev/null
+ # [v4.7.1] xargs -r is GNU-only; BusyBox xargs lacks it. The input is
+ # never empty in practice (backup.info + restore.sh always exist) so we
+ # can drop the flag for portability.
+ find . -maxdepth 2 -type f ! -name 'checksums.sha256' -print 2>/dev/null \
+ | sed 's|^\./||' \
+ | sort \
+ | xargs sha256sum 2>/dev/null > checksums.sha256
+ chmod 600 checksums.sha256 2>/dev/null || true
+ ) || true
+
+ umask "$__saved_umask"
cleanup_old_backups
info "$(msg INFO_BACKUP_CREATED) $backup_dir"
@@ -819,8 +1969,26 @@ enable_bbr() {
return 0
fi
sysctl_conf="/etc/sysctl.conf"
- grep -q '^net.core.default_qdisc=fq$' "$sysctl_conf" 2>/dev/null || echo 'net.core.default_qdisc=fq' >>"$sysctl_conf"
- grep -q '^net.ipv4.tcp_congestion_control=bbr$' "$sysctl_conf" 2>/dev/null || echo 'net.ipv4.tcp_congestion_control=bbr' >>"$sysctl_conf"
+ # [v4.7.4 FIX-LOW] Defense-in-depth: if /etc/sysctl.conf is a symlink, the
+ # >> redirect would follow it. Refuse to append in that case (don't blindly
+ # remove — sysctl.conf is sometimes legitimately a symlink to a packaged file).
+ if [ -L "$sysctl_conf" ]; then
+ warn "$sysctl_conf is a symlink — skipping BBR sysctl write (manual configuration required)"
+ return 0
+ fi
+ added_any=0
+ if ! grep -q '^net.core.default_qdisc=fq$' "$sysctl_conf" 2>/dev/null; then
+ echo 'net.core.default_qdisc=fq' >> "$sysctl_conf"
+ added_any=1
+ fi
+ if ! grep -q '^net.ipv4.tcp_congestion_control=bbr$' "$sysctl_conf" 2>/dev/null; then
+ echo 'net.ipv4.tcp_congestion_control=bbr' >> "$sysctl_conf"
+ added_any=1
+ fi
+ if [ "$added_any" = "1" ]; then
+ artifact_track "BBR_SYSCTL=$sysctl_conf"
+ audit_log "BBR_ENABLED" "appended to $sysctl_conf"
+ fi
sysctl -p >>"$LOG_FILE" 2>&1 || true
}
@@ -842,19 +2010,37 @@ ensure_ssh_server() {
}
protect_sshd_service() {
- if command -v systemctl >/dev/null 2>&1; then
- info "$(msg SYS_PROT)"
- systemctl enable ssh sshd 2>/dev/null || true
- systemctl unmask ssh sshd 2>/dev/null || true
- mkdir -p /etc/systemd/system/sshd.service.d/ 2>/dev/null || true
- cat > /etc/systemd/system/sshd.service.d/override.conf </dev/null 2>&1 || return 0
+ info "$(msg SYS_PROT)"
+ systemctl enable ssh sshd 2>/dev/null || true
+ systemctl unmask ssh sshd 2>/dev/null || true
+
+ override_dir="/etc/systemd/system/sshd.service.d"
+ override_file="$override_dir/override.conf"
+
+ # [v4.7.0 / MED] Refuse to follow a symlinked override dir (package compromise vector)
+ if [ -L "$override_dir" ]; then
+ warn "$override_dir is a symlink — skipping systemd override"
+ return 0
+ fi
+ mkdir -p "$override_dir" 2>/dev/null || true
+ chmod 755 "$override_dir" 2>/dev/null || true
+
+ # [v4.7.4 FIX-LOW] Symlink defense (override_file path).
+ unlink_if_symlink "$override_file"
+ # Only write (and track) if file is new or content differs.
+ desired_content='[Service]
Restart=on-failure
RestartSec=5s
-OOMScoreAdjust=-500
-EOF
- systemctl daemon-reload >>"$LOG_FILE" 2>&1 || true
+OOMScoreAdjust=-500'
+ if [ ! -f "$override_file" ] || ! printf '%s' "$desired_content" | cmp -s - "$override_file" 2>/dev/null; then
+ printf '%s\n' "$desired_content" > "$override_file" 2>/dev/null || warn "failed to write $override_file"
+ chmod 644 "$override_file" 2>/dev/null || true
+ chown root:root "$override_file" 2>/dev/null || true
+ artifact_track "SYSTEMD_OVERRIDE=$override_file"
+ audit_log "SYSTEMD_OVERRIDE_WRITTEN" "$override_file"
fi
+ systemctl daemon-reload >>"$LOG_FILE" 2>&1 || true
}
detect_openssh_version() {
@@ -904,45 +2090,288 @@ detect_kbd_interactive_support() {
}
# ---------------- Firewall / SELinux ----------------
-allow_firewall_port() {
- p="$1"
- if command -v ufw >/dev/null 2>&1; then
- ufw allow "${p}/tcp" >>"$LOG_FILE" 2>&1 || true
- elif command -v firewall-cmd >/dev/null 2>&1; then
- firewall-cmd --permanent --add-port="${p}/tcp" >>"$LOG_FILE" 2>&1 || true
- firewall-cmd --reload >>"$LOG_FILE" 2>&1 || true
- elif command -v iptables >/dev/null 2>&1; then
- iptables -C INPUT -p tcp --dport "$p" -j ACCEPT 2>/dev/null || iptables -I INPUT -p tcp --dport "$p" -j ACCEPT 2>>"$LOG_FILE" || true
+
+# [v4.7.3 FIX-H5 / v4.7.4 extended] Read last-applied state from disk and, if
+# it records a DIFFERENT port than the one we're about to install, remove
+# stale firewall AND SELinux state so a chain of `--port=X then --port=Y`
+# runs doesn't leave cruft on every prior X.
+remove_stale_firewall_port() {
+ new_port="$1"
+ [ -f "$LAST_APPLIED_FILE" ] || return 0
+
+ # ---- Firewall stale-rule cleanup ----
+ old_spec=$(awk -F= '$1=="FIREWALL"{print $2; exit}' "$LAST_APPLIED_FILE" 2>/dev/null)
+ if [ -n "$old_spec" ]; then
+ case "$old_spec" in
+ *:*) old_backend="${old_spec%%:*}"; old_port="${old_spec#*:}" ;;
+ *) old_backend="iptables"; old_port="$old_spec" ;;
+ esac
+ case "$old_port" in ''|*[!0-9]*) old_port="" ;; esac
+ if [ -n "$old_port" ] && [ "$old_port" != "$new_port" ] && [ "$old_port" != "22" ]; then
+ warn "Cleaning stale firewall rule from previous run: tcp/$old_port (backend: $old_backend)"
+ case "$old_backend" in
+ ufw) command -v ufw >/dev/null 2>&1 && ufw delete allow "${old_port}/tcp" >>"$LOG_FILE" 2>&1 || true ;;
+ firewalld)
+ if command -v firewall-cmd >/dev/null 2>&1; then
+ firewall-cmd --permanent --remove-port="${old_port}/tcp" >>"$LOG_FILE" 2>&1 || true
+ firewall-cmd --reload >>"$LOG_FILE" 2>&1 || true
+ fi ;;
+ iptables) command -v iptables >/dev/null 2>&1 && iptables -D INPUT -p tcp --dport "$old_port" -j ACCEPT >>"$LOG_FILE" 2>&1 || true ;;
+ esac
+ command -v ip6tables >/dev/null 2>&1 && ip6tables -D INPUT -p tcp --dport "$old_port" -j ACCEPT >>"$LOG_FILE" 2>&1 || true
+ audit_log "STALE_FIREWALL_REMOVED" "backend=$old_backend port=$old_port"
+ fi
fi
+ # ---- [v4.7.4 / v4.7.5 FIX-MED-1] SELinux stale-label cleanup ----
+ # Only delete a label that THIS script previously INSTALLED (SELINUX_OWNED=y).
+ # A label recorded as NOOP (SELINUX_OWNED=n) belongs to the operator or
+ # was set up out-of-band — deleting it would silently destroy unrelated
+ # system state.
+ old_se=$(awk -F= '$1=="SELINUX_PORT"{print $2; exit}' "$LAST_APPLIED_FILE" 2>/dev/null)
+ old_owned=$(awk -F= '$1=="SELINUX_OWNED"{print $2; exit}' "$LAST_APPLIED_FILE" 2>/dev/null)
+ case "$old_se" in ''|*[!0-9]*) old_se="" ;; esac
+
+ if [ -n "$old_se" ] && [ "$old_se" != "$new_port" ] && [ "$old_se" != "22" ]; then
+ if [ "$old_owned" = "y" ]; then
+ if command -v semanage >/dev/null 2>&1; then
+ warn "Removing stale SELinux ssh_port_t label from previous run: tcp/$old_se"
+ if semanage port -d -t ssh_port_t -p tcp "$old_se" >>"$LOG_FILE" 2>&1; then
+ audit_log "STALE_SELINUX_REMOVED" "port=$old_se"
+ # [v4.7.5 FIX-MED-2 / v4.7.6 FIX-MED] Strip SELINUX_PORT= and
+ # SELINUX_OWNED= lines from the persisted file. If the script
+ # crashes between here and the next record_last_applied call, the
+ # file won't claim we still own a label that no longer exists.
+ #
+ # v4.7.6 fix: previous form `grep -v ... > tmp && mv || rm tmp`
+ # broke when the file contained ONLY SELINUX_* lines — grep -v
+ # produced no output, exited 1, the mv never fired, the rm cleaned
+ # tmp, and the original file kept the stale lines. Use `|| true`
+ # to mask grep's no-match exit code, then mv unconditionally.
+ if [ -f "$LAST_APPLIED_FILE" ]; then
+ __tmp_la="${LAST_APPLIED_FILE}.tmp.$$"
+ grep -vE '^(SELINUX_PORT|SELINUX_OWNED)=' "$LAST_APPLIED_FILE" 2>/dev/null > "$__tmp_la" || true
+ if mv "$__tmp_la" "$LAST_APPLIED_FILE" 2>/dev/null; then
+ chmod 600 "$LAST_APPLIED_FILE" 2>/dev/null || true
+ else
+ rm -f "$__tmp_la" 2>/dev/null || true
+ fi
+ unset __tmp_la
+ fi
+ else
+ audit_log "STALE_SELINUX_REMOVE_FAILED" "port=$old_se"
+ fi
+ else
+ audit_log "STALE_SELINUX_SKIP" "port=$old_se reason=no_semanage"
+ warn "Cannot remove stale SELinux label (semanage not installed): tcp/$old_se"
+ fi
+ else
+ # Pre-existing label (SELINUX_OWNED=n). Don't delete; just note.
+ audit_log "STALE_SELINUX_KEPT" "port=$old_se reason=not_owned_by_script"
+ fi
+ fi
+}
+
+# [v4.7.3 FIX-H5 / v4.7.4 / v4.7.5] Write the current state to
+# LAST_APPLIED_FILE so the NEXT run can pick up where this one left off.
+# Args:
+# $1 = port (validated digits)
+# $2 = firewall backend ("" if none)
+# $3 = "y" if THIS run's port is labeled ssh_port_t (NOOP or ADDED/MODIFIED)
+# $4 = "y" if THIS run actually INSTALLED/RETYPED the label (i.e. cleanup
+# on next run is allowed to `semanage port -d` it); "n" means the
+# label pre-existed independently (sysadmin-installed) and must be
+# preserved across port changes / rollbacks.
+record_last_applied() {
+ port="$1"; backend="$2"; selinux="${3:-n}"; owned="${4:-n}"
+ mkdir -p "$(dirname "$LAST_APPLIED_FILE")" 2>/dev/null || return 0
+ chmod 700 "$(dirname "$LAST_APPLIED_FILE")" 2>/dev/null || true
+ # [v4.7.4 FIX-LOW] Symlink defense before writing.
+ unlink_if_symlink "$LAST_APPLIED_FILE"
+
+ # Preserve existing SELINUX_PORT/SELINUX_OWNED if caller didn't pass them.
+ preserved_se=""
+ preserved_owned=""
+ if [ "$selinux" != "y" ] && [ -f "$LAST_APPLIED_FILE" ]; then
+ preserved_se=$(awk -F= '$1=="SELINUX_PORT"{print $2; exit}' "$LAST_APPLIED_FILE" 2>/dev/null)
+ preserved_owned=$(awk -F= '$1=="SELINUX_OWNED"{print $2; exit}' "$LAST_APPLIED_FILE" 2>/dev/null)
+ fi
+
+ # [v4.7.7 FIX-MED] Atomic write: previously a direct `> "$LAST_APPLIED_FILE"`
+ # truncate-then-write left the file partial/empty on signal or ENOSPC mid-
+ # write — the next run misinterpreted the orphaned state and either left a
+ # stale firewall rule untouched or tried to `semanage port -d` a non-existent
+ # label. Write to .tmp, then atomically mv.
+ __la_tmp="${LAST_APPLIED_FILE}.tmp.$$"
+ {
+ [ -n "$backend" ] && printf 'FIREWALL=%s:%s\n' "$backend" "$port"
+ printf 'PORT=%s\n' "$port"
+ if [ "$selinux" = "y" ]; then
+ printf 'SELINUX_PORT=%s\n' "$port"
+ printf 'SELINUX_OWNED=%s\n' "$owned"
+ elif [ -n "$preserved_se" ]; then
+ printf 'SELINUX_PORT=%s\n' "$preserved_se"
+ [ -n "$preserved_owned" ] && printf 'SELINUX_OWNED=%s\n' "$preserved_owned"
+ fi
+ } > "$__la_tmp" 2>/dev/null
+ if [ -s "$__la_tmp" ]; then
+ mv "$__la_tmp" "$LAST_APPLIED_FILE" 2>/dev/null || rm -f "$__la_tmp" 2>/dev/null
+ chmod 600 "$LAST_APPLIED_FILE" 2>/dev/null || true
+ else
+ rm -f "$__la_tmp" 2>/dev/null || true
+ fi
+ unset __la_tmp
+}
+
+# [v4.7.1 FIX-H2] Tag the artifact with WHICH backend added the rule, so the
+# rollback path only undoes that backend (instead of blindly trying all three
+# and potentially deleting an unrelated rule that another tool installed).
+allow_firewall_port() {
+ p="$1"
+ backend=""
+ # [v4.7.8 FIX-MED W2-2] Differentiate "rule actually added" from "rule
+ # already there" so idempotent re-runs don't pollute the audit log with
+ # phantom FIREWALL_OPENED entries.
+ rule_was_new="n"
+
+ if command -v ufw >/dev/null 2>&1; then
+ # ufw allow returns the same exit code whether new or pre-existing —
+ # check via `ufw status` BEFORE adding.
+ if ufw status 2>/dev/null | grep -Eq "^${p}/tcp[[:space:]]+ALLOW"; then
+ backend="ufw" # pre-existing — track for rollback symmetry but don't claim "OPENED"
+ else
+ ufw allow "${p}/tcp" >>"$LOG_FILE" 2>&1 && { backend="ufw"; rule_was_new="y"; } || true
+ fi
+ elif command -v firewall-cmd >/dev/null 2>&1; then
+ if firewall-cmd --query-port="${p}/tcp" >/dev/null 2>&1; then
+ backend="firewalld" # pre-existing
+ elif firewall-cmd --permanent --add-port="${p}/tcp" >>"$LOG_FILE" 2>&1; then
+ firewall-cmd --reload >>"$LOG_FILE" 2>&1 || true
+ backend="firewalld"; rule_was_new="y"
+ fi
+ elif command -v iptables >/dev/null 2>&1; then
+ if iptables -C INPUT -p tcp --dport "$p" -j ACCEPT 2>/dev/null; then
+ backend="iptables" # pre-existing
+ elif iptables -I INPUT -p tcp --dport "$p" -j ACCEPT 2>>"$LOG_FILE"; then
+ backend="iptables"; rule_was_new="y"
+ fi
+ fi
+
+ # IPv6 — not tracked separately. We treat it as a best-effort companion.
if command -v ip6tables >/dev/null 2>&1; then
- ip6tables -C INPUT -p tcp --dport "$p" -j ACCEPT 2>/dev/null || ip6tables -I INPUT -p tcp --dport "$p" -j ACCEPT 2>>"$LOG_FILE" || true
+ if ip6tables -C INPUT -p tcp --dport "$p" -j ACCEPT 2>/dev/null; then
+ :
+ else
+ ip6tables -I INPUT -p tcp --dport "$p" -j ACCEPT 2>>"$LOG_FILE" || true
+ fi
+ fi
+
+ # [v4.7.8 FIX-MED W2-2] Only emit FIREWALL_OPENED audit + artifact_track
+ # when we actually INSTALLED the rule. Pre-existing rules don't need
+ # rollback (we didn't create them) and shouldn't show in the audit trail
+ # as "opened by this run".
+ if [ -n "$backend" ] && [ "$rule_was_new" = "y" ]; then
+ artifact_track "FIREWALL_PORT=${backend}:${p}"
+ audit_log "FIREWALL_OPENED" "backend=$backend port=$p"
+ # [v4.7.3 FIX-H5] Persist so the next run can remove this rule if the
+ # port changes. Only after success; rollback removes the artifact AND
+ # rewrites/discards the persisted state via cleanup of the in-flight run.
+ # [v4.7.5] At this point handle_selinux hasn't run yet, so the SELinux
+ # state is whatever a previous run persisted (preserved automatically).
+ # The second record_last_applied call below (after handle_selinux) is the
+ # one that knows OWNED vs NOOP and persists the final state.
+ record_last_applied "$p" "$backend" "n" "n"
+ elif [ -n "$backend" ]; then
+ audit_log "FIREWALL_NOOP" "backend=$backend port=$p reason=already_allowed"
+ # Still update LAST_APPLIED_FILE so next run knows what backend we'd use
+ # to clean it if the port changes.
+ record_last_applied "$p" "$backend" "n" "n"
+ fi
+
+ if [ "$STRICT_MODE" = "y" ] && [ -z "$backend" ]; then
+ case "$p" in
+ 22) ;;
+ *) warn "STRICT: could not confirm firewall rule for tcp/$p" ;;
+ esac
fi
}
handle_selinux() {
port="$1"
- if command -v getenforce >/dev/null 2>&1; then
- if getenforce 2>/dev/null | grep -qi "Enforcing"; then
- info "$(msg SELINUX_DET)"
- if ! command -v semanage >/dev/null 2>&1; then
- info "$(msg SELINUX_INS)"
- case "$PM" in
- yum) install_pkg_try policycoreutils-python-utils policycoreutils-python ;;
- apt) install_pkg_try policycoreutils python3-policycoreutils ;;
- esac
- fi
- if command -v semanage >/dev/null 2>&1; then
- if semanage port -a -t ssh_port_t -p tcp "$port" >>"$LOG_FILE" 2>&1 || \
- semanage port -m -t ssh_port_t -p tcp "$port" >>"$LOG_FILE" 2>&1; then
- ok "$(msg SELINUX_OK)"
- else
- warn "$(msg SELINUX_FAIL)"
- fi
- else
- warn "$(msg SELINUX_FAIL)"
- fi
+ # [v4.7.6 FIX-CRIT] Initialize __selinux_done — without this, the
+ # "all 3 semanage attempts failed" path (else-branch) doesn't set it,
+ # then the `[ "$__selinux_done" = "y" ]` check below errors under
+ # `set -u`, crashing the script and triggering rollback. This defeats
+ # the non-strict graceful-degrade design (should warn + continue).
+ __selinux_done="n"
+ command -v getenforce >/dev/null 2>&1 || return 0
+ getenforce 2>/dev/null | grep -qi "Enforcing" || return 0
+
+ info "$(msg SELINUX_DET)"
+ if ! command -v semanage >/dev/null 2>&1; then
+ info "$(msg SELINUX_INS)"
+ case "$PM" in
+ yum) install_pkg_try policycoreutils-python-utils policycoreutils-python ;;
+ apt) install_pkg_try policycoreutils python3-policycoreutils ;;
+ esac
+ fi
+ if command -v semanage >/dev/null 2>&1; then
+ # [v4.7.4 FIX] Check FIRST whether this port is ALREADY labeled as
+ # ssh_port_t. If so, both `-a` (duplicate) and `-m` (no-op) succeed but
+ # the previous code falsely warned "previous SELinux type was
+ # overwritten" — there was nothing to overwrite. Now we no-op cleanly.
+ if semanage port -l 2>/dev/null | awk -v p="$port" '
+ $1=="ssh_port_t" {
+ for (i=3; i<=NF; i++) {
+ gsub(/,/, "", $i)
+ if ($i == p) { found=1; exit }
+ }
+ }
+ END { exit !found }
+ '; then
+ info "SELinux port $port already labeled ssh_port_t — no-op"
+ audit_log "SELINUX_PORT_NOOP" "port=$port already_labeled"
+ # [v4.7.5 FIX-MED-1] DO NOT artifact_track here. The label pre-existed
+ # before this run (sysadmin-installed or set by a prior script run that
+ # already persisted state). Tracking it would let rollback `semanage
+ # port -d` a label we didn't create. The OWNERSHIP record lives in
+ # LAST_APPLIED_FILE if a previous run actually installed it.
+ __selinux_done="y"
+ elif semanage port -a -t ssh_port_t -p tcp "$port" >>"$LOG_FILE" 2>&1; then
+ artifact_track "SELINUX_PORT=$port"
+ audit_log "SELINUX_PORT_ADDED" "port=$port type=ssh_port_t"
+ ok "$(msg SELINUX_OK)"
+ SELINUX_LABEL_OWNED_BY_RUN="y"
+ __selinux_done="y"
+ elif semanage port -m -t ssh_port_t -p tcp "$port" >>"$LOG_FILE" 2>&1; then
+ # [v4.7.3 FIX-H7] `-m` modifies an EXISTING label that wasn't already
+ # ssh_port_t (caught above) — some other service had this port (e.g.
+ # http_port_t). Can't cleanly restore the original label on rollback.
+ warn "SELinux port $port was MODIFIED (not added) — previous SELinux type was overwritten"
+ warn " If another service used this port label, restore it manually:"
+ warn " semanage port -d -t ssh_port_t -p tcp $port && semanage port -a -t -p tcp $port"
+ audit_log "SELINUX_PORT_MODIFIED" "port=$port (previous label overwritten, no rollback)"
+ [ "$STRICT_MODE" = "y" ] && die "STRICT: refusing to overwrite SELinux port label that already exists"
+ ok "$(msg SELINUX_OK)"
+ # [v4.7.5] -m path: WE retyped the label, so we own it. Rollback can
+ # safely `semanage port -d` (though it won't restore the original type).
+ SELINUX_LABEL_OWNED_BY_RUN="y"
+ __selinux_done="y"
+ else
+ warn "$(msg SELINUX_FAIL)"
+ audit_log "SELINUX_PORT_FAIL" "port=$port"
+ [ "$STRICT_MODE" = "y" ] && die "STRICT: SELinux port label add failed"
fi
+ # [v4.7.4 / v4.7.5] Export to caller so allow_firewall_port's
+ # record_last_applied can persist SELinux state.
+ # SELINUX_LABEL_APPLIED is the "should we record this port in
+ # LAST_APPLIED_FILE" signal (true if NOOP, ADDED, or MODIFIED — anything
+ # that means "this run's port is labeled ssh_port_t").
+ # SELINUX_LABEL_OWNED_BY_RUN is the "may rollback delete it" signal —
+ # only true when WE installed or retyped the label.
+ [ "$__selinux_done" = "y" ] && SELINUX_LABEL_APPLIED="y"
+ else
+ warn "$(msg SELINUX_FAIL)"
fi
}
@@ -972,9 +2401,11 @@ ensure_port_tools() {
if ! command -v nc >/dev/null 2>&1; then
echo "Installing missing dependency: netcat..."
case "$PM" in
- apt) install_pkg netcat-openbsd ;;
- yum) install_pkg nc ;;
- apk) install_pkg netcat-openbsd ;;
+ # [v4.7.3 FIX-MED] Try multiple `nc` package names — varies by distro
+ # (netcat-openbsd on Debian/Ubuntu/Alpine, nmap-ncat or nc on RHEL).
+ apt) install_pkg_try netcat-openbsd netcat ncat ;;
+ yum) install_pkg_try nmap-ncat nc netcat ;;
+ apk) install_pkg_try netcat-openbsd ncat ;;
esac
fi
# ==============================
@@ -995,11 +2426,12 @@ is_port_free() {
if command -v ss >/dev/null 2>&1; then
if ss -lnt 2>/dev/null | awk -v port="$p" '
+ BEGIN { found = 0 }
NR > 1 {
n = split($4, parts, ":")
- if (parts[n] == port) { exit 0 }
+ if (parts[n] == port) { found = 1; exit }
}
- END { exit 1 }
+ END { exit !found }
'; then
return 1
fi
@@ -1008,11 +2440,12 @@ is_port_free() {
if command -v netstat >/dev/null 2>&1; then
if netstat -lnt 2>/dev/null | awk -v port="$p" '
+ BEGIN { found = 0 }
NR > 2 {
n = split($4, parts, ":")
- if (parts[n] == port) { exit 0 }
+ if (parts[n] == port) { found = 1; exit }
}
- END { exit 1 }
+ END { exit !found }
'; then
return 1
fi
@@ -1054,57 +2487,108 @@ pick_random_port() {
validate_port() {
port="$1"
-
+
case "$port" in
''|*[!0-9]*) return 1 ;;
esac
-
+
+ # [v4.7.6 FIX-MED] Reject leading zeros. Otherwise `--port=03306` passes
+ # digit + range checks (decimal-parsed to 3306), bypasses
+ # is_hard_reserved (which is a STRING `case` matching only "3306"),
+ # and the script writes `Port 03306` to sshd_config — which OpenSSH
+ # parses as decimal 3306, silently shadowing MySQL. Similar issue
+ # for `--port=0022` (script string-compares to "22" elsewhere and
+ # treats it as non-standard, racing firewall/SELinux state vs sshd's
+ # actual listening port).
+ case "$port" in
+ 0?*) return 1 ;;
+ esac
+
[ "$port" -ge 1 ] 2>/dev/null && [ "$port" -le 65535 ] 2>/dev/null || return 1
-
+
if [ "$port" -lt 1024 ] && [ "$port" != "22" ]; then
return 1
fi
-
+
return 0
}
# ---------------- User & Sudo ----------------
validate_username() {
raw="$1"
- # [UX-FIX] 自动去除首尾空格,并将大写转小写(可选,这里主要去空格)
- u=$(echo "$raw" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
-
+ # [v4.7.0 / MED] printf '%s\n' instead of echo to handle inputs starting
+ # with -e / -n safely on shells where /bin/sh resolves to bash.
+ u=$(printf '%s' "$raw" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
+
# 必须将清洗后的变量回写给全局变量,否则后续 useradd 还是会用带空格的
TARGET_USER="$u"
-
+
[ "$u" = "root" ] && return 0
-
+
len=${#u}
[ "$len" -ge 2 ] && [ "$len" -le 32 ] || return 1
- echo "$u" | grep -Eq '^[a-z_][a-z0-9_-]*$' || return 1
-
+ # [v4.7.6 FIX-LOW] Reject embedded newlines BEFORE grep — POSIX grep is
+ # per-line-anchored, so a value like "admin\nbadguy" passes since each
+ # line individually matches. A simple case-match on the whole value
+ # rejects ANY character outside the allowed set including LF/CR.
+ case "$u" in
+ *[!a-z0-9_-]*) return 1 ;;
+ esac
+ printf '%s\n' "$u" | grep -Eq '^[a-z_][a-z0-9_-]*$' || return 1
+
case "$u" in bin|daemon|adm|lp|sync|shutdown|halt|mail|operator|games|ftp|nobody) return 1 ;; esac
return 0
}
-# [SEC-FIX] Fixed grep prefix match - require whitespace or end after username
+# [v4.7.0 / HIGH] Wheel/sudo group membership check + stable filename.
safe_configure_sudo() {
user="$1"
if [ ! -d /etc/sudoers.d ]; then warn "$(msg WARN_NO_SUDOERS_DIR)"; return 0; fi
- # [SEC-FIX] Match user followed by whitespace to avoid prefix matching
- if grep -Eq "^[[:space:]]*${user}[[:space:]]" /etc/sudoers /etc/sudoers.d/* 2>/dev/null; then
+
+ # 1. Is user already explicitly named in /etc/sudoers* with a whitespace boundary?
+ if grep -Eq "^[[:space:]]*${user}[[:space:]]" /etc/sudoers /etc/sudoers.d/* 2>/dev/null; then
info "$(msg INFO_SUDO_EXISTS)"
return 0
fi
- timestamp=$(date +%Y%m%d%H%M%S)
- sudoers_file="/etc/sudoers.d/server-init-$user-$timestamp"
+ # 2. Is user already in a group that grants sudo? Check both explicit
+ # membership (id -nG) and the %group rules present in sudoers files.
+ user_groups=$(id -nG "$user" 2>/dev/null | tr ' ' '\n')
+ if [ -n "$user_groups" ]; then
+ for g in wheel sudo admin; do
+ if printf '%s\n' "$user_groups" | grep -qx "$g"; then
+ # User is in the group AND that group is granted sudo in some config file?
+ if grep -Eq "^[[:space:]]*%${g}[[:space:]]" /etc/sudoers /etc/sudoers.d/* 2>/dev/null; then
+ info "$(msg INFO_SUDO_EXISTS) (via group %$g)"
+ return 0
+ fi
+ fi
+ done
+ fi
+
+ # 3. New sudoers file — stable name per user (idempotent across re-runs).
+ # [v4.7.2 FIX] Write & validate the NEW file BEFORE touching any legacy
+ # files. Previously we removed legacy variants first, which on visudo
+ # failure left the operator with NO sudo at all (lockout).
+ sudoers_file="/etc/sudoers.d/server-init-$user"
+ # [v4.7.4 FIX-LOW] Symlink defense before write. /etc/sudoers.d is normally
+ # 750 root:root but defense-in-depth: a pre-planted symlink would let `cat`
+ # overwrite an arbitrary file (e.g. /etc/passwd) with sudoers content.
+ unlink_if_symlink "$sudoers_file"
+ # [v4.7.2 FIX] umask 337 so the file is born at 0440 (sudoers requires
+ # group/world-NON-writable; some sudo builds also warn on world-readable).
+ # Default umask (022) would create at 0644 with a brief window before our
+ # explicit chmod 440, during which a parallel sudo invocation could warn.
+ __sudoers_old_umask=$(umask)
+ umask 337
cat > "$sudoers_file" </dev/null 2>&1; then
if ! visudo -c -f "$sudoers_file" >/dev/null 2>&1; then
@@ -1113,7 +2597,40 @@ EOF
return 1
fi
fi
- chmod 440 "$sudoers_file" 2>/dev/null || { rm -f "$sudoers_file"; err "$(msg ERR_SUDOERS_PERM)"; return 1; }
+ if ! chmod 440 "$sudoers_file" 2>/dev/null; then
+ rm -f "$sudoers_file"
+ err "$(msg ERR_SUDOERS_PERM)"
+ return 1
+ fi
+ chown root:root "$sudoers_file" 2>/dev/null || true
+ artifact_track "SUDOERS_FILE=$sudoers_file"
+ audit_log "SUDOERS_WRITTEN" "$sudoers_file"
+
+ # 4. [v4.7.2 FIX] Now that the new file is in place, clean up pre-v4.7.0
+ # timestamped variants. Strict pattern: server-init--
+ # so we cannot accidentally match another user whose name shares this
+ # user's name as a prefix (e.g. cleaning "admin" must NOT remove
+ # "server-init-admin-bot"). Operator-created files like
+ # "server-init--special-policy" are also preserved.
+ for legacy in /etc/sudoers.d/server-init-"$user"-*; do
+ [ -f "$legacy" ] || continue
+ suffix="${legacy##*/server-init-"$user"-}"
+ # Only purely numeric suffixes (the v4.6.x timestamp format) qualify.
+ case "$suffix" in
+ ''|*[!0-9]*) continue ;;
+ esac
+ # [v4.7.2 FIX] rm FIRST, then audit_log only on success. The previous
+ # ordering wrote the audit-log entry unconditionally — if rm silently
+ # failed (read-only fs, immutable bit), the audit trail would falsely
+ # claim a removal that never happened.
+ if rm -f "$legacy" 2>/dev/null; then
+ audit_log "LEGACY_SUDOERS_REMOVED" "$legacy"
+ info "Removed pre-v4.7.0 timestamped sudoers: $legacy"
+ else
+ warn "Failed to remove legacy sudoers file: $legacy"
+ fi
+ done
+
info "$(msg INFO_SUDO_CONFIGURED)"
return 0
}
@@ -1219,51 +2736,222 @@ safe_ensure_user() {
[ "$user_created" -eq 1 ] || { err "$(msg ERR_USER_CREATE_FAIL)"; return 1; }
id "$user" >/dev/null 2>&1 || { err "$(msg ERR_USER_VERIFY_FAIL)"; return 1; }
- # [FIX] 解锁用户账户,防止因 shadow 密码锁定导致 sudo NOPASSWD 失效
- # 使用 passwd -d 清除密码(变为无密码状态),配合 SSH Key 使用是安全的,且能让 PAM 通过账户检查
- passwd -d "$user" >/dev/null 2>&1 || usermod -U "$user" >/dev/null 2>&1 || true
+ # [v4.7.0] Mark this user as script-created for rollback tracking.
+ artifact_track "USER_CREATED=$user"
+ # [v4.7.1] Mark for finalize_user_password_policy: this is a NEW account, safe to passwd -d.
+ USER_WAS_CREATED="y"
+ # [v4.7.4 FIX-MED] Audit trail for user creation (forensic completeness).
+ audit_log "USER_CREATED" "user=$user shell=$shell"
- safe_configure_sudo "$user" || true
+ # [v4.7.0 / CRIT] Do NOT clear password here. Previously this was unconditional,
+ # which (combined with the fallback "key-failed → keep password auth" policy)
+ # could leave a NEW account with no password AND password auth enabled.
+ # Password-clear is now deferred to finalize_user_password_policy, which only
+ # runs after deploy_keys succeeds.
+
+ # [v4.7.1 FIX-H1 / v4.7.2] Sudoers failure must not be silent.
+ if safe_configure_sudo "$user"; then
+ SUDO_DEPLOYED="y"
+ else
+ SUDO_DEPLOYED="n"
+ audit_log "SUDO_FAIL" "Could not write sudoers entry for $user"
+ if [ "$STRICT_MODE" = "y" ]; then
+ err "STRICT: sudoers configuration failed for $user"
+ return 1
+ fi
+ warn "Account $user was created WITHOUT sudo. See $AUDIT_FILE."
+ fi
return 0
}
+# [v4.7.0 / v4.7.1] Account password policy applied AFTER key deploy outcome is known.
+#
+# New account (USER_WAS_CREATED=y):
+# KEY_OK=y → passwd -d (unlock, key-only auth)
+# KEY_OK=n → leave locked (no password set by useradd anyway). Fallback
+# password-auth must NEVER mean "anyone who knows the username
+# can SSH in passwordlessly".
+#
+# Pre-existing account (USER_WAS_CREATED=n) [v4.7.1 fix]:
+# We MUST NOT touch the password. The admin-managed password stays.
+# Key deployment merely ADDS a key; the existing password remains valid
+# for any service that uses it (su, sudo with password, console, etc.).
+#
+finalize_user_password_policy() {
+ user="$1"
+ key_ok="$2"
+ was_created="$3"
+ sudo_ok="${4:-y}" # default y for backwards compat
+ [ "$user" = "root" ] && return 0
+
+ if [ "$was_created" != "y" ]; then
+ # [v4.7.1] Pre-existing account: do not clobber its password.
+ info "Account $user pre-existed — leaving password state untouched"
+ return 0
+ fi
+
+ if [ "$key_ok" != "y" ]; then
+ warn "Account $user remains locked because key deployment failed"
+ ACCOUNT_LOCKED_REASON="key deployment failed"
+ audit_log "ACCOUNT_KEPT_LOCKED" "user=$user reason=key_deploy_failed"
+ return 0
+ fi
+
+ # [v4.7.2 FIX] Be conservative when sudo deployment ALSO failed. A new
+ # passwordless+no-sudo account is rarely the operator's intent and means
+ # nobody can elevate from it. Keep the account locked so the operator
+ # actively notices and fixes it (rather than discovering it later).
+ if [ "$sudo_ok" != "y" ]; then
+ warn "Account $user kept locked: sudo deployment failed (avoid passwordless+no-sudo)"
+ ACCOUNT_LOCKED_REASON="sudo deployment failed"
+ audit_log "ACCOUNT_KEPT_LOCKED" "user=$user reason=sudo_deploy_failed"
+ return 0
+ fi
+
+ if passwd -d "$user" >/dev/null 2>&1; then
+ info "Account $user: password cleared (key-only auth)"
+ audit_log "PASSWORD_CLEARED" "user=$user method=passwd_-d"
+ elif command -v usermod >/dev/null 2>&1 && usermod -p "" "$user" >/dev/null 2>&1; then
+ # Some minimal RHEL-family images ship shadow-utils (useradd/usermod) but
+ # not the separate passwd(1) package. `usermod -U` refuses to unlock an
+ # account when that would leave an empty password, while `passwd -d` would
+ # intentionally create exactly that key-only state. Use usermod -p "" as
+ # the equivalent fallback after key deployment and sudo setup succeeded.
+ info "Account $user: password cleared via usermod -p (key-only auth)"
+ audit_log "PASSWORD_CLEARED" "user=$user method=usermod_-p_empty"
+ elif usermod -U "$user" >/dev/null 2>&1; then
+ info "Account $user: unlocked via usermod"
+ audit_log "PASSWORD_CLEARED" "user=$user method=usermod_-U"
+ else
+ # [v4.7.3 FIX-H2] Both `passwd -d` and `usermod -U` failed — previously
+ # silent. Now surface the locked state so the operator sees the warning
+ # in print_final_summary instead of an apparent-success banner with a
+ # broken account.
+ warn "Account $user: passwd -d AND usermod -U both failed — account remains locked"
+ ACCOUNT_LOCKED_REASON="passwd-d and usermod-U both failed"
+ audit_log "ACCOUNT_KEPT_LOCKED" "user=$user reason=passwd_usermod_both_failed"
+ fi
+}
+
# ---------------- Keys ----------------
+# [v4.7.0] Hardening: max-filesize, max-redirs, strict-mode requires HTTPS.
+# Response is also piped through `head -c` as a belt-and-suspenders DoS guard
+# for wget (which lacks --max-filesize). 64 KiB is plenty for a key list.
+KEY_MAX_BYTES=65536
+KEY_MAX_REDIRS=3
+
fetch_keys() {
mode="$1"
val="$2"
url=""
case "$mode" in
- gh) url="https://github.com/$val.keys" ;;
+ gh)
+ # [v4.7.1 FIX-M2] GitHub usernames: 1-39 chars, alphanumeric + hyphen,
+ # cannot start or end with hyphen. Anything else → don't substitute it
+ # into a URL (avoids path-traversal-style URLs reaching github.com).
+ # [v4.7.6 FIX-LOW] Reject embedded newlines / weird bytes BEFORE grep
+ # (POSIX grep is per-line-anchored — multi-line strings would each
+ # match individually).
+ case "$val" in
+ *[!A-Za-z0-9-]*) warn "Invalid GitHub username (illegal character): $val"; return 1 ;;
+ esac
+ if ! printf '%s' "$val" | grep -Eq '^[A-Za-z0-9](-?[A-Za-z0-9]){0,38}$'; then
+ warn "Invalid GitHub username (must match ^[A-Za-z0-9](-?[A-Za-z0-9]){0,38}$): $val"
+ return 1
+ fi
+ url="https://github.com/$val.keys" ;;
url) url="$val" ;;
- raw) printf "%s\n" "$val"; return 0 ;;
+ raw) printf '%s\n' "$val"; return 0 ;;
*) return 1 ;;
esac
if [ "$mode" = "url" ]; then
- echo "$url" | grep -Eq '^https://|^http://' || { warn "Invalid URL scheme (must be http/https)"; return 1; }
+ case "$url" in
+ https://*) : ;;
+ http://*)
+ if [ "$STRICT_MODE" = "y" ]; then
+ warn "STRICT: refusing plaintext http:// key URL"
+ return 1
+ fi
+ warn "Plaintext http:// URL — keys are public but a MITM could substitute. Prefer https://"
+ # [v4.7.5 FIX-LOW] Forensic audit: post-incident analysis needs to
+ # know if a key URL was fetched over plaintext. URL itself stays in
+ # /var/log/server-init.log debug but is omitted here for redaction.
+ audit_log "INSECURE_KEY_URL" "scheme=http non_strict_mode"
+ ;;
+ *)
+ warn "Invalid URL scheme (must be https://, or http:// in non-strict mode)"
+ return 1
+ ;;
+ esac
fi
+ # [v4.7.1 FIX-M3] Download to a TEMP FILE first, then size-check, then emit.
+ # The previous design piped curl | head -c which masks curl's max-filesize
+ # exit code (63) via SIGPIPE and lets a 1GB-of-junk URL silently succeed
+ # with 64KB of attacker-controlled content.
+ tmpf=""
+ if command -v mktemp >/dev/null 2>&1; then
+ tmpf=$(mktemp "$TMP_DIR/fetched_keys.XXXXXX" 2>/dev/null || echo "$TMP_DIR/fetched_keys.$$")
+ else
+ tmpf="$TMP_DIR/fetched_keys.$$"
+ fi
+ : > "$tmpf"
+ chmod 600 "$tmpf" 2>/dev/null || true
+
retries=0
max_retries=3
+ rc=1
while [ "$retries" -lt "$max_retries" ]; do
+ : > "$tmpf"
if command -v curl >/dev/null 2>&1; then
- if curl -fsSL --connect-timeout 10 --max-time 30 "$url" 2>>"$LOG_FILE"; then
- return 0
- fi
+ curl -fsSL \
+ --connect-timeout 10 --max-time 30 \
+ --max-filesize "$KEY_MAX_BYTES" \
+ --max-redirs "$KEY_MAX_REDIRS" \
+ --proto '=https,http' \
+ "$url" >"$tmpf" 2>>"$LOG_FILE"
+ rc=$?
elif command -v wget >/dev/null 2>&1; then
- if wget -qO- --timeout=30 "$url" 2>>"$LOG_FILE"; then
- return 0
- fi
+ # [v4.7.3 FIX-MED] wget has no --max-filesize; instead we set
+ # --quota=BYTES which aborts the transfer once exceeded. That bounds
+ # the on-disk damage. The post-fetch wc -c size check still applies.
+ wget -qO "$tmpf" --timeout=30 \
+ --max-redirect="$KEY_MAX_REDIRS" \
+ --quota="$KEY_MAX_BYTES" \
+ "$url" 2>>"$LOG_FILE"
+ rc=$?
else
warn "Need curl or wget to fetch keys"
+ rm -f "$tmpf" 2>/dev/null
return 1
fi
+
+ if [ "$rc" -eq 0 ] && [ -s "$tmpf" ]; then
+ # Enforce size cap (paranoid: covers wget which has no max-filesize).
+ sz=$(wc -c < "$tmpf" 2>/dev/null | tr -d ' ')
+ if [ -n "$sz" ] && [ "$sz" -gt "$KEY_MAX_BYTES" ] 2>/dev/null; then
+ warn "Fetched response exceeded $KEY_MAX_BYTES bytes — refusing"
+ rm -f "$tmpf" 2>/dev/null
+ return 1
+ fi
+ cat "$tmpf"
+ rm -f "$tmpf" 2>/dev/null
+ return 0
+ fi
+
retries=$((retries+1))
[ "$retries" -lt "$max_retries" ] && sleep 2
done
- warn "Failed to fetch keys after $max_retries attempts"
+ rm -f "$tmpf" 2>/dev/null
+ warn "Failed to fetch keys after $max_retries attempts (last rc=$rc)"
+ # [v4.7.8 FIX-MED W6-1] Forensic audit: previously, a fetch failure was
+ # only `warn`'d. SIEM operators investigating a ROLLBACK had no breadcrumb
+ # explaining why the script aborted (the rolled-back artifacts plus a
+ # ROLLBACK entry, but no KEY_FETCH_FAILED → no clear cause).
+ audit_log "KEY_FETCH_FAILED" "mode=$mode rc=$rc retries=$max_retries"
return 1
}
@@ -1298,10 +2986,15 @@ validate_ssh_key_line() {
esac
fi
- # [SEC-FIX] Optional: use ssh-keygen for stronger validation if available
+ # [v4.7.0] Use mktemp so concurrent validations in a `while read` loop
+ # cannot collide on the same filename inside TMP_DIR.
if command -v ssh-keygen >/dev/null 2>&1; then
- tmpk="$TMP_DIR/keycheck.$$"
- printf "%s\n" "$line" > "$tmpk"
+ if command -v mktemp >/dev/null 2>&1; then
+ tmpk=$(mktemp "$TMP_DIR/keycheck.XXXXXX" 2>/dev/null || echo "$TMP_DIR/keycheck.$$")
+ else
+ tmpk="$TMP_DIR/keycheck.$$"
+ fi
+ printf '%s\n' "$line" > "$tmpk"
if ! ssh-keygen -l -f "$tmpk" >/dev/null 2>&1; then
rm -f "$tmpk" 2>/dev/null || true
return 1
@@ -1321,6 +3014,29 @@ deploy_keys() {
dir="$home/.ssh"
auth="$dir/authorized_keys"
+ # [v4.7.3 FIX-H3] Defensive home-path validation. /etc/passwd is the source
+ # of truth, but if it has been tampered with (e.g. someone set a
+ # non-root user's home to /root or /etc), deploy_keys would happily mkdir
+ # .ssh inside it and chown to the unprivileged user — instant escalation.
+ # Whitelist the realistic home-dir prefixes.
+ case "$home" in
+ /home/*|/Users/*|/var/lib/*) : ;; # standard user homes
+ /root)
+ # Only allowed when the target user IS root.
+ if [ "$user" != "root" ]; then
+ err "Refusing to deploy keys for non-root user '$user' into /root"
+ return 1
+ fi ;;
+ /*) :
+ # Other absolute paths permitted only if the directory is already owned
+ # by the target user (operator's existing custom layout). Reject if
+ # owned by someone else (defends against the tampered-/etc/passwd case).
+ ;;
+ *)
+ err "Refusing: home dir '$home' is not an absolute path"
+ return 1 ;;
+ esac
+
# [SEC-FIX] Refuse symlink home directory (local privilege escalation prevention)
[ -z "$home" ] && { err "$(msg ERR_HOME_NOT_DIR)"; return 1; }
if [ -L "$home" ]; then
@@ -1332,6 +3048,25 @@ deploy_keys() {
return 1
fi
+ # [v4.7.3 FIX-H3] Refuse to deploy keys into a directory that isn't owned
+ # by the target user (or by root, for root). Catches tampered /etc/passwd
+ # pointing a user's home at /root, /etc, etc.
+ home_owner=""
+ if stat -c "%U" "$home" >/dev/null 2>&1; then
+ home_owner=$(stat -c "%U" "$home" 2>/dev/null)
+ else
+ home_owner=$(ls -ld "$home" 2>/dev/null | awk '{print $3}')
+ fi
+ if [ -n "$home_owner" ]; then
+ case "$user:$home_owner" in
+ root:root) : ;; # root → /root, fine
+ *:"$user") : ;; # user → user-owned dir, fine
+ *)
+ err "Refusing: $home is owned by '$home_owner', not '$user'. /etc/passwd may be tampered."
+ return 1 ;;
+ esac
+ fi
+
# [SEC-FIX] Refuse symlink .ssh directory
if [ -L "$dir" ]; then
err "$(msg ERR_SSH_DIR_SYMLINK): $dir"
@@ -1361,6 +3096,16 @@ deploy_keys() {
# [SEC-FIX] Only chown specific paths, no -R (symlink traversal risk)
chown "${user}:" "$dir" "$auth" 2>/dev/null || chown "$user" "$dir" "$auth" 2>/dev/null || true
+ # [v4.7.0 / MED] Verify ownership stuck. On NFS root_squash or restrictive
+ # filesystems chown can silently fail and leave files owned by root, which
+ # sshd then refuses to read ("bad ownership"). Catch this BEFORE we disable
+ # password auth on the strength of "KEY_OK=y".
+ actual_owner=$(stat -c "%U" "$auth" 2>/dev/null || ls -l "$auth" 2>/dev/null | awk '{print $3}')
+ if [ -n "$actual_owner" ] && [ "$actual_owner" != "$user" ]; then
+ err "deploy_keys: $auth owned by '$actual_owner' (expected '$user') — sshd will reject. Aborting."
+ return 1
+ fi
+
valid_keys_file="$TMP_DIR/valid_keys.$$"
deployed_count=0
@@ -1394,6 +3139,7 @@ deploy_keys() {
if [ "$deployed_count" -gt 0 ]; then
info "$(msg INFO_KEYS_DEPLOYED) $deployed_count"
+ audit_log "KEYS_DEPLOYED" "user=$user count=$deployed_count auth=$auth"
return 0
else
warn "$(msg WARN_NO_VALID_KEYS)"
@@ -1408,8 +3154,28 @@ cleanup_sshd_config_d() {
for conf in "$SSH_CONF_D"/*.conf; do
[ -f "$conf" ] || continue
# [SEC-FIX] Case-insensitive matching
- if awk '{line=tolower($0)} line ~ /^[[:space:]]*(port|permitrootlogin|passwordauthentication|pubkeyauthentication|challengeresponseauthentication|kbdinteractiveauthentication|kexalgorithms|ciphers|macs|addressfamily|listenaddress)[[:space:]]/ {exit 0} END{exit 1}' "$conf" 2>/dev/null; then
- mv "$conf" "${conf}.bak_server_init" 2>/dev/null || true
+ if awk '
+ BEGIN { found = 0 }
+ {
+ line = tolower($0)
+ if (line ~ /^[[:space:]]*(port|permitrootlogin|passwordauthentication|pubkeyauthentication|challengeresponseauthentication|kbdinteractiveauthentication|kexalgorithms|ciphers|macs|addressfamily|listenaddress)[[:space:]]/) {
+ found = 1
+ exit
+ }
+ }
+ END { exit !found }
+ ' "$conf" 2>/dev/null; then
+ # [v4.7.3 FIX-H4] If a stale .bak_server_init from a prior crashed
+ # run already exists, do NOT overwrite it (we'd lose the original
+ # snapshot). Pick a timestamped name so both are preserved.
+ # Append $$ as well so two runs in the same second never collide.
+ target="${conf}.bak_server_init"
+ if [ -e "$target" ]; then
+ target="${conf}.bak_server_init.$(date +%s 2>/dev/null || echo 0).$$"
+ warn "Pre-existing $conf.bak_server_init kept; using $target"
+ fi
+ mv "$conf" "$target" 2>/dev/null || true
+ audit_log "DROPIN_RENAMED" "$conf -> $target"
warn "$(msg CLEAN_D) $conf"
fi
done
@@ -1420,10 +3186,21 @@ remove_managed_block() {
tmp_in="$TMP_DIR/sshd_config.in"
tmp_out="$TMP_DIR/sshd_config.out"
cp -p "$SSH_CONF" "$tmp_in" 2>/dev/null || true
+ # [v4.7.0 / MED] Tolerate CRLF endings, leading whitespace and trailing
+ # whitespace on the marker lines so a hand-edited file still gets cleaned.
awk -v b="$BLOCK_BEGIN" -v e="$BLOCK_END" '
- $0==b {skip=1; next}
- $0==e {skip=0; next}
- skip!=1 {print}
+ function norm(s) {
+ sub(/\r$/, "", s)
+ sub(/^[[:space:]]+/, "", s)
+ sub(/[[:space:]]+$/, "", s)
+ return s
+ }
+ {
+ n = norm($0)
+ if (n == b) { skip = 1; next }
+ if (n == e) { skip = 0; next }
+ if (skip != 1) print
+ }
' "$tmp_in" >"$tmp_out"
if [ -s "$tmp_out" ]; then
cat "$tmp_out" > "$SSH_CONF"
@@ -1435,11 +3212,17 @@ sanitize_sshd_config() {
info "$(msg INFO_SANITIZE_DUP)"
tmp_san="$TMP_DIR/sshd_config.sanitized"
- # [SEC-FIX] Use case-insensitive matching with tolower()
+ # [SEC-FIX] Case-insensitive matching with tolower()
+ # [v4.7.3 FIX-MED] Stop sanitizing once a `Match` block is seen. Directives
+ # inside Match blocks are scope-restricted by design and our managed-block
+ # comment-out would silently disable operator-intended overrides.
awk '
{
low = tolower($0)
}
+ # Once we hit a Match directive, just pass everything through unchanged.
+ in_match { print; next }
+ low ~ /^[[:space:]]*match[[:space:]]/ { in_match = 1; print; next }
low ~ /^[[:space:]]*port[[:space:]]/ { print "# [server-init disabled] " $0; next }
low ~ /^[[:space:]]*permitrootlogin[[:space:]]/ { print "# [server-init disabled] " $0; next }
low ~ /^[[:space:]]*passwordauthentication[[:space:]]/ { print "# [server-init disabled] " $0; next }
@@ -1567,7 +3350,7 @@ build_block() {
file="$1"
{
echo "$BLOCK_BEGIN"
- echo "# Managed by server-init v4.6.2"
+ echo "# Managed by server-init v4.7.8"
echo "# Generated: $(date)"
echo "# OpenSSH: ${OPENSSH_VER_MAJOR}.${OPENSSH_VER_MINOR}"
echo "# Do NOT edit inside this block. Changes will be overwritten."
@@ -1641,22 +3424,17 @@ install_managed_block() {
match_line=$(awk '/^[[:space:]]*#/ {next} /^[[:space:]]*Match[[:space:]]/ {print NR; exit}' "$SSH_CONF" 2>/dev/null)
if [ -z "$match_line" ]; then
- cat "$SSH_CONF" "$block" > "$tmp"
+ cat "$block" "$SSH_CONF" > "$tmp"
else
info "$(msg INFO_MATCH_INSERT)"
- awk -v ml="$match_line" -v bf="$block" '
- NR < ml { print }
- NR == ml {
- while ((getline line < bf) > 0) print line
- close(bf)
- print
- }
- NR > ml { print }
- ' "$SSH_CONF" > "$tmp"
+ cat "$block" "$SSH_CONF" > "$tmp"
fi
chmod 600 "$tmp" 2>/dev/null || true
- mv "$tmp" "$SSH_CONF"
+ # [v4.7.0 / MED] Fail loudly if rename fails (e.g. cross-device) so we don't
+ # silently continue with the old config being "validated" by later steps.
+ mv "$tmp" "$SSH_CONF" || die "install_managed_block: failed to mv $tmp → $SSH_CONF"
+ audit_log "MANAGED_BLOCK_INSTALLED" "config=$SSH_CONF port=$SSH_PORT"
}
verify_sshd_listening() {
@@ -1755,85 +3533,104 @@ enhanced_ssh_test() {
if [ "$success" -eq 1 ]; then
ok "$(msg TEST_OK)"
+ audit_log "LOGIN_TEST_PASSED" "user=$user port=$port"
else
+ # [v4.7.3 NOTE] An ssh-client login test launched from root with no
+ # client-side private key for $user cannot succeed; this is EXPECTED in
+ # the vast majority of runs. We've already validated server health via
+ # `sshd -t`, `verify_sshd_listening`, and an SSH-2.0 banner exchange.
+ # So this branch is informational, not a server-side failure signal.
warn "$(msg WARN_PORT_OPEN_BUT_FAIL)"
+ audit_log "LOGIN_TEST_INCONCLUSIVE" "user=$user port=$port (expected when root lacks client key)"
fi
-
+
return 0
}
update_motd() {
info "$(msg MOTD_UPD)"
-
+
# ---------------------------------------------------------
- # 1. 清理旧的静态 MOTD
+ # 1. 清理 /etc/motd 中的历史 server-init 遗留行,并保留一份真实 .bak
+ # [v4.7.1 FIX-R3] motd mutation is now tracked as an artifact so that
+ # rollback can restore the original from .bak.
# ---------------------------------------------------------
motd="/etc/motd"
if [ -f "$motd" ]; then
- cp -p "$motd" "${motd}.bak" 2>/dev/null
- # 清理旧的 Server Init 遗留信息
- grep -vE "Server Init|Login User:|SSH Port:|Auth Type:|Firewall:|={10,}" "$motd" > "${motd}.clean" 2>/dev/null
- cat "${motd}.clean" > "$motd"
- rm -f "${motd}.clean" "${motd}.bak" 2>/dev/null
+ motd_changed=0
+ # First time only — preserve real .bak; subsequent runs keep that bak.
+ if [ ! -f "${motd}.bak" ]; then
+ cp -p "$motd" "${motd}.bak" 2>/dev/null && motd_changed=1
+ fi
+ # [v4.7.3 FIX-MED] Anchor patterns to start-of-line so the operator's own
+ # MOTD lines that happen to contain "Login User:" or similar text are not
+ # accidentally stripped. Older versions used unanchored grep which could
+ # eat user content.
+ if grep -qE "^[[:space:]]*(Server Init|Login User:|SSH Port:|Auth Type:|Firewall:)|^={10,}" "$motd" 2>/dev/null; then
+ grep -vE "^[[:space:]]*(Server Init|Login User:|SSH Port:|Auth Type:|Firewall:)|^={10,}" "$motd" > "${motd}.clean" 2>/dev/null
+ if [ -f "${motd}.clean" ]; then
+ mv "${motd}.clean" "$motd" 2>/dev/null || rm -f "${motd}.clean" 2>/dev/null
+ motd_changed=1
+ fi
+ fi
+ if [ "$motd_changed" = "1" ] && [ -f "${motd}.bak" ]; then
+ artifact_track "MOTD_BAK=$motd"
+ fi
fi
# ---------------------------------------------------------
- # 2. 创建动态脚本 (生成到 /etc/profile.d/)
+ # 2. 写出动态登录横幅(POSIX 兼容;颜色用 printf %b 避免 dash 字面输出)
# ---------------------------------------------------------
- mkdir -p /etc/profile.d
+ mkdir -p /etc/profile.d 2>/dev/null || true
+ banner_file="/etc/profile.d/z99-ssh-init-banner.sh"
+ # [v4.7.4 FIX-LOW] Symlink defense.
+ unlink_if_symlink "$banner_file"
- # 使用 'EOF' 防止变量在写入文件时被提前解析
- cat > "/etc/profile.d/z99-ssh-init-banner.sh" <<'EOF'
+ # [v4.7.0 / HIGH] Values are validated upstream ($SSH_PORT digits-only,
+ # $FINAL_AUTH from a fixed set) — write the final form directly in one
+ # heredoc instead of a heredoc + post-sed pass (no race window).
+ if [ "$KEY_OK" = "y" ]; then
+ final_auth="Key Only (Secure)"
+ else
+ final_auth="Password/Key"
+ fi
+
+ cat > "$banner_file" </dev/null || echo "unknown")
+REAL_PORT="$SSH_PORT"
+AUTH_TYPE="$final_auth"
+REAL_USER=\$(whoami 2>/dev/null || echo "unknown")
-# 如果有读取权限(通常是 root),则尝试动态刷新显示最新配置
-if [ -r "$SSH_CONF" ]; then
- CONF_PORT=$(awk '/^[[:space:]]*Port[[:space:]]+[0-9]+/{print $2}' "$SSH_CONF" | tail -n 1)
- [ -n "$CONF_PORT" ] && REAL_PORT="$CONF_PORT"
-
- if grep -Ei '^[[:space:]]*PasswordAuthentication[[:space:]]+yes' "$SSH_CONF" >/dev/null 2>&1; then
+# Refresh from live config if readable (root sessions).
+if [ -r "\$SSH_CONF" ]; then
+ CONF_PORT=\$(awk '/^[[:space:]]*Port[[:space:]]+[0-9]+/{print \$2}' "\$SSH_CONF" | tail -n 1)
+ [ -n "\$CONF_PORT" ] && REAL_PORT="\$CONF_PORT"
+ if grep -Ei '^[[:space:]]*PasswordAuthentication[[:space:]]+yes' "\$SSH_CONF" >/dev/null 2>&1; then
AUTH_TYPE="Password/Key"
else
AUTH_TYPE="Key Only (Secure)"
fi
fi
-# 颜色定义 (ANSI)
-C_RESET="\033[0m"
-C_CYAN="\033[0;36m"
-C_GREEN="\033[1;32m"
-C_YELLOW="\033[1;33m"
-
-# 打印横幅
-printf "\n"
-printf "${C_CYAN}===============================================================================${C_RESET}\n"
-printf "${C_CYAN} Server Init Managed - SSH Hardened${C_RESET}\n"
-printf "${C_CYAN}===============================================================================${C_RESET}\n"
-printf " Login User: ${C_GREEN}%s${C_RESET}\n" "$REAL_USER"
-printf " SSH Port: ${C_GREEN}%s${C_RESET} (Dynamic Check)\n" "$REAL_PORT"
-printf " Auth Type: %s\n" "$AUTH_TYPE"
-printf " Firewall: Please ensure TCP/${C_YELLOW}%s${C_RESET} is allowed.\n" "$REAL_PORT"
-printf "${C_CYAN}===============================================================================${C_RESET}\n"
-printf "\n"
+printf '\n'
+printf '%b===============================================================================%b\n' "\033[0;36m" "\033[0m"
+printf '%b Server Init Managed - SSH Hardened%b\n' "\033[0;36m" "\033[0m"
+printf '%b===============================================================================%b\n' "\033[0;36m" "\033[0m"
+printf ' Login User: %b%s%b\n' "\033[1;32m" "\$REAL_USER" "\033[0m"
+printf ' SSH Port: %b%s%b (Dynamic Check)\n' "\033[1;32m" "\$REAL_PORT" "\033[0m"
+printf ' Auth Type: %s\n' "\$AUTH_TYPE"
+printf ' Firewall: Please ensure TCP/%b%s%b is allowed.\n' "\033[1;33m" "\$REAL_PORT" "\033[0m"
+printf '%b===============================================================================%b\n' "\033[0;36m" "\033[0m"
+printf '\n'
EOF
- # ---------------------------------------------------------
- # 3. [修复核心] 注入当前脚本已知的正确变量 (解决权限导致的显示问题)
- # ---------------------------------------------------------
- # 根据当前脚本执行状态确定认证类型
- [ "$KEY_OK" = "y" ] && FINAL_AUTH="Key Only (Secure)" || FINAL_AUTH="Password/Key"
-
- # 使用 | 作为 sed 定界符,防止 FINAL_AUTH 中的 / 引起语法错误
- # 将 REAL_PORT 和 AUTH_TYPE 的默认值直接修改为本次脚本执行的真实值
- sed -i "s|REAL_PORT=\"22\"|REAL_PORT=\"$SSH_PORT\"|" "/etc/profile.d/z99-ssh-init-banner.sh"
- sed -i "s|AUTH_TYPE=\"Unknown\"|AUTH_TYPE=\"$FINAL_AUTH\"|" "/etc/profile.d/z99-ssh-init-banner.sh"
-
- chmod 644 "/etc/profile.d/z99-ssh-init-banner.sh"
+ chown root:root "$banner_file" 2>/dev/null || true
+ chmod 0644 "$banner_file" 2>/dev/null || true
+ artifact_track "PROFILE_D_BANNER=$banner_file"
+ audit_log "MOTD_BANNER_WRITTEN" "$banner_file"
}
generate_health_report() {
@@ -1842,10 +3639,15 @@ generate_health_report() {
duration=$((end_time - SCRIPT_START_TIME))
sys_uptime=$(uptime -p 2>/dev/null || uptime 2>/dev/null | awk -F, '{print $1}')
+ # [v4.7.0] Pre-compute fields with if/then/else (avoids &&...|| precedence trap)
+ if [ "$KEY_OK" = "y" ]; then key_auth_label="YES"; else key_auth_label="NO"; fi
+ if [ "$IPV6_ENABLED" = "y" ]; then ipv6_label="Enabled"; else ipv6_label="Disabled"; fi
+ if is_port_free "$SSH_PORT"; then port_label="NOT LISTENING (Error)"; else port_label="LISTENING (OK)"; fi
+
{
echo "=== Server Init Health Report ==="
echo "Generated: $(date)"
- echo "Version: v4.6.2 Fortress Pro"
+ echo "Version: v4.7.8 Enterprise Hardening"
echo "Execution Time: ${duration}s"
echo ""
echo "--- System ---"
@@ -1855,11 +3657,17 @@ generate_health_report() {
echo "--- SSH Config ---"
echo "Port: $SSH_PORT"
echo "User: $TARGET_USER"
- echo "KeyAuth: $([ "$KEY_OK" = "y" ] && echo "YES" || echo "NO")"
+ echo "KeyAuth: $key_auth_label"
+ # [v4.7.2] Surface locked-account state if applicable.
+ if [ -n "$ACCOUNT_LOCKED_REASON" ]; then
+ echo "AccountLocked: YES ($ACCOUNT_LOCKED_REASON)"
+ else
+ echo "AccountLocked: NO"
+ fi
echo ""
echo "--- Network ---"
- echo "IPv6: $([ "$IPV6_ENABLED" = "y" ] && echo "Enabled" || echo "Disabled")"
- echo "Port Status: $(is_port_free "$SSH_PORT" && echo "NOT LISTENING (Error)" || echo "LISTENING (OK)")"
+ echo "IPv6: $ipv6_label"
+ echo "Port Status: $port_label"
echo "Crypto Mode: $CRYPTO_MODE"
} > "$report_file" 2>/dev/null || true
chmod 600 "$report_file" 2>/dev/null || true
@@ -1868,8 +3676,17 @@ generate_health_report() {
print_final_summary() {
public_ip=""
- if command -v curl >/dev/null 2>&1; then
- public_ip=$(curl -4fsSL --max-time 2 https://api.ipify.org 2>/dev/null || echo "")
+ # [v4.7.0] Gate external lookup behind a flag for offline / air-gapped use.
+ # [v4.7.1 FIX-M8] Apply the same protocol/redirect hardening as fetch_keys.
+ if [ "$ARG_NO_IP_PROBE" != "y" ] && command -v curl >/dev/null 2>&1; then
+ public_ip=$(curl -4fsSL --max-time 2 \
+ --max-redirs 2 \
+ --proto '=https' \
+ https://api.ipify.org 2>/dev/null || echo "")
+ # [v4.7.6 FIX-LOW] Sanitize: a hostile (or MITM'd) ipify response could
+ # contain ANSI CSI sequences that the operator's terminal renders.
+ # Strip everything except IPv4 chars + length cap.
+ public_ip=$(printf '%s' "$public_ip" | tr -cd '0-9.' | cut -c1-15)
fi
local_ip=""
@@ -1889,36 +3706,62 @@ print_final_summary() {
end_time=$(date +%s)
duration=$((end_time - SCRIPT_START_TIME))
+ # [v4.7.0] Use %b for ANSI sequences so dash interprets them correctly
echo ""
- echo "${CYAN}╔════════════════════════════════════════════════════════════════════╗${NC}"
- printf "${CYAN}║ %-66s ║${NC}\n" "$(msg BOX_TITLE)"
- echo "${CYAN}╠════════════════════════════════════════════════════════════════════╣${NC}"
- printf "${CYAN}║ %-66s ║${NC}\n" " $(msg BOX_SSH)"
- [ -n "$public_ip" ] && printf "${CYAN}║ Public: ssh -p %-5s %s@%s %-16s ║${NC}\n" "$SSH_PORT" "$TARGET_USER" "$public_ip" ""
- [ -n "$local_ip" ] && printf "${CYAN}║ Local: ssh -p %-5s %s@%s %-16s ║${NC}\n" "$SSH_PORT" "$TARGET_USER" "$local_ip" ""
- echo "${CYAN}║ ║${NC}"
+ printf '%b╔════════════════════════════════════════════════════════════════════╗%b\n' "$CYAN" "$NC"
+ printf '%b║ %-66s ║%b\n' "$CYAN" "$(msg BOX_TITLE)" "$NC"
+ printf '%b╠════════════════════════════════════════════════════════════════════╣%b\n' "$CYAN" "$NC"
+ printf '%b║ %-66s ║%b\n' "$CYAN" " $(msg BOX_SSH)" "$NC"
+ # [v4.7.3 FIX-LOW] If the account is locked, do NOT print the ssh command —
+ # it won't work and operators have copy-pasted such hints to their grief.
+ # Show a placeholder instead, the explicit warning block below explains.
+ if [ -n "$ACCOUNT_LOCKED_REASON" ]; then
+ printf '%b║ (ssh command suppressed: account is locked, see below) %-3s ║%b\n' "$CYAN" "" "$NC"
+ else
+ if [ -n "$public_ip" ]; then
+ printf '%b║ Public: ssh -p %-5s %s@%s %-16s ║%b\n' "$CYAN" "$SSH_PORT" "$TARGET_USER" "$public_ip" "" "$NC"
+ fi
+ if [ -n "$local_ip" ]; then
+ printf '%b║ Local: ssh -p %-5s %s@%s %-16s ║%b\n' "$CYAN" "$SSH_PORT" "$TARGET_USER" "$local_ip" "" "$NC"
+ fi
+ fi
+ printf '%b║ ║%b\n' "$CYAN" "$NC"
if [ "$KEY_OK" = "y" ]; then
- printf "${CYAN}║ %-66s ║${NC}\n" " $(msg BOX_KEY_ON)"
+ printf '%b║ %-66s ║%b\n' "$CYAN" " $(msg BOX_KEY_ON)" "$NC"
else
- printf "${CYAN}║ %-66s ║${NC}\n" " $(msg BOX_KEY_OFF)"
+ printf '%b║ %-66s ║%b\n' "$CYAN" " $(msg BOX_KEY_OFF)" "$NC"
fi
if [ "$SSH_PORT" != "22" ]; then
- printf "${CYAN}║ %-66s ║${NC}\n" " $(msg BOX_PORT)$SSH_PORT"
- printf "${CYAN}║ %-66s ║${NC}\n" " $(msg BOX_FW)"
+ printf '%b║ %-66s ║%b\n' "$CYAN" " $(msg BOX_PORT)$SSH_PORT" "$NC"
+ printf '%b║ %-66s ║%b\n' "$CYAN" " $(msg BOX_FW)" "$NC"
if is_k8s_nodeport "$SSH_PORT"; then
- printf "${CYAN}║ %-66s ║${NC}\n" " $(msg BOX_K8S_WARN)"
+ printf '%b║ %-66s ║%b\n' "$CYAN" " $(msg BOX_K8S_WARN)" "$NC"
fi
fi
- echo "${CYAN}║ ║${NC}"
- printf "${CYAN}║ %-66s ║${NC}\n" " $(msg BOX_WARN)"
- echo "${CYAN}╚════════════════════════════════════════════════════════════════════╝${NC}"
+ # [v4.7.2] Show explicit locked-account warning if applicable, so the
+ # operator doesn't blindly try the SSH command above.
+ # Truncate the username at 20 chars for box-width safety (printf %-66s pads
+ # but never truncates; a 32-char name would push the right border past col 66).
+ if [ -n "$ACCOUNT_LOCKED_REASON" ]; then
+ short_user=$(printf '%.20s' "$TARGET_USER")
+ short_reason=$(printf '%.50s' "$ACCOUNT_LOCKED_REASON")
+ printf '%b║ ║%b\n' "$CYAN" "$NC"
+ printf '%b║ %-66s ║%b\n' "$CYAN" " ! ACCOUNT LOCKED: $short_user cannot SSH yet" "$NC"
+ printf '%b║ %-66s ║%b\n' "$CYAN" " Reason: $short_reason" "$NC"
+ printf '%b║ %-66s ║%b\n' "$CYAN" " Fix: passwd -d $short_user (then test SSH)" "$NC"
+ fi
+
+ printf '%b║ ║%b\n' "$CYAN" "$NC"
+ printf '%b║ %-66s ║%b\n' "$CYAN" " $(msg BOX_WARN)" "$NC"
+ printf '%b╚════════════════════════════════════════════════════════════════════╝%b\n' "$CYAN" "$NC"
echo ""
- echo "Log: $LOG_FILE"
- echo "Audit: $AUDIT_FILE"
- echo "Time: ${duration}s"
+ echo "Log: $LOG_FILE"
+ echo "Audit: $AUDIT_FILE"
+ echo "Health: /var/log/server-init-health.log"
+ echo "Time: ${duration}s"
}
validate_ssh_config_comprehensive() {
@@ -1931,17 +3774,40 @@ validate_ssh_config_comprehensive() {
return 1
fi
- password_auth=$(grep -Ei '^[[:space:]]*PasswordAuthentication[[:space:]]' "$config_file" 2>/dev/null | awk '{print $NF}' | tr -d '\r' | tail -1)
- pubkey_auth=$(grep -Ei '^[[:space:]]*PubkeyAuthentication[[:space:]]' "$config_file" 2>/dev/null | awk '{print $NF}' | tr -d '\r' | tail -1)
- port_setting=$(grep -Ei '^[[:space:]]*Port[[:space:]]' "$config_file" 2>/dev/null | awk '{print $NF}' | tr -d '\r' | tail -1)
+ # [v4.7.0 / HIGH] Use sshd -T (effective config) instead of grep|tail|$NF.
+ # The previous approach picked the LAST matching line which may live inside
+ # a `Match` block, giving the wrong effective value at the global scope.
+ # sshd -T resolves Match blocks against the simulated identity below.
+ sshd_T_out=""
+ if openssh_version_ge 6 8; then
+ sshd_T_out=$(sshd -T -C "user=$user,host=localhost,addr=127.0.0.1" -f "$config_file" 2>>"$LOG_FILE" || true)
+ fi
+ if [ -z "$sshd_T_out" ]; then
+ sshd_T_out=$(sshd -T -f "$config_file" 2>>"$LOG_FILE" || true)
+ fi
+
+ if [ -n "$sshd_T_out" ]; then
+ password_auth=$(printf '%s\n' "$sshd_T_out" | awk 'tolower($1)=="passwordauthentication" {print tolower($2); exit}')
+ pubkey_auth=$(printf '%s\n' "$sshd_T_out" | awk 'tolower($1)=="pubkeyauthentication" {print tolower($2); exit}')
+ port_setting=$(printf '%s\n' "$sshd_T_out" | awk 'tolower($1)=="port" {print $2; exit}')
+ else
+ # [v4.7.1 FIX-H3] sshd -T failed in BOTH variants — usually means a Match
+ # block prevents `-T` without proper `-C`, or sshd is too old. The grep
+ # fallback is approximate and can be wrong inside Match blocks. STRICT
+ # mode refuses to proceed on approximate validation.
+ err "sshd -T produced no output (Match block + old sshd?). Validation will be approximate."
+ if [ "$STRICT_MODE" = "y" ]; then
+ die "STRICT: refusing to apply config without authoritative sshd -T validation"
+ fi
+ warn "Continuing with grep-based fallback — values inside Match blocks may give wrong answers"
+ password_auth=$(grep -Ei '^[[:space:]]*PasswordAuthentication[[:space:]]' "$config_file" 2>/dev/null | awk '{print $NF}' | tr -d '\r' | tail -1 | tr '[:upper:]' '[:lower:]')
+ pubkey_auth=$(grep -Ei '^[[:space:]]*PubkeyAuthentication[[:space:]]' "$config_file" 2>/dev/null | awk '{print $NF}' | tr -d '\r' | tail -1 | tr '[:upper:]' '[:lower:]')
+ port_setting=$(grep -Ei '^[[:space:]]*Port[[:space:]]' "$config_file" 2>/dev/null | awk '{print $NF}' | tr -d '\r' | tail -1)
+ fi
[ -n "$password_auth" ] || password_auth="yes"
- [ -n "$pubkey_auth" ] || pubkey_auth="yes"
- [ -n "$port_setting" ] || port_setting="22"
-
- # Normalize to lowercase for comparison
- password_auth=$(echo "$password_auth" | tr '[:upper:]' '[:lower:]')
- pubkey_auth=$(echo "$pubkey_auth" | tr '[:upper:]' '[:lower:]')
+ [ -n "$pubkey_auth" ] || pubkey_auth="yes"
+ [ -n "$port_setting" ] || port_setting="22"
if [ "$password_auth" = "no" ] && [ "$pubkey_auth" = "no" ]; then
die "$(msg ERR_DEADLOCK)"
@@ -1957,11 +3823,11 @@ validate_ssh_config_comprehensive() {
fi
insecure_options=0
- if grep -qi "^[[:space:]]*X11Forwarding[[:space:]]*yes" "$config_file" 2>/dev/null; then
+ if printf '%s\n' "$sshd_T_out" | awk 'BEGIN { found = 0 } tolower($1)=="x11forwarding" && tolower($2)=="yes" { found = 1; exit } END { exit !found }' 2>/dev/null; then
warn "$(msg WARN_X11_FORWARDING)"
insecure_options=$((insecure_options + 1))
fi
- if grep -qi "^[[:space:]]*PermitEmptyPasswords[[:space:]]*yes" "$config_file" 2>/dev/null; then
+ if printf '%s\n' "$sshd_T_out" | awk 'BEGIN { found = 0 } tolower($1)=="permitemptypasswords" && tolower($2)=="yes" { found = 1; exit } END { exit !found }' 2>/dev/null; then
warn "$(msg WARN_EMPTY_PASSWORDS)"
insecure_options=$((insecure_options + 1))
fi
@@ -1973,8 +3839,20 @@ validate_ssh_config_comprehensive() {
# =========================================================
# Entry
# =========================================================
-[ "$(id -u)" -eq 0 ] || { echo "$(msg MUST_ROOT)"; exit 1; }
-audit_log "START" "Script started with args: $*"
+[ "$(id -u)" -eq 0 ] || { msg MUST_ROOT; exit 1; }
+
+# [v4.7.0] Redact sensitive argv (URLs may carry tokens; raw keys are large) before audit.
+sanitized_args=""
+for _a in "$@"; do
+ case "$_a" in
+ --key-raw=*) sanitized_args="$sanitized_args --key-raw=" ;;
+ --key-url=*) sanitized_args="$sanitized_args --key-url=" ;;
+ --key-gh=*) sanitized_args="$sanitized_args --key-gh=" ;;
+ *) sanitized_args="$sanitized_args $_a" ;;
+ esac
+done
+audit_log "START" "Script started with args:$sanitized_args"
+unset sanitized_args _a
if command -v clear >/dev/null 2>&1; then clear; fi
echo "================================================="
@@ -2027,7 +3905,7 @@ else
while :; do
printf "%s" "$(msg INPUT_PORT)"
read -r MANUAL_PORT
- echo "$MANUAL_PORT" | grep -Eq '^[0-9]+$' || { msg PORT_ERR; continue; }
+ printf '%s\n' "$MANUAL_PORT" | grep -Eq '^[0-9]+$' || { msg PORT_ERR; continue; }
[ "$MANUAL_PORT" -ge 1024 ] 2>/dev/null && [ "$MANUAL_PORT" -le 65535 ] 2>/dev/null || { msg PORT_ERR; continue; }
if is_hard_reserved "$MANUAL_PORT"; then
msg PORT_RES
@@ -2046,7 +3924,14 @@ fi
if [ -n "$ARG_KEY_TYPE" ]; then
KEY_OPT="auto"; KEY_TYPE="$ARG_KEY_TYPE"; KEY_VAL="$ARG_KEY_VAL"
- printf "%s%s\n" "$(msg AUTO_SKIP)" "$KEY_TYPE ($KEY_VAL)"
+ # [v4.7.1 FIX-M1] Don't echo raw key material or URLs (may contain tokens)
+ # to stdout — the audit log is already sanitized but stdout/scrollback
+ # captures (tee, screen, tmux) bypass that.
+ case "$KEY_TYPE" in
+ raw) printf "%s%s\n" "$(msg AUTO_SKIP)" "$KEY_TYPE ()" ;;
+ url) printf "%s%s\n" "$(msg AUTO_SKIP)" "$KEY_TYPE ()" ;;
+ *) printf "%s%s\n" "$(msg AUTO_SKIP)" "$KEY_TYPE ($KEY_VAL)" ;;
+ esac
else
echo ""
msg ASK_KEY_T; msg OPT_KEY_1; msg OPT_KEY_2; msg OPT_KEY_3
@@ -2054,7 +3939,22 @@ else
case "$KEY_OPT" in
1) KEY_TYPE="gh"; printf "%s" "$(msg INPUT_GH)"; read -r KEY_VAL ;;
2) KEY_TYPE="url"; printf "%s" "$(msg INPUT_URL)"; read -r KEY_VAL ;;
- 3) KEY_TYPE="raw"; msg INPUT_RAW; raw=""; while IFS= read -r l; do [ -z "$l" ] && break; raw="${raw}${l}\n"; done; KEY_VAL="$(printf "%b" "$raw")" ;;
+ 3)
+ # [v4.7.3 FIX-MED] Build $raw with a REAL newline, not literal '\n' that
+ # later needs printf %b — which would interpret any \c the user typed
+ # and silently truncate the result.
+ KEY_TYPE="raw"
+ msg INPUT_RAW
+ raw=""
+ __nl='
+'
+ while IFS= read -r l; do
+ [ -z "$l" ] && break
+ raw="${raw}${l}${__nl}"
+ done
+ KEY_VAL="$raw"
+ unset raw l __nl
+ ;;
*) die "Invalid Option" ;;
esac
fi
@@ -2120,9 +4020,43 @@ if [ "$PORT_OPT" = "2" ]; then
fi
fi
+# [v4.7.3 FIX-H5] Always run stale-rule cleanup — including when this run
+# REVERTS to port 22. Otherwise a sequence of `--port=2222` then later
+# `--port=22` would leak the 2222 rule.
+remove_stale_firewall_port "$SSH_PORT"
+
if [ "$SSH_PORT" != "22" ]; then
allow_firewall_port "$SSH_PORT"
handle_selinux "$SSH_PORT"
+ # [v4.7.4] Re-record state AFTER handle_selinux so the persisted file
+ # also reflects SELinux ownership. allow_firewall_port already called
+ # record_last_applied with the firewall backend; this overwrites with the
+ # complete picture including SELINUX_PORT.
+ if [ "$SELINUX_LABEL_APPLIED" = "y" ]; then
+ # Re-read the firewall backend just persisted (or fall back to empty).
+ if [ -f "$LAST_APPLIED_FILE" ]; then
+ __recorded_fw=$(awk -F= '$1=="FIREWALL"{print $2; exit}' "$LAST_APPLIED_FILE" 2>/dev/null)
+ case "$__recorded_fw" in
+ *:*) __recorded_be="${__recorded_fw%%:*}" ;;
+ *) __recorded_be="" ;;
+ esac
+ # [v4.7.5] Pass SELINUX_LABEL_OWNED_BY_RUN as 4th arg so the persisted
+ # file distinguishes "we own this and may delete on next run" from
+ # "label pre-existed independently, don't touch on next run".
+ record_last_applied "$SSH_PORT" "$__recorded_be" "y" "$SELINUX_LABEL_OWNED_BY_RUN"
+ unset __recorded_fw __recorded_be
+ else
+ record_last_applied "$SSH_PORT" "" "y" "$SELINUX_LABEL_OWNED_BY_RUN"
+ fi
+ fi
+else
+ # [v4.7.3 + v4.7.4] When the operator reverts to port 22, clear the
+ # last-applied state. The stale-cleanup above already removed the prior
+ # firewall + SELinux state for the OLD port. Nothing to remember.
+ if [ -f "$LAST_APPLIED_FILE" ]; then
+ rm -f "$LAST_APPLIED_FILE" 2>/dev/null || true
+ audit_log "LAST_APPLIED_CLEARED" "reverting to port 22"
+ fi
fi
safe_ensure_user "$TARGET_USER" || die "User setup failed"
@@ -2140,6 +4074,12 @@ else
warn "$(msg W_KEY_FAIL)"
fi
+# [v4.7.0 / CRIT] Apply password policy now that KEY_OK is known.
+# [v4.7.1] Pass USER_WAS_CREATED so pre-existing admin passwords are preserved.
+# [v4.7.2] Pass SUDO_DEPLOYED so new accounts that lack sudo stay locked
+# (prevents passwordless+no-sudo combo).
+finalize_user_password_policy "$TARGET_USER" "$KEY_OK" "$USER_WAS_CREATED" "$SUDO_DEPLOYED"
+
if has_global_ipv6; then
IPV6_ENABLED="y"
info "$(msg IPV6_CFG)"
@@ -2164,7 +4104,10 @@ build_block "$tmp"
install_managed_block "$tmp"
-if [ "$ARG_DELAY_RESTART" != "y" ]; then protect_sshd_service; fi
+# [v4.7.0 / MED] protect_sshd_service is a safety feature — it must run
+# regardless of whether we'll restart sshd in this invocation. The flag was
+# only ever supposed to skip the live restart + connection tests.
+protect_sshd_service
if ! sshd -t -f "$SSH_CONF" 2>>"$LOG_FILE"; then die "$(msg E_SSHD_CHK)"; fi
validate_ssh_config_comprehensive "$SSH_CONF" "$TARGET_USER" "$KEY_OK"
@@ -2172,6 +4115,12 @@ validate_ssh_config_comprehensive "$SSH_CONF" "$TARGET_USER" "$KEY_OK"
# [SEC-FIX] Handle --delay-restart properly: skip restart and tests
if [ "$ARG_DELAY_RESTART" = "y" ]; then
warn "$(msg DELAY_RESTART_MSG)"
+ # [v4.7.3 FIX-MED] Surface the systemd override status — we wrote it but
+ # didn't restart, so the operator should know the protection (Restart=,
+ # OOMScoreAdjust=) only takes effect on next sshd start.
+ if command -v systemctl >/dev/null 2>&1 && [ -f /etc/systemd/system/sshd.service.d/override.conf ]; then
+ info "systemd override written at /etc/systemd/system/sshd.service.d/override.conf — applies on next sshd restart"
+ fi
update_motd
generate_health_report