Bump version to v4.6.1 and enhance security features
Updated the script version to v4.6.1 and made various security fixes, including symlink protection and improved validation for SSH keys and configurations.
This commit is contained in:
1 parent
03c43cd2a8
commit
3368584ccd
1 file changed
+158
-89
@@ -3,25 +3,20 @@
|
||||
# linux-ssh-init-sh
|
||||
# Server Init & SSH Hardening Script
|
||||
#
|
||||
# Release: v4.6.0 (Fortress Pro - Security Audit Fix)
|
||||
# Release: v4.6.1 (Fortress Pro - Final Audit Fix)
|
||||
#
|
||||
# POSIX sh compatible (Debian dash / CentOS / Alpine / Ubuntu)
|
||||
#
|
||||
# Key policy:
|
||||
# - If `base64` exists: strict base64 decode + key length checks
|
||||
# - If `base64` missing: downgrade to format-only validation (skip length checks)
|
||||
#
|
||||
# Changelog v4.6.0:
|
||||
# - [SEC] Fix: Removed dangerous `. source` of state file (RCE risk)
|
||||
# - [SEC] Fix: RUNTIME_DIR never falls back to bare /tmp
|
||||
# - [SEC] Fix: KEY_OK now based on actually deployed keys, not file existence
|
||||
# - [SEC] Fix: restart_sshd failure now triggers rollback (not just warn)
|
||||
# - [SEC] Fix: enhanced_ssh_test requires SSH-2.0 banner
|
||||
# - Fix: --port parameter now validates range and reserved ports
|
||||
# - Fix: validate_username now allows root (matching documentation)
|
||||
# - Fix: csv_contains uses case instead of grep (regex safety)
|
||||
# - Fix: IPv6 detection excludes loopback interface
|
||||
# - Fix: install_managed_block uses awk instead of tail -n +N
|
||||
# Changelog v4.6.1:
|
||||
# - [SEC] Fix: deploy_keys() now refuses symlinks (local privilege escalation)
|
||||
# - [SEC] Fix: Removed chown -R (symlink traversal risk)
|
||||
# - [SEC] Fix: Added KbdInteractiveAuthentication no for true key-only auth
|
||||
# - Fix: cleanup_old_backups() awk logic bug (was deleting all backups)
|
||||
# - Fix: Use 127.0.0.1 instead of localhost in tests (IPv6 mismatch)
|
||||
# - Fix: Removed rsa-sha2-256/512 from key type regex
|
||||
# - Fix: --delay-restart now skips listen/connection tests
|
||||
# - Fix: safe_configure_sudo grep prefix match issue
|
||||
# - Fix: Case-insensitive sshd_config directive matching
|
||||
# =========================================================
|
||||
|
||||
set -u
|
||||
@@ -52,13 +47,17 @@ if [ -z "$TMP_DIR" ]; then
|
||||
fi
|
||||
[ -z "$rand_suffix" ] && rand_suffix="$$"
|
||||
TMP_DIR="/tmp/ssh-init.${$}.${rand_suffix}.$(date +%s 2>/dev/null || echo 0)"
|
||||
mkdir -p "$TMP_DIR" 2>/dev/null || { echo "FATAL: Cannot create temp directory: $TMP_DIR" >&2; exit 1; }
|
||||
# [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-FIX] State & Lock Management ----------------
|
||||
# Never fall back to bare /tmp - always use a private directory
|
||||
# ---------------- [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
|
||||
@@ -68,15 +67,14 @@ for try_dir in "/run/server-init" "/var/lib/server-init"; do
|
||||
done
|
||||
|
||||
if [ -z "$RUNTIME_DIR" ]; then
|
||||
# Must create a private directory under /tmp, never use /tmp directly
|
||||
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)"
|
||||
if ! mkdir -p "$RUNTIME_DIR" 2>/dev/null; then
|
||||
# Last resort: use mktemp
|
||||
# [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
|
||||
@@ -119,6 +117,7 @@ MACS_LINE=""
|
||||
CRYPTO_MODE="skip"
|
||||
IPV6_ENABLED="n"
|
||||
ROOT_KEY_PRESENT="n"
|
||||
SUPPORTS_KBD_INTERACTIVE="n"
|
||||
|
||||
# ---------------- Automation Variables ----------------
|
||||
ARG_USER=""
|
||||
@@ -158,7 +157,7 @@ msg() {
|
||||
if [ "$LANG_CUR" = "zh" ]; then
|
||||
case "$key" in
|
||||
MUST_ROOT) echo "必须以 root 权限运行此脚本" ;;
|
||||
BANNER) echo "服务器初始化 & SSH 安全加固 (v4.6.0 Fortress Pro)" ;;
|
||||
BANNER) echo "服务器初始化 & SSH 安全加固 (v4.6.1 Fortress Pro)" ;;
|
||||
STRICT_ON) echo "STRICT 模式已开启:任何关键错误将直接退出" ;;
|
||||
ASK_USER) echo "SSH 登录用户 (默认 " ;;
|
||||
ERR_USER_INV) echo "❌ 用户名无效 (仅限小写字母/数字/下划线,且避开系统保留名)" ;;
|
||||
@@ -215,7 +214,7 @@ msg() {
|
||||
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/IPv6/Local)..." ;;
|
||||
TEST_CONN) echo "正在进行 SSH 连接测试 (IPv4/Local)..." ;;
|
||||
TEST_OK) echo "SSH 连接测试通过" ;;
|
||||
TEST_FAIL) echo "SSH 连接测试全部失败!新配置可能无法连接,正在回滚..." ;;
|
||||
IPV6_CFG) echo "检测到全局 IPv6 环境,已添加 :: 监听支持" ;;
|
||||
@@ -284,12 +283,19 @@ msg() {
|
||||
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.6.0 Fortress Pro)" ;;
|
||||
BANNER) echo "Server Init & SSH Hardening (v4.6.1 Fortress Pro)" ;;
|
||||
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)" ;;
|
||||
@@ -346,7 +352,7 @@ msg() {
|
||||
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/IPv6/Local)..." ;;
|
||||
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." ;;
|
||||
@@ -415,6 +421,13 @@ msg() {
|
||||
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
|
||||
@@ -600,7 +613,6 @@ update_state() {
|
||||
chmod 600 "$STATE_FILE" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# [SEC-FIX] Safe state file parsing - never source untrusted files
|
||||
parse_state_value() {
|
||||
key="$1"
|
||||
file="$2"
|
||||
@@ -611,7 +623,6 @@ parse_state_value() {
|
||||
|
||||
check_previous_state() {
|
||||
if [ -f "$STATE_FILE" ]; then
|
||||
# Security check: ensure we own this file
|
||||
state_owner=""
|
||||
if stat -c "%u" "$STATE_FILE" >/dev/null 2>&1; then
|
||||
state_owner=$(stat -c "%u" "$STATE_FILE" 2>/dev/null)
|
||||
@@ -628,7 +639,6 @@ check_previous_state() {
|
||||
|
||||
warn "$(msg WARN_RESUME)"
|
||||
|
||||
# [SEC-FIX] Safe parsing instead of source
|
||||
prev_phase=$(parse_state_value "PHASE" "$STATE_FILE")
|
||||
prev_user=$(parse_state_value "USER" "$STATE_FILE")
|
||||
prev_port=$(parse_state_value "PORT" "$STATE_FILE")
|
||||
@@ -642,7 +652,6 @@ check_previous_state() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# Optionally restore previous values
|
||||
[ -n "$prev_user" ] && [ -z "$ARG_USER" ] && TARGET_USER="$prev_user"
|
||||
[ -n "$prev_port" ] && [ -z "$ARG_PORT" ] && SSH_PORT="$prev_port"
|
||||
fi
|
||||
@@ -710,6 +719,7 @@ setup_rollback() {
|
||||
}
|
||||
|
||||
# ---------------- Persistent Backup ----------------
|
||||
# [SEC-FIX] Fixed cleanup_old_backups awk logic bug
|
||||
cleanup_old_backups() {
|
||||
keep_count=10
|
||||
if [ -d "$BACKUP_REPO" ]; then
|
||||
@@ -718,7 +728,8 @@ cleanup_old_backups() {
|
||||
if [ -n "$count" ] && [ "$count" -gt "$keep_count" ] 2>/dev/null; then
|
||||
to_rm=$((count - keep_count))
|
||||
info "$(msg INFO_CLEANING_BACKUPS) $to_rm $(msg INFO_OLD_BACKUPS)"
|
||||
echo "$backup_list" | awk -v n="$to_rm" 'NR > (NR - n)' | while IFS= read -r d; do
|
||||
# [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
|
||||
@@ -758,7 +769,7 @@ backup_config_persistent() {
|
||||
{
|
||||
echo "=== Server Init Backup ==="
|
||||
echo "Time: $(date)"
|
||||
echo "Version: 4.6.0"
|
||||
echo "Version: 4.6.1"
|
||||
echo "User: ${TARGET_USER:-unknown}"
|
||||
echo "Port: ${SSH_PORT:-unknown}"
|
||||
echo "OpenSSH: ${OPENSSH_VER_MAJOR}.${OPENSSH_VER_MINOR}"
|
||||
@@ -870,6 +881,16 @@ openssh_version_ge() {
|
||||
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 ----------------
|
||||
allow_firewall_port() {
|
||||
p="$1"
|
||||
@@ -1007,19 +1028,15 @@ pick_random_port() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# [SEC-FIX] Validate port number for command line arguments
|
||||
validate_port() {
|
||||
port="$1"
|
||||
|
||||
# Must be numeric
|
||||
case "$port" in
|
||||
''|*[!0-9]*) return 1 ;;
|
||||
esac
|
||||
|
||||
# Must be in valid range
|
||||
[ "$port" -ge 1 ] 2>/dev/null && [ "$port" -le 65535 ] 2>/dev/null || return 1
|
||||
|
||||
# Check reserved ports (allow 22)
|
||||
if [ "$port" -lt 1024 ] && [ "$port" != "22" ]; then
|
||||
return 1
|
||||
fi
|
||||
@@ -1028,26 +1045,28 @@ validate_port() {
|
||||
}
|
||||
|
||||
# ---------------- User & Sudo ----------------
|
||||
# [SEC-FIX] Allow root username (matching documentation)
|
||||
validate_username() {
|
||||
u="$1"
|
||||
|
||||
# Allow root explicitly
|
||||
[ "$u" = "root" ] && return 0
|
||||
|
||||
len=${#u}
|
||||
[ "$len" -ge 2 ] && [ "$len" -le 32 ] || return 1
|
||||
echo "$u" | grep -Eq '^[a-z_][a-z0-9_-]*$' || return 1
|
||||
|
||||
# Block system reserved names (except root which is handled above)
|
||||
case "$u" in bin|daemon|adm|lp|sync|shutdown|halt|mail|operator|games|ftp|nobody) return 1 ;; esac
|
||||
return 0
|
||||
}
|
||||
|
||||
# [SEC-FIX] Fixed grep prefix match - require whitespace or end after username
|
||||
safe_configure_sudo() {
|
||||
user="$1"
|
||||
if [ ! -d /etc/sudoers.d ]; then warn "$(msg WARN_NO_SUDOERS_DIR)"; return 0; fi
|
||||
if grep -q "^[[:space:]]*$user" /etc/sudoers /etc/sudoers.d/* 2>/dev/null; then info "$(msg INFO_SUDO_EXISTS)"; return 0; fi
|
||||
# [SEC-FIX] Match user followed by whitespace to avoid prefix matching
|
||||
if grep -Eq "^[[:space:]]*${user}[[:space:]]" /etc/sudoers /etc/sudoers.d/* 2>/dev/null; then
|
||||
info "$(msg INFO_SUDO_EXISTS)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
timestamp=$(date +%Y%m%d%H%M%S)
|
||||
sudoers_file="/etc/sudoers.d/server-init-$user-$timestamp"
|
||||
@@ -1215,13 +1234,15 @@ fetch_keys() {
|
||||
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
|
||||
|
||||
if ! printf '%s' "$line" | grep -Eq '^(ssh-(rsa|dss|ed25519)|ecdsa-sha2-nistp[0-9]+|sk-(ssh-ed25519|ecdsa-sha2-nistp[0-9]+)@openssh\.com|ssh-(rsa|dss|ed25519)-cert-v01@openssh\.com|ecdsa-sha2-nistp[0-9]+-cert-v01@openssh\.com|rsa-sha2-(256|512))[[:space:]]+[A-Za-z0-9+/]+=*([[:space:]]+.*)?$'; then
|
||||
# [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
|
||||
|
||||
@@ -1235,7 +1256,7 @@ validate_ssh_key_line() {
|
||||
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|rsa-sha2-256|rsa-sha2-512)
|
||||
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 ;;
|
||||
@@ -1244,11 +1265,22 @@ validate_ssh_key_line() {
|
||||
esac
|
||||
fi
|
||||
|
||||
# [SEC-FIX] Optional: use ssh-keygen for stronger validation if available
|
||||
if command -v ssh-keygen >/dev/null 2>&1; then
|
||||
tmpk="$TMP_DIR/keycheck.$$"
|
||||
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] Improved deploy_keys - returns success only if THIS deployment added valid keys
|
||||
# [SEC-FIX] Completely rewritten deploy_keys with symlink protection
|
||||
deploy_keys() {
|
||||
user="$1"
|
||||
keys="$2"
|
||||
@@ -1256,22 +1288,53 @@ deploy_keys() {
|
||||
dir="$home/.ssh"
|
||||
auth="$dir/authorized_keys"
|
||||
|
||||
# [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
|
||||
|
||||
# [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
|
||||
|
||||
if ! chown -R "${user}:${user}" "$dir" 2>/dev/null; then
|
||||
chown -R "$user" "$dir" 2>/dev/null || true
|
||||
# [SEC-FIX] Only chown specific paths, no -R (symlink traversal risk)
|
||||
if ! chown "${user}:${user}" "$dir" "$auth" 2>/dev/null; then
|
||||
chown "$user" "$dir" "$auth" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Track keys we successfully deploy in this run
|
||||
valid_keys_file="$TMP_DIR/valid_keys.$$"
|
||||
deployed_count=0
|
||||
|
||||
: > "$valid_keys_file"
|
||||
|
||||
# First pass: validate and collect valid keys
|
||||
printf "%s\n" "$keys" | while IFS= read -r line; do
|
||||
[ -z "$line" ] && continue
|
||||
clean_line=$(validate_ssh_key_line "$line")
|
||||
@@ -1280,21 +1343,17 @@ deploy_keys() {
|
||||
fi
|
||||
done
|
||||
|
||||
# Check if we have any valid keys from this input
|
||||
if [ ! -s "$valid_keys_file" ]; then
|
||||
warn "$(msg WARN_NO_VALID_KEYS)"
|
||||
rm -f "$valid_keys_file" 2>/dev/null || true
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Second pass: deploy valid keys and verify they exist in authorized_keys
|
||||
while IFS= read -r key; do
|
||||
[ -z "$key" ] && continue
|
||||
# Add if not already present
|
||||
if ! grep -qxF "$key" "$auth" 2>/dev/null; then
|
||||
printf "%s\n" "$key" >> "$auth"
|
||||
fi
|
||||
# Verify it's now in the file
|
||||
if grep -qxF "$key" "$auth" 2>/dev/null; then
|
||||
deployed_count=$((deployed_count + 1))
|
||||
fi
|
||||
@@ -1302,7 +1361,6 @@ deploy_keys() {
|
||||
|
||||
rm -f "$valid_keys_file" 2>/dev/null || true
|
||||
|
||||
# Success only if at least one key from THIS deployment is in the file
|
||||
if [ "$deployed_count" -gt 0 ]; then
|
||||
info "$(msg INFO_KEYS_DEPLOYED) $deployed_count"
|
||||
return 0
|
||||
@@ -1313,11 +1371,13 @@ deploy_keys() {
|
||||
}
|
||||
|
||||
# ---------------- 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
|
||||
if grep -Eq '^[[:space:]]*(Port|PermitRootLogin|PasswordAuthentication|PubkeyAuthentication|ChallengeResponseAuthentication|KexAlgorithms|Ciphers|MACs|AddressFamily|ListenAddress)[[:space:]]' "$conf"; then
|
||||
# [SEC-FIX] Case-insensitive matching
|
||||
if awk 'BEGIN{IGNORECASE=1} /^[[:space:]]*(Port|PermitRootLogin|PasswordAuthentication|PubkeyAuthentication|ChallengeResponseAuthentication|KbdInteractiveAuthentication|KexAlgorithms|Ciphers|MACs|AddressFamily|ListenAddress)[[:space:]]/ {exit 0} END{exit 1}' "$conf" 2>/dev/null; then
|
||||
mv "$conf" "${conf}.bak_server_init" 2>/dev/null || true
|
||||
warn "$(msg CLEAN_D) $conf"
|
||||
fi
|
||||
@@ -1339,16 +1399,20 @@ remove_managed_block() {
|
||||
fi
|
||||
}
|
||||
|
||||
# [SEC-FIX] Case-insensitive sanitization
|
||||
sanitize_sshd_config() {
|
||||
info "$(msg INFO_SANITIZE_DUP)"
|
||||
tmp_san="$TMP_DIR/sshd_config.sanitized"
|
||||
|
||||
# [SEC-FIX] Use case-insensitive matching with tolower()
|
||||
awk '
|
||||
BEGIN { IGNORECASE = 1 }
|
||||
/^[[:space:]]*Port[[:space:]]/ { print "# [server-init disabled] " $0; next }
|
||||
/^[[:space:]]*PermitRootLogin[[:space:]]/ { print "# [server-init disabled] " $0; next }
|
||||
/^[[:space:]]*PasswordAuthentication[[:space:]]/ { print "# [server-init disabled] " $0; next }
|
||||
/^[[:space:]]*PubkeyAuthentication[[:space:]]/ { print "# [server-init disabled] " $0; next }
|
||||
/^[[:space:]]*ChallengeResponseAuthentication[[:space:]]/ { print "# [server-init disabled] " $0; next }
|
||||
/^[[:space:]]*KbdInteractiveAuthentication[[:space:]]/ { print "# [server-init disabled] " $0; next }
|
||||
/^[[:space:]]*KexAlgorithms[[:space:]]/ { print "# [server-init disabled] " $0; next }
|
||||
/^[[:space:]]*Ciphers[[:space:]]/ { print "# [server-init disabled] " $0; next }
|
||||
/^[[:space:]]*MACs[[:space:]]/ { print "# [server-init disabled] " $0; next }
|
||||
@@ -1362,18 +1426,12 @@ sanitize_sshd_config() {
|
||||
fi
|
||||
}
|
||||
|
||||
# [SEC-FIX] Improved IPv6 detection - exclude loopback
|
||||
has_global_ipv6() {
|
||||
# Prefer ip command for accurate scope detection
|
||||
if command -v ip >/dev/null 2>&1; then
|
||||
ip -6 addr show scope global 2>/dev/null | grep -q inet6 && return 0
|
||||
fi
|
||||
|
||||
# Fallback to /proc but exclude loopback interface
|
||||
if [ -f /proc/net/if_inet6 ]; then
|
||||
# Format: address ifindex prefix_len scope flags ifname
|
||||
# Field 4 is scope: 00=global, 20=link-local, 10=host(loopback)
|
||||
# Field 6 is interface name
|
||||
awk '$4 == "00" && $6 != "lo" { found=1; exit } END { exit !found }' /proc/net/if_inet6 2>/dev/null && return 0
|
||||
fi
|
||||
|
||||
@@ -1385,7 +1443,6 @@ has_global_ipv6() {
|
||||
}
|
||||
|
||||
# ----- Crypto selection -----
|
||||
# [SEC-FIX] Use case for csv_contains instead of grep (regex safety)
|
||||
csv_contains() {
|
||||
csv="$1"
|
||||
item="$2"
|
||||
@@ -1400,7 +1457,6 @@ csv_intersect_ordered() {
|
||||
supp="$2"
|
||||
result=""
|
||||
|
||||
# Use subshell to isolate IFS change
|
||||
result=$(
|
||||
IFS=,
|
||||
for a in $pref; do
|
||||
@@ -1410,7 +1466,6 @@ csv_intersect_ordered() {
|
||||
done
|
||||
)
|
||||
|
||||
# Convert newlines to commas
|
||||
echo "$result" | tr '\n' ',' | sed 's/,$//'
|
||||
}
|
||||
|
||||
@@ -1418,12 +1473,10 @@ get_sshd_T_value() {
|
||||
key="$1"
|
||||
v=""
|
||||
|
||||
# Only try -C on OpenSSH 6.8+
|
||||
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
|
||||
|
||||
# Fallback without -C
|
||||
if [ -z "$v" ]; then
|
||||
v=$(sshd -T -f "$SSH_CONF" 2>/dev/null | awk -v k="$key" 'tolower($1)==k {print $2; exit}')
|
||||
fi
|
||||
@@ -1481,7 +1534,7 @@ build_block() {
|
||||
file="$1"
|
||||
{
|
||||
echo "$BLOCK_BEGIN"
|
||||
echo "# Managed by server-init v4.6.0"
|
||||
echo "# Managed by server-init v4.6.1"
|
||||
echo "# Generated: $(date)"
|
||||
echo "# OpenSSH: ${OPENSSH_VER_MAJOR}.${OPENSSH_VER_MINOR}"
|
||||
echo "# Do NOT edit inside this block. Changes will be overwritten."
|
||||
@@ -1506,6 +1559,10 @@ build_block() {
|
||||
echo "PasswordAuthentication 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"
|
||||
@@ -1542,20 +1599,16 @@ build_block() {
|
||||
} >"$file"
|
||||
}
|
||||
|
||||
# [SEC-FIX] Use awk instead of tail -n +N for better POSIX compatibility
|
||||
install_managed_block() {
|
||||
block="$1"
|
||||
tmp="$TMP_DIR/sshd_config.merge"
|
||||
|
||||
# Find first non-comment Match line
|
||||
match_line=$(awk '/^[[:space:]]*#/ {next} /^[[:space:]]*Match[[:space:]]/ {print NR; exit}' "$SSH_CONF" 2>/dev/null)
|
||||
|
||||
if [ -z "$match_line" ]; then
|
||||
# No Match block found -> append at end
|
||||
cat "$SSH_CONF" "$block" > "$tmp"
|
||||
else
|
||||
info "$(msg INFO_MATCH_INSERT)"
|
||||
# Insert before Match block using awk
|
||||
awk -v ml="$match_line" -v bf="$block" '
|
||||
NR < ml { print }
|
||||
NR == ml {
|
||||
@@ -1583,7 +1636,8 @@ verify_sshd_listening() {
|
||||
return 0
|
||||
fi
|
||||
if command -v nc >/dev/null 2>&1; then
|
||||
nc -z -w 1 localhost "$port" 2>/dev/null && return 0
|
||||
# [SEC-FIX] Use 127.0.0.1 instead of localhost
|
||||
nc -z -w 1 127.0.0.1 "$port" 2>/dev/null && return 0
|
||||
fi
|
||||
sleep 1
|
||||
elapsed=$((elapsed + 1))
|
||||
@@ -1591,7 +1645,7 @@ verify_sshd_listening() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# [SEC-FIX] Enhanced SSH test - require SSH-2.0 banner
|
||||
# [SEC-FIX] Use 127.0.0.1 instead of localhost to avoid IPv6 mismatch
|
||||
enhanced_ssh_test() {
|
||||
port="$1"
|
||||
user="$2"
|
||||
@@ -1602,14 +1656,14 @@ enhanced_ssh_test() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
# [SEC-FIX] Banner check is now required, not optional
|
||||
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 localhost "$port" 2>/dev/null || true)
|
||||
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 localhost "$port" 2>/dev/null || true)
|
||||
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
|
||||
@@ -1619,25 +1673,23 @@ enhanced_ssh_test() {
|
||||
err "$(msg ERR_NO_BANNER)"
|
||||
fi
|
||||
else
|
||||
# If nc is not available, we can't verify banner - consider it passed with warning
|
||||
warn "nc not available, skipping banner check"
|
||||
banner_ok=1
|
||||
fi
|
||||
|
||||
# [SEC-FIX] Must have valid SSH banner to pass
|
||||
if [ "$banner_ok" -ne 1 ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# SSH client test - this may fail without the right key, but banner was OK
|
||||
attempts=1
|
||||
max_attempts=3
|
||||
success=0
|
||||
while [ "$attempts" -le "$max_attempts" ]; do
|
||||
if command -v ssh >/dev/null 2>&1; then
|
||||
if ssh -o BatchMode=yes -o ConnectTimeout=3 -o StrictHostKeyChecking=no \
|
||||
# [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@localhost" "exit 0" >/dev/null 2>&1; then
|
||||
-p "$port" "$user@127.0.0.1" "exit 0" >/dev/null 2>&1; then
|
||||
success=1
|
||||
break
|
||||
fi
|
||||
@@ -1649,7 +1701,6 @@ enhanced_ssh_test() {
|
||||
if [ "$success" -eq 1 ]; then
|
||||
ok "$(msg TEST_OK)"
|
||||
else
|
||||
# Banner was OK but auth failed - this is expected without the right key
|
||||
warn "$(msg WARN_PORT_OPEN_BUT_FAIL)"
|
||||
fi
|
||||
|
||||
@@ -1685,7 +1736,7 @@ generate_health_report() {
|
||||
{
|
||||
echo "=== Server Init Health Report ==="
|
||||
echo "Generated: $(date)"
|
||||
echo "Version: v4.6.0 Fortress Pro"
|
||||
echo "Version: v4.6.1 Fortress Pro"
|
||||
echo "Execution Time: ${duration}s"
|
||||
echo ""
|
||||
echo "--- System ---"
|
||||
@@ -1771,14 +1822,18 @@ validate_ssh_config_comprehensive() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
password_auth=$(grep -E '^[[:space:]]*PasswordAuthentication[[:space:]]' "$config_file" 2>/dev/null | awk '{print $NF}' | tr -d '\r')
|
||||
pubkey_auth=$(grep -E '^[[:space:]]*PubkeyAuthentication[[:space:]]' "$config_file" 2>/dev/null | awk '{print $NF}' | tr -d '\r')
|
||||
port_setting=$(grep -E '^[[:space:]]*Port[[:space:]]' "$config_file" 2>/dev/null | awk '{print $NF}' | tr -d '\r')
|
||||
password_auth=$(grep -Ei '^[[:space:]]*PasswordAuthentication[[:space:]]' "$config_file" 2>/dev/null | awk '{print $NF}' | tr -d '\r' | tail -1)
|
||||
pubkey_auth=$(grep -Ei '^[[:space:]]*PubkeyAuthentication[[:space:]]' "$config_file" 2>/dev/null | awk '{print $NF}' | tr -d '\r' | tail -1)
|
||||
port_setting=$(grep -Ei '^[[:space:]]*Port[[:space:]]' "$config_file" 2>/dev/null | awk '{print $NF}' | tr -d '\r' | tail -1)
|
||||
|
||||
[ -n "$password_auth" ] || password_auth="yes"
|
||||
[ -n "$pubkey_auth" ] || pubkey_auth="yes"
|
||||
[ -n "$port_setting" ] || port_setting="22"
|
||||
|
||||
# Normalize to lowercase for comparison
|
||||
password_auth=$(echo "$password_auth" | tr '[:upper:]' '[:lower:]')
|
||||
pubkey_auth=$(echo "$pubkey_auth" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
if [ "$password_auth" = "no" ] && [ "$pubkey_auth" = "no" ]; then
|
||||
die "$(msg ERR_DEADLOCK)"
|
||||
fi
|
||||
@@ -1793,11 +1848,11 @@ validate_ssh_config_comprehensive() {
|
||||
fi
|
||||
|
||||
insecure_options=0
|
||||
if grep -q "^[[:space:]]*X11Forwarding[[:space:]]*yes" "$config_file" 2>/dev/null; then
|
||||
if grep -qi "^[[:space:]]*X11Forwarding[[:space:]]*yes" "$config_file" 2>/dev/null; then
|
||||
warn "$(msg WARN_X11_FORWARDING)"
|
||||
insecure_options=$((insecure_options + 1))
|
||||
fi
|
||||
if grep -q "^[[:space:]]*PermitEmptyPasswords[[:space:]]*yes" "$config_file" 2>/dev/null; then
|
||||
if grep -qi "^[[:space:]]*PermitEmptyPasswords[[:space:]]*yes" "$config_file" 2>/dev/null; then
|
||||
warn "$(msg WARN_EMPTY_PASSWORDS)"
|
||||
insecure_options=$((insecure_options + 1))
|
||||
fi
|
||||
@@ -1835,13 +1890,11 @@ else
|
||||
done
|
||||
fi
|
||||
|
||||
# [SEC-FIX] Validate --port parameter
|
||||
if [ -n "$ARG_PORT" ]; then
|
||||
case "$ARG_PORT" in
|
||||
22) PORT_OPT="1"; SSH_PORT="22" ;;
|
||||
random) PORT_OPT="2"; SSH_PORT="22" ;;
|
||||
*)
|
||||
# Validate the port
|
||||
if ! validate_port "$ARG_PORT"; then
|
||||
die "$(msg PORT_ERR): $ARG_PORT"
|
||||
fi
|
||||
@@ -1926,6 +1979,7 @@ 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
|
||||
@@ -1995,7 +2049,22 @@ if [ "$ARG_DELAY_RESTART" != "y" ]; then protect_sshd_service; fi
|
||||
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] restart_sshd failure now triggers rollback
|
||||
# [SEC-FIX] Handle --delay-restart properly: skip restart and tests
|
||||
if [ "$ARG_DELAY_RESTART" = "y" ]; then
|
||||
warn "$(msg DELAY_RESTART_MSG)"
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user