From d17e8814d01b45fbecff4bf2541ca71d4fa926d8 Mon Sep 17 00:00:00 2001 From: librelad Date: Sun, 24 May 2026 18:21:46 +0100 Subject: [PATCH] feat(desudo): root-owned DNS + host-SSH-access helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 } — 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 Signed-off-by: librelad --- init.sh | 52 +++---- scripts/docker/command/run_privileged.sh | 39 ++++-- scripts/network/dns/setup_dns.sh | 28 ++-- scripts/ssh/host_access.sh | 119 ++++++---------- scripts/system/libreportal-dns | 42 ++++++ scripts/system/libreportal-ssh-access | 129 ++++++++++++++++++ .../generators/system/webui_ssh_access.sh | 4 +- 7 files changed, 278 insertions(+), 135 deletions(-) create mode 100644 scripts/system/libreportal-dns create mode 100644 scripts/system/libreportal-ssh-access diff --git a/init.sh b/init.sh index 1ca3dcb..4f6f5a4 100755 --- a/init.sh +++ b/init.sh @@ -706,33 +706,37 @@ initUsers() fi rm -f "$sudoers_tmp" - initOwnershipHelper + initRootHelpers } -# Install the root-owned ownership-reconcile helper. Under Model A the runtime -# runs AS the manager, so establishing the /docker ownership model needs root — -# but granting the manager a blanket `sudo chown`/`sudo chmod` would be -# root-equivalent. This helper does a FIXED set of reconciles on FIXED paths; it -# lives root:root 0755 where the manager can't edit it, so the scoped sudoers can -# allow it wholesale. The manager name is baked in here (manager can't change it). -initOwnershipHelper() +# Install the root-owned privilege helpers. Under Model A the runtime runs AS the +# manager, so the genuine-root operations it can't drop (the /docker ownership +# model, /etc/resolv.conf edits, host SSH access) need root — but granting the +# manager blanket `sudo chown/chmod/tee/sed/cp` would be root-equivalent. Each +# helper does a FIXED, self-validated set of operations and lives root:root 0755 +# where the manager can't edit it, so the scoped sudoers can allow each wholesale. +# 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_dst="/usr/local/sbin/libreportal-ownership" - if [[ ! -f "$helper_src" ]]; then - isError "Ownership helper source missing ($helper_src) — skipping install." - return 1 - fi - local helper_tmp - helper_tmp=$(mktemp) - sed "s/__MANAGER__/${sudo_user_name}/g" "$helper_src" > "$helper_tmp" - if bash -n "$helper_tmp" 2>/dev/null; then - sudo install -m 0755 -o root -g root "$helper_tmp" "$helper_dst" - isSuccessful "Installed root-owned ownership helper ($helper_dst)." - else - isError "Refusing to install a malformed ownership helper." - fi - rm -f "$helper_tmp" + local helper helper_src helper_dst helper_tmp + 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 + isError "Root helper source missing ($helper_src) — skipping." + continue + fi + helper_tmp=$(mktemp) + sed "s/__MANAGER__/${sudo_user_name}/g" "$helper_src" > "$helper_tmp" + if bash -n "$helper_tmp" 2>/dev/null; then + sudo install -m 0755 -o root -g root "$helper_tmp" "$helper_dst" + isSuccessful "Installed root-owned helper ($helper_dst)." + else + isError "Refusing to install a malformed root helper ($helper)." + fi + rm -f "$helper_tmp" + done } initFolders() diff --git a/scripts/docker/command/run_privileged.sh b/scripts/docker/command/run_privileged.sh index 0dffc78..df3a6c8 100644 --- a/scripts/docker/command/run_privileged.sh +++ b/scripts/docker/command/run_privileged.sh @@ -86,28 +86,39 @@ runBackupOp() { sudo -E -u "$docker_install_user" "$@" } -# Trigger a fixed ownership reconcile through the ROOT-OWNED helper installed at -# /usr/local/sbin/libreportal-ownership. This is how the manager-run runtime -# (Model A) establishes the ownership model — manager owns the control plane, the -# docker install user owns the containers — without the scoped sudoers having to -# grant a blanket `sudo chown`/`sudo chmod` (which would be root-equivalent: chown -# /etc/sudoers and so on). The helper validates its own (fixed-path) operations, -# so the sudoers can allow it wholesale. -# action ∈ {reconcile [mode]|traversal|containers-top|app-perms|webui|taskdir| -# app-data-nobody } -# At install time (already root) the helper may not be installed yet, so run the -# bundled copy directly — no sudo, no escalation, since we are root already. -runOwnership() { - local helper="/usr/local/sbin/libreportal-ownership" +# Run one of the ROOT-OWNED LibrePortal helpers installed (root:root 0755) under +# /usr/local/sbin by init.sh. These are how the manager-run runtime (Model A) +# performs the genuine-root operations it can't drop — establishing the /docker +# ownership model, editing /etc/resolv.conf, managing host SSH access — WITHOUT +# the scoped sudoers granting blanket `sudo chown/chmod/tee/sed/cp` (which would +# be root-equivalent: chown /etc/sudoers, tee a new sudoers drop-in, …). Each +# helper validates its own fixed-path operations, so the sudoers can allow it +# wholesale. At install time (already root) the installed helper may be absent, so +# run the bundled copy directly — no sudo, no escalation, since we are root. +_runRootHelper() { + local name="$1"; shift + local helper="/usr/local/sbin/$name" if [[ -x "$helper" ]]; then sudo "$helper" "$@" elif [[ $EUID -eq 0 ]]; then - bash "${script_dir:-/docker/install}/scripts/system/libreportal-ownership" "$@" + bash "${script_dir:-/docker/install}/scripts/system/$name" "$@" else sudo "$helper" "$@" fi } +# Ownership reconcile: action ∈ {reconcile [mode]|traversal|containers-top| +# app-perms|webui|taskdir|app-data-nobody } +runOwnership() { _runRootHelper libreportal-ownership "$@"; } + +# /etc/resolv.conf edits: {clear|add } +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 |key-remove |pw-set } +runSshAccess() { _runRootHelper libreportal-ssh-access "$@"; } + # Genuine system-administration command (ufw/systemctl/apt/sysctl/useradd, /etc # edits). Needs real root in both modes; funnelled through one place so it can # later be confined to a scoped sudoers allowlist. diff --git a/scripts/network/dns/setup_dns.sh b/scripts/network/dns/setup_dns.sh index 77666f8..aa2c36b 100755 --- a/scripts/network/dns/setup_dns.sh +++ b/scripts/network/dns/setup_dns.sh @@ -8,14 +8,14 @@ updateDNS() if [[ "$OS_TYPE" == "Ubuntu" || "$OS_TYPE" == "Debian" ]]; then dnsRemoveNameservers() { - result=$(runSystem sed -i '/^nameserver/d' /etc/resolv.conf) + result=$(runResolv clear) checkSuccess "Removing all instances of Nameserver from Resolv.conf" } if [[ "$flag" == "standalonewireguard" ]]; then dnsRemoveNameservers; - echo "nameserver $CFG_DNS_SERVER_1" | runSystem tee -a /etc/resolv.conf > /dev/null - echo "nameserver $CFG_DNS_SERVER_2" | runSystem tee -a /etc/resolv.conf > /dev/null + runResolv add "$CFG_DNS_SERVER_1" + runResolv add "$CFG_DNS_SERVER_2" else # Check if AdGuard is installed local status=$(dockerCheckAppInstalled "adguard" "docker") @@ -23,7 +23,7 @@ updateDNS() setupDNSIP adguard; local adguard_ip="$dns_ip_setup" # Testing Docker IP Address - result=$(runSystem ping -c 1 $adguard_ip) + result=$(ping -c 1 $adguard_ip) if [ $? -eq 0 ]; then isSuccessful "Ping to $adguard_ip was successful." else @@ -31,7 +31,7 @@ updateDNS() isNotice "Defaulting to DNS 1 Server $CFG_DNS_SERVER_1." local adguard_ip="$CFG_DNS_SERVER_1" # Fallback to Quad9 if DNS has issues - result=$(runSystem ping -c 1 $adguard_ip) + result=$(ping -c 1 $adguard_ip) if [ $? -eq 0 ]; then isSuccessful "Ping to $adguard_ip was successful." else @@ -43,7 +43,7 @@ updateDNS() else local adguard_ip="$CFG_DNS_SERVER_1" # Fallback to Quad9 if DNS has issues - result=$(runSystem ping -c 1 $adguard_ip) + result=$(ping -c 1 $adguard_ip) if [ $? -eq 0 ]; then isSuccessful "Ping to $adguard_ip was successful." else @@ -59,7 +59,7 @@ updateDNS() setupDNSIP pihole; local pihole_ip="$dns_ip_setup" # Testing Docker IP Address - result=$(runSystem ping -c 1 $pihole_ip) + result=$(ping -c 1 $pihole_ip) if [ $? -eq 0 ]; then isSuccessful "Ping to $pihole_ip was successful." else @@ -67,7 +67,7 @@ updateDNS() isNotice "Defaulting to DNS 2 Server $CFG_DNS_SERVER_2." local pihole_ip="$CFG_DNS_SERVER_2" # Fallback to Quad9 if DNS has issues - result=$(runSystem ping -c 1 $pihole_ip) + result=$(ping -c 1 $pihole_ip) if [ $? -eq 0 ]; then isSuccessful "Ping to $pihole_ip was successful." else @@ -102,8 +102,8 @@ updateDNS() checkSuccess "Updated Wireguard default DNS to $adguard_ip" fi dnsRemoveNameservers; - echo "nameserver $adguard_ip" | runSystem tee -a /etc/resolv.conf > /dev/null - echo "nameserver $pihole_ip" | runSystem tee -a /etc/resolv.conf > /dev/null + runResolv add "$adguard_ip" + runResolv add "$pihole_ip" elif [[ "$pihole_ip" == *10.100.0* ]]; then # Wireguard update local status=$(dockerCheckAppInstalled "wireguard" "docker") @@ -118,8 +118,8 @@ updateDNS() checkSuccess "Updated Wireguard default DNS to $pihole_ip" fi dnsRemoveNameservers; - echo "nameserver $pihole_ip" | runSystem tee -a /etc/resolv.conf > /dev/null - echo "nameserver $adguard_ip" | runSystem tee -a /etc/resolv.conf > /dev/null + runResolv add "$pihole_ip" + runResolv add "$adguard_ip" else # Wireguard update local status=$(dockerCheckAppInstalled "wireguard" "docker") @@ -134,8 +134,8 @@ updateDNS() checkSuccess "Updated Wireguard default DNS to $adguard_ip" fi dnsRemoveNameservers; - echo "nameserver $adguard_ip" | runSystem tee -a /etc/resolv.conf > /dev/null - echo "nameserver $pihole_ip" | runSystem tee -a /etc/resolv.conf > /dev/null + runResolv add "$adguard_ip" + runResolv add "$pihole_ip" fi if [ "$flag" == "install" ]; then initializeAppVariables $app_name; diff --git a/scripts/ssh/host_access.sh b/scripts/ssh/host_access.sh index 340b417..1c225d8 100644 --- a/scripts/ssh/host_access.sh +++ b/scripts/ssh/host_access.sh @@ -5,6 +5,11 @@ # password authentication. Everything here is on-demand only: nothing runs # during install or deploy. LibrePortal is the *server* here, so the admin # 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. hostSshUser() @@ -33,33 +38,19 @@ hostSshRefreshUi() hostSshEnsureDir() { - local u sshdir akf - 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" + runSshAccess ensure-dir } # Count valid authorized public keys. hostSshKeyCount() { - local akf; akf=$(hostSshAuthKeysFile) - runSystem test -f "$akf" || { echo 0; return; } - runSystem grep -cvE '^[[:space:]]*($|#)' "$akf" 2>/dev/null || echo 0 + runSshAccess key-count 2>/dev/null || echo 0 } # True when sshd currently allows password authentication. hostSshPasswordAuthEnabled() { - local v - 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 + [[ "$(runSshAccess pw-status 2>/dev/null)" != "off" ]] } # Add a base64-encoded PUBLIC key to the install user's authorized_keys. @@ -68,71 +59,43 @@ hostSshKeyAdd() local key_b64="$1" [[ -z "$key_b64" ]] && { isError "hostSshKeyAdd requires "; return 1; } - local pub - pub=$(echo "$key_b64" | base64 -d 2>/dev/null | tr -d '\r' | grep -vE '^[[:space:]]*$' | head -1) - [[ -z "$pub" ]] && { isError "Empty key after decode"; return 1; } - - if ! printf '%s\n' "$pub" | ssh-keygen -l -f - >/dev/null 2>&1; then - isError "Not a valid SSH public key (expected e.g. 'ssh-ed25519 AAAA... comment')" - return 1 - fi - - hostSshEnsureDir - 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" + local out rc + out=$(runSshAccess key-add "$key_b64") + rc=$? + case "$out" in + added) isSuccessful "SSH key authorized for $(hostSshUser)" ;; + already-authorized) isNotice "That key is already authorized." ;; + *) + [[ $rc -ne 0 ]] && { isError "Could not add key (not a valid SSH public key?)"; return 1; } + ;; + esac hostSshRefreshUi } -# Remove the authorized key whose fingerprint matches $1. Guards against -# removing the last key while password auth is off (that would lock you out). +# Remove the authorized key whose fingerprint matches $1. The helper guards +# against removing the last key while password auth is off (lockout). hostSshKeyRemove() { local fp="$1" [[ -z "$fp" ]] && { isError "hostSshKeyRemove requires "; 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." return 1 fi - - local tmp; tmp=$(mktemp) - local removed=0 line lfp - 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 < <(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" + case "$out" in + removed) isSuccessful "Removed SSH key $fp" ;; + no-match) isNotice "No key matched fingerprint $fp" ;; + *) [[ $rc -ne 0 ]] && { isError "Could not remove key."; return 1; } ;; + esac hostSshRefreshUi } -# Enable/disable sshd password authentication. Disabling is guarded: there -# must be at least one authorized key, or you'd lock yourself out. +# Enable/disable sshd password authentication. Disabling is guarded (helper-side): +# there must be at least one authorized key, or you'd lock yourself out. hostSshSetPasswordAuth() { local want="$1" # on|off @@ -141,23 +104,17 @@ hostSshSetPasswordAuth() *) isError "hostSshSetPasswordAuth requires on|off"; return 1 ;; 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." return 1 fi - - local value="yes"; [[ "$want" == "off" ]] && value="no" - 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" + if [[ $rc -ne 0 ]]; then + isError "sshd config test failed — restored backup, no change made." return 1 fi - runSystem systemctl reload ssh 2>/dev/null || runSystem systemctl reload sshd 2>/dev/null - isSuccessful "Password login ${want} (sshd reloaded; backup at $backup)" + isSuccessful "Password login ${want} (sshd reloaded)." hostSshRefreshUi } diff --git a/scripts/system/libreportal-dns b/scripts/system/libreportal-dns new file mode 100644 index 0000000..6df0c27 --- /dev/null +++ b/scripts/system/libreportal-dns @@ -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 }" >&2; exit 2 ;; +esac diff --git a/scripts/system/libreportal-ssh-access b/scripts/system/libreportal-ssh-access new file mode 100644 index 0000000..b227ebc --- /dev/null +++ b/scripts/system/libreportal-ssh-access @@ -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 " >&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 " >&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 |key-remove |pw-set }" >&2; exit 2 ;; +esac diff --git a/scripts/webui/data/generators/system/webui_ssh_access.sh b/scripts/webui/data/generators/system/webui_ssh_access.sh index 2bb6a27..b086ff9 100644 --- a/scripts/webui/data/generators/system/webui_ssh_access.sh +++ b/scripts/webui/data/generators/system/webui_ssh_access.sh @@ -28,7 +28,7 @@ webuiGenerateSshAccess() fi 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 [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue type=$(awk '{print $1}' <<< "$line") @@ -39,7 +39,7 @@ webuiGenerateSshAccess() $first || keys_json+="," first=false keys_json+="{\"type\":\"$(jsonEscape "$type")\",\"fingerprint\":\"$(jsonEscape "$fpr")\",\"comment\":\"$(jsonEscape "$comment")\"}" - done < <(runSystem cat "$akf") + done < <(runSshAccess read-keys) fi keys_json+="]"