12 Commits
Author SHA1 Message Date
naturallaw777 1d46d036c0 nixpkgs update 2026-09-07 10:55:32 -05:00
Sovran Systems 9ae4e34fe0 Switch LibreOffice from fresh to stable version 2026-09-03 16:31:12 -05:00
Sovran Systems d1e226a687 fix(hub): self-heal truncated/corrupt Nix downloads and keep failed updates retryable
Updater/rebuild self-heal:
- Add a shared run_step wrapper used by both the update and rebuild
  scripts. On the first failure matching a transient fetch/cache signature
  (truncated tarball, corrupt NAR, hash mismatch, network timeout,
  interrupted download), clear Nix's fetch caches and repair the store,
  then retry once. Real config errors do not match and still fail loudly.
- The kernel-change boot fallback in the rebuild path is also wrapped.
- Fixes the reported 'cannot read file from tarball: Truncated tar archive
  detected' failure, which a plain re-run cannot clear because Nix reuses
  the corrupt cached archive.

Failed-update recovery / reporting:
- check_for_updates() now compares the running Hub version against the
  branch VERSION, so a failed 'nix flake update' (lock advanced but no
  generation staged) can no longer masquerade as 'up to date' and block
  retries.
- /api/updates/check surfaces a persistent 'failed' state; /api/updates/run
  never blocks a retry after a failure.
- Dashboard shows a red 'Update failed - click to retry' tile; the modal
  offers a Retry Update button and stops offering a reboot on failure.
2026-09-03 11:54:29 -05:00
Patch Bot 49e41eeea9 ui: simplify element-calling port modal — trim verbose steps and remove extra verification task
- Replace 3 long instruction bullets with 1 plaintext line
- Shorten intro (drop long parenthetical on IP location)
- Remove 'how to confirm it worked' note — if router applies rules, ports are open
- Keep port table intact (40000-40099 current set)
2026-09-03 09:52:27 -05:00
naturallaw777 88be5b99dd updated flake to build through the new sovran-bitcoin input 2026-09-02 15:29:20 -05:00
naturallaw777 93492e88fb chore(release): prepare v1.1.4 2026-09-02 15:13:56 -05:00
naturallaw777 7da37cdb34 updated nixpkgs 2026-09-02 14:52:15 -05:00
Sovran_SystemsOS f34d1533c3 element-calling: fix Nix string interpolation of LAN_CIDR echo
The debug echo used bash ${LAN_CIDR:-<none>} syntax, but inside a Nix
indented string ${...} is Nix interpolation, not bash. Nix parsed
'LAN_CIDR:-<none>' as a lambda and failed the build with 'cannot coerce a
function to a string'. Rewrite the echo without brace expansion.
2026-09-01 12:29:47 -05:00
Sovran_SystemsOS 220d6dff2c docs/hub: update Element Calling port guidance to the new port set
User-facing polish to match the element-calling port changes:

- helpers.js: port-forward guide example now shows the 40000-40099 range
  instead of the removed 30000-40000.
- versions.json: livekit dev fallback 1.5.2 -> 1.13.6 (the Nix-generated
  version in sovran-hub.nix already derives from pkgs.livekit.version).
- server.py: docstring/comment examples reference 40000-40099.
- CHANGELOG.md: record the calling changes under [Unreleased].

The tile/modal port tables themselves are data-driven from server.py's
port_requirements / _PORTS_ELEMENT_CALLING (updated in the previous commit),
so no further UI changes are needed.
2026-09-01 12:15:16 -05:00
Sovran_SystemsOS 81ab3b2280 element-calling: fix Wi-Fi calls and tighten media/TURN ports
Root cause of 'calls fail on Wi-Fi but work on mobile data': LiveKit only
advertised the public/WAN IP (rtc.node_ip), so LAN clients had to hairpin
through the router for media. Fixes and cleanup:

- rtc.advertise_internal_ip: true — also advertise the primary interface's
  LAN host candidate, so Wi-Fi callers connect directly (no hairpin).
- Drop rtc.port_range_start/end (30000-40000) and keep the single UDP mux
  (udp_port: 7882). In LiveKit 1.13.x the range takes precedence over
  udp_port, so media was actually spread over 10000 ports.
- Drop turn.tls_port: 5349 — LiveKit advertises turns:<domain>:443 to clients
  regardless of tls_port, so a 5349 TURN/TLS listener was unreachable dead
  config (and needless attack surface).
- Pin TURN relay allocation to 40000-40099 (disjoint from the media mux) and
  open/forward that range; the old default overlapped RTC media.
- turn.allow_restricted_peer_cidrs with the LAN subnet derived from the
  primary interface: without it the relay refuses to deliver to the private
  LAN host candidate and its final hop would fall back to WAN hairpin.
- Update Hub port guidance (server.py) to the new list.
2026-09-01 12:15:16 -05:00
Sovran_SystemsOS d5d40a1697 flake: pin LiveKit to 1.13.6 for rtc.advertise_internal_ip support
rtc.advertise_internal_ip (which lets LAN callers reach the SFU via a LAN
host candidate, avoiding NAT-hairpin on the router) is only honoured when
node_ip is set manually from LiveKit v1.13.6 (mediatransportutil f234b53).
nixpkgs-unstable currently ships 1.13.5, so pin 1.13.6 with the hashes from
nixpkgs master. Remove this override once nixpkgs-unstable reaches >= 1.13.6.
2026-09-01 12:15:16 -05:00
naturallaw777 3756a0058d updated sovran-bitcon 2026-08-31 20:37:46 -05:00
16 changed files with 335 additions and 105 deletions
+37
View File
@@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
## [1.1.4] - 2026-09-02
### Added
- Element-calling: fix Nix string interpolation of LAN_CIDR echo
- Docs/hub: update Element Calling port guidance to the new port set
- Element-calling: fix Wi-Fi calls and tighten media/TURN ports
- Flake: pin LiveKit to 1.13.6 for rtc.advertise_internal_ip support
### Changed
- Updated nixpkgs
- Updated sovran-bitcon
- Dedupe NWC tooling — use Sovran_Bitcoin's sovran-nwc
- Update sovran-bitcoin flake input
- Extract bitcoin stack into Sovran_Bitcoin flake input
### Fixed
- Set lnurl domainFile for Hub-managed Lightning Address domain
- Correct disablewallet casing
- Fixed typo
[1.1.4]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.4
## [Unreleased]
### Changed
- Element Calling: pin LiveKit to 1.13.6 and advertise the LAN interface
(rtc.advertise_internal_ip) so Wi-Fi callers get a local ICE candidate and
calls no longer depend on router NAT hairpin
- Element Calling: use a single UDP media mux (7882); move the TURN relay to
40000-40099 and remove the unreachable 5349 TURN/TLS listener
- Hub: update Element Calling router-port guidance to the new port set
### Fixed
- Element Calling: TURN relay could not deliver media to the SFU on routers
without NAT loopback (turn.allow_restricted_peer_cidrs now permits the LAN
subnet derived from the primary interface)
## [1.1.3] - 2026-08-27
### Added
+12 -12
View File
@@ -21,9 +21,9 @@ Lightning infrastructure, private cloud, and communications platform when you
are ready.
[Visit the Website](https://sovransystems.com) ·
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.3.iso) ·
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.4.iso) ·
[Try it safely in a VM](#try-it-first-in-a-virtual-machine) ·
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.3.iso.sha256) ·
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.4.iso.sha256) ·
[Build from Source](#build-from-source)
<img src="assets/desktop-screenshot.webp" alt="Sovran_SystemsOS private Bitcoin desktop" width="800" />
@@ -345,8 +345,8 @@ with an imaging application such as [Balena Etcher](https://etcher.balena.io).
### 1. Download the ISO and checksum
- [Download Sovran_SystemsOS-1.1.3.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.3.iso)
- [Download Sovran_SystemsOS-1.1.3.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.3.iso.sha256)
- [Download Sovran_SystemsOS-1.1.4.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.4.iso)
- [Download Sovran_SystemsOS-1.1.4.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.4.iso.sha256)
The download may take some time. Do not rename or modify the ISO before
verifying it, and keep both files in the same folder.
@@ -364,16 +364,16 @@ checksum exactly.
Open a terminal in the download folder and run:
```bash
sha256sum --check Sovran_SystemsOS-1.1.3.iso.sha256
sha256sum --check Sovran_SystemsOS-1.1.4.iso.sha256
```
A successful comparison reports:
```text
Sovran_SystemsOS-1.1.3.iso: OK
Sovran_SystemsOS-1.1.4.iso: OK
```
You can also run `sha256sum Sovran_SystemsOS-1.1.3.iso` and compare the output
You can also run `sha256sum Sovran_SystemsOS-1.1.4.iso` and compare the output
against the checksum file manually.
</details>
@@ -384,11 +384,11 @@ against the checksum file manually.
Open Terminal in the download folder and run:
```bash
shasum -a 256 Sovran_SystemsOS-1.1.3.iso
shasum -a 256 Sovran_SystemsOS-1.1.4.iso
```
Compare the value shown in Terminal with the value inside
`Sovran_SystemsOS-1.1.3.iso.sha256`.
`Sovran_SystemsOS-1.1.4.iso.sha256`.
</details>
@@ -398,7 +398,7 @@ Compare the value shown in Terminal with the value inside
Open PowerShell in the download folder and run:
```powershell
Get-FileHash .\Sovran_SystemsOS-1.1.3.iso -Algorithm SHA256
Get-FileHash .\Sovran_SystemsOS-1.1.4.iso -Algorithm SHA256
```
Compare the value under `Hash` with the published checksum.
@@ -413,7 +413,7 @@ match exactly.
1. Download and install [Balena Etcher](https://etcher.balena.io), then
connect the USB drive.
2. Choose **Flash from file** and select `Sovran_SystemsOS-1.1.3.iso`.
2. Choose **Flash from file** and select `Sovran_SystemsOS-1.1.4.iso`.
3. Choose **Select target**, select the USB drive, and review your selection
carefully.
4. Choose **Flash** and wait for the writing and verification process to
@@ -856,7 +856,7 @@ primary location for collaboration. Please read our
## Privacy. Sovereignty. Bitcoin.
[Visit Sovran Systems](https://sovransystems.com) ·
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.3.iso) ·
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.4.iso) ·
[View the License](LICENSE)
</div>
+1 -1
View File
@@ -1 +1 @@
1.1.3
1.1.4
+81 -14
View File
@@ -291,11 +291,10 @@ FEATURE_REGISTRY = [
"port_requirements": [
{"port": "80", "protocol": "TCP", "description": "HTTP (redirect to HTTPS)"},
{"port": "443", "protocol": "TCP", "description": "HTTPS (domain)"},
{"port": "7881", "protocol": "TCP", "description": "LiveKit WebRTC signalling"},
{"port": "7882", "protocol": "UDP", "description": "LiveKit media (UDP mux)"},
{"port": "5349", "protocol": "TCP", "description": "TURN over TLS"},
{"port": "3478", "protocol": "UDP", "description": "TURN (STUN/relay)"},
{"port": "30000-40000", "protocol": "TCP/UDP", "description": "TURN relay (WebRTC)"},
{"port": "7881", "protocol": "TCP", "description": "WebRTC media (TCP fallback)"},
{"port": "7882", "protocol": "UDP", "description": "WebRTC media (UDP)"},
{"port": "3478", "protocol": "UDP", "description": "TURN relay + STUN"},
{"port": "40000-40099", "protocol": "UDP", "description": "TURN relay (WebRTC media)"},
],
},
{
@@ -395,11 +394,10 @@ FEATURE_SERVICE_MAP = {
# Port requirements for service tiles (keyed by unit name or icon)
_PORTS_ELEMENT_CALLING = [
{"port": "7881", "protocol": "TCP", "description": "LiveKit WebRTC signalling"},
{"port": "7882", "protocol": "UDP", "description": "LiveKit media (UDP mux)"},
{"port": "5349", "protocol": "TCP", "description": "TURN over TLS"},
{"port": "3478", "protocol": "UDP", "description": "TURN (STUN/relay)"},
{"port": "30000-40000", "protocol": "TCP/UDP", "description": "TURN relay (WebRTC)"},
{"port": "7881", "protocol": "TCP", "description": "WebRTC media (TCP fallback)"},
{"port": "7882", "protocol": "UDP", "description": "WebRTC media (UDP)"},
{"port": "3478", "protocol": "UDP", "description": "TURN relay + STUN"},
{"port": "40000-40099", "protocol": "UDP", "description": "TURN relay (WebRTC media)"},
]
# Units whose port requirements exist purely so the user can forward them in
@@ -910,11 +908,68 @@ def _get_remote_rev(branch=None):
return None
def _parse_version(text):
"""Return a (major, minor, patch) tuple from a VERSION string, or None."""
try:
match = re.search(r"(\d+)\.(\d+)\.(\d+)", str(text))
if match:
return tuple(int(g) for g in match.groups())
except Exception:
pass
return None
def _get_remote_version(branch=None):
"""Read VERSION on the tracked branch, e.g. the stable release version."""
try:
ref = branch or "stable"
url = (
"https://git.sovransystems.com/api/v1/repos/"
"Sovran_Systems/Sovran_SystemsOS/raw/VERSION?ref="
+ urllib.parse.quote(ref)
)
req = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(req, timeout=15) as resp:
return _parse_version(resp.read().decode())
except Exception:
pass
return None
def check_for_updates() -> bool | None:
"""Whether an update is available.
Primary signal: the flake lock's pinned Sovran_Systems rev differs from
the remote branch head. BUT a failed update rewrites ``flake.lock`` (the
``nix flake update`` step) without staging a generation (the
``nixos-rebuild boot`` step failed), so after a failure the lock and the
remote agree while the *running* system is still on the old version. In
that case the rev comparison alone reports a false "up to date" and hides
the failed update from the dashboard.
Backstop: compare the running Hub version against the branch VERSION.
A newer released version while running an older one means the update did
not apply (build failed, reboot skipped, generation rolled back) and must
be offered again.
"""
locked_rev, branch = _get_locked_info()
remote_rev = _get_remote_rev(branch)
if locked_rev and remote_rev:
return locked_rev != remote_rev
rev_differs = locked_rev != remote_rev
if rev_differs:
return True
# Revs match — make sure the pinned (failed) rev isn't masking an
# older *running* system.
running_ver = _parse_version(_get_sovran_version())
remote_ver = _get_remote_version(branch)
if running_ver and remote_ver and remote_ver > running_ver:
return True
return False
# Couldn't compare revs — fall back to the version backstop.
running_ver = _parse_version(_get_sovran_version())
remote_ver = _get_remote_version(branch)
if running_ver and remote_ver:
return remote_ver > running_ver
return None # inconclusive — couldn't read lock or reach remote
@@ -1114,7 +1169,7 @@ def _get_firewall_allowed_ports() -> dict[str, set[int]]:
)
if proc.returncode == 0:
for line in proc.stdout.splitlines():
# e.g. ACCEPT tcp -- ... dpt:443 or dpts:7882:7894
# e.g. ACCEPT tcp -- ... dpt:443 or dpts:40000:40099
m = re.search(r'(tcp|udp).*dpts?:(\d+)(?::(\d+))?', line)
if m:
proto_match = m.group(1)
@@ -1172,7 +1227,7 @@ def _resolve_all_addresses_cached(domain: str) -> list[str]:
def _port_range_to_ints(port_str: str) -> list[int]:
"""Convert a port string like ``"443"``, ``"30000-40000"`` to a list of ints."""
"""Convert a port string like ``"443"``, ``"40000-40099"`` to a list of ints."""
port_str = port_str.strip()
if re.match(r'^\d+$', port_str):
return [int(port_str)]
@@ -3887,6 +3942,11 @@ async def api_updates_check():
# Avoid a slow remote update check when there is already an operation
# the dashboard needs to surface.
return {"available": True, "status": status.lower()}
if status == "FAILED":
# The last update did not complete (build failed). Keep offering the
# update so the user can re-run it rather than silently landing on a
# false "up to date".
return {"available": True, "status": "failed"}
available = await loop.run_in_executor(None, check_for_updates)
# None means inconclusive (check failed) — report as available so the UI doesn't block
@@ -3964,8 +4024,15 @@ async def api_updates_run():
except OSError:
pass
# Re-read status: a prior failed update leaves flake.lock advanced even
# though no generation was staged, so the rev-based check below can say
# "no updates" even though the system is still old. A failed update must
# always be re-runnable to recover.
persisted_status = await loop.run_in_executor(None, _read_update_status)
last_failed = persisted_status == "FAILED"
available = await loop.run_in_executor(None, check_for_updates)
if available is False: # only block when positively confirmed no updates
if available is False and not last_failed: # only block when positively confirmed no updates
# Clear stale status/log so they don't contaminate future modal opens.
_write_update_status("IDLE")
try:
@@ -7,6 +7,7 @@ if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal);
if ($btnReboot) $btnReboot.addEventListener("click", doReboot);
if ($btnSave) $btnSave.addEventListener("click", saveErrorReport);
if ($btnRetryUpdate) $btnRetryUpdate.addEventListener("click", retryUpdateStatus);
if ($btnRetryRun) $btnRetryRun.addEventListener("click", retryUpdateRun);
// Browser timers and requests may be suspended while an RDP session/tab is in
// the background. Reconcile immediately when the user returns instead of
+7 -16
View File
@@ -67,7 +67,7 @@ function renderPortForwardGuideHtml(ports, opts) {
var noteClass = opts.noteClass || "port-req-hint";
var ipHtml = opts.internalIp
? '<code class="port-req-internal-ip">' + escHtml(opts.internalIp) + '</code>'
: 'this computer&rsquo;s <strong>internal IP</strong> (shown as &ldquo;Internal IP&rdquo; at the top of the Hub dashboard)';
: 'this computer&rsquo;s <strong>internal IP</strong>';
var rows = (ports || []).map(function(p) {
return '<tr>' +
@@ -78,26 +78,17 @@ function renderPortForwardGuideHtml(ports, opts) {
}).join("");
var forWhat = opts.serviceName
? 'For <strong>' + escHtml(opts.serviceName) + '</strong> to be reachable from outside your home network, open'
: 'Open';
? 'To make <strong>' + escHtml(opts.serviceName) + '</strong> reachable from outside your home, forward these ports to ' + ipHtml + ':'
: 'Forward these ports to ' + ipHtml + ':';
return '<p class="' + introClass + '">' +
forWhat + ' the ports below in your router&rsquo;s <strong>port forwarding</strong> settings ' +
'and point them at ' + ipHtml + '.' +
return '<p class="' + introClass + '">' + forWhat + '</p>' +
'<p class="port-req-steps" style="margin-top:6px;margin-bottom:10px;font-size:0.92em;color:#555;">' +
'Set the internal and external port to the <strong>same number</strong>. Match <strong>TCP</strong> or <strong>UDP</strong> exactly. For ranges like <strong>40000-40099</strong>, use your router&rsquo;s range fields (start 40000, end 40099).' +
'</p>' +
'<ul class="port-req-steps">' +
'<li>Set the <strong>internal (private) port</strong> and the <strong>external (public) port</strong> to the <strong>same number</strong>.</li>' +
'<li>Match the <strong>protocol</strong> exactly — a rule set to TCP will not pass UDP traffic. Where the table says <strong>TCP + UDP</strong>, create both rules (or pick &ldquo;Both&rdquo;/&ldquo;TCP/UDP&rdquo; if your router offers it).</li>' +
'<li>For a range such as <strong>30000-40000</strong>, use your router&rsquo;s port-range fields — start 30000, end 40000 — rather than one rule per port.</li>' +
'</ul>' +
'<table class="' + tableClass + '">' +
'<thead><tr><th>Port(s)</th><th>Protocol</th><th>Used for</th></tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>' +
'<p class="' + noteClass + '">' +
'📱 <strong>How to confirm it worked:</strong> forwarding happens on your router, so it can only be verified from outside your network. ' +
'Turn Wi-Fi off on your phone and open the service over mobile data — if it loads, your ports are open.' +
'</p>';
'</table>';
}
function formatDuration(seconds) {
@@ -53,6 +53,7 @@ const $modalLog = document.getElementById("modal-log");
const $btnReboot = document.getElementById("btn-reboot");
const $btnSave = document.getElementById("btn-save-report");
const $btnRetryUpdate = document.getElementById("btn-retry-update-status");
const $btnRetryRun = document.getElementById("btn-retry-update");
const $btnCloseModal = document.getElementById("btn-close-modal");
const $rebootOverlay = document.getElementById("reboot-overlay");
+7 -1
View File
@@ -275,7 +275,13 @@ async function checkUpdates() {
var sidebarUpdateBtn = document.getElementById("sidebar-btn-update");
var sidebarUpdateHint = document.getElementById("sidebar-update-hint");
if (sidebarUpdateBtn) {
if (updateStatus === "reboot_required") {
if (updateStatus === "failed") {
// Last update errored and did not apply — surface it as a persistent
// red banner that re-opens the failed run with a "Retry Update" action.
sidebarUpdateBtn.style.borderColor = "#e01b24";
sidebarUpdateBtn.style.backgroundColor = "rgba(224, 27, 36, 0.10)";
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Update failed — click to retry";
} else if (updateStatus === "reboot_required") {
sidebarUpdateBtn.style.borderColor = "#e5a50a";
sidebarUpdateBtn.style.backgroundColor = "rgba(229, 165, 10, 0.10)";
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Restart required";
+20 -3
View File
@@ -14,7 +14,10 @@ async function openUpdateModal() {
{ cache: "no-store" },
STATUS_POLL_FETCH_TIMEOUT
);
if (current.running || current.result === "reboot_required") {
if (current.running || current.result === "reboot_required" || current.result === "failed") {
// An in-progress update, a staged update awaiting reboot, or a prior
// failed update — reattach to the persisted systemd/log state instead of
// starting over or wrongly reporting "up to date".
showExistingUpdate(current);
return;
}
@@ -41,6 +44,7 @@ async function openUpdateModal() {
if ($btnReboot) $btnReboot.style.display = "none";
if ($btnSave) $btnSave.style.display = "none";
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
if ($btnRetryRun) $btnRetryRun.style.display = "none";
if ($btnCloseModal) $btnCloseModal.disabled = false;
$modal.classList.add("open");
return;
@@ -69,6 +73,7 @@ function prepareUpdateModal() {
if ($btnReboot) $btnReboot.style.display = "none";
if ($btnSave) $btnSave.style.display = "none";
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
if ($btnRetryRun) $btnRetryRun.style.display = "none";
if ($btnCloseModal) $btnCloseModal.disabled = true;
$modal.classList.add("open");
}
@@ -154,6 +159,7 @@ function startUpdate() {
if ($btnReboot) $btnReboot.style.display = "none";
if ($btnSave) $btnSave.style.display = "none";
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
if ($btnRetryRun) $btnRetryRun.style.display = "none";
if ($btnCloseModal) $btnCloseModal.disabled = false;
_updateFinished = true;
return;
@@ -283,6 +289,16 @@ function retryUpdateStatus() {
startUpdatePoll();
}
// Re-run a failed (or never-applied) update from scratch. The backend always
// allows this after a FAILED attempt even though flake.lock may already be
// advanced (the previous build never staged a bootable generation).
function retryUpdateRun() {
if ($btnRetryRun) $btnRetryRun.style.display = "none";
if ($btnSave) $btnSave.style.display = "none";
if ($btnReboot) $btnReboot.style.display = "none";
_doOpenUpdateModal();
}
function resumeUpdateStatusAfterInterruption() {
if (!$modal || !$modal.classList.contains("open")) return;
if (_updateStatusUnavailable) {
@@ -304,9 +320,10 @@ function onUpdateDone(result) {
if ($modalStatus) $modalStatus.textContent = "✓ Update complete — restart required";
if ($btnReboot) $btnReboot.style.display = "inline-flex";
} else {
if ($modalStatus) $modalStatus.textContent = "✗ Update failed";
if ($modalStatus) $modalStatus.textContent = "✗ Update failed — your system was not changed. Run the update again or save the error report for support.";
if ($btnRetryRun) $btnRetryRun.style.display = "inline-flex";
if ($btnSave) $btnSave.style.display = "inline-flex";
if ($btnReboot) $btnReboot.style.display = "inline-flex";
if ($btnReboot) $btnReboot.style.display = "none";
}
}
@@ -75,6 +75,7 @@
<div class="modal-footer">
<button class="btn btn-save" id="btn-save-report" style="display:none">Save Error Report</button>
<button class="btn btn-save" id="btn-retry-update-status" style="display:none">Retry Status</button>
<button class="btn btn-reboot" id="btn-retry-update" style="display:none">Retry Update</button>
<button class="btn btn-reboot" id="btn-reboot" style="display:none">Restart Entire System</button>
<button class="btn btn-close-modal" id="btn-close-modal" disabled>Close</button>
</div>
+1 -1
View File
@@ -10,7 +10,7 @@
"albyhub.service": "1.24.0",
"mempool.service": "3.2.1",
"matrix-synapse.service": "1.115.0",
"livekit.service": "1.5.2",
"livekit.service": "1.13.6",
"vaultwarden.service": "1.32.0",
"haven-relay.service": "0.1.0"
}
+1 -1
View File
@@ -147,7 +147,7 @@
hunspell hunspellDicts.en_US
synadm brave-origin dua
gparted pv unzip parted screen zenity
libargon2 gnome-terminal libreoffice-fresh
libargon2 gnome-terminal libreoffice-stable
dig firefox wp-cli axel
lk-jwt-service livekit-libwebrtc livekit
matrix-synapse age onlyoffice-desktopeditors
Generated
+30 -30
View File
@@ -5,11 +5,11 @@
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1787839684,
"narHash": "sha256-xTafqsVs2/zyGsaCel41QT5Lpbs/rG56Kp+YsmLXL8I=",
"lastModified": 1788527183,
"narHash": "sha256-Jvuh4VWR8Ep+UlX1siyiMWJAn9fPNZKN5IxzVOnGAec=",
"owner": "emmanuelrosa",
"repo": "btc-clients-nix",
"rev": "ef81159876930802ef9861aa5c039cec6886a30f",
"rev": "761b147fd7038b210251b16cb2b4e3fe5e9b603b",
"type": "github"
},
"original": {
@@ -26,11 +26,11 @@
]
},
"locked": {
"lastModified": 1785627969,
"narHash": "sha256-4dtXQk/NMePegK/nWp5NSeuZKLATItOq61lpEvmXqGw=",
"lastModified": 1787559586,
"narHash": "sha256-onL0VLf9vPllmT0H/OlURIU5r5t5WIEl7t4tVNKT0Nw=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "427bf4bd9435fdf21321c8cc628c24efc14c0f7a",
"rev": "9d0d87172c374f89da73c1cfe6d81ae62feac1f1",
"type": "github"
},
"original": {
@@ -41,11 +41,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1785590095,
"narHash": "sha256-CNO2szJbdLjVN/Hi1BML9MSALz1GM2fIdwnzs404QO8=",
"lastModified": 1788179970,
"narHash": "sha256-r5LmxzIhsu5+oDybatN/HJ8roYOKjb2Apa5xI6v46VU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e568f3b19d54b08f48bfae9b12b3e124d1a28002",
"rev": "1db62ab7d2ccf1916bbf7deb61fc9d16f1c4ab49",
"type": "github"
},
"original": {
@@ -56,11 +56,11 @@
},
"nixpkgs-stable": {
"locked": {
"lastModified": 1787753485,
"narHash": "sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg=",
"lastModified": 1788690626,
"narHash": "sha256-+v4I4LawmRD/mVxO7QIAerRrCkElp3YImzWkkUnvOTg=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "062346a6d85bc4b49dfaa61c986e9c5be21217d1",
"rev": "c25784012c9982bca5b3e0de87e90bbdac8927d3",
"type": "github"
},
"original": {
@@ -72,11 +72,11 @@
},
"nixpkgs-stable_2": {
"locked": {
"lastModified": 1787753485,
"narHash": "sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg=",
"lastModified": 1788297115,
"narHash": "sha256-Z+vUNbfd2FIKkWOTkcT7RYlh3oFCnig/d2eXD1SWf2E=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "062346a6d85bc4b49dfaa61c986e9c5be21217d1",
"rev": "a3116115851d68b8952a2a4221cc25a84e56b532",
"type": "github"
},
"original": {
@@ -88,11 +88,11 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1787498568,
"narHash": "sha256-9i/VTdusq/+NM/tz+J1Re+ojkMB8MBf0QshnYfzHz30=",
"lastModified": 1788614874,
"narHash": "sha256-7QYjT2vHLuX9Z1pdxHXDKCbh1CR3D/2rywB9Tx0MPRg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "56c02bc00adcf003215cc4bd996d6efaf4cff188",
"rev": "c043004d1c6985732bcc1cbc5a9c9aecbbb4e0f0",
"type": "github"
},
"original": {
@@ -104,11 +104,11 @@
},
"nixpkgs_3": {
"locked": {
"lastModified": 1787364730,
"narHash": "sha256-NcYt9QJfpJiF1lAyN8BDPB4EeScbPU+EwQqPiBElrpU=",
"lastModified": 1787631388,
"narHash": "sha256-vMiXptXarfSdJb1Gkc+FYVOAibuBRj7qxGa8z68q1Uw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "a831408e6378bc02ebf8cc09b52c96ca86f6bab4",
"rev": "ac6b2166e7a9375683b8e98f860f273222337b16",
"type": "github"
},
"original": {
@@ -120,11 +120,11 @@
},
"nixpkgs_4": {
"locked": {
"lastModified": 1787736819,
"narHash": "sha256-cV5xEJJK3BvhU8rEd4mC9UsmDi5qscv/kzGPhBRC5WA=",
"lastModified": 1788179007,
"narHash": "sha256-hn1oU2rue2SYK8dAr8+WNZWtbsz1S2W5mnHlSEuh3bo=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9fbb54b33e91ee4ca368e35a78e0613c720600b3",
"rev": "34ab99075ac4f7e40cf037eef32cb1c360bb85e9",
"type": "github"
},
"original": {
@@ -141,11 +141,11 @@
"systems": "systems"
},
"locked": {
"lastModified": 1787691219,
"narHash": "sha256-CjeJTOjrluiDuL0W/YBFkSBOQjBYmdKwj7hWAJ4PIh0=",
"lastModified": 1788190018,
"narHash": "sha256-59BAfH0txPAZrPBF4QJqwvUWppD+ICrcjA1LZAmPnrQ=",
"owner": "nix-community",
"repo": "nixvim",
"rev": "ebec1ae277a50b16e7d6682edd009ef585d68261",
"rev": "41844750e55f17b1385d5b09ca7ade5f11f49506",
"type": "github"
},
"original": {
@@ -169,11 +169,11 @@
"nixpkgs-stable": "nixpkgs-stable_2"
},
"locked": {
"lastModified": 1788225336,
"narHash": "sha256-ixw/ChkLWWrLOzoiSa9n1g2g5KDIVkedC6eYFCba+gA=",
"lastModified": 1788796120,
"narHash": "sha256-SLxKMbMPf0MN4tVLAURr6p5mNsieXH8dzmMZisqljmM=",
"owner": "naturallaw777",
"repo": "Sovran_Bitcoin",
"rev": "37caecaa3f3666231c8ec82a9a9a0df53fa40910",
"rev": "17cc04f5229563cb06f7e1843ccb8994af1df7f9",
"type": "github"
},
"original": {
+18 -1
View File
@@ -19,6 +19,24 @@
system = prev.stdenv.hostPlatform.system;
config.allowUnfree = true;
};
# Pin LiveKit to 1.13.6: element-calling.nix sets
# rtc.advertise_internal_ip, which gives LAN callers a host candidate
# so calls work on Wi-Fi without the router needing NAT-hairpin. That
# flag is only honoured when node_ip is set manually from LiveKit
# v1.13.6 (mediatransportutil f234b53); nixpkgs-unstable currently
# ships 1.13.5. Remove this override once nixpkgs-unstable reaches
# >= 1.13.6.
livekit = prev.livekit.overrideAttrs (old: {
version = "1.13.6";
src = prev.fetchFromGitHub {
owner = "livekit";
repo = "livekit";
rev = "v1.13.6";
hash = "sha256-sUAx6ooeEUUqot5xuZv7xiQa3DdRFVULteTwYgFUzCI=";
};
vendorHash = "sha256-nOGSmoNuQQm/sIVI1HojsiS4GkbhA68uYMQ6X7d4a5Q=";
});
};
in
{
@@ -37,7 +55,6 @@
{ nixpkgs.hostPlatform = "x86_64-linux"; nixpkgs.overlays = [ overlay-stable ]; }
./iso/common.nix
sovran-bitcoin.nixosModules.default
./modules/sovran-bitcoin-integration.nix
nixvim.nixosModules.nixvim
];
};
+73 -12
View File
@@ -150,6 +150,61 @@ let
"haven-relay.service" = if pkgs ? haven-relay then pkgs.haven-relay.version else (if pkgs ? haven then pkgs.haven.version else "0.1.0");
});
# Shared shell prelude used by both the update and rebuild wrapper scripts.
# A flake/package fetch that is interrupted (network blip, reboot
# mid-download, disk filled, hiccup on the remote) can leave a truncated
# tarball or partial git clone in Nix's download caches. Nix then reuses the
# corrupt archive on every retry and dies with "cannot read file from
# tarball: Truncated tar archive detected" — a failure that is NOT fixed by
# simply re-running, but IS fixed by clearing the fetch caches. run_step runs
# a command and, on the first failure that matches a download/cache
# signature, clears the caches and retries once. Real config errors never
# match, so they still fail loudly. Each sourcing script must define $LOG.
nix-self-heal-prelude = ''
transient_failure() {
grep -Eqi 'truncated tar|unexpected end of (file|archive)|unexpected eof|corrupt(ed)? (archive|nar|download|file)|could not (fetch|download)|download.*(failed|interrupted)|timed out|timeout|connection (reset|refused|timed out)|network is unreachable|temporary failure in name resolution|checksum mismatch|hash mismatch|nar hash|unable to download|store path.*is not valid|cannot read file from tarball|into the git cache' "$LOG"
}
clear_fetch_caches() {
echo "[SELF-HEAL] Clearing stale Nix download caches and verifying the Nix store"
# Re-fetchable caches only; /nix/store generations and the running system
# are never touched here.
rm -rf /root/.cache/nix/tarballs /root/.cache/nix/vcs-cache /root/.cache/nix/git* /root/.cache/nix/flakes 2>/dev/null || true
# Fast closure-level repair only. A full --check-contents scan hashes
# every store path and can take tens of minutes on a big node; the cache
# clear above is the actual fix for truncated/corrupt downloads.
nix-store --verify --repair >/dev/null 2>&1 || true
echo "[SELF-HEAL] Caches cleared; retrying"
echo ""
}
# run_step LABEL CMD [ARGS...] run a build step; on a transient
# fetch/cache failure, heal once and retry. Returns the command exit code
# but leaves error messaging to the caller.
run_step() {
label="$1"; shift
rc=1
for try in 1 2; do
if [ "$try" -eq 2 ]; then
echo " $label retry after cache repair "
fi
"$@"
rc=$?
if [ "$rc" -eq 0 ]; then
return 0
fi
if [ "$try" -eq 1 ] && transient_failure; then
echo ""
echo "[SELF-HEAL] $label failed on a download/cache error (see above)."
clear_fetch_caches
continue
fi
return "$rc"
done
return "$rc"
}
'';
# ── Update wrapper script ──────────────────────────────────────
update-script = pkgs.writeShellScript "sovran-hub-update.sh" ''
set -uo pipefail
@@ -171,12 +226,14 @@ let
RC=0
${nix-self-heal-prelude}
echo " Step 1/3: nix flake update "
if ! nix flake update --flake /etc/nixos --print-build-logs \
if ! run_step "nix flake update" nix flake update --flake /etc/nixos --print-build-logs \
--option connect-timeout 10 \
--option stalled-download-timeout 90 \
--option download-attempts 7 \
--option fallback true 2>&1; then
--option fallback true; then
echo "[ERROR] nix flake update failed"
RC=1
fi
@@ -186,22 +243,22 @@ let
echo " Step 2/3: nixos-rebuild boot (stage next reboot) "
# Stream output straight into $LOG (see rebuild-script) so the Hub UI
# shows live progress instead of an empty log during long builds.
nixos-rebuild boot --flake /etc/nixos --print-build-logs \
if run_step "nixos-rebuild boot" nixos-rebuild boot --flake /etc/nixos --print-build-logs \
--option connect-timeout 10 \
--option stalled-download-timeout 90 \
--option download-attempts 7 \
--option fallback true
BOOT_RC=$?
if [ "$BOOT_RC" -ne 0 ]; then
echo "[ERROR] nixos-rebuild boot failed"
RC=1
elif ! readlink -f /nix/var/nix/profiles/system > "$GENERATION"; then
--option fallback true; then
if ! readlink -f /nix/var/nix/profiles/system > "$GENERATION"; then
# The marker is informational only. The Hub derives pending-reboot
# state from the NixOS system profile itself, so failing to record
# the marker must not fail an otherwise successful update.
echo "[WARNING] update succeeded but its staged generation could not be recorded"
rm -f "$GENERATION"
fi
else
echo "[ERROR] nixos-rebuild boot failed"
RC=1
fi
echo ""
fi
@@ -245,12 +302,15 @@ let
echo " Sovran_SystemsOS Rebuild $(date)"
echo ""
echo ""
${nix-self-heal-prelude}
echo " Rebuilding system configuration "
# Stream output straight into $LOG (tee'd by the exec redirect above) so
# the Hub UI shows live progress. Capturing the output in a variable
# kept the log empty for the entire build+activation, which made long
# rebuilds can otherwise look like a hang.
nixos-rebuild switch --flake /etc/nixos --print-build-logs \
run_step "nixos-rebuild switch" nixos-rebuild switch --flake /etc/nixos --print-build-logs \
--option connect-timeout 10 \
--option stalled-download-timeout 90 \
--option download-attempts 7 \
@@ -266,11 +326,11 @@ let
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 \
if run_step "nixos-rebuild boot" nixos-rebuild boot --flake /etc/nixos --print-build-logs \
--option connect-timeout 10 \
--option stalled-download-timeout 90 \
--option download-attempts 7 \
--option fallback true 2>&1; then
--option fallback true; then
echo "REBOOT_REQUIRED" > "$STATUS"
else
echo "[ERROR] nixos-rebuild boot also failed"
@@ -278,6 +338,7 @@ let
exit 1
fi
else
echo "[ERROR] nixos-rebuild switch failed"
echo ""
echo ""
echo " Rebuild failed see errors above"
+38 -7
View File
@@ -185,6 +185,14 @@ EOF
fi
echo "Detected primary network interface: $IFACE"
# Derive the LAN subnet this box sits on so the embedded TURN relay
# is allowed to hand media to LiveKit's LAN host candidate (see the
# allow_restricted_peer_cidrs block below). Computed from the primary
# interface's own address, so it always matches the subnet the LAN
# clients (phones on Wi-Fi) actually live on.
LAN_CIDR=$(ip -4 -o addr show dev "$IFACE" | awk '{print $4}' | grep -vE '^(127\.|169\.254\.)' | head -n1 | python3 -c 'import sys, ipaddress; s = sys.stdin.read().strip(); print(str(ipaddress.ip_network(s, strict=False)) if s else "")' 2>/dev/null)
if [ -n "$LAN_CIDR" ]; then echo "Derived LAN CIDR for TURN relay: $LAN_CIDR"; else echo "Derived LAN CIDR for TURN relay: <none>"; fi
# 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
@@ -229,10 +237,9 @@ port: 7880
rtc:
use_external_ip: false
node_ip: $PUBLIC_IP
advertise_internal_ip: true
tcp_port: 7881
udp_port: 7882
port_range_start: 30000
port_range_end: 40000
interfaces:
includes:
- $IFACE
@@ -244,10 +251,9 @@ port: 7880
rtc:
use_external_ip: true
skip_external_ip_validation: true
advertise_internal_ip: true
tcp_port: 7881
udp_port: 7882
port_range_start: 30000
port_range_end: 40000
interfaces:
includes:
- $IFACE
@@ -264,16 +270,37 @@ EOF
# vhost (/livekit/jwt/sfu_webhook → 8073).
LK_KEY=$(cut -d: -f1 < ${livekitKeyFile} | tr -d '[:space:]')
# TURN/TLS is intentionally not configured (no tls_port): LiveKit
# advertises turns:<domain>:443 to clients regardless of tls_port, so
# a 5349 TURN/TLS listener would be unreachable and only adds attack
# surface. The staged cert/key stay for a future TURN/TLS-on-443
# (Caddy layer4 SNI) setup.
cat >> /run/livekit/livekit.yaml <<EOF
room:
auto_create: false
turn:
enabled: true
domain: $MATRIX
tls_port: 5349
udp_port: 3478
relay_range_start: 40000
relay_range_end: 40099
cert_file: /run/credentials/livekit.service/turn-cert
key_file: /run/credentials/livekit.service/turn-key
EOF
# By default the embedded TURN relay refuses to send media to
# private/loopback peers. That would force its final hop to the
# public/WAN IP (hairpin NAT) — exactly what breaks calls on routers
# without NAT loopback. Allow the LAN subnet so the relay can deliver
# directly to LiveKit's LAN host candidate instead.
if [ -n "$LAN_CIDR" ]; then
cat >> /run/livekit/livekit.yaml <<EOF
allow_restricted_peer_cidrs:
- $LAN_CIDR
EOF
fi
cat >> /run/livekit/livekit.yaml <<EOF
webhook:
api_key: $LK_KEY
urls:
@@ -315,10 +342,14 @@ EOF
"turn-key:/var/lib/livekit/turn.key"
];
networking.firewall.allowedTCPPorts = [ 5349 7881 ];
# 5349/TCP (TURN/TLS) is deliberately absent — see livekit-turn-setup. RTC
# media uses the single UDP mux (7882); the 30000-40000 range is gone so
# media is no longer spread across 10000 ports. The TURN relay allocation
# range (40000-40099) is kept separate from the media mux.
networking.firewall.allowedTCPPorts = [ 7881 ];
networking.firewall.allowedUDPPorts = [ 3478 7882 ];
networking.firewall.allowedUDPPortRanges = [
{ from = 30000; to = 40000; } # LiveKit internal TURN relay range
{ from = 40000; to = 40099; } # LiveKit embedded TURN relay allocation range
];
####### JWT SERVICE RUNTIME CONFIG #######