mirror of
https://github.com/LamGC/quickly-conf-sshd.git
synced 2026-10-12 23:33:56 +00:00
508 lines
24 KiB
Bash
Executable File
508 lines
24 KiB
Bash
Executable File
#!/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
|
|
|
|
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 the configured default.
|
|
-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
|
|
|
|
-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
|
|
}
|
|
select_mode() {
|
|
[ "$mode" = sync ] || fail 'Conflicting or duplicate operation options.'
|
|
mode=$1
|
|
}
|
|
while [ "$#" -gt 0 ]; do
|
|
case $1 in
|
|
-h|--help)
|
|
if [ "$#" -ne 1 ] || [ "$mode" != sync ] || [ "$cron_requested" != no ]; then
|
|
fail '--help must be used alone.'
|
|
fi
|
|
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.'
|
|
|
|
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
|
|
|
|
# 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
|
|
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
|
|
|
|
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
|
|
|
|
[ -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
|
|
if [ -e "$1" ] || [ -L "$1" ]; then
|
|
fail "File appeared concurrently; retry after reviewing: $1"
|
|
fi
|
|
else
|
|
if [ ! -f "$1" ] || ! cmp -s "$1" "$2"; then
|
|
fail "File changed concurrently; retry after reviewing: $1"
|
|
fi
|
|
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"
|
|
cat "$payload"
|
|
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"
|
|
cat "$payload"
|
|
printf '%s\n' "$END_KEYS"
|
|
dd if="$auth_snapshot" bs=1 skip="$2" 2>/dev/null
|
|
} >> "$new_auth"
|
|
fi
|
|
else
|
|
{
|
|
printf '%s\n' "$BEGIN_KEYS"
|
|
cat "$payload"
|
|
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
|