From 4982f727cfcb85e01324c92885e5b40aea75e9db Mon Sep 17 00:00:00 2001 From: c-ujung Date: Sat, 23 May 2026 15:03:22 +0800 Subject: [PATCH] feat: v4.7.8 Enterprise Hardening (18 audit rounds, 11 fix iterations) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aggregates v4.7.0 through v4.7.8. Roughly 1850 lines added to init.sh (2206 -> 4059); 10 CI jobs (was 1); README +113/+115 lines (zh/en). CRIT/HIGH fixes across iterations: - existing-user passwd-d regression (v4.7.1) - rollback artifact tracking with provenance (v4.7.0/4/5) - restart_sshd direct-spawn fallback for no-init envs (v4.7.1) - chroot pkill defense (v4.7.6/8: both direct-spawn and systemctl paths) - PATH inheritance hardening (v4.7.8): /usr/local/sbin:/usr/local/bin: /usr/sbin:/usr/bin:/sbin:/bin set at script top - audit_log newline-injection defense (v4.7.6) - script-level mutex with stale-PID + kill -0 liveness (v4.7.6/7) - ARTIFACT_FILE mirror to RUNTIME_DIR for post-SIGKILL recovery (v4.7.6) - SELinux NOOP-vs-ADD provenance (v4.7.5) + cross-run cleanup (v4.7.4) - leading-zero port bypass of is_hard_reserved (v4.7.6, e.g. --port=03306 silently shadowing MySQL) - setup_rollback ENOSPC verification (v4.7.7): empty snapshot would blank live sshd_config on rollback - record_last_applied atomic write via tmp+mv (v4.7.7) - audit_log stderr fallback when /var/log is unwritable (v4.7.7) - duplicate-argv hard-fail (v4.7.6): --user/--port/--key-* duplicates now refused instead of silent last-wins - validate_username + --key-gh per-line grep bypass (v4.7.6): newline- containing values now rejected via case-match before grep - leading-zero port (v4.7.6): rejected to prevent sshd parsing 03306 as decimal 3306, shadowing MySQL 41 audit_log actions for SIEM ingestion. README documents all of them with bold-marked high-priority alerts (ROLLBACK_RM_FAILED / STRANDED_MUTATIONS_DETECTED / SUDO_FAIL / etc). 10 CI jobs cover: shellcheck (POSIX -s sh), 9-distro matrix with 18 assertions + idempotency re-run, live restart with banner check, existing-user safety (passwd -S = P), cross-user sudoers glob safety, legacy v4.6.x sudoers cleanup, CLI flag smoke (--version/-V/-h/ --no-ip-probe/--strict), --no-ip-probe verification, ACCOUNT_KEPT_LOCKED path, restore.sh checksum gate + FORCE=1 bypass. Branch convention: dev/v4.7.8 — not yet promoted to main. Awaiting real-machine deployment validation before tag + release. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/test.yml | 623 +++++++++- README.md | 113 +- README_EN.md | 115 +- init.sh | 2369 ++++++++++++++++++++++++++++++++---- 4 files changed, 2882 insertions(+), 338 deletions(-) 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..a1b62e1 100644 --- a/init.sh +++ b/init.sh @@ -7,25 +7,305 @@ # 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 + SCRIPT_START_TIME=$(date +%s) # ---------------- Configuration ---------------- @@ -38,6 +318,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 +376,68 @@ 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 + # Colors RED='\033[0;31m' GREEN='\033[0;32m' @@ -114,6 +455,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 +501,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 +772,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 +913,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 +946,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 +1086,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 +1098,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 +1260,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 +1297,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 +1599,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 +1674,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 +1696,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 +1710,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 +1885,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 +1926,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 +2006,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 +2317,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 # ============================== @@ -1054,57 +2401,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 +2511,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 +2650,214 @@ 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 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 +2892,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 +2920,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 +2954,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 +3002,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 +3045,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)" @@ -1409,7 +3061,17 @@ cleanup_sshd_config_d() { [ -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 + # [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 +3082,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 +3108,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 +3246,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." @@ -1656,7 +3335,10 @@ install_managed_block() { 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 +3437,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 +3543,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 +3561,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 +3580,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 +3610,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 +3678,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 +3727,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 'tolower($1)=="x11forwarding" && tolower($2)=="yes" {exit 0} END {exit 1}' 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 'tolower($1)=="permitemptypasswords" && tolower($2)=="yes" {exit 0} END {exit 1}' 2>/dev/null; then warn "$(msg WARN_EMPTY_PASSWORDS)" insecure_options=$((insecure_options + 1)) fi @@ -1973,8 +3743,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 +3809,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 +3828,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 +3843,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 +3924,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 +3978,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 +4008,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 +4019,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