- docs: remove the docs/README.md index and docs/CONTRIBUTING.md pointer (duplicate filenames); the canonical contributing guide stays at docs/contributing/contributing.md. Clean tree, no name collisions. - scripts/system/*: 6 helper headers + host_access.sh said the helpers install to /usr/local/sbin, but init.sh installs all of them to /usr/local/lib/libreportal/ (verified via initRootHelpers + the sudoers Cmnd_Alias). Corrected. The only remaining /usr/local/sbin is the legit PATH export in the task processor. - frontend kernel: drop migration-era comments that are now false post- modularization (feature-registry 'passive/phase 0/unused', lifecycle 'ctx.services lands with Phase 2', manifest 'scan generator lands') — describe current behaviour instead. Comment-only edits to scripts/system/* — no footprint_version bump (no behavioural change; bumping would force needless reinstalls). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
130 lines
4.8 KiB
Bash
130 lines
4.8 KiB
Bash
#!/bin/bash
|
|
# LibrePortal host-SSH-access helper — the only root-privileged management of the
|
|
# admin's authorized_keys and sshd PasswordAuthentication the manager may trigger.
|
|
# Installed root:root 0755 to /usr/local/lib/libreportal/ by init.sh. Self-contained (sources
|
|
# no manager code) so the scoped sudoers can allow it instead of blanket
|
|
# `sudo tee`/`sudo sed`/`sudo cp` on /etc/ssh + the admin's ~/.ssh (root). The
|
|
# lockout guards live HERE, in the trust boundary, so a compromised manager can't
|
|
# bypass them by editing the calling script.
|
|
|
|
set -u
|
|
|
|
[[ $EUID -eq 0 ]] || { echo "libreportal-ssh-access: must run as root" >&2; exit 1; }
|
|
|
|
# Baked by init.sh at install (placeholder replaced); default if run unbaked.
|
|
MANAGER="__MANAGER__"
|
|
[[ "$MANAGER" == "__MANAGER__" || -z "$MANAGER" ]] && MANAGER="libreportal"
|
|
|
|
if [[ "$MANAGER" == "root" ]]; then SSH_HOME="/root"; else SSH_HOME="/home/$MANAGER"; fi
|
|
SSH_DIR="$SSH_HOME/.ssh"
|
|
AKF="$SSH_DIR/authorized_keys"
|
|
SSHD_CONFIG="/etc/ssh/sshd_config"
|
|
|
|
_ensure_dir() {
|
|
mkdir -p "$SSH_DIR"
|
|
touch "$AKF"
|
|
chmod 700 "$SSH_DIR"
|
|
chmod 600 "$AKF"
|
|
chown -R "$MANAGER:$MANAGER" "$SSH_DIR"
|
|
}
|
|
|
|
_key_count() {
|
|
[[ -f "$AKF" ]] || { echo 0; return; }
|
|
grep -cvE '^[[:space:]]*($|#)' "$AKF" 2>/dev/null || echo 0
|
|
}
|
|
|
|
# 0 = password auth enabled (or unspecified default), 1 = disabled.
|
|
_pw_enabled() {
|
|
local v
|
|
v=$(sshd -T 2>/dev/null | awk '/^passwordauthentication/ {print $2}')
|
|
[[ -z "$v" ]] && v=$(grep -iE '^[[:space:]]*PasswordAuthentication' "$SSHD_CONFIG" 2>/dev/null | tail -1 | awk '{print tolower($2)}')
|
|
[[ "$v" == "no" ]] && return 1
|
|
return 0
|
|
}
|
|
|
|
key_add() {
|
|
local key_b64="$1"
|
|
[[ -z "$key_b64" ]] && { echo "key-add requires <base64-public-key>" >&2; return 1; }
|
|
local pub
|
|
pub=$(printf '%s' "$key_b64" | base64 -d 2>/dev/null | tr -d '\r' | grep -vE '^[[:space:]]*$' | head -1)
|
|
[[ -z "$pub" ]] && { echo "empty key after decode" >&2; return 1; }
|
|
if ! printf '%s\n' "$pub" | ssh-keygen -l -f - >/dev/null 2>&1; then
|
|
echo "not a valid SSH public key" >&2; return 1
|
|
fi
|
|
_ensure_dir
|
|
local body; body=$(awk '{print $2}' <<< "$pub")
|
|
if grep -qF "$body" "$AKF" 2>/dev/null; then
|
|
echo "already-authorized"
|
|
else
|
|
printf '%s\n' "$pub" >> "$AKF"
|
|
echo "added"
|
|
fi
|
|
chown "$MANAGER:$MANAGER" "$AKF"
|
|
chmod 600 "$AKF"
|
|
}
|
|
|
|
key_remove() {
|
|
local fp="$1"
|
|
[[ -z "$fp" ]] && { echo "key-remove requires <fingerprint>" >&2; return 1; }
|
|
[[ "$fp" =~ ^[A-Za-z0-9:+/=._-]+$ ]] || { echo "invalid fingerprint" >&2; return 1; }
|
|
[[ -f "$AKF" ]] || { echo "no authorized_keys file" >&2; return 1; }
|
|
# Lockout guard (in the trust boundary): never remove the last key while
|
|
# password login is off.
|
|
if ! _pw_enabled && [[ "$(_key_count)" -le 1 ]]; then
|
|
echo "refuse-last-key" >&2; return 2
|
|
fi
|
|
local tmp removed=0 line lfp
|
|
tmp=$(mktemp)
|
|
while IFS= read -r line; do
|
|
if [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]]; then
|
|
printf '%s\n' "$line" >> "$tmp"; continue
|
|
fi
|
|
lfp=$(printf '%s\n' "$line" | ssh-keygen -l -f - 2>/dev/null | awk '{print $2}')
|
|
if [[ -n "$lfp" && "$lfp" == "$fp" ]]; then removed=1; continue; fi
|
|
printf '%s\n' "$line" >> "$tmp"
|
|
done < "$AKF"
|
|
if [[ "$removed" -eq 1 ]]; then
|
|
cp "$tmp" "$AKF"
|
|
chown "$MANAGER:$MANAGER" "$AKF"
|
|
chmod 600 "$AKF"
|
|
echo "removed"
|
|
else
|
|
echo "no-match"
|
|
fi
|
|
rm -f "$tmp"
|
|
}
|
|
|
|
pw_set() {
|
|
local want="$1"
|
|
case "$want" in on|off) ;; *) echo "pw-set requires on|off" >&2; return 1 ;; esac
|
|
# Lockout guard: don't disable password login with no keys.
|
|
if [[ "$want" == "off" && "$(_key_count)" -lt 1 ]]; then
|
|
echo "refuse-no-keys" >&2; return 2
|
|
fi
|
|
local value="yes"; [[ "$want" == "off" ]] && value="no"
|
|
local backup="${SSHD_CONFIG}.libreportal.$(date +%s)"
|
|
cp "$SSHD_CONFIG" "$backup"
|
|
sed -i '/^[[:space:]]*#\?[[:space:]]*PasswordAuthentication\b/d' "$SSHD_CONFIG"
|
|
printf 'PasswordAuthentication %s\n' "$value" >> "$SSHD_CONFIG"
|
|
if ! sshd -t 2>/dev/null; then
|
|
cp "$backup" "$SSHD_CONFIG"
|
|
echo "sshd-test-failed" >&2; return 1
|
|
fi
|
|
systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null
|
|
echo "set:$want backup:$backup"
|
|
}
|
|
|
|
action="${1:-}"; shift 2>/dev/null || true
|
|
case "$action" in
|
|
ensure-dir) _ensure_dir ;;
|
|
key-count) _key_count ;;
|
|
pw-status) if _pw_enabled; then echo on; else echo off; fi ;;
|
|
has-keys) [[ -f "$AKF" ]] ;;
|
|
read-keys) [[ -f "$AKF" ]] && cat "$AKF" ;;
|
|
authkeys-path) printf '%s\n' "$AKF" ;;
|
|
key-add) key_add "${1:-}" ;;
|
|
key-remove) key_remove "${1:-}" ;;
|
|
pw-set) pw_set "${1:-}" ;;
|
|
*) echo "usage: libreportal-ssh-access {ensure-dir|key-count|pw-status|has-keys|read-keys|authkeys-path|key-add <b64>|key-remove <fp>|pw-set <on|off>}" >&2; exit 2 ;;
|
|
esac
|