Caddy fronted the Hub at http://sovransystemsos.local, but the Hub already listens on 0.0.0.0:8937 itself, and nothing Caddy added is something it needs: - Not the name. That is avahi's: mDNS advertises a hostname, not a port, so the name resolves wherever the Hub listens. - Not TLS (the site was plain http), not authentication, not cache headers. The header block duplicated NoCacheMiddleware, and its Clear-Site-Data ("cache") overrode the app's stronger ("cache", "storage"). - Not access control, and this is the point. With ports 80/443 forwarded for public services, a Host header on those ports reached the Hub. That second door is how the reported bug happened, andc33457fguards it with an address check instead of closing it. The Hub is now served on port 8937 only, at http://sovransystemsos.local:8937, and Caddy has no site for it. The only thing Caddy answers on 80/443 is the public sites. Caddy keeps Ride The Lightning (:3051) and Mempool (:60847), because those do need it: Sovran_Bitcoin binds both to 127.0.0.1 and RTL's unit is sandboxed to loopback besides, so Caddy is how the local network reaches them. - caddy.nix: no Hub site. Caddy runs wherever RTL and Mempool do, which includes Bitcoin Node Only. There it did not run at all (enable was needsHttpsPorts || extraVhosts != ""), so :3051 and :60847 were open in the firewall with nothing listening. Ports 80/443 still follow needsHttpsPorts alone, so Node Only does not open them. The two sites are written only where their service exists; they were unconditional. - sovran-hub.nix: 8937 follows the new hub.directPort, 60847 follows Mempool. It used to be `[ 8937 60847 ]` on every role, Desktop Only included. - roles.nix: hub.directPort defaults to !roles.desktop: open on Server + Desktop and Bitcoin Node Only, closed on Desktop Only, where the Hub is reached from the machine itself through the desktop window on localhost. - The bind stays 0.0.0.0, which is IPv4 only: with that bind [::1]:8937 is refused and "localhost" falls back to 127.0.0.1. That is on purpose and is now said in the comment. An IPv6 listener would let in clients whose global addresses the Hub cannot tell from a stranger's, which is the question the previous commit declines to answer by guessing. - README, SECURITY.md and two strings in index.html give the new URL. Behaviour changes: the Hub's address gains :8937, and http://sovransystemsos.local on port 80 no longer reaches it. Bitcoin Node Only now runs Caddy. Evaluated with nix eval (nixpkgs as flake.lock pins it, Sovran_Bitcoin at the locked revision), firewall TCP ports per role:c33457fthis commit Server + Desktop 22 80 443 3051 8937 60847 22 80 443 3051 8937 Bitcoin Node Only 22 3051 8937 60847 22 3051 8937 60847 (Caddy now runs) Desktop Only 22 8937 60847 22 Port 22 is open on every role although sshd listens on loopback only; the last commit of this series deals with that. The Caddyfile the module really generates (the evaluated generator script, run, then `caddy validate` with Caddy 2.9.1): Node Only gets the two sites and nothing else; Server + Desktop with every domain configured gets the seven domain sites plus :3051 and :60847 and no mention of the Hub; with Bitcoin off there are no local-network sites; Node Only with Bitcoin off and no domains leaves Caddy off. Add tests/test_hub_direct.py and keep tests/test_caddy_lan_only.py for the two sites it still covers.
630 lines
31 KiB
Nix
630 lines
31 KiB
Nix
{ config, lib, pkgs, ... }:
|
|
|
|
let
|
|
cfg = config.sovran_systemsOS;
|
|
|
|
# Read the OS version from the repo-root VERSION file at eval time so the
|
|
# Hub always ships a real, baked-in version string instead of relying on
|
|
# a runtime file lookup (which returns "dev" when /etc/nixos/VERSION is
|
|
# missing, e.g. in a dev checkout — this is the "vdev" bug in the Hub UI).
|
|
versionFile = ../../VERSION;
|
|
sovranVersion =
|
|
if builtins.pathExists versionFile
|
|
then builtins.replaceStrings [ "v" "\n" "\r" ] [ "" "" "" ] (builtins.readFile versionFile)
|
|
else "1.0.0";
|
|
|
|
monitoredServices =
|
|
# ── Infrastructure — System Passwords (always present) ─────
|
|
[
|
|
{ name = "System Passwords"; unit = "root-password-setup.service"; type = "system"; icon = "passwords"; enabled = true; category = "infrastructure"; credentials = [
|
|
{ label = "Free Account — Username"; value = "free"; }
|
|
{ label = "Free Account / Hub Login — Password"; file = "/var/lib/secrets/free-password"; }
|
|
{ label = "Administrator (root) Password"; file = "/var/lib/secrets/root-password"; }
|
|
{ label = "SSH Passphrase — use via: ssh root@localhost"; file = "/var/lib/secrets/ssh-passphrase"; }
|
|
]; }
|
|
]
|
|
# ── Infrastructure — Caddy + Tor (NOT desktop-only) ────────
|
|
++ lib.optionals (!cfg.roles.desktop) [
|
|
{ name = "Caddy"; unit = "caddy.service"; type = "system"; icon = "caddy"; enabled = true; category = "infrastructure"; credentials = []; }
|
|
{ name = "Tor"; unit = "tor.service"; type = "system"; icon = "tor"; enabled = true; category = "infrastructure"; credentials = []; }
|
|
]
|
|
# ── Infrastructure — Remote Desktop (all roles) ─────────────
|
|
++ [
|
|
{ name = "Remote Desktop"; unit = "gnome-remote-desktop.service"; type = "system"; icon = "rdp"; enabled = cfg.features.rdp; category = "infrastructure"; credentials = [
|
|
{ label = "Username"; file = "/var/lib/gnome-remote-desktop/rdp-username"; }
|
|
{ label = "Password"; file = "/var/lib/gnome-remote-desktop/rdp-password"; }
|
|
{ label = "Address"; file = "/var/lib/secrets/internal-ip"; suffix = ":3389"; }
|
|
{ label = "How to Connect"; value = "1. Install an RDP client (e.g. Remmina, Microsoft Remote Desktop)\n2. Create a new RDP connection\n3. Enter the Address above as the host\n4. Enter the Username and Password above"; }
|
|
]; }
|
|
]
|
|
# ── Bitcoin Base ────────────────────────────────────────────
|
|
++ lib.optionals cfg.services.bitcoin [
|
|
{ name = "Bitcoin Core"; unit = "bitcoind.service"; type = "system"; icon = "bitcoin-core"; enabled = cfg.services.bitcoin; category = "bitcoin-base"; credentials = [
|
|
{ label = "Tor Bitcoin P2P Address — Reachable only through Tor"; file = "/var/lib/tor/onion/bitcoind/hostname"; suffix = ":8333"; }
|
|
]; }
|
|
]
|
|
# ── Bitcoin Apps (services on top of the node) ─────────────
|
|
++ lib.optionals cfg.services.bitcoin [
|
|
{ name = "Electrs"; unit = "electrs.service"; type = "system"; icon = "electrs"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
|
|
{ label = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/electrs/hostname"; prefix = "http://"; }
|
|
{ label = "Port"; value = "50001"; }
|
|
]; }
|
|
{ name = "LND"; unit = "lnd.service"; type = "system"; icon = "lnd"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = []; }
|
|
{ name = "Ride The Lightning"; unit = "rtl.service"; type = "system"; icon = "rtl"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
|
|
{ label = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/rtl/hostname"; prefix = "http://"; suffix = "/rtl/"; }
|
|
{ label = "Local Network — Access on your home network only"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":3051/rtl/"; }
|
|
{ label = "Password"; file = "/etc/nix-bitcoin-secrets/rtl-password"; }
|
|
{ label = "How to Access"; value = "• Tor Address: Open in Tor Browser from any device, anywhere in the world\n• Local Network: Open in any browser, but only when connected to your home network"; }
|
|
]; }
|
|
{ name = "BTCPayserver"; unit = "btcpayserver.service"; type = "system"; icon = "btcpayserver"; enabled = cfg.web.btcpayserver; category = "bitcoin-apps"; credentials = [
|
|
{ label = "URL"; file = "/var/lib/domains/btcpayserver"; prefix = "https://"; }
|
|
{ label = "Note"; value = "Create your admin account on first visit"; }
|
|
]; }
|
|
{ name = "Zeus Connect"; unit = "zeus-connect-setup.service"; type = "system"; icon = "zeus"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
|
|
{ label = "QR Code"; file = "/var/lib/secrets/zeus-connect-url"; qrcode = true; qronly = true; }
|
|
]; }
|
|
{ name = "Sparrow Auto-Link"; unit = "sparrow-autoconnect.service"; type = "system"; icon = "sparrow"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
|
|
{ label = "Server"; value = "tcp://127.0.0.1:50001 (Electrs)"; }
|
|
{ label = "Status"; value = "Auto-configured on first boot"; }
|
|
]; }
|
|
{ name = "Lightning Wallet Connections"; unit = "albyhub.service"; type = "system"; icon = "nwc"; enabled = cfg.features."nwc-wallets"; category = "bitcoin-apps"; credentials = [
|
|
{ label = "Lightning Address Domain"; file = "/var/lib/domains/lightning"; }
|
|
]; }
|
|
{ name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; enabled = cfg.features.mempool; category = "bitcoin-apps"; credentials = [
|
|
{ label = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/mempool-frontend/hostname"; prefix = "http://"; }
|
|
{ label = "Local Network — Access on your home network only"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":60847"; }
|
|
{ label = "How to Access"; value = "• Tor Address: Open in Tor Browser from any device, anywhere in the world\n• Local Network: Open in any browser, but only when connected to your home network"; }
|
|
]; }
|
|
]
|
|
# ── Communication (server+desktop only) ────────────────────
|
|
++ lib.optionals cfg.roles.server_plus_desktop [
|
|
{ name = "Matrix-Synapse"; unit = "matrix-synapse.service"; type = "system"; icon = "synapse"; enabled = cfg.services.synapse; category = "communication"; credentials = [
|
|
{ label = "Homeserver URL"; file = "/var/lib/secrets/matrix-homeserver-url"; }
|
|
{ label = "Admin Username"; file = "/var/lib/secrets/matrix-admin-username"; }
|
|
{ label = "Admin Password"; file = "/var/lib/secrets/matrix-admin-password"; }
|
|
{ label = "Test Username"; file = "/var/lib/secrets/matrix-test-username"; }
|
|
{ label = "Test Password"; file = "/var/lib/secrets/matrix-test-password"; }
|
|
]; }
|
|
{ name = "LiveKit (Element Call)"; unit = "livekit.service"; type = "system"; icon = "element-calling"; enabled = cfg.features.element-calling; category = "communication"; credentials = []; }
|
|
]
|
|
# ── Self-Hosted Apps (server+desktop only) ─────────────────
|
|
++ lib.optionals cfg.roles.server_plus_desktop [
|
|
{ name = "VaultWarden"; unit = "vaultwarden.service"; type = "system"; icon = "vaultwarden"; enabled = cfg.services.vaultwarden; category = "apps"; credentials = [
|
|
{ label = "URL"; file = "/var/lib/domains/vaultwarden"; prefix = "https://"; }
|
|
{ label = "Admin Panel"; file = "/var/lib/domains/vaultwarden"; prefix = "https://"; suffix = "/admin"; }
|
|
{ label = "Admin Token"; file = "/var/lib/secrets/vaultwarden/vaultwarden.env"; extract = "ADMIN_TOKEN"; }
|
|
]; }
|
|
{ name = "Nextcloud"; unit = "phpfpm-nextcloud.service"; type = "system"; icon = "nextcloud"; enabled = cfg.services.nextcloud; category = "apps"; credentials = [
|
|
{ label = "Credentials"; file = "/var/lib/secrets/nextcloud-admin"; multiline = true; }
|
|
]; }
|
|
{ name = "WordPress"; unit = "phpfpm-wordpress.service"; type = "system"; icon = "wordpress"; enabled = cfg.services.wordpress; category = "apps"; credentials = [
|
|
{ label = "Credentials"; file = "/var/lib/secrets/wordpress-admin"; multiline = true; }
|
|
]; }
|
|
]
|
|
# ── Nostr / Relay (server+desktop only) ────────────────────
|
|
++ lib.optionals cfg.roles.server_plus_desktop [
|
|
{ name = "Haven Relay"; unit = "haven-relay.service"; type = "system"; icon = "haven"; enabled = cfg.features.haven; category = "nostr"; credentials = []; }
|
|
]
|
|
# ── Support (always present) ────────────────────────────────
|
|
++ [
|
|
{ name = "Tech Support"; unit = "sovran-tech-support"; type = "support"; icon = "support"; enabled = true; category = "support"; credentials = []; }
|
|
];
|
|
|
|
activeRole =
|
|
if cfg.roles.desktop then "desktop"
|
|
else if cfg.roles.node then "node"
|
|
else "server_plus_desktop";
|
|
|
|
# IPv4 a.b.c.d[/0-32] or IPv6 [/0-128], and never a /0 (that is "everyone").
|
|
octet = "(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])";
|
|
lanNetworkOk = p:
|
|
(
|
|
builtins.match "${octet}(\\.${octet}){3}(/(3[0-2]|[12]?[0-9]))?" p != null
|
|
|| builtins.match "[0-9a-fA-F:]*:[0-9a-fA-F:]*(/(12[0-8]|1[01][0-9]|[1-9]?[0-9]))?" p != null
|
|
)
|
|
&& builtins.match ".*/0" p == null;
|
|
|
|
generatedConfig = pkgs.writeText "sovran-hub-config.json"
|
|
(builtins.toJSON {
|
|
refresh_interval = 5;
|
|
command_method = "systemctl";
|
|
role = activeRole;
|
|
services = monitoredServices;
|
|
feature_manager = true;
|
|
# Read by LanOnlyMiddleware in server.py.
|
|
lan_only = cfg.hub.lanOnly;
|
|
lan_extra_networks = cfg.hub.extraLanNetworks;
|
|
feature_states = {
|
|
bitcoin-tor-gossip = cfg.features.bitcoin-tor-gossip;
|
|
};
|
|
sovran_version = sovranVersion;
|
|
});
|
|
|
|
generatedVersions = pkgs.writeText "sovran-hub-versions.json" (builtins.toJSON {
|
|
"caddy.service" = if pkgs ? caddy then pkgs.caddy.version else "2.8.4";
|
|
"tor.service" = if pkgs ? tor then pkgs.tor.version else "0.4.8.12";
|
|
"gnome-remote-desktop.service" = if pkgs ? gnome-remote-desktop then pkgs.gnome-remote-desktop.version else "46.0";
|
|
"bitcoind.service" = if pkgs ? bitcoind then pkgs.bitcoind.version else "27.1.0";
|
|
"electrs.service" = if pkgs ? electrs then pkgs.electrs.version else "0.10.6";
|
|
"lnd.service" = if pkgs ? lnd then pkgs.lnd.version else "0.18.0";
|
|
# Vendored packages come from the Sovran_Bitcoin flake overlay
|
|
# (pkgs.sovran-bitcoin.*). Evaluate them directly so the Hub shows
|
|
# the exact versions this system ships.
|
|
"rtl.service" = pkgs.sovran-bitcoin.rtl.version;
|
|
"btcpayserver.service" = lib.getVersion config.services.btcpayserver.package;
|
|
"albyhub.service" = pkgs.sovran-bitcoin.albyhub.version;
|
|
"mempool.service" = pkgs.sovran-bitcoin.mempool-backend.version;
|
|
"matrix-synapse.service" = if pkgs ? matrix-synapse then pkgs.matrix-synapse.version else "1.115.0";
|
|
"livekit.service" = if pkgs ? livekit then pkgs.livekit.version else "1.5.2";
|
|
"vaultwarden.service" = if pkgs ? vaultwarden then pkgs.vaultwarden.version else "1.32.0";
|
|
# Nextcloud and WordPress are downloaded into /var/lib/www, so their
|
|
# deployed versions are read from their own PHP version files by the Hub.
|
|
"haven-relay.service" = if pkgs ? haven-relay then pkgs.haven-relay.version else (if pkgs ? haven then pkgs.haven.version else "0.1.0");
|
|
});
|
|
|
|
# Shared shell prelude used by both the update and rebuild wrapper scripts.
|
|
# A flake/package fetch that is interrupted (network blip, reboot
|
|
# mid-download, disk filled, hiccup on the remote) can leave a truncated
|
|
# tarball or partial git clone in Nix's download caches. Nix then reuses the
|
|
# corrupt archive on every retry and dies with "cannot read file from
|
|
# tarball: Truncated tar archive detected" — a failure that is NOT fixed by
|
|
# simply re-running, but IS fixed by clearing the fetch caches. run_step runs
|
|
# a command and, on the first failure that matches a download/cache
|
|
# signature, clears the caches and retries once. Real config errors never
|
|
# match, so they still fail loudly. Each sourcing script must define $LOG.
|
|
nix-self-heal-prelude = ''
|
|
transient_failure() {
|
|
grep -Eqi 'truncated tar|unexpected end of (file|archive)|unexpected eof|corrupt(ed)? (archive|nar|download|file)|could not (fetch|download)|download.*(failed|interrupted)|timed out|timeout|connection (reset|refused|timed out)|network is unreachable|temporary failure in name resolution|checksum mismatch|hash mismatch|nar hash|unable to download|store path.*is not valid|cannot read file from tarball|into the git cache' "$LOG"
|
|
}
|
|
|
|
clear_fetch_caches() {
|
|
echo "[SELF-HEAL] Clearing stale Nix download caches and verifying the Nix store…"
|
|
# Re-fetchable caches only; /nix/store generations and the running system
|
|
# are never touched here.
|
|
rm -rf /root/.cache/nix/tarballs /root/.cache/nix/vcs-cache /root/.cache/nix/git* /root/.cache/nix/flakes 2>/dev/null || true
|
|
# Fast closure-level repair only. A full --check-contents scan hashes
|
|
# every store path and can take tens of minutes on a big node; the cache
|
|
# clear above is the actual fix for truncated/corrupt downloads.
|
|
nix-store --verify --repair >/dev/null 2>&1 || true
|
|
echo "[SELF-HEAL] Caches cleared; retrying…"
|
|
echo ""
|
|
}
|
|
|
|
# run_step LABEL CMD [ARGS...] — run a build step; on a transient
|
|
# fetch/cache failure, heal once and retry. Returns the command exit code
|
|
# but leaves error messaging to the caller.
|
|
run_step() {
|
|
label="$1"; shift
|
|
rc=1
|
|
for try in 1 2; do
|
|
if [ "$try" -eq 2 ]; then
|
|
echo "── $label — retry after cache repair ──"
|
|
fi
|
|
"$@"
|
|
rc=$?
|
|
if [ "$rc" -eq 0 ]; then
|
|
return 0
|
|
fi
|
|
if [ "$try" -eq 1 ] && transient_failure; then
|
|
echo ""
|
|
echo "[SELF-HEAL] $label failed on a download/cache error (see above)."
|
|
clear_fetch_caches
|
|
continue
|
|
fi
|
|
return "$rc"
|
|
done
|
|
return "$rc"
|
|
}
|
|
'';
|
|
|
|
# ── Update wrapper script ──────────────────────────────────────
|
|
update-script = pkgs.writeShellScript "sovran-hub-update.sh" ''
|
|
set -uo pipefail
|
|
export PATH="${lib.makeBinPath [ pkgs.nix pkgs.nixos-rebuild pkgs.git pkgs.flatpak pkgs.coreutils ]}:$PATH"
|
|
|
|
LOG="/var/log/sovran-hub-update.log"
|
|
STATUS="/var/log/sovran-hub-update.status"
|
|
GENERATION="/var/log/sovran-hub-update.generation"
|
|
|
|
echo "RUNNING" > "$STATUS"
|
|
rm -f "$GENERATION"
|
|
: > "$LOG"
|
|
exec > >(tee -a "$LOG") 2>&1
|
|
|
|
echo "══════════════════════════════════════════════════"
|
|
echo " Sovran_SystemsOS Update — $(date)"
|
|
echo "══════════════════════════════════════════════════"
|
|
echo ""
|
|
|
|
RC=0
|
|
|
|
${nix-self-heal-prelude}
|
|
|
|
echo "── Step 1/3: nix flake update ────────────────────"
|
|
if ! run_step "nix flake update" nix flake update --flake /etc/nixos --print-build-logs \
|
|
--option connect-timeout 10 \
|
|
--option stalled-download-timeout 90 \
|
|
--option download-attempts 7 \
|
|
--option fallback true; then
|
|
echo "[ERROR] nix flake update failed"
|
|
RC=1
|
|
fi
|
|
echo ""
|
|
|
|
if [ "$RC" -eq 0 ]; then
|
|
echo "── Step 2/3: nixos-rebuild boot (stage next reboot) ──"
|
|
# Stream output straight into $LOG (see rebuild-script) so the Hub UI
|
|
# shows live progress instead of an empty log during long builds.
|
|
if run_step "nixos-rebuild boot" nixos-rebuild boot --flake /etc/nixos --print-build-logs \
|
|
--option connect-timeout 10 \
|
|
--option stalled-download-timeout 90 \
|
|
--option download-attempts 7 \
|
|
--option fallback true; then
|
|
if ! readlink -f /nix/var/nix/profiles/system > "$GENERATION"; then
|
|
# The marker is informational only. The Hub derives pending-reboot
|
|
# state from the NixOS system profile itself, so failing to record
|
|
# the marker must not fail an otherwise successful update.
|
|
echo "[WARNING] update succeeded but its staged generation could not be recorded"
|
|
rm -f "$GENERATION"
|
|
fi
|
|
else
|
|
echo "[ERROR] nixos-rebuild boot failed"
|
|
RC=1
|
|
fi
|
|
echo ""
|
|
fi
|
|
|
|
if [ "$RC" -eq 0 ]; then
|
|
echo "── Step 3/3: flatpak update ────────────────────────"
|
|
if ! flatpak update -y 2>&1; then
|
|
echo "[WARNING] flatpak update failed (non-fatal)"
|
|
fi
|
|
echo ""
|
|
fi
|
|
|
|
if [ "$RC" -eq 0 ]; then
|
|
echo "══════════════════════════════════════════════════"
|
|
echo " ✓ Update staged successfully"
|
|
echo " Reboot required to activate the new system"
|
|
echo "══════════════════════════════════════════════════"
|
|
echo "REBOOT_REQUIRED" > "$STATUS"
|
|
else
|
|
echo "══════════════════════════════════════════════════"
|
|
echo " ✗ Update failed — see errors above"
|
|
echo "══════════════════════════════════════════════════"
|
|
echo "FAILED" > "$STATUS"
|
|
fi
|
|
|
|
exit "$RC"
|
|
'';
|
|
|
|
# ── Rebuild wrapper script ─────────────────────────────────────
|
|
rebuild-script = pkgs.writeShellScript "sovran-hub-rebuild.sh" ''
|
|
set -uo pipefail
|
|
export PATH="${lib.makeBinPath [ pkgs.nix pkgs.nixos-rebuild pkgs.coreutils ]}:$PATH"
|
|
|
|
LOG="/var/log/sovran-hub-rebuild.log"
|
|
STATUS="/var/log/sovran-hub-rebuild.status"
|
|
|
|
echo "RUNNING" > "$STATUS"
|
|
: > "$LOG"
|
|
exec > >(tee -a "$LOG") 2>&1
|
|
|
|
echo "══════════════════════════════════════════════════"
|
|
echo " Sovran_SystemsOS Rebuild — $(date)"
|
|
echo "══════════════════════════════════════════════════"
|
|
echo ""
|
|
|
|
${nix-self-heal-prelude}
|
|
|
|
echo "── Rebuilding system configuration ──────────────"
|
|
# Stream output straight into $LOG (tee'd by the exec redirect above) so
|
|
# the Hub UI shows live progress. Capturing the output in a variable
|
|
# kept the log empty for the entire build+activation, which made long
|
|
# rebuilds can otherwise look like a hang.
|
|
run_step "nixos-rebuild switch" nixos-rebuild switch --flake /etc/nixos --print-build-logs \
|
|
--option connect-timeout 10 \
|
|
--option stalled-download-timeout 90 \
|
|
--option download-attempts 7 \
|
|
--option fallback true
|
|
SWITCH_RC=$?
|
|
if [ "$SWITCH_RC" -eq 0 ]; then
|
|
echo ""
|
|
echo "══════════════════════════════════════════════════"
|
|
echo " ✓ Rebuild completed successfully"
|
|
echo "══════════════════════════════════════════════════"
|
|
echo "SUCCESS" > "$STATUS"
|
|
elif grep -q "switchInhibitors\|Pre-switch checks failed" "$LOG"; then
|
|
echo ""
|
|
echo " ✓ Build succeeded — a reboot is required to apply this rebuild"
|
|
echo " (Critical system components changed; running nixos-rebuild boot instead)"
|
|
if run_step "nixos-rebuild boot" nixos-rebuild boot --flake /etc/nixos --print-build-logs \
|
|
--option connect-timeout 10 \
|
|
--option stalled-download-timeout 90 \
|
|
--option download-attempts 7 \
|
|
--option fallback true; then
|
|
echo "REBOOT_REQUIRED" > "$STATUS"
|
|
else
|
|
echo "[ERROR] nixos-rebuild boot also failed"
|
|
echo "FAILED" > "$STATUS"
|
|
exit 1
|
|
fi
|
|
else
|
|
echo "[ERROR] nixos-rebuild switch failed"
|
|
echo ""
|
|
echo "══════════════════════════════════════════════════"
|
|
echo " ✗ Rebuild failed — see errors above"
|
|
echo "══════════════════════════════════════════════════"
|
|
echo "FAILED" > "$STATUS"
|
|
exit 1
|
|
fi
|
|
'';
|
|
|
|
# ── Brave Origin launcher wrapper: a *persistent* per-user profile dir.
|
|
# It must NOT be wiped on exit: the Hub's logout marker cookie
|
|
# (hub_manual_logout) and the session cookie live in this profile.
|
|
# Launching from a fresh/ephemeral profile every time throws away the
|
|
# marker, so /auto-login would mint a new session and silently log the
|
|
# user straight back in after they signed out and reopened the window.
|
|
# A stable directory also keeps the Wayland app_id deterministic so
|
|
# GNOME Shell can match the window to the .desktop entry (dock icon).
|
|
hub-brave-wrapper = pkgs.writeShellScript "sovran-hub-brave.sh" ''
|
|
export PATH="${lib.makeBinPath [ pkgs.brave-origin pkgs.coreutils ]}:$PATH"
|
|
# Per-user, persistent browser state. $XDG_STATE_HOME keeps it out of the
|
|
# way of backups and survives reboots and window close/reopen.
|
|
if [ -n "$XDG_STATE_HOME" ]; then
|
|
HUB_DATA="$XDG_STATE_HOME/sovran-hub-browser"
|
|
else
|
|
HUB_DATA="$HOME/.local/state/sovran-hub-browser"
|
|
fi
|
|
mkdir -p "$HUB_DATA"
|
|
export BAMF_DESKTOP_FILE_HINT="/run/current-system/sw/share/applications/sovran-hub.desktop"
|
|
export GIO_LAUNCHED_DESKTOP_FILE="/run/current-system/sw/share/applications/sovran-hub.desktop"
|
|
# With a persistent profile Brave Origin's one-time "Proceed with Origin
|
|
# for free on Linux" onboarding dialog only appears once; keep skipping it
|
|
# anyway so it can never block auto-login (Linux-only switch).
|
|
brave-origin --app=http://localhost:8937/auto-login \
|
|
--skip-origin-startup-dialog \
|
|
--class=sovran-hub \
|
|
--user-data-dir="$HUB_DATA" \
|
|
--password-store=basic \
|
|
--disable-gpu \
|
|
--disable-features=WebRtcPipeWireCapturer \
|
|
--ozone-platform=wayland
|
|
'';
|
|
|
|
# ── Hub auto-launch wrapper script ────────────────────────────────
|
|
hub-autolaunch-script = pkgs.writeShellScript "sovran-hub-autolaunch.sh" ''
|
|
export PATH="${lib.makeBinPath [ pkgs.curl pkgs.coreutils ]}:$PATH"
|
|
|
|
DISABLE_FLAG="/var/lib/sovran/hub-autolaunch-disabled"
|
|
BOOT_FLAG="/run/sovran-hub-autolaunch-done"
|
|
|
|
# User disabled auto-launch via Hub toggle
|
|
[ -f "$DISABLE_FLAG" ] && exit 0
|
|
|
|
# Already launched this boot
|
|
[ -f "$BOOT_FLAG" ] && exit 0
|
|
|
|
touch "$BOOT_FLAG"
|
|
|
|
# Wait for Hub server to become ready (max ~15 seconds)
|
|
for i in $(seq 1 15); do
|
|
curl -s -o /dev/null http://localhost:8937 && break
|
|
sleep 1
|
|
done
|
|
|
|
${hub-brave-wrapper}
|
|
'';
|
|
|
|
sovran-hub-web = pkgs.python3Packages.buildPythonApplication {
|
|
pname = "sovran-systemsos-hub-web";
|
|
# Keep the package metadata in lockstep with the version shown in the Hub.
|
|
version = sovranVersion;
|
|
format = "other";
|
|
|
|
src = ../../app;
|
|
|
|
nativeBuildInputs = [ pkgs.librsvg ];
|
|
|
|
propagatedBuildInputs = with pkgs.python3Packages; [
|
|
fastapi
|
|
uvicorn
|
|
jinja2
|
|
python-multipart
|
|
];
|
|
|
|
dontBuild = true;
|
|
|
|
installPhase = ''
|
|
runHook preInstall
|
|
|
|
install -d $out/lib/sovran-hub-web
|
|
cp -r sovran_systemsos_web $out/lib/sovran-hub-web/
|
|
|
|
cp ${generatedConfig} $out/lib/sovran-hub-web/config.json
|
|
cp ${generatedVersions} $out/lib/sovran-hub-web/versions.json
|
|
printf '%s' "${sovranVersion}" > $out/lib/sovran-hub-web/VERSION
|
|
|
|
install -d $out/share/sovran-hub/icons
|
|
cp icons/* $out/share/sovran-hub/icons/ 2>/dev/null || true
|
|
|
|
install -d $out/share/icons/hicolor/scalable/apps
|
|
cp sovran_systemsos_web/static/sovran-hub-icon.svg $out/share/icons/hicolor/scalable/apps/sovran-hub.svg
|
|
|
|
for size in 48 128 256 512; do
|
|
install -d $out/share/icons/hicolor/''${size}x''${size}/apps
|
|
rsvg-convert -w ''${size} -h ''${size} sovran_systemsos_web/static/sovran-hub-icon.svg -o $out/share/icons/hicolor/''${size}x''${size}/apps/sovran-hub.png
|
|
done
|
|
|
|
install -d $out/share/applications
|
|
cat > $out/share/applications/sovran-hub.desktop <<DESKTOP
|
|
[Desktop Entry]
|
|
Type=Application
|
|
Name=Sovran Hub
|
|
Comment=Open Sovran_SystemsOS Hub dashboard
|
|
Exec=${hub-brave-wrapper}
|
|
Icon=sovran-hub
|
|
Terminal=false
|
|
Categories=System;
|
|
StartupNotify=true
|
|
StartupWMClass=brave-localhost__auto-login-Default
|
|
X-GNOME-SingleWindow=true
|
|
DESKTOP
|
|
|
|
install -d $out/bin
|
|
cat > $out/bin/sovran-hub-web <<LAUNCHER
|
|
#!${pkgs.python3}/bin/python3
|
|
import os, sys
|
|
base = os.path.join("$out", "lib", "sovran-hub-web")
|
|
sys.path.insert(0, base)
|
|
# Canonical NWC implementation — single source of truth from the
|
|
# Sovran_Bitcoin flake (imported directly by server.py).
|
|
sys.path.insert(0, os.path.join("${pkgs.sovran-bitcoin.nwc}", "lib", "sovran-nwc"))
|
|
os.environ["SOVRAN_HUB_CONFIG"] = os.path.join(base, "config.json")
|
|
os.environ["SOVRAN_HUB_VERSIONS"] = os.path.join(base, "versions.json")
|
|
os.environ["SOVRAN_HUB_ICONS"] = os.path.join("$out", "share", "sovran-hub", "icons")
|
|
import uvicorn
|
|
uvicorn.run(
|
|
"sovran_systemsos_web.server:app",
|
|
# IPv4 only, on purpose. The desktop launcher uses "localhost", which
|
|
# falls back to 127.0.0.1, and other devices reach the Hub over IPv4 too.
|
|
# An IPv6 listener would admit clients whose global addresses the Hub's own
|
|
# check cannot tell from a stranger's (see LanPolicy). Which devices may
|
|
# connect is up to the firewall (hub.directPort) and that check
|
|
# (hub.lanOnly), not this bind.
|
|
host="0.0.0.0",
|
|
port=8937,
|
|
log_level="info",
|
|
)
|
|
LAUNCHER
|
|
chmod +x $out/bin/sovran-hub-web
|
|
|
|
# nwc-wallet / nwc-lnurl binaries are no longer shipped here: the
|
|
# Sovran_Bitcoin flake provides them (env-wrapped nwc-wallet via
|
|
# albyhub.nix, nwc-lnurl.service via lnurl.nix) — single source of truth.
|
|
runHook postInstall
|
|
'';
|
|
|
|
meta = {
|
|
description = "Sovran_SystemsOS Hub — web-based systemd service manager";
|
|
mainProgram = "sovran-hub-web";
|
|
};
|
|
};
|
|
|
|
in
|
|
{
|
|
options.services.sovranHub.webPackage = lib.mkOption {
|
|
type = lib.types.package;
|
|
default = sovran-hub-web;
|
|
description = "The sovran-hub-web Python application package. Other modules use this to reference Hub-installed scripts without duplicating the Python path setup.";
|
|
};
|
|
|
|
config = {
|
|
# Catch a typo'd network at build time. The Hub ignores an entry it cannot
|
|
# parse (it must never widen its policy by guessing), so without this the
|
|
# only symptom would be a client that is refused for no visible reason.
|
|
assertions = [
|
|
{
|
|
assertion = builtins.all lanNetworkOk cfg.hub.extraLanNetworks;
|
|
message = ''
|
|
sovran_systemsOS.hub.extraLanNetworks must be a list of IPv4 or IPv6
|
|
networks in CIDR notation, for example [ "203.0.113.0/28" ]. A /0
|
|
prefix is not accepted; set sovran_systemsOS.hub.lanOnly = false to
|
|
let every client through. Got:
|
|
${builtins.toJSON cfg.hub.extraLanNetworks}
|
|
'';
|
|
}
|
|
];
|
|
|
|
systemd.services.sovran-hub-web = {
|
|
description = "Sovran_SystemsOS Hub Web Interface";
|
|
wantedBy = [ "multi-user.target" ];
|
|
after = [ "network.target" ];
|
|
conflicts = [ "sovran-hub-reboot.service" ];
|
|
|
|
serviceConfig = {
|
|
ExecStart = "${sovran-hub-web}/bin/sovran-hub-web";
|
|
Restart = "on-failure";
|
|
RestartPreventExitStatus = "SIGTERM";
|
|
RestartSec = "5s";
|
|
User = "root";
|
|
StandardOutput = "journal";
|
|
StandardError = "journal";
|
|
};
|
|
|
|
path = [
|
|
pkgs.bash
|
|
pkgs.gawk
|
|
pkgs.qrencode
|
|
pkgs.curl
|
|
pkgs.iproute2
|
|
pkgs.nftables
|
|
pkgs.iptables
|
|
pkgs.hostname
|
|
pkgs.coreutils
|
|
pkgs.findutils
|
|
pkgs.gnugrep
|
|
pkgs.rsync
|
|
pkgs.acl
|
|
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 = {
|
|
description = "Sovran_SystemsOS System Update";
|
|
restartIfChanged = false; # Don't let nixos-rebuild kill an in-flight update
|
|
stopIfChanged = false; # Don't stop it during activation either
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
ExecStart = "${update-script}";
|
|
};
|
|
};
|
|
|
|
systemd.services.sovran-hub-rebuild = {
|
|
description = "Sovran_SystemsOS System Rebuild";
|
|
restartIfChanged = false; # Don't let nixos-rebuild kill an in-flight rebuild
|
|
stopIfChanged = false; # Don't stop it during activation either
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
ExecStart = "${rebuild-script}";
|
|
};
|
|
};
|
|
|
|
systemd.services.sovran-hub-reboot = {
|
|
description = "Sovran_SystemsOS System Reboot";
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
ExecStart = "/run/current-system/sw/bin/systemctl --force reboot";
|
|
};
|
|
};
|
|
|
|
environment.systemPackages = [ sovran-hub-web ];
|
|
|
|
# The Hub is served on its own port, not through Caddy (see caddy.nix).
|
|
# Nothing here filters by client address: that is the Hub's own check
|
|
# (sovran_systemsOS.hub.lanOnly), and which networks can route to this
|
|
# computer at all is the router's call.
|
|
# 60847 is where Caddy serves Mempool, so it is open only when Mempool is.
|
|
networking.firewall.allowedTCPPorts =
|
|
lib.optionals cfg.hub.directPort [ 8937 ]
|
|
++ lib.optionals (cfg.services.bitcoin && cfg.features.mempool) [ 60847 ];
|
|
|
|
# ── Auto-launch Hub in browser on login ───────────────────────
|
|
environment.etc."xdg/autostart/sovran-hub-autolaunch.desktop".text = ''
|
|
[Desktop Entry]
|
|
Type=Application
|
|
Name=Sovran Hub Auto-Launch
|
|
Exec=${hub-autolaunch-script}
|
|
Terminal=false
|
|
X-GNOME-Autostart-enabled=true
|
|
NoDisplay=true
|
|
'';
|
|
|
|
};
|
|
}
|