85 Commits

Author SHA1 Message Date
Sovran_Systems
8413093d43 Merge pull request #139 from naturallaw777/copilot/fix-flake-lock-issue
installer: pre-resolve flake lock to staging-dev instead of deleting it
2026-04-07 21:49:58 -05:00
copilot-swe-agent[bot]
1a8a1736bf fix: pre-resolve flake lock to staging-dev during installation
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/14550e27-a253-453b-b454-097575e924fa

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-08 02:48:59 +00:00
copilot-swe-agent[bot]
51c7d172b3 Initial plan 2026-04-08 02:46:49 +00:00
Sovran_Systems
6999ae5680 Merge pull request #138 from naturallaw777/copilot/fix-onboarding-wizard-issues
onboarding: remove scroll boxes, fix footer spacing, add per-field domain saves
2026-04-07 19:55:56 -05:00
copilot-swe-agent[bot]
0c3f74e7de Fix onboarding wizard: remove scroll boxes, fix footer spacing, add per-field save buttons
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/0b500e06-d8c5-4745-9768-29523ffc99c6

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-08 00:55:08 +00:00
copilot-swe-agent[bot]
d2703ff84b Initial plan 2026-04-08 00:51:40 +00:00
Sovran_Systems
1a9e0825fc Merge pull request #137 from naturallaw777/copilot/fix-onboarding-visual-consistency
Fix onboarding wizard: consistent card styling, footer spacing, and password description
2026-04-07 19:43:02 -05:00
copilot-swe-agent[bot]
284a861927 Fix onboarding wizard: consistent card styling, footer spacing, and password description
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/ce004fc7-c96f-4765-bc21-87ce579352d0

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-08 00:39:45 +00:00
Sovran_Systems
02b4e6b5b4 Merge pull request #136 from naturallaw777/copilot/fix-domain-configuration-in-modal
Fix: Replace dead "Feature Manager" sidebar references with inline Configure Domain button
2026-04-07 19:39:43 -05:00
copilot-swe-agent[bot]
60084c292e Initial plan 2026-04-08 00:38:42 +00:00
copilot-swe-agent[bot]
fa22a080b9 fix: replace broken Feature Manager references with Configure Domain button
- server.py: add domain_name to /api/service-detail response
- service-detail.js: replace both Feature Manager references with Configure Domain / Reconfigure Domain buttons with click handlers
- tiles.css: add .svc-detail-domain-btn class for button spacing

Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/ae38c98e-28bb-4d1e-8dae-78ebde64ad44

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-08 00:37:40 +00:00
copilot-swe-agent[bot]
70f0af98f6 Initial plan 2026-04-08 00:33:37 +00:00
Sovran_Systems
cd4df316ae Merge pull request #135 from naturallaw777/copilot/fix-bitcoind-i-o-error
Fix bitcoind/electrs I/O crash when second drive mounts after service start
2026-04-07 19:24:22 -05:00
copilot-swe-agent[bot]
ff55dce746 Add mount dependency for bitcoind and electrs systemd services
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/1def4c7b-d90d-4b0c-87a7-87dc729661b1

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-08 00:23:35 +00:00
copilot-swe-agent[bot]
5a86c03f74 Initial plan 2026-04-08 00:22:26 +00:00
1c2df46ac4 updated installer.py 2026-04-07 17:59:53 -05:00
8839620e63 updated caddy.nix 2026-04-07 17:36:26 -05:00
c03126e8f8 .iso update 2026-04-07 17:02:43 -05:00
Sovran_Systems
10ef36859d Merge pull request #132 from naturallaw777/copilot/fix-ownership-permissions
Replace tmpfiles rules with systemd oneshot service for recursive ownership fix on second drive
2026-04-07 16:41:54 -05:00
Sovran_Systems
4acb75f2bd Merge pull request #133 from naturallaw777/copilot/update-deployed-flake-url
Point installer DEPLOYED_FLAKE at staging-dev branch
2026-04-07 16:41:20 -05:00
copilot-swe-agent[bot]
77e2fb2537 Fix installer DEPLOYED_FLAKE to point to staging-dev branch
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/43e96fac-1140-42e5-9981-00069570967c

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 21:40:26 +00:00
copilot-swe-agent[bot]
c7bbb97a68 Initial plan 2026-04-07 21:39:42 +00:00
copilot-swe-agent[bot]
6d1c360c02 Replace tmpfiles rules with systemd oneshot service for recursive chown on second drive
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/96b8f8fe-5a1d-42e5-8b2d-5dd5aee96044

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 21:29:33 +00:00
copilot-swe-agent[bot]
3b73eb3bd1 Initial plan 2026-04-07 21:28:36 +00:00
Sovran_Systems
6ffcc056ad Merge pull request #131 from naturallaw777/copilot/fix-sovran-legacy-security-check
Replace Python `crypt` module with `openssl passwd` (Python 3.13 compatibility)
2026-04-07 16:17:02 -05:00
copilot-swe-agent[bot]
742f680d0d fix: replace Python crypt module with openssl passwd for Python 3.13 compatibility
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/9544e3d5-f7f8-4299-9198-3b5f1f835d14

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 21:11:13 +00:00
copilot-swe-agent[bot]
c872f1c6b0 Initial plan 2026-04-07 21:04:58 +00:00
Sovran_Systems
bc5a40f143 Merge pull request #130 from naturallaw777/copilot/add-sovran-auto-seal-service
Add sovran-auto-seal: automatic first-boot seal with live-system safety guards
2026-04-07 15:48:25 -05:00
copilot-swe-agent[bot]
c2bd3f6273 Add sovran-auto-seal systemd service to factory-seal.nix
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/869df8d4-3811-4a1a-b026-e978d3a81589

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 20:43:15 +00:00
copilot-swe-agent[bot]
343dee3576 Initial plan 2026-04-07 20:40:53 +00:00
Sovran_Systems
ebcafd3c6d Merge pull request #129 from naturallaw777/copilot/add-tmpfiles-rules-for-bitcoin-electrs
[WIP] Add tmpfiles rules for Bitcoin and Electrs data directories
2026-04-07 15:21:26 -05:00
copilot-swe-agent[bot]
5231b5ca4b Add systemd.tmpfiles.rules for Bitcoin/Electrs directory permissions
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/ea46340b-7cf5-404b-9cef-b5ed1fcb2ecb

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 20:21:07 +00:00
Sovran_Systems
1195456bee Merge pull request #128 from naturallaw777/copilot/fix-flake-nix-references
[WIP] Fix flake.nix references after nixos-install cleanup
2026-04-07 15:21:02 -05:00
copilot-swe-agent[bot]
48de6b9821 fix(installer): improve error handling for deployed flake.nix write
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/b7dfaecc-2b2e-4f5f-bb9a-f97ced90e76e

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 20:20:36 +00:00
copilot-swe-agent[bot]
cd4a17fe31 Initial plan 2026-04-07 20:20:01 +00:00
copilot-swe-agent[bot]
d3a5b3e6ef fix(installer): write deployed flake.nix and remove flake.lock after install cleanup
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/b7dfaecc-2b2e-4f5f-bb9a-f97ced90e76e

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 20:18:36 +00:00
copilot-swe-agent[bot]
3c4c6c7389 Initial plan 2026-04-07 20:16:57 +00:00
Sovran_Systems
876f728aa2 Merge pull request #127 from naturallaw777/copilot/update-api-password-check
Use /etc/shadow as authoritative source for factory default password detection
2026-04-07 13:55:53 -05:00
copilot-swe-agent[bot]
950a6dabd8 Use /etc/shadow as source of truth for factory default password detection
- server.py: add _is_free_password_default() helper that reads /etc/shadow
  and hashes known defaults ("free", "gosovransystems") via crypt module;
  update api_password_is_default to use it instead of reading the secrets file
- factory-seal.nix: replace file-based free-password check with shadow-based
  cryptographic check using python3 + crypt module; add pkgs.python3 to path;
  pass values via env vars to avoid shell expansion of hash $ characters

Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/31e6fc93-8b4b-47af-9c47-568da0905301

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 18:50:16 +00:00
copilot-swe-agent[bot]
1d9589a186 Initial plan 2026-04-07 18:46:24 +00:00
Sovran_Systems
b13fa7dc05 Merge pull request #126 from naturallaw777/copilot/fix-security-warning-reappearance
Fix legacy security warning reappearing on every reboot after password change
2026-04-07 13:29:32 -05:00
copilot-swe-agent[bot]
069f6c3ec7 Avoid storing password in variable to prevent process listing exposure
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/c18311e4-609d-4edf-a2a1-a018baede373

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 18:27:32 +00:00
copilot-swe-agent[bot]
5a27b79b51 Fix security warning reappearing after every reboot
Add two early-exit checks in sovran-legacy-security-check before the
legacy fallthrough block:
1. Exit if /var/lib/sovran/onboarding-complete exists (Hub onboarding done)
2. Exit if /var/lib/secrets/free-password exists and is not "free" (password changed)

This prevents the boot-time service from overwriting the security-status
file that /api/change-password clears after a successful password change.

Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/c18311e4-609d-4edf-a2a1-a018baede373

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 18:26:54 +00:00
copilot-swe-agent[bot]
72453c80bf Initial plan 2026-04-07 18:25:47 +00:00
14800ffb1e update flake 2026-04-07 13:14:21 -05:00
e2f36d01bc update flake 2026-04-07 13:13:06 -05:00
55b231b456 update flake and installer 2026-04-07 13:11:39 -05:00
Sovran_Systems
b4b2607df1 Merge pull request #125 from naturallaw777/copilot/update-security-check-for-unsealed-state
[WIP] Update sovran-legacy-security-check to warn on unsealed state
2026-04-07 12:50:45 -05:00
copilot-swe-agent[bot]
ac9ba4776c Detect and warn when machine was set up without factory seal
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/169de2bb-0655-4504-a270-8c0341c0d3dd

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 17:48:38 +00:00
copilot-swe-agent[bot]
85aca0d022 Initial plan 2026-04-07 17:45:41 +00:00
Sovran_Systems
80c74b2d1a Merge pull request #124 from naturallaw777/copilot/add-password-creation-step-onboarding
Add password creation step to first-boot onboarding wizard
2026-04-07 12:45:34 -05:00
copilot-swe-agent[bot]
d28f224ad5 feat: add password creation step to onboarding wizard (#2)
- Add GET /api/security/password-is-default endpoint in server.py
- Add Step 2 (Create Your Password) to onboarding wizard HTML
- Renumber old steps: Domains→3, Ports→4, Complete→5
- Add 5th step dot indicator
- Update onboarding.js: TOTAL_STEPS=5, ROLE_SKIP_STEPS=[3,4] for desktop/node
- Add loadStep2/saveStep2 for password step with smart default detection
- Rename old step functions to loadStep3/saveStep3/loadStep4
- Add password form CSS styles in onboarding.css

Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/74a30916-fb2d-4f1d-9763-e380b1aa5540

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 17:36:59 +00:00
copilot-swe-agent[bot]
f2a808ed13 Initial plan 2026-04-07 17:29:46 +00:00
Sovran_Systems
4ef420651d Merge pull request #122 from naturallaw777/copilot/fix-installer-create-password-step
Fix installer password step: replace chroot+sh with direct chpasswd --root
2026-04-07 12:17:24 -05:00
copilot-swe-agent[bot]
65ce66a541 Fix chpasswd: run directly from host with --root /mnt, no chroot needed
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/3ff98bf4-8f62-4c81-90fd-36854e88266f

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 17:14:32 +00:00
copilot-swe-agent[bot]
deae53b721 Initial plan 2026-04-07 17:13:16 +00:00
Sovran_Systems
f459e83861 Merge pull request #121 from naturallaw777/copilot/fix-change-password-form-issues
Fix System Passwords change-password form: chpasswd path on NixOS, show/hide toggle, UX clarity
2026-04-07 12:03:23 -05:00
copilot-swe-agent[bot]
badab99242 Fix chpasswd path on NixOS, add password toggle/hints/validation in change-password form
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/de03873d-5cdb-4929-bd4a-4d306916b525

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 17:01:54 +00:00
copilot-swe-agent[bot]
84124ba1b1 Initial plan 2026-04-07 16:57:23 +00:00
Sovran_Systems
2ad0d2072d Merge pull request #119 from naturallaw777/copilot/fix-change-passwords-button
[WIP] Fix non-functional change passwords button in Hub
2026-04-07 11:45:15 -05:00
copilot-swe-agent[bot]
ff1632dcda Fix Change Passwords button: add API endpoint, system password modal, fix security banner link
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/bf43bea9-9f93-4f7b-b6fd-c76714e7f25b

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 16:44:57 +00:00
Sovran_Systems
531b8c1d09 Merge pull request #120 from naturallaw777/copilot/fix-installer-password-step
[WIP] Fix installer failure at 'Create Password' step
2026-04-07 11:44:49 -05:00
copilot-swe-agent[bot]
a8128cef8d Fix chpasswd: find binary in Nix store and pipe password inline
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/630a25f6-417a-47de-b163-b519252b403c

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 16:43:50 +00:00
copilot-swe-agent[bot]
3baffb2a69 Initial plan 2026-04-07 16:42:51 +00:00
copilot-swe-agent[bot]
06bdf999a6 Initial plan 2026-04-07 16:41:02 +00:00
Sovran_Systems
76ff1f4d4f Merge pull request #118 from naturallaw777/copilot/fix-update-status-handling
[WIP] Fix update status handling for interrupted builds
2026-04-07 11:29:49 -05:00
copilot-swe-agent[bot]
2360b4147c fix: recover stale RUNNING status files on Hub server startup
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/22f9df39-fb39-4ffb-8c6b-c7323a894bee

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 16:29:08 +00:00
copilot-swe-agent[bot]
37874ff58e Initial plan 2026-04-07 16:26:26 +00:00
Sovran_Systems
aef13155fc Merge pull request #117 from naturallaw777/copilot/remove-security-warning-modal
[WIP] Remove legacy password warning modal and add inline message
2026-04-07 11:17:56 -05:00
copilot-swe-agent[bot]
1d4f104524 Replace security warning modal with inline banner in Preferences section
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/e7946288-08c7-4081-85dd-6780f1eba17a

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 16:17:23 +00:00
copilot-swe-agent[bot]
11ec4b4816 Initial plan 2026-04-07 16:14:42 +00:00
Sovran_Systems
2bd899848d Merge pull request #115 from naturallaw777/copilot/add-password-warning-screen
[WIP] Add old password warning screen for legacy machines
2026-04-07 10:50:11 -05:00
Sovran_Systems
18a6e8d24c Merge pull request #116 from naturallaw777/copilot/fix-installer-password-error
Fix installer password step: replace bare chroot with nixos-enter
2026-04-07 10:49:31 -05:00
copilot-swe-agent[bot]
13c686a8a1 feat: add legacy security warning API and UI modal for pre-factory-seal machines
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/f7c8f11b-873b-403f-ac55-8b5b7cd9f1fb

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 15:49:25 +00:00
copilot-swe-agent[bot]
7a172c0306 Fix chpasswd not found by using nixos-enter instead of bare chroot
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/1bb103de-c4a5-4701-b1b8-6aad670b97c3

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 15:45:30 +00:00
copilot-swe-agent[bot]
7fc04fcf20 Initial plan 2026-04-07 15:44:31 +00:00
copilot-swe-agent[bot]
a40ea61415 Initial plan 2026-04-07 15:43:22 +00:00
eba517d34d update flake 2026-04-07 10:13:24 -05:00
Sovran_Systems
38257492bd Merge pull request #113 from naturallaw777/copilot/remove-pdf-references
Rename credentials-pdf.nix → credentials.nix and remove all pdf references
2026-04-07 10:06:28 -05:00
93592c984d removed erroniousfile 2026-04-07 10:05:37 -05:00
copilot-swe-agent[bot]
7a08bc0b2b Remove all PDF references: rename credentials-pdf.nix and update references
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/150954c9-65a0-4d5b-b8e2-08f301f07511

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 15:04:33 +00:00
copilot-swe-agent[bot]
25e8cac613 Initial plan 2026-04-07 15:02:58 +00:00
Sovran_Systems
02eaea85d8 Merge pull request #112 from naturallaw777/copilot/add-zeus-connect-setup-service
[WIP] Add zeus-connect-setup service to wallet autoconnect module
2026-04-07 09:54:16 -05:00
copilot-swe-agent[bot]
6c433d642d Add zeus-connect-setup service and timer to wallet-autoconnect.nix
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/6b3d9c59-40e1-45c1-93f9-a5ba6547567b

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 14:52:40 +00:00
copilot-swe-agent[bot]
7aed3e09e8 Initial plan 2026-04-07 14:51:00 +00:00
23 changed files with 1264 additions and 347 deletions

View File

@@ -9,6 +9,7 @@ import json
import os
import pwd
import re
import shutil
import socket
import subprocess
import time
@@ -59,6 +60,11 @@ REBOOT_COMMAND = ["reboot"]
ONBOARDING_FLAG = "/var/lib/sovran/onboarding-complete"
AUTOLAUNCH_DISABLE_FLAG = "/var/lib/sovran/hub-autolaunch-disabled"
# ── Legacy security check constants ──────────────────────────────
SECURITY_STATUS_FILE = "/var/lib/sovran/security-status"
SECURITY_WARNING_FILE = "/var/lib/sovran/security-warning"
# ── Tech Support constants ────────────────────────────────────────
SUPPORT_KEY_FILE = "/root/.ssh/sovran_support_authorized"
@@ -699,7 +705,7 @@ def _check_port_status(
def _generate_qr_base64(data: str) -> str | None:
"""Generate a QR code PNG and return it as a base64-encoded data URI.
Uses qrencode CLI (available on the system via credentials-pdf.nix)."""
Uses qrencode CLI (available on the system via credentials.nix)."""
try:
result = subprocess.run(
["qrencode", "-o", "-", "-t", "PNG", "-s", "6", "-m", "2", "-l", "H", data],
@@ -2156,6 +2162,7 @@ async def api_service_detail(unit: str, icon: str | None = None):
"credentials": resolved_creds,
"needs_domain": needs_domain,
"domain": domain,
"domain_name": domain_key,
"domain_status": domain_status,
"port_requirements": port_requirements,
"port_statuses": port_statuses,
@@ -2916,6 +2923,196 @@ async def api_domains_check(req: DomainCheckRequest):
return {"domains": list(check_results)}
# ── Legacy security check ─────────────────────────────────────────
@app.get("/api/security/status")
async def api_security_status():
"""Return the legacy security status and warning message, if present.
Reads /var/lib/sovran/security-status and /var/lib/sovran/security-warning.
Returns {"status": "legacy", "warning": "<message>"} for legacy machines,
or {"status": "ok", "warning": ""} when the files are absent.
"""
try:
with open(SECURITY_STATUS_FILE, "r") as f:
status = f.read().strip()
except FileNotFoundError:
status = "ok"
warning = ""
if status == "legacy":
try:
with open(SECURITY_WARNING_FILE, "r") as f:
warning = f.read().strip()
except FileNotFoundError:
warning = (
"This machine was manufactured before the factory-seal process. "
"The default system password may be known to the factory. "
"Please change your system and application passwords immediately."
)
elif status == "unsealed":
try:
with open(SECURITY_WARNING_FILE, "r") as f:
warning = f.read().strip()
except FileNotFoundError:
warning = (
"This machine was set up without the factory seal process. "
"Factory test data — including SSH keys, database contents, and wallet information — "
"may still be present on this system."
)
return {"status": status, "warning": warning}
def _is_free_password_default() -> bool:
"""Check /etc/shadow directly to see if 'free' still has a factory default password.
Hashes each known factory default against the current shadow hash so that
password changes made via GNOME, passwd, or any method other than the Hub
are detected correctly.
"""
import subprocess
import re as _re
FACTORY_DEFAULTS = ["free", "gosovransystems"]
# Map shadow algorithm IDs to openssl passwd flags (SHA-512 and SHA-256 only,
# matching the shell-script counterpart in factory-seal.nix)
ALGO_FLAGS = {"6": "-6", "5": "-5"}
try:
with open("/etc/shadow", "r") as f:
for line in f:
parts = line.strip().split(":")
if parts[0] == "free" and len(parts) > 1:
current_hash = parts[1]
if not current_hash or current_hash in ("!", "*", "!!"):
return True # locked/no password — treat as default
# Parse hash: $id$[rounds=N$]salt$hash
hash_fields = current_hash.split("$")
# hash_fields: ["", id, salt_or_rounds, ...]
if len(hash_fields) < 4:
return True # unrecognized format — assume default for safety
algo_id = hash_fields[1]
salt_field = hash_fields[2]
if algo_id not in ALGO_FLAGS:
return True # unrecognized algorithm — assume default for safety
if salt_field.startswith("rounds="):
return True # can't extract real salt simply — assume default for safety
# Validate salt contains only safe characters (alphanumeric, '.', '/', '-', '_')
# to guard against unexpected shadow file content before passing to subprocess
if not _re.fullmatch(r"[A-Za-z0-9./\-_]+", salt_field):
return True # unexpected salt format — assume default for safety
openssl_flag = ALGO_FLAGS[algo_id]
for default_pw in FACTORY_DEFAULTS:
try:
result = subprocess.run(
["openssl", "passwd", openssl_flag, "-salt", salt_field, default_pw],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0 and result.stdout.strip() == current_hash:
return True
except Exception:
return True # if openssl fails, assume default for safety
return False
except (FileNotFoundError, PermissionError):
pass
return True # if /etc/shadow is unreadable, assume default for safety
@app.get("/api/security/password-is-default")
async def api_password_is_default():
"""Check if the free account password is still the factory default.
Uses /etc/shadow as the authoritative source so that password changes made
via GNOME Settings, the passwd command, or any other method are detected
correctly — not just changes made through the Hub or change-free-password.
"""
return {"is_default": _is_free_password_default()}
# ── System password change ────────────────────────────────────────
FREE_PASSWORD_FILE = "/var/lib/secrets/free-password"
class ChangePasswordRequest(BaseModel):
new_password: str
confirm_password: str
@app.post("/api/change-password")
async def api_change_password(req: ChangePasswordRequest):
"""Change the system 'free' user password.
Updates /etc/shadow via chpasswd and writes the new password to
/var/lib/secrets/free-password so the Hub credentials view stays in sync.
Also clears the legacy security-status and security-warning files so the
security banner disappears after a successful change.
"""
if not req.new_password:
raise HTTPException(status_code=400, detail="New password must not be empty.")
if req.new_password != req.confirm_password:
raise HTTPException(status_code=400, detail="Passwords do not match.")
if len(req.new_password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters long.")
# Locate chpasswd binary (NixOS puts it in the Nix store, not /usr/bin)
chpasswd_bin = (
shutil.which("chpasswd")
or ("/run/current-system/sw/bin/chpasswd"
if os.path.isfile("/run/current-system/sw/bin/chpasswd") else None)
)
if chpasswd_bin is None:
raise HTTPException(
status_code=500,
detail="chpasswd binary not found. Cannot update system password.",
)
# Update /etc/shadow via chpasswd
try:
result = subprocess.run(
[chpasswd_bin],
input=f"free:{req.new_password}",
capture_output=True,
text=True,
)
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip() or "chpasswd failed."
raise HTTPException(status_code=500, detail=detail)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to update system password: {exc}")
# Write new password to secrets file so Hub credentials stay in sync
try:
os.makedirs(os.path.dirname(FREE_PASSWORD_FILE), exist_ok=True)
with open(FREE_PASSWORD_FILE, "w") as f:
f.write(req.new_password)
os.chmod(FREE_PASSWORD_FILE, 0o600)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to write secrets file: {exc}")
# Clear legacy security status so the warning banner is removed — but only
# for "legacy" machines (pre-seal era). For "unsealed" machines, changing
# passwords is not enough; the factory residue (SSH keys, wallet data,
# databases) remains until a proper re-seal or re-install is performed.
try:
with open(SECURITY_STATUS_FILE, "r") as f:
current_status = f.read().strip()
if current_status == "legacy":
os.remove(SECURITY_STATUS_FILE)
try:
os.remove(SECURITY_WARNING_FILE)
except FileNotFoundError:
pass
except (FileNotFoundError, OSError):
pass
return {"ok": True}
# ── Matrix user management ────────────────────────────────────────
MATRIX_USERS_FILE = "/var/lib/secrets/matrix-users"
@@ -3103,3 +3300,55 @@ async def _startup_save_ip():
loop = asyncio.get_event_loop()
ip = await loop.run_in_executor(None, _get_internal_ip)
_save_internal_ip(ip)
# ── Startup: recover stale RUNNING status files ──────────────────
_SAFE_UNIT_RE = re.compile(r'^[a-zA-Z0-9@._\-]+\.service$')
def _recover_stale_status(status_file: str, log_file: str, unit_name: str):
"""If status_file says RUNNING but the systemd unit is not active, reset to FAILED."""
if not _SAFE_UNIT_RE.match(unit_name):
return
try:
with open(status_file, "r") as f:
status = f.read().strip()
except FileNotFoundError:
return
if status != "RUNNING":
return
try:
result = subprocess.run(
["systemctl", "is-active", unit_name],
capture_output=True, text=True, timeout=10,
)
active = result.stdout.strip() == "active"
except Exception:
active = False
if not active:
try:
with open(status_file, "w") as f:
f.write("FAILED")
except OSError:
pass
try:
with open(log_file, "a") as f:
f.write(
"\n[Hub] Process was interrupted (stale RUNNING status detected"
" on startup). Marking as failed.\n"
)
except OSError:
pass
@app.on_event("startup")
async def _startup_recover_stale_status():
"""Reset stale RUNNING status files left by interrupted update/rebuild jobs."""
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, _recover_stale_status, UPDATE_STATUS, UPDATE_LOG, UPDATE_UNIT)
await loop.run_in_executor(None, _recover_stale_status, REBUILD_STATUS, REBUILD_LOG, REBUILD_UNIT)

View File

@@ -146,17 +146,6 @@
gap: 16px;
}
.onboarding-card--scroll {
max-height: 360px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
}
.onboarding-card--ports {
overflow: visible;
}
/* Body text */
.onboarding-body-text {
@@ -228,7 +217,9 @@
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 4px;
padding-top: 24px;
padding-bottom: 24px;
margin-top: auto;
}
.onboarding-btn-next {
@@ -575,6 +566,110 @@
color: var(--text-secondary);
}
/* ── Password step (Step 2) ─────────────────────────────────────── */
.onboarding-password-group {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 0;
}
.onboarding-password-input-wrap {
display: flex;
align-items: center;
gap: 6px;
}
.onboarding-password-input {
flex: 1;
padding: 9px 12px;
border: 1px solid var(--border-color);
border-radius: var(--radius-btn);
background-color: var(--card-color);
color: var(--text-primary);
font-size: 0.88rem;
font-family: 'Cantarell', 'Inter', 'Segoe UI', sans-serif;
transition: border-color 0.15s;
}
.onboarding-password-input:focus {
outline: none;
border-color: var(--accent-color);
}
.onboarding-password-toggle {
padding: 6px 10px;
background-color: var(--card-color);
border: 1px solid var(--border-color);
border-radius: var(--radius-btn);
color: var(--text-secondary);
cursor: pointer;
font-size: 1rem;
line-height: 1;
transition: background-color 0.15s, border-color 0.15s;
flex-shrink: 0;
}
.onboarding-password-toggle:hover {
background-color: rgba(137, 180, 250, 0.12);
border-color: var(--accent-color);
}
.onboarding-password-hint {
font-size: 0.78rem;
color: var(--text-dim);
line-height: 1.4;
}
.onboarding-password-warning {
padding: 10px 14px;
background-color: rgba(229, 165, 10, 0.1);
border: 1px solid rgba(229, 165, 10, 0.35);
border-radius: 8px;
font-size: 0.85rem;
color: var(--yellow);
line-height: 1.5;
margin-top: 6px;
}
.onboarding-password-success {
padding: 12px 16px;
background-color: rgba(166, 227, 161, 0.1);
border: 1px solid rgba(166, 227, 161, 0.35);
border-radius: 8px;
font-size: 0.92rem;
color: var(--green);
line-height: 1.5;
}
.onboarding-password-optional {
margin-top: 12px;
font-size: 0.88rem;
}
.onboarding-password-optional > summary {
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
color: var(--accent-color);
list-style: none;
user-select: none;
}
.onboarding-password-optional > summary::-webkit-details-marker {
display: none;
}
.onboarding-password-optional > summary::before {
content: '▶ ';
font-size: 0.65em;
}
.onboarding-password-optional[open] > summary::before {
content: '▼ ';
}
/* ── Reboot overlay ─────────────────────────────────────────────── */
.reboot-overlay {

View File

@@ -0,0 +1,107 @@
/* ── Legacy security inline warning banner ───────────────────────── */
.security-inline-banner {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 14px;
margin-bottom: 12px;
background-color: rgba(180, 100, 0, 0.12);
border-left: 3px solid #c97a00;
border-radius: 6px;
color: var(--text-primary);
}
.security-inline-icon {
font-size: 1rem;
color: #e69000;
flex-shrink: 0;
}
.security-inline-text {
font-size: 0.82rem;
line-height: 1.5;
color: var(--text-secondary);
}
.security-inline-link {
display: inline-block;
font-size: 0.82rem;
font-weight: 600;
color: #e69000;
text-decoration: none;
border: 1px solid #c97a00;
border-radius: 4px;
padding: 4px 10px;
align-self: flex-start;
transition: background-color 0.15s;
}
.security-inline-link:hover {
background-color: rgba(180, 100, 0, 0.22);
}
/* ── System change-password form extras ──────────────────────────── */
.sys-chpw-header {
margin-bottom: 14px;
}
.sys-chpw-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
}
.sys-chpw-desc {
font-size: 0.82rem;
color: var(--text-secondary);
line-height: 1.5;
}
.pw-input-wrap {
position: relative;
display: flex;
align-items: center;
}
.pw-input-wrap .matrix-form-input {
padding-right: 2.4rem;
width: 100%;
}
.pw-toggle-btn {
position: absolute;
right: 6px;
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
padding: 2px 4px;
line-height: 1;
color: var(--text-secondary);
opacity: 0.75;
transition: opacity 0.15s;
}
.pw-toggle-btn:hover {
opacity: 1;
}
.pw-hint {
font-size: 0.76rem;
color: var(--text-secondary);
margin-top: 4px;
}
.pw-credentials-note {
font-size: 0.78rem;
color: #c97a00;
background-color: rgba(180, 100, 0, 0.10);
border-left: 2px solid #c97a00;
border-radius: 4px;
padding: 7px 10px;
margin-bottom: 12px;
line-height: 1.5;
}

View File

@@ -310,6 +310,12 @@
text-decoration: underline;
}
/* ── Service detail: Domain configure button ─────────────────────── */
.svc-detail-domain-btn {
margin-top: 12px;
}
/* ── Service detail: Addon feature toggle ────────────────────────── */
.svc-detail-addon-row {

View File

@@ -84,6 +84,9 @@ async function init() {
// If we can't reach the endpoint, continue to normal dashboard
}
// Check for legacy machine security warning
await checkLegacySecurity();
try {
var cfg = await apiFetch("/api/config");
_currentRole = cfg.role || "server_plus_desktop";

View File

@@ -599,9 +599,29 @@ function renderAutolaunchToggle(enabled) {
var section = document.createElement("div");
section.className = "category-section autolaunch-section";
var securityBanner = "";
if (_securityIsLegacy) {
var msg = _securityWarningMessage || "Your system may have factory default passwords. Please change your passwords to secure your system.";
var linkText, linkAction;
if (_securityStatus === "unsealed") {
linkText = "Contact Support";
linkAction = "openSupportModal(); return false;";
} else {
linkText = "Change Passwords";
linkAction = "openServiceDetailModal('root-password-setup.service', 'System Passwords', 'passwords'); return false;";
}
securityBanner =
'<div class="security-inline-banner">' +
'<span class="security-inline-icon">⚠</span>' +
'<span class="security-inline-text">' + msg + '</span>' +
'<a class="security-inline-link" href="#" onclick="' + linkAction + '">' + linkText + '</a>' +
'</div>';
}
section.innerHTML =
'<div class="section-header">Preferences</div>' +
'<hr class="section-divider" />' +
securityBanner +
'<div class="feature-card">' +
'<div class="feature-card-top">' +
'<div class="feature-card-info">' +

View File

@@ -0,0 +1,16 @@
"use strict";
// ── Legacy security warning ───────────────────────────────────────
async function checkLegacySecurity() {
try {
var data = await apiFetch("/api/security/status");
if (data && (data.status === "legacy" || data.status === "unsealed")) {
_securityIsLegacy = true;
_securityStatus = data.status;
_securityWarningMessage = data.warning || "This machine may have a security issue. Please review your system security.";
}
} catch (_) {
// Non-fatal — silently ignore if the endpoint is unreachable
}
}

View File

@@ -244,8 +244,8 @@ async function openServiceDetailModal(unit, name, icon) {
'<li>Find the domain you purchased for this service</li>' +
'<li>Create a Dynamic DNS record pointing to your external IP: <code>' + escHtml(ds.expected_ip || "—") + '</code></li>' +
'<li>Copy the DDNS curl command from Njal.la\'s dashboard</li>' +
'<li>You can re-enter it in the Feature Manager to update your configuration</li>' +
'</ol>' +
'<button class="btn btn-primary svc-detail-domain-btn" id="svc-detail-reconfig-domain-btn">🔄 Reconfigure Domain</button>' +
'</div>';
} else {
domainBadge = '<span class="svc-detail-domain-value">' + escHtml(data.domain) + '</span>';
@@ -257,9 +257,9 @@ async function openServiceDetailModal(unit, name, icon) {
'<p style="margin-top:8px">To get this service working:</p>' +
'<ol>' +
'<li>Purchase a subdomain at <a href="https://njal.la" target="_blank">njal.la</a> (if you haven\'t already)</li>' +
'<li>Go to the <strong>Feature Manager</strong> in the sidebar</li>' +
'<li>Find this service and configure your domain through the setup wizard</li>' +
'<li>Use the button below to configure your domain through the setup wizard</li>' +
'</ol>' +
'<button class="btn btn-primary svc-detail-domain-btn" id="svc-detail-config-domain-btn">🌐 Configure Domain</button>' +
'</div>';
}
@@ -280,6 +280,10 @@ async function openServiceDetailModal(unit, name, icon) {
'<button class="matrix-action-btn" id="matrix-add-user-btn"> Add New User</button>' +
'<button class="matrix-action-btn" id="matrix-change-pw-btn">🔑 Change Password</button>' +
'</div>' : "") +
(unit === "root-password-setup.service" ?
'<hr class="matrix-actions-divider"><div class="matrix-actions-row">' +
'<button class="matrix-action-btn" id="sys-change-pw-btn">🔑 Change Password</button>' +
'</div>' : "") +
'</div>';
} else if (!data.enabled && !data.feature) {
html += '<div class="svc-detail-section">' +
@@ -366,6 +370,11 @@ async function openServiceDetailModal(unit, name, icon) {
if (changePwBtn) changePwBtn.addEventListener("click", function() { openMatrixChangePasswordModal(unit, name, icon); });
}
if (unit === "root-password-setup.service") {
var sysPwBtn = document.getElementById("sys-change-pw-btn");
if (sysPwBtn) sysPwBtn.addEventListener("click", function() { openSystemChangePasswordModal(unit, name, icon); });
}
if (data.feature) {
var addonBtn = document.getElementById("svc-detail-addon-btn");
if (addonBtn) {
@@ -376,6 +385,26 @@ async function openServiceDetailModal(unit, name, icon) {
});
}
}
// Configure Domain button (for non-feature services that need a domain)
var configDomainBtn = document.getElementById("svc-detail-config-domain-btn");
var reconfigDomainBtn = document.getElementById("svc-detail-reconfig-domain-btn");
var domainBtn = configDomainBtn || reconfigDomainBtn;
if (domainBtn && data.needs_domain && data.domain_name) {
var pseudoFeat = {
id: data.domain_name,
name: name,
domain_name: data.domain_name,
needs_ddns: true,
extra_fields: []
};
domainBtn.addEventListener("click", function() {
closeCredsModal();
openDomainSetupModal(pseudoFeat, function() {
openServiceDetailModal(unit, name, icon);
});
});
}
} catch (err) {
if ($credsBody) $credsBody.innerHTML = '<p class="creds-empty">Could not load service details.</p>';
}
@@ -535,4 +564,104 @@ function openMatrixChangePasswordModal(unit, name, icon) {
});
}
function openSystemChangePasswordModal(unit, name, icon) {
if (!$credsBody) return;
$credsBody.innerHTML =
'<div class="sys-chpw-header">' +
'<div class="sys-chpw-title">🔑 Change \'free\' Account Password</div>' +
'<div class="sys-chpw-desc">This updates the system login password for the <strong>free</strong> user account on this device.</div>' +
'</div>' +
'<div class="matrix-form-group"><label class="matrix-form-label" for="sys-chpw-new">New Password</label>' +
'<div class="pw-input-wrap">' +
'<input class="matrix-form-input" type="password" id="sys-chpw-new" placeholder="New strong password" autocomplete="new-password">' +
'<button type="button" class="pw-toggle-btn" id="sys-chpw-new-toggle" aria-label="Toggle password visibility">👁</button>' +
'</div>' +
'<div class="pw-hint">Password must be at least 8 characters.</div></div>' +
'<div class="matrix-form-group"><label class="matrix-form-label" for="sys-chpw-confirm">Confirm Password</label>' +
'<div class="pw-input-wrap">' +
'<input class="matrix-form-input" type="password" id="sys-chpw-confirm" placeholder="Confirm new password" autocomplete="new-password">' +
'<button type="button" class="pw-toggle-btn" id="sys-chpw-confirm-toggle" aria-label="Toggle password visibility">👁</button>' +
'</div></div>' +
'<div class="pw-credentials-note">⚠ After changing, your updated password will appear in the System Passwords credentials tile. Make sure to remember it.</div>' +
'<div class="matrix-form-actions">' +
'<button class="matrix-form-back" id="sys-chpw-back-btn">← Back</button>' +
'<button class="matrix-form-submit" id="sys-chpw-submit-btn">Change Password</button>' +
'</div>' +
'<div class="matrix-form-result" id="sys-chpw-result"></div>';
document.getElementById("sys-chpw-back-btn").addEventListener("click", function() {
openServiceDetailModal(unit, name, icon);
});
document.getElementById("sys-chpw-new-toggle").addEventListener("click", function() {
var inp = document.getElementById("sys-chpw-new");
var isHidden = inp.type === "password";
inp.type = isHidden ? "text" : "password";
this.textContent = isHidden ? "👁‍🗨" : "👁";
});
document.getElementById("sys-chpw-confirm-toggle").addEventListener("click", function() {
var inp = document.getElementById("sys-chpw-confirm");
var isHidden = inp.type === "password";
inp.type = isHidden ? "text" : "password";
this.textContent = isHidden ? "👁‍🗨" : "👁";
});
document.getElementById("sys-chpw-submit-btn").addEventListener("click", async function() {
var submitBtn = document.getElementById("sys-chpw-submit-btn");
var resultEl = document.getElementById("sys-chpw-result");
var newPassword = document.getElementById("sys-chpw-new").value || "";
var confirmPassword = document.getElementById("sys-chpw-confirm").value || "";
if (!newPassword || !confirmPassword) {
resultEl.className = "matrix-form-result error";
resultEl.textContent = "Both password fields are required.";
return;
}
if (newPassword.length < 8) {
resultEl.className = "matrix-form-result error";
resultEl.textContent = "Password must be at least 8 characters.";
return;
}
if (newPassword !== confirmPassword) {
resultEl.className = "matrix-form-result error";
resultEl.textContent = "Passwords do not match.";
return;
}
submitBtn.disabled = true;
submitBtn.textContent = "Changing…";
resultEl.className = "matrix-form-result";
resultEl.textContent = "";
try {
await apiFetch("/api/change-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ new_password: newPassword, confirm_password: confirmPassword })
});
resultEl.className = "matrix-form-result success";
resultEl.textContent = "✅ System password changed successfully.";
submitBtn.textContent = "Change Password";
submitBtn.disabled = false;
// Hide the legacy security banner if it's visible — but only for
// "legacy" status machines. For "unsealed" machines, changing passwords
// is not enough; the factory residue remains until a proper re-seal or re-install.
if (typeof _securityIsLegacy !== "undefined" && _securityIsLegacy &&
(typeof _securityStatus === "undefined" || _securityStatus !== "unsealed")) {
_securityIsLegacy = false;
var banner = document.querySelector(".security-inline-banner");
if (banner) banner.remove();
}
} catch (err) {
resultEl.className = "matrix-form-result error";
resultEl.textContent = "❌ " + (err.message || "Failed to change password.");
submitBtn.textContent = "Change Password";
submitBtn.disabled = false;
}
});
}
function closeCredsModal() { if ($credsModal) $credsModal.classList.remove("open"); }

View File

@@ -99,5 +99,10 @@ const $upgradeConfirmBtn = document.getElementById("upgrade-confirm-btn");
const $upgradeCancelBtn = document.getElementById("upgrade-cancel-btn");
const $upgradeCloseBtn = document.getElementById("upgrade-close-btn");
// Legacy security warning state (populated by checkLegacySecurity in security.js)
var _securityIsLegacy = false;
var _securityStatus = "ok"; // "ok", "legacy", or "unsealed"
var _securityWarningMessage = "";
// System status banner
// (removed — health is now shown per-tile via the composite health field)

View File

@@ -1,21 +1,25 @@
/* Sovran_SystemsOS Hub — First-Boot Onboarding Wizard
Drives the 4-step post-install setup flow. */
Drives the 5-step post-install setup flow. */
"use strict";
// ── Constants ─────────────────────────────────────────────────────
const TOTAL_STEPS = 4;
const TOTAL_STEPS = 5;
// Steps to skip per role (steps 2 and 3 involve domain/port setup)
// Steps to skip per role (steps 3 and 4 involve domain/port setup)
// Step 2 (password) is NEVER skipped — all roles need it.
const ROLE_SKIP_STEPS = {
"desktop": [2, 3],
"node": [2, 3],
"desktop": [3, 4],
"node": [3, 4],
};
// ── Role state (loaded at init) ───────────────────────────────────
var _onboardingRole = "server_plus_desktop";
// Password default state (loaded at step 2)
var _passwordIsDefault = true;
// Domains that may need configuration, with service unit mapping for enabled check
const DOMAIN_DEFS = [
{ name: "matrix", label: "Matrix (Synapse)", unit: "matrix-synapse.service", needsDdns: true },
@@ -91,6 +95,8 @@ function showStep(step) {
// Lazy-load step content
if (step === 2) loadStep2();
if (step === 3) loadStep3();
if (step === 4) loadStep4();
// Step 5 (Complete) is static — no lazy-load needed
}
// Return the next step number, skipping over role-excluded steps
@@ -119,12 +125,135 @@ async function loadStep1() {
} catch (_) {}
}
// ── Step 2: Domain Configuration ─────────────────────────────────
// ── Step 2: Create Your Password ─────────────────────────────────
async function loadStep2() {
var body = document.getElementById("step-2-body");
if (!body) return;
var nextBtn = document.getElementById("step-2-next");
try {
var result = await apiFetch("/api/security/password-is-default");
_passwordIsDefault = result.is_default !== false;
} catch (_) {
_passwordIsDefault = true;
}
if (_passwordIsDefault) {
// Factory-sealed scenario: password must be set before continuing
if (nextBtn) nextBtn.textContent = "Set Password & Continue \u2192";
body.innerHTML =
'<div class="onboarding-password-group">' +
'<label class="onboarding-domain-label" for="pw-new">New Password</label>' +
'<div class="onboarding-password-input-wrap">' +
'<input class="onboarding-password-input" type="password" id="pw-new" autocomplete="new-password" placeholder="At least 8 characters" />' +
'<button type="button" class="onboarding-password-toggle" data-target="pw-new" aria-label="Show password">👁</button>' +
'</div>' +
'<p class="onboarding-password-hint">Minimum 8 characters</p>' +
'</div>' +
'<div class="onboarding-password-group">' +
'<label class="onboarding-domain-label" for="pw-confirm">Confirm Password</label>' +
'<div class="onboarding-password-input-wrap">' +
'<input class="onboarding-password-input" type="password" id="pw-confirm" autocomplete="new-password" placeholder="Re-enter your password" />' +
'<button type="button" class="onboarding-password-toggle" data-target="pw-confirm" aria-label="Show password">👁</button>' +
'</div>' +
'</div>' +
'<div class="onboarding-password-warning">⚠️ Write this password down — it cannot be recovered.</div>';
// Wire show/hide toggles
body.querySelectorAll(".onboarding-password-toggle").forEach(function(btn) {
btn.addEventListener("click", function() {
var inp = document.getElementById(btn.dataset.target);
if (inp) inp.type = (inp.type === "password") ? "text" : "password";
});
});
} else {
// DIY install scenario: password already set by installer
if (nextBtn) nextBtn.textContent = "Continue \u2192";
body.innerHTML =
'<div class="onboarding-password-success">✅ Your password was already set during installation.</div>' +
'<details class="onboarding-password-optional">' +
'<summary>Change it anyway</summary>' +
'<div style="margin-top:14px;">' +
'<div class="onboarding-password-group">' +
'<label class="onboarding-domain-label" for="pw-new">New Password</label>' +
'<div class="onboarding-password-input-wrap">' +
'<input class="onboarding-password-input" type="password" id="pw-new" autocomplete="new-password" placeholder="At least 8 characters" />' +
'<button type="button" class="onboarding-password-toggle" data-target="pw-new" aria-label="Show password">👁</button>' +
'</div>' +
'<p class="onboarding-password-hint">Minimum 8 characters</p>' +
'</div>' +
'<div class="onboarding-password-group">' +
'<label class="onboarding-domain-label" for="pw-confirm">Confirm Password</label>' +
'<div class="onboarding-password-input-wrap">' +
'<input class="onboarding-password-input" type="password" id="pw-confirm" autocomplete="new-password" placeholder="Re-enter your password" />' +
'<button type="button" class="onboarding-password-toggle" data-target="pw-confirm" aria-label="Show password">👁</button>' +
'</div>' +
'</div>' +
'<div class="onboarding-password-warning">⚠️ Write this password down — it cannot be recovered.</div>' +
'</div>' +
'</details>';
// Wire show/hide toggles
body.querySelectorAll(".onboarding-password-toggle").forEach(function(btn) {
btn.addEventListener("click", function() {
var inp = document.getElementById(btn.dataset.target);
if (inp) inp.type = (inp.type === "password") ? "text" : "password";
});
});
}
}
async function saveStep2() {
var newPw = document.getElementById("pw-new");
var confirmPw = document.getElementById("pw-confirm");
// If no fields visible or both empty and password already set → skip
if (!newPw || !newPw.value.trim()) {
if (!_passwordIsDefault) return true; // already set, no change requested
setStatus("step-2-status", "⚠ Please enter a password.", "error");
return false;
}
var pw = newPw.value;
var cpw = confirmPw ? confirmPw.value : "";
if (pw.length < 8) {
setStatus("step-2-status", "⚠ Password must be at least 8 characters.", "error");
return false;
}
if (pw !== cpw) {
setStatus("step-2-status", "⚠ Passwords do not match.", "error");
return false;
}
setStatus("step-2-status", "Saving password…", "info");
try {
await apiFetch("/api/change-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ new_password: pw, confirm_password: cpw }),
});
} catch (err) {
setStatus("step-2-status", "⚠ " + err.message, "error");
return false;
}
setStatus("step-2-status", "✓ Password saved", "ok");
_passwordIsDefault = false;
return true;
}
// ── Step 3: Domain Configuration ─────────────────────────────────
async function loadStep3() {
var body = document.getElementById("step-3-body");
if (!body) return;
try {
// Fetch services, domains, and network info in parallel
var results = await Promise.all([
@@ -179,6 +308,8 @@ async function loadStep2() {
html += '<label class="onboarding-domain-label onboarding-domain-label--sub">Njal.la DDNS Curl Command</label>';
html += '<input class="onboarding-domain-input domain-field-input" type="text" id="ddns-input-' + escHtml(d.name) + '" data-ddns="' + escHtml(d.name) + '" placeholder="curl &quot;https://njal.la/update/?h=' + escHtml(d.name) + '.yourdomain.com&amp;k=abc123&amp;auto&quot;" />';
html += '<p class="onboarding-hint" style="margin-top:4px;"> Paste the curl URL from your Njal.la dashboard\'s Dynamic record</p>';
html += '<button type="button" class="btn btn-primary onboarding-domain-save-btn" data-save-domain="' + escHtml(d.name) + '" style="align-self:flex-start;margin-top:8px;font-size:0.82rem;padding:6px 16px;">Save</button>';
html += '<span class="onboarding-domain-save-status" id="domain-save-status-' + escHtml(d.name) + '" style="font-size:0.82rem;min-height:1.2em;"></span>';
html += '</div>';
});
}
@@ -189,13 +320,82 @@ async function loadStep2() {
html += '<label class="onboarding-domain-label">📧 SSL Certificate Email</label>';
html += '<p class="onboarding-hint onboarding-hint--inline">Let\'s Encrypt uses this for certificate expiry notifications.</p>';
html += '<input class="onboarding-domain-input domain-field-input" type="email" id="ssl-email-input" placeholder="you@example.com" value="' + escHtml(emailVal) + '" />';
html += '<button type="button" class="btn btn-primary onboarding-domain-save-btn" data-save-email="true" style="align-self:flex-start;margin-top:8px;font-size:0.82rem;padding:6px 16px;">Save</button>';
html += '<span class="onboarding-domain-save-status" id="domain-save-status-email" style="font-size:0.82rem;min-height:1.2em;"></span>';
html += '</div>';
body.innerHTML = html;
// Wire per-field save buttons for domains
body.querySelectorAll('[data-save-domain]').forEach(function(btn) {
btn.addEventListener('click', async function() {
var domainName = btn.dataset.saveDomain;
var domainInput = document.getElementById('domain-input-' + domainName);
var ddnsInput = document.getElementById('ddns-input-' + domainName);
var statusEl = document.getElementById('domain-save-status-' + domainName);
var domainVal = domainInput ? domainInput.value.trim() : '';
var ddnsVal = ddnsInput ? ddnsInput.value.trim() : '';
if (!domainVal) {
if (statusEl) { statusEl.textContent = '⚠ Enter a domain first'; statusEl.style.color = 'var(--red)'; }
return;
}
btn.disabled = true;
btn.textContent = 'Saving…';
if (statusEl) { statusEl.textContent = ''; }
try {
await apiFetch('/api/domains/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain_name: domainName, domain: domainVal, ddns_url: ddnsVal }),
});
if (statusEl) { statusEl.textContent = '✓ Saved'; statusEl.style.color = 'var(--green)'; }
} catch (err) {
if (statusEl) { statusEl.textContent = '⚠ ' + err.message; statusEl.style.color = 'var(--red)'; }
}
btn.disabled = false;
btn.textContent = 'Save';
});
});
// Wire save button for SSL email
body.querySelectorAll('[data-save-email]').forEach(function(btn) {
btn.addEventListener('click', async function() {
var emailInput = document.getElementById('ssl-email-input');
var statusEl = document.getElementById('domain-save-status-email');
var emailVal = emailInput ? emailInput.value.trim() : '';
if (!emailVal) {
if (statusEl) { statusEl.textContent = '⚠ Enter an email first'; statusEl.style.color = 'var(--red)'; }
return;
}
btn.disabled = true;
btn.textContent = 'Saving…';
if (statusEl) { statusEl.textContent = ''; }
try {
await apiFetch('/api/domains/set-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: emailVal }),
});
if (statusEl) { statusEl.textContent = '✓ Saved'; statusEl.style.color = 'var(--green)'; }
} catch (err) {
if (statusEl) { statusEl.textContent = '⚠ ' + err.message; statusEl.style.color = 'var(--red)'; }
}
btn.disabled = false;
btn.textContent = 'Save';
});
});
}
async function saveStep2() {
setStatus("step-2-status", "Saving domains…", "info");
async function saveStep3() {
setStatus("step-3-status", "Saving domains…", "info");
var errors = [];
// Save each domain input
@@ -235,18 +435,18 @@ async function saveStep2() {
}
if (errors.length > 0) {
setStatus("step-2-status", "⚠ Some errors: " + errors.join("; "), "error");
setStatus("step-3-status", "⚠ Some errors: " + errors.join("; "), "error");
return false;
}
setStatus("step-2-status", "✓ Saved", "ok");
setStatus("step-3-status", "✓ Saved", "ok");
return true;
}
// ── Step 3: Port Forwarding ───────────────────────────────────────
// ── Step 4: Port Forwarding ───────────────────────────────────────
async function loadStep3() {
var body = document.getElementById("step-3-body");
async function loadStep4() {
var body = document.getElementById("step-4-body");
if (!body) return;
body.innerHTML = '<p class="onboarding-loading">Checking ports…</p>';
@@ -327,10 +527,10 @@ async function loadStep3() {
body.innerHTML = html;
}
// ── Step 4: Complete ──────────────────────────────────────────────
// ── Step 5: Complete ──────────────────────────────────────────────
async function completeOnboarding() {
var btn = document.getElementById("step-4-finish");
var btn = document.getElementById("step-5-finish");
if (btn) { btn.disabled = true; btn.textContent = "Finishing…"; }
try {
@@ -345,28 +545,40 @@ async function completeOnboarding() {
// ── Event wiring ──────────────────────────────────────────────────
function wireNavButtons() {
// Step 1 → next (may skip 2+3 for desktop/node)
// Step 1 → next
var s1next = document.getElementById("step-1-next");
if (s1next) s1next.addEventListener("click", function() { showStep(nextStep(1)); });
// Step 2 → 3 (save first)
// Step 2 → 3 (save password first)
var s2next = document.getElementById("step-2-next");
if (s2next) s2next.addEventListener("click", async function() {
s2next.disabled = true;
var origText = s2next.textContent;
s2next.textContent = "Saving…";
await saveStep2();
var ok = await saveStep2();
s2next.disabled = false;
s2next.textContent = "Save & Continue →";
showStep(nextStep(2));
s2next.textContent = origText;
if (ok) showStep(nextStep(2));
});
// Step 3 → 4 (Complete)
// Step 3 → 4 (save domains first)
var s3next = document.getElementById("step-3-next");
if (s3next) s3next.addEventListener("click", function() { showStep(nextStep(3)); });
if (s3next) s3next.addEventListener("click", async function() {
s3next.disabled = true;
s3next.textContent = "Saving…";
await saveStep3();
s3next.disabled = false;
s3next.textContent = "Save & Continue →";
showStep(nextStep(3));
});
// Step 4: finish
var s4finish = document.getElementById("step-4-finish");
if (s4finish) s4finish.addEventListener("click", completeOnboarding);
// Step 4 → 5 (Complete)
var s4next = document.getElementById("step-4-next");
if (s4next) s4next.addEventListener("click", function() { showStep(nextStep(4)); });
// Step 5: finish
var s5finish = document.getElementById("step-5-finish");
if (s5finish) s5finish.addEventListener("click", completeOnboarding);
// Back buttons
document.querySelectorAll(".onboarding-btn-back").forEach(function(btn) {

View File

@@ -14,6 +14,7 @@
<link rel="stylesheet" href="/static/css/onboarding.css" />
<link rel="stylesheet" href="/static/css/support.css" />
<link rel="stylesheet" href="/static/css/domain-setup.css" />
<link rel="stylesheet" href="/static/css/security.css" />
</head>
<body>
@@ -236,6 +237,7 @@
<script src="/static/js/update.js"></script>
<script src="/static/js/rebuild.js"></script>
<script src="/static/js/features.js"></script>
<script src="/static/js/security.js"></script>
<script src="/static/js/events.js"></script>
</body>
</html>

View File

@@ -34,6 +34,8 @@
<span class="onboarding-step-dot" data-step="3">3</span>
<span class="onboarding-step-connector"></span>
<span class="onboarding-step-dot" data-step="4">4</span>
<span class="onboarding-step-connector"></span>
<span class="onboarding-step-dot" data-step="5">5</span>
</div>
<!-- Step panels -->
@@ -70,8 +72,29 @@
</div>
</div>
<!-- ── Step 2: Domain Configuration ── -->
<!-- ── Step 2: Create Your Password ── -->
<div class="onboarding-panel" id="step-2" style="display:none">
<div class="onboarding-step-header">
<span class="onboarding-step-icon">🔒</span>
<h2 class="onboarding-step-title">Create Your Password</h2>
<p class="onboarding-step-desc">
Choose a strong password for your <strong>'free'</strong> user account.
</p>
</div>
<div class="onboarding-card" id="step-2-body">
<p class="onboarding-loading">Checking password status…</p>
</div>
<div id="step-2-status" class="onboarding-save-status"></div>
<div class="onboarding-footer">
<button class="btn btn-close-modal onboarding-btn-back" data-prev="1">← Back</button>
<button class="btn btn-primary onboarding-btn-next" id="step-2-next">
Set Password &amp; Continue →
</button>
</div>
</div>
<!-- ── Step 3: Domain Configuration ── -->
<div class="onboarding-panel" id="step-3" style="display:none">
<div class="onboarding-step-header">
<span class="onboarding-step-icon">🌐</span>
<h2 class="onboarding-step-title">Domain Configuration</h2>
@@ -82,20 +105,20 @@
Finally, paste the DDNS curl command from your Njal.la dashboard for each service below.
</p>
</div>
<div class="onboarding-card onboarding-card--scroll" id="step-2-body">
<div class="onboarding-card" id="step-3-body">
<p class="onboarding-loading">Loading service information…</p>
</div>
<div id="step-2-status" class="onboarding-save-status"></div>
<div id="step-3-status" class="onboarding-save-status"></div>
<div class="onboarding-footer">
<button class="btn btn-close-modal onboarding-btn-back" data-prev="1">← Back</button>
<button class="btn btn-primary onboarding-btn-next" id="step-2-next">
<button class="btn btn-close-modal onboarding-btn-back" data-prev="2">← Back</button>
<button class="btn btn-primary onboarding-btn-next" id="step-3-next">
Save &amp; Continue →
</button>
</div>
</div>
<!-- ── Step 3: Port Forwarding ── -->
<div class="onboarding-panel" id="step-3" style="display:none">
<!-- ── Step 4: Port Forwarding ── -->
<div class="onboarding-panel" id="step-4" style="display:none">
<div class="onboarding-step-header">
<span class="onboarding-step-icon">🔌</span>
<h2 class="onboarding-step-title">Port Forwarding Check</h2>
@@ -104,19 +127,19 @@
<strong>Ports 80 and 443 must be open for SSL certificates to work.</strong>
</p>
</div>
<div class="onboarding-card onboarding-card--ports" id="step-3-body">
<div class="onboarding-card" id="step-4-body">
<p class="onboarding-loading">Checking ports…</p>
</div>
<div class="onboarding-footer">
<button class="btn btn-close-modal onboarding-btn-back" data-prev="2">← Back</button>
<button class="btn btn-primary onboarding-btn-next" id="step-3-next">
<button class="btn btn-close-modal onboarding-btn-back" data-prev="3">← Back</button>
<button class="btn btn-primary onboarding-btn-next" id="step-4-next">
Continue →
</button>
</div>
</div>
<!-- ── Step 4: Complete ── -->
<div class="onboarding-panel" id="step-4" style="display:none">
<!-- ── Step 5: Complete ── -->
<div class="onboarding-panel" id="step-5" style="display:none">
<div class="onboarding-hero">
<div class="onboarding-logo"></div>
<h1 class="onboarding-title">Your Sovran_SystemsOS is Ready!</h1>
@@ -128,13 +151,14 @@
monitor your services, manage credentials, and make changes at any time.
</p>
<ul class="onboarding-checklist" id="onboarding-checklist">
<li>✅ Password configured</li>
<li>✅ Domain configuration saved</li>
<li>✅ Port forwarding reviewed</li>
</ul>
</div>
<div class="onboarding-footer">
<button class="btn btn-close-modal onboarding-btn-back" data-prev="3">← Back</button>
<button class="btn btn-primary" id="step-4-finish">
<button class="btn btn-close-modal onboarding-btn-back" data-prev="4">← Back</button>
<button class="btn btn-primary" id="step-5-finish">
Go to Dashboard →
</button>
</div>

36
flake.lock generated
View File

@@ -5,11 +5,11 @@
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1773169138,
"narHash": "sha256-6X41z8o2z8KjF4gMzLTPD41WjvCDGXTc0muPGmwcOMk=",
"lastModified": 1775155316,
"narHash": "sha256-4H8aEChZ6rra9jd8OcVHgHs3IuzKzpDt4PPtsPJrkyM=",
"owner": "emmanuelrosa",
"repo": "bitcoin-knots-bip-110-nix",
"rev": "b9d018b71e20ce8c1567cbc2401b6edc2c1c7793",
"rev": "663ea34f6f846f48c385a73d4581ba599bb5bbc0",
"type": "github"
},
"original": {
@@ -24,11 +24,11 @@
"oldNixpkgs": "oldNixpkgs"
},
"locked": {
"lastModified": 1774797058,
"narHash": "sha256-URUOiKNjG3s7vDkTj554+3yzQ0qqNQoQwHdc7vs63X0=",
"lastModified": 1775155241,
"narHash": "sha256-J8dGfHLXlpJgDSgbBuQtll9chM9Hsv5hhVWglSRLgIE=",
"owner": "emmanuelrosa",
"repo": "btc-clients-nix",
"rev": "a10dae067da04b7b170eed73efc665d27fc0e0c5",
"rev": "e76e32fef6d0b8a8e9a32318835ba09915232a5c",
"type": "github"
},
"original": {
@@ -127,11 +127,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1772380631,
"narHash": "sha256-FhW0uxeXjefINP0vUD4yRBB52Us7fXZPk9RiPAopfiY=",
"lastModified": 1775054576,
"narHash": "sha256-iiIr1hlTMu2LLARsUYtiqlE90tqocqIMVLK2fIzB/UY=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "6d3b61b190a899042ce82a5355111976ba76d698",
"rev": "fc4b9b74d4b0bdbf3c97fef4bd34c05225172912",
"type": "github"
},
"original": {
@@ -191,11 +191,11 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1772380631,
"narHash": "sha256-FhW0uxeXjefINP0vUD4yRBB52Us7fXZPk9RiPAopfiY=",
"lastModified": 1775054576,
"narHash": "sha256-iiIr1hlTMu2LLARsUYtiqlE90tqocqIMVLK2fIzB/UY=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "6d3b61b190a899042ce82a5355111976ba76d698",
"rev": "fc4b9b74d4b0bdbf3c97fef4bd34c05225172912",
"type": "github"
},
"original": {
@@ -222,11 +222,11 @@
},
"nixpkgs_4": {
"locked": {
"lastModified": 1775036866,
"narHash": "sha256-ZojAnPuCdy657PbTq5V0Y+AHKhZAIwSIT2cb8UgAz/U=",
"lastModified": 1775423009,
"narHash": "sha256-vPKLpjhIVWdDrfiUM8atW6YkIggCEKdSAlJPzzhkQlw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "6201e203d09599479a3b3450ed24fa81537ebc4e",
"rev": "68d8aa3d661f0e6bd5862291b5bb263b2a6595c9",
"type": "github"
},
"original": {
@@ -259,11 +259,11 @@
"systems": "systems_2"
},
"locked": {
"lastModified": 1774802402,
"narHash": "sha256-L1UJ/zxKTyyaGGmytH6OYlgQ0HGSMhvPkvU+iz4Mkb8=",
"lastModified": 1775307257,
"narHash": "sha256-y9hEecHH4ennFwIcw1n480YCGh73DkEmizmQnyXuvgg=",
"owner": "nix-community",
"repo": "nixvim",
"rev": "cbd8536a05d1aae2593cb5c9ace1010c8c5845cb",
"rev": "2e008bb941f72379d5b935d5bfe70ed8b7c793ff",
"type": "github"
},
"original": {

View File

@@ -25,27 +25,17 @@
modules = [
{ nixpkgs.hostPlatform = "x86_64-linux"; }
self.nixosModules.Sovran_SystemsOS
/etc/nixos/hardware-configuration.nix
/etc/nixos/role-state.nix
/etc/nixos/custom.nix
./hardware-configuration.nix
./role-state.nix
./custom.nix
];
};
nixosConfigurations.sovran-iso-desktop = nixpkgs.lib.nixosSystem {
nixosConfigurations.sovran_systemsos-iso = nixpkgs.lib.nixosSystem {
modules = [
{ nixpkgs.hostPlatform = "x86_64-linux"; }
({ config, pkgs, ... }: { nixpkgs.overlays = [ overlay-stable ]; })
./iso/desktop.nix
nix-bitcoin.nixosModules.default
nixvim.nixosModules.nixvim
];
};
nixosConfigurations.sovran-iso-server = nixpkgs.lib.nixosSystem {
modules = [
{ nixpkgs.hostPlatform = "x86_64-linux"; }
({ config, pkgs, ... }: { nixpkgs.overlays = [ overlay-stable ]; })
./iso/server.nix
./iso/common.nix
nix-bitcoin.nixosModules.default
nixvim.nixosModules.nixvim
];

View File

@@ -14,6 +14,28 @@ LOGO = "/etc/sovran/logo.png"
LOG = "/tmp/sovran-install.log"
FLAKE = "/etc/sovran/flake"
DEPLOYED_FLAKE = """\
{
description = "Sovran_SystemsOS for the Sovran Pro from Sovran Systems";
inputs = {
Sovran_Systems.url = "git+https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS?ref=staging-dev";
};
outputs = { self, Sovran_Systems, ... }@inputs: {
nixosConfigurations."nixos" = Sovran_Systems.inputs.nixpkgs.lib.nixosSystem {
modules = [
{ nixpkgs.hostPlatform = "x86_64-linux"; }
./hardware-configuration.nix
./role-state.nix
./custom.nix
Sovran_Systems.nixosModules.Sovran_SystemsOS
];
};
};
}
"""
try:
logfile = open(LOG, "a")
atexit.register(logfile.close)
@@ -87,7 +109,7 @@ def symbolic_icon(name):
return icon
# ── Application ────────────────────────────────────────────────────────────────
# ── Application ──────────────────────────────────────────────────────────
class InstallerApp(Adw.Application):
def __init__(self):
@@ -99,7 +121,7 @@ class InstallerApp(Adw.Application):
self.win.present()
# ── Main Window ────────────────────────────────────────────────────────────────
# ── Main Window ──────────────────────────────────────────────────────────
class InstallerWindow(Adw.ApplicationWindow):
def __init__(self, **kwargs):
@@ -146,7 +168,7 @@ class InstallerWindow(Adw.ApplicationWindow):
break
self.push_page(title, child)
# ── Shared widgets ───────────────<EFBFBD><EFBFBD>─────────────────────────────────────
# ── Shared widgets ────────────────────────────────────────────────────
def make_scrolled_log(self):
sw = Gtk.ScrolledWindow()
@@ -793,6 +815,9 @@ class InstallerWindow(Adw.ApplicationWindow):
data_p1 = f"{data_path}p1" if "nvme" in data_path else f"{data_path}1"
run_stream(["sudo", "mkdir", "-p", "/mnt/run/media/Second_Drive"], buf)
run_stream(["sudo", "mount", data_p1, "/mnt/run/media/Second_Drive"], buf)
run_stream(["sudo", "mkdir", "-p", "/mnt/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node"], buf)
run_stream(["sudo", "mkdir", "-p", "/mnt/run/media/Second_Drive/BTCEcoandBackup/Electrs_Data"], buf)
run_stream(["sudo", "mkdir", "-p", "/mnt/run/media/Second_Drive/BTCEcoandBackup/NixOS_Snapshot_Backup"], buf)
GLib.idle_add(append_text, buf, "\n=== Generating hardware config ===\n")
run_stream(["sudo", "nixos-generate-config", "--root", "/mnt"], buf)
@@ -831,7 +856,7 @@ class InstallerWindow(Adw.ApplicationWindow):
raise RuntimeError(f"Failed to write role-state.nix: {proc.stderr}")
run(["sudo", "cp", "/mnt/etc/nixos/custom.template.nix", "/mnt/etc/nixos/custom.nix"])
# ── Step 4: Ready to install ────────<EFBFBD><EFBFBD><EFBFBD>──────────────────────────────────
# ── Step 4: Ready to install ──────────────────────────────────────────
def push_ready(self):
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
@@ -930,109 +955,24 @@ class InstallerWindow(Adw.ApplicationWindow):
path = os.path.join(nixos_dir, entry)
run(["sudo", "rm", "-rf", path])
GLib.idle_add(self.push_create_password)
# ── Step 5b: Create Password ──────────────────────────────────────────
def push_create_password(self):
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
status = Adw.StatusPage()
status.set_title("Create Your Password")
status.set_description(
"Choose a password for your 'free' user account. "
"This will be your login password."
GLib.idle_add(append_text, buf, "Writing deployed flake.nix...\n")
proc = subprocess.run(
["sudo", "tee", "/mnt/etc/nixos/flake.nix"],
input=DEPLOYED_FLAKE,
capture_output=True,
text=True,
)
status.set_vexpand(True)
log(proc.stdout)
if proc.returncode != 0:
log(proc.stderr)
raise RuntimeError(proc.stderr.strip() or "Failed to write deployed flake.nix")
GLib.idle_add(append_text, buf, "Locking flake to staging-dev...\n")
run_stream(["sudo", "nix", "--extra-experimental-features", "nix-command flakes",
"flake", "lock", "/mnt/etc/nixos"], buf)
form_group = Adw.PreferencesGroup()
form_group.set_margin_start(40)
form_group.set_margin_end(40)
GLib.idle_add(self.push_complete)
pw_row = Adw.PasswordEntryRow()
pw_row.set_title("Password")
form_group.add(pw_row)
confirm_row = Adw.PasswordEntryRow()
confirm_row.set_title("Confirm Password")
form_group.add(confirm_row)
error_lbl = Gtk.Label()
error_lbl.set_margin_start(40)
error_lbl.set_margin_end(40)
error_lbl.set_margin_top(8)
error_lbl.set_visible(False)
error_lbl.add_css_class("error")
content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16)
content_box.append(status)
content_box.append(form_group)
content_box.append(error_lbl)
outer.append(content_box)
def on_submit(btn):
password = pw_row.get_text()
confirm = confirm_row.get_text()
if not password:
error_lbl.set_text("Password cannot be empty.")
error_lbl.set_visible(True)
return
if len(password) < 8:
error_lbl.set_text("Password must be at least 8 characters.")
error_lbl.set_visible(True)
return
if password != confirm:
error_lbl.set_text("Passwords do not match.")
error_lbl.set_visible(True)
return
btn.set_sensitive(False)
error_lbl.set_visible(False)
try:
run(["sudo", "mkdir", "-p", "/mnt/var/lib/secrets"])
proc = subprocess.run(
["sudo", "tee", "/mnt/var/lib/secrets/free-password"],
input=password, text=True, capture_output=True
)
if proc.returncode != 0:
raise RuntimeError(proc.stderr.strip() or "Failed to write password file")
run(["sudo", "chmod", "600", "/mnt/var/lib/secrets/free-password"])
proc = subprocess.run(
["sudo", "chroot", "/mnt", "chpasswd"],
input=f"free:{password}",
capture_output=True, text=True
)
if proc.returncode != 0:
raise RuntimeError(proc.stderr.strip() or "Failed to set password in chroot")
run(["sudo", "touch", "/mnt/var/lib/sovran-customer-onboarded"])
except Exception as e:
error_lbl.set_text(str(e))
error_lbl.set_visible(True)
btn.set_sensitive(True)
return
GLib.idle_add(self.push_complete)
submit_btn = Gtk.Button(label="Set Password & Continue")
submit_btn.add_css_class("suggested-action")
submit_btn.add_css_class("pill")
submit_btn.connect("clicked", on_submit)
nav = Gtk.Box()
nav.set_margin_bottom(24)
nav.set_margin_end(40)
nav.set_halign(Gtk.Align.END)
nav.append(submit_btn)
outer.append(nav)
self.push_page("Create Password", outer)
return False
# ── Step 6: Complete ───────────────────────────────────────────────────
# ── Complete ───────────────────────────────────────────────────────────
def push_complete(self):
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
@@ -1043,7 +983,7 @@ class InstallerWindow(Adw.ApplicationWindow):
status.set_vexpand(True)
creds_group = Adw.PreferencesGroup()
creds_group.set_title("Write down your login details before rebooting")
creds_group.set_title("Important — read before rebooting")
creds_group.set_margin_start(40)
creds_group.set_margin_end(40)
@@ -1053,15 +993,15 @@ class InstallerWindow(Adw.ApplicationWindow):
creds_group.add(user_row)
pass_row = Adw.ActionRow()
pass_row.set_title("Password")
pass_row.set_subtitle("The password you just created")
pass_row.set_title("Default Password")
pass_row.set_subtitle("free — you will be prompted to change it on first boot")
creds_group.add(pass_row)
note_row = Adw.ActionRow()
note_row.set_title("App Passwords")
note_row.set_title("First Boot Setup")
note_row.set_subtitle(
"After rebooting, all app passwords (Nextcloud, Bitcoin, Matrix, etc.) "
"will be available in the Sovran Hub on your dashboard."
"After rebooting, the Sovran Hub will guide you through setting "
"your password, domains, and all app credentials."
)
creds_group.add(note_row)
@@ -1109,4 +1049,4 @@ class InstallerWindow(Adw.ApplicationWindow):
if __name__ == "__main__":
app = InstallerApp()
app.run(None)
app.run(None)

View File

@@ -69,7 +69,36 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
};
nix-bitcoin.useVersionLockedPkgs = false;
systemd.services.bitcoind = {
requires = [ "run-media-Second_Drive.mount" ];
after = [ "run-media-Second_Drive.mount" ];
};
systemd.services.electrs = {
requires = [ "run-media-Second_Drive.mount" ];
after = [ "run-media-Second_Drive.mount" ];
};
systemd.services.sovran-btc-permissions = {
description = "Fix Bitcoin/Electrs data directory ownership on second drive";
wantedBy = [ "multi-user.target" ];
after = [ "run-media-Second_Drive.mount" ];
before = [ "bitcoind.service" "electrs.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
if [ -d /run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node ]; then
chown -R bitcoin:bitcoin /run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node
fi
if [ -d /run/media/Second_Drive/BTCEcoandBackup/Electrs_Data ]; then
chown -R electrs:electrs /run/media/Second_Drive/BTCEcoandBackup/Electrs_Data
fi
'';
};
sovran_systemsOS.domainRequirements = [
{ name = "btcpayserver"; label = "BTCPay Server"; example = "pay.yourdomain.com"; }
];

View File

@@ -9,7 +9,18 @@ in
enable = true;
user = "caddy";
group = "root";
configFile = "/run/caddy/Caddyfile";
};
# Override ExecStart + ExecReload to point at the runtime-generated Caddyfile
systemd.services.caddy.serviceConfig = {
ExecStart = lib.mkForce [
""
"${pkgs.caddy}/bin/caddy run --config /run/caddy/Caddyfile --adapter caddyfile"
];
ExecReload = lib.mkForce [
""
"${pkgs.caddy}/bin/caddy reload --config /run/caddy/Caddyfile --adapter caddyfile --force"
];
};
systemd.services.caddy-generate-config = {
@@ -178,4 +189,4 @@ ${extraVhosts}
CUSTOM_VHOSTS_EOF
'';
};
}
}

View File

@@ -89,6 +89,132 @@ in
{
environment.systemPackages = [ sovran-factory-seal ];
# ── Auto-seal on first customer boot ───────────────────────────────
systemd.services.sovran-auto-seal = {
description = "Auto-seal Sovran system on first customer boot";
wantedBy = [ "multi-user.target" ];
before = [ "sovran-hub.service" "sovran-legacy-security-check.service" ];
after = [ "local-fs.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
path = [ pkgs.coreutils pkgs.e2fsprogs pkgs.openssl pkgs.postgresql pkgs.mariadb pkgs.shadow ];
script = ''
# Idempotency check
if [ -f /var/lib/sovran-factory-sealed ]; then
echo "sovran-auto-seal: already sealed, nothing to do."
exit 0
fi
echo "sovran-auto-seal: seal flag missing checking system state..."
# Safety guard 1: customer has already onboarded
if [ -f /var/lib/sovran-customer-onboarded ]; then
echo "sovran-auto-seal: /var/lib/sovran-customer-onboarded exists live system detected. Restoring flag and exiting."
touch /var/lib/sovran-factory-sealed
chattr +i /var/lib/sovran-factory-sealed 2>/dev/null || true
exit 0
fi
# Safety guard 2: onboarding was completed
if [ -f /var/lib/sovran/onboarding-complete ]; then
echo "sovran-auto-seal: /var/lib/sovran/onboarding-complete exists live system detected. Restoring flag and exiting."
touch /var/lib/sovran-factory-sealed
chattr +i /var/lib/sovran-factory-sealed 2>/dev/null || true
exit 0
fi
# Safety guard 3: password has been changed from factory defaults
if [ -f /etc/shadow ]; then
FREE_HASH=$(grep '^free:' /etc/shadow | cut -d: -f2)
if [ -n "$FREE_HASH" ] && [ "$FREE_HASH" != "!" ] && [ "$FREE_HASH" != "*" ]; then
ALGO_ID=$(printf '%s' "$FREE_HASH" | cut -d'$' -f2)
SALT=$(printf '%s' "$FREE_HASH" | cut -d'$' -f3)
STILL_DEFAULT=false
# If the salt field starts with "rounds=", we cannot extract the real salt
# with a simple cut treat as still-default for safety
if printf '%s' "$SALT" | grep -q '^rounds='; then
STILL_DEFAULT=true
else
for DEFAULT_PW in "free" "gosovransystems"; do
case "$ALGO_ID" in
6) EXPECTED=$(openssl passwd -6 -salt "$SALT" "$DEFAULT_PW" 2>/dev/null) ;;
5) EXPECTED=$(openssl passwd -5 -salt "$SALT" "$DEFAULT_PW" 2>/dev/null) ;;
*)
# Unknown hash algorithm treat as still-default for safety
STILL_DEFAULT=true
break
;;
esac
if [ -n "$EXPECTED" ] && [ "$EXPECTED" = "$FREE_HASH" ]; then
STILL_DEFAULT=true
break
fi
done
fi
if [ "$STILL_DEFAULT" = "false" ]; then
echo "sovran-auto-seal: password has been changed from factory defaults live system detected. Restoring flag and exiting."
touch /var/lib/sovran-factory-sealed
chattr +i /var/lib/sovran-factory-sealed 2>/dev/null || true
exit 0
fi
fi
fi
# All safety guards passed: this is a fresh/unsealed system
echo "sovran-auto-seal: fresh system confirmed performing auto-seal..."
# 1. Wipe generated secrets
echo "sovran-auto-seal: wiping secrets..."
[ -d /var/lib/secrets ] && find /var/lib/secrets -mindepth 1 -delete || true
rm -rf /var/lib/matrix-synapse/registration-secret
rm -rf /var/lib/matrix-synapse/db-password
rm -rf /var/lib/gnome-remote-desktop/rdp-password
rm -rf /var/lib/gnome-remote-desktop/rdp-username
rm -rf /var/lib/gnome-remote-desktop/rdp-credentials
rm -rf /var/lib/livekit/livekit_keyFile
rm -rf /etc/nix-bitcoin-secrets/*
# 2. Wipe LND wallet data
echo "sovran-auto-seal: wiping LND wallet data..."
rm -rf /var/lib/lnd/*
# 3. Remove SSH factory key
echo "sovran-auto-seal: removing SSH factory key..."
rm -f /home/free/.ssh/factory_login /home/free/.ssh/factory_login.pub
if [ -f /root/.ssh/authorized_keys ]; then
sed -i '/factory_login/d' /root/.ssh/authorized_keys
fi
# 4. Drop application databases
echo "sovran-auto-seal: dropping application databases..."
sudo -u postgres psql -c "DROP DATABASE IF EXISTS \"matrix-synapse\";" 2>/dev/null || true
sudo -u postgres psql -c "DROP DATABASE IF EXISTS nextclouddb;" 2>/dev/null || true
mysql -u root -e "DROP DATABASE IF EXISTS wordpressdb;" 2>/dev/null || true
# 5. Remove application config files
echo "sovran-auto-seal: removing application config files..."
rm -rf /var/lib/www/wordpress/wp-config.php
rm -rf /var/lib/www/nextcloud/config/config.php
# 6. Wipe Vaultwarden data
echo "sovran-auto-seal: wiping Vaultwarden data..."
rm -rf /var/lib/bitwarden_rs/*
rm -rf /var/lib/vaultwarden/*
# 7. Set sealed flag and make it immutable
echo "sovran-auto-seal: setting sealed flag..."
touch /var/lib/sovran-factory-sealed
chattr +i /var/lib/sovran-factory-sealed 2>/dev/null || true
# 8. Remove onboarded flag so onboarding runs fresh
rm -f /var/lib/sovran-customer-onboarded
echo "sovran-auto-seal: auto-seal complete. Continuing boot into onboarding."
'';
};
# ── Legacy security check: warn existing (pre-seal) machines ───────
systemd.services.sovran-legacy-security-check = {
description = "Check for legacy (pre-factory-seal) security status";
@@ -98,13 +224,64 @@ in
Type = "oneshot";
RemainAfterExit = true;
};
path = [ pkgs.coreutils ];
path = [ pkgs.coreutils pkgs.openssl ];
script = ''
# If already onboarded or sealed, nothing to do
[ -f /var/lib/sovran-customer-onboarded ] && exit 0
# If sealed AND onboarded fully clean, nothing to do
[ -f /var/lib/sovran-factory-sealed ] && [ -f /var/lib/sovran-customer-onboarded ] && exit 0
# If sealed but not yet onboarded seal was run, customer hasn't finished setup yet, that's fine
[ -f /var/lib/sovran-factory-sealed ] && exit 0
# If secrets exist but no sealed/onboarded flag, this is a legacy machine
# If onboarded but NOT sealed installer ran without factory seal!
if [ -f /var/lib/sovran-customer-onboarded ] && [ ! -f /var/lib/sovran-factory-sealed ]; then
mkdir -p /var/lib/sovran
echo "unsealed" > /var/lib/sovran/security-status
cat > /var/lib/sovran/security-warning << 'EOF'
This machine was set up without the factory seal process. Factory test data including SSH keys, database contents, and wallet information may still be present on this system. It is strongly recommended to back up any important data and re-install using a fresh ISO, or contact Sovran Systems support for assistance.
EOF
exit 0
fi
# If the user completed Hub onboarding, they've addressed security
[ -f /var/lib/sovran/onboarding-complete ] && exit 0
# If the free password has been changed from ALL known factory defaults, no warning needed
if [ -f /etc/shadow ]; then
FREE_HASH=$(grep '^free:' /etc/shadow | cut -d: -f2)
if [ -n "$FREE_HASH" ] && [ "$FREE_HASH" != "!" ] && [ "$FREE_HASH" != "*" ]; then
ALGO_ID=$(printf '%s' "$FREE_HASH" | cut -d'$' -f2)
SALT=$(printf '%s' "$FREE_HASH" | cut -d'$' -f3)
STILL_DEFAULT=false
# If the salt field starts with "rounds=", we cannot extract the real salt
# with a simple cut treat as still-default for safety
if printf '%s' "$SALT" | grep -q '^rounds='; then
STILL_DEFAULT=true
else
for DEFAULT_PW in "free" "gosovransystems"; do
case "$ALGO_ID" in
6) EXPECTED=$(openssl passwd -6 -salt "$SALT" "$DEFAULT_PW" 2>/dev/null) ;;
5) EXPECTED=$(openssl passwd -5 -salt "$SALT" "$DEFAULT_PW" 2>/dev/null) ;;
*)
# Unknown hash algorithm treat as still-default for safety
STILL_DEFAULT=true
break
;;
esac
if [ -n "$EXPECTED" ] && [ "$EXPECTED" = "$FREE_HASH" ]; then
STILL_DEFAULT=true
break
fi
done
fi
if [ "$STILL_DEFAULT" = "false" ]; then
# Password was changed clear any legacy warning and exit
rm -f /var/lib/sovran/security-status /var/lib/sovran/security-warning
exit 0
fi
fi
fi
# No flags at all + secrets exist = legacy (pre-seal era) machine
if [ -f /var/lib/secrets/root-password ]; then
mkdir -p /var/lib/sovran
echo "legacy" > /var/lib/sovran/security-status

View File

@@ -17,7 +17,7 @@
# ── Always on (no flag) ───────────────────────────────────
./php.nix
./Sovran_SystemsOS_File_Fixes_And_New_Services.nix
./credentials-pdf.nix
./credentials.nix
# ── Services (default ON — disable in custom.nix) ─────────
./synapse.nix

View File

@@ -80,4 +80,45 @@ EOF
'';
};
# ── Zeus Connect (lndconnect URL for mobile wallet) ──────────
systemd.services.zeus-connect-setup = {
description = "Save Zeus lndconnect URL";
wantedBy = [ "multi-user.target" ];
after = [ "lnd.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
path = [ pkgs.coreutils "/run/current-system/sw" ];
script = ''
SECRET_FILE="/var/lib/secrets/zeus-connect-url"
mkdir -p /var/lib/secrets
URL=""
if command -v lndconnect >/dev/null 2>&1; then
URL=$(lndconnect --url 2>/dev/null || true)
elif command -v lnconnect-clnrest >/dev/null 2>&1; then
URL=$(lnconnect-clnrest --url 2>/dev/null || true)
fi
if [ -n "$URL" ]; then
echo "$URL" > "$SECRET_FILE"
chmod 600 "$SECRET_FILE"
echo "Zeus connect URL saved."
else
echo "No lndconnect URL available yet."
fi
'';
};
# ── Refresh Zeus URL periodically (certs/macaroons may rotate)
systemd.timers.zeus-connect-setup = {
wantedBy = [ "timers.target" ];
timerConfig = {
OnBootSec = "2min";
OnUnitActiveSec = "30min";
Unit = "zeus-connect-setup.service";
};
};
}

View File

@@ -1,139 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sovran_SystemsOS — First-Boot Setup</title>
<link rel="stylesheet" href="/static/style.css?v={{ style_css_hash }}" />
</head>
<body class="onboarding-body">
<!-- Onboarding wizard container -->
<div class="onboarding-shell">
<!-- Progress bar -->
<div class="onboarding-progress-bar">
<div class="onboarding-progress-fill" id="onboarding-progress-fill"></div>
</div>
<!-- Step indicators -->
<div class="onboarding-steps-nav" id="onboarding-steps-nav">
<span class="onboarding-step-dot" data-step="1">1</span>
<span class="onboarding-step-connector"></span>
<span class="onboarding-step-dot" data-step="2">2</span>
<span class="onboarding-step-connector"></span>
<span class="onboarding-step-dot" data-step="3">3</span>
<span class="onboarding-step-connector"></span>
<span class="onboarding-step-dot" data-step="4">4</span>
</div>
<!-- Step panels -->
<div class="onboarding-panel-wrap">
<!-- ── Step 1: Welcome ── -->
<div class="onboarding-panel" id="step-1">
<div class="onboarding-hero">
<div class="onboarding-logo">
<img src="/static/logo-light.svg" alt="Sovran Systems" class="onboarding-logo-img" />
</div>
<h1 class="onboarding-title">Welcome to Sovran_SystemsOS!</h1>
<p class="onboarding-subtitle">Be Digitally Sovereign</p>
</div>
<div class="onboarding-card">
<p class="onboarding-body-text">
Your system is installed and ready to configure. This wizard will guide
you through the final setup steps so everything works perfectly.
</p>
<div class="onboarding-role-row" id="onboarding-role-row">
<span class="onboarding-role-label">Your Role:</span>
<span class="onboarding-role-badge" id="onboarding-role-badge">Loading…</span>
</div>
<p class="onboarding-body-text onboarding-body-text--dim">
This setup only takes a few minutes. You can always revisit these
settings from the main Hub dashboard.
</p>
</div>
<div class="onboarding-footer">
<div></div>
<button class="btn btn-primary onboarding-btn-next" id="step-1-next">
Let's Go →
</button>
</div>
</div>
<!-- ── Step 2: Domain Configuration ── -->
<div class="onboarding-panel" id="step-2" style="display:none">
<div class="onboarding-step-header">
<span class="onboarding-step-icon">🌐</span>
<h2 class="onboarding-step-title">Domain Configuration</h2>
<p class="onboarding-step-desc">
Sovran_SystemsOS uses <strong><a href="https://njal.la" target="_blank" style="color: var(--accent-color);">Njal.la</a></strong> for domains and Dynamic DNS.
First, create an account at <strong>Njal.la</strong> and purchase your domain.
Then, in the Njal.la web interface, create a <strong>Dynamic</strong> record pointing to this machine's external IP address (shown below).
Finally, paste the DDNS curl command from your Njal.la dashboard for each service below.
</p>
</div>
<div class="onboarding-card onboarding-card--scroll" id="step-2-body">
<p class="onboarding-loading">Loading service information…</p>
</div>
<div id="step-2-status" class="onboarding-save-status"></div>
<div class="onboarding-footer">
<button class="btn btn-close-modal onboarding-btn-back" data-prev="1">← Back</button>
<button class="btn btn-primary onboarding-btn-next" id="step-2-next">
Save &amp; Continue →
</button>
</div>
</div>
<!-- ── Step 3: Port Forwarding ── -->
<div class="onboarding-panel" id="step-3" style="display:none">
<div class="onboarding-step-header">
<span class="onboarding-step-icon">🔌</span>
<h2 class="onboarding-step-title">Port Forwarding Check</h2>
<p class="onboarding-step-desc">
Forward these ports on your router to this machine. Each port only needs to be opened once — they are shared across all your services.
<strong>Ports 80 and 443 must be open for SSL certificates to work.</strong>
</p>
</div>
<div class="onboarding-card onboarding-card--ports" id="step-3-body">
<p class="onboarding-loading">Checking ports…</p>
</div>
<div class="onboarding-footer">
<button class="btn btn-close-modal onboarding-btn-back" data-prev="2">← Back</button>
<button class="btn btn-primary onboarding-btn-next" id="step-3-next">
Continue →
</button>
</div>
</div>
<!-- ── Step 4: Complete ── -->
<div class="onboarding-panel" id="step-4" style="display:none">
<div class="onboarding-hero">
<div class="onboarding-logo"></div>
<h1 class="onboarding-title">Your Sovran_SystemsOS is Ready!</h1>
<p class="onboarding-subtitle">Setup complete</p>
</div>
<div class="onboarding-card">
<p class="onboarding-body-text">
All configuration steps are done. Head to the main Hub dashboard to
monitor your services, manage credentials, and make changes at any time.
</p>
<ul class="onboarding-checklist" id="onboarding-checklist">
<li>✅ Domain configuration saved</li>
<li>✅ Port forwarding reviewed</li>
</ul>
</div>
<div class="onboarding-footer">
<button class="btn btn-close-modal onboarding-btn-back" data-prev="3">← Back</button>
<button class="btn btn-primary" id="step-4-finish">
Go to Dashboard →
</button>
</div>
</div>
</div><!-- /panel-wrap -->
</div><!-- /shell -->
<script src="/static/onboarding.js?v={{ onboarding_js_hash }}"></script>
</body>
</html>

2
result
View File

@@ -1 +1 @@
/nix/store/pdwygxbd76b12qll8mnvirs6bym97hla-Sovran_SystemsOS.iso
/nix/store/5g99gvpfy2ha4lvglbcx017ryqndgnli-Sovran_SystemsOS.iso