51 Commits
Author SHA1 Message Date
Sovran SystemsandGitHub c202c69898 Merge pull request #332 from naturallaw777/copilot/fix-manual-backup-failure
Fix home.tar exit-1 failure on active desktop; harden full backup workflow
2026-07-18 18:21:07 +00:00
copilot-swe-agent[bot]andGitHub d1251bf238 Final polish: explicit LOCK_FD close, tar --list SIGPIPE comment, marker timing comment, _extract_bash_function docstring 2026-07-18 18:11:19 +00:00
copilot-swe-agent[bot]andGitHub de25110493 Address code review round 2: Python regex for function extraction, bash 4.1+ comment, find -print0 for manifest, mktemp 0600 comment 2026-07-18 18:08:33 +00:00
copilot-swe-agent[bot]andGitHub ab31f4f60b Address code review: source allowlist function from script in tests, fix find exclusions for BACKUP_COMPLETE, simplify log message 2026-07-18 18:05:46 +00:00
copilot-swe-agent[bot]andGitHub f5a5e99de4 Fix test assertion pattern and replace em dashes in new log messages 2026-07-18 18:02:41 +00:00
copilot-swe-agent[bot]andGitHub a30d4d5e74 Fix home.tar exit-1 failure: add tar tolerance, partial atomics, INCOMPLETE marker, flock, browser cache exclusions 2026-07-18 18:00:29 +00:00
copilot-swe-agent[bot]andGitHub 12b8872847 Initial plan 2026-07-18 17:44:54 +00:00
Sovran SystemsandGitHub 56e0c4dcd2 Merge pull request #331 from naturallaw777/copilot/fix-manual-backup-failure
Fix Manual Backup exit-code-127: add bash+gawk to service PATH, harden launcher
2026-07-18 16:40:29 +00:00
copilot-swe-agent[bot]andGitHub 22aa251d64 Address code review: compute stderr_text only when chunks are present 2026-07-18 16:30:38 +00:00
copilot-swe-agent[bot]andGitHub 35c4de412f Fix Manual Backup exit-code-127: add bash+gawk to service PATH, harden launcher 2026-07-18 16:29:10 +00:00
copilot-swe-agent[bot]andGitHub 16067be909 Initial plan 2026-07-18 16:23:49 +00:00
Sovran SystemsandGitHub 36abece7f9 Merge pull request #330 from naturallaw777/copilot/implement-manual-backup-workflow
Implement reliable exFAT Manual Backup with tar artifacts, DB exports, and lifecycle hardening
2026-07-18 15:31:38 +00:00
copilot-swe-agent[bot]andGitHub 36187c0504 Refine backup validation and manifest details 2026-07-17 17:00:32 +00:00
copilot-swe-agent[bot]andGitHub 992c806ed7 Address validation feedback for backup workflow 2026-07-17 16:58:34 +00:00
copilot-swe-agent[bot]andGitHub 9f3d3e7670 Implement reliable exFAT manual backup workflow 2026-07-17 16:56:08 +00:00
copilot-swe-agent[bot]andGitHub 0ec8203557 Initial plan 2026-07-17 16:49:35 +00:00
Sovran SystemsandGitHub ab4de8da7d Merge pull request #329 from naturallaw777/copilot/fix-nix-build-regression
Fix `sovran-hosts-update` ShellCheck build regression from `writeShellApplication`
2026-07-16 20:40:41 +00:00
copilot-swe-agent[bot]andGitHub 92cf417760 test: harden flake configuration detection 2026-07-16 20:38:48 +00:00
copilot-swe-agent[bot]andGitHub ec4c1c851b test: derive nix helper build attr from flake 2026-07-16 20:37:48 +00:00
copilot-swe-agent[bot]andGitHub 38f49e9161 fix: group sovran hosts append redirection 2026-07-16 20:36:33 +00:00
copilot-swe-agent[bot]andGitHub c853853616 Initial plan 2026-07-16 20:33:53 +00:00
Sovran SystemsandGitHub 3e2bc106b1 Merge pull request #328 from naturallaw777/copilot/fix-sovran-hosts-update-runtime-dependency
fix(local-domain-loopback): replace raw /etc script with writeShellApplication, declare explicit runtimeInputs
2026-07-16 20:28:39 +00:00
copilot-swe-agent[bot]andGitHub d4f8c7b431 fix: convert sovran-hosts-update to writeShellApplication with explicit runtimeInputs
- Replace environment.etc raw script with pkgs.writeShellApplication
- Declare runtimeInputs: pkgs.coreutils, pkgs.gawk, pkgs.gnugrep
- Use awk -v for safe marker variable passing (no shell interpolation)
- Point systemd ExecStart and activation script at lib.getExe hostsUpdateScript
- Keep /etc/sovran-hosts-update.sh as a source symlink for operator discoverability
- Remove environment.systemPackages reliance
- Emit warning (not silently swallow) on activation failure
- Add structural regression tests (19 new tests, all passing)
2026-07-16 20:26:59 +00:00
copilot-swe-agent[bot]andGitHub dcbac4760f Initial plan 2026-07-16 20:23:44 +00:00
naturallaw777 2b8ee5ff26 updated readme 2026-07-15 15:25:14 -05:00
Sovran SystemsandGitHub b4990d70ef Merge pull request #327 from naturallaw777/copilot/remove-restart-sidebar-action
Move Reboot from sidebar to compact header button
2026-07-15 18:59:25 +00:00
copilot-swe-agent[bot]andGitHub 59b734995b feat: move Reboot button from sidebar to header, between role badge and Sign Out 2026-07-15 18:58:10 +00:00
copilot-swe-agent[bot]andGitHub 599405ce18 Initial plan 2026-07-15 18:55:54 +00:00
Sovran SystemsandGitHub 4b31255f13 Merge pull request #326 from naturallaw777/copilot/add-system-restart-action
feat: implement consistent whole-system restart UX across the Hub
2026-07-15 18:26:26 +00:00
copilot-swe-agent[bot]andGitHub 6b79c212a8 refactor: improve variable name in openRestartConfirmDialog for clarity 2026-07-15 16:51:52 +00:00
copilot-swe-agent[bot]andGitHub c3950547b0 feat: implement consistent restart UX across Sovran_SystemsOS Hub
- Add Restart Entire System sidebar action with amber treatment and divider
- Add shared restart confirmation dialog with conflict detection
- Update reboot overlay: new title, body copy, status progression, error card
- Improve doReboot(): failure handling, aria-live status messages
- Standardize 'restart required' / 'Restart Entire System' terminology
- Update security.js reboot flow to use shared doReboot()
- Update installer.py button label
- Add .btn-restart-amber, .restart-conflict-box, .sidebar-restart-btn CSS
2026-07-15 16:50:01 +00:00
copilot-swe-agent[bot]andGitHub b28d6dd32c Initial plan 2026-07-15 16:41:20 +00:00
Sovran SystemsandGitHub 7ac3775a96 Merge pull request #325 from naturallaw777/copilot/implement-server-side-loopback-solution
feat: server-side loopback overrides + Hub diagnostic fixes for NAT loopback
2026-07-15 16:21:10 +00:00
copilot-swe-agent[bot]andGitHub de9b069a88 fix: address code review feedback
- Use set -eu (not set -euf) in sovran-hosts-update.sh
- Add sync note for domain validation regex between shell and Python
- Rename `seen` to `unique_addresses` in _resolve_all_addresses
- Improve loopback override UI message with external DNS check guidance
- Add docstring note about first-address display in _resolve_all_addresses"
2026-07-15 15:26:43 +00:00
copilot-swe-agent[bot]andGitHub 2cb0c734d8 feat: server-side loopback overrides and Hub diagnostic fixes
- Add modules/core/local-domain-loopback.nix: systemd service and
  activation script that write configured service domains to a
  Sovran-managed block in /etc/hosts (127.0.0.1 / ::1) so requests
  originating on this computer reach Caddy without NAT loopback.

- Import local-domain-loopback.nix in modules/modules.nix.

- server.py: add _validate_domain_value, _is_loopback_address,
  _resolve_all_addresses, _trigger_hosts_update helpers.

- server.py: update _check_domain_reachable to use --resolve so
  reachability is checked locally via Caddy, not via NAT loopback.

- server.py: update _evaluate_domain_checklist, api_services inline DNS
  check, and api_domains_check to recognise loopback resolution as an
  intentional local override rather than a DNS mismatch.

- server.py: call _trigger_hosts_update from api_domains_set after
  saving a service domain so the /etc/hosts entry is applied immediately.

- Add app/tests/test_loopback_diagnostics.py with 47 tests covering
  domain validation, loopback detection, diagnostic checklist logic,
  composite health, and api_domains_check."
2026-07-15 15:24:16 +00:00
copilot-swe-agent[bot]andGitHub 6f908513e3 Initial plan 2026-07-15 15:14:53 +00:00
Sovran SystemsandGitHub 53f59aa388 Merge pull request #324 from naturallaw777/copilot/fix-rdp-boot-time-setup
fix(rdp): prepend /run/wrappers/bin to PATH and add pkexec preflight check
2026-07-14 16:02:03 +00:00
copilot-swe-agent[bot]andGitHub 69e996ff98 fix(rdp): prepend /run/wrappers/bin to PATH and add pkexec preflight check
GRD 50.x invokes pkexec internally for every grdctl --system call, even
when the caller is root.  An isolated systemd script PATH does not include
/run/wrappers/bin automatically, causing exit 70 at boot.

Changes:
- Prepend /run/wrappers/bin to PATH at script start so every subsequent
  grdctl --system resolves the NixOS setuid pkexec wrapper.
- Add an explicit preflight check (test -x /run/wrappers/bin/pkexec) with
  a clear error message before the first grdctl_system call.
- pkgs.polkit remains absent from the service path.
- Update test_setup_runs_grdctl_directly_as_root to reflect that pkexec
  now appears in the preflight check (not as a direct invocation).
- Add test_run_wrappers_bin_prepended_to_path and test_pkexec_preflight_check.
2026-07-14 15:54:40 +00:00
copilot-swe-agent[bot]andGitHub 6e4d0d22a1 Initial plan 2026-07-14 15:51:34 +00:00
Sovran SystemsandGitHub 881587c42a Merge pull request #323 from naturallaw777/copilot/fix-gnome-remote-desktop-boot-setup
fix(rdp): run grdctl --system directly as root, drop runuser/polkit/util-linux
2026-07-14 15:20:22 +00:00
copilot-swe-agent[bot]andGitHub 1bb7d1c680 style: fix spelling authorisation → authorization in test comment 2026-07-14 00:29:26 +00:00
copilot-swe-agent[bot]andGitHub fd5f651f2c fix(rdp): run grdctl --system directly as root, remove runuser/polkit/util-linux
Root cause: gnome-remote-desktop-setup.service ran as root but dropped
privileges via `runuser -u gnome-remote-desktop`. Non-root grdctl --system
attempts authorisation through pkexec. The Nix-store pkexec binary is not
setuid, so every system-mode credential call silently failed while the script
still printed "configured successfully". GNOME Remote Desktop then started
without applied credentials, causing Remmina to loop at the login dialog.

Fix:
- Remove `runuser -u gnome-remote-desktop --` from grdctl_system helper;
  the root-run oneshot service can call grdctl --system directly.
- Remove pkgs.polkit and pkgs.util-linux from the setup service path as
  neither polkit nor runuser is needed any more.
- Update tests: rename test_setup_runs_grdctl_as_gnome_remote_desktop_user
  to test_setup_runs_grdctl_directly_as_root and assert that runuser,
  pkexec, and sudo are absent; add
  test_privilege_escalation_packages_absent_from_setup_path.

All 21 app/tests pass.
2026-07-14 00:28:17 +00:00
copilot-swe-agent[bot]andGitHub b4317c9589 Initial plan 2026-07-14 00:26:18 +00:00
Sovran SystemsandGitHub 07f3e4cef9 Merge pull request #322 from naturallaw777/copilot/fix-rdp-credentials-activation
Fix GNOME Remote Desktop boot-time credential activation for Hub-managed RDP
2026-07-13 23:56:48 +00:00
copilot-swe-agent[bot]andGitHub 7592bda57a fix: apply hub-managed GRD credentials at boot 2026-07-13 23:54:08 +00:00
copilot-swe-agent[bot]andGitHub 47f496efcf fix: tighten GRD boot credential setup 2026-07-13 23:48:12 +00:00
copilot-swe-agent[bot]andGitHub 4a2c3a8eb4 Initial plan 2026-07-13 23:25:36 +00:00
Sovran SystemsandGitHub e9e7451a8e Merge pull request #321 from naturallaw777/copilot/update-element-calling-nix
element-calling: universal LiveKit interface detection, full JWT routing, homeserver config
2026-07-13 18:26:16 +00:00
copilot-swe-agent[bot]andGitHub 06c0cfbb78 feat(element-calling): universal LiveKit interface detection, JWT/Caddy fixes 2026-07-13 18:24:43 +00:00
copilot-swe-agent[bot]andGitHub 37b0369361 Initial plan 2026-07-13 18:20:58 +00:00
naturallaw777 81a974d715 nixpkgs update and btc client update 2026-07-13 13:02:42 -05:00
23 changed files with 4026 additions and 412 deletions
+996 -109
View File
File diff suppressed because it is too large Load Diff
@@ -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,39 +130,37 @@ 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
# Must be a partition or disk, and be removable/hotplug
[[ "$dev_type" == "part" || "$dev_type" == "disk" ]] || continue
[[ "$hotplug" == "1" || "$removable" == "1" ]] || continue
[[ -n "$mountpoint" ]] || continue
# Filter out internal labels while IFS=$'\t' read -r dev_type hotplug removable label mountpoint; do
local skip=0 [[ "$dev_type" == "part" || "$dev_type" == "disk" ]] || continue
for lbl in "${INTERNAL_LABELS[@]}"; do [[ "$hotplug" == "1" || "$removable" == "1" ]] || continue
[[ "$label" == "$lbl" ]] && skip=1 && break [[ -n "$mountpoint" ]] || continue
done
[[ "$skip" -eq 1 ]] && continue
# Filter out internal mount points local skip=0
is_internal "$mountpoint" && continue for lbl in "${INTERNAL_LABELS[@]}"; do
[[ "$label" == "$lbl" ]] && skip=1 && break
done
[[ "$skip" -eq 1 ]] && continue
if mountpoint -q "$mountpoint" 2>/dev/null; then is_internal "$mountpoint" && continue
target="$mountpoint"
break if mountpoint -q "$mountpoint" 2>/dev/null; then
fi target="$mountpoint"
done < <(lsblk -J -o NAME,LABEL,MOUNTPOINT,HOTPLUG,RM,TYPE 2>/dev/null | \ break
python3 -c " fi
done < <(lsblk -J -o NAME,LABEL,MOUNTPOINT,HOTPLUG,RM,TYPE 2>/dev/null | \
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,14 +275,43 @@ 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)"
case "$ROLE" in case "$ROLE" in
desktop) ROLE_LABEL="Desktop Only" ;; desktop) ROLE_LABEL="Desktop Only" ;;
node) ROLE_LABEL="Node (Bitcoin-only)" ;; node) ROLE_LABEL="Node (Bitcoin-only)" ;;
server_plus_desktop) ROLE_LABEL="Server + Desktop" ;; server_plus_desktop) ROLE_LABEL="Server + Desktop" ;;
*) ROLE_LABEL="$ROLE" ;; *) ROLE_LABEL="$ROLE" ;;
esac esac
log "Detected role: $ROLE_LABEL" log "Detected role: $ROLE_LABEL"
@@ -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"
+322 -78
View File
@@ -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,19 +2914,17 @@ 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
_cached_external_ip != "unavailable" elif (
and resolved_ip != _cached_external_ip _cached_external_ip != "unavailable"
): and not any(a == _cached_external_ip for a in addrs)
dns_ok = False ):
else:
dns_ok = False
except (socket.gaierror, Exception):
dns_ok = False dns_ok = False
if not dns_ok: if not dns_ok:
@@ -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,38 +4180,35 @@ 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 {
"domain": domain, "status": "unresolvable",
"resolved_ip": None, "expected_ip": external_ip,
}
resolved_ip = results[0][4][0]
if external_ip == "unavailable":
return {
"domain": domain, "status": "error",
"resolved_ip": resolved_ip, "expected_ip": external_ip,
}
if resolved_ip == external_ip:
return {
"domain": domain, "status": "connected",
"resolved_ip": resolved_ip, "expected_ip": external_ip,
}
return {
"domain": domain, "status": "dns_mismatch",
"resolved_ip": resolved_ip, "expected_ip": external_ip,
}
except socket.gaierror:
return { return {
"domain": domain, "status": "unresolvable", "domain": domain, "status": "unresolvable",
"resolved_ip": None, "expected_ip": external_ip, "resolved_ip": None, "expected_ip": external_ip,
} }
except Exception: 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":
return { return {
"domain": domain, "status": "error", "domain": domain, "status": "error",
"resolved_ip": None, "expected_ip": external_ip, "resolved_ip": resolved_ip, "expected_ip": external_ip,
} }
if any(a == external_ip for a in addrs):
return {
"domain": domain, "status": "connected",
"resolved_ip": resolved_ip, "expected_ip": external_ip,
}
return {
"domain": domain, "status": "dns_mismatch",
"resolved_ip": resolved_ip, "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";
+8 -11
View File
@@ -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 (_) {}
} }
+35 -3
View File
@@ -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);
_serverWentDown = true; if (!_serverWentDown) {
_serverWentDown = true;
_setRebootStatus("The computer is restarting\u2026");
}
setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
}); });
} }
+46 -10
View File
@@ -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 13 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>
+193
View File
@@ -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()
+399
View File
@@ -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()
+688
View File
@@ -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()
+122
View File
@@ -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
View File
@@ -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": 1783856661,
"narHash": "sha256-5Dgj5+pIQYZKrXUGaLCk7CKfN3MmpwIhO94++WVxvng=", "narHash": "sha256-ZGP04e+Q6WyQJGA9ZvI5CL6+heGQldbAG9U1T9NGvmU=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "80d591ed473cfc46329932c2aadac9b435342c7c", "rev": "569d578509928497eddc3fdbf94a799027050be4",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -187,11 +187,11 @@
}, },
"nixpkgs_3": { "nixpkgs_3": {
"locked": { "locked": {
"lastModified": 1782959384, "lastModified": 1783776592,
"narHash": "sha256-xnJJk+ct+D2+wdRxj1wk36w5zV9RVESwRqcklPdt3fM=", "narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "65179426c83bb3f6bc14898b42ea1c6f01d374b0", "rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -203,11 +203,11 @@
}, },
"nixpkgs_4": { "nixpkgs_4": {
"locked": { "locked": {
"lastModified": 1782948114, "lastModified": 1783791668,
"narHash": "sha256-AXmz9ho4Lud5CsbrZsuSVwpQZ4o5FgZ1chxBn5cJ8+0=", "narHash": "sha256-zbcZ1dmBTPfJ7Mlqh/yLEPGpgJnwuv4Xr1xucy2WqMA=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "9e92285f211dad236540fd617d7e30e0b99bc0e1", "rev": "716c7a2664ca8325617b8a7fbb609273f2c4cae7",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -224,11 +224,11 @@
"systems": "systems_2" "systems": "systems_2"
}, },
"locked": { "locked": {
"lastModified": 1783173302, "lastModified": 1783941741,
"narHash": "sha256-nlnOw/zsD2H2NHSZ5oNWwcjuM17vipyAapfXsO78GjY=", "narHash": "sha256-F+3M1IZrJa920cx2/k2AMKqedEodxLF7COJVkLJwUBo=",
"owner": "nix-community", "owner": "nix-community",
"repo": "nixvim", "repo": "nixvim",
"rev": "a402fdf2a1ef297d8ea7c95b90d6af0dbe90ab11", "rev": "e6715f01d9f56f07a27a01386b85ae22b06f0705",
"type": "github" "type": "github"
}, },
"original": { "original": {
+1 -1
View File
@@ -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"]))
+171
View File
@@ -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" ];
};
}
+12 -1
View File
@@ -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 = {
+52 -8
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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"
''; '';