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:
parent
dd414a6e73
commit
d17e8814d0
34
init.sh
34
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"
|
||||
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 "Ownership helper source missing ($helper_src) — skipping install."
|
||||
return 1
|
||||
isError "Root helper source missing ($helper_src) — skipping."
|
||||
continue
|
||||
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)."
|
||||
isSuccessful "Installed root-owned helper ($helper_dst)."
|
||||
else
|
||||
isError "Refusing to install a malformed ownership helper."
|
||||
isError "Refusing to install a malformed root helper ($helper)."
|
||||
fi
|
||||
rm -f "$helper_tmp"
|
||||
done
|
||||
}
|
||||
|
||||
initFolders()
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
42
scripts/system/libreportal-dns
Normal file
42
scripts/system/libreportal-dns
Normal 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
|
||||
129
scripts/system/libreportal-ssh-access
Normal file
129
scripts/system/libreportal-ssh-access
Normal 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
|
||||
@ -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+="]"
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user