mirror of
https://github.com/LamGC/quickly-conf-sshd.git
synced 2026-10-12 23:33:56 +00:00
feat: independently publish server SSH keys with safe sync and Worker homepage
This commit is contained in:
+473
-309
@@ -1,323 +1,487 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
set -o pipefail
|
||||
#!/bin/sh
|
||||
# conf-sshd-script-v2
|
||||
# Generated configuration. Never source or eval downloaded data.
|
||||
BASE_URL='@@BASE_URL@@'
|
||||
DEFAULT_CRON='@@DEFAULT_CRON@@'
|
||||
|
||||
########## 一些配置 ##########
|
||||
set -eu
|
||||
umask 077
|
||||
LC_ALL=C
|
||||
export LC_ALL
|
||||
BEGIN_KEYS='# --- BEGIN MANAGED BY CONF-SSHD SCRIPT ---'
|
||||
END_KEYS='# --- END MANAGED BY CONF-SSHD SCRIPT ---'
|
||||
BEGIN_CRON='# --- BEGIN CRON MANAGED BY CONF-SSHD SCRIPT ---'
|
||||
END_CRON='# --- END CRON MANAGED BY CONF-SSHD SCRIPT ---'
|
||||
mode=sync
|
||||
cron_requested=no
|
||||
cron_value=
|
||||
dry_run=no
|
||||
no_install=no
|
||||
root_password=
|
||||
temps=
|
||||
lock_owned=no
|
||||
created_state=no
|
||||
created_ssh=no
|
||||
|
||||
# 默认获取 SSH key 的地方,一般是 Github.
|
||||
sshkey_url="{{ SSH_KEY_URL }}"
|
||||
# 默认的 Cron 执行计划, 每天凌晨 0 点执行
|
||||
default_cron="{{ DEFAULT_CRON }}"
|
||||
# 脚本 Url
|
||||
script_url="{{ SCRIPT_URL }}"
|
||||
say() { printf '%s\n' "$*"; }
|
||||
fail() { printf 'conf-sshd: %s\n' "$*" >&2; exit 1; }
|
||||
usage() {
|
||||
cat <<'HELP'
|
||||
Usage: sh conf-sshd.sh [options]
|
||||
(no options), -o, --only-update-keys Validate and sync managed public keys only
|
||||
-c, --cron ["five fields"|false] Sync and schedule; bare -c keeps an existing
|
||||
schedule, otherwise every 15 minutes.
|
||||
-c false only removes this script's schedule.
|
||||
-u, --update-self Explicitly update the installed script only
|
||||
--uninstall Remove own schedule/script; preserve keys/config
|
||||
-p, --allow-root-passwd yes|no Explicit root-only sshd_config edit; never reload
|
||||
-n, --no-install-sshd Compatibility flag; packages are never installed
|
||||
--dry-run Validate and show changes without applying them
|
||||
--status Show local installation and schedule status
|
||||
-h, --help Show this help
|
||||
|
||||
############ 脚本区 ##########
|
||||
|
||||
script_params=("$@")
|
||||
has_param() {
|
||||
for param in "${script_params[@]}"; do
|
||||
for tParam in "$@"; do
|
||||
if [ "$tParam" == "$param" ]; then
|
||||
echo "true"
|
||||
return
|
||||
fi
|
||||
done
|
||||
done
|
||||
echo "false"
|
||||
-u, --uninstall, -p and --status are exclusive operations. -o cannot use -c or -p.
|
||||
Requires curl, ssh-keygen, awk, standard POSIX tools, mktemp, stat, and one of
|
||||
sha256sum, shasum or openssl. Scheduling also requires crontab. Downloads require
|
||||
HTTPS; existing keys survive any validation/download failure.
|
||||
No SSH service is installed, restarted, or reloaded. Keep recovery access ready.
|
||||
The payload SHA-256 detects inconsistency; trust comes from HTTPS and its source.
|
||||
HELP
|
||||
}
|
||||
|
||||
get_param_value() {
|
||||
local find=false
|
||||
for param in "${script_params[@]}"; do
|
||||
if [ "$find" == "true" ]; then
|
||||
if [[ $param == -* ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
echo "$param"
|
||||
return
|
||||
fi
|
||||
for tParam in "$@"; do
|
||||
if [ "$tParam" == "$param" ]; then
|
||||
find=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
done
|
||||
select_mode() {
|
||||
[ "$mode" = sync ] || fail 'Conflicting or duplicate operation options.'
|
||||
mode=$1
|
||||
}
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case $1 in
|
||||
-h|--help) [ "$#" -eq 1 ] && [ "$mode" = sync ] && [ "$cron_requested" = no ] || fail '--help must be used alone.'; usage; exit 0 ;;
|
||||
-o|--only-update-keys) select_mode keys ;;
|
||||
-u|--update-self) select_mode update ;;
|
||||
--uninstall) select_mode uninstall ;;
|
||||
--status) select_mode status ;;
|
||||
-p|--allow-root-passwd)
|
||||
select_mode config
|
||||
[ "$#" -ge 2 ] || fail '-p requires yes or no.'
|
||||
shift; case $1 in yes|no) root_password=$1 ;; *) fail '-p requires yes or no.' ;; esac ;;
|
||||
-c|--cron)
|
||||
[ "$cron_requested" = no ] || fail 'Duplicate -c option.'
|
||||
cron_requested=yes
|
||||
if [ "$#" -gt 1 ]; then
|
||||
case $2 in -*) : ;; *) shift; cron_value=$1; [ -n "$cron_value" ] || fail 'Empty cron expression.' ;; esac
|
||||
fi ;;
|
||||
--dry-run) [ "$dry_run" = no ] || fail 'Duplicate --dry-run.'; dry_run=yes ;;
|
||||
-n|--no-install-sshd) [ "$no_install" = no ] || fail 'Duplicate -n.'; no_install=yes ;;
|
||||
*) fail "Unknown option or unexpected argument: $1" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
[ "$cron_requested" = no ] || [ "$mode" = sync ] || fail '-c cannot be combined with this operation.'
|
||||
|
||||
# 帮助信息.
|
||||
if [ "$(has_param "-h" "--help")" == "true" ]; then
|
||||
echo "Usage: $0 [options]"
|
||||
echo "Options:"
|
||||
echo " -h, --help Print this help message."
|
||||
echo ""
|
||||
echo "Available to any user: "
|
||||
echo " -c, --cron \"<cron> | false\" Configure Crontab to automatically update ssh keys."
|
||||
echo " Cron expression MUST be quoted (e.g., \"* 0 * * *\")."
|
||||
echo " If 'false' is specified, Crontab settings will be deleted."
|
||||
echo ""
|
||||
echo " -o, --only-update-keys Only update SSH keys, do not configure ssh server."
|
||||
echo " -u, --update-self Update this script to the latest version."
|
||||
echo " --uninstall Uninstall this script (remove cron and local files)."
|
||||
echo ""
|
||||
echo "only available when the script is executed as root:"
|
||||
echo " -n, --no-install-sshd Do not install SSH Server."
|
||||
echo " -p, --allow-root-passwd <yes | no> Allow Root to log in with a password."
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
need() { command -v "$1" >/dev/null 2>&1 || fail "Missing dependency: $1. Install it explicitly before continuing."; }
|
||||
for dep in awk cat chmod cmp cp dd grep id mkdir mktemp mv od rm rmdir sed stat tail tr wc; do need "$dep"; done
|
||||
uid=$(id -u)
|
||||
# Reject paths that could alter line-based cron records. Spaces and quotes are safe.
|
||||
case ${HOME:-} in /*) : ;; *) fail 'HOME must be an absolute directory.' ;; esac
|
||||
[ "$(printf '%s' "$HOME" | tr -d '\040-\176\200-\377'; printf .)" = . ] || fail 'HOME must not contain control characters.'
|
||||
case $HOME in *\\*) fail 'HOME paths containing backslashes are not supported.' ;; esac
|
||||
HOME=${HOME%/}
|
||||
[ -n "$HOME" ] || fail 'HOME must not be the filesystem root.'
|
||||
state=$HOME/.conf-sshd
|
||||
ssh_dir=$HOME/.ssh
|
||||
auth_file=$ssh_dir/authorized_keys
|
||||
target_script=$state/conf-sshd.sh
|
||||
|
||||
update_sshkeys() {
|
||||
if [ "$sshkey_url" == "" ] || [[ "$sshkey_url" == "{{"* ]]; then
|
||||
echo "ERROR: sshkey_url is not configured."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Downloading SSH public key from '$sshkey_url'"
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
|
||||
local dl_tmp_file=~/.ssh/authorized_keys.dl.tmp
|
||||
if ! curl -sL "$sshkey_url" -o "$dl_tmp_file"; then
|
||||
echo "Failed to download SSH public key at $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
rm -f "$dl_tmp_file"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -s "$dl_tmp_file" ] || ! grep -qE "(ssh-rsa|ssh-ed25519|ecdsa-sha2-nistp)" "$dl_tmp_file"; then
|
||||
echo "Downloaded file is empty or does not contain valid SSH key types at $(date '+%Y-m-d %H:%M:%S')"
|
||||
echo "Aborting update to prevent lockout."
|
||||
rm -f "$dl_tmp_file"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "-------------------- SSH Keys --------------------"
|
||||
cat "$dl_tmp_file"
|
||||
echo "--------------------------------------------------"
|
||||
|
||||
local auth_file=~/.ssh/authorized_keys
|
||||
local new_auth_file=~/.ssh/authorized_keys.new.tmp
|
||||
|
||||
# 受管理文本块标记
|
||||
local begin_marker="# --- BEGIN MANAGED BY CONF-SSHD SCRIPT ---"
|
||||
local end_marker="# --- END MANAGED BY CONF-SSHD SCRIPT ---"
|
||||
|
||||
local managed_block_found=false
|
||||
local inside_managed_block=false
|
||||
|
||||
touch "$auth_file"
|
||||
true > "$new_auth_file"
|
||||
|
||||
while IFS= read -r line; do
|
||||
if [ "$line" == "$begin_marker" ]; then
|
||||
managed_block_found=true
|
||||
inside_managed_block=true
|
||||
|
||||
{
|
||||
echo "$begin_marker"
|
||||
cat "$dl_tmp_file"
|
||||
echo "$end_marker"
|
||||
} >> "$new_auth_file"
|
||||
|
||||
elif [ "$line" == "$end_marker" ]; then
|
||||
inside_managed_block=false
|
||||
elif [ "$inside_managed_block" == "false" ]; then
|
||||
echo "$line" >> "$new_auth_file"
|
||||
fi
|
||||
done < "$auth_file"
|
||||
|
||||
if [ "$managed_block_found" == "false" ]; then
|
||||
if [ -s "$new_auth_file" ]; then
|
||||
if [ "$(tail -c 1 "$new_auth_file")" != "" ]; then
|
||||
# 最后一个字符不是换行符,添加一个
|
||||
echo "" >> "$new_auth_file"
|
||||
fi
|
||||
fi
|
||||
|
||||
{
|
||||
echo "$begin_marker"
|
||||
cat "$dl_tmp_file"
|
||||
echo "$end_marker"
|
||||
} >> "$new_auth_file"
|
||||
fi
|
||||
|
||||
mv "$new_auth_file" "$auth_file"
|
||||
rm -f "$dl_tmp_file"
|
||||
|
||||
chmod 600 "$auth_file"
|
||||
echo "SSH public key updated successfully (managed block only) at $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
}
|
||||
|
||||
# 检查是否指定了 --uninstall
|
||||
if [ "$(has_param "--uninstall")" == "true" ]; then
|
||||
echo "Uninstalling conf-sshd (disabling auto-updates)..."
|
||||
|
||||
if [ "$(command -v crontab)" != "" ]; then
|
||||
echo "Removing Crontab entry..."
|
||||
( (crontab -l 2>/dev/null || true) | grep -F -v "conf-sshd.sh") | crontab -
|
||||
else
|
||||
echo "Crontab utility not found, skipping Crontab removal."
|
||||
fi
|
||||
|
||||
script_dir="$HOME/.conf-sshd"
|
||||
if [ -d "$script_dir" ]; then
|
||||
echo "Removing script files from $script_dir..."
|
||||
rm -rf "$script_dir"
|
||||
else
|
||||
echo "Script directory $script_dir not found, skipping removal."
|
||||
fi
|
||||
|
||||
echo "Uninstall complete."
|
||||
echo "Note: authorized_keys managed block and sshd_config were NOT affected."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 检查是否只更新密钥.
|
||||
if [ "$(has_param "-o" "--only-update-keys")" == "true" ]; then
|
||||
update_sshkeys
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 检查是否指定了 --update-self
|
||||
if [ "$(has_param "-u" "--update-self")" == "true" ]; then
|
||||
echo "Updating conf-sshd script..."
|
||||
mkdir -p ~/.conf-sshd
|
||||
target_script=~/.conf-sshd/conf-sshd.sh
|
||||
|
||||
if [ -f "$target_script" ]; then
|
||||
cp "$target_script" "$target_script.bak"
|
||||
fi
|
||||
|
||||
# 下载到临时文件
|
||||
if ! curl -sL "$script_url" -o "$target_script.tmp"; then
|
||||
echo "Script download failed at $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
rm -f "$target_script.tmp"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mv "$target_script.tmp" "$target_script"
|
||||
|
||||
chmod +x "$target_script"
|
||||
echo "Script updated successfully at $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 检查 SSHD 是否安装.
|
||||
if ! /usr/sbin/sshd -T > /dev/null 2>&1 && [ "$(has_param "-n" "--no-install-sshd")" == "false" ]; then
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
echo "The ssh server is not installed, and the script is executed as root, so it will be installed."
|
||||
if [ -f /etc/redhat-release ]; then
|
||||
yum install -y openssh-server
|
||||
elif [ -f /etc/debian_version ]; then
|
||||
apt-get update
|
||||
apt-get install -y openssh-server
|
||||
fi
|
||||
echo "The ssh server has been installed."
|
||||
else
|
||||
echo "The ssh server is not installed, but the script is executed as a non-root user and cannot be installed."
|
||||
exit 1
|
||||
fi
|
||||
# GNU/Linux and BSD/macOS stat. No filesystem changes precede ownership checks.
|
||||
if stat -c '%u %a %h' "$HOME" >/dev/null 2>&1; then
|
||||
stat_info() { stat -c '%u %a %h' "$1"; }
|
||||
else
|
||||
echo "The ssh server is already installed."
|
||||
stat_info() { stat -f '%u %Lp %l' "$1"; }
|
||||
fi
|
||||
check_path() {
|
||||
checked=$1
|
||||
[ ! -L "$checked" ] || fail "Refusing symlink: $checked"
|
||||
if [ -e "$checked" ]; then
|
||||
if [ "$2" = dir ]; then [ -d "$checked" ] || fail "Not a directory: $checked"
|
||||
else [ -f "$checked" ] || fail "Not a regular file: $checked"; fi
|
||||
info=$(stat_info "$checked") || fail "Cannot inspect: $checked"
|
||||
# stat produces three numeric fields.
|
||||
# shellcheck disable=SC2086
|
||||
set -- $info
|
||||
[ "$1" = "$uid" ] || fail "Not owned by current user: $checked"
|
||||
case $2 in *[!0-7]*|'') fail "Cannot inspect permissions: $checked" ;; esac
|
||||
# Group/world writable paths allow races before an atomic replacement.
|
||||
[ "$((0$2 & 0022))" -eq 0 ] || fail "Group/world writable path: $checked"
|
||||
if [ -f "$checked" ]; then [ "$3" = 1 ] || fail "Refusing multiply linked file: $checked"; fi
|
||||
fi
|
||||
}
|
||||
check_path "$HOME" dir
|
||||
[ -d "$HOME" ] || fail 'HOME does not exist.'
|
||||
check_path "$state" dir
|
||||
check_path "$ssh_dir" dir
|
||||
check_path "$auth_file" file
|
||||
check_path "$target_script" file
|
||||
|
||||
validate_cron() {
|
||||
printf '%s\n' "$1" | awk '
|
||||
function number(s) { return s ~ /^[0-9]+$/ }
|
||||
function field(s,lo,hi, n,a,i,p,b,r,k,step) {
|
||||
if(s ~ /[^0-9*\/,\-]/ || s == "") return 0
|
||||
n=split(s,a,",")
|
||||
for(i=1;i<=n;i++) {
|
||||
p=split(a[i],b,"/"); if(p>2 || b[1]=="") return 0
|
||||
if(p==2 && (!number(b[2]) || b[2]+0<1 || b[2]+0>hi+1)) return 0
|
||||
if(b[1]=="*") continue
|
||||
r=split(b[1],k,"-"); if(r>2 || !number(k[1])) return 0
|
||||
if(k[1]+0<lo || k[1]+0>hi) return 0
|
||||
if(r==2 && (!number(k[2]) || k[2]+0<k[1]+0 || k[2]+0>hi)) return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
NR!=1 || NF!=5 { exit 1 }
|
||||
{ if(!field($1,0,59)||!field($2,0,23)||!field($3,1,31)||!field($4,1,12)||!field($5,0,7)) exit 1
|
||||
print $1 " " $2 " " $3 " " $4 " " $5 }
|
||||
END { if(NR!=1) exit 1 }'
|
||||
}
|
||||
if [ "$cron_requested" = yes ] && [ -n "$cron_value" ] && [ "$cron_value" != false ]; then
|
||||
cron_value=$(validate_cron "$cron_value") || fail 'Invalid cron expression; use five quoted numeric fields.'
|
||||
fi
|
||||
|
||||
# 检查是否指定了 --allow-root-passwd
|
||||
if [ "$(has_param "-p" "--allow-root-passwd")" == "true" ]; then
|
||||
# 检查当前用户是否为 root
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
allow_root_passwd=$(get_param_value "-p" "--allow-root-passwd" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
sshd_config_file="/etc/ssh/sshd_config"
|
||||
new_sshd_permit_root_login_setting=""
|
||||
|
||||
if [ "$allow_root_passwd" == "yes" ]; then
|
||||
new_sshd_permit_root_login_setting="PermitRootLogin yes"
|
||||
echo "Setting: Root user is allowed to log in with password."
|
||||
elif [ "$allow_root_passwd" == "no" ]; then
|
||||
new_sshd_permit_root_login_setting="PermitRootLogin prohibit-password"
|
||||
echo "Setting: Root user is prohibited from logging in with password."
|
||||
else
|
||||
echo "Please specify 'yes' or 'no' for --allow-root-passwd."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -qE '^#?PermitRootLogin' "$sshd_config_file"; then
|
||||
sed -i "s@^#?PermitRootLogin.*@$new_sshd_permit_root_login_setting@g" "$sshd_config_file"
|
||||
else
|
||||
echo "$new_sshd_permit_root_login_setting" >> "$sshd_config_file"
|
||||
fi
|
||||
echo "SSHD config updated. Please restart sshd service to apply changes."
|
||||
|
||||
else
|
||||
echo "The script is executed as a non-root user and cannot set whether to allow root to log in with a password."
|
||||
exit 1
|
||||
cleanup() {
|
||||
result=$?
|
||||
trap - 0 HUP INT TERM
|
||||
old_ifs=$IFS; IFS='
|
||||
'
|
||||
for path in $temps; do rm -f "$path"; done
|
||||
IFS=$old_ifs
|
||||
if [ "$lock_owned" = yes ]; then rm -f "$state/lock/pid"; rmdir "$state/lock" 2>/dev/null || :; fi
|
||||
if [ "$dry_run" = yes ] || [ "$mode" = uninstall ]; then
|
||||
if [ "$created_ssh" = yes ]; then rmdir "$ssh_dir" 2>/dev/null || :; fi
|
||||
if [ "$created_state" = yes ] || [ "$mode" = uninstall ]; then rmdir "$state" 2>/dev/null || :; fi
|
||||
fi
|
||||
exit "$result"
|
||||
}
|
||||
new_temp() {
|
||||
TEMP=$(mktemp "$1/.conf-sshd.XXXXXX") || fail 'Cannot create secure temporary file.'
|
||||
temps="$temps
|
||||
$TEMP"
|
||||
chmod 600 "$TEMP"
|
||||
}
|
||||
last_lf() { [ "$(tail -c 1 "$1" | od -An -tu1 | tr -d ' \n')" = 10 ]; }
|
||||
quote_sh() { printf "'"; printf '%s' "$1" | sed "s/'/'\\\\''/g"; printf "'"; }
|
||||
cron_command="/bin/sh $(quote_sh "$target_script") -o"
|
||||
legacy_command="\"/bin/bash $target_script -o\" >> $state/run.log 2>&1"
|
||||
|
||||
if [ "$mode" = status ]; then
|
||||
[ "$dry_run" = no ] || fail '--status does not use --dry-run.'
|
||||
if [ -f "$target_script" ]; then say "Installed script: $target_script"; else say 'Script is not installed for scheduled use.'; fi
|
||||
if [ -f "$auth_file" ]; then say "Authorized keys: $auth_file (not modified)"; else say 'No authorized_keys file.'; fi
|
||||
if command -v crontab >/dev/null 2>&1; then
|
||||
if status_cron=$(crontab -l 2>&1); then
|
||||
say 'Current crontab (read-only):'; say "$status_cron"
|
||||
else
|
||||
case $status_cron in 'no crontab for '*) say 'No crontab is installed for this user.' ;;
|
||||
*) fail "Cannot read current crontab: $status_cron" ;; esac
|
||||
fi
|
||||
else say 'crontab is not installed.'; fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 更新密钥.
|
||||
update_sshkeys
|
||||
|
||||
# 检查是否指定了 --cron
|
||||
if [ "$(has_param "-c" "--cron")" == "true" ]; then
|
||||
# 检查 Crontab 是否已安装
|
||||
if [ "$(command -v crontab)" == "" ]; then
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
echo "The crontab is not installed, and the script is executed as a root user, so it will be installed."
|
||||
if [ -f /etc/redhat-release ]; then
|
||||
yum install -y crontabs
|
||||
elif [ -f /etc/debian_version ]; then
|
||||
apt-get update
|
||||
apt-get install -y cron
|
||||
fi
|
||||
echo "The crontab has been installed."
|
||||
else
|
||||
echo "The crontab is not installed, but the script is executed as a non-root user and cannot be installed."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "The crontab is already installed."
|
||||
fi
|
||||
cron=$(get_param_value "-c" "--cron" | tr '[:upper:]' '[:lower:]')
|
||||
if [ "$cron" == "false" ]; then
|
||||
# 检查 Crontab 是否已经设置
|
||||
if [ "$( (crontab -l 2>/dev/null || true) | grep -F -c "conf-sshd.sh" )" -eq 0 ]; then
|
||||
echo "Crontab already clean. Will not be configured."
|
||||
exit 0
|
||||
else
|
||||
(crontab -l 2>/dev/null || true) | grep -F -v "conf-sshd.sh" | crontab -
|
||||
echo "Crontab has been removed."
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
if [ "$cron" == "" ]; then
|
||||
cron=$default_cron
|
||||
fi
|
||||
# 将当前脚本移动到 ~/.conf-sshd/conf-sshd.sh 中.
|
||||
mkdir -p ~/.conf-sshd
|
||||
target_script=~/.conf-sshd/conf-sshd.sh
|
||||
if [ ! -f "$0" ]; then
|
||||
echo "Downloading conf-sshd script..."
|
||||
if ! curl -sL "$script_url" -o "$target_script.tmp"; then
|
||||
echo "Script download failed at $(date '+%Y-m-d %H:%M:%S')"
|
||||
rm -f "$target_script.tmp"
|
||||
exit 1
|
||||
fi
|
||||
mv "$target_script.tmp" "$target_script"
|
||||
else
|
||||
echo "Copying conf-sshd script..."
|
||||
cp "$0" "$target_script"
|
||||
fi
|
||||
|
||||
chmod +x "$target_script"
|
||||
echo "Install conf-sshd script successfully."
|
||||
# 将当前脚本追加到当前用户的 Crontab 中
|
||||
echo "Configuring Crontab..."
|
||||
cron_command="\"/bin/bash $target_script -o\" >> $HOME/.conf-sshd/run.log 2>&1"
|
||||
cron_job="$cron $cron_command"
|
||||
|
||||
( (crontab -l 2>/dev/null || true) | grep -F -v "conf-sshd.sh") | { cat; echo "$cron_job"; } | crontab -
|
||||
|
||||
echo "Crontab has been configured.(Cron: '$cron')"
|
||||
fi
|
||||
[ -d "$state" ] || { mkdir "$state"; created_state=yes; }
|
||||
if [ "$dry_run" = no ]; then chmod 700 "$state"; fi
|
||||
trap cleanup 0
|
||||
trap 'exit 129' HUP
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
if mkdir "$state/lock" 2>/dev/null; then
|
||||
lock_owned=yes; printf '%s\n' "$$" > "$state/lock/pid"
|
||||
else
|
||||
fail "Another run holds $state/lock. If it is stale, verify the recorded process before removing that directory."
|
||||
fi
|
||||
|
||||
# Inputs are origin-only build constants; also reject unbuilt or corrupted scripts.
|
||||
validate_build() {
|
||||
printf '%s\n' "$BASE_URL" | grep -Eq '^https://([A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?|\[[0-9A-Fa-f:]+\])(:[0-9]{1,5})?$' || fail 'BASE_URL must be a configured HTTPS origin.'
|
||||
validate_cron "$DEFAULT_CRON" >/dev/null || fail 'Invalid or unbuilt default cron configuration.'
|
||||
}
|
||||
download() {
|
||||
need curl
|
||||
# No redirects: a configured origin must serve these endpoints directly.
|
||||
curl --fail --silent --show-error --proto '=https' --tlsv1.2 \
|
||||
--connect-timeout 10 --max-time 30 --retry 2 --retry-delay 1 \
|
||||
--retry-max-time 65 --max-filesize 1048576 --output "$2" "$1" || fail "Download failed: $1 (existing files unchanged)."
|
||||
[ "$(wc -c < "$2" | tr -d ' ')" -le 1048576 ] || fail 'Downloaded response is too large.'
|
||||
}
|
||||
sha256() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}'
|
||||
elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}'
|
||||
elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$1" | awk '{print $NF}'
|
||||
else fail 'Install sha256sum, shasum, or openssl before syncing keys.'; fi
|
||||
}
|
||||
check_expected_file() {
|
||||
if [ "$2" = absent ]; then
|
||||
[ ! -e "$1" ] && [ ! -L "$1" ] || fail "File appeared concurrently; retry after reviewing: $1"
|
||||
else
|
||||
[ -f "$1" ] && cmp -s "$1" "$2" || fail "File changed concurrently; retry after reviewing: $1"
|
||||
fi
|
||||
}
|
||||
replace_file() {
|
||||
destination=$1; staged=$2; file_mode=$3
|
||||
check_path "$destination" file
|
||||
check_path "$destination.conf-sshd.bak" file
|
||||
if [ "$#" -ge 4 ]; then check_expected_file "$destination" "$4"; fi
|
||||
chmod "$file_mode" "$staged"
|
||||
if [ "$dry_run" = yes ]; then say "Would atomically replace $destination (mode $file_mode)."; return; fi
|
||||
if [ -f "$destination" ]; then
|
||||
new_temp "${destination%/*}"; backup_tmp=$TEMP
|
||||
cp "$destination" "$backup_tmp"; chmod 600 "$backup_tmp"
|
||||
if [ "$#" -ge 4 ]; then check_expected_file "$destination" "$4"; fi
|
||||
mv -f "$backup_tmp" "$destination.conf-sshd.bak"
|
||||
fi
|
||||
if [ "$#" -ge 4 ]; then check_expected_file "$destination" "$4"; fi
|
||||
mv -f "$staged" "$destination"
|
||||
}
|
||||
validate_script() {
|
||||
sh -n "$1" || fail 'Downloaded script has invalid shell syntax.'
|
||||
[ "$(grep -Fxc '# conf-sshd-script-v2' "$1" || :)" = 1 ] || fail 'Downloaded script is missing its unique version marker.'
|
||||
[ "$(grep -Fxc "BASE_URL='$BASE_URL'" "$1" || :)" = 1 ] || fail 'Downloaded script changed its configured origin.'
|
||||
[ "$(grep -Fxc "DEFAULT_CRON='$DEFAULT_CRON'" "$1" || :)" = 1 ] || fail 'Downloaded script has invalid default cron configuration.'
|
||||
if grep -Eq '@@[A-Z_]+@@' "$1"; then fail 'Downloaded script contains unexpanded build tokens.'; fi
|
||||
}
|
||||
install_script() {
|
||||
validate_build
|
||||
new_temp "$state"; script_tmp=$TEMP
|
||||
# In a piped invocation $0 can name an unrelated cwd file. Always fetch the
|
||||
# configured origin, rather than guessing whether $0 is this script.
|
||||
download "$BASE_URL/script.sh" "$script_tmp"
|
||||
validate_script "$script_tmp"
|
||||
replace_file "$target_script" "$script_tmp" 700
|
||||
}
|
||||
|
||||
# Rewrite only an exact paired managed block or the exact known legacy command.
|
||||
# Unexpected cron errors and ambiguous/malformed managed entries fail closed.
|
||||
prepare_cron() {
|
||||
need crontab
|
||||
case $HOME in *%*) fail 'Cron does not safely support a percent sign in HOME.' ;; esac
|
||||
new_temp "$state"; old_cron=$TEMP
|
||||
new_temp "$state"; cron_errors=$TEMP
|
||||
if crontab -l > "$old_cron" 2> "$cron_errors"; then :
|
||||
elif grep -Eq '^no crontab for [^[:cntrl:]]+$' "$cron_errors" && [ ! -s "$old_cron" ]; then :
|
||||
else fail 'Cannot read current crontab; refusing to replace it.'; fi
|
||||
new_temp "$state"; clean_cron=$TEMP
|
||||
new_temp "$state"; saved_schedule=$TEMP
|
||||
if last_lf "$old_cron"; then cron_lastlf=1; else cron_lastlf=0; fi
|
||||
CONF_CRON_COMMAND=$cron_command CONF_LEGACY_COMMAND=$legacy_command \
|
||||
awk -v begin="$BEGIN_CRON" -v end="$END_CRON" \
|
||||
-v saved="$saved_schedule" -v lastlf="$cron_lastlf" '
|
||||
BEGIN { command=ENVIRON["CONF_CRON_COMMAND"]; legacy=ENVIRON["CONF_LEGACY_COMMAND"] }
|
||||
function extract(line, rest,i,t,s) {
|
||||
rest=line; s=""
|
||||
for(i=1;i<=5;i++) {
|
||||
sub(/^[ \t]+/,"",rest)
|
||||
if(!match(rest,/^[^ \t]+/)) return ""
|
||||
t=substr(rest,1,RLENGTH); rest=substr(rest,RLENGTH+1)
|
||||
s=s (i==1 ? "" : " ") t
|
||||
}
|
||||
sub(/^[ \t]+/,"",rest)
|
||||
return rest==command || rest==legacy ? s : ""
|
||||
}
|
||||
{ lines[NR]=$0
|
||||
if($0==begin) { if(block || began++) bad=1; block=1; drop[NR]=1; next }
|
||||
if($0==end) { if(!block || ended++) bad=1; block=0; drop[NR]=1; next }
|
||||
if($0 ~ /^[ \t]*#[ \t-]*(BEGIN|END)[ \t]+CRON MANAGED BY CONF-SSHD SCRIPT/) { bad=1; next }
|
||||
schedule=extract($0)
|
||||
if(block && schedule=="") bad=1
|
||||
if(schedule!="") { if(found++) bad=1; print schedule > saved; drop[NR]=1 }
|
||||
}
|
||||
END {
|
||||
if(block || began!=ended || (began && !found) || bad) exit 1
|
||||
for(i=1;i<=NR;i++) if(!drop[i]) { printf "%s",lines[i]; if(i<NR || lastlf) printf "\n" }
|
||||
}' "$old_cron" > "$clean_cron" || fail 'Malformed, ambiguous, or unfamiliar managed cron entry; left unchanged.'
|
||||
existing_schedule=$(cat "$saved_schedule")
|
||||
if [ -n "$existing_schedule" ]; then
|
||||
existing_schedule=$(validate_cron "$existing_schedule") || fail 'Existing managed cron expression is invalid.'
|
||||
fi
|
||||
}
|
||||
apply_cron() {
|
||||
if [ "$1" != false ]; then
|
||||
if [ -s "$clean_cron" ] && ! last_lf "$clean_cron"; then printf '\n' >> "$clean_cron"; fi
|
||||
printf '%s\n%s %s\n%s\n' "$BEGIN_CRON" "$1" "$cron_command" "$END_CRON" >> "$clean_cron"
|
||||
fi
|
||||
if cmp -s "$old_cron" "$clean_cron"; then say 'Managed cron is unchanged.'; return; fi
|
||||
if [ "$dry_run" = yes ]; then say "Would set managed cron: $1"; return; fi
|
||||
# Detect an independent crontab editor rather than overwrite concurrent edits.
|
||||
new_temp "$state"; check_cron=$TEMP
|
||||
if crontab -l > "$check_cron" 2> "$cron_errors"; then :
|
||||
elif grep -Eq '^no crontab for [^[:cntrl:]]+$' "$cron_errors" && [ ! -s "$check_cron" ]; then :
|
||||
else fail 'Cannot recheck current crontab.'; fi
|
||||
cmp -s "$old_cron" "$check_cron" || fail 'Crontab changed concurrently; retry after reviewing it.'
|
||||
crontab "$clean_cron" || fail 'Could not install crontab.'
|
||||
say "Managed cron set: $1"
|
||||
}
|
||||
|
||||
sync_keys() {
|
||||
need ssh-keygen
|
||||
validate_build
|
||||
new_temp "$state"; response=$TEMP
|
||||
download "$BASE_URL/ssh.keys" "$response"
|
||||
last_lf "$response" || fail 'Key response must end with LF.'
|
||||
new_temp "$state"; invalid_bytes=$TEMP
|
||||
tr -d '\012\040-\176\200-\377' < "$response" > "$invalid_bytes"
|
||||
[ ! -s "$invalid_bytes" ] || fail 'Key response contains forbidden control bytes.'
|
||||
new_temp "$state"; metadata=$TEMP
|
||||
awk '
|
||||
NR==1 { if($0!="# conf-sshd-v1") exit 1 }
|
||||
NR==2 { if(substr($0,1,11)!="# revision " || length($0)!=51 || substr($0,12) ~ /[^0-9a-fA-F]/) exit 1; print substr($0,12) }
|
||||
NR==3 { if($0 !~ /^# key-count (0|[1-9][0-9]*)$/) exit 1; n=substr($0,13); if(n+0>10000) exit 1; print n }
|
||||
NR==4 { if($0!="# allow-empty yes" && $0!="# allow-empty no") exit 1; print substr($0,15) }
|
||||
NR==5 { if(substr($0,1,9)!="# sha256 " || length($0)!=73 || substr($0,10) ~ /[^0-9a-f]/) exit 1; print substr($0,10) }
|
||||
NR==6 { if($0!="# end-metadata") exit 1 }
|
||||
END { if(NR<6) exit 1 }' "$response" > "$metadata" || fail 'Invalid key-response metadata.'
|
||||
revision=$(sed -n '1p' "$metadata")
|
||||
key_count=$(sed -n '2p' "$metadata")
|
||||
allow_empty=$(sed -n '3p' "$metadata")
|
||||
expected_hash=$(sed -n '4p' "$metadata")
|
||||
new_temp "$state"; payload=$TEMP
|
||||
tail -n +7 "$response" > "$payload"
|
||||
[ "$(sha256 "$payload")" = "$expected_hash" ] || fail 'Payload SHA-256 mismatch.'
|
||||
[ "$(wc -l < "$payload" | tr -d ' ')" = "$key_count" ] || fail 'Payload key count mismatch.'
|
||||
if [ "$key_count" = 0 ]; then [ "$allow_empty" = yes ] || fail 'Empty key set was not explicitly allowed.'; fi
|
||||
awk '
|
||||
{
|
||||
if($0 ~ /[ \t]$/ || $0 !~ /^[^ ]+ [^ ]+( [^ ].*)?$/ || NF<2) exit 1
|
||||
if($1!="ssh-ed25519" && $1!="ssh-rsa" && $1!="ecdsa-sha2-nistp256" && $1!="ecdsa-sha2-nistp384" && $1!="ecdsa-sha2-nistp521" && $1!="[email protected]" && $1!="[email protected]") exit 1
|
||||
if($2 !~ /^[A-Za-z0-9+\/]+={0,2}$/) {
|
||||
# POSIX awk implementations without interval-expression support.
|
||||
blob=$2; sub(/==?$/,"",blob); if(blob=="" || blob ~ /[^A-Za-z0-9+\/]/) exit 1
|
||||
}
|
||||
if(length($2)%4!=0 || seen[$1 " " $2]++) exit 1
|
||||
if($2 ~ /==$/ && substr($2,length($2)-2,1) !~ /^[AQgw]$/) exit 1
|
||||
if($2 ~ /[^=]=$/ && substr($2,length($2)-1,1) !~ /^[AEIMQUYcgkosw048]$/) exit 1
|
||||
}' "$payload" || fail 'Invalid, duplicate, unsupported, or noncanonical public key line.'
|
||||
new_temp "$state"; one_key=$TEMP
|
||||
new_temp "$state"; key_info=$TEMP
|
||||
while IFS= read -r key_line; do
|
||||
printf '%s\n' "$key_line" > "$one_key"
|
||||
ssh-keygen -l -f "$one_key" > "$key_info" 2>/dev/null || fail 'ssh-keygen rejected a public key.'
|
||||
case $key_line in ssh-rsa\ *)
|
||||
bits=$(awk 'NR==1 {print $1}' "$key_info")
|
||||
case $bits in ''|*[!0-9]*) fail 'Cannot determine RSA key size.' ;; esac
|
||||
[ "$bits" -ge 2048 ] || fail 'RSA keys must be at least 2048 bits.' ;;
|
||||
esac
|
||||
done < "$payload"
|
||||
[ -d "$ssh_dir" ] || { mkdir "$ssh_dir"; created_ssh=yes; }
|
||||
# Existing secure directory permissions need not be changed in dry-run mode.
|
||||
if [ "$dry_run" = no ]; then chmod 700 "$ssh_dir"; fi
|
||||
check_path "$auth_file" file
|
||||
new_temp "$ssh_dir"; new_auth=$TEMP
|
||||
auth_snapshot=absent
|
||||
if [ -f "$auth_file" ]; then
|
||||
new_temp "$state"; auth_snapshot=$TEMP
|
||||
cp "$auth_file" "$auth_snapshot"
|
||||
|
||||
new_temp "$state"; bounds=$TEMP
|
||||
bytes=$(wc -c < "$auth_snapshot" | tr -d ' ')
|
||||
awk -v begin="$BEGIN_KEYS" -v end="$END_KEYS" -v size="$bytes" '
|
||||
{ start=offset+0; offset+=length($0)+1
|
||||
if($0==begin) { if(inblock || began++) bad=1; inblock=1; first=start }
|
||||
else if($0==end) { if(!inblock || ended++) bad=1; inblock=0; last=offset }
|
||||
else if($0 ~ /^[ \t]*#[ \t-]*(BEGIN|END)[ \t]+MANAGED BY CONF-SSHD SCRIPT/) bad=1
|
||||
}
|
||||
END { if(inblock || began!=ended || bad) exit 1
|
||||
if(began) print first " " (last>size ? size : last); else print "absent" }
|
||||
' "$auth_snapshot" > "$bounds" || fail 'Malformed or duplicate authorized_keys managed markers; left unchanged.'
|
||||
positions=$(cat "$bounds")
|
||||
if [ "$positions" = absent ]; then
|
||||
cat "$auth_snapshot" > "$new_auth"
|
||||
if [ -s "$new_auth" ] && ! last_lf "$new_auth"; then printf '\n' >> "$new_auth"; fi
|
||||
printf '%s\n' "$BEGIN_KEYS" >> "$new_auth"; cat "$payload" >> "$new_auth"; printf '%s\n' "$END_KEYS" >> "$new_auth"
|
||||
else
|
||||
# Validated awk output: two decimal offsets.
|
||||
# shellcheck disable=SC2086
|
||||
set -- $positions
|
||||
dd if="$auth_snapshot" bs=1 count="$1" 2>/dev/null > "$new_auth"
|
||||
printf '%s\n' "$BEGIN_KEYS" >> "$new_auth"; cat "$payload" >> "$new_auth"; printf '%s\n' "$END_KEYS" >> "$new_auth"
|
||||
dd if="$auth_snapshot" bs=1 skip="$2" 2>/dev/null >> "$new_auth"
|
||||
fi
|
||||
else
|
||||
printf '%s\n' "$BEGIN_KEYS" > "$new_auth"; cat "$payload" >> "$new_auth"; printf '%s\n' "$END_KEYS" >> "$new_auth"
|
||||
fi
|
||||
check_path "$auth_file" file
|
||||
check_expected_file "$auth_file" "$auth_snapshot"
|
||||
if [ "$auth_snapshot" != absent ] && cmp -s "$auth_snapshot" "$new_auth"; then
|
||||
if [ "$dry_run" = no ]; then chmod 600 "$auth_file"; fi
|
||||
say "Keys unchanged ($key_count keys, revision $revision)."
|
||||
else
|
||||
replace_file "$auth_file" "$new_auth" 600 "$auth_snapshot"
|
||||
say "Validated $key_count keys at revision $revision."
|
||||
fi
|
||||
}
|
||||
|
||||
edit_sshd_config() {
|
||||
[ "$uid" = 0 ] || fail '-p requires root.'
|
||||
need sshd
|
||||
config=/etc/ssh/sshd_config
|
||||
check_path /etc/ssh dir
|
||||
check_path "$config" file
|
||||
[ -f "$config" ] || fail 'sshd_config does not exist; install/configure sshd yourself first.'
|
||||
new_temp /etc/ssh; config_snapshot=$TEMP
|
||||
cp "$config" "$config_snapshot"
|
||||
# Editing a global directive cannot reliably predict Include/Match overrides.
|
||||
if awk '
|
||||
{ name=$0; sub(/^[[:space:]]*/,"",name)
|
||||
if(substr(name,1,1)=="\"") { name=substr(name,2); sub(/\".*$/,"",name) }
|
||||
else sub(/[[:space:]=].*$/,"",name)
|
||||
name=tolower(name); if(name=="include" || name=="match") { found=1; exit }
|
||||
}
|
||||
END { exit !found }
|
||||
' "$config_snapshot"; then
|
||||
fail 'sshd_config uses Include or Match. Review and change root-login policy manually; nothing changed.'
|
||||
fi
|
||||
sshd -t -f "$config" || fail 'Existing sshd_config is invalid.'
|
||||
new_temp /etc/ssh; config_tmp=$TEMP
|
||||
if [ "$root_password" = yes ]; then setting=yes; else setting=prohibit-password; fi
|
||||
awk -v value="$setting" '
|
||||
BEGIN { print "PermitRootLogin " value }
|
||||
{ name=$0; sub(/^[[:space:]]*/,"",name)
|
||||
if(substr(name,1,1)=="\"") { name=substr(name,2); sub(/\".*$/,"",name) }
|
||||
else sub(/[[:space:]=].*$/,"",name)
|
||||
if(tolower(name)!="permitrootlogin") print
|
||||
}
|
||||
' "$config_snapshot" > "$config_tmp"
|
||||
sshd -t -f "$config_tmp" || fail 'Staged sshd_config failed validation.'
|
||||
new_temp "$state"; effective=$TEMP
|
||||
sshd -T -f "$config_tmp" > "$effective" || fail 'Cannot evaluate staged sshd_config.'
|
||||
actual=$(awk '$1=="permitrootlogin" {print $2}' "$effective")
|
||||
case "$setting:$actual" in yes:yes|prohibit-password:prohibit-password|prohibit-password:without-password) : ;; *) fail 'Effective root-login policy does not match the requested setting.' ;; esac
|
||||
replace_file "$config" "$config_tmp" 600 "$config_snapshot"
|
||||
say 'Root-login policy validated. No service reload was performed; test recovery access before applying it.'
|
||||
[ "$root_password" != yes ] || say 'PermitRootLogin yes does not enable PasswordAuthentication if it is disabled elsewhere.'
|
||||
}
|
||||
|
||||
case $mode in
|
||||
update)
|
||||
validate_build
|
||||
[ -f "$target_script" ] || fail 'No installed script. Install scheduling with -c first.'
|
||||
new_temp "$state"; updated_script=$TEMP
|
||||
download "$BASE_URL/script.sh" "$updated_script"
|
||||
validate_script "$updated_script"
|
||||
replace_file "$target_script" "$updated_script" 700
|
||||
say 'Script update validated. Keys, cron and sshd configuration were not changed.' ;;
|
||||
uninstall)
|
||||
if command -v crontab >/dev/null 2>&1; then prepare_cron; apply_cron false; fi
|
||||
for uninstall_file in "$target_script" "$target_script.conf-sshd.bak"; do
|
||||
check_path "$uninstall_file" file
|
||||
if [ "$dry_run" = yes ]; then say "Would remove $uninstall_file"; else rm -f "$uninstall_file"; fi
|
||||
done
|
||||
say 'Uninstalled managed scheduling/script. Authorized keys and sshd configuration are preserved.' ;;
|
||||
config) edit_sshd_config ;;
|
||||
sync|keys)
|
||||
if [ "$cron_requested" = yes ]; then
|
||||
prepare_cron
|
||||
if [ "$cron_value" = false ]; then apply_cron false; exit 0; fi
|
||||
validate_build
|
||||
if [ -z "$cron_value" ]; then cron_value=${existing_schedule:-$DEFAULT_CRON}; fi
|
||||
fi
|
||||
sync_keys
|
||||
if [ "$cron_requested" = yes ]; then install_script; apply_cron "$cron_value"; fi ;;
|
||||
esac
|
||||
|
||||
Reference in New Issue
Block a user