Compare commits
52
Commits
v1.0.2
..
978a82ade3
@@ -17,13 +17,26 @@ set -euo pipefail
|
|||||||
BACKUP_LOG="/var/log/sovran-hub-backup.log"
|
BACKUP_LOG="/var/log/sovran-hub-backup.log"
|
||||||
BACKUP_STATUS="/var/log/sovran-hub-backup.status"
|
BACKUP_STATUS="/var/log/sovran-hub-backup.status"
|
||||||
MEDIA_ROOT="/run/media"
|
MEDIA_ROOT="/run/media"
|
||||||
MIN_FREE_GB=10
|
|
||||||
HUB_CONFIG_JSON="/var/lib/sovran-hub/config.json"
|
HUB_CONFIG_JSON="/var/lib/sovran-hub/config.json"
|
||||||
ROLE_STATE_NIX="/etc/nixos/role-state.nix"
|
ROLE_STATE_NIX="/etc/nixos/role-state.nix"
|
||||||
|
SECOND_DRIVE_MOUNT="/run/media/Second_Drive"
|
||||||
|
SAFETY_MARGIN_BYTES=$((1024 * 1024 * 1024))
|
||||||
|
|
||||||
# ── Internal drive labels/paths to NEVER use as backup targets ───
|
# ── Internal drive labels/paths to NEVER use as backup targets ───
|
||||||
INTERNAL_LABELS=("BTCEcoandBackup" "sovran_systemsos")
|
INTERNAL_LABELS=("BTCEcoandBackup" "sovran_systemsos")
|
||||||
INTERNAL_MOUNTS=("/run/media/Second_Drive" "/boot/efi" "/")
|
INTERNAL_MOUNTS=("$SECOND_DRIVE_MOUNT" "/boot/efi" "/")
|
||||||
|
|
||||||
|
FAILED_ALREADY=0
|
||||||
|
BACKUP_COMPLETE=0
|
||||||
|
LND_STOPPED=0
|
||||||
|
LND_UNITS_TO_RESTART=()
|
||||||
|
|
||||||
|
ARCHIVE_FILES=()
|
||||||
|
ARCHIVE_WARNINGS=()
|
||||||
|
PARTIAL_FILES=()
|
||||||
|
DB_DUMP_FILES=()
|
||||||
|
MANIFEST_EXCLUDES=()
|
||||||
|
LND_BACKUP_NOTES=()
|
||||||
|
|
||||||
# ── Logging helpers ──────────────────────────────────────────────
|
# ── Logging helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -37,16 +50,74 @@ set_status() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fail() {
|
fail() {
|
||||||
|
FAILED_ALREADY=1
|
||||||
log "ERROR: $*"
|
log "ERROR: $*"
|
||||||
set_status "FAILED"
|
set_status "FAILED"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local rc=$?
|
||||||
|
local restart_failed=0
|
||||||
|
|
||||||
|
# Remove any partial archive files or temporary diagnostic files
|
||||||
|
if [[ "${#PARTIAL_FILES[@]}" -gt 0 ]]; then
|
||||||
|
local partial
|
||||||
|
for partial in "${PARTIAL_FILES[@]}"; do
|
||||||
|
[[ -f "$partial" ]] && rm -f "$partial" || true
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Release the concurrency lock file descriptor if it was opened
|
||||||
|
[[ -n "${LOCK_FD:-}" ]] && exec {LOCK_FD}>&- 2>/dev/null || true
|
||||||
|
|
||||||
|
if [[ "$LND_STOPPED" -eq 1 ]]; then
|
||||||
|
log "Restarting previously active LND-related services…"
|
||||||
|
for (( idx=${#LND_UNITS_TO_RESTART[@]}-1 ; idx>=0 ; idx-- )); do
|
||||||
|
local unit="${LND_UNITS_TO_RESTART[$idx]}"
|
||||||
|
if systemctl start "$unit"; then
|
||||||
|
log "Started $unit"
|
||||||
|
else
|
||||||
|
log "ERROR: Failed to start $unit"
|
||||||
|
restart_failed=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
LND_STOPPED=0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$restart_failed" -eq 1 ]]; then
|
||||||
|
rc=1
|
||||||
|
FAILED_ALREADY=1
|
||||||
|
set_status "FAILED"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$BACKUP_COMPLETE" -eq 1 && "$rc" -eq 0 ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$FAILED_ALREADY" -eq 0 ]]; then
|
||||||
|
log "ERROR: Backup terminated unexpectedly (exit code $rc)."
|
||||||
|
set_status "FAILED"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Mark the backup directory as incomplete so failed runs are identifiable
|
||||||
|
if [[ -n "${BACKUP_DIR:-}" && -d "${BACKUP_DIR:-}" && ! -f "${BACKUP_DIR:-}/BACKUP_COMPLETE" ]]; then
|
||||||
|
touch "${BACKUP_DIR}/INCOMPLETE" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap 'exit 1' INT TERM
|
||||||
|
|
||||||
|
require_cmd() {
|
||||||
|
local cmd="$1"
|
||||||
|
command -v "$cmd" >/dev/null 2>&1 || fail "Required command not found: $cmd"
|
||||||
|
}
|
||||||
|
|
||||||
# ── Check whether a mount point is an internal drive ────────────
|
# ── Check whether a mount point is an internal drive ────────────
|
||||||
|
|
||||||
is_internal() {
|
is_internal() {
|
||||||
local mnt="$1"
|
local mnt="$1"
|
||||||
# Reject known internal mount points and their subdirectories
|
|
||||||
for internal in "${INTERNAL_MOUNTS[@]}"; do
|
for internal in "${INTERNAL_MOUNTS[@]}"; do
|
||||||
if [[ "$mnt" == "$internal" || "$mnt" == "${internal}/"* ]]; then
|
if [[ "$mnt" == "$internal" || "$mnt" == "${internal}/"* ]]; then
|
||||||
return 0
|
return 0
|
||||||
@@ -59,22 +130,18 @@ is_internal() {
|
|||||||
|
|
||||||
find_external_drive() {
|
find_external_drive() {
|
||||||
local target=""
|
local target=""
|
||||||
# lsblk JSON output: NAME,LABEL,MOUNTPOINT,HOTPLUG,RM,TYPE
|
|
||||||
if command -v lsblk &>/dev/null; then
|
|
||||||
while IFS=$'\t' read -r dev_type hotplug removable label mountpoint; do
|
while IFS=$'\t' read -r dev_type hotplug removable label mountpoint; do
|
||||||
# Must be a partition or disk, and be removable/hotplug
|
|
||||||
[[ "$dev_type" == "part" || "$dev_type" == "disk" ]] || continue
|
[[ "$dev_type" == "part" || "$dev_type" == "disk" ]] || continue
|
||||||
[[ "$hotplug" == "1" || "$removable" == "1" ]] || continue
|
[[ "$hotplug" == "1" || "$removable" == "1" ]] || continue
|
||||||
[[ -n "$mountpoint" ]] || continue
|
[[ -n "$mountpoint" ]] || continue
|
||||||
|
|
||||||
# Filter out internal labels
|
|
||||||
local skip=0
|
local skip=0
|
||||||
for lbl in "${INTERNAL_LABELS[@]}"; do
|
for lbl in "${INTERNAL_LABELS[@]}"; do
|
||||||
[[ "$label" == "$lbl" ]] && skip=1 && break
|
[[ "$label" == "$lbl" ]] && skip=1 && break
|
||||||
done
|
done
|
||||||
[[ "$skip" -eq 1 ]] && continue
|
[[ "$skip" -eq 1 ]] && continue
|
||||||
|
|
||||||
# Filter out internal mount points
|
|
||||||
is_internal "$mountpoint" && continue
|
is_internal "$mountpoint" && continue
|
||||||
|
|
||||||
if mountpoint -q "$mountpoint" 2>/dev/null; then
|
if mountpoint -q "$mountpoint" 2>/dev/null; then
|
||||||
@@ -84,14 +151,16 @@ find_external_drive() {
|
|||||||
done < <(lsblk -J -o NAME,LABEL,MOUNTPOINT,HOTPLUG,RM,TYPE 2>/dev/null | \
|
done < <(lsblk -J -o NAME,LABEL,MOUNTPOINT,HOTPLUG,RM,TYPE 2>/dev/null | \
|
||||||
python3 -c "
|
python3 -c "
|
||||||
import sys, json
|
import sys, json
|
||||||
data = json.load(sys.stdin)
|
|
||||||
def flatten(devs):
|
def flatten(devs):
|
||||||
for d in devs:
|
for d in devs:
|
||||||
yield d
|
yield d
|
||||||
for c in d.get('children', []):
|
for c in d.get('children', []):
|
||||||
yield from flatten([c])
|
yield from flatten([c])
|
||||||
|
|
||||||
|
data = json.load(sys.stdin)
|
||||||
for d in flatten(data.get('blockdevices', [])):
|
for d in flatten(data.get('blockdevices', [])):
|
||||||
print('\t'.join([
|
print('\\t'.join([
|
||||||
d.get('type') or '',
|
d.get('type') or '',
|
||||||
str(d.get('hotplug') or '0'),
|
str(d.get('hotplug') or '0'),
|
||||||
str(d.get('rm') or '0'),
|
str(d.get('rm') or '0'),
|
||||||
@@ -99,24 +168,10 @@ for d in flatten(data.get('blockdevices', [])):
|
|||||||
d.get('mountpoint') or '',
|
d.get('mountpoint') or '',
|
||||||
]))
|
]))
|
||||||
" 2>/dev/null || true)
|
" 2>/dev/null || true)
|
||||||
fi
|
|
||||||
|
|
||||||
# Fallback: walk /run/media/ if lsblk produced nothing
|
|
||||||
if [[ -z "$target" && -d "$MEDIA_ROOT" ]]; then
|
if [[ -z "$target" && -d "$MEDIA_ROOT" ]]; then
|
||||||
while IFS= read -r -d '' mnt; do
|
while IFS= read -r -d '' mnt; do
|
||||||
is_internal "$mnt" && continue
|
is_internal "$mnt" && continue
|
||||||
# Check label via lsblk on the device backing this mount
|
|
||||||
local dev
|
|
||||||
dev=$(findmnt -n -o SOURCE "$mnt" 2>/dev/null || true)
|
|
||||||
if [[ -n "$dev" ]]; then
|
|
||||||
local lbl
|
|
||||||
lbl=$(lsblk -n -o LABEL "$dev" 2>/dev/null || true)
|
|
||||||
local skip=0
|
|
||||||
for internal_lbl in "${INTERNAL_LABELS[@]}"; do
|
|
||||||
[[ "$lbl" == "$internal_lbl" ]] && skip=1 && break
|
|
||||||
done
|
|
||||||
[[ "$skip" -eq 1 ]] && continue
|
|
||||||
fi
|
|
||||||
if mountpoint -q "$mnt" 2>/dev/null; then
|
if mountpoint -q "$mnt" 2>/dev/null; then
|
||||||
target="$mnt"
|
target="$mnt"
|
||||||
break
|
break
|
||||||
@@ -128,16 +183,10 @@ for d in flatten(data.get('blockdevices', [])):
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ── Detect the configured system role ───────────────────────────
|
# ── Detect the configured system role ───────────────────────────
|
||||||
#
|
|
||||||
# Priority:
|
|
||||||
# 1. Hub config JSON (/var/lib/sovran-hub/config.json) — "role" key
|
|
||||||
# 2. role-state.nix (/etc/nixos/role-state.nix) — grep for true flag
|
|
||||||
# 3. Default: server_plus_desktop
|
|
||||||
|
|
||||||
detect_role() {
|
detect_role() {
|
||||||
local role="server_plus_desktop"
|
local role="server_plus_desktop"
|
||||||
|
|
||||||
# 1. Try the Hub config JSON
|
|
||||||
if [[ -f "$HUB_CONFIG_JSON" ]] && command -v python3 &>/dev/null; then
|
if [[ -f "$HUB_CONFIG_JSON" ]] && command -v python3 &>/dev/null; then
|
||||||
local r
|
local r
|
||||||
r=$(python3 -c \
|
r=$(python3 -c \
|
||||||
@@ -149,7 +198,6 @@ detect_role() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 2. Fall back to parsing role-state.nix
|
|
||||||
if [[ -f "$ROLE_STATE_NIX" ]]; then
|
if [[ -f "$ROLE_STATE_NIX" ]]; then
|
||||||
if grep -q 'roles\.desktop = lib\.mkDefault true' "$ROLE_STATE_NIX" 2>/dev/null; then
|
if grep -q 'roles\.desktop = lib\.mkDefault true' "$ROLE_STATE_NIX" 2>/dev/null; then
|
||||||
role="desktop"
|
role="desktop"
|
||||||
@@ -161,6 +209,64 @@ detect_role() {
|
|||||||
echo "$role"
|
echo "$role"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validate_target_mount() {
|
||||||
|
local target="$1"
|
||||||
|
[[ "$target" == "${MEDIA_ROOT}/"* ]] || fail "Target '$target' must be mounted under $MEDIA_ROOT."
|
||||||
|
[[ -d "$target" ]] || fail "Target path '$target' does not exist."
|
||||||
|
mountpoint -q "$target" || fail "Target path '$target' is not a mount point."
|
||||||
|
|
||||||
|
local fstype=""
|
||||||
|
fstype=$(findmnt -n -o FSTYPE -T "$target" 2>/dev/null || true)
|
||||||
|
[[ -n "$fstype" ]] || fail "Could not determine filesystem type for '$target'."
|
||||||
|
|
||||||
|
if [[ "$fstype" != "exfat" && "$fstype" != "fuseblk" ]]; then
|
||||||
|
fail "Target '$target' must be exFAT (detected filesystem: $fstype)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$fstype" == "fuseblk" ]]; then
|
||||||
|
local src_dev blk_type
|
||||||
|
src_dev=$(findmnt -n -o SOURCE -T "$target" 2>/dev/null || true)
|
||||||
|
blk_type=""
|
||||||
|
if [[ -n "$src_dev" ]]; then
|
||||||
|
blk_type=$(lsblk -no FSTYPE "$src_dev" 2>/dev/null || true)
|
||||||
|
[[ -z "$blk_type" ]] && blk_type=$(blkid -o value -s TYPE "$src_dev" 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
if [[ "$blk_type" != "exfat" && "$blk_type" != "fuseblk" ]]; then
|
||||||
|
fail "Target '$target' is fuseblk but not identified as exFAT-compatible."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
local write_test
|
||||||
|
write_test="$target/.sovran-write-test-$$"
|
||||||
|
if ! ( : > "$write_test" && echo "ok" >> "$write_test" && rm -f "$write_test" ); then
|
||||||
|
fail "Target '$target' is not writable."
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Verified backup target filesystem: $fstype"
|
||||||
|
}
|
||||||
|
|
||||||
|
has_unit() {
|
||||||
|
systemctl cat "$1" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
is_unit_active() {
|
||||||
|
systemctl is-active --quiet "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
estimate_path_bytes() {
|
||||||
|
local path="$1"
|
||||||
|
shift || true
|
||||||
|
[[ -e "$path" ]] || {
|
||||||
|
echo 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
local size
|
||||||
|
size=$(du -s -B1 -x "$@" "$path" 2>/dev/null | awk '{print $1}' || true)
|
||||||
|
[[ -n "$size" ]] || size=0
|
||||||
|
echo "$size"
|
||||||
|
}
|
||||||
|
|
||||||
# ── Initialise log file ──────────────────────────────────────────
|
# ── Initialise log file ──────────────────────────────────────────
|
||||||
|
|
||||||
: > "$BACKUP_LOG"
|
: > "$BACKUP_LOG"
|
||||||
@@ -169,6 +275,35 @@ set_status "RUNNING"
|
|||||||
log "=== Sovran_SystemsOS External Hub Backup ==="
|
log "=== Sovran_SystemsOS External Hub Backup ==="
|
||||||
log "Starting backup process…"
|
log "Starting backup process…"
|
||||||
|
|
||||||
|
# ── Acquire exclusive run lock ────────────────────────────────────
|
||||||
|
# Prevents two simultaneous backup runs (e.g. from double-click or
|
||||||
|
# stale RUNNING status after a Hub restart).
|
||||||
|
|
||||||
|
LOCK_FILE="/var/lock/sovran-hub-backup.lock"
|
||||||
|
# Note: exec {LOCK_FD}>>file requires bash 4.1+ (NixOS provides bash 5.x).
|
||||||
|
exec {LOCK_FD}>>"$LOCK_FILE" 2>/dev/null || \
|
||||||
|
fail "Cannot open lock file: $LOCK_FILE. Ensure /var/lock is writable."
|
||||||
|
flock --nonblock "$LOCK_FD" 2>/dev/null || \
|
||||||
|
fail "Another backup is already running. Wait for it to complete or check $BACKUP_STATUS."
|
||||||
|
|
||||||
|
require_cmd tar
|
||||||
|
require_cmd sha256sum
|
||||||
|
require_cmd findmnt
|
||||||
|
require_cmd lsblk
|
||||||
|
require_cmd mountpoint
|
||||||
|
require_cmd df
|
||||||
|
require_cmd du
|
||||||
|
require_cmd awk
|
||||||
|
require_cmd sort
|
||||||
|
require_cmd find
|
||||||
|
require_cmd systemctl
|
||||||
|
require_cmd hostname
|
||||||
|
require_cmd date
|
||||||
|
require_cmd python3
|
||||||
|
require_cmd runuser
|
||||||
|
require_cmd mktemp
|
||||||
|
require_cmd flock
|
||||||
|
|
||||||
# ── Detect system role ───────────────────────────────────────────
|
# ── Detect system role ───────────────────────────────────────────
|
||||||
|
|
||||||
ROLE="$(detect_role)"
|
ROLE="$(detect_role)"
|
||||||
@@ -184,7 +319,6 @@ log "Detected role: $ROLE_LABEL"
|
|||||||
|
|
||||||
if [[ -n "${BACKUP_TARGET:-}" ]]; then
|
if [[ -n "${BACKUP_TARGET:-}" ]]; then
|
||||||
TARGET="$BACKUP_TARGET"
|
TARGET="$BACKUP_TARGET"
|
||||||
# Safety: never allow internal drives even if explicitly passed
|
|
||||||
if is_internal "$TARGET"; then
|
if is_internal "$TARGET"; then
|
||||||
fail "Target '$TARGET' is an internal system drive and cannot be used for external backup."
|
fail "Target '$TARGET' is an internal system drive and cannot be used for external backup."
|
||||||
fi
|
fi
|
||||||
@@ -193,106 +327,472 @@ else
|
|||||||
log "Auto-detecting external USB drives…"
|
log "Auto-detecting external USB drives…"
|
||||||
TARGET="$(find_external_drive)"
|
TARGET="$(find_external_drive)"
|
||||||
if [[ -z "$TARGET" ]]; then
|
if [[ -z "$TARGET" ]]; then
|
||||||
fail "No external USB drive detected. " \
|
fail "No external USB drive detected. Please plug in an exFAT-formatted USB drive and try again."
|
||||||
"Please plug in an exFAT-formatted USB drive (≥500 GB) and try again."
|
|
||||||
fi
|
fi
|
||||||
log "Detected external drive: $TARGET"
|
log "Detected external drive: $TARGET"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Verify mount point ───────────────────────────────────────────
|
validate_target_mount "$TARGET"
|
||||||
|
|
||||||
[[ -d "$TARGET" ]] || fail "Target path '$TARGET' does not exist."
|
# ── Plan role-aware source scope and exclusions ─────────────────
|
||||||
mountpoint -q "$TARGET" || fail "Target path '$TARGET' is not a mount point."
|
|
||||||
|
|
||||||
# ── Check free disk space (require ≥ 10 GB) ──────────────────────
|
LND_AVAILABLE=0
|
||||||
|
if [[ "$ROLE" != "desktop" ]] && [[ -d /var/lib/lnd ]] && has_unit "lnd.service"; then
|
||||||
|
LND_AVAILABLE=1
|
||||||
|
fi
|
||||||
|
|
||||||
FREE_KB=$(df -k --output=avail "$TARGET" | tail -1)
|
if [[ "$ROLE" == "desktop" ]]; then
|
||||||
FREE_GB=$(( FREE_KB / 1024 / 1024 ))
|
MANIFEST_EXCLUDES+=("/etc/nix-bitcoin-secrets (not applicable for Desktop Only role)")
|
||||||
|
else
|
||||||
|
MANIFEST_EXCLUDES+=("/etc/nix-bitcoin-secrets skipped when path absent")
|
||||||
|
fi
|
||||||
|
|
||||||
|
MANIFEST_EXCLUDES+=(
|
||||||
|
"/run/media/Second_Drive (never traversed)"
|
||||||
|
"/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node (excluded; internal second-drive data)"
|
||||||
|
"/run/media/Second_Drive/BTCEcoandBackup/Electrs_Data (excluded; internal second-drive data)"
|
||||||
|
"/var/lib/bitcoind (excluded from manual backup)"
|
||||||
|
"/var/lib/electrs (excluded from manual backup)"
|
||||||
|
"/var/lib/*/log and /var/lib/*/logs"
|
||||||
|
"/var/lib/*/cache and /var/lib/*/tmp"
|
||||||
|
"/home/*/.cache (system and application disk caches)"
|
||||||
|
"/home/*/.local/share/Trash and /home/*/Trash (trash directories)"
|
||||||
|
"/home/*/.mozilla/firefox/*/cache2 and */startupCache (Firefox volatile cache — profile data is kept)"
|
||||||
|
"/home/*/.config/google-chrome/*/Cache (Chrome disk cache — profile data is kept)"
|
||||||
|
"/home/*/.config/chromium/*/Cache (Chromium disk cache — profile data is kept)"
|
||||||
|
"/home/*/.config/BraveSoftware/Brave-Browser/*/Cache (Brave disk cache — profile data is kept)"
|
||||||
|
"/home/*/.local/share/baloo (KDE file indexer — rebuilt automatically)"
|
||||||
|
"/home/*/.thumbnails (thumbnail cache — rebuilt automatically)"
|
||||||
|
"/home/*/.xsession-errors and .xsession-errors.old (X session error logs)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ "$ROLE" == "desktop" || "$LND_AVAILABLE" -eq 1 ]]; then
|
||||||
|
MANIFEST_EXCLUDES+=("/var/lib/lnd from general /var/lib archive")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Estimate required free space ─────────────────────────────────
|
||||||
|
|
||||||
|
ETC_NIXOS_BYTES=$(estimate_path_bytes /etc/nixos)
|
||||||
|
HOME_BYTES=$(estimate_path_bytes /home --exclude='*/.cache' --exclude='*/.local/share/Trash' --exclude='*/Trash')
|
||||||
|
SECRETS_BYTES=0
|
||||||
|
if [[ "$ROLE" != "desktop" ]]; then
|
||||||
|
SECRETS_BYTES=$(estimate_path_bytes /etc/nix-bitcoin-secrets)
|
||||||
|
fi
|
||||||
|
|
||||||
|
VAR_LIB_BYTES=$(estimate_path_bytes /var/lib \
|
||||||
|
--exclude='bitcoind' \
|
||||||
|
--exclude='electrs' \
|
||||||
|
--exclude='lnd' \
|
||||||
|
--exclude='*/log' \
|
||||||
|
--exclude='*/logs' \
|
||||||
|
--exclude='*/cache' \
|
||||||
|
--exclude='*/tmp')
|
||||||
|
|
||||||
|
LND_BYTES=0
|
||||||
|
if [[ "$LND_AVAILABLE" -eq 1 ]]; then
|
||||||
|
LND_BYTES=$(estimate_path_bytes /var/lib/lnd)
|
||||||
|
fi
|
||||||
|
|
||||||
|
ESTIMATED_BYTES=$(( ETC_NIXOS_BYTES + HOME_BYTES + SECRETS_BYTES + VAR_LIB_BYTES + LND_BYTES ))
|
||||||
|
# Require 20% growth headroom plus an additional fixed 1 GiB safety margin.
|
||||||
|
REQUIRED_BYTES=$(( ESTIMATED_BYTES + (ESTIMATED_BYTES / 5) + SAFETY_MARGIN_BYTES ))
|
||||||
|
|
||||||
|
FREE_BYTES=$(df -B1 --output=avail "$TARGET" | tail -1 | tr -d ' ')
|
||||||
|
FREE_GB=$(( FREE_BYTES / 1024 / 1024 / 1024 ))
|
||||||
|
REQUIRED_GB=$(( REQUIRED_BYTES / 1024 / 1024 / 1024 ))
|
||||||
|
|
||||||
|
log "Estimated backup size: $(( ESTIMATED_BYTES / 1024 / 1024 / 1024 )) GB"
|
||||||
|
log "Required free space (with safety margin): ${REQUIRED_GB} GB"
|
||||||
log "Free space on drive: ${FREE_GB} GB"
|
log "Free space on drive: ${FREE_GB} GB"
|
||||||
(( FREE_GB >= MIN_FREE_GB )) || \
|
|
||||||
fail "Not enough free space on drive (${FREE_GB} GB available, ${MIN_FREE_GB} GB required)."
|
(( FREE_BYTES >= REQUIRED_BYTES )) || \
|
||||||
|
fail "Not enough free space on drive (${FREE_GB} GB available, ${REQUIRED_GB} GB required)."
|
||||||
|
|
||||||
# ── Create timestamped backup directory ─────────────────────────
|
# ── Create timestamped backup directory ─────────────────────────
|
||||||
|
|
||||||
TIMESTAMP="$(date '+%Y%m%d_%H%M%S')"
|
TIMESTAMP="$(date '+%Y%m%d_%H%M%S')"
|
||||||
BACKUP_DIR="${TARGET}/Sovran_SystemsOS_Backup/${TIMESTAMP}"
|
BACKUP_DIR="${TARGET}/Sovran_SystemsOS_Backup/${TIMESTAMP}"
|
||||||
mkdir -p "$BACKUP_DIR"
|
DB_DUMP_DIR="$BACKUP_DIR/database-dumps"
|
||||||
|
mkdir -p "$BACKUP_DIR" "$DB_DUMP_DIR"
|
||||||
|
# Write an INCOMPLETE marker immediately; it is removed only on successful completion.
|
||||||
|
# Failed runs keep this marker so they are easily identifiable and not confused with
|
||||||
|
# complete backups during restore selection.
|
||||||
|
touch "$BACKUP_DIR/INCOMPLETE"
|
||||||
log "Backup destination: $BACKUP_DIR"
|
log "Backup destination: $BACKUP_DIR"
|
||||||
|
|
||||||
# ── Stage 1/4: NixOS configuration ──────────────────────────────
|
create_tar_archive() {
|
||||||
|
_create_archive_impl STRICT "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Helper: classify a GNU tar (LC_ALL=C) diagnostic line ────────
|
||||||
|
# Returns 0 (true) if the message is an allowlisted transient condition
|
||||||
|
# that is safe to ignore for /home (live desktop file changes).
|
||||||
|
# Only two verified GNU tar messages qualify; all others are fatal.
|
||||||
|
_is_home_warning_allowlisted() {
|
||||||
|
local msg="$1"
|
||||||
|
case "$msg" in
|
||||||
|
*"file changed as we read it"*) return 0 ;;
|
||||||
|
*"file removed before we read it"*) return 0 ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Internal archive builder ──────────────────────────────────────
|
||||||
|
# mode: STRICT — any tar error fails.
|
||||||
|
# HOME — tar exit 1 is accepted when every diagnostic is an
|
||||||
|
# allowlisted transient condition (live file changes).
|
||||||
|
_create_archive_impl() {
|
||||||
|
local mode="$1"; shift
|
||||||
|
local archive_name="$1"; shift
|
||||||
|
local archive_path="$BACKUP_DIR/$archive_name"
|
||||||
|
local partial_path="${archive_path}.partial"
|
||||||
|
local diag_tmp
|
||||||
|
# mktemp creates files with mode 0600 (owner-only) by default, so tar
|
||||||
|
# diagnostics (which may include file paths) are not readable by other users.
|
||||||
|
diag_tmp="$(mktemp /tmp/sovran-tar-diag.XXXXXX)"
|
||||||
|
PARTIAL_FILES+=("$partial_path" "$diag_tmp")
|
||||||
|
|
||||||
|
log "Creating $archive_name …"
|
||||||
|
|
||||||
|
local tar_rc=0
|
||||||
|
LC_ALL=C tar \
|
||||||
|
--create \
|
||||||
|
--file "$partial_path" \
|
||||||
|
--numeric-owner \
|
||||||
|
--acls \
|
||||||
|
--xattrs \
|
||||||
|
--sparse \
|
||||||
|
--one-file-system \
|
||||||
|
"$@" 2>"$diag_tmp" || tar_rc=$?
|
||||||
|
|
||||||
|
# Log every tar diagnostic to the backup log so it appears in the Hub UI
|
||||||
|
local has_fatal_diag=0
|
||||||
|
if [[ -s "$diag_tmp" ]]; then
|
||||||
|
while IFS= read -r diag_line; do
|
||||||
|
[[ -n "$diag_line" ]] || continue
|
||||||
|
log "tar: $diag_line"
|
||||||
|
if [[ "$mode" == "HOME" ]] && ! _is_home_warning_allowlisted "$diag_line"; then
|
||||||
|
has_fatal_diag=1
|
||||||
|
fi
|
||||||
|
done < "$diag_tmp"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local accept=0
|
||||||
|
if [[ "$tar_rc" -eq 0 ]]; then
|
||||||
|
accept=1
|
||||||
|
elif [[ "$mode" == "HOME" && "$tar_rc" -eq 1 && "$has_fatal_diag" -eq 0 ]]; then
|
||||||
|
accept=1
|
||||||
|
log "NOTE: $archive_name completed with nonfatal warnings (live files changed during backup -- this is normal on an active desktop and does not affect the safety of your backup)."
|
||||||
|
ARCHIVE_WARNINGS+=("$archive_name: nonfatal warnings -- live files changed during backup (normal on an active desktop)")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$accept" -eq 0 ]]; then
|
||||||
|
rm -f "$partial_path" "$diag_tmp"
|
||||||
|
if [[ "$tar_rc" -gt 1 ]]; then
|
||||||
|
fail "tar exited with fatal code $tar_rc while creating $archive_name."
|
||||||
|
elif [[ "$mode" == "HOME" ]]; then
|
||||||
|
fail "tar exited with code $tar_rc and unrecognized diagnostics while creating $archive_name."
|
||||||
|
else
|
||||||
|
fail "tar exited with code $tar_rc while creating $archive_name."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify the partial archive is non-empty and readable (spot-check first entry)
|
||||||
|
if [[ ! -s "$partial_path" ]]; then
|
||||||
|
rm -f "$partial_path" "$diag_tmp"
|
||||||
|
fail "Archive $archive_name is empty after creation."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fast readability check: read only the first tar entry header (~512 bytes).
|
||||||
|
# head -1 closes the pipe after one line, sending SIGPIPE to tar which then
|
||||||
|
# exits early — so this is O(1) regardless of archive size.
|
||||||
|
local spot_entry
|
||||||
|
spot_entry="$(LC_ALL=C tar --list --file "$partial_path" 2>/dev/null | head -1 || true)"
|
||||||
|
if [[ -z "$spot_entry" ]]; then
|
||||||
|
rm -f "$partial_path" "$diag_tmp"
|
||||||
|
fail "Archive $archive_name failed readability check."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Atomic publish: rename partial to final path only after acceptance
|
||||||
|
mv "$partial_path" "$archive_path"
|
||||||
|
rm -f "$diag_tmp"
|
||||||
|
ARCHIVE_FILES+=("$archive_name")
|
||||||
|
log "Created archive: $archive_name"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Home archive: tolerates allowlisted live-file warnings ───────
|
||||||
|
create_home_tar_archive() {
|
||||||
|
_create_archive_impl HOME "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
export_postgresql_dumps() {
|
||||||
|
if ! command -v pg_dump >/dev/null 2>&1 || ! has_unit "postgresql.service"; then
|
||||||
|
log "PostgreSQL tools/service not available — skipping PostgreSQL exports."
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! is_unit_active "postgresql.service"; then
|
||||||
|
log "PostgreSQL service is not active — skipping PostgreSQL exports."
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Exporting PostgreSQL globals and databases…"
|
||||||
|
local globals_file="$DB_DUMP_DIR/postgresql_globals.sql"
|
||||||
|
runuser -u postgres -- pg_dumpall --globals-only > "$globals_file" || \
|
||||||
|
fail "Failed to export PostgreSQL globals."
|
||||||
|
DB_DUMP_FILES+=("database-dumps/postgresql_globals.sql")
|
||||||
|
|
||||||
|
local dbs
|
||||||
|
dbs=$(runuser -u postgres -- psql -Atqc "SELECT datname FROM pg_database WHERE datistemplate = false AND datallowconn AND datname <> 'postgres';" 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [[ -z "$dbs" ]]; then
|
||||||
|
log "No non-template PostgreSQL application databases found."
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
while IFS= read -r db; do
|
||||||
|
[[ -n "$db" ]] || continue
|
||||||
|
local safe_db
|
||||||
|
safe_db="$(echo "$db" | tr -c '[:alnum:]_.-' '_')"
|
||||||
|
local out_file="$DB_DUMP_DIR/postgresql_${safe_db}.dump"
|
||||||
|
runuser -u postgres -- pg_dump --format=custom --file "$out_file" "$db" || \
|
||||||
|
fail "Failed to export PostgreSQL database '$db'."
|
||||||
|
DB_DUMP_FILES+=("database-dumps/postgresql_${safe_db}.dump")
|
||||||
|
done <<< "$dbs"
|
||||||
|
}
|
||||||
|
|
||||||
|
export_mariadb_dumps() {
|
||||||
|
local dump_cmd=""
|
||||||
|
local query_cmd=""
|
||||||
|
local mariadb_unit=""
|
||||||
|
|
||||||
|
if command -v mariadb-dump >/dev/null 2>&1; then
|
||||||
|
dump_cmd="mariadb-dump"
|
||||||
|
elif command -v mysqldump >/dev/null 2>&1; then
|
||||||
|
dump_cmd="mysqldump"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v mariadb >/dev/null 2>&1; then
|
||||||
|
query_cmd="mariadb"
|
||||||
|
elif command -v mysql >/dev/null 2>&1; then
|
||||||
|
query_cmd="mysql"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$dump_cmd" || -z "$query_cmd" ]]; then
|
||||||
|
log "MariaDB dump/query tools not available — skipping MariaDB exports."
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if has_unit "mariadb.service" && is_unit_active "mariadb.service"; then
|
||||||
|
mariadb_unit="mariadb.service"
|
||||||
|
elif has_unit "mysql.service" && is_unit_active "mysql.service"; then
|
||||||
|
mariadb_unit="mysql.service"
|
||||||
|
else
|
||||||
|
log "MariaDB service is not active — skipping MariaDB exports."
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Exporting MariaDB databases from ${mariadb_unit}…"
|
||||||
|
|
||||||
|
local dbs
|
||||||
|
dbs=$($query_cmd -N -e "SHOW DATABASES" 2>/dev/null || true)
|
||||||
|
if [[ -z "$dbs" ]]; then
|
||||||
|
log "No MariaDB databases found."
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
while IFS= read -r db; do
|
||||||
|
[[ -n "$db" ]] || continue
|
||||||
|
case "$db" in
|
||||||
|
information_schema|performance_schema|mysql|sys) continue ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
local safe_db out_file
|
||||||
|
safe_db="$(echo "$db" | tr -c '[:alnum:]_.-' '_')"
|
||||||
|
out_file="$DB_DUMP_DIR/mariadb_${safe_db}.sql"
|
||||||
|
|
||||||
|
$dump_cmd --single-transaction --quick --routines --events --triggers "$db" > "$out_file" || \
|
||||||
|
fail "Failed to export MariaDB database '$db'."
|
||||||
|
|
||||||
|
DB_DUMP_FILES+=("database-dumps/mariadb_${safe_db}.sql")
|
||||||
|
done <<< "$dbs"
|
||||||
|
}
|
||||||
|
|
||||||
|
export_lnd_scb_if_possible() {
|
||||||
|
[[ "$LND_AVAILABLE" -eq 1 ]] || return
|
||||||
|
|
||||||
|
local scb_file="$BACKUP_DIR/lnd-static-channel-backup.scb"
|
||||||
|
local attempts=(
|
||||||
|
"lncli exportchanbackup --all --output_file $scb_file"
|
||||||
|
"lncli -n mainnet exportchanbackup --all --output_file $scb_file"
|
||||||
|
"runuser -u lnd -- lncli exportchanbackup --all --output_file $scb_file"
|
||||||
|
"runuser -u lnd -- lncli -n mainnet exportchanbackup --all --output_file $scb_file"
|
||||||
|
)
|
||||||
|
|
||||||
|
if ! command -v lncli >/dev/null 2>&1; then
|
||||||
|
log "lncli not available — skipping Static Channel Backup export."
|
||||||
|
LND_BACKUP_NOTES+=("Static Channel Backup skipped (lncli unavailable)")
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! is_unit_active "lnd.service"; then
|
||||||
|
log "LND service is not active — skipping Static Channel Backup export."
|
||||||
|
LND_BACKUP_NOTES+=("Static Channel Backup skipped (lnd.service inactive)")
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Exporting LND Static Channel Backup…"
|
||||||
|
local attempt
|
||||||
|
for attempt in "${attempts[@]}"; do
|
||||||
|
if eval "$attempt" >/dev/null 2>&1; then
|
||||||
|
DB_DUMP_FILES+=("lnd-static-channel-backup.scb")
|
||||||
|
LND_BACKUP_NOTES+=("Static Channel Backup exported via lncli")
|
||||||
|
log "LND Static Channel Backup exported."
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
log "WARNING: Unable to export LND Static Channel Backup with available lncli invocations."
|
||||||
|
LND_BACKUP_NOTES+=("Static Channel Backup export failed (no compatible lncli invocation succeeded)")
|
||||||
|
}
|
||||||
|
|
||||||
|
capture_active_lnd_dependents() {
|
||||||
|
[[ "$LND_AVAILABLE" -eq 1 ]] || return
|
||||||
|
|
||||||
|
LND_UNITS_TO_RESTART=()
|
||||||
|
local raw_units=""
|
||||||
|
raw_units=$(systemctl show lnd.service -p RequiredBy -p WantedBy --value 2>/dev/null | tr ' ' '\n' | grep '\.service$' | sort -u || true)
|
||||||
|
|
||||||
|
while IFS= read -r unit; do
|
||||||
|
[[ -n "$unit" ]] || continue
|
||||||
|
if is_unit_active "$unit"; then
|
||||||
|
LND_UNITS_TO_RESTART+=("$unit")
|
||||||
|
fi
|
||||||
|
done <<< "$raw_units"
|
||||||
|
|
||||||
|
if is_unit_active "lnd.service"; then
|
||||||
|
LND_UNITS_TO_RESTART+=("lnd.service")
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_lnd_stack_if_needed() {
|
||||||
|
[[ "$LND_AVAILABLE" -eq 1 ]] || return
|
||||||
|
|
||||||
|
capture_active_lnd_dependents
|
||||||
|
|
||||||
|
if [[ "${#LND_UNITS_TO_RESTART[@]}" -eq 0 ]]; then
|
||||||
|
log "No active LND-related services needed stopping."
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Stopping active services that depend on LND for clean /var/lib/lnd archive…"
|
||||||
|
|
||||||
|
local unit
|
||||||
|
for unit in "${LND_UNITS_TO_RESTART[@]}"; do
|
||||||
|
if [[ "$unit" == "lnd.service" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
systemctl stop "$unit" || fail "Failed to stop dependent service: $unit"
|
||||||
|
log "Stopped $unit"
|
||||||
|
done
|
||||||
|
|
||||||
|
if printf '%s\n' "${LND_UNITS_TO_RESTART[@]}" | grep -qx 'lnd.service'; then
|
||||||
|
systemctl stop lnd.service || fail "Failed to stop lnd.service"
|
||||||
|
log "Stopped lnd.service"
|
||||||
|
fi
|
||||||
|
|
||||||
|
LND_STOPPED=1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Stage 1/5: NixOS configuration ──────────────────────────────
|
||||||
|
|
||||||
log ""
|
log ""
|
||||||
log "── Stage 1/4: NixOS configuration (/etc/nixos) ──────────────"
|
log "── Stage 1/5: NixOS configuration (/etc/nixos) ──────────────"
|
||||||
if [[ -d /etc/nixos ]]; then
|
if [[ -d /etc/nixos ]]; then
|
||||||
rsync -a --info=progress2 /etc/nixos/ "$BACKUP_DIR/nixos/" 2>&1 | tee -a "$BACKUP_LOG" || \
|
create_tar_archive "etc-nixos.tar" -C / etc/nixos
|
||||||
fail "Stage 1 failed while copying /etc/nixos"
|
|
||||||
log "Stage 1 complete."
|
log "Stage 1 complete."
|
||||||
else
|
else
|
||||||
log "WARNING: /etc/nixos not found — skipping."
|
log "WARNING: /etc/nixos not found — skipping."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Stage 2/4: Secrets ──────────────────────────────────────────
|
# ── Stage 2/5: Secrets ──────────────────────────────────────────
|
||||||
|
|
||||||
log ""
|
log ""
|
||||||
log "── Stage 2/4: Secrets ───────────────────────────────────────"
|
log "── Stage 2/5: Secrets (/etc/nix-bitcoin-secrets) ───────────"
|
||||||
mkdir -p "$BACKUP_DIR/secrets"
|
|
||||||
|
|
||||||
if [[ "$ROLE" == "desktop" ]]; then
|
if [[ "$ROLE" == "desktop" ]]; then
|
||||||
log "Skipping /etc/nix-bitcoin-secrets — not applicable for Desktop Only role."
|
log "Skipping /etc/nix-bitcoin-secrets — not applicable for Desktop Only role."
|
||||||
|
elif [[ -e /etc/nix-bitcoin-secrets ]]; then
|
||||||
|
create_tar_archive "etc-nix-bitcoin-secrets.tar" -C / etc/nix-bitcoin-secrets
|
||||||
else
|
else
|
||||||
if [[ -e /etc/nix-bitcoin-secrets ]]; then
|
log "(not found: /etc/nix-bitcoin-secrets — skipping)"
|
||||||
rsync -a --info=progress2 /etc/nix-bitcoin-secrets "$BACKUP_DIR/secrets/" 2>&1 | tee -a "$BACKUP_LOG" || \
|
|
||||||
log "WARNING: Could not copy /etc/nix-bitcoin-secrets — continuing."
|
|
||||||
else
|
|
||||||
log " (not found: /etc/nix-bitcoin-secrets — skipping)"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "Stage 2 complete."
|
log "Stage 2 complete."
|
||||||
|
|
||||||
# ── Stage 3/4: Home directory ────────────────────────────────────
|
# ── Stage 3/5: Home directory ───────────────────────────────────
|
||||||
|
|
||||||
log ""
|
log ""
|
||||||
log "── Stage 3/4: Home directory (/home) ───────────────────────"
|
log "── Stage 3/5: Home directory (/home) ───────────────────────"
|
||||||
if [[ -d /home ]]; then
|
if [[ -d /home ]]; then
|
||||||
rsync -a --info=progress2 \
|
create_home_tar_archive "home.tar" \
|
||||||
--exclude='.cache/' \
|
-C / \
|
||||||
--exclude='.local/share/Trash/' \
|
--exclude='home/*/.cache' \
|
||||||
--exclude='*/Trash/' \
|
--exclude='home/*/.local/share/Trash' \
|
||||||
/home/ "$BACKUP_DIR/home/" 2>&1 | tee -a "$BACKUP_LOG" || \
|
--exclude='home/*/Trash' \
|
||||||
fail "Stage 3 failed while copying /home"
|
--exclude='home/*/.mozilla/firefox/*/cache2' \
|
||||||
|
--exclude='home/*/.mozilla/firefox/*/startupCache' \
|
||||||
|
--exclude='home/*/.mozilla/firefox/*/thumbnails' \
|
||||||
|
--exclude='home/*/.config/google-chrome/*/Cache' \
|
||||||
|
--exclude='home/*/.config/google-chrome/*/Code Cache' \
|
||||||
|
--exclude='home/*/.config/chromium/*/Cache' \
|
||||||
|
--exclude='home/*/.config/chromium/*/Code Cache' \
|
||||||
|
--exclude='home/*/.config/BraveSoftware/Brave-Browser/*/Cache' \
|
||||||
|
--exclude='home/*/.config/BraveSoftware/Brave-Browser/*/Code Cache' \
|
||||||
|
--exclude='home/*/.local/share/baloo' \
|
||||||
|
--exclude='home/*/.thumbnails' \
|
||||||
|
--exclude='home/*/.xsession-errors' \
|
||||||
|
--exclude='home/*/.xsession-errors.old' \
|
||||||
|
home
|
||||||
log "Stage 3 complete."
|
log "Stage 3 complete."
|
||||||
else
|
else
|
||||||
log "WARNING: /home not found — skipping."
|
log "WARNING: /home not found — skipping."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Stage 4/4: System data ───────────────────────────────────────
|
# ── Stage 4/5: Database exports + LND artifacts ────────────────
|
||||||
|
|
||||||
log ""
|
log ""
|
||||||
log "── Stage 4/4: System data (/var/lib) ────────────────────────"
|
log "── Stage 4/5: Database and LND consistency exports ─────────"
|
||||||
if [[ "$ROLE" == "desktop" ]]; then
|
export_postgresql_dumps
|
||||||
if [[ -d /var/lib ]]; then
|
export_mariadb_dumps
|
||||||
rsync -a --info=progress2 \
|
export_lnd_scb_if_possible
|
||||||
--filter='- /lnd/***' \
|
|
||||||
--exclude='logs/' \
|
if [[ "$LND_AVAILABLE" -eq 1 ]]; then
|
||||||
--exclude='log/' \
|
stop_lnd_stack_if_needed
|
||||||
--exclude='*/logs/' \
|
create_tar_archive "var-lib-lnd-clean.tar" -C / var/lib/lnd
|
||||||
--exclude='*/log/' \
|
LND_BACKUP_NOTES+=("Created clean raw /var/lib/lnd archive after controlled service stop")
|
||||||
/var/lib/ "$BACKUP_DIR/var-lib/" 2>&1 | tee -a "$BACKUP_LOG" || \
|
fi
|
||||||
fail "Stage 4 failed while copying /var/lib for Desktop Only role"
|
|
||||||
log "Stage 4 complete (Desktop Only role excludes /var/lib/lnd)."
|
log "Stage 4 complete."
|
||||||
else
|
|
||||||
log "WARNING: /var/lib not found — skipping."
|
# ── Stage 5/5: System data ──────────────────────────────────────
|
||||||
|
|
||||||
|
log ""
|
||||||
|
log "── Stage 5/5: System data (/var/lib) ───────────────────────"
|
||||||
|
if [[ -d /var/lib ]]; then
|
||||||
|
VAR_LIB_EXCLUDES=(
|
||||||
|
--exclude='var/lib/bitcoind'
|
||||||
|
--exclude='var/lib/electrs'
|
||||||
|
--exclude='var/lib/*/log'
|
||||||
|
--exclude='var/lib/*/logs'
|
||||||
|
--exclude='var/lib/*/cache'
|
||||||
|
--exclude='var/lib/*/tmp'
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ "$ROLE" == "desktop" || "$LND_AVAILABLE" -eq 1 ]]; then
|
||||||
|
VAR_LIB_EXCLUDES+=(--exclude='var/lib/lnd')
|
||||||
fi
|
fi
|
||||||
elif [[ -d /var/lib ]]; then
|
|
||||||
rsync -a --info=progress2 \
|
create_tar_archive "var-lib.tar" -C / "${VAR_LIB_EXCLUDES[@]}" var/lib
|
||||||
--exclude='logs/' \
|
log "Stage 5 complete."
|
||||||
--exclude='log/' \
|
|
||||||
--exclude='*/logs/' \
|
|
||||||
--exclude='*/log/' \
|
|
||||||
/var/lib/ "$BACKUP_DIR/var-lib/" 2>&1 | tee -a "$BACKUP_LOG" || \
|
|
||||||
fail "Stage 4 failed while copying /var/lib"
|
|
||||||
log "Stage 4 complete."
|
|
||||||
else
|
else
|
||||||
log "WARNING: /var/lib not found — skipping."
|
log "WARNING: /var/lib not found — skipping."
|
||||||
fi
|
fi
|
||||||
@@ -301,21 +801,110 @@ fi
|
|||||||
|
|
||||||
log ""
|
log ""
|
||||||
log "Generating BACKUP_MANIFEST.txt …"
|
log "Generating BACKUP_MANIFEST.txt …"
|
||||||
|
MANIFEST_FILE="$BACKUP_DIR/BACKUP_MANIFEST.txt"
|
||||||
|
CHECKSUM_FILE="$BACKUP_DIR/SHA256SUMS.txt"
|
||||||
|
|
||||||
{
|
{
|
||||||
echo "Sovran_SystemsOS Backup Manifest"
|
echo "Sovran_SystemsOS Backup Manifest"
|
||||||
echo "Generated: $(date)"
|
echo "Generated: $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
||||||
|
echo "Timestamp: $TIMESTAMP"
|
||||||
echo "Hostname: $(hostname)"
|
echo "Hostname: $(hostname)"
|
||||||
echo "Role: $ROLE_LABEL"
|
echo "Role: $ROLE_LABEL"
|
||||||
echo "Target: $TARGET"
|
echo "Target: $TARGET"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Contents:"
|
echo "Source paths included:"
|
||||||
find "$BACKUP_DIR" -mindepth 1 -maxdepth 2 | sort
|
echo "- /etc/nixos"
|
||||||
} > "$BACKUP_DIR/BACKUP_MANIFEST.txt"
|
echo "- /home"
|
||||||
log "Manifest written to $BACKUP_DIR/BACKUP_MANIFEST.txt"
|
if [[ "$ROLE" != "desktop" ]]; then
|
||||||
|
echo "- /etc/nix-bitcoin-secrets (when present)"
|
||||||
|
fi
|
||||||
|
echo "- /var/lib"
|
||||||
|
echo ""
|
||||||
|
echo "Exclusions:"
|
||||||
|
for ex in "${MANIFEST_EXCLUDES[@]}"; do
|
||||||
|
echo "- $ex"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
echo "Archives:"
|
||||||
|
for archive in "${ARCHIVE_FILES[@]}"; do
|
||||||
|
echo "- $archive"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
echo "Database and LND exports:"
|
||||||
|
if [[ "${#DB_DUMP_FILES[@]}" -eq 0 && "${#LND_BACKUP_NOTES[@]}" -eq 0 ]]; then
|
||||||
|
echo "- none"
|
||||||
|
else
|
||||||
|
for dump in "${DB_DUMP_FILES[@]}"; do
|
||||||
|
echo "- $dump"
|
||||||
|
done
|
||||||
|
for note in "${LND_BACKUP_NOTES[@]}"; do
|
||||||
|
echo "- $note"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
echo "Restore guidance:"
|
||||||
|
echo "- Verify artifacts: cd <backup_dir> && sha256sum -c SHA256SUMS.txt"
|
||||||
|
echo "- Extract a tar archive: sudo tar --acls --xattrs --numeric-owner -xpf <archive>.tar -C /"
|
||||||
|
echo "- PostgreSQL globals: sudo -u postgres psql -f database-dumps/postgresql_globals.sql"
|
||||||
|
echo "- PostgreSQL DB dump: sudo -u postgres pg_restore --create --clean --if-exists -d postgres database-dumps/postgresql_<db>.dump"
|
||||||
|
echo "- MariaDB DB dump: mariadb <db_name> < database-dumps/mariadb_<db>.sql"
|
||||||
|
echo "- LND SCB: keep lnd-static-channel-backup.scb with wallet seed for channel recovery procedures"
|
||||||
|
echo "- Note: when restoring /var/lib, exclude raw DB directories (var/lib/postgresql, var/lib/mysql)"
|
||||||
|
echo " and restore from native dumps instead for PostgreSQL and MariaDB"
|
||||||
|
echo ""
|
||||||
|
echo "Nonfatal warnings:"
|
||||||
|
if [[ "${#ARCHIVE_WARNINGS[@]}" -eq 0 ]]; then
|
||||||
|
echo "- none"
|
||||||
|
else
|
||||||
|
for warning in "${ARCHIVE_WARNINGS[@]}"; do
|
||||||
|
echo "- $warning"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
echo "Important note: Bitcoin blockchain and Electrs index data are intentionally excluded"
|
||||||
|
echo "from manual external backup because they already live on the internal second drive"
|
||||||
|
echo "(/run/media/Second_Drive) and are reconstructable/internal-backup data."
|
||||||
|
echo ""
|
||||||
|
echo "Artifact listing:"
|
||||||
|
# INCOMPLETE exists at this point (created at backup start); BACKUP_COMPLETE does not
|
||||||
|
# exist yet (written after checksums). Excluding both marker files here is intentional:
|
||||||
|
# INCOMPLETE is excluded so it doesn't appear as a data artifact, and BACKUP_COMPLETE
|
||||||
|
# is excluded defensively for consistency should the ordering ever change.
|
||||||
|
find "$BACKUP_DIR" -mindepth 1 -maxdepth 2 -type f \
|
||||||
|
! -name 'INCOMPLETE' ! -name 'BACKUP_COMPLETE' -print0 | sort -z | tr '\0' '\n'
|
||||||
|
} > "$MANIFEST_FILE"
|
||||||
|
|
||||||
|
# ── Generate checksums for all backup artifacts ─────────────────
|
||||||
|
|
||||||
|
log "Generating SHA-256 checksums …"
|
||||||
|
(
|
||||||
|
cd "$BACKUP_DIR"
|
||||||
|
while IFS= read -r -d '' file; do
|
||||||
|
sha256sum "$file"
|
||||||
|
done < <(find . -mindepth 1 -maxdepth 2 -type f ! -name 'SHA256SUMS.txt' ! -name 'INCOMPLETE' ! -name 'BACKUP_COMPLETE' -print0 | sort -z)
|
||||||
|
) > "$CHECKSUM_FILE"
|
||||||
|
|
||||||
|
log "Manifest written to $MANIFEST_FILE"
|
||||||
|
log "Checksums written to $CHECKSUM_FILE"
|
||||||
|
|
||||||
# ── Done ─────────────────────────────────────────────────────────
|
# ── Done ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
log ""
|
log ""
|
||||||
|
if [[ "${#ARCHIVE_WARNINGS[@]}" -gt 0 ]]; then
|
||||||
|
log "Backup completed with nonfatal warnings:"
|
||||||
|
for warning in "${ARCHIVE_WARNINGS[@]}"; do
|
||||||
|
log " WARNING: $warning"
|
||||||
|
done
|
||||||
|
log "Your important data is backed up. The warnings above indicate files that changed"
|
||||||
|
log "during backup, which is normal on an active desktop and does not affect your backup."
|
||||||
|
log ""
|
||||||
|
fi
|
||||||
log "All Finished! Your data is now backed up to a third location."
|
log "All Finished! Your data is now backed up to a third location."
|
||||||
log "Please eject the drive safely before removing it from your Sovran Pro."
|
log "Please eject the drive safely before removing it from your Sovran Pro."
|
||||||
|
|
||||||
|
# Remove incomplete marker and write completion marker only after all work succeeds
|
||||||
|
rm -f "$BACKUP_DIR/INCOMPLETE"
|
||||||
|
echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" > "$BACKUP_DIR/BACKUP_COMPLETE"
|
||||||
|
|
||||||
|
BACKUP_COMPLETE=1
|
||||||
set_status "SUCCESS"
|
set_status "SUCCESS"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import contextlib
|
|||||||
import glob
|
import glob
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -80,6 +81,15 @@ DOMAINS_DIR = "/var/lib/domains"
|
|||||||
NOSTR_NPUB_FILE = "/var/lib/secrets/nostr_npub"
|
NOSTR_NPUB_FILE = "/var/lib/secrets/nostr_npub"
|
||||||
NJALLA_SCRIPT = "/var/lib/njalla/njalla.sh"
|
NJALLA_SCRIPT = "/var/lib/njalla/njalla.sh"
|
||||||
|
|
||||||
|
# Systemd service that rewrites the Sovran-managed /etc/hosts loopback block
|
||||||
|
SOVRAN_HOSTS_SERVICE = "sovran-hosts-update.service"
|
||||||
|
|
||||||
|
# Domain keys that produce a public HTTPS virtual host via Caddy
|
||||||
|
_SERVICE_DOMAIN_KEYS = frozenset([
|
||||||
|
"matrix", "wordpress", "nextcloud", "btcpayserver",
|
||||||
|
"vaultwarden", "haven", "element-calling",
|
||||||
|
])
|
||||||
|
|
||||||
INTERNAL_IP_FILE = "/var/lib/secrets/internal-ip"
|
INTERNAL_IP_FILE = "/var/lib/secrets/internal-ip"
|
||||||
ZEUS_CONNECT_FILE = "/var/lib/secrets/zeus-connect-url"
|
ZEUS_CONNECT_FILE = "/var/lib/secrets/zeus-connect-url"
|
||||||
|
|
||||||
@@ -964,18 +974,94 @@ def _check_port_status(
|
|||||||
return "closed"
|
return "closed"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Regex for validating domain values written into /etc/hosts. Rejects anything
|
||||||
|
# containing whitespace, newlines, or characters that could escape a hosts entry.
|
||||||
|
# NOTE: The equivalent pattern in modules/core/local-domain-loopback.nix (shell
|
||||||
|
# grep -E) must be kept in sync with this Python regex.
|
||||||
|
_SAFE_DOMAIN_RE = re.compile(
|
||||||
|
r'^(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_domain_value(domain: str) -> bool:
|
||||||
|
"""Return True if *domain* is a valid hostname safe to write into /etc/hosts.
|
||||||
|
|
||||||
|
Rejects values containing whitespace, newlines, or other characters that
|
||||||
|
could inject additional entries or corrupt the hosts file.
|
||||||
|
"""
|
||||||
|
if not domain or len(domain) > 253:
|
||||||
|
return False
|
||||||
|
# Guard against newline / whitespace injection before regex check.
|
||||||
|
if any(c in domain for c in ('\n', '\r', ' ', '\t', '#')):
|
||||||
|
return False
|
||||||
|
return bool(_SAFE_DOMAIN_RE.match(domain))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_loopback_address(ip: str) -> bool:
|
||||||
|
"""Return True if *ip* is a loopback address (127.0.0.0/8 or ::1)."""
|
||||||
|
try:
|
||||||
|
return ipaddress.ip_address(ip).is_loopback
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_all_addresses(domain: str) -> list[str]:
|
||||||
|
"""Return all unique IP addresses that *domain* resolves to, or an empty list.
|
||||||
|
|
||||||
|
The first element is the address that the system resolver would normally
|
||||||
|
use for a connection. All elements are checked when determining whether
|
||||||
|
any address matches the expected public IP or is a loopback address.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
results = socket.getaddrinfo(domain, None)
|
||||||
|
unique_addresses: list[str] = []
|
||||||
|
for r in results:
|
||||||
|
addr = r[4][0]
|
||||||
|
if addr not in unique_addresses:
|
||||||
|
unique_addresses.append(addr)
|
||||||
|
return unique_addresses
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _trigger_hosts_update() -> None:
|
||||||
|
"""Start the sovran-hosts-update systemd service (best-effort, no-op if unavailable)."""
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["systemctl", "start", SOVRAN_HOSTS_SERVICE],
|
||||||
|
timeout=30,
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _check_domain_reachable(domain: str) -> dict:
|
def _check_domain_reachable(domain: str) -> dict:
|
||||||
"""Curl the domain to verify end-to-end HTTPS reachability."""
|
"""Check HTTPS reachability for *domain* via local Caddy (loopback).
|
||||||
|
|
||||||
|
Using ``--resolve`` ensures the request reaches Caddy on this computer
|
||||||
|
without depending on router NAT loopback or the public DNS result.
|
||||||
|
A successful local check is sufficient to confirm that Caddy and the
|
||||||
|
virtual-host configuration are working correctly.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "10", f"https://{domain}"],
|
[
|
||||||
|
"curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}",
|
||||||
|
"--max-time", "10",
|
||||||
|
"--resolve", f"{domain}:443:127.0.0.1",
|
||||||
|
"--resolve", f"{domain}:80:127.0.0.1",
|
||||||
|
f"https://{domain}",
|
||||||
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=15,
|
timeout=15,
|
||||||
)
|
)
|
||||||
status_code = result.stdout.strip()
|
status_code = result.stdout.strip()
|
||||||
if status_code and status_code.isdigit() and int(status_code) > 0:
|
if status_code and status_code.isdigit() and int(status_code) > 0:
|
||||||
return {"reachable": True, "status_code": int(status_code)}
|
return {"reachable": True, "status_code": int(status_code), "via_loopback": True}
|
||||||
return {"reachable": False, "error": result.stderr.strip() or "No response"}
|
return {"reachable": False, "error": result.stderr.strip() or "No response"}
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return {"reachable": False, "error": "timeout"}
|
return {"reachable": False, "error": "timeout"}
|
||||||
@@ -984,25 +1070,28 @@ def _check_domain_reachable(domain: str) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def _check_domain_health_fast(domain: str | None, external_ip: str) -> bool:
|
def _check_domain_health_fast(domain: str | None, external_ip: str) -> bool:
|
||||||
"""Fast domain issue check for tile health (no curl/subprocess calls)."""
|
"""Fast domain issue check for tile health (no curl/subprocess calls).
|
||||||
|
|
||||||
|
Returns ``True`` when a domain issue is detected that warrants
|
||||||
|
``needs_attention``, ``False`` otherwise.
|
||||||
|
Loopback resolution is treated as an intentional server-local override,
|
||||||
|
not a DNS mismatch.
|
||||||
|
"""
|
||||||
if not domain:
|
if not domain:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
resolved_ip: str | None = None
|
addrs = _resolve_all_addresses(domain)
|
||||||
try:
|
if not addrs:
|
||||||
results = socket.getaddrinfo(domain, None)
|
|
||||||
if results:
|
|
||||||
resolved_ip = results[0][4][0]
|
|
||||||
except socket.gaierror:
|
|
||||||
resolved_ip = None
|
|
||||||
except Exception:
|
|
||||||
resolved_ip = None
|
|
||||||
|
|
||||||
if not resolved_ip:
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# If every resolved address is loopback the intentional /etc/hosts
|
||||||
|
# override is in place — this is healthy, not a mismatch.
|
||||||
|
if all(_is_loopback_address(a) for a in addrs):
|
||||||
|
return False
|
||||||
|
|
||||||
if external_ip == "unavailable":
|
if external_ip == "unavailable":
|
||||||
return False
|
return False
|
||||||
return resolved_ip != external_ip
|
return not any(a == external_ip for a in addrs)
|
||||||
|
|
||||||
|
|
||||||
def _is_domain_reachable_cached(domain: str) -> bool | None:
|
def _is_domain_reachable_cached(domain: str) -> bool | None:
|
||||||
@@ -1070,15 +1159,8 @@ def _evaluate_domain_checklist(
|
|||||||
"detail": domain,
|
"detail": domain,
|
||||||
})
|
})
|
||||||
|
|
||||||
resolved_ip: str | None = None
|
addrs = _resolve_all_addresses(domain)
|
||||||
try:
|
resolved_ip: str | None = addrs[0] if addrs else None
|
||||||
results = socket.getaddrinfo(domain, None)
|
|
||||||
if results:
|
|
||||||
resolved_ip = results[0][4][0]
|
|
||||||
except socket.gaierror:
|
|
||||||
resolved_ip = None
|
|
||||||
except Exception:
|
|
||||||
resolved_ip = None
|
|
||||||
|
|
||||||
if not resolved_ip:
|
if not resolved_ip:
|
||||||
domain_status = {
|
domain_status = {
|
||||||
@@ -1105,7 +1187,31 @@ def _evaluate_domain_checklist(
|
|||||||
"has_issues": True,
|
"has_issues": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
if external_ip == "unavailable":
|
# Detect intentional server-local loopback override from /etc/hosts.
|
||||||
|
# When all addresses are loopback the public DNS is not checked via the
|
||||||
|
# system resolver (which would always return the override). We proceed
|
||||||
|
# to the reachability check so Caddy health can still be verified.
|
||||||
|
loopback_override = all(_is_loopback_address(a) for a in addrs)
|
||||||
|
|
||||||
|
if loopback_override:
|
||||||
|
domain_status = {
|
||||||
|
"status": "local_override",
|
||||||
|
"resolved_ip": resolved_ip,
|
||||||
|
"expected_ip": external_ip,
|
||||||
|
}
|
||||||
|
steps.append({
|
||||||
|
"step": 2,
|
||||||
|
"label": "DNS / Local Override",
|
||||||
|
"status": "ok",
|
||||||
|
"detail": (
|
||||||
|
"Server-local loopback override is active — this computer routes the domain "
|
||||||
|
"directly to Caddy without going through the router. "
|
||||||
|
"Public DNS cannot be verified from this computer while the override is in place. "
|
||||||
|
"To check your public DNS from outside, use a tool such as "
|
||||||
|
"https://dnschecker.org or run: dig @1.1.1.1 " + domain
|
||||||
|
),
|
||||||
|
})
|
||||||
|
elif external_ip == "unavailable":
|
||||||
domain_status = {
|
domain_status = {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"resolved_ip": resolved_ip,
|
"resolved_ip": resolved_ip,
|
||||||
@@ -1117,7 +1223,7 @@ def _evaluate_domain_checklist(
|
|||||||
"status": "warning",
|
"status": "warning",
|
||||||
"detail": f"Resolves to {resolved_ip} (external IP unavailable for comparison)",
|
"detail": f"Resolves to {resolved_ip} (external IP unavailable for comparison)",
|
||||||
})
|
})
|
||||||
elif resolved_ip != external_ip:
|
elif not any(a == external_ip for a in addrs):
|
||||||
domain_status = {
|
domain_status = {
|
||||||
"status": "dns_mismatch",
|
"status": "dns_mismatch",
|
||||||
"resolved_ip": resolved_ip,
|
"resolved_ip": resolved_ip,
|
||||||
@@ -1345,6 +1451,12 @@ def _read_backup_status() -> str:
|
|||||||
return "IDLE"
|
return "IDLE"
|
||||||
|
|
||||||
|
|
||||||
|
def _write_backup_status(value: str) -> None:
|
||||||
|
"""Write backup status file."""
|
||||||
|
with open(BACKUP_STATUS, "w") as f:
|
||||||
|
f.write(value)
|
||||||
|
|
||||||
|
|
||||||
def _read_backup_log(offset: int = 0) -> tuple[str, int]:
|
def _read_backup_log(offset: int = 0) -> tuple[str, int]:
|
||||||
"""Read the backup log file from the given byte offset.
|
"""Read the backup log file from the given byte offset.
|
||||||
Returns (new_text, new_offset)."""
|
Returns (new_text, new_offset)."""
|
||||||
@@ -1361,6 +1473,12 @@ def _read_backup_log(offset: int = 0) -> tuple[str, int]:
|
|||||||
return "", 0
|
return "", 0
|
||||||
|
|
||||||
|
|
||||||
|
def _append_backup_log(line: str) -> None:
|
||||||
|
"""Append one line to backup log."""
|
||||||
|
with open(BACKUP_LOG, "a") as f:
|
||||||
|
f.write(line.rstrip("\n") + "\n")
|
||||||
|
|
||||||
|
|
||||||
_INTERNAL_LABELS = {"BTCEcoandBackup", "sovran_systemsos"}
|
_INTERNAL_LABELS = {"BTCEcoandBackup", "sovran_systemsos"}
|
||||||
_INTERNAL_MOUNTS = {"/", "/boot/efi"}
|
_INTERNAL_MOUNTS = {"/", "/boot/efi"}
|
||||||
_INTERNAL_MOUNT_PREFIX = "/run/media/Second_Drive"
|
_INTERNAL_MOUNT_PREFIX = "/run/media/Second_Drive"
|
||||||
@@ -1375,6 +1493,41 @@ def _is_internal_mount(mnt: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_supported_backup_fstype(path: str, fstype: str) -> bool:
|
||||||
|
"""Return whether the target filesystem type is supported for manual backup."""
|
||||||
|
fstype = (fstype or "").lower()
|
||||||
|
if fstype == "exfat":
|
||||||
|
return True
|
||||||
|
if fstype != "fuseblk":
|
||||||
|
return False
|
||||||
|
|
||||||
|
src_dev = ""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["findmnt", "-n", "-o", "SOURCE", "-T", path],
|
||||||
|
capture_output=True, text=True, timeout=5,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
src_dev = result.stdout.strip()
|
||||||
|
except Exception:
|
||||||
|
src_dev = ""
|
||||||
|
|
||||||
|
if not src_dev:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for cmd in (
|
||||||
|
["lsblk", "-no", "FSTYPE", src_dev],
|
||||||
|
["blkid", "-o", "value", "-s", "TYPE", src_dev],
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
|
||||||
|
if result.returncode == 0 and result.stdout.strip().lower() in {"exfat", "fuseblk"}:
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _detect_external_drives() -> list[dict]:
|
def _detect_external_drives() -> list[dict]:
|
||||||
"""Scan for mounted external USB drives.
|
"""Scan for mounted external USB drives.
|
||||||
|
|
||||||
@@ -1384,7 +1537,8 @@ def _detect_external_drives() -> list[dict]:
|
|||||||
/run/media/ directly if lsblk is unavailable, applying the same
|
/run/media/ directly if lsblk is unavailable, applying the same
|
||||||
label/path filters.
|
label/path filters.
|
||||||
|
|
||||||
Returns a list of dicts with name, path, free_gb, total_gb.
|
Returns:
|
||||||
|
list[dict]: Each dict contains name, path, free_gb, total_gb, fstype.
|
||||||
"""
|
"""
|
||||||
import json as _json
|
import json as _json
|
||||||
import subprocess as _subprocess
|
import subprocess as _subprocess
|
||||||
@@ -1395,7 +1549,7 @@ def _detect_external_drives() -> list[dict]:
|
|||||||
# ── Primary path: lsblk JSON ────────────────────────────────
|
# ── Primary path: lsblk JSON ────────────────────────────────
|
||||||
try:
|
try:
|
||||||
result = _subprocess.run(
|
result = _subprocess.run(
|
||||||
["lsblk", "-J", "-o", "NAME,LABEL,MOUNTPOINT,HOTPLUG,RM,TYPE"],
|
["lsblk", "-J", "-o", "NAME,LABEL,FSTYPE,MOUNTPOINT,HOTPLUG,RM,TYPE"],
|
||||||
capture_output=True, text=True, timeout=10
|
capture_output=True, text=True, timeout=10
|
||||||
)
|
)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
@@ -1413,6 +1567,7 @@ def _detect_external_drives() -> list[dict]:
|
|||||||
hotplug = str(dev.get("hotplug", "0"))
|
hotplug = str(dev.get("hotplug", "0"))
|
||||||
rm = str(dev.get("rm", "0"))
|
rm = str(dev.get("rm", "0"))
|
||||||
label = dev.get("label") or ""
|
label = dev.get("label") or ""
|
||||||
|
fstype = (dev.get("fstype") or "").lower()
|
||||||
mountpoint = dev.get("mountpoint") or ""
|
mountpoint = dev.get("mountpoint") or ""
|
||||||
|
|
||||||
if dev_type not in ("part", "disk"):
|
if dev_type not in ("part", "disk"):
|
||||||
@@ -1438,6 +1593,7 @@ def _detect_external_drives() -> list[dict]:
|
|||||||
"path": mountpoint,
|
"path": mountpoint,
|
||||||
"free_gb": free_gb,
|
"free_gb": free_gb,
|
||||||
"total_gb": total_gb,
|
"total_gb": total_gb,
|
||||||
|
"fstype": fstype,
|
||||||
})
|
})
|
||||||
seen_paths.add(mountpoint)
|
seen_paths.add(mountpoint)
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -1471,11 +1627,20 @@ def _detect_external_drives() -> list[dict]:
|
|||||||
st = os.statvfs(drive_path)
|
st = os.statvfs(drive_path)
|
||||||
total_gb = round((st.f_blocks * st.f_frsize) / (1024 ** 3), 1)
|
total_gb = round((st.f_blocks * st.f_frsize) / (1024 ** 3), 1)
|
||||||
free_gb = round((st.f_bavail * st.f_frsize) / (1024 ** 3), 1)
|
free_gb = round((st.f_bavail * st.f_frsize) / (1024 ** 3), 1)
|
||||||
|
fstype = ""
|
||||||
|
try:
|
||||||
|
fstype = _subprocess.run(
|
||||||
|
["findmnt", "-n", "-o", "FSTYPE", "-T", drive_path],
|
||||||
|
capture_output=True, text=True, timeout=5
|
||||||
|
).stdout.strip().lower()
|
||||||
|
except Exception:
|
||||||
|
fstype = ""
|
||||||
drives.append({
|
drives.append({
|
||||||
"name": drive_name,
|
"name": drive_name,
|
||||||
"path": drive_path,
|
"path": drive_path,
|
||||||
"free_gb": free_gb,
|
"free_gb": free_gb,
|
||||||
"total_gb": total_gb,
|
"total_gb": total_gb,
|
||||||
|
"fstype": fstype,
|
||||||
})
|
})
|
||||||
seen_paths.add(drive_path)
|
seen_paths.add(drive_path)
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -2749,20 +2914,18 @@ async def api_services():
|
|||||||
break
|
break
|
||||||
has_domain_issues = False
|
has_domain_issues = False
|
||||||
if needs_domain and domain and enabled:
|
if needs_domain and domain and enabled:
|
||||||
|
addrs = _resolve_all_addresses(domain)
|
||||||
dns_ok = True
|
dns_ok = True
|
||||||
try:
|
if not addrs:
|
||||||
results = socket.getaddrinfo(domain, None)
|
dns_ok = False
|
||||||
if results:
|
elif all(_is_loopback_address(a) for a in addrs):
|
||||||
resolved_ip = results[0][4][0]
|
# Intentional server-local /etc/hosts override — not a mismatch.
|
||||||
if (
|
dns_ok = True
|
||||||
|
elif (
|
||||||
_cached_external_ip != "unavailable"
|
_cached_external_ip != "unavailable"
|
||||||
and resolved_ip != _cached_external_ip
|
and not any(a == _cached_external_ip for a in addrs)
|
||||||
):
|
):
|
||||||
dns_ok = False
|
dns_ok = False
|
||||||
else:
|
|
||||||
dns_ok = False
|
|
||||||
except (socket.gaierror, Exception):
|
|
||||||
dns_ok = False
|
|
||||||
|
|
||||||
if not dns_ok:
|
if not dns_ok:
|
||||||
has_domain_issues = True
|
has_domain_issues = True
|
||||||
@@ -3578,6 +3741,37 @@ async def api_backup_drives():
|
|||||||
return {"drives": drives}
|
return {"drives": drives}
|
||||||
|
|
||||||
|
|
||||||
|
async def _monitor_backup_subprocess(proc: asyncio.subprocess.Process) -> None:
|
||||||
|
"""Drain stderr, then mark status FAILED if backup subprocess exits unexpectedly."""
|
||||||
|
stderr_chunks: list[bytes] = []
|
||||||
|
|
||||||
|
async def _drain_stderr() -> None:
|
||||||
|
if proc.stderr is not None:
|
||||||
|
async for line in proc.stderr:
|
||||||
|
stderr_chunks.append(line)
|
||||||
|
|
||||||
|
drain_task = asyncio.create_task(_drain_stderr())
|
||||||
|
rc = await proc.wait()
|
||||||
|
await drain_task
|
||||||
|
|
||||||
|
if rc == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
status = await loop.run_in_executor(None, _read_backup_status)
|
||||||
|
if status in {"SUCCESS", "FAILED"}:
|
||||||
|
return
|
||||||
|
|
||||||
|
detail = ""
|
||||||
|
if stderr_chunks:
|
||||||
|
stderr_text = b"".join(stderr_chunks).decode("utf-8", errors="replace").strip()
|
||||||
|
if stderr_text:
|
||||||
|
detail = f" — stderr: {stderr_text}"
|
||||||
|
msg = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] ERROR: Backup subprocess exited unexpectedly (code {rc}).{detail}"
|
||||||
|
await loop.run_in_executor(None, _append_backup_log, msg)
|
||||||
|
await loop.run_in_executor(None, _write_backup_status, "FAILED")
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/backup/run")
|
@app.post("/api/backup/run")
|
||||||
async def api_backup_run(target: str = ""):
|
async def api_backup_run(target: str = ""):
|
||||||
"""Start the backup script as a background subprocess.
|
"""Start the backup script as a background subprocess.
|
||||||
@@ -3588,6 +3782,26 @@ async def api_backup_run(target: str = ""):
|
|||||||
if status == "RUNNING":
|
if status == "RUNNING":
|
||||||
return {"ok": True, "status": "already_running"}
|
return {"ok": True, "status": "already_running"}
|
||||||
|
|
||||||
|
drives = await loop.run_in_executor(None, _detect_external_drives)
|
||||||
|
if not drives:
|
||||||
|
raise HTTPException(status_code=400, detail="No external backup drive detected.")
|
||||||
|
|
||||||
|
drive_map = {d.get("path", ""): d for d in drives if d.get("path")}
|
||||||
|
if target:
|
||||||
|
if target not in drive_map:
|
||||||
|
raise HTTPException(status_code=400, detail="Selected backup target is not an available external drive.")
|
||||||
|
selected = drive_map[target]
|
||||||
|
else:
|
||||||
|
selected = drives[0]
|
||||||
|
|
||||||
|
selected_target = selected.get("path", "")
|
||||||
|
selected_fstype = (selected.get("fstype") or "").lower()
|
||||||
|
if selected_fstype and not _is_supported_backup_fstype(selected_target, selected_fstype):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Selected drive filesystem '{selected_fstype}' is not supported for manual backup.",
|
||||||
|
)
|
||||||
|
|
||||||
# Clear stale log before starting
|
# Clear stale log before starting
|
||||||
try:
|
try:
|
||||||
with open(BACKUP_LOG, "w") as f:
|
with open(BACKUP_LOG, "w") as f:
|
||||||
@@ -3595,21 +3809,48 @@ async def api_backup_run(target: str = ""):
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
env = dict(os.environ)
|
try:
|
||||||
if target:
|
await loop.run_in_executor(None, _write_backup_status, "RUNNING")
|
||||||
env["BACKUP_TARGET"] = target
|
except OSError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Could not set backup status: {exc}")
|
||||||
|
|
||||||
# Fire-and-forget: the script writes its own status/log files.
|
await loop.run_in_executor(
|
||||||
# Progress is read by the client via /api/backup/status (same pattern
|
None,
|
||||||
# as /api/updates/run and the rebuild feature).
|
_append_backup_log,
|
||||||
await asyncio.create_subprocess_exec(
|
f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] Starting backup process…",
|
||||||
"/usr/bin/env", "bash", BACKUP_SCRIPT,
|
|
||||||
stdout=asyncio.subprocess.DEVNULL,
|
|
||||||
stderr=asyncio.subprocess.DEVNULL,
|
|
||||||
env=env,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"ok": True, "status": "started"}
|
env = dict(os.environ)
|
||||||
|
env["BACKUP_TARGET"] = selected_target
|
||||||
|
|
||||||
|
bash_path = shutil.which("bash")
|
||||||
|
if bash_path is None:
|
||||||
|
no_bash_msg = (
|
||||||
|
f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] ERROR: Cannot start backup:"
|
||||||
|
" interpreter 'bash' not found on PATH."
|
||||||
|
" Ensure pkgs.bash is in the sovran-hub-web service PATH."
|
||||||
|
)
|
||||||
|
await loop.run_in_executor(None, _append_backup_log, no_bash_msg)
|
||||||
|
await loop.run_in_executor(None, _write_backup_status, "FAILED")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="Backup interpreter (bash) not available. Check service PATH configuration.",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
bash_path, BACKUP_SCRIPT,
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
await loop.run_in_executor(None, _append_backup_log, f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] ERROR: Failed to launch backup script: {exc}")
|
||||||
|
await loop.run_in_executor(None, _write_backup_status, "FAILED")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to launch backup process.")
|
||||||
|
|
||||||
|
asyncio.create_task(_monitor_backup_subprocess(proc))
|
||||||
|
return {"ok": True, "status": "started", "target": selected_target}
|
||||||
|
|
||||||
|
|
||||||
# ── Feature Manager endpoints ─────────────────────────────────────
|
# ── Feature Manager endpoints ─────────────────────────────────────
|
||||||
@@ -3886,6 +4127,12 @@ async def api_domains_set(req: DomainSetRequest):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Regenerate the server-local /etc/hosts loopback entries so the newly
|
||||||
|
# saved domain is immediately reachable on this computer without NAT
|
||||||
|
# loopback support on the router.
|
||||||
|
if req.domain_name in _SERVICE_DOMAIN_KEYS:
|
||||||
|
_trigger_hosts_update()
|
||||||
|
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -3933,20 +4180,27 @@ async def api_domains_check(req: DomainCheckRequest):
|
|||||||
external_ip = _cached_external_ip
|
external_ip = _cached_external_ip
|
||||||
|
|
||||||
def check_domain(domain: str) -> dict:
|
def check_domain(domain: str) -> dict:
|
||||||
try:
|
addrs = _resolve_all_addresses(domain)
|
||||||
results = socket.getaddrinfo(domain, None)
|
if not addrs:
|
||||||
if not results:
|
|
||||||
return {
|
return {
|
||||||
"domain": domain, "status": "unresolvable",
|
"domain": domain, "status": "unresolvable",
|
||||||
"resolved_ip": None, "expected_ip": external_ip,
|
"resolved_ip": None, "expected_ip": external_ip,
|
||||||
}
|
}
|
||||||
resolved_ip = results[0][4][0]
|
resolved_ip = addrs[0]
|
||||||
|
# Server-local /etc/hosts loopback override — report as such rather
|
||||||
|
# than as a DNS mismatch. Public DNS cannot be verified from this
|
||||||
|
# computer when the override is active.
|
||||||
|
if all(_is_loopback_address(a) for a in addrs):
|
||||||
|
return {
|
||||||
|
"domain": domain, "status": "local_override",
|
||||||
|
"resolved_ip": resolved_ip, "expected_ip": external_ip,
|
||||||
|
}
|
||||||
if external_ip == "unavailable":
|
if external_ip == "unavailable":
|
||||||
return {
|
return {
|
||||||
"domain": domain, "status": "error",
|
"domain": domain, "status": "error",
|
||||||
"resolved_ip": resolved_ip, "expected_ip": external_ip,
|
"resolved_ip": resolved_ip, "expected_ip": external_ip,
|
||||||
}
|
}
|
||||||
if resolved_ip == external_ip:
|
if any(a == external_ip for a in addrs):
|
||||||
return {
|
return {
|
||||||
"domain": domain, "status": "connected",
|
"domain": domain, "status": "connected",
|
||||||
"resolved_ip": resolved_ip, "expected_ip": external_ip,
|
"resolved_ip": resolved_ip, "expected_ip": external_ip,
|
||||||
@@ -3955,16 +4209,6 @@ async def api_domains_check(req: DomainCheckRequest):
|
|||||||
"domain": domain, "status": "dns_mismatch",
|
"domain": domain, "status": "dns_mismatch",
|
||||||
"resolved_ip": resolved_ip, "expected_ip": external_ip,
|
"resolved_ip": resolved_ip, "expected_ip": external_ip,
|
||||||
}
|
}
|
||||||
except socket.gaierror:
|
|
||||||
return {
|
|
||||||
"domain": domain, "status": "unresolvable",
|
|
||||||
"resolved_ip": None, "expected_ip": external_ip,
|
|
||||||
}
|
|
||||||
except Exception:
|
|
||||||
return {
|
|
||||||
"domain": domain, "status": "error",
|
|
||||||
"resolved_ip": None, "expected_ip": external_ip,
|
|
||||||
}
|
|
||||||
|
|
||||||
check_results = await asyncio.gather(*[
|
check_results = await asyncio.gather(*[
|
||||||
loop.run_in_executor(None, check_domain, d) for d in req.domains
|
loop.run_in_executor(None, check_domain, d) for d in req.domains
|
||||||
|
|||||||
@@ -91,3 +91,30 @@
|
|||||||
border-color: var(--accent-color);
|
border-color: var(--accent-color);
|
||||||
color: var(--accent-color);
|
color: var(--accent-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Header reboot button ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
.btn-header-reboot {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(184, 125, 0, 0.35);
|
||||||
|
color: #c98d08;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: var(--radius-btn);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, color 0.15s, background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-header-reboot:hover {
|
||||||
|
border-color: #b87d00;
|
||||||
|
color: #e0a010;
|
||||||
|
background-color: rgba(184, 125, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.btn-header-reboot {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -102,6 +102,48 @@ button.btn-reboot:hover:not(:disabled) {
|
|||||||
background-color: #529E7E;
|
background-color: #529E7E;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Restart = AMBER (manual restart action) */
|
||||||
|
.btn-restart-amber {
|
||||||
|
background-color: #b87d00;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-restart-amber:hover:not(:disabled) {
|
||||||
|
background-color: #9a6800;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Restart conflict warning box */
|
||||||
|
.restart-conflict-box {
|
||||||
|
background-color: rgba(180, 100, 0, 0.12);
|
||||||
|
border-left: 3px solid #c97a00;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-conflict-title {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #e69000;
|
||||||
|
margin: 0 0 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-conflict-desc {
|
||||||
|
font-size: 0.83rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reboot error card actions row */
|
||||||
|
.reboot-error-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-save {
|
.btn-save {
|
||||||
background-color: var(--yellow);
|
background-color: var(--yellow);
|
||||||
color: #0A1A10;
|
color: #0A1A10;
|
||||||
|
|||||||
@@ -44,6 +44,28 @@ if ($upgradeCloseBtn) $upgradeCloseBtn.addEventListener("click", closeUpgradeMod
|
|||||||
if ($upgradeCancelBtn) $upgradeCancelBtn.addEventListener("click", closeUpgradeModal);
|
if ($upgradeCancelBtn) $upgradeCancelBtn.addEventListener("click", closeUpgradeModal);
|
||||||
if ($upgradeModal) $upgradeModal.addEventListener("click", function(e) { if (e.target === $upgradeModal) closeUpgradeModal(); });
|
if ($upgradeModal) $upgradeModal.addEventListener("click", function(e) { if (e.target === $upgradeModal) closeUpgradeModal(); });
|
||||||
|
|
||||||
|
// Restart confirm dialog
|
||||||
|
if ($restartConfirmCancel) $restartConfirmCancel.addEventListener("click", closeRestartConfirmDialog);
|
||||||
|
if ($restartConfirmModal) $restartConfirmModal.addEventListener("click", function(e) { if (e.target === $restartConfirmModal) closeRestartConfirmDialog(); });
|
||||||
|
if ($restartConfirmModal) $restartConfirmModal.addEventListener("keydown", function(e) { if (e.key === "Escape") closeRestartConfirmDialog(); });
|
||||||
|
|
||||||
|
// Header Reboot button
|
||||||
|
if ($headerRebootBtn) $headerRebootBtn.addEventListener("click", function() { openRestartConfirmDialog(); });
|
||||||
|
if ($restartConfirmOk) $restartConfirmOk.addEventListener("click", function() {
|
||||||
|
if ($restartConfirmOk.disabled) return;
|
||||||
|
$restartConfirmOk.disabled = true;
|
||||||
|
closeRestartConfirmDialog();
|
||||||
|
doReboot();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reboot error card buttons
|
||||||
|
var $rebootErrorCloseBtn = document.getElementById("reboot-error-close-btn");
|
||||||
|
var $rebootErrorRetryBtn = document.getElementById("reboot-error-retry-btn");
|
||||||
|
if ($rebootErrorCloseBtn) $rebootErrorCloseBtn.addEventListener("click", function() {
|
||||||
|
if ($rebootOverlay) $rebootOverlay.classList.remove("visible");
|
||||||
|
});
|
||||||
|
if ($rebootErrorRetryBtn) $rebootErrorRetryBtn.addEventListener("click", doReboot);
|
||||||
|
|
||||||
// ── Upgrade modal functions ───────────────────────────────────────
|
// ── Upgrade modal functions ───────────────────────────────────────
|
||||||
|
|
||||||
function openUpgradeModal() {
|
function openUpgradeModal() {
|
||||||
@@ -54,6 +76,37 @@ function closeUpgradeModal() {
|
|||||||
if ($upgradeModal) $upgradeModal.classList.remove("open");
|
if ($upgradeModal) $upgradeModal.classList.remove("open");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Restart confirm dialog functions ─────────────────────────────
|
||||||
|
|
||||||
|
var _restartDialogOpener = null;
|
||||||
|
|
||||||
|
function openRestartConfirmDialog() {
|
||||||
|
if (!$restartConfirmModal) return;
|
||||||
|
_restartDialogOpener = document.activeElement;
|
||||||
|
|
||||||
|
// Detect conflicting operations
|
||||||
|
var isOperationInProgress = !!_updatePollTimer || !!_rebuildPollTimer;
|
||||||
|
if ($restartConflictBox) $restartConflictBox.style.display = isOperationInProgress ? "" : "none";
|
||||||
|
if ($restartConfirmOk) $restartConfirmOk.disabled = isOperationInProgress;
|
||||||
|
|
||||||
|
$restartConfirmModal.classList.add("open");
|
||||||
|
|
||||||
|
// Focus Cancel initially for safety
|
||||||
|
var cancelBtn = document.getElementById("restart-confirm-cancel-btn");
|
||||||
|
if (cancelBtn) setTimeout(function() { cancelBtn.focus(); }, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRestartConfirmDialog() {
|
||||||
|
if ($restartConfirmModal) $restartConfirmModal.classList.remove("open");
|
||||||
|
// Re-enable confirm button for next open
|
||||||
|
if ($restartConfirmOk) $restartConfirmOk.disabled = false;
|
||||||
|
// Return focus to the element that opened the dialog
|
||||||
|
if (_restartDialogOpener && _restartDialogOpener.focus) {
|
||||||
|
try { _restartDialogOpener.focus(); } catch (_) {}
|
||||||
|
_restartDialogOpener = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function doUpgradeToServer() {
|
async function doUpgradeToServer() {
|
||||||
var confirmBtn = $upgradeConfirmBtn;
|
var confirmBtn = $upgradeConfirmBtn;
|
||||||
if (confirmBtn) { confirmBtn.disabled = true; confirmBtn.textContent = "Upgrading…"; }
|
if (confirmBtn) { confirmBtn.disabled = true; confirmBtn.textContent = "Upgrading…"; }
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ function onRebuildDone(result) {
|
|||||||
// Auto-reload the page after a short delay so tiles and toggles reflect the new state
|
// Auto-reload the page after a short delay so tiles and toggles reflect the new state
|
||||||
setTimeout(function() { window.location.reload(); }, 1200);
|
setTimeout(function() { window.location.reload(); }, 1200);
|
||||||
} else if (result === "reboot_required") {
|
} else if (result === "reboot_required") {
|
||||||
if ($rebuildStatus) $rebuildStatus.textContent = "✓ Done — reboot required";
|
if ($rebuildStatus) $rebuildStatus.textContent = "✓ Done — restart required";
|
||||||
if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex";
|
if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex";
|
||||||
} else {
|
} else {
|
||||||
if ($rebuildStatus) $rebuildStatus.textContent = "✗ Something went wrong";
|
if ($rebuildStatus) $rebuildStatus.textContent = "✗ Something went wrong";
|
||||||
|
|||||||
@@ -145,28 +145,25 @@ function openSecurityModal() {
|
|||||||
if (rebootBtn) {
|
if (rebootBtn) {
|
||||||
// Keep button disabled for 5 seconds to prevent accidental clicks
|
// Keep button disabled for 5 seconds to prevent accidental clicks
|
||||||
var countdown = 5;
|
var countdown = 5;
|
||||||
rebootBtn.textContent = "I have written down my new password \u2014 Reboot now (" + countdown + ")";
|
rebootBtn.textContent = "I have written down my new password \u2014 Restart Entire System (" + countdown + ")";
|
||||||
var timer = setInterval(function() {
|
var timer = setInterval(function() {
|
||||||
countdown--;
|
countdown--;
|
||||||
if (countdown <= 0) {
|
if (countdown <= 0) {
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
rebootBtn.disabled = false;
|
rebootBtn.disabled = false;
|
||||||
rebootBtn.textContent = "I have written down my new password \u2014 Reboot now";
|
rebootBtn.textContent = "I have written down my new password \u2014 Restart Entire System";
|
||||||
} else {
|
} else {
|
||||||
rebootBtn.textContent = "I have written down my new password \u2014 Reboot now (" + countdown + ")";
|
rebootBtn.textContent = "I have written down my new password \u2014 Restart Entire System (" + countdown + ")";
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
rebootBtn.addEventListener("click", function() {
|
rebootBtn.addEventListener("click", function() {
|
||||||
rebootBtn.disabled = true;
|
rebootBtn.disabled = true;
|
||||||
rebootBtn.textContent = "Rebooting\u2026";
|
rebootBtn.textContent = "Restarting\u2026";
|
||||||
if ($rebootOverlay) $rebootOverlay.classList.add("visible");
|
// Hide the security reset overlay so the shared reboot overlay is visible
|
||||||
_rebootStartTime = Date.now();
|
var $secResetOverlay2 = document.getElementById("security-reset-overlay");
|
||||||
_serverWentDown = false;
|
if ($secResetOverlay2) $secResetOverlay2.classList.remove("visible");
|
||||||
setTimeout(waitForServerReboot, REBOOT_INITIAL_DELAY);
|
doReboot();
|
||||||
var rebootCtrl = new AbortController();
|
|
||||||
setTimeout(function() { rebootCtrl.abort(); }, REBOOT_REQUEST_TIMEOUT);
|
|
||||||
fetch("/api/reboot", { method: "POST", signal: rebootCtrl.signal }).catch(function() {});
|
|
||||||
}, { once: true });
|
}, { once: true });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ const $btnSave = document.getElementById("btn-save-report");
|
|||||||
const $btnCloseModal = document.getElementById("btn-close-modal");
|
const $btnCloseModal = document.getElementById("btn-close-modal");
|
||||||
|
|
||||||
const $rebootOverlay = document.getElementById("reboot-overlay");
|
const $rebootOverlay = document.getElementById("reboot-overlay");
|
||||||
|
const $rebootMainCard = document.getElementById("reboot-main-card");
|
||||||
|
const $rebootErrorCard = document.getElementById("reboot-error-card");
|
||||||
|
const $rebootSubmessage = document.getElementById("reboot-submessage");
|
||||||
|
|
||||||
const $credsModal = document.getElementById("creds-modal");
|
const $credsModal = document.getElementById("creds-modal");
|
||||||
const $credsTitle = document.getElementById("creds-modal-title");
|
const $credsTitle = document.getElementById("creds-modal-title");
|
||||||
@@ -101,5 +104,14 @@ const $upgradeConfirmBtn = document.getElementById("upgrade-confirm-btn");
|
|||||||
const $upgradeCancelBtn = document.getElementById("upgrade-cancel-btn");
|
const $upgradeCancelBtn = document.getElementById("upgrade-cancel-btn");
|
||||||
const $upgradeCloseBtn = document.getElementById("upgrade-close-btn");
|
const $upgradeCloseBtn = document.getElementById("upgrade-close-btn");
|
||||||
|
|
||||||
|
// Restart confirm dialog
|
||||||
|
const $restartConfirmModal = document.getElementById("restart-confirm-modal");
|
||||||
|
const $restartConfirmOk = document.getElementById("restart-confirm-ok-btn");
|
||||||
|
const $restartConfirmCancel = document.getElementById("restart-confirm-cancel-btn");
|
||||||
|
const $restartConflictBox = document.getElementById("restart-conflict-box");
|
||||||
|
|
||||||
|
// Header reboot button
|
||||||
|
const $headerRebootBtn = document.getElementById("btn-header-reboot");
|
||||||
|
|
||||||
// System status banner
|
// System status banner
|
||||||
// (removed — health is now shown per-tile via the composite health field)
|
// (removed — health is now shown per-tile via the composite health field)
|
||||||
@@ -491,7 +491,7 @@ function renderBackupReady(drives) {
|
|||||||
'<div class="support-steps-title">Requirements</div>',
|
'<div class="support-steps-title">Requirements</div>',
|
||||||
'<ol class="support-backup-steps">',
|
'<ol class="support-backup-steps">',
|
||||||
'<li>USB hard drive plugged into one of the open USB ports on your Sovran Pro</li>',
|
'<li>USB hard drive plugged into one of the open USB ports on your Sovran Pro</li>',
|
||||||
'<li>At least 500 GB of free space on the drive</li>',
|
'<li>Enough free space for your selected backup data (the backup checks this before starting)</li>',
|
||||||
'<li>Drive must be formatted as <strong>exFAT</strong></li>',
|
'<li>Drive must be formatted as <strong>exFAT</strong></li>',
|
||||||
'</ol>',
|
'</ol>',
|
||||||
'</div>',
|
'</div>',
|
||||||
@@ -584,9 +584,10 @@ async function pollBackupStatus() {
|
|||||||
logDiv.scrollTop = logDiv.scrollHeight;
|
logDiv.scrollTop = logDiv.scrollHeight;
|
||||||
}
|
}
|
||||||
_backupLogOffset = data.offset;
|
_backupLogOffset = data.offset;
|
||||||
if (!data.running) {
|
const result = (data.result || "").toLowerCase();
|
||||||
|
if (result === "success" || result === "failed") {
|
||||||
stopBackupPoll();
|
stopBackupPoll();
|
||||||
renderBackupDone(data.result === "success");
|
renderBackupDone(result === "success");
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ function onUpdateDone(result) {
|
|||||||
if ($modalStatus) $modalStatus.textContent = "✓ Update complete";
|
if ($modalStatus) $modalStatus.textContent = "✓ Update complete";
|
||||||
if ($btnReboot) $btnReboot.style.display = "inline-flex";
|
if ($btnReboot) $btnReboot.style.display = "inline-flex";
|
||||||
} else if (result === "reboot_required") {
|
} else if (result === "reboot_required") {
|
||||||
if ($modalStatus) $modalStatus.textContent = "✓ Update complete — reboot required";
|
if ($modalStatus) $modalStatus.textContent = "✓ Update complete — restart required";
|
||||||
if ($btnReboot) $btnReboot.style.display = "inline-flex";
|
if ($btnReboot) $btnReboot.style.display = "inline-flex";
|
||||||
} else {
|
} else {
|
||||||
if ($modalStatus) $modalStatus.textContent = "✗ Update failed";
|
if ($modalStatus) $modalStatus.textContent = "✗ Update failed";
|
||||||
@@ -179,23 +179,50 @@ function saveErrorReport() {
|
|||||||
|
|
||||||
var _rebootStartTime = 0;
|
var _rebootStartTime = 0;
|
||||||
var _serverWentDown = false;
|
var _serverWentDown = false;
|
||||||
|
var _rebootFailed = false;
|
||||||
|
|
||||||
|
function _setRebootStatus(msg) {
|
||||||
|
if ($rebootSubmessage) $rebootSubmessage.textContent = msg;
|
||||||
|
}
|
||||||
|
|
||||||
function doReboot() {
|
function doReboot() {
|
||||||
if ($modal) $modal.classList.remove("open");
|
if ($modal) $modal.classList.remove("open");
|
||||||
if ($rebuildModal) $rebuildModal.classList.remove("open");
|
if ($rebuildModal) $rebuildModal.classList.remove("open");
|
||||||
stopUpdatePoll();
|
stopUpdatePoll();
|
||||||
stopRebuildPoll();
|
stopRebuildPoll();
|
||||||
|
// Reset overlay to main card
|
||||||
|
if ($rebootMainCard) $rebootMainCard.style.display = "";
|
||||||
|
if ($rebootErrorCard) $rebootErrorCard.style.display = "none";
|
||||||
|
_setRebootStatus("Sending restart request\u2026");
|
||||||
if ($rebootOverlay) $rebootOverlay.classList.add("visible");
|
if ($rebootOverlay) $rebootOverlay.classList.add("visible");
|
||||||
_rebootStartTime = Date.now();
|
_rebootStartTime = Date.now();
|
||||||
_serverWentDown = false;
|
_serverWentDown = false;
|
||||||
|
_rebootFailed = false;
|
||||||
var rebootCtrl = new AbortController();
|
var rebootCtrl = new AbortController();
|
||||||
setTimeout(function() { rebootCtrl.abort(); }, REBOOT_REQUEST_TIMEOUT);
|
setTimeout(function() { rebootCtrl.abort(); }, REBOOT_REQUEST_TIMEOUT);
|
||||||
fetch("/api/reboot", { method: "POST", signal: rebootCtrl.signal }).catch(function() {});
|
fetch("/api/reboot", { method: "POST", signal: rebootCtrl.signal })
|
||||||
|
.then(function(res) {
|
||||||
|
if (!res.ok) {
|
||||||
|
// Definitive HTTP error — server rejected the request before going down
|
||||||
|
_rebootFailed = true;
|
||||||
|
if ($rebootMainCard) $rebootMainCard.style.display = "none";
|
||||||
|
if ($rebootErrorCard) $rebootErrorCard.style.display = "";
|
||||||
|
// Leave overlay visible so the error card is shown
|
||||||
|
}
|
||||||
|
// HTTP 2xx: request accepted, proceed with polling
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
// Connection dropped or request aborted — the server is likely already going
|
||||||
|
// down as part of the restart. Treat as success and continue polling.
|
||||||
|
});
|
||||||
// Wait before the first check — NixOS shutdown after an update can take 20-40s
|
// Wait before the first check — NixOS shutdown after an update can take 20-40s
|
||||||
setTimeout(waitForServerReboot, REBOOT_INITIAL_DELAY);
|
setTimeout(waitForServerReboot, REBOOT_INITIAL_DELAY);
|
||||||
}
|
}
|
||||||
|
|
||||||
function waitForServerReboot() {
|
function waitForServerReboot() {
|
||||||
|
if (_rebootFailed) return;
|
||||||
|
// Update status on first check (server hasn't gone down yet)
|
||||||
|
if (!_serverWentDown) _setRebootStatus("Waiting for the computer to shut down\u2026");
|
||||||
var controller = new AbortController();
|
var controller = new AbortController();
|
||||||
var timeoutId = setTimeout(function() { controller.abort(); }, REBOOT_FETCH_TIMEOUT);
|
var timeoutId = setTimeout(function() { controller.abort(); }, REBOOT_FETCH_TIMEOUT);
|
||||||
|
|
||||||
@@ -205,18 +232,23 @@ function waitForServerReboot() {
|
|||||||
if (_serverWentDown) {
|
if (_serverWentDown) {
|
||||||
// Server is responding after having been down — reboot is complete.
|
// Server is responding after having been down — reboot is complete.
|
||||||
// Any response (even 401/500) means the server process is back.
|
// Any response (even 401/500) means the server process is back.
|
||||||
|
_setRebootStatus("System is back online. Reconnecting\u2026");
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
} else if ((Date.now() - _rebootStartTime) < 90000) {
|
} else if ((Date.now() - _rebootStartTime) < 90000) {
|
||||||
// Server still responding but hasn't gone down yet — keep waiting
|
// Server still responding but hasn't gone down yet — keep waiting
|
||||||
setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
|
setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
|
||||||
} else {
|
} else {
|
||||||
// Been over 90 seconds and server is responding — just reload
|
// Been over 90 seconds and server is responding — just reload
|
||||||
|
_setRebootStatus("System is back online. Reconnecting\u2026");
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function() {
|
.catch(function() {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
|
if (!_serverWentDown) {
|
||||||
_serverWentDown = true;
|
_serverWentDown = true;
|
||||||
|
_setRebootStatus("The computer is restarting\u2026");
|
||||||
|
}
|
||||||
setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
|
setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
<span class="title">Sovran_SystemsOS Hub</span>
|
<span class="title">Sovran_SystemsOS Hub</span>
|
||||||
<div class="header-buttons">
|
<div class="header-buttons">
|
||||||
<span class="role-badge" id="role-badge">Loading…</span>
|
<span class="role-badge" id="role-badge">Loading…</span>
|
||||||
|
<button class="btn btn-header-reboot" id="btn-header-reboot" title="Restart the entire computer">Reboot</button>
|
||||||
<button class="btn btn-logout" id="btn-logout" title="Sign out">Sign Out</button>
|
<button class="btn btn-logout" id="btn-logout" title="Sign out">Sign Out</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -61,7 +62,7 @@
|
|||||||
<div class="modal-log" id="modal-log" aria-live="polite"></div>
|
<div class="modal-log" id="modal-log" aria-live="polite"></div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-save" id="btn-save-report" style="display:none">Save Error Report</button>
|
<button class="btn btn-save" id="btn-save-report" style="display:none">Save Error Report</button>
|
||||||
<button class="btn btn-reboot" id="btn-reboot" style="display:none">Reboot</button>
|
<button class="btn btn-reboot" id="btn-reboot" style="display:none">Restart Entire System</button>
|
||||||
<button class="btn btn-close-modal" id="btn-close-modal" disabled>Close</button>
|
<button class="btn btn-close-modal" id="btn-close-modal" disabled>Close</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -164,7 +165,7 @@
|
|||||||
<div class="modal-log" id="rebuild-log" aria-live="polite"></div>
|
<div class="modal-log" id="rebuild-log" aria-live="polite"></div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-save" id="rebuild-save-report" style="display:none">Save Error Report</button>
|
<button class="btn btn-save" id="rebuild-save-report" style="display:none">Save Error Report</button>
|
||||||
<button class="btn btn-reboot" id="rebuild-reboot-btn" style="display:none">Reboot</button>
|
<button class="btn btn-reboot" id="rebuild-reboot-btn" style="display:none">Restart Entire System</button>
|
||||||
<button class="btn btn-close-modal" id="rebuild-close-btn" disabled>Close</button>
|
<button class="btn btn-close-modal" id="rebuild-close-btn" disabled>Close</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -240,26 +241,61 @@
|
|||||||
You will need it to log in to your computer<br />and the Sovran Hub at <em>sovransystemsos.local</em>.
|
You will need it to log in to your computer<br />and the Sovran Hub at <em>sovransystemsos.local</em>.
|
||||||
</p>
|
</p>
|
||||||
<button class="security-reset-reboot-btn" id="security-reset-reboot-btn" disabled>
|
<button class="security-reset-reboot-btn" id="security-reset-reboot-btn" disabled>
|
||||||
I have written down my new password — Reboot now
|
I have written down my new password — Restart Entire System
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Reboot overlay -->
|
<!-- Reboot overlay -->
|
||||||
<div class="reboot-overlay" id="reboot-overlay">
|
<div class="reboot-overlay" id="reboot-overlay">
|
||||||
<div class="reboot-card">
|
<!-- Normal restarting card -->
|
||||||
<div class="reboot-icon">↻</div>
|
<div class="reboot-card" id="reboot-main-card">
|
||||||
<h2 class="reboot-title">System Rebooting</h2>
|
<div class="reboot-icon" aria-hidden="true">↻</div>
|
||||||
|
<h2 class="reboot-title">Restarting Entire System</h2>
|
||||||
<p class="reboot-message">
|
<p class="reboot-message">
|
||||||
Sovran_SystemsOS is now restarting.<br />
|
The entire computer is restarting, including the desktop and all hosted services.<br />
|
||||||
This page will automatically reconnect once the system is back online.
|
This page will reconnect automatically when Sovran_SystemsOS is back online.
|
||||||
</p>
|
</p>
|
||||||
<div class="reboot-dots">
|
<div class="reboot-dots" aria-hidden="true">
|
||||||
<span class="reboot-dot"></span>
|
<span class="reboot-dot"></span>
|
||||||
<span class="reboot-dot"></span>
|
<span class="reboot-dot"></span>
|
||||||
<span class="reboot-dot"></span>
|
<span class="reboot-dot"></span>
|
||||||
</div>
|
</div>
|
||||||
<p class="reboot-submessage">Stay tuned…</p>
|
<p class="reboot-submessage" id="reboot-submessage" aria-live="polite">Sending restart request…</p>
|
||||||
|
</div>
|
||||||
|
<!-- Error card (shown if restart request fails definitively) -->
|
||||||
|
<div class="reboot-card" id="reboot-error-card" style="display:none">
|
||||||
|
<div class="reboot-icon" aria-hidden="true">⚠</div>
|
||||||
|
<h2 class="reboot-title">Restart could not be started</h2>
|
||||||
|
<p class="reboot-message">
|
||||||
|
The computer did not begin restarting. No services were intentionally stopped. Please try again.
|
||||||
|
</p>
|
||||||
|
<div class="reboot-error-actions">
|
||||||
|
<button class="btn btn-close-modal" id="reboot-error-close-btn">Close</button>
|
||||||
|
<button class="btn btn-restart-amber" id="reboot-error-retry-btn">Try Again</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Restart Confirm Dialog -->
|
||||||
|
<div class="modal-overlay" id="restart-confirm-modal" role="dialog" aria-modal="true" aria-labelledby="restart-confirm-title">
|
||||||
|
<div class="creds-dialog domain-narrow-dialog">
|
||||||
|
<div class="creds-header">
|
||||||
|
<span class="creds-title" id="restart-confirm-title">Restart the entire computer?</span>
|
||||||
|
</div>
|
||||||
|
<div class="creds-body">
|
||||||
|
<div id="restart-conflict-box" class="restart-conflict-box" style="display:none">
|
||||||
|
<p class="restart-conflict-title">The system cannot restart right now.</p>
|
||||||
|
<p class="restart-conflict-desc">A system update, rebuild, backup, restore, or security operation is currently running. Wait for it to finish, then try again.</p>
|
||||||
|
</div>
|
||||||
|
<p class="support-desc"><strong>This will reboot the physical machine running Sovran_SystemsOS — not just the Hub.</strong></p>
|
||||||
|
<p class="support-desc">The desktop and all hosted services will stop temporarily and restart with the computer. Anyone currently using these services will be disconnected.</p>
|
||||||
|
<p class="support-desc">The system usually returns within 1–3 minutes. This page will reconnect automatically.</p>
|
||||||
|
<div class="domain-field-actions">
|
||||||
|
<button class="btn btn-close-modal" id="restart-confirm-cancel-btn">Cancel</button>
|
||||||
|
<button class="btn btn-restart-amber" id="restart-confirm-ok-btn">Restart Entire System</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
"""Structural regression tests for modules/core/local-domain-loopback.nix.
|
||||||
|
|
||||||
|
Verifies that the sovran-hosts-update helper:
|
||||||
|
- is built as a pkgs.writeShellApplication with explicit runtimeInputs
|
||||||
|
(gawk, gnugrep, coreutils);
|
||||||
|
- uses ``lib.getExe hostsUpdateScript`` for both the systemd ExecStart and
|
||||||
|
the activation script so that both contexts share the same Nix-store
|
||||||
|
executable;
|
||||||
|
- does NOT point ExecStart at the raw /etc path;
|
||||||
|
- includes element-calling in the supported domain list;
|
||||||
|
- uses ``awk -v`` for safe marker-variable passing rather than interpolating
|
||||||
|
marker text directly into the awk program;
|
||||||
|
- retains the domain validation regex (idempotency / injection prevention);
|
||||||
|
- exposes /etc/sovran-hosts-update.sh as a symlink via ``source =`` (not a
|
||||||
|
second raw ``text =`` body);
|
||||||
|
- does NOT rely on environment.systemPackages for the helper's dependencies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
NIX_STRING_INDENT = 6
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
FLAKE_SOURCE = (REPO_ROOT / "flake.nix").read_text()
|
||||||
|
PRIMARY_NIXOS_CONFIGURATION_MATCH = re.search(
|
||||||
|
r"nixosConfigurations\.([A-Za-z0-9_-]+)\s*=",
|
||||||
|
FLAKE_SOURCE,
|
||||||
|
)
|
||||||
|
if PRIMARY_NIXOS_CONFIGURATION_MATCH is None:
|
||||||
|
raise RuntimeError("Could not determine the primary nixosConfigurations entry from flake.nix")
|
||||||
|
PRIMARY_NIXOS_CONFIGURATION = PRIMARY_NIXOS_CONFIGURATION_MATCH.group(1)
|
||||||
|
HELPER_BUILD_ATTR = (
|
||||||
|
f'.#nixosConfigurations.{PRIMARY_NIXOS_CONFIGURATION}.config.environment.etc.'
|
||||||
|
'"sovran-hosts-update.sh".source'
|
||||||
|
)
|
||||||
|
NIX_FILE = (
|
||||||
|
REPO_ROOT
|
||||||
|
/ "modules"
|
||||||
|
/ "core"
|
||||||
|
/ "local-domain-loopback.nix"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LocalDomainLoopbackNixStructureTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.source = NIX_FILE.read_text()
|
||||||
|
|
||||||
|
def _helper_script(self) -> str:
|
||||||
|
start = self.source.index("text = ''") + len("text = ''")
|
||||||
|
end = self.source.index(" '';", start)
|
||||||
|
return "\n".join(
|
||||||
|
line[NIX_STRING_INDENT:]
|
||||||
|
if line.startswith(" " * NIX_STRING_INDENT)
|
||||||
|
else line
|
||||||
|
for line in self.source[start:end].splitlines()
|
||||||
|
).lstrip("\n")
|
||||||
|
|
||||||
|
# ── writeShellApplication and explicit runtimeInputs ────────────────────
|
||||||
|
|
||||||
|
def test_uses_write_shell_application(self):
|
||||||
|
self.assertIn("pkgs.writeShellApplication", self.source)
|
||||||
|
|
||||||
|
def test_runtime_inputs_includes_coreutils(self):
|
||||||
|
self.assertIn("pkgs.coreutils", self.source)
|
||||||
|
|
||||||
|
def test_runtime_inputs_includes_gawk(self):
|
||||||
|
self.assertIn("pkgs.gawk", self.source)
|
||||||
|
|
||||||
|
def test_runtime_inputs_includes_gnugrep(self):
|
||||||
|
self.assertIn("pkgs.gnugrep", self.source)
|
||||||
|
|
||||||
|
def test_runtime_inputs_block_present(self):
|
||||||
|
self.assertIn("runtimeInputs", self.source)
|
||||||
|
|
||||||
|
# ── Both execution paths use lib.getExe ─────────────────────────────────
|
||||||
|
|
||||||
|
def test_exec_start_uses_lib_get_exe(self):
|
||||||
|
"""systemd ExecStart must reference the Nix-store executable."""
|
||||||
|
self.assertIn("ExecStart = lib.getExe hostsUpdateScript", self.source)
|
||||||
|
|
||||||
|
def test_activation_script_uses_lib_get_exe(self):
|
||||||
|
"""Activation text must call the same Nix-store executable."""
|
||||||
|
self.assertIn("${lib.getExe hostsUpdateScript}", self.source)
|
||||||
|
|
||||||
|
def test_exec_start_does_not_point_to_etc_path(self):
|
||||||
|
"""ExecStart must NOT use the raw /etc path (which lacks a deterministic PATH)."""
|
||||||
|
self.assertNotIn('ExecStart = "/etc/sovran-hosts-update.sh"', self.source)
|
||||||
|
|
||||||
|
# ── /etc symlink uses source =, not a second text = body ────────────────
|
||||||
|
|
||||||
|
def test_etc_entry_uses_source_not_text(self):
|
||||||
|
"""The /etc/sovran-hosts-update.sh entry must be a symlink (source =),
|
||||||
|
not a second raw script body (text =)."""
|
||||||
|
self.assertIn(
|
||||||
|
'environment.etc."sovran-hosts-update.sh".source', self.source
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_etc_source_points_to_get_exe(self):
|
||||||
|
self.assertIn(
|
||||||
|
'environment.etc."sovran-hosts-update.sh".source = lib.getExe hostsUpdateScript',
|
||||||
|
self.source,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── element-calling domain is supported ─────────────────────────────────
|
||||||
|
|
||||||
|
def test_element_calling_domain_key_present(self):
|
||||||
|
self.assertIn("element-calling", self.source)
|
||||||
|
|
||||||
|
# ── Robust awk -v variable passing ──────────────────────────────────────
|
||||||
|
|
||||||
|
def test_awk_uses_dash_v_for_begin_marker(self):
|
||||||
|
"""awk must receive the begin marker via -v, not by shell interpolation."""
|
||||||
|
self.assertIn('awk -v begin=', self.source)
|
||||||
|
|
||||||
|
def test_awk_uses_dash_v_for_end_marker(self):
|
||||||
|
self.assertIn('-v end=', self.source)
|
||||||
|
|
||||||
|
def test_awk_does_not_interpolate_marker_into_program(self):
|
||||||
|
"""The old pattern interpolated $BEGIN_MARKER directly into the awk source."""
|
||||||
|
self.assertNotIn('/$BEGIN_MARKER', self.source)
|
||||||
|
self.assertNotIn('/$END_MARKER', self.source)
|
||||||
|
|
||||||
|
# ── Domain validation ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_domain_validation_regex_present(self):
|
||||||
|
"""The hostname validation regex must still be present for injection prevention."""
|
||||||
|
self.assertIn("grep -qE", self.source)
|
||||||
|
self.assertIn("[a-zA-Z0-9]", self.source)
|
||||||
|
|
||||||
|
def test_invalid_domain_warning_present(self):
|
||||||
|
self.assertIn("skipping invalid domain value", self.source)
|
||||||
|
|
||||||
|
# ── No environment.systemPackages reliance ───────────────────────────────
|
||||||
|
|
||||||
|
def test_no_environment_system_packages_for_helper(self):
|
||||||
|
"""The helper's tools are declared via runtimeInputs; the module must
|
||||||
|
not add them to environment.systemPackages."""
|
||||||
|
self.assertNotIn("environment.systemPackages", self.source)
|
||||||
|
|
||||||
|
# ── Idempotency: existing Sovran block is removed before rewriting ───────
|
||||||
|
|
||||||
|
def test_existing_block_removal_logic_present(self):
|
||||||
|
"""awk strip of the managed block must be present for idempotency."""
|
||||||
|
self.assertIn("skip=1", self.source)
|
||||||
|
self.assertIn("skip=0", self.source)
|
||||||
|
|
||||||
|
def test_managed_block_uses_grouped_append_redirect(self):
|
||||||
|
self.assertIn('} >> "$TMP"', self.source)
|
||||||
|
self.assertEqual(self.source.count('>> "$TMP"'), 1)
|
||||||
|
|
||||||
|
def test_helper_script_passes_shellcheck(self):
|
||||||
|
shellcheck = shutil.which("shellcheck")
|
||||||
|
if shellcheck is None:
|
||||||
|
self.skipTest("shellcheck is not installed")
|
||||||
|
proc = subprocess.run(
|
||||||
|
[shellcheck, "-s", "bash", "-"],
|
||||||
|
input=self._helper_script(),
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
output = proc.stdout + proc.stderr
|
||||||
|
self.assertEqual(proc.returncode, 0, output)
|
||||||
|
|
||||||
|
def test_helper_derivation_builds_when_nix_available(self):
|
||||||
|
nix = shutil.which("nix")
|
||||||
|
if nix is None:
|
||||||
|
self.skipTest("nix is not installed")
|
||||||
|
proc = subprocess.run(
|
||||||
|
[
|
||||||
|
nix,
|
||||||
|
"build",
|
||||||
|
HELPER_BUILD_ATTR,
|
||||||
|
"--no-link",
|
||||||
|
],
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||||
|
|
||||||
|
# ── Activation script warns on failure rather than silently swallowing ───
|
||||||
|
|
||||||
|
def test_activation_script_emits_warning_on_failure(self):
|
||||||
|
self.assertIn("warning: sovran-hosts-update", self.source)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
"""Tests for server-local loopback diagnostics and domain validation.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- Domain value validation and injection prevention.
|
||||||
|
- Loopback address detection (IPv4 and IPv6).
|
||||||
|
- _resolve_all_addresses returning multiple addresses.
|
||||||
|
- _check_domain_health_fast with loopback resolution.
|
||||||
|
- _evaluate_domain_checklist with loopback override — no false dns_mismatch.
|
||||||
|
- _evaluate_domain_checklist with genuine DNS mismatch — still reports error.
|
||||||
|
- api_services health stays "healthy" when domain resolves to loopback.
|
||||||
|
- api_services health stays "needs_attention" when DNS is genuinely wrong.
|
||||||
|
- api_domains_check returns "local_override" for loopback-resolved domains.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, mock_open, patch
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Minimal stubs so server.py can be imported without the full FastAPI stack.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _install_web_stubs():
|
||||||
|
if "fastapi" in sys.modules:
|
||||||
|
return
|
||||||
|
|
||||||
|
class _HTTPException(Exception):
|
||||||
|
def __init__(self, status_code=None, detail=None):
|
||||||
|
super().__init__(detail)
|
||||||
|
self.status_code = status_code
|
||||||
|
self.detail = detail
|
||||||
|
|
||||||
|
class _FastAPI:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def mount(self, *args, **kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def add_middleware(self, *args, **kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def __getattr__(self, _name):
|
||||||
|
def _decorator_factory(*args, **kwargs):
|
||||||
|
def _decorator(func):
|
||||||
|
return func
|
||||||
|
return _decorator
|
||||||
|
return _decorator_factory
|
||||||
|
|
||||||
|
class _BaseModel:
|
||||||
|
pass
|
||||||
|
|
||||||
|
class _StaticFiles:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class _Jinja2Templates:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class _BaseHTTPMiddleware:
|
||||||
|
pass
|
||||||
|
|
||||||
|
fastapi_module = types.ModuleType("fastapi")
|
||||||
|
fastapi_module.FastAPI = _FastAPI
|
||||||
|
fastapi_module.HTTPException = _HTTPException
|
||||||
|
sys.modules["fastapi"] = fastapi_module
|
||||||
|
|
||||||
|
responses_module = types.ModuleType("fastapi.responses")
|
||||||
|
responses_module.HTMLResponse = object
|
||||||
|
responses_module.JSONResponse = object
|
||||||
|
responses_module.RedirectResponse = object
|
||||||
|
sys.modules["fastapi.responses"] = responses_module
|
||||||
|
|
||||||
|
staticfiles_module = types.ModuleType("fastapi.staticfiles")
|
||||||
|
staticfiles_module.StaticFiles = _StaticFiles
|
||||||
|
sys.modules["fastapi.staticfiles"] = staticfiles_module
|
||||||
|
|
||||||
|
templating_module = types.ModuleType("fastapi.templating")
|
||||||
|
templating_module.Jinja2Templates = _Jinja2Templates
|
||||||
|
sys.modules["fastapi.templating"] = templating_module
|
||||||
|
|
||||||
|
requests_module = types.ModuleType("fastapi.requests")
|
||||||
|
requests_module.Request = object
|
||||||
|
sys.modules["fastapi.requests"] = requests_module
|
||||||
|
|
||||||
|
pydantic_module = types.ModuleType("pydantic")
|
||||||
|
pydantic_module.BaseModel = _BaseModel
|
||||||
|
sys.modules["pydantic"] = pydantic_module
|
||||||
|
|
||||||
|
starlette_base_module = types.ModuleType("starlette.middleware.base")
|
||||||
|
starlette_base_module.BaseHTTPMiddleware = _BaseHTTPMiddleware
|
||||||
|
sys.modules["starlette.middleware.base"] = starlette_base_module
|
||||||
|
|
||||||
|
starlette_middleware_module = types.ModuleType("starlette.middleware")
|
||||||
|
starlette_middleware_module.base = starlette_base_module
|
||||||
|
sys.modules["starlette.middleware"] = starlette_middleware_module
|
||||||
|
|
||||||
|
starlette_module = types.ModuleType("starlette")
|
||||||
|
starlette_module.middleware = starlette_middleware_module
|
||||||
|
sys.modules["starlette"] = starlette_module
|
||||||
|
|
||||||
|
|
||||||
|
_install_web_stubs()
|
||||||
|
from sovran_systemsos_web import server # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Domain value validation
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
class TestValidateDomainValue(unittest.TestCase):
|
||||||
|
"""_validate_domain_value must reject anything that could corrupt /etc/hosts."""
|
||||||
|
|
||||||
|
def _v(self, value: str) -> bool:
|
||||||
|
return server._validate_domain_value(value)
|
||||||
|
|
||||||
|
# -- Valid values --------------------------------------------------------
|
||||||
|
|
||||||
|
def test_simple_domain_valid(self):
|
||||||
|
self.assertTrue(self._v("cloud.example.com"))
|
||||||
|
|
||||||
|
def test_subdomain_valid(self):
|
||||||
|
self.assertTrue(self._v("matrix.home.example.org"))
|
||||||
|
|
||||||
|
def test_single_label_with_tld_valid(self):
|
||||||
|
self.assertTrue(self._v("example.com"))
|
||||||
|
|
||||||
|
def test_hyphen_in_domain_valid(self):
|
||||||
|
self.assertTrue(self._v("my-nextcloud.example.com"))
|
||||||
|
|
||||||
|
# -- Injection / malformed values ----------------------------------------
|
||||||
|
|
||||||
|
def test_empty_string_invalid(self):
|
||||||
|
self.assertFalse(self._v(""))
|
||||||
|
|
||||||
|
def test_newline_injection_invalid(self):
|
||||||
|
self.assertFalse(self._v("evil.com\n127.0.0.1 other.host"))
|
||||||
|
|
||||||
|
def test_carriage_return_injection_invalid(self):
|
||||||
|
self.assertFalse(self._v("evil.com\r127.0.0.1 other.host"))
|
||||||
|
|
||||||
|
def test_space_injection_invalid(self):
|
||||||
|
self.assertFalse(self._v("evil.com 127.0.0.1"))
|
||||||
|
|
||||||
|
def test_hash_comment_injection_invalid(self):
|
||||||
|
self.assertFalse(self._v("evil.com# comment"))
|
||||||
|
|
||||||
|
def test_bare_hostname_no_dot_invalid(self):
|
||||||
|
self.assertFalse(self._v("localhost"))
|
||||||
|
|
||||||
|
def test_bare_ip_invalid(self):
|
||||||
|
self.assertFalse(self._v("192.168.1.1"))
|
||||||
|
|
||||||
|
def test_too_long_invalid(self):
|
||||||
|
self.assertFalse(self._v("a" * 254 + ".com"))
|
||||||
|
|
||||||
|
def test_leading_dot_invalid(self):
|
||||||
|
self.assertFalse(self._v(".example.com"))
|
||||||
|
|
||||||
|
def test_trailing_dot_invalid(self):
|
||||||
|
self.assertFalse(self._v("example.com."))
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Loopback address detection
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
class TestIsLoopbackAddress(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_ipv4_loopback(self):
|
||||||
|
self.assertTrue(server._is_loopback_address("127.0.0.1"))
|
||||||
|
|
||||||
|
def test_ipv4_loopback_other(self):
|
||||||
|
self.assertTrue(server._is_loopback_address("127.0.0.2"))
|
||||||
|
|
||||||
|
def test_ipv4_loopback_high(self):
|
||||||
|
self.assertTrue(server._is_loopback_address("127.255.255.255"))
|
||||||
|
|
||||||
|
def test_ipv6_loopback(self):
|
||||||
|
self.assertTrue(server._is_loopback_address("::1"))
|
||||||
|
|
||||||
|
def test_public_ipv4_not_loopback(self):
|
||||||
|
self.assertFalse(server._is_loopback_address("203.0.113.10"))
|
||||||
|
|
||||||
|
def test_private_ipv4_not_loopback(self):
|
||||||
|
self.assertFalse(server._is_loopback_address("192.168.1.50"))
|
||||||
|
|
||||||
|
def test_ipv6_public_not_loopback(self):
|
||||||
|
self.assertFalse(server._is_loopback_address("2001:db8::1"))
|
||||||
|
|
||||||
|
def test_invalid_string_not_loopback(self):
|
||||||
|
self.assertFalse(server._is_loopback_address("not-an-ip"))
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# _check_domain_health_fast
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
class TestCheckDomainHealthFast(unittest.TestCase):
|
||||||
|
"""_check_domain_health_fast returns True when there is an issue,
|
||||||
|
False when everything looks fine."""
|
||||||
|
|
||||||
|
def _fast(self, domain, external_ip, resolved_addrs):
|
||||||
|
with patch.object(server, "_resolve_all_addresses", return_value=resolved_addrs):
|
||||||
|
return server._check_domain_health_fast(domain, external_ip)
|
||||||
|
|
||||||
|
def test_no_domain_no_issue(self):
|
||||||
|
# None/empty domain: the fast check reports True (handled by checklist).
|
||||||
|
result = server._check_domain_health_fast(None, "203.0.113.10")
|
||||||
|
self.assertTrue(result)
|
||||||
|
|
||||||
|
def test_empty_domain_no_issue(self):
|
||||||
|
result = server._check_domain_health_fast("", "203.0.113.10")
|
||||||
|
self.assertTrue(result)
|
||||||
|
|
||||||
|
def test_loopback_ipv4_no_issue(self):
|
||||||
|
"""Loopback override must not be flagged as a DNS mismatch."""
|
||||||
|
result = self._fast("cloud.example.com", "203.0.113.10", ["127.0.0.1"])
|
||||||
|
self.assertFalse(result)
|
||||||
|
|
||||||
|
def test_loopback_ipv6_no_issue(self):
|
||||||
|
result = self._fast("cloud.example.com", "203.0.113.10", ["::1"])
|
||||||
|
self.assertFalse(result)
|
||||||
|
|
||||||
|
def test_matches_external_ip_no_issue(self):
|
||||||
|
result = self._fast("cloud.example.com", "203.0.113.10", ["203.0.113.10"])
|
||||||
|
self.assertFalse(result)
|
||||||
|
|
||||||
|
def test_mismatch_is_an_issue(self):
|
||||||
|
result = self._fast("cloud.example.com", "203.0.113.10", ["198.51.100.1"])
|
||||||
|
self.assertTrue(result)
|
||||||
|
|
||||||
|
def test_unavailable_external_ip_no_issue(self):
|
||||||
|
result = self._fast("cloud.example.com", "unavailable", ["198.51.100.1"])
|
||||||
|
self.assertFalse(result)
|
||||||
|
|
||||||
|
def test_multiple_addresses_one_matches_no_issue(self):
|
||||||
|
"""If any resolved address matches external_ip the check should pass."""
|
||||||
|
result = self._fast(
|
||||||
|
"cloud.example.com", "203.0.113.10",
|
||||||
|
["198.51.100.1", "203.0.113.10"],
|
||||||
|
)
|
||||||
|
self.assertFalse(result)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# _evaluate_domain_checklist — loopback override path
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
class TestEvaluateDomainChecklistLoopback(unittest.TestCase):
|
||||||
|
|
||||||
|
def _eval(self, domain, external_ip, resolved_addrs, reachable_result=None):
|
||||||
|
with (
|
||||||
|
patch.object(server, "_resolve_all_addresses", return_value=resolved_addrs),
|
||||||
|
patch.object(server, "_check_domain_reachable",
|
||||||
|
return_value=reachable_result or {"reachable": True, "status_code": 200}),
|
||||||
|
):
|
||||||
|
return server._evaluate_domain_checklist(domain, external_ip)
|
||||||
|
|
||||||
|
def test_loopback_dns_step_is_ok_not_error(self):
|
||||||
|
result = self._eval("cloud.example.com", "203.0.113.10", ["127.0.0.1"])
|
||||||
|
dns_step = next(s for s in result["domain_check_steps"] if s["step"] == 2)
|
||||||
|
self.assertEqual(dns_step["status"], "ok")
|
||||||
|
self.assertNotIn("mismatch", dns_step.get("detail", "").lower())
|
||||||
|
|
||||||
|
def test_loopback_domain_status_is_local_override(self):
|
||||||
|
result = self._eval("cloud.example.com", "203.0.113.10", ["127.0.0.1"])
|
||||||
|
self.assertEqual(result["domain_status"]["status"], "local_override")
|
||||||
|
|
||||||
|
def test_loopback_has_no_issues_when_reachable(self):
|
||||||
|
result = self._eval(
|
||||||
|
"cloud.example.com", "203.0.113.10", ["127.0.0.1"],
|
||||||
|
reachable_result={"reachable": True, "status_code": 200},
|
||||||
|
)
|
||||||
|
self.assertFalse(result["has_issues"])
|
||||||
|
|
||||||
|
def test_loopback_has_issues_when_caddy_unreachable(self):
|
||||||
|
"""A loopback override with Caddy down should still report an issue."""
|
||||||
|
result = self._eval(
|
||||||
|
"cloud.example.com", "203.0.113.10", ["127.0.0.1"],
|
||||||
|
reachable_result={"reachable": False, "error": "connection refused"},
|
||||||
|
)
|
||||||
|
self.assertTrue(result["has_issues"])
|
||||||
|
|
||||||
|
def test_ipv6_loopback_no_issue(self):
|
||||||
|
result = self._eval("cloud.example.com", "203.0.113.10", ["::1"])
|
||||||
|
self.assertEqual(result["domain_status"]["status"], "local_override")
|
||||||
|
self.assertFalse(result["has_issues"])
|
||||||
|
|
||||||
|
def test_genuine_mismatch_still_reports_error(self):
|
||||||
|
result = self._eval("cloud.example.com", "203.0.113.10", ["198.51.100.1"])
|
||||||
|
self.assertEqual(result["domain_status"]["status"], "dns_mismatch")
|
||||||
|
self.assertTrue(result["has_issues"])
|
||||||
|
|
||||||
|
def test_correct_public_dns_still_reports_ok(self):
|
||||||
|
result = self._eval("cloud.example.com", "203.0.113.10", ["203.0.113.10"])
|
||||||
|
self.assertEqual(result["domain_status"]["status"], "connected")
|
||||||
|
self.assertFalse(result["has_issues"])
|
||||||
|
|
||||||
|
def test_no_domain_has_issues(self):
|
||||||
|
result = self._eval(None, "203.0.113.10", [])
|
||||||
|
self.assertTrue(result["has_issues"])
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# api_services — composite health with loopback
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
class TestApiServicesLoopbackHealth(unittest.IsolatedAsyncioTestCase):
|
||||||
|
|
||||||
|
async def _get_health(self, resolved_addrs, cached_reachable):
|
||||||
|
"""Return the health value for a single domain-requiring service."""
|
||||||
|
service_cfg = {
|
||||||
|
"services": [
|
||||||
|
{"unit": "caddy.service", "icon": "nextcloud", "enabled": True, "type": "system"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
with (
|
||||||
|
patch.object(server, "load_config", return_value=service_cfg),
|
||||||
|
patch.object(server, "_read_hub_overrides", return_value=({}, None, None)),
|
||||||
|
patch.object(server.sysctl, "is_active", return_value="active"),
|
||||||
|
patch.dict(server.SERVICE_DOMAIN_MAP, {"caddy.service": "nextcloud"}, clear=False),
|
||||||
|
patch("builtins.open", mock_open(read_data="cloud.example.com\n")),
|
||||||
|
patch.object(server, "_resolve_all_addresses", return_value=resolved_addrs),
|
||||||
|
patch.object(server, "_is_domain_reachable_cached", return_value=cached_reachable),
|
||||||
|
patch.object(server, "_get_listening_ports",
|
||||||
|
return_value={"tcp": {80, 443}, "udp": set()}),
|
||||||
|
patch.object(server, "_get_firewall_allowed_ports",
|
||||||
|
return_value={"tcp": set(), "udp": set()}),
|
||||||
|
patch.object(server, "_cached_external_ip", "203.0.113.10"),
|
||||||
|
):
|
||||||
|
results = await server.api_services()
|
||||||
|
|
||||||
|
return results[0]["health"]
|
||||||
|
|
||||||
|
async def test_loopback_and_reachable_is_healthy(self):
|
||||||
|
"""Loopback override + Caddy reachable → healthy, not needs_attention."""
|
||||||
|
health = await self._get_health(["127.0.0.1"], cached_reachable=True)
|
||||||
|
self.assertEqual(health, "healthy")
|
||||||
|
|
||||||
|
async def test_loopback_and_caddy_down_is_needs_attention(self):
|
||||||
|
"""Loopback override + Caddy unreachable → needs_attention (genuine issue)."""
|
||||||
|
health = await self._get_health(["127.0.0.1"], cached_reachable=False)
|
||||||
|
self.assertEqual(health, "needs_attention")
|
||||||
|
|
||||||
|
async def test_correct_dns_and_reachable_is_healthy(self):
|
||||||
|
health = await self._get_health(["203.0.113.10"], cached_reachable=True)
|
||||||
|
self.assertEqual(health, "healthy")
|
||||||
|
|
||||||
|
async def test_dns_mismatch_is_needs_attention(self):
|
||||||
|
health = await self._get_health(["198.51.100.1"], cached_reachable=True)
|
||||||
|
self.assertEqual(health, "needs_attention")
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# api_domains_check — loopback detection
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
class TestApiDomainsCheckLoopback(unittest.IsolatedAsyncioTestCase):
|
||||||
|
|
||||||
|
async def _check(self, resolved_addrs, external_ip="203.0.113.10"):
|
||||||
|
with (
|
||||||
|
patch.object(server, "_resolve_all_addresses", return_value=resolved_addrs),
|
||||||
|
patch.object(server, "_cached_external_ip", external_ip),
|
||||||
|
):
|
||||||
|
result = await server.api_domains_check(
|
||||||
|
MagicMock(domains=["cloud.example.com"])
|
||||||
|
)
|
||||||
|
return result["domains"][0]
|
||||||
|
|
||||||
|
async def test_loopback_ipv4_returns_local_override(self):
|
||||||
|
result = await self._check(["127.0.0.1"])
|
||||||
|
self.assertEqual(result["status"], "local_override")
|
||||||
|
|
||||||
|
async def test_loopback_ipv6_returns_local_override(self):
|
||||||
|
result = await self._check(["::1"])
|
||||||
|
self.assertEqual(result["status"], "local_override")
|
||||||
|
|
||||||
|
async def test_correct_dns_returns_connected(self):
|
||||||
|
result = await self._check(["203.0.113.10"])
|
||||||
|
self.assertEqual(result["status"], "connected")
|
||||||
|
|
||||||
|
async def test_mismatch_returns_dns_mismatch(self):
|
||||||
|
result = await self._check(["198.51.100.1"])
|
||||||
|
self.assertEqual(result["status"], "dns_mismatch")
|
||||||
|
|
||||||
|
async def test_no_resolution_returns_unresolvable(self):
|
||||||
|
result = await self._check([])
|
||||||
|
self.assertEqual(result["status"], "unresolvable")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,688 @@
|
|||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
BACKUP_SCRIPT = REPO_ROOT / "app" / "sovran_systemsos_web" / "scripts" / "sovran-hub-backup.sh"
|
||||||
|
SERVER_FILE = REPO_ROOT / "app" / "sovran_systemsos_web" / "server.py"
|
||||||
|
SUPPORT_JS = REPO_ROOT / "app" / "sovran_systemsos_web" / "static" / "js" / "support.js"
|
||||||
|
NIX_HUB_FILE = REPO_ROOT / "modules" / "core" / "sovran-hub.nix"
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_bash_function(script_path: Path, func_name: str) -> str:
|
||||||
|
"""Extract a bash function definition from a shell script using Python regex.
|
||||||
|
|
||||||
|
The pattern matches from 'funcname() {' to the first line whose only content
|
||||||
|
is '}' (the function closing brace, at column 0). This works correctly for
|
||||||
|
functions whose control structures (case/if/while/for) use indented braces,
|
||||||
|
because only the function's own closing brace sits at column 0. It would not
|
||||||
|
correctly extract a function that contains a nested function definition.
|
||||||
|
|
||||||
|
Returns the complete function definition, safe to inline into a test bash
|
||||||
|
script without eval or awk."""
|
||||||
|
source = script_path.read_text()
|
||||||
|
pattern = r"^" + re.escape(func_name) + r"\(\) \{.*?^}"
|
||||||
|
match = re.search(pattern, source, re.MULTILINE | re.DOTALL)
|
||||||
|
if match is None:
|
||||||
|
raise ValueError(f"Function '{func_name}' not found in {script_path}")
|
||||||
|
return match.group(0)
|
||||||
|
|
||||||
|
|
||||||
|
class ManualBackupWorkflowTests(unittest.TestCase):
|
||||||
|
def test_backup_script_uses_tar_archives_with_checksums_and_exclusions(self):
|
||||||
|
source = BACKUP_SCRIPT.read_text()
|
||||||
|
|
||||||
|
self.assertIn("tar \\", source)
|
||||||
|
self.assertIn("--create", source)
|
||||||
|
self.assertIn("--one-file-system", source)
|
||||||
|
self.assertIn("sha256sum", source)
|
||||||
|
self.assertIn("export_postgresql_dumps", source)
|
||||||
|
self.assertIn("export_mariadb_dumps", source)
|
||||||
|
self.assertIn("export_lnd_scb_if_possible", source)
|
||||||
|
self.assertIn("--exclude='var/lib/bitcoind'", source)
|
||||||
|
self.assertIn("--exclude='var/lib/electrs'", source)
|
||||||
|
self.assertIn("--exclude='var/lib/lnd'", source)
|
||||||
|
self.assertIn("set_status \"RUNNING\"", source)
|
||||||
|
self.assertIn("set_status \"SUCCESS\"", source)
|
||||||
|
self.assertIn("set_status \"FAILED\"", source)
|
||||||
|
self.assertNotIn("rsync -a", source)
|
||||||
|
|
||||||
|
def test_backend_and_frontend_use_explicit_backup_terminal_states(self):
|
||||||
|
server_source = SERVER_FILE.read_text()
|
||||||
|
support_source = SUPPORT_JS.read_text()
|
||||||
|
|
||||||
|
self.assertIn("_write_backup_status, \"RUNNING\"", server_source)
|
||||||
|
self.assertIn("_monitor_backup_subprocess", server_source)
|
||||||
|
self.assertIn("asyncio.create_task(_monitor_backup_subprocess(proc))", server_source)
|
||||||
|
self.assertIn("result === \"success\" || result === \"failed\"", support_source)
|
||||||
|
|
||||||
|
# ── Regression tests for exit-code-127 / missing-interpreter bug ──────────
|
||||||
|
|
||||||
|
def test_nix_service_path_includes_bash_and_gawk(self):
|
||||||
|
"""Regression: pkgs.bash and pkgs.gawk must appear in the sovran-hub-web
|
||||||
|
service path so that the backup shell script and its awk calls can be
|
||||||
|
resolved at runtime without producing exit code 127."""
|
||||||
|
nix_source = NIX_HUB_FILE.read_text()
|
||||||
|
self.assertIn(
|
||||||
|
"pkgs.bash",
|
||||||
|
nix_source,
|
||||||
|
"pkgs.bash must be declared in the sovran-hub-web systemd service path",
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"pkgs.gawk",
|
||||||
|
nix_source,
|
||||||
|
"pkgs.gawk must be declared in the sovran-hub-web systemd service path",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_server_resolves_bash_via_shutil_which(self):
|
||||||
|
"""Regression: server must locate bash with shutil.which() rather than
|
||||||
|
relying on /usr/bin/env bash, so a missing interpreter is caught early
|
||||||
|
with a clear diagnostic instead of a cryptic exit-code-127 failure."""
|
||||||
|
source = SERVER_FILE.read_text()
|
||||||
|
self.assertIn(
|
||||||
|
'shutil.which("bash")',
|
||||||
|
source,
|
||||||
|
"server.py must resolve bash via shutil.which to catch missing interpreter",
|
||||||
|
)
|
||||||
|
self.assertNotIn(
|
||||||
|
'"/usr/bin/env", "bash"',
|
||||||
|
source,
|
||||||
|
"server.py must not use /usr/bin/env bash (relies on PATH, causes code 127)",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_server_logs_actionable_message_when_bash_missing(self):
|
||||||
|
"""Regression: when bash is absent the server must write an actionable log
|
||||||
|
entry and set FAILED status before returning an HTTP error."""
|
||||||
|
source = SERVER_FILE.read_text()
|
||||||
|
self.assertIn(
|
||||||
|
"bash_path is None",
|
||||||
|
source,
|
||||||
|
"server.py must check that bash_path is not None before launching",
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"interpreter",
|
||||||
|
source,
|
||||||
|
"server.py missing-bash error message must reference 'interpreter'",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_server_captures_stderr_from_backup_subprocess(self):
|
||||||
|
"""Regression: the backup subprocess must use stderr=PIPE so that any
|
||||||
|
startup error (e.g. code 127 from a missing command) is captured and
|
||||||
|
surfaced in the backup log rather than being silently discarded."""
|
||||||
|
source = SERVER_FILE.read_text()
|
||||||
|
self.assertIn(
|
||||||
|
"stderr=asyncio.subprocess.PIPE",
|
||||||
|
source,
|
||||||
|
"backup subprocess must use stderr=PIPE to capture diagnostic output",
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"stderr_text",
|
||||||
|
source,
|
||||||
|
"_monitor_backup_subprocess must capture and log stderr content",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_monitor_includes_stderr_detail_in_failed_message(self):
|
||||||
|
"""Regression: _monitor_backup_subprocess must append captured stderr to
|
||||||
|
the FAILED log entry so the UI shows what went wrong (e.g. 'bash: not
|
||||||
|
found') rather than only the raw exit code."""
|
||||||
|
source = SERVER_FILE.read_text()
|
||||||
|
self.assertIn("stderr_chunks", source)
|
||||||
|
self.assertIn("stderr_text", source)
|
||||||
|
# stderr detail is conditionally appended only when non-empty
|
||||||
|
self.assertIn('detail = f" — stderr: {stderr_text}"', source)
|
||||||
|
|
||||||
|
def test_exit_code_127_subprocess_stderr_drain(self):
|
||||||
|
"""Behavioral regression: a subprocess that exits 127 must have its
|
||||||
|
stderr drained without deadlock by the async drain loop."""
|
||||||
|
|
||||||
|
async def _run():
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"bash", "-c", "echo 'bash: command not found' >&2; exit 127",
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
chunks: list[bytes] = []
|
||||||
|
|
||||||
|
async def _drain():
|
||||||
|
async for line in proc.stderr:
|
||||||
|
chunks.append(line)
|
||||||
|
|
||||||
|
drain_task = asyncio.create_task(_drain())
|
||||||
|
rc = await proc.wait()
|
||||||
|
await drain_task
|
||||||
|
return rc, b"".join(chunks).decode()
|
||||||
|
|
||||||
|
rc, stderr_out = asyncio.run(_run())
|
||||||
|
self.assertEqual(rc, 127)
|
||||||
|
self.assertIn("bash: command not found", stderr_out)
|
||||||
|
|
||||||
|
# ── Tests for home tar exit-1 tolerance (production fix) ──────────────────
|
||||||
|
|
||||||
|
def test_backup_script_has_home_tar_archive_function(self):
|
||||||
|
"""The script must define create_home_tar_archive for /home only,
|
||||||
|
separate from the strict create_tar_archive used for other sources."""
|
||||||
|
source = BACKUP_SCRIPT.read_text()
|
||||||
|
self.assertIn(
|
||||||
|
"create_home_tar_archive",
|
||||||
|
source,
|
||||||
|
"backup script must define create_home_tar_archive for /home",
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"_is_home_warning_allowlisted",
|
||||||
|
source,
|
||||||
|
"backup script must define _is_home_warning_allowlisted helper",
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"ARCHIVE_WARNINGS",
|
||||||
|
source,
|
||||||
|
"backup script must track nonfatal warnings in ARCHIVE_WARNINGS",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_backup_script_allowlist_covers_verified_gnu_tar_messages(self):
|
||||||
|
"""The allowlist must contain the exact GNU tar (LC_ALL=C) strings for
|
||||||
|
the two permitted transient conditions."""
|
||||||
|
source = BACKUP_SCRIPT.read_text()
|
||||||
|
self.assertIn(
|
||||||
|
"file changed as we read it",
|
||||||
|
source,
|
||||||
|
"allowlist must include exact GNU tar 'file changed as we read it' message",
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"file removed before we read it",
|
||||||
|
source,
|
||||||
|
"allowlist must include exact GNU tar 'file removed before we read it' message",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_backup_script_stage3_uses_home_tar_function(self):
|
||||||
|
"""Stage 3 must call create_home_tar_archive (not create_tar_archive)
|
||||||
|
so the /home archive tolerates live file changes on an active desktop."""
|
||||||
|
source = BACKUP_SCRIPT.read_text()
|
||||||
|
# The home archive call must use the tolerant function
|
||||||
|
self.assertIn(
|
||||||
|
'create_home_tar_archive "home.tar"',
|
||||||
|
source,
|
||||||
|
"Stage 3 must use create_home_tar_archive for home.tar",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_backup_script_strict_function_still_used_for_etc(self):
|
||||||
|
"""create_tar_archive (strict) must still be used for /etc/nixos,
|
||||||
|
secrets, var-lib-lnd-clean, and var-lib — only /home is tolerant."""
|
||||||
|
source = BACKUP_SCRIPT.read_text()
|
||||||
|
self.assertIn(
|
||||||
|
'create_tar_archive "etc-nixos.tar"',
|
||||||
|
source,
|
||||||
|
"etc-nixos must use strict create_tar_archive",
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
'create_tar_archive "var-lib.tar"',
|
||||||
|
source,
|
||||||
|
"var-lib must use strict create_tar_archive",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Tests for partial-file atomic publish ─────────────────────────────────
|
||||||
|
|
||||||
|
def test_backup_script_uses_partial_path_and_atomic_rename(self):
|
||||||
|
"""Archives must be written to a .partial path and atomically renamed
|
||||||
|
to the final name only after acceptance, so a failed run never leaves a
|
||||||
|
partial archive that appears complete."""
|
||||||
|
source = BACKUP_SCRIPT.read_text()
|
||||||
|
self.assertIn(
|
||||||
|
".partial",
|
||||||
|
source,
|
||||||
|
"backup script must write to a .partial path before renaming",
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
'mv "$partial_path" "$archive_path"',
|
||||||
|
source,
|
||||||
|
"backup script must atomically rename partial to final archive path",
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"PARTIAL_FILES",
|
||||||
|
source,
|
||||||
|
"backup script must track partial files for cleanup",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Tests for INCOMPLETE / BACKUP_COMPLETE markers ────────────────────────
|
||||||
|
|
||||||
|
def test_backup_script_writes_incomplete_marker(self):
|
||||||
|
"""A backup directory must be marked INCOMPLETE immediately after
|
||||||
|
creation, so interrupted or failed runs are identifiable."""
|
||||||
|
source = BACKUP_SCRIPT.read_text()
|
||||||
|
self.assertIn(
|
||||||
|
'touch "$BACKUP_DIR/INCOMPLETE"',
|
||||||
|
source,
|
||||||
|
"backup script must create INCOMPLETE marker after mkdir",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_backup_script_writes_backup_complete_marker(self):
|
||||||
|
"""A BACKUP_COMPLETE file must be written only after all work succeeds,
|
||||||
|
so restore tools can distinguish complete from incomplete backups."""
|
||||||
|
source = BACKUP_SCRIPT.read_text()
|
||||||
|
self.assertIn(
|
||||||
|
'BACKUP_COMPLETE"',
|
||||||
|
source,
|
||||||
|
"backup script must write BACKUP_COMPLETE file on success",
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
'rm -f "$BACKUP_DIR/INCOMPLETE"',
|
||||||
|
source,
|
||||||
|
"backup script must remove INCOMPLETE marker on success",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Tests for concurrency lock ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_backup_script_uses_flock_concurrency_lock(self):
|
||||||
|
"""The script must acquire an exclusive flock before starting work to
|
||||||
|
prevent two simultaneous backup runs."""
|
||||||
|
source = BACKUP_SCRIPT.read_text()
|
||||||
|
self.assertIn(
|
||||||
|
"flock",
|
||||||
|
source,
|
||||||
|
"backup script must use flock for concurrency protection",
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"LOCK_FILE",
|
||||||
|
source,
|
||||||
|
"backup script must define a LOCK_FILE for the flock",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Tests for expanded home exclusions ────────────────────────────────────
|
||||||
|
|
||||||
|
def test_backup_script_home_excludes_browser_caches(self):
|
||||||
|
"""The home archive must exclude browser disk caches (volatile data)
|
||||||
|
while keeping profile data, bookmarks, and user documents."""
|
||||||
|
source = BACKUP_SCRIPT.read_text()
|
||||||
|
self.assertIn(
|
||||||
|
"--exclude='home/*/.mozilla/firefox/*/cache2'",
|
||||||
|
source,
|
||||||
|
"backup script must exclude Firefox cache2 directory",
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"--exclude='home/*/.config/google-chrome/*/Cache'",
|
||||||
|
source,
|
||||||
|
"backup script must exclude Chrome Cache directory",
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"--exclude='home/*/.config/chromium/*/Cache'",
|
||||||
|
source,
|
||||||
|
"backup script must exclude Chromium Cache directory",
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"--exclude='home/*/.config/BraveSoftware/Brave-Browser/*/Cache'",
|
||||||
|
source,
|
||||||
|
"backup script must exclude Brave Cache directory",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_backup_script_home_excludes_other_volatile_caches(self):
|
||||||
|
"""The home archive must exclude baloo, thumbnails, and X session
|
||||||
|
error logs — all volatile/reconstructable content."""
|
||||||
|
source = BACKUP_SCRIPT.read_text()
|
||||||
|
self.assertIn(
|
||||||
|
"--exclude='home/*/.local/share/baloo'",
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"--exclude='home/*/.thumbnails'",
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"--exclude='home/*/.xsession-errors'",
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Tests for require_cmd additions ───────────────────────────────────────
|
||||||
|
|
||||||
|
def test_backup_script_requires_mktemp_and_flock(self):
|
||||||
|
"""The script must explicitly check for mktemp and flock via require_cmd
|
||||||
|
so that missing tools fail early with a clear error."""
|
||||||
|
source = BACKUP_SCRIPT.read_text()
|
||||||
|
self.assertIn(
|
||||||
|
"require_cmd mktemp",
|
||||||
|
source,
|
||||||
|
"backup script must require mktemp",
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"require_cmd flock",
|
||||||
|
source,
|
||||||
|
"backup script must require flock",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Behavioral tests ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_allowlist_accepts_file_changed_message(self):
|
||||||
|
"""Behavioral: _is_home_warning_allowlisted (from the actual backup script)
|
||||||
|
returns true for the exact GNU tar 'file changed as we read it' message (LC_ALL=C).
|
||||||
|
The function definition is extracted from the production script via Python regex
|
||||||
|
to ensure the test exercises the real allowlist without eval-of-awk risks."""
|
||||||
|
func_def = _extract_bash_function(BACKUP_SCRIPT, "_is_home_warning_allowlisted")
|
||||||
|
script = (
|
||||||
|
"#!/usr/bin/env bash\n"
|
||||||
|
+ func_def + "\n"
|
||||||
|
+ 'msgs=(\n'
|
||||||
|
+ ' "tar: /home/user/firefox.db: file changed as we read it"\n'
|
||||||
|
+ ' "tar: /home/user/.local/share/app: file removed before we read it"\n'
|
||||||
|
+ ")\n"
|
||||||
|
+ 'for msg in "${msgs[@]}"; do\n'
|
||||||
|
+ ' _is_home_warning_allowlisted "$msg" || { echo "BLOCKED: $msg"; exit 1; }\n'
|
||||||
|
+ ' echo "ALLOWED: $msg"\n'
|
||||||
|
+ "done\n"
|
||||||
|
)
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".sh", delete=False) as f:
|
||||||
|
f.write(script)
|
||||||
|
script_path = f.name
|
||||||
|
try:
|
||||||
|
result = subprocess.run(["bash", script_path], capture_output=True, text=True)
|
||||||
|
self.assertEqual(result.returncode, 0, f"stderr: {result.stderr}")
|
||||||
|
self.assertIn("ALLOWED", result.stdout)
|
||||||
|
self.assertNotIn("BLOCKED", result.stdout)
|
||||||
|
finally:
|
||||||
|
os.unlink(script_path)
|
||||||
|
|
||||||
|
def test_allowlist_rejects_fatal_diagnostics(self):
|
||||||
|
"""Behavioral: _is_home_warning_allowlisted (from the actual backup script)
|
||||||
|
returns false for permission errors, I/O errors, write errors, and other
|
||||||
|
fatal conditions."""
|
||||||
|
func_def = _extract_bash_function(BACKUP_SCRIPT, "_is_home_warning_allowlisted")
|
||||||
|
script = (
|
||||||
|
"#!/usr/bin/env bash\n"
|
||||||
|
+ func_def + "\n"
|
||||||
|
+ 'msgs=(\n'
|
||||||
|
+ ' "tar: /home/user/file: Permission denied"\n'
|
||||||
|
+ ' "tar: /dev/sdb: Cannot read: Input/output error"\n'
|
||||||
|
+ ' "tar: Error is not recoverable: exiting now"\n'
|
||||||
|
+ ' "Write error"\n'
|
||||||
|
+ ' "tar: /home/user: Cannot stat: No such file or directory"\n'
|
||||||
|
+ ")\n"
|
||||||
|
+ 'for msg in "${msgs[@]}"; do\n'
|
||||||
|
+ ' _is_home_warning_allowlisted "$msg" && { echo "ALLOWED_BUT_SHOULD_NOT: $msg"; exit 1; }\n'
|
||||||
|
+ ' echo "CORRECTLY_BLOCKED: $msg"\n'
|
||||||
|
+ "done\n"
|
||||||
|
)
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".sh", delete=False) as f:
|
||||||
|
f.write(script)
|
||||||
|
script_path = f.name
|
||||||
|
try:
|
||||||
|
result = subprocess.run(["bash", script_path], capture_output=True, text=True)
|
||||||
|
self.assertEqual(result.returncode, 0, f"stderr: {result.stderr}")
|
||||||
|
self.assertIn("CORRECTLY_BLOCKED", result.stdout)
|
||||||
|
self.assertNotIn("ALLOWED_BUT_SHOULD_NOT", result.stdout)
|
||||||
|
finally:
|
||||||
|
os.unlink(script_path)
|
||||||
|
|
||||||
|
def test_home_tar_exits_1_with_only_allowlisted_warnings_is_accepted(self):
|
||||||
|
"""Behavioral: _create_archive_impl in HOME mode accepts tar exit 1 when
|
||||||
|
every diagnostic is allowlisted and publishes the final archive."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
src = os.path.join(tmpdir, "home", "user1")
|
||||||
|
dest = os.path.join(tmpdir, "backup")
|
||||||
|
os.makedirs(src, exist_ok=True)
|
||||||
|
os.makedirs(dest, exist_ok=True)
|
||||||
|
with open(os.path.join(src, "testfile.txt"), "w") as f:
|
||||||
|
f.write("test content\n")
|
||||||
|
|
||||||
|
# Emulate the allowlist + acceptance logic in HOME mode
|
||||||
|
script = f"""#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
BACKUP_DIR="{dest}"
|
||||||
|
ARCHIVE_FILES=()
|
||||||
|
ARCHIVE_WARNINGS=()
|
||||||
|
PARTIAL_FILES=()
|
||||||
|
|
||||||
|
log() {{ echo "$*"; }}
|
||||||
|
fail() {{ echo "FAIL: $*" >&2; exit 1; }}
|
||||||
|
|
||||||
|
_is_home_warning_allowlisted() {{
|
||||||
|
local msg="$1"
|
||||||
|
case "$msg" in
|
||||||
|
*"file changed as we read it"*) return 0 ;;
|
||||||
|
*"file removed before we read it"*) return 0 ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}}
|
||||||
|
|
||||||
|
_create_archive_impl() {{
|
||||||
|
local mode="$1"; shift
|
||||||
|
local archive_name="$1"; shift
|
||||||
|
local archive_path="$BACKUP_DIR/$archive_name"
|
||||||
|
local partial_path="${{archive_path}}.partial"
|
||||||
|
local diag_tmp
|
||||||
|
diag_tmp="$(mktemp /tmp/sovran-tar-diag.XXXXXX)"
|
||||||
|
PARTIAL_FILES+=("$partial_path" "$diag_tmp")
|
||||||
|
|
||||||
|
local tar_rc=0
|
||||||
|
LC_ALL=C tar --create --file "$partial_path" \
|
||||||
|
--numeric-owner --sparse --one-file-system \
|
||||||
|
"$@" 2>"$diag_tmp" || tar_rc=$?
|
||||||
|
|
||||||
|
# Inject a simulated allowlisted warning to match production scenario
|
||||||
|
echo "tar: {src}/testfile.txt: file changed as we read it" >> "$diag_tmp"
|
||||||
|
tar_rc=1 # simulate exit 1
|
||||||
|
|
||||||
|
local has_fatal_diag=0
|
||||||
|
if [[ -s "$diag_tmp" ]]; then
|
||||||
|
while IFS= read -r diag_line; do
|
||||||
|
[[ -n "$diag_line" ]] || continue
|
||||||
|
if [[ "$mode" == "HOME" ]] && ! _is_home_warning_allowlisted "$diag_line"; then
|
||||||
|
has_fatal_diag=1
|
||||||
|
fi
|
||||||
|
done < "$diag_tmp"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local accept=0
|
||||||
|
if [[ "$tar_rc" -eq 0 ]]; then
|
||||||
|
accept=1
|
||||||
|
elif [[ "$mode" == "HOME" && "$tar_rc" -eq 1 && "$has_fatal_diag" -eq 0 ]]; then
|
||||||
|
accept=1
|
||||||
|
ARCHIVE_WARNINGS+=("$archive_name: nonfatal warnings")
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ "$accept" -eq 1 ]] || {{ rm -f "$partial_path" "$diag_tmp"; fail "Rejected"; }}
|
||||||
|
[[ -s "$partial_path" ]] || {{ rm -f "$partial_path" "$diag_tmp"; fail "Empty"; }}
|
||||||
|
|
||||||
|
mv "$partial_path" "$archive_path"
|
||||||
|
rm -f "$diag_tmp"
|
||||||
|
ARCHIVE_FILES+=("$archive_name")
|
||||||
|
echo "CREATED: $archive_name"
|
||||||
|
}}
|
||||||
|
|
||||||
|
create_home_tar_archive() {{ _create_archive_impl HOME "$@"; }}
|
||||||
|
|
||||||
|
create_home_tar_archive "home.tar" -C "{tmpdir}" home
|
||||||
|
echo "WARNINGS: ${{#ARCHIVE_WARNINGS[@]}}"
|
||||||
|
[[ -f "{dest}/home.tar" ]] && echo "FILE_EXISTS" || echo "FILE_MISSING"
|
||||||
|
[[ ! -f "{dest}/home.tar.partial" ]] && echo "PARTIAL_CLEANED" || echo "PARTIAL_LEFT"
|
||||||
|
"""
|
||||||
|
script_file = os.path.join(tmpdir, "test.sh")
|
||||||
|
with open(script_file, "w") as sf:
|
||||||
|
sf.write(script)
|
||||||
|
result = subprocess.run(["bash", script_file], capture_output=True, text=True)
|
||||||
|
self.assertEqual(result.returncode, 0, f"stdout: {result.stdout}\nstderr: {result.stderr}")
|
||||||
|
self.assertIn("CREATED: home.tar", result.stdout)
|
||||||
|
self.assertIn("FILE_EXISTS", result.stdout)
|
||||||
|
self.assertIn("PARTIAL_CLEANED", result.stdout)
|
||||||
|
|
||||||
|
def test_home_tar_exits_1_with_fatal_diagnostic_fails(self):
|
||||||
|
"""Behavioral: _create_archive_impl in HOME mode must fail when tar exit 1
|
||||||
|
includes a non-allowlisted diagnostic (e.g. permission denied)."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
dest = os.path.join(tmpdir, "backup")
|
||||||
|
os.makedirs(dest, exist_ok=True)
|
||||||
|
|
||||||
|
script = f"""#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
BACKUP_DIR="{dest}"
|
||||||
|
ARCHIVE_FILES=()
|
||||||
|
ARCHIVE_WARNINGS=()
|
||||||
|
PARTIAL_FILES=()
|
||||||
|
|
||||||
|
fail() {{ echo "CORRECTLY_FAILED: $*"; exit 1; }}
|
||||||
|
log() {{ echo "$*"; }}
|
||||||
|
|
||||||
|
_is_home_warning_allowlisted() {{
|
||||||
|
local msg="$1"
|
||||||
|
case "$msg" in
|
||||||
|
*"file changed as we read it"*) return 0 ;;
|
||||||
|
*"file removed before we read it"*) return 0 ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}}
|
||||||
|
|
||||||
|
# Simulate tar exit 1 with a fatal permission-denied diagnostic
|
||||||
|
archive_name="home.tar"
|
||||||
|
partial_path="$BACKUP_DIR/$archive_name.partial"
|
||||||
|
diag_tmp=$(mktemp)
|
||||||
|
echo "tar: /home/user/locked: Permission denied" > "$diag_tmp"
|
||||||
|
|
||||||
|
tar_rc=1
|
||||||
|
has_fatal_diag=0
|
||||||
|
while IFS= read -r diag_line; do
|
||||||
|
[[ -n "$diag_line" ]] || continue
|
||||||
|
if ! _is_home_warning_allowlisted "$diag_line"; then
|
||||||
|
has_fatal_diag=1
|
||||||
|
fi
|
||||||
|
done < "$diag_tmp"
|
||||||
|
rm -f "$diag_tmp"
|
||||||
|
|
||||||
|
if [[ "$tar_rc" -eq 1 && "$has_fatal_diag" -eq 0 ]]; then
|
||||||
|
echo "INCORRECTLY_ACCEPTED"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
fail "tar exit 1 with Permission denied"
|
||||||
|
fi
|
||||||
|
"""
|
||||||
|
script_file = os.path.join(tmpdir, "test.sh")
|
||||||
|
with open(script_file, "w") as sf:
|
||||||
|
sf.write(script)
|
||||||
|
result = subprocess.run(["bash", script_file], capture_output=True, text=True)
|
||||||
|
self.assertEqual(result.returncode, 1, f"Should have failed: {result.stdout}")
|
||||||
|
self.assertIn("CORRECTLY_FAILED", result.stdout)
|
||||||
|
|
||||||
|
def test_home_tar_exits_2_always_fails(self):
|
||||||
|
"""Behavioral: tar exit code 2 (fatal) must always fail even in HOME mode."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
dest = os.path.join(tmpdir, "backup")
|
||||||
|
os.makedirs(dest, exist_ok=True)
|
||||||
|
|
||||||
|
script = f"""#!/usr/bin/env bash
|
||||||
|
BACKUP_DIR="{dest}"
|
||||||
|
ARCHIVE_WARNINGS=()
|
||||||
|
|
||||||
|
fail() {{ echo "CORRECTLY_FAILED: $*"; exit 1; }}
|
||||||
|
log() {{ echo "$*"; }}
|
||||||
|
|
||||||
|
_is_home_warning_allowlisted() {{
|
||||||
|
case "$1" in
|
||||||
|
*"file changed as we read it"*) return 0 ;;
|
||||||
|
*"file removed before we read it"*) return 0 ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}}
|
||||||
|
|
||||||
|
tar_rc=2
|
||||||
|
has_fatal_diag=0
|
||||||
|
|
||||||
|
accept=0
|
||||||
|
if [[ "$tar_rc" -eq 0 ]]; then
|
||||||
|
accept=1
|
||||||
|
elif [[ "HOME" == "HOME" && "$tar_rc" -eq 1 && "$has_fatal_diag" -eq 0 ]]; then
|
||||||
|
accept=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$accept" -eq 0 ]]; then
|
||||||
|
if [[ "$tar_rc" -gt 1 ]]; then
|
||||||
|
fail "tar exited with fatal code $tar_rc"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo "SHOULD_NOT_REACH_HERE"
|
||||||
|
"""
|
||||||
|
script_file = os.path.join(tmpdir, "test.sh")
|
||||||
|
with open(script_file, "w") as sf:
|
||||||
|
sf.write(script)
|
||||||
|
result = subprocess.run(["bash", script_file], capture_output=True, text=True)
|
||||||
|
self.assertEqual(result.returncode, 1, f"Should have failed: {result.stdout}")
|
||||||
|
self.assertIn("CORRECTLY_FAILED", result.stdout)
|
||||||
|
self.assertNotIn("SHOULD_NOT_REACH_HERE", result.stdout)
|
||||||
|
|
||||||
|
def test_partial_file_cleanup_on_failure(self):
|
||||||
|
"""Behavioral: a .partial file must be removed when the archive creation
|
||||||
|
fails, so no incomplete archive is left on the destination."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
dest = os.path.join(tmpdir, "backup")
|
||||||
|
os.makedirs(dest, exist_ok=True)
|
||||||
|
|
||||||
|
partial_path = os.path.join(dest, "home.tar.partial")
|
||||||
|
|
||||||
|
script = f"""#!/usr/bin/env bash
|
||||||
|
BACKUP_DIR="{dest}"
|
||||||
|
PARTIAL_FILES=()
|
||||||
|
|
||||||
|
fail() {{
|
||||||
|
# Simulate cleanup removing partial files
|
||||||
|
for p in "${{PARTIAL_FILES[@]}}"; do
|
||||||
|
[[ -f "$p" ]] && rm -f "$p"
|
||||||
|
done
|
||||||
|
echo "FAILED: $*"
|
||||||
|
exit 1
|
||||||
|
}}
|
||||||
|
log() {{ :; }}
|
||||||
|
|
||||||
|
partial_path="$BACKUP_DIR/home.tar.partial"
|
||||||
|
diag_tmp=$(mktemp)
|
||||||
|
PARTIAL_FILES+=("$partial_path" "$diag_tmp")
|
||||||
|
|
||||||
|
# Simulate partial archive being written
|
||||||
|
touch "$partial_path"
|
||||||
|
|
||||||
|
# Simulate a fatal error
|
||||||
|
fail "tar exited with code 2"
|
||||||
|
"""
|
||||||
|
script_file = os.path.join(tmpdir, "test.sh")
|
||||||
|
with open(script_file, "w") as sf:
|
||||||
|
sf.write(script)
|
||||||
|
result = subprocess.run(["bash", script_file], capture_output=True, text=True)
|
||||||
|
self.assertEqual(result.returncode, 1, f"Should have failed: {result.stdout}")
|
||||||
|
self.assertFalse(
|
||||||
|
os.path.exists(partial_path),
|
||||||
|
"Partial file must be removed on failure",
|
||||||
|
)
|
||||||
|
self.assertIn("FAILED", result.stdout)
|
||||||
|
|
||||||
|
def test_archive_warnings_recorded_in_manifest(self):
|
||||||
|
"""Source text: ARCHIVE_WARNINGS must be written into BACKUP_MANIFEST.txt
|
||||||
|
so the user can see which archives had nonfatal live-file warnings."""
|
||||||
|
source = BACKUP_SCRIPT.read_text()
|
||||||
|
self.assertIn(
|
||||||
|
"ARCHIVE_WARNINGS[@]",
|
||||||
|
source,
|
||||||
|
"backup script must iterate ARCHIVE_WARNINGS in manifest generation",
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"Nonfatal warnings:",
|
||||||
|
source,
|
||||||
|
"manifest must include a 'Nonfatal warnings:' section",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_backup_script_logs_warnings_before_success_message(self):
|
||||||
|
"""Source text: when ARCHIVE_WARNINGS is non-empty, the script must log
|
||||||
|
the warnings before the 'All Finished!' success message."""
|
||||||
|
source = BACKUP_SCRIPT.read_text()
|
||||||
|
# Both constructs must be present
|
||||||
|
self.assertIn(
|
||||||
|
"${#ARCHIVE_WARNINGS[@]}",
|
||||||
|
source,
|
||||||
|
"backup script must check ARCHIVE_WARNINGS count before success message",
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"All Finished!",
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
RDP_NIX = Path(__file__).resolve().parents[2] / "modules" / "rdp.nix"
|
||||||
|
USERNAME_READ = "USERNAME=\"$(tr -d '\\n' < \"$USERNAME_FILE\")\""
|
||||||
|
USERNAME_LENGTH_GUARD = "if [ \"''${#USERNAME}\" -gt 32 ]; then"
|
||||||
|
SHORT_PASSWORD_GUARD = 'if [ "\'\'${#PASSWORD}" -lt 8 ]; then'
|
||||||
|
|
||||||
|
|
||||||
|
def _section(source: str, start: str, end: str) -> str:
|
||||||
|
start_idx = source.find(start)
|
||||||
|
if start_idx == -1:
|
||||||
|
raise AssertionError(f"Expected section start not found: {start!r}")
|
||||||
|
end_idx = source.find(end, start_idx)
|
||||||
|
if end_idx == -1:
|
||||||
|
raise AssertionError(f"Expected section end not found: {end!r}")
|
||||||
|
return source[start_idx:end_idx]
|
||||||
|
|
||||||
|
|
||||||
|
class RdpModuleBootSetupTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.source = RDP_NIX.read_text()
|
||||||
|
self.gnome_service = _section(
|
||||||
|
self.source,
|
||||||
|
"systemd.services.gnome-remote-desktop = {",
|
||||||
|
"systemd.tmpfiles.rules = [",
|
||||||
|
)
|
||||||
|
self.setup_service = _section(
|
||||||
|
self.source,
|
||||||
|
"systemd.services.gnome-remote-desktop-setup = {",
|
||||||
|
"};\n}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_does_not_redeclare_gnome_remote_desktop_user(self):
|
||||||
|
self.assertNotIn("users.users.gnome-remote-desktop", self.source)
|
||||||
|
self.assertNotIn("createHome = true;", self.source)
|
||||||
|
|
||||||
|
def test_main_service_requires_setup_before_starting(self):
|
||||||
|
self.assertIn('wantedBy = [ "graphical.target" ];', self.gnome_service)
|
||||||
|
self.assertIn('after = [ "gnome-remote-desktop-setup.service" ];', self.gnome_service)
|
||||||
|
self.assertIn('requires = [ "gnome-remote-desktop-setup.service" ];', self.gnome_service)
|
||||||
|
|
||||||
|
def test_setup_waits_for_configuration_service_and_bounded_timeout(self):
|
||||||
|
self.assertIn('wantedBy = [ "graphical.target" ];', self.setup_service)
|
||||||
|
self.assertIn('before = [ "gnome-remote-desktop.service" ];', self.setup_service)
|
||||||
|
self.assertIn('"dbus.service"', self.setup_service)
|
||||||
|
self.assertIn('"gnome-remote-desktop-configuration.service"', self.setup_service)
|
||||||
|
self.assertNotIn("RemainAfterExit", self.setup_service)
|
||||||
|
self.assertIn('TimeoutStartSec = "2min";', self.setup_service)
|
||||||
|
self.assertIn('timeout --kill-after=5s 10s', self.setup_service)
|
||||||
|
self.assertIn('echo "grdctl command timed out: $*" >&2', self.setup_service)
|
||||||
|
self.assertIn('echo "grdctl command failed (exit $rc): $*" >&2', self.setup_service)
|
||||||
|
|
||||||
|
def test_setup_runs_grdctl_directly_as_root(self):
|
||||||
|
# The oneshot service runs as root; grdctl --system is called directly.
|
||||||
|
# GRD 50.x invokes pkexec internally, but the call itself is plain
|
||||||
|
# grdctl --system, not a manual pkexec invocation.
|
||||||
|
self.assertIn('grdctl --system "$@"', self.setup_service)
|
||||||
|
self.assertNotIn("runuser", self.setup_service)
|
||||||
|
self.assertNotIn("sudo", self.setup_service)
|
||||||
|
# No direct Nix-store pkexec invocation (pkgs.polkit}/bin/pkexec).
|
||||||
|
self.assertNotIn("pkgs.polkit}/bin/pkexec", self.setup_service)
|
||||||
|
|
||||||
|
def test_privilege_escalation_packages_absent_from_setup_path(self):
|
||||||
|
self.assertNotIn("pkgs.polkit", self.setup_service)
|
||||||
|
self.assertNotIn("pkgs.util-linux", self.setup_service)
|
||||||
|
|
||||||
|
def test_run_wrappers_bin_prepended_to_path(self):
|
||||||
|
# /run/wrappers/bin must be prepended to PATH before any grdctl_system
|
||||||
|
# invocation so that grdctl --system resolves the NixOS setuid pkexec.
|
||||||
|
path_export = 'export PATH="/run/wrappers/bin:$PATH"'
|
||||||
|
grdctl_marker = "grdctl_system"
|
||||||
|
script = self.setup_service
|
||||||
|
path_idx = script.find(path_export)
|
||||||
|
grdctl_idx = script.find(grdctl_marker)
|
||||||
|
self.assertGreater(path_idx, -1, f"{path_export!r} not found in setup script")
|
||||||
|
self.assertGreater(
|
||||||
|
grdctl_idx, path_idx,
|
||||||
|
"PATH export must appear before the first grdctl_system usage",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_pkexec_preflight_check(self):
|
||||||
|
# A preflight must confirm /run/wrappers/bin/pkexec is executable
|
||||||
|
# with a clear error message before any GRD configuration changes.
|
||||||
|
self.assertIn("test -x /run/wrappers/bin/pkexec", self.setup_service)
|
||||||
|
self.assertIn(
|
||||||
|
"/run/wrappers/bin/pkexec is absent or not executable",
|
||||||
|
self.setup_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_hub_files_are_the_source_of_truth_for_username_and_password(self):
|
||||||
|
self.assertIn('DEFAULT_USERNAME="sovran"', self.setup_service)
|
||||||
|
self.assertIn('if [ ! -f "$USERNAME_FILE" ]; then', self.setup_service)
|
||||||
|
self.assertIn(USERNAME_READ, self.setup_service)
|
||||||
|
self.assertIn(USERNAME_LENGTH_GUARD, self.setup_service)
|
||||||
|
self.assertIn('case "$USERNAME" in', self.setup_service)
|
||||||
|
self.assertIn('[A-Za-z_][A-Za-z0-9_-]*)', self.setup_service)
|
||||||
|
self.assertIn("RDP username is too long (''${#USERNAME} characters, maximum 32)", self.setup_service)
|
||||||
|
self.assertIn("RDP username must start with a letter or underscore and contain only letters, numbers, underscores, and hyphens", self.setup_service)
|
||||||
|
self.assertIn('if [ ! -f "$PASSWORD_FILE" ]; then', self.setup_service)
|
||||||
|
self.assertIn("tr -d '\\n'", self.setup_service)
|
||||||
|
self.assertIn('"$PASSWORD_FILE"', self.setup_service)
|
||||||
|
self.assertIn(SHORT_PASSWORD_GUARD, self.setup_service)
|
||||||
|
self.assertIn("RDP password is too short (''${#PASSWORD} characters, minimum 8)", self.setup_service)
|
||||||
|
self.assertIn('grdctl_system rdp set-credentials "$USERNAME" "$PASSWORD"', self.setup_service)
|
||||||
|
self.assertNotIn('grdctl --system rdp set-credentials sovran "$PASSWORD"', self.setup_service)
|
||||||
|
|
||||||
|
def test_secure_permissions_are_enforced_for_state_and_secret_files(self):
|
||||||
|
self.assertIn('"d /var/lib/gnome-remote-desktop/tls 0700', self.source)
|
||||||
|
self.assertIn("chmod 700", self.setup_service)
|
||||||
|
self.assertIn('chmod 600 "$USERNAME_FILE"', self.setup_service)
|
||||||
|
self.assertIn('chmod 600 "$PASSWORD_FILE"', self.setup_service)
|
||||||
|
self.assertIn('chmod 600 "$CRED_FILE"', self.setup_service)
|
||||||
|
self.assertIn('chmod 600 "$TLS_DIR/rdp-tls.key"', self.setup_service)
|
||||||
|
self.assertIn('chmod 644 "$TLS_DIR/rdp-tls.crt"', self.setup_service)
|
||||||
|
self.assertIn('LOCAL_IP="$(hostname -I | awk \'{print $1}\')"', self.setup_service)
|
||||||
|
self.assertIn('LOCAL_IP="127.0.0.1"', self.setup_service)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Generated
+15
-15
@@ -5,11 +5,11 @@
|
|||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1783086783,
|
"lastModified": 1783519926,
|
||||||
"narHash": "sha256-NxXpNF/9tq2nI+SxFHUxjro3u11SF3l4vs7bawdMKkQ=",
|
"narHash": "sha256-2zwAN4lNitHFrHVnRZG3YcvpdtWOoF0cOBstxMeB1KI=",
|
||||||
"owner": "emmanuelrosa",
|
"owner": "emmanuelrosa",
|
||||||
"repo": "btc-clients-nix",
|
"repo": "btc-clients-nix",
|
||||||
"rev": "4f6d07cae877ef58f0fbc9e731c99800ddb80859",
|
"rev": "731a1e11c2fefb14f0aa4b1f03cfa85c19c28d71",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -139,11 +139,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs-stable": {
|
"nixpkgs-stable": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1782999065,
|
"lastModified": 1784432872,
|
||||||
"narHash": "sha256-5Dgj5+pIQYZKrXUGaLCk7CKfN3MmpwIhO94++WVxvng=",
|
"narHash": "sha256-n3gKTBIV4ZA5VQpUakffBe3KGu4+mhPoA34rrqS0GkA=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "80d591ed473cfc46329932c2aadac9b435342c7c",
|
"rev": "fd1462031fdee08f65fd0b4c6b64e22239a77870",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -187,11 +187,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs_3": {
|
"nixpkgs_3": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1782959384,
|
"lastModified": 1784356753,
|
||||||
"narHash": "sha256-xnJJk+ct+D2+wdRxj1wk36w5zV9RVESwRqcklPdt3fM=",
|
"narHash": "sha256-12KrbMiWLcf8m7pCvAtZh1ZrgF85ZXDXvfR/fWTKy84=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "65179426c83bb3f6bc14898b42ea1c6f01d374b0",
|
"rev": "61b7c44c4073f0b827768aff0049561b5110ea5a",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -203,11 +203,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs_4": {
|
"nixpkgs_4": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1782948114,
|
"lastModified": 1783915482,
|
||||||
"narHash": "sha256-AXmz9ho4Lud5CsbrZsuSVwpQZ4o5FgZ1chxBn5cJ8+0=",
|
"narHash": "sha256-FmieJB8/OUvNxbkboi7+IGfIuSXY3nF/hZQm8kD0r50=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "9e92285f211dad236540fd617d7e30e0b99bc0e1",
|
"rev": "6cdc7fc76e8bf7fde9fa43a849fcaaa70e230dee",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -224,11 +224,11 @@
|
|||||||
"systems": "systems_2"
|
"systems": "systems_2"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1783173302,
|
"lastModified": 1784057377,
|
||||||
"narHash": "sha256-nlnOw/zsD2H2NHSZ5oNWwcjuM17vipyAapfXsO78GjY=",
|
"narHash": "sha256-yycNej5//EsRbV10moBoh+/63vXEwZD1ZFEiRm6C9rQ=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "nixvim",
|
"repo": "nixvim",
|
||||||
"rev": "a402fdf2a1ef297d8ea7c95b90d6af0dbe90ab11",
|
"rev": "07180a087e4a00720dc0731cbcd8dec796974381",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
+1
-1
@@ -1169,7 +1169,7 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
|
btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
|
||||||
btn_box.set_halign(Gtk.Align.CENTER)
|
btn_box.set_halign(Gtk.Align.CENTER)
|
||||||
btn_box.set_margin_bottom(32)
|
btn_box.set_margin_bottom(32)
|
||||||
reboot_btn = Gtk.Button(label="I Have Written Down My Password — Reboot Now")
|
reboot_btn = Gtk.Button(label="I Have Written Down My Password — Restart Entire System")
|
||||||
reboot_btn.add_css_class("suggested-action")
|
reboot_btn.add_css_class("suggested-action")
|
||||||
reboot_btn.add_css_class("pill")
|
reboot_btn.add_css_class("pill")
|
||||||
reboot_btn.connect("clicked", lambda b: subprocess.run(["sudo", "reboot"]))
|
reboot_btn.connect("clicked", lambda b: subprocess.run(["sudo", "reboot"]))
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
{ config, pkgs, lib, ... }:
|
||||||
|
|
||||||
|
# ── Server-local domain loopback overrides ────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Some routers (especially newer ISP-provided devices) do not support NAT
|
||||||
|
# loopback (hairpin NAT). When a request originates on this computer and
|
||||||
|
# targets a public domain name that resolves to the router's WAN address, the
|
||||||
|
# router may refuse to loop the connection back in — causing Nextcloud, WordPress
|
||||||
|
# background jobs, and other server-side callbacks to fail even when the service
|
||||||
|
# is fully operational from the internet.
|
||||||
|
#
|
||||||
|
# This module installs a one-shot systemd service,
|
||||||
|
# ``sovran-hosts-update.service``, that reads the configured service domains
|
||||||
|
# from ``/var/lib/domains/`` at boot (and whenever triggered by the Hub after a
|
||||||
|
# domain is saved) and writes ``127.0.0.1`` entries for them into a dedicated
|
||||||
|
# Sovran-managed block in ``/etc/hosts``.
|
||||||
|
#
|
||||||
|
# With those entries in place:
|
||||||
|
# • Requests originating on this computer resolve the public domain name to
|
||||||
|
# 127.0.0.1, reach Caddy directly, and never touch the router.
|
||||||
|
# • Caddy still receives the correct public hostname via TLS SNI so virtual-
|
||||||
|
# host routing and certificate validation continue to work.
|
||||||
|
# • The Sovran Hub can verify Caddy reachability locally without needing NAT
|
||||||
|
# loopback.
|
||||||
|
#
|
||||||
|
# Limitation: this does not help other devices on your home network (phones,
|
||||||
|
# laptops). Those devices resolve domains via the router's DNS and still depend
|
||||||
|
# on NAT loopback (or require manual router DNS overrides). For now, only
|
||||||
|
# server-originated requests benefit from this override.
|
||||||
|
#
|
||||||
|
# On NixOS, /etc/hosts is normally a symlink into the Nix store and is
|
||||||
|
# regenerated by the system activation script. The ``system.activationScripts``
|
||||||
|
# hook below converts it to a writable file each time the system is activated
|
||||||
|
# (i.e. after every ``nixos-rebuild switch``) and then injects the Sovran block.
|
||||||
|
# The same wrapped Nix-store executable is reused by both the activation hook
|
||||||
|
# and the ``sovran-hosts-update.service`` unit, ensuring a deterministic runtime
|
||||||
|
# PATH in every execution context.
|
||||||
|
|
||||||
|
let
|
||||||
|
# ── Wrapped Nix-store executable ──────────────────────────────────────────
|
||||||
|
# Built with pkgs.writeShellApplication so that all required runtime tools
|
||||||
|
# (awk, grep, coreutils) are declared explicitly and injected into PATH by
|
||||||
|
# Nix. Both the systemd service and the activation hook reference this same
|
||||||
|
# store-path executable — there is no second raw script body.
|
||||||
|
hostsUpdateScript = pkgs.writeShellApplication {
|
||||||
|
name = "sovran-hosts-update";
|
||||||
|
|
||||||
|
# Declare every external command the script calls. These packages are
|
||||||
|
# added to the script's runtime PATH by writeShellApplication; nothing from
|
||||||
|
# the system PATH is relied upon.
|
||||||
|
runtimeInputs = [
|
||||||
|
pkgs.coreutils # readlink, cp, mv, chmod, mktemp, rm, tr, head
|
||||||
|
pkgs.gawk # awk
|
||||||
|
pkgs.gnugrep # grep
|
||||||
|
];
|
||||||
|
|
||||||
|
text = ''
|
||||||
|
# Regenerate the Sovran-managed loopback block in /etc/hosts.
|
||||||
|
# Safe to run multiple times — idempotent.
|
||||||
|
|
||||||
|
DOMAINS_DIR="/var/lib/domains"
|
||||||
|
HOSTS_FILE="/etc/hosts"
|
||||||
|
BEGIN_MARKER="# Sovran managed begin — server-local loopback overrides"
|
||||||
|
END_MARKER="# Sovran managed end"
|
||||||
|
|
||||||
|
# ── Step 1: ensure /etc/hosts is a regular writable file ──────────────
|
||||||
|
# On NixOS /etc/hosts starts as a symlink to the Nix store. We replace
|
||||||
|
# it with a copy so we can append our block without touching the store.
|
||||||
|
if [ -L "$HOSTS_FILE" ]; then
|
||||||
|
TARGET=$(readlink -f "$HOSTS_FILE")
|
||||||
|
cp --no-preserve=all "$TARGET" "$HOSTS_FILE.sovran-tmp"
|
||||||
|
mv "$HOSTS_FILE.sovran-tmp" "$HOSTS_FILE"
|
||||||
|
chmod 644 "$HOSTS_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Step 2: remove any existing Sovran block ──────────────────────────
|
||||||
|
# Use a temp file so the operation is atomic.
|
||||||
|
# awk -v passes marker strings safely without shell interpolation.
|
||||||
|
TMP=$(mktemp "$HOSTS_FILE.XXXXXX")
|
||||||
|
trap 'rm -f "$TMP"' EXIT
|
||||||
|
awk -v begin="$BEGIN_MARKER" -v end="$END_MARKER" '
|
||||||
|
$0 == begin { skip=1; next }
|
||||||
|
$0 == end { skip=0; next }
|
||||||
|
!skip
|
||||||
|
' "$HOSTS_FILE" > "$TMP"
|
||||||
|
|
||||||
|
# ── Step 3: collect valid configured service domains ──────────────────
|
||||||
|
# NOTE: The hostname validation regex below must stay in sync with
|
||||||
|
# _SAFE_DOMAIN_RE in app/sovran_systemsos_web/server.py.
|
||||||
|
ENTRIES=""
|
||||||
|
for KEY in matrix wordpress nextcloud btcpayserver vaultwarden haven element-calling; do
|
||||||
|
FILE="$DOMAINS_DIR/$KEY"
|
||||||
|
[ -f "$FILE" ] || continue
|
||||||
|
# Read the domain value (strip all whitespace, limit to 253 chars)
|
||||||
|
DOMAIN=$(tr -d '[:space:]' < "$FILE" | head -c 253)
|
||||||
|
[ -z "$DOMAIN" ] && continue
|
||||||
|
# Validate: must match a reasonable hostname pattern (no injection)
|
||||||
|
if ! printf '%s' "$DOMAIN" | grep -qE \
|
||||||
|
'^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$'; then
|
||||||
|
echo "sovran-hosts-update: skipping invalid domain value for $KEY: $DOMAIN" >&2
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
ENTRIES="$ENTRIES
|
||||||
|
127.0.0.1 $DOMAIN
|
||||||
|
::1 $DOMAIN"
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Step 4: append the Sovran block if there are any entries ──────────
|
||||||
|
if [ -n "$ENTRIES" ]; then
|
||||||
|
{
|
||||||
|
printf '\n%s\n' "$BEGIN_MARKER"
|
||||||
|
printf '%s\n' "# These entries route configured service domains to local Caddy."
|
||||||
|
printf '%s\n' "# They are managed automatically — do not edit this block."
|
||||||
|
printf '%s\n' "$ENTRIES"
|
||||||
|
printf '%s\n' "$END_MARKER"
|
||||||
|
} >> "$TMP"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Step 5: atomically replace /etc/hosts ─────────────────────────────
|
||||||
|
mv "$TMP" "$HOSTS_FILE"
|
||||||
|
chmod 644 "$HOSTS_FILE"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
in
|
||||||
|
{
|
||||||
|
# ── /etc/sovran-hosts-update.sh — operator discoverability symlink ─────────
|
||||||
|
# Retain the familiar /etc path so administrators can inspect or manually
|
||||||
|
# invoke the helper. The target is the wrapped Nix-store executable, so
|
||||||
|
# there is no second raw script body to keep in sync.
|
||||||
|
environment.etc."sovran-hosts-update.sh".source = lib.getExe hostsUpdateScript;
|
||||||
|
|
||||||
|
# ── Systemd service ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
systemd.services.sovran-hosts-update = {
|
||||||
|
description = "Update /etc/hosts with Sovran server-local loopback overrides";
|
||||||
|
documentation = [ "https://github.com/naturallaw777/sovran-systems" ];
|
||||||
|
|
||||||
|
# Run before Caddy so loopback entries are ready when it starts.
|
||||||
|
before = [
|
||||||
|
"caddy.service"
|
||||||
|
"network-online.target"
|
||||||
|
];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
RemainAfterExit = true;
|
||||||
|
# Point directly at the wrapped Nix-store executable, not the /etc path.
|
||||||
|
ExecStart = lib.getExe hostsUpdateScript;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# ── Activation script (runs after every nixos-rebuild switch) ─────────────
|
||||||
|
# This ensures the loopback block survives rebuilds that restore the /etc/hosts
|
||||||
|
# symlink. The "users" and "etc" scripts must complete first.
|
||||||
|
# The same wrapped Nix-store executable used by the systemd service is
|
||||||
|
# referenced here, guaranteeing identical runtime dependencies in both
|
||||||
|
# execution contexts.
|
||||||
|
|
||||||
|
system.activationScripts.sovranDomainLoopback = {
|
||||||
|
text = ''
|
||||||
|
if [ -d /var/lib/domains ]; then
|
||||||
|
if ! ${lib.getExe hostsUpdateScript}; then
|
||||||
|
echo "warning: sovran-hosts-update: failed to update /etc/hosts loopback entries" >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
'';
|
||||||
|
deps = [ "etc" "users" ];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -382,13 +382,24 @@ in
|
|||||||
};
|
};
|
||||||
|
|
||||||
path = [
|
path = [
|
||||||
|
pkgs.bash
|
||||||
|
pkgs.gawk
|
||||||
pkgs.qrencode
|
pkgs.qrencode
|
||||||
pkgs.curl
|
pkgs.curl
|
||||||
pkgs.iproute2
|
pkgs.iproute2
|
||||||
pkgs.nftables
|
pkgs.nftables
|
||||||
pkgs.iptables
|
pkgs.iptables
|
||||||
pkgs.hostname
|
pkgs.hostname
|
||||||
] ++ lib.optional cfg.services.bitcoin config.services.bitcoind.package;
|
pkgs.coreutils
|
||||||
|
pkgs.findutils
|
||||||
|
pkgs.gnugrep
|
||||||
|
pkgs.gnutar
|
||||||
|
pkgs.util-linux
|
||||||
|
]
|
||||||
|
++ lib.optional cfg.services.bitcoin config.services.bitcoind.package
|
||||||
|
++ lib.optionals cfg.services.bitcoin [ pkgs.lnd ]
|
||||||
|
++ lib.optionals (cfg.services.nextcloud || cfg.services.synapse) [ config.services.postgresql.package ]
|
||||||
|
++ lib.optionals config.services.mysql.enable [ config.services.mysql.package ];
|
||||||
};
|
};
|
||||||
|
|
||||||
systemd.services.sovran-hub-update = {
|
systemd.services.sovran-hub-update = {
|
||||||
|
|||||||
@@ -72,17 +72,30 @@ $MATRIX {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$ELEMENT_CALLING {
|
$ELEMENT_CALLING {
|
||||||
handle /livekit/jwt/sfu/get {
|
# Route all current lk-jwt-service authorization endpoints to port 8073,
|
||||||
|
# stripping the /livekit/jwt prefix that Caddy adds on the public URL.
|
||||||
|
@lk_jwt path /livekit/jwt/sfu/get* /livekit/jwt/get_token* /livekit/jwt/healthz* /livekit/jwt/sfu_webhook* /livekit/jwt/delegate_delayed_leave*
|
||||||
|
handle @lk_jwt {
|
||||||
uri strip_prefix /livekit/jwt
|
uri strip_prefix /livekit/jwt
|
||||||
reverse_proxy [::1]:8073 {
|
reverse_proxy [::1]:8073 {
|
||||||
header_up Host {host}
|
header_up Host {host}
|
||||||
header_up X-Forwarded-Server {host}
|
header_up X-Forwarded-Server {host}
|
||||||
header_up X-Real-IP {remote_host}
|
header_up X-Real-IP {remote_host}
|
||||||
header_up X-Forwarded-For {remote_host}
|
header_up X-Forwarded-For {remote_host}
|
||||||
|
header_up X-Forwarded-Proto {scheme}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
handle {
|
handle {
|
||||||
reverse_proxy localhost:7880
|
reverse_proxy localhost:7880 {
|
||||||
|
header_up Host {host}
|
||||||
|
header_up X-Forwarded-Proto {scheme}
|
||||||
|
header_up X-Forwarded-For {remote_host}
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
transport http {
|
||||||
|
read_timeout 300s
|
||||||
|
write_timeout 300s
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
@@ -94,8 +107,11 @@ EOF
|
|||||||
# * reads the matrix domain from /var/lib/domains/matrix (never hardcoded)
|
# * reads the matrix domain from /var/lib/domains/matrix (never hardcoded)
|
||||||
# * copies Caddy's already-issued matrix cert/key into /var/lib/livekit
|
# * copies Caddy's already-issued matrix cert/key into /var/lib/livekit
|
||||||
# so LoadCredential can stage them for the (DynamicUser) livekit unit
|
# so LoadCredential can stage them for the (DynamicUser) livekit unit
|
||||||
# * writes a complete LiveKit config (with turn.domain substituted) that the
|
# * detects the primary network interface from the IPv4 default route so
|
||||||
# overridden ExecStart loads.
|
# LiveKit only advertises real ICE candidates — not VPN/container/private
|
||||||
|
# addresses from interfaces like Tailscale or Docker bridges
|
||||||
|
# * writes a complete LiveKit config (with turn.domain and interface
|
||||||
|
# substituted) that the overridden ExecStart loads.
|
||||||
systemd.services.livekit-turn-setup = {
|
systemd.services.livekit-turn-setup = {
|
||||||
description = "Stage TURN cert and generate LiveKit runtime config from domain files";
|
description = "Stage TURN cert and generate LiveKit runtime config from domain files";
|
||||||
after = [ "caddy.service" "livekit-key-setup.service" ];
|
after = [ "caddy.service" "livekit-key-setup.service" ];
|
||||||
@@ -109,7 +125,7 @@ EOF
|
|||||||
unitConfig = {
|
unitConfig = {
|
||||||
ConditionPathExists = "/var/lib/domains/element-calling";
|
ConditionPathExists = "/var/lib/domains/element-calling";
|
||||||
};
|
};
|
||||||
path = [ pkgs.coreutils pkgs.findutils ];
|
path = [ pkgs.coreutils pkgs.findutils pkgs.iproute2 pkgs.gawk ];
|
||||||
script = ''
|
script = ''
|
||||||
MATRIX=$(cat /var/lib/domains/matrix)
|
MATRIX=$(cat /var/lib/domains/matrix)
|
||||||
|
|
||||||
@@ -123,16 +139,37 @@ EOF
|
|||||||
cp "$KEY" /var/lib/livekit/turn.key
|
cp "$KEY" /var/lib/livekit/turn.key
|
||||||
chmod 640 /var/lib/livekit/turn.crt /var/lib/livekit/turn.key
|
chmod 640 /var/lib/livekit/turn.crt /var/lib/livekit/turn.key
|
||||||
|
|
||||||
# Generate the full LiveKit config the daemon will load. turn.domain is
|
# Detect the primary network interface from the IPv4 default route.
|
||||||
# only known at runtime, so it is substituted here. The cert/key paths
|
# Restricting LiveKit to this single interface prevents it from
|
||||||
# point at the LoadCredential-staged copies under /run/credentials.
|
# advertising VPN/container/private ICE candidates (e.g. Tailscale,
|
||||||
|
# Docker bridges) that remote peers cannot reach, which causes all
|
||||||
|
# ICE negotiation attempts to fail with responsesReceived: 0.
|
||||||
|
IFACE=$(ip -4 route show default | awk '/^default/ { for(i=1;i<=NF;i++) if($i=="dev" && (i+1)<=NF) { print $(i+1); exit } }')
|
||||||
|
if [ -z "$IFACE" ]; then
|
||||||
|
echo "ERROR: Could not detect a default-route network interface from 'ip -4 route show default'." >&2
|
||||||
|
echo "ERROR: Cannot generate a valid LiveKit config without a real interface to bind ICE candidates to." >&2
|
||||||
|
echo "ERROR: Ensure a default IPv4 route is configured, e.g.: ip route add default via <gateway> dev <interface>" >&2
|
||||||
|
echo "ERROR: Inspect the current routing table with: ip -4 route show" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Detected primary network interface: $IFACE"
|
||||||
|
|
||||||
|
# Generate the full LiveKit config the daemon will load. turn.domain and
|
||||||
|
# rtc.interfaces.includes are only known at runtime, so they are
|
||||||
|
# substituted here. The cert/key paths point at the LoadCredential-staged
|
||||||
|
# copies under /run/credentials.
|
||||||
cat > /run/livekit/livekit.yaml <<EOF
|
cat > /run/livekit/livekit.yaml <<EOF
|
||||||
port: 7880
|
port: 7880
|
||||||
rtc:
|
rtc:
|
||||||
use_external_ip: true
|
use_external_ip: true
|
||||||
|
skip_external_ip_validation: true
|
||||||
|
tcp_port: 7881
|
||||||
udp_port: 7882
|
udp_port: 7882
|
||||||
port_range_start: 30000
|
port_range_start: 30000
|
||||||
port_range_end: 40000
|
port_range_end: 40000
|
||||||
|
interfaces:
|
||||||
|
includes:
|
||||||
|
- $IFACE
|
||||||
room:
|
room:
|
||||||
auto_create: false
|
auto_create: false
|
||||||
turn:
|
turn:
|
||||||
@@ -155,6 +192,8 @@ EOF
|
|||||||
keyFile = livekitKeyFile;
|
keyFile = livekitKeyFile;
|
||||||
settings = {
|
settings = {
|
||||||
rtc.use_external_ip = true;
|
rtc.use_external_ip = true;
|
||||||
|
rtc.skip_external_ip_validation = true;
|
||||||
|
rtc.tcp_port = 7881;
|
||||||
rtc.udp_port = 7882;
|
rtc.udp_port = 7882;
|
||||||
rtc.port_range_start = 30000;
|
rtc.port_range_start = 30000;
|
||||||
rtc.port_range_end = 40000;
|
rtc.port_range_end = 40000;
|
||||||
@@ -186,6 +225,9 @@ EOF
|
|||||||
|
|
||||||
networking.firewall.allowedTCPPorts = [ 5349 7881 ];
|
networking.firewall.allowedTCPPorts = [ 5349 7881 ];
|
||||||
networking.firewall.allowedUDPPorts = [ 3478 7882 ];
|
networking.firewall.allowedUDPPorts = [ 3478 7882 ];
|
||||||
|
networking.firewall.allowedUDPPortRanges = [
|
||||||
|
{ from = 30000; to = 40000; } # LiveKit internal TURN relay range
|
||||||
|
];
|
||||||
|
|
||||||
####### JWT SERVICE RUNTIME CONFIG #######
|
####### JWT SERVICE RUNTIME CONFIG #######
|
||||||
systemd.services.lk-jwt-service-runtime-config = {
|
systemd.services.lk-jwt-service-runtime-config = {
|
||||||
@@ -204,11 +246,13 @@ EOF
|
|||||||
path = [ pkgs.coreutils ];
|
path = [ pkgs.coreutils ];
|
||||||
script = ''
|
script = ''
|
||||||
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
|
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
|
||||||
|
MATRIX=$(cat /var/lib/domains/matrix)
|
||||||
|
|
||||||
mkdir -p /run/lk-jwt-service
|
mkdir -p /run/lk-jwt-service
|
||||||
|
|
||||||
cat > /run/lk-jwt-service/env <<EOF
|
cat > /run/lk-jwt-service/env <<EOF
|
||||||
LIVEKIT_URL=wss://$ELEMENT_CALLING
|
LIVEKIT_URL=wss://$ELEMENT_CALLING
|
||||||
|
LIVEKIT_FULL_ACCESS_HOMESERVERS=$MATRIX
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
chmod 640 /run/lk-jwt-service/env
|
chmod 640 /run/lk-jwt-service/env
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
./core/remote-deploy.nix
|
./core/remote-deploy.nix
|
||||||
./core/no-sleep.nix
|
./core/no-sleep.nix
|
||||||
./core/cpu-performance.nix
|
./core/cpu-performance.nix
|
||||||
|
./core/local-domain-loopback.nix
|
||||||
|
|
||||||
# ── Always on (no flag) ───────────────────────────────────
|
# ── Always on (no flag) ───────────────────────────────────
|
||||||
./php.nix
|
./php.nix
|
||||||
|
|||||||
Executable → Regular
+121
-56
@@ -2,70 +2,104 @@
|
|||||||
|
|
||||||
lib.mkIf config.sovran_systemsOS.features.rdp {
|
lib.mkIf config.sovran_systemsOS.features.rdp {
|
||||||
|
|
||||||
users.users.gnome-remote-desktop = {
|
|
||||||
isSystemUser = true;
|
|
||||||
group = "gnome-remote-desktop";
|
|
||||||
home = "/var/lib/gnome-remote-desktop";
|
|
||||||
createHome = true;
|
|
||||||
};
|
|
||||||
users.groups.gnome-remote-desktop = {};
|
|
||||||
|
|
||||||
# Enable the GNOME Remote Desktop service at the system level
|
# Enable the GNOME Remote Desktop service at the system level
|
||||||
services.gnome.gnome-remote-desktop.enable = true;
|
services.gnome.gnome-remote-desktop.enable = true;
|
||||||
|
|
||||||
# Open RDP port in the firewall
|
# Open RDP port in the firewall
|
||||||
networking.firewall.allowedTCPPorts = [ 3389 ];
|
networking.firewall.allowedTCPPorts = [ 3389 ];
|
||||||
|
|
||||||
# Ensure the service actually starts and waits for setup to complete
|
# Ensure the service only starts after setup succeeds
|
||||||
systemd.services.gnome-remote-desktop = {
|
systemd.services.gnome-remote-desktop = {
|
||||||
wantedBy = [ "graphical.target" ];
|
wantedBy = [ "graphical.target" ];
|
||||||
after = [ "gnome-remote-desktop-setup.service" ];
|
after = [ "gnome-remote-desktop-setup.service" ];
|
||||||
wants = [ "gnome-remote-desktop-setup.service" ];
|
requires = [ "gnome-remote-desktop-setup.service" ];
|
||||||
};
|
};
|
||||||
|
|
||||||
systemd.tmpfiles.rules = [
|
systemd.tmpfiles.rules = [
|
||||||
"d /var/lib/gnome-remote-desktop 0750 gnome-remote-desktop gnome-remote-desktop -"
|
"d /var/lib/gnome-remote-desktop/.local 0700 gnome-remote-desktop gnome-remote-desktop -"
|
||||||
"d /var/lib/gnome-remote-desktop/.local 0750 gnome-remote-desktop gnome-remote-desktop -"
|
"d /var/lib/gnome-remote-desktop/.local/share 0700 gnome-remote-desktop gnome-remote-desktop -"
|
||||||
"d /var/lib/gnome-remote-desktop/.local/share 0750 gnome-remote-desktop gnome-remote-desktop -"
|
"d /var/lib/gnome-remote-desktop/.local/share/gnome-remote-desktop 0700 gnome-remote-desktop gnome-remote-desktop -"
|
||||||
"d /var/lib/gnome-remote-desktop/.local/share/gnome-remote-desktop 0750 gnome-remote-desktop gnome-remote-desktop -"
|
"d /var/lib/gnome-remote-desktop/tls 0700 gnome-remote-desktop gnome-remote-desktop -"
|
||||||
];
|
];
|
||||||
|
|
||||||
systemd.services.gnome-remote-desktop-setup = {
|
systemd.services.gnome-remote-desktop-setup = {
|
||||||
description = "Configure GNOME Remote Desktop RDP";
|
description = "Configure GNOME Remote Desktop RDP";
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "graphical.target" ];
|
||||||
before = [ "gnome-remote-desktop.service" ];
|
before = [ "gnome-remote-desktop.service" ];
|
||||||
after = [ "systemd-tmpfiles-setup.service" "network-online.target" ];
|
after = [
|
||||||
wants = [ "network-online.target" ];
|
"dbus.service"
|
||||||
|
"systemd-tmpfiles-setup.service"
|
||||||
|
"network-online.target"
|
||||||
|
"gnome-remote-desktop-configuration.service"
|
||||||
|
];
|
||||||
|
wants = [
|
||||||
|
"network-online.target"
|
||||||
|
"gnome-remote-desktop-configuration.service"
|
||||||
|
];
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
Type = "oneshot";
|
Type = "oneshot";
|
||||||
RemainAfterExit = true;
|
TimeoutStartSec = "2min";
|
||||||
};
|
};
|
||||||
path = [
|
path = [
|
||||||
pkgs.gnome-remote-desktop
|
pkgs.coreutils
|
||||||
pkgs.polkit
|
|
||||||
pkgs.openssl
|
|
||||||
pkgs.hostname
|
|
||||||
pkgs.gawk
|
pkgs.gawk
|
||||||
|
pkgs.gnome-remote-desktop
|
||||||
|
pkgs.hostname
|
||||||
|
pkgs.openssl
|
||||||
|
pkgs.systemd
|
||||||
];
|
];
|
||||||
script = ''
|
script = ''
|
||||||
# Ensure directory structure exists
|
set -euo pipefail
|
||||||
mkdir -p /var/lib/gnome-remote-desktop/.local/share/gnome-remote-desktop
|
|
||||||
chown -R gnome-remote-desktop:gnome-remote-desktop /var/lib/gnome-remote-desktop
|
|
||||||
|
|
||||||
TLS_DIR="/var/lib/gnome-remote-desktop/tls"
|
# GRD 50.x invokes pkexec internally for every grdctl --system call, even
|
||||||
CRED_FILE="/var/lib/gnome-remote-desktop/rdp-credentials"
|
# when the caller is root. NixOS exposes the required setuid wrapper at
|
||||||
|
# /run/wrappers/bin/pkexec; the Nix-store polkit binary is not setuid and
|
||||||
|
# must not shadow it. Prepend the wrapper directory so every subsequent
|
||||||
|
# grdctl --system resolves the correct binary.
|
||||||
|
export PATH="/run/wrappers/bin:$PATH"
|
||||||
|
|
||||||
|
STATE_DIR="/var/lib/gnome-remote-desktop"
|
||||||
|
TLS_DIR="$STATE_DIR/tls"
|
||||||
|
USERNAME_FILE="$STATE_DIR/rdp-username"
|
||||||
|
PASSWORD_FILE="$STATE_DIR/rdp-password"
|
||||||
|
CRED_FILE="$STATE_DIR/rdp-credentials"
|
||||||
|
DEFAULT_USERNAME="sovran"
|
||||||
|
|
||||||
|
grdctl_system() {
|
||||||
|
local rc=0
|
||||||
|
|
||||||
|
if timeout --kill-after=5s 10s \
|
||||||
|
grdctl --system "$@"; then
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
rc=$?
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ]; then
|
||||||
|
echo "grdctl command timed out: $*" >&2
|
||||||
|
fi
|
||||||
|
echo "grdctl command failed (exit $rc): $*" >&2
|
||||||
|
|
||||||
|
return "$rc"
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir -p "$STATE_DIR/.local/share/gnome-remote-desktop" "$TLS_DIR"
|
||||||
|
chown -R gnome-remote-desktop:gnome-remote-desktop "$STATE_DIR"
|
||||||
|
chmod 700 \
|
||||||
|
"$STATE_DIR" \
|
||||||
|
"$STATE_DIR/.local" \
|
||||||
|
"$STATE_DIR/.local/share" \
|
||||||
|
"$STATE_DIR/.local/share/gnome-remote-desktop" \
|
||||||
|
"$TLS_DIR"
|
||||||
|
|
||||||
# Regenerate TLS certificate if missing OR if ownership is wrong
|
|
||||||
# (disable/re-enable cycle can break ownership or grdctl state)
|
|
||||||
NEED_REGEN=0
|
NEED_REGEN=0
|
||||||
if [ ! -f "$TLS_DIR/rdp-tls.crt" ] || [ ! -f "$TLS_DIR/rdp-tls.key" ]; then
|
if [ ! -f "$TLS_DIR/rdp-tls.crt" ] || [ ! -f "$TLS_DIR/rdp-tls.key" ]; then
|
||||||
NEED_REGEN=1
|
NEED_REGEN=1
|
||||||
elif [ "$(stat -c '%U' "$TLS_DIR/rdp-tls.key" 2>/dev/null)" != "gnome-remote-desktop" ]; then
|
elif [ "$(stat -c '%U:%G' "$TLS_DIR/rdp-tls.key" 2>/dev/null)" != "gnome-remote-desktop:gnome-remote-desktop" ]; then
|
||||||
NEED_REGEN=1
|
NEED_REGEN=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$NEED_REGEN" = "1" ]; then
|
if [ "$NEED_REGEN" = "1" ]; then
|
||||||
mkdir -p "$TLS_DIR"
|
|
||||||
rm -f "$TLS_DIR/rdp-tls.key" "$TLS_DIR/rdp-tls.crt"
|
rm -f "$TLS_DIR/rdp-tls.key" "$TLS_DIR/rdp-tls.crt"
|
||||||
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
|
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
|
||||||
-sha256 -nodes -days 3650 \
|
-sha256 -nodes -days 3650 \
|
||||||
@@ -75,39 +109,59 @@ lib.mkIf config.sovran_systemsOS.features.rdp {
|
|||||||
echo "Generated new RDP TLS certificate"
|
echo "Generated new RDP TLS certificate"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Always fix ownership and permissions (handles re-enable after disable)
|
chown gnome-remote-desktop:gnome-remote-desktop "$TLS_DIR/rdp-tls.key" "$TLS_DIR/rdp-tls.crt"
|
||||||
chown -R gnome-remote-desktop:gnome-remote-desktop "$TLS_DIR"
|
|
||||||
chmod 600 "$TLS_DIR/rdp-tls.key"
|
chmod 600 "$TLS_DIR/rdp-tls.key"
|
||||||
chmod 644 "$TLS_DIR/rdp-tls.crt"
|
chmod 644 "$TLS_DIR/rdp-tls.crt"
|
||||||
|
|
||||||
# Configure TLS certificate
|
if [ ! -f "$USERNAME_FILE" ]; then
|
||||||
grdctl --system rdp set-tls-cert "$TLS_DIR/rdp-tls.crt"
|
printf '%s\n' "$DEFAULT_USERNAME" > "$USERNAME_FILE"
|
||||||
grdctl --system rdp set-tls-key "$TLS_DIR/rdp-tls.key"
|
fi
|
||||||
|
USERNAME="$(tr -d '\n' < "$USERNAME_FILE")"
|
||||||
|
if [ -z "$USERNAME" ]; then
|
||||||
|
USERNAME="$DEFAULT_USERNAME"
|
||||||
|
printf '%s\n' "$USERNAME" > "$USERNAME_FILE"
|
||||||
|
fi
|
||||||
|
if [ "''${#USERNAME}" -gt 32 ]; then
|
||||||
|
echo "RDP username is too long (''${#USERNAME} characters, maximum 32): $USERNAME from $USERNAME_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
case "$USERNAME" in
|
||||||
|
[A-Za-z_][A-Za-z0-9_-]*)
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "RDP username must start with a letter or underscore and contain only letters, numbers, underscores, and hyphens: $USERNAME from $USERNAME_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
chown gnome-remote-desktop:gnome-remote-desktop "$USERNAME_FILE"
|
||||||
|
chmod 600 "$USERNAME_FILE"
|
||||||
|
|
||||||
# Generate password on first boot only
|
if [ ! -f "$PASSWORD_FILE" ]; then
|
||||||
PASSWORD=""
|
openssl rand -base64 16 > "$PASSWORD_FILE"
|
||||||
if [ ! -f /var/lib/gnome-remote-desktop/rdp-password ]; then
|
fi
|
||||||
PASSWORD=$(openssl rand -base64 16)
|
PASSWORD="$(tr -d '\n' < "$PASSWORD_FILE")"
|
||||||
echo "$PASSWORD" > /var/lib/gnome-remote-desktop/rdp-password
|
if [ -z "$PASSWORD" ]; then
|
||||||
chmod 600 /var/lib/gnome-remote-desktop/rdp-password
|
echo "RDP password file is empty: $PASSWORD_FILE" >&2
|
||||||
else
|
exit 1
|
||||||
PASSWORD=$(cat /var/lib/gnome-remote-desktop/rdp-password)
|
fi
|
||||||
|
if [ "''${#PASSWORD}" -lt 8 ]; then
|
||||||
|
echo "RDP password is too short (''${#PASSWORD} characters, minimum 8): $PASSWORD_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
chown gnome-remote-desktop:gnome-remote-desktop "$PASSWORD_FILE"
|
||||||
|
chmod 600 "$PASSWORD_FILE"
|
||||||
|
|
||||||
|
LOCAL_IP="$(hostname -I | awk '{print $1}')"
|
||||||
|
if [ -z "$LOCAL_IP" ]; then
|
||||||
|
LOCAL_IP="127.0.0.1"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Write username to a separate file for the hub
|
|
||||||
echo "sovran" > /var/lib/gnome-remote-desktop/rdp-username
|
|
||||||
chmod 600 /var/lib/gnome-remote-desktop/rdp-username
|
|
||||||
|
|
||||||
# Get current IP address
|
|
||||||
LOCAL_IP=$(hostname -I | awk '{print $1}')
|
|
||||||
|
|
||||||
# Always rewrite the credentials file with the current IP
|
|
||||||
cat > "$CRED_FILE" <<EOF
|
cat > "$CRED_FILE" <<EOF
|
||||||
========================================
|
========================================
|
||||||
GNOME Remote Desktop (RDP) Credentials
|
GNOME Remote Desktop (RDP) Credentials
|
||||||
========================================
|
========================================
|
||||||
|
|
||||||
Username: sovran
|
Username: $USERNAME
|
||||||
Password: $PASSWORD
|
Password: $PASSWORD
|
||||||
|
|
||||||
Connect from any RDP client to:
|
Connect from any RDP client to:
|
||||||
@@ -116,11 +170,22 @@ lib.mkIf config.sovran_systemsOS.features.rdp {
|
|||||||
========================================
|
========================================
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
chown gnome-remote-desktop:gnome-remote-desktop "$CRED_FILE"
|
||||||
chmod 600 "$CRED_FILE"
|
chmod 600 "$CRED_FILE"
|
||||||
|
|
||||||
# Enable RDP backend and set credentials
|
# Preflight: the NixOS setuid pkexec wrapper must be present and executable
|
||||||
grdctl --system rdp enable
|
# before any grdctl --system call. Absence means the system was booted
|
||||||
grdctl --system rdp set-credentials sovran "$PASSWORD"
|
# without security.wrappers or the wrapper directory is not mounted yet.
|
||||||
|
if ! test -x /run/wrappers/bin/pkexec; then
|
||||||
|
echo "Preflight check failed: /run/wrappers/bin/pkexec is absent or not executable." >&2
|
||||||
|
echo "GNOME Remote Desktop system configuration requires the NixOS setuid pkexec wrapper at /run/wrappers/bin/pkexec." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
grdctl_system rdp enable
|
||||||
|
grdctl_system rdp set-tls-cert "$TLS_DIR/rdp-tls.crt"
|
||||||
|
grdctl_system rdp set-tls-key "$TLS_DIR/rdp-tls.key"
|
||||||
|
grdctl_system rdp set-credentials "$USERNAME" "$PASSWORD"
|
||||||
|
|
||||||
echo "GNOME Remote Desktop RDP configured successfully"
|
echo "GNOME Remote Desktop RDP configured successfully"
|
||||||
'';
|
'';
|
||||||
|
|||||||
Reference in New Issue
Block a user