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

52
init.sh
View File

@ -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()

View File

@ -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 <app>}
# 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 <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
# edits). Needs real root in both modes; funnelled through one place so it can
# later be confined to a scoped sudoers allowlist.

View File

@ -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;

View File

@ -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 <base64-public-key>"; 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 <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."
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
}

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
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+="]"