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
|
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()
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
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+="]"
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user