From 3a679d7343512b6a0a0101944dcfbdb33799f9c2 Mon Sep 17 00:00:00 2001 From: librelad Date: Sat, 23 May 2026 16:40:59 +0100 Subject: [PATCH] feat(ssh): admin host SSH-access engine (backend + CLI + snapshot) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fresh, on-demand inbound SSH-access management for the host (replaces the old maze). scripts/ssh/host_access.sh manages the install user's authorized_keys — add a pasted public key (validated), list, remove — and toggles sshd password login behind a lockout guard (won't disable passwords with no key; won't drop the last key while passwords are off; sshd -t before reload, with backup). New 'ssh' CLI category (status/key-add/key-remove/password-auth/generate) and a webuiGenerateSshAccess snapshot (data/ssh/access.json: user, password_auth, authorized keys as type+fingerprint+comment — public only) wired into the regen chain. Nothing runs automatically; only explicit admin actions change anything. WebUI page next. Co-Authored-By: Claude Opus 4.7 Signed-off-by: librelad --- scripts/cli/commands/ssh/cli_ssh_commands.sh | 36 ++++ scripts/cli/commands/ssh/cli_ssh_header.sh | 23 +++ scripts/source/files/app_files.sh | 1 + scripts/source/files/arrays/files_cli.sh | 2 + scripts/source/files/arrays/files_ssh.sh | 9 + scripts/source/files/arrays/files_webui.sh | 1 + scripts/source/files/cli_files.sh | 1 + scripts/ssh/host_access.sh | 163 ++++++++++++++++++ .../generators/system/webui_ssh_access.sh | 51 ++++++ scripts/webui/webui_updater.sh | 4 + 10 files changed, 291 insertions(+) create mode 100644 scripts/cli/commands/ssh/cli_ssh_commands.sh create mode 100644 scripts/cli/commands/ssh/cli_ssh_header.sh create mode 100644 scripts/source/files/arrays/files_ssh.sh create mode 100644 scripts/ssh/host_access.sh create mode 100644 scripts/webui/data/generators/system/webui_ssh_access.sh diff --git a/scripts/cli/commands/ssh/cli_ssh_commands.sh b/scripts/cli/commands/ssh/cli_ssh_commands.sh new file mode 100644 index 0000000..9139f2f --- /dev/null +++ b/scripts/cli/commands/ssh/cli_ssh_commands.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +cliHandleSshCommands() +{ + local action="$initial_command2" + local arg="$initial_command3" + + case "$action" in + ""|help) + cliShowSshHelp + ;; + status) + local pw="off"; hostSshPasswordAuthEnabled && pw="on" + echo "user=$(hostSshUser) password_login=$pw authorized_keys=$(hostSshKeyCount)" + ;; + key-add) + [[ -z "$arg" ]] && { isNotice "Usage: ssh key-add "; cliShowSshHelp; return; } + hostSshKeyAdd "$arg" + ;; + key-remove) + [[ -z "$arg" ]] && { isNotice "Usage: ssh key-remove "; cliShowSshHelp; return; } + hostSshKeyRemove "$arg" + ;; + password-auth) + [[ -z "$arg" ]] && { isNotice "Usage: ssh password-auth "; cliShowSshHelp; return; } + hostSshSetPasswordAuth "$arg" + ;; + generate) + webuiGenerateSshAccess + ;; + *) + isNotice "Unknown ssh action: $action" + cliShowSshHelp + ;; + esac +} diff --git a/scripts/cli/commands/ssh/cli_ssh_header.sh b/scripts/cli/commands/ssh/cli_ssh_header.sh new file mode 100644 index 0000000..90e8129 --- /dev/null +++ b/scripts/cli/commands/ssh/cli_ssh_header.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +cliShowSshHelp() +{ + isHeader "LibrePortal SSH Access Commands" + echo "ssh status" + echo " Show the login user, password-login state, and authorized key count." + echo "" + echo "ssh key-add " + echo " Authorize a public key for SSH login (base64-encoded; the WebUI" + echo " encodes pasted keys for you)." + echo "" + echo "ssh key-remove " + echo " Remove an authorized key by its SHA256 fingerprint." + echo "" + echo "ssh password-auth " + echo " Enable/disable sshd password login. Disabling is refused unless at" + echo " least one key is authorized (lockout guard)." + echo "" + echo "ssh generate" + echo " Regenerate the WebUI SSH-access snapshot (access.json)." + echo "" +} diff --git a/scripts/source/files/app_files.sh b/scripts/source/files/app_files.sh index b423ac1..1d4c72a 100755 --- a/scripts/source/files/app_files.sh +++ b/scripts/source/files/app_files.sh @@ -23,6 +23,7 @@ files_libreportal_app=( "${restore_scripts[@]}" "${setup_scripts[@]}" "${source_scripts[@]}" + "${ssh_scripts[@]}" "${ssl_scripts[@]}" "${start_scripts[@]}" "${swapfile_scripts[@]}" diff --git a/scripts/source/files/arrays/files_cli.sh b/scripts/source/files/arrays/files_cli.sh index 91a0742..7ae26dc 100755 --- a/scripts/source/files/arrays/files_cli.sh +++ b/scripts/source/files/arrays/files_cli.sh @@ -30,6 +30,8 @@ cli_scripts=( "cli/commands/restore/cli_restore_header.sh" "cli/commands/setup/cli_setup_commands.sh" "cli/commands/setup/cli_setup_header.sh" + "cli/commands/ssh/cli_ssh_commands.sh" + "cli/commands/ssh/cli_ssh_header.sh" "cli/commands/system/cli_system_commands.sh" "cli/commands/system/cli_system_header.sh" "cli/commands/update/cli_update_commands.sh" diff --git a/scripts/source/files/arrays/files_ssh.sh b/scripts/source/files/arrays/files_ssh.sh new file mode 100644 index 0000000..7695f02 --- /dev/null +++ b/scripts/source/files/arrays/files_ssh.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +# This file is auto-generated by generate_arrays.sh +# Do not edit manually - run './scripts/source/files/generate_arrays.sh run' to regenerate + +ssh_scripts=( + "ssh/host_access.sh" + +) diff --git a/scripts/source/files/arrays/files_webui.sh b/scripts/source/files/arrays/files_webui.sh index 7302a15..e567a1f 100755 --- a/scripts/source/files/arrays/files_webui.sh +++ b/scripts/source/files/arrays/files_webui.sh @@ -26,6 +26,7 @@ webui_scripts=( "webui/data/generators/config/webui_cli_config_set.sh" "webui/data/generators/config/webui_generate_configs.sh" "webui/data/generators/config/webui_update_config.sh" + "webui/data/generators/system/webui_ssh_access.sh" "webui/data/generators/system/webui_system_disk.sh" "webui/data/generators/system/webui_system_info.sh" "webui/data/generators/system/webui_system_memory.sh" diff --git a/scripts/source/files/cli_files.sh b/scripts/source/files/cli_files.sh index e24fb1a..82f6c46 100755 --- a/scripts/source/files/cli_files.sh +++ b/scripts/source/files/cli_files.sh @@ -23,6 +23,7 @@ files_libreportal_cli=( "${restore_scripts[@]}" "${setup_scripts[@]}" "${source_scripts[@]}" + "${ssh_scripts[@]}" "${ssl_scripts[@]}" "${start_scripts[@]}" "${swapfile_scripts[@]}" diff --git a/scripts/ssh/host_access.sh b/scripts/ssh/host_access.sh new file mode 100644 index 0000000..1f43641 --- /dev/null +++ b/scripts/ssh/host_access.sh @@ -0,0 +1,163 @@ +#!/bin/bash + +# Admin SSH access to THIS host. Manages the install user's authorized_keys — +# paste a public key to grant access — and, behind a lockout guard, sshd +# 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. + +# The user admins actually log in as (has sudo). Falls back to libreportal. +hostSshUser() +{ + echo "${sudo_user_name:-libreportal}" +} + +hostSshHome() +{ + local u; u=$(hostSshUser) + [[ "$u" == "root" ]] && { echo "/root"; return; } + echo "/home/$u" +} + +hostSshAuthKeysFile() +{ + echo "$(hostSshHome)/.ssh/authorized_keys" +} + +# Refresh the WebUI access snapshot after a change. No-op if generator absent. +hostSshRefreshUi() +{ + declare -f webuiGenerateSshAccess >/dev/null 2>&1 && webuiGenerateSshAccess >/dev/null 2>&1 + return 0 +} + +hostSshEnsureDir() +{ + local u sshdir akf + u=$(hostSshUser) + sshdir="$(hostSshHome)/.ssh" + akf=$(hostSshAuthKeysFile) + sudo mkdir -p "$sshdir" + sudo touch "$akf" + sudo chmod 700 "$sshdir" + sudo chmod 600 "$akf" + sudo chown -R "$u":"$u" "$sshdir" +} + +# Count valid authorized public keys. +hostSshKeyCount() +{ + local akf; akf=$(hostSshAuthKeysFile) + sudo test -f "$akf" || { echo 0; return; } + sudo grep -cvE '^[[:space:]]*($|#)' "$akf" 2>/dev/null || echo 0 +} + +# True when sshd currently allows password authentication. +hostSshPasswordAuthEnabled() +{ + local v + v=$(sudo 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. +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 sudo grep -qF "$body" "$akf" 2>/dev/null; then + isNotice "That key is already authorized." + else + printf '%s\n' "$pub" | sudo tee -a "$akf" >/dev/null + isSuccessful "SSH key authorized for $(hostSshUser)" + fi + sudo chown "$(hostSshUser)":"$(hostSshUser)" "$akf" + sudo chmod 600 "$akf" + 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). +hostSshKeyRemove() +{ + local fp="$1" + [[ -z "$fp" ]] && { isError "hostSshKeyRemove requires "; return 1; } + local akf; akf=$(hostSshAuthKeysFile) + sudo test -f "$akf" || { isError "No authorized_keys file"; return 1; } + + if ! hostSshPasswordAuthEnabled && [[ "$(hostSshKeyCount)" -le 1 ]]; 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 < <(sudo cat "$akf") + + if [[ "$removed" -eq 1 ]]; then + sudo cp "$tmp" "$akf" + sudo chown "$(hostSshUser)":"$(hostSshUser)" "$akf" + sudo chmod 600 "$akf" + isSuccessful "Removed SSH key $fp" + else + isNotice "No key matched fingerprint $fp" + fi + rm -f "$tmp" + hostSshRefreshUi +} + +# Enable/disable sshd password authentication. Disabling is guarded: there +# must be at least one authorized key, or you'd lock yourself out. +hostSshSetPasswordAuth() +{ + local want="$1" # on|off + case "$want" in + on|off) ;; + *) isError "hostSshSetPasswordAuth requires on|off"; return 1 ;; + esac + + if [[ "$want" == "off" && "$(hostSshKeyCount)" -lt 1 ]]; 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)" + sudo cp "$sshd_config" "$backup" + sudo sed -i '/^[[:space:]]*#\?[[:space:]]*PasswordAuthentication\b/d' "$sshd_config" + echo "PasswordAuthentication $value" | sudo tee -a "$sshd_config" >/dev/null + + if ! sudo sshd -t 2>/dev/null; then + isError "sshd config test failed — restoring backup, no change made." + sudo cp "$backup" "$sshd_config" + return 1 + fi + sudo systemctl reload ssh 2>/dev/null || sudo systemctl reload sshd 2>/dev/null + isSuccessful "Password login ${want} (sshd reloaded; backup at $backup)" + hostSshRefreshUi +} diff --git a/scripts/webui/data/generators/system/webui_ssh_access.sh b/scripts/webui/data/generators/system/webui_ssh_access.sh new file mode 100644 index 0000000..18063fb --- /dev/null +++ b/scripts/webui/data/generators/system/webui_ssh_access.sh @@ -0,0 +1,51 @@ +#!/bin/bash + +# Emit the admin SSH-access snapshot the WebUI reads: the install user, whether +# sshd allows password login, and the list of authorized public keys (type + +# fingerprint + comment). Public keys only — never anything secret. + +webuiGenerateSshAccess() +{ + local out_dir="$containers_dir/libreportal/frontend/data/ssh" + local out_file="$out_dir/access.json" + sudo mkdir -p "$out_dir" + + local jsonEscape + jsonEscape() { + local s="$1" + s="${s//\\/\\\\}"; s="${s//\"/\\\"}" + s="${s//$'\t'/ }"; s="${s//$'\r'/}"; s="${s//$'\n'/ }" + printf '%s' "$s" + } + + local user akf pw_auth + user=$(declare -f hostSshUser >/dev/null 2>&1 && hostSshUser || echo "${sudo_user_name:-libreportal}") + akf=$(declare -f hostSshAuthKeysFile >/dev/null 2>&1 && hostSshAuthKeysFile || echo "/home/$user/.ssh/authorized_keys") + if declare -f hostSshPasswordAuthEnabled >/dev/null 2>&1 && hostSshPasswordAuthEnabled; then + pw_auth="true" + else + pw_auth="false" + fi + + local keys_json="[" first=true line type comment info fpr + if sudo test -f "$akf"; then + while IFS= read -r line; do + [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue + type=$(awk '{print $1}' <<< "$line") + comment=$(awk '{ $1=""; $2=""; sub(/^[[:space:]]+/,""); print }' <<< "$line") + info=$(printf '%s\n' "$line" | ssh-keygen -l -f - 2>/dev/null) + [[ -z "$info" ]] && continue + fpr=$(awk '{print $2}' <<< "$info") + $first || keys_json+="," + first=false + keys_json+="{\"type\":\"$(jsonEscape "$type")\",\"fingerprint\":\"$(jsonEscape "$fpr")\",\"comment\":\"$(jsonEscape "$comment")\"}" + done < <(sudo cat "$akf") + fi + keys_json+="]" + + printf '{"generated_at":"%s","user":"%s","password_auth":%s,"keys":%s}\n' \ + "$(date -Iseconds)" "$(jsonEscape "$user")" "$pw_auth" "$keys_json" \ + | sudo tee "$out_file" >/dev/null + createTouch "$out_file" "$docker_install_user" "silent" + isSuccessful "SSH access snapshot regenerated" +} diff --git a/scripts/webui/webui_updater.sh b/scripts/webui/webui_updater.sh index ac10306..aa05ce4 100755 --- a/scripts/webui/webui_updater.sh +++ b/scripts/webui/webui_updater.sh @@ -85,6 +85,10 @@ webuiLibrePortalUpdate() { local result=$(webuiGenerateBackupLocations && webuiGenerateBackupDashboard && webuiGenerateBackupSnapshots all && webuiGenerateBackupAppStatus && webuiGenerateBackupEngines && webuiGenerateBackupSchema && webuiGenerateBackupPasswords) checkSuccess "Refreshed backup dashboard data..." + # SSH access snapshot (authorized keys + password-login state) + local result=$(webuiGenerateSshAccess) + checkSuccess "Refreshed SSH access data..." + # Sync app icons local result=$(webuiSyncAppIcons) checkSuccess "Synced app icons..."