1 Commits
Author SHA1 Message Date
naturallaw777 23f8297333 updated caddy icon 2026-04-13 18:18:06 -05:00
62 changed files with 1946 additions and 5580 deletions
+1 -1046
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -11,11 +11,11 @@
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs19" />
<g
transform="translate(-51.987986,-84.712078)"
id="g19">
<g
transform="matrix(1.16548,0,0,1.10195,-176.58262,-151.90354)"
id="g18">
<g
id="Icon"
transform="matrix(0.99999699,0,0,1.0000031,-3876.6653,-1644.4859)">
transform="matrix(0.858013,0,0,0.907485,-3219.3351,-1431.3666)">
<g
id="g3">
<g
@@ -89,5 +89,5 @@
</g>
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

@@ -239,13 +239,33 @@ mkdir -p "$BACKUP_DIR/secrets"
if [[ "$ROLE" == "desktop" ]]; then
log "Skipping /etc/nix-bitcoin-secrets — not applicable for Desktop Only role."
# /var/lib/domains is still backed up if present (hub state)
for SRC in /var/lib/domains; do
if [[ -e "$SRC" ]]; then
rsync -a --info=progress2 "$SRC" "$BACKUP_DIR/secrets/" 2>&1 | tee -a "$BACKUP_LOG" || \
log "WARNING: Could not copy $SRC — continuing."
else
log " (not found: $SRC — skipping)"
fi
done
else
if [[ -e /etc/nix-bitcoin-secrets ]]; then
rsync -a --info=progress2 /etc/nix-bitcoin-secrets "$BACKUP_DIR/secrets/" 2>&1 | tee -a "$BACKUP_LOG" || \
log "WARNING: Could not copy /etc/nix-bitcoin-secrets — continuing."
else
log " (not found: /etc/nix-bitcoin-secrets — skipping)"
fi
for SRC in /etc/nix-bitcoin-secrets /var/lib/domains; do
if [[ -e "$SRC" ]]; then
rsync -a --info=progress2 "$SRC" "$BACKUP_DIR/secrets/" 2>&1 | tee -a "$BACKUP_LOG" || \
log "WARNING: Could not copy $SRC — continuing."
else
log " (not found: $SRC — skipping)"
fi
done
fi
# Hub state files from /var/lib/secrets/ (backed up for all roles)
if [[ -d /var/lib/secrets ]]; then
mkdir -p "$BACKUP_DIR/secrets/hub-state"
rsync -a --info=progress2 /var/lib/secrets/ "$BACKUP_DIR/secrets/hub-state/" 2>&1 | tee -a "$BACKUP_LOG" || \
log "WARNING: Could not copy /var/lib/secrets — continuing."
else
log " (not found: /var/lib/secrets — skipping)"
fi
log "Stage 2 complete."
@@ -266,35 +286,20 @@ else
log "WARNING: /home not found — skipping."
fi
# ── Stage 4/4: System data ───────────────────────────────────────
# ── Stage 4/4: Wallet and node data ─────────────────────────────
log ""
log "── Stage 4/4: System data (/var/lib) ────────────────────────"
log "── Stage 4/4: Wallet and node data (/var/lib/lnd) ──────────"
if [[ "$ROLE" == "desktop" ]]; then
if [[ -d /var/lib ]]; then
rsync -a --info=progress2 \
--filter='- /lnd/***' \
--exclude='logs/' \
--exclude='log/' \
--exclude='*/logs/' \
--exclude='*/log/' \
/var/lib/ "$BACKUP_DIR/var-lib/" 2>&1 | tee -a "$BACKUP_LOG" || \
fail "Stage 4 failed while copying /var/lib for Desktop Only role"
log "Stage 4 complete (Desktop Only role excludes /var/lib/lnd)."
else
log "WARNING: /var/lib not found — skipping."
fi
elif [[ -d /var/lib ]]; then
log "Skipping Stage 4 (LND wallet data) — not applicable for Desktop Only role."
elif [[ -d /var/lib/lnd ]]; then
rsync -a --info=progress2 \
--exclude='logs/' \
--exclude='log/' \
--exclude='*/logs/' \
--exclude='*/log/' \
/var/lib/ "$BACKUP_DIR/var-lib/" 2>&1 | tee -a "$BACKUP_LOG" || \
fail "Stage 4 failed while copying /var/lib"
/var/lib/lnd/ "$BACKUP_DIR/lnd/" 2>&1 | tee -a "$BACKUP_LOG" || \
fail "Stage 4 failed while copying /var/lib/lnd"
log "Stage 4 complete."
else
log "WARNING: /var/lib not found — skipping."
log "WARNING: /var/lib/lnd not found — skipping."
fi
# ── Generate manifest ────────────────────────────────────────────
File diff suppressed because it is too large Load Diff
@@ -91,30 +91,3 @@
border-color: var(--accent-color);
color: var(--accent-color);
}
/* ── Header reboot button ───────────────────────────────────────── */
.btn-header-reboot {
background: transparent;
border: 1px solid rgba(184, 125, 0, 0.35);
color: #c98d08;
font-size: 0.78rem;
font-weight: 600;
padding: 4px 12px;
border-radius: var(--radius-btn);
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background-color 0.15s;
}
.btn-header-reboot:hover {
border-color: #b87d00;
color: #e0a010;
background-color: rgba(184, 125, 0, 0.1);
}
@media (max-width: 480px) {
.btn-header-reboot {
padding: 4px 8px;
font-size: 0.72rem;
}
}
@@ -102,48 +102,6 @@ button.btn-reboot:hover:not(:disabled) {
background-color: #529E7E;
}
/* Restart = AMBER (manual restart action) */
.btn-restart-amber {
background-color: #b87d00;
color: #fff;
}
.btn-restart-amber:hover:not(:disabled) {
background-color: #9a6800;
}
/* Restart conflict warning box */
.restart-conflict-box {
background-color: rgba(180, 100, 0, 0.12);
border-left: 3px solid #c97a00;
border-radius: 6px;
padding: 12px 14px;
margin-bottom: 14px;
}
.restart-conflict-title {
font-size: 0.88rem;
font-weight: 700;
color: #e69000;
margin: 0 0 6px 0;
}
.restart-conflict-desc {
font-size: 0.83rem;
color: var(--text-secondary);
line-height: 1.5;
margin: 0;
}
/* Reboot error card actions row */
.reboot-error-actions {
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
margin-top: 20px;
}
.btn-save {
background-color: var(--yellow);
color: #0A1A10;
@@ -95,7 +95,6 @@
.status-dot.disabled { background-color: var(--grey); }
.status-dot.needs-attention { background-color: var(--yellow); }
.status-dot.syncing { background-color: #f5a623; animation: pulse-badge 1.5s infinite; }
.status-dot.checking-reachability { background-color: var(--accent-color); animation: pulse-badge 1s infinite; }
/* ── Bitcoin IBD sync progress bar ──────────────────────────────── */
@@ -155,69 +154,6 @@
white-space: nowrap;
}
/* ── BIP-110 status badge (tile + detail modal) ───────────────────── */
.tile-bip110-badge {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 0.64rem;
font-weight: 600;
border-radius: 4px;
padding: 2px 6px;
margin-top: 4px;
white-space: nowrap;
letter-spacing: 0.02em;
}
.tile-bip110-badge--active {
background: rgba(109, 191, 139, 0.18);
color: var(--green);
border: 1px solid rgba(109, 191, 139, 0.3);
}
.tile-bip110-badge--locked_in {
background: rgba(94, 173, 138, 0.15);
color: var(--accent-color);
border: 1px solid rgba(94, 173, 138, 0.3);
}
.tile-bip110-badge--signaling {
background: rgba(94, 173, 138, 0.12);
color: var(--accent-color);
border: 1px solid rgba(94, 173, 138, 0.2);
}
.tile-bip110-badge--not_signaling {
background: rgba(229, 165, 10, 0.12);
color: var(--yellow);
border: 1px solid rgba(229, 165, 10, 0.25);
}
.tile-bip110-badge--unsupported {
background: rgba(94, 122, 106, 0.12);
color: var(--grey);
border: 1px solid rgba(94, 122, 106, 0.2);
}
.tile-bip110-badge--unknown {
background: transparent;
color: var(--text-dim);
border: 1px solid var(--border-color);
}
.bip110-status-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.bip110-source-label {
color: var(--text-dim);
font-size: 0.75rem;
}
/* ── Service detail modal sections ───────────────────────────────── */
.svc-detail-section {
@@ -416,47 +352,6 @@
font-weight: 600;
}
.btn-warning {
background-color: #d97706;
color: #fff;
}
.btn-warning:hover:not(:disabled) {
background-color: #b45309;
}
.svc-detail-restart-section {
border-top: 1px solid var(--border-color);
padding-top: 16px;
}
.svc-detail-restart-btn {
margin-top: 8px;
}
.svc-detail-restart-result {
margin-top: 12px;
padding: 12px 16px;
border-radius: 8px;
font-size: 0.88rem;
line-height: 1.5;
display: none;
}
.svc-detail-restart-result.success {
background-color: rgba(109, 191, 139, 0.12);
border: 1px solid var(--green);
color: var(--green);
display: block;
}
.svc-detail-restart-result.error {
background-color: rgba(239, 68, 68, 0.12);
border: 1px solid #ef4444;
color: #f87171;
display: block;
}
/* ── Desktop launch buttons ──────────────────────────────────────── */
@@ -6,9 +6,6 @@ const POLL_INTERVAL_SERVICES = 5000;
const POLL_INTERVAL_UPDATES = 1800000;
const UPDATE_POLL_INTERVAL = 2000;
const REBOOT_CHECK_INTERVAL = 5000;
const REBOOT_FETCH_TIMEOUT = 12000;
const REBOOT_REQUEST_TIMEOUT = 4000;
const REBOOT_INITIAL_DELAY = 25000;
const SUPPORT_TIMER_INTERVAL = 1000;
const CATEGORY_ORDER = [
+1 -68
View File
@@ -44,28 +44,6 @@ if ($upgradeCloseBtn) $upgradeCloseBtn.addEventListener("click", closeUpgradeMod
if ($upgradeCancelBtn) $upgradeCancelBtn.addEventListener("click", closeUpgradeModal);
if ($upgradeModal) $upgradeModal.addEventListener("click", function(e) { if (e.target === $upgradeModal) closeUpgradeModal(); });
// Restart confirm dialog
if ($restartConfirmCancel) $restartConfirmCancel.addEventListener("click", closeRestartConfirmDialog);
if ($restartConfirmModal) $restartConfirmModal.addEventListener("click", function(e) { if (e.target === $restartConfirmModal) closeRestartConfirmDialog(); });
if ($restartConfirmModal) $restartConfirmModal.addEventListener("keydown", function(e) { if (e.key === "Escape") closeRestartConfirmDialog(); });
// Header Reboot button
if ($headerRebootBtn) $headerRebootBtn.addEventListener("click", function() { openRestartConfirmDialog(); });
if ($restartConfirmOk) $restartConfirmOk.addEventListener("click", function() {
if ($restartConfirmOk.disabled) return;
$restartConfirmOk.disabled = true;
closeRestartConfirmDialog();
doReboot();
});
// Reboot error card buttons
var $rebootErrorCloseBtn = document.getElementById("reboot-error-close-btn");
var $rebootErrorRetryBtn = document.getElementById("reboot-error-retry-btn");
if ($rebootErrorCloseBtn) $rebootErrorCloseBtn.addEventListener("click", function() {
if ($rebootOverlay) $rebootOverlay.classList.remove("visible");
});
if ($rebootErrorRetryBtn) $rebootErrorRetryBtn.addEventListener("click", doReboot);
// ── Upgrade modal functions ───────────────────────────────────────
function openUpgradeModal() {
@@ -76,63 +54,18 @@ function closeUpgradeModal() {
if ($upgradeModal) $upgradeModal.classList.remove("open");
}
// ── Restart confirm dialog functions ─────────────────────────────
var _restartDialogOpener = null;
function openRestartConfirmDialog() {
if (!$restartConfirmModal) return;
_restartDialogOpener = document.activeElement;
// Detect conflicting operations
var isOperationInProgress = !!_updatePollTimer || !!_rebuildPollTimer;
if ($restartConflictBox) $restartConflictBox.style.display = isOperationInProgress ? "" : "none";
if ($restartConfirmOk) $restartConfirmOk.disabled = isOperationInProgress;
$restartConfirmModal.classList.add("open");
// Focus Cancel initially for safety
var cancelBtn = document.getElementById("restart-confirm-cancel-btn");
if (cancelBtn) setTimeout(function() { cancelBtn.focus(); }, 50);
}
function closeRestartConfirmDialog() {
if ($restartConfirmModal) $restartConfirmModal.classList.remove("open");
// Re-enable confirm button for next open
if ($restartConfirmOk) $restartConfirmOk.disabled = false;
// Return focus to the element that opened the dialog
if (_restartDialogOpener && _restartDialogOpener.focus) {
try { _restartDialogOpener.focus(); } catch (_) {}
_restartDialogOpener = null;
}
}
async function doUpgradeToServer() {
var confirmBtn = $upgradeConfirmBtn;
if (confirmBtn) { confirmBtn.disabled = true; confirmBtn.textContent = "Upgrading…"; }
closeUpgradeModal();
// Reuse the rebuild modal to show reboot progress
// Reuse the rebuild modal to show progress
_rebuildFeatureName = "Server + Desktop";
_rebuildIsEnabling = true;
openRebuildModal();
try {
await apiFetch("/api/role/upgrade-to-server", { method: "POST" });
// Server is rebooting — show message and wait for it to come back
if ($rebuildStatus) $rebuildStatus.textContent = "Rebooting — the setup wizard will guide you through domain and port configuration…";
if ($rebuildSpinner) $rebuildSpinner.classList.add("spinning");
// Poll until server comes back, then redirect to onboarding
var pollInterval = setInterval(async function() {
try {
await apiFetch("/api/ping");
clearInterval(pollInterval);
window.location.href = "/onboarding";
} catch (_) {
// Server still down — keep polling
}
}, 3000);
} catch (err) {
if ($rebuildStatus) $rebuildStatus.textContent = "✗ Upgrade failed: " + err.message;
if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning");
+125 -200
View File
@@ -73,131 +73,28 @@ function openDomainSetupModal(feat, onSaved) {
npubField = '<div class="domain-field-group"><label class="domain-field-label" for="domain-npub-input">Nostr Public Key (npub1...):</label><input class="domain-field-input" type="text" id="domain-npub-input" placeholder="npub1..." value="' + escHtml(currentNpub) + '" /></div>';
}
var introHtml;
if (_currentRole === "node") {
introHtml =
'<p>To enable <strong>' + escHtml(feat.name) + '</strong>, it needs its own domain from Njal.la.</p>' +
'<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">' +
'<li>Create an account at <a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">njal.la</a>.</li>' +
'<li>Set up a domain for it — either a free subdomain or a separate domain. Pick one option:</li>' +
'</ol>';
} else {
introHtml =
'<p>To enable <strong>' + escHtml(feat.name) + '</strong>, it needs its own domain from Njal.la. ' +
'In your Njal.la account, set up a domain for it — either a free subdomain or a separate domain. Pick one option:</p>';
}
var externalIp = _cachedExternalIp || "your external IP";
$domainSetupBody.innerHTML =
'<div class="domain-setup-intro">' +
introHtml +
'<details style="margin-top:10px;">' +
'<summary style="cursor:pointer;font-weight:600;">Option A — Free subdomain (recommended)</summary>' +
'<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">' +
'<li>In Njal.la, open a domain you own and click &quot;Add record&quot;.</li>' +
'<li>Set record type to <strong>Dynamic</strong>.</li>' +
'<li>In the <strong>Name</strong> field, type ONLY the host part — the word before your domain.<br>' +
'(Example only, your choice — for &quot;call.yourdomain.com&quot; you&apos;d type just: &nbsp;<code>call</code>)<br>' +
'&#9888; Do NOT type the full domain here — Njal.la adds it automatically.</li>' +
'<li>A Dynamic record has NO IP field — the IP auto-fills after the rebuild/reboot.</li>' +
'<li>Copy the curl command Njal.la gives you, e.g.:<br>' +
'<code style="font-size:0.8em;">curl &quot;https://njal.la/update/?h=call.yourdomain.com&amp;k=abc123&amp;auto&quot;</code></li>' +
'<p><strong>Before continuing:</strong></p>' +
'<ol>' +
'<li>Create an account at <a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">https://njal.la</a></li>' +
'<li>Purchase a new domain on Njal.la, or create a subdomain from a domain you already own. Tip: Subdomains are free to create — you only need to purchase one domain, and you can add as many subdomains as you need at no extra cost.</li>' +
'<li>In the Njal.la web interface, create a <strong>Dynamic</strong> record pointing to this machine\'s external IP address:<br>' +
'<span style="display:inline-block;margin-top:4px;padding:4px 10px;background:var(--card-color);border:1px solid var(--border-color);border-radius:6px;font-family:monospace;font-size:1em;font-weight:700;">' + escHtml(externalIp) + '</span></li>' +
'<li>Njal.la will give you a curl command like:<br>' +
'<code style="font-size:0.8em;">curl &quot;https://njal.la/update/?h=sub.domain.com&amp;k=abc123&amp;auto&quot;</code></li>' +
'<li>Enter the subdomain and paste that curl command below</li>' +
'</ol>' +
'</details>' +
'<details style="margin-top:6px;">' +
'<summary style="cursor:pointer;font-weight:600;">Option B — Separate / new domain</summary>' +
'<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">' +
'<li>In Njal.la, buy the domain you want.</li>' +
'<li>Add a Dynamic record as in Option A. If this domain is dedicated to the service, leave the Name field blank or use <code>@</code>.</li>' +
'<li>Copy the curl command Njal.la gives you.</li>' +
'</ol>' +
'</details>' +
'<p style="margin-top:10px;">Below, enter the full domain for this service — a subdomain (e.g. call.yourdomain.com) or a separate domain (e.g. call.com) — and paste its curl command.</p>' +
'</div>' +
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service domain (e.g. call.yourdomain.com):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="myservice.example.com" /></div>' +
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Subdomain (e.g. myservice.example.com):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="myservice.example.com" /></div>' +
'<div class="domain-field-group"><label class="domain-field-label" for="domain-ddns-input">Njal.la Dynamic DNS Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl &quot;https://njal.la/update/?h=myservice.example.com&amp;k=abc123&amp;auto&quot;" /><p class="domain-field-hint"> Paste the full curl command from your Njal.la dashboard\'s Dynamic record</p></div>' +
npubField +
'<div class="domain-field-actions"><button class="btn btn-close-modal" id="domain-setup-cancel-btn">Cancel</button><button class="btn btn-primary" id="domain-setup-save-btn">Save &amp; Enable</button></div>';
document.getElementById("domain-setup-cancel-btn").addEventListener("click", closeDomainSetupModal);
document.getElementById("domain-setup-save-btn").addEventListener("click", async function() {
var subdomain = (document.getElementById("domain-subdomain-input") || {}).value || "";
var ddnsUrl = (document.getElementById("domain-ddns-input") || {}).value || "";
var npub = document.getElementById("domain-npub-input") ? (document.getElementById("domain-npub-input").value || "") : "";
subdomain = subdomain.trim();
ddnsUrl = ddnsUrl.trim();
npub = npub.trim();
if (!subdomain) { alert("Please enter a domain."); return; }
if (feat.id === "haven" && !npub) { alert("Please enter your Nostr public key."); return; }
var saveBtn = document.getElementById("domain-setup-save-btn");
saveBtn.disabled = true;
saveBtn.textContent = "Saving…";
try {
await apiFetch("/api/domains/set", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
domain_name: feat.domain_name,
domain: subdomain,
ddns_url: ddnsUrl,
}),
});
closeDomainSetupModal();
onSaved(npub);
} catch (err) {
saveBtn.disabled = false;
saveBtn.textContent = "Save & Enable";
alert("Failed to save domain. Please try again.");
}
});
$domainSetupModal.classList.add("open");
}
function openDomainReconfigureModal(feat, existingDomain, onSaved) {
if (!$domainSetupModal) return;
if ($domainSetupTitle) $domainSetupTitle.textContent = "🔄 Reconfigure Domain — " + feat.name;
var npubField = "";
if (feat.id === "haven") {
var currentNpub = "";
if (feat.extra_fields && feat.extra_fields.length > 0) {
for (var i = 0; i < feat.extra_fields.length; i++) {
if (feat.extra_fields[i].id === "nostr_npub") {
currentNpub = feat.extra_fields[i].current_value || "";
break;
}
}
}
npubField = '<div class="domain-field-group"><label class="domain-field-label" for="domain-npub-input">Nostr Public Key (npub1...):</label><input class="domain-field-input" type="text" id="domain-npub-input" placeholder="npub1..." value="' + escHtml(currentNpub) + '" /></div>';
}
var externalIp = _cachedExternalIp || "your external IP";
var currentDomain = existingDomain || "";
$domainSetupBody.innerHTML =
'<div class="domain-setup-intro">' +
'<p>Your domain <strong>' + escHtml(currentDomain || "this domain") + '</strong> is configured but isn\'t resolving correctly.</p>' +
'<p><strong>Troubleshooting steps:</strong></p>' +
'<ol>' +
'<li>Log into your Njal.la dashboard at <a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">https://njal.la</a></li>' +
'<li>Find the DNS record for <strong>' + escHtml(currentDomain || "your domain") + '</strong>. In Njal.la\'s Name field, note that only the host part is stored (the word before the domain) — not the full domain.</li>' +
'<li>Verify it has a <strong>Dynamic</strong> record pointing to your current external IP:<br>' +
'<span style="display:inline-block;margin-top:4px;padding:4px 10px;background:var(--card-color);border:1px solid var(--border-color);border-radius:6px;font-family:monospace;font-size:1em;font-weight:700;">' + escHtml(externalIp) + '</span></li>' +
'<li>If the IP is wrong or the record is missing, update it</li>' +
'<li>If you changed the DDNS curl command, paste the updated one below</li>' +
'</ol>' +
'</div>' +
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service domain (e.g. call.yourdomain.com):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="myservice.example.com" value="' + escHtml(currentDomain) + '" /></div>' +
'<div class="domain-field-group"><label class="domain-field-label" for="domain-ddns-input">Njal.la Dynamic DNS Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl &quot;https://njal.la/update/?h=myservice.example.com&amp;k=abc123&amp;auto&quot;" /><p class="domain-field-hint"> Paste the full curl command from your Njal.la dashboard\'s Dynamic record</p></div>' +
npubField +
'<div class="domain-field-actions"><button class="btn btn-close-modal" id="domain-setup-cancel-btn">Cancel</button><button class="btn btn-primary" id="domain-setup-save-btn">Save &amp; Update</button></div>';
document.getElementById("domain-setup-cancel-btn").addEventListener("click", closeDomainSetupModal);
document.getElementById("domain-setup-save-btn").addEventListener("click", async function() {
var subdomain = (document.getElementById("domain-subdomain-input") || {}).value || "";
var ddnsUrl = (document.getElementById("domain-ddns-input") || {}).value || "";
@@ -227,7 +124,7 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) {
onSaved(npub);
} catch (err) {
saveBtn.disabled = false;
saveBtn.textContent = "Save & Update";
saveBtn.textContent = "Save & Enable";
alert("Failed to save domain. Please try again.");
}
});
@@ -248,59 +145,114 @@ function openPortRequirementsModal(featureName, ports, onContinue) {
? '<button class="btn btn-primary" id="port-req-continue-btn">I Understand — Continue</button>'
: '';
function renderPortRequirements(internalIp) {
var rows = ports.map(function(p) {
return '<tr><td class="port-req-port">' + escHtml(p.port) + '</td>' +
'<td class="port-req-proto">' + escHtml(p.protocol) + '</td>' +
'<td class="port-req-desc">' + escHtml(p.description) + '</td></tr>';
}).join("");
var ipLine = internalIp
? '<p class="port-req-intro">Forward each port below <strong>to this machine\'s internal IP: <code class="port-req-internal-ip">' + escHtml(internalIp) + '</code></strong></p>'
: "<p class=\"port-req-intro\">Forward each port below to this machine's internal LAN IP in your router's port forwarding settings.</p>";
$portReqBody.innerHTML =
'<p class="port-req-intro"><strong>Port Forwarding Required</strong></p>' +
'<p class="port-req-intro">For <strong>' + escHtml(featureName) + "</strong> to work with clients outside your local network, " +
"you must configure <strong>port forwarding</strong> in your router's admin panel.</p>" +
ipLine +
'<table class="port-req-table">' +
'<thead><tr><th>Port(s)</th><th>Protocol</th><th>Purpose</th></tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>' +
"<p class=\"port-req-hint\"><strong>How to verify:</strong> Router-side forwarding cannot be checked from inside your network. " +
"To confirm ports are forwarded correctly, test from a device on a different network (e.g. a phone on mobile data) " +
"or check your router's port forwarding page.</p>" +
'<p class="port-req-hint"> Search "<em>how to set up port forwarding on [your router model]</em>" for step-by-step instructions.</p>' +
'<div class="domain-field-actions">' +
'<button class="btn btn-close-modal" id="port-req-dismiss-btn">Dismiss</button>' +
continueBtn +
'</div>';
document.getElementById("port-req-dismiss-btn").onclick = function() {
closePortRequirementsModal();
};
if (onContinue) {
document.getElementById("port-req-continue-btn").onclick = function() {
closePortRequirementsModal();
onContinue();
};
}
}
// Show loading state while fetching port status
$portReqBody.innerHTML =
'<p class="port-req-intro">Checking port status for <strong>' + escHtml(featureName) + '</strong>…</p>' +
'<p class="port-req-hint">Detecting which ports are open on this machine…</p>';
$portReqModal.classList.add("open");
renderPortRequirements(null);
fetch("/api/network")
// Fetch live port status from local system commands (no external calls)
fetch("/api/ports/status", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ports: ports }),
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (!$portReqModal.classList.contains("open")) return;
var internalIp = (data.internal_ip && data.internal_ip !== "unavailable")
? data.internal_ip : null;
renderPortRequirements(internalIp);
var portStatuses = {};
(data.ports || []).forEach(function(p) {
portStatuses[p.port + "/" + p.protocol] = p.status;
});
var rows = ports.map(function(p) {
var key = p.port + "/" + p.protocol;
var status = portStatuses[key] || "unknown";
var statusHtml;
if (status === "listening") {
statusHtml = '<span class="port-status-listening" title="Service is running and firewall allows this port">🟢 Listening</span>';
} else if (status === "firewall_open") {
statusHtml = '<span class="port-status-open" title="Firewall allows this port but no service is bound yet">🟡 Open (idle)</span>';
} else if (status === "closed") {
statusHtml = '<span class="port-status-closed" title="Firewall blocks this port and/or nothing is listening">🔴 Closed</span>';
} else {
statusHtml = '<span class="port-status-unknown" title="Status could not be determined">⚪ Unknown</span>';
}
return '<tr>' +
'<td class="port-req-port">' + escHtml(p.port) + '</td>' +
'<td class="port-req-proto">' + escHtml(p.protocol) + '</td>' +
'<td class="port-req-desc">' + escHtml(p.description) + '</td>' +
'<td class="port-req-status">' + statusHtml + '</td>' +
'</tr>';
}).join("");
var ipLine = internalIp
? '<p class="port-req-intro">Forward each port below <strong>to this machine\'s internal IP: <code class="port-req-internal-ip">' + escHtml(internalIp) + '</code></strong></p>'
: "<p class=\"port-req-intro\">Forward each port below to this machine's internal LAN IP in your router's port forwarding settings.</p>";
$portReqBody.innerHTML =
'<p class="port-req-intro"><strong>Port Forwarding Required</strong></p>' +
'<p class="port-req-intro">For <strong>' + escHtml(featureName) + "</strong> to work with clients outside your local network, " +
"you must configure <strong>port forwarding</strong> in your router's admin panel.</p>" +
ipLine +
'<table class="port-req-table">' +
'<thead><tr><th>Port(s)</th><th>Protocol</th><th>Purpose</th><th>Status</th></tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>' +
"<p class=\"port-req-hint\"><strong>How to verify:</strong> Router-side forwarding cannot be checked from inside your network. " +
"To confirm ports are forwarded correctly, test from a device on a different network (e.g. a phone on mobile data) " +
"or check your router's port forwarding page.</p>" +
'<p class="port-req-hint"> Search "<em>how to set up port forwarding on [your router model]</em>" for step-by-step instructions.</p>' +
'<div class="domain-field-actions">' +
'<button class="btn btn-close-modal" id="port-req-dismiss-btn">Dismiss</button>' +
continueBtn +
'</div>';
document.getElementById("port-req-dismiss-btn").addEventListener("click", function() {
closePortRequirementsModal();
});
if (onContinue) {
document.getElementById("port-req-continue-btn").addEventListener("click", function() {
closePortRequirementsModal();
onContinue();
});
}
})
.catch(function(err) {
console.warn("Failed to fetch network info for port requirements modal:", err);
.catch(function() {
// Fallback: show static table without status column if fetch fails
var rows = ports.map(function(p) {
return '<tr><td class="port-req-port">' + escHtml(p.port) + '</td>' +
'<td class="port-req-proto">' + escHtml(p.protocol) + '</td>' +
'<td class="port-req-desc">' + escHtml(p.description) + '</td></tr>';
}).join("");
$portReqBody.innerHTML =
'<p class="port-req-intro"><strong>Port Forwarding Required</strong></p>' +
'<p class="port-req-intro">For <strong>' + escHtml(featureName) + '</strong> to work with clients outside your local network, ' +
'you must configure <strong>port forwarding</strong> in your router\'s admin panel and forward each port below to this machine\'s internal LAN IP.</p>' +
'<table class="port-req-table">' +
'<thead><tr><th>Port(s)</th><th>Protocol</th><th>Purpose</th></tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>' +
'<p class="port-req-hint"> Search "<em>how to set up port forwarding on [your router model]</em>" for step-by-step instructions.</p>' +
'<div class="domain-field-actions">' +
'<button class="btn btn-close-modal" id="port-req-dismiss-btn">Dismiss</button>' +
continueBtn +
'</div>';
document.getElementById("port-req-dismiss-btn").addEventListener("click", function() {
closePortRequirementsModal();
});
if (onContinue) {
document.getElementById("port-req-continue-btn").addEventListener("click", function() {
closePortRequirementsModal();
onContinue();
});
}
});
}
@@ -397,52 +349,25 @@ function handleFeatureToggle(feat, newEnabled) {
}
function proceedAfterConflictCheck() {
// Show port requirements notification if the feature has extra port needs
var ports = feat.port_requirements || [];
if (ports.length === 0) {
if (ports.length > 0) {
openPortRequirementsModal(feat.name, ports, proceedAfterPortCheck);
} else {
proceedAfterPortCheck();
return;
}
// Check which ports are actually closed before showing the modal
fetch("/api/ports/status", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ports: ports }),
})
.then(function(r) {
if (!r.ok) throw new Error("Port status request failed: " + r.status);
return r.json();
})
.then(function(data) {
var portStatuses = {};
(data.ports || []).forEach(function(p) {
portStatuses[p.port + "/" + p.protocol] = p.status;
});
var closedPorts = ports.filter(function(p) {
var key = p.port + "/" + p.protocol;
var status = portStatuses[key] || "unknown";
return status !== "listening" && status !== "firewall_open";
});
if (closedPorts.length === 0) {
proceedAfterPortCheck();
} else {
openPortRequirementsModal(feat.name, closedPorts, proceedAfterPortCheck);
}
})
.catch(function(err) {
console.warn("Failed to fetch port status for feature enable flow:", err);
// Safe fallback if status check fails
openPortRequirementsModal(feat.name, ports, proceedAfterPortCheck);
});
}
if (feat.id === "bitcoin-core") {
var confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Core will replace Bitcoin Knots + BIP110 as the active node. Your timechain data will be preserved — you will not need to re-download the timechain. Continue?";
if (conflictNames.length > 0) {
var confirmMsg;
if (feat.id === "bip110") {
confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Knots + BIP110 will disable Bitcoin Core (if active). Your timechain data will be preserved — you will not need to re-download the timechain. Continue?";
} else if (feat.id === "bitcoin-core") {
confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Core will disable Bitcoin Knots + BIP110 (if active). Your timechain data will be preserved — you will not need to re-download the timechain. Continue?";
} else {
confirmMsg = "This will disable " + conflictNames.join(", ") + ". Continue?";
}
openFeatureConfirm(confirmMsg, proceedAfterConflictCheck);
} else if (conflictNames.length > 0) {
openFeatureConfirm("This will disable " + conflictNames.join(", ") + ". Continue?", proceedAfterConflictCheck);
} else {
proceedAfterConflictCheck();
}
@@ -14,7 +14,6 @@ function statusClass(health) {
if (health === "disabled") return "disabled";
if (health === "syncing") return "syncing";
if (STATUS_LOADING_STATES.has(health)) return "loading";
if (health === "checking_reachability") return "checking-reachability";
return "unknown";
}
@@ -28,7 +27,6 @@ function statusText(health, enabled) {
if (health === "syncing") return "Syncing\u2026";
if (!health || health === "unknown") return "Unknown";
if (STATUS_LOADING_STATES.has(health)) return health;
if (health === "checking_reachability") return "Checking\u2026";
return health;
}
@@ -60,17 +58,3 @@ async function apiFetch(path, options) {
}
return res.json();
}
// ── BIP-110 badge state config ────────────────────────────────────
// Shared lookup used by tiles.js and service-detail.js.
// Keys match the "state" values returned by /api/bitcoin/bip110.
var BIP110_BADGE_CONFIG = {
active: { cls: 'tile-bip110-badge--active', label: 'Active', title: 'BIP-110 is active on this node' },
locked_in: { cls: 'tile-bip110-badge--locked_in', label: 'Locked In', title: 'BIP-110 is locked in and will activate shortly' },
signaling: { cls: 'tile-bip110-badge--signaling', label: 'Signaling', title: 'Node is signaling readiness for BIP-110' },
not_signaling: { cls: 'tile-bip110-badge--not_signaling',label: 'Not Signaling', title: 'Node supports BIP-110 but is not signaling this period' },
unsupported: { cls: 'tile-bip110-badge--unsupported', label: 'Not Supported', title: 'This node build does not include BIP-110' },
unknown: { cls: 'tile-bip110-badge--unknown', label: '\u2014', title: 'Status unavailable (node syncing or RPC not ready)' }
};
+3 -10
View File
@@ -51,26 +51,19 @@ async function pollRebuildStatus() {
if (data.running) return;
_rebuildFinished = true;
stopRebuildPoll();
if (data.result === "reboot_required") {
onRebuildDone("reboot_required");
} else {
onRebuildDone(data.result === "success");
}
onRebuildDone(data.result === "success");
} catch (err) {
if (!_rebuildServerDown) { _rebuildServerDown = true; if ($rebuildStatus) $rebuildStatus.textContent = "Applying changes…"; }
}
}
function onRebuildDone(result) {
function onRebuildDone(success) {
if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning");
if ($rebuildClose) $rebuildClose.disabled = false;
if (result === true) {
if (success) {
if ($rebuildStatus) $rebuildStatus.textContent = "✓ Done";
// Auto-reload the page after a short delay so tiles and toggles reflect the new state
setTimeout(function() { window.location.reload(); }, 1200);
} else if (result === "reboot_required") {
if ($rebuildStatus) $rebuildStatus.textContent = "✓ Done — restart required";
if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex";
} else {
if ($rebuildStatus) $rebuildStatus.textContent = "✗ Something went wrong";
if ($rebuildSave) $rebuildSave.style.display = "inline-flex";
+10 -9
View File
@@ -145,25 +145,26 @@ function openSecurityModal() {
if (rebootBtn) {
// Keep button disabled for 5 seconds to prevent accidental clicks
var countdown = 5;
rebootBtn.textContent = "I have written down my new password \u2014 Restart Entire System (" + countdown + ")";
rebootBtn.textContent = "I have written down my new password \u2014 Reboot now (" + countdown + ")";
var timer = setInterval(function() {
countdown--;
if (countdown <= 0) {
clearInterval(timer);
rebootBtn.disabled = false;
rebootBtn.textContent = "I have written down my new password \u2014 Restart Entire System";
rebootBtn.textContent = "I have written down my new password \u2014 Reboot now";
} else {
rebootBtn.textContent = "I have written down my new password \u2014 Restart Entire System (" + countdown + ")";
rebootBtn.textContent = "I have written down my new password \u2014 Reboot now (" + countdown + ")";
}
}, 1000);
rebootBtn.addEventListener("click", function() {
rebootBtn.addEventListener("click", async function() {
rebootBtn.disabled = true;
rebootBtn.textContent = "Restarting\u2026";
// Hide the security reset overlay so the shared reboot overlay is visible
var $secResetOverlay2 = document.getElementById("security-reset-overlay");
if ($secResetOverlay2) $secResetOverlay2.classList.remove("visible");
doReboot();
rebootBtn.textContent = "Rebooting\u2026";
try {
await apiFetch("/api/reboot", { method: "POST" });
} catch (_) {}
if ($rebootOverlay) $rebootOverlay.classList.add("visible");
setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
}, { once: true });
}
} catch (err) {
@@ -7,16 +7,11 @@ function _renderCredsHtml(credentials, unit) {
for (var i = 0; i < credentials.length; i++) {
var cred = credentials[i];
var id = "cred-" + Math.random().toString(36).substring(2, 8);
var displayValue = linkify(cred.value);
var qrBlock = "";
if (cred.qrcode) {
qrBlock = '<div class="creds-qr-wrap"><img class="creds-qr-img" src="' + cred.qrcode + '" alt="QR Code for ' + escHtml(cred.label) + '"><div class="creds-qr-hint">Scan with Zeus app on your phone</div></div>';
}
// If qronly, render the label + QR block only — skip value and copy button
if (cred.qronly) {
html += '<div class="creds-row"><div class="creds-label">' + escHtml(cred.label) + '</div>' + qrBlock + '</div>';
continue;
}
var displayValue = linkify(cred.value);
html += '<div class="creds-row"><div class="creds-label">' + escHtml(cred.label) + '</div>' + qrBlock + '<div class="creds-value-wrap"><div class="creds-value" id="' + id + '">' + displayValue + '</div><button class="creds-copy-btn" data-target="' + id + '">Copy</button></div></div>';
}
return html;
@@ -107,137 +102,156 @@ async function openServiceDetailModal(unit, name, icon) {
'</div>' +
'</div>';
// Section B2: BIP-110 live status (bip110 tile only)
if (icon === 'bip110' && data.bip110) {
var bip110 = data.bip110;
var bip110State = bip110.state || 'unknown';
var bip110Cfg = BIP110_BADGE_CONFIG[bip110State] || BIP110_BADGE_CONFIG.unknown;
var bip110Source = bip110.source ? ' <span class="bip110-source-label">(source: ' + escHtml(bip110.source) + ')</span>' : '';
html += '<div class="svc-detail-section">' +
'<div class="svc-detail-section-title">BIP-110 Deployment Status</div>' +
'<div class="bip110-status-row">' +
'<span class="tile-bip110-badge ' + bip110Cfg.cls + '" title="' + escHtml(bip110Cfg.title) + '">' + escHtml(bip110Cfg.label) + '</span>' +
bip110Source +
'</div>' +
'</div>';
}
// Section C: Domain diagnostics (domain services)
if (data.needs_domain) {
var steps = data.domain_check_steps || [];
var stepsHtml = "";
steps.forEach(function(step) {
var iconLabel = "—";
if (step.status === "ok") iconLabel = "✅";
else if (step.status === "error") iconLabel = "❌";
else if (step.status === "warning") iconLabel = "⚠️";
else if (step.status === "skipped") iconLabel = "⏭️";
var detail = escHtml(step.detail || "").replace(/\n/g, "<br>");
stepsHtml += '<div class="svc-detail-troubleshoot" style="margin-bottom:10px">' +
'<strong>' + iconLabel + ' Step ' + escHtml(String(step.step)) + ': ' + escHtml(step.label || "") + '</strong>' +
(detail ? '<div style="margin-top:6px">' + detail + '</div>' : '') +
'</div>';
});
var domainActionHtml = "";
var ds = data.domain_status || {};
if (!data.domain && data.domain_name) {
domainActionHtml = '<button class="btn btn-primary svc-detail-domain-btn" id="svc-detail-config-domain-btn">🌐 Configure Domain</button>';
} else if (data.domain && (ds.status === "dns_mismatch" || ds.status === "unresolvable")) {
domainActionHtml = '<button class="btn btn-primary svc-detail-domain-btn" id="svc-detail-reconfig-domain-btn">🔄 Reconfigure Domain</button>';
}
html += '<div class="svc-detail-section">' +
'<div class="svc-detail-section-title">Domain Diagnostic Checklist</div>' +
stepsHtml +
domainActionHtml +
'</div>';
if (unit === "livekit.service" && data.extra_ports && data.extra_ports.length > 0) {
var trimmedInternalIp = data.internal_ip ? String(data.internal_ip).trim() : "";
var internalIp = trimmedInternalIp || "";
var internalIpHtml = internalIp ? escHtml(internalIp) : "Could not detect";
var routerIpHelp = internalIp
? "Use this IP address as the destination/internal IP when creating each router forwarding rule."
: "Use this computers internal IP as the destination/internal IP when creating each router forwarding rule.";
var routerNextStep = internalIp
? 'Next step: Log in to your router and create forwarding rules for the ports above. Set the destination/internal IP to <strong>' + internalIpHtml + '</strong>.'
: 'Next step: Log in to your router and create forwarding rules for the ports above. Use this computers internal IP as the destination/internal IP.';
var domainConfigured = !!(data.domain && String(data.domain).trim());
var extraRows = "";
data.extra_ports.forEach(function(p) {
var statusIcon, statusClass2;
if (!effectiveEnabled) {
statusIcon = "⚠ Configure Element Call first";
statusClass2 = "port-status-open";
} else if (!domainConfigured) {
statusIcon = "⚠ Configure domain first";
statusClass2 = "port-status-open";
} else if (p.status === "listening") {
statusIcon = "✅ Ready";
statusClass2 = "port-status-listening";
} else if (p.status === "firewall_open") {
statusIcon = "✅ Ready";
statusClass2 = "port-status-open";
} else if (p.status === "closed") {
statusIcon = "❌ Not ready yet";
statusClass2 = "port-status-closed";
} else {
statusIcon = "— Could not check";
statusClass2 = "port-status-unknown";
}
extraRows += '<tr>' +
'<td class="svc-detail-port-table-port">' + escHtml(p.port) + '</td>' +
'<td class="svc-detail-port-table-proto">' + escHtml(p.protocol) + '</td>' +
'<td class="svc-detail-port-table-desc">' + escHtml(p.description || "") + '</td>' +
'<td class="svc-detail-port-table-status ' + statusClass2 + '">' + statusIcon + '</td>' +
'</tr>';
});
html += '<div class="svc-detail-section">' +
'<div class="svc-detail-section-title">Ports to Forward in Your Router</div>' +
'<div class="svc-detail-port-note">Forward these ports in your router to this Sovran_SystemsOS computer.</div>' +
'<div class="svc-detail-port-note"><strong>Router Forward-To IP:</strong> ' + internalIpHtml + '</div>' +
'<div class="svc-detail-port-note">' + routerIpHelp + '</div>' +
'<table class="svc-detail-port-table">' +
'<thead><tr><th>Port</th><th>Protocol</th><th>Used For</th><th>Sovran_SystemsOS Status</th></tr></thead>' +
'<tbody>' + extraRows + '</tbody>' +
'</table>' +
'<div class="svc-detail-port-note">The Hub can check whether Sovran_SystemsOS is ready on this computer, but full public port verification requires an outside internet check.</div>' +
'<div class="svc-detail-port-note">' + routerNextStep + '</div>' +
'</div>';
}
} else if (data.port_statuses && data.port_statuses.length > 0) {
// Non-domain services (SSH) keep local single-port checks.
// Section C: Ports (only if service has port_requirements)
if (data.port_statuses && data.port_statuses.length > 0) {
var anyPortClosed = data.port_statuses.some(function(p) { return p.status === "closed"; });
var portTableRows = "";
data.port_statuses.forEach(function(p) {
var statusIcon, statusClass2;
if (p.status === "listening") {
statusIcon = "✅ Ready";
statusIcon = "✅ Open";
statusClass2 = "port-status-listening";
} else if (p.status === "firewall_open") {
statusIcon = "✅ Ready";
statusIcon = "🟡 Firewall open";
statusClass2 = "port-status-open";
} else if (p.status === "closed") {
statusIcon = "❌ Not ready";
statusIcon = "🔴 Closed";
statusClass2 = "port-status-closed";
} else {
statusIcon = "— Could not check";
statusIcon = "— Unknown";
statusClass2 = "port-status-unknown";
}
var desc = p.description;
var portNum = parseInt(p.port, 10);
if (portNum === 80 || portNum === 443) {
desc += " (shared — all services)";
}
portTableRows += '<tr>' +
'<td class="svc-detail-port-table-port">' + escHtml(p.port) + '</td>' +
'<td class="svc-detail-port-table-proto">' + escHtml(p.protocol) + '</td>' +
'<td class="svc-detail-port-table-desc">' + escHtml(p.description || "") + '</td>' +
'<td class="svc-detail-port-table-desc">' + escHtml(desc) + '</td>' +
'<td class="svc-detail-port-table-status ' + statusClass2 + '">' + statusIcon + '</td>' +
'</tr>';
});
var troubleshootHtml = "";
if (anyPortClosed) {
var sharedPorts = [];
var specificPorts = [];
data.port_statuses.forEach(function(p) {
if (p.status === "closed") {
var portNum = parseInt(p.port, 10);
if (portNum === 80 || portNum === 443) {
sharedPorts.push(p);
} else {
specificPorts.push(p);
}
}
});
var troubleParts = [];
if (sharedPorts.length > 0) {
troubleParts.push(
'<strong>⚠️ Ports 80 and 443 need to be forwarded on your router.</strong>' +
'<p style="margin-top:8px">These are <strong>shared system ports</strong> — you only need to set them up once and they cover all your domain-based services ' +
'(BTCPayServer, Nextcloud, Matrix, WordPress, etc.).</p>' +
'<p style="margin-top:8px">If you already forwarded these ports during onboarding, you don\'t need to do it again. Otherwise:</p>' +
'<ol>' +
'<li>Log into your router\'s admin panel (usually <code>http://192.168.1.1</code>)</li>' +
'<li>Find the <strong>Port Forwarding</strong> section</li>' +
'<li>Forward port <strong>80 (TCP)</strong> and port <strong>443 (TCP)</strong> to your machine\'s internal IP: <code>' + escHtml(data.internal_ip || "—") + '</code></li>' +
'<li>Save your router settings</li>' +
'</ol>' +
'<p style="margin-top:8px">💡 Once these two ports are forwarded, you won\'t see this warning on any service again.</p>'
);
}
if (specificPorts.length > 0) {
var portList = specificPorts.map(function(p) {
return '<strong>' + escHtml(p.port) + ' (' + escHtml(p.protocol) + ')</strong> — ' + escHtml(p.description);
}).join('<br>');
troubleParts.push(
'<strong>⚠️ This service requires additional ports to be forwarded:</strong>' +
'<p style="margin-top:8px">' + portList + '</p>' +
'<ol>' +
'<li>Log into your router\'s admin panel</li>' +
'<li>Forward each port listed above to your machine\'s internal IP: <code>' + escHtml(data.internal_ip || "—") + '</code></li>' +
'<li>Save your router settings</li>' +
'</ol>'
);
}
troubleshootHtml = '<div class="svc-detail-troubleshoot">' + troubleParts.join('<hr style="border:none;border-top:1px solid rgba(255,255,255,0.1);margin:16px 0">') + '</div>';
}
html += '<div class="svc-detail-section">' +
'<div class="svc-detail-section-title">Port Requirements</div>' +
'<div class="svc-detail-port-note">This shows whether Sovran_SystemsOS is ready to use this port on this computer. If you need access from outside your home network, forward this port in your router.</div>' +
'<div class="svc-detail-section-title">Port Status</div>' +
'<table class="svc-detail-port-table">' +
'<thead><tr><th>Port</th><th>Protocol</th><th>Used For</th><th>Sovran_SystemsOS Status</th></tr></thead>' +
'<thead><tr>' +
'<th>Port</th><th>Protocol</th><th>Description</th><th>Status</th>' +
'</tr></thead>' +
'<tbody>' + portTableRows + '</tbody>' +
'</table>' +
troubleshootHtml +
'</div>';
}
// Section D: Domain (only if service needs_domain)
if (data.needs_domain) {
var domainStatusHtml = "";
var ds = data.domain_status || {};
var domainBadge = "";
if (data.domain) {
if (ds.status === "connected") {
domainBadge = '<span class="svc-detail-domain-value"><span class="tile-domain-label--ok">✓ ' + escHtml(data.domain) + '</span></span>';
} else if (ds.status === "dns_mismatch") {
domainBadge = '<span class="svc-detail-domain-value"><span class="tile-domain-label--warn">⚠ ' + escHtml(data.domain) + ' (IP mismatch)</span></span>';
domainStatusHtml = '<div class="svc-detail-troubleshoot">' +
'<strong>⚠️ Your domain resolves to ' + escHtml(ds.resolved_ip || "unknown") + ' but your external IP is ' + escHtml(ds.expected_ip || "unknown") + '.</strong>' +
'<p style="margin-top:8px">This usually means the DNS record needs to be updated:</p>' +
'<ol>' +
'<li>Go to <a href="https://njal.la" target="_blank">njal.la</a> and log into your account</li>' +
'<li>Find your domain and check the Dynamic DNS record</li>' +
'<li>Make sure it points to your current external IP: <code>' + escHtml(ds.expected_ip || "—") + '</code></li>' +
'<li>If you set up a DDNS curl command during onboarding, verify it\'s running correctly</li>' +
'</ol>' +
'</div>';
} else if (ds.status === "unresolvable") {
domainBadge = '<span class="svc-detail-domain-value"><span class="tile-domain-label--error">✗ ' + escHtml(data.domain) + ' (DNS error)</span></span>';
domainStatusHtml = '<div class="svc-detail-troubleshoot">' +
'<strong>⚠️ This domain cannot be resolved. DNS is not configured yet.</strong>' +
'<p style="margin-top:8px">Let\'s get it set up:</p>' +
'<ol>' +
'<li>Go to <a href="https://njal.la" target="_blank">njal.la</a> and log into your account</li>' +
'<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>' +
'</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>';
}
} else {
domainBadge = '<span class="svc-detail-domain-value"><span class="tile-domain-label--warn">Not configured</span></span>';
domainStatusHtml = '<div class="svc-detail-troubleshoot">' +
'<strong>⚠️ No domain has been configured for this service yet.</strong>' +
'<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>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>';
}
html += '<div class="svc-detail-section">' +
'<div class="svc-detail-section-title">Domain</div>' +
domainBadge +
domainStatusHtml +
'</div>';
}
@@ -279,7 +293,7 @@ async function openServiceDetailModal(unit, name, icon) {
var addonBtnCls = feat.enabled ? "btn btn-close-modal" : "btn btn-primary";
// Section title: use a more specific label for mutually-exclusive Bitcoin node features
var addonSectionTitle = (feat.id === "bitcoin-core")
var addonSectionTitle = (feat.id === "bip110" || feat.id === "bitcoin-core")
? "\u20BF Bitcoin Node Selection"
: "\uD83D\uDD27 Addon Feature";
@@ -312,15 +326,6 @@ async function openServiceDetailModal(unit, name, icon) {
'</div>';
}
if ((effectiveEnabled || data.enabled) && unit !== "phpfpm-nextcloud.service" && unit !== "phpfpm-wordpress.service") {
html += '<div class="svc-detail-section svc-detail-restart-section">' +
'<div class="svc-detail-section-title">Troubleshooting</div>' +
'<p class="svc-detail-desc">If you\'re experiencing issues with this service, try restarting it.</p>' +
'<button class="btn btn-warning svc-detail-restart-btn" id="svc-detail-restart-btn">🔄 Restart Service</button>' +
'<div class="svc-detail-restart-result" id="svc-detail-restart-result"></div>' +
'</div>';
}
$credsBody.innerHTML = html;
_attachCopyHandlers($credsBody);
@@ -347,38 +352,11 @@ async function openServiceDetailModal(unit, name, icon) {
}
}
var restartBtn = document.getElementById("svc-detail-restart-btn");
var restartResult = document.getElementById("svc-detail-restart-result");
if (restartBtn && restartResult) {
var RESTART_REFRESH_DELAY_MS = 3000;
restartBtn.addEventListener("click", async function() {
restartBtn.disabled = true;
restartBtn.textContent = "Restarting…";
restartResult.className = "svc-detail-restart-result";
restartResult.textContent = "";
try {
await apiFetch("/api/service/" + encodeURIComponent(unit) + "/restart", { method: "POST" });
restartResult.classList.add("success");
restartResult.textContent = "✅ Service restarted successfully.";
restartBtn.disabled = false;
restartBtn.textContent = "🔄 Restart Service";
setTimeout(function() {
openServiceDetailModal(unit, name, icon);
}, RESTART_REFRESH_DELAY_MS);
} catch (e) {
restartResult.classList.add("error");
restartResult.textContent = e && e.message ? e.message : "Failed to restart service. Please check service logs and try again.";
restartBtn.disabled = false;
restartBtn.textContent = "🔄 Restart Service";
}
});
}
// Configure / Reconfigure Domain buttons (for non-feature services that need a domain)
// 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");
if ((configDomainBtn || reconfigDomainBtn) && data.needs_domain && data.domain_name) {
var domainBtn = configDomainBtn || reconfigDomainBtn;
if (domainBtn && data.needs_domain && data.domain_name) {
var pseudoFeat = {
id: data.domain_name,
name: name,
@@ -386,18 +364,12 @@ async function openServiceDetailModal(unit, name, icon) {
needs_ddns: true,
extra_fields: []
};
if (configDomainBtn) configDomainBtn.addEventListener("click", function() {
domainBtn.addEventListener("click", function() {
closeCredsModal();
openDomainSetupModal(pseudoFeat, function() {
openServiceDetailModal(unit, name, icon);
});
});
if (reconfigDomainBtn) reconfigDomainBtn.addEventListener("click", function() {
closeCredsModal();
openDomainReconfigureModal(pseudoFeat, data.domain || "", function() {
openServiceDetailModal(unit, name, icon);
});
});
}
} catch (err) {
if ($credsBody) $credsBody.innerHTML = '<p class="creds-empty">Could not load service details.</p>';
@@ -576,7 +548,7 @@ function openSystemChangePasswordModal(unit, name, icon) {
'<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">⚠ This will change both your desktop login and Hub login password. After changing, your updated password will appear in the System Passwords credentials tile.</div>' +
'<div class="pw-credentials-note">⚠ This will change both your desktop login and Hub login password. After changing, your updated password will appear in the System Passwords credentials tile. Make sure to remember it — you will need it to sign back into the Hub.</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>' +
@@ -49,9 +49,6 @@ const $btnSave = document.getElementById("btn-save-report");
const $btnCloseModal = document.getElementById("btn-close-modal");
const $rebootOverlay = document.getElementById("reboot-overlay");
const $rebootMainCard = document.getElementById("reboot-main-card");
const $rebootErrorCard = document.getElementById("reboot-error-card");
const $rebootSubmessage = document.getElementById("reboot-submessage");
const $credsModal = document.getElementById("creds-modal");
const $credsTitle = document.getElementById("creds-modal-title");
@@ -104,14 +101,5 @@ const $upgradeConfirmBtn = document.getElementById("upgrade-confirm-btn");
const $upgradeCancelBtn = document.getElementById("upgrade-cancel-btn");
const $upgradeCloseBtn = document.getElementById("upgrade-close-btn");
// Restart confirm dialog
const $restartConfirmModal = document.getElementById("restart-confirm-modal");
const $restartConfirmOk = document.getElementById("restart-confirm-ok-btn");
const $restartConfirmCancel = document.getElementById("restart-confirm-cancel-btn");
const $restartConflictBox = document.getElementById("restart-conflict-box");
// Header reboot button
const $headerRebootBtn = document.getElementById("btn-header-reboot");
// System status banner
// (removed — health is now shown per-tile via the composite health field)
@@ -500,8 +500,9 @@ function renderBackupReady(drives) {
'<div class="support-steps-title">What gets backed up</div>',
'<ol class="support-backup-steps">',
'<li>NixOS configuration (<code>/etc/nixos</code>)</li>',
'<li>Bitcoin &amp; Lightning wallet data (<code>/var/lib/lnd</code>)</li>',
'<li>nix-bitcoin secrets (<code>/etc/nix-bitcoin-secrets</code>)</li>',
'<li>System service data (<code>/var/lib</code>) including Vaultwarden, bitcoind, LND, sovran-hub, domains, and secrets</li>',
'<li>Domain configurations (<code>/var/lib/domains</code>)</li>',
'<li>Home directory (<code>/home</code>)</li>',
'</ol>',
'</div>',
+1 -34
View File
@@ -4,21 +4,6 @@
// Keyed by tileId: { progress: float, timestamp: ms }
var _btcSyncPrev = {};
// ── BIP-110 badge helper ──────────────────────────────────────────
function _renderBip110Badge(bip110) {
if (!bip110) return '';
var state = bip110.state || 'unknown';
var cfg = BIP110_BADGE_CONFIG[state] || BIP110_BADGE_CONFIG.unknown;
return '<div class="tile-bip110-badge ' + cfg.cls + '" title="' + escHtml(cfg.title) + '">' + escHtml(cfg.label) + '</div>';
}
function _firstElementFromHtml(html) {
var tmp = document.createElement("div");
tmp.innerHTML = html;
return tmp.firstElementChild || null;
}
// ── Render: initial build ─────────────────────────────────────────
function buildTiles(services, categoryLabels) {
@@ -180,8 +165,7 @@ function buildTile(svc) {
var ver = svc.version || svc.bitcoin_version || '';
var versionLabel = ver ? '<div class="tile-version">' + escHtml(ver) + '</div>' : '';
var bip110Badge = (svc.icon === 'bip110') ? _renderBip110Badge(svc.bip110) : '';
tile.innerHTML = '<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'"><div class="tile-icon-fallback" style="display:none">?</div><div class="tile-name">' + escHtml(svc.name) + '</div>' + versionLabel + bip110Badge + '<div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></div>';
tile.innerHTML = '<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'"><div class="tile-icon-fallback" style="display:none">?</div><div class="tile-name">' + escHtml(svc.name) + '</div>' + versionLabel + '<div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></div>';
tile.style.cursor = "pointer";
tile.addEventListener("click", function() {
@@ -281,23 +265,6 @@ function updateTiles(services) {
}
}
}
// Update BIP-110 badge for bip110 tiles
if (svc.icon === 'bip110') {
var badgeHtml = _renderBip110Badge(svc.bip110);
var badgeEl = tile.querySelector(".tile-bip110-badge");
if (badgeEl) {
// Replace existing badge in-place
var newBadge = _firstElementFromHtml(badgeHtml);
if (newBadge) { badgeEl.replaceWith(newBadge); } else { badgeEl.remove(); }
} else if (badgeHtml) {
// Insert badge after version label (or after tile-name if no version)
var anchorEl = tile.querySelector(".tile-version") || tile.querySelector(".tile-name");
if (anchorEl) {
var newBadgeEl = _firstElementFromHtml(badgeHtml);
if (newBadgeEl) anchorEl.insertAdjacentElement("afterend", newBadgeEl);
}
}
}
}
}
}
+20 -66
View File
@@ -111,20 +111,14 @@ async function pollUpdateStatus() {
if (data.log) appendLog(data.log);
_updateLogOffset = data.offset;
}
if (data.result === "reboot_required") {
appendLog("[Server restarted — update completed, reboot required.]\n");
} else if (data.result === "success") {
if (data.result === "success") {
appendLog("[Server restarted — update completed successfully.]\n");
} else {
appendLog("[Server restarted — update encountered an error.]\n");
}
_updateFinished = true;
stopUpdatePoll();
if (data.result === "reboot_required") {
onUpdateDone("reboot_required");
} else {
onUpdateDone(data.result === "success");
}
onUpdateDone(data.result === "success");
return;
}
appendLog("[Server reconnected]\n");
@@ -135,27 +129,19 @@ async function pollUpdateStatus() {
if (data.running) return;
_updateFinished = true;
stopUpdatePoll();
if (data.result === "reboot_required") {
onUpdateDone("reboot_required");
} else if (data.result === "success") {
onUpdateDone(true);
} else {
onUpdateDone(false);
}
if (data.result === "success") onUpdateDone(true);
else onUpdateDone(false);
} catch (err) {
if (!_serverWasDown) { _serverWasDown = true; appendLog("\n[Server restarting — waiting for it to come back…]\n"); if ($modalStatus) $modalStatus.textContent = "Server restarting…"; }
}
}
function onUpdateDone(result) {
function onUpdateDone(success) {
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
if ($btnCloseModal) $btnCloseModal.disabled = false;
if (result === true) {
if (success) {
if ($modalStatus) $modalStatus.textContent = "✓ Update complete";
if ($btnReboot) $btnReboot.style.display = "inline-flex";
} else if (result === "reboot_required") {
if ($modalStatus) $modalStatus.textContent = "✓ Update complete — restart required";
if ($btnReboot) $btnReboot.style.display = "inline-flex";
} else {
if ($modalStatus) $modalStatus.textContent = "✗ Update failed";
if ($btnSave) $btnSave.style.display = "inline-flex";
@@ -179,76 +165,44 @@ function saveErrorReport() {
var _rebootStartTime = 0;
var _serverWentDown = false;
var _rebootFailed = false;
function _setRebootStatus(msg) {
if ($rebootSubmessage) $rebootSubmessage.textContent = msg;
}
function doReboot() {
if ($modal) $modal.classList.remove("open");
if ($rebuildModal) $rebuildModal.classList.remove("open");
stopUpdatePoll();
stopRebuildPoll();
// Reset overlay to main card
if ($rebootMainCard) $rebootMainCard.style.display = "";
if ($rebootErrorCard) $rebootErrorCard.style.display = "none";
_setRebootStatus("Sending restart request\u2026");
if ($rebootOverlay) $rebootOverlay.classList.add("visible");
_rebootStartTime = Date.now();
_serverWentDown = false;
_rebootFailed = false;
var rebootCtrl = new AbortController();
setTimeout(function() { rebootCtrl.abort(); }, REBOOT_REQUEST_TIMEOUT);
fetch("/api/reboot", { method: "POST", signal: rebootCtrl.signal })
.then(function(res) {
if (!res.ok) {
// Definitive HTTP error — server rejected the request before going down
_rebootFailed = true;
if ($rebootMainCard) $rebootMainCard.style.display = "none";
if ($rebootErrorCard) $rebootErrorCard.style.display = "";
// Leave overlay visible so the error card is shown
}
// HTTP 2xx: request accepted, proceed with polling
})
.catch(function() {
// Connection dropped or request aborted — the server is likely already going
// down as part of the restart. Treat as success and continue polling.
});
// Wait before the first check — NixOS shutdown after an update can take 20-40s
setTimeout(waitForServerReboot, REBOOT_INITIAL_DELAY);
fetch("/api/reboot", { method: "POST" }).catch(function() {});
// Wait 15 seconds before the first check — give the system time to actually shut down
setTimeout(waitForServerReboot, 15000);
}
function waitForServerReboot() {
if (_rebootFailed) return;
// Update status on first check (server hasn't gone down yet)
if (!_serverWentDown) _setRebootStatus("Waiting for the computer to shut down\u2026");
var controller = new AbortController();
var timeoutId = setTimeout(function() { controller.abort(); }, REBOOT_FETCH_TIMEOUT);
var timeoutId = setTimeout(function() { controller.abort(); }, REBOOT_CHECK_INTERVAL);
fetch("/api/ping", { cache: "no-store", signal: controller.signal, headers: { "Connection": "close" } })
fetch("/api/config", { cache: "no-store", signal: controller.signal })
.then(function(res) {
clearTimeout(timeoutId);
if (_serverWentDown) {
// Server is responding after having been down — reboot is complete.
// Any response (even 401/500) means the server process is back.
_setRebootStatus("System is back online. Reconnecting\u2026");
if (res.ok && _serverWentDown) {
// Server is back after having been down — reboot is complete
window.location.reload();
} else if ((Date.now() - _rebootStartTime) < 90000) {
} else if (res.ok && !_serverWentDown && (Date.now() - _rebootStartTime) < 30000) {
// Server still responding but hasn't gone down yet — keep waiting
setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
} else {
// Been over 90 seconds and server is responding — just reload
_setRebootStatus("System is back online. Reconnecting\u2026");
} else if (res.ok) {
// Been over 30 seconds and server is responding — just reload
window.location.reload();
} else {
_serverWentDown = true;
setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
}
})
.catch(function() {
clearTimeout(timeoutId);
if (!_serverWentDown) {
_serverWentDown = true;
_setRebootStatus("The computer is restarting\u2026");
}
_serverWentDown = true;
setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
});
}
+31 -118
View File
@@ -33,7 +33,6 @@ const DOMAIN_DEFS = [
var _currentStep = 1;
var _servicesData = null;
var _domainsData = null;
var _migrationOccurred = false;
// ── Helpers ───────────────────────────────────────────────────────
@@ -66,48 +65,6 @@ function setStatus(elId, msg, type) {
el.className = "onboarding-save-status" + (type ? " onboarding-save-status--" + type : "");
}
function updateStep5Checklist() {
var checklist = document.getElementById("onboarding-checklist");
if (!checklist) return;
var existing = document.getElementById("onboarding-migration-check");
if (_migrationOccurred) {
if (!existing) {
var li = document.createElement("li");
li.id = "onboarding-migration-check";
li.textContent = "✅ Migration password noted";
checklist.appendChild(li);
}
return;
}
if (existing) existing.remove();
}
function showMigrationStep(password) {
for (var i = 1; i <= TOTAL_STEPS; i++) {
var panel = document.getElementById("step-" + i);
if (panel) panel.style.display = "none";
}
var migrationPanel = document.getElementById("step-migration");
if (migrationPanel) migrationPanel.style.display = "";
var pw = document.getElementById("migration-password-value");
if (pw) pw.textContent = password || "";
var progressBar = document.getElementById("onboarding-progress-bar");
if (progressBar) progressBar.style.display = "none";
var nav = document.getElementById("onboarding-steps-nav");
if (nav) nav.style.display = "none";
}
function showStep1FromMigration() {
var migrationPanel = document.getElementById("step-migration");
if (migrationPanel) migrationPanel.style.display = "none";
var progressBar = document.getElementById("onboarding-progress-bar");
if (progressBar) progressBar.style.display = "";
var nav = document.getElementById("onboarding-steps-nav");
if (nav) nav.style.display = "";
showStep(1);
loadStep1();
}
// ── Progress / step navigation ────────────────────────────────────
function updateProgress(step) {
@@ -333,6 +290,8 @@ async function loadStep3() {
return;
}
var externalIp = (networkData && networkData.external_ip) || "Unknown (could not retrieve)";
// Build set of enabled service units
var enabledUnits = new Set();
(_servicesData || []).forEach(function(svc) {
@@ -350,31 +309,25 @@ async function loadStep3() {
html += '<p class="onboarding-body-text">No domain-based services are enabled for your role. You can skip this step.</p>';
} else {
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
+ '<p style="margin:0 0 8px;"><strong>Sovran_SystemsOS uses Njal.la for domains and Dynamic DNS.</strong></p>'
+ '<strong>Before you continue:</strong>'
+ '<ol style="margin:8px 0 0 16px; padding:0; line-height:1.7;">'
+ '<li>Create an account at <a href="https://njal.la" target="_blank" style="color:var(--accent-color);">https://njal.la</a>.</li>'
+ '<li>Buy at least one domain. Each service below needs its own domain — you can either give each service its own subdomain of a single domain you buy (subdomains are free, and one domain can have many), OR use a separate domain for each. Your choice.</li>'
+ '<li>For each service, add a <strong>Dynamic</strong> record in Njal.la:'
+ '<ul style="margin:4px 0 0 16px;padding:0;line-height:1.7;">'
+ '<li>In the Njal.la <strong>Name</strong> field, type ONLY the host part — the word before your domain.<br>'
+ '(Example only, your choice — for &quot;call.yourdomain.com&quot; you&apos;d type just: <code>call</code>.)<br>'
+ 'If you bought a whole separate domain just for this service, leave Name blank or use <code>@</code>.<br>'
+ '&#9888; Do NOT type the full domain in the Name field — Njal.la adds it automatically.</li>'
+ '<li>A Dynamic record has NO IP field. You don&apos;t enter an IP anywhere — it auto-fills once Sovran_SystemsOS updates it (on save, and again after reboot).</li>'
+ '</ul>'
+ '</li>'
+ '<li>Njal.la gives you a curl command like:<br>'
+ '<code style="font-size:0.8em;">curl &quot;https://njal.la/update/?h=call.yourdomain.com&amp;k=abc123&amp;auto&quot;</code></li>'
+ '<li>Create an account at <a href="https://njal.la" target="_blank" style="color:var(--accent-color);">https://njal.la</a></li>'
+ '<li>Purchase a new domain on Njal.la, or create a subdomain from a domain you already own. Tip: Subdomains are free to create — you only need to purchase one domain, and you can add as many subdomains as you need at no extra cost.</li>'
+ '<li>In the Njal.la web interface, create a <strong>Dynamic</strong> record pointing to this machine\'s external IP address:<br>'
+ '<span style="display:inline-block;margin-top:4px;padding:4px 12px;background:var(--card-color);border:1px solid var(--border-color);border-radius:6px;font-family:monospace;font-size:1.1em;font-weight:700;letter-spacing:0.03em;">' + escHtml(externalIp) + '</span></li>'
+ '<li>Njal.la will give you a curl command like:<br>'
+ '<code style="font-size:0.8em;">curl "https://njal.la/update/?h=sub.domain.com&amp;k=abc123&amp;auto"</code></li>'
+ '<li>Enter the subdomain and paste that curl command below for each service</li>'
+ '</ol>'
+ '</div>';
html += '<p class="onboarding-hint">Enter each service\'s full domain — a subdomain (e.g. <code>call.yourdomain.com</code>) or a separate domain (e.g. <code>call.com</code>) — and its Njal.la DDNS curl command.</p>';
html += '<p class="onboarding-hint">Enter each fully-qualified subdomain (e.g. <code>matrix.yourdomain.com</code>) and its Njal.la DDNS curl command.</p>';
relevantDomains.forEach(function(d) {
var currentVal = (_domainsData && _domainsData[d.name]) || "";
html += '<div class="onboarding-domain-group">';
html += '<label class="onboarding-domain-label">' + escHtml(d.label) + '</label>';
html += '<input class="onboarding-domain-input domain-field-input" type="text" id="domain-input-' + escHtml(d.name) + '" data-domain="' + escHtml(d.name) + '" placeholder="e.g. ' + escHtml(d.name) + '.yourdomain.com" value="' + escHtml(currentVal) + '" />';
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=...&amp;k=...&amp;auto&quot;" />';
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>';
@@ -516,7 +469,7 @@ async function saveStep3() {
async function loadStep4() {
var body = document.getElementById("step-4-body");
if (!body) return;
body.innerHTML = '<p class="onboarding-loading">Loading router setup…</p>';
body.innerHTML = '<p class="onboarding-loading">Checking ports…</p>';
var networkData = null;
@@ -527,59 +480,51 @@ async function loadStep4() {
return;
}
var trimmedInternalIp = (networkData && networkData.internal_ip) ? String(networkData.internal_ip).trim() : "";
var internalIp = trimmedInternalIp || "";
var hasInternalIp = !!internalIp;
var ip = escHtml(internalIp || "Could not detect");
var routerIpHelp = hasInternalIp
? "Use this IP address as the destination/internal IP when creating each router forwarding rule."
: "Use this computers internal IP as the destination/internal IP when creating each router forwarding rule.";
var destinationInstruction = hasInternalIp
? 'Set the destination/internal IP to <strong>' + ip + '</strong>'
: 'Use this computers internal IP as the destination/internal IP';
var internalIp = (networkData && networkData.internal_ip) || "unknown";
var ip = escHtml(internalIp);
var html = '<p class="onboarding-port-note" style="margin-bottom:14px;">'
+ '⚠ <strong>Each port only needs to be forwarded once — all services share the same ports.</strong>'
+ '</p>';
html += '<div class="onboarding-port-ip">';
html += ' <span class="onboarding-port-ip-label">Forward router traffic to this Sovran_SystemsOS computer:</span>';
html += ' <span class="onboarding-port-ip-label">Forward ports to this machine\'s internal IP:</span>';
html += ' <span class="port-req-internal-ip">' + ip + '</span>';
html += '</div>';
html += '<div class="onboarding-port-note" style="margin:8px 0 16px;">' + routerIpHelp + '</div>';
// Required ports table
html += '<div class="onboarding-port-section" style="margin-bottom:20px;">';
html += '<div class="onboarding-port-section-title" style="font-weight:700;margin-bottom:8px;">Required Router Rules</div>';
html += '<div class="onboarding-port-section-title" style="font-weight:700;margin-bottom:8px;">Required Ports — open these on your router:</div>';
html += '<table class="onboarding-port-table">';
html += '<thead><tr><th>Port</th><th>Protocol</th><th>Forward&nbsp;To</th><th>Used For</th></tr></thead>';
html += '<thead><tr><th>Port</th><th>Protocol</th><th>Forward&nbsp;to</th><th>Purpose</th></tr></thead>';
html += '<tbody>';
html += '<tr><td class="port-req-port">80</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">HTTP / SSL setup</td></tr>';
html += '<tr><td class="port-req-port">80</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">HTTP</td></tr>';
html += '<tr><td class="port-req-port">443</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">HTTPS</td></tr>';
html += '<tr><td class="port-req-port">22</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">Remote SSH access</td></tr>';
html += '<tr><td class="port-req-port">22</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">SSH Remote Access</td></tr>';
html += '<tr><td class="port-req-port">8448</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">Matrix Federation</td></tr>';
html += '</tbody></table>';
html += '</div>';
// Optional ports table
html += '<div class="onboarding-port-section" style="margin-bottom:20px;">';
html += '<div class="onboarding-port-section-title" style="font-weight:700;margin-bottom:4px;">Element Call Router Rules</div>';
html += '<div style="font-size:0.88em;margin-bottom:8px;color:var(--color-text-muted,#888);">Only add these if you enable Element Call. These ports help video and audio calls connect reliably.</div>';
html += '<div class="onboarding-port-section-title" style="font-weight:700;margin-bottom:4px;">Optional — Only needed if you enable Element Calling:</div>';
html += '<div style="font-size:0.88em;margin-bottom:8px;color:var(--color-text-muted,#888);">These 5 additional port openings are required on top of the 4 required ports above.</div>';
html += '<table class="onboarding-port-table">';
html += '<thead><tr><th>Port</th><th>Protocol</th><th>Forward&nbsp;To</th><th>Used For</th></tr></thead>';
html += '<thead><tr><th>Port</th><th>Protocol</th><th>Forward&nbsp;to</th><th>Purpose</th></tr></thead>';
html += '<tbody>';
html += '<tr><td class="port-req-port">7881</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">LiveKit WebRTC signalling</td></tr>';
html += '<tr><td class="port-req-port">7882</td><td class="port-req-proto">UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">LiveKit media (UDP mux)</td></tr>';
html += '<tr><td class="port-req-port">78827894</td><td class="port-req-proto">UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">LiveKit media streams</td></tr>';
html += '<tr><td class="port-req-port">5349</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">TURN over TLS</td></tr>';
html += '<tr><td class="port-req-port">3478</td><td class="port-req-proto">UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">TURN (STUN/relay)</td></tr>';
html += '<tr><td class="port-req-port">30000-40000</td><td class="port-req-proto">TCP &amp; UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">TURN relay (WebRTC)</td></tr>';
html += '<tr><td class="port-req-port">3000040000</td><td class="port-req-proto">TCP/UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">TURN relay (WebRTC)</td></tr>';
html += '</tbody></table>';
html += '<div style="font-size:0.85em;margin-top:6px;color:var(--color-text-muted,#888);"> The <strong>30000-40000</strong> range is a single forwarding rule — just set its protocol to <strong>both TCP and UDP</strong> (often shown as "Both" or "TCP/UDP" on your router).</div>';
html += '</div>';
// Totals
html += '<div class="onboarding-port-totals">';
html += '<strong>Total port openings: 3</strong> (without Element Call)<br>';
html += '<strong>Total port openings: 8</strong> (with Element Call — 3 required + 5 optional)';
html += '<strong>Total port openings: 4</strong> (without Element Calling)<br>';
html += '<strong>Total port openings: 9</strong> (with Element Calling4 required + 5 optional)';
html += '</div>';
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
@@ -594,16 +539,12 @@ async function loadStep4() {
+ '<li>Open your router\'s admin panel — usually <code>http://192.168.1.1</code> or <code>http://192.168.0.1</code></li>'
+ '<li>Look for <strong>"Port Forwarding"</strong>, <strong>"NAT"</strong>, or <strong>"Virtual Server"</strong> in the settings</li>'
+ '<li>Create a new rule for each port listed above</li>'
+ '<li>' + destinationInstruction + '</li>'
+ '<li>Set the destination/internal IP to <strong>' + ip + '</strong></li>'
+ '<li>Set both internal and external port to the same number</li>'
+ '<li>Save and apply changes</li>'
+ '</ol>'
+ '</details>';
html += '<div class="onboarding-port-note" style="margin-top:12px;">'
+ '<strong>Important:</strong> The Hub can show which ports Sovran_SystemsOS needs, but it cannot fully confirm router forwarding from inside your home network. Full public port verification requires an outside internet check.'
+ '</div>';
body.innerHTML = html;
}
@@ -625,23 +566,6 @@ async function completeOnboarding() {
// ── Event wiring ──────────────────────────────────────────────────
function wireNavButtons() {
var migrationContinue = document.getElementById("migration-password-continue");
if (migrationContinue) migrationContinue.addEventListener("click", async function() {
migrationContinue.disabled = true;
migrationContinue.textContent = "Continuing…";
setStatus("migration-password-status", "Saving acknowledgement…", "info");
try {
await apiFetch("/api/migration/password-acknowledge", { method: "POST" });
_migrationOccurred = true;
updateStep5Checklist();
showStep1FromMigration();
} catch (err) {
setStatus("migration-password-status", "⚠ " + err.message, "error");
migrationContinue.disabled = false;
migrationContinue.textContent = "I've written it down — Continue →";
}
});
// Step 1 → next
var s1next = document.getElementById("step-1-next");
if (s1next) s1next.addEventListener("click", function() { showStep(nextStep(1)); });
@@ -703,17 +627,6 @@ document.addEventListener("DOMContentLoaded", async function() {
} catch (_) {}
wireNavButtons();
try {
var migration = await apiFetch("/api/migration/password-status");
if (migration && migration.pending) {
updateStep5Checklist();
showMigrationStep(migration.password || "");
return;
}
} catch (_) {}
updateStep5Checklist();
showStep(1);
updateProgress(1);
loadStep1();
});
+34 -70
View File
@@ -4,17 +4,17 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sovran_SystemsOS Hub</title>
<link rel="stylesheet" href="/static/css/base.css?v={{ asset_version }}" />
<link rel="stylesheet" href="/static/css/buttons.css?v={{ asset_version }}" />
<link rel="stylesheet" href="/static/css/header.css?v={{ asset_version }}" />
<link rel="stylesheet" href="/static/css/layout.css?v={{ asset_version }}" />
<link rel="stylesheet" href="/static/css/tiles.css?v={{ asset_version }}" />
<link rel="stylesheet" href="/static/css/modals.css?v={{ asset_version }}" />
<link rel="stylesheet" href="/static/css/features.css?v={{ asset_version }}" />
<link rel="stylesheet" href="/static/css/onboarding.css?v={{ asset_version }}" />
<link rel="stylesheet" href="/static/css/support.css?v={{ asset_version }}" />
<link rel="stylesheet" href="/static/css/domain-setup.css?v={{ asset_version }}" />
<link rel="stylesheet" href="/static/css/security.css?v={{ asset_version }}" />
<link rel="stylesheet" href="/static/css/base.css" />
<link rel="stylesheet" href="/static/css/buttons.css" />
<link rel="stylesheet" href="/static/css/header.css" />
<link rel="stylesheet" href="/static/css/layout.css" />
<link rel="stylesheet" href="/static/css/tiles.css" />
<link rel="stylesheet" href="/static/css/modals.css" />
<link rel="stylesheet" href="/static/css/features.css" />
<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>
@@ -24,7 +24,6 @@
<span class="title">Sovran_SystemsOS Hub</span>
<div class="header-buttons">
<span class="role-badge" id="role-badge">Loading…</span>
<button class="btn btn-header-reboot" id="btn-header-reboot" title="Restart the entire computer">Reboot</button>
<button class="btn btn-logout" id="btn-logout" title="Sign out">Sign Out</button>
</div>
</header>
@@ -62,7 +61,7 @@
<div class="modal-log" id="modal-log" aria-live="polite"></div>
<div class="modal-footer">
<button class="btn btn-save" id="btn-save-report" style="display:none">Save Error Report</button>
<button class="btn btn-reboot" id="btn-reboot" style="display:none">Restart Entire System</button>
<button class="btn btn-reboot" id="btn-reboot" style="display:none">Reboot</button>
<button class="btn btn-close-modal" id="btn-close-modal" disabled>Close</button>
</div>
</div>
@@ -165,7 +164,7 @@
<div class="modal-log" id="rebuild-log" aria-live="polite"></div>
<div class="modal-footer">
<button class="btn btn-save" id="rebuild-save-report" style="display:none">Save Error Report</button>
<button class="btn btn-reboot" id="rebuild-reboot-btn" style="display:none">Restart Entire System</button>
<button class="btn btn-reboot" id="rebuild-reboot-btn" style="display:none">Reboot</button>
<button class="btn btn-close-modal" id="rebuild-close-btn" disabled>Close</button>
</div>
</div>
@@ -186,7 +185,7 @@
<div class="upgrade-info-box">
<p class="upgrade-info-title">⚠ What you should know:</p>
<ul class="upgrade-info-list">
<li>You will need to purchase domains for your services <a href="https://njal.la" target="_blank" rel="noopener noreferrer">Njal.la</a> is the only supported domain provider</li>
<li>You will need to purchase domains for your services (we recommend <a href="https://njal.la" target="_blank" rel="noopener noreferrer">njal.la</a>)</li>
<li>Some services require ports to be opened on your router</li>
</ul>
</div>
@@ -241,74 +240,39 @@
You will need it to log in to your computer<br />and the Sovran Hub at <em>sovransystemsos.local</em>.
</p>
<button class="security-reset-reboot-btn" id="security-reset-reboot-btn" disabled>
I have written down my new password — Restart Entire System
I have written down my new password — Reboot now
</button>
</div>
</div>
<!-- Reboot overlay -->
<div class="reboot-overlay" id="reboot-overlay">
<!-- Normal restarting card -->
<div class="reboot-card" id="reboot-main-card">
<div class="reboot-icon" aria-hidden="true"></div>
<h2 class="reboot-title">Restarting Entire System</h2>
<div class="reboot-card">
<div class="reboot-icon"></div>
<h2 class="reboot-title">System Rebooting</h2>
<p class="reboot-message">
The entire computer is restarting, including the desktop and all hosted services.<br />
This page will reconnect automatically when Sovran_SystemsOS is back online.
Sovran_SystemsOS is now restarting.<br />
This page will automatically reconnect once the system is back online.
</p>
<div class="reboot-dots" aria-hidden="true">
<div class="reboot-dots">
<span class="reboot-dot"></span>
<span class="reboot-dot"></span>
<span class="reboot-dot"></span>
</div>
<p class="reboot-submessage" id="reboot-submessage" aria-live="polite">Sending restart request</p>
</div>
<!-- Error card (shown if restart request fails definitively) -->
<div class="reboot-card" id="reboot-error-card" style="display:none">
<div class="reboot-icon" aria-hidden="true"></div>
<h2 class="reboot-title">Restart could not be started</h2>
<p class="reboot-message">
The computer did not begin restarting. No services were intentionally stopped. Please try again.
</p>
<div class="reboot-error-actions">
<button class="btn btn-close-modal" id="reboot-error-close-btn">Close</button>
<button class="btn btn-restart-amber" id="reboot-error-retry-btn">Try Again</button>
</div>
<p class="reboot-submessage">Stay tuned</p>
</div>
</div>
<!-- Restart Confirm Dialog -->
<div class="modal-overlay" id="restart-confirm-modal" role="dialog" aria-modal="true" aria-labelledby="restart-confirm-title">
<div class="creds-dialog domain-narrow-dialog">
<div class="creds-header">
<span class="creds-title" id="restart-confirm-title">Restart the entire computer?</span>
</div>
<div class="creds-body">
<div id="restart-conflict-box" class="restart-conflict-box" style="display:none">
<p class="restart-conflict-title">The system cannot restart right now.</p>
<p class="restart-conflict-desc">A system update, rebuild, backup, restore, or security operation is currently running. Wait for it to finish, then try again.</p>
</div>
<p class="support-desc"><strong>This will reboot the physical machine running Sovran_SystemsOS — not just the Hub.</strong></p>
<p class="support-desc">The desktop and all hosted services will stop temporarily and restart with the computer. Anyone currently using these services will be disconnected.</p>
<p class="support-desc">The system usually returns within 13 minutes. This page will reconnect automatically.</p>
<div class="domain-field-actions">
<button class="btn btn-close-modal" id="restart-confirm-cancel-btn">Cancel</button>
<button class="btn btn-restart-amber" id="restart-confirm-ok-btn">Restart Entire System</button>
</div>
</div>
</div>
</div>
<script src="/static/js/constants.js?v={{ asset_version }}"></script>
<script src="/static/js/state.js?v={{ asset_version }}"></script>
<script src="/static/js/helpers.js?v={{ asset_version }}"></script>
<script src="/static/js/tiles.js?v={{ asset_version }}"></script>
<script src="/static/js/service-detail.js?v={{ asset_version }}"></script>
<script src="/static/js/support.js?v={{ asset_version }}"></script>
<script src="/static/js/update.js?v={{ asset_version }}"></script>
<script src="/static/js/rebuild.js?v={{ asset_version }}"></script>
<script src="/static/js/features.js?v={{ asset_version }}"></script>
<script src="/static/js/security.js?v={{ asset_version }}"></script>
<script src="/static/js/events.js?v={{ asset_version }}"></script>
<script src="/static/js/constants.js"></script>
<script src="/static/js/state.js"></script>
<script src="/static/js/helpers.js"></script>
<script src="/static/js/tiles.js"></script>
<script src="/static/js/service-detail.js"></script>
<script src="/static/js/support.js"></script>
<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>
</html>
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sovran Hub — Login</title>
<link rel="stylesheet" href="/static/css/base.css?v={{ asset_version }}" />
<link rel="stylesheet" href="/static/css/buttons.css?v={{ asset_version }}" />
<link rel="stylesheet" href="/static/css/base.css" />
<link rel="stylesheet" href="/static/css/buttons.css" />
</head>
<body>
<div class="login-wrapper">
@@ -21,7 +21,7 @@
<div class="onboarding-shell">
<!-- Progress bar -->
<div class="onboarding-progress-bar" id="onboarding-progress-bar">
<div class="onboarding-progress-bar">
<div class="onboarding-progress-fill" id="onboarding-progress-fill"></div>
</div>
@@ -41,33 +41,6 @@
<!-- Step panels -->
<div class="onboarding-panel-wrap">
<!-- ── Migration Password Gate (pre-step) ── -->
<div class="onboarding-panel" id="step-migration" style="display:none">
<div class="onboarding-hero">
<div class="onboarding-logo">🔐</div>
<h1 class="onboarding-title">Your system has been migrated to Sovran_SystemsOS</h1>
<p class="onboarding-subtitle">Important password update required</p>
</div>
<div class="onboarding-card">
<p class="onboarding-body-text" style="text-align:center; margin-bottom:4px;">
Your new login password is:
</p>
<div id="migration-password-value" style="font-family:monospace; font-size:1.35rem; font-weight:700; color:var(--text-primary); background:rgba(109, 191, 139, 0.10); border:1.5px solid rgba(109, 191, 139, 0.35); border-radius:8px; padding:14px 24px; letter-spacing:0.04em; text-align:center; word-break:break-all; margin-bottom:8px;">
&nbsp;
</div>
<div style="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.92rem; color:var(--yellow); line-height:1.55;">
⚠ Write this password down! You will need it to log in next time. This is also your Sovran Hub login password.
</div>
<div id="migration-password-status" class="onboarding-save-status" style="margin-top:8px;"></div>
</div>
<div class="onboarding-footer">
<div></div>
<button class="btn btn-primary" id="migration-password-continue">
I've written it down — Continue →
</button>
</div>
</div>
<!-- ── Step 1: Welcome ── -->
<div class="onboarding-panel" id="step-1">
<div class="onboarding-hero">
@@ -128,8 +101,9 @@
<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.
Create an account at Njal.la, then for each service below, add a <strong>Dynamic</strong> record — no IP needed, it auto-populates once the DDNS curl command runs.
Paste the curl command from your Njal.la dashboard for each service.
First, create an account at <strong>Njal.la</strong> and purchase a new domain, or create a subdomain from a domain you already own. Tip: Subdomains are free to create — you only need to purchase one domain, and you can add as many subdomains as you need at no extra cost.
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" id="step-3-body">
@@ -148,14 +122,14 @@
<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">Router Setup</h2>
<h2 class="onboarding-step-title">Port Forwarding Check</h2>
<p class="onboarding-step-desc">
Forward these ports in your router to this Sovran_SystemsOS computer. These rules let people reach your services from outside your home network.
<strong>Ports 80 and 443 are required for HTTPS and SSL certificates.</strong>
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" id="step-4-body">
<p class="onboarding-loading">Loading router setup</p>
<p class="onboarding-loading">Checking ports</p>
</div>
<div class="onboarding-footer">
<button class="btn btn-close-modal onboarding-btn-back" data-prev="3">← Back</button>
@@ -196,4 +170,4 @@
<script src="/static/onboarding.js?v={{ onboarding_js_hash }}"></script>
</body>
</html>
</html>
-166
View File
@@ -1,166 +0,0 @@
import unittest
from unittest.mock import patch
from pathlib import Path
import sys
import types
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
def _install_web_stubs():
if "fastapi" in sys.modules:
return
class _HTTPException(Exception):
def __init__(self, status_code=None, detail=None):
super().__init__(detail)
self.status_code = status_code
self.detail = detail
class _FastAPI:
def __init__(self, *args, **kwargs):
pass
def mount(self, *args, **kwargs):
return None
def add_middleware(self, *args, **kwargs):
return None
def __getattr__(self, _name):
def _decorator_factory(*args, **kwargs):
def _decorator(func):
return func
return _decorator
return _decorator_factory
class _BaseModel:
pass
class _StaticFiles:
def __init__(self, *args, **kwargs):
pass
class _Jinja2Templates:
def __init__(self, *args, **kwargs):
pass
class _BaseHTTPMiddleware:
pass
fastapi_module = types.ModuleType("fastapi")
fastapi_module.FastAPI = _FastAPI
fastapi_module.HTTPException = _HTTPException
sys.modules["fastapi"] = fastapi_module
responses_module = types.ModuleType("fastapi.responses")
responses_module.HTMLResponse = object
responses_module.JSONResponse = object
responses_module.RedirectResponse = object
sys.modules["fastapi.responses"] = responses_module
staticfiles_module = types.ModuleType("fastapi.staticfiles")
staticfiles_module.StaticFiles = _StaticFiles
sys.modules["fastapi.staticfiles"] = staticfiles_module
templating_module = types.ModuleType("fastapi.templating")
templating_module.Jinja2Templates = _Jinja2Templates
sys.modules["fastapi.templating"] = templating_module
requests_module = types.ModuleType("fastapi.requests")
requests_module.Request = object
sys.modules["fastapi.requests"] = requests_module
pydantic_module = types.ModuleType("pydantic")
pydantic_module.BaseModel = _BaseModel
sys.modules["pydantic"] = pydantic_module
starlette_base_module = types.ModuleType("starlette.middleware.base")
starlette_base_module.BaseHTTPMiddleware = _BaseHTTPMiddleware
sys.modules["starlette.middleware.base"] = starlette_base_module
starlette_middleware_module = types.ModuleType("starlette.middleware")
starlette_middleware_module.base = starlette_base_module
sys.modules["starlette.middleware"] = starlette_middleware_module
starlette_module = types.ModuleType("starlette")
starlette_module.middleware = starlette_middleware_module
sys.modules["starlette"] = starlette_module
_install_web_stubs()
from sovran_systemsos_web import server
class Bip110StatusTests(unittest.TestCase):
def _status(self, deploy_info, net_info):
with patch.object(server, "_get_bitcoin_deployment_info", return_value=deploy_info), patch.object(
server, "_get_bitcoin_version_info", return_value=net_info
):
return server._get_bip110_status()
def test_started_reduced_data_reports_signaling(self):
deploy_info = {
"deployments": {
"reduced_data": {
"type": "bip9",
"active": False,
"bip9": {
"bit": 4,
"status": "started",
"statistics": {"elapsed": 833, "count": 4, "threshold": 1109},
"signalling": "--#--",
},
}
}
}
result = self._status(deploy_info, {"subversion": "/Satoshi:29.0.0/"})
self.assertEqual(
result,
{"supported": True, "signaling": True, "state": "signaling", "source": "getdeploymentinfo"},
)
def test_active_reduced_data_reports_active(self):
deploy_info = {
"deployments": {"reduced_data": {"active": True, "bip9": {"bit": 4, "status": "active"}}}
}
result = self._status(deploy_info, {"subversion": "/Satoshi:29.0.0/"})
self.assertEqual(result["state"], "active")
self.assertTrue(result["supported"])
self.assertTrue(result["signaling"])
self.assertEqual(result["source"], "getdeploymentinfo")
def test_locked_in_reduced_data_reports_locked_in(self):
deploy_info = {
"deployments": {"reduced_data": {"active": False, "bip9": {"bit": 4, "status": "locked_in"}}}
}
result = self._status(deploy_info, {"subversion": "/Satoshi:29.0.0/"})
self.assertEqual(result["state"], "locked_in")
self.assertTrue(result["supported"])
self.assertTrue(result["signaling"])
self.assertEqual(result["source"], "getdeploymentinfo")
def test_no_bip110_deployment_and_plain_subversion_reports_unsupported(self):
deploy_info = {
"deployments": {
"taproot": {"type": "bip9", "active": True, "bip9": {"bit": 2, "status": "active"}},
}
}
result = self._status(deploy_info, {"subversion": "/Satoshi:27.0.0/"})
self.assertEqual(
result,
{"supported": False, "signaling": False, "state": "unsupported", "source": "subversion"},
)
def test_node_unreachable_reports_unknown(self):
result = self._status(None, None)
self.assertEqual(result, {"supported": False, "signaling": False, "state": "unknown", "source": "none"})
if __name__ == "__main__":
unittest.main()
-44
View File
@@ -1,44 +0,0 @@
import unittest
from pathlib import Path
HUB_NIX = Path(__file__).resolve().parents[2] / "modules" / "core" / "sovran-hub.nix"
def _section(source: str, start: str, end: str) -> str:
start_idx = source.find(start)
if start_idx == -1:
raise AssertionError(f"Expected section start not found: {start!r}")
end_idx = source.find(end, start_idx)
if end_idx == -1:
raise AssertionError(f"Expected section end not found: {end!r}")
return source[start_idx:end_idx]
class HubUpdateBootStagingTests(unittest.TestCase):
def setUp(self):
self.source = HUB_NIX.read_text()
self.update_section = _section(
self.source,
'update-script = pkgs.writeShellScript "sovran-hub-update.sh" \'\'',
"# ── Rebuild wrapper script",
)
self.rebuild_section = _section(
self.source,
'rebuild-script = pkgs.writeShellScript "sovran-hub-rebuild.sh" \'\'',
"# ── Brave launcher wrapper",
)
def test_full_update_uses_boot_not_switch(self):
self.assertIn("nixos-rebuild boot --flake /etc/nixos", self.update_section)
self.assertNotIn("nixos-rebuild switch --flake /etc/nixos", self.update_section)
def test_full_update_marks_reboot_required(self):
self.assertIn('echo "REBOOT_REQUIRED" > "$STATUS"', self.update_section)
def test_rebuild_path_keeps_switch_semantics(self):
self.assertIn("nixos-rebuild switch --flake /etc/nixos", self.rebuild_section)
if __name__ == "__main__":
unittest.main()
-399
View File
@@ -1,399 +0,0 @@
"""Tests for server-local loopback diagnostics and domain validation.
Covers:
- Domain value validation and injection prevention.
- Loopback address detection (IPv4 and IPv6).
- _resolve_all_addresses returning multiple addresses.
- _check_domain_health_fast with loopback resolution.
- _evaluate_domain_checklist with loopback override — no false dns_mismatch.
- _evaluate_domain_checklist with genuine DNS mismatch — still reports error.
- api_services health stays "healthy" when domain resolves to loopback.
- api_services health stays "needs_attention" when DNS is genuinely wrong.
- api_domains_check returns "local_override" for loopback-resolved domains.
"""
import unittest
from pathlib import Path
from unittest.mock import MagicMock, mock_open, patch
import sys
import types
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
# ---------------------------------------------------------------------------
# Minimal stubs so server.py can be imported without the full FastAPI stack.
# ---------------------------------------------------------------------------
def _install_web_stubs():
if "fastapi" in sys.modules:
return
class _HTTPException(Exception):
def __init__(self, status_code=None, detail=None):
super().__init__(detail)
self.status_code = status_code
self.detail = detail
class _FastAPI:
def __init__(self, *args, **kwargs):
pass
def mount(self, *args, **kwargs):
return None
def add_middleware(self, *args, **kwargs):
return None
def __getattr__(self, _name):
def _decorator_factory(*args, **kwargs):
def _decorator(func):
return func
return _decorator
return _decorator_factory
class _BaseModel:
pass
class _StaticFiles:
def __init__(self, *args, **kwargs):
pass
class _Jinja2Templates:
def __init__(self, *args, **kwargs):
pass
class _BaseHTTPMiddleware:
pass
fastapi_module = types.ModuleType("fastapi")
fastapi_module.FastAPI = _FastAPI
fastapi_module.HTTPException = _HTTPException
sys.modules["fastapi"] = fastapi_module
responses_module = types.ModuleType("fastapi.responses")
responses_module.HTMLResponse = object
responses_module.JSONResponse = object
responses_module.RedirectResponse = object
sys.modules["fastapi.responses"] = responses_module
staticfiles_module = types.ModuleType("fastapi.staticfiles")
staticfiles_module.StaticFiles = _StaticFiles
sys.modules["fastapi.staticfiles"] = staticfiles_module
templating_module = types.ModuleType("fastapi.templating")
templating_module.Jinja2Templates = _Jinja2Templates
sys.modules["fastapi.templating"] = templating_module
requests_module = types.ModuleType("fastapi.requests")
requests_module.Request = object
sys.modules["fastapi.requests"] = requests_module
pydantic_module = types.ModuleType("pydantic")
pydantic_module.BaseModel = _BaseModel
sys.modules["pydantic"] = pydantic_module
starlette_base_module = types.ModuleType("starlette.middleware.base")
starlette_base_module.BaseHTTPMiddleware = _BaseHTTPMiddleware
sys.modules["starlette.middleware.base"] = starlette_base_module
starlette_middleware_module = types.ModuleType("starlette.middleware")
starlette_middleware_module.base = starlette_base_module
sys.modules["starlette.middleware"] = starlette_middleware_module
starlette_module = types.ModuleType("starlette")
starlette_module.middleware = starlette_middleware_module
sys.modules["starlette"] = starlette_module
_install_web_stubs()
from sovran_systemsos_web import server # noqa: E402
# ===========================================================================
# Domain value validation
# ===========================================================================
class TestValidateDomainValue(unittest.TestCase):
"""_validate_domain_value must reject anything that could corrupt /etc/hosts."""
def _v(self, value: str) -> bool:
return server._validate_domain_value(value)
# -- Valid values --------------------------------------------------------
def test_simple_domain_valid(self):
self.assertTrue(self._v("cloud.example.com"))
def test_subdomain_valid(self):
self.assertTrue(self._v("matrix.home.example.org"))
def test_single_label_with_tld_valid(self):
self.assertTrue(self._v("example.com"))
def test_hyphen_in_domain_valid(self):
self.assertTrue(self._v("my-nextcloud.example.com"))
# -- Injection / malformed values ----------------------------------------
def test_empty_string_invalid(self):
self.assertFalse(self._v(""))
def test_newline_injection_invalid(self):
self.assertFalse(self._v("evil.com\n127.0.0.1 other.host"))
def test_carriage_return_injection_invalid(self):
self.assertFalse(self._v("evil.com\r127.0.0.1 other.host"))
def test_space_injection_invalid(self):
self.assertFalse(self._v("evil.com 127.0.0.1"))
def test_hash_comment_injection_invalid(self):
self.assertFalse(self._v("evil.com# comment"))
def test_bare_hostname_no_dot_invalid(self):
self.assertFalse(self._v("localhost"))
def test_bare_ip_invalid(self):
self.assertFalse(self._v("192.168.1.1"))
def test_too_long_invalid(self):
self.assertFalse(self._v("a" * 254 + ".com"))
def test_leading_dot_invalid(self):
self.assertFalse(self._v(".example.com"))
def test_trailing_dot_invalid(self):
self.assertFalse(self._v("example.com."))
# ===========================================================================
# Loopback address detection
# ===========================================================================
class TestIsLoopbackAddress(unittest.TestCase):
def test_ipv4_loopback(self):
self.assertTrue(server._is_loopback_address("127.0.0.1"))
def test_ipv4_loopback_other(self):
self.assertTrue(server._is_loopback_address("127.0.0.2"))
def test_ipv4_loopback_high(self):
self.assertTrue(server._is_loopback_address("127.255.255.255"))
def test_ipv6_loopback(self):
self.assertTrue(server._is_loopback_address("::1"))
def test_public_ipv4_not_loopback(self):
self.assertFalse(server._is_loopback_address("203.0.113.10"))
def test_private_ipv4_not_loopback(self):
self.assertFalse(server._is_loopback_address("192.168.1.50"))
def test_ipv6_public_not_loopback(self):
self.assertFalse(server._is_loopback_address("2001:db8::1"))
def test_invalid_string_not_loopback(self):
self.assertFalse(server._is_loopback_address("not-an-ip"))
# ===========================================================================
# _check_domain_health_fast
# ===========================================================================
class TestCheckDomainHealthFast(unittest.TestCase):
"""_check_domain_health_fast returns True when there is an issue,
False when everything looks fine."""
def _fast(self, domain, external_ip, resolved_addrs):
with patch.object(server, "_resolve_all_addresses", return_value=resolved_addrs):
return server._check_domain_health_fast(domain, external_ip)
def test_no_domain_no_issue(self):
# None/empty domain: the fast check reports True (handled by checklist).
result = server._check_domain_health_fast(None, "203.0.113.10")
self.assertTrue(result)
def test_empty_domain_no_issue(self):
result = server._check_domain_health_fast("", "203.0.113.10")
self.assertTrue(result)
def test_loopback_ipv4_no_issue(self):
"""Loopback override must not be flagged as a DNS mismatch."""
result = self._fast("cloud.example.com", "203.0.113.10", ["127.0.0.1"])
self.assertFalse(result)
def test_loopback_ipv6_no_issue(self):
result = self._fast("cloud.example.com", "203.0.113.10", ["::1"])
self.assertFalse(result)
def test_matches_external_ip_no_issue(self):
result = self._fast("cloud.example.com", "203.0.113.10", ["203.0.113.10"])
self.assertFalse(result)
def test_mismatch_is_an_issue(self):
result = self._fast("cloud.example.com", "203.0.113.10", ["198.51.100.1"])
self.assertTrue(result)
def test_unavailable_external_ip_no_issue(self):
result = self._fast("cloud.example.com", "unavailable", ["198.51.100.1"])
self.assertFalse(result)
def test_multiple_addresses_one_matches_no_issue(self):
"""If any resolved address matches external_ip the check should pass."""
result = self._fast(
"cloud.example.com", "203.0.113.10",
["198.51.100.1", "203.0.113.10"],
)
self.assertFalse(result)
# ===========================================================================
# _evaluate_domain_checklist — loopback override path
# ===========================================================================
class TestEvaluateDomainChecklistLoopback(unittest.TestCase):
def _eval(self, domain, external_ip, resolved_addrs, reachable_result=None):
with (
patch.object(server, "_resolve_all_addresses", return_value=resolved_addrs),
patch.object(server, "_check_domain_reachable",
return_value=reachable_result or {"reachable": True, "status_code": 200}),
):
return server._evaluate_domain_checklist(domain, external_ip)
def test_loopback_dns_step_is_ok_not_error(self):
result = self._eval("cloud.example.com", "203.0.113.10", ["127.0.0.1"])
dns_step = next(s for s in result["domain_check_steps"] if s["step"] == 2)
self.assertEqual(dns_step["status"], "ok")
self.assertNotIn("mismatch", dns_step.get("detail", "").lower())
def test_loopback_domain_status_is_local_override(self):
result = self._eval("cloud.example.com", "203.0.113.10", ["127.0.0.1"])
self.assertEqual(result["domain_status"]["status"], "local_override")
def test_loopback_has_no_issues_when_reachable(self):
result = self._eval(
"cloud.example.com", "203.0.113.10", ["127.0.0.1"],
reachable_result={"reachable": True, "status_code": 200},
)
self.assertFalse(result["has_issues"])
def test_loopback_has_issues_when_caddy_unreachable(self):
"""A loopback override with Caddy down should still report an issue."""
result = self._eval(
"cloud.example.com", "203.0.113.10", ["127.0.0.1"],
reachable_result={"reachable": False, "error": "connection refused"},
)
self.assertTrue(result["has_issues"])
def test_ipv6_loopback_no_issue(self):
result = self._eval("cloud.example.com", "203.0.113.10", ["::1"])
self.assertEqual(result["domain_status"]["status"], "local_override")
self.assertFalse(result["has_issues"])
def test_genuine_mismatch_still_reports_error(self):
result = self._eval("cloud.example.com", "203.0.113.10", ["198.51.100.1"])
self.assertEqual(result["domain_status"]["status"], "dns_mismatch")
self.assertTrue(result["has_issues"])
def test_correct_public_dns_still_reports_ok(self):
result = self._eval("cloud.example.com", "203.0.113.10", ["203.0.113.10"])
self.assertEqual(result["domain_status"]["status"], "connected")
self.assertFalse(result["has_issues"])
def test_no_domain_has_issues(self):
result = self._eval(None, "203.0.113.10", [])
self.assertTrue(result["has_issues"])
# ===========================================================================
# api_services — composite health with loopback
# ===========================================================================
class TestApiServicesLoopbackHealth(unittest.IsolatedAsyncioTestCase):
async def _get_health(self, resolved_addrs, cached_reachable):
"""Return the health value for a single domain-requiring service."""
service_cfg = {
"services": [
{"unit": "caddy.service", "icon": "nextcloud", "enabled": True, "type": "system"}
]
}
with (
patch.object(server, "load_config", return_value=service_cfg),
patch.object(server, "_read_hub_overrides", return_value=({}, None, None)),
patch.object(server.sysctl, "is_active", return_value="active"),
patch.dict(server.SERVICE_DOMAIN_MAP, {"caddy.service": "nextcloud"}, clear=False),
patch("builtins.open", mock_open(read_data="cloud.example.com\n")),
patch.object(server, "_resolve_all_addresses", return_value=resolved_addrs),
patch.object(server, "_is_domain_reachable_cached", return_value=cached_reachable),
patch.object(server, "_get_listening_ports",
return_value={"tcp": {80, 443}, "udp": set()}),
patch.object(server, "_get_firewall_allowed_ports",
return_value={"tcp": set(), "udp": set()}),
patch.object(server, "_cached_external_ip", "203.0.113.10"),
):
results = await server.api_services()
return results[0]["health"]
async def test_loopback_and_reachable_is_healthy(self):
"""Loopback override + Caddy reachable → healthy, not needs_attention."""
health = await self._get_health(["127.0.0.1"], cached_reachable=True)
self.assertEqual(health, "healthy")
async def test_loopback_and_caddy_down_is_needs_attention(self):
"""Loopback override + Caddy unreachable → needs_attention (genuine issue)."""
health = await self._get_health(["127.0.0.1"], cached_reachable=False)
self.assertEqual(health, "needs_attention")
async def test_correct_dns_and_reachable_is_healthy(self):
health = await self._get_health(["203.0.113.10"], cached_reachable=True)
self.assertEqual(health, "healthy")
async def test_dns_mismatch_is_needs_attention(self):
health = await self._get_health(["198.51.100.1"], cached_reachable=True)
self.assertEqual(health, "needs_attention")
# ===========================================================================
# api_domains_check — loopback detection
# ===========================================================================
class TestApiDomainsCheckLoopback(unittest.IsolatedAsyncioTestCase):
async def _check(self, resolved_addrs, external_ip="203.0.113.10"):
with (
patch.object(server, "_resolve_all_addresses", return_value=resolved_addrs),
patch.object(server, "_cached_external_ip", external_ip),
):
result = await server.api_domains_check(
MagicMock(domains=["cloud.example.com"])
)
return result["domains"][0]
async def test_loopback_ipv4_returns_local_override(self):
result = await self._check(["127.0.0.1"])
self.assertEqual(result["status"], "local_override")
async def test_loopback_ipv6_returns_local_override(self):
result = await self._check(["::1"])
self.assertEqual(result["status"], "local_override")
async def test_correct_dns_returns_connected(self):
result = await self._check(["203.0.113.10"])
self.assertEqual(result["status"], "connected")
async def test_mismatch_returns_dns_mismatch(self):
result = await self._check(["198.51.100.1"])
self.assertEqual(result["status"], "dns_mismatch")
async def test_no_resolution_returns_unresolvable(self):
result = await self._check([])
self.assertEqual(result["status"], "unresolvable")
if __name__ == "__main__":
unittest.main()
-122
View File
@@ -1,122 +0,0 @@
import unittest
from pathlib import Path
RDP_NIX = Path(__file__).resolve().parents[2] / "modules" / "rdp.nix"
USERNAME_READ = "USERNAME=\"$(tr -d '\\n' < \"$USERNAME_FILE\")\""
USERNAME_LENGTH_GUARD = "if [ \"''${#USERNAME}\" -gt 32 ]; then"
SHORT_PASSWORD_GUARD = 'if [ "\'\'${#PASSWORD}" -lt 8 ]; then'
def _section(source: str, start: str, end: str) -> str:
start_idx = source.find(start)
if start_idx == -1:
raise AssertionError(f"Expected section start not found: {start!r}")
end_idx = source.find(end, start_idx)
if end_idx == -1:
raise AssertionError(f"Expected section end not found: {end!r}")
return source[start_idx:end_idx]
class RdpModuleBootSetupTests(unittest.TestCase):
def setUp(self):
self.source = RDP_NIX.read_text()
self.gnome_service = _section(
self.source,
"systemd.services.gnome-remote-desktop = {",
"systemd.tmpfiles.rules = [",
)
self.setup_service = _section(
self.source,
"systemd.services.gnome-remote-desktop-setup = {",
"};\n}",
)
def test_does_not_redeclare_gnome_remote_desktop_user(self):
self.assertNotIn("users.users.gnome-remote-desktop", self.source)
self.assertNotIn("createHome = true;", self.source)
def test_main_service_requires_setup_before_starting(self):
self.assertIn('wantedBy = [ "graphical.target" ];', self.gnome_service)
self.assertIn('after = [ "gnome-remote-desktop-setup.service" ];', self.gnome_service)
self.assertIn('requires = [ "gnome-remote-desktop-setup.service" ];', self.gnome_service)
def test_setup_waits_for_configuration_service_and_bounded_timeout(self):
self.assertIn('wantedBy = [ "graphical.target" ];', self.setup_service)
self.assertIn('before = [ "gnome-remote-desktop.service" ];', self.setup_service)
self.assertIn('"dbus.service"', self.setup_service)
self.assertIn('"gnome-remote-desktop-configuration.service"', self.setup_service)
self.assertNotIn("RemainAfterExit", self.setup_service)
self.assertIn('TimeoutStartSec = "2min";', self.setup_service)
self.assertIn('timeout --kill-after=5s 10s', self.setup_service)
self.assertIn('echo "grdctl command timed out: $*" >&2', self.setup_service)
self.assertIn('echo "grdctl command failed (exit $rc): $*" >&2', self.setup_service)
def test_setup_runs_grdctl_directly_as_root(self):
# The oneshot service runs as root; grdctl --system is called directly.
# GRD 50.x invokes pkexec internally, but the call itself is plain
# grdctl --system, not a manual pkexec invocation.
self.assertIn('grdctl --system "$@"', self.setup_service)
self.assertNotIn("runuser", self.setup_service)
self.assertNotIn("sudo", self.setup_service)
# No direct Nix-store pkexec invocation (pkgs.polkit}/bin/pkexec).
self.assertNotIn("pkgs.polkit}/bin/pkexec", self.setup_service)
def test_privilege_escalation_packages_absent_from_setup_path(self):
self.assertNotIn("pkgs.polkit", self.setup_service)
self.assertNotIn("pkgs.util-linux", self.setup_service)
def test_run_wrappers_bin_prepended_to_path(self):
# /run/wrappers/bin must be prepended to PATH before any grdctl_system
# invocation so that grdctl --system resolves the NixOS setuid pkexec.
path_export = 'export PATH="/run/wrappers/bin:$PATH"'
grdctl_marker = "grdctl_system"
script = self.setup_service
path_idx = script.find(path_export)
grdctl_idx = script.find(grdctl_marker)
self.assertGreater(path_idx, -1, f"{path_export!r} not found in setup script")
self.assertGreater(
grdctl_idx, path_idx,
"PATH export must appear before the first grdctl_system usage",
)
def test_pkexec_preflight_check(self):
# A preflight must confirm /run/wrappers/bin/pkexec is executable
# with a clear error message before any GRD configuration changes.
self.assertIn("test -x /run/wrappers/bin/pkexec", self.setup_service)
self.assertIn(
"/run/wrappers/bin/pkexec is absent or not executable",
self.setup_service,
)
def test_hub_files_are_the_source_of_truth_for_username_and_password(self):
self.assertIn('DEFAULT_USERNAME="sovran"', self.setup_service)
self.assertIn('if [ ! -f "$USERNAME_FILE" ]; then', self.setup_service)
self.assertIn(USERNAME_READ, self.setup_service)
self.assertIn(USERNAME_LENGTH_GUARD, self.setup_service)
self.assertIn('case "$USERNAME" in', self.setup_service)
self.assertIn('[A-Za-z_][A-Za-z0-9_-]*)', self.setup_service)
self.assertIn("RDP username is too long (''${#USERNAME} characters, maximum 32)", self.setup_service)
self.assertIn("RDP username must start with a letter or underscore and contain only letters, numbers, underscores, and hyphens", self.setup_service)
self.assertIn('if [ ! -f "$PASSWORD_FILE" ]; then', self.setup_service)
self.assertIn("tr -d '\\n'", self.setup_service)
self.assertIn('"$PASSWORD_FILE"', self.setup_service)
self.assertIn(SHORT_PASSWORD_GUARD, self.setup_service)
self.assertIn("RDP password is too short (''${#PASSWORD} characters, minimum 8)", self.setup_service)
self.assertIn('grdctl_system rdp set-credentials "$USERNAME" "$PASSWORD"', self.setup_service)
self.assertNotIn('grdctl --system rdp set-credentials sovran "$PASSWORD"', self.setup_service)
def test_secure_permissions_are_enforced_for_state_and_secret_files(self):
self.assertIn('"d /var/lib/gnome-remote-desktop/tls 0700', self.source)
self.assertIn("chmod 700", self.setup_service)
self.assertIn('chmod 600 "$USERNAME_FILE"', self.setup_service)
self.assertIn('chmod 600 "$PASSWORD_FILE"', self.setup_service)
self.assertIn('chmod 600 "$CRED_FILE"', self.setup_service)
self.assertIn('chmod 600 "$TLS_DIR/rdp-tls.key"', self.setup_service)
self.assertIn('chmod 644 "$TLS_DIR/rdp-tls.crt"', self.setup_service)
self.assertIn('LOCAL_IP="$(hostname -I | awk \'{print $1}\')"', self.setup_service)
self.assertIn('LOCAL_IP="127.0.0.1"', self.setup_service)
if __name__ == "__main__":
unittest.main()
@@ -1,171 +0,0 @@
import unittest
from pathlib import Path
from unittest.mock import mock_open, patch
import sys
import types
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
def _install_web_stubs():
if "fastapi" in sys.modules:
return
class _HTTPException(Exception):
def __init__(self, status_code=None, detail=None):
super().__init__(detail)
self.status_code = status_code
self.detail = detail
class _FastAPI:
def __init__(self, *args, **kwargs):
pass
def mount(self, *args, **kwargs):
return None
def add_middleware(self, *args, **kwargs):
return None
def __getattr__(self, _name):
def _decorator_factory(*args, **kwargs):
def _decorator(func):
return func
return _decorator
return _decorator_factory
class _BaseModel:
pass
class _StaticFiles:
def __init__(self, *args, **kwargs):
pass
class _Jinja2Templates:
def __init__(self, *args, **kwargs):
pass
class _BaseHTTPMiddleware:
pass
fastapi_module = types.ModuleType("fastapi")
fastapi_module.FastAPI = _FastAPI
fastapi_module.HTTPException = _HTTPException
sys.modules["fastapi"] = fastapi_module
responses_module = types.ModuleType("fastapi.responses")
responses_module.HTMLResponse = object
responses_module.JSONResponse = object
responses_module.RedirectResponse = object
sys.modules["fastapi.responses"] = responses_module
staticfiles_module = types.ModuleType("fastapi.staticfiles")
staticfiles_module.StaticFiles = _StaticFiles
sys.modules["fastapi.staticfiles"] = staticfiles_module
templating_module = types.ModuleType("fastapi.templating")
templating_module.Jinja2Templates = _Jinja2Templates
sys.modules["fastapi.templating"] = templating_module
requests_module = types.ModuleType("fastapi.requests")
requests_module.Request = object
sys.modules["fastapi.requests"] = requests_module
pydantic_module = types.ModuleType("pydantic")
pydantic_module.BaseModel = _BaseModel
sys.modules["pydantic"] = pydantic_module
starlette_base_module = types.ModuleType("starlette.middleware.base")
starlette_base_module.BaseHTTPMiddleware = _BaseHTTPMiddleware
sys.modules["starlette.middleware.base"] = starlette_base_module
starlette_middleware_module = types.ModuleType("starlette.middleware")
starlette_middleware_module.base = starlette_base_module
sys.modules["starlette.middleware"] = starlette_middleware_module
starlette_module = types.ModuleType("starlette")
starlette_module.middleware = starlette_middleware_module
sys.modules["starlette"] = starlette_module
_install_web_stubs()
from sovran_systemsos_web import server
class ServiceDetailRouterWordingTests(unittest.IsolatedAsyncioTestCase):
async def test_livekit_service_detail_includes_internal_ip(self):
service_cfg = {
"services": [
{"unit": "livekit.service", "icon": "element-call", "enabled": True, "type": "system"}
]
}
domain_eval = {
"domain_status": {"status": "ok"},
"domain_reachable": {"reachable": True},
"domain_check_steps": [],
"has_issues": False,
}
with (
patch.object(server, "load_config", return_value=service_cfg),
patch.object(server, "_read_hub_overrides", return_value=({}, None, None)),
patch.object(server.sysctl, "is_active", return_value="active"),
patch.dict(server.SERVICE_DOMAIN_MAP, {"livekit.service": "element-call"}, clear=False),
patch.dict(
server.SERVICE_PORT_REQUIREMENTS,
{"livekit.service": [{"port": "7881", "protocol": "TCP", "description": "LiveKit"}]},
clear=False,
),
patch("builtins.open", mock_open(read_data="call.example.com\n")),
patch.object(server, "_evaluate_domain_checklist", return_value=domain_eval),
patch.object(server, "_get_internal_ip", return_value="192.168.1.44"),
patch.object(server, "_save_internal_ip"),
patch.object(server, "_get_listening_ports", return_value={"tcp": {7881}, "udp": set()}),
patch.object(server, "_get_firewall_allowed_ports", return_value={"tcp": set(), "udp": set()}),
):
result = await server.api_service_detail("livekit.service")
self.assertEqual(result["internal_ip"], "192.168.1.44")
self.assertEqual(result["extra_ports"][0]["status"], "listening")
self.assertEqual(result["domain_check_steps"][-1]["label"], "Router Setup Needed")
async def test_livekit_router_step_uses_not_ready_yet_wording(self):
service_cfg = {
"services": [
{"unit": "livekit.service", "icon": "element-call", "enabled": True, "type": "system"}
]
}
domain_eval = {
"domain_status": {"status": "ok"},
"domain_reachable": {"reachable": True},
"domain_check_steps": [],
"has_issues": False,
}
with (
patch.object(server, "load_config", return_value=service_cfg),
patch.object(server, "_read_hub_overrides", return_value=({}, None, None)),
patch.object(server.sysctl, "is_active", return_value="active"),
patch.dict(server.SERVICE_DOMAIN_MAP, {"livekit.service": "element-call"}, clear=False),
patch.dict(
server.SERVICE_PORT_REQUIREMENTS,
{"livekit.service": [{"port": "7881", "protocol": "TCP", "description": "LiveKit"}]},
clear=False,
),
patch("builtins.open", mock_open(read_data="call.example.com\n")),
patch.object(server, "_evaluate_domain_checklist", return_value=domain_eval),
patch.object(server, "_get_internal_ip", return_value="192.168.1.44"),
patch.object(server, "_save_internal_ip"),
patch.object(server, "_get_listening_ports", return_value={"tcp": set(), "udp": set()}),
patch.object(server, "_get_firewall_allowed_ports", return_value={"tcp": set(), "udp": set()}),
):
result = await server.api_service_detail("livekit.service")
self.assertEqual(result["extra_ports"][0]["status"], "closed")
self.assertIn("Not ready yet", result["domain_check_steps"][-1]["detail"])
if __name__ == "__main__":
unittest.main()
@@ -1,99 +0,0 @@
"""Regression test for Starlette 1.1.0+ TemplateResponse keyword-argument style.
Prior to this fix, the three HTML routes called:
templates.TemplateResponse("name.html", {"request": request, ...})
which passes the context dict as the second positional argument. With the
updated Starlette/FastAPI versions shipped in NixOS unstable (Starlette 1.1.0,
FastAPI 0.136.3) that positional argument is the template name, causing Jinja2
to receive a dict as a cache key and raise:
TypeError: unhashable type: 'dict'
The fix updates every call to use keyword arguments:
templates.TemplateResponse(request=request, name="name.html", context={...})
"""
import ast
import unittest
from pathlib import Path
SERVER_PY = Path(__file__).resolve().parents[1] / "sovran_systemsos_web" / "server.py"
def _template_response_calls(source: str):
"""Return a list of ast.Call nodes that are TemplateResponse calls."""
tree = ast.parse(source)
calls = []
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
if isinstance(func, ast.Attribute) and func.attr == "TemplateResponse":
calls.append(node)
return calls
class TemplateResponseSignatureTests(unittest.TestCase):
def setUp(self):
self.source = SERVER_PY.read_text()
self.calls = _template_response_calls(self.source)
def test_at_least_one_template_response_call_found(self):
self.assertGreater(len(self.calls), 0, "No TemplateResponse calls found in server.py")
def test_no_old_style_positional_dict_context(self):
"""No TemplateResponse call should pass a dict literal as its second positional arg.
The old style was:
templates.TemplateResponse("name.html", {"request": request, ...})
where args[0] is a string and args[1] is a Dict node. That pattern
triggers the Starlette 1.1.0 bug.
"""
for call in self.calls:
positional = call.args
if len(positional) >= 2 and isinstance(positional[1], ast.Dict):
self.fail(
f"Found old-style TemplateResponse call at line {call.lineno}: "
"second positional argument is a dict literal. "
"Use keyword arguments (request=, name=, context=) instead."
)
def test_request_not_duplicated_in_context(self):
"""The 'request' key must not appear inside the context= dict when
request= is already passed as a dedicated keyword argument."""
for call in self.calls:
kw_dict = {kw.arg: kw.value for kw in call.keywords if isinstance(kw, ast.keyword)}
if "request" not in kw_dict:
continue # no request= kwarg, nothing to check
context_node = kw_dict.get("context")
if not isinstance(context_node, ast.Dict):
continue
for key_node in context_node.keys:
if isinstance(key_node, ast.Constant) and key_node.value == "request":
self.fail(
f"TemplateResponse at line {call.lineno} passes 'request' both as "
"request= keyword argument and inside the context dict."
)
def test_all_calls_use_keyword_arguments(self):
"""Every TemplateResponse call should use keyword arguments for request, name,
and context rather than relying on positional ordering."""
for call in self.calls:
kw_args = {kw.arg for kw in call.keywords if isinstance(kw, ast.keyword)}
self.assertIn(
"request",
kw_args,
f"TemplateResponse at line {call.lineno} is missing keyword argument 'request='.",
)
self.assertIn(
"name",
kw_args,
f"TemplateResponse at line {call.lineno} is missing keyword argument 'name='.",
)
if __name__ == "__main__":
unittest.main()
Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 KiB

-52
View File
@@ -1,52 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#153126"/>
<stop offset="55%" stop-color="#0F241B"/>
<stop offset="100%" stop-color="#091C14"/>
</linearGradient>
<linearGradient id="outerArc" x1="70" y1="40" x2="190" y2="210" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#42F39A"/>
<stop offset="45%" stop-color="#28D978"/>
<stop offset="100%" stop-color="#1AA45D"/>
</linearGradient>
<linearGradient id="innerArc" x1="90" y1="60" x2="180" y2="190" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#27C86F"/>
<stop offset="100%" stop-color="#157E49"/>
</linearGradient>
<filter id="innerShade" x="-10%" y="-10%" width="120%" height="120%">
<feOffset dx="0" dy="2"/>
<feGaussianBlur stdDeviation="5" result="blur"/>
<feComposite in="blur" in2="SourceAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 .18 0"/>
</filter>
</defs>
<rect width="256" height="256" rx="48" ry="48" fill="url(#bg)"/>
<rect x="1.5" y="1.5" width="253" height="253" rx="46.5" ry="46.5"
fill="none" stroke="rgba(255,255,255,0.08)"/>
<rect x="6" y="6" width="244" height="244" rx="42" ry="42"
fill="none" filter="url(#innerShade)"/>
<path d="M128 32 A96 96 0 1 1 58 196"
fill="none"
stroke="url(#outerArc)"
stroke-width="12"
stroke-linecap="round"/>
<path d="M128 56 A72 72 0 1 1 76 178"
fill="none"
stroke="url(#innerArc)"
stroke-width="10"
stroke-linecap="round"/>
<circle cx="128" cy="128" r="8" fill="#F2FFF7"/>
<circle cx="128" cy="128" r="18" fill="none" stroke="#7BFFC0" stroke-opacity="0.14" stroke-width="4"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

+11 -36
View File
@@ -3,6 +3,7 @@
{
imports = [
./modules/modules.nix
./iso/branding.nix
];
# ── Boot ────────────────────────────────────────────────────
@@ -10,8 +11,6 @@
boot.loader.efi.canTouchEfiVariables = true;
boot.loader.efi.efiSysMountPoint = "/boot/efi";
boot.kernelPackages = pkgs.linuxPackages_latest;
boot.kernelParams = [ "quiet" "loglevel=3" "rd.systemd.show_status=false" "udev.log_level=3" ];
boot.blacklistedKernelModules = [ "rxrpc" ];
# ── Filesystems ─────────────────────────────────────────────
fileSystems."/run/media/Second_Drive" = {
@@ -26,20 +25,14 @@
nix.settings = {
experimental-features = [ "nix-command" "flakes" ];
download-buffer-size = 524288000;
# Network resilience for cache.nixos.org (Fastly) flakiness.
connect-timeout = 10; # fail-fast on dead TCP connects (default: 0 = unlimited)
stalled-download-timeout = 90; # default 300s; retry sooner on stalled transfers
download-attempts = 7; # default 5
http-connections = 25; # cap concurrency (helps MTU/middlebox paths)
fallback = true; # build locally if a substitute can't be fetched
};
# ── Networking ──────────────────────────────────────────────
networking.hostName = "nixos";
networking.networkmanager.enable = true;
networking.firewall.enable = true;
networking.firewall.allowedUDPPorts = [ 5353 ];
networking.firewall.allowedTCPPorts = [ 8448 3051 ];
networking.firewall.allowedUDPPorts = [ 8448 3051 5353 ];
# ── Avahi (mDNS) ───────────────────────────────────────────
services.avahi = {
@@ -70,6 +63,7 @@
# ── Desktop ────────────────────────────────────────────────
services.displayManager.gdm.enable = true;
services.displayManager.gdm.autoSuspend = false;
services.displayManager.gdm.wayland = true;
services.desktopManager.gnome.enable = true;
services.printing.enable = true;
systemd.enableEmergencyMode = false;
@@ -77,16 +71,6 @@
security.pam.services.gdm-password.enableGnomeKeyring = true;
security.pam.services.gdm-autologin.enableGnomeKeyring = true;
# Declaratively guarantee the GNOME Keyring default pointer exists.
# Defining the full path ensures root doesn't accidentally lock the user out of .local
systemd.tmpfiles.rules = [
"d /home/free/.local 0700 free users -"
"d /home/free/.local/share 0700 free users -"
"d /home/free/.local/share/keyrings 0700 free users -"
"f /home/free/.local/share/keyrings/default 0600 free users - login\n"
];
# ── Audio ──────────────────────────────────────────────────
services.pulseaudio.enable = false;
security.rtkit.enable = true;
@@ -105,24 +89,15 @@
};
services.displayManager.autoLogin.enable = false;
# services.displayManager.autoLogin.user = "free"; # Disabled — user logs in via GDM
# ── Flatpak ────────────────────────────────────────────────
services.flatpak.enable = true;
systemd.services.flatpak-repo = {
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" "nss-lookup.target" ];
wants = [ "network-online.target" "nss-lookup.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
path = [ pkgs.flatpak ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
Restart = "on-failure";
RestartSec = "15s";
};
unitConfig = {
StartLimitIntervalSec = 120;
StartLimitBurst = 5;
};
script = ''
flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
'';
@@ -145,12 +120,12 @@
ranger fastfetch gedit openssl pwgen
aspell aspellDicts.en lm_sensors
hunspell hunspellDicts.en_US
synadm brave dua
synadm brave dua bitwarden-desktop
gparted pv unzip parted screen zenity
libargon2 gnome-terminal libreoffice-fresh
dig firefox wp-cli axel
lk-jwt-service livekit-libwebrtc livekit
matrix-synapse age onlyoffice-desktopeditors
dig firefox element-desktop wp-cli axel
lk-jwt-service livekit-libwebrtc livekit-cli livekit
matrix-synapse age
];
# ── Shell ──────────────────────────────────────────────────
+93
View File
@@ -0,0 +1,93 @@
# Sovran Hub — Manual Backup
The manual backup service copies critical system data from your Sovran Pro to an external USB drive, providing a third copy of your data (your Sovran Pro already maintains an automatic internal backup on its second drive).
Backups are written to:
```
<USB drive>/Sovran_SystemsOS_Backup/<timestamp>/
```
where `<timestamp>` is formatted as `YYYYMMDD_HHMMSS`.
---
## Backup Stages
The script always attempts all four stages, but skips stages that are irrelevant to the system's configured role (see [Per-Role Breakdown](#per-role-breakdown) below).
| Stage | Directory | Contents |
|-------|-----------|----------|
| **1/4 — NixOS config** | `/etc/nixos/` | Full NixOS system configuration: `role-state.nix`, `custom.nix`, flake files, and any other config managed by the Hub |
| **2/4 — Secrets** | `/etc/nix-bitcoin-secrets`, `/var/lib/domains`, `/var/lib/secrets` | Bitcoin/LND secrets, domain configurations for all web services, and Hub state files |
| **3/4 — Home directory** | `/home/` | All user home directories (`.cache/` and Trash are excluded) |
| **4/4 — LND wallet data** | `/var/lib/lnd/` | Lightning Network node wallet and channel data (log files excluded) |
---
## Per-Role Breakdown
The script detects the system role at runtime by reading `/var/lib/sovran-hub/config.json` (falling back to `/etc/nixos/role-state.nix`) and adjusts its behaviour accordingly.
### Server + Desktop (default)
All services are enabled: Bitcoin, Matrix Synapse, Vaultwarden, WordPress, Nextcloud.
| Stage | Status | Notes |
|-------|--------|-------|
| Stage 1 — NixOS config | ✅ Backed up | Full server configuration |
| Stage 2 — Secrets | ✅ Backed up | Bitcoin secrets, domain configs, and Hub state |
| Stage 3 — Home directory | ✅ Backed up | Desktop user data |
| Stage 4 — LND wallet | ✅ Backed up | Lightning wallet and channel data |
This produces the largest backup. All four stages generate meaningful data.
### Desktop Only
All server services are disabled (`bitcoin = false`, `synapse = false`, `vaultwarden = false`, `wordpress = false`, `nextcloud = false`). Only GNOME desktop is active.
| Stage | Status | Notes |
|-------|--------|-------|
| Stage 1 — NixOS config | ✅ Backed up | Simpler config (no server services) |
| Stage 2 — Secrets | ⚠️ Partial | `/etc/nix-bitcoin-secrets` is **skipped** (not applicable for Desktop Only role). `/var/lib/domains` and `/var/lib/secrets` (Hub state) are still backed up if present |
| Stage 3 — Home directory | ✅ Backed up | **The most important data for this role** |
| Stage 4 — LND wallet | ⏭️ Skipped | Explicitly skipped — not applicable for Desktop Only role |
This produces the smallest and fastest backup. Stages 1 and 3 are the primary sources of meaningful data.
### Node (Bitcoin-only)
Only the Bitcoin ecosystem is active: `bitcoind`, `electrs`, `lnd`, `rtl`, `btcpay`, `mempool`, and `bip110`. All other server services are disabled.
| Stage | Status | Notes |
|-------|--------|-------|
| Stage 1 — NixOS config | ✅ Backed up | Node-specific configuration |
| Stage 2 — Secrets | ✅ Backed up | Bitcoin secrets and Hub state. `/var/lib/domains` may be minimal (BTCPay runs but is not exposed via Caddy) |
| Stage 3 — Home directory | ✅ Backed up | User data |
| Stage 4 — LND wallet | ✅ Backed up | **Critical** — Lightning wallet and channel data |
All four stages run, matching Server + Desktop behaviour. The `/var/lib/domains` directory may be sparsely populated since non-Bitcoin web services are not configured.
---
## Backup Manifest
After all stages complete, the script writes a `BACKUP_MANIFEST.txt` file inside the timestamped backup directory. This file records the date, hostname, detected role, target drive, and a directory listing of everything that was backed up.
---
## Running the Backup
The backup is triggered from the Sovran Hub web UI. You can also run it directly:
```bash
# Auto-detect the first external USB drive
sudo bash /path/to/sovran-hub-backup.sh
# Specify a target drive explicitly
sudo BACKUP_TARGET=/run/media/<user>/<drive> bash /path/to/sovran-hub-backup.sh
```
The script requires at least **10 GB** of free space on the target drive and will refuse to write to internal system drives.
Logs are written to `/var/log/sovran-hub-backup.log` and the current status (`RUNNING`, `SUCCESS`, or `FAILED`) is tracked in `/var/log/sovran-hub-backup.status`.
+472
View File
@@ -0,0 +1,472 @@
# Remote Deployment via Headscale (Self-Hosted Tailscale)
This guide covers the Sovran Systems remote deployment system built on [Headscale](https://headscale.net) — a self-hosted, open-source implementation of the Tailscale coordination server. Freshly booted ISOs automatically join a private WireGuard mesh VPN without any per-machine key pre-generation.
---
## Architecture Overview
```
┌─────────────────────────────────────────────────────────┐
│ Internet │
└────────────┬─────────────────────┬──────────────────────┘
│ │
▼ ▼
┌────────────────────┐ ┌─────────────────────────────────┐
│ Admin Workstation │ │ Sovran VPS │
│ │ │ ┌─────────────────────────────┐ │
│ tailscale up │ │ │ Headscale (port 8080) │ │
│ --login-server │◄──┼─►│ Coordination server │ │
│ hs.example.com │ │ ├─────────────────────────────┤ │
│ │ │ │ Provisioning API (9090) │ │
└────────────────────┘ │ │ POST /register │ │
│ │ GET /machines │ │
│ │ GET /health │ │
│ ├─────────────────────────────┤ │
│ │ Caddy (80/443) │ │
│ │ hs.example.com → :8080 │ │
│ │ prov.example.com → :9090 │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────┘
│ WireGuard mesh (Tailnet)
┌─────────────────────────────────┐
│ Deploy Target Machine │
│ │
│ Boot live ISO → │
│ sovran-auto-provision → │
│ POST /register → │
│ tailscale up --authkey=... │
└─────────────────────────────────┘
```
**Components:**
- **`sovran-provisioner.nix`** — NixOS module deployed on a separate VPS; runs Headscale + provisioning API + Caddy.
- **Live ISO** (`iso/common.nix`) — Auto-registers with the provisioning server and joins the Tailnet on boot.
- **`remote-deploy.nix`** — Post-install NixOS module that uses Tailscale/Headscale for ongoing access.
---
## Part 1: VPS Setup — Deploy `sovran-provisioner.nix`
### Prerequisites
- A NixOS VPS (any provider) with a public IP
- Two DNS A records pointing to your VPS:
- `hs.yourdomain.com` → VPS IP (Headscale coordination server)
- `prov.yourdomain.com` → VPS IP (Provisioning API)
- Ports 80, 443 (TCP) and 3478 (UDP, STUN/DERP) open in your VPS firewall
### DNS Records
| Type | Name | Value |
|------|-----------------------|------------|
| A | `hs.yourdomain.com` | `<VPS IP>` |
| A | `prov.yourdomain.com` | `<VPS IP>` |
### NixOS Configuration
Add the following to your VPS's `/etc/nixos/configuration.nix`:
```nix
{ config, lib, pkgs, ... }:
{
imports = [
./hardware-configuration.nix
/path/to/sovran-provisioner.nix # or fetch from the repo
];
sovranProvisioner = {
enable = true;
domain = "prov.yourdomain.com";
headscaleDomain = "hs.yourdomain.com";
# Optional: customise defaults
headscaleUser = "sovran-deploy"; # namespace for deploy machines
adminUser = "admin"; # namespace for your workstation
keyExpiry = "1h"; # pre-auth keys expire after 1 hour
rateLimitMax = 10; # max registrations per window
rateLimitWindow = 60; # window in seconds
};
# Required for Caddy ACME (Let's Encrypt)
networking.hostName = "sovran-vps";
system.stateVersion = "24.11";
}
```
### Deploy
```bash
nixos-rebuild switch
```
Caddy will automatically obtain TLS certificates via Let's Encrypt.
### Retrieve the Enrollment Token
```bash
cat /var/lib/sovran-provisioner/enroll-token
```
Keep this token secret — it is used to authenticate ISO registrations. The token is auto-generated on first boot and stored at this path. You never need to set it manually. Just `cat` it from the VPS and copy it to `iso/secrets/enroll-token` before building the ISO.
---
## Part 2: Admin Workstation Setup
Join your Tailnet as an admin so you can reach deployed machines:
### Install Tailscale
Follow the [Tailscale installation guide](https://tailscale.com/download) for your OS, or on NixOS:
```nix
services.tailscale.enable = true;
```
### Join the Tailnet
```bash
sudo tailscale up --login-server https://hs.yourdomain.com --accept-dns=false
```
> **Note:** The `--accept-dns=false` flag prevents Tailscale from taking over your system DNS resolver. This is important if you are behind a VPN (see [Troubleshooting](#troubleshooting) below).
Tailscale prints a URL. Open it and copy the node key (starts with `mkey:`).
### Approve the Node in Headscale
On the VPS, first find the numeric user ID for the `admin` user, then register the node:
```bash
# Look up the numeric ID for the admin user (Headscale 0.28.0 requires -u <id>)
headscale users list -o json
# Register the node using the numeric user ID
headscale nodes register -u <admin-user-id> --key mkey:xxxxxxxxxxxxxxxx
```
Your workstation is now on the Tailnet. You can list nodes:
```bash
headscale nodes list
```
---
## Part 3: Building the Deploy ISO
### Add Secrets (gitignored)
The secrets directory `iso/secrets/` is gitignored. Populate it before building:
```bash
# Copy the enrollment token from the VPS
ssh root@<VPS> cat /var/lib/sovran-provisioner/enroll-token > iso/secrets/enroll-token
# Set the provisioner URL
echo "https://prov.yourdomain.com" > iso/secrets/provisioner-url
```
These files are baked into the ISO at build time. If the files are absent the ISO still builds — the auto-provision service exits cleanly with "No enroll token found, skipping auto-provision", leaving DIY users unaffected.
### Build the ISO
```bash
nix build .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage
```
The resulting ISO is in `./result/iso/`.
---
## Part 4: Deployment Workflow
### Step-by-Step
1. **Hand the ISO to the remote person** — they burn it to a USB drive and boot.
2. **ISO boots and auto-registers**`sovran-auto-provision.service` runs automatically:
- Reads `enroll-token` and `provisioner-url` from `/etc/sovran/`
- `POST https://prov.yourdomain.com/register` with hostname + MAC
- Receives a Headscale pre-auth key
- Runs `tailscale up --login-server=... --authkey=...`
- The machine appears in `headscale nodes list` within ~30 seconds
3. **Approve the node (if not using auto-approve)** — on the VPS:
```bash
headscale nodes list
# Note the node key for the new machine
```
4. **SSH from your workstation** — once the machine is on the Tailnet:
```bash
# Get the machine's Tailscale IP
headscale nodes list | grep sovran-deploy-
# SSH in
ssh root@100.64.x.x # password: sovran-remote (live ISO default)
```
5. **Run the headless installer**:
The `--deploy-key` is your SSH public key that gets injected into `root`'s `authorized_keys` on the deployed machine. This grants full root access for initial setup. Generate it once on your workstation if you haven't already:
```bash
ssh-keygen -t ed25519 -f ~/.ssh/sovran-deploy -C "sovran-deploy"
```
After deployment is complete and you disable deploy mode, this key is removed.
```bash
sudo sovran-install-headless.sh \
--disk /dev/sda \
--role server \
--deploy-key "$(cat ~/.ssh/sovran-deploy.pub)" \
--headscale-server "https://hs.yourdomain.com" \
--headscale-key "$(headscale preauthkeys create -u $(headscale users list -o json | jq -r '.[] | select(.name=="sovran-deploy") | .id') -e 2h -o json | jq -r '.key')"
```
6. **Machine reboots into Sovran_SystemsOS** — `deploy-tailscale-connect.service` runs:
- Reads `/var/lib/secrets/headscale-authkey`
- Joins the Tailnet with a deterministic hostname (`sovran-<hostname>`)
7. **Post-install SSH and RDP**:
```bash
# SSH over Tailnet
ssh root@<tailscale-ip>
# RDP over Tailnet (desktop role) — Sovran_SystemsOS uses GNOME Remote Desktop (native Wayland RDP)
# Retrieve the auto-generated RDP password:
ssh root@<tailscale-ip> cat /var/lib/gnome-remote-desktop/rdp-password
# Then connect with any RDP client (Remmina, GNOME Connections, Microsoft Remote Desktop):
# Host: <tailscale-ip>:3389 User: sovran Password: <from above>
```
8. **Disable deploy mode** — edit `/etc/nixos/custom.nix` on the target, set `enable = false`, then:
```bash
sudo nixos-rebuild switch
```
---
## Part 5: Post-Install Access
### SSH
```bash
# Over Tailnet
ssh root@100.64.x.x
```
### RDP (desktop/server roles)
Sovran_SystemsOS uses **GNOME Remote Desktop** (native Wayland RDP — not xfreerdp). The RDP service auto-generates credentials on first boot.
**Username:** `sovran`
**Password:** auto-generated — retrieve it via SSH:
```bash
ssh root@<tailscale-ip> cat /var/lib/gnome-remote-desktop/rdp-password
```
Connect using any RDP client (Remmina, GNOME Connections, Microsoft Remote Desktop) to `<tailscale-ip>:3389`.
---
## Security Model
| Concern | Mitigation |
|---------|-----------|
| Enrollment token theft | Token only triggers key generation; it does not grant access to the machine itself |
| Rogue device joins Tailnet | Visible in `headscale nodes list`; removable instantly with `headscale nodes delete` |
| Pre-auth key reuse | Keys are ephemeral and expire in 1 hour (configurable via `keyExpiry`) |
| Rate limiting | Provisioning API limits to 10 registrations/minute by default (configurable) |
| SSH access | Requires ed25519 key injected at install time; password authentication disabled |
| Credential storage | Auth key written to `/var/lib/secrets/headscale-authkey` (mode 600) on the installed OS |
### Token Rotation
To rotate the enrollment token:
1. On the VPS:
```bash
openssl rand -hex 32 > /var/lib/sovran-provisioner/enroll-token
chmod 600 /var/lib/sovran-provisioner/enroll-token
```
2. Update `iso/secrets/enroll-token` and rebuild the ISO.
Old ISOs with the previous token will fail to register (receive 401).
---
## Monitoring
### List Active Tailnet Nodes
```bash
# On the VPS
headscale nodes list
```
### List Registered Machines (Provisioning API)
```bash
curl -s -H "Authorization: Bearer $(cat /var/lib/sovran-provisioner/enroll-token)" \
https://prov.yourdomain.com/machines | jq .
```
### Health Check
```bash
curl https://prov.yourdomain.com/health
# {"status": "ok"}
```
### Provisioner Logs
```bash
journalctl -u sovran-provisioner -f
```
### Headscale Logs
```bash
journalctl -u headscale -f
```
---
## Cleanup
### Remove a Machine from the Tailnet
```bash
headscale nodes list
headscale nodes delete --identifier <id>
```
### Disable Deploy Mode on an Installed Machine
Edit `/etc/nixos/custom.nix`:
```nix
sovran_systemsOS.deploy.enable = false;
```
Then rebuild:
```bash
nixos-rebuild switch
```
This stops the Tailscale connect service.
### Revoke All Active Pre-Auth Keys
```bash
# List pre-auth keys (Headscale 0.28.0: no --user flag on list)
headscale preauthkeys list
# Expire a specific key — use numeric user ID (-u <id>)
# First find the user ID:
headscale users list -o json
# Then expire the key:
headscale preauthkeys expire -u <user-id> --key <key>
```
---
## Troubleshooting
### VPN Conflicts (Mullvad, WireGuard, etc.)
**Symptom:** `tailscale up` hangs or fails with `connection refused` on port 443, even though `curl https://hs.yourdomain.com/health` works fine.
**Cause:** VPNs like Mullvad route all traffic — including Tailscale's control-plane connections — through the VPN tunnel. Additionally, Tailscale's DNS handler (`--accept-dns=true` by default) hijacks DNS resolution and may prevent correct resolution of your Headscale server even when logged out.
**Solution:**
1. Disconnect your VPN temporarily and retry `tailscale up`.
2. If you need the VPN active, use split tunneling to exclude `tailscaled`:
```bash
# Mullvad CLI
mullvad split-tunnel add $(pidof tailscaled)
```
Or in the Mullvad GUI: **Settings → Split tunneling → Add tailscaled**.
3. Always pass `--accept-dns=false` when enrolling to avoid DNS hijacking:
```bash
sudo tailscale up --login-server https://hs.yourdomain.com --authkey <key> --accept-dns=false
```
---
### "RATELIMIT" in tailscaled Logs
**Symptom:** `journalctl -u tailscaled` shows lines like:
```
[RATELIMIT] format("Received error: %v")
```
**Cause:** This is **NOT** a server-side rate limit from Headscale. It is tailscaled's internal log suppressor de-duplicating repeated connection-refused error messages. The real underlying error is `connection refused`.
**What to check:**
1. Is Headscale actually running? `curl https://hs.yourdomain.com/health`
2. Is your VPN blocking the connection? (see VPN Conflicts above)
3. Is there a firewall blocking port 443?
---
### "connection refused" on Port 443
If `tailscale up` fails but `curl` works, the issue is usually DNS or VPN:
```bash
# Does curl reach Headscale successfully?
curl -v https://hs.yourdomain.com/health
# Force IPv4 vs IPv6 to identify if it's an address-family issue
curl -4 https://hs.yourdomain.com/health
curl -6 https://hs.yourdomain.com/health
# Check what IP headscale resolves to
dig +short hs.yourdomain.com
# What resolver is the system using?
cat /etc/resolv.conf
```
If curl works but tailscale doesn't, tailscaled may be using a different DNS resolver (e.g. its own `100.100.100.100` stub resolver). Fix: pass `--accept-dns=false`.
---
### Headscale User ID Lookup (0.28.0)
Headscale 0.28.0 removed `--user <name>` in favour of `-u <numeric-id>`. To find the numeric ID for a user:
```bash
headscale users list -o json
# Output: [{"id": "1", "name": "sovran-deploy", ...}, ...]
# One-liner to get the ID for a specific user
headscale users list -o json | jq -r '.[] | select(.name=="sovran-deploy") | .id'
```
Then use the numeric ID in subsequent commands:
```bash
headscale preauthkeys create -u 1 -e 1h -o json
headscale nodes register -u 1 --key mkey:xxxx
```
---
## Reference
| Component | Port | Protocol | Description |
|-----------|------|----------|-------------|
| Caddy | 80 | TCP | HTTP → HTTPS redirect |
| Caddy | 443 | TCP | HTTPS (Let's Encrypt) |
| Headscale | 8080 | TCP | Coordination server (proxied by Caddy) |
| Provisioner | 9090 | TCP | Registration API (proxied by Caddy) |
| DERP/STUN | 3478 | UDP | WireGuard relay fallback |
| Tailscale | N/A | WireGuard | Mesh VPN between nodes |
+259
View File
@@ -0,0 +1,259 @@
# Tech Support: Security Design, User Flow, and Incident Response
## Overview
The Sovran Hub includes a **Tech Support** feature that lets Sovran Systems
staff remotely diagnose and fix issues on a user's machine via SSH — without
ever having access to private keys or wallet funds.
Wallet protection is the default. The user must make an active, time-limited
choice to grant support staff access to wallet files, and can revoke that
access at any time.
---
## Implementation Details
### Restricted User Instead of Root
When a user enables support access the Hub:
1. Ensures the `sovran-support` system user exists (declared declaratively in
`modules/core/tech-support.nix`; the Hub also provisions it on demand as a
fallback on non-NixOS systems).
2. Writes the Sovran Systems public SSH key **only** to
`/var/lib/sovran-support/.ssh/authorized_keys`, not to root's
`authorized_keys`.
3. Applies POSIX ACLs (`setfacl -R -m u:sovran-support:---`) to every wallet
directory that exists on disk, denying all access by the support user.
4. Records a timestamped `SUPPORT_ENABLED` event in the audit log at
`/var/log/sovran-support-audit.log`.
When the session ends (or if the Hub cannot create the restricted user), the
key is removed and all ACLs are revoked immediately.
### Protected Wallet Paths
The following directories are locked by default when a support session starts:
| Path | Contents |
|------|----------|
| `/etc/nix-bitcoin-secrets` | nix-bitcoin generated secrets |
| `/var/lib/bitcoind` | Bitcoin Core chainstate and wallet |
| `/var/lib/lnd` | LND wallet and channel database |
| `/home` | User home directories |
Paths are only locked if they exist on disk at the time the session starts.
### POSIX ACL Mechanics
POSIX ACLs on Linux handle access checks in this order:
1. If the process UID matches the file owner UID → use owner permissions
2. **If there is a matching named-user ACL entry → use that entry's
permissions** (clamped by the mask entry)
3. If any group matches → use group permissions
4. Otherwise → use "other" permissions
Setting `u:sovran-support:---` creates a named-user ACL entry with no
permissions. Because the named-user entry is checked before the group/other
entries, the support user cannot access those directories regardless of the
"other" permission bits.
`setfacl` and `getfacl` are provided by the `acl` package, which is added to
`environment.systemPackages` by `modules/core/tech-support.nix`.
### Fallback to Root (When Restricted User Cannot Be Created)
If the `sovran-support` user does not exist and cannot be created (e.g.,
`users.mutableUsers = false` and the declarative module has not been deployed
yet), the Hub falls back to adding the support key to root's
`authorized_keys`. The modal prominently warns the user when this has happened
so they can decide whether to end the session.
### Audit Log
Every session event is appended to `/var/log/sovran-support-audit.log`:
```
[2025-01-15 14:32:01 UTC] SUPPORT_ENABLED: restricted_user=True acl_applied=True protected_paths=4
[2025-01-15 14:45:00 UTC] WALLET_UNLOCKED: duration=3600s expires=2025-01-15 15:45:00 UTC
[2025-01-15 15:45:00 UTC] WALLET_RELOCKED: auto-expired
[2025-01-15 16:01:22 UTC] SUPPORT_DISABLED
```
The last 100 lines of this log are accessible from the Hub UI while a session
is active (or after it ends, until the page is refreshed).
---
## Security Tradeoffs
### What This Protects Against
- **Accidental wallet exposure** — support staff cannot read wallet files
during a normal session; they must ask the user to explicitly grant access.
- **Credential theft** — private keys in the wallet directories are not
visible to the `sovran-support` user by default.
- **Scope creep** — the restricted user account limits the blast radius of an
SSH session compared to direct root access.
### Known Limitations
| Limitation | Mitigation |
|------------|------------|
| Support user still has system-wide bash access | Restrict with `ForceCommand` or AppArmor in the NixOS config if a narrower scope is required |
| ACLs apply only to directories that exist at session start | If new wallet directories are created during a session, they are not auto-protected. Re-lock and re-enable support to pick up new paths |
| Root fallback grants full access | The Hub UI warns the user prominently; users should end the session if they are uncomfortable |
| `setfacl` / ACL filesystem support required | The `acl` package is declared in `tech-support.nix`; most Linux filesystems (ext4, btrfs, xfs) support ACLs by default |
| Wallet access grant is time-limited but lazy-expired | Expiry is checked on the next `/api/support/status` poll (every 10 seconds in the UI); there is a small window after expiry |
### Defense-in-Depth Recommendations
For environments that require stronger isolation, consider layering one or
more additional controls:
- **`ForceCommand`** in `sshd_config` (or `~/.ssh/authorized_keys` command
prefix) to restrict the support user to a specific diagnostic script.
- **`ChrootDirectory`** in the `sshd_config` `Match User sovran-support` block
to confine the session to a prepared directory tree.
- **AppArmor or SELinux** profiles that deny the support process read access
to wallet paths at the kernel level.
- **Namespace/bind-mount overlays** (e.g., via a wrapper systemd unit) to
present a sanitized filesystem view.
---
## User Flow
```
User opens Hub → Clicks "Tech Support" in sidebar
Modal: "Need help from Sovran Systems?"
• Explains what will happen
• Shows Wallet Protection notice
• User clicks "Enable Support Access"
Hub: 1. Creates / verifies sovran-support user
2. Writes SSH key to that user's authorized_keys
3. Applies POSIX ACL deny on all existing wallet paths
4. Saves session metadata + writes SUPPORT_ENABLED to audit log
Modal: "Support Access is Active"
• Live session duration timer
• Wallet Files: Protected panel
Optional: "Grant Wallet Access" (time-limited, user-chosen)
• "End Support Session" button
• "View Audit Log" button
(User grants wallet access)
Hub: • Removes ACL deny entries
• Records WALLET_UNLOCKED event with expiry time
• Starts countdown timer in UI
(Timer expires or user clicks "Re-lock Wallet Now")
Hub: • Re-applies ACL deny entries
• Removes WALLET_UNLOCK_FILE
• Records WALLET_RELOCKED event
(User clicks "End Support Session")
Hub: 1. Removes SSH key from sovran-support authorized_keys
2. Removes SSH key from root authorized_keys (legacy cleanup)
3. Revokes any wallet unlock, re-applies ACL deny
4. Verifies key is gone
5. Records SUPPORT_DISABLED event
Modal: "Support Session Ended — SSH key removed"
• Shows verified removal status
```
---
## Incident Response
### Scenario 1 — You accidentally granted wallet access and are unsure what was copied
**Immediate steps:**
1. Click **"Re-lock Wallet Now"** in the Hub modal, or click
**"End Support Session"** to simultaneously revoke SSH access and wallet
access.
2. Open the **Audit Log** from the Hub modal and note the timestamps of
`WALLET_UNLOCKED` and `WALLET_RELOCKED` events.
3. Check `/var/log/auth.log` (or `journalctl -u sshd`) for SSH login events
by `sovran-support` during the unlocked window.
**Assessment:**
- If no SSH login occurred during the wallet-unlocked window, your keys are
safe.
- If an SSH login did occur, treat private keys as potentially compromised.
**Recovery if keys may be compromised:**
| Wallet | Recovery action |
|--------|----------------|
| LND | Move all funds out using `lncli sendcoins` to a freshly generated on-chain address; close channels; recreate wallet |
| Sparrow | Sweep funds to a new wallet generated on an air-gapped device |
| Bisq | Withdraw all BSQ and BTC to external wallets; delete the Bisq data directory and recreate |
| nix-bitcoin secrets | Rotate all secrets with `nix-bitcoin-secrets generate` and redeploy |
**Report the incident:**
Contact Sovran Systems immediately at support@sovransystems.com with:
- The audit log output (`/var/log/sovran-support-audit.log`)
- The SSH auth log for the affected time window
- A description of what you were troubleshooting
---
### Scenario 2 — Support session cannot be ended (button fails or server is unresponsive)
**Manual key removal (run as root on the device):**
```bash
# Remove from support user's authorized_keys
rm -f /var/lib/sovran-support/.ssh/authorized_keys
# Remove from root's authorized_keys (fallback / legacy)
sed -i '/sovransystemsos-support/d' /root/.ssh/authorized_keys
# Remove wallet unlock state
rm -f /var/lib/secrets/support-wallet-unlock
# Re-apply wallet ACL protections
setfacl -R -m u:sovran-support:--- /etc/nix-bitcoin-secrets \
/var/lib/bitcoind /var/lib/lnd /home 2>/dev/null || true
# Restart sshd to drop any active connections
systemctl restart sshd
```
---
### Scenario 3 — You see an unexpected SUPPORT_ENABLED in the audit log
This should never happen without physical or remote access to the Hub web
interface. If you see an unexpected entry:
1. Immediately run the manual key removal commands above.
2. Change the Sovran Hub web interface password.
3. Check `/var/log/nginx/access.log` (or Caddy access logs) for unexpected
requests to `/api/support/enable`.
4. Consider rebooting the device to clear any in-memory state.
5. Report the incident to Sovran Systems.
---
*This document is part of the Sovran_SystemsOS repository. For the
authoritative and up-to-date version, see the repository.*
Generated
+95 -44
View File
@@ -1,15 +1,34 @@
{
"nodes": {
"btc-clients": {
"bip110": {
"inputs": {
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1783519926,
"narHash": "sha256-2zwAN4lNitHFrHVnRZG3YcvpdtWOoF0cOBstxMeB1KI=",
"lastModified": 1775155316,
"narHash": "sha256-4H8aEChZ6rra9jd8OcVHgHs3IuzKzpDt4PPtsPJrkyM=",
"owner": "emmanuelrosa",
"repo": "bitcoin-knots-bip-110-nix",
"rev": "663ea34f6f846f48c385a73d4581ba599bb5bbc0",
"type": "github"
},
"original": {
"owner": "emmanuelrosa",
"repo": "bitcoin-knots-bip-110-nix",
"type": "github"
}
},
"btc-clients": {
"inputs": {
"nixpkgs": "nixpkgs_2",
"oldNixpkgs": "oldNixpkgs"
},
"locked": {
"lastModified": 1775833364,
"narHash": "sha256-RsaXYEUUF1g/a5ET0QKTX1p3SCaCIAZYCZDLe8htv88=",
"owner": "emmanuelrosa",
"repo": "btc-clients-nix",
"rev": "731a1e11c2fefb14f0aa4b1f03cfa85c19c28d71",
"rev": "17f676710a6e9483f30b24eb2948bf51c961203a",
"type": "github"
},
"original": {
@@ -52,11 +71,11 @@
]
},
"locked": {
"lastModified": 1782949081,
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
"lastModified": 1772408722,
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
"type": "github"
},
"original": {
@@ -87,16 +106,16 @@
"inputs": {
"extra-container": "extra-container",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs_2",
"nixpkgs": "nixpkgs_3",
"nixpkgs-25_05": "nixpkgs-25_05",
"nixpkgs-unstable": "nixpkgs-unstable"
},
"locked": {
"lastModified": 1779253922,
"narHash": "sha256-k5DpYVfyy27ELuEiV+51EfVg7B6vKUW63NWeA6eKGd0=",
"lastModified": 1767721199,
"narHash": "sha256-UzRxDiJlopBGPTjyhCdMP+QdTwXK+l+y45urXCyH69A=",
"owner": "fort-nix",
"repo": "nix-bitcoin",
"rev": "1496f842477976c085cd96f1837ea12444014088",
"rev": "5b532698ce9e8bd79b07d77ab4fc60e1a8408f73",
"type": "github"
},
"original": {
@@ -108,26 +127,27 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1782911660,
"narHash": "sha256-PbR+tJ5E/Ux+01UtdFKqblccVA4/FgWbkym4ev3VHHQ=",
"lastModified": 1775054576,
"narHash": "sha256-iiIr1hlTMu2LLARsUYtiqlE90tqocqIMVLK2fIzB/UY=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "cf720c15e108d432d29041cc5a185630809acefb",
"rev": "fc4b9b74d4b0bdbf3c97fef4bd34c05225172912",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "master",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-25_05": {
"locked": {
"lastModified": 1767313136,
"narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=",
"lastModified": 1767051569,
"narHash": "sha256-0MnuWoN+n1UYaGBIpqpPs9I9ZHW4kynits4mrnh1Pk4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d",
"rev": "40ee5e1944bebdd128f9fbada44faefddfde29bd",
"type": "github"
},
"original": {
@@ -139,27 +159,27 @@
},
"nixpkgs-stable": {
"locked": {
"lastModified": 1783856661,
"narHash": "sha256-ZGP04e+Q6WyQJGA9ZvI5CL6+heGQldbAG9U1T9NGvmU=",
"lastModified": 1751274312,
"narHash": "sha256-/bVBlRpECLVzjV19t5KMdMFWSwKLtb5RyXdjz3LJT+g=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "569d578509928497eddc3fdbf94a799027050be4",
"rev": "50ab793786d9de88ee30ec4e4c24fb4236fc2674",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-26.05",
"ref": "nixos-24.11",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-unstable": {
"locked": {
"lastModified": 1778869304,
"narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=",
"lastModified": 1767364772,
"narHash": "sha256-fFUnEYMla8b7UKjijLnMe+oVFOz6HjijGGNS1l7dYaQ=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "d233902339c02a9c334e7e593de68855ad26c4cb",
"rev": "16c7794d0a28b5a37904d55bcca36003b9109aaa",
"type": "github"
},
"original": {
@@ -171,11 +191,26 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1778737229,
"narHash": "sha256-6xWoytx8jFW4PF1GjRm/i/53trbpKGfz6zjzQGBr4cI=",
"lastModified": 1775054576,
"narHash": "sha256-iiIr1hlTMu2LLARsUYtiqlE90tqocqIMVLK2fIzB/UY=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "d7a713c0b7e47c908258e71cba7a2d77cc8d71d5",
"rev": "fc4b9b74d4b0bdbf3c97fef4bd34c05225172912",
"type": "github"
},
"original": {
"owner": "nixos",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1767480499,
"narHash": "sha256-8IQQUorUGiSmFaPnLSo2+T+rjHtiNWc+OAzeHck7N48=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "30a3c519afcf3f99e2c6df3b359aec5692054d92",
"type": "github"
},
"original": {
@@ -185,13 +220,13 @@
"type": "github"
}
},
"nixpkgs_3": {
"nixpkgs_4": {
"locked": {
"lastModified": 1783776592,
"narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=",
"lastModified": 1775710090,
"narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3",
"rev": "4c1018dae018162ec878d42fec712642d214fdfa",
"type": "github"
},
"original": {
@@ -201,13 +236,13 @@
"type": "github"
}
},
"nixpkgs_4": {
"nixpkgs_5": {
"locked": {
"lastModified": 1783791668,
"narHash": "sha256-zbcZ1dmBTPfJ7Mlqh/yLEPGpgJnwuv4Xr1xucy2WqMA=",
"lastModified": 1774701658,
"narHash": "sha256-CIS/4AMUSwUyC8X5g+5JsMRvIUL3YUfewe8K4VrbsSQ=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "716c7a2664ca8325617b8a7fbb609273f2c4cae7",
"rev": "b63fe7f000adcfa269967eeff72c64cafecbbebe",
"type": "github"
},
"original": {
@@ -220,15 +255,15 @@
"nixvim": {
"inputs": {
"flake-parts": "flake-parts",
"nixpkgs": "nixpkgs_4",
"nixpkgs": "nixpkgs_5",
"systems": "systems_2"
},
"locked": {
"lastModified": 1783941741,
"narHash": "sha256-F+3M1IZrJa920cx2/k2AMKqedEodxLF7COJVkLJwUBo=",
"lastModified": 1775837497,
"narHash": "sha256-L17VI03w/wVXvc1SK7EI1muLqHxD3+esYPPzgQvvdOE=",
"owner": "nix-community",
"repo": "nixvim",
"rev": "e6715f01d9f56f07a27a01386b85ae22b06f0705",
"rev": "a587a96a48c705609bfd2ad23f9ae5961eb0d373",
"type": "github"
},
"original": {
@@ -237,11 +272,28 @@
"type": "github"
}
},
"oldNixpkgs": {
"locked": {
"lastModified": 1727619874,
"narHash": "sha256-a4Jcd+vjQAzF675/7B1LN3U2ay22jfDAVA8pOml5J/0=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "6710d0dd013f55809648dfb1265b8f85447d30a6",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "6710d0dd013f55809648dfb1265b8f85447d30a6",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"bip110": "bip110",
"btc-clients": "btc-clients",
"nix-bitcoin": "nix-bitcoin",
"nixpkgs": "nixpkgs_3",
"nixpkgs": "nixpkgs_4",
"nixpkgs-stable": "nixpkgs-stable",
"nixvim": "nixvim"
}
@@ -263,16 +315,15 @@
},
"systems_2": {
"locked": {
"lastModified": 1774449309,
"narHash": "sha256-brhZ8DmuGtzkCYHJg4HEd602amKm89Y9ytsFZ5uWD1w=",
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "c29398b59d2048c4ab79345812849c9bd15e9150",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"ref": "future-26.11",
"repo": "default",
"type": "github"
}
+4 -2
View File
@@ -6,10 +6,11 @@
nix-bitcoin.url = "github:fort-nix/nix-bitcoin/release";
nixvim.url = "github:nix-community/nixvim";
btc-clients.url = "github:emmanuelrosa/btc-clients-nix";
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05";
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-24.11";
bip110.url = "github:emmanuelrosa/bitcoin-knots-bip-110-nix";
};
outputs = { self, nixpkgs, nix-bitcoin, nixvim, btc-clients, nixpkgs-stable, ... }:
outputs = { self, nixpkgs, nix-bitcoin, nixvim, btc-clients, nixpkgs-stable, bip110, ... }:
let
overlay-stable = final: prev: {
@@ -55,6 +56,7 @@
btc-clients.packages.${pkgs.system}.bisq2
btc-clients.packages.${pkgs.system}.sparrow
];
sovran_systemsOS.packages.bip110 = bip110.packages.${pkgs.system}.bitcoind-knots-bip-110;
};
};
};
-52
View File
@@ -1,52 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#153126"/>
<stop offset="55%" stop-color="#0F241B"/>
<stop offset="100%" stop-color="#091C14"/>
</linearGradient>
<linearGradient id="outerArc" x1="70" y1="40" x2="190" y2="210" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#42F39A"/>
<stop offset="45%" stop-color="#28D978"/>
<stop offset="100%" stop-color="#1AA45D"/>
</linearGradient>
<linearGradient id="innerArc" x1="90" y1="60" x2="180" y2="190" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#27C86F"/>
<stop offset="100%" stop-color="#157E49"/>
</linearGradient>
<filter id="innerShade" x="-10%" y="-10%" width="120%" height="120%">
<feOffset dx="0" dy="2"/>
<feGaussianBlur stdDeviation="5" result="blur"/>
<feComposite in="blur" in2="SourceAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 .18 0"/>
</filter>
</defs>
<rect width="256" height="256" rx="48" ry="48" fill="url(#bg)"/>
<rect x="1.5" y="1.5" width="253" height="253" rx="46.5" ry="46.5"
fill="none" stroke="rgba(255,255,255,0.08)"/>
<rect x="6" y="6" width="244" height="244" rx="42" ry="42"
fill="none" filter="url(#innerShade)"/>
<path d="M128 32 A96 96 0 1 1 58 196"
fill="none"
stroke="url(#outerArc)"
stroke-width="12"
stroke-linecap="round"/>
<path d="M128 56 A72 72 0 1 1 76 178"
fill="none"
stroke="url(#innerArc)"
stroke-width="10"
stroke-linecap="round"/>
<circle cx="128" cy="128" r="8" fill="#F2FFF7"/>
<circle cx="128" cy="128" r="18" fill="none" stroke="#7BFFC0" stroke-opacity="0.14" stroke-width="4"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

+12
View File
@@ -0,0 +1,12 @@
{ config, pkgs, lib, ... }:
let
theme = pkgs.callPackage ./plymouth-theme.nix {};
in
{
boot.plymouth.enable = true;
boot.plymouth.theme = "sovran";
boot.plymouth.themePackages = [ theme ];
boot.kernelParams = [ "quiet" "splash" ];
boot.initrd.systemd.enable = true;
}
+1
View File
@@ -16,6 +16,7 @@ in
{
imports = [
"${modulesPath}/installer/cd-dvd/installation-cd-graphical-gnome.nix"
./branding.nix
];
image.baseName = lib.mkForce "Sovran_SystemsOS";
+14 -120
View File
@@ -8,7 +8,6 @@ import os
import secrets
import subprocess
import sys
import tempfile
import threading
import time
@@ -21,7 +20,7 @@ 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=stable";
Sovran_Systems.url = "git+https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS?ref=staging-dev";
};
outputs = { self, Sovran_Systems, ... }@inputs: {
@@ -157,7 +156,6 @@ class InstallerWindow(Adw.ApplicationWindow):
self.boot_size = None
self.data_disk = None
self.data_size = None
self.data_drive_has_timechain = False
self.free_password = None
# Root navigation view
@@ -363,40 +361,6 @@ class InstallerWindow(Adw.ApplicationWindow):
sep.set_margin_end(40)
outer.append(sep)
notice_frame = Gtk.Frame()
notice_frame.add_css_class("card")
notice_frame.set_margin_start(40)
notice_frame.set_margin_end(40)
notice_frame.set_margin_top(20)
notice_frame.set_margin_bottom(4)
notice_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
notice_box.set_margin_top(12)
notice_box.set_margin_bottom(12)
notice_box.set_margin_start(16)
notice_box.set_margin_end(16)
notice_icon = symbolic_icon("dialog-information-symbolic")
notice_icon.set_valign(Gtk.Align.START)
notice_box.append(notice_icon)
notice_lbl = Gtk.Label()
notice_lbl.set_use_markup(True)
notice_lbl.set_wrap(True)
notice_lbl.set_xalign(0)
notice_lbl.set_halign(Gtk.Align.FILL)
notice_lbl.set_markup(
"<span weight='bold'>Heads up — Server + Desktop prerequisites</span>\n"
"• A domain or subdomain from <span weight='bold'>https://njal.la</span>\n"
"• The ability to open / forward ports on your router\n\n"
"Don't worry — after install, the onboarding wizard walks you through every step.\n"
"<span size='small'>Desktop Only and Node Only do not require a domain or port forwarding.</span>"
)
notice_box.append(notice_lbl)
notice_frame.set_child(notice_box)
outer.append(notice_frame)
# Role label
role_lbl = Gtk.Label()
role_lbl.set_markup("<span size='medium' weight='bold'>Choose your installation type:</span>")
@@ -703,19 +667,11 @@ class InstallerWindow(Adw.ApplicationWindow):
def push_disk_confirm(self):
"""Show the selected drives and ask the user to type ERASE to confirm."""
self.data_drive_has_timechain = False
if self.data_disk:
data_path = f"/dev/{self.data_disk}"
self.data_drive_has_timechain = self.detect_existing_timechain_data(data_path)
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
# Disk info group
disk_group = Adw.PreferencesGroup()
if self.data_disk and self.data_drive_has_timechain:
disk_group.set_title("OS drive to be erased (data drive preserved)")
else:
disk_group.set_title("Drives to be erased")
disk_group.set_title("Drives to be erased")
disk_group.set_margin_top(24)
disk_group.set_margin_start(40)
disk_group.set_margin_end(40)
@@ -732,21 +688,12 @@ class InstallerWindow(Adw.ApplicationWindow):
data_row.set_subtitle(f"/dev/{self.data_disk}{human_size(self.data_size)}")
data_row.add_prefix(symbolic_icon("drive-harddisk-symbolic"))
disk_group.add(data_row)
if self.data_drive_has_timechain:
note_row = Adw.ActionRow()
note_row.set_title(f"Existing Bitcoin timechain detected on /dev/{self.data_disk}")
note_row.set_subtitle("Data will be preserved and mounted as-is.")
note_row.add_prefix(symbolic_icon("emblem-ok-symbolic"))
disk_group.add(note_row)
outer.append(disk_group)
# Warning banner
banner = Adw.Banner()
if self.data_disk and self.data_drive_has_timechain:
banner.set_title("⚠ All data on the OS disk will be permanently destroyed. Existing Bitcoin data disk will be preserved.")
else:
banner.set_title("⚠ All data on the above disk(s) will be permanently destroyed.")
banner.set_title("⚠ All data on the above disk(s) will be permanently destroyed.")
banner.set_revealed(True)
banner.set_margin_top(16)
banner.set_margin_start(40)
@@ -828,61 +775,9 @@ class InstallerWindow(Adw.ApplicationWindow):
# ── Worker: partition ─────────────────────────────────────────────────
def partition_path(self, dev_path, num):
return f"{dev_path}p{num}" if "nvme" in dev_path else f"{dev_path}{num}"
def detect_existing_timechain_data(self, data_path, buf=None):
data_p1 = self.partition_path(data_path, 1)
if not os.path.exists(data_p1):
return False
label = ""
for cmd in (
["sudo", "lsblk", "-no", "LABEL", data_p1],
["sudo", "blkid", "-o", "value", "-s", "LABEL", data_p1],
):
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode == 0:
stdout = proc.stdout.strip()
label = stdout.splitlines()[0] if stdout else ""
if label:
break
if label != "BTCEcoandBackup":
return False
check_mount = tempfile.mkdtemp(prefix="sovran-installer-data-check-")
mounted = False
try:
run(["sudo", "mount", "-o", "ro", data_p1, check_mount])
mounted = True
has_bitcoin = os.path.isdir(f"{check_mount}/BTCEcoandBackup/Bitcoin_Node")
has_electrs = os.path.isdir(f"{check_mount}/BTCEcoandBackup/Electrs_Data")
if has_bitcoin and has_electrs:
if buf is not None:
GLib.idle_add(
append_text,
buf,
"=== Existing Bitcoin timechain detected on data drive — preserving data ===\n",
)
return True
return False
except Exception as e:
log(f"Timechain detection failed for {data_p1} at mount/check step ({check_mount}): {e}")
return False
finally:
if mounted:
subprocess.run(["sudo", "umount", check_mount], capture_output=True, text=True)
subprocess.run(["sudo", "rmdir", check_mount], capture_output=True, text=True)
def do_partition(self, buf):
boot_path = f"/dev/{self.boot_disk}"
data_path = f"/dev/{self.data_disk}" if self.data_disk else None
self.data_drive_has_timechain = False
if data_path:
self.data_drive_has_timechain = self.detect_existing_timechain_data(data_path, buf)
# ── Wipe disk(s) ──
GLib.idle_add(append_text, buf, "=== Wiping disk(s) ===\n")
@@ -890,12 +785,12 @@ class InstallerWindow(Adw.ApplicationWindow):
run_stream(["sudo", "sgdisk", "--zap-all", boot_path], buf)
run_stream(["sudo", "wipefs", "--all", "--force", boot_path], buf)
if data_path and not self.data_drive_has_timechain:
if data_path:
run_stream(["sudo", "sgdisk", "--zap-all", data_path], buf)
run_stream(["sudo", "wipefs", "--all", "--force", data_path], buf)
run_stream(["sudo", "partprobe", boot_path], buf)
if data_path and not self.data_drive_has_timechain:
if data_path:
run_stream(["sudo", "partprobe", data_path], buf)
time.sleep(2)
@@ -911,7 +806,7 @@ class InstallerWindow(Adw.ApplicationWindow):
time.sleep(2)
# ── Partition data disk (if selected) ──
if data_path and not self.data_drive_has_timechain:
if data_path:
GLib.idle_add(append_text, buf, "\n=== Partitioning data disk ===\n")
run_stream(["sudo", "sgdisk",
"-n", "1:1M:0", "-t", "1:8300", "-c", "1:primary",
@@ -922,14 +817,14 @@ class InstallerWindow(Adw.ApplicationWindow):
# ── Format partitions ──
GLib.idle_add(append_text, buf, "\n=== Formatting partitions ===\n")
boot_p1 = self.partition_path(boot_path, 1)
boot_p2 = self.partition_path(boot_path, 2)
boot_p1 = f"{boot_path}p1" if "nvme" in boot_path else f"{boot_path}1"
boot_p2 = f"{boot_path}p2" if "nvme" in boot_path else f"{boot_path}2"
run_stream(["sudo", "mkfs.vfat", "-F", "32", boot_p1], buf)
run_stream(["sudo", "mkfs.ext4", "-F", "-L", "sovran_systemsos", boot_p2], buf)
if data_path and not self.data_drive_has_timechain:
data_p1 = self.partition_path(data_path, 1)
if data_path:
data_p1 = f"{data_path}p1" if "nvme" in data_path else f"{data_path}1"
run_stream(["sudo", "mkfs.ext4", "-F", "-L", "BTCEcoandBackup", data_p1], buf)
# ── Mount filesystems ──
@@ -939,7 +834,7 @@ class InstallerWindow(Adw.ApplicationWindow):
run_stream(["sudo", "mount", "-o", "umask=0077,defaults", boot_p1, "/mnt/boot/efi"], buf)
if data_path:
data_p1 = self.partition_path(data_path, 1)
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)
@@ -982,7 +877,6 @@ class InstallerWindow(Adw.ApplicationWindow):
if proc.returncode != 0:
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"])
run(["sudo", "chmod", "644", "/mnt/etc/nixos/custom.nix"])
# ── Step 4: Ready to install ──────────────────────────────────────────
@@ -1094,7 +988,7 @@ class InstallerWindow(Adw.ApplicationWindow):
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 stable...\n")
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)
@@ -1169,7 +1063,7 @@ class InstallerWindow(Adw.ApplicationWindow):
btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
btn_box.set_halign(Gtk.Align.CENTER)
btn_box.set_margin_bottom(32)
reboot_btn = Gtk.Button(label="I Have Written Down My Password — Restart Entire System")
reboot_btn = Gtk.Button(label="I Have Written Down My Password — Reboot Now")
reboot_btn.add_css_class("suggested-action")
reboot_btn.add_css_class("pill")
reboot_btn.connect("clicked", lambda b: subprocess.run(["sudo", "reboot"]))
@@ -1203,4 +1097,4 @@ class InstallerWindow(Adw.ApplicationWindow):
if __name__ == "__main__":
app = InstallerApp()
app.run(None)
app.run(None)
+39
View File
@@ -0,0 +1,39 @@
{ pkgs, lib }:
pkgs.stdenv.mkDerivation {
pname = "sovran-plymouth-theme";
version = "1.0";
src = ./.;
installPhase = ''
mkdir -p $out/share/plymouth/themes/sovran
cp ${./assets/splash-logo.png} $out/share/plymouth/themes/sovran/logo.png
cat > $out/share/plymouth/themes/sovran/sovran.plymouth <<EOF
[Plymouth Theme]
Name=Sovran Systems
Description=Sovran Systems Splash
ModuleName=script
[script]
ImageDir=$out/share/plymouth/themes/sovran
ScriptFile=$out/share/plymouth/themes/sovran/sovran.script
EOF
cat > $out/share/plymouth/themes/sovran/sovran.script <<'EOF'
# Background color: #CFFFD7 (RGB 207,255,215)
bg_r = 207/255.0
bg_g = 255/255.0
bg_b = 215/255.0
Window.SetBackgroundTopColor (bg_r, bg_g, bg_b);
Window.SetBackgroundBottomColor (bg_r, bg_g, bg_b);
logo = Image("logo.png");
logo_sprite = Sprite(logo);
logo_sprite.SetX((Window.GetWidth() - logo.GetWidth()) / 2);
logo_sprite.SetY((Window.GetHeight() - logo.GetHeight()) / 2);
EOF
'';
}
+4 -29
View File
@@ -24,7 +24,6 @@ ROLE="server"
DEPLOY_KEY=""
HEADSCALE_SERVER=""
HEADSCALE_KEY=""
DATA_DISK_HAS_TIMECHAIN=false
FLAKE="/etc/sovran/flake"
LOG="/tmp/sovran-headless-install.log"
@@ -103,42 +102,19 @@ part_suffix() {
fi
}
# ── Detect existing Bitcoin timechain data on data disk ───────────────────────
if [[ -n "$DATA_DISK" ]]; then
DATA_P1=$(part_suffix "$DATA_DISK" 1)
if [[ -b "$DATA_P1" ]]; then
DATA_LABEL=$(lsblk -no LABEL "$DATA_P1" 2>/dev/null | head -n1 || true)
if [[ -z "$DATA_LABEL" ]]; then
DATA_LABEL=$(blkid -o value -s LABEL "$DATA_P1" 2>/dev/null || true)
fi
if [[ "$DATA_LABEL" == "BTCEcoandBackup" ]]; then
CHECK_MOUNT=$(mktemp -d /tmp/sovran-data-check.XXXXXX)
if mount -o ro "$DATA_P1" "$CHECK_MOUNT" 2>/dev/null; then
if [[ -d "$CHECK_MOUNT/BTCEcoandBackup/Bitcoin_Node" && -d "$CHECK_MOUNT/BTCEcoandBackup/Electrs_Data" ]]; then
DATA_DISK_HAS_TIMECHAIN=true
log "Existing Bitcoin timechain detected on data drive — preserving data"
fi
umount "$CHECK_MOUNT" || true
fi
rmdir "$CHECK_MOUNT" 2>/dev/null || true
fi
fi
fi
# ── Step 1: Wipe disks ────────────────────────────────────────────────────────
log "=== Wiping disk(s) ==="
sgdisk --zap-all "$DISK"
wipefs --all --force "$DISK"
if [[ -n "$DATA_DISK" && "$DATA_DISK_HAS_TIMECHAIN" != true ]]; then
if [[ -n "$DATA_DISK" ]]; then
sgdisk --zap-all "$DATA_DISK"
wipefs --all --force "$DATA_DISK"
fi
partprobe "$DISK"
[[ -n "$DATA_DISK" && "$DATA_DISK_HAS_TIMECHAIN" != true ]] && partprobe "$DATA_DISK"
[[ -n "$DATA_DISK" ]] && partprobe "$DATA_DISK"
sleep 2
# ── Step 2: Partition OS disk ─────────────────────────────────────────────────
@@ -153,7 +129,7 @@ partprobe "$DISK"
sleep 2
# ── Step 3: Partition data disk (if present) ──────────────────────────────────
if [[ -n "$DATA_DISK" && "$DATA_DISK_HAS_TIMECHAIN" != true ]]; then
if [[ -n "$DATA_DISK" ]]; then
log "=== Partitioning data disk ==="
sgdisk \
-n "1:1M:0" -t "1:8300" -c "1:primary" \
@@ -171,7 +147,7 @@ BOOT_P2=$(part_suffix "$DISK" 2)
mkfs.vfat -F 32 "$BOOT_P1"
mkfs.ext4 -F -L sovran_systemsos "$BOOT_P2"
if [[ -n "$DATA_DISK" && "$DATA_DISK_HAS_TIMECHAIN" != true ]]; then
if [[ -n "$DATA_DISK" ]]; then
DATA_P1=$(part_suffix "$DATA_DISK" 1)
mkfs.ext4 -F -L BTCEcoandBackup "$DATA_P1"
fi
@@ -245,7 +221,6 @@ if [[ -n "$DEPLOY_KEY" || -n "$HEADSCALE_SERVER" ]]; then
} > /mnt/etc/nixos/custom.nix
else
cp /mnt/etc/nixos/custom.template.nix /mnt/etc/nixos/custom.nix
chmod 644 /mnt/etc/nixos/custom.nix
fi
# ── Write Headscale auth key if provided ─────────────────────────────────────
+23
View File
@@ -0,0 +1,23 @@
{ config, lib, pkgs, ... }:
let
cfg = config.sovran_systemsOS;
in
{
options.sovran_systemsOS.packages.bip110 = lib.mkOption {
type = lib.types.nullOr lib.types.package;
default = null;
description = "BIP110 Bitcoin package";
};
config = lib.mkIf (
cfg.features.bip110 &&
cfg.packages.bip110 != null
) {
services.bitcoind.package = lib.mkForce cfg.packages.bip110;
environment.systemPackages = [
cfg.packages.bip110
];
};
}
+4 -15
View File
@@ -4,7 +4,7 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
services.bitcoind = {
enable = true;
package = pkgs.bitcoind-knots;
package = config.nix-bitcoin.pkgs.bitcoind-knots;
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node";
txindex = true;
tor.proxy = true;
@@ -73,19 +73,11 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
systemd.services.bitcoind = {
requires = [ "run-media-Second_Drive.mount" ];
after = [ "run-media-Second_Drive.mount" ];
serviceConfig.PrivateUsers = lib.mkForce false;
};
systemd.services.electrs = {
requires = lib.mkForce [ "run-media-Second_Drive.mount" ];
after = [ "run-media-Second_Drive.mount" "bitcoind.service" ];
wants = [ "bitcoind.service" ];
};
systemd.services.lnd = {
wants = [ "bitcoind.service" ];
# requires for bitcoind set by nix-bitcoin; mkForce removes it
requires = lib.mkForce [ ];
requires = [ "run-media-Second_Drive.mount" ];
after = [ "run-media-Second_Drive.mount" ];
};
systemd.services.sovran-btc-permissions = {
@@ -107,10 +99,7 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
'';
};
networking.firewall.allowedTCPPorts = [ 3051 ];
networking.firewall.allowedUDPPorts = [ 3051 ];
sovran_systemsOS.domainRequirements = [
sovran_systemsOS.domainRequirements = [
{ name = "btcpayserver"; label = "BTCPay Server"; example = "pay.yourdomain.com"; }
];
}
+9 -12
View File
@@ -16,10 +16,7 @@ let
in
{
services.caddy = {
# Only enable Caddy when at least one domain-based service needs it or
# the operator has defined custom vhosts. This prevents Caddy from
# running on Desktop Only installs that have no web services configured.
enable = needsHttpsPorts || extraVhosts != "";
enable = true;
user = "caddy";
group = "root";
};
@@ -97,10 +94,10 @@ EOF
$MATRIX {
reverse_proxy /_matrix/* http://localhost:8008
reverse_proxy /_synapse/client/* http://localhost:8008
handle /.well-known/matrix/server {
header Content-Type application/json
respond \`{"m.server":"$MATRIX:443"}\` 200
}
}
$MATRIX:8448 {
reverse_proxy http://localhost:8008
}
EOF
fi
@@ -113,7 +110,7 @@ EOF
$WORDPRESS {
encode gzip zstd
root * /var/lib/www/wordpress
php_fastcgi unix//run/phpfpm/wordpress.sock
php_fastcgi unix//run/phpfpm/mypool.sock
file_server browse
}
EOF
@@ -126,7 +123,7 @@ EOF
$NEXTCLOUD {
encode gzip zstd
root * /var/lib/www/nextcloud
php_fastcgi unix//run/phpfpm/nextcloud.sock {
php_fastcgi unix//run/phpfpm/mypool.sock {
trusted_proxies private_ranges
}
file_server
@@ -192,7 +189,7 @@ EOF
http://sovransystemsos.local {
reverse_proxy localhost:8937
header {
Clear-Site-Data "\"cache\""
Clear-Site-Data "\"cache\", \"cookies\", \"storage\""
Cache-Control "no-store, no-cache, must-revalidate, max-age=0"
Pragma "no-cache"
Expires "0"
@@ -224,4 +221,4 @@ ${extraVhosts}
CUSTOM_VHOSTS_EOF
'';
};
}
}
-52
View File
@@ -1,52 +0,0 @@
# ── modules/core/cpu-performance.nix ──────────────────────────────────────────
# Forces all CPU cores to run at maximum frequency on node and server_plus_desktop
# roles. Desktop-only installs retain normal OS power management behaviour.
#
# Three layers:
# 1. power-profiles-daemon disabled — removes the GNOME power profile picker;
# no user can switch profiles
# 2. cpufreq performance governor — pins every core to max frequency via
# kernel, enforced at boot by a oneshot unit
# 3. systemd oneshot enforcement — belt-and-suspenders; applies the governor
# after every boot even if module loads late
{ config, lib, pkgs, ... }:
{
config = lib.mkIf (!config.sovran_systemsOS.roles.desktop) {
# ── Layer 1: disable power-profiles-daemon ───────────────────────────────
# This removes the power-profile switcher from GNOME Settings entirely.
services.power-profiles-daemon.enable = false;
# ── Layer 2: set cpufreq governor to performance ─────────────────────────
# Pins all cores to max frequency. Works on Intel (intel_pstate) and AMD
# (amd-pstate / acpi-cpufreq) alike.
powerManagement.cpuFreqGovernor = "performance";
# ── Layer 3: enforce at boot via systemd oneshot ─────────────────────────
# Belt-and-suspenders: ensures the governor is applied after every boot even
# if the kernel module loads late.
systemd.services.cpu-performance = {
description = "Set CPU governor to performance on all cores";
wantedBy = [ "multi-user.target" ];
after = [ "systemd-modules-load.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
found=0
for gov in /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor; do
if [ -w "$gov" ]; then
echo performance > "$gov"
found=1
fi
done
if [ "$found" -eq 0 ]; then
echo "cpu-performance: no writable cpufreq governors found (VM or unsupported hardware)" >&2
fi
'';
};
};
}
-145
View File
@@ -1,145 +0,0 @@
{ config, pkgs, lib, ... }:
# ── Server-local domain loopback overrides ────────────────────────────────────
#
# Some routers (especially newer ISP-provided devices) do not support NAT
# loopback (hairpin NAT). When a request originates on this computer and
# targets a public domain name that resolves to the router's WAN address, the
# router may refuse to loop the connection back in — causing Nextcloud, WordPress
# background jobs, and other server-side callbacks to fail even when the service
# is fully operational from the internet.
#
# This module installs a one-shot systemd service,
# ``sovran-hosts-update.service``, that reads the configured service domains
# from ``/var/lib/domains/`` at boot (and whenever triggered by the Hub after a
# domain is saved) and writes ``127.0.0.1`` entries for them into a dedicated
# Sovran-managed block in ``/etc/hosts``.
#
# With those entries in place:
# • Requests originating on this computer resolve the public domain name to
# 127.0.0.1, reach Caddy directly, and never touch the router.
# • Caddy still receives the correct public hostname via TLS SNI so virtual-
# host routing and certificate validation continue to work.
# • The Sovran Hub can verify Caddy reachability locally without needing NAT
# loopback.
#
# Limitation: this does not help other devices on your home network (phones,
# laptops). Those devices resolve domains via the router's DNS and still depend
# on NAT loopback (or require manual router DNS overrides). For now, only
# server-originated requests benefit from this override.
#
# On NixOS, /etc/hosts is normally a symlink into the Nix store and is
# regenerated by the system activation script. The ``system.activationScripts``
# hook below converts it to a writable file each time the system is activated
# (i.e. after every ``nixos-rebuild switch``) and then injects the Sovran block.
# The same script is also run by the ``sovran-hosts-update.service`` unit so
# that the Hub can trigger it immediately after saving a domain without
# requiring a full rebuild.
{
# ── Helper script (stored in the Nix store, never reads /var/lib at eval) ──
environment.systemPackages = [ pkgs.coreutils ];
environment.etc."sovran-hosts-update.sh" = {
mode = "0755";
text = ''
#!/bin/sh
# Regenerate the Sovran-managed loopback block in /etc/hosts.
# Safe to run multiple times idempotent.
set -eu
DOMAINS_DIR="/var/lib/domains"
HOSTS_FILE="/etc/hosts"
BEGIN_MARKER="# Sovran managed begin server-local loopback overrides"
END_MARKER="# Sovran managed end"
# Step 1: ensure /etc/hosts is a regular writable file
# On NixOS /etc/hosts starts as a symlink to the Nix store. We replace
# it with a copy so we can append our block without touching the store.
if [ -L "$HOSTS_FILE" ]; then
TARGET=$(readlink -f "$HOSTS_FILE")
cp --no-preserve=all "$TARGET" "$HOSTS_FILE.sovran-tmp"
mv "$HOSTS_FILE.sovran-tmp" "$HOSTS_FILE"
chmod 644 "$HOSTS_FILE"
fi
# Step 2: remove any existing Sovran block
# Use a temp file so the operation is atomic.
TMP=$(mktemp "$HOSTS_FILE.XXXXXX")
trap 'rm -f "$TMP"' EXIT
awk "
/^$BEGIN_MARKER\$/ { skip=1; next }
/^$END_MARKER\$/ { skip=0; next }
!skip
" "$HOSTS_FILE" > "$TMP"
# Step 3: collect valid configured service domains
# NOTE: The hostname validation regex below must stay in sync with
# _SAFE_DOMAIN_RE in app/sovran_systemsos_web/server.py.
ENTRIES=""
for KEY in matrix wordpress nextcloud btcpayserver vaultwarden haven element-calling; do
FILE="$DOMAINS_DIR/$KEY"
[ -f "$FILE" ] || continue
# Read the domain value (strip all whitespace, limit to 253 chars)
DOMAIN=$(tr -d '[:space:]' < "$FILE" | head -c 253)
[ -z "$DOMAIN" ] && continue
# Validate: must match a reasonable hostname pattern (no injection)
if ! printf '%s' "$DOMAIN" | grep -qE \
'^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$'; then
echo "sovran-hosts-update: skipping invalid domain value for $KEY: $DOMAIN" >&2
continue
fi
ENTRIES="$ENTRIES
127.0.0.1 $DOMAIN
::1 $DOMAIN"
done
# Step 4: append the Sovran block if there are any entries
if [ -n "$ENTRIES" ]; then
printf '\n%s\n' "$BEGIN_MARKER" >> "$TMP"
printf '%s\n' "# These entries route configured service domains to local Caddy." >> "$TMP"
printf '%s\n' "# They are managed automatically do not edit this block." >> "$TMP"
printf '%s\n' "$ENTRIES" >> "$TMP"
printf '%s\n' "$END_MARKER" >> "$TMP"
fi
# Step 5: atomically replace /etc/hosts
mv "$TMP" "$HOSTS_FILE"
chmod 644 "$HOSTS_FILE"
'';
};
# ── Systemd service ────────────────────────────────────────────────────────
systemd.services.sovran-hosts-update = {
description = "Update /etc/hosts with Sovran server-local loopback overrides";
documentation = [ "https://github.com/naturallaw777/sovran-systems" ];
# Run before Caddy so loopback entries are ready when it starts.
before = [
"caddy.service"
"network-online.target"
];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = "/etc/sovran-hosts-update.sh";
};
};
# ── Activation script (runs after every nixos-rebuild switch) ─────────────
# This ensures the loopback block survives rebuilds that restore the /etc/hosts
# symlink. The "users" and "etc" scripts must complete first.
system.activationScripts.sovranDomainLoopback = {
text = ''
if [ -x /etc/sovran-hosts-update.sh ] && [ -d /var/lib/domains ]; then
/etc/sovran-hosts-update.sh || true
fi
'';
deps = [ "etc" "users" ];
};
}
-40
View File
@@ -1,40 +0,0 @@
# ── modules/core/no-sleep.nix ─────────────────────────────────────────────────
# Prevents the machine from ever sleeping or suspending at the system level.
#
# Only applies to server_plus_desktop and node roles. Desktop-only installs
# retain normal OS sleep/suspend behaviour.
#
# This operates at two layers below GNOME:
# 1. systemd-logind — ignores all hardware power events (lid, suspend key, etc.)
# 2. systemd targets — masks sleep/suspend/hibernate targets so nothing can
# trigger them, not even `systemctl suspend` or D-Bus calls.
#
# This is intentional for a 24/7 server/node. The GNOME-layer power settings in
# sovran_systemsos-desktop.nix remain in place as a belt-and-suspenders complement
# for active user sessions.
{ config, lib, ... }:
{
config = lib.mkIf (!config.sovran_systemsOS.roles.desktop) {
# ── Layer 1: logind hardware event handling ──────────────────────────────
services.logind.settings.Login = {
HandleLidSwitch = "ignore";
HandleLidSwitchDocked = "ignore";
HandleLidSwitchExternalPower = "ignore";
HandleSuspendKey = "ignore";
HandleHibernateKey = "ignore";
HandlePowerKey = "ignore";
IdleAction = "ignore";
IdleActionSec = 0;
};
# ── Layer 2: mask systemd sleep targets ─────────────────────────────────
# Nothing on the system can suspend/hibernate — not root, not GNOME, not D-Bus.
systemd.targets.sleep.enable = false;
systemd.targets.suspend.enable = false;
systemd.targets.hibernate.enable = false;
systemd.targets.hybrid-sleep.enable = false;
};
}
+8 -23
View File
@@ -3,13 +3,6 @@
{
config = lib.mkMerge [
# nix-bitcoin is globally imported by the flake (nixosModules.Sovran_SystemsOS).
# This default satisfies nix-bitcoin's generateSecrets assertion so that Desktop
# Only systems can evaluate without enabling any Bitcoin services.
{
nix-bitcoin.generateSecrets = lib.mkDefault true;
}
# ── Server+Desktop Role (default) ─────────────────────────
(lib.mkIf config.sovran_systemsOS.roles.server_plus_desktop {
sovran_systemsOS.web.btcpayserver = lib.mkDefault true;
@@ -19,28 +12,19 @@
(lib.mkIf config.sovran_systemsOS.roles.desktop {
services.desktopManager.gnome.enable = true;
# Force all server/node services and features off so they cannot be
# accidentally enabled via custom.nix or option defaults on Desktop Only.
sovran_systemsOS.services = {
synapse = lib.mkForce false;
bitcoin = lib.mkForce false;
vaultwarden = lib.mkForce false;
wordpress = lib.mkForce false;
nextcloud = lib.mkForce false;
synapse = lib.mkDefault false;
bitcoin = lib.mkDefault false;
vaultwarden = lib.mkDefault false;
wordpress = lib.mkDefault false;
nextcloud = lib.mkDefault false;
};
sovran_systemsOS.features = {
haven = lib.mkForce false;
mempool = lib.mkForce false;
element-calling = lib.mkForce false;
bitcoin-core = lib.mkForce false;
};
sovran_systemsOS.web.btcpayserver = lib.mkForce false;
sovran_systemsOS.web.btcpayserver = lib.mkDefault false;
})
# ── Bitcoin Node Only Role ────────────────────────────────
# Bitcoin ecosystem + mempool, BTCPay runs but not exposed via Caddy
# Bitcoin ecosystem + mempool + bip110, BTCPay runs but not exposed via Caddy
(lib.mkIf config.sovran_systemsOS.roles.node {
sovran_systemsOS.services = {
bitcoin = lib.mkDefault true;
@@ -52,6 +36,7 @@
sovran_systemsOS.features = {
mempool = lib.mkDefault true;
bip110 = lib.mkDefault true;
};
sovran_systemsOS.web.btcpayserver = lib.mkDefault false;
+1 -24
View File
@@ -43,24 +43,12 @@
# ── Features (default OFF — user can enable in custom.nix) ──
features = {
haven = lib.mkEnableOption "Haven NOSTR relay";
bip110 = lib.mkEnableOption "BIP-110 Bitcoin Better Money";
mempool = lib.mkEnableOption "Bitcoin Mempool Explorer";
element-calling = lib.mkEnableOption "Element Video and Audio Calling";
bitcoin-core = lib.mkEnableOption "Bitcoin Core";
rdp = lib.mkEnableOption "Gnome Remote Desktop";
sshd = lib.mkEnableOption "SSH remote access";
# Deprecated: BIP-110 is now built into mainline Bitcoin Knots and is the
# default node. This option is retained ONLY so that existing machines with
# `sovran_systemsOS.features.bip110 = lib.mkForce true;` left in their local
# custom.nix continue to evaluate. It has no effect and will be removed in a
# future release once the Hub has cleaned up old custom.nix files.
bip110 = lib.mkOption {
type = lib.types.nullOr lib.types.bool;
default = null;
internal = true;
visible = false;
description = "(Deprecated, no-op) BIP-110 is now built into Bitcoin Knots.";
};
};
# ── Web exposure (controls Caddy vhosts) ──────────────────
@@ -101,15 +89,4 @@
description = "Nostr public key (npub1...) for Haven relay";
};
};
config = lib.mkIf (config.sovran_systemsOS.features.bip110 != null) {
warnings = [
''
sovran_systemsOS.features.bip110 is deprecated and has no effect:
BIP-110 is now built into mainline Bitcoin Knots, which is the default node.
You can safely remove the `sovran_systemsOS.features.bip110` line from
/etc/nixos/custom.nix. The Sovran Hub will also remove it automatically.
''
];
};
}
+28 -74
View File
@@ -24,47 +24,52 @@ let
{ 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"; }
{ 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\n5. Connect you will see your desktop remotely"; }
]; }
]
# ── Bitcoin Base (node implementations) ────────────────────
++ lib.optionals cfg.services.bitcoin [
{ name = "Bitcoin Knots + BIP110"; unit = "bitcoind.service"; type = "system"; icon = "bip110"; enabled = cfg.services.bitcoin && !cfg.features.bitcoin-core; category = "bitcoin-base"; credentials = [
{ label = "Tor Address Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; }
{ name = "Bitcoin Knots + BIP110"; unit = "bitcoind.service"; type = "system"; icon = "bip110"; enabled = cfg.features.bip110; category = "bitcoin-base"; credentials = [
{ label = "Tor Address"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; }
]; }
{ name = "Bitcoin Knots"; unit = "bitcoind.service"; type = "system"; icon = "bitcoind"; enabled = cfg.services.bitcoin && !cfg.features.bitcoin-core && !cfg.features.bip110; category = "bitcoin-base"; credentials = [
{ label = "Tor Address"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; }
]; }
{ name = "Bitcoin Core"; unit = "bitcoind.service"; type = "system"; icon = "bitcoin-core"; enabled = cfg.features.bitcoin-core; category = "bitcoin-base"; credentials = [
{ label = "Tor Address Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; }
{ label = "Tor Address"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; }
]; }
]
# ── 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 = "Tor Address"; 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://"; }
{ label = "Local Network Access on your home network only"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":3051"; }
{ label = "Tor Access"; file = "/var/lib/tor/onion/rtl/hostname"; prefix = "http://"; }
{ label = "Local Network"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":3051"; }
{ 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 = [
{ name = "BTCPayserver"; unit = "btcpayserver.service"; type = "system"; icon = "btcpayserver"; enabled = cfg.services.bitcoin; 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; }
{ label = "How to Connect"; value = "1. Download Zeus from App Store or Google Play\n2. Open Zeus Scan Node Config\n3. Scan the QR code above"; }
{ label = "Connection URL"; file = "/var/lib/secrets/zeus-connect-url"; qrcode = true; }
{ label = "How to Connect"; value = "1. Download Zeus from App Store or Google Play\n2. Open Zeus Scan Node Config\n3. Scan the QR code above or paste the Connection URL"; }
]; }
{ 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 = "Bisq Auto-Link"; unit = "bisq-autoconnect.service"; type = "system"; icon = "bisq"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
{ label = "Node"; value = "127.0.0.1:8333 (Bitcoin Core)"; }
{ label = "Status"; value = "Auto-configured on first boot"; }
]; }
{ 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"; }
{ label = "Tor Access"; file = "/var/lib/tor/onion/mempool-frontend/hostname"; prefix = "http://"; }
{ label = "Local Network"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":60847"; }
]; }
]
# ── Communication (server+desktop only) ────────────────────
@@ -135,27 +140,16 @@ let
RC=0
echo " Step 1/3: nix flake update "
if ! 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 2>&1; then
if ! nix flake update --flake /etc/nixos --print-build-logs 2>&1; 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) "
BOOT_OUT=$(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 2>&1)
BOOT_RC=$?
echo "$BOOT_OUT"
if [ "$BOOT_RC" -ne 0 ]; then
echo "[ERROR] nixos-rebuild boot failed"
echo " Step 2/3: nixos-rebuild switch "
if ! nixos-rebuild switch --flake /etc/nixos --print-build-logs 2>&1; then
echo "[ERROR] nixos-rebuild switch failed"
RC=1
fi
echo ""
@@ -171,10 +165,9 @@ let
if [ "$RC" -eq 0 ]; then
echo ""
echo " Update staged successfully"
echo " Reboot required to activate the new system"
echo " Update completed successfully"
echo ""
echo "REBOOT_REQUIRED" > "$STATUS"
echo "SUCCESS" > "$STATUS"
else
echo ""
echo " Update failed see errors above"
@@ -202,34 +195,12 @@ let
echo ""
echo ""
echo " Rebuilding system configuration "
SWITCH_OUT=$(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 2>&1)
SWITCH_RC=$?
echo "$SWITCH_OUT"
if [ "$SWITCH_RC" -eq 0 ]; then
if nixos-rebuild switch --flake /etc/nixos --print-build-logs 2>&1; then
echo ""
echo ""
echo " Rebuild completed successfully"
echo ""
echo "SUCCESS" > "$STATUS"
elif echo "$SWITCH_OUT" | grep -q "switchInhibitors\|Pre-switch checks failed"; then
echo ""
echo " Build succeeded a reboot is required to apply this rebuild"
echo " (Critical system components changed; running nixos-rebuild boot instead)"
if 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 2>&1; then
echo "REBOOT_REQUIRED" > "$STATUS"
else
echo "[ERROR] nixos-rebuild boot also failed"
echo "FAILED" > "$STATUS"
exit 1
fi
else
echo ""
echo ""
@@ -369,26 +340,17 @@ in
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.qrencode
pkgs.curl
pkgs.iproute2
pkgs.nftables
pkgs.iptables
pkgs.hostname
] ++ lib.optional cfg.services.bitcoin config.services.bitcoind.package;
path = [ pkgs.qrencode ] ++ lib.optional cfg.services.bitcoin config.services.bitcoind.package;
};
systemd.services.sovran-hub-update = {
@@ -411,17 +373,9 @@ in
};
};
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 ];
networking.firewall.allowedTCPPorts = [ 8937 60847 ];
networking.firewall.allowedTCPPorts = [ 3051 8937 60847 ];
# ── Auto-launch Hub in browser on login ───────────────────────
environment.etc."xdg/autostart/sovran-hub-autolaunch.desktop".text = ''
+12 -42
View File
@@ -22,28 +22,6 @@ let
STAMP="$HOME/.config/sovran-theme-applied"
USER_DB="$HOME/.config/dconf/user"
# Always apply wallpaper on version change
WALLPAPER_VERSION="${customWallpaper.version}"
WALLPAPER_STAMP="$HOME/.config/sovran-wallpaper-version"
BG_DIR="/run/current-system/sw/share/backgrounds/sovran"
ULTRAWIDE="$BG_DIR/sovran-ultrawide.png"
CURRENT_WALLPAPER_VERSION=""
if [ -r "$WALLPAPER_STAMP" ]; then
read -r CURRENT_WALLPAPER_VERSION < "$WALLPAPER_STAMP"
fi
if [ "$CURRENT_WALLPAPER_VERSION" != "$WALLPAPER_VERSION" ]; then
if [ -f "$ULTRAWIDE" ]; then
${pkgs.dconf}/bin/dconf write /org/gnome/desktop/background/picture-uri "'file://$ULTRAWIDE'"
${pkgs.dconf}/bin/dconf write /org/gnome/desktop/background/picture-uri-dark "'file://$ULTRAWIDE'"
${pkgs.dconf}/bin/dconf write /org/gnome/desktop/background/picture-options "'zoom'"
mkdir -p "$(dirname "$WALLPAPER_STAMP")"
echo "$WALLPAPER_VERSION" > "$WALLPAPER_STAMP"
fi
fi
# Already applied skip
if [ -f "$STAMP" ]; then
exit 0
@@ -58,17 +36,19 @@ let
# Fresh install no user-db exists yet, apply full Sovran theme below
mkdir -p "$HOME/.config"
cat > "$HOME/.config/mimeapps.list" << EOF
[Default Applications]
text/html=brave-browser.desktop
x-scheme-handler/http=brave-browser.desktop
x-scheme-handler/https=brave-browser.desktop
x-scheme-handler/about=brave-browser.desktop
x-scheme-handler/unknown=brave-browser.desktop
EOF
BG_DIR="/run/current-system/sw/share/backgrounds/sovran"
ULTRAWIDE="$BG_DIR/sovran-ultrawide.png"
CHOSEN="$ULTRAWIDE"
${pkgs.dconf}/bin/dconf load / << EOF
[org/gnome/desktop/background]
picture-uri='file://$CHOSEN'
picture-uri-dark='file://$CHOSEN'
picture-options='zoom'
primary-color='#000000'
secondary-color='#000000'
[org/gnome/desktop/interface]
color-scheme='prefer-dark'
enable-animations=true
@@ -432,14 +412,4 @@ in
}
];
xdg.mime.defaultApplications = {
"text/html" = "brave-browser.desktop";
"x-scheme-handler/http" = "brave-browser.desktop";
"x-scheme-handler/https" = "brave-browser.desktop";
"x-scheme-handler/about" = "brave-browser.desktop";
"x-scheme-handler/unknown" = "brave-browser.desktop";
};
environment.sessionVariables.BROWSER = "brave-browser";
}
}
+4 -37
View File
@@ -31,7 +31,7 @@ lib.mkIf userExists {
};
systemd.services.factory-ssh-keygen = {
description = "Generate or repair factory SSH key for ${userName}";
description = "Generate factory SSH key for ${userName} if missing";
wantedBy = [ "multi-user.target" ];
after = [ "ssh-passphrase-setup.service" ];
requires = [ "ssh-passphrase-setup.service" ];
@@ -39,47 +39,14 @@ lib.mkIf userExists {
Type = "oneshot";
RemainAfterExit = true;
};
path = [ pkgs.openssh pkgs.coreutils pkgs.util-linux ];
path = [ pkgs.openssh pkgs.coreutils ];
script = ''
set -eu
PASSPHRASE=$(cat /var/lib/secrets/ssh-passphrase)
lock_file="${keyPath}.lock"
exec 9>"$lock_file"
if ! flock -n 9; then
echo "Factory SSH key setup is already running." >&2
exit 1
fi
generate_factory_key() {
if [ ! -f "${keyPath}" ]; then
PASSPHRASE=$(cat /var/lib/secrets/ssh-passphrase)
ssh-keygen -q -N "$PASSPHRASE" -t ed25519 -f "${keyPath}"
chown ${userName}:users "${keyPath}" "${keyPath}.pub"
chmod 600 "${keyPath}"
chmod 644 "${keyPath}.pub"
}
if [ ! -f "${keyPath}" ]; then
generate_factory_key
elif ! ssh-keygen -y -P "$PASSPHRASE" -f "${keyPath}" >/dev/null 2>&1; then
backup_suffix="$(date -u +%Y%m%d_%H%M%S)-$$"
backup_path="${keyPath}.bak-$backup_suffix"
backup_index=0
while [ -e "$backup_path" ] || [ -e "$backup_path.pub" ]; do
backup_index=$((backup_index + 1))
backup_path="${keyPath}.bak-$backup_suffix-$backup_index"
done
echo "Existing factory SSH key does not match current passphrase; backing it up to $backup_path and generating a replacement."
mv "${keyPath}" "$backup_path"
if [ -f "${keyPath}.pub" ]; then
mv "${keyPath}.pub" "$backup_path.pub"
fi
generate_factory_key
fi
'';
};
+23 -105
View File
@@ -33,8 +33,8 @@ let
echo "$NEW_PASS" > "$SECRET_FILE"
chmod 600 "$SECRET_FILE"
echo "Password for 'free' updated and saved."
# Delete the old GNOME Keyring databases so a fresh one is created on next GDM login.
rm -f /home/free/.local/share/keyrings/*.keyring
# Delete the old GNOME Keyring so it is recreated with the new password on next GDM login.
rm -rf /home/free/.local/share/keyrings/*
echo "GNOME Keyring files cleared a fresh keyring will be created on next login."
'';
in
@@ -87,7 +87,6 @@ in
};
path = [ pkgs.shadow pkgs.coreutils ];
script = ''
set -euo pipefail
SECRET_FILE="/var/lib/secrets/root-password"
if [ ! -f "$SECRET_FILE" ]; then
mkdir -p /var/lib/secrets
@@ -119,117 +118,36 @@ in
systemd.services.free-password-setup = {
description = "Generate and set a random 'free' user password";
wantedBy = [ "multi-user.target" ];
before = [ "display-manager.service" ];
after = [ "systemd-user-sessions.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
path = [ pkgs.shadow pkgs.coreutils ];
script = ''
set -euo pipefail
SECRET_FILE="/var/lib/secrets/free-password"
PENDING_FILE="/var/lib/secrets/free-password-migration-pending"
if [ -f "$SECRET_FILE" ]; then
echo "free:$(cat "$SECRET_FILE")" | chpasswd
exit 0
fi
SHADOW_HASH=""
while IFS=: read -r user hash _; do
if [ "$user" = "free" ]; then
SHADOW_HASH="$hash"
break
fi
done < /etc/shadow
HAS_REAL_HASH=0
case "$SHADOW_HASH" in
""|"!"|"*"|"!!"|"!"*|"*"*)
HAS_REAL_HASH=0
;;
*)
HAS_REAL_HASH=1
;;
esac
if [ "$HAS_REAL_HASH" -eq 1 ]; then
if [ ! -f "$SECRET_FILE" ]; then
mkdir -p /var/lib/secrets
touch "$PENDING_FILE"
chmod 600 "$PENDING_FILE"
exit 0
# Generate a diceware-style passphrase: word-word-word-N
WORDS="apple barn brook cabin cedar cloud coral crane delta eagle ember \
fern field flame flora flint frost grove haven hedge holly heron \
jade juniper kelp larch lemon lilac linden loch lotus maple marsh \
meadow mist mossy mount oak ocean olive petal pine pixel plum pond \
prism quartz raven ridge river robin rocky rose rowan sage sand \
sierra silver slate snow solar spark spruce stone storm summit \
swift thorn tide timber torch trout vale vault vine walnut wave \
willow wren amber aspen birch blaze bloom bluff coast copper crest \
dune elder fjord forge glade glen glow gulf"
WORD_ARRAY=($WORDS)
COUNT=''${#WORD_ARRAY[@]}
W1=''${WORD_ARRAY[$((RANDOM % COUNT))]}
W2=''${WORD_ARRAY[$((RANDOM % COUNT))]}
W3=''${WORD_ARRAY[$((RANDOM % COUNT))]}
DIGIT=$((RANDOM % 10))
FREE_PASS="$W1-$W2-$W3-$DIGIT"
echo "$FREE_PASS" > "$SECRET_FILE"
chmod 600 "$SECRET_FILE"
fi
mkdir -p /var/lib/secrets
# Generate a diceware-style passphrase: word-word-word-N
WORDS="apple barn brook cabin cedar cloud coral crane delta eagle ember \
fern field flame flora flint frost grove haven hedge holly heron \
jade juniper kelp larch lemon lilac linden loch lotus maple marsh \
meadow mist mossy mount oak ocean olive petal pine pixel plum pond \
prism quartz raven ridge river robin rocky rose rowan sage sand \
sierra silver slate snow solar spark spruce stone storm summit \
swift thorn tide timber torch trout vale vault vine walnut wave \
willow wren amber aspen birch blaze bloom bluff coast copper crest \
dune elder fjord forge glade glen glow gulf"
WORD_ARRAY=($WORDS)
COUNT=''${#WORD_ARRAY[@]}
W1=''${WORD_ARRAY[$((RANDOM % COUNT))]}
W2=''${WORD_ARRAY[$((RANDOM % COUNT))]}
W3=''${WORD_ARRAY[$((RANDOM % COUNT))]}
DIGIT=$((RANDOM % 10))
FREE_PASS="$W1-$W2-$W3-$DIGIT"
echo "$FREE_PASS" > "$SECRET_FILE"
chmod 600 "$SECRET_FILE"
echo "free:$FREE_PASS" | chpasswd
'';
};
systemd.services.free-password-migration = {
description = "Generate and set 'free' password for migrated machines";
wantedBy = [ "multi-user.target" ];
before = [ "display-manager.service" ];
after = [ "systemd-user-sessions.service" "free-password-setup.service" ];
requires = [ "free-password-setup.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
path = [ pkgs.shadow pkgs.coreutils ];
script = ''
set -euo pipefail
PENDING_FILE="/var/lib/secrets/free-password-migration-pending"
SECRET_FILE="/var/lib/secrets/free-password"
NEWPASS_FILE="/var/lib/secrets/free-password-migration-newpass"
[ -f "$PENDING_FILE" ] || exit 0
mkdir -p /var/lib/secrets
WORDS="apple barn brook cabin cedar cloud coral crane delta eagle ember \
fern field flame flora flint frost grove haven hedge holly heron \
jade juniper kelp larch lemon lilac linden loch lotus maple marsh \
meadow mist mossy mount oak ocean olive petal pine pixel plum pond \
prism quartz raven ridge river robin rocky rose rowan sage sand \
sierra silver slate snow solar spark spruce stone storm summit \
swift thorn tide timber torch trout vale vault vine walnut wave \
willow wren amber aspen birch blaze bloom bluff coast copper crest \
dune elder fjord forge glade glen glow gulf"
WORD_ARRAY=($WORDS)
COUNT=''${#WORD_ARRAY[@]}
W1=''${WORD_ARRAY[$((RANDOM % COUNT))]}
W2=''${WORD_ARRAY[$((RANDOM % COUNT))]}
W3=''${WORD_ARRAY[$((RANDOM % COUNT))]}
DIGIT=$((RANDOM % 10))
FREE_PASS="$W1-$W2-$W3-$DIGIT"
printf '%s\n' "$FREE_PASS" > "$SECRET_FILE"
chmod 600 "$SECRET_FILE"
printf '%s\n' "$FREE_PASS" > "$NEWPASS_FILE"
chmod 600 "$NEWPASS_FILE"
rm -f "$PENDING_FILE"
echo "free:$(cat "$SECRET_FILE")" | chpasswd
'';
};
+20 -106
View File
@@ -34,8 +34,8 @@ lib.mkIf config.sovran_systemsOS.features.element-calling {
};
####### ENSURE SERVICES START AFTER KEY EXISTS #######
systemd.services.livekit.after = [ "livekit-key-setup.service" "livekit-turn-setup.service" ];
systemd.services.livekit.wants = [ "livekit-key-setup.service" "livekit-turn-setup.service" ];
systemd.services.livekit.after = [ "livekit-key-setup.service" ];
systemd.services.livekit.wants = [ "livekit-key-setup.service" ];
systemd.services.lk-jwt-service.after = [ "livekit-key-setup.service" ];
systemd.services.lk-jwt-service.wants = [ "livekit-key-setup.service" ];
@@ -68,54 +68,35 @@ $MATRIX {
header /.well-known/matrix/* Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
header /.well-known/matrix/* Access-Control-Allow-Headers "X-Requested-With, Content-Type, Authorization"
respond /.well-known/matrix/client \`{ "m.homeserver": {"base_url": "https://$MATRIX" }, "org.matrix.msc4143.rtc_foci": [{ "type":"livekit", "livekit_service_url":"https://$ELEMENT_CALLING/livekit/jwt" }] }\`
respond /.well-known/matrix/server \`{"m.server":"$MATRIX:443"}\`
}
$MATRIX:8448 {
reverse_proxy http://localhost:8008
}
$ELEMENT_CALLING {
# Route all current lk-jwt-service authorization endpoints to port 8073,
# stripping the /livekit/jwt prefix that Caddy adds on the public URL.
@lk_jwt path /livekit/jwt/sfu/get* /livekit/jwt/get_token* /livekit/jwt/healthz* /livekit/jwt/sfu_webhook* /livekit/jwt/delegate_delayed_leave*
handle @lk_jwt {
handle /livekit/jwt/sfu/get {
uri strip_prefix /livekit/jwt
reverse_proxy [::1]:8073 {
header_up Host {host}
header_up X-Forwarded-Server {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
}
}
handle {
reverse_proxy localhost:7880 {
header_up Host {host}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-For {remote_host}
header_up X-Real-IP {remote_host}
transport http {
read_timeout 300s
write_timeout 300s
}
}
reverse_proxy localhost:7880
}
}
EOF
'';
};
####### LIVEKIT TURN SETUP (runtime cert + config) #######
# Replaces the old dead livekit-runtime-config.service. At runtime this:
# * reads the matrix domain from /var/lib/domains/matrix (never hardcoded)
# * copies Caddy's already-issued matrix cert/key into /var/lib/livekit
# so LoadCredential can stage them for the (DynamicUser) livekit unit
# * detects the primary network interface from the IPv4 default route so
# LiveKit only advertises real ICE candidates — not VPN/container/private
# addresses from interfaces like Tailscale or Docker bridges
# * writes a complete LiveKit config (with turn.domain and interface
# substituted) that the overridden ExecStart loads.
systemd.services.livekit-turn-setup = {
description = "Stage TURN cert and generate LiveKit runtime config from domain files";
after = [ "caddy.service" "livekit-key-setup.service" ];
####### LIVEKIT RUNTIME CONFIG #######
systemd.services.livekit-runtime-config = {
description = "Generate LiveKit runtime config from domain files";
before = [ "livekit.service" ];
after = [ "livekit-key-setup.service" ];
requiredBy = [ "livekit.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
@@ -125,63 +106,20 @@ EOF
unitConfig = {
ConditionPathExists = "/var/lib/domains/element-calling";
};
path = [ pkgs.coreutils pkgs.findutils pkgs.iproute2 pkgs.gawk ];
path = [ pkgs.coreutils ];
script = ''
MATRIX=$(cat /var/lib/domains/matrix)
mkdir -p /run/livekit
# Copy Caddy's already-issued matrix cert/key into LiveKit's state dir.
# The ACME CA hostname directory can vary, so glob for the domain dir.
CRT=$(find /var/lib/caddy -path "*/$MATRIX/$MATRIX.crt" | head -n1)
KEY=$(find /var/lib/caddy -path "*/$MATRIX/$MATRIX.key" | head -n1)
cp "$CRT" /var/lib/livekit/turn.crt
cp "$KEY" /var/lib/livekit/turn.key
chmod 640 /var/lib/livekit/turn.crt /var/lib/livekit/turn.key
# Detect the primary network interface from the IPv4 default route.
# Restricting LiveKit to this single interface prevents it from
# advertising VPN/container/private ICE candidates (e.g. Tailscale,
# Docker bridges) that remote peers cannot reach, which causes all
# ICE negotiation attempts to fail with responsesReceived: 0.
IFACE=$(ip -4 route show default | awk '/^default/ { for(i=1;i<=NF;i++) if($i=="dev" && (i+1)<=NF) { print $(i+1); exit } }')
if [ -z "$IFACE" ]; then
echo "ERROR: Could not detect a default-route network interface from 'ip -4 route show default'." >&2
echo "ERROR: Cannot generate a valid LiveKit config without a real interface to bind ICE candidates to." >&2
echo "ERROR: Ensure a default IPv4 route is configured, e.g.: ip route add default via <gateway> dev <interface>" >&2
echo "ERROR: Inspect the current routing table with: ip -4 route show" >&2
exit 1
fi
echo "Detected primary network interface: $IFACE"
# Generate the full LiveKit config the daemon will load. turn.domain and
# rtc.interfaces.includes are only known at runtime, so they are
# substituted here. The cert/key paths point at the LoadCredential-staged
# copies under /run/credentials.
cat > /run/livekit/livekit.yaml <<EOF
port: 7880
rtc:
use_external_ip: true
skip_external_ip_validation: true
tcp_port: 7881
udp_port: 7882
port_range_start: 30000
port_range_end: 40000
interfaces:
includes:
- $IFACE
room:
auto_create: false
cat > /run/livekit/runtime-config.yaml <<EOF
turn:
enabled: true
domain: $MATRIX
tls_port: 5349
udp_port: 3478
cert_file: /run/credentials/livekit.service/turn-cert
key_file: /run/credentials/livekit.service/turn-key
cert_file: /var/lib/livekit/$MATRIX.crt
key_file: /var/lib/livekit/$MATRIX.key
EOF
chmod 644 /run/livekit/livekit.yaml
chmod 640 /run/livekit/runtime-config.yaml
'';
};
@@ -192,11 +130,7 @@ EOF
keyFile = livekitKeyFile;
settings = {
rtc.use_external_ip = true;
rtc.skip_external_ip_validation = true;
rtc.tcp_port = 7881;
rtc.udp_port = 7882;
rtc.port_range_start = 30000;
rtc.port_range_end = 40000;
rtc.udp_port = "7882-7894";
room.auto_create = false;
turn = {
enabled = true;
@@ -206,27 +140,9 @@ EOF
};
};
# Override ExecStart to load the runtime-generated config (which carries the
# runtime-only turn.domain), mirroring the Caddy ExecStart override pattern in
# modules/core/caddy.nix. Deliver the TURN cert/key via LoadCredential so they
# are readable under the upstream unit's DynamicUser=true sandbox without
# weakening it. Everything else about the standard unit is left intact.
systemd.services.livekit.serviceConfig.ExecStart = lib.mkForce [
""
"${pkgs.livekit}/bin/livekit-server --config /run/credentials/livekit.service/livekit-config --key-file /run/credentials/livekit.service/livekit-secrets"
];
systemd.services.livekit.serviceConfig.LoadCredential = [
"livekit-config:/run/livekit/livekit.yaml"
"livekit-secrets:${livekitKeyFile}"
"turn-cert:/var/lib/livekit/turn.crt"
"turn-key:/var/lib/livekit/turn.key"
];
networking.firewall.allowedTCPPorts = [ 5349 7881 ];
networking.firewall.allowedUDPPorts = [ 3478 7882 ];
networking.firewall.allowedTCPPorts = [ 7881 ];
networking.firewall.allowedUDPPortRanges = [
{ from = 30000; to = 40000; } # LiveKit internal TURN relay range
{ from = 7882; to = 7894; }
];
####### JWT SERVICE RUNTIME CONFIG #######
@@ -246,13 +162,11 @@ EOF
path = [ pkgs.coreutils ];
script = ''
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
MATRIX=$(cat /var/lib/domains/matrix)
mkdir -p /run/lk-jwt-service
cat > /run/lk-jwt-service/env <<EOF
LIVEKIT_URL=wss://$ELEMENT_CALLING
LIVEKIT_FULL_ACCESS_HOMESERVERS=$MATRIX
EOF
chmod 640 /run/lk-jwt-service/env
+1 -3
View File
@@ -14,9 +14,6 @@
./core/sovran-hub.nix
./core/legacy-cleanup.nix
./core/remote-deploy.nix
./core/no-sleep.nix
./core/cpu-performance.nix
./core/local-domain-loopback.nix
# ── Always on (no flag) ───────────────────────────────────
./php.nix
@@ -32,6 +29,7 @@
# ── Features (default OFF — enable in custom.nix) ─────────
./haven.nix
./bip110.nix
./element-calling.nix
./mempool.nix
./bitcoin-core.nix
+12 -106
View File
@@ -53,7 +53,7 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud {
# ── Fully automated Nextcloud setup ───────────────────────
systemd.services.nextcloud-init = {
description = "Download, extract, and fully configure Nextcloud";
after = [ "network-online.target" "postgresql.service" "phpfpm-nextcloud.service" "nextcloud-db-init.service" ];
after = [ "network-online.target" "postgresql.service" "phpfpm-mypool.service" "nextcloud-db-init.service" ];
wants = [ "network-online.target" ];
requires = [ "postgresql.service" "nextcloud-db-init.service" ];
wantedBy = [ "multi-user.target" ];
@@ -73,7 +73,7 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud {
set -euo pipefail
INSTALL_DIR="/var/lib/www/nextcloud"
DATA_DIR="/var/lib/nextcloud"
DATA_DIR="/var/lib/www/nextcloud-data"
DOMAIN=$(cat /var/lib/domains/nextcloud)
DB_NAME="nextclouddb"
DB_USER="ncusr"
@@ -81,11 +81,6 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud {
DB_HOST="localhost"
ADMIN_USER=$(pwgen -s 16 1)
ADMIN_PASS=$(pwgen -s 24 1)
SERVER_ID=$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n')
if [ -z "$SERVER_ID" ]; then
echo "Failed to generate Nextcloud server_id"
exit 1
fi
echo ""
echo " Nextcloud Automated Installation"
@@ -97,22 +92,20 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud {
curl -L -o "$TEMP_DIR/nextcloud.zip" "https://download.nextcloud.com/server/releases/latest.zip"
unzip -q "$TEMP_DIR/nextcloud.zip" -d "$TEMP_DIR"
mkdir -p "$INSTALL_DIR"
cp -a "$TEMP_DIR/nextcloud/." "$INSTALL_DIR/"
cp -a "$TEMP_DIR/nextcloud/"* "$INSTALL_DIR/"
rm -rf "$TEMP_DIR"
echo "Download complete."
fi
chown -R caddy:php "$INSTALL_DIR"
mkdir -p "$DATA_DIR"
chown -R caddy:root "$INSTALL_DIR"
chown -R caddy:root "$DATA_DIR"
find "$INSTALL_DIR" -type d -exec chmod 750 {} \;
find "$INSTALL_DIR" -type f -exec chmod 640 {} \;
chmod -R 770 "$INSTALL_DIR/apps"
chmod -R 770 "$INSTALL_DIR/config"
if [ ! -d "$DATA_DIR" ]; then
mkdir -p "$DATA_DIR"
chown -R caddy:php "$DATA_DIR"
chmod -R 770 "$DATA_DIR"
fi
chmod -R 770 "$DATA_DIR"
echo "Waiting for PostgreSQL..."
for i in $(seq 1 30); do
@@ -139,35 +132,15 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud {
/run/wrappers/bin/su -s /bin/sh caddy -c "
php $INSTALL_DIR/occ config:system:set trusted_domains 0 --value='$DOMAIN'
php $INSTALL_DIR/occ config:system:set overwrite.cli.url --value='https://$DOMAIN'
php $INSTALL_DIR/occ config:system:set overwritehost --value='$DOMAIN'
php $INSTALL_DIR/occ config:system:set overwriteprotocol --value='https'
"
/run/wrappers/bin/su -s /bin/sh caddy -c "
php $INSTALL_DIR/occ config:system:set trusted_proxies 0 --value='127.0.0.1'
php $INSTALL_DIR/occ config:system:set trusted_proxies 1 --value='::1'
php $INSTALL_DIR/occ config:system:set forwarded_for_headers 0 --value='HTTP_X_FORWARDED_FOR'
php $INSTALL_DIR/occ config:system:set default_phone_region --value='US'
php $INSTALL_DIR/occ config:system:set maintenance_window_start --type=integer --value=1
php $INSTALL_DIR/occ config:system:set memcache.local --value='\OC\Memcache\APCu'
php $INSTALL_DIR/occ config:system:set memcache.locking --value='\OC\Memcache\APCu'
php $INSTALL_DIR/occ config:system:set server_id --value='$SERVER_ID'
php $INSTALL_DIR/occ background:cron
"
/run/wrappers/bin/su -s /bin/sh caddy -c "
php $INSTALL_DIR/occ integrity:check-core
php $INSTALL_DIR/occ maintenance:repair
php $INSTALL_DIR/occ db:add-missing-indices
php $INSTALL_DIR/occ db:add-missing-columns
php $INSTALL_DIR/occ db:add-missing-primary-keys
php $INSTALL_DIR/occ maintenance:repair --include-expensive
# AppAPI deploy daemon warnings are avoided by disabling app_api when present.
if php $INSTALL_DIR/occ app:info app_api >/dev/null 2>&1; then
php $INSTALL_DIR/occ app:disable app_api
fi
"
/run/wrappers/bin/su -s /bin/sh caddy -c "
php $INSTALL_DIR/occ app:install calendar || true
php $INSTALL_DIR/occ app:install contacts || true
@@ -199,86 +172,19 @@ CREDS
'';
};
systemd.services.nextcloud-detect-existing = {
description = "Detect pre-existing Nextcloud installation and populate hub credentials";
after = [ "postgresql.service" ];
wants = [ "postgresql.service" ];
wantedBy = [ "multi-user.target" ];
unitConfig = {
ConditionPathExists = [
"/var/lib/www/nextcloud/config/config.php"
"!/var/lib/secrets/nextcloud-admin"
];
};
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
path = with pkgs; [ coreutils gnused ];
script = ''
set -euo pipefail
CREDS_FILE="/var/lib/secrets/nextcloud-admin"
DOMAIN_FILE="/var/lib/domains/nextcloud"
DOMAIN="your-domain"
if [ -f "$DOMAIN_FILE" ]; then
FILE_DOMAIN="$(sed -n '1{s/^[[:space:]]*//;s/[[:space:]]*$//;p;}' "$DOMAIN_FILE")"
if [ -n "$FILE_DOMAIN" ]; then
DOMAIN="$FILE_DOMAIN"
fi
fi
mkdir -p /var/lib/secrets
cat > "$CREDS_FILE" << CREDS
Nextcloud (Pre-existing Installation)
URL: https://$DOMAIN/
Note: This Nextcloud was installed before Sovran_SystemsOS.
Use your existing admin credentials to log in.
Reset: sudo -u caddy php /var/lib/www/nextcloud/occ user:resetpassword <username>
CREDS
chmod 600 "$CREDS_FILE"
'';
};
services.cron.systemCronJobs = [
"*/5 * * * * caddy /run/current-system/sw/bin/php -f /var/lib/www/nextcloud/cron.php"
];
systemd.tmpfiles.rules = [
"d /var/lib/www 0755 caddy php -"
"d /var/lib/www/nextcloud 0750 caddy php -"
"d /var/lib/nextcloud 0770 caddy php -"
"d /var/lib/www 0755 caddy root -"
"d /var/lib/www/nextcloud 0750 caddy root -"
"d /var/lib/www/nextcloud-data 0770 caddy root -"
];
services.phpfpm.pools.nextcloud = {
user = "caddy";
group = "php";
phpPackage = config.sovran_systemsOS.phpPackage;
phpOptions = lib.mkAfter ''
output_buffering = 0
'';
settings = {
"pm" = "dynamic";
"pm.max_children" = 75;
"pm.start_servers" = 10;
"pm.min_spare_servers" = 5;
"pm.max_spare_servers" = 20;
"pm.max_requests" = 500;
"clear_env" = "no";
"listen" = "/run/phpfpm/nextcloud.sock";
};
};
environment.systemPackages = with pkgs; [ unzip ];
sovran_systemsOS.domainRequirements = [
{ name = "nextcloud"; label = "Nextcloud"; example = "cloud.yourdomain.com"; }
];
}
}
+29 -20
View File
@@ -28,30 +28,39 @@ let
};
in
{
options.sovran_systemsOS.phpPackage = lib.mkOption {
type = lib.types.package;
default = custom-php;
description = "Shared PHP package with all extensions for Sovran_SystemsOS services";
{
users.users = {
php = {
isSystemUser = true;
createHome = false;
uid = 7777;
};
};
config = {
users.users = {
users.users.php.group = "php";
users.groups.php = {};
php = {
isSystemUser = true;
createHome = false;
uid = 7777;
};
};
users.users.php.group = "php";
environment.systemPackages = with pkgs; [
users.groups.php = {};
custom-php
];
environment.systemPackages = with pkgs; [
custom-php
];
services.phpfpm.pools = {
mypool = {
user = "caddy";
group = "php";
phpPackage = custom-php;
settings = {
"pm" = "dynamic";
"pm.max_children" = 75;
"pm.start_servers" = 10;
"pm.min_spare_servers" = 5;
"pm.max_spare_servers" = 20;
"pm.max_requests" = 500;
"clear_env" = "no";
};
};
};
}
Regular → Executable
+55 -120
View File
@@ -2,104 +2,70 @@
lib.mkIf config.sovran_systemsOS.features.rdp {
users.users.gnome-remote-desktop = {
isSystemUser = true;
group = "gnome-remote-desktop";
home = "/var/lib/gnome-remote-desktop";
createHome = true;
};
users.groups.gnome-remote-desktop = {};
# Enable the GNOME Remote Desktop service at the system level
services.gnome.gnome-remote-desktop.enable = true;
# Open RDP port in the firewall
networking.firewall.allowedTCPPorts = [ 3389 ];
# Ensure the service only starts after setup succeeds
# Ensure the service actually starts and waits for setup to complete
systemd.services.gnome-remote-desktop = {
wantedBy = [ "graphical.target" ];
after = [ "gnome-remote-desktop-setup.service" ];
requires = [ "gnome-remote-desktop-setup.service" ];
wants = [ "gnome-remote-desktop-setup.service" ];
};
systemd.tmpfiles.rules = [
"d /var/lib/gnome-remote-desktop/.local 0700 gnome-remote-desktop gnome-remote-desktop -"
"d /var/lib/gnome-remote-desktop/.local/share 0700 gnome-remote-desktop gnome-remote-desktop -"
"d /var/lib/gnome-remote-desktop/.local/share/gnome-remote-desktop 0700 gnome-remote-desktop gnome-remote-desktop -"
"d /var/lib/gnome-remote-desktop/tls 0700 gnome-remote-desktop gnome-remote-desktop -"
"d /var/lib/gnome-remote-desktop 0750 gnome-remote-desktop gnome-remote-desktop -"
"d /var/lib/gnome-remote-desktop/.local 0750 gnome-remote-desktop gnome-remote-desktop -"
"d /var/lib/gnome-remote-desktop/.local/share 0750 gnome-remote-desktop gnome-remote-desktop -"
"d /var/lib/gnome-remote-desktop/.local/share/gnome-remote-desktop 0750 gnome-remote-desktop gnome-remote-desktop -"
];
systemd.services.gnome-remote-desktop-setup = {
description = "Configure GNOME Remote Desktop RDP";
wantedBy = [ "graphical.target" ];
wantedBy = [ "multi-user.target" ];
before = [ "gnome-remote-desktop.service" ];
after = [
"dbus.service"
"systemd-tmpfiles-setup.service"
"network-online.target"
"gnome-remote-desktop-configuration.service"
];
wants = [
"network-online.target"
"gnome-remote-desktop-configuration.service"
];
after = [ "systemd-tmpfiles-setup.service" "network-online.target" ];
wants = [ "network-online.target" ];
serviceConfig = {
Type = "oneshot";
TimeoutStartSec = "2min";
RemainAfterExit = true;
};
path = [
pkgs.coreutils
pkgs.gawk
pkgs.gnome-remote-desktop
pkgs.hostname
pkgs.polkit
pkgs.openssl
pkgs.systemd
pkgs.hostname
pkgs.gawk
];
script = ''
set -euo pipefail
# Ensure directory structure exists
mkdir -p /var/lib/gnome-remote-desktop/.local/share/gnome-remote-desktop
chown -R gnome-remote-desktop:gnome-remote-desktop /var/lib/gnome-remote-desktop
# GRD 50.x invokes pkexec internally for every grdctl --system call, even
# when the caller is root. NixOS exposes the required setuid wrapper at
# /run/wrappers/bin/pkexec; the Nix-store polkit binary is not setuid and
# must not shadow it. Prepend the wrapper directory so every subsequent
# grdctl --system resolves the correct binary.
export PATH="/run/wrappers/bin:$PATH"
STATE_DIR="/var/lib/gnome-remote-desktop"
TLS_DIR="$STATE_DIR/tls"
USERNAME_FILE="$STATE_DIR/rdp-username"
PASSWORD_FILE="$STATE_DIR/rdp-password"
CRED_FILE="$STATE_DIR/rdp-credentials"
DEFAULT_USERNAME="sovran"
grdctl_system() {
local rc=0
if timeout --kill-after=5s 10s \
grdctl --system "$@"; then
return 0
else
rc=$?
fi
if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ]; then
echo "grdctl command timed out: $*" >&2
fi
echo "grdctl command failed (exit $rc): $*" >&2
return "$rc"
}
mkdir -p "$STATE_DIR/.local/share/gnome-remote-desktop" "$TLS_DIR"
chown -R gnome-remote-desktop:gnome-remote-desktop "$STATE_DIR"
chmod 700 \
"$STATE_DIR" \
"$STATE_DIR/.local" \
"$STATE_DIR/.local/share" \
"$STATE_DIR/.local/share/gnome-remote-desktop" \
"$TLS_DIR"
TLS_DIR="/var/lib/gnome-remote-desktop/tls"
CRED_FILE="/var/lib/gnome-remote-desktop/rdp-credentials"
# Regenerate TLS certificate if missing OR if ownership is wrong
# (disable/re-enable cycle can break ownership or grdctl state)
NEED_REGEN=0
if [ ! -f "$TLS_DIR/rdp-tls.crt" ] || [ ! -f "$TLS_DIR/rdp-tls.key" ]; then
NEED_REGEN=1
elif [ "$(stat -c '%U:%G' "$TLS_DIR/rdp-tls.key" 2>/dev/null)" != "gnome-remote-desktop:gnome-remote-desktop" ]; then
elif [ "$(stat -c '%U' "$TLS_DIR/rdp-tls.key" 2>/dev/null)" != "gnome-remote-desktop" ]; then
NEED_REGEN=1
fi
if [ "$NEED_REGEN" = "1" ]; then
mkdir -p "$TLS_DIR"
rm -f "$TLS_DIR/rdp-tls.key" "$TLS_DIR/rdp-tls.crt"
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
-sha256 -nodes -days 3650 \
@@ -109,59 +75,39 @@ lib.mkIf config.sovran_systemsOS.features.rdp {
echo "Generated new RDP TLS certificate"
fi
chown gnome-remote-desktop:gnome-remote-desktop "$TLS_DIR/rdp-tls.key" "$TLS_DIR/rdp-tls.crt"
# Always fix ownership and permissions (handles re-enable after disable)
chown -R gnome-remote-desktop:gnome-remote-desktop "$TLS_DIR"
chmod 600 "$TLS_DIR/rdp-tls.key"
chmod 644 "$TLS_DIR/rdp-tls.crt"
if [ ! -f "$USERNAME_FILE" ]; then
printf '%s\n' "$DEFAULT_USERNAME" > "$USERNAME_FILE"
fi
USERNAME="$(tr -d '\n' < "$USERNAME_FILE")"
if [ -z "$USERNAME" ]; then
USERNAME="$DEFAULT_USERNAME"
printf '%s\n' "$USERNAME" > "$USERNAME_FILE"
fi
if [ "''${#USERNAME}" -gt 32 ]; then
echo "RDP username is too long (''${#USERNAME} characters, maximum 32): $USERNAME from $USERNAME_FILE" >&2
exit 1
fi
case "$USERNAME" in
[A-Za-z_][A-Za-z0-9_-]*)
;;
*)
echo "RDP username must start with a letter or underscore and contain only letters, numbers, underscores, and hyphens: $USERNAME from $USERNAME_FILE" >&2
exit 1
;;
esac
chown gnome-remote-desktop:gnome-remote-desktop "$USERNAME_FILE"
chmod 600 "$USERNAME_FILE"
# Configure TLS certificate
grdctl --system rdp set-tls-cert "$TLS_DIR/rdp-tls.crt"
grdctl --system rdp set-tls-key "$TLS_DIR/rdp-tls.key"
if [ ! -f "$PASSWORD_FILE" ]; then
openssl rand -base64 16 > "$PASSWORD_FILE"
fi
PASSWORD="$(tr -d '\n' < "$PASSWORD_FILE")"
if [ -z "$PASSWORD" ]; then
echo "RDP password file is empty: $PASSWORD_FILE" >&2
exit 1
fi
if [ "''${#PASSWORD}" -lt 8 ]; then
echo "RDP password is too short (''${#PASSWORD} characters, minimum 8): $PASSWORD_FILE" >&2
exit 1
fi
chown gnome-remote-desktop:gnome-remote-desktop "$PASSWORD_FILE"
chmod 600 "$PASSWORD_FILE"
LOCAL_IP="$(hostname -I | awk '{print $1}')"
if [ -z "$LOCAL_IP" ]; then
LOCAL_IP="127.0.0.1"
# Generate password on first boot only
PASSWORD=""
if [ ! -f /var/lib/gnome-remote-desktop/rdp-password ]; then
PASSWORD=$(openssl rand -base64 16)
echo "$PASSWORD" > /var/lib/gnome-remote-desktop/rdp-password
chmod 600 /var/lib/gnome-remote-desktop/rdp-password
else
PASSWORD=$(cat /var/lib/gnome-remote-desktop/rdp-password)
fi
# Write username to a separate file for the hub
echo "sovran" > /var/lib/gnome-remote-desktop/rdp-username
chmod 600 /var/lib/gnome-remote-desktop/rdp-username
# Get current IP address
LOCAL_IP=$(hostname -I | awk '{print $1}')
# Always rewrite the credentials file with the current IP
cat > "$CRED_FILE" <<EOF
========================================
GNOME Remote Desktop (RDP) Credentials
========================================
Username: $USERNAME
Username: sovran
Password: $PASSWORD
Connect from any RDP client to:
@@ -170,22 +116,11 @@ lib.mkIf config.sovran_systemsOS.features.rdp {
========================================
EOF
chown gnome-remote-desktop:gnome-remote-desktop "$CRED_FILE"
chmod 600 "$CRED_FILE"
# Preflight: the NixOS setuid pkexec wrapper must be present and executable
# before any grdctl --system call. Absence means the system was booted
# without security.wrappers or the wrapper directory is not mounted yet.
if ! test -x /run/wrappers/bin/pkexec; then
echo "Preflight check failed: /run/wrappers/bin/pkexec is absent or not executable." >&2
echo "GNOME Remote Desktop system configuration requires the NixOS setuid pkexec wrapper at /run/wrappers/bin/pkexec." >&2
exit 1
fi
grdctl_system rdp enable
grdctl_system rdp set-tls-cert "$TLS_DIR/rdp-tls.crt"
grdctl_system rdp set-tls-key "$TLS_DIR/rdp-tls.key"
grdctl_system rdp set-credentials "$USERNAME" "$PASSWORD"
# Enable RDP backend and set credentials
grdctl --system rdp enable
grdctl --system rdp set-credentials sovran "$PASSWORD"
echo "GNOME Remote Desktop RDP configured successfully"
'';
+1
View File
@@ -118,6 +118,7 @@ EOF
"198.18.0.0/15" "198.51.100.0/24" "2001:db8::/32" "203.0.113.0/24"
"224.0.0.0/4" "::1/128" "fc00::/7" "fe80::/10" "fec0::/10" "ff00::/8"
];
url_preview_ip_ranger_whitelist = [ "127.0.0.1" ];
presence.enabled = true;
enable_registration = false;
listeners = [
+38 -1
View File
@@ -31,7 +31,6 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
cat > "$CONFIG_FILE" << 'EOF'
{
"mode": "ONLINE",
"serverType": "ELECTRUM_SERVER",
"electrumServer": "tcp://127.0.0.1:50001",
"useProxy": false
@@ -43,6 +42,44 @@ EOF
'';
};
# ── Bisq 1 Auto-Connect ─────────────────────────────────────
systemd.services.bisq-autoconnect = {
description = "Auto-configure Bisq to use local Bitcoin node";
after = [ "bitcoind.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
path = [ pkgs.coreutils pkgs.iproute2 ];
script = ''
BISQ_CONF="/home/free/.local/share/Bisq/bisq.properties"
if [ -f "$BISQ_CONF" ]; then
echo "Bisq config already exists, skipping"
exit 0
fi
# Wait for bitcoind RPC to be ready (up to 30 attempts)
ATTEMPTS=0
until ss -ltn 2>/dev/null | grep -q ':8333' || [ "$ATTEMPTS" -ge 30 ]; do
ATTEMPTS=$((ATTEMPTS + 1))
sleep 2
done
mkdir -p /home/free/.local/share/Bisq
cat > "$BISQ_CONF" << 'EOF'
btcNodes=127.0.0.1:8333
useTorForBtc=true
useCustomBtcNodes=true
EOF
chown -R free:users /home/free/.local/share/Bisq
echo "Bisq auto-configured to use local Bitcoin node"
'';
};
# ── Zeus Connect (lndconnect URL for mobile wallet) ──────────
systemd.services.zeus-connect-setup = {
description = "Save Zeus lndconnect URL";
+6 -70
View File
@@ -46,7 +46,7 @@ lib.mkIf config.sovran_systemsOS.services.wordpress {
# ── Fully automated WordPress setup ───────────────────────
systemd.services.wordpress-init = {
description = "Download, extract, and fully configure WordPress";
after = [ "network-online.target" "mysql.service" "phpfpm-wordpress.service" "wordpress-db-init.service" ];
after = [ "network-online.target" "mysql.service" "phpfpm-mypool.service" "wordpress-db-init.service" ];
wants = [ "network-online.target" ];
requires = [ "mysql.service" "wordpress-db-init.service" ];
wantedBy = [ "multi-user.target" ];
@@ -94,10 +94,10 @@ lib.mkIf config.sovran_systemsOS.services.wordpress {
echo "Download complete."
fi
chown -R caddy:php "$INSTALL_DIR"
find "$INSTALL_DIR" -type d -exec chmod 750 {} \;
find "$INSTALL_DIR" -type f -exec chmod 640 {} \;
chmod -R 770 "$INSTALL_DIR/wp-content"
chown -R caddy:root "$INSTALL_DIR"
find "$INSTALL_DIR" -type d -exec chmod 755 {} \;
find "$INSTALL_DIR" -type f -exec chmod 644 {} \;
chmod -R 775 "$INSTALL_DIR/wp-content"
echo "Generating wp-config.php..."
cd "$INSTALL_DIR"
@@ -162,73 +162,9 @@ CREDS
'';
};
systemd.services.wordpress-detect-existing = {
description = "Detect pre-existing WordPress installation and populate hub credentials";
after = [ "mysql.service" ];
wants = [ "mysql.service" ];
wantedBy = [ "multi-user.target" ];
unitConfig = {
ConditionPathExists = [
"/var/lib/www/wordpress/wp-config.php"
"!/var/lib/secrets/wordpress-admin"
];
};
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
path = with pkgs; [ coreutils gnused ];
script = ''
set -euo pipefail
CREDS_FILE="/var/lib/secrets/wordpress-admin"
DOMAIN_FILE="/var/lib/domains/wordpress"
DOMAIN="your-domain"
if [ -f "$DOMAIN_FILE" ]; then
FILE_DOMAIN="$(sed -n '1{s/^[[:space:]]*//;s/[[:space:]]*$//;p;}' "$DOMAIN_FILE")"
if [ -n "$FILE_DOMAIN" ]; then
DOMAIN="$FILE_DOMAIN"
fi
fi
mkdir -p /var/lib/secrets
cat > "$CREDS_FILE" << CREDS
WordPress (Pre-existing Installation)
URL: https://$DOMAIN/wp-admin/
Note: This WordPress was installed before Sovran_SystemsOS.
Use your existing admin credentials to log in.
Reset: wp user update <username> --user_pass=<new-password>
CREDS
chmod 600 "$CREDS_FILE"
'';
};
services.phpfpm.pools.wordpress = {
user = "caddy";
group = "php";
phpPackage = config.sovran_systemsOS.phpPackage;
settings = {
"pm" = "dynamic";
"pm.max_children" = 75;
"pm.start_servers" = 10;
"pm.min_spare_servers" = 5;
"pm.max_spare_servers" = 20;
"pm.max_requests" = 500;
"clear_env" = "no";
"listen" = "/run/phpfpm/wordpress.sock";
};
};
systemd.tmpfiles.rules = [
"d /var/lib/www 0755 caddy root -"
"d /var/lib/www/wordpress 0750 caddy php -"
"d /var/lib/www/wordpress 0755 caddy root -"
];
environment.systemPackages = with pkgs; [ wp-cli unzip ];