feat(desudo): root-owned DNS + host-SSH-access helpers

Two more runtime root file-primitive subsystems moved behind self-
validating root-owned helpers so the scoped sudoers needn't grant blanket
sudo sed/tee/cp on /etc (which is root-equivalent — sudo arg wildcards
match across '/', so even path-scoped entries are bypassable):

- scripts/system/libreportal-dns: {clear|add <ip>} — edits /etc/resolv.conf
  only, validates the IP argument
- scripts/system/libreportal-ssh-access: authorized_keys + sshd
  PasswordAuthentication management, with the lockout guards moved INTO the
  helper (the trust boundary) so a compromised manager can't bypass them
- run_privileged: _runRootHelper dispatcher + runResolv / runSshAccess
  (runOwnership now uses it too)
- init.sh: initRootHelpers installs all three helpers root:root 0755 with
  the manager name baked in
- setup_dns -> runResolv (+ ping de-sudo'd, works unprivileged); host_access
  + webui_ssh_access -> runSshAccess

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-05-24 18:21:46 +01:00
parent dd414a6e73
commit d17e8814d0
7 changed files with 278 additions and 135 deletions

34
init.sh
View File

@ -706,33 +706,37 @@ initUsers()
fi fi
rm -f "$sudoers_tmp" rm -f "$sudoers_tmp"
initOwnershipHelper initRootHelpers
} }
# Install the root-owned ownership-reconcile helper. Under Model A the runtime # Install the root-owned privilege helpers. Under Model A the runtime runs AS the
# runs AS the manager, so establishing the /docker ownership model needs root — # manager, so the genuine-root operations it can't drop (the /docker ownership
# but granting the manager a blanket `sudo chown`/`sudo chmod` would be # model, /etc/resolv.conf edits, host SSH access) need root — but granting the
# root-equivalent. This helper does a FIXED set of reconciles on FIXED paths; it # manager blanket `sudo chown/chmod/tee/sed/cp` would be root-equivalent. Each
# lives root:root 0755 where the manager can't edit it, so the scoped sudoers can # helper does a FIXED, self-validated set of operations and lives root:root 0755
# allow it wholesale. The manager name is baked in here (manager can't change it). # where the manager can't edit it, so the scoped sudoers can allow each wholesale.
initOwnershipHelper() # The manager name is baked in here (the manager can't change it); the sed is a
# no-op on helpers without the placeholder.
initRootHelpers()
{ {
local helper_src="$script_dir/scripts/system/libreportal-ownership" local helper helper_src helper_dst helper_tmp
local helper_dst="/usr/local/sbin/libreportal-ownership" for helper in libreportal-ownership libreportal-dns libreportal-ssh-access; do
helper_src="$script_dir/scripts/system/$helper"
helper_dst="/usr/local/sbin/$helper"
if [[ ! -f "$helper_src" ]]; then if [[ ! -f "$helper_src" ]]; then
isError "Ownership helper source missing ($helper_src) — skipping install." isError "Root helper source missing ($helper_src) — skipping."
return 1 continue
fi fi
local helper_tmp
helper_tmp=$(mktemp) helper_tmp=$(mktemp)
sed "s/__MANAGER__/${sudo_user_name}/g" "$helper_src" > "$helper_tmp" sed "s/__MANAGER__/${sudo_user_name}/g" "$helper_src" > "$helper_tmp"
if bash -n "$helper_tmp" 2>/dev/null; then if bash -n "$helper_tmp" 2>/dev/null; then
sudo install -m 0755 -o root -g root "$helper_tmp" "$helper_dst" sudo install -m 0755 -o root -g root "$helper_tmp" "$helper_dst"
isSuccessful "Installed root-owned ownership helper ($helper_dst)." isSuccessful "Installed root-owned helper ($helper_dst)."
else else
isError "Refusing to install a malformed ownership helper." isError "Refusing to install a malformed root helper ($helper)."
fi fi
rm -f "$helper_tmp" rm -f "$helper_tmp"
done
} }
initFolders() initFolders()

View File

@ -86,28 +86,39 @@ runBackupOp() {
sudo -E -u "$docker_install_user" "$@" sudo -E -u "$docker_install_user" "$@"
} }
# Trigger a fixed ownership reconcile through the ROOT-OWNED helper installed at # Run one of the ROOT-OWNED LibrePortal helpers installed (root:root 0755) under
# /usr/local/sbin/libreportal-ownership. This is how the manager-run runtime # /usr/local/sbin by init.sh. These are how the manager-run runtime (Model A)
# (Model A) establishes the ownership model — manager owns the control plane, the # performs the genuine-root operations it can't drop — establishing the /docker
# docker install user owns the containers — without the scoped sudoers having to # ownership model, editing /etc/resolv.conf, managing host SSH access — WITHOUT
# grant a blanket `sudo chown`/`sudo chmod` (which would be root-equivalent: chown # the scoped sudoers granting blanket `sudo chown/chmod/tee/sed/cp` (which would
# /etc/sudoers and so on). The helper validates its own (fixed-path) operations, # be root-equivalent: chown /etc/sudoers, tee a new sudoers drop-in, …). Each
# so the sudoers can allow it wholesale. # helper validates its own fixed-path operations, so the sudoers can allow it
# action ∈ {reconcile [mode]|traversal|containers-top|app-perms|webui|taskdir| # wholesale. At install time (already root) the installed helper may be absent, so
# app-data-nobody <app>} # run the bundled copy directly — no sudo, no escalation, since we are root.
# At install time (already root) the helper may not be installed yet, so run the _runRootHelper() {
# bundled copy directly — no sudo, no escalation, since we are root already. local name="$1"; shift
runOwnership() { local helper="/usr/local/sbin/$name"
local helper="/usr/local/sbin/libreportal-ownership"
if [[ -x "$helper" ]]; then if [[ -x "$helper" ]]; then
sudo "$helper" "$@" sudo "$helper" "$@"
elif [[ $EUID -eq 0 ]]; then elif [[ $EUID -eq 0 ]]; then
bash "${script_dir:-/docker/install}/scripts/system/libreportal-ownership" "$@" bash "${script_dir:-/docker/install}/scripts/system/$name" "$@"
else else
sudo "$helper" "$@" sudo "$helper" "$@"
fi fi
} }
# Ownership reconcile: action ∈ {reconcile [mode]|traversal|containers-top|
# app-perms|webui|taskdir|app-data-nobody <app>}
runOwnership() { _runRootHelper libreportal-ownership "$@"; }
# /etc/resolv.conf edits: {clear|add <ip>}
runResolv() { _runRootHelper libreportal-dns "$@"; }
# Host SSH access (authorized_keys + sshd PasswordAuthentication):
# {ensure-dir|key-count|pw-status|has-keys|read-keys|authkeys-path|
# key-add <b64>|key-remove <fp>|pw-set <on|off>}
runSshAccess() { _runRootHelper libreportal-ssh-access "$@"; }
# Genuine system-administration command (ufw/systemctl/apt/sysctl/useradd, /etc # Genuine system-administration command (ufw/systemctl/apt/sysctl/useradd, /etc
# edits). Needs real root in both modes; funnelled through one place so it can # edits). Needs real root in both modes; funnelled through one place so it can
# later be confined to a scoped sudoers allowlist. # later be confined to a scoped sudoers allowlist.

View File

@ -8,14 +8,14 @@ updateDNS()
if [[ "$OS_TYPE" == "Ubuntu" || "$OS_TYPE" == "Debian" ]]; then if [[ "$OS_TYPE" == "Ubuntu" || "$OS_TYPE" == "Debian" ]]; then
dnsRemoveNameservers() dnsRemoveNameservers()
{ {
result=$(runSystem sed -i '/^nameserver/d' /etc/resolv.conf) result=$(runResolv clear)
checkSuccess "Removing all instances of Nameserver from Resolv.conf" checkSuccess "Removing all instances of Nameserver from Resolv.conf"
} }
if [[ "$flag" == "standalonewireguard" ]]; then if [[ "$flag" == "standalonewireguard" ]]; then
dnsRemoveNameservers; dnsRemoveNameservers;
echo "nameserver $CFG_DNS_SERVER_1" | runSystem tee -a /etc/resolv.conf > /dev/null runResolv add "$CFG_DNS_SERVER_1"
echo "nameserver $CFG_DNS_SERVER_2" | runSystem tee -a /etc/resolv.conf > /dev/null runResolv add "$CFG_DNS_SERVER_2"
else else
# Check if AdGuard is installed # Check if AdGuard is installed
local status=$(dockerCheckAppInstalled "adguard" "docker") local status=$(dockerCheckAppInstalled "adguard" "docker")
@ -23,7 +23,7 @@ updateDNS()
setupDNSIP adguard; setupDNSIP adguard;
local adguard_ip="$dns_ip_setup" local adguard_ip="$dns_ip_setup"
# Testing Docker IP Address # Testing Docker IP Address
result=$(runSystem ping -c 1 $adguard_ip) result=$(ping -c 1 $adguard_ip)
if [ $? -eq 0 ]; then if [ $? -eq 0 ]; then
isSuccessful "Ping to $adguard_ip was successful." isSuccessful "Ping to $adguard_ip was successful."
else else
@ -31,7 +31,7 @@ updateDNS()
isNotice "Defaulting to DNS 1 Server $CFG_DNS_SERVER_1." isNotice "Defaulting to DNS 1 Server $CFG_DNS_SERVER_1."
local adguard_ip="$CFG_DNS_SERVER_1" local adguard_ip="$CFG_DNS_SERVER_1"
# Fallback to Quad9 if DNS has issues # Fallback to Quad9 if DNS has issues
result=$(runSystem ping -c 1 $adguard_ip) result=$(ping -c 1 $adguard_ip)
if [ $? -eq 0 ]; then if [ $? -eq 0 ]; then
isSuccessful "Ping to $adguard_ip was successful." isSuccessful "Ping to $adguard_ip was successful."
else else
@ -43,7 +43,7 @@ updateDNS()
else else
local adguard_ip="$CFG_DNS_SERVER_1" local adguard_ip="$CFG_DNS_SERVER_1"
# Fallback to Quad9 if DNS has issues # Fallback to Quad9 if DNS has issues
result=$(runSystem ping -c 1 $adguard_ip) result=$(ping -c 1 $adguard_ip)
if [ $? -eq 0 ]; then if [ $? -eq 0 ]; then
isSuccessful "Ping to $adguard_ip was successful." isSuccessful "Ping to $adguard_ip was successful."
else else
@ -59,7 +59,7 @@ updateDNS()
setupDNSIP pihole; setupDNSIP pihole;
local pihole_ip="$dns_ip_setup" local pihole_ip="$dns_ip_setup"
# Testing Docker IP Address # Testing Docker IP Address
result=$(runSystem ping -c 1 $pihole_ip) result=$(ping -c 1 $pihole_ip)
if [ $? -eq 0 ]; then if [ $? -eq 0 ]; then
isSuccessful "Ping to $pihole_ip was successful." isSuccessful "Ping to $pihole_ip was successful."
else else
@ -67,7 +67,7 @@ updateDNS()
isNotice "Defaulting to DNS 2 Server $CFG_DNS_SERVER_2." isNotice "Defaulting to DNS 2 Server $CFG_DNS_SERVER_2."
local pihole_ip="$CFG_DNS_SERVER_2" local pihole_ip="$CFG_DNS_SERVER_2"
# Fallback to Quad9 if DNS has issues # Fallback to Quad9 if DNS has issues
result=$(runSystem ping -c 1 $pihole_ip) result=$(ping -c 1 $pihole_ip)
if [ $? -eq 0 ]; then if [ $? -eq 0 ]; then
isSuccessful "Ping to $pihole_ip was successful." isSuccessful "Ping to $pihole_ip was successful."
else else
@ -102,8 +102,8 @@ updateDNS()
checkSuccess "Updated Wireguard default DNS to $adguard_ip" checkSuccess "Updated Wireguard default DNS to $adguard_ip"
fi fi
dnsRemoveNameservers; dnsRemoveNameservers;
echo "nameserver $adguard_ip" | runSystem tee -a /etc/resolv.conf > /dev/null runResolv add "$adguard_ip"
echo "nameserver $pihole_ip" | runSystem tee -a /etc/resolv.conf > /dev/null runResolv add "$pihole_ip"
elif [[ "$pihole_ip" == *10.100.0* ]]; then elif [[ "$pihole_ip" == *10.100.0* ]]; then
# Wireguard update # Wireguard update
local status=$(dockerCheckAppInstalled "wireguard" "docker") local status=$(dockerCheckAppInstalled "wireguard" "docker")
@ -118,8 +118,8 @@ updateDNS()
checkSuccess "Updated Wireguard default DNS to $pihole_ip" checkSuccess "Updated Wireguard default DNS to $pihole_ip"
fi fi
dnsRemoveNameservers; dnsRemoveNameservers;
echo "nameserver $pihole_ip" | runSystem tee -a /etc/resolv.conf > /dev/null runResolv add "$pihole_ip"
echo "nameserver $adguard_ip" | runSystem tee -a /etc/resolv.conf > /dev/null runResolv add "$adguard_ip"
else else
# Wireguard update # Wireguard update
local status=$(dockerCheckAppInstalled "wireguard" "docker") local status=$(dockerCheckAppInstalled "wireguard" "docker")
@ -134,8 +134,8 @@ updateDNS()
checkSuccess "Updated Wireguard default DNS to $adguard_ip" checkSuccess "Updated Wireguard default DNS to $adguard_ip"
fi fi
dnsRemoveNameservers; dnsRemoveNameservers;
echo "nameserver $adguard_ip" | runSystem tee -a /etc/resolv.conf > /dev/null runResolv add "$adguard_ip"
echo "nameserver $pihole_ip" | runSystem tee -a /etc/resolv.conf > /dev/null runResolv add "$pihole_ip"
fi fi
if [ "$flag" == "install" ]; then if [ "$flag" == "install" ]; then
initializeAppVariables $app_name; initializeAppVariables $app_name;

View File

@ -5,6 +5,11 @@
# password authentication. Everything here is on-demand only: nothing runs # password authentication. Everything here is on-demand only: nothing runs
# during install or deploy. LibrePortal is the *server* here, so the admin # during install or deploy. LibrePortal is the *server* here, so the admin
# brings their own public key; we never handle their private key. # brings their own public key; we never handle their private key.
#
# All the privileged work (editing ~/.ssh and /etc/ssh/sshd_config) lives in the
# root-owned helper /usr/local/sbin/libreportal-ssh-access (runSshAccess), which
# also enforces the lockout guards in the trust boundary. These functions are the
# manager-side CLI/WebUI front for it: they shape arguments and print the UX.
# The user admins actually log in as (has sudo). Falls back to libreportal. # The user admins actually log in as (has sudo). Falls back to libreportal.
hostSshUser() hostSshUser()
@ -33,33 +38,19 @@ hostSshRefreshUi()
hostSshEnsureDir() hostSshEnsureDir()
{ {
local u sshdir akf runSshAccess ensure-dir
u=$(hostSshUser)
sshdir="$(hostSshHome)/.ssh"
akf=$(hostSshAuthKeysFile)
runSystem mkdir -p "$sshdir"
runSystem touch "$akf"
runSystem chmod 700 "$sshdir"
runSystem chmod 600 "$akf"
runSystem chown -R "$u":"$u" "$sshdir"
} }
# Count valid authorized public keys. # Count valid authorized public keys.
hostSshKeyCount() hostSshKeyCount()
{ {
local akf; akf=$(hostSshAuthKeysFile) runSshAccess key-count 2>/dev/null || echo 0
runSystem test -f "$akf" || { echo 0; return; }
runSystem grep -cvE '^[[:space:]]*($|#)' "$akf" 2>/dev/null || echo 0
} }
# True when sshd currently allows password authentication. # True when sshd currently allows password authentication.
hostSshPasswordAuthEnabled() hostSshPasswordAuthEnabled()
{ {
local v [[ "$(runSshAccess pw-status 2>/dev/null)" != "off" ]]
v=$(runSystem 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 # default-on when unspecified
} }
# Add a base64-encoded PUBLIC key to the install user's authorized_keys. # Add a base64-encoded PUBLIC key to the install user's authorized_keys.
@ -68,71 +59,43 @@ hostSshKeyAdd()
local key_b64="$1" local key_b64="$1"
[[ -z "$key_b64" ]] && { isError "hostSshKeyAdd requires <base64-public-key>"; return 1; } [[ -z "$key_b64" ]] && { isError "hostSshKeyAdd requires <base64-public-key>"; return 1; }
local pub local out rc
pub=$(echo "$key_b64" | base64 -d 2>/dev/null | tr -d '\r' | grep -vE '^[[:space:]]*$' | head -1) out=$(runSshAccess key-add "$key_b64")
[[ -z "$pub" ]] && { isError "Empty key after decode"; return 1; } rc=$?
case "$out" in
if ! printf '%s\n' "$pub" | ssh-keygen -l -f - >/dev/null 2>&1; then added) isSuccessful "SSH key authorized for $(hostSshUser)" ;;
isError "Not a valid SSH public key (expected e.g. 'ssh-ed25519 AAAA... comment')" already-authorized) isNotice "That key is already authorized." ;;
return 1 *)
fi [[ $rc -ne 0 ]] && { isError "Could not add key (not a valid SSH public key?)"; return 1; }
;;
hostSshEnsureDir esac
local akf body
akf=$(hostSshAuthKeysFile)
body=$(awk '{print $2}' <<< "$pub")
if runSystem grep -qF "$body" "$akf" 2>/dev/null; then
isNotice "That key is already authorized."
else
printf '%s\n' "$pub" | runSystem tee -a "$akf" >/dev/null
isSuccessful "SSH key authorized for $(hostSshUser)"
fi
runSystem chown "$(hostSshUser)":"$(hostSshUser)" "$akf"
runSystem chmod 600 "$akf"
hostSshRefreshUi hostSshRefreshUi
} }
# Remove the authorized key whose fingerprint matches $1. Guards against # Remove the authorized key whose fingerprint matches $1. The helper guards
# removing the last key while password auth is off (that would lock you out). # against removing the last key while password auth is off (lockout).
hostSshKeyRemove() hostSshKeyRemove()
{ {
local fp="$1" local fp="$1"
[[ -z "$fp" ]] && { isError "hostSshKeyRemove requires <fingerprint>"; return 1; } [[ -z "$fp" ]] && { isError "hostSshKeyRemove requires <fingerprint>"; return 1; }
local akf; akf=$(hostSshAuthKeysFile)
runSystem test -f "$akf" || { isError "No authorized_keys file"; return 1; }
if ! hostSshPasswordAuthEnabled && [[ "$(hostSshKeyCount)" -le 1 ]]; then local out rc
out=$(runSshAccess key-remove "$fp" 2>&1)
rc=$?
if [[ $rc -eq 2 ]]; then
isError "Refusing to remove the last key while password login is disabled — you'd be locked out. Re-enable password login first." isError "Refusing to remove the last key while password login is disabled — you'd be locked out. Re-enable password login first."
return 1 return 1
fi fi
case "$out" in
local tmp; tmp=$(mktemp) removed) isSuccessful "Removed SSH key $fp" ;;
local removed=0 line lfp no-match) isNotice "No key matched fingerprint $fp" ;;
while IFS= read -r line; do *) [[ $rc -ne 0 ]] && { isError "Could not remove key."; return 1; } ;;
if [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]]; then esac
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 < <(runSystem cat "$akf")
if [[ "$removed" -eq 1 ]]; then
runSystem cp "$tmp" "$akf"
runSystem chown "$(hostSshUser)":"$(hostSshUser)" "$akf"
runSystem chmod 600 "$akf"
isSuccessful "Removed SSH key $fp"
else
isNotice "No key matched fingerprint $fp"
fi
rm -f "$tmp"
hostSshRefreshUi hostSshRefreshUi
} }
# Enable/disable sshd password authentication. Disabling is guarded: there # Enable/disable sshd password authentication. Disabling is guarded (helper-side):
# must be at least one authorized key, or you'd lock yourself out. # there must be at least one authorized key, or you'd lock yourself out.
hostSshSetPasswordAuth() hostSshSetPasswordAuth()
{ {
local want="$1" # on|off local want="$1" # on|off
@ -141,23 +104,17 @@ hostSshSetPasswordAuth()
*) isError "hostSshSetPasswordAuth requires on|off"; return 1 ;; *) isError "hostSshSetPasswordAuth requires on|off"; return 1 ;;
esac esac
if [[ "$want" == "off" && "$(hostSshKeyCount)" -lt 1 ]]; then local out rc
out=$(runSshAccess pw-set "$want" 2>&1)
rc=$?
if [[ $rc -eq 2 ]]; then
isError "Refusing to disable password login with no authorized keys — add a key first or you'll be locked out." isError "Refusing to disable password login with no authorized keys — add a key first or you'll be locked out."
return 1 return 1
fi fi
if [[ $rc -ne 0 ]]; then
local value="yes"; [[ "$want" == "off" ]] && value="no" isError "sshd config test failed — restored backup, no change made."
local backup="${sshd_config}.libreportal.$(date +%s)"
runSystem cp "$sshd_config" "$backup"
runSystem sed -i '/^[[:space:]]*#\?[[:space:]]*PasswordAuthentication\b/d' "$sshd_config"
echo "PasswordAuthentication $value" | runSystem tee -a "$sshd_config" >/dev/null
if ! runSystem sshd -t 2>/dev/null; then
isError "sshd config test failed — restoring backup, no change made."
runSystem cp "$backup" "$sshd_config"
return 1 return 1
fi fi
runSystem systemctl reload ssh 2>/dev/null || runSystem systemctl reload sshd 2>/dev/null isSuccessful "Password login ${want} (sshd reloaded)."
isSuccessful "Password login ${want} (sshd reloaded; backup at $backup)"
hostSshRefreshUi hostSshRefreshUi
} }

View File

@ -0,0 +1,42 @@
#!/bin/bash
# LibrePortal DNS helper — the only root-privileged edit of /etc/resolv.conf the
# manager may trigger. Installed root:root 0755 to /usr/local/sbin by init.sh.
# Self-contained (sources no manager code). Operates ONLY on /etc/resolv.conf and
# only with strictly-validated IP arguments, so the scoped sudoers can allow it
# wholesale instead of a blanket `sudo sed`/`sudo tee` (which would be root).
set -u
[[ $EUID -eq 0 ]] || { echo "libreportal-dns: must run as root" >&2; exit 1; }
RESOLV="/etc/resolv.conf"
_is_ip() {
local ip="$1"
# IPv4
if [[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
local o; for o in ${ip//./ }; do (( o <= 255 )) || return 1; done
return 0
fi
# IPv6 (loose but safe — only hex/colon, no shell metachars)
[[ "$ip" =~ ^[0-9A-Fa-f:]+$ ]] && return 0
return 1
}
clear_ns() {
[[ -f "$RESOLV" ]] || return 0
sed -i '/^nameserver/d' "$RESOLV"
}
add_ns() {
local ip="$1"
_is_ip "$ip" || { echo "libreportal-dns: invalid IP '$ip'" >&2; return 1; }
printf 'nameserver %s\n' "$ip" >> "$RESOLV"
}
action="${1:-}"; shift 2>/dev/null || true
case "$action" in
clear) clear_ns ;;
add) add_ns "${1:-}" ;;
*) echo "usage: libreportal-dns {clear|add <ip>}" >&2; exit 2 ;;
esac

View File

@ -0,0 +1,129 @@
#!/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/sbin 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

View File

@ -28,7 +28,7 @@ webuiGenerateSshAccess()
fi fi
local keys_json="[" first=true line type comment info fpr local keys_json="[" first=true line type comment info fpr
if runSystem test -f "$akf"; then if runSshAccess has-keys; then
while IFS= read -r line; do while IFS= read -r line; do
[[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue
type=$(awk '{print $1}' <<< "$line") type=$(awk '{print $1}' <<< "$line")
@ -39,7 +39,7 @@ webuiGenerateSshAccess()
$first || keys_json+="," $first || keys_json+=","
first=false first=false
keys_json+="{\"type\":\"$(jsonEscape "$type")\",\"fingerprint\":\"$(jsonEscape "$fpr")\",\"comment\":\"$(jsonEscape "$comment")\"}" keys_json+="{\"type\":\"$(jsonEscape "$type")\",\"fingerprint\":\"$(jsonEscape "$fpr")\",\"comment\":\"$(jsonEscape "$comment")\"}"
done < <(runSystem cat "$akf") done < <(runSshAccess read-keys)
fi fi
keys_json+="]" keys_json+="]"