Aggregates v4.7.0 through v4.7.8. Roughly 1850 lines added to init.sh
(2206 -> 4059); 10 CI jobs (was 1); README +113/+115 lines (zh/en).
CRIT/HIGH fixes across iterations:
- existing-user passwd-d regression (v4.7.1)
- rollback artifact tracking with provenance (v4.7.0/4/5)
- restart_sshd direct-spawn fallback for no-init envs (v4.7.1)
- chroot pkill defense (v4.7.6/8: both direct-spawn and systemctl paths)
- PATH inheritance hardening (v4.7.8): /usr/local/sbin:/usr/local/bin:
/usr/sbin:/usr/bin:/sbin:/bin set at script top
- audit_log newline-injection defense (v4.7.6)
- script-level mutex with stale-PID + kill -0 liveness (v4.7.6/7)
- ARTIFACT_FILE mirror to RUNTIME_DIR for post-SIGKILL recovery (v4.7.6)
- SELinux NOOP-vs-ADD provenance (v4.7.5) + cross-run cleanup (v4.7.4)
- leading-zero port bypass of is_hard_reserved (v4.7.6, e.g. --port=03306
silently shadowing MySQL)
- setup_rollback ENOSPC verification (v4.7.7): empty snapshot would
blank live sshd_config on rollback
- record_last_applied atomic write via tmp+mv (v4.7.7)
- audit_log stderr fallback when /var/log is unwritable (v4.7.7)
- duplicate-argv hard-fail (v4.7.6): --user/--port/--key-* duplicates
now refused instead of silent last-wins
- validate_username + --key-gh per-line grep bypass (v4.7.6): newline-
containing values now rejected via case-match before grep
- leading-zero port (v4.7.6): rejected to prevent sshd parsing 03306
as decimal 3306, shadowing MySQL
41 audit_log actions for SIEM ingestion. README documents all of them
with bold-marked high-priority alerts (ROLLBACK_RM_FAILED /
STRANDED_MUTATIONS_DETECTED / SUDO_FAIL / etc).
10 CI jobs cover: shellcheck (POSIX -s sh), 9-distro matrix with 18
assertions + idempotency re-run, live restart with banner check,
existing-user safety (passwd -S = P), cross-user sudoers glob safety,
legacy v4.6.x sudoers cleanup, CLI flag smoke (--version/-V/-h/
--no-ip-probe/--strict), --no-ip-probe verification, ACCOUNT_KEPT_LOCKED
path, restore.sh checksum gate + FORCE=1 bypass.
Branch convention: dev/v4.7.8 — not yet promoted to main. Awaiting
real-machine deployment validation before tag + release.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4060 lines
171 KiB
Bash
4060 lines
171 KiB
Bash
#!/bin/sh
|
||
# =========================================================
|
||
# linux-ssh-init-sh
|
||
# Server Init & SSH Hardening Script
|
||
#
|
||
# Author: 247like
|
||
# GitHub: https://github.com/247like/linux-ssh-init-sh
|
||
# License: MIT
|
||
#
|
||
# Release: v4.7.8 (Enterprise Hardening — 18th-round red-team / scenario fixes)
|
||
#
|
||
# POSIX sh compatible (Debian dash / CentOS / Alpine / Ubuntu / RHEL)
|
||
#
|
||
# 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-<user>-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 ----------------
|
||
LANG_CUR="zh"
|
||
LOG_FILE="/var/log/server-init.log"
|
||
AUDIT_FILE="/var/log/server-init-audit.log"
|
||
BACKUP_REPO="/var/backups/ssh-config"
|
||
SSH_CONF="/etc/ssh/sshd_config"
|
||
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)
|
||
umask 077
|
||
TMP_DIR=""
|
||
if command -v mktemp >/dev/null 2>&1; then
|
||
TMP_DIR=$(mktemp -d 2>/dev/null || mktemp -d -t ssh-init-XXXXXX 2>/dev/null || echo "")
|
||
fi
|
||
if [ -z "$TMP_DIR" ]; then
|
||
rand_suffix=""
|
||
if [ -r /dev/urandom ] && command -v od >/dev/null 2>&1; then
|
||
rand_suffix=$(od -An -N4 -tx1 /dev/urandom 2>/dev/null | tr -d ' \n')
|
||
fi
|
||
[ -z "$rand_suffix" ] && rand_suffix="$$"
|
||
TMP_DIR="/tmp/ssh-init.${$}.${rand_suffix}.$(date +%s 2>/dev/null || echo 0)"
|
||
# [SEC-FIX] Use mkdir without -p to avoid reusing existing directories
|
||
mkdir "$TMP_DIR" 2>/dev/null || {
|
||
# Retry with different name
|
||
TMP_DIR="/tmp/ssh-init.${rand_suffix}.$$"
|
||
mkdir "$TMP_DIR" 2>/dev/null || { echo "FATAL: Cannot create temp directory: $TMP_DIR" >&2; exit 1; }
|
||
}
|
||
fi
|
||
chmod 700 "$TMP_DIR" 2>/dev/null || true
|
||
umask "$old_umask"
|
||
|
||
# ---------------- [SEC] State & Lock Management ----------------
|
||
RUNTIME_DIR=""
|
||
for try_dir in "/run/server-init" "/var/lib/server-init"; do
|
||
if mkdir -p "$try_dir" 2>/dev/null; then
|
||
RUNTIME_DIR="$try_dir"
|
||
break
|
||
fi
|
||
done
|
||
|
||
if [ -z "$RUNTIME_DIR" ]; then
|
||
rand_rt=""
|
||
if [ -r /dev/urandom ] && command -v od >/dev/null 2>&1; then
|
||
rand_rt=$(od -An -N4 -tx1 /dev/urandom 2>/dev/null | tr -d ' \n')
|
||
fi
|
||
[ -z "$rand_rt" ] && rand_rt="$$"
|
||
RUNTIME_DIR="/tmp/server-init.${rand_rt}.$(date +%s 2>/dev/null || echo 0)"
|
||
# [SEC-FIX] Use mkdir without -p
|
||
if ! mkdir "$RUNTIME_DIR" 2>/dev/null; then
|
||
if command -v mktemp >/dev/null 2>&1; then
|
||
RUNTIME_DIR=$(mktemp -d /tmp/server-init.XXXXXX 2>/dev/null || echo "")
|
||
fi
|
||
fi
|
||
[ -n "$RUNTIME_DIR" ] && [ -d "$RUNTIME_DIR" ] || { echo "FATAL: Cannot create runtime directory" >&2; exit 1; }
|
||
fi
|
||
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'
|
||
YELLOW='\033[1;33m'
|
||
BLUE='\033[0;34m'
|
||
CYAN='\033[0;36m'
|
||
NC='\033[0m'
|
||
|
||
# ---------------- Initialize Variables ----------------
|
||
TARGET_USER=""
|
||
SSH_PORT="22"
|
||
KEY_OK="n"
|
||
PORT_OPT="1"
|
||
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
|
||
|
||
KEX_LINE=""
|
||
CIPHERS_LINE=""
|
||
MACS_LINE=""
|
||
CRYPTO_MODE="skip"
|
||
IPV6_ENABLED="n"
|
||
ROOT_KEY_PRESENT="n"
|
||
SUPPORTS_KBD_INTERACTIVE="n"
|
||
|
||
# ---------------- Automation Variables ----------------
|
||
ARG_USER=""
|
||
ARG_PORT=""
|
||
ARG_KEY_TYPE=""
|
||
ARG_KEY_VAL=""
|
||
ARG_UPDATE=""
|
||
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=*)
|
||
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 <<EOF
|
||
linux-ssh-init-sh v4.7.8 — Server Init & SSH Hardening
|
||
|
||
USAGE:
|
||
./init.sh [OPTIONS]
|
||
|
||
CONTROL:
|
||
--lang=zh|en Force locale (default: zh)
|
||
--yes Skip the final "proceed?" prompt
|
||
--strict Abort on any critical failure (no fallback)
|
||
--delay-restart Write config but do not restart sshd / run tests
|
||
--no-ip-probe Skip public IP lookup (offline-friendly)
|
||
-V, --version Print version and exit
|
||
-h, --help This help
|
||
|
||
USER & PORT:
|
||
--user=NAME Login user (default: deploy; "root" allowed)
|
||
--port=22|random|N SSH port (random uses 49152-65535)
|
||
|
||
KEY SOURCE (pick one):
|
||
--key-gh=USERNAME Pull from https://github.com/USERNAME.keys
|
||
--key-url=URL Fetch from URL (https://; http:// only outside strict)
|
||
--key-raw="ssh-..." Inline key(s); use literal \\n between multiple lines
|
||
|
||
SYSTEM:
|
||
--update / --no-update Upgrade installed packages
|
||
--bbr / --no-bbr Enable TCP BBR congestion control
|
||
|
||
LOGS:
|
||
/var/log/server-init.log (debug log)
|
||
/var/log/server-init-audit.log (audit trail)
|
||
/var/log/server-init-health.log (post-run snapshot)
|
||
/var/backups/ssh-config/<TS>/ (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.7.8 Enterprise Hardening)" ;;
|
||
STRICT_ON) echo "STRICT 模式已开启:任何关键错误将直接退出" ;;
|
||
ASK_USER) echo "SSH 登录用户 (默认 " ;;
|
||
ERR_USER_INV) echo "❌ 用户名无效 (仅限小写字母/数字/下划线,且避开系统保留名)" ;;
|
||
ASK_PORT_T) echo "SSH 端口配置:" ;;
|
||
OPT_PORT_1) echo "1) 使用 22 (默认)" ;;
|
||
OPT_PORT_2) echo "2) 随机高端口 (49152+, 自动避开 K8s)" ;;
|
||
OPT_PORT_3) echo "3) 手动指定" ;;
|
||
SELECT) echo "请选择 [1-3]: " ;;
|
||
INPUT_PORT) echo "请输入端口号 (1024-65535): " ;;
|
||
PORT_ERR) echo "❌ 端口输入无效 (非数字或超范围)" ;;
|
||
PORT_RES) echo "❌ 端口被系统保留或不建议使用 (如 80, 443, 3306 等)" ;;
|
||
PORT_K8S) echo "⚠️ 警告: 此端口位于 Kubernetes NodePort 常用范围 (30000-32767),可能冲突" ;;
|
||
ASK_KEY_T) echo "SSH 公钥来源:" ;;
|
||
OPT_KEY_1) echo "1) GitHub 用户导入" ;;
|
||
OPT_KEY_2) echo "2) URL 下载" ;;
|
||
OPT_KEY_3) echo "3) 手动粘贴" ;;
|
||
INPUT_GH) echo "请输入 GitHub 用户名: " ;;
|
||
INPUT_URL) echo "请输入公钥 URL: " ;;
|
||
INPUT_RAW) echo "请粘贴公钥内容 (空行结束输入): " ;;
|
||
ASK_UPD) echo "是否更新系统软件包? [y/n] (默认 n): " ;;
|
||
ASK_BBR) echo "是否开启 BBR 加速? [y/n] (默认 n): " ;;
|
||
CONFIRM_T) echo "---------------- 执行确认 ----------------" ;;
|
||
C_USER) echo "登录用户: " ;;
|
||
C_PORT) echo "端口模式: " ;;
|
||
C_KEY) echo "密钥来源: " ;;
|
||
C_UPD) echo "系统更新: " ;;
|
||
C_BBR) echo "开启 BBR: " ;;
|
||
WARN_FW) echo "⚠ 注意:修改端口前,请确认云厂商防火墙/安全组已放行对应 TCP 端口" ;;
|
||
ASK_SURE) echo "确认执行? [y/n]: " ;;
|
||
CANCEL) echo "已取消操作" ;;
|
||
I_INSTALL) echo "正在安装基础依赖..." ;;
|
||
I_UPD) echo "正在更新系统..." ;;
|
||
I_BBR) echo "正在配置 BBR..." ;;
|
||
I_USER) echo "正在配置用户..." ;;
|
||
I_SSH_INSTALL) echo "未检测到 OpenSSH,正在安装..." ;;
|
||
I_KEY_OK) echo "公钥部署成功" ;;
|
||
W_KEY_FAIL) echo "公钥部署失败,将启用安全回退策略以避免失联" ;;
|
||
I_BACKUP) echo "已全量备份配置 (SSH/User/Firewall): " ;;
|
||
E_SSHD_CHK) echo "sshd 配置校验失败,正在回滚..." ;;
|
||
E_GREP_FAIL) echo "配置验证失败:关键参数未生效,正在回滚..." ;;
|
||
E_RESTART) echo "SSH 服务重启失败,正在回滚..." ;;
|
||
W_RESTART) echo "无法自动重启 SSH 服务,请手动重启" ;;
|
||
W_LISTEN_FAIL) echo "SSHD 已重启但端口未监听,可能启动失败,正在回滚..." ;;
|
||
DONE_T) echo "================ 完成 ================" ;;
|
||
DONE_MSG1) echo "请【不要关闭】当前窗口。" ;;
|
||
DONE_MSG2) echo "请新开一个终端窗口测试登录:" ;;
|
||
DONE_FW) echo "⚠ 若无法连接,请再次检查防火墙设置" ;;
|
||
AUTO_SKIP) echo "检测到参数输入,跳过询问: " ;;
|
||
RB_START) echo "脚本执行出现关键错误,开始自动回滚..." ;;
|
||
RB_DONE) echo "回滚完成。系统状态已恢复。" ;;
|
||
RB_FAIL) echo "致命错误:回滚失败!请立即手动检查 /etc/ssh/sshd_config" ;;
|
||
SELINUX_DET) echo "检测到 SELinux Enforcing 模式,正在配置端口规则..." ;;
|
||
SELINUX_OK) echo "SELinux 端口规则添加成功" ;;
|
||
SELINUX_FAIL) echo "SELinux 规则添加失败,请手动执行: semanage port -a -t ssh_port_t -p tcp PORT" ;;
|
||
SELINUX_INS) echo "正在安装 SELinux 管理工具..." ;;
|
||
CLEAN_D) echo "检测到冲突的配置片段,已备份并移除: " ;;
|
||
TEST_CONN) echo "正在进行 SSH 连接测试 (IPv4/Local)..." ;;
|
||
TEST_OK) echo "SSH 连接测试通过" ;;
|
||
TEST_FAIL) echo "SSH 连接测试全部失败!新配置可能无法连接,正在回滚..." ;;
|
||
IPV6_CFG) echo "检测到全局 IPv6 环境,已添加 :: 监听支持" ;;
|
||
SYS_PROT) echo "正在添加 systemd 服务防误杀保护..." ;;
|
||
MOTD_UPD) echo "正在更新登录提示信息 (MotD)..." ;;
|
||
COMPAT_WARN) echo "检测到兼容性限制,已自动调整配置..." ;;
|
||
AUDIT_START) echo "开始执行审计记录..." ;;
|
||
BOX_TITLE) echo "初始化完成 - 安全配置已生效" ;;
|
||
BOX_SSH) echo "SSH 连接信息:" ;;
|
||
BOX_KEY_ON) echo "🔐 密钥认证: 已启用 (密码登录已禁用)" ;;
|
||
BOX_KEY_OFF) echo "⚠️ 密钥认证: 未启用 (密码登录保持可用/回退策略已启用)" ;;
|
||
BOX_PORT) echo "📍 端口变更: 22 → " ;;
|
||
BOX_FW) echo "⚠️ 请确认防火墙已开放 TCP 端口" ;;
|
||
BOX_WARN) echo "重要: 请在新窗口中测试连接,确认成功后再关闭此窗口!" ;;
|
||
BOX_K8S_WARN) echo "⚠️ 注意: 使用了 Kubernetes NodePort 范围端口" ;;
|
||
ERR_MISSING) echo "❌ 缺少必要命令,无法继续: " ;;
|
||
ERR_MISSING_SSHD) echo "❌ 未找到 sshd 命令,请先安装 OpenSSH Server" ;;
|
||
WARN_DISK) echo "⚠️ 磁盘空间不足: " ;;
|
||
WARN_MEM) echo "⚠️ 可用内存不足: " ;;
|
||
WARN_RESUME) echo "检测到未完成的初始化,可能上次执行异常终止" ;;
|
||
ASK_RESUME) echo "检测到未完成的操作,是否继续? [y/N]: " ;;
|
||
ERR_BACKUP_DIR) echo "❌ 无法创建备份目录:" ;;
|
||
ERR_BACKUP_DIR_ALT) echo "❌ 无法创建备用备份目录" ;;
|
||
ERR_BACKUP_SUBDIR) echo "❌ 无法创建备份子目录:" ;;
|
||
INFO_BACKUP_CREATED) echo "✅ 备份已创建:" ;;
|
||
INFO_CLEANING_BACKUPS) echo "🧹 正在清理" ;;
|
||
INFO_OLD_BACKUPS) echo "个旧备份..." ;;
|
||
ERR_LOCK_DIR) echo "❌ 无法创建锁目录:" ;;
|
||
WARN_LOCK_DIR_PERM) echo "⚠️ 无法设置锁目录权限,继续尝试..." ;;
|
||
WARN_CLEAN_LOCKS) echo "⚠️ 清理旧的锁文件..." ;;
|
||
WARN_INVALID_KEY) echo "⚠️ 跳过无效的SSH密钥行" ;;
|
||
WARN_SHORT_RSA_KEY) echo "⚠️ RSA密钥过短:" ;;
|
||
WARN_SHORT_ED25519_KEY) echo "⚠️ Ed25519密钥过短:" ;;
|
||
WARN_SHORT_DSA_KEY) echo "⚠️ DSA密钥过短:" ;;
|
||
ERR_INVALID_KEY_FORMAT) echo "❌ SSH密钥格式无效" ;;
|
||
ERR_MISSING_BASE64) echo "❌ SSH密钥缺少base64部分" ;;
|
||
ERR_INVALID_BASE64) echo "❌ SSH密钥base64编码无效" ;;
|
||
WARN_NO_BASE64_SKIPLEN) echo "⚠️ 未检测到 base64 命令:将跳过密钥长度校验,仅做格式校验" ;;
|
||
WARN_USER_SHELL) echo "⚠️ 用户shell不允许登录:" ;;
|
||
ASK_CHANGE_SHELL) echo "是否更改用户的shell为/bin/bash? [y/N]: " ;;
|
||
WARN_CHANGE_SHELL_FAIL) echo "⚠️ 更改shell失败" ;;
|
||
WARN_UNUSUAL_SHELL) echo "⚠️ 用户使用非常规shell:" ;;
|
||
WARN_HOME_OWNER) echo "⚠️ 用户家目录所有者异常:" ;;
|
||
WARN_HOME_NOT_WRITABLE) echo "⚠️ 用户家目录不可写" ;;
|
||
ERR_USER_CREATE_FAIL) echo "❌ 创建用户失败" ;;
|
||
ERR_USER_VERIFY_FAIL) echo "❌ 用户创建后验证失败" ;;
|
||
WARN_NO_SUDOERS_DIR) echo "⚠️ 没有/etc/sudoers.d目录,跳过sudo配置" ;;
|
||
INFO_SUDO_EXISTS) echo "ℹ️ 用户已配置sudo权限" ;;
|
||
ERR_SUDOERS_SYNTAX) echo "❌ sudoers文件语法错误,已删除" ;;
|
||
ERR_SUDOERS_PERM) echo "❌ 无法设置sudoers文件权限" ;;
|
||
INFO_SUDO_CONFIGURED) echo "✅ 为用户配置了sudo权限" ;;
|
||
WARN_SSH_PROTOCOL) echo "⚠️ SSH协议握手失败或超时" ;;
|
||
INFO_SSH_PROTOCOL_OK) echo "✅ SSH协议握手成功" ;;
|
||
WARN_PORT_OPEN_BUT_FAIL) echo "⚠️ 端口已打开,但SSH客户端连接失败(通常因无私钥或默认私钥不匹配)。此非错误,请务必人工测试连接!" ;;
|
||
WARN_X11_FORWARDING) echo "⚠️ X11转发已启用,可能存在安全风险" ;;
|
||
WARN_EMPTY_PASSWORDS) echo "⚠️ 允许空密码,存在安全风险" ;;
|
||
WARN_INSECURE_OPTIONS) echo "⚠️ 检测到非关键的不安全选项 (仅提示,不影响安装)" ;;
|
||
ERR_DEADLOCK) echo "❌ 致命错误:密码和密钥认证同时被禁用,将导致锁定!" ;;
|
||
ERR_PASSWORD_NO_KEY) echo "❌ 致命错误:密码认证已禁用但未成功部署SSH密钥" ;;
|
||
ERR_ROOT_NO_KEY) echo "❌ 致命错误:root密码登录已禁用但未部署SSH密钥" ;;
|
||
WARN_PORT_MISMATCH) echo "⚠️ 配置中的端口与目标端口不匹配" ;;
|
||
ERR_CANNOT_RESERVE_PORT) echo "❌ 无法预留端口,端口可能已被占用" ;;
|
||
INFO_OLD_SSH_SKIP_ALGO) echo "ℹ️ OpenSSH较旧或无法检测支持列表:跳过现代加密算法强制配置" ;;
|
||
INFO_SANITIZE_DUP) echo "ℹ️ 清理原配置文件中的重复指令..." ;;
|
||
INFO_MATCH_INSERT) echo "ℹ️ 检测到 Match 块:托管配置将插入到首个 Match 之前,以避免语法/作用域问题" ;;
|
||
ERR_NO_BANNER) echo "❌ 未能获取 SSH-2.0 协议 banner,服务可能未正常启动" ;;
|
||
INFO_KEYS_DEPLOYED) echo "✅ 成功部署密钥数量:" ;;
|
||
WARN_NO_VALID_KEYS) echo "⚠️ 没有有效的SSH密钥被部署" ;;
|
||
ERR_HOME_SYMLINK) echo "❌ 拒绝:用户家目录是符号链接" ;;
|
||
ERR_SSH_DIR_SYMLINK) echo "❌ 拒绝:.ssh 目录是符号链接" ;;
|
||
ERR_AUTH_KEYS_SYMLINK) echo "❌ 拒绝:authorized_keys 是符号链接" ;;
|
||
ERR_HOME_NOT_DIR) echo "❌ 拒绝:用户家目录不是目录" ;;
|
||
ERR_SSH_DIR_NOT_DIR) echo "❌ 拒绝:.ssh 存在但不是目录" ;;
|
||
ERR_AUTH_KEYS_NOT_FILE) echo "❌ 拒绝:authorized_keys 存在但不是普通文件" ;;
|
||
DELAY_RESTART_MSG) echo "⚠️ 延迟重启模式:配置已写入,请手动重启 sshd 并测试连接" ;;
|
||
*) echo "$key" ;;
|
||
esac
|
||
else
|
||
case "$key" in
|
||
MUST_ROOT) echo "Must be run as root" ;;
|
||
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)" ;;
|
||
ASK_PORT_T) echo "SSH Port Configuration:" ;;
|
||
OPT_PORT_1) echo "1) Use 22 (Default)" ;;
|
||
OPT_PORT_2) echo "2) Random High Port (49152+, avoids K8s)" ;;
|
||
OPT_PORT_3) echo "3) Manual Input" ;;
|
||
SELECT) echo "Select [1-3]: " ;;
|
||
INPUT_PORT) echo "Enter Port (1024-65535): " ;;
|
||
PORT_ERR) echo "❌ Invalid port (not numeric or out of range)" ;;
|
||
PORT_RES) echo "❌ Port is reserved (e.g. 80, 443, 3306)" ;;
|
||
PORT_K8S) echo "⚠️ Warning: Port falls in Kubernetes NodePort range (30000-32767)" ;;
|
||
ASK_KEY_T) echo "SSH Public Key Source:" ;;
|
||
OPT_KEY_1) echo "1) GitHub User" ;;
|
||
OPT_KEY_2) echo "2) URL Download" ;;
|
||
OPT_KEY_3) echo "3) Manual Paste" ;;
|
||
INPUT_GH) echo "Enter GitHub Username: " ;;
|
||
INPUT_URL) echo "Enter Key URL: " ;;
|
||
INPUT_RAW) echo "Paste Key (Empty line to finish): " ;;
|
||
ASK_UPD) echo "Update system packages? [y/n] (default n): " ;;
|
||
ASK_BBR) echo "Enable TCP BBR? [y/n] (default n): " ;;
|
||
CONFIRM_T) echo "---------------- Confirmation ----------------" ;;
|
||
C_USER) echo "User: " ;;
|
||
C_PORT) echo "Port: " ;;
|
||
C_KEY) echo "Key Source: " ;;
|
||
C_UPD) echo "Update: " ;;
|
||
C_BBR) echo "Enable BBR: " ;;
|
||
WARN_FW) echo "⚠ WARNING: Ensure Cloud Firewall/Security Group allows the new TCP port" ;;
|
||
ASK_SURE) echo "Proceed? [y/n]: " ;;
|
||
CANCEL) echo "Cancelled." ;;
|
||
I_INSTALL) echo "Installing dependencies..." ;;
|
||
I_UPD) echo "Updating system..." ;;
|
||
I_BBR) echo "Configuring BBR..." ;;
|
||
I_USER) echo "Configuring user..." ;;
|
||
I_SSH_INSTALL) echo "OpenSSH not found, installing..." ;;
|
||
I_KEY_OK) echo "SSH Key deployed successfully" ;;
|
||
W_KEY_FAIL) echo "Key deployment failed; enabling fallback policy to avoid lockout" ;;
|
||
I_BACKUP) echo "Full backup created (SSH/User/Firewall): " ;;
|
||
E_SSHD_CHK) echo "sshd config validation failed, rolling back..." ;;
|
||
E_GREP_FAIL) echo "Config validation failed: Critical settings not active. Rolling back..." ;;
|
||
E_RESTART) echo "SSH service restart failed, rolling back..." ;;
|
||
W_RESTART) echo "Could not restart sshd automatically. Please restart manually." ;;
|
||
W_LISTEN_FAIL) echo "SSHD restarted but port is not listening. Rolling back..." ;;
|
||
DONE_T) echo "================ DONE ================" ;;
|
||
DONE_MSG1) echo "Please DO NOT close this window yet." ;;
|
||
DONE_MSG2) echo "Open a NEW terminal to test login:" ;;
|
||
DONE_FW) echo "⚠ If connection fails, check your Firewall settings." ;;
|
||
AUTO_SKIP) echo "Argument detected, skipping prompt: " ;;
|
||
RB_START) echo "Critical error. Starting automatic rollback..." ;;
|
||
RB_DONE) echo "Rollback complete. System state restored." ;;
|
||
RB_FAIL) echo "FATAL: Rollback failed! Manually check /etc/ssh/sshd_config" ;;
|
||
SELINUX_DET) echo "SELinux Enforcing detected. Configuring port rules..." ;;
|
||
SELINUX_OK) echo "SELinux port rule added successfully." ;;
|
||
SELINUX_FAIL) echo "SELinux rule failed. Manually run: semanage port -a -t ssh_port_t -p tcp PORT" ;;
|
||
SELINUX_INS) echo "Installing SELinux management tools..." ;;
|
||
CLEAN_D) echo "Detected conflicting config fragment, backed up and removed: " ;;
|
||
TEST_CONN) echo "Testing SSH connection (IPv4/Local)..." ;;
|
||
TEST_OK) echo "SSH connection test passed." ;;
|
||
TEST_FAIL) echo "SSH connection test FAILED! Rolling back..." ;;
|
||
IPV6_CFG) echo "Global IPv6 detected. Added listen address :: support." ;;
|
||
SYS_PROT) echo "Adding systemd service protection (anti-kill)..." ;;
|
||
MOTD_UPD) echo "Updating Message of the Day (MotD)..." ;;
|
||
COMPAT_WARN) echo "Compatibility limits detected; adjusted configuration automatically..." ;;
|
||
AUDIT_START) echo "Starting audit logging..." ;;
|
||
BOX_TITLE) echo "Init Complete - Security Applied" ;;
|
||
BOX_SSH) echo "SSH Connection Info:" ;;
|
||
BOX_KEY_ON) echo "🔐 Key Auth: ENABLED (Password Disabled)" ;;
|
||
BOX_KEY_OFF) echo "⚠️ Key Auth: DISABLED (Password/Fallback Enabled)" ;;
|
||
BOX_PORT) echo "📍 Port Change: 22 → " ;;
|
||
BOX_FW) echo "⚠️ Verify Firewall Open for TCP Port" ;;
|
||
BOX_WARN) echo "IMPORTANT: Test connection in NEW window before closing this one!" ;;
|
||
BOX_K8S_WARN) echo "⚠️ NOTE: Using K8s NodePort range" ;;
|
||
ERR_MISSING) echo "❌ Missing essential commands: " ;;
|
||
ERR_MISSING_SSHD) echo "❌ sshd command not found, please install OpenSSH Server first" ;;
|
||
WARN_DISK) echo "⚠️ Low disk space: " ;;
|
||
WARN_MEM) echo "⚠️ Low memory: " ;;
|
||
WARN_RESUME) echo "Detected incomplete initialization, last execution may have crashed" ;;
|
||
ASK_RESUME) echo "Detected incomplete operation, continue? [y/N]: " ;;
|
||
ERR_BACKUP_DIR) echo "❌ Cannot create backup directory:" ;;
|
||
ERR_BACKUP_DIR_ALT) echo "❌ Cannot create alternative backup directory" ;;
|
||
ERR_BACKUP_SUBDIR) echo "❌ Cannot create backup subdirectory:" ;;
|
||
INFO_BACKUP_CREATED) echo "✅ Backup created:" ;;
|
||
INFO_CLEANING_BACKUPS) echo "🧹 Cleaning" ;;
|
||
INFO_OLD_BACKUPS) echo "old backups..." ;;
|
||
ERR_LOCK_DIR) echo "❌ Cannot create lock directory:" ;;
|
||
WARN_LOCK_DIR_PERM) echo "⚠️ Cannot set lock directory permissions, continuing..." ;;
|
||
WARN_CLEAN_LOCKS) echo "⚠️ Cleaning old lock files..." ;;
|
||
WARN_INVALID_KEY) echo "⚠️ Skipping invalid SSH key line" ;;
|
||
WARN_SHORT_RSA_KEY) echo "⚠️ RSA key too short:" ;;
|
||
WARN_SHORT_ED25519_KEY) echo "⚠️ Ed25519 key too short:" ;;
|
||
WARN_SHORT_DSA_KEY) echo "⚠️ DSA key too short:" ;;
|
||
ERR_INVALID_KEY_FORMAT) echo "❌ SSH key format invalid" ;;
|
||
ERR_MISSING_BASE64) echo "❌ SSH key missing base64 part" ;;
|
||
ERR_INVALID_BASE64) echo "❌ SSH key base64 encoding invalid" ;;
|
||
WARN_NO_BASE64_SKIPLEN) echo "⚠️ base64 not found: skipping key length checks (format-only validation)" ;;
|
||
WARN_USER_SHELL) echo "⚠️ User shell does not allow login:" ;;
|
||
ASK_CHANGE_SHELL) echo "Change user's shell to /bin/bash? [y/N]: " ;;
|
||
WARN_CHANGE_SHELL_FAIL) echo "⚠️ Failed to change shell" ;;
|
||
WARN_UNUSUAL_SHELL) echo "⚠️ User uses unusual shell:" ;;
|
||
WARN_HOME_OWNER) echo "⚠️ User home directory owner mismatch:" ;;
|
||
WARN_HOME_NOT_WRITABLE) echo "⚠️ User home directory not writable" ;;
|
||
ERR_USER_CREATE_FAIL) echo "❌ Failed to create user" ;;
|
||
ERR_USER_VERIFY_FAIL) echo "❌ User verification failed after creation" ;;
|
||
WARN_NO_SUDOERS_DIR) echo "⚠️ No /etc/sudoers.d directory, skipping sudo config" ;;
|
||
INFO_SUDO_EXISTS) echo "ℹ️ User already has sudo permissions" ;;
|
||
ERR_SUDOERS_SYNTAX) echo "❌ sudoers file syntax error, deleted" ;;
|
||
ERR_SUDOERS_PERM) echo "❌ Cannot set sudoers file permissions" ;;
|
||
INFO_SUDO_CONFIGURED) echo "✅ Configured sudo permissions for user" ;;
|
||
WARN_SSH_PROTOCOL) echo "⚠️ SSH protocol handshake failed or timed out" ;;
|
||
INFO_SSH_PROTOCOL_OK) echo "✅ SSH protocol handshake successful" ;;
|
||
WARN_PORT_OPEN_BUT_FAIL) echo "⚠️ Port is open, but SSH connection failed (likely due to missing/mismatched private key). This is NOT an error. Please verify connection manually!" ;;
|
||
WARN_X11_FORWARDING) echo "⚠️ X11 forwarding enabled, potential security risk" ;;
|
||
WARN_EMPTY_PASSWORDS) echo "⚠️ Empty passwords allowed, security risk" ;;
|
||
WARN_INSECURE_OPTIONS) echo "⚠️ Found non-critical insecure options (Info only, proceeding)" ;;
|
||
ERR_DEADLOCK) echo "❌ FATAL: Both password and key authentication disabled, will cause lockout!" ;;
|
||
ERR_PASSWORD_NO_KEY) echo "❌ FATAL: Password auth disabled but no SSH key deployed" ;;
|
||
ERR_ROOT_NO_KEY) echo "❌ FATAL: Root password login disabled but no SSH key deployed" ;;
|
||
WARN_PORT_MISMATCH) echo "⚠️ Port in config does not match target port" ;;
|
||
ERR_CANNOT_RESERVE_PORT) echo "❌ Cannot reserve port, port may be occupied" ;;
|
||
INFO_OLD_SSH_SKIP_ALGO) echo "ℹ️ Old OpenSSH or unable to detect supported lists: skipping forced crypto algorithms" ;;
|
||
INFO_SANITIZE_DUP) echo "ℹ️ Sanitizing duplicate directives in original config..." ;;
|
||
INFO_MATCH_INSERT) echo "ℹ️ Match blocks detected: inserting managed block before first Match to avoid scope issues" ;;
|
||
ERR_NO_BANNER) echo "❌ Failed to get SSH-2.0 protocol banner, service may not be running properly" ;;
|
||
INFO_KEYS_DEPLOYED) echo "✅ Number of keys deployed:" ;;
|
||
WARN_NO_VALID_KEYS) echo "⚠️ No valid SSH keys were deployed" ;;
|
||
ERR_HOME_SYMLINK) echo "❌ Refuse: user home is symlink" ;;
|
||
ERR_SSH_DIR_SYMLINK) echo "❌ Refuse: .ssh is symlink" ;;
|
||
ERR_AUTH_KEYS_SYMLINK) echo "❌ Refuse: authorized_keys is symlink" ;;
|
||
ERR_HOME_NOT_DIR) echo "❌ Refuse: user home is not a directory" ;;
|
||
ERR_SSH_DIR_NOT_DIR) echo "❌ Refuse: .ssh exists but is not a directory" ;;
|
||
ERR_AUTH_KEYS_NOT_FILE) echo "❌ Refuse: authorized_keys exists but is not a regular file" ;;
|
||
DELAY_RESTART_MSG) echo "⚠️ Delay restart mode: config written, please manually restart sshd and test" ;;
|
||
*) echo "$key" ;;
|
||
esac
|
||
fi
|
||
}
|
||
|
||
# ---------------- 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)"
|
||
if touch "$logfile_new" 2>/dev/null; then
|
||
chmod 600 "$logfile_new" 2>/dev/null || true
|
||
if [ "$logfile" = "$LOG_FILE" ]; then
|
||
LOG_FILE="$logfile_new"
|
||
else
|
||
AUDIT_FILE="$logfile_new"
|
||
fi
|
||
fi
|
||
fi
|
||
else
|
||
touch "$logfile" 2>/dev/null || true
|
||
chmod 600 "$logfile" 2>/dev/null || true
|
||
fi
|
||
done
|
||
}
|
||
|
||
init_log_files
|
||
|
||
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 || {
|
||
# [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"
|
||
}
|
||
|
||
# [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
|
||
# =========================================================
|
||
|
||
preflight_checks() {
|
||
essential_cmds="cat grep awk sed cp mv chmod chown mkdir rm id"
|
||
extended_cmds="wc tr head cut touch find sleep date df uname tail"
|
||
|
||
missing_cmds=""
|
||
for cmd in $essential_cmds; do
|
||
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||
missing_cmds="$missing_cmds $cmd"
|
||
fi
|
||
done
|
||
if [ -n "$missing_cmds" ]; then
|
||
die "$(msg ERR_MISSING)$missing_cmds"
|
||
fi
|
||
|
||
for cmd in $extended_cmds; do
|
||
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||
warn "Optional command not found: $cmd (some features may be limited)"
|
||
fi
|
||
done
|
||
|
||
available_kb=$(df -k / 2>/dev/null | awk 'NR==2 {print $4}' 2>/dev/null || echo "")
|
||
if [ -n "$available_kb" ] && [ "$available_kb" -lt 5120 ] 2>/dev/null; then
|
||
warn "$(msg WARN_DISK)${available_kb}KB"
|
||
fi
|
||
|
||
if [ -f /proc/meminfo ]; then
|
||
mem_avail=$(grep MemAvailable /proc/meminfo 2>/dev/null | awk '{print $2}' 2>/dev/null || echo "")
|
||
if [ -n "$mem_avail" ] && [ "$mem_avail" -lt 51200 ] 2>/dev/null; then
|
||
warn "$(msg WARN_MEM)${mem_avail}KB"
|
||
fi
|
||
fi
|
||
|
||
if ! command -v base64 >/dev/null 2>&1; then
|
||
warn "$(msg WARN_NO_BASE64_SKIPLEN)"
|
||
fi
|
||
}
|
||
|
||
# ---------------- Package Manager ----------------
|
||
detect_pm() {
|
||
[ -f /etc/alpine-release ] && { echo apk; return; }
|
||
[ -f /etc/debian_version ] && { echo apt; return; }
|
||
[ -f /etc/redhat-release ] && { echo yum; return; }
|
||
echo unknown
|
||
}
|
||
|
||
PM="$(detect_pm)"
|
||
APT_UPDATED="n"
|
||
APK_UPDATED="n"
|
||
YUM_PREPARED="n"
|
||
|
||
pm_prepare_once() {
|
||
case "$PM" in
|
||
apt) [ "$APT_UPDATED" != "y" ] && { apt-get update -y >>"$LOG_FILE" 2>&1 || true; APT_UPDATED="y"; } ;;
|
||
apk) [ "$APK_UPDATED" != "y" ] && { apk update >>"$LOG_FILE" 2>&1 || true; APK_UPDATED="y"; } ;;
|
||
yum) [ "$YUM_PREPARED" != "y" ] && {
|
||
if command -v dnf >/dev/null 2>&1; then dnf makecache -y >>"$LOG_FILE" 2>&1 || true;
|
||
else yum makecache -y >>"$LOG_FILE" 2>&1 || true; fi
|
||
YUM_PREPARED="y"; } ;;
|
||
esac
|
||
}
|
||
|
||
install_pkg() {
|
||
case "$PM" in
|
||
apt) pm_prepare_once; DEBIAN_FRONTEND=noninteractive apt-get install -y "$@" >>"$LOG_FILE" 2>&1 ;;
|
||
yum) pm_prepare_once;
|
||
if command -v dnf >/dev/null 2>&1; then dnf install -y "$@" >>"$LOG_FILE" 2>&1;
|
||
else yum install -y "$@" >>"$LOG_FILE" 2>&1; fi ;;
|
||
apk) pm_prepare_once; apk add --no-cache "$@" >>"$LOG_FILE" 2>&1 ;;
|
||
*) return 1 ;;
|
||
esac
|
||
}
|
||
|
||
install_pkg_try() {
|
||
for p in "$@"; do
|
||
if install_pkg "$p" >/dev/null 2>&1; then return 0; fi
|
||
done
|
||
return 1
|
||
}
|
||
|
||
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;
|
||
if command -v dnf >/dev/null 2>&1; then dnf upgrade -y >>"$LOG_FILE" 2>&1;
|
||
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() {
|
||
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
|
||
systemctl stop ssh.socket >/dev/null 2>&1 || true
|
||
systemctl disable ssh.socket >/dev/null 2>&1 || true
|
||
if systemctl restart sshd >>"$LOG_FILE" 2>&1 || systemctl restart ssh >>"$LOG_FILE" 2>&1; then
|
||
return 0
|
||
fi
|
||
res=$?
|
||
fi
|
||
|
||
# ----- 2. OpenRC -----
|
||
if command -v rc-service >/dev/null 2>&1; then
|
||
rc-service sshd restart >>"$LOG_FILE" 2>&1 && return 0
|
||
res=$?
|
||
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=$?
|
||
fi
|
||
|
||
return "$res"
|
||
}
|
||
|
||
# ---------------- Robust Rollback ----------------
|
||
ROLLBACK_DIR="$TMP_DIR/rollback"
|
||
|
||
update_state() {
|
||
phase="$1"
|
||
details="${2:-}"
|
||
{
|
||
echo "PHASE=$phase"
|
||
echo "TIMESTAMP=$(date +%s)"
|
||
echo "USER=${TARGET_USER:-unknown}"
|
||
echo "PORT=${SSH_PORT:-22}"
|
||
echo "KEY_OK=${KEY_OK:-n}"
|
||
echo "DETAILS=$details"
|
||
} > "$STATE_FILE" 2>/dev/null || true
|
||
chmod 600 "$STATE_FILE" 2>/dev/null || true
|
||
}
|
||
|
||
parse_state_value() {
|
||
key="$1"
|
||
file="$2"
|
||
if [ -f "$file" ] && [ -r "$file" ]; then
|
||
sed -n "s/^${key}=//p" "$file" 2>/dev/null | head -n 1 | tr -d '\r'
|
||
fi
|
||
}
|
||
|
||
check_previous_state() {
|
||
if [ -f "$STATE_FILE" ]; then
|
||
state_owner=""
|
||
if stat -c "%u" "$STATE_FILE" >/dev/null 2>&1; then
|
||
state_owner=$(stat -c "%u" "$STATE_FILE" 2>/dev/null)
|
||
else
|
||
state_owner=$(ls -ln "$STATE_FILE" 2>/dev/null | awk '{print $3}')
|
||
fi
|
||
|
||
current_uid=$(id -u)
|
||
if [ "$state_owner" != "$current_uid" ] 2>/dev/null; then
|
||
warn "State file owned by different user, ignoring"
|
||
rm -f "$STATE_FILE" 2>/dev/null || true
|
||
return 0
|
||
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
|
||
if [ "${continue_resume:-n}" != "y" ]; then
|
||
rm -f "$STATE_FILE" 2>/dev/null || true
|
||
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
|
||
# [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=$?
|
||
# [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)"
|
||
|
||
# 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
|
||
|
||
# 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
|
||
|
||
cleanup_locks
|
||
exit "$RET"
|
||
}
|
||
|
||
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"
|
||
|
||
# [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
|
||
|
||
# [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 ----------------
|
||
# [SEC-FIX] Fixed cleanup_old_backups awk logic bug
|
||
cleanup_old_backups() {
|
||
keep_count=10
|
||
if [ -d "$BACKUP_REPO" ]; then
|
||
backup_list=$(ls -dt "$BACKUP_REPO"/*/ 2>/dev/null) || return 0
|
||
# [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)
|
||
echo "$backup_list" | tail -n "$to_rm" | while IFS= read -r d; do
|
||
[ -n "$d" ] && [ -d "$d" ] && rm -rf "$d" 2>/dev/null || true
|
||
done
|
||
fi
|
||
fi
|
||
}
|
||
|
||
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")
|
||
fi
|
||
|
||
backup_dir="$BACKUP_REPO/$timestamp"
|
||
|
||
if ! mkdir -p "$BACKUP_REPO" 2>/dev/null; then
|
||
warn "$(msg ERR_BACKUP_DIR) $BACKUP_REPO"
|
||
# [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
|
||
# [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.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
|
||
# 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"
|
||
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
|
||
|
||
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"
|
||
return 0
|
||
}
|
||
|
||
# ---------------- BBR ----------------
|
||
enable_bbr() {
|
||
command -v sysctl >/dev/null 2>&1 || return 0
|
||
if ! sysctl -n net.ipv4.tcp_available_congestion_control 2>/dev/null | grep -q bbr; then
|
||
warn "Kernel does not support BBR, skipping."
|
||
return 0
|
||
fi
|
||
sysctl_conf="/etc/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
|
||
}
|
||
|
||
# ---------------- SSHD Helpers ----------------
|
||
ensure_ssh_server() {
|
||
if [ -f "$SSH_CONF" ] && command -v sshd >/dev/null 2>&1; then
|
||
return 0
|
||
fi
|
||
info "$(msg I_SSH_INSTALL)"
|
||
case "$PM" in
|
||
apk) install_pkg openssh openssh-server ;;
|
||
*) install_pkg openssh-server ;;
|
||
esac
|
||
|
||
if ! command -v sshd >/dev/null 2>&1; then
|
||
die "$(msg ERR_MISSING_SSHD)"
|
||
fi
|
||
[ -f "$SSH_CONF" ] || die "OpenSSH Install Failed"
|
||
}
|
||
|
||
protect_sshd_service() {
|
||
command -v systemctl >/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'
|
||
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() {
|
||
OPENSSH_VER_MAJOR=0
|
||
OPENSSH_VER_MINOR=0
|
||
ver_str=""
|
||
|
||
if command -v sshd >/dev/null 2>&1; then
|
||
ver_str=$(sshd -V 2>&1 | sed -n 's/.*OpenSSH_\([0-9]*\)\.\([0-9]*\).*/\1.\2/p' | head -1)
|
||
fi
|
||
|
||
if [ -z "$ver_str" ] && command -v ssh >/dev/null 2>&1; then
|
||
ver_str=$(ssh -V 2>&1 | sed -n 's/.*OpenSSH_\([0-9]*\)\.\([0-9]*\).*/\1.\2/p' | head -1)
|
||
fi
|
||
|
||
if [ -n "$ver_str" ]; then
|
||
OPENSSH_VER_MAJOR=$(echo "$ver_str" | cut -d. -f1 2>/dev/null || echo 0)
|
||
OPENSSH_VER_MINOR=$(echo "$ver_str" | cut -d. -f2 2>/dev/null || echo 0)
|
||
fi
|
||
|
||
case "$OPENSSH_VER_MAJOR" in
|
||
''|*[!0-9]*) OPENSSH_VER_MAJOR=7 ;;
|
||
esac
|
||
case "$OPENSSH_VER_MINOR" in
|
||
''|*[!0-9]*) OPENSSH_VER_MINOR=0 ;;
|
||
esac
|
||
[ "$OPENSSH_VER_MAJOR" -eq 0 ] 2>/dev/null && OPENSSH_VER_MAJOR=7
|
||
}
|
||
|
||
openssh_version_ge() {
|
||
req_major="$1"
|
||
req_minor="${2:-0}"
|
||
|
||
[ "$OPENSSH_VER_MAJOR" -gt "$req_major" ] && return 0
|
||
[ "$OPENSSH_VER_MAJOR" -eq "$req_major" ] && [ "$OPENSSH_VER_MINOR" -ge "$req_minor" ] && return 0
|
||
return 1
|
||
}
|
||
|
||
# [SEC-FIX] Detect if sshd supports KbdInteractiveAuthentication
|
||
detect_kbd_interactive_support() {
|
||
SUPPORTS_KBD_INTERACTIVE="n"
|
||
if command -v sshd >/dev/null 2>&1; then
|
||
if sshd -T 2>/dev/null | grep -qi '^kbdinteractiveauthentication'; then
|
||
SUPPORTS_KBD_INTERACTIVE="y"
|
||
fi
|
||
fi
|
||
}
|
||
|
||
# ---------------- Firewall / SELinux ----------------
|
||
|
||
# [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
|
||
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"
|
||
# [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 <ORIG_TYPE> -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
|
||
}
|
||
|
||
# ---------------- Port Logic ----------------
|
||
is_hard_reserved() {
|
||
case "$1" in
|
||
53|80|443|3306)
|
||
return 0 ;;
|
||
esac
|
||
return 1
|
||
}
|
||
|
||
is_k8s_nodeport() { [ "$1" -ge 30000 ] && [ "$1" -le 32767 ]; }
|
||
|
||
rand_u16() {
|
||
if [ -r /dev/urandom ] && command -v od >/dev/null 2>&1; then
|
||
od -An -N2 -tu2 /dev/urandom 2>/dev/null | tr -d ' '
|
||
elif command -v shuf >/dev/null 2>&1; then
|
||
shuf -i 1024-65535 -n 1
|
||
else
|
||
echo $(( ( $(date +%s 2>/dev/null || echo 12345) + $$ ) % 65536 ))
|
||
fi
|
||
}
|
||
|
||
ensure_port_tools() {
|
||
# === [新增] 强制安装 nc (netcat) ===
|
||
if ! command -v nc >/dev/null 2>&1; then
|
||
echo "Installing missing dependency: netcat..."
|
||
case "$PM" in
|
||
# [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
|
||
# ==============================
|
||
|
||
# 原有的 ss/netstat 检查保持不变
|
||
command -v ss >/dev/null 2>&1 && return 0
|
||
command -v netstat >/dev/null 2>&1 && return 0
|
||
case "$PM" in
|
||
apt) install_pkg_try iproute2 >/dev/null 2>&1 || true ;;
|
||
yum) install_pkg_try iproute >/dev/null 2>&1 || true ;;
|
||
apk) install_pkg_try iproute2 iproute2-ss >/dev/null 2>&1 || true ;;
|
||
esac
|
||
install_pkg_try net-tools >/dev/null 2>&1 || true
|
||
}
|
||
|
||
is_port_free() {
|
||
p="$1"
|
||
|
||
if command -v ss >/dev/null 2>&1; then
|
||
if ss -lnt 2>/dev/null | awk -v port="$p" '
|
||
NR > 1 {
|
||
n = split($4, parts, ":")
|
||
if (parts[n] == port) { exit 0 }
|
||
}
|
||
END { exit 1 }
|
||
'; then
|
||
return 1
|
||
fi
|
||
return 0
|
||
fi
|
||
|
||
if command -v netstat >/dev/null 2>&1; then
|
||
if netstat -lnt 2>/dev/null | awk -v port="$p" '
|
||
NR > 2 {
|
||
n = split($4, parts, ":")
|
||
if (parts[n] == port) { exit 0 }
|
||
}
|
||
END { exit 1 }
|
||
'; then
|
||
return 1
|
||
fi
|
||
return 0
|
||
fi
|
||
|
||
return 1
|
||
}
|
||
|
||
pick_random_port() {
|
||
ensure_port_tools
|
||
i=0
|
||
|
||
if ! mkdir -p "$LOCK_DIR" 2>/dev/null; then
|
||
warn "$(msg ERR_LOCK_DIR) $LOCK_DIR"
|
||
return 1
|
||
fi
|
||
chmod 700 "$LOCK_DIR" 2>/dev/null || warn "$(msg WARN_LOCK_DIR_PERM)"
|
||
find "$LOCK_DIR" -name "port-*.lock" -mmin +5 -delete 2>/dev/null || true
|
||
|
||
while [ "$i" -lt 100 ]; do
|
||
r="$(rand_u16)"
|
||
p=$(( 49152 + (r % (65535 - 49152)) ))
|
||
lockfile="$LOCK_DIR/port-$p.lock"
|
||
if mkdir "$lockfile" 2>/dev/null; then
|
||
if is_port_free "$p"; then
|
||
echo "$p"
|
||
return 0
|
||
else
|
||
rmdir "$lockfile" 2>/dev/null || true
|
||
fi
|
||
fi
|
||
i=$((i+1))
|
||
done
|
||
|
||
warn "$(msg ERR_CANNOT_RESERVE_PORT)"
|
||
return 1
|
||
}
|
||
|
||
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"
|
||
# [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
|
||
# [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
|
||
}
|
||
|
||
# [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
|
||
|
||
# 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
|
||
|
||
# 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" <<EOF
|
||
# Generated by linux-ssh-init-sh v4.7.8
|
||
# Stable filename per-user — safe to delete to revoke.
|
||
$user ALL=(ALL) NOPASSWD:ALL
|
||
Defaults:$user !requiretty
|
||
Defaults:$user env_keep += "SSH_AUTH_SOCK"
|
||
EOF
|
||
umask "$__sudoers_old_umask"
|
||
|
||
if command -v visudo >/dev/null 2>&1; then
|
||
if ! visudo -c -f "$sudoers_file" >/dev/null 2>&1; then
|
||
rm -f "$sudoers_file"
|
||
err "$(msg ERR_SUDOERS_SYNTAX)"
|
||
return 1
|
||
fi
|
||
fi
|
||
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-<user>-<digits-only>
|
||
# 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-<user>-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
|
||
}
|
||
|
||
get_user_home() {
|
||
user="$1"
|
||
home=""
|
||
|
||
if command -v getent >/dev/null 2>&1; then
|
||
home=$(getent passwd "$user" 2>/dev/null | cut -d: -f6)
|
||
fi
|
||
|
||
if [ -z "$home" ] && [ -r /etc/passwd ]; then
|
||
home=$(awk -F: -v u="$user" '$1==u {print $6}' /etc/passwd 2>/dev/null)
|
||
fi
|
||
|
||
if [ -z "$home" ]; then
|
||
if [ "$user" = "root" ]; then
|
||
home="/root"
|
||
else
|
||
home="/home/$user"
|
||
fi
|
||
fi
|
||
|
||
echo "$home"
|
||
}
|
||
|
||
get_user_shell() {
|
||
user="$1"
|
||
shell=""
|
||
|
||
if command -v getent >/dev/null 2>&1; then
|
||
shell=$(getent passwd "$user" 2>/dev/null | cut -d: -f7)
|
||
fi
|
||
|
||
if [ -z "$shell" ] && [ -r /etc/passwd ]; then
|
||
shell=$(awk -F: -v u="$user" '$1==u {print $7}' /etc/passwd 2>/dev/null)
|
||
fi
|
||
|
||
echo "$shell"
|
||
}
|
||
|
||
safe_ensure_user() {
|
||
user="$1"
|
||
[ "$user" = "root" ] && return 0
|
||
|
||
if id "$user" >/dev/null 2>&1; then
|
||
shell=$(get_user_shell "$user")
|
||
home_dir=$(get_user_home "$user")
|
||
|
||
case "$shell" in
|
||
/bin/bash|/bin/sh|/usr/bin/bash|/usr/bin/sh|/bin/dash|/bin/ash) ;;
|
||
/sbin/nologin|/bin/false|/usr/sbin/nologin)
|
||
warn "$(msg WARN_USER_SHELL) $shell"
|
||
if [ "$AUTO_CONFIRM" != "y" ]; then
|
||
printf "%s" "$(msg ASK_CHANGE_SHELL)"
|
||
read -r change_shell
|
||
if [ "${change_shell:-n}" = "y" ]; then
|
||
new_shell="/bin/sh"
|
||
for try_shell in /bin/bash /bin/ash /bin/sh; do
|
||
[ -x "$try_shell" ] && { new_shell="$try_shell"; break; }
|
||
done
|
||
if command -v chsh >/dev/null 2>&1; then
|
||
chsh -s "$new_shell" "$user" 2>>"$LOG_FILE" || warn "$(msg WARN_CHANGE_SHELL_FAIL)"
|
||
elif command -v usermod >/dev/null 2>&1; then
|
||
usermod -s "$new_shell" "$user" 2>>"$LOG_FILE" || warn "$(msg WARN_CHANGE_SHELL_FAIL)"
|
||
else
|
||
warn "$(msg WARN_CHANGE_SHELL_FAIL)"
|
||
fi
|
||
fi
|
||
fi
|
||
;;
|
||
"") ;;
|
||
*) warn "$(msg WARN_UNUSUAL_SHELL) $shell" ;;
|
||
esac
|
||
|
||
if [ -n "$home_dir" ] && [ -d "$home_dir" ]; then
|
||
dir_owner=""
|
||
if stat -c "%U" "$home_dir" >/dev/null 2>&1; then
|
||
dir_owner=$(stat -c "%U" "$home_dir" 2>/dev/null)
|
||
else
|
||
dir_owner=$(ls -ld "$home_dir" 2>/dev/null | awk '{print $3}')
|
||
fi
|
||
[ -n "$dir_owner" ] && [ "$dir_owner" != "$user" ] && warn "$(msg WARN_HOME_OWNER) $dir_owner"
|
||
[ ! -w "$home_dir" ] && warn "$(msg WARN_HOME_NOT_WRITABLE)"
|
||
fi
|
||
return 0
|
||
fi
|
||
|
||
info "$(msg I_USER) $user"
|
||
shell="/bin/sh"
|
||
for test_shell in /bin/bash /usr/bin/bash /bin/ash /bin/sh /usr/bin/sh /bin/dash; do
|
||
if [ -x "$test_shell" ]; then shell="$test_shell"; break; fi
|
||
done
|
||
|
||
user_created=0
|
||
if command -v useradd >/dev/null 2>&1; then
|
||
useradd -m -s "$shell" "$user" >>"$LOG_FILE" 2>&1 && user_created=1
|
||
elif command -v adduser >/dev/null 2>&1; then
|
||
adduser -D -s "$shell" "$user" >>"$LOG_FILE" 2>&1 && user_created=1
|
||
fi
|
||
|
||
[ "$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; }
|
||
|
||
# [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"
|
||
|
||
# [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)
|
||
# [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 ;;
|
||
*) return 1 ;;
|
||
esac
|
||
|
||
if [ "$mode" = "url" ]; then
|
||
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
|
||
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
|
||
# [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
|
||
|
||
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
|
||
}
|
||
|
||
# [SEC-FIX] Removed rsa-sha2-256/512 from key type regex (they are signature algorithms, not key types)
|
||
validate_ssh_key_line() {
|
||
line="$1"
|
||
|
||
line=$(printf '%s' "$line" | tr -d '\000-\037\177' | sed 's/[[:space:]]*#.*$//')
|
||
[ -z "$line" ] && return 1
|
||
|
||
# [SEC-FIX] Tightened regex: removed rsa-sha2-256/512, restricted ecdsa to known curves
|
||
if ! printf '%s' "$line" | grep -Eq '^(ssh-(rsa|dss|ed25519)|ecdsa-sha2-nistp(256|384|521)|sk-(ssh-ed25519|ecdsa-sha2-nistp256)@openssh\.com|ssh-(rsa|dss|ed25519)-cert-v01@openssh\.com|ecdsa-sha2-nistp(256|384|521)-cert-v01@openssh\.com)[[:space:]]+[A-Za-z0-9+/]+=*([[:space:]]+.*)?$'; then
|
||
return 1
|
||
fi
|
||
|
||
key_part=$(printf '%s' "$line" | awk '{print $2}')
|
||
[ -n "$key_part" ] || return 1
|
||
|
||
if command -v base64 >/dev/null 2>&1; then
|
||
if ! printf '%s' "$key_part" | base64 -d >/dev/null 2>&1; then
|
||
return 1
|
||
fi
|
||
key_type=$(printf '%s' "$line" | awk '{print $1}')
|
||
key_bytes=$(printf '%s' "$key_part" | base64 -d 2>/dev/null | wc -c | tr -d ' ')
|
||
case "$key_type" in
|
||
ssh-rsa)
|
||
[ -n "$key_bytes" ] && [ "$key_bytes" -ge 256 ] 2>/dev/null || return 1 ;;
|
||
ssh-ed25519|sk-ssh-ed25519@openssh.com)
|
||
[ -n "$key_bytes" ] && [ "$key_bytes" -ge 32 ] 2>/dev/null || return 1 ;;
|
||
ssh-dss)
|
||
[ -n "$key_bytes" ] && [ "$key_bytes" -ge 40 ] 2>/dev/null || return 1 ;;
|
||
esac
|
||
fi
|
||
|
||
# [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
|
||
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
|
||
fi
|
||
rm -f "$tmpk" 2>/dev/null || true
|
||
fi
|
||
|
||
printf "%s\n" "$line"
|
||
return 0
|
||
}
|
||
|
||
# [SEC-FIX] Completely rewritten deploy_keys with symlink protection
|
||
deploy_keys() {
|
||
user="$1"
|
||
keys="$2"
|
||
home=$(get_user_home "$user")
|
||
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
|
||
err "$(msg ERR_HOME_SYMLINK): $home"
|
||
return 1
|
||
fi
|
||
if [ ! -d "$home" ]; then
|
||
err "$(msg ERR_HOME_NOT_DIR): $home"
|
||
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"
|
||
return 1
|
||
fi
|
||
if [ -e "$dir" ] && [ ! -d "$dir" ]; then
|
||
err "$(msg ERR_SSH_DIR_NOT_DIR): $dir"
|
||
return 1
|
||
fi
|
||
|
||
mkdir -p "$dir" 2>/dev/null || return 1
|
||
chmod 700 "$dir" 2>/dev/null || true
|
||
|
||
# [SEC-FIX] Refuse symlink authorized_keys
|
||
if [ -L "$auth" ]; then
|
||
err "$(msg ERR_AUTH_KEYS_SYMLINK): $auth"
|
||
return 1
|
||
fi
|
||
if [ -e "$auth" ] && [ ! -f "$auth" ]; then
|
||
err "$(msg ERR_AUTH_KEYS_NOT_FILE): $auth"
|
||
return 1
|
||
fi
|
||
|
||
touch "$auth" 2>/dev/null || return 1
|
||
chmod 600 "$auth" 2>/dev/null || true
|
||
|
||
# [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
|
||
|
||
: > "$valid_keys_file"
|
||
|
||
printf "%s\n" "$keys" | while IFS= read -r line; do
|
||
[ -z "$line" ] && continue
|
||
clean_line=$(validate_ssh_key_line "$line")
|
||
if [ -n "$clean_line" ]; then
|
||
printf "%s\n" "$clean_line" >> "$valid_keys_file"
|
||
fi
|
||
done
|
||
|
||
if [ ! -s "$valid_keys_file" ]; then
|
||
warn "$(msg WARN_NO_VALID_KEYS)"
|
||
rm -f "$valid_keys_file" 2>/dev/null || true
|
||
return 1
|
||
fi
|
||
|
||
while IFS= read -r key; do
|
||
[ -z "$key" ] && continue
|
||
if ! grep -qxF "$key" "$auth" 2>/dev/null; then
|
||
printf "%s\n" "$key" >> "$auth"
|
||
fi
|
||
if grep -qxF "$key" "$auth" 2>/dev/null; then
|
||
deployed_count=$((deployed_count + 1))
|
||
fi
|
||
done < "$valid_keys_file"
|
||
|
||
rm -f "$valid_keys_file" 2>/dev/null || true
|
||
|
||
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)"
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
# ---------------- sshd_config management ----------------
|
||
# [SEC-FIX] Case-insensitive directive matching
|
||
cleanup_sshd_config_d() {
|
||
if [ -d "$SSH_CONF_D" ]; then
|
||
for conf in "$SSH_CONF_D"/*.conf; do
|
||
[ -f "$conf" ] || continue
|
||
# [SEC-FIX] Case-insensitive matching
|
||
if awk '{line=tolower($0)} line ~ /^[[:space:]]*(port|permitrootlogin|passwordauthentication|pubkeyauthentication|challengeresponseauthentication|kbdinteractiveauthentication|kexalgorithms|ciphers|macs|addressfamily|listenaddress)[[:space:]]/ {exit 0} END{exit 1}' "$conf" 2>/dev/null; then
|
||
# [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
|
||
fi
|
||
}
|
||
|
||
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" '
|
||
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"
|
||
fi
|
||
}
|
||
|
||
# [SEC-FIX] Case-insensitive sanitization
|
||
sanitize_sshd_config() {
|
||
info "$(msg INFO_SANITIZE_DUP)"
|
||
tmp_san="$TMP_DIR/sshd_config.sanitized"
|
||
|
||
# [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 }
|
||
low ~ /^[[:space:]]*pubkeyauthentication[[:space:]]/ { print "# [server-init disabled] " $0; next }
|
||
low ~ /^[[:space:]]*challengeresponseauthentication[[:space:]]/ { print "# [server-init disabled] " $0; next }
|
||
low ~ /^[[:space:]]*kbdinteractiveauthentication[[:space:]]/ { print "# [server-init disabled] " $0; next }
|
||
low ~ /^[[:space:]]*kexalgorithms[[:space:]]/ { print "# [server-init disabled] " $0; next }
|
||
low ~ /^[[:space:]]*ciphers[[:space:]]/ { print "# [server-init disabled] " $0; next }
|
||
low ~ /^[[:space:]]*macs[[:space:]]/ { print "# [server-init disabled] " $0; next }
|
||
low ~ /^[[:space:]]*addressfamily[[:space:]]/ { print "# [server-init disabled] " $0; next }
|
||
low ~ /^[[:space:]]*listenaddress[[:space:]]/ { print "# [server-init disabled] " $0; next }
|
||
{ print }
|
||
' "$SSH_CONF" > "$tmp_san"
|
||
|
||
if [ -s "$tmp_san" ]; then
|
||
cat "$tmp_san" > "$SSH_CONF"
|
||
fi
|
||
}
|
||
|
||
has_global_ipv6() {
|
||
if command -v ip >/dev/null 2>&1; then
|
||
ip -6 addr show scope global 2>/dev/null | grep -q inet6 && return 0
|
||
fi
|
||
|
||
if [ -f /proc/net/if_inet6 ]; then
|
||
awk '$4 == "00" && $6 != "lo" { found=1; exit } END { exit !found }' /proc/net/if_inet6 2>/dev/null && return 0
|
||
fi
|
||
|
||
if command -v ifconfig >/dev/null 2>&1; then
|
||
ifconfig 2>/dev/null | grep -i 'inet6.*global' >/dev/null 2>&1 && return 0
|
||
fi
|
||
|
||
return 1
|
||
}
|
||
|
||
# ----- Crypto selection -----
|
||
csv_contains() {
|
||
csv="$1"
|
||
item="$2"
|
||
case ",$csv," in
|
||
*,"$item",*) return 0 ;;
|
||
*) return 1 ;;
|
||
esac
|
||
}
|
||
|
||
csv_intersect_ordered() {
|
||
pref="$1"
|
||
supp="$2"
|
||
result=""
|
||
|
||
result=$(
|
||
IFS=,
|
||
for a in $pref; do
|
||
if [ -n "$a" ] && csv_contains "$supp" "$a"; then
|
||
printf "%s\n" "$a"
|
||
fi
|
||
done
|
||
)
|
||
|
||
echo "$result" | tr '\n' ',' | sed 's/,$//'
|
||
}
|
||
|
||
get_sshd_T_value() {
|
||
key="$1"
|
||
v=""
|
||
|
||
if openssh_version_ge 6 8; then
|
||
v=$(sshd -T -C user=root,host=localhost,addr=127.0.0.1 -f "$SSH_CONF" 2>/dev/null | awk -v k="$key" 'tolower($1)==k {print $2; exit}')
|
||
fi
|
||
|
||
if [ -z "$v" ]; then
|
||
v=$(sshd -T -f "$SSH_CONF" 2>/dev/null | awk -v k="$key" 'tolower($1)==k {print $2; exit}')
|
||
fi
|
||
|
||
echo "$v"
|
||
}
|
||
|
||
compute_crypto_lines() {
|
||
KEX_LINE=""
|
||
CIPHERS_LINE=""
|
||
MACS_LINE=""
|
||
CRYPTO_MODE="skip"
|
||
|
||
if ! command -v sshd >/dev/null 2>&1; then
|
||
CRYPTO_MODE="skip"
|
||
return 0
|
||
fi
|
||
|
||
supp_kex=$(get_sshd_T_value "kexalgorithms")
|
||
supp_ciphers=$(get_sshd_T_value "ciphers")
|
||
supp_macs=$(get_sshd_T_value "macs")
|
||
|
||
pref_kex="curve25519-sha256@libssh.org,curve25519-sha256,diffie-hellman-group-exchange-sha256,diffie-hellman-group16-sha512"
|
||
pref_ciphers="chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr"
|
||
pref_macs="hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128-etm@openssh.com,hmac-sha2-512,hmac-sha2-256,umac-128@openssh.com"
|
||
|
||
if [ -n "$supp_kex" ] || [ -n "$supp_ciphers" ] || [ -n "$supp_macs" ]; then
|
||
sel_kex=$(csv_intersect_ordered "$pref_kex" "$supp_kex")
|
||
sel_ciphers=$(csv_intersect_ordered "$pref_ciphers" "$supp_ciphers")
|
||
sel_macs=$(csv_intersect_ordered "$pref_macs" "$supp_macs")
|
||
|
||
[ -n "$sel_kex" ] && KEX_LINE="KexAlgorithms $sel_kex"
|
||
[ -n "$sel_ciphers" ] && CIPHERS_LINE="Ciphers $sel_ciphers"
|
||
[ -n "$sel_macs" ] && MACS_LINE="MACs $sel_macs"
|
||
|
||
if [ -n "$KEX_LINE" ] || [ -n "$CIPHERS_LINE" ] || [ -n "$MACS_LINE" ]; then
|
||
CRYPTO_MODE="filtered"
|
||
else
|
||
CRYPTO_MODE="skip"
|
||
fi
|
||
return 0
|
||
fi
|
||
|
||
if openssh_version_ge 6 5; then
|
||
KEX_LINE="KexAlgorithms curve25519-sha256@libssh.org,diffie-hellman-group-exchange-sha256"
|
||
CIPHERS_LINE="Ciphers chacha20-poly1305@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr"
|
||
MACS_LINE="MACs hmac-sha2-512,hmac-sha2-256"
|
||
CRYPTO_MODE="fallback"
|
||
else
|
||
CRYPTO_MODE="skip"
|
||
fi
|
||
}
|
||
|
||
build_block() {
|
||
file="$1"
|
||
{
|
||
echo "$BLOCK_BEGIN"
|
||
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."
|
||
echo ""
|
||
|
||
echo "Port $SSH_PORT"
|
||
|
||
[ -n "$KEX_LINE" ] && echo "$KEX_LINE"
|
||
[ -n "$CIPHERS_LINE" ] && echo "$CIPHERS_LINE"
|
||
[ -n "$MACS_LINE" ] && echo "$MACS_LINE"
|
||
|
||
if [ "$IPV6_ENABLED" = "y" ]; then
|
||
echo "AddressFamily any"
|
||
# [FIX-AWS] Debian 12 Socket Activation 冲突修复: 不显式指定监听地址
|
||
# echo "ListenAddress ::"
|
||
# echo "ListenAddress 0.0.0.0"
|
||
else
|
||
echo "AddressFamily inet"
|
||
# echo "ListenAddress 0.0.0.0"
|
||
fi
|
||
|
||
if [ "$KEY_OK" = "y" ]; then
|
||
echo "PasswordAuthentication no"
|
||
echo "PermitEmptyPasswords no"
|
||
echo "ChallengeResponseAuthentication no"
|
||
echo "PubkeyAuthentication yes"
|
||
# [SEC-FIX] Add KbdInteractiveAuthentication no for true key-only auth
|
||
if [ "$SUPPORTS_KBD_INTERACTIVE" = "y" ]; then
|
||
echo "KbdInteractiveAuthentication no"
|
||
fi
|
||
else
|
||
echo "PasswordAuthentication yes"
|
||
echo "PubkeyAuthentication yes"
|
||
fi
|
||
|
||
if [ "$TARGET_USER" = "root" ]; then
|
||
if [ "$KEY_OK" = "y" ]; then
|
||
if openssh_version_ge 7 0; then
|
||
echo "PermitRootLogin prohibit-password"
|
||
else
|
||
echo "PermitRootLogin without-password"
|
||
fi
|
||
else
|
||
echo "PermitRootLogin yes"
|
||
fi
|
||
else
|
||
if [ "$KEY_OK" = "y" ]; then
|
||
echo "PermitRootLogin no"
|
||
else
|
||
if [ "$ROOT_KEY_PRESENT" = "y" ]; then
|
||
if openssh_version_ge 7 0; then
|
||
echo "PermitRootLogin prohibit-password"
|
||
else
|
||
echo "PermitRootLogin without-password"
|
||
fi
|
||
else
|
||
echo "PermitRootLogin yes"
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
echo ""
|
||
echo "$BLOCK_END"
|
||
} >"$file"
|
||
}
|
||
|
||
install_managed_block() {
|
||
block="$1"
|
||
tmp="$TMP_DIR/sshd_config.merge"
|
||
|
||
match_line=$(awk '/^[[:space:]]*#/ {next} /^[[:space:]]*Match[[:space:]]/ {print NR; exit}' "$SSH_CONF" 2>/dev/null)
|
||
|
||
if [ -z "$match_line" ]; then
|
||
cat "$SSH_CONF" "$block" > "$tmp"
|
||
else
|
||
info "$(msg INFO_MATCH_INSERT)"
|
||
awk -v ml="$match_line" -v bf="$block" '
|
||
NR < ml { print }
|
||
NR == ml {
|
||
while ((getline line < bf) > 0) print line
|
||
close(bf)
|
||
print
|
||
}
|
||
NR > ml { print }
|
||
' "$SSH_CONF" > "$tmp"
|
||
fi
|
||
|
||
chmod 600 "$tmp" 2>/dev/null || true
|
||
# [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() {
|
||
port="$1"
|
||
timeout_s=30 # [FIX] 延长至 30 秒,适应 Vultr 等慢速机器
|
||
elapsed=0
|
||
|
||
ensure_port_tools
|
||
|
||
while [ "$elapsed" -lt "$timeout_s" ]; do
|
||
if ! is_port_free "$port"; then
|
||
return 0
|
||
fi
|
||
if command -v nc >/dev/null 2>&1; then
|
||
# [SEC-FIX] Use 127.0.0.1 instead of localhost
|
||
# [兼容性优化]
|
||
# 1. 尝试 IPv4 本地回环 (大多数系统的标准情况)
|
||
# 2>/dev/null 屏蔽了不支持 IPv4 时的报错
|
||
nc -z -w 1 127.0.0.1 "$port" 2>/dev/null && return 0
|
||
# 2. 尝试 IPv6 本地回环 (针对 Debian 12 默认开启 bindv6only 的情况)
|
||
# 如果系统不支持 IPv6,这行命令会静默失败,不会中断脚本
|
||
nc -z -w 1 ::1 "$port" >/dev/null 2>&1 && return 0
|
||
fi
|
||
sleep 1
|
||
elapsed=$((elapsed + 1))
|
||
done
|
||
return 1
|
||
}
|
||
|
||
# [SEC-FIX] Use 127.0.0.1 instead of localhost to avoid IPv6 mismatch
|
||
enhanced_ssh_test() {
|
||
port="$1"
|
||
user="$2"
|
||
info "$(msg TEST_CONN)"
|
||
|
||
if ! verify_sshd_listening "$port"; then
|
||
err "SSHD not listening on port $port"
|
||
return 1
|
||
fi
|
||
|
||
banner_ok=0
|
||
if command -v nc >/dev/null 2>&1; then
|
||
proto=""
|
||
# [SEC-FIX] Use 127.0.0.1 instead of localhost
|
||
if command -v timeout >/dev/null 2>&1; then
|
||
proto=$(printf "SSH-2.0-TEST\r\n" | timeout 3 nc 127.0.0.1 "$port" 2>/dev/null || true)
|
||
else
|
||
proto=$(printf "SSH-2.0-TEST\r\n" | nc -w 3 127.0.0.1 "$port" 2>/dev/null || true)
|
||
fi
|
||
|
||
if echo "$proto" | grep -q "SSH-2.0"; then
|
||
ok "$(msg INFO_SSH_PROTOCOL_OK)"
|
||
banner_ok=1
|
||
else
|
||
err "$(msg ERR_NO_BANNER)"
|
||
fi
|
||
else
|
||
warn "nc not available, skipping banner check"
|
||
banner_ok=1
|
||
fi
|
||
|
||
if [ "$banner_ok" -ne 1 ]; then
|
||
return 1
|
||
fi
|
||
|
||
attempts=1
|
||
max_attempts=3
|
||
success=0
|
||
while [ "$attempts" -le "$max_attempts" ]; do
|
||
if command -v ssh >/dev/null 2>&1; then
|
||
# [SEC-FIX] Use -4 flag and 127.0.0.1
|
||
if ssh -4 -o BatchMode=yes -o ConnectTimeout=3 -o StrictHostKeyChecking=no \
|
||
-o UserKnownHostsFile=/dev/null -o GlobalKnownHostsFile=/dev/null \
|
||
-p "$port" "$user@127.0.0.1" "exit 0" >/dev/null 2>&1; then
|
||
success=1
|
||
break
|
||
fi
|
||
# 尝试 1: IPv4 回环 (主流情况)
|
||
if ssh -o BatchMode=yes -o ConnectTimeout=3 -o StrictHostKeyChecking=no \
|
||
-o UserKnownHostsFile=/dev/null -o GlobalKnownHostsFile=/dev/null \
|
||
-p "$port" "$user@127.0.0.1" "exit 0" >/dev/null 2>&1; then
|
||
success=1
|
||
break
|
||
fi
|
||
# 尝试 2: IPv6 回环 (针对纯 IPv6 环境的备选)
|
||
if ssh -o BatchMode=yes -o ConnectTimeout=3 -o StrictHostKeyChecking=no \
|
||
-o UserKnownHostsFile=/dev/null -o GlobalKnownHostsFile=/dev/null \
|
||
-p "$port" "$user@::1" "exit 0" >/dev/null 2>&1; then
|
||
success=1
|
||
break
|
||
fi
|
||
fi
|
||
attempts=$((attempts + 1))
|
||
[ "$attempts" -le "$max_attempts" ] && sleep 1
|
||
done
|
||
|
||
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. 清理 /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
|
||
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. 写出动态登录横幅(POSIX 兼容;颜色用 printf %b 避免 dash 字面输出)
|
||
# ---------------------------------------------------------
|
||
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"
|
||
|
||
# [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" <<EOF
|
||
#!/bin/sh
|
||
# Generated by linux-ssh-init-sh v4.7.8
|
||
# Login banner: prefers live sshd_config values, falls back to install-time values.
|
||
SSH_CONF="/etc/ssh/sshd_config"
|
||
REAL_PORT="$SSH_PORT"
|
||
AUTH_TYPE="$final_auth"
|
||
REAL_USER=\$(whoami 2>/dev/null || echo "unknown")
|
||
|
||
# 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
|
||
|
||
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
|
||
|
||
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() {
|
||
report_file="/var/log/server-init-health.log"
|
||
end_time=$(date +%s)
|
||
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.7.8 Enterprise Hardening"
|
||
echo "Execution Time: ${duration}s"
|
||
echo ""
|
||
echo "--- System ---"
|
||
echo "Uptime: $sys_uptime"
|
||
echo "OpenSSH: ${OPENSSH_VER_MAJOR}.${OPENSSH_VER_MINOR}"
|
||
echo ""
|
||
echo "--- SSH Config ---"
|
||
echo "Port: $SSH_PORT"
|
||
echo "User: $TARGET_USER"
|
||
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_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
|
||
info "Health report saved to: $report_file"
|
||
}
|
||
|
||
print_final_summary() {
|
||
public_ip=""
|
||
# [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=""
|
||
if command -v hostname >/dev/null 2>&1; then
|
||
local_ip=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||
fi
|
||
if [ -z "$local_ip" ] && command -v ip >/dev/null 2>&1; then
|
||
local_ip=$(ip -4 addr show 2>/dev/null | awk '
|
||
/inet / {
|
||
ip=$2
|
||
sub(/\/.*/, "", ip)
|
||
if (ip !~ /^127\./) { print ip; exit }
|
||
}
|
||
')
|
||
fi
|
||
|
||
end_time=$(date +%s)
|
||
duration=$((end_time - SCRIPT_START_TIME))
|
||
|
||
# [v4.7.0] Use %b for ANSI sequences so dash interprets them correctly
|
||
echo ""
|
||
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 '%b║ %-66s ║%b\n' "$CYAN" " $(msg BOX_KEY_ON)" "$NC"
|
||
else
|
||
printf '%b║ %-66s ║%b\n' "$CYAN" " $(msg BOX_KEY_OFF)" "$NC"
|
||
fi
|
||
|
||
if [ "$SSH_PORT" != "22" ]; then
|
||
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 '%b║ %-66s ║%b\n' "$CYAN" " $(msg BOX_K8S_WARN)" "$NC"
|
||
fi
|
||
fi
|
||
|
||
# [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 "Health: /var/log/server-init-health.log"
|
||
echo "Time: ${duration}s"
|
||
}
|
||
|
||
validate_ssh_config_comprehensive() {
|
||
config_file="$1"
|
||
user="$2"
|
||
key_ok="$3"
|
||
|
||
if ! sshd -t -f "$config_file" 2>>"$LOG_FILE"; then
|
||
err "SSH Config Syntax Error"
|
||
return 1
|
||
fi
|
||
|
||
# [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"
|
||
|
||
if [ "$password_auth" = "no" ] && [ "$pubkey_auth" = "no" ]; then
|
||
die "$(msg ERR_DEADLOCK)"
|
||
fi
|
||
if [ "$password_auth" = "no" ] && [ "$key_ok" != "y" ]; then
|
||
die "$(msg ERR_PASSWORD_NO_KEY)"
|
||
fi
|
||
if [ "$user" = "root" ] && [ "$password_auth" = "no" ] && [ "$key_ok" != "y" ]; then
|
||
die "$(msg ERR_ROOT_NO_KEY)"
|
||
fi
|
||
if [ "$port_setting" != "$SSH_PORT" ]; then
|
||
warn "$(msg WARN_PORT_MISMATCH) ($port_setting vs $SSH_PORT)"
|
||
fi
|
||
|
||
insecure_options=0
|
||
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 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
|
||
[ "$insecure_options" -gt 0 ] && warn "$(msg WARN_INSECURE_OPTIONS) $insecure_options"
|
||
|
||
return 0
|
||
}
|
||
|
||
# =========================================================
|
||
# Entry
|
||
# =========================================================
|
||
[ "$(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=<redacted>" ;;
|
||
--key-url=*) sanitized_args="$sanitized_args --key-url=<redacted>" ;;
|
||
--key-gh=*) sanitized_args="$sanitized_args --key-gh=<redacted>" ;;
|
||
*) 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 "================================================="
|
||
msg BANNER
|
||
echo "================================================="
|
||
[ "$STRICT_MODE" = "y" ] && msg STRICT_ON
|
||
|
||
preflight_checks
|
||
|
||
# Phase 1: Input
|
||
if [ -n "$ARG_USER" ]; then
|
||
TARGET_USER="$ARG_USER"
|
||
validate_username "$TARGET_USER" || die "$(msg ERR_USER_INV): $TARGET_USER"
|
||
printf "%s%s\n" "$(msg AUTO_SKIP)" "$TARGET_USER"
|
||
else
|
||
while :; do
|
||
printf "%s%s): " "$(msg ASK_USER)" "$DEFAULT_USER"
|
||
read -r TARGET_USER
|
||
[ -z "$TARGET_USER" ] && TARGET_USER="$DEFAULT_USER"
|
||
validate_username "$TARGET_USER" && break
|
||
msg ERR_USER_INV
|
||
done
|
||
fi
|
||
|
||
if [ -n "$ARG_PORT" ]; then
|
||
case "$ARG_PORT" in
|
||
22) PORT_OPT="1"; SSH_PORT="22" ;;
|
||
random) PORT_OPT="2"; SSH_PORT="22" ;;
|
||
*)
|
||
if ! validate_port "$ARG_PORT"; then
|
||
die "$(msg PORT_ERR): $ARG_PORT"
|
||
fi
|
||
if is_hard_reserved "$ARG_PORT"; then
|
||
die "$(msg PORT_RES): $ARG_PORT"
|
||
fi
|
||
if is_k8s_nodeport "$ARG_PORT"; then
|
||
warn "$(msg PORT_K8S)"
|
||
fi
|
||
PORT_OPT="3"; SSH_PORT="$ARG_PORT"
|
||
;;
|
||
esac
|
||
printf "%s%s\n" "$(msg AUTO_SKIP)" "$ARG_PORT (Mode $PORT_OPT)"
|
||
else
|
||
echo ""
|
||
msg ASK_PORT_T; msg OPT_PORT_1; msg OPT_PORT_2; msg OPT_PORT_3
|
||
printf "%s" "$(msg SELECT)"; read -r PORT_OPT
|
||
[ -z "$PORT_OPT" ] && PORT_OPT="1"
|
||
SSH_PORT="22"
|
||
if [ "$PORT_OPT" = "3" ]; then
|
||
while :; do
|
||
printf "%s" "$(msg INPUT_PORT)"
|
||
read -r MANUAL_PORT
|
||
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
|
||
continue
|
||
elif is_k8s_nodeport "$MANUAL_PORT"; then
|
||
msg PORT_K8S
|
||
printf "%s" "$(msg ASK_SURE)"
|
||
read -r force_port
|
||
[ "${force_port:-n}" = "y" ] || continue
|
||
fi
|
||
SSH_PORT="$MANUAL_PORT"
|
||
break
|
||
done
|
||
fi
|
||
fi
|
||
|
||
if [ -n "$ARG_KEY_TYPE" ]; then
|
||
KEY_OPT="auto"; KEY_TYPE="$ARG_KEY_TYPE"; KEY_VAL="$ARG_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 (<redacted>)" ;;
|
||
url) printf "%s%s\n" "$(msg AUTO_SKIP)" "$KEY_TYPE (<redacted>)" ;;
|
||
*) 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
|
||
printf "%s" "$(msg SELECT)"; read -r KEY_OPT
|
||
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)
|
||
# [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
|
||
|
||
if [ -n "$ARG_UPDATE" ]; then DO_UPDATE="$ARG_UPDATE"; printf "%s%s\n" "$(msg AUTO_SKIP)" "Update=$DO_UPDATE"; else printf "%s" "$(msg ASK_UPD)"; read -r DO_UPDATE; [ -z "$DO_UPDATE" ] && DO_UPDATE="n"; fi
|
||
if [ -n "$ARG_BBR" ]; then DO_BBR="$ARG_BBR"; printf "%s%s\n" "$(msg AUTO_SKIP)" "BBR=$DO_BBR"; else printf "%s" "$(msg ASK_BBR)"; read -r DO_BBR; [ -z "$DO_BBR" ] && DO_BBR="n"; fi
|
||
|
||
# Phase 2: Confirm
|
||
if [ "$AUTO_CONFIRM" = "y" ]; then
|
||
echo ""; info "Auto-Confirm: Skipping interactive confirmation."
|
||
else
|
||
echo ""
|
||
msg CONFIRM_T
|
||
echo "$(msg C_USER)$TARGET_USER"
|
||
echo "$(msg C_PORT)$SSH_PORT (Mode: $PORT_OPT)"
|
||
echo "$(msg C_KEY)$KEY_TYPE"
|
||
echo "$(msg C_UPD)$DO_UPDATE"
|
||
echo "$(msg C_BBR)$DO_BBR"
|
||
[ "$PORT_OPT" != "1" ] && msg WARN_FW
|
||
printf "%s" "$(msg ASK_SURE)"
|
||
read -r CONFIRM
|
||
[ "${CONFIRM:-n}" = "y" ] || die "$(msg CANCEL)"
|
||
fi
|
||
|
||
# Phase 3: Execute
|
||
msg AUDIT_START
|
||
setup_rollback
|
||
backup_config_persistent || true
|
||
|
||
info "$(msg I_INSTALL)"
|
||
ensure_ssh_server
|
||
|
||
detect_openssh_version
|
||
detect_kbd_interactive_support
|
||
info "OpenSSH Version: ${OPENSSH_VER_MAJOR}.${OPENSSH_VER_MINOR}"
|
||
|
||
install_pkg_try curl >/dev/null 2>&1 || true
|
||
install_pkg_try wget >/dev/null 2>&1 || true
|
||
|
||
# === [新增] 补充常用管理工具 ===
|
||
if ! command -v sudo >/dev/null 2>&1; then
|
||
info "Installing missing dependency: sudo..."
|
||
install_pkg sudo >/dev/null 2>&1 || true
|
||
fi
|
||
|
||
if ! command -v hostname >/dev/null 2>&1; then
|
||
# Debian/Ubuntu 下 hostname 命令通常在 hostname 包或 net-tools 中
|
||
install_pkg hostname >/dev/null 2>&1 || true
|
||
fi
|
||
|
||
if [ "$DO_UPDATE" = "y" ]; then info "$(msg I_UPD)"; update_system; fi
|
||
if [ "$DO_BBR" = "y" ]; then info "$(msg I_BBR)"; enable_bbr; fi
|
||
|
||
if [ "$PORT_OPT" = "2" ]; then
|
||
p="$(pick_random_port 2>/dev/null || true)"
|
||
if [ -n "$p" ]; then
|
||
SSH_PORT="$p"
|
||
info "Random Port: $SSH_PORT"
|
||
else
|
||
[ "$STRICT_MODE" = "y" ] && die "STRICT: Random port failed"
|
||
warn "Random port failed, fallback to 22"
|
||
SSH_PORT="22"
|
||
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"
|
||
|
||
root_home=$(get_user_home root)
|
||
[ -s "$root_home/.ssh/authorized_keys" ] && ROOT_KEY_PRESENT="y" || ROOT_KEY_PRESENT="n"
|
||
|
||
KEY_OK="n"
|
||
KEY_DATA="$(fetch_keys "$KEY_TYPE" "$KEY_VAL" 2>/dev/null || true)"
|
||
if [ -n "$KEY_DATA" ] && deploy_keys "$TARGET_USER" "$KEY_DATA"; then
|
||
KEY_OK="y"
|
||
info "$(msg I_KEY_OK)"
|
||
else
|
||
[ "$STRICT_MODE" = "y" ] && die "STRICT: Key deploy failed"
|
||
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)"
|
||
else
|
||
IPV6_ENABLED="n"
|
||
fi
|
||
|
||
compute_crypto_lines
|
||
if [ "$CRYPTO_MODE" = "skip" ]; then
|
||
info "$(msg INFO_OLD_SSH_SKIP_ALGO)"
|
||
elif [ "$CRYPTO_MODE" = "fallback" ]; then
|
||
warn "$(msg COMPAT_WARN)"
|
||
fi
|
||
|
||
info "$(msg I_BACKUP)$SSH_CONF"
|
||
cleanup_sshd_config_d
|
||
remove_managed_block
|
||
sanitize_sshd_config
|
||
|
||
tmp="$TMP_DIR/sshd_block_final"
|
||
build_block "$tmp"
|
||
|
||
install_managed_block "$tmp"
|
||
|
||
# [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"
|
||
|
||
# [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
|
||
|
||
trap - INT TERM EXIT HUP
|
||
cleanup_state
|
||
cleanup_locks
|
||
rm -rf "$TMP_DIR" 2>/dev/null || true
|
||
|
||
print_final_summary
|
||
audit_log "DONE" "Completed (delay-restart). user=$TARGET_USER port=$SSH_PORT key_ok=$KEY_OK"
|
||
exit 0
|
||
fi
|
||
|
||
if ! restart_sshd; then
|
||
die "$(msg E_RESTART)"
|
||
fi
|
||
|
||
grep -Eq "^[[:space:]]*Port[[:space:]]+$SSH_PORT([[:space:]]|\$)" "$SSH_CONF" 2>/dev/null || die "$(msg E_GREP_FAIL)"
|
||
verify_sshd_listening "$SSH_PORT" || die "$(msg W_LISTEN_FAIL)"
|
||
enhanced_ssh_test "$SSH_PORT" "$TARGET_USER" || die "$(msg TEST_FAIL)"
|
||
|
||
update_motd
|
||
generate_health_report
|
||
|
||
trap - INT TERM EXIT HUP
|
||
cleanup_state
|
||
cleanup_locks
|
||
rm -rf "$TMP_DIR" 2>/dev/null || true
|
||
|
||
print_final_summary
|
||
audit_log "DONE" "Completed successfully. user=$TARGET_USER port=$SSH_PORT key_ok=$KEY_OK"
|
||
exit 0
|