Compare commits

..
40 Commits
Author SHA1 Message Date
Arena.ai Agent c33457fff2 caddy: serve the Hub, RTL and Mempool sites to local clients only
The Hub (sovransystemsos.local), Ride The Lightning (:3051) and Mempool
(:60847) sites are meant for the home network. With ports 80/443
forwarded for public services, Caddy also receives requests from other
clients, so these sites now check the client address as well as the Host
header.

A new snippet, sovran_lan_only, closes the connection unless the client
is on this computer or the local network: private_ranges, 100.64.0.0/10
(Tailscale), 169.254.0.0/16, fe80::/10 and fc00::/7. IPv6 global
addresses (2000::/3) are not filtered: computers on the network often
connect over their own global address, which cannot be told apart from
one on the internet by the address alone. Only the three local sites
import the snippet; the domain sites for public services are unchanged.

Clients with a public IPv4 address on the local network are no longer
served on these sites. The Hub is still available on port 8937.

Checked with Caddy 2.11.4 and the Caddyfile the generator writes: public
IPv4 clients get the connection closed on all three sites, local clients
are served, and the public domain sites answer as before.

Add tests/test_caddy_lan_only.py and a note in SECURITY.md.
2026-10-01 21:43:47 -05:00
Arena.ai Agent 34cfba4282 docs, hub, installer: say Server + Desktop makes the home IP public
Server + Desktop publishes services under the operator's own domain, and
the DNS record for that domain points at the home connection, so anyone
can look up the home IP address. None of the places that offer Server +
Desktop said so.

- README: new section "Server + Desktop and your home IP address" (what
  becomes public, what does not, the alternatives, and what happens
  technically), plus a note on the role table and in the security
  overview.
- SECURITY.md: a matching section, the consequence noted next to "Public
  web services exposed only when enabled by the operator", and the
  supported versions row no longer pins 1.0.x.
- ISO installer: the Server + Desktop role card ends with the warning.
- Hub: the domain setup text (onboarding, feature setup and domain
  reconfiguration share renderDomainNeedsHtml) and the upgrade dialog
  carry the same notice.
- Add tests/test_exposure_guards.py. It fails if one of these places
  loses the notice or the README anchor stops resolving.
2026-10-01 21:43:47 -05:00
Arena.ai Agent 2d777450e1 ddns: take the public IP from Njal.la only and give it to LiveKit
The Hub no longer asks a STUN server, a public DNS resolver or a "what
is my IP" service for the home IP address. The DDNS update asks Njal.la
to use the address the request comes from ("&auto"), reads back the
address Njal.la says it recorded and saves it to
/var/lib/secrets/external-ip. Njal.la is the only third party that
learns the address; it has to, to publish it.

- Add app/sovran_systemsos_web/ddns_update.py, installed as
  /etc/sovran/ddns-update.py and run by sovran-ddns-update.service. It
  runs curl --ipv4 without redirects, accepts only a public IPv4
  address, and rewrites the file atomically and only when the address
  changes. Stored "&a=${IP}" URLs are converted when they are used and
  "&quiet" is dropped.
- Rewrite modules/core/njalla.nix around that runner and delete
  modules/core/public-ip.nix. Setting a sovran_systemsOS.publicIP.*
  option now fails with a message that says where the address comes
  from. Activation removes the old scripts in /var/lib/sovran. The
  existing external-ip file keeps working.
- server.py reads the saved address and starts
  sovran-ddns-update.service after a domain is saved, instead of looking
  the address up itself.
- Element calling uses sovran_systemsOS.elementCalling.externalIP if
  set, otherwise the saved address, and fails with a clear message when
  neither exists or the address is not public. It no longer falls back
  to STUN. livekit-external-ip.path re-runs livekit-turn-setup and
  starts LiveKit when the address changes.
- Add tests/test_ddns_update.py.
2026-10-01 21:43:47 -05:00
naturallaw777 726fec1990 updated nixpkgs and Sovran_Bitcoin update and the new Bisq 1.10.9 2026-10-01 13:36:12 -05:00
naturallaw777 70ccf1eba1 chore(release): prepare v1.1.7 2026-09-21 18:09:10 -05:00
naturallaw777 f6caa2ff32 updated nixpkgs 2026-09-21 18:07:21 -05:00
Sovran Contributor 8bc325b148 postgresql: drop per-database autovacuum ALTERs (rejected by Postgres)
Follow-up to the previous two commits: the ALTER DATABASE ... SET
autovacuum_* commands fail at boot with

  ERROR: parameter "autovacuum_vacuum_scale_factor" cannot be changed now

and take matrix-synapse-db-tune.service (and nextcloud-db-init.service)
down with them.

Root cause: ALTER DATABASE/ROLE ... SET validates through
set_config_option() with an interactive context, and guc.c rejects any
PGC_SIGHUP parameter set that way. All autovacuum_* GUCs are
SIGHUP-context, so per-database scoping is impossible for them — only
USERSET-level parameters (e.g. work_mem, statement_timeout) can be set
per-database.

Nothing is lost: the same values are already set cluster-wide in
configuration.nix, which covers both nextclouddb and matrix-synapse.
Remove the ALTERs from nextcloud-db-init and delete the now-purposeless
matrix-synapse-db-tune service.
2026-09-21 14:21:01 -05:00
Sovran Contributor e31094c194 synapse: performance tuning for 32 GB Server+Desktop hosts
Monolith Synapse spends most of its RAM on caches to avoid Postgres
round-trips, but Sovran ships stock cache settings (global_factor 0.5,
10K event cache, no autotuning) and a default 5-connection DB pool.

- caches.global_factor 4.0 + 100K event cache + autotuning capped at
  2G (target 1G), with boosts for the /sync and room-join hot paths.
- DB pool cp_min 5 / cp_max 15, txn_limit 10000 (fewer reconnects).
- gc_thresholds raised to cut GC pauses on a 32 GB box.
- cache-memory extra for cache-size statistics.
- Per-database autovacuum (ALTER DATABASE, scoped to matrix-synapse)
  matching the nextclouddb tuning.

Deliberately unchanged: presence and URL previews stay enabled
(disabling them is faster but user-visible), and no workers — monolith
is the right call under ~100 users. Workers would need Redis
replication, the redis extra, and Caddy reverse-proxy rework; revisit
if federation load ever justifies it.
2026-09-21 14:00:19 -05:00
Sovran Contributor 09d4cc9b83 nextcloud, postgresql: fix Nextcloud 35 DB warnings on 32 GB hosts
Nextcloud 35's Database checks flag three Performance issues out of the
box: buffer cache hit ratio ~96% (wants 99%+), 100k+ dead tuples, and
million-plus sequential scans on oc_mail_tags / oc_guests_users.

Root causes in Sovran: stock 128MB shared_buffers, stock 60s autovacuum
naptime, APCu file locking, and db:add-missing-indices running exactly
once at install time (never on upgrades or app installs).

Size Postgres for the README's Server + Desktop recommendation (32 GB
RAM, NVMe): 2GB shared_buffers, 12GB effective_cache_size, 512MB
maintenance_work_mem, 32MB work_mem, 4GB max_wal_size, 30s autovacuum
naptime with 4 workers. shared_buffers stays below the 25% rule because
Postgres shares the box with bitcoind, Electrs, LND, MariaDB and PHP-FPM.

Scope the aggressive autovacuum to nextclouddb via ALTER DATABASE so the
shared matrix-synapse DB keeps the milder cluster defaults.

Add a local Redis (127.0.0.1:6379, Nextcloud only) and move
memcache.distributed/locking to Redis; migrate existing installs with a
one-shot since nextcloud-init never re-runs.

Add a weekly nextcloud-db-maintenance timer (VACUUM ANALYZE +
db:add-missing-*) so upgrades and later app installs can't regress the
checks again.

Note: shared_buffers needs one 'systemctl restart postgresql', which
briefly takes down both Nextcloud and Matrix. Everything else is
reload-only or scoped to nextclouddb.
2026-09-21 13:59:52 -05:00
naturallaw777 3341659a0c updated to php85 and fixes 2026-09-19 16:24:57 -05:00
Sovran Systems 32e1119e33 Updated to proper syntax to prevent build errors. 2026-09-17 16:28:37 -05:00
Sovran Systems 0f7ef8422d Clean up flake.nix by removing comments and LiveKit override
Removed comments and overridden attributes for LiveKit version in flake.nix.
2026-09-17 16:19:37 -05:00
naturallaw777 dd6042928a updated flake lock which contains Bisq 1.10.8 and Bisq2 2.1.13 2026-09-17 15:37:28 -05:00
naturallaw777 74405b2ffc chore(release): prepare v1.1.6 2026-09-15 15:17:10 -05:00
naturallaw777 258da6a337 updated nix packages includes the new mempool version 2026-09-15 14:15:20 -05:00
Sovran Systems 73ab3f40c1 Rename 'The Sovran Hub' to 'The Hub' in README 2026-09-09 11:19:50 -05:00
Sovran Systems 0768712bf7 Rename 'Sovran Hub' to 'The Hub' in README
Updated references from 'Sovran Hub' to 'The Hub' for consistency.
2026-09-09 11:19:03 -05:00
naturallaw777 2ac30dc10a docs: update Sovran Hub screenshot to the v1.1.5 redesign
Replace the pre-redesign Hub capture with the new welcome dashboard
introduced in v1.1.5 — the default view showing system status, Bitcoin
sync, and the update card at a glance.

The capture is rendered from the real Hub frontend (Server + Desktop
role, demo credentials/domains) at 1920x1080, and doubles as the hero
shot of the marketing kit.  Also drops the asset from 391 KB to 59 KB
with no visible loss.
2026-09-09 11:04:57 -05:00
naturallaw777 499676569e chore(release): prepare v1.1.5 2026-09-09 08:41:01 -05:00
naturallaw777 35dbd198b8 updated nixpkgs 2026-09-09 08:39:07 -05:00
naturallaw777 e3f8a2579a hub: don't claim the OS "keeps itself current" on the dashboard
The welcome dashboard's updates card showed "Sovran_SystemsOS keeps
itself current" whenever no updates were pending.  Nothing in the OS
auto-updates: the Hub only *checks* for updates (on load and every
30 minutes while the Hub is open); applying an update is a manual
"Update System" action followed by a reboot.  The sub-line promised
behavior that does not exist.

Tie "up to date" to the last check instead ("Last check found no
updates · Click to check again"), matching the actionable phrasing
of the card's other states ("Click to review and update", "Click to
retry the update").

Also add a "Checking for updates" state for the first paint, so the
card never claims "up to date" before the first /api/updates/check
has returned.
2026-09-09 07:29:13 -05:00
naturallaw777 9919966070 updated Sovran-Bitcoin 2026-09-08 21:57:22 -05:00
naturallaw777 859f25f0c1 hub: point RTL credentials at /rtl/ and bump dev version to 0.15.12
Sovran_Bitcoin 0.15.12 serves the RTL UI under /rtl/ (upstream Angular
<base href="/rtl/"> + PathLocationStrategy); the package redirects / to
/rtl/ only for the exact root path.

Update the Hub's RTL tile so the Tor and Local Network credentials show
the canonical /rtl/ URLs (with trailing slash, which the redirect does
not cover) instead of relying on the root-path redirect. Bump the dev
versions.json fallback for rtl.service from 0.15.10 to 0.15.12 to match
the flake (deployed systems already read pkgs.sovran-bitcoin.rtl.version).
2026-09-08 21:39:27 -05:00
naturallaw777 4c22c2363b Make dashboard cards uniform and symmetric; stop clipping update text
The four welcome cards varied in shape and typography: the grid
produced a 3+1 orphan layout at wide widths, sub text was
single-line ellipsized (the updates card ended mid-sentence with
"…"), the Network card's values used a different size than the
other cards' sub text, and only the clickable cards carried a
chevron circle.

- Grid is now a symmetric 2×2 at wide widths (equal-height rows);
  the three-card Desktop-only role renders one balanced row
- Card sub text wraps instead of being cut off — "Sovran_SystemsOS
  keeps itself current" is fully visible at every width
- One type scale across all cards: 0.9rem titles, 0.8rem sub text
  (Network values aligned to it), 46px chips, uniform min-height
- Chevron circles removed from the cards — hover/focus lift is the
  click affordance, and every card has the same shape
- The updates card now reflects the real update state (failed,
  restart required, running) instead of only "updates available";
  it can no longer claim up-to-date while a restart is pending
2026-09-08 20:08:52 -05:00
naturallaw777 bbf53d089a Fix role fit-and-finish: Desktop-only router note, narrow-viewport layout
Systems Operational on roles with no enabled domain services (a
fresh Desktop-only install, or everything turned off) rendered no
Router card but still showed a "Who uses these ports" note listing
services the machine does not have. Any role with no enabled domain
service now gets the calm "No router setup needed yet" card with
role-appropriate wording, and the who-uses note only appears when
a domain service is actually enabled.

Narrow viewports (phones, half-screen RDP) had no layout at all:
the sidebar and topbar forced ~900px of horizontal scroll on every
role. The sidebar now collapses to a 76px icon rail below 920px,
the topbar wraps its search onto a second row below 640px, and the
welcome column reflows to one card per row. Legacy pre-redesign
media rules in onboarding.css (which targeted the old DOM and set
.sidebar{width:100%}) are removed — they silently overrode the new
layout, and one shrank the Zeus QR to 200px; QR codes keep their
repo-original 240px at every width.
2026-09-08 19:38:34 -05:00
naturallaw777 4476a5e0e2 Add boot splash; separate Systems Operational and Security icons
Loading indicator:
- Branded boot splash (Hub logo inside an accent spinner ring,
  "Starting The Hub") covers the shell while the first services
  data loads, then fades out once the welcome dashboard has
  rendered; never blocks longer than 25s and reassures the user
  after 8s (message about post-reboot delays)
- Fire the network and update checks before the first services
  render so the dashboard cards are current at first paint
- Sidebar Update button now adopts the last known update state
  when built (order-independent), and the welcome dashboard
  re-renders when the update state changes

Icons:
- New monochrome g-pulse glyph (activity line) for Systems
  Operational: welcome card, dialog header, and System Status
  section — the shield no longer doubles as Security
- Tech Support / Security dialog header gets a standard chip;
  Security shows the shield chip with a plain "Security" title
  (no emoji), and the shared dialog title now resets correctly
  when reopening Tech Support after Security
2026-09-08 19:15:06 -05:00
naturallaw777 36b77e3f0a Extend welcome dashboard background to the panel edges
The drifting orb glow was clipped to the centered 1040px column,
leaving visible walls where it met the content padding. Make the
welcome section full-bleed instead: cancel the content-area padding
with negative margins and stretch it to all four panel edges
(sidebar border, topbar, viewport right edge, bottom). The heading,
cards, and Browse button keep their centered 1040px column via
.welcome-inner.
2026-09-08 18:58:48 -05:00
naturallaw777 78ae9e78ed Add welcome dashboard as default view
Replace the All Services grid as the landing view with a
Nextcloud-style welcome dashboard:

- Greeting (time-of-day), "Welcome to Your Sovereign Digital &
  Financial Life" headline, version + role meta line
- Status cards: Systems Operational, Network (LAN/WAN/hostname),
  Bitcoin sync progress with ETA, and available-updates card
- Calm drifting orb background animation (transform-only,
  disabled under prefers-reduced-motion)
- Services grid stays mounted but hidden until the user browses
  (Browse button, search, or a nav category); Dashboard nav item
  returns to the welcome view
- Move role badge and autolaunch preference out of the sidebar;
  autolaunch toggle now lives in the Systems Operational modal
- Remove LAN/WAN chip from the topbar; IPs live on the Network card
2026-09-08 18:46:43 -05:00
naturallaw777 61ef286420 Monochrome updater glyph; fix oversized dialog header icon
The colored updater icon broke the monochrome system-action set in the
sidebar. Replace it with a new g-update glyph — a down arrow dropping
into an open tray ("get / install updates") — drawn in the same 24×24,
2px round-cap stroke style as the other sidebar glyphs.

- Sidebar Update System row: g-update glyph, currentColor like the rest.
- Update dialog header: same glyph in the green chip, matching the
  rebuild dialog and Systems Operational header treatment.
- Also fixes a latent sizing bug: the previous colored <img> had no CSS
  rule (the .upd-icon rule from the earlier patch never landed), so the
  dialog header icon rendered at the source file's intrinsic 128px. The
  chip markup uses the existing 54px .upd-chip rules.
2026-09-08 18:25:47 -05:00
naturallaw777 ca1a5c9eb8 Merge Bitcoin categories into one; rename Self-Hosted Apps to Personal Apps
- One "Bitcoin" category: the service catalog distinguishes bitcoin-base
  from bitcoin-apps, which surfaced as two sidebar menus and two tile
  sections. The Hub now normalizes both into a single "Bitcoin" category
  (nav item with combined count, one tile section). CATEGORY_ALIASES in
  constants.js maps the catalog keys; server config sends one
  ("bitcoin", "Bitcoin") entry and the node role allowlist is updated
  to match. The nix catalog is unchanged.
- "Self-Hosted Apps" is now "Personal Apps" — every service here is
  self-hosted, so the label added no distinction.
2026-09-08 18:18:36 -05:00
naturallaw777 6d32bebdc6 Neutral graphite theme; branded updater icon
Coloring: the base surfaces were green-tinted darks, which read as a
green-hued background rather than green highlights. Shift the whole
surface ramp to neutral graphite (slightly cool, and lighter overall)
and keep green strictly for highlights — brand, buttons, switches,
status dots and pills, sync bars, focus rings.

- Tokens: bg #17191d, surface #1c1f24, card #23272c, hover #292e34,
  elevated #26292e, inset #121417; text neutrals #e9edec/#a9b0b3/
  #7a8388. Accent, borders, radii, shadows unchanged.
- Remove the green ambient radial washes behind the app (and login).
- Reboot / security-reset overlay gradients neutralized.

Updater icon: restore the repo's branded Sovran updater icon
(/static/icons/update.svg, true colors) in the sidebar Update System
row and in the update dialog header, replacing the generic refresh
glyph. The rebuild dialog keeps its glyph chip (no branded icon
exists for it).
2026-09-08 18:09:15 -05:00
naturallaw777 e6078b9c89 Simplify Systems Operational, reword Domain Status, blue restart buttons
- Restart and retry actions (topbar Reboot, Restart Entire System in the
  update and rebuild dialogs and the restart confirmation, Retry Update,
  Try Again) are now blue instead of amber — amber read as an error.
  Status pills keep their semantic colors (amber = restart required).
- The service-modal domain section is no longer a checklist: it is titled
  "Domain Status" and shows a single green "Domain is active" line with
  the domain (or the backend's not-configured detail and Configure
  Domain action when there is no domain yet). No "Step 1" wording.
- Systems Operational keeps the complexity hidden: the Router card now
  shows just a verdict — green "Ports 80 and 443 are open" or red
  "Ports 80 and 443 are not open" — derived from the same live backend
  diagnostics. Port-forwarding instructions are gone (onboarding covers
  them); on the Node-only role the card still explains that ports only
  matter once BTCPay Server or Lightning Wallet Connections (LNURL) is
  enabled.
2026-09-08 17:52:04 -05:00
naturallaw777 ea9d4f21d7 Polish pass: sysops wording, QR size, NWC toolbar, logo, diagnostics placement
Eight fixes from live testing:

Systems Operational modal:
- Drop the "test from your phone on mobile data" (hairpin NAT) sentence
  from the router note — too technical for the intended audience.
- Node-only role: when BTCPay Server and Lightning Wallet Connections
  (LNURL) are both off, the router card becomes a simple "No router
  setup needed yet" note explaining that ports 80/443 only matter if
  one of those services is turned on. When one is enabled, the card
  shows the same port steps and live domain/port diagnostics as the
  Desktop + Server role (diagnostics now poll only enabled services).

Service modals:
- The Domain Diagnostic Checklist now shows only the domain-active
  step (Domain Configured). DNS and port diagnostics live in Systems
  Operational, which shows the full checklist.
- Node-only role: BTCPay Server and Lightning Wallet Connections
  modals gain a "Ports to Forward in Your Router" section with the
  standard 80/443 wording and this computer's LAN address.
- Domain setup and reconfigure dialogs no longer contain router
  port-forwarding instructions (already handled during Desktop +
  Server onboarding and shown in Systems Operational).

Lightning Wallet Connections:
- Refresh and New Wallet toolbar buttons now share one height and
  baseline (a leftover 12px top margin on Refresh was offsetting it).
- The header status chip has a proper gap between the status dot and
  its label.

Zeus Connect / QR codes:
- QR codes render at the original 240px with the white frame and
  pixelated upscaling, restoring scannability.

Brand:
- The sidebar logo loads via /static/sovran-hub-icon.svg (same as the
  login page) instead of an inline <use> symbol — the gradient-heavy
  symbol did not render reliably. The icon sprite is hidden with the
  browser-safe zero-size pattern instead of display:none.
2026-09-08 17:19:26 -05:00
naturallaw777 d164d423ad Rework Update System dialog to the approved design; match rebuild dialog
The update dialog kept the old bare title + spinner layout from the
previous theme. Rework it (and the rebuild dialog, for consistency) to
the approved The Hub dialog anatomy:

- Header: green chip icon, title, version chip, status pill
  (Checking… / Up to date / Updating… / Restart required / Update
  failed / Status unknown) and a header close button. A spinner appears
  in the header while an update is starting or running.
- System Details card: current version, release channel, and last
  checked (relative time, refreshed on every check).
- The log renders as a console with green "ok" and dim hint lines; the
  up-to-date result shows as a single green console line exactly once
  (the redundant status line is hidden in that state).
- Footer: Close plus a "Check again" primary action that re-runs the
  update check. Close and Check again are disabled while a check or
  update is in flight; all existing recovery actions (Save Error
  Report, Retry Status, Retry Update, Restart Entire System) keep their
  exact semantics.
- Opening the dialog now shows a "Checking…" state immediately while
  the existing reattach-then-check logic runs; reattaching to an
  in-progress update (page reload, RDP reconnect) is unchanged.

Sidebar Update System status tints now use the theme palette (red /
amber / blue / green), and the periodic background check refreshes the
dialog's "last checked" value.

Rebuild dialog gets the same header (icon, version chip, Applying… /
Done / Restart required / Failed pill, header close disabled while a
rebuild runs); its log stays hidden as before.

No API or state-machine changes: same endpoints, same polling, same
reattach and recovery behavior.
2026-09-08 16:40:06 -05:00
naturallaw777 2013df55bd Redesign The Hub web UI: softer dark theme, sidebar nav, status widgets
Apply the approved "The Hub" redesign to the web admin while keeping every
existing mechanic intact (polling, service-detail modals, Matrix and system
password management, NWC wallet manager, update/rebuild/backup/security/
reboot flows, feature manager, onboarding, role handling).

Layout (templates/index.html):
- Old header bar + IP bar replaced by a sidebar + topbar app shell.
  Sidebar carries the brand (The Hub / Sovran_SystemsOS version), category
  navigation with live counts, the System actions (Update System, Tech
  Support, Manual Backup, Security, node-only Upgrade), Feature Manager /
  Preferences, and the role badge.
- Topbar carries the page title, a service search box, the LAN | WAN
  network chip (external IP always visible, one line), Reboot and Sign Out.
- New widgets row: Systems Operational summary (opens the new Systems
  Operational modal) and Bitcoin Core sync progress with block/ETA.
- New Systems Operational modal: service counts, router port-forwarding
  steps (80/443 to this machine's LAN IP), the live domain diagnostics
  checklist, and which services use those ports.

New static/js/dashboard.js (namespaced IIFE, no new globals) renders the
nav, search filtering, widgets, and the Systems Operational modal; it is
driven by the existing /api/services payloads via a
window.dashboardServicesUpdated() hook called from buildTiles/updateTiles.

Visual design (static/css/*):
- New token set (softer dark surfaces, lifted contrast, Sovran green
  reserved for status and actions) with legacy variable names aliased so
  every secondary sheet re-skins automatically.
- Tiles, dialogs, buttons, inputs, toggles, tables, forms, overlays and
  the login page restyled to the GNOME/libadwaita-flavored surfaces:
  20px cards, 26px dialogs, pill buttons, libadwaita switches, mono value
  pills with Copy buttons, consistent modal anatomy.
- Inline SVG symbol set for chrome/nav icons (monochrome, currentColor);
  service icons still load from /static/icons/*.svg as before.
- Sidebar system buttons now use vector glyphs instead of emoji.

Behavioral details:
- Service detail modal header gains a status pill next to the version
  chip; credentials render with pre-wrap for multiline values.
- First-login security banner now renders as a card inside the content
  area instead of a full-width strip above the app.
- Search + category filtering hide/show sections and tiles without
  touching the polling or update logic.

Onboarding and login pages rebranded to "The Hub" with aligned palette.
2026-09-08 15:52:49 -05:00
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
52 changed files with 4838 additions and 2545 deletions

No files matched your search

+61
View File
@@ -7,6 +7,67 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
--- ---
## [1.1.7] - 2026-09-21
### Added
- Postgresql: drop per-database autovacuum ALTERs (rejected by Postgres)
- Synapse: performance tuning for 32 GB Server+Desktop hosts
- Nextcloud, postgresql: fix Nextcloud 35 DB warnings on 32 GB hosts
- Clean up flake.nix by removing comments and LiveKit override
### Changed
- Updated nixpkgs
- Updated to php85 and fixes
- Updated to proper syntax to prevent build errors.
- Updated flake lock which contains Bisq 1.10.8 and Bisq2 2.1.13
[1.1.7]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.7
## [1.1.6] - 2026-09-15
### Added
- Rename 'The Sovran Hub' to 'The Hub' in README
- Rename 'Sovran Hub' to 'The Hub' in README
### Changed
- Updated nix packages includes the new mempool version
### Documentation
- Update Sovran Hub screenshot to the v1.1.5 redesign
[1.1.6]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.6
## [1.1.5] - 2026-09-09
### Added
- Hub: don't claim the OS "keeps itself current" on the dashboard
- Hub: point RTL credentials at /rtl/ and bump dev version to 0.15.12
- Make dashboard cards uniform and symmetric; stop clipping update text
- Add boot splash; separate Systems Operational and Security icons
- Extend welcome dashboard background to the panel edges
- Add welcome dashboard as default view
- Monochrome updater glyph; fix oversized dialog header icon
- Merge Bitcoin categories into one; rename Self-Hosted Apps to Personal Apps
- Neutral graphite theme; branded updater icon
- Simplify Systems Operational, reword Domain Status, blue restart buttons
- Polish pass: sysops wording, QR size, NWC toolbar, logo, diagnostics placement
- Rework Update System dialog to the approved design; match rebuild dialog
- Redesign The Hub web UI: softer dark theme, sidebar nav, status widgets
- Nixpkgs update
- Switch LibreOffice from fresh to stable version
- Ui: simplify element-calling port modal — trim verbose steps and remove extra verification task
### Changed
- Updated nixpkgs
- Updated Sovran-Bitcoin
- Updated flake to build through the new sovran-bitcoin input
### Fixed
- Fix role fit-and-finish: Desktop-only router note, narrow-viewport layout
- Self-heal truncated/corrupt Nix downloads and keep failed updates retryable
[1.1.5]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.5
## [1.1.4] - 2026-09-02 ## [1.1.4] - 2026-09-02
### Added ### Added
+82 -20
View File
@@ -21,9 +21,9 @@ Lightning infrastructure, private cloud, and communications platform when you
are ready. are ready.
[Visit the Website](https://sovransystems.com) · [Visit the Website](https://sovransystems.com) ·
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.4.iso) · [Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.7.iso) ·
[Try it safely in a VM](#try-it-first-in-a-virtual-machine) · [Try it safely in a VM](#try-it-first-in-a-virtual-machine) ·
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.4.iso.sha256) · [Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.7.iso.sha256) ·
[Build from Source](#build-from-source) [Build from Source](#build-from-source)
<img src="assets/desktop-screenshot.webp" alt="Sovran_SystemsOS private Bitcoin desktop" width="800" /> <img src="assets/desktop-screenshot.webp" alt="Sovran_SystemsOS private Bitcoin desktop" width="800" />
@@ -45,7 +45,7 @@ are ready.
- [What is included](#what-is-included) - [What is included](#what-is-included)
- [Three modes](#three-modes) - [Three modes](#three-modes)
- [Use it your way](#use-it-your-way) - [Use it your way](#use-it-your-way)
- [The Sovran Hub](#the-sovran-hub) - [The Hub](#the-hub)
- [Install Sovran_SystemsOS](#install-sovran_systemsos) - [Install Sovran_SystemsOS](#install-sovran_systemsos)
- [For developers](#for-developers) - [For developers](#for-developers)
- [Development workflow](#development-workflow) - [Development workflow](#development-workflow)
@@ -202,7 +202,7 @@ Bitcoin and self-hosting infrastructure runs on the machine.
|---|---|---| |---|---|---|
| **Desktop** | Everyday users and computers with modest hardware | Sparrow, Bisq, and Bisq 2 for self-custody and peer-to-peer Bitcoin use | | **Desktop** | Everyday users and computers with modest hardware | Sparrow, Bisq, and Bisq 2 for self-custody and peer-to-peer Bitcoin use |
| **Node** | People ready to verify and operate their own Bitcoin infrastructure | Everything in Desktop, plus the full Bitcoin stack: Bitcoin Core, Electrs, LND, Ride The Lightning, BTCPay Server, and wallet-to-node connections | | **Node** | People ready to verify and operate their own Bitcoin infrastructure | Everything in Desktop, plus the full Bitcoin stack: Bitcoin Core, Electrs, LND, Ride The Lightning, BTCPay Server, and wallet-to-node connections |
| **Server + Desktop** | Bitcoiners who want the same sovereignty over their communications, cloud, passwords, and web services | The complete Node stack, plus the private self-hosted services | | **Server + Desktop** | Bitcoiners who want the same sovereignty over their communications, cloud, passwords, and web services | The complete Node stack, plus the private self-hosted services. **Makes your home IP address public:** [read this first](#server--desktop-and-your-home-ip-address) |
**Desktop: start with your keys.** Desktop is not a reduced or Bitcoin-free **Desktop: start with your keys.** Desktop is not a reduced or Bitcoin-free
edition. It is a complete, private everyday computer with a clean GNOME edition. It is a complete, private everyday computer with a clean GNOME
@@ -237,6 +237,66 @@ communications, identity, and services.
> provider allows port forwarding. Most home routers and providers already > provider allows port forwarding. Most home routers and providers already
> support this. If you are unsure, a quick search for your router model and > support this. If you are unsure, a quick search for your router model and
> "port forwarding" will usually turn up a step-by-step guide. > "port forwarding" will usually turn up a step-by-step guide.
>
> **This mode also makes your home IP address public.** Read
> [what that means](#server--desktop-and-your-home-ip-address) before you
> choose it.
### Server + Desktop and your home IP address
> **⚠️ Server + Desktop makes your home IP address public.**
> Public services need a domain name that points at your home internet
> connection. When you finish the guided domain setup, Sovran_SystemsOS puts
> your home's public IP address in a Dynamic DNS record at
> [Njal.la](https://njal.la) and keeps it up to date, and you forward ports 80
> and 443 on your router to this computer. From then on:
>
> - **Anyone can look up your domain and see your home IP address.** An IP
> address typically reveals your internet provider and your approximate
> location, and it ties everything you publish on that domain to your home
> connection.
> - **Domain privacy does not hide it.** Registrar privacy protects the
> registrant's identity, not the IP address in your DNS records.
> - **Your connection is open to the whole internet on those ports.** Scanners
> and bots constantly probe public IP addresses, so expect automated probing
> and login attempts against every service you publish.
> - **Your service names are discoverable.** Public HTTPS certificates are
> listed in public Certificate Transparency logs, so hostnames such as
> `vault.yourdomain.com` can be found, and then resolved to your IP address,
> even if you never share them.
Nothing is published until you finish domain setup and port forwarding, but that
setup is the point of this mode, so assume your IP address will be public.
**Desktop** publishes nothing. **Node** publishes nothing unless you turn on a
feature that needs a domain: *Put BTCPay Server Online* or *Lightning Wallet
Connections*.
**If you do not want your home IP address to be public,** choose Desktop or
Node. Advanced users can put a VPS, reverse proxy, or tunnel in front of their
services so DNS points there instead of at their home. Sovran_SystemsOS does not
set this up for you, and the Hub's domain checks currently expect DNS to point
at your home IP address.
<details>
<summary><strong>What happens technically</strong></summary>
- You create a **Dynamic** DNS record at Njal.la and paste its update command
into the Hub. The Hub only accepts `njal.la` update URLs.
- The `sovran-ddns-update` timer asks Njal.la to point your record at the
address the request came from. It does this right after you save a domain,
two minutes after boot, and then every 15 minutes.
- Njal.la reports that address back, and Sovran_SystemsOS keeps it for Element
calling and the Hub. Nothing else looks up your public IP address: no STUN
server, public DNS resolver, or "what is my IP" service is involved. See
`modules/core/njalla.nix`.
- Once a service that needs a domain is turned on, the firewall opens TCP and
UDP ports 80 and 443 for Caddy, which requests public HTTPS certificates for
the domains you configure. See `modules/core/caddy.nix`.
- Optional features can need more ports. Element calling, for example, needs
TCP 7881 and UDP 3478, 7882, and 40000–40099. The Hub lists the ports each
feature needs.
</details>
--- ---
@@ -264,11 +324,11 @@ infrastructure behind them.
--- ---
## The Sovran Hub ## The Hub
### Your private infrastructure, controlled from any screen. ### Your private infrastructure, controlled from any screen.
The Sovran Hub is the command center built into Sovran_SystemsOS. It is both a The Hub is the command center built into Sovran_SystemsOS. It is both a
local desktop application and a private web interface served directly by your local desktop application and a private web interface served directly by your
Sovran_SystemsOS machine. Nothing needs to be installed on the device opening Sovran_SystemsOS machine. Nothing needs to be installed on the device opening
the Hub: you only need a modern browser and access to the same local network. the Hub: you only need a modern browser and access to the same local network.
@@ -281,9 +341,9 @@ From one place, the Hub helps you:
- Reach your Bitcoin tools, private cloud, and communications - Reach your Bitcoin tools, private cloud, and communications
- Perform supported system operations without everyday terminal commands - Perform supported system operations without everyday terminal commands
<img src="assets/sovran-hub-screenshot.webp" alt="The Sovran Hub dashboard" width="800" /> <img src="assets/sovran-hub-screenshot.webp" alt="The Sovran Hub welcome dashboard" width="800" />
*The Sovran Hub: manage your private infrastructure from one place.* *The Hub: your whole system at a glance — Bitcoin, Lightning, and your private apps.*
### Example home setup ### Example home setup
@@ -300,7 +360,7 @@ From one place, the Hub helps you:
┌──────────────────────────┐ ┌──────────────────────────┐
│ Sovran_SystemsOS │ │ Sovran_SystemsOS │
│ │ │ │
│ • Sovran Hub │ │ • The Hub │
│ • Bitcoin node │ │ • Bitcoin node │
│ • Sparrow Wallet │ │ • Sparrow Wallet │
│ • Bisq and Bisq 2 │ │ • Bisq and Bisq 2 │
@@ -345,8 +405,8 @@ with an imaging application such as [Balena Etcher](https://etcher.balena.io).
### 1. Download the ISO and checksum ### 1. Download the ISO and checksum
- [Download Sovran_SystemsOS-1.1.4.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.4.iso) - [Download Sovran_SystemsOS-1.1.7.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.7.iso)
- [Download Sovran_SystemsOS-1.1.4.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.4.iso.sha256) - [Download Sovran_SystemsOS-1.1.7.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.7.iso.sha256)
The download may take some time. Do not rename or modify the ISO before The download may take some time. Do not rename or modify the ISO before
verifying it, and keep both files in the same folder. verifying it, and keep both files in the same folder.
@@ -364,16 +424,16 @@ checksum exactly.
Open a terminal in the download folder and run: Open a terminal in the download folder and run:
```bash ```bash
sha256sum --check Sovran_SystemsOS-1.1.4.iso.sha256 sha256sum --check Sovran_SystemsOS-1.1.7.iso.sha256
``` ```
A successful comparison reports: A successful comparison reports:
```text ```text
Sovran_SystemsOS-1.1.4.iso: OK Sovran_SystemsOS-1.1.7.iso: OK
``` ```
You can also run `sha256sum Sovran_SystemsOS-1.1.4.iso` and compare the output You can also run `sha256sum Sovran_SystemsOS-1.1.7.iso` and compare the output
against the checksum file manually. against the checksum file manually.
</details> </details>
@@ -384,11 +444,11 @@ against the checksum file manually.
Open Terminal in the download folder and run: Open Terminal in the download folder and run:
```bash ```bash
shasum -a 256 Sovran_SystemsOS-1.1.4.iso shasum -a 256 Sovran_SystemsOS-1.1.7.iso
``` ```
Compare the value shown in Terminal with the value inside Compare the value shown in Terminal with the value inside
`Sovran_SystemsOS-1.1.4.iso.sha256`. `Sovran_SystemsOS-1.1.7.iso.sha256`.
</details> </details>
@@ -398,7 +458,7 @@ Compare the value shown in Terminal with the value inside
Open PowerShell in the download folder and run: Open PowerShell in the download folder and run:
```powershell ```powershell
Get-FileHash .\Sovran_SystemsOS-1.1.4.iso -Algorithm SHA256 Get-FileHash .\Sovran_SystemsOS-1.1.7.iso -Algorithm SHA256
``` ```
Compare the value under `Hash` with the published checksum. Compare the value under `Hash` with the published checksum.
@@ -413,7 +473,7 @@ match exactly.
1. Download and install [Balena Etcher](https://etcher.balena.io), then 1. Download and install [Balena Etcher](https://etcher.balena.io), then
connect the USB drive. connect the USB drive.
2. Choose **Flash from file** and select `Sovran_SystemsOS-1.1.4.iso`. 2. Choose **Flash from file** and select `Sovran_SystemsOS-1.1.7.iso`.
3. Choose **Select target**, select the USB drive, and review your selection 3. Choose **Select target**, select the USB drive, and review your selection
carefully. carefully.
4. Choose **Flash** and wait for the writing and verification process to 4. Choose **Flash** and wait for the writing and verification process to
@@ -730,7 +790,9 @@ Sovran_SystemsOS uses layered controls:
- Separate service users, systemd sandboxing, and loopback bindings where practical - Separate service users, systemd sandboxing, and loopback bindings where practical
- Tor enforcement for supported Bitcoin services - Tor enforcement for supported Bitcoin services
- Restricted, time-limited support access with scoped `sudo` - Restricted, time-limited support access with scoped `sudo`
- Operator-controlled public service exposure - Operator-controlled public service exposure (Server + Desktop
[makes your home IP address public](#server--desktop-and-your-home-ip-address)
once you set up a domain)
See [`SECURITY.md`](SECURITY.md) for the threat model, limitations, reporting, See [`SECURITY.md`](SECURITY.md) for the threat model, limitations, reporting,
and operator guidance. No operating system can protect funds after recovery and operator guidance. No operating system can protect funds after recovery
@@ -856,7 +918,7 @@ primary location for collaboration. Please read our
## Privacy. Sovereignty. Bitcoin. ## Privacy. Sovereignty. Bitcoin.
[Visit Sovran Systems](https://sovransystems.com) · [Visit Sovran Systems](https://sovransystems.com) ·
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.4.iso) · [Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.7.iso) ·
[View the License](LICENSE) [View the License](LICENSE)
</div> </div>
+26 -2
View File
@@ -4,7 +4,7 @@
| Release | Supported | | Release | Supported |
|---|:---:| |---|:---:|
| Latest `1.0.x` stable release | Yes | | Latest stable release | Yes |
| `main` / `staging-dev` | Development only | | `main` / `staging-dev` | Development only |
| Older than `1.0.0` | No | | Older than `1.0.0` | No |
@@ -35,6 +35,29 @@ external networks and are outside a “fully offline” model.
The local Hub currently uses HTTP. Authentication does not encrypt local network The local Hub currently uses HTTP. Authentication does not encrypt local network
traffic, so use a trusted LAN and avoid public or guest Wi-Fi. traffic, so use a trusted LAN and avoid public or guest Wi-Fi.
Caddy serves the Hub (`sovransystemsos.local`), Ride The Lightning (port 3051),
and Mempool (port 60847) only to this computer and to clients on your local
network (private, link-local, and VPN addresses), even when ports 80 and 443 are
forwarded to this computer for public services. Other IPv4 clients get the
connection closed. IPv6 global addresses are not filtered.
### Public services and your home IP address
Server + Desktop publishes services under your own domain. The Dynamic DNS
record at Njal.la then points at your home's public IP address, which anyone
can look up (domain privacy does not hide it), and ports 80 and 443 are open
to the whole internet. Public HTTPS certificates also list your service
hostnames in Certificate Transparency logs. Desktop publishes nothing. Node
publishes nothing unless *Put BTCPay Server Online* or *Lightning Wallet
Connections* is on. See
[Server + Desktop and your home IP address](README.md#server--desktop-and-your-home-ip-address)
for what this means and the alternatives.
Sovran_SystemsOS does not ask a STUN server, public DNS resolver, or “what is
my IP” service for your address. The DDNS update asks Njal.la to use the
address the request came from, and the address Njal.la reports back is the one
Element calling and the Hub use.
### Bitcoin stack ### Bitcoin stack
Bitcoin and Lightning modules are maintained in the standalone Bitcoin and Lightning modules are maintained in the standalone
@@ -64,7 +87,8 @@ change both the system and local configuration.
- Separate service users and systemd sandboxing where supported - Separate service users and systemd sandboxing where supported
- Administrative service ports bound to loopback where practical - Administrative service ports bound to loopback where practical
- Tor enforced for supported Bitcoin traffic and onion services - Tor enforced for supported Bitcoin traffic and onion services
- Public web services exposed only when enabled by the operator - Public web services exposed only when enabled by the operator (this makes
your home IP address public)
Tor reduces network exposure for configured Bitcoin services. It is not a Tor reduces network exposure for configured Bitcoin services. It is not a
guarantee against every IP leak, application bug, or traffic-analysis attack. guarantee against every IP leak, application bug, or traffic-analysis attack.
+1 -1
View File
@@ -1 +1 @@
1.1.4 1.1.7
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""Sovran DDNS update runner (sovran-ddns-update.service).
For every stored Njal.la update URL this asks Njal.la to point the record at
the address the request came from ("&auto"), then reads back the address
Njal.la says it recorded. That address is saved to /var/lib/secrets/external-ip,
where livekit-turn-setup and the Hub read it.
Njal.la is the only party involved. It has to learn the address to publish
it, so nothing else -- no STUN server, no public resolver, no "what is my IP"
service -- is ever asked for it.
Kept from the previous runner:
* every URL goes through _validate_ddns_url() (https, njal.la only, /update/)
* curl is run directly: no shell, no redirects
* the update key is never printed or logged
The module is installed next to security_helpers.py (/etc/sovran/) and run as a
script by the service; it is also importable as
sovran_systemsos_web.ddns_update so the tests can exercise it.
"""
import ipaddress
import json
import os
import subprocess
import sys
import tempfile
try:
from .security_helpers import _validate_ddns_url # imported as part of the Hub package
except ImportError: # run as a script from /etc/sovran, next to security_helpers.py
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from security_helpers import _validate_ddns_url
URLS_FILE = "/var/lib/njalla/ddns_urls.json"
IP_FILE = "/var/lib/secrets/external-ip"
_CGNAT = ipaddress.ip_network("100.64.0.0/10")
def is_public_ipv4(value) -> bool:
"""True for a globally routable IPv4 literal (not private, loopback, CGNAT ...)."""
try:
ip = ipaddress.ip_address(str(value).strip())
except ValueError:
return False
if ip.version != 4 or ip in _CGNAT:
return False
# is_global alone is not enough: CPython reports multicast as global.
return ip.is_global and not (
ip.is_multicast or ip.is_reserved or ip.is_loopback
or ip.is_link_local or ip.is_unspecified or ip.is_private
)
def normalise_url(raw: str) -> str:
"""Return the URL to call for a stored (or freshly pasted) update URL.
* Older Hubs stored "...&a=${IP}": the address was looked up locally and
substituted. Njal.la can use the address the request came from, so that
placeholder becomes "&auto".
* "&quiet" is dropped: the reply is how we learn the address Njal.la recorded.
"""
return raw.replace("&a=${IP}", "&auto").replace("&quiet", "")
def parse_reply(body: str):
"""Return the public IPv4 address Njal.la says it recorded, or None.
A successful update replies with JSON of the form
{"status": 200, "message": "record updated", "value": {"A": "203.0.113.7", ...}}
"""
try:
data = json.loads(body)
except (TypeError, ValueError):
return None
if not isinstance(data, dict) or str(data.get("status")) != "200":
return None
value = data.get("value")
ip = value.get("A") if isinstance(value, dict) else None
return str(ip).strip() if is_public_ipv4(ip) else None
def read_ip_file(path: str = None):
"""The address recorded by the last successful update, or None."""
try:
with open(path or IP_FILE) as f:
return f.read().strip() or None
except OSError:
return None
def write_ip_file(ip: str, path: str = None) -> None:
"""Replace the file atomically so a path watcher never sees a partial write."""
path = path or IP_FILE
directory = os.path.dirname(path)
os.makedirs(directory, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=directory, prefix=".external-ip-")
try:
with os.fdopen(fd, "w") as f:
f.write(ip)
os.chmod(tmp, 0o644)
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
def update_all(urls, *, run=None, validate=_validate_ddns_url):
"""Call every update URL once; return the address Njal.la reported, or None.
Nothing secret is printed: URLs are only ever referred to by position.
"""
run = run or subprocess.run # resolved per call so tests can substitute it
reported = None
seen = set()
todo = []
for raw in urls:
url = normalise_url(raw)
if url not in seen: # an old "&a=${IP}" entry and its "&auto" twin are one record
seen.add(url)
todo.append(url)
for number, url in enumerate(todo, 1):
try:
validate(url)
proc = run(
["curl", "--silent", "--ipv4", "--max-time", "15", "--fail", "--no-location", url],
capture_output=True, text=True, timeout=20, check=False,
)
except Exception:
print(f"DDNS update {number}/{len(todo)}: skipped (invalid URL or curl unavailable)")
continue
if proc.returncode != 0:
print(f"DDNS update {number}/{len(todo)}: failed (curl exit {proc.returncode})")
continue
ip = parse_reply(proc.stdout)
if ip is None:
print(f"DDNS update {number}/{len(todo)}: Njal.la did not report a public IPv4 address")
continue
print(f"DDNS update {number}/{len(todo)}: ok")
if reported is None:
reported = ip
elif ip != reported:
print("DDNS: Njal.la reported different addresses for different records; using the first")
return reported
def main() -> int:
try:
with open(URLS_FILE) as f:
urls = json.load(f)
if not isinstance(urls, list):
raise ValueError("not a list")
except Exception:
return 0 # no URLs configured -- nothing to do
urls = [u for u in urls if isinstance(u, str)]
if not urls:
return 0
ip = update_all(urls)
if ip is None:
print("DDNS: no address reported by Njal.la; keeping the last known one")
return 0
previous = read_ip_file()
if ip == previous:
print(f"DDNS: public IP unchanged ({ip})")
return 0
write_ip_file(ip)
print(f"DDNS: public IP is now {ip} (was {previous or 'unknown'})")
return 0
if __name__ == "__main__":
sys.exit(main())
+101 -86
View File
@@ -21,7 +21,6 @@ import subprocess
import tempfile import tempfile
import threading import threading
import time import time
import sys
import urllib.error import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
@@ -41,6 +40,7 @@ from .config import load_config, load_versions
from . import systemctl as sysctl from . import systemctl as sysctl
from sovran_nwc import nwc_hub_manager as _nwc_mgr from sovran_nwc import nwc_hub_manager as _nwc_mgr
from . import support_ops as _support_ops from . import support_ops as _support_ops
from .ddns_update import normalise_url as _normalise_ddns_url
from .security_helpers import ( from .security_helpers import (
_nix_escape, _nix_escape,
NPUB_RE, NPUB_RE,
@@ -234,10 +234,9 @@ _support_expiry_timer_lock = Lock()
CATEGORY_ORDER = [ CATEGORY_ORDER = [
("infrastructure", "Infrastructure"), ("infrastructure", "Infrastructure"),
("bitcoin-base", "Bitcoin Base"), ("bitcoin", "Bitcoin"),
("bitcoin-apps", "Bitcoin Apps"),
("communication", "Communication"), ("communication", "Communication"),
("apps", "Self-Hosted Apps"), ("apps", "Personal Apps"),
("nostr", "Nostr"), ("nostr", "Nostr"),
("support", "Support"), ("support", "Support"),
("feature-manager", "Feature Manager"), ("feature-manager", "Feature Manager"),
@@ -452,7 +451,7 @@ ROLE_LABELS = {
ROLE_CATEGORIES: dict[str, set[str] | None] = { ROLE_CATEGORIES: dict[str, set[str] | None] = {
"server_plus_desktop": None, "server_plus_desktop": None,
"desktop": {"infrastructure", "support", "feature-manager"}, "desktop": {"infrastructure", "support", "feature-manager"},
"node": {"infrastructure", "bitcoin-base", "bitcoin-apps", "support", "feature-manager"}, "node": {"infrastructure", "bitcoin", "support", "feature-manager"},
} }
# Features shown per role (None = show all) # Features shown per role (None = show all)
@@ -908,11 +907,68 @@ def _get_remote_rev(branch=None):
return 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: 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() locked_rev, branch = _get_locked_info()
remote_rev = _get_remote_rev(branch) remote_rev = _get_remote_rev(branch)
if locked_rev and remote_rev: 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 return None # inconclusive — couldn't read lock or reach remote
@@ -952,43 +1008,20 @@ def _save_internal_ip(ip: str):
pass pass
def _save_external_ip(ip: str):
"""Write the external IP to a file so other services (e.g. LiveKit) can
reference it without running their own detection."""
if ip and ip != "unavailable":
try:
os.makedirs(os.path.dirname(EXTERNAL_IP_FILE), exist_ok=True)
with open(EXTERNAL_IP_FILE, "w") as f:
f.write(ip)
except OSError:
pass
def _get_external_ip() -> str: def _get_external_ip() -> str:
"""Public IP via the shared detector (/var/lib/sovran/public-ip.py). """Public IP as recorded by the Njal.la DDNS runner (ddns_update.py).
The detector owns discovery (STUN -> DNS -> opt-in HTTPS echo), caches the Nothing here looks the address up or contacts anyone. The DDNS update asks
result in /var/lib/secrets/external-ip, and contacts at most one third Njal.la to use the address the request came from, Njal.la reports it back,
party per refresh interval. This function only reads the cache and asks and the runner saves it to EXTERNAL_IP_FILE. Returns "unavailable" until
the detector to refresh when it is missing or stale — it performs no the first successful update (and on machines with no DDNS URL, e.g. Desktop).
per-call external queries of its own.
""" """
try:
r = subprocess.run(
[sys.executable, "/var/lib/sovran/public-ip.py", "check"],
capture_output=True, text=True, timeout=20,
)
if r.returncode == 0 and r.stdout.strip():
return r.stdout.strip().splitlines()[0]
except Exception:
pass
try: try:
with open(EXTERNAL_IP_FILE) as f: with open(EXTERNAL_IP_FILE) as f:
ip = f.read().strip() ip = f.read().strip()
if ip: ipaddress.ip_address(ip)
return ip return ip
except OSError: except (OSError, ValueError):
pass
return "unavailable" return "unavailable"
@@ -3740,9 +3773,6 @@ async def api_network():
# Keep the internal-ip file in sync for credential lookups # Keep the internal-ip file in sync for credential lookups
_save_internal_ip(internal) _save_internal_ip(internal)
_cached_external_ip = external _cached_external_ip = external
# Persist the external IP so other services (e.g. LiveKit) can reuse the
# Hub's detection instead of running their own.
_save_external_ip(external)
return {"internal_ip": internal, "external_ip": external} return {"internal_ip": internal, "external_ip": external}
@@ -3885,6 +3915,11 @@ async def api_updates_check():
# Avoid a slow remote update check when there is already an operation # Avoid a slow remote update check when there is already an operation
# the dashboard needs to surface. # the dashboard needs to surface.
return {"available": True, "status": status.lower()} 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) available = await loop.run_in_executor(None, check_for_updates)
# None means inconclusive (check failed) — report as available so the UI doesn't block # None means inconclusive (check failed) — report as available so the UI doesn't block
@@ -3962,8 +3997,15 @@ async def api_updates_run():
except OSError: except OSError:
pass 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) 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. # Clear stale status/log so they don't contaminate future modal opens.
_write_update_status("IDLE") _write_update_status("IDLE")
try: try:
@@ -4655,48 +4697,23 @@ def _save_ddns_urls(urls: list[str]) -> None:
def _run_njalla_ddns() -> None: def _run_njalla_ddns() -> None:
"""Update Njal.la DDNS records immediately (best-effort). """Ask the DDNS runner to update Njal.la right away (best-effort, non-blocking).
Resolves the current public IP once, then invokes ``curl`` directly as a The runner (modules/core/njalla.nix -> ddns_update.py) is the only code that
subprocess for each stored DDNS update URL. No shell interpolation is talks to Njal.la. It validates every stored URL, calls it with "&auto" so
performed and no user-controlled value is interpreted as shell syntax. Njal.la uses the address the request came from, and records the address
Each URL is revalidated through ``_validate_ddns_url()`` after ``${IP}`` Njal.la reports back for LiveKit and the Hub (EXTERNAL_IP_FILE).
substitution; URLs that fail validation are silently skipped.
Called when a domain/DDNS entry is saved and when a DDNS-backed feature Called when a domain/DDNS entry is saved and when a DDNS-backed feature is
is enabled, so DNS is refreshed right away instead of waiting for the enabled, so DNS is refreshed right away instead of waiting for the
15-minute timer tick (see modules/core/njalla.nix). 15-minute timer tick.
""" """
urls = _load_ddns_urls() if not _load_ddns_urls():
if not urls:
return return
# Resolve current public IP (best-effort; skip if unavailable)
public_ip = ""
try: try:
ip_result = subprocess.run(
["dig", "@resolver4.opendns.com", "myip.opendns.com", "+short", "-4"],
capture_output=True, text=True, timeout=10, check=False,
)
raw_ip = ip_result.stdout.strip().splitlines()[0] if ip_result.stdout.strip() else ""
# Validate strictly as a proper IPv4/IPv6 address before substitution
ipaddress.ip_address(raw_ip)
public_ip = raw_ip
except Exception:
public_ip = ""
if not public_ip:
return # skip to avoid sending bare ${IP} to curl
for raw_url in urls:
try:
# Replace the placeholder with the validated IP (safe string replacement)
url = raw_url.replace("${IP}", public_ip)
# Revalidate after substitution — enforces /update/ path, no $, etc.
_validate_ddns_url(url)
subprocess.run( subprocess.run(
["curl", "--silent", "--max-time", "15", "--fail", "--no-location", url], ["systemctl", "start", "--no-block", "sovran-ddns-update.service"],
timeout=20, check=False, capture_output=True, timeout=10, check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
) )
except Exception: except Exception:
pass pass
@@ -4838,18 +4855,17 @@ async def api_domains_set(req: DomainSetRequest):
# Strip surrounding quotes # Strip surrounding quotes
if len(ddns_url) >= 2 and ddns_url[0] in ('"', "'") and ddns_url[-1] == ddns_url[0]: if len(ddns_url) >= 2 and ddns_url[0] in ('"', "'") and ddns_url[-1] == ddns_url[0]:
ddns_url = ddns_url[1:-1] ddns_url = ddns_url[1:-1]
# Replace trailing &auto with the IP placeholder used by _run_njalla_ddns # Keep Njal.la's "&auto": Njal.la then uses the address the request comes
if ddns_url.endswith("&auto"): # from, so nothing on this machine has to look the address up. Old
ddns_url = ddns_url[:-5] + "&a=${IP}" # "&a=${IP}" pastes and "&quiet" are normalised exactly as the runner does.
ddns_url = _normalise_ddns_url(ddns_url)
# Validate URL strictly — reject injection attempts before persisting. # Validate URL strictly — reject injection attempts before persisting.
# The placeholder ${IP} is replaced temporarily so the validator sees a
# real address; the original URL (with the placeholder) is kept for storage.
try: try:
_validate_ddns_url(ddns_url.replace("${IP}", "127.0.0.1")) _validate_ddns_url(ddns_url)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=f"Invalid DDNS URL: {exc}") raise HTTPException(status_code=400, detail=f"Invalid DDNS URL: {exc}")
# Persist the URL in the JSON store (never in executable shell source) # Persist the URL in the JSON store (never in executable shell source)
existing_urls = _load_ddns_urls() existing_urls = list(dict.fromkeys(_normalise_ddns_url(u) for u in _load_ddns_urls()))
if ddns_url not in existing_urls: if ddns_url not in existing_urls:
existing_urls.append(ddns_url) existing_urls.append(ddns_url)
try: try:
@@ -6240,13 +6256,12 @@ async def _background_domain_reachability_checker():
consecutive_failures = 0 consecutive_failures = 0
while True: while True:
try: try:
# Keep the persisted external IP fresh (dynamic WAN IPs), so # Pick up the address the Njal.la DDNS runner last recorded (a plain
# services like LiveKit can read /var/lib/secrets/external-ip. # file read; nothing is looked up from here).
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
external = await loop.run_in_executor(None, _get_external_ip) external = await loop.run_in_executor(None, _get_external_ip)
if external != "unavailable": if external != "unavailable":
_cached_external_ip = external _cached_external_ip = external
_save_external_ip(external)
cfg = load_config() cfg = load_config()
services = cfg.get("services", []) services = cfg.get("services", [])
+117 -61
View File
@@ -1,6 +1,11 @@
/* Sovran_SystemsOS Hub — Web UI Stylesheet /* Sovran_SystemsOS Hub — Web UI Stylesheet
Dark theme — near-black with green accents matching the Sovran Hub icon The Hub redesign — softer dark theme, GNOME 50 / libadwaita surfaces,
v8 — Black-forward, green used for accents/borders/highlights only */ Sovran green reserved for status and actions.
Design tokens are defined once here. Legacy variable names from the
previous theme are aliased to the new values so every stylesheet
(support, security, domain-setup, onboarding, …) re-skins without
needing per-rule edits. */
*, *::before, *::after { *, *::before, *::after {
box-sizing: border-box; box-sizing: border-box;
@@ -9,22 +14,48 @@
} }
:root { :root {
--bg-color: #080a09; color-scheme: dark;
--surface-color: rgba(14, 16, 15, 0.7);
--card-color: rgba(20, 22, 21, 0.6); /* ── The Hub palette ─────────────────────────────────────────── */
--border-color: rgba(255, 255, 255, 0.06); --bg: #17191d;
--text-primary: #ecf3ef; --surface: #1c1f24;
--text-secondary: #8aaa9a; --card: #23272c;
--text-dim: #4a6658; --card-hover: #292e34;
--accent-color: #5EAD8A; --elevated: #26292e;
--green: #6DBF8B; --inset: #121417;
--yellow: #e5a50a; --border: rgba(255, 255, 255, 0.07);
--red: #e01b24; --border-strong: rgba(255, 255, 255, 0.14);
--grey: #5E7A6A; --text: #e9edec;
--radius-card: 18px; --text-2: #a9b0b3;
--radius-btn: 8px; --text-3: #7a8388;
--shadow-card: 0 4px 16px rgba(0, 0, 0, 0.4); --accent: #3ecf8e;
--shadow-hover: 0 8px 32px rgba(0, 0, 0, 0.5); --accent-strong: #42f39a;
--accent-deep: #1aa45d;
--accent-dim: rgba(66, 243, 154, 0.12);
--amber: #e9b64a;
--red: #f66151;
--blue: #78aeed;
--mono: 'JetBrains Mono', 'Fira Code', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
--radius-card: 20px;
--radius-dialog: 26px;
--shadow-card: 0 10px 30px rgba(0, 0, 0, 0.25);
--shadow-hover: 0 16px 36px rgba(0, 0, 0, 0.38);
--shadow-pop: 0 30px 80px rgba(0, 0, 0, 0.5);
/* ── Legacy aliases (previous theme) — keep every old sheet working */
--bg-color: var(--bg);
--surface-color: var(--surface);
--card-color: var(--card);
--border-color: var(--border);
--text-primary: var(--text);
--text-secondary: var(--text-2);
--text-dim: var(--text-3);
--accent-color: var(--accent);
--green: var(--accent);
--yellow: var(--amber);
--red: var(--red);
--grey: var(--text-3);
--radius-btn: 12px;
} }
html, body { html, body {
@@ -32,19 +63,27 @@ html, body {
} }
body { body {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-family: Inter, Cantarell, 'Segoe UI', system-ui, -apple-system, 'Helvetica Neue', Arial, sans-serif;
background: background: var(--bg);
radial-gradient(ellipse at top, rgba(94, 173, 138, 0.04) 0%, transparent 50%), color: var(--text);
var(--bg-color); font-size: 15px;
color: var(--text-primary);
line-height: 1.5; line-height: 1.5;
-webkit-font-smoothing: antialiased;
min-height: 100vh; min-height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden; overflow: hidden;
} }
/* ── Login page ──────────────────────────────────────────────────── */ button { font-family: inherit; }
::selection { background: rgba(66, 243, 154, 0.25); }
/* ── Scrollbars ─────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.10); border-radius: 99px; border: 2px solid transparent; background-clip: padding-box; }
::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.18); background-clip: padding-box; }
::-webkit-scrollbar-track { background: transparent; }
/* ── Login page ─────────────────────────────────────────────────── */
.login-wrapper { .login-wrapper {
display: flex; display: flex;
@@ -52,86 +91,103 @@ body {
justify-content: center; justify-content: center;
min-height: 100vh; min-height: 100vh;
padding: 24px; padding: 24px;
position: relative;
z-index: 1;
} }
.login-card { .login-card {
background-color: rgba(14, 16, 15, 0.75);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 20px;
padding: 48px 40px;
width: 100%; width: 100%;
max-width: 400px; max-width: 402px;
box-shadow: 0 8px 32px rgba(0,0,0,0.5); text-align: center;
background: var(--elevated);
border: 1px solid var(--border-strong);
border-radius: 28px;
padding: 46px 42px 38px;
box-shadow: var(--shadow-pop);
} }
.login-header { .login-header {
text-align: center; text-align: center;
margin-bottom: 32px; margin-bottom: 26px;
} }
.login-logo { .login-logo {
height: 64px; width: 78px;
margin-bottom: 16px; height: 78px;
margin-bottom: 18px;
filter: drop-shadow(0 8px 24px rgba(28, 196, 120, 0.3));
} }
.login-title { .login-title {
font-size: 1.25rem; font-size: 1.3rem;
font-weight: 700; font-weight: 800;
color: var(--text-primary); letter-spacing: -0.01em;
color: var(--text);
}
.login-title em { font-style: normal; color: var(--accent); }
.login-sub {
color: var(--text-2);
font-size: 0.85rem;
margin: 7px 0 0;
} }
.login-form { .login-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 14px;
} }
.form-group label { .form-group label {
display: block; display: block;
font-size: 0.82rem; font-size: 0.8rem;
font-weight: 600; font-weight: 650;
color: var(--text-secondary); color: var(--text-2);
margin-bottom: 6px; margin-bottom: 6px;
text-align: left;
} }
.form-group input { .form-group input {
width: 100%; width: 100%;
padding: 10px 14px; padding: 11px 16px;
border: 1px solid var(--border-color); border: 1px solid var(--border-strong);
border-radius: var(--radius-btn); border-radius: 14px;
background-color: var(--card-color); background: var(--inset);
color: var(--text-primary); color: var(--text);
font: inherit;
font-size: 0.92rem; font-size: 0.92rem;
outline: 0;
transition: border-color 0.15s, box-shadow 0.15s;
} }
.form-group input:focus { .form-group input:focus {
outline: none; border-color: rgba(66, 243, 154, 0.55);
border-color: var(--accent-color); box-shadow: 0 0 0 3px rgba(66, 243, 154, 0.13);
} }
.btn-login { .btn-login {
width: 100%; width: 100%;
padding: 12px; padding: 12px;
border-radius: var(--radius-btn); border-radius: 99px;
background-color: var(--accent-color); background: linear-gradient(180deg, #3fd68e, #1ea263);
color: #0A1A10; color: #04220f;
font-size: 0.95rem; font-size: 0.92rem;
font-weight: 700; font-weight: 700;
margin-top: 8px; margin-top: 8px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.28), 0 6px 18px rgba(35, 199, 124, 0.22);
} }
.btn-login:hover { .btn-login:hover:not(:disabled) {
opacity: 0.88; filter: brightness(1.08);
} }
.login-error { .login-error {
background-color: rgba(224, 27, 36, 0.12); background: rgba(246, 97, 81, 0.10);
border: 1px solid var(--red); border: 1px solid rgba(246, 97, 81, 0.35);
color: #f87171; color: #f8a39b;
padding: 10px 14px; padding: 10px 14px;
border-radius: 8px; border-radius: 12px;
font-size: 0.85rem; font-size: 0.85rem;
display: none; display: none;
} }
+124 -24
View File
@@ -5,34 +5,134 @@ button {
cursor: pointer; cursor: pointer;
border: none; border: none;
outline: none; outline: none;
transition: opacity 0.15s, box-shadow 0.15s, background-color 0.15s; transition: background 0.16s, border-color 0.16s, color 0.16s, transform 0.16s, filter 0.16s, opacity 0.15s;
} }
button:disabled { button:disabled {
opacity: 0.45; opacity: 0.55;
cursor: default; cursor: default;
pointer-events: none;
} }
.btn { .btn {
padding: 7px 16px; display: inline-flex;
border-radius: var(--radius-btn); align-items: center;
font-size: 0.88rem; justify-content: center;
font-weight: 600; gap: 8px;
border-radius: 99px;
padding: 8px 18px;
font-size: 0.84rem;
font-weight: 700;
letter-spacing: 0.01em;
cursor: pointer;
border: 1px solid transparent;
white-space: nowrap;
} }
.btn svg { width: 15px; height: 15px; }
.btn:active { transform: scale(0.97); }
.btn-primary { .btn-primary {
background-color: var(--accent-color); background: linear-gradient(180deg, #3fd68e, #1ea263);
color: #0A1A10; color: #04220f;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.28), 0 6px 18px rgba(35, 199, 124, 0.22);
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
opacity: 0.88; filter: brightness(1.08);
transform: translateY(-1px);
} }
/* Update System button: uses accent green by default */ .btn-primary:active:not(:disabled) { transform: scale(0.97); }
/* Ghost — the default secondary action (legacy: btn-close-modal / btn-save) */
.btn-ghost,
.btn-close-modal,
.btn-save {
background: transparent;
border-color: var(--border-strong);
color: var(--text-2);
}
.btn-ghost:hover:not(:disabled),
.btn-close-modal:hover:not(:disabled),
.btn-save:hover:not(:disabled) {
color: var(--text);
border-color: rgba(255, 255, 255, 0.26);
background: rgba(255, 255, 255, 0.04);
}
/* Blue — restart / retry actions (amber reads as an error) */
.btn-amber,
.btn-reboot,
.btn-restart-amber,
.btn-warning {
background: rgba(120, 174, 237, 0.10);
border-color: rgba(120, 174, 237, 0.35);
color: #a5cbf2;
}
.btn-amber:hover:not(:disabled),
.btn-reboot:hover:not(:disabled),
.btn-restart-amber:hover:not(:disabled),
.btn-warning:hover:not(:disabled) {
background: rgba(120, 174, 237, 0.18);
border-color: rgba(120, 174, 237, 0.55);
color: #c2dcf8;
}
.btn-danger {
background: rgba(246, 97, 81, 0.10);
border-color: rgba(246, 97, 81, 0.35);
color: #f8a39b;
}
.btn-danger:hover:not(:disabled) {
background: rgba(246, 97, 81, 0.18);
border-color: rgba(246, 97, 81, 0.5);
}
.btn-sm { padding: 6px 14px; font-size: 0.77rem; }
/* ── Header / topbar buttons ────────────────────────────────────── */
.btn-header-reboot {
background: rgba(120, 174, 237, 0.10);
border: 1px solid rgba(120, 174, 237, 0.35);
color: #a5cbf2;
font-size: 0.77rem;
font-weight: 700;
padding: 6px 14px;
border-radius: 99px;
}
.btn-header-reboot:hover:not(:disabled) {
background: rgba(120, 174, 237, 0.18);
border-color: rgba(120, 174, 237, 0.55);
}
.btn-logout {
background: transparent;
border: 1px solid var(--border-strong);
color: var(--text-2);
font-size: 0.77rem;
font-weight: 700;
padding: 6px 14px;
border-radius: 99px;
}
.btn-logout:hover {
color: var(--text);
border-color: rgba(255, 255, 255, 0.26);
background: rgba(255, 255, 255, 0.04);
}
/* ── Update System button (sidebar) ─────────────────────────────── */
.btn-update { .btn-update {
background-color: #4A9474; background: transparent;
color: #E0F2EA; border: 1px solid var(--border-strong);
color: var(--text-2);
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -40,24 +140,22 @@ button:disabled {
} }
.btn-update:hover:not(:disabled) { .btn-update:hover:not(:disabled) {
opacity: 0.88; color: var(--text);
border-color: rgba(255, 255, 255, 0.26);
background: rgba(255, 255, 255, 0.04);
} }
/* Update System button: brighter green when updates are available */
.btn-update.has-updates { .btn-update.has-updates {
background-color: #5EAD8A; background: rgba(66, 243, 154, 0.12);
color: #0A1A10; border-color: rgba(66, 243, 154, 0.4);
} color: var(--accent);
.btn-update.has-updates:hover:not(:disabled) {
background-color: #78C8A2;
} }
.update-badge { .update-badge {
display: none; display: none;
width: 10px; width: 10px;
height: 10px; height: 10px;
background-color: var(--yellow); background-color: var(--amber);
border-radius: 50%; border-radius: 50%;
animation: pulse-badge 1.4s ease-in-out infinite; animation: pulse-badge 1.4s ease-in-out infinite;
} }
@@ -71,9 +169,11 @@ button:disabled {
50% { opacity: 0.5; transform: scale(1.35); } 50% { opacity: 0.5; transform: scale(1.35); }
} }
/* ── Icon buttons ───────────────────────────────────────────────── */
.btn-icon { .btn-icon {
background: none; background: none;
color: var(--text-secondary); color: var(--text-2);
padding: 6px; padding: 6px;
border-radius: 50%; border-radius: 50%;
font-size: 1.1rem; font-size: 1.1rem;
@@ -81,6 +181,6 @@ button:disabled {
} }
.btn-icon:hover:not(:disabled) { .btn-icon:hover:not(:disabled) {
background-color: var(--border-color); background-color: rgba(255, 255, 255, 0.06);
color: var(--text-primary); color: var(--text);
} }
@@ -18,7 +18,7 @@ domain-field-label {
domain-field-input { domain-field-input {
width: 100%; width: 100%;
background-color: #0c0f0e; background-color: var(--inset);
color: var(--text-primary); color: var(--text-primary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 8px; border-radius: 8px;
@@ -75,7 +75,7 @@ domain-field-actions {
.domain-field-input { .domain-field-input {
width: 100%; width: 100%;
background-color: #0c0f0e; background-color: var(--inset);
color: var(--text-primary); color: var(--text-primary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 8px; border-radius: 8px;
@@ -198,21 +198,21 @@ domain-field-actions {
} }
.port-proto-badge--tcp { .port-proto-badge--tcp {
color: #6dbf8b; color: var(--accent);
background: rgba(109, 191, 139, 0.12); background: rgba(66, 243, 154, 0.12);
border: 1px solid rgba(109, 191, 139, 0.4); border: 1px solid rgba(66, 243, 154, 0.4);
} }
.port-proto-badge--udp { .port-proto-badge--udp {
color: #6aa9e0; color: var(--blue);
background: rgba(106, 169, 224, 0.12); background: rgba(106, 169, 224, 0.12);
border: 1px solid rgba(106, 169, 224, 0.4); border: 1px solid rgba(106, 169, 224, 0.4);
} }
.port-proto-badge--both { .port-proto-badge--both {
color: #e5a50a; color: var(--amber);
background: rgba(229, 165, 10, 0.12); background: rgba(233, 182, 74, 0.12);
border: 1px solid rgba(229, 165, 10, 0.45); border: 1px solid rgba(233, 182, 74, 0.45);
} }
.port-proto-note { .port-proto-note {
@@ -230,7 +230,7 @@ domain-field-actions {
margin-bottom: 14px; margin-bottom: 14px;
padding: 10px 14px; padding: 10px 14px;
background: rgba(255, 180, 0, 0.10); background: rgba(255, 180, 0, 0.10);
border: 1px solid var(--warning-color, #f59e0b); border: 1px solid var(--warning-color, var(--amber));
border-radius: 8px; border-radius: 8px;
font-size: 0.88rem; font-size: 0.88rem;
line-height: 1.6; line-height: 1.6;
+142 -89
View File
@@ -1,143 +1,196 @@
/* ── Feature Manager styles ──────────────────────────────────────── */ /* ── Sidebar: Feature Manager + Preferences ─────────────────────── */
.feature-manager-section { .category-section.autolaunch-section,
margin-bottom: 32px; .category-section.feature-manager-section {
margin-bottom: 0;
} }
.feature-subcategory { /* The old "Preferences" header + hr becomes a small sidebar label.
margin-bottom: 16px; features.js renders: .section-header + hr.section-divider + cards */
.autolaunch-section .section-header,
.feature-manager-section .section-header {
font-size: 0.66rem;
font-weight: 750;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-3);
margin: 0;
padding: 14px 10px 7px;
display: block;
} }
.autolaunch-section .section-divider,
.feature-manager-section .section-divider {
background: none;
height: 0;
margin: 0;
}
.feature-cards-wrap { display: flex; flex-direction: column; }
.feature-subcategory { display: flex; flex-direction: column; }
.feature-subcategory-header { .feature-subcategory-header {
font-size: 0.78rem; font-size: 0.66rem;
font-weight: 700; font-weight: 750;
letter-spacing: 0.14em;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--text-3);
color: var(--text-dim); padding: 12px 10px 6px;
margin-bottom: 8px;
padding-left: 4px;
}
.feature-cards-wrap {
display: flex;
flex-direction: column;
gap: 10px;
} }
/* Feature card — sidebar preference row with a switch */
.feature-card { .feature-card {
background-color: var(--card-color); display: flex;
border: 1px solid var(--border-color); gap: 10px;
border-radius: 12px; align-items: flex-start;
padding: 14px 16px; padding: 9px 10px;
border-radius: 10px;
transition: background 0.15s;
} }
.feature-card:hover { background: rgba(255, 255, 255, 0.05); }
.feature-card-top { .feature-card-top {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 12px; gap: 10px;
margin-bottom: 8px; width: 100%;
} }
.feature-card-info { .feature-card-info { min-width: 0; flex: 1; }
flex: 1;
min-width: 0;
}
.feature-card-name { .feature-card-name {
font-size: 0.9rem; font-size: 0.84rem;
font-weight: 700; font-weight: 600;
color: var(--text-primary); color: var(--text);
margin-bottom: 4px; line-height: 1.3;
} }
.feature-card-desc { .feature-card-desc {
font-size: 0.78rem; margin-top: 2px;
color: var(--text-secondary); font-size: 0.68rem;
line-height: 1.5; color: var(--text-3);
line-height: 1.45;
} }
.feature-card-status { .feature-card-status {
font-size: 0.72rem; font-size: 0.66rem;
color: var(--text-dim); font-weight: 700;
padding: 2px 8px;
border-radius: 99px;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border);
color: var(--text-2);
}
.feature-card-status.status-on {
color: var(--accent);
background: var(--accent-dim);
border-color: rgba(66, 243, 154, 0.3);
}
.feature-conflict-warning {
margin-top: 8px;
font-size: 0.68rem;
color: var(--amber);
line-height: 1.5;
}
/* Domain badges inside feature cards */
.feature-domain-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.64rem;
font-weight: 700;
padding: 2px 8px;
border-radius: 99px;
margin-top: 6px; margin-top: 6px;
} }
.feature-domain-badge.configured {
color: var(--accent);
background: var(--accent-dim);
}
.feature-domain-badge.not-configured {
color: var(--amber);
background: rgba(233, 182, 74, 0.10);
}
.feature-domain-icon { font-size: 0.8rem; }
.feature-domain-label {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 0.66rem;
font-weight: 650;
margin-top: 5px;
color: var(--text-3);
}
.feature-domain-label--ok { color: var(--accent); }
.feature-domain-label--warn { color: var(--amber); }
.feature-domain-label--error { color: var(--red); }
.feature-domain-label--checking { color: var(--blue); }
/* ── Toggle switch (libadwaita style) ───────────────────────────── */
.feature-toggle { .feature-toggle {
position: relative; position: relative;
display: inline-block; display: inline-block;
width: 44px; width: 46px;
height: 24px; height: 26px;
flex-shrink: 0; flex-shrink: 0;
margin-left: auto;
margin-top: 2px;
cursor: pointer; cursor: pointer;
} }
.feature-toggle-input { .feature-toggle-input {
opacity: 0;
width: 0;
height: 0;
position: absolute; position: absolute;
inset: 0;
opacity: 0;
margin: 0;
cursor: pointer;
} }
.feature-toggle-slider { .feature-toggle-slider {
position: absolute; position: absolute;
inset: 0; inset: 0;
background-color: var(--border-color); border-radius: 99px;
border-radius: 24px; background: rgba(255, 255, 255, 0.12);
transition: background-color 0.2s; border: 1px solid var(--border-strong);
transition: 0.2s;
pointer-events: none;
} }
.feature-toggle-slider::before { .feature-toggle-slider::after {
content: ""; content: "";
position: absolute; position: absolute;
width: 18px; top: 2px;
height: 18px; left: 2px;
left: 3px; width: 20px;
top: 3px; height: 20px;
background-color: #fff;
border-radius: 50%; border-radius: 50%;
transition: transform 0.2s; background: #cfd8d2;
transition: 0.2s cubic-bezier(0.3, 0.8, 0.4, 1.2);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
} }
.feature-toggle.active .feature-toggle-slider { .feature-toggle-input:checked + .feature-toggle-slider {
background-color: var(--green); background: var(--accent);
border-color: transparent;
} }
.feature-toggle.active .feature-toggle-slider::before { .feature-toggle-input:checked + .feature-toggle-slider::after {
transform: translateX(20px); left: 23px;
background: #fff;
} }
.feature-domain-badge { .feature-toggle-input:focus-visible + .feature-toggle-slider {
display: flex; box-shadow: 0 0 0 3px rgba(66, 243, 154, 0.3);
align-items: center;
gap: 6px;
margin-top: 6px;
font-size: 0.78rem;
}
.feature-domain-icon {
flex-shrink: 0;
}
.feature-domain-label {
color: var(--text-secondary);
}
.feature-domain-label--checking {
color: var(--text-dim);
font-style: italic;
}
.feature-domain-label--ok {
color: var(--green);
font-weight: 600;
}
.feature-domain-label--warn {
color: var(--yellow);
font-weight: 600;
}
.feature-domain-label--error {
color: var(--red);
font-weight: 600;
} }
+76 -115
View File
@@ -1,69 +1,93 @@
/* ── Header bar ─────────────────────────────────────────────────── */ /* ── Topbar (replaces the old header-bar + ip-bar) ──────────────── */
.header-bar { .topbar {
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
background-color: rgba(10, 12, 11, 0.82);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
padding: 8px 24px;
display: flex; display: flex;
flex-direction: row;
align-items: center; align-items: center;
justify-content: space-between; gap: 14px;
gap: 16px; padding: 13px 26px;
position: sticky; background: var(--surface);
top: 0; border-bottom: 1px solid var(--border);
z-index: 100;
}
.header-logo {
height: 80px;
width: auto;
display: block;
flex-shrink: 0; flex-shrink: 0;
position: relative;
z-index: 5;
} }
.header-bar .title { .page-title {
font-size: 1.15rem; font-size: 1.02rem;
font-weight: 700; font-weight: 800;
color: var(--text-primary); letter-spacing: -0.01em;
white-space: nowrap;
} }
.title-group { /* ── Search ─────────────────────────────────────────────────────── */
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 6px 10px;
}
.header-buttons { .search-box {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 9px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 99px;
padding: 8px 16px;
flex: 1;
max-width: 400px;
min-width: 140px;
margin-left: 18px;
color: var(--text-3);
transition: border-color 0.15s, box-shadow 0.15s;
} }
.search-box:focus-within {
border-color: rgba(66, 243, 154, 0.35);
box-shadow: 0 0 0 3px rgba(66, 243, 154, 0.09);
}
.search-box svg { width: 15px; height: 15px; flex-shrink: 0; }
.search-box input {
background: none;
border: 0;
outline: 0;
color: var(--text);
width: 100%;
font: inherit;
font-size: 0.87rem;
}
.search-box input::placeholder { color: var(--text-3); }
.top-actions {
display: flex;
gap: 9px;
margin-left: auto;
order: 5;
}
/* ── Legacy badges (still used inside dialogs) ──────────────────── */
.role-badge { .role-badge {
background-color: var(--accent-color); display: inline-flex;
color: #0A1A10; align-items: center;
justify-content: center;
gap: 7px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border-strong);
color: var(--text-2);
font-size: 0.72rem; font-size: 0.72rem;
font-weight: 700; font-weight: 750;
padding: 3px 10px; padding: 6px 12px;
border-radius: 20px; border-radius: 99px;
letter-spacing: 0.03em; letter-spacing: 0.02em;
} }
/* ── OS Version Badge — identical styling to the version badge ────
── shown next to titles in the service modal windows ──────────── */
.os-version-badge { .os-version-badge {
background-color: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.05);
color: var(--text-secondary); color: var(--text-2);
font-size: 0.72rem; font-size: 0.66rem;
font-weight: 600; font-weight: 600;
padding: 2px 10px; padding: 2px 9px;
border-radius: 12px; border-radius: 99px;
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid var(--border);
letter-spacing: 0.02em; letter-spacing: 0.02em;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -71,76 +95,13 @@
flex-shrink: 0; flex-shrink: 0;
} }
/* ── IP bar ─────────────────────────────────────────────────────── */
.ip-bar {
background-color: rgba(10, 12, 11, 0.7);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
padding: 8px 24px;
display: flex;
align-items: center;
justify-content: center;
gap: 32px;
font-size: 0.82rem;
color: var(--text-secondary);
}
.ip-bar .ip-label {
color: var(--text-dim);
margin-right: 6px;
}
.ip-bar .ip-value {
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
color: var(--accent-color);
font-weight: 600;
}
.ip-separator {
color: var(--border-color);
}
.btn-logout {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.18);
color: var(--text-secondary);
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;
}
.btn-logout:hover {
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) { @media (max-width: 480px) {
.btn-header-reboot { .btn-header-reboot {
padding: 4px 8px; padding: 5px 10px;
font-size: 0.72rem;
}
.btn-logout {
padding: 5px 10px;
font-size: 0.72rem; font-size: 0.72rem;
} }
} }
+573 -80
View File
@@ -1,138 +1,533 @@
/* ── Main content ───────────────────────────────────────────────── */ /* ── App shell: sidebar + main column ───────────────────────────── */
.main-content { .app {
display: flex; display: flex;
align-items: flex-start; height: 100vh;
position: relative;
z-index: 1;
}
.main {
flex: 1; flex: 1;
overflow: hidden; display: flex;
max-width: 1400px; flex-direction: column;
width: 100%; min-width: 0;
margin-left: auto; }
margin-right: auto;
.content {
flex: 1;
overflow-y: auto;
padding: 26px 30px 70px;
min-width: 0;
} }
/* ── Sidebar ────────────────────────────────────────────────────── */ /* ── Sidebar ────────────────────────────────────────────────────── */
.sidebar { .sidebar {
width: 270px; width: 250px;
flex-shrink: 0; flex-shrink: 0;
height: 100%; background: var(--surface);
overflow-y: auto; border-right: 1px solid var(--border);
border-right: 1px solid rgba(255, 255, 255, 0.06);
background-color: rgba(12, 14, 13, 0.65);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
padding: 20px 14px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0; padding: 20px 14px 14px;
overflow-y: auto;
} }
/* ── Sidebar: Tech Support button ───────────────────────────────── */ .brand {
display: flex;
align-items: center;
gap: 12px;
padding: 2px 8px 20px;
}
.brand-logo {
width: 42px;
height: 42px;
flex-shrink: 0;
filter: drop-shadow(0 4px 14px rgba(28, 196, 120, 0.25));
}
.brand-text { display: flex; flex-direction: column; min-width: 0; }
.brand-title {
font-size: 1.02rem;
font-weight: 800;
letter-spacing: -0.01em;
line-height: 1.2;
}
.brand-title em { font-style: normal; color: var(--accent); }
.brand-sub {
font-size: 0.7rem;
color: var(--text-3);
font-family: var(--mono);
margin-top: 3px;
}
.nav-label {
font-size: 0.66rem;
font-weight: 750;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-3);
padding: 14px 10px 7px;
}
/* Category nav (dashboard.js) */
.nav-item {
display: flex;
align-items: center;
gap: 11px;
width: 100%;
padding: 8px 10px;
border-radius: 10px;
border: 0;
background: none;
color: var(--text-2);
font-size: 0.87rem;
font-weight: 600;
cursor: pointer;
text-align: left;
transition: background 0.15s, color 0.15s;
}
.nav-item svg { width: 17px; height: 17px; flex-shrink: 0; }
.nav-item:hover {
background: rgba(255, 255, 255, 0.05);
color: var(--text);
}
.nav-item.active {
background: rgba(255, 255, 255, 0.06);
color: var(--text);
box-shadow: inset 2.5px 0 0 var(--accent);
}
.nav-text {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.nav-count {
margin-left: auto;
font-size: 0.67rem;
font-weight: 750;
flex-shrink: 0;
color: var(--text-3);
background: rgba(255, 255, 255, 0.05);
padding: 2px 8px;
border-radius: 99px;
}
.nav-item.active .nav-count {
background: rgba(255, 255, 255, 0.10);
color: var(--text-2);
}
/* System items (tiles.js renders these as .sidebar-support-btn) */
.sidebar-support-btn { .sidebar-support-btn {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 11px;
width: 100%; width: 100%;
background-color: var(--card-color); padding: 8px 10px;
border: 1px solid var(--border-color); border-radius: 10px;
border-radius: 12px; border: 0;
padding: 12px 14px; background: none;
color: var(--text-primary); color: var(--text-2);
font-size: 0.87rem;
font-weight: 600;
cursor: pointer; cursor: pointer;
transition: border-style 0.15s, border-color 0.15s, background-color 0.15s;
text-align: left; text-align: left;
transition: background 0.15s, color 0.15s, border-color 0.15s;
} }
.sidebar-support-btn:hover { .sidebar-support-btn:hover {
border-color: var(--accent-color); background: rgba(255, 255, 255, 0.05);
border-style: solid; color: var(--text);
background-color: #162320;
} }
.sidebar-support-btn + .sidebar-support-btn { .sidebar-support-btn + .sidebar-support-btn {
margin-top: 8px; margin-top: 2px;
} }
.sidebar-support-icon { .sidebar-support-icon {
font-size: 1.5rem; font-size: 1rem;
width: 17px;
height: 17px;
display: grid;
place-items: center;
flex-shrink: 0; flex-shrink: 0;
} }
.sidebar-support-icon svg { width: 17px; height: 17px; }
.sidebar-support-text { .sidebar-support-text {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 1px;
min-width: 0;
} }
.sidebar-support-title { .sidebar-support-title {
font-size: 0.88rem; font-size: 0.87rem;
font-weight: 700; font-weight: 600;
color: var(--text-primary); line-height: 1.3;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.sidebar-support-hint { .sidebar-support-hint {
font-size: 0.72rem; font-size: 0.68rem;
color: var(--accent-color); font-weight: 650;
font-weight: 600; color: var(--text-3);
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.sidebar-divider { .sidebar-divider {
border: none; border: none;
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border);
margin: 16px 0; margin: 14px 0 0;
} }
/* ── Upgrade modal ──────────────────────────────────────────────── */ .sidebar-spacer { flex: 1; min-height: 18px; }
.upgrade-dialog { /* ── Welcome dashboard (default view) ───────────────────────────── */
max-width: 480px;
.welcome {
position: relative;
/* Full-bleed: cancel .content's padding so the glow reaches
every edge of the panel (sidebar border, topbar, viewport). */
margin: -26px -30px -70px;
padding: 70px 38px 70px;
overflow: hidden;
min-height: calc(100% + 96px);
} }
.upgrade-info-box { /* Soft drifting glow — calm, GPU-cheap (transforms only) */
background-color: var(--card-color); .welcome-bg {
border: 1px solid var(--border-color); position: absolute;
border-radius: 10px; inset: 0;
padding: 14px 18px; pointer-events: none;
margin-bottom: 14px;
} }
.upgrade-info-title { .orb {
font-size: 0.88rem; position: absolute;
font-weight: 700; border-radius: 50%;
color: var(--text-primary); will-change: transform;
}
.orb-a {
width: 560px; height: 560px;
left: -140px; top: -180px;
background: radial-gradient(circle, rgba(62, 207, 142, 0.10), transparent 70%);
animation: orb-drift-a 26s ease-in-out infinite alternate;
}
.orb-b {
width: 680px; height: 680px;
right: -200px; top: 20%;
background: radial-gradient(circle, rgba(120, 174, 237, 0.07), transparent 70%);
animation: orb-drift-b 34s ease-in-out infinite alternate;
}
.orb-c {
width: 460px; height: 460px;
left: 34%; bottom: -200px;
background: radial-gradient(circle, rgba(66, 243, 154, 0.06), transparent 70%);
animation: orb-drift-c 42s ease-in-out infinite alternate;
}
@keyframes orb-drift-a { to { transform: translate(70px, 50px) scale(1.14); } }
@keyframes orb-drift-b { to { transform: translate(-60px, -70px) scale(1.10); } }
@keyframes orb-drift-c { to { transform: translate(50px, -40px) scale(1.18); } }
@media (prefers-reduced-motion: reduce) {
.orb { animation: none; }
.welcome-inner { animation: none; }
}
/* ── Boot splash ────────────────────────────────────────────────── */
/* Covers the shell while the first services/config data loads; the
inline script in index.html lifts it (window.__liftAppSplash). */
#app-splash {
position: fixed;
inset: 0;
z-index: 2000;
background: var(--bg);
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.45s ease;
}
#app-splash.done { opacity: 0; pointer-events: none; }
.splash-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 24px;
}
.splash-ring {
position: relative;
width: 96px;
height: 96px;
display: grid;
place-items: center;
margin-bottom: 8px; margin-bottom: 8px;
} }
.upgrade-info-list { .splash-ring img { width: 60px; height: 60px; opacity: 0.95; }
padding-left: 20px;
font-size: 0.85rem; .splash-ring-arc {
color: var(--text-secondary); position: absolute;
line-height: 1.7; inset: 0;
margin: 0; border-radius: 50%;
border: 3px solid rgba(255, 255, 255, 0.10);
border-top-color: var(--accent);
animation: splash-spin 1s linear infinite;
} }
.upgrade-info-list a { @keyframes splash-spin { to { transform: rotate(360deg); } }
color: var(--accent-color);
.splash-title {
font-size: 1.02rem;
font-weight: 700;
color: var(--text);
} }
.upgrade-rebuild-note { .splash-sub {
font-style: italic; font-size: 0.84rem;
color: var(--text-dim); color: var(--text-3);
font-size: 0.82rem;
} }
@media (prefers-reduced-motion: reduce) {
.splash-ring-arc { animation-duration: 2.5s; }
}
.welcome-inner {
position: relative;
max-width: 1040px;
margin: 0 auto;
animation: welcome-in 0.5s ease both;
}
@keyframes welcome-in {
from { opacity: 0; transform: translateY(14px); }
}
.welcome-greeting {
font-size: 0.98rem;
font-weight: 650;
color: var(--text-2);
}
.welcome-title {
margin-top: 6px;
font-size: clamp(1.7rem, 2.8vw, 2.25rem);
font-weight: 800;
letter-spacing: -0.02em;
line-height: 1.18;
}
.welcome-title em { font-style: normal; color: var(--accent); }
.welcome-meta {
margin-top: 12px;
font-size: 0.8rem;
color: var(--text-3);
font-family: var(--mono);
}
.welcome-meta-sep { margin: 0 8px; opacity: 0.6; }
.welcome-cards {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-auto-rows: 1fr; /* both rows share the taller height → uniform cards */
gap: 16px;
margin-top: 34px;
}
/* Three cards (Desktop-only role, no Bitcoin card) → one symmetric row */
@media (min-width: 681px) {
.welcome-cards:not(:has(#w-btc)) {
grid-template-columns: repeat(3, 1fr);
}
}
/* wrappers whose children become grid items */
.welcome-dyn { display: contents; }
.w-network .net-row {
display: flex;
align-items: baseline;
gap: 10px;
margin-top: 6px;
font-family: var(--mono);
}
.w-network .net-k {
font-size: 0.6rem;
font-weight: 800;
letter-spacing: 0.08em;
color: var(--text-3);
width: 30px;
flex-shrink: 0;
}
.w-network .ip-value {
font-size: 0.8rem;
font-weight: 600;
color: var(--text);
}
.w-network .sub { margin-top: 8px; }
.welcome-browse {
margin-top: 26px;
}
.widget {
background: var(--card);
border: 1px solid var(--border);
border-radius: 20px;
padding: 17px 19px;
display: flex;
gap: 14px;
align-items: center;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03), var(--shadow-card);
transition: border-color 0.18s;
position: relative;
overflow: hidden;
min-width: 0;
min-height: 88px; /* uniform card height everywhere */
}
.widget:hover { border-color: var(--border-strong); }
.widget.clickable:hover { transform: translateY(-1px); }
.widget.clickable { cursor: pointer; }
.widget.clickable:focus-visible {
outline: 2px solid rgba(66, 243, 154, 0.45);
outline-offset: 2px;
}
.widget-chip {
width: 46px;
height: 46px;
border-radius: 14px;
flex-shrink: 0;
display: grid;
place-items: center;
color: #fff;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 6px 16px rgba(0, 0, 0, 0.3);
}
.widget-chip svg { width: 22px; height: 22px; }
.widget-chip img { width: 46px; height: 46px; display: block; object-fit: contain; }
.widget-chip.chip-green {
background: linear-gradient(160deg, #2f9f6b, #17683f);
}
.widget-chip.chip-btc {
background: linear-gradient(160deg, #f7931a, #b96a0a);
}
.widget-chip.chip-amber {
background: linear-gradient(160deg, #b98426, #8a5f16);
}
.widget-chip.chip-sovran {
background: linear-gradient(160deg, #42f39a, #1aa45d);
}
.widget-chip.chip-neutral {
background: linear-gradient(160deg, #2e333a, #23272c);
}
.w-body { min-width: 0; }
.widget h3 {
font-size: 0.9rem;
font-weight: 700;
letter-spacing: -0.005em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.widget .sub {
margin-top: 4px;
font-size: 0.8rem;
line-height: 1.45;
color: var(--text-2);
overflow-wrap: anywhere;
}
.widget .sub b { color: var(--text); font-weight: 650; }
.widget .sub .good { color: var(--accent); font-weight: 650; }
.widget .sub .warn { color: var(--amber); font-weight: 650; }
.w-bar {
height: 7px;
border-radius: 99px;
background: rgba(255, 255, 255, 0.07);
overflow: hidden;
margin: 8px 0 7px;
}
.w-bar-fill {
height: 100%;
border-radius: 99px;
background: linear-gradient(90deg, #42f39a, #1aa45d);
transition: width 0.6s cubic-bezier(0.3, 0.7, 0.3, 1);
}
/* ── Tiles area ─────────────────────────────────────────────────── */ /* ── Tiles area ─────────────────────────────────────────────────── */
#tiles-area { #tiles-area {
flex: 1;
height: 100%;
overflow-y: auto;
padding: 24px 20px 48px;
min-width: 0; min-width: 0;
} }
.dashboard-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 72px 24px;
color: var(--text-3);
font-size: 0.9rem;
}
.dashboard-loading-spinner {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2.5px solid rgba(255, 255, 255, 0.12);
border-top-color: var(--accent);
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Category sections ──────────────────────────────────────────── */ /* ── Category sections ──────────────────────────────────────────── */
.category-section { .category-section {
@@ -140,25 +535,30 @@
} }
.section-header { .section-header {
font-size: 0.82rem; display: flex;
font-weight: 700; align-items: center;
letter-spacing: 0.08em; gap: 10px;
margin-bottom: 15px;
font-size: 0.72rem;
font-weight: 750;
letter-spacing: 0.14em;
text-transform: uppercase; text-transform: uppercase;
color: var(--text-secondary); color: var(--text-2);
margin-bottom: 4px; white-space: nowrap;
padding-left: 4px;
} }
.section-divider { .section-divider {
border: none; border: none;
border-top: 1px solid var(--border-color); flex: 1;
margin-bottom: 16px; height: 1px;
background: linear-gradient(90deg, var(--border), transparent);
margin: 0;
} }
.tiles-grid { .tiles-grid {
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: repeat(auto-fill, minmax(158px, 1fr));
gap: 14px; gap: 15px;
} }
/* ── Empty state ────────────────────────────────────────────────── */ /* ── Empty state ────────────────────────────────────────────────── */
@@ -166,10 +566,103 @@
.empty-state { .empty-state {
text-align: center; text-align: center;
padding: 64px 24px; padding: 64px 24px;
color: var(--text-dim); color: var(--text-3);
} }
.empty-state p { .empty-state p {
font-size: 1rem; font-size: 1rem;
margin-bottom: 8px; margin-bottom: 8px;
} }
/* ── Upgrade modal (kept from previous layout) ──────────────────── */
.upgrade-dialog {
max-width: 480px;
}
.upgrade-info-box {
background: rgba(0, 0, 0, 0.16);
border: 1px solid var(--border-strong);
border-radius: 16px;
padding: 14px 16px;
margin: 14px 0;
}
.upgrade-info-title {
font-size: 0.88rem;
font-weight: 700;
color: var(--text);
margin-bottom: 8px;
}
.upgrade-info-list {
padding-left: 20px;
font-size: 0.85rem;
color: var(--text-2);
line-height: 1.7;
margin: 0;
}
.upgrade-info-list a {
color: var(--accent);
}
.upgrade-rebuild-note {
font-style: italic;
color: var(--text-3);
font-size: 0.82rem;
}
/* ── First-login security banner (inside .content) ──────────────── */
.security-first-login-banner {
display: flex;
align-items: center;
gap: 12px;
background: rgba(233, 182, 74, 0.08);
border: 1px solid rgba(233, 182, 74, 0.30);
border-radius: 16px;
padding: 12px 16px;
color: var(--text-2);
font-size: 0.85rem;
margin-bottom: 24px;
}
/* ── Narrow viewports (phone / half-screen RDP) ──────────────────── */
/* The sidebar becomes an icon rail so the content keeps room, and the
topbar wraps its search onto a second row instead of overflowing. */
@media (max-width: 920px) {
.sidebar {
width: 76px;
padding: 16px 8px 12px;
}
.brand { justify-content: center; padding: 2px 0 18px; gap: 0; }
.brand-text,
.nav-label,
.nav-text,
.nav-count,
.sidebar-support-text,
.sidebar-divider,
#sidebar-features { display: none; }
.nav-item { justify-content: center; gap: 0; padding: 10px 0; }
.sidebar-support-btn { justify-content: center; gap: 0; padding: 10px 0; }
.content { padding: 18px 18px 40px; }
/* keep the welcome background full-bleed against the new padding */
.welcome {
margin: -18px -18px -40px;
padding: 48px 20px 40px;
min-height: calc(100% + 58px);
}
}
@media (max-width: 640px) {
.topbar { flex-wrap: wrap; row-gap: 10px; padding: 12px 16px; }
.search-box { order: 5; flex-basis: 100%; }
.welcome { padding: 40px 16px 36px; }
.welcome-cards { grid-template-columns: 1fr; }
}
File diff suppressed because it is too large. Load diff
@@ -7,7 +7,7 @@
justify-content: flex-start; justify-content: flex-start;
min-height: 100vh; min-height: 100vh;
background: background:
radial-gradient(ellipse at top, rgba(94, 173, 138, 0.04) 0%, transparent 50%), radial-gradient(ellipse at top, rgba(62, 207, 142, 0.04) 0%, transparent 50%),
var(--bg-color); var(--bg-color);
padding: 24px 16px 48px; padding: 24px 16px 48px;
overflow-y: auto; overflow-y: auto;
@@ -70,13 +70,13 @@
.onboarding-step-dot.active { .onboarding-step-dot.active {
background-color: var(--accent-color); background-color: var(--accent-color);
border-color: var(--accent-color); border-color: var(--accent-color);
color: #0A1A10; color: #04220f;
} }
.onboarding-step-dot.completed { .onboarding-step-dot.completed {
background-color: var(--green); background-color: var(--green);
border-color: var(--green); border-color: var(--green);
color: #0A1A10; color: #04220f;
} }
.onboarding-step-connector { .onboarding-step-connector {
@@ -139,7 +139,7 @@
/* Cards */ /* Cards */
.onboarding-card { .onboarding-card {
background-color: rgba(14, 16, 15, 0.65); background-color: rgba(18, 24, 20, 0.65);
backdrop-filter: blur(14px); backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
border: 1px solid rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.06);
@@ -184,10 +184,10 @@
font-size: 0.82rem; font-size: 0.82rem;
font-weight: 700; font-weight: 700;
color: var(--accent-color); color: var(--accent-color);
background-color: rgba(94, 173, 138, 0.10); background-color: rgba(62, 207, 142, 0.10);
padding: 3px 10px; padding: 3px 10px;
border-radius: 20px; border-radius: 20px;
border: 1px solid rgba(94, 173, 138, 0.25); border: 1px solid rgba(62, 207, 142, 0.25);
} }
/* Step header */ /* Step header */
@@ -371,8 +371,8 @@
.onboarding-port-warn { .onboarding-port-warn {
padding: 10px 14px; padding: 10px 14px;
background-color: rgba(229, 165, 10, 0.1); background-color: rgba(233, 182, 74, 0.1);
border: 1px solid rgba(229, 165, 10, 0.35); border: 1px solid rgba(233, 182, 74, 0.35);
border-radius: 8px; border-radius: 8px;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--yellow); color: var(--yellow);
@@ -409,8 +409,8 @@
.onboarding-creds-notice { .onboarding-creds-notice {
padding: 12px 16px; padding: 12px 16px;
background-color: rgba(94, 173, 138, 0.08); background-color: rgba(62, 207, 142, 0.08);
border: 1px solid rgba(94, 173, 138, 0.20); border: 1px solid rgba(62, 207, 142, 0.20);
border-radius: 8px; border-radius: 8px;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-secondary); color: var(--text-secondary);
@@ -503,8 +503,8 @@
font-size: 0.72rem; font-size: 0.72rem;
padding: 2px 8px; padding: 2px 8px;
border-radius: 4px; border-radius: 4px;
background-color: rgba(94, 173, 138, 0.08); background-color: rgba(62, 207, 142, 0.08);
border: 1px solid rgba(94, 173, 138, 0.20); border: 1px solid rgba(62, 207, 142, 0.20);
color: var(--accent-color); color: var(--accent-color);
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
@@ -512,7 +512,7 @@
} }
.onboarding-cred-reveal-btn:hover { .onboarding-cred-reveal-btn:hover {
background-color: rgba(94, 173, 138, 0.15); background-color: rgba(62, 207, 142, 0.15);
} }
/* Completion checklist (Step 5) */ /* Completion checklist (Step 5) */
@@ -671,7 +671,7 @@
} }
.onboarding-password-toggle:hover { .onboarding-password-toggle:hover {
background-color: rgba(94, 173, 138, 0.10); background-color: rgba(62, 207, 142, 0.10);
border-color: var(--accent-color); border-color: var(--accent-color);
} }
@@ -683,8 +683,8 @@
.onboarding-password-warning { .onboarding-password-warning {
padding: 10px 14px; padding: 10px 14px;
background-color: rgba(229, 165, 10, 0.1); background-color: rgba(233, 182, 74, 0.1);
border: 1px solid rgba(229, 165, 10, 0.35); border: 1px solid rgba(233, 182, 74, 0.35);
border-radius: 8px; border-radius: 8px;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--yellow); color: var(--yellow);
@@ -694,8 +694,8 @@
.onboarding-password-success { .onboarding-password-success {
padding: 12px 16px; padding: 12px 16px;
background-color: rgba(94, 173, 138, 0.10); background-color: rgba(62, 207, 142, 0.10);
border: 1px solid rgba(94, 173, 138, 0.30); border: 1px solid rgba(62, 207, 142, 0.30);
border-radius: 8px; border-radius: 8px;
font-size: 0.92rem; font-size: 0.92rem;
color: var(--green); color: var(--green);
@@ -748,7 +748,7 @@
} }
.reboot-card { .reboot-card {
background-color: rgba(14, 16, 15, 0.8); background-color: rgba(18, 24, 20, 0.8);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.08);
@@ -823,57 +823,8 @@
/* ── Responsive ─────────────────────────────────────────────────── */ /* ── Responsive ─────────────────────────────────────────────────── */
@media (max-width: 768px) { /* Legacy pre-redesign narrow-screen rules removed: they targeted the old
body { DOM (.main-content, .header-bar, .ip-bar) and their .sidebar /
overflow: auto; #tiles-area / .service-tile / .creds-qr-img overrides fought the new
} responsive layout in layout.css (sidebar icon rail, wrapping topbar).
.main-content { Narrow-viewport behavior now lives in layout.css + header.css. */
flex-direction: column;
overflow: visible;
}
.sidebar {
width: 100%;
height: auto;
border-right: none;
border-bottom: 1px solid var(--border-color);
padding: 14px 12px;
}
#tiles-area {
height: auto;
overflow-y: visible;
padding: 16px 12px 40px;
}
}
@media (max-width: 600px) {
.header-bar {
padding: 10px 14px;
gap: 10px;
}
.header-bar .title {
font-size: 0.95rem;
}
.ip-bar {
gap: 16px;
flex-wrap: wrap;
padding: 8px 14px;
}
.tiles-grid {
justify-content: center;
}
.service-tile {
width: 140px;
min-height: 130px;
}
.reboot-card {
padding: 36px 28px;
margin: 0 16px;
}
.creds-dialog {
margin: 0 12px;
}
.creds-qr-img {
width: 200px;
height: 200px;
}
}
@@ -28,7 +28,7 @@
.security-warning-box { .security-warning-box {
background-color: rgba(180, 40, 40, 0.10); background-color: rgba(180, 40, 40, 0.10);
border-left: 3px solid #c94040; border-left: 3px solid var(--red);
border-radius: 6px; border-radius: 6px;
padding: 12px 14px; padding: 12px 14px;
margin-bottom: 14px; margin-bottom: 14px;
@@ -78,7 +78,7 @@
} }
.btn-danger { .btn-danger {
background-color: #c94040; background-color: var(--red);
color: #fff; color: #fff;
border: none; border: none;
border-radius: 6px; border-radius: 6px;
@@ -90,7 +90,7 @@
} }
.btn-danger:hover:not(:disabled) { .btn-danger:hover:not(:disabled) {
background-color: #a83030; background-color: var(--red);
} }
.btn-danger:disabled { .btn-danger:disabled {
@@ -105,8 +105,8 @@
} }
.security-status-info { color: var(--text-secondary); } .security-status-info { color: var(--text-secondary); }
.security-status-ok { color: #6DBF8B; } .security-status-ok { color: var(--accent); }
.security-status-error { color: #e05252; } .security-status-error { color: var(--red); }
/* ── Verify System Integrity ─────────────────────────────────────── */ /* ── Verify System Integrity ─────────────────────────────────────── */
@@ -160,7 +160,7 @@
.security-verify-link { .security-verify-link {
font-size: 0.78rem; font-size: 0.78rem;
color: var(--accent-color, #6DBF8B); color: var(--accent-color, var(--accent));
text-decoration: none; text-decoration: none;
} }
@@ -174,13 +174,13 @@
} }
.security-verify-pass { .security-verify-pass {
background-color: rgba(109, 191, 139, 0.15); background-color: rgba(66, 243, 154, 0.15);
color: #6DBF8B; color: var(--accent);
} }
.security-verify-fail { .security-verify-fail {
background-color: rgba(224, 82, 82, 0.15); background-color: rgba(224, 82, 82, 0.15);
color: #e05252; color: var(--red);
} }
.security-verify-errors { .security-verify-errors {
@@ -221,12 +221,11 @@
display: none; display: none;
position: fixed; position: fixed;
inset: 0; inset: 0;
background-color: rgba(6, 8, 7, 0.94); background: radial-gradient(1000px 640px at 50% 18%, #202429, #0f1113 72%);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
z-index: 1000; z-index: 1000;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 26px;
animation: security-reset-fade-in 0.35s ease-out; animation: security-reset-fade-in 0.35s ease-out;
} }
@@ -253,13 +252,13 @@
} }
.security-reset-password-box { .security-reset-password-box {
font-family: monospace; font-family: var(--mono);
font-size: 1.35rem; font-size: 1.35rem;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
background: rgba(109, 191, 139, 0.10); background: var(--accent-dim);
border: 1.5px solid rgba(109, 191, 139, 0.35); border: 1.5px solid rgba(66, 243, 154, 0.35);
border-radius: 8px; border-radius: 14px;
padding: 14px 24px; padding: 14px 24px;
letter-spacing: 0.04em; letter-spacing: 0.04em;
text-align: center; text-align: center;
@@ -277,10 +276,11 @@
} }
.security-reset-reboot-btn { .security-reset-reboot-btn {
background-color: #6DBF8B; background: linear-gradient(180deg, #3fd68e, #1ea263);
color: #0a0c0b; color: #04220f;
border: none; border: none;
border-radius: 7px; border-radius: 99px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.28), 0 6px 18px rgba(35, 199, 124, 0.22);
padding: 11px 22px; padding: 11px 22px;
font-size: 0.88rem; font-size: 0.88rem;
font-weight: 700; font-weight: 700;
@@ -290,7 +290,7 @@
} }
.security-reset-reboot-btn:hover:not(:disabled) { .security-reset-reboot-btn:hover:not(:disabled) {
background-color: #5aab78; background-color: var(--accent);
} }
.security-reset-reboot-btn:disabled { .security-reset-reboot-btn:disabled {
@@ -303,11 +303,12 @@
.security-first-login-banner { .security-first-login-banner {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
gap: 12px; gap: 12px;
padding: 12px 18px; padding: 12px 16px;
background-color: rgba(94, 173, 138, 0.08); margin-bottom: 24px;
border-bottom: 2px solid rgba(94, 173, 138, 0.25); background: rgba(233, 182, 74, 0.08);
border: 1px solid rgba(233, 182, 74, 0.30);
border-radius: 16px;
color: var(--text-primary); color: var(--text-primary);
} }
@@ -333,19 +334,21 @@
.security-banner-dismiss { .security-banner-dismiss {
background: none; background: none;
border: 1px solid var(--border-color); border: 1px solid var(--border-strong);
border-radius: 4px; border-radius: 99px;
cursor: pointer; cursor: pointer;
font-size: 0.9rem; font-size: 0.9rem;
color: var(--text-secondary); color: var(--text-secondary);
padding: 2px 7px; padding: 4px 10px;
flex-shrink: 0; flex-shrink: 0;
line-height: 1.4; line-height: 1.4;
transition: background-color 0.15s; transition: 0.15s;
} }
.security-banner-dismiss:hover { .security-banner-dismiss:hover {
background-color: rgba(0,0,0,0.08); color: var(--text);
border-color: rgba(255, 255, 255, 0.26);
background: rgba(255, 255, 255, 0.04);
} }
/* ── Legacy security inline warning banner ───────────────────────── */ /* ── Legacy security inline warning banner ───────────────────────── */
@@ -357,14 +360,14 @@
padding: 12px 14px; padding: 12px 14px;
margin-bottom: 12px; margin-bottom: 12px;
background-color: rgba(180, 100, 0, 0.12); background-color: rgba(180, 100, 0, 0.12);
border-left: 3px solid #c97a00; border-left: 3px solid #c98d08;
border-radius: 6px; border-radius: 6px;
color: var(--text-primary); color: var(--text-primary);
} }
.security-inline-icon { .security-inline-icon {
font-size: 1rem; font-size: 1rem;
color: #e69000; color: #e0a010;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -378,9 +381,9 @@
display: inline-block; display: inline-block;
font-size: 0.82rem; font-size: 0.82rem;
font-weight: 600; font-weight: 600;
color: #e69000; color: #e0a010;
text-decoration: none; text-decoration: none;
border: 1px solid #c97a00; border: 1px solid #c98d08;
border-radius: 4px; border-radius: 4px;
padding: 4px 10px; padding: 4px 10px;
align-self: flex-start; align-self: flex-start;
@@ -447,9 +450,9 @@
.pw-credentials-note { .pw-credentials-note {
font-size: 0.78rem; font-size: 0.78rem;
color: #c97a00; color: #c98d08;
background-color: rgba(180, 100, 0, 0.10); background-color: rgba(180, 100, 0, 0.10);
border-left: 2px solid #c97a00; border-left: 2px solid #c98d08;
border-radius: 4px; border-radius: 4px;
padding: 7px 10px; padding: 7px 10px;
margin-bottom: 12px; margin-bottom: 12px;
+11 -11
View File
@@ -104,7 +104,7 @@
} }
.support-steps code { .support-steps code {
background-color: rgba(94, 173, 138, 0.10); background-color: rgba(62, 207, 142, 0.10);
padding: 2px 6px; padding: 2px 6px;
border-radius: 4px; border-radius: 4px;
font-size: 0.82rem; font-size: 0.82rem;
@@ -116,7 +116,7 @@
padding: 12px; padding: 12px;
border-radius: var(--radius-btn); border-radius: var(--radius-btn);
background-color: var(--accent-color); background-color: var(--accent-color);
color: #0A1A10; color: #04220f;
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 700; font-weight: 700;
margin-bottom: 10px; margin-bottom: 10px;
@@ -146,7 +146,7 @@
padding: 12px; padding: 12px;
border-radius: var(--radius-btn); border-radius: var(--radius-btn);
background-color: var(--accent-color); background-color: var(--accent-color);
color: #0A1A10; color: #04220f;
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 700; font-weight: 700;
margin-top: 16px; margin-top: 16px;
@@ -168,7 +168,7 @@
} }
.support-btn-auditlog:hover:not(:disabled) { .support-btn-auditlog:hover:not(:disabled) {
background-color: #1c2a24; background-color: var(--card-hover);
} }
.support-fine-print { .support-fine-print {
@@ -219,13 +219,13 @@
} }
.support-wallet-protected { .support-wallet-protected {
background-color: rgba(109, 191, 139, 0.06); background-color: rgba(66, 243, 154, 0.06);
border-color: rgba(109, 191, 139, 0.3); border-color: rgba(66, 243, 154, 0.3);
} }
.support-wallet-unlocked { .support-wallet-unlocked {
background-color: rgba(229, 165, 10, 0.06); background-color: rgba(233, 182, 74, 0.06);
border-color: rgba(229, 165, 10, 0.3); border-color: rgba(233, 182, 74, 0.3);
} }
.support-wallet-warning { .support-wallet-warning {
@@ -290,7 +290,7 @@
padding: 8px 16px; padding: 8px 16px;
border-radius: var(--radius-btn); border-radius: var(--radius-btn);
background-color: var(--yellow); background-color: var(--yellow);
color: #0A1A10; color: #04220f;
font-size: 0.82rem; font-size: 0.82rem;
font-weight: 700; font-weight: 700;
} }
@@ -310,7 +310,7 @@
} }
.support-btn-wallet-lock:hover:not(:disabled) { .support-btn-wallet-lock:hover:not(:disabled) {
background-color: #529E7E; background-color: var(--accent);
} }
/* ── Audit log ───────────────────────────────────────────────────── */ /* ── Audit log ───────────────────────────────────────────────────── */
@@ -324,7 +324,7 @@
.support-audit-log { .support-audit-log {
max-height: 200px; max-height: 200px;
overflow-y: auto; overflow-y: auto;
background-color: #0c0f0e; background-color: var(--inset);
border-radius: 8px; border-radius: 8px;
padding: 10px 14px; padding: 10px 14px;
} }
+106 -401
View File
@@ -1,473 +1,178 @@
/* ── Service tile card (status-only) ─────────────────────────────── */ /* ── Service tiles ──────────────────────────────────────────────── */
.dashboard-loading {
min-height: 180px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
color: var(--text-secondary);
font-size: 0.9rem;
}
.dashboard-loading-spinner {
width: 16px;
height: 16px;
border: 2px solid var(--border-color);
border-top-color: var(--accent-color);
border-radius: 50%;
animation: dashboard-loading-spin 0.8s linear infinite;
}
@keyframes dashboard-loading-spin {
to { transform: rotate(360deg); }
}
.service-tile { .service-tile {
width: 160px; background: var(--card);
min-height: 130px; border: 1px solid var(--border);
background-color: var(--card-color); border-radius: 20px;
border: 1px solid var(--border-color); overflow: hidden;
backdrop-filter: blur(8px); padding: 21px 12px 17px;
-webkit-backdrop-filter: blur(8px);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center;
padding: 20px 12px 18px;
gap: 0;
transition: box-shadow 0.2s, border-color 0.2s;
position: relative;
cursor: pointer; cursor: pointer;
position: relative;
transition: transform 0.18s, border-color 0.18s, background 0.18s, box-shadow 0.18s, opacity 0.18s;
animation: tileIn 0.45s cubic-bezier(0.2, 0.8, 0.3, 1) backwards;
}
@keyframes tileIn {
from { opacity: 0; transform: translateY(10px) scale(0.96); }
} }
.service-tile:hover { .service-tile:hover {
transform: translateY(-4px);
border-color: var(--border-strong);
background: var(--card-hover);
box-shadow: var(--shadow-hover); box-shadow: var(--shadow-hover);
border-color: var(--accent-color);
} }
.service-tile.disabled { .service-tile:active { transform: translateY(-1px) scale(0.985); }
opacity: 0.45;
.service-tile:focus-visible {
outline: 2px solid rgba(66, 243, 154, 0.45);
outline-offset: 2px;
} }
.service-tile.disabled { opacity: 0.55; }
.tile-icon { .tile-icon {
width: 48px; width: 62px;
height: 48px; height: 62px;
display: block;
object-fit: contain; object-fit: contain;
margin-bottom: 10px;
} }
.tile-icon-fallback { .tile-icon-fallback {
width: 48px; width: 62px;
height: 48px; height: 62px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background-color: var(--border-color); color: var(--text-3);
border-radius: 12px; font-size: 1.2rem;
color: var(--text-dim); font-weight: 700;
font-size: 1.5rem;
margin-bottom: 10px;
} }
.tile-name { .tile-name {
font-size: 0.88rem; margin-top: 13px;
font-weight: 600; font-size: 0.86rem;
font-weight: 650;
text-align: center; text-align: center;
color: var(--text-primary); line-height: 1.25;
line-height: 1.3; min-height: 2.5em;
max-width: 140px;
word-break: break-word;
hyphens: auto;
min-height: 1.3em;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: var(--text);
max-width: 100%;
} }
/* Status pill — dot + label inside a rounded pill */
.tile-status { .tile-status {
font-size: 0.75rem; margin-top: 9px;
margin-top: 8px; display: inline-flex;
display: flex;
align-items: center; align-items: center;
gap: 5px; gap: 6px;
color: var(--text-secondary); font-size: 0.69rem;
font-weight: 700;
padding: 3px 10px;
border-radius: 99px;
letter-spacing: 0.01em;
white-space: nowrap;
background: rgba(255, 255, 255, 0.05);
color: var(--text-2);
} }
.tile-version { .status-text { line-height: 1; }
font-size: 0.7rem;
color: var(--text-dim);
margin-top: 2px;
text-align: center;
}
.status-dot { .status-dot {
width: 8px; width: 6px;
height: 8px; height: 6px;
border-radius: 50%; border-radius: 50%;
background: var(--text-3);
flex-shrink: 0; flex-shrink: 0;
background-color: var(--grey);
} }
.status-dot.active { background-color: var(--green); } .status-dot.active { background: var(--accent); }
.status-dot.inactive { background-color: var(--red); } .status-dot.needs-attention { background: var(--amber); }
.status-dot.loading { background-color: var(--yellow); animation: pulse-badge 1s infinite; } .status-dot.failed { background: var(--red); }
.status-dot.failed { background-color: var(--red); } .status-dot.inactive,
.status-dot.disabled { background-color: var(--grey); } .status-dot.disabled { background: var(--text-3); }
.status-dot.needs-attention { background-color: var(--yellow); } .status-dot.syncing,
.status-dot.syncing { background-color: #f5a623; animation: pulse-badge 1.5s infinite; } .status-dot.loading,
.status-dot.checking-reachability { background-color: var(--accent-color); animation: pulse-badge 1s infinite; } .status-dot.checking-reachability { background: var(--blue); animation: tile-dot-pulse 1.4s ease-in-out infinite; }
.status-dot.unknown { background: var(--text-3); }
/* ── Bitcoin IBD sync progress bar ──────────────────────────────── */ @keyframes tile-dot-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
/* Progressive enhancement: tint the whole pill on modern browsers */
.tile-status:has(.status-dot.active) { background: var(--accent-dim); color: var(--accent); }
.tile-status:has(.status-dot.needs-attention) { background: rgba(233, 182, 74, 0.10); color: var(--amber); }
.tile-status:has(.status-dot.failed) { background: rgba(246, 97, 81, 0.10); color: var(--red); }
.tile-status:has(.status-dot.syncing),
.tile-status:has(.status-dot.loading),
.tile-status:has(.status-dot.checking-reachability) { background: rgba(120, 174, 237, 0.10); color: var(--blue); }
.support-status-label {
font-size: 0.69rem;
font-weight: 700;
color: var(--text-2);
}
/* ── Bitcoin IBD sync tile ──────────────────────────────────────── */
.tile-sync-container { .tile-sync-container {
display: flex; margin-top: 12px;
flex-direction: column;
align-items: center;
gap: 4px;
width: 100%; width: 100%;
margin-top: 6px; padding: 0 6px;
} }
.tile-sync-label { .tile-sync-label {
font-size: 0.72rem; font-size: 0.66rem;
color: #f5a623; font-weight: 750;
font-weight: 600; letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--amber);
text-align: center; text-align: center;
white-space: nowrap;
} }
.tile-sync-bar-row { .tile-sync-bar-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 5px; gap: 8px;
width: 100%; margin-top: 7px;
} }
.tile-sync-bar-track { .tile-sync-bar-track {
flex: 1; flex: 1;
height: 6px; height: 7px;
background-color: var(--border-color); border-radius: 99px;
border-radius: 3px; background: rgba(255, 255, 255, 0.07);
overflow: hidden; overflow: hidden;
} }
.tile-sync-bar-fill { .tile-sync-bar-fill {
height: 100%; height: 100%;
background-color: #f5a623; border-radius: 99px;
border-radius: 3px; background: linear-gradient(90deg, #42f39a, #1aa45d);
transition: width 0.6s ease; transition: width 0.6s cubic-bezier(0.3, 0.7, 0.3, 1);
min-width: 2px;
} }
.tile-sync-percent { .tile-sync-percent {
font-size: 0.72rem; font-family: var(--mono);
font-size: 0.7rem;
font-weight: 700; font-weight: 700;
color: #f5a623; color: var(--amber);
white-space: nowrap; flex-shrink: 0;
min-width: 2.5em;
text-align: right;
} }
.tile-sync-eta { .tile-sync-eta {
font-size: 0.68rem; margin-top: 6px;
color: var(--text-dim); font-family: var(--mono);
font-size: 0.66rem;
color: var(--text-3);
text-align: center; text-align: center;
white-space: nowrap;
}
/* ── Service detail modal sections ───────────────────────────────── */
.svc-detail-section {
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-color);
}
.svc-detail-section:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.svc-detail-section-title {
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim);
margin-bottom: 10px;
}
.svc-detail-desc {
font-size: 0.9rem;
color: var(--text-secondary);
line-height: 1.6;
}
.svc-detail-status {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
font-weight: 600;
color: var(--text-primary);
}
/* ── Service detail: Domain ──────────────────────────────────────── */
.svc-detail-domain-value {
font-size: 0.9rem;
color: var(--text-primary);
font-weight: 600;
}
.tile-domain-label--ok {
color: var(--green);
font-weight: 600;
}
.tile-domain-label--warn {
color: var(--yellow);
font-weight: 600;
}
.tile-domain-label--error {
color: var(--red);
font-weight: 600;
}
/* ── Service detail: Port table ──────────────────────────────────── */
.svc-detail-port-table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
margin-top: 8px;
}
.svc-detail-port-table th {
text-align: left;
color: var(--text-dim);
font-weight: 600;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 6px 10px;
border-bottom: 1px solid var(--border-color);
}
.svc-detail-port-table td {
padding: 8px 10px;
border-bottom: 1px solid rgba(30, 45, 39, 0.6);
color: var(--text-primary);
}
.svc-detail-port-table tr:last-child td {
border-bottom: none;
}
.svc-detail-port-table-port {
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
font-weight: 600;
color: var(--accent-color);
}
.svc-detail-port-table-proto {
text-transform: uppercase;
color: var(--text-secondary);
}
.svc-detail-port-table-desc {
color: var(--text-secondary);
}
.svc-detail-port-table-status {
font-weight: 600;
}
.port-status-listening { color: var(--green); }
.port-status-open { color: var(--yellow); }
.port-status-closed { color: var(--red); }
.port-status-unknown { color: var(--text-dim); }
/* ── Service detail: Troubleshoot box ────────────────────────────── */
.svc-detail-troubleshoot {
margin-top: 12px;
padding: 14px 16px;
background-color: rgba(229, 165, 10, 0.08);
border: 1px solid rgba(229, 165, 10, 0.3);
border-radius: 10px;
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.6;
}
.svc-detail-troubleshoot strong {
color: var(--yellow);
}
.svc-detail-troubleshoot ol {
margin-top: 8px;
padding-left: 20px;
}
.svc-detail-troubleshoot li {
margin-bottom: 4px;
}
.svc-detail-troubleshoot code {
background-color: rgba(94, 173, 138, 0.10);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.82rem;
color: var(--accent-color);
}
.svc-detail-troubleshoot a {
color: var(--accent-color);
text-decoration: none;
}
.svc-detail-troubleshoot a:hover {
text-decoration: underline;
}
/* ── Service detail: Domain configure button ─────────────────────── */
.svc-detail-domain-btn {
margin-top: 12px;
}
/* ── Service detail: Addon feature toggle ────────────────────────── */
.svc-detail-addon-row {
display: flex;
align-items: center;
gap: 14px;
margin-top: 12px;
}
.svc-detail-addon-status {
font-size: 0.88rem;
font-weight: 700;
}
.addon-status--on {
color: var(--green);
}
.addon-status--off {
color: var(--text-dim);
}
.svc-detail-option-card {
padding: 16px;
background: rgba(94, 173, 138, 0.06);
border: 1px solid rgba(94, 173, 138, 0.24);
border-radius: 10px;
}
.svc-detail-option-list {
margin: 10px 0 0;
padding-left: 20px;
color: var(--text-secondary);
font-size: 0.84rem;
line-height: 1.55;
}
.svc-detail-option-list li {
margin-bottom: 5px;
}
.svc-detail-option-privacy {
margin-top: 12px;
padding: 10px 12px;
border-left: 3px solid var(--accent-color);
background: rgba(94, 173, 138, 0.08);
border-radius: 6px;
color: var(--text-secondary);
font-size: 0.82rem;
line-height: 1.5;
}
.svc-detail-option-privacy strong {
color: var(--accent-color);
}
.svc-detail-related-feature-btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.feature-conflict-warning {
margin-top: 8px;
margin-bottom: 8px;
padding: 10px 14px;
background-color: rgba(229, 165, 10, 0.1);
border: 1px solid rgba(229, 165, 10, 0.3);
border-radius: 8px;
font-size: 0.82rem;
color: var(--yellow);
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 ──────────────────────────────────────── */
.svc-detail-launch-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.svc-detail-launch-btn {
font-size: 0.85rem;
padding: 8px 18px;
cursor: pointer;
} }
@@ -23,13 +23,19 @@ const SUPPORT_TIMER_INTERVAL = 1000;
const CATEGORY_ORDER = [ const CATEGORY_ORDER = [
"infrastructure", "infrastructure",
"bitcoin-base", "bitcoin",
"bitcoin-apps",
"communication", "communication",
"apps", "apps",
"nostr", "nostr",
]; ];
/* The service catalog still distinguishes the Bitcoin foundation services
from the Bitcoin apps; the Hub shows them as one "Bitcoin" section. */
const CATEGORY_ALIASES = {
"bitcoin-base": "bitcoin",
"bitcoin-apps": "bitcoin",
};
const FEATURE_SUBCATEGORY_LABELS = { const FEATURE_SUBCATEGORY_LABELS = {
"infrastructure": "🔧 Infrastructure", "infrastructure": "🔧 Infrastructure",
"bitcoin": "₿ Bitcoin", "bitcoin": "₿ Bitcoin",
@@ -0,0 +1,451 @@
"use strict";
/* ── The Hub dashboard chrome ──────────────────────────────────────
Welcome dashboard (default view), category navigation, service
search, status cards, and the Systems Operational modal.
Everything lives in an IIFE so no globals leak into the other Hub
scripts; tiles.js calls window.dashboardServicesUpdated() whenever
the service list refreshes. */
(function () {
var $nav = document.getElementById("sidebar-nav");
var $pageTitle = document.getElementById("page-title");
var $search = document.getElementById("search-input");
var $sysModal = document.getElementById("systems-modal");
var $sysBody = document.getElementById("systems-body");
var $welcome = document.getElementById("welcome-view");
var $tilesArea = document.getElementById("tiles-area");
var $wcSystems = document.getElementById("wc-systems");
var $wcMore = document.getElementById("wc-more");
var $greeting = document.getElementById("welcome-greeting");
var $welcomeRole = document.getElementById("welcome-role");
var $browseBtn = document.getElementById("welcome-browse-btn");
var _view = "dashboard"; // "dashboard" | "services"
var _cat = "all";
var _query = "";
var CAT_ICONS = {
"all": "g-grid",
"infrastructure": "g-server",
"bitcoin": "g-btc-sym",
"communication": "g-chat",
"apps": "g-dots",
"nostr": "g-antenna",
"other": "g-dots"
};
var CAT_FALLBACK_LABELS = {
"infrastructure": "Infrastructure",
"bitcoin": "Bitcoin",
"communication": "Communication",
"apps": "Personal Apps",
"nostr": "Nostr",
"other": "Other"
};
/* Units that serve a domain — used to find a live diagnostics
checklist for the Systems Operational modal (order matters only
for which service is polled first). */
var DOMAIN_UNITS = [
"matrix-synapse.service",
"btcpayserver.service",
"vaultwarden.service",
"phpfpm-nextcloud.service",
"phpfpm-wordpress.service",
"haven-relay.service",
"livekit.service",
"albyhub.service"
];
function icon(id) {
return '<svg><use href="#' + id + '"/></svg>';
}
function visibleServices() {
var services = (typeof _servicesCache !== "undefined" && _servicesCache) ? _servicesCache : [];
return services.filter(function (s) {
return s.category !== "support" && s.type !== "support";
});
}
/* ── Views ────────────────────────────────────────────────────── */
function setTitle(t) {
if ($pageTitle) $pageTitle.textContent = t;
}
function syncNav() {
if (!$nav) return;
$nav.querySelectorAll(".nav-item").forEach(function (b) {
var isActive;
if (b.dataset.cat === "__dash") isActive = (_view === "dashboard");
else isActive = (_view === "services" && b.dataset.cat === _cat);
b.classList.toggle("active", isActive);
});
}
function showDashboard() {
_view = "dashboard";
_cat = "all";
if ($welcome) $welcome.style.display = "";
if ($tilesArea) $tilesArea.style.display = "none";
setTitle("Dashboard");
syncNav();
}
function showServices(cat) {
_view = "services";
if (cat) _cat = cat;
if ($welcome) $welcome.style.display = "none";
if ($tilesArea) $tilesArea.style.display = "";
setTitle(_cat === "all" ? "All services" : catLabel(_cat));
syncNav();
applyFilter();
}
/* ── Category navigation ──────────────────────────────────────── */
function renderNav() {
if (!$nav) return;
var counts = {};
var order = [];
visibleServices().forEach(function (s) {
var cat = s.category || "other";
if (CATEGORY_ALIASES[cat]) cat = CATEGORY_ALIASES[cat];
if (!counts[cat]) { counts[cat] = 0; order.push(cat); }
counts[cat]++;
});
var total = visibleServices().length;
var html = '<div class="nav-label">Menu</div>';
html += '<button class="nav-item' + (_view === "dashboard" ? " active" : "") + '" data-cat="__dash" type="button">' +
icon("g-home") +
'<span class="nav-text">Dashboard</span>' +
'</button>';
html += '<div class="nav-label">Services</div>';
html += navItem("all", "All services", total);
order.forEach(function (cat) {
html += navItem(cat, catLabel(cat), counts[cat]);
});
$nav.innerHTML = html;
$nav.querySelectorAll(".nav-item").forEach(function (btn) {
btn.addEventListener("click", function () {
if (btn.dataset.cat === "__dash") showDashboard();
else showServices(btn.dataset.cat);
});
});
}
function navItem(cat, label, count) {
return '<button class="nav-item' + (_view === "services" && cat === _cat ? " active" : "") + '" data-cat="' + escHtml(cat) + '" type="button">' +
icon(CAT_ICONS[cat] || "g-dots") +
'<span class="nav-text">' + escHtml(label) + '</span>' +
'<span class="nav-count">' + count + '</span>' +
'</button>';
}
function catLabel(cat) {
if (typeof _categoryLabels !== "undefined" && _categoryLabels[cat]) return _categoryLabels[cat];
return CAT_FALLBACK_LABELS[cat] || cat;
}
/* ── Filtering (applies in the services view) ─────────────────── */
function applyFilter() {
if (_view !== "services") return;
var area = $tilesArea;
if (!area) return;
var q = _query.trim().toLowerCase();
area.querySelectorAll(".category-section").forEach(function (section) {
var catMatch = (_cat === "all") || (section.dataset.category === _cat);
var anyVisible = false;
section.querySelectorAll(".service-tile").forEach(function (tile) {
var nameEl = tile.querySelector(".tile-name");
var name = nameEl ? nameEl.textContent.toLowerCase() : "";
var match = catMatch && (!q || name.indexOf(q) !== -1);
tile.style.display = match ? "" : "none";
if (match) anyVisible = true;
});
section.style.display = (catMatch && (anyVisible || !q)) ? "" : "none";
});
}
if ($search) {
$search.addEventListener("input", function () {
// Searching implies browsing services — leave the welcome view.
if (_view === "dashboard" && $search.value) showServices("all");
_query = $search.value;
applyFilter();
});
}
if ($browseBtn) {
$browseBtn.addEventListener("click", function () { showServices("all"); });
}
/* ── Welcome header ───────────────────────────────────────────── */
function updateWelcomeMeta() {
if ($greeting) {
var h = new Date().getHours();
var g;
if (h >= 5 && h < 12) g = "Good morning";
else if (h >= 12 && h < 17) g = "Good afternoon";
else g = "Good evening";
$greeting.textContent = g;
}
if ($welcomeRole) {
$welcomeRole.textContent = (typeof window._roleLabel !== "undefined" && window._roleLabel) ? window._roleLabel : "";
}
}
/* ── Status cards ─────────────────────────────────────────────── */
function serviceCounts() {
var services = visibleServices();
var running = 0, attention = 0, off = 0;
var attentionNames = [], offNames = [];
services.forEach(function (s) {
if (!s.enabled) { off++; offNames.push(s.name); return; }
var h = s.health || s.status;
if (h === "needs_attention" || h === "failed") { attention++; attentionNames.push(s.name); }
else running++;
});
return { services: services, running: running, attention: attention, off: off, attentionNames: attentionNames, offNames: offNames };
}
function renderWidgets() {
if (!$wcSystems) return;
var c = serviceCounts();
if (!c.services.length) { $wcSystems.innerHTML = ""; if ($wcMore) $wcMore.innerHTML = ""; return; }
/* Systems operational */
var sub = '<b>' + c.running + '</b> running';
if (c.attention) sub += ' · <span class="warn">' + c.attention + ' needs attention</span>';
if (c.off) sub += ' · ' + c.off + ' off';
var attentionTitle = c.attention ? "Systems need attention" : "Systems operational";
$wcSystems.innerHTML =
'<div class="widget clickable" id="w-systems" role="button" tabindex="0" title="System status and router setup">' +
'<div class="widget-chip chip-green">' + icon("g-pulse") + '</div>' +
'<div class="w-body"><h3>' + attentionTitle + '</h3><div class="sub">' + sub + '</div></div>' +
'</div>';
var wSys = document.getElementById("w-systems");
if (wSys) {
wSys.addEventListener("click", openSystemsModal);
wSys.addEventListener("keydown", function (e) { if (e.key === "Enter") openSystemsModal(); });
}
if (!$wcMore) return;
var more = "";
/* Bitcoin Core sync */
var btc = null;
c.services.forEach(function (s) {
if (s.sync_ibd && s.enabled) btc = s;
});
if (btc) {
var pct = Math.round((btc.sync_progress || 0) * 100);
var blocks = btc.sync_blocks ? btc.sync_blocks.toLocaleString() : "—";
var eta = (typeof _calcBtcEta === "function") ? _calcBtcEta(btc.unit + "::" + btc.name, btc.sync_progress || 0) : "";
more +=
'<div class="widget clickable" id="w-btc" role="button" tabindex="0" title="' + escHtml(btc.name) + ' details">' +
'<div class="widget-chip chip-btc"><img src="/static/icons/' + escHtml(btc.icon) + '.svg" alt="" style="width:46px;height:46px;display:block;object-fit:contain"/></div>' +
'<div class="w-body"><h3>' + escHtml(btc.name) + ' — syncing timechain</h3>' +
'<div class="w-bar"><div class="w-bar-fill" style="width:' + pct + '%"></div></div>' +
'<div class="sub">Block <b>' + blocks + '</b> · ' + pct + '% · <span class="warn">' + escHtml(eta) + '</span></div></div>' +
'</div>';
} else {
var btcDone = null;
c.services.forEach(function (s) { if (s.unit === "bitcoind.service" && s.enabled) btcDone = s; });
if (btcDone) {
var blk = btcDone.sync_blocks ? btcDone.sync_blocks.toLocaleString() : "";
more +=
'<div class="widget clickable" id="w-btc" role="button" tabindex="0" title="' + escHtml(btcDone.name) + ' details">' +
'<div class="widget-chip chip-btc"><img src="/static/icons/' + escHtml(btcDone.icon) + '.svg" alt="" style="width:46px;height:46px;display:block;object-fit:contain"/></div>' +
'<div class="w-body"><h3>' + escHtml(btcDone.name) + '</h3><div class="sub"><span class="good">Fully synced</span>' + (blk ? ' · Block <b>' + blk + '</b>' : '') + '</div></div>' +
'</div>';
}
}
/* Updates — mirror the sidebar's read of the update state, so the card
never claims "up to date" while an update failed or needs a restart */
var upd = (typeof window._lastUpdateCheck === "object" && window._lastUpdateCheck) ? window._lastUpdateCheck : null;
var updStatus = (upd && upd.status) || "idle";
var updTitle, updSub, updChip;
if (updStatus === "failed") {
updTitle = "Update failed"; updSub = "Click to retry the update"; updChip = "chip-amber";
} else if (updStatus === "reboot_required") {
updTitle = "Restart required"; updSub = "Click to restart and finish the update"; updChip = "chip-amber";
} else if (updStatus === "running") {
updTitle = "Update in progress"; updSub = "Installing the new system generation"; updChip = "chip-amber";
} else if (upd && upd.available) {
updTitle = "Updates available"; updSub = "Click to review and update"; updChip = "chip-amber";
} else if (!upd) {
/* First check hasn't returned yet — never claim "up to date" before
a check has actually completed */
updTitle = "Checking for updates"; updSub = "Comparing with the latest Sovran_SystemsOS release"; updChip = "chip-green";
} else {
/* Updates are applied by the user, not automatically — the card must
not imply the OS updates itself */
updTitle = "System is up to date"; updSub = "Last check found no updates · Click to check again"; updChip = "chip-green";
}
more +=
'<div class="widget clickable" id="w-updates" role="button" tabindex="0" title="Check for system updates">' +
'<div class="widget-chip ' + updChip + '">' + icon("g-update") + '</div>' +
'<div class="w-body"><h3>' + updTitle + '</h3>' +
'<div class="sub">' + updSub + '</div></div>' +
'</div>';
$wcMore.innerHTML = more;
var wBtc = document.getElementById("w-btc");
if (wBtc) {
var svc = btc || btcDone;
if (svc) {
wBtc.addEventListener("click", function () { openServiceDetailModal(svc.unit, svc.name, svc.icon); });
wBtc.addEventListener("keydown", function (e) { if (e.key === "Enter") openServiceDetailModal(svc.unit, svc.name, svc.icon); });
}
}
var wUpd = document.getElementById("w-updates");
if (wUpd) {
wUpd.addEventListener("click", function () { openUpdateModal(); });
wUpd.addEventListener("keydown", function (e) { if (e.key === "Enter") openUpdateModal(); });
}
}
/* ── Systems Operational modal ────────────────────────────────── */
function isNodeRole() {
return (typeof _currentRole !== "undefined" && _currentRole === "node");
}
function hasEnabledDomainService(services) {
return DOMAIN_UNITS.some(function (u) {
return services.some(function (s) { return s.unit === u && s.enabled; });
});
}
function whoUsesPorts() {
if (isNodeRole()) {
return 'On this <strong>Bitcoin Node</strong> install, <strong>BTCPay Server</strong> and <strong>Lightning Wallet Connections (LNURL)</strong> are the domain services that use these ports.';
}
return 'All your domain services share ports 80 and 443 — Matrix, BTCPay Server, VaultWarden, Nextcloud, WordPress, Haven Relay, Lightning Wallet Connections, and Element Calling.';
}
function step(n, title, sub, value) {
return '<div class="sysstep"><div class="sysnum">' + n + '</div><div class="sysstep-x">' +
'<div class="sysstep-t">' + title + (sub ? ' <span class="sysstep-sub">· ' + sub + '</span>' : '') + '</div>' +
(value ? '<div class="sysval"><span class="sysval-text">' + value + '</span></div>' : '') +
'</div></div>';
}
function openSystemsModal() {
if (!$sysModal || !$sysBody) return;
var c = serviceCounts();
var html = "";
/* System status */
html += '<div class="sysmodal-card">' +
'<div class="sysmodal-card-title">' + icon("g-pulse") + 'System Status</div>' +
step(1, "Services running", "", String(c.running)) +
step(2, "Needs attention", "", c.attention ? escHtml(c.attentionNames.join(", ")) : "None") +
step(3, "Turned off", "", c.off ? escHtml(c.offNames.join(", ")) : "None") +
'</div>';
/* Router — a simple open / not-open verdict. How to open the ports is
covered during onboarding, so the modal does not repeat instructions.
When no domain service is enabled (a fresh Desktop-only install, a
Node with BTCPay/LNURL off, or everything turned off), there is
nothing to check on the router — say so instead of listing services
this machine does not have. */
if (!hasEnabledDomainService(c.services)) {
var noRouterDesc = isNodeRole()
? 'Ports 80 and 443 only need to be forwarded on your router if you turn on <strong>BTCPay Server</strong> or <strong>Lightning Wallet Connections (LNURL)</strong>. If you enable one of them, come back here to check your ports.'
: 'None of your services need ports forwarded from your router right now. If you turn on a service that uses a domain, come back here to check your ports.';
html += '<div class="sysmodal-card">' +
'<div class="sysmodal-card-title">' + icon("g-wifi") + 'Router</div>' +
'<div class="sysnote"><div class="sysnote-title">' + icon("g-check") + 'No router setup needed yet</div>' +
'<div class="sysnote-desc">' + noRouterDesc + '</div></div>' +
'</div>';
} else {
html += '<div class="sysmodal-card" id="sys-ports-card" style="display:none">' +
'<div class="sysmodal-card-title">' + icon("g-wifi") + 'Router</div>' +
'<div id="sys-ports-status"><div class="sysfineprint">Checking…</div></div>' +
'</div>';
}
/* Who uses these ports (only meaningful when a domain service is
actually enabled — otherwise the router note above covers it) */
if (hasEnabledDomainService(c.services)) {
html += '<div class="sysnote" style="margin-top:14px">' +
'<div class="sysnote-title">' + icon("g-antenna") + 'Who uses these ports</div>' +
'<div class="sysnote-desc">' + whoUsesPorts() + '</div></div>';
}
$sysBody.innerHTML = html;
$sysModal.classList.add("open");
/* Poll the first configured domain service and reduce its diagnostics
to one verdict: the ports are open or they are not. */
var portsCard = document.getElementById("sys-ports-card");
var portsEl = document.getElementById("sys-ports-status");
var units = DOMAIN_UNITS.filter(function (u) {
return c.services.some(function (s) { return s.unit === u && s.enabled; });
});
if (!units.length || !portsCard || !portsEl) return;
portsCard.style.display = "";
apiFetch("/api/service-detail/" + encodeURIComponent(units[0]))
.then(function (data) {
var steps = (data && data.domain_check_steps) || [];
var portsStep = null;
steps.forEach(function (s) {
if (Number(s.step) === 3 || /ports?\s*80/i.test(s.label || "")) portsStep = s;
});
if (!portsStep) { portsCard.style.display = "none"; return; }
if (portsStep.status === "ok") {
portsEl.innerHTML = '<div class="svc-detail-status" style="font-size:0.92rem"><span class="status-dot active"></span>Ports 80 and 443 are open</div>';
} else {
portsEl.innerHTML = '<div class="svc-detail-status" style="font-size:0.92rem"><span class="status-dot failed"></span>Ports 80 and 443 are not open</div>';
}
})
.catch(function () {
portsCard.style.display = "none";
});
}
function closeSystemsModal() {
if ($sysModal) $sysModal.classList.remove("open");
}
var sysClose = document.getElementById("systems-close-btn");
if (sysClose) sysClose.addEventListener("click", closeSystemsModal);
if ($sysModal) {
$sysModal.addEventListener("click", function (e) {
if (e.target === $sysModal) closeSystemsModal();
});
$sysModal.addEventListener("keydown", function (e) {
if (e.key === "Escape") closeSystemsModal();
});
}
/* ── Public hook for tiles.js ────────────────────────────────── */
window.dashboardServicesUpdated = function () {
renderNav();
updateWelcomeMeta();
renderWidgets();
applyFilter();
};
// Initial view
showDashboard();
updateWelcomeMeta();
})();
@@ -43,6 +43,11 @@ function renderDomainNeedsHtml(opts) {
+ '<a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">Njal.la</a>' + '<a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">Njal.la</a>'
+ " and connecting your services. Just follow the steps below.</p>"; + " and connecting your services. Just follow the steps below.</p>";
} }
// Every variant above ends with a domain that points at the user's home
// connection. Say what that publishes (README: "Server + Desktop and your
// home IP address").
html += "<p>⚠️ <strong>Heads-up:</strong> your domain points at your home internet connection, "
+ "so anyone can look up your home IP address. Domain privacy does not hide it.</p>";
return html; return html;
} }
+10 -5
View File
@@ -4,9 +4,12 @@
// if ($updateBtn) $updateBtn.addEventListener("click", openUpdateModal); // moved to sidebar in tiles.js // if ($updateBtn) $updateBtn.addEventListener("click", openUpdateModal); // moved to sidebar in tiles.js
if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal); if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal);
if ($btnCheckAgain) $btnCheckAgain.addEventListener("click", function() { openUpdateModal(); });
if ($updateCloseBtn) $updateCloseBtn.addEventListener("click", closeUpdateModal);
if ($btnReboot) $btnReboot.addEventListener("click", doReboot); if ($btnReboot) $btnReboot.addEventListener("click", doReboot);
if ($btnSave) $btnSave.addEventListener("click", saveErrorReport); if ($btnSave) $btnSave.addEventListener("click", saveErrorReport);
if ($btnRetryUpdate) $btnRetryUpdate.addEventListener("click", retryUpdateStatus); 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 // Browser timers and requests may be suspended while an RDP session/tab is in
// the background. Reconcile immediately when the user returns instead of // the background. Reconcile immediately when the user returns instead of
@@ -27,6 +30,7 @@ if ($logoutBtn) $logoutBtn.addEventListener("click", function () {
// Rebuild modal // Rebuild modal
if ($rebuildClose) $rebuildClose.addEventListener("click", closeRebuildModal); if ($rebuildClose) $rebuildClose.addEventListener("click", closeRebuildModal);
if ($rebuildCloseHdr) $rebuildCloseHdr.addEventListener("click", closeRebuildModal);
if ($rebuildReboot) $rebuildReboot.addEventListener("click", doReboot); if ($rebuildReboot) $rebuildReboot.addEventListener("click", doReboot);
if ($rebuildSave) $rebuildSave.addEventListener("click", saveRebuildErrorReport); if ($rebuildSave) $rebuildSave.addEventListener("click", saveRebuildErrorReport);
if ($rebuildModal) $rebuildModal.addEventListener("click", function(e) { if (e.target === $rebuildModal) closeRebuildModal(); }); if ($rebuildModal) $rebuildModal.addEventListener("click", function(e) { if (e.target === $rebuildModal) closeRebuildModal(); });
@@ -174,9 +178,9 @@ function showSecurityBanner() {
'</div>' + '</div>' +
'<button class="security-banner-dismiss" id="security-banner-dismiss-btn" title="Dismiss">\u2715</button>'; '<button class="security-banner-dismiss" id="security-banner-dismiss-btn" title="Dismiss">\u2715</button>';
var mainContent = document.querySelector(".main-content"); var contentArea = document.querySelector(".content");
if (mainContent) { if (contentArea) {
mainContent.insertAdjacentElement("beforebegin", banner); contentArea.insertAdjacentElement("afterbegin", banner);
} else { } else {
document.body.insertAdjacentElement("afterbegin", banner); document.body.insertAdjacentElement("afterbegin", banner);
} }
@@ -238,10 +242,11 @@ async function init() {
} }
var badge = document.getElementById("role-badge"); var badge = document.getElementById("role-badge");
if (badge && cfg.role_label) badge.textContent = cfg.role_label; if (badge && cfg.role_label) badge.textContent = cfg.role_label;
window._roleLabel = cfg.role_label || "";
await refreshServices();
loadNetwork(); loadNetwork();
checkUpdates(); checkUpdates();
await refreshServices();
setInterval(refreshServices, POLL_INTERVAL_SERVICES); setInterval(refreshServices, POLL_INTERVAL_SERVICES);
setInterval(checkUpdates, POLL_INTERVAL_UPDATES); setInterval(checkUpdates, POLL_INTERVAL_UPDATES);
@@ -251,9 +256,9 @@ async function init() {
} }
loadAutolaunchToggle(); loadAutolaunchToggle();
} catch (_) { } catch (_) {
await refreshServices();
loadNetwork(); loadNetwork();
checkUpdates(); checkUpdates();
await refreshServices();
setInterval(refreshServices, POLL_INTERVAL_SERVICES); setInterval(refreshServices, POLL_INTERVAL_SERVICES);
setInterval(checkUpdates, POLL_INTERVAL_UPDATES); setInterval(checkUpdates, POLL_INTERVAL_UPDATES);
loadAutolaunchToggle(); loadAutolaunchToggle();
+5 -12
View File
@@ -97,7 +97,6 @@ function openDomainSetupModal(feat, onSaved) {
nwcWarning + nwcWarning +
renderDomainNeedsHtml({ serviceName: feat.name, hostExample: hostExample, purpose: purpose }) + renderDomainNeedsHtml({ serviceName: feat.name, hostExample: hostExample, purpose: purpose }) +
renderNjallaStepsHtml({ hostExample: hostExample, pasteHint: "below" }) + renderNjallaStepsHtml({ hostExample: hostExample, pasteHint: "below" }) +
'<div class="onboarding-port-warn" id="domain-router-box" style="margin-top:12px;"></div>' +
'<p style="margin-top:10px;">Enter the address for this service and paste the update command from Njal.la.</p>' + '<p style="margin-top:10px;">Enter the address for this service and paste the update command from Njal.la.</p>' +
'</div>' + '</div>' +
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service address (e.g. ' + domainLabelExample + '):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="' + domainPlaceholder + '" /></div>' + '<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service address (e.g. ' + domainLabelExample + '):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="' + domainPlaceholder + '" /></div>' +
@@ -144,8 +143,6 @@ function openDomainSetupModal(feat, onSaved) {
$domainSetupModal.classList.add("open"); $domainSetupModal.classList.add("open");
// Fill the router port-forwarding box with this computer's LAN IP (best-effort)
renderRouterPortsBox("domain-router-box");
} }
function openDomainReconfigureModal(feat, existingDomain, onSaved) { function openDomainReconfigureModal(feat, existingDomain, onSaved) {
@@ -193,9 +190,7 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) {
'<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>' + '<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 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>' + '<li>If you changed the DDNS curl command, paste the updated one below</li>' +
'<li>Confirm ports <strong>80</strong> and <strong>443</strong> (TCP) are still forwarded on your router to this computer — see the reminder below:</li>' +
'</ol>' + '</ol>' +
'<div class="onboarding-port-warn" id="domain-router-box" style="margin-top:12px;"></div>' +
'</div>' + '</div>' +
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service domain (e.g. ' + domainLabelExample + '):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="' + domainPlaceholder + '" value="' + escHtml(currentDomain) + '" /></div>' + '<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service domain (e.g. ' + domainLabelExample + '):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="' + domainPlaceholder + '" 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=' + domainPlaceholder + '&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>' + '<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=' + domainPlaceholder + '&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>' +
@@ -241,8 +236,6 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) {
$domainSetupModal.classList.add("open"); $domainSetupModal.classList.add("open");
// Fill the router port-forwarding box with this computer's LAN IP (best-effort)
renderRouterPortsBox("domain-router-box");
} }
function closeDomainSetupModal() { function closeDomainSetupModal() {
@@ -635,16 +628,16 @@ async function loadAutolaunchToggle() {
} }
function renderAutolaunchToggle(enabled) { function renderAutolaunchToggle(enabled) {
// Remove existing section if any // The preference lives in the Systems Operational modal (moved from the sidebar)
var old = $sidebarFeatures.querySelector(".autolaunch-section"); var slot = document.getElementById("autolaunch-slot");
if (!slot) return;
var old = slot.querySelector(".autolaunch-section");
if (old) old.parentNode.removeChild(old); if (old) old.parentNode.removeChild(old);
var section = document.createElement("div"); var section = document.createElement("div");
section.className = "category-section autolaunch-section"; section.className = "category-section autolaunch-section";
section.innerHTML = section.innerHTML =
'<div class="section-header">Preferences</div>' +
'<hr class="section-divider" />' +
'<div class="feature-card">' + '<div class="feature-card">' +
'<div class="feature-card-top">' + '<div class="feature-card-top">' +
'<div class="feature-card-info">' + '<div class="feature-card-info">' +
@@ -658,7 +651,7 @@ function renderAutolaunchToggle(enabled) {
'</div>' + '</div>' +
'</div>'; '</div>';
$sidebarFeatures.appendChild(section); slot.appendChild(section);
var input = document.getElementById("autolaunch-toggle-input"); var input = document.getElementById("autolaunch-toggle-input");
var label = document.getElementById("autolaunch-toggle-label"); var label = document.getElementById("autolaunch-toggle-label");
+7 -16
View File
@@ -67,7 +67,7 @@ function renderPortForwardGuideHtml(ports, opts) {
var noteClass = opts.noteClass || "port-req-hint"; var noteClass = opts.noteClass || "port-req-hint";
var ipHtml = opts.internalIp var ipHtml = opts.internalIp
? '<code class="port-req-internal-ip">' + escHtml(opts.internalIp) + '</code>' ? '<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) { var rows = (ports || []).map(function(p) {
return '<tr>' + return '<tr>' +
@@ -78,26 +78,17 @@ function renderPortForwardGuideHtml(ports, opts) {
}).join(""); }).join("");
var forWhat = opts.serviceName var forWhat = opts.serviceName
? 'For <strong>' + escHtml(opts.serviceName) + '</strong> to be reachable from outside your home network, open' ? 'To make <strong>' + escHtml(opts.serviceName) + '</strong> reachable from outside your home, forward these ports to ' + ipHtml + ':'
: 'Open'; : 'Forward these ports to ' + ipHtml + ':';
return '<p class="' + introClass + '">' + return '<p class="' + introClass + '">' + forWhat + '</p>' +
forWhat + ' the ports below in your router&rsquo;s <strong>port forwarding</strong> settings ' + '<p class="port-req-steps" style="margin-top:6px;margin-bottom:10px;font-size:0.92em;color:#555;">' +
'and point them at ' + ipHtml + '.' + '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>' + '</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>40000-40099</strong>, use your router&rsquo;s port-range fields — start 40000, end 40099 — rather than one rule per port.</li>' +
'</ul>' +
'<table class="' + tableClass + '">' + '<table class="' + tableClass + '">' +
'<thead><tr><th>Port(s)</th><th>Protocol</th><th>Used for</th></tr></thead>' + '<thead><tr><th>Port(s)</th><th>Protocol</th><th>Used for</th></tr></thead>' +
'<tbody>' + rows + '</tbody>' + '<tbody>' + rows + '</tbody>' +
'</table>' + '</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>';
} }
function formatDuration(seconds) { function formatDuration(seconds) {
+36 -5
View File
@@ -2,6 +2,35 @@
// ── Rebuild modal ───────────────────────────────────────────────── // ── Rebuild modal ─────────────────────────────────────────────────
// Status line + header pill for the rebuild dialog (same presentation
// contract as the update dialog's _setUpdateStatus).
function _setRebuildStatus(text) {
if ($rebuildStatus) $rebuildStatus.textContent = text;
if ($rebuildPill) {
var cls = "upd-pill";
var html;
if (text.charAt(0) === "✓") {
if (text.indexOf("restart required") !== -1) {
cls += " st-needs-attention"; html = '<span class="status-dot needs-attention pulse"></span>Restart required';
} else {
cls += " st-active"; html = '<span class="status-dot active"></span>Done';
}
} else if (text.charAt(0) === "✗") {
cls += " st-failed"; html = '<span class="status-dot failed"></span>Failed';
} else {
cls += " st-loading"; html = '<span class="status-dot loading pulse"></span>Applying…';
}
$rebuildPill.className = cls;
$rebuildPill.innerHTML = html;
}
if ($rebuildStatus) {
var msg = "update-status-msg";
if (text.charAt(0) === "✓") $rebuildStatus.className = (text.indexOf("restart required") !== -1) ? msg + " st-warn" : msg + " st-ok";
else if (text.charAt(0) === "✗") $rebuildStatus.className = msg + " st-err";
else $rebuildStatus.className = msg;
}
}
function openRebuildModal() { function openRebuildModal() {
if (!$rebuildModal) return; if (!$rebuildModal) return;
_rebuildLog = ""; _rebuildLog = "";
@@ -13,11 +42,12 @@ function openRebuildModal() {
if ($rebuildLog) { $rebuildLog.textContent = ""; $rebuildLog.style.display = "none"; } if ($rebuildLog) { $rebuildLog.textContent = ""; $rebuildLog.style.display = "none"; }
var action = _rebuildIsEnabling ? "Enabling" : "Disabling"; var action = _rebuildIsEnabling ? "Enabling" : "Disabling";
var label = _rebuildFeatureName || "feature"; var label = _rebuildFeatureName || "feature";
if ($rebuildStatus) $rebuildStatus.textContent = action + " " + label + "…"; _setRebuildStatus(action + " " + label + "…");
if ($rebuildSpinner) $rebuildSpinner.classList.add("spinning"); if ($rebuildSpinner) $rebuildSpinner.classList.add("spinning");
if ($rebuildReboot) $rebuildReboot.style.display = "none"; if ($rebuildReboot) $rebuildReboot.style.display = "none";
if ($rebuildSave) $rebuildSave.style.display = "none"; if ($rebuildSave) $rebuildSave.style.display = "none";
if ($rebuildClose) $rebuildClose.disabled = true; if ($rebuildClose) $rebuildClose.disabled = true;
if ($rebuildCloseHdr) $rebuildCloseHdr.disabled = true;
$rebuildModal.classList.add("open"); $rebuildModal.classList.add("open");
// Delay first poll slightly to let the rebuild service start and clear stale log // Delay first poll slightly to let the rebuild service start and clear stale log
setTimeout(startRebuildPoll, 1500); setTimeout(startRebuildPoll, 1500);
@@ -76,7 +106,7 @@ async function pollRebuildStatus() {
window.location.reload(); window.location.reload();
return; return;
} }
if (!_rebuildServerDown) { _rebuildServerDown = true; if ($rebuildStatus) $rebuildStatus.textContent = "Applying changes…"; } if (!_rebuildServerDown) { _rebuildServerDown = true; _setRebuildStatus("Applying changes…"); }
} finally { } finally {
_rebuildPollInFlight = false; _rebuildPollInFlight = false;
} }
@@ -85,15 +115,16 @@ async function pollRebuildStatus() {
function onRebuildDone(result) { function onRebuildDone(result) {
if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning"); if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning");
if ($rebuildClose) $rebuildClose.disabled = false; if ($rebuildClose) $rebuildClose.disabled = false;
if ($rebuildCloseHdr) $rebuildCloseHdr.disabled = false;
if (result === true) { if (result === true) {
if ($rebuildStatus) $rebuildStatus.textContent = "✓ Done"; _setRebuildStatus("✓ Done");
// Auto-reload the page after a short delay so tiles and toggles reflect the new state // Auto-reload the page after a short delay so tiles and toggles reflect the new state
setTimeout(function() { window.location.reload(); }, 1200); setTimeout(function() { window.location.reload(); }, 1200);
} else if (result === "reboot_required") { } else if (result === "reboot_required") {
if ($rebuildStatus) $rebuildStatus.textContent = "✓ Done — restart required"; _setRebuildStatus("✓ Done — restart required");
if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex"; if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex";
} else { } else {
if ($rebuildStatus) $rebuildStatus.textContent = "✗ Something went wrong"; _setRebuildStatus("✗ Something went wrong");
if ($rebuildSave) $rebuildSave.style.display = "inline-flex"; if ($rebuildSave) $rebuildSave.style.display = "inline-flex";
if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex"; if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex";
} }
@@ -5,7 +5,12 @@
function openSecurityModal() { function openSecurityModal() {
if ($supportModal) $supportModal.classList.add("open"); if ($supportModal) $supportModal.classList.add("open");
var title = document.getElementById("support-modal-title"); var title = document.getElementById("support-modal-title");
if (title) title.textContent = "\uD83D\uDEE1 Security"; if (title) title.textContent = "Security";
var chip = document.getElementById("support-modal-chip");
if (chip) {
var use = chip.querySelector("use");
if (use) use.setAttribute("href", "#g-shield-check");
}
if ($supportBody) { if ($supportBody) {
$supportBody.innerHTML = $supportBody.innerHTML =
@@ -696,6 +696,17 @@ async function openServiceDetailModal(unit, name, icon) {
: (data.health || data.status); : (data.health || data.status);
var sc = statusClass(effectiveHealth); var sc = statusClass(effectiveHealth);
var st = statusText(effectiveHealth, effectiveEnabled); var st = statusText(effectiveHealth, effectiveEnabled);
if ($credsTitle) {
var existingPill = $credsTitle.querySelector(".creds-title-status-pill");
if (!existingPill) {
var pill = document.createElement("span");
pill.className = "creds-title-status-pill";
$credsTitle.appendChild(pill);
existingPill = pill;
}
existingPill.className = "creds-title-status-pill st-" + sc;
existingPill.textContent = st;
}
addSetup('<div class="svc-detail-section">' + addSetup('<div class="svc-detail-section">' +
'<div class="svc-detail-section-title">Status</div>' + '<div class="svc-detail-section-title">Status</div>' +
'<div class="svc-detail-status">' + '<div class="svc-detail-status">' +
@@ -707,19 +718,27 @@ async function openServiceDetailModal(unit, name, icon) {
// Section C: Domain diagnostics (domain services) // Section C: Domain diagnostics (domain services)
if (data.needs_domain) { if (data.needs_domain) {
var steps = data.domain_check_steps || []; // The Hub shows only the domain-active step here; the full DNS and
// port diagnostics live in the Systems Operational modal.
var steps = (data.domain_check_steps || []).filter(function (s) {
return Number(s.step) === 1;
});
var stepsHtml = ""; var stepsHtml = "";
steps.forEach(function(step) { 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>"); var detail = escHtml(step.detail || "").replace(/\n/g, "<br>");
if (step.status === "ok") {
// Not a checklist anymore — just note that the domain works.
stepsHtml += '<div class="svc-detail-status"><span class="status-dot active"></span>Domain is active</div>' +
(detail ? '<div class="svc-detail-desc" style="margin-top:6px">' + detail + '</div>' : '');
} else {
var iconLabel = "❌";
if (step.status === "warning") iconLabel = "⚠️";
else if (step.status === "skipped") iconLabel = "⏭️";
stepsHtml += '<div class="svc-detail-troubleshoot" style="margin-bottom:10px">' + stepsHtml += '<div class="svc-detail-troubleshoot" style="margin-bottom:10px">' +
'<strong>' + iconLabel + ' Step ' + escHtml(String(step.step)) + ': ' + escHtml(step.label || "") + '</strong>' + '<strong>' + iconLabel + ' ' + escHtml(step.label || "Domain not configured") + '</strong>' +
(detail ? '<div style="margin-top:6px">' + detail + '</div>' : '') + (detail ? '<div style="margin-top:6px">' + detail + '</div>' : '') +
'</div>'; '</div>';
}
}); });
var domainActionHtml = ""; var domainActionHtml = "";
@@ -731,11 +750,22 @@ async function openServiceDetailModal(unit, name, icon) {
} }
addSetup('<div class="svc-detail-section">' + addSetup('<div class="svc-detail-section">' +
'<div class="svc-detail-section-title">Domain Diagnostic Checklist</div>' + '<div class="svc-detail-section-title">Domain Status</div>' +
stepsHtml + stepsHtml +
domainActionHtml + domainActionHtml +
'</div>'); '</div>');
// Node-only role: BTCPay Server and Lightning Wallet Connections are
// the domain services — surface the router task here. (Desktop + Server
// set this up during onboarding; Systems Operational shows it too.)
if (typeof _currentRole !== "undefined" && _currentRole === "node" &&
(unit === "btcpayserver.service" || unit === "albyhub.service")) {
addSetup('<div class="svc-detail-section">' +
'<div class="svc-detail-section-title">Ports to Forward in Your Router</div>' +
'<div class="onboarding-port-warn" id="svc-node-router-box"></div>' +
'</div>');
}
if (data.router_ports && data.router_ports.length > 0) { if (data.router_ports && data.router_ports.length > 0) {
var trimmedInternalIp = data.internal_ip ? String(data.internal_ip).trim() : ""; var trimmedInternalIp = data.internal_ip ? String(data.internal_ip).trim() : "";
addSetup('<div class="svc-detail-section">' + addSetup('<div class="svc-detail-section">' +
@@ -993,6 +1023,9 @@ async function openServiceDetailModal(unit, name, icon) {
$credsBody.innerHTML = html; $credsBody.innerHTML = html;
if (isNwc) _nwcWireTabs(); if (isNwc) _nwcWireTabs();
_attachCopyHandlers($credsBody); _attachCopyHandlers($credsBody);
if (document.getElementById("svc-node-router-box")) {
renderRouterPortsBox("svc-node-router-box");
}
if (_isNwcServiceUnit(unit) && (effectiveEnabled || data.enabled)) { if (_isNwcServiceUnit(unit) && (effectiveEnabled || data.enabled)) {
await _nwcInitWalletFlow(unit, name, icon); await _nwcInitWalletFlow(unit, name, icon);
var nwcRtlBtn = document.getElementById("nwc-open-rtl-btn"); var nwcRtlBtn = document.getElementById("nwc-open-rtl-btn");
@@ -53,7 +53,12 @@ const $modalLog = document.getElementById("modal-log");
const $btnReboot = document.getElementById("btn-reboot"); const $btnReboot = document.getElementById("btn-reboot");
const $btnSave = document.getElementById("btn-save-report"); const $btnSave = document.getElementById("btn-save-report");
const $btnRetryUpdate = document.getElementById("btn-retry-update-status"); const $btnRetryUpdate = document.getElementById("btn-retry-update-status");
const $btnRetryRun = document.getElementById("btn-retry-update");
const $btnCloseModal = document.getElementById("btn-close-modal"); const $btnCloseModal = document.getElementById("btn-close-modal");
const $btnCheckAgain = document.getElementById("btn-check-again");
const $updateCloseBtn = document.getElementById("update-close-btn");
const $updPill = document.getElementById("upd-pill");
const $updLastChecked = document.getElementById("upd-last-checked");
const $rebootOverlay = document.getElementById("reboot-overlay"); const $rebootOverlay = document.getElementById("reboot-overlay");
const $rebootMainCard = document.getElementById("reboot-main-card"); const $rebootMainCard = document.getElementById("reboot-main-card");
@@ -79,6 +84,8 @@ const $rebuildLog = document.getElementById("rebuild-log");
const $rebuildReboot = document.getElementById("rebuild-reboot-btn"); const $rebuildReboot = document.getElementById("rebuild-reboot-btn");
const $rebuildSave = document.getElementById("rebuild-save-report"); const $rebuildSave = document.getElementById("rebuild-save-report");
const $rebuildClose = document.getElementById("rebuild-close-btn"); const $rebuildClose = document.getElementById("rebuild-close-btn");
const $rebuildCloseHdr = document.getElementById("rebuild-close-hdr");
const $rebuildPill = document.getElementById("rebuild-pill");
// Feature Manager — domain setup modal // Feature Manager — domain setup modal
const $domainSetupModal = document.getElementById("domain-setup-modal"); const $domainSetupModal = document.getElementById("domain-setup-modal");
@@ -5,6 +5,14 @@
async function openSupportModal() { async function openSupportModal() {
if (!$supportModal) return; if (!$supportModal) return;
$supportModal.classList.add("open"); $supportModal.classList.add("open");
// The dialog is shared with Security — always restore its identity
var title = document.getElementById("support-modal-title");
if (title) title.textContent = "Tech Support";
var chip = document.getElementById("support-modal-chip");
if (chip) {
var use = chip.querySelector("use");
if (use) use.setAttribute("href", "#g-lifebuoy");
}
$supportBody.innerHTML = '<p class="creds-loading">Checking support status…</p>'; $supportBody.innerHTML = '<p class="creds-loading">Checking support status…</p>';
try { try {
var status = await apiFetch("/api/support/status"); var status = await apiFetch("/api/support/status");
+44 -18
View File
@@ -25,6 +25,7 @@ function buildTiles(services, categoryLabels) {
continue; continue;
} }
var cat = svc.category || "other"; var cat = svc.category || "other";
if (CATEGORY_ALIASES[cat]) cat = CATEGORY_ALIASES[cat];
if (!grouped[cat]) grouped[cat] = []; if (!grouped[cat]) grouped[cat] = [];
grouped[cat].push(svc); grouped[cat].push(svc);
} }
@@ -52,6 +53,7 @@ function buildTiles(services, categoryLabels) {
if ($tilesArea.children.length === 0) { if ($tilesArea.children.length === 0) {
$tilesArea.innerHTML = '<div class="empty-state"><p>No services configured.</p></div>'; $tilesArea.innerHTML = '<div class="empty-state"><p>No services configured.</p></div>';
} }
if (typeof window.dashboardServicesUpdated === "function") window.dashboardServicesUpdated();
} }
function renderSidebarSupport(supportServices) { function renderSidebarSupport(supportServices) {
@@ -62,20 +64,22 @@ function renderSidebarSupport(supportServices) {
sidebarUpdateBtn.className = "sidebar-support-btn"; sidebarUpdateBtn.className = "sidebar-support-btn";
sidebarUpdateBtn.id = "sidebar-btn-update"; sidebarUpdateBtn.id = "sidebar-btn-update";
sidebarUpdateBtn.innerHTML = sidebarUpdateBtn.innerHTML =
'<img class="sidebar-support-icon" src="/static/icons/update.svg" alt="Update" style="width:1.5rem;height:1.5rem;">' + '<span class="sidebar-support-icon"><svg><use href="#g-update"/></svg></span>' +
'<span class="sidebar-support-text">' + '<span class="sidebar-support-text">' +
'<span class="sidebar-support-title">Update System</span>' + '<span class="sidebar-support-title">Update System</span>' +
'<span class="sidebar-support-hint" id="sidebar-update-hint">Check for updates</span>' + '<span class="sidebar-support-hint" id="sidebar-update-hint">Check for updates</span>' +
'</span>'; '</span>';
sidebarUpdateBtn.addEventListener("click", function() { openUpdateModal(); }); sidebarUpdateBtn.addEventListener("click", function() { openUpdateModal(); });
$sidebarSupport.appendChild(sidebarUpdateBtn); $sidebarSupport.appendChild(sidebarUpdateBtn);
// checkUpdates may already have run before the sidebar was built
applyUpdateSidebarState(window._lastUpdateCheck);
for (var i = 0; i < supportServices.length; i++) { for (var i = 0; i < supportServices.length; i++) {
var svc = supportServices[i]; var svc = supportServices[i];
var btn = document.createElement("button"); var btn = document.createElement("button");
btn.className = "sidebar-support-btn"; btn.className = "sidebar-support-btn";
btn.innerHTML = btn.innerHTML =
'<span class="sidebar-support-icon">🛟</span>' + '<span class="sidebar-support-icon"><svg><use href="#g-lifebuoy"/></svg></span>' +
'<span class="sidebar-support-text">' + '<span class="sidebar-support-text">' +
'<span class="sidebar-support-title">' + escHtml(svc.name || "Tech Support") + '</span>' + '<span class="sidebar-support-title">' + escHtml(svc.name || "Tech Support") + '</span>' +
'<span class="sidebar-support-hint">Click for help</span>' + '<span class="sidebar-support-hint">Click for help</span>' +
@@ -88,7 +92,7 @@ function renderSidebarSupport(supportServices) {
var backupBtn = document.createElement("button"); var backupBtn = document.createElement("button");
backupBtn.className = "sidebar-support-btn"; backupBtn.className = "sidebar-support-btn";
backupBtn.innerHTML = backupBtn.innerHTML =
'<span class="sidebar-support-icon">💾</span>' + '<span class="sidebar-support-icon"><svg><use href="#g-box"/></svg></span>' +
'<span class="sidebar-support-text">' + '<span class="sidebar-support-text">' +
'<span class="sidebar-support-title">Manual Backup</span>' + '<span class="sidebar-support-title">Manual Backup</span>' +
'<span class="sidebar-support-hint">Back up to external drive</span>' + '<span class="sidebar-support-hint">Back up to external drive</span>' +
@@ -100,7 +104,7 @@ function renderSidebarSupport(supportServices) {
var securityBtn = document.createElement("button"); var securityBtn = document.createElement("button");
securityBtn.className = "sidebar-support-btn"; securityBtn.className = "sidebar-support-btn";
securityBtn.innerHTML = securityBtn.innerHTML =
'<span class="sidebar-support-icon">\uD83D\uDEE1</span>' + '<span class="sidebar-support-icon"><svg><use href="#g-shield-check"/></svg></span>' +
'<span class="sidebar-support-text">' + '<span class="sidebar-support-text">' +
'<span class="sidebar-support-title">Security</span>' + '<span class="sidebar-support-title">Security</span>' +
'<span class="sidebar-support-hint">Reset &amp; verify system</span>' + '<span class="sidebar-support-hint">Reset &amp; verify system</span>' +
@@ -113,7 +117,7 @@ function renderSidebarSupport(supportServices) {
var upgradeBtn = document.createElement("button"); var upgradeBtn = document.createElement("button");
upgradeBtn.className = "sidebar-support-btn"; upgradeBtn.className = "sidebar-support-btn";
upgradeBtn.innerHTML = upgradeBtn.innerHTML =
'<span class="sidebar-support-icon">🚀</span>' + '<span class="sidebar-support-icon"><svg><use href="#g-antenna"/></svg></span>' +
'<span class="sidebar-support-text">' + '<span class="sidebar-support-text">' +
'<span class="sidebar-support-title">Upgrade to Full Server</span>' + '<span class="sidebar-support-title">Upgrade to Full Server</span>' +
'<span class="sidebar-support-hint">Unlock all services</span>' + '<span class="sidebar-support-hint">Unlock all services</span>' +
@@ -237,6 +241,7 @@ function updateTiles(services) {
if (text) text.textContent = st; if (text) text.textContent = st;
} }
} }
if (typeof window.dashboardServicesUpdated === "function") window.dashboardServicesUpdated();
} }
// ── Service polling ─────────────────────────────────────────────── // ── Service polling ───────────────────────────────────────────────
@@ -248,6 +253,10 @@ async function refreshServices() {
var services = await apiFetch("/api/services"); var services = await apiFetch("/api/services");
if (_firstLoad) { buildTiles(services, _categoryLabels); _firstLoad = false; } if (_firstLoad) { buildTiles(services, _categoryLabels); _firstLoad = false; }
else { updateTiles(services); } else { updateTiles(services); }
// Service data has rendered — the dashboard is ready; lift the boot
// splash (no-op once lifted). Note: dashboardServicesUpdated may also
// fire from checkUpdates before this resolves, so the lift lives here.
if (typeof window.__liftAppSplash === "function") window.__liftAppSplash();
} catch (err) { console.warn("Failed to fetch services:", err); } } catch (err) { console.warn("Failed to fetch services:", err); }
} }
@@ -267,25 +276,33 @@ async function loadNetwork() {
// ── Update check ────────────────────────────────────────────────── // ── Update check ──────────────────────────────────────────────────
async function checkUpdates() { // Paint the sidebar Update button from the last known update state.
try { // Called after each check AND when the button is (re)built, so the hint
var data = await apiFetch("/api/updates/check"); // is correct regardless of which finishes first at startup.
var hasUpdates = !!data.available; function applyUpdateSidebarState(data) {
var updateStatus = data.status || "idle"; if (!data) return;
var sidebarUpdateBtn = document.getElementById("sidebar-btn-update"); var sidebarUpdateBtn = document.getElementById("sidebar-btn-update");
var sidebarUpdateHint = document.getElementById("sidebar-update-hint"); var sidebarUpdateHint = document.getElementById("sidebar-update-hint");
if (sidebarUpdateBtn) { if (!sidebarUpdateBtn) return;
if (updateStatus === "reboot_required") { var hasUpdates = !!data.available;
sidebarUpdateBtn.style.borderColor = "#e5a50a"; var updateStatus = data.status || "idle";
sidebarUpdateBtn.style.backgroundColor = "rgba(229, 165, 10, 0.10)"; 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 = "#f66151";
sidebarUpdateBtn.style.backgroundColor = "rgba(246, 97, 81, 0.10)";
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Update failed — click to retry";
} else if (updateStatus === "reboot_required") {
sidebarUpdateBtn.style.borderColor = "#e9b64a";
sidebarUpdateBtn.style.backgroundColor = "rgba(233, 182, 74, 0.10)";
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Restart required"; if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Restart required";
} else if (updateStatus === "running") { } else if (updateStatus === "running") {
sidebarUpdateBtn.style.borderColor = "#3584e4"; sidebarUpdateBtn.style.borderColor = "#78aeed";
sidebarUpdateBtn.style.backgroundColor = "rgba(53, 132, 228, 0.10)"; sidebarUpdateBtn.style.backgroundColor = "rgba(120, 174, 237, 0.10)";
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Update in progress…"; if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Update in progress…";
} else if (hasUpdates) { } else if (hasUpdates) {
sidebarUpdateBtn.style.borderColor = "#2ec27e"; sidebarUpdateBtn.style.borderColor = "#3ecf8e";
sidebarUpdateBtn.style.backgroundColor = "rgba(46, 194, 126, 0.08)"; sidebarUpdateBtn.style.backgroundColor = "rgba(62, 207, 142, 0.10)";
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Updates available!"; if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Updates available!";
} else { } else {
sidebarUpdateBtn.style.borderColor = ""; sidebarUpdateBtn.style.borderColor = "";
@@ -293,5 +310,14 @@ async function checkUpdates() {
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "System is up to date"; if (sidebarUpdateHint) sidebarUpdateHint.textContent = "System is up to date";
} }
} }
async function checkUpdates() {
try {
var data = await apiFetch("/api/updates/check");
window._lastUpdateCheck = data;
if (typeof markUpdateChecked === "function") markUpdateChecked();
applyUpdateSidebarState(data);
// The welcome dashboard's updates card reads this state
if (typeof window.dashboardServicesUpdated === "function") window.dashboardServicesUpdated();
} catch (_) {} } catch (_) {}
} }
+113 -15
View File
@@ -2,9 +2,88 @@
// ── Update modal ────────────────────────────────────────────────── // ── Update modal ──────────────────────────────────────────────────
// ── Update dialog presentation (pill, status line, last-checked) ──
var _updateLastCheckTs = 0;
function markUpdateChecked() {
_updateLastCheckTs = Date.now();
}
function _updateLastCheckedText() {
if (!$updLastChecked) return;
if (!_updateLastCheckTs) { $updLastChecked.textContent = "—"; return; }
var s = Math.floor((Date.now() - _updateLastCheckTs) / 1000);
if (s < 30) { $updLastChecked.textContent = "just now"; return; }
var m = Math.floor(s / 60);
if (m < 60) { $updLastChecked.textContent = m + (m === 1 ? " minute ago" : " minutes ago"); return; }
var h = Math.floor(m / 60);
$updLastChecked.textContent = h + (h === 1 ? " hour ago" : " hours ago");
}
function setUpdatePill(state) {
if (!$updPill) return;
var cls = "upd-pill";
var html;
if (state === "checking") {
cls += " st-loading"; html = '<span class="status-dot loading pulse"></span>Checking…';
} else if (state === "uptodate") {
cls += " st-active"; html = '<span class="status-dot active"></span>Up to date';
} else if (state === "complete") {
cls += " st-active"; html = '<span class="status-dot active"></span>Update complete';
} else if (state === "reboot") {
cls += " st-needs-attention"; html = '<span class="status-dot needs-attention pulse"></span>Restart required';
} else if (state === "failed") {
cls += " st-failed"; html = '<span class="status-dot failed"></span>Update failed';
} else if (state === "unavailable") {
cls += " st-needs-attention"; html = '<span class="status-dot needs-attention pulse"></span>Status unknown';
} else {
cls += " st-loading"; html = '<span class="status-dot loading pulse"></span>Updating…';
}
$updPill.className = cls;
$updPill.innerHTML = html;
}
// Single place that reflects update state in the dialog: status message
// text + color, header pill, and the Close / Check again availability.
function _setUpdateStatus(text) {
if ($modalStatus) $modalStatus.textContent = text;
var state;
if (text.charAt(0) === "✗") state = "failed";
else if (text.indexOf("restart required") !== -1) state = "reboot";
else if (text.charAt(0) === "✓") state = (text.indexOf("already up to date") !== -1) ? "uptodate" : "complete";
else if (text.indexOf("unavailable") !== -1) state = "unavailable";
else if (text.indexOf("Checking") === 0) state = "checking";
else state = "updating";
if ($modalStatus) {
var msg = "update-status-msg";
if (state === "failed") $modalStatus.className = msg + " st-err";
else if (state === "reboot" || state === "unavailable") $modalStatus.className = msg + " st-warn";
else if (state === "uptodate" || state === "complete") $modalStatus.className = msg + " st-ok";
else $modalStatus.className = msg;
// In the up-to-date state the green console line already says it —
// the mockup shows it exactly once.
$modalStatus.style.display = (state === "uptodate") ? "none" : "";
}
setUpdatePill(state);
var interactive = (state !== "updating");
if ($updateCloseBtn) $updateCloseBtn.disabled = !interactive;
if ($btnCheckAgain) {
$btnCheckAgain.disabled = (state === "checking" || state === "updating");
$btnCheckAgain.style.display = interactive ? "inline-flex" : "none";
}
_updateLastCheckedText();
}
async function openUpdateModal() { async function openUpdateModal() {
if (!$modal) return; if (!$modal) return;
// Open immediately in a checking state; the status/check requests below
// fill in the real result (mockup: auto-check on open).
$modal.classList.add("open");
if ($modalLog) $modalLog.innerHTML = '<span class="dim">Checking for updates…</span>';
_setUpdateStatus("Checking for updates…");
// Reattach before checking for new updates. This makes a browser reload, // Reattach before checking for new updates. This makes a browser reload,
// RDP reconnect, or suspended tab recover the authoritative systemd-backed // RDP reconnect, or suspended tab recover the authoritative systemd-backed
// state instead of starting over or claiming the system is merely up to date. // state instead of starting over or claiming the system is merely up to date.
@@ -14,7 +93,10 @@ async function openUpdateModal() {
{ cache: "no-store" }, { cache: "no-store" },
STATUS_POLL_FETCH_TIMEOUT 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); showExistingUpdate(current);
return; return;
} }
@@ -28,6 +110,7 @@ async function openUpdateModal() {
STATUS_POLL_FETCH_TIMEOUT STATUS_POLL_FETCH_TIMEOUT
) )
.then(function(data) { .then(function(data) {
markUpdateChecked();
if (!data.available) { if (!data.available) {
stopUpdatePoll(); stopUpdatePoll();
_updateLog = ""; _updateLog = "";
@@ -35,12 +118,13 @@ async function openUpdateModal() {
_updateVisibleLogChars = 0; _updateVisibleLogChars = 0;
_updateFinished = true; _updateFinished = true;
_updateStatusUnavailable = false; _updateStatusUnavailable = false;
if ($modalLog) $modalLog.textContent = ""; if ($modalLog) $modalLog.innerHTML = '<span class="ok">✓ System is already up to date</span>';
if ($modalStatus) $modalStatus.textContent = "✓ System is already up to date"; _setUpdateStatus("✓ System is already up to date");
if ($modalSpinner) $modalSpinner.classList.remove("spinning"); if ($modalSpinner) $modalSpinner.classList.remove("spinning");
if ($btnReboot) $btnReboot.style.display = "none"; if ($btnReboot) $btnReboot.style.display = "none";
if ($btnSave) $btnSave.style.display = "none"; if ($btnSave) $btnSave.style.display = "none";
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none"; if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
if ($btnRetryRun) $btnRetryRun.style.display = "none";
if ($btnCloseModal) $btnCloseModal.disabled = false; if ($btnCloseModal) $btnCloseModal.disabled = false;
$modal.classList.add("open"); $modal.classList.add("open");
return; return;
@@ -64,11 +148,12 @@ function prepareUpdateModal() {
_updateStatusUnavailable = false; _updateStatusUnavailable = false;
_updatePollFailures = 0; _updatePollFailures = 0;
if ($modalLog) $modalLog.textContent = ""; if ($modalLog) $modalLog.textContent = "";
if ($modalStatus) $modalStatus.textContent = "Starting update…"; _setUpdateStatus("Starting update…");
if ($modalSpinner) $modalSpinner.classList.add("spinning"); if ($modalSpinner) $modalSpinner.classList.add("spinning");
if ($btnReboot) $btnReboot.style.display = "none"; if ($btnReboot) $btnReboot.style.display = "none";
if ($btnSave) $btnSave.style.display = "none"; if ($btnSave) $btnSave.style.display = "none";
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none"; if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
if ($btnRetryRun) $btnRetryRun.style.display = "none";
if ($btnCloseModal) $btnCloseModal.disabled = true; if ($btnCloseModal) $btnCloseModal.disabled = true;
$modal.classList.add("open"); $modal.classList.add("open");
} }
@@ -84,7 +169,7 @@ function showExistingUpdate(data) {
_updateLogOffset = Number(data.offset) || 0; _updateLogOffset = Number(data.offset) || 0;
if (data.running) { if (data.running) {
if ($modalStatus) $modalStatus.textContent = "Updating…"; _setUpdateStatus("Updating…");
startUpdatePoll(); startUpdatePoll();
return; return;
} }
@@ -149,17 +234,19 @@ function startUpdate() {
) )
.then(function(data) { .then(function(data) {
if (data.status === "no_updates") { if (data.status === "no_updates") {
if ($modalStatus) $modalStatus.textContent = "✓ System is already up to date"; if ($modalLog) $modalLog.innerHTML = '<span class="ok">✓ System is already up to date</span>';
_setUpdateStatus("✓ System is already up to date");
if ($modalSpinner) $modalSpinner.classList.remove("spinning"); if ($modalSpinner) $modalSpinner.classList.remove("spinning");
if ($btnReboot) $btnReboot.style.display = "none"; if ($btnReboot) $btnReboot.style.display = "none";
if ($btnSave) $btnSave.style.display = "none"; if ($btnSave) $btnSave.style.display = "none";
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none"; if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
if ($btnRetryRun) $btnRetryRun.style.display = "none";
if ($btnCloseModal) $btnCloseModal.disabled = false; if ($btnCloseModal) $btnCloseModal.disabled = false;
_updateFinished = true; _updateFinished = true;
return; return;
} }
if (data.status === "already_running") appendLog("[Update already in progress, attaching…]\n\n"); if (data.status === "already_running") appendLog("[Update already in progress, attaching…]\n\n");
if ($modalStatus) $modalStatus.textContent = "Updating…"; _setUpdateStatus("Updating…");
startUpdatePoll(); startUpdatePoll();
}) })
.catch(function(err) { .catch(function(err) {
@@ -229,7 +316,7 @@ async function pollUpdateStatus() {
return; return;
} }
appendLog("[Update status reconnected]\n"); appendLog("[Update status reconnected]\n");
if ($modalStatus) $modalStatus.textContent = "Updating…"; _setUpdateStatus("Updating…");
} }
if (data.log) appendLog(data.log); if (data.log) appendLog(data.log);
_updateLogOffset = data.offset; _updateLogOffset = data.offset;
@@ -252,7 +339,7 @@ async function pollUpdateStatus() {
if (!_serverWasDown) { if (!_serverWasDown) {
_serverWasDown = true; _serverWasDown = true;
appendLog("\n[Update status connection interrupted — retrying…]\n"); appendLog("\n[Update status connection interrupted — retrying…]\n");
if ($modalStatus) $modalStatus.textContent = "Reconnecting to update…"; _setUpdateStatus("Reconnecting to update…");
} }
} finally { } finally {
_updatePollInFlight = false; _updatePollInFlight = false;
@@ -264,7 +351,7 @@ function showUpdateStatusUnavailable() {
_updateStatusUnavailable = true; _updateStatusUnavailable = true;
stopUpdatePoll(); stopUpdatePoll();
if ($modalSpinner) $modalSpinner.classList.remove("spinning"); if ($modalSpinner) $modalSpinner.classList.remove("spinning");
if ($modalStatus) $modalStatus.textContent = "Update status unavailable — update may still be running"; _setUpdateStatus("Update status unavailable — update may still be running");
appendLog("\n[The Hub could not confirm update status. The background update was not stopped. Select Retry Status after reconnecting.]\n"); appendLog("\n[The Hub could not confirm update status. The background update was not stopped. Select Retry Status after reconnecting.]\n");
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "inline-flex"; if ($btnRetryUpdate) $btnRetryUpdate.style.display = "inline-flex";
if ($btnCloseModal) $btnCloseModal.disabled = false; if ($btnCloseModal) $btnCloseModal.disabled = false;
@@ -277,12 +364,22 @@ function retryUpdateStatus() {
_updatePollFailures = 0; _updatePollFailures = 0;
_serverWasDown = true; _serverWasDown = true;
if ($modalSpinner) $modalSpinner.classList.add("spinning"); if ($modalSpinner) $modalSpinner.classList.add("spinning");
if ($modalStatus) $modalStatus.textContent = "Reconnecting to update…"; _setUpdateStatus("Reconnecting to update…");
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none"; if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
if ($btnCloseModal) $btnCloseModal.disabled = true; if ($btnCloseModal) $btnCloseModal.disabled = true;
startUpdatePoll(); 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() { function resumeUpdateStatusAfterInterruption() {
if (!$modal || !$modal.classList.contains("open")) return; if (!$modal || !$modal.classList.contains("open")) return;
if (_updateStatusUnavailable) { if (_updateStatusUnavailable) {
@@ -298,15 +395,16 @@ function onUpdateDone(result) {
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none"; if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
if ($btnCloseModal) $btnCloseModal.disabled = false; if ($btnCloseModal) $btnCloseModal.disabled = false;
if (result === true) { if (result === true) {
if ($modalStatus) $modalStatus.textContent = "✓ Update complete"; _setUpdateStatus("✓ Update complete");
if ($btnReboot) $btnReboot.style.display = "inline-flex"; if ($btnReboot) $btnReboot.style.display = "inline-flex";
} else if (result === "reboot_required") { } else if (result === "reboot_required") {
if ($modalStatus) $modalStatus.textContent = "✓ Update complete — restart required"; _setUpdateStatus("✓ Update complete — restart required");
if ($btnReboot) $btnReboot.style.display = "inline-flex"; if ($btnReboot) $btnReboot.style.display = "inline-flex";
} else { } else {
if ($modalStatus) $modalStatus.textContent = "✗ Update failed"; _setUpdateStatus("✗ 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 ($btnSave) $btnSave.style.display = "inline-flex";
if ($btnReboot) $btnReboot.style.display = "inline-flex"; if ($btnReboot) $btnReboot.style.display = "none";
} }
} }
+318 -40
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sovran_SystemsOS Hub</title> <title>Sovran_SystemsOS — The Hub</title>
<link rel="stylesheet" href="/static/css/base.css?v={{ asset_version }}" /> <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/buttons.css?v={{ asset_version }}" />
<link rel="stylesheet" href="/static/css/header.css?v={{ asset_version }}" /> <link rel="stylesheet" href="/static/css/header.css?v={{ asset_version }}" />
@@ -18,65 +18,324 @@
</head> </head>
<body> <body>
<!-- Header bar --> <svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
<header class="header-bar">
<img src="/static/sovran-hub-icon.svg" alt="Sovran Hub" class="header-logo" />
<div class="title-group"> <!-- service glyphs -->
<span class="title">Sovran_SystemsOS Hub</span> <!-- ═══ Real service logos — extracted verbatim from app/icons/*.svg ═══
(internal ids namespaced per icon to avoid collisions) -->
<!-- OS Version Badge — styled identically to the version badge in service modals --> <!-- symbolic helpers (nav + modal internals) -->
<span class="os-version-badge" id="os-version-badge" title="Sovran_SystemsOS Version">v{{ sovran_version }}</span> <!-- Bitcoin nav icon: the OFFICIAL logo silhouette (paths taken verbatim
from app/icons/bitcoin-core.svg) rendered monochrome in currentColor
so it matches the symbolic sidebar set -->
<symbol id="g-btc-sym" viewBox="-34 -34 580 580">
<path fill="currentColor" d="M317.871 7.656c-137.12-34.192-276.024 49.28-310.2 186.44-34.208 137.136 49.256 276.048 186.36 310.24 137.16 34.199 276.063-49.265 310.256-186.408 34.192-137.152-49.264-276.08-186.416-310.272m50.936 211.872c-3.688 24.936-17.512 37.008-35.864 41.24 25.2 13.12 38.024 33.239 25.809 68.12-15.16 43.319-51.176 46.976-99.072 37.912l-11.624 46.584-28.088-7 11.472-45.96a1076 1076 0 0 1-22.384-5.809l-11.512 46.177-28.056-7 11.624-46.673c-6.561-1.68-13.225-3.464-20.024-5.168l-36.552-9.111 13.943-32.152s20.696 5.504 20.416 5.096c7.952 1.969 11.48-3.216 12.872-6.672l18.368-73.64.048-.2 13.104-52.568c.344-5.968-1.712-13.496-13.088-16.336.439-.296-20.4-5.072-20.4-5.072l7.472-30 38.736 9.673-.032.144c5.824 1.448 11.824 2.824 17.937 4.216L245.423 89.2l28.072 7-11.28 45.224c7.536 1.721 15.12 3.456 22.504 5.297l11.2-44.929 28.088 7-11.504 46.145c35.464 12.215 61.401 30.527 56.304 64.591"/>
<path fill="currentColor" d="m254.647 174.6-13.983 56.08c15.855 3.951 64.735 20.071 72.656-11.656 8.248-33.096-42.817-40.472-58.673-44.424"/>
<path fill="currentColor" d="m233.608 258.984-15.425 61.832c19.04 4.729 77.769 23.584 86.448-11.296 9.072-36.376-51.984-45.784-71.023-50.536"/>
</symbol>
<symbol id="g-chat" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 5.5v7a2.5 2.5 0 0 1-2.5 2.5H10l-4 3.2c-.6.5-1 .3-1-.5V5.5A2.5 2.5 0 0 1 7.5 3h10A2.5 2.5 0 0 1 20 5.5z"/>
<circle cx="8.7" cy="9.2" r="0.5" fill="currentColor"/><circle cx="12" cy="9.2" r="0.5" fill="currentColor"/><circle cx="15.3" cy="9.2" r="0.5" fill="currentColor"/>
</g>
</symbol>
<symbol id="g-antenna" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round">
<path d="M5.4 9.9a9.3 9.3 0 0 1 13.2 0"/>
<path d="M8.1 12.9a5.6 5.6 0 0 1 7.8 0"/>
<circle cx="12" cy="16.6" r="1.5" fill="currentColor" stroke="none"/>
<path d="M12 16.6V21"/>
</g>
</symbol>
<symbol id="g-lock" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<rect x="5.5" y="10.5" width="13" height="9.5" rx="2.6"/>
<path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5"/>
<circle cx="12" cy="15" r="1.3" fill="currentColor" stroke="none"/>
</g>
</symbol>
<!-- nav + ui symbolic icons -->
<symbol id="g-home" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 11.2L12 4.5l8 6.7"/>
<path d="M6 10.5v8a1.2 1.2 0 0 0 1.2 1.2h9.6a1.2 1.2 0 0 0 1.2-1.2v-8"/>
<path d="M10 19.7v-5.4h4v5.4"/>
</g>
</symbol>
<symbol id="g-grid" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round">
<rect x="3.5" y="3.5" width="7.2" height="7.2" rx="2"/><rect x="13.3" y="3.5" width="7.2" height="7.2" rx="2"/>
<rect x="3.5" y="13.3" width="7.2" height="7.2" rx="2"/><rect x="13.3" y="13.3" width="7.2" height="7.2" rx="2"/>
</g>
</symbol>
<symbol id="g-server" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round">
<rect x="3.5" y="3.5" width="17" height="7.4" rx="2"/><rect x="3.5" y="13.1" width="17" height="7.4" rx="2"/>
<circle cx="7.2" cy="7.2" r="0.6" fill="currentColor"/><circle cx="7.2" cy="16.8" r="0.6" fill="currentColor"/>
</g>
</symbol>
<symbol id="g-dots" viewBox="0 0 24 24">
<g fill="currentColor">
<circle cx="5" cy="5" r="1.7"/><circle cx="12" cy="5" r="1.7"/><circle cx="19" cy="5" r="1.7"/>
<circle cx="5" cy="12" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="19" cy="12" r="1.7"/>
<circle cx="5" cy="19" r="1.7"/><circle cx="12" cy="19" r="1.7"/><circle cx="19" cy="19" r="1.7"/>
</g>
</symbol>
<symbol id="g-refresh" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M20 12a8 8 0 1 1-2.9-6.2"/>
<path d="M20.6 2.6l-.5 4.9-4.6-1.7z" fill="currentColor" stroke="none"/>
</g>
</symbol>
<!-- Update System: down arrow into a tray (get / install updates) -->
<symbol id="g-update" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 4v10.5"/>
<path d="M7.8 10.3L12 14.5l4.2-4.2"/>
<path d="M4.5 16.5v2a2.5 2.5 0 0 0 2.5 2.5h10a2.5 2.5 0 0 0 2.5-2.5v-2"/>
</g>
</symbol>
<symbol id="g-box" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<rect x="3.5" y="4" width="17" height="4.4" rx="1.6"/>
<path d="M5.2 8.4v9a2.4 2.4 0 0 0 2.4 2.4h8.8a2.4 2.4 0 0 0 2.4-2.4v-9"/>
<path d="M10 12.5h4"/>
</g>
</symbol>
<symbol id="g-shield-check" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2.5 4.5 5.5v6c0 4.6 3.1 8 7.5 9.9 4.4-1.9 7.5-5.3 7.5-9.9v-6z"/>
<path d="M8.6 12l2.4 2.4 4.4-4.6"/>
</g>
</symbol>
<!-- Systems Operational: activity pulse (system health) — distinct from
the Security shield above -->
<symbol id="g-pulse" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M3 12h4l3-7 4 14 3-7h4"/>
</symbol>
<symbol id="g-lifebuoy" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<circle cx="12" cy="12" r="8.6"/><circle cx="12" cy="12" r="3.6"/>
<path d="M6 6l3.2 3.2M18 6l-3.2 3.2M6 18l3.2-3.2M18 18l-3.2-3.2"/>
</g>
</symbol>
<symbol id="g-power" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M7.2 6.1a7 7 0 1 0 9.6 0"/>
<path d="M12 2.8v8.4"/>
</g>
</symbol>
<symbol id="g-logout" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 4h4.5A1.5 1.5 0 0 1 20 5.5v13a1.5 1.5 0 0 1-1.5 1.5H14"/>
<path d="M4 12h10M10.5 8.5 14 12l-3.5 3.5"/>
</g>
</symbol>
<symbol id="g-search" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="11" cy="11" r="6.5"/>
<path d="M15.8 15.8 21 21"/>
</g>
</symbol>
<symbol id="g-copy" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="11" height="11" rx="2.2"/>
<path d="M5.5 15H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v.5"/>
</g>
</symbol>
<symbol id="g-check" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.5l5 5 10-11"/></symbol>
<symbol id="g-chev" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" d="M9.5 5.5 16 12l-6.5 6.5"/></symbol>
<symbol id="g-x" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" d="M6 6l12 12M18 6 6 18"/></symbol>
<symbol id="g-wifi" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round">
<path d="M4.5 10.2a11 11 0 0 1 15 0"/>
<path d="M7.3 13.3a7 7 0 0 1 9.4 0"/>
<circle cx="12" cy="17" r="1.4" fill="currentColor" stroke="none"/>
</g>
</symbol>
<symbol id="g-key" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<circle cx="7.5" cy="15.5" r="4"/>
<path d="M10.4 12.6 20 3M16.2 6.8l2.4 2.4M18.8 4.2l2.2 2.2"/>
</g>
</symbol>
<symbol id="g-alert" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3.6 22.2 20.4H1.8z"/>
<path d="M12 9.8v4.6"/><circle cx="12" cy="17.2" r="0.6" fill="currentColor" stroke="none"/>
</g>
</symbol>
</svg>
<!-- ═══ BOOT SPLASH ═══ covers the shell while the first data loads;
dashboard.js lifts it once the welcome cards are rendered -->
<div id="app-splash" role="status" aria-live="polite">
<div class="splash-card">
<div class="splash-ring">
<img src="/static/sovran-hub-icon.svg" alt="" width="64" height="64" />
<span class="splash-ring-arc" aria-hidden="true"></span>
</div>
<div class="splash-title">Starting The Hub</div>
<div class="splash-sub" id="splash-sub">Gathering your services…</div>
</div>
</div>
<script>
(function () {
var splash = document.getElementById("app-splash");
var sub = document.getElementById("splash-sub");
var lifted = false;
function lift() {
if (lifted || !splash) return;
lifted = true;
splash.classList.add("done");
setTimeout(function () {
if (splash && splash.parentNode) splash.parentNode.removeChild(splash);
}, 500);
}
window.__liftAppSplash = lift;
// Reassure the user if the backend is slow (e.g. right after a reboot)
setTimeout(function () {
if (!lifted && sub) sub.textContent = "Still starting\u2026 this can take a moment after a reboot.";
}, 8000);
// Never trap the user behind the splash
setTimeout(lift, 25000);
})();
</script>
<div class="app">
<!-- ═══ SIDEBAR ═══ -->
<aside class="sidebar" id="sidebar">
<div class="brand">
<img src="/static/sovran-hub-icon.svg" class="brand-logo" alt="The Hub" />
<div class="brand-text">
<div class="brand-title">The <em>Hub</em></div>
<div class="brand-sub">Sovran_SystemsOS v{{ sovran_version }}</div>
</div>
</div> </div>
<div class="header-buttons"> <nav id="sidebar-nav" aria-label="Service categories"></nav>
<span class="role-badge" id="role-badge">Loading…</span>
<div class="nav-label">System</div>
<div id="sidebar-support"></div>
<!-- Feature Manager + Preferences (features.js) -->
<div id="sidebar-features"></div>
<div class="sidebar-spacer"></div>
</aside>
<!-- ═══ MAIN ═══ -->
<div class="main">
<header class="topbar">
<div class="page-title" id="page-title">Dashboard</div>
<div class="search-box">
<svg><use href="#g-search"/></svg>
<input id="search-input" type="text" placeholder="Search services…" autocomplete="off" />
</div>
<div class="top-actions">
<button class="btn btn-header-reboot" id="btn-header-reboot" title="Restart the entire computer">Reboot</button> <button class="btn btn-header-reboot" id="btn-header-reboot" title="Restart the entire computer">Reboot</button>
<button class="btn btn-logout" id="btn-logout" title="Sign out">Sign Out</button> <button class="btn btn-logout" id="btn-logout" title="Sign out">Sign Out</button>
</div> </div>
</header> </header>
<!-- IP bar --> <main class="content" id="content">
<div class="ip-bar"> <!-- ═══ WELCOME DASHBOARD (default view) ═══ -->
<span> <section class="welcome" id="welcome-view">
<span class="ip-label">Internal IP:</span> <div class="welcome-bg" aria-hidden="true">
<span class="ip-value" id="ip-internal">…</span> <div class="orb orb-a"></div>
</span> <div class="orb orb-b"></div>
<span class="ip-separator">|</span> <div class="orb orb-c"></div>
<span>
<span class="ip-label">External IP:</span>
<span class="ip-value" id="ip-external">…</span>
</span>
</div> </div>
<div class="welcome-inner">
<div class="welcome-greeting" id="welcome-greeting">Hello</div>
<h1 class="welcome-title">Welcome to Your <em>Sovereign</em> Digital &amp; Financial Life</h1>
<div class="welcome-meta">Sovran_SystemsOS v{{ sovran_version }}<span class="welcome-meta-sep">·</span><span id="welcome-role"></span></div>
<div class="welcome-cards" id="welcome-cards">
<div class="welcome-dyn" id="wc-systems"></div>
<div class="widget w-network" id="w-network">
<div class="widget-chip chip-green"><svg><use href="#g-wifi"/></svg></div>
<div class="w-body">
<h3>Network</h3>
<div class="net-row"><span class="net-k">LAN</span><span class="ip-value" id="ip-internal">…</span></div>
<div class="net-row"><span class="net-k">WAN</span><span class="ip-value" id="ip-external">…</span></div>
<div class="sub">sovransystemsos.local</div>
</div>
</div>
<div class="welcome-dyn" id="wc-more"></div>
</div>
<button class="btn btn-ghost welcome-browse" id="welcome-browse-btn">Browse all services →</button>
</div>
</section>
<!-- Service tiles --> <!-- ═══ SERVICES GRID ═══ -->
<main class="main-content"> <div id="tiles-area" style="display:none">
<aside class="sidebar" id="sidebar">
<div id="sidebar-support"></div>
<div id="sidebar-features"></div>
</aside>
<div id="tiles-area">
<div class="dashboard-loading" role="status" aria-live="polite"> <div class="dashboard-loading" role="status" aria-live="polite">
<span class="dashboard-loading-spinner" aria-hidden="true"></span> <span class="dashboard-loading-spinner" aria-hidden="true"></span>
<span>Loading service status…</span> <span>Loading service status…</span>
</div> </div>
</div> </div>
</main> </main>
</div>
</div>
<!-- ═══ SYSTEMS OPERATIONAL MODAL (dashboard.js) ═══ -->
<div class="modal-overlay" id="systems-modal" role="dialog" aria-modal="true" aria-labelledby="systems-modal-title">
<div class="creds-dialog">
<div class="creds-header">
<span class="creds-title" id="systems-modal-title">
<span class="widget-chip chip-green" style="width:54px;height:54px;border-radius:16px"><svg style="width:27px;height:27px"><use href="#g-pulse"/></svg></span>
<span>Systems Operational</span>
</span>
<button class="creds-close-btn" id="systems-close-btn" title="Close">✕</button>
</div>
<div class="creds-body" id="systems-body">
<p class="creds-loading">Loading…</p>
</div>
<!-- Auto-launch preference (features.js) — moved here from the sidebar -->
<div class="sysprefs" id="autolaunch-slot"></div>
</div>
</div>
<!-- Update modal --> <!-- Update modal -->
<div class="modal-overlay" id="update-modal" role="dialog" aria-modal="true" aria-labelledby="modal-title-text"> <div class="modal-overlay" id="update-modal" role="dialog" aria-modal="true" aria-labelledby="modal-title-text">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-header"> <div class="upd-header">
<span class="modal-title" id="modal-title-text">Sovran_SystemsOS Update</span> <span class="widget-chip chip-sovran upd-chip"><svg class="upd-chip-svg"><use href="#g-update"/></svg></span>
<div class="modal-spinner" id="modal-spinner"></div> <span class="upd-title-wrap">
<span class="modal-status" id="modal-status">Updating…</span> <span class="upd-title" id="modal-title-text">Sovran_SystemsOS Update</span>
<span class="upd-sub">
<span class="ver-chip">Sovran_SystemsOS v{{ sovran_version }}</span>
<span class="upd-pill" id="upd-pill"></span>
<span class="modal-spinner" id="modal-spinner"></span>
</span>
</span>
<button class="creds-close-btn" id="update-close-btn" title="Close">✕</button>
</div> </div>
<div class="modal-body-scroll">
<div class="sysmodal-card">
<div class="sysmodal-card-title"><svg><use href="#g-server"/></svg>System Details</div>
<div class="sysstep"><div class="sysnum">1</div><div class="sysstep-x"><div class="sysstep-t">Current version</div><div class="sysval"><span class="sysval-text">{{ sovran_version }}</span></div></div></div>
<div class="sysstep"><div class="sysnum">2</div><div class="sysstep-x"><div class="sysstep-t">Release channel</div><div class="sysval"><span class="sysval-text">stable</span></div></div></div>
<div class="sysstep"><div class="sysnum">3</div><div class="sysstep-x"><div class="sysstep-t">Last checked</div><div class="sysval"><span class="sysval-text" id="upd-last-checked">—</span></div></div></div>
</div>
<div class="update-status-msg" id="modal-status">Checking for updates…</div>
<div class="modal-log" id="modal-log" aria-live="polite"></div> <div class="modal-log" id="modal-log" aria-live="polite"></div>
</div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-save" id="btn-save-report" style="display:none">Save Error Report</button> <button class="btn btn-save" id="btn-save-report" style="display:none">Save Error Report</button>
<button class="btn btn-save" id="btn-retry-update-status" style="display:none">Retry Status</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-reboot" id="btn-reboot" style="display:none">Restart Entire System</button>
<span class="modal-footer-spacer"></span>
<button class="btn btn-close-modal" id="btn-close-modal" disabled>Close</button> <button class="btn btn-close-modal" id="btn-close-modal" disabled>Close</button>
<button class="btn btn-primary" id="btn-check-again"><svg><use href="#g-refresh"/></svg>Check again</button>
</div> </div>
</div> </div>
</div> </div>
@@ -98,7 +357,10 @@
<div class="modal-overlay" id="support-modal" role="dialog" aria-modal="true" aria-labelledby="support-modal-title"> <div class="modal-overlay" id="support-modal" role="dialog" aria-modal="true" aria-labelledby="support-modal-title">
<div class="creds-dialog"> <div class="creds-dialog">
<div class="creds-header"> <div class="creds-header">
<span class="creds-title" id="support-modal-title">Tech Support</span> <span class="creds-title">
<span class="widget-chip chip-neutral" id="support-modal-chip" style="width:54px;height:54px;border-radius:16px"><svg style="width:27px;height:27px"><use href="#g-lifebuoy"/></svg></span>
<span id="support-modal-title">Tech Support</span>
</span>
<button class="creds-close-btn" id="support-close-btn" title="Close">✕</button> <button class="creds-close-btn" id="support-close-btn" title="Close">✕</button>
</div> </div>
<div class="creds-body" id="support-body"> <div class="creds-body" id="support-body">
@@ -170,15 +432,26 @@
<!-- Rebuild Modal --> <!-- Rebuild Modal -->
<div class="modal-overlay" id="rebuild-modal" role="dialog" aria-modal="true" aria-labelledby="rebuild-modal-title"> <div class="modal-overlay" id="rebuild-modal" role="dialog" aria-modal="true" aria-labelledby="rebuild-modal-title">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-header"> <div class="upd-header">
<span class="modal-title" id="rebuild-modal-title">Sovran_SystemsOS Rebuild</span> <span class="widget-chip chip-sovran upd-chip"><svg class="upd-chip-svg"><use href="#g-server"/></svg></span>
<div class="modal-spinner" id="rebuild-spinner"></div> <span class="upd-title-wrap">
<span class="modal-status" id="rebuild-status">Rebuilding…</span> <span class="upd-title" id="rebuild-modal-title">Sovran_SystemsOS Rebuild</span>
<span class="upd-sub">
<span class="ver-chip">Sovran_SystemsOS v{{ sovran_version }}</span>
<span class="upd-pill" id="rebuild-pill"></span>
<span class="modal-spinner" id="rebuild-spinner"></span>
</span>
</span>
<button class="creds-close-btn" id="rebuild-close-hdr" title="Close">✕</button>
</div>
<div class="modal-body-scroll">
<div class="update-status-msg" id="rebuild-status">Rebuilding…</div>
<div class="modal-log" id="rebuild-log" aria-live="polite" style="display:none"></div>
</div> </div>
<div class="modal-log" id="rebuild-log" aria-live="polite"></div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-save" id="rebuild-save-report" style="display:none">Save Error Report</button> <button class="btn btn-save" id="rebuild-save-report" style="display:none">Save Error Report</button>
<button class="btn btn-reboot" id="rebuild-reboot-btn" style="display:none">Restart Entire System</button> <button class="btn btn-reboot" id="rebuild-reboot-btn" style="display:none">Restart Entire System</button>
<span class="modal-footer-spacer"></span>
<button class="btn btn-close-modal" id="rebuild-close-btn" disabled>Close</button> <button class="btn btn-close-modal" id="rebuild-close-btn" disabled>Close</button>
</div> </div>
</div> </div>
@@ -203,6 +476,10 @@
<li>To make your services available outside your home, complete one router task: forward ports <strong>80 and 443</strong> to this computer</li> <li>To make your services available outside your home, complete one router task: forward ports <strong>80 and 443</strong> to this computer</li>
</ul> </ul>
</div> </div>
<p class="support-desc">
⚠️ <strong>Heads-up:</strong> your domain points at your home internet connection,
so anyone can look up your home IP address. Domain privacy does not hide it.
</p>
<p class="support-desc"> <p class="support-desc">
The Hub guides you through every step. The Hub guides you through every step.
</p> </p>
@@ -255,7 +532,7 @@
<div class="reboot-overlay" id="reboot-overlay"> <div class="reboot-overlay" id="reboot-overlay">
<!-- Normal restarting card --> <!-- Normal restarting card -->
<div class="reboot-card" id="reboot-main-card"> <div class="reboot-card" id="reboot-main-card">
<div class="reboot-icon" aria-hidden="true">↻</div> <div class="reboot-icon" aria-hidden="true"></div>
<h2 class="reboot-title">Restarting Entire System</h2> <h2 class="reboot-title">Restarting Entire System</h2>
<p class="reboot-message"> <p class="reboot-message">
The entire computer is restarting, including the desktop and all hosted services.<br /> The entire computer is restarting, including the desktop and all hosted services.<br />
@@ -270,7 +547,7 @@
</div> </div>
<!-- Error card (shown if restart request fails definitively) --> <!-- Error card (shown if restart request fails definitively) -->
<div class="reboot-card" id="reboot-error-card" style="display:none"> <div class="reboot-card" id="reboot-error-card" style="display:none">
<div class="reboot-icon" aria-hidden="true">⚠</div> <div class="reboot-icon" aria-hidden="true"></div>
<h2 class="reboot-title">Restart could not be started</h2> <h2 class="reboot-title">Restart could not be started</h2>
<p class="reboot-message"> <p class="reboot-message">
The computer did not begin restarting. No services were intentionally stopped. Please try again. The computer did not begin restarting. No services were intentionally stopped. Please try again.
@@ -315,6 +592,7 @@
<script src="/static/js/rebuild.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/features.js?v={{ asset_version }}"></script>
<script src="/static/js/security.js?v={{ asset_version }}"></script> <script src="/static/js/security.js?v={{ asset_version }}"></script>
<script src="/static/js/dashboard.js?v={{ asset_version }}"></script>
<script src="/static/js/events.js?v={{ asset_version }}"></script> <script src="/static/js/events.js?v={{ asset_version }}"></script>
</body> </body>
</html> </html>
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sovran Hub — Login</title> <title>The Hub — Login</title>
<link rel="stylesheet" href="/static/css/base.css?v={{ asset_version }}" /> <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/buttons.css?v={{ asset_version }}" />
</head> </head>
@@ -11,8 +11,9 @@
<div class="login-wrapper"> <div class="login-wrapper">
<div class="login-card"> <div class="login-card">
<div class="login-header"> <div class="login-header">
<img src="/static/sovran-hub-icon.svg" alt="Sovran Hub" class="login-logo" /> <img src="/static/sovran-hub-icon.svg" alt="The Hub" class="login-logo" />
<div class="login-title">Sovran Hub</div> <div class="login-title">The <em>Hub</em></div>
<div class="login-sub">Sovran_SystemsOS</div>
</div> </div>
<form class="login-form" id="login-form" onsubmit="return false;"> <form class="login-form" id="login-form" onsubmit="return false;">
@@ -50,10 +50,10 @@
<p class="onboarding-body-text" style="text-align:center; margin-bottom:4px;"> <p class="onboarding-body-text" style="text-align:center; margin-bottom:4px;">
Your new login password is: Your new login password is:
</p> </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;"> <div id="migration-password-value" style="font-family:monospace; font-size:1.35rem; font-weight:700; color:var(--text-primary); background:rgba(62, 207, 142, 0.10); border:1.5px solid rgba(62, 207, 142, 0.35); border-radius:8px; padding:14px 24px; letter-spacing:0.04em; text-align:center; word-break:break-all; margin-bottom:8px;">
&nbsp; &nbsp;
</div> </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;"> <div style="padding:10px 14px; background-color:rgba(233, 182, 74, 0.1); border:1px solid rgba(233, 182, 74, 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. ⚠ Write this password down! You will need it to log in next time. This is also your Sovran Hub login password.
</div> </div>
<div id="migration-password-status" class="onboarding-save-status" style="margin-top:8px;"></div> <div id="migration-password-status" class="onboarding-save-status" style="margin-top:8px;"></div>
+1 -1
View File
@@ -5,7 +5,7 @@
"bitcoind.service": "27.1.0", "bitcoind.service": "27.1.0",
"electrs.service": "0.10.6", "electrs.service": "0.10.6",
"lnd.service": "0.18.0", "lnd.service": "0.18.0",
"rtl.service": "0.15.10", "rtl.service": "0.15.12",
"btcpayserver.service": "2.4.2", "btcpayserver.service": "2.4.2",
"albyhub.service": "1.24.0", "albyhub.service": "1.24.0",
"mempool.service": "3.2.1", "mempool.service": "3.2.1",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 KiB

After

Width:  |  Height:  |  Size: 59 KiB

+37 -1
View File
@@ -147,7 +147,7 @@
hunspell hunspellDicts.en_US hunspell hunspellDicts.en_US
synadm brave-origin dua synadm brave-origin dua
gparted pv unzip parted screen zenity gparted pv unzip parted screen zenity
libargon2 gnome-terminal libreoffice-fresh libargon2 gnome-terminal libreoffice-stable
dig firefox wp-cli axel dig firefox wp-cli axel
lk-jwt-service livekit-libwebrtc livekit lk-jwt-service livekit-libwebrtc livekit
matrix-synapse age onlyoffice-desktopeditors matrix-synapse age onlyoffice-desktopeditors
@@ -165,6 +165,14 @@
programs.fish = { enable = true; promptInit = "fastfetch"; }; programs.fish = { enable = true; promptInit = "fastfetch"; };
# ── PostgreSQL base ──────────────────────────────────────── # ── PostgreSQL base ────────────────────────────────────────
# Shared cluster for Nextcloud (nextclouddb) + Matrix Synapse.
# Sized for the README's Server + Desktop recommendation (32 GB RAM,
# 500 GB NVMe OS + 2 TB NVMe timechain). Postgres shares the box with
# Bitcoin Core, Electrs, LND, MariaDB, PHP-FPM and GNOME, so
# shared_buffers stays below the 25%-of-RAM dedicated-server rule.
# Fixes Nextcloud 35 Database checks (pg.cache_hit_ratio,
# pg.dead_tuples). Override in custom.nix for other hosts, e.g.:
# services.postgresql.settings.shared_buffers = lib.mkForce "512MB";
services.postgresql = { services.postgresql = {
enable = true; enable = true;
authentication = lib.mkForce '' authentication = lib.mkForce ''
@@ -172,6 +180,34 @@
host all all 127.0.0.1/32 trust host all all 127.0.0.1/32 trust
host all all ::1/128 trust host all all ::1/128 trust
''; '';
settings = {
# Memory — fixes low buffer cache hit ratio (stock default is
# 128MB shared_buffers). effective_cache_size is only a planner
# hint, not an allocation, so it can be generous.
# NOTE: changing shared_buffers requires a Postgres restart.
shared_buffers = "2GB";
effective_cache_size = "12GB";
maintenance_work_mem = "512MB";
work_mem = "32MB";
wal_buffers = "64MB";
# Checkpoints — spread write bursts out on NVMe. Reload-only.
min_wal_size = "1GB";
max_wal_size = "4GB";
checkpoint_completion_target = 0.9;
# Autovacuum — the stock 60s naptime can't keep up with
# Nextcloud's and Synapse's write-heavy tables (filecache,
# activity, jobs, state). Reload-only.
autovacuum_naptime = "30s";
autovacuum_vacuum_scale_factor = 0.05;
autovacuum_analyze_scale_factor = 0.025;
autovacuum_max_workers = 4;
# NVMe planner assumptions (README: NVMe OS + data disks).
random_page_cost = "1.1";
effective_io_concurrency = 200;
};
}; };
# ── Backups ──────────────────────────────────────────────── # ── Backups ────────────────────────────────────────────────
Generated
+29 -46
View File
@@ -5,11 +5,11 @@
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
}, },
"locked": { "locked": {
"lastModified": 1787862680, "lastModified": 1790862821,
"narHash": "sha256-mv+W62cI1Bjtkz9k8N8M7+7VSauv0/WmBrDYjcy5sE0=", "narHash": "sha256-IcLn2hPlxsbn2PKVlFD6gsDzkVurQ7b0KPtyORUGFcs=",
"owner": "emmanuelrosa", "owner": "emmanuelrosa",
"repo": "btc-clients-nix", "repo": "btc-clients-nix",
"rev": "f00585b12e751ac738392e2cccfbe305d077e2d4", "rev": "99b0442dcc15198efcb62f3c1e7561a361749a6a",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -26,11 +26,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1787559586, "lastModified": 1788450739,
"narHash": "sha256-onL0VLf9vPllmT0H/OlURIU5r5t5WIEl7t4tVNKT0Nw=", "narHash": "sha256-glZLQlzIn1fXH6PazR2iUmTo7kzzyYSshrWhLS9TqCU=",
"owner": "hercules-ci", "owner": "hercules-ci",
"repo": "flake-parts", "repo": "flake-parts",
"rev": "9d0d87172c374f89da73c1cfe6d81ae62feac1f1", "rev": "31729ca8cbdb4fa927b34e5f4353e6a83f39e993",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -41,11 +41,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1785590095, "lastModified": 1790861130,
"narHash": "sha256-CNO2szJbdLjVN/Hi1BML9MSALz1GM2fIdwnzs404QO8=", "narHash": "sha256-cA8TrQntLbNO14wivJx7Gi2pZBhhBcMplgzIA3sk3TA=",
"owner": "NixOS", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "e568f3b19d54b08f48bfae9b12b3e124d1a28002", "rev": "3d23ea05a8a3be3f078845c2753af10cef80e1a5",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -56,27 +56,11 @@
}, },
"nixpkgs-stable": { "nixpkgs-stable": {
"locked": { "locked": {
"lastModified": 1788297115, "lastModified": 1790750587,
"narHash": "sha256-Z+vUNbfd2FIKkWOTkcT7RYlh3oFCnig/d2eXD1SWf2E=", "narHash": "sha256-VfjaoJ1Uyb7JZTrBgE5Jf2nhjtQPmD9KOd19HJwmZwM=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "a3116115851d68b8952a2a4221cc25a84e56b532", "rev": "78e9c786dc08cd4f3420c2395cd977206a9b1da2",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-26.05",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-stable_2": {
"locked": {
"lastModified": 1788297115,
"narHash": "sha256-Z+vUNbfd2FIKkWOTkcT7RYlh3oFCnig/d2eXD1SWf2E=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "a3116115851d68b8952a2a4221cc25a84e56b532",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -88,11 +72,11 @@
}, },
"nixpkgs_2": { "nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1788179007, "lastModified": 1790822859,
"narHash": "sha256-hn1oU2rue2SYK8dAr8+WNZWtbsz1S2W5mnHlSEuh3bo=", "narHash": "sha256-69xHQhAeMAD2wDXO7T2pcOZIF9Sga2W+JkmY2a11Ops=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "34ab99075ac4f7e40cf037eef32cb1c360bb85e9", "rev": "c59305bab2065cfecc4944690d9eedbb56f3a9fa",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -104,11 +88,11 @@
}, },
"nixpkgs_3": { "nixpkgs_3": {
"locked": { "locked": {
"lastModified": 1787631388, "lastModified": 1789724158,
"narHash": "sha256-vMiXptXarfSdJb1Gkc+FYVOAibuBRj7qxGa8z68q1Uw=", "narHash": "sha256-nlKgrm0dsVhOSopKheVBCcOpiIticufPPVLdVOI0euA=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "ac6b2166e7a9375683b8e98f860f273222337b16", "rev": "0a3468a402c449992505b6a9fc5b06580141b750",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -120,11 +104,11 @@
}, },
"nixpkgs_4": { "nixpkgs_4": {
"locked": { "locked": {
"lastModified": 1788179007, "lastModified": 1790822859,
"narHash": "sha256-hn1oU2rue2SYK8dAr8+WNZWtbsz1S2W5mnHlSEuh3bo=", "narHash": "sha256-69xHQhAeMAD2wDXO7T2pcOZIF9Sga2W+JkmY2a11Ops=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "34ab99075ac4f7e40cf037eef32cb1c360bb85e9", "rev": "c59305bab2065cfecc4944690d9eedbb56f3a9fa",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -141,11 +125,11 @@
"systems": "systems" "systems": "systems"
}, },
"locked": { "locked": {
"lastModified": 1788190018, "lastModified": 1790541651,
"narHash": "sha256-59BAfH0txPAZrPBF4QJqwvUWppD+ICrcjA1LZAmPnrQ=", "narHash": "sha256-/496IQz8qNWHHLnc3Zvr0l4uxJ34igNhJhn7ZKAefFk=",
"owner": "nix-community", "owner": "nix-community",
"repo": "nixvim", "repo": "nixvim",
"rev": "41844750e55f17b1385d5b09ca7ade5f11f49506", "rev": "5980a626794486abad69fca7667f9c11dfbc3bd7",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -165,15 +149,14 @@
}, },
"sovran-bitcoin": { "sovran-bitcoin": {
"inputs": { "inputs": {
"nixpkgs": "nixpkgs_4", "nixpkgs": "nixpkgs_4"
"nixpkgs-stable": "nixpkgs-stable_2"
}, },
"locked": { "locked": {
"lastModified": 1788378694, "lastModified": 1790877969,
"narHash": "sha256-s2yvGC1IHrQq2jP4KSbV2TA9Gh1T/YkcS+9neS/WcVI=", "narHash": "sha256-Ia88keP9t3B6ECVw+LP4xasPdvzw1TIWya1K+3dySu0=",
"owner": "naturallaw777", "owner": "naturallaw777",
"repo": "Sovran_Bitcoin", "repo": "Sovran_Bitcoin",
"rev": "b4678fcc712da9dd01c167f62275192183490085", "rev": "b0da63bd81f1a1b1a970068b3061c6c8389db9aa",
"type": "github" "type": "github"
}, },
"original": { "original": {
-30
View File
@@ -6,8 +6,6 @@
nixvim.url = "github:nix-community/nixvim"; nixvim.url = "github:nix-community/nixvim";
btc-clients.url = "github:emmanuelrosa/btc-clients-nix"; btc-clients.url = "github:emmanuelrosa/btc-clients-nix";
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05"; nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05";
# Bitcoin / Lightning stack — standalone flake, consumed as a module.
sovran-bitcoin.url = "github:naturallaw777/Sovran_Bitcoin"; sovran-bitcoin.url = "github:naturallaw777/Sovran_Bitcoin";
}; };
@@ -19,24 +17,6 @@
system = prev.stdenv.hostPlatform.system; system = prev.stdenv.hostPlatform.system;
config.allowUnfree = true; 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 in
{ {
@@ -55,7 +35,6 @@
{ nixpkgs.hostPlatform = "x86_64-linux"; nixpkgs.overlays = [ overlay-stable ]; } { nixpkgs.hostPlatform = "x86_64-linux"; nixpkgs.overlays = [ overlay-stable ]; }
./iso/common.nix ./iso/common.nix
sovran-bitcoin.nixosModules.default sovran-bitcoin.nixosModules.default
./modules/sovran-bitcoin-integration.nix
nixvim.nixosModules.nixvim nixvim.nixosModules.nixvim
]; ];
}; };
@@ -78,14 +57,5 @@
]; ];
}; };
}; };
checks.x86_64-linux = let
pkgs = import nixpkgs {
system = "x86_64-linux";
};
in {
# Bitcoin hardening and package checks now live in the Sovran_Bitcoin flake.
# Run them with: nix build github:naturallaw777/Sovran_Bitcoin#checks.x86_64-linux
};
}; };
} }
+1 -1
View File
@@ -471,7 +471,7 @@ class InstallerWindow(Adw.ApplicationWindow):
# Role cards # Role cards
roles = [ roles = [
("Server + Desktop", ("Server + Desktop",
"Full sovereignty: host your own websites, cloud, chat, passwords, and Bitcoin services instead of relying on Big Tech. Sovran_SystemsOS walks you through getting your domain from Njal.la and connecting everything. One router task is required: forward ports 80 and 443 to this computer.", "Full sovereignty: host your own websites, cloud, chat, passwords, and Bitcoin services instead of relying on Big Tech. Sovran_SystemsOS walks you through getting your domain from Njal.la and connecting everything. One router task is required: forward ports 80 and 443 to this computer. Heads-up: this makes your home IP address public.",
"Server+Desktop"), "Server+Desktop"),
("Desktop Only", ("Desktop Only",
"A beautiful, easy-to-use desktop without the background server applications.", "A beautiful, easy-to-use desktop without the background server applications.",
+23
View File
@@ -89,6 +89,26 @@ EOF
EOF EOF
''} ''}
# ── LAN-only guard ──────────────────────────────
# The Hub, RTL and Mempool sites below are meant for this home network
# only. Forwarding ports 80/443 on the router also lets other clients
# reach Caddy, so these sites check where a request comes from, not just
# which Host it asks for. Anyone else gets the connection closed.
# private_ranges 10/8, 172.16/12, 192.168/16, 127/8, fd00::/8, ::1
# 100.64.0.0/10 Tailscale and other VPN addresses
# 169.254.0.0/16, fe80::/10, fc00::/7 link-local and unique-local
# 2000::/3 IPv6 global addresses. Computers on this network
# often connect over their own global address, which
# looks the same as one from the internet, so IPv6
# global addresses are not filtered.
cat >> /run/caddy/Caddyfile <<'EOF'
(sovran_lan_only) {
@outside not remote_ip private_ranges 100.64.0.0/10 169.254.0.0/16 fe80::/10 fc00::/7 2000::/3
abort @outside
}
EOF
# ── Matrix ────────────────────────────────────── # ── Matrix ──────────────────────────────────────
if [ -n "$MATRIX" ]; then if [ -n "$MATRIX" ]; then
if [ -f /run/caddy/element-calling.snippet ]; then if [ -f /run/caddy/element-calling.snippet ]; then
@@ -206,6 +226,7 @@ EOF
cat >> /run/caddy/Caddyfile <<EOF cat >> /run/caddy/Caddyfile <<EOF
http://sovransystemsos.local { http://sovransystemsos.local {
import sovran_lan_only
reverse_proxy localhost:8937 reverse_proxy localhost:8937
header { header {
Clear-Site-Data "\"cache\"" Clear-Site-Data "\"cache\""
@@ -220,6 +241,7 @@ EOF
cat >> /run/caddy/Caddyfile <<EOF cat >> /run/caddy/Caddyfile <<EOF
:3051 { :3051 {
import sovran_lan_only
reverse_proxy :3050 reverse_proxy :3050
encode gzip zstd encode gzip zstd
} }
@@ -229,6 +251,7 @@ EOF
cat >> /run/caddy/Caddyfile <<EOF cat >> /run/caddy/Caddyfile <<EOF
:60847 { :60847 {
import sovran_lan_only
reverse_proxy :60845 reverse_proxy :60845
encode gzip zstd encode gzip zstd
} }
+33 -89
View File
@@ -1,43 +1,64 @@
{ config, pkgs, lib, ... }: { config, pkgs, lib, ... }:
{ {
# The public-IP detector (STUN / OpenDNS / HTTPS echo) is gone: the public
# address is whatever Njal.la reports back for the DDNS update below, and
# nothing else on the system looks it up. Fail with a pointer, instead of
# silently ignoring them, if a custom.nix still sets one of its old options.
imports = map (opt:
lib.mkRemovedOptionModule [ "sovran_systemsOS" "publicIP" opt ]
"Sovran no longer looks up the public IP: the Njal.la DDNS update reports it (modules/core/njalla.nix). To force an address for Element Calling, set sovran_systemsOS.elementCalling.externalIP."
) [ "stunServer" "stunPort" "dnsResolver" "httpsEcho" "cacheTTL" ];
# ── Ensure njalla directory exists on every build ──────────────────────── # ── Ensure njalla directory exists on every build ────────────────────────
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [
"d /var/lib/njalla 0750 root root -" "d /var/lib/njalla 0750 root root -"
]; ];
# ── Install the shared validation helper so the DDNS runner can import it ─ # ── Install the DDNS runner and the validator it shares with the Hub ─────
# The exact same _validate_ddns_url() function used by the Hub web application # Both files come straight from the Hub's source tree and are installed side
# is installed here as a read-only system file. The DDNS runner imports it # by side as read-only system files. The runner imports the exact same
# directly so the two code paths share one validator — no weaker inline copy. # _validate_ddns_url() the Hub API uses — no weaker inline copy.
environment.etc."sovran/security_helpers.py" = { environment.etc."sovran/security_helpers.py" = {
source = ../../app/sovran_systemsos_web/security_helpers.py; source = ../../app/sovran_systemsos_web/security_helpers.py;
mode = "0444"; mode = "0444";
user = "root"; user = "root";
group = "root"; group = "root";
}; };
environment.etc."sovran/ddns-update.py" = {
source = ../../app/sovran_systemsos_web/ddns_update.py;
mode = "0444";
user = "root";
group = "root";
};
# ── Safe DDNS update service ───────────────────────────────────────────── # ── Safe DDNS update service ─────────────────────────────────────────────
# Reads DDNS update URLs from the JSON store written by the Hub API and # Reads DDNS update URLs from the JSON store written by the Hub API and
# invokes curl directly — no shell interpolation, no script execution. # invokes curl directly — no shell interpolation, no script execution.
# Replaces the legacy root cron job that ran /var/lib/njalla/njalla.sh. # Njal.la is asked to use the address the request came from ("&auto") and
# reports it back; the runner saves it to /var/lib/secrets/external-ip,
# where LiveKit and the Hub read it. See app/sovran_systemsos_web/ddns_update.py.
systemd.services.sovran-ddns-update = { systemd.services.sovran-ddns-update = {
description = "Sovran Njal.la DDNS update (safe JSON-based runner)"; description = "Sovran Njal.la DDNS update (safe JSON-based runner)";
wants = [ "network-online.target" ]; wants = [ "network-online.target" ];
after = [ "network-online.target" ]; after = [ "network-online.target" ];
# curl is not in a NixOS unit's default PATH (coreutils, findutils, grep,
# sed, systemd): without this the runner cannot start it.
path = [ pkgs.curl ];
serviceConfig = { serviceConfig = {
Type = "oneshot"; Type = "oneshot";
User = "root"; User = "root";
ExecStart = "${pkgs.python3}/bin/python3 /var/lib/sovran/ddns-update.py"; ExecStart = "${pkgs.python3}/bin/python3 /etc/sovran/ddns-update.py";
# Harden the service — it only needs network access and read access to # Harden the service — it needs network access, the URL store, and the
# /var/lib/njalla/ddns_urls.json. # file that receives the reported address.
NoNewPrivileges = true; NoNewPrivileges = true;
ProtectSystem = "strict"; ProtectSystem = "strict";
ReadWritePaths = [ "/var/lib/njalla" "/var/lib/secrets" ]; ReadWritePaths = [ "/var/lib/njalla" "/var/lib/secrets" ];
ReadOnlyPaths = [ "/etc/sovran" ]; ReadOnlyPaths = [ "/etc/sovran" ];
ProtectHome = true; ProtectHome = true;
PrivateTmp = true; PrivateTmp = true;
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ]; # AF_UNIX: name lookups can go through nscd / systemd-resolved sockets.
RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
}; };
}; };
@@ -52,86 +73,9 @@
}; };
}; };
# Install the Python runner script at build time so the service can find it. # The runner used to be written to /var/lib/sovran by this activation script,
# The script is owned by root and not world-writable. # next to the old public-ip.py detector. Remove those stale copies.
# Uses _validate_ddns_url() from /etc/sovran/security_helpers.py — the same
# production validator used by the Hub API — before executing any curl call.
# No shell is used; no redirects; no script execution.
# ${IP} placeholder is preserved in stored URLs and substituted at runtime;
# the URL is validated after substitution so any remaining $ is rejected.
system.activationScripts.sovran-ddns-update-script = '' system.activationScripts.sovran-ddns-update-script = ''
install -d -m 0755 /var/lib/sovran rm -f /var/lib/sovran/ddns-update.py /var/lib/sovran/public-ip.py
cat > /var/lib/sovran/ddns-update.py <<'PYEOF'
#!/usr/bin/env python3
"""Sovran safe DDNS update runner.
Reads ddns_urls.json, substitutes the public IP for the ''${IP} placeholder,
validates each URL using the production _validate_ddns_url() from
/etc/sovran/security_helpers.py, then calls curl per URL.
No shell interpolation. No redirects. No script execution.
"""
import ipaddress, json, os, subprocess, sys
sys.path.insert(0, '/etc/sovran')
try:
from security_helpers import _validate_ddns_url
except ImportError:
sys.exit(1) # validator missing — fail so systemd logs the misconfiguration
URLS_FILE = "/var/lib/njalla/ddns_urls.json"
try:
with open(URLS_FILE) as f:
urls = json.load(f)
if not isinstance(urls, list):
raise ValueError("not a list")
except Exception:
sys.exit(0) # no URLs configured — nothing to do
# Resolve current public IP via the shared detector — one script, one cache
# (STUN -> DNS -> opt-in HTTPS echo; see /var/lib/sovran/public-ip.py).
# The detector refreshes /var/lib/secrets/external-ip, which the Hub and
# LiveKit read as well, so the whole system shares a single detected value.
public_ip = ""
try:
r = subprocess.run(
[sys.executable, "/var/lib/sovran/public-ip.py", "check"],
capture_output=True, text=True, timeout=20,
)
raw = r.stdout.strip().splitlines()[0] if r.stdout.strip() else ""
ipaddress.ip_address(raw) # validates — raises if not a real IP
public_ip = raw
except Exception:
pass
if not public_ip:
# Last resort: the shared cache file, if the detector is unavailable.
try:
with open("/var/lib/secrets/external-ip") as f:
raw = f.read().strip()
ipaddress.ip_address(raw)
public_ip = raw
except Exception:
pass
if not public_ip:
sys.exit(0) # no IP resolved — skip to avoid sending bare ''${IP}
for raw_url in urls:
try:
# Substitute ''${IP} placeholder then validate through production validator.
# After substitution there must be no $ left; _validate_ddns_url rejects
# any remaining $ expression.
url = raw_url.replace("''${IP}", public_ip)
_validate_ddns_url(url)
subprocess.run(
["curl", "--silent", "--max-time", "15", "--fail", "--no-location", url],
timeout=20, check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
except Exception:
pass
PYEOF
chmod 0500 /var/lib/sovran/ddns-update.py
''; '';
} }
-323
View File
@@ -1,323 +0,0 @@
# ── Unified public-IP detection (privacy-first) ─────────────────────────────
#
# One script, one cache file, every consumer on the system reads the same
# value. Previously the public IP was detected independently in three places,
# each phoning home to a different third party:
# * the Hub (server.py _get_external_ip) → api.ipify.org / ifconfig.me /
# icanhazip.com over HTTPS on every /api/network call and every
# background-loop tick
# * DDNS (ddns-update.py) → myip.opendns.com via OpenDNS
# * LiveKit → STUN (its own embedded detection)
#
# This module replaces all of that with a single script
# (/var/lib/sovran/public-ip.py) that detects the IP once per TTL using the
# least-exposing mechanism available, and caches it in
# /var/lib/secrets/external-ip. Consumers (Hub, DDNS, LiveKit) read the cache
# and only invoke the script when it is missing or stale.
#
# Detection chain (first success wins, stops immediately):
# 1. pin — sovran_systemsOS.elementCalling.externalIP (baked in)
# 2. cache — /var/lib/secrets/external-ip if newer than cacheTTL
# 3. STUN — UDP binding request (one packet, no application data,
# no HTTP metadata; the same protocol every WebRTC client
# uses). Server configurable via publicIP.stunServer.
# 4. DNS — "myip.opendns.com" A query via publicIP.dnsResolver
# (single DNS query, no HTTP headers)
# 5. HTTPS echo — ONLY endpoints listed in publicIP.httpsEcho (empty by
# default → never contacted)
#
# Privacy property: while the cache is fresh, zero third parties are
# contacted. When detection runs, at most ONE party learns the IP per
# refresh interval (default 5 minutes), and the STUN/DNS mechanisms expose
# nothing beyond the bare address.
{
config,
pkgs,
lib,
...
}:
let
stunServer = config.sovran_systemsOS.publicIP.stunServer;
stunPort = config.sovran_systemsOS.publicIP.stunPort;
dnsResolver = config.sovran_systemsOS.publicIP.dnsResolver;
httpsEcho = config.sovran_systemsOS.publicIP.httpsEcho;
cacheTTL = config.sovran_systemsOS.publicIP.cacheTTL;
# Optional pin shared with element-calling (baked in at build time).
pin = if config.sovran_systemsOS.elementCalling.externalIP != null then config.sovran_systemsOS.elementCalling.externalIP else "";
echoList = lib.concatStringsSep "," (map (u: "'${u}'") httpsEcho);
in
{
options.sovran_systemsOS.publicIP = {
stunServer = lib.mkOption {
type = lib.types.str;
default = "stun.l.google.com";
description = ''
STUN server used to discover the public IP over UDP. STUN is the most
privacy-preserving detection mechanism: a single stateless packet,
no HTTP metadata. Only used when the cache is stale.
'';
};
stunPort = lib.mkOption {
type = lib.types.port;
default = 19302;
};
dnsResolver = lib.mkOption {
type = lib.types.str;
default = "resolver4.opendns.com";
description = ''
DNS resolver used as fallback (myip.opendns.com trick) when STUN is
unavailable (e.g. ISP blocks UDP egress). A single DNS query, no
HTTP headers.
'';
};
httpsEcho = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "https://api.ipify.org" ];
description = ''
OPT-IN HTTPS endpoints that return the caller's public IP as a bare
IPv4 literal. Each listed endpoint observes this server's public IP
and HTTP metadata every time detection runs. Empty by default — no
HTTPS echo service is ever contacted unless you add one here. This is
the last-resort fallback after STUN and DNS.
'';
};
cacheTTL = lib.mkOption {
type = lib.types.int;
default = 300;
description = "Seconds the detected public IP is cached before re-detection.";
};
};
# ── Install the unified detector ──────────────────────────────────────────
# This module declares `options` above, so ALL configuration must go under
# the `config` attribute: NixOS forbids mixing bare top-level settings
# (like `system.*`) with the `options`/`config` keyword attributes in the
# same module. (Fixes: "Module ... has an unsupported attribute `system'".)
config.system.activationScripts.sovranPublicIpInstall = lib.stringAfter [ "users" ] ''
install -d -m 0755 /var/lib/sovran
cat > /var/lib/sovran/public-ip.py <<'PYEOF'
#!/usr/bin/env python3
"""sovran-public-ip — one detector, one cache, every consumer reads the same IP.
Privacy-first detection chain (first success wins):
1. pin — baked in from sovran_systemsOS.elementCalling.externalIP
2. cache — /var/lib/secrets/external-ip if newer than CACHE_TTL seconds
3. STUN — UDP binding request (one packet, no application data)
4. DNS — myip.opendns.com A query via the configured resolver
5. HTTPS — ONLY endpoints baked in from publicIP.httpsEcho (opt-in)
Usage:
public-ip.py check print current public IP (cache first; refresh if stale)
public-ip.py refresh force re-detection, update the cache file, print IP
Exit status: 0 with the IP on stdout on success; 1 if no IP is available
(cached value, if any, is still printed to stdout with a warning on stderr).
"""
import ipaddress
import os
import random
import socket
import struct
import sys
import time
import urllib.request
CACHE_FILE = "/var/lib/secrets/external-ip"
PIN = "${pin}"
STUN_SERVER = "${stunServer}"
STUN_PORT = ${toString stunPort}
DNS_RESOLVER = "${dnsResolver}"
DNS_HOST = "myip.opendns.com"
ECHO_URLS = [ ${echoList} ]
CACHE_TTL = ${toString cacheTTL}
TIMEOUT = 3.0
# ---------------------------------------------------------------------------
# Detection primitives
# ---------------------------------------------------------------------------
def is_usable_ip(text: str) -> bool:
"""True if text is a globally routable IPv4 that LiveKit may advertise."""
try:
ip = ipaddress.ip_address(text)
except ValueError:
return False
if ip.version != 4:
return False
if (ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast
or ip.is_reserved or ip.is_unspecified or not ip.is_global):
return False
# RFC 6598 shared (CGNAT) space — not reachable from the internet.
if ip in ipaddress.ip_network("100.64.0.0/10"):
return False
return True
def stun_public_ip() -> str | None:
"""RFC 5389 Binding request over UDP; returns the mapped (public) IPv4."""
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(TIMEOUT)
try:
txid = random.randbytes(12)
req = struct.pack("!HHI", 0x0001, 0, 0) + txid # Binding request
sock.sendto(req, (STUN_SERVER, STUN_PORT))
data, _ = sock.recvfrom(2048)
except OSError:
return None
finally:
sock.close()
if len(data) < 20:
return None
mtype, _mlen = struct.unpack("!HH", data[:4])
if mtype != 0x0101: # Binding success response
return None
cookie = data[4:8]
i = 20
while i + 4 <= len(data):
atype, alen = struct.unpack("!HH", data[i : i + 4])
aval = data[i + 4 : i + 4 + alen]
if atype in (0x0001, 0x0020) and len(aval) >= 8: # MAPPED / XOR-MAPPED
family = aval[1]
if family == 0x01: # IPv4
raw = aval[4:8]
if atype == 0x0020: # XOR with magic cookie + txid prefix
raw = bytes(b ^ c for b, c in zip(raw, cookie + txid[:4]))
return socket.inet_ntop(socket.AF_INET, raw)
i += 4 + ((alen + 3) // 4) * 4
return None
def dns_public_ip() -> str | None:
"""Minimal DNS A query for myip.opendns.com against the given resolver."""
qid = random.randint(0, 0xFFFF)
qname = b"".join(bytes([len(p)]) + p.encode() for p in DNS_HOST.split(".")) + b"\x00"
query = struct.pack("!HHHHHH", qid, 0x0100, 1, 0, 0, 0) + qname + struct.pack("!HH", 1, 1)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(TIMEOUT)
try:
sock.sendto(query, (DNS_RESOLVER, 53))
data, _ = sock.recvfrom(4096)
except OSError:
return None
finally:
sock.close()
try:
if len(data) < 12:
return None
rid, _flags, _qd, an, _ns, _ar = struct.unpack("!HHHHHH", data[:12])
if rid != qid or an == 0:
return None
i = 12
for _ in range(_qd): # skip question
while data[i] != 0:
i += 1 + data[i]
i += 5
for _ in range(an):
if data[i] & 0xC0 == 0xC0:
i += 2
else:
while data[i] != 0:
i += 1 + data[i]
i += 1
rtype, _rclass, _ttl, rdlen = struct.unpack("!HHIH", data[i : i + 10])
i += 10
if rtype == 1 and rdlen == 4:
return socket.inet_ntop(socket.AF_INET, data[i : i + 4])
i += rdlen
except (IndexError, struct.error):
return None
return None
def echo_public_ip() -> str | None:
"""Opt-in HTTPS echo endpoints (baked in at build time; empty by default)."""
for url in ECHO_URLS:
try:
req = urllib.request.Request(url, headers={"User-Agent": "sovran-public-ip"})
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
text = resp.read().decode().strip()
if is_usable_ip(text):
return text
except Exception:
continue
return None
# ---------------------------------------------------------------------------
# Cache handling
# ---------------------------------------------------------------------------
def read_cache() -> str:
try:
with open(CACHE_FILE) as f:
return f.read().strip()
except OSError:
return ""
def write_cache(ip: str) -> None:
try:
os.makedirs(os.path.dirname(CACHE_FILE), exist_ok=True)
tmp = f"{CACHE_FILE}.tmp"
with open(tmp, "w") as f:
f.write(ip + "\n")
os.replace(tmp, CACHE_FILE)
except OSError:
pass
def cache_fresh() -> bool:
try:
return time.time() - os.path.getmtime(CACHE_FILE) < CACHE_TTL
except OSError:
return False
def detect() -> str:
"""Run the chain; returns usable IP or an empty string."""
if PIN and is_usable_ip(PIN):
return PIN
for fn in (stun_public_ip, dns_public_ip, echo_public_ip):
try:
cand = fn()
except Exception:
continue
if cand and is_usable_ip(cand):
return cand
return ""
def main() -> int:
force = len(sys.argv) > 1 and sys.argv[1] == "refresh"
ip = ""
if not force and cache_fresh():
ip = read_cache()
if not ip:
ip = detect()
if ip:
write_cache(ip)
else:
stale = read_cache()
if stale:
print(stale)
print("WARNING: detection failed; using last known public IP", file=sys.stderr)
return 0
print("ERROR: could not determine a public IP (STUN/DNS unreachable)", file=sys.stderr)
return 1
print(ip)
return 0
if __name__ == "__main__":
sys.exit(main())
PYEOF
chmod 0555 /var/lib/sovran/public-ip.py
'';
}
+5 -3
View File
@@ -107,9 +107,11 @@
description = '' description = ''
Optional pin: force LiveKit to advertise this public IPv4 in its Optional pin: force LiveKit to advertise this public IPv4 in its
host/TURN ICE candidates. Not required in normal operation — the host/TURN ICE candidates. Not required in normal operation — the
module auto-detects the public IP at runtime (HTTPS egress address is the one Njal.la reports for the DDNS update (set up in
detection, falling back to STUN). Set it only to override a the Hub's Domains page), and nothing on this system looks it up
mis-detected address (e.g. multi-WAN/VPN setups). anywhere else. Set it for a fixed public address with no Njal.la
DDNS entry, or to override the reported one (e.g. multi-WAN/VPN
setups).
''; '';
}; };
}; };
+75 -14
View File
@@ -51,8 +51,8 @@ let
]; } ]; }
{ name = "LND"; unit = "lnd.service"; type = "system"; icon = "lnd"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = []; } { 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 = [ { 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 = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/rtl/hostname"; prefix = "http://"; suffix = "/rtl/"; }
{ label = "Local Network — Access on your home network only"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":3051"; } { label = "Local Network — Access on your home network only"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":3051/rtl/"; }
{ label = "Password"; file = "/etc/nix-bitcoin-secrets/rtl-password"; } { label = "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"; } { 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"; }
]; } ]; }
@@ -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"); "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 wrapper script ──────────────────────────────────────
update-script = pkgs.writeShellScript "sovran-hub-update.sh" '' update-script = pkgs.writeShellScript "sovran-hub-update.sh" ''
set -uo pipefail set -uo pipefail
@@ -171,12 +226,14 @@ let
RC=0 RC=0
${nix-self-heal-prelude}
echo "── Step 1/3: nix flake update ────────────────────" 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 connect-timeout 10 \
--option stalled-download-timeout 90 \ --option stalled-download-timeout 90 \
--option download-attempts 7 \ --option download-attempts 7 \
--option fallback true 2>&1; then --option fallback true; then
echo "[ERROR] nix flake update failed" echo "[ERROR] nix flake update failed"
RC=1 RC=1
fi fi
@@ -186,22 +243,22 @@ let
echo "── Step 2/3: nixos-rebuild boot (stage next reboot) ──" echo "── Step 2/3: nixos-rebuild boot (stage next reboot) ──"
# Stream output straight into $LOG (see rebuild-script) so the Hub UI # Stream output straight into $LOG (see rebuild-script) so the Hub UI
# shows live progress instead of an empty log during long builds. # 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 connect-timeout 10 \
--option stalled-download-timeout 90 \ --option stalled-download-timeout 90 \
--option download-attempts 7 \ --option download-attempts 7 \
--option fallback true --option fallback true; then
BOOT_RC=$? if ! readlink -f /nix/var/nix/profiles/system > "$GENERATION"; then
if [ "$BOOT_RC" -ne 0 ]; then
echo "[ERROR] nixos-rebuild boot failed"
RC=1
elif ! readlink -f /nix/var/nix/profiles/system > "$GENERATION"; then
# The marker is informational only. The Hub derives pending-reboot # The marker is informational only. The Hub derives pending-reboot
# state from the NixOS system profile itself, so failing to record # state from the NixOS system profile itself, so failing to record
# the marker must not fail an otherwise successful update. # the marker must not fail an otherwise successful update.
echo "[WARNING] update succeeded but its staged generation could not be recorded" echo "[WARNING] update succeeded but its staged generation could not be recorded"
rm -f "$GENERATION" rm -f "$GENERATION"
fi fi
else
echo "[ERROR] nixos-rebuild boot failed"
RC=1
fi
echo "" echo ""
fi fi
@@ -245,12 +302,15 @@ let
echo " Sovran_SystemsOS Rebuild — $(date)" echo " Sovran_SystemsOS Rebuild — $(date)"
echo "══════════════════════════════════════════════════" echo "══════════════════════════════════════════════════"
echo "" echo ""
${nix-self-heal-prelude}
echo "── Rebuilding system configuration ──────────────" echo "── Rebuilding system configuration ──────────────"
# Stream output straight into $LOG (tee'd by the exec redirect above) so # 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 # the Hub UI shows live progress. Capturing the output in a variable
# kept the log empty for the entire build+activation, which made long # kept the log empty for the entire build+activation, which made long
# rebuilds can otherwise look like a hang. # 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 connect-timeout 10 \
--option stalled-download-timeout 90 \ --option stalled-download-timeout 90 \
--option download-attempts 7 \ --option download-attempts 7 \
@@ -266,11 +326,11 @@ let
echo "" echo ""
echo " ✓ Build succeeded — a reboot is required to apply this rebuild" echo " ✓ Build succeeded — a reboot is required to apply this rebuild"
echo " (Critical system components changed; running nixos-rebuild boot instead)" 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 connect-timeout 10 \
--option stalled-download-timeout 90 \ --option stalled-download-timeout 90 \
--option download-attempts 7 \ --option download-attempts 7 \
--option fallback true 2>&1; then --option fallback true; then
echo "REBOOT_REQUIRED" > "$STATUS" echo "REBOOT_REQUIRED" > "$STATUS"
else else
echo "[ERROR] nixos-rebuild boot also failed" echo "[ERROR] nixos-rebuild boot also failed"
@@ -278,6 +338,7 @@ let
exit 1 exit 1
fi fi
else else
echo "[ERROR] nixos-rebuild switch failed"
echo "" echo ""
echo "══════════════════════════════════════════════════" echo "══════════════════════════════════════════════════"
echo " ✗ Rebuild failed — see errors above" echo " ✗ Rebuild failed — see errors above"
+41 -37
View File
@@ -204,34 +204,36 @@ EOF
# NAT with port-forwarding. It does not need to be assigned to this box, # NAT with port-forwarding. It does not need to be assigned to this box,
# and it may be dynamic. # and it may be dynamic.
# #
# Reuse the shared detector (/var/lib/sovran/public-ip.py — see # Nothing here looks the address up. Priority:
# modules/core/public-ip.nix) instead of running our own: one script,
# one cache, privacy-first (STUN -> DNS -> opt-in HTTPS echo). Priority:
# 1. sovran_systemsOS.elementCalling.externalIP (explicit pin, if set) # 1. sovran_systemsOS.elementCalling.externalIP (explicit pin, if set)
# 2. /var/lib/secrets/external-ip (the shared cache) # 2. /var/lib/secrets/external-ip — the address Njal.la reported for the
# 3. run the detector now (it refreshes the cache) # last DDNS update (modules/core/njalla.nix). The runner rewrites that
# 4. STUN auto-detection (use_external_ip) as the fallback, with a # file only when the address changes, and livekit-external-ip.path
# warning — this is where broken installs used to silently end up # then re-runs this script.
# advertising a private IP, causing "call connects but no video". # With neither, or with an address that is not public, this unit fails with
# a clear message instead of guessing: advertising a wrong or private
# address is what produces "call connects but no video".
EXTERNAL_IP='${if config.sovran_systemsOS.elementCalling.externalIP != null then config.sovran_systemsOS.elementCalling.externalIP else ""}' EXTERNAL_IP='${if config.sovran_systemsOS.elementCalling.externalIP != null then config.sovran_systemsOS.elementCalling.externalIP else ""}'
PUBLIC_IP="$EXTERNAL_IP" PUBLIC_IP="$EXTERNAL_IP"
if [ -z "$PUBLIC_IP" ] && [ -f /var/lib/secrets/external-ip ]; then if [ -z "$PUBLIC_IP" ] && [ -f /var/lib/secrets/external-ip ]; then
PUBLIC_IP=$(tr -d '[:space:]' < /var/lib/secrets/external-ip 2>/dev/null) PUBLIC_IP=$(tr -d '[:space:]' < /var/lib/secrets/external-ip 2>/dev/null)
fi fi
if [ -z "$PUBLIC_IP" ] && [ -x /var/lib/sovran/public-ip.py ]; then
PUBLIC_IP=$(python3 /var/lib/sovran/public-ip.py check 2>/dev/null | head -n1) if [ -z "$PUBLIC_IP" ]; then
echo "ERROR: no public IP is known for LiveKit yet." >&2
echo "ERROR: It is recorded after the first successful Njal.la DDNS update (Hub, Domains)." >&2
echo "ERROR: To use a fixed address instead, set sovran_systemsOS.elementCalling.externalIP." >&2
exit 1
fi fi
# Reject non-routable addresses (loopback, private, link-local, CGNAT). # Reject non-routable addresses (loopback, private, link-local, CGNAT).
# A detected/pinned address like this must never be advertised. if printf '%s' "$PUBLIC_IP" | grep -qE \
if [ -n "$PUBLIC_IP" ] && printf '%s' "$PUBLIC_IP" | grep -qE \ '^(0\.|127\.|10\.|100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.|169\.254\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)'; then
'^(0\.|127\.|10\.|100\.64\.|169\.254\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)'; then echo "ERROR: $PUBLIC_IP is not a public address, so remote peers cannot reach LiveKit there." >&2
echo "WARNING: external IP '$PUBLIC_IP' is not routable; falling back to STUN auto-detection." >&2 exit 1
PUBLIC_IP=""
fi fi
if [ -n "$PUBLIC_IP" ]; then
cat > /run/livekit/livekit.yaml <<EOF cat > /run/livekit/livekit.yaml <<EOF
port: 7880 port: 7880
rtc: rtc:
@@ -245,21 +247,6 @@ rtc:
- $IFACE - $IFACE
EOF EOF
echo "LiveKit will advertise public IP: $PUBLIC_IP" echo "LiveKit will advertise public IP: $PUBLIC_IP"
else
cat > /run/livekit/livekit.yaml <<EOF
port: 7880
rtc:
use_external_ip: true
skip_external_ip_validation: true
advertise_internal_ip: true
tcp_port: 7881
udp_port: 7882
interfaces:
includes:
- $IFACE
EOF
echo "WARNING: could not determine a public IP for LiveKit; using STUN auto-detection. If calls connect without media, check STUN egress or set sovran_systemsOS.elementCalling.externalIP." >&2
fi
# Webhooks → lk-jwt-service. The JWT service validates the HMAC # Webhooks → lk-jwt-service. The JWT service validates the HMAC
# signature against the same key file it issues tokens with, and uses # signature against the same key file it issues tokens with, and uses
@@ -414,15 +401,32 @@ EOF
# Restart LiveKit / lk-jwt-service when a rebuild regenerates their runtime # Restart LiveKit / lk-jwt-service when a rebuild regenerates their runtime
# configs (new domains, externalIP, full-access list), mirroring the domain # configs (new domains, externalIP, full-access list), mirroring the domain
# change flow. # change flow.
# Re-run the config generator and restart LiveKit when a rebuild regenerates
# the runtime config, or when the Hub persists a new external IP (dynamic
# WAN IPs), so the advertised ICE candidate stays current without a manual
# restart. The trigger chain: external-ip change → livekit-turn-setup
# re-runs → rewrites livekit.yaml → livekit restarts with the new config.
systemd.services.livekit-turn-setup.restartTriggers = [ "/var/lib/secrets/external-ip" ];
systemd.services.livekit.restartTriggers = [ "/run/livekit/livekit.yaml" ]; systemd.services.livekit.restartTriggers = [ "/run/livekit/livekit.yaml" ];
systemd.services.lk-jwt-service.restartTriggers = [ "/run/lk-jwt-service/env" ]; systemd.services.lk-jwt-service.restartTriggers = [ "/run/lk-jwt-service/env" ];
# Follow a changing public IP. ddns-update.py rewrites
# /var/lib/secrets/external-ip only when Njal.la reports a different address;
# this path unit then re-runs livekit-turn-setup (new node_ip and TURN
# address) and starts LiveKit if it is not running, e.g. because no address
# was known yet at boot. restartTriggers cannot do this: it is evaluated when
# the system is built, so it cannot watch a file that changes at runtime.
systemd.paths.livekit-external-ip = {
description = "Watch the public IP recorded by the Njal.la DDNS runner";
wantedBy = [ "multi-user.target" ];
pathConfig.PathChanged = "/var/lib/secrets/external-ip";
};
systemd.services.livekit-external-ip = {
description = "Re-run LiveKit setup for a changed public IP";
serviceConfig.Type = "oneshot";
unitConfig.ConditionPathExists = "/var/lib/domains/element-calling";
script = ''
# livekit.service requires livekit-turn-setup, so it restarts with it.
systemctl restart livekit-turn-setup.service
# No-op if LiveKit is already running; starts it after an earlier failure.
systemctl start livekit.service
'';
};
####### PUBLIC REACHABILITY SELF-CHECK ####### ####### PUBLIC REACHABILITY SELF-CHECK #######
# Diagnostic only — never a hard dependency of livekit/caddy. Catches the # Diagnostic only — never a hard dependency of livekit/caddy. Catches the
# classic "call connects but no media" setup errors at boot instead of at # classic "call connects but no media" setup errors at boot instead of at
-1
View File
@@ -17,7 +17,6 @@
./core/no-sleep.nix ./core/no-sleep.nix
./core/cpu-performance.nix ./core/cpu-performance.nix
./core/local-domain-loopback.nix ./core/local-domain-loopback.nix
./core/public-ip.nix
# ── Always on (no flag) ─────────────────────────────────── # ── Always on (no flag) ───────────────────────────────────
./php.nix ./php.nix
+112 -3
View File
@@ -3,10 +3,22 @@
lib.mkIf config.sovran_systemsOS.services.nextcloud { lib.mkIf config.sovran_systemsOS.services.nextcloud {
# ── PostgreSQL database ─────────────────────────────────── # ── PostgreSQL database ───────────────────────────────────
# Cluster-wide tuning (shared_buffers, autovacuum) lives in
# configuration.nix so it is shared with Matrix Synapse.
services.postgresql = { services.postgresql = {
enable = true; enable = true;
}; };
# ── Redis for Nextcloud distributed cache + file locking ───
# Nextcloud does not recommend APCu for memcache.locking in production.
# TCP on localhost avoids unix-socket permission juggling with the caddy user.
# Scoped to Nextcloud only — Synapse / MariaDB / Bitcoin are unaffected.
services.redis.servers.nextcloud = {
enable = true;
bind = "127.0.0.1";
port = 6379;
};
# ── Auto-generate DB password and initialize ────────────── # ── Auto-generate DB password and initialize ──────────────
systemd.services.nextcloud-db-init = { systemd.services.nextcloud-db-init = {
description = "Initialize Nextcloud PostgreSQL database with auto-generated password"; description = "Initialize Nextcloud PostgreSQL database with auto-generated password";
@@ -47,14 +59,20 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud {
if ! psql -U postgres -lqt | cut -d \| -f 1 | grep -qw "nextclouddb"; then if ! psql -U postgres -lqt | cut -d \| -f 1 | grep -qw "nextclouddb"; then
psql -U postgres -c "CREATE DATABASE nextclouddb WITH OWNER ncusr TEMPLATE template0 LC_COLLATE = 'C' LC_CTYPE = 'C';" psql -U postgres -c "CREATE DATABASE nextclouddb WITH OWNER ncusr TEMPLATE template0 LC_COLLATE = 'C' LC_CTYPE = 'C';"
fi fi
# NOTE: autovacuum GUCs are SIGHUP-context, so they cannot be set
# per-database — ALTER DATABASE ... SET rejects them with
# 'parameter "..." cannot be changed now'. They are set
# cluster-wide in configuration.nix instead, which already covers
# both nextclouddb and matrix-synapse.
''; '';
}; };
# ── Fully automated Nextcloud setup ─────────────────────── # ── Fully automated Nextcloud setup ───────────────────────
systemd.services.nextcloud-init = { systemd.services.nextcloud-init = {
description = "Download, extract, and fully configure Nextcloud"; 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-nextcloud.service" "nextcloud-db-init.service" "redis-nextcloud.service" ];
wants = [ "network-online.target" ]; wants = [ "network-online.target" "redis-nextcloud.service" ];
requires = [ "postgresql.service" "nextcloud-db-init.service" ]; requires = [ "postgresql.service" "nextcloud-db-init.service" ];
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
@@ -150,7 +168,11 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud {
php $INSTALL_DIR/occ config:system:set default_phone_region --value='US' 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 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.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 memcache.distributed --value='\OC\Memcache\Redis'
php $INSTALL_DIR/occ config:system:set memcache.locking --value='\OC\Memcache\Redis'
php $INSTALL_DIR/occ config:system:set redis host --value='127.0.0.1'
php $INSTALL_DIR/occ config:system:set redis port --type=integer --value=6379
php $INSTALL_DIR/occ config:system:set redis timeout --value='1.5'
php $INSTALL_DIR/occ config:system:set server_id --value='$SERVER_ID' php $INSTALL_DIR/occ config:system:set server_id --value='$SERVER_ID'
php $INSTALL_DIR/occ background:cron php $INSTALL_DIR/occ background:cron
" "
@@ -247,6 +269,93 @@ CREDS
''; '';
}; };
# ── Migrate existing installs to Redis locking ────────────
# nextcloud-init only runs on fresh installs (ConditionPathExists
# !config.php), so pre-existing / pre-Sovran installs would keep
# APCu locking forever. This one-shot is idempotent and safe to
# re-run on every boot — occ just overwrites the same values.
systemd.services.nextcloud-redis-migrate = {
description = "Point existing Nextcloud installs at Redis locking";
after = [ "postgresql.service" "redis-nextcloud.service" "phpfpm-nextcloud.service" ];
wants = [ "redis-nextcloud.service" ];
wantedBy = [ "multi-user.target" ];
unitConfig = {
ConditionPathExists = [
"/var/lib/www/nextcloud/occ"
"/var/lib/www/nextcloud/config/config.php"
];
};
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
path = with pkgs; [ coreutils shadow ];
script = ''
set -euo pipefail
INSTALL_DIR="/var/lib/www/nextcloud"
# Wait briefly for Redis (TCP localhost:6379).
for i in $(seq 1 15); do
if (echo > /dev/tcp/127.0.0.1/6379) >/dev/null 2>&1; then
break
fi
sleep 2
done
/run/wrappers/bin/su -s /bin/sh caddy -c "
php $INSTALL_DIR/occ config:system:set memcache.local --value='\OC\Memcache\APCu'
php $INSTALL_DIR/occ config:system:set memcache.distributed --value='\OC\Memcache\Redis'
php $INSTALL_DIR/occ config:system:set memcache.locking --value='\OC\Memcache\Redis'
php $INSTALL_DIR/occ config:system:set redis host --value='127.0.0.1'
php $INSTALL_DIR/occ config:system:set redis port --type=integer --value=6379
php $INSTALL_DIR/occ config:system:set redis timeout --value='1.5'
"
'';
};
# ── Recurring DB maintenance (Nextcloud 35 checks) ───────────
# nextcloud-init runs db:add-missing-indices exactly once. Upgrades
# (e.g. to NC35) and later app installs (Mail, Guests) add tables
# like oc_mail_tags / oc_guests_users that then seq-scan forever.
# Weekly: VACUUM ANALYZE (dead tuples) + backfill missing indices.
# Scoped to nextclouddb only — matrix-synapse is untouched.
systemd.services.nextcloud-db-maintenance = {
description = "Nextcloud DB maintenance: VACUUM + missing indices";
after = [ "postgresql.service" "redis-nextcloud.service" "phpfpm-nextcloud.service" ];
wants = [ "postgresql.service" ];
unitConfig = {
ConditionPathExists = [
"/var/lib/www/nextcloud/occ"
"/var/lib/www/nextcloud/config/config.php"
];
};
serviceConfig = {
Type = "oneshot";
};
path = [ config.services.postgresql.package pkgs.coreutils pkgs.shadow ];
script = ''
set -euo pipefail
INSTALL_DIR="/var/lib/www/nextcloud"
echo "Vacuuming nextclouddb..."
psql -U postgres -d nextclouddb -c "VACUUM (ANALYZE);"
echo "Backfilling Nextcloud indices..."
/run/wrappers/bin/su -s /bin/sh caddy -c "
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
"
echo "Nextcloud DB maintenance complete."
'';
};
systemd.timers.nextcloud-db-maintenance = {
description = "Weekly Nextcloud DB maintenance";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = "Sun 03:30";
Persistent = true;
RandomizedDelaySec = "30m";
};
};
services.cron.systemCronJobs = [ services.cron.systemCronJobs = [
"*/5 * * * * caddy /run/current-system/sw/bin/php -f /var/lib/www/nextcloud/cron.php" "*/5 * * * * caddy /run/current-system/sw/bin/php -f /var/lib/www/nextcloud/cron.php"
]; ];
+53 -11
View File
@@ -1,29 +1,70 @@
{ config, pkgs, lib, ... }: { config, pkgs, lib, ... }:
# ── Shared PHP for Nextcloud + WordPress ──────────────────────────────────────
#
# One interpreter (with one extension set and one php.ini) is shared by the
# phpfpm-nextcloud and phpfpm-wordpress pools, the Nextcloud cron job and the
# occ / wp-cli helper scripts. Every consumer must reference
# config.sovran_systemsOS.phpPackage (or /run/current-system/sw/bin/php) so
# that the CLI and the FPM pools always run the *same* PHP.
#
# Version policy (September 2026):
# • Nextcloud 35 supports PHP 8.3 / 8.4 / 8.5 and recommends 8.5. Its setup
# check flags 8.3 as "deprecated since Nextcloud 35" and warns that
# Nextcloud 36 may require at least 8.4.
# • WordPress 6.9 / 7.0 fully support PHP 8.4 and 8.5.
# • PHP 8.3 has been security-only since 2025-12-31; PHP 8.4 leaves active
# support on 2026-12-31; PHP 8.5 is actively supported until 2027-12-31.
#
# To fall back to PHP 8.4 (nixpkgs' current default `pkgs.php`) change only
# the `phpBase` line below.
let let
phpBase = pkgs.php85;
custom-php = phpBase.buildEnv {
# `enabled` is nixpkgs' default extension set. It already contains every
# module Nextcloud lists as required or recommended (ctype, curl, dom,
# fileinfo, gd, intl, mbstring, openssl, posix, session, simplexml,
# xmlreader, xmlwriter, zip, zlib, pdo_pgsql, pdo_mysql, bcmath, gmp,
# exif, sodium, sysvsem, pcntl, ...). OPcache is compiled into PHP >= 8.5
# and no longer appears as a separate extension.
extensions = { enabled, all }: enabled ++ (with all; [
bz2 # Nextcloud: bz2 archive support
apcu # Nextcloud: memcache.local (apc.enable_cli=1 below is mandatory for occ + cron)
redis # Nextcloud: memcache.distributed / file locking once a Redis server is configured
imagick # Nextcloud: previews + theming (nixpkgs ImageMagick is built with SVG support)
memcached # WordPress object-cache plugins (legacy option for Nextcloud)
]);
custom-php = pkgs.php83.buildEnv {
extensions = { enabled, all }: enabled ++ (with all; [ bz2 apcu redis imagick memcached ]);
extraConfig = '' extraConfig = ''
; ── Error handling (production) ─────────────────────────────────
display_errors = Off
display_startup_errors = Off
log_errors = On
display_errors = On ; ── Limits ──────────────────────────────────────────────────────
display_startup_errors = On
max_execution_time = 10000 max_execution_time = 10000
max_input_time = 3000 max_input_time = 3000
memory_limit = 1G; memory_limit = 1G
opcache.enable=1;
opcache.memory_consumption=512;
opcache_revalidate_freq = 240;
opcache.max_accelerated_files=20000;
post_max_size = 3G post_max_size = 3G
upload_max_filesize = 3G upload_max_filesize = 3G
apc.enable_cli=1
; ── OPcache (Nextcloud "Server tuning" recommendations) ─────────
opcache.enable = 1
opcache.memory_consumption = 512
opcache.interned_strings_buffer = 192 opcache.interned_strings_buffer = 192
opcache.max_accelerated_files = 20000
opcache.revalidate_freq = 240
opcache.save_comments = 1
; ── APCu ────────────────────────────────────────────────────────
apc.enable_cli = 1
; ── phpredis session locking (only used with session.save_handler = redis)
redis.session.locking_enabled = 1 redis.session.locking_enabled = 1
redis.session.lock_retries = -1 redis.session.lock_retries = -1
redis.session.lock_wait_time = 10000 redis.session.lock_wait_time = 10000
''; '';
}; };
in in
@@ -55,3 +96,4 @@ in
]; ];
}; };
} }
+33
View File
@@ -94,16 +94,23 @@ EOF
# ── Synapse service ───────────────────────────────────────── # ── Synapse service ─────────────────────────────────────────
services.matrix-synapse = { services.matrix-synapse = {
enable = true; enable = true;
# cache-memory provides cache-size statistics for the autotuning below
# (in addition to the NixOS defaults).
extras = [ "systemd" "postgres" "url-preview" "cache-memory" ];
extraConfigFiles = [ extraConfigFiles = [
"/run/matrix-synapse/runtime-config.yaml" "/run/matrix-synapse/runtime-config.yaml"
]; ];
settings = { settings = {
database = { database = {
name = "psycopg2"; name = "psycopg2";
# Recycle pooled connections less often (fewer reconnects).
txn_limit = 10000;
args = { args = {
host = "localhost"; host = "localhost";
database = "matrix-synapse"; database = "matrix-synapse";
user = "matrix-synapse"; user = "matrix-synapse";
cp_min = 5;
cp_max = 15;
}; };
}; };
push.include_content = false; push.include_content = false;
@@ -120,6 +127,32 @@ EOF
]; ];
presence.enabled = true; presence.enabled = true;
enable_registration = false; enable_registration = false;
# ── Performance (32 GB Server + Desktop) ─────────────────
# Synapse trades RAM for fewer Postgres round-trips; most RAM goes
# to caches. Stock is global_factor 0.5 + 10K event cache, which
# leaves syncs hitting the database on every request.
# Deliberately unchanged: presence and URL previews stay enabled —
# disabling them is faster but changes user-visible behavior.
event_cache_size = "100K";
caches = {
global_factor = 4.0;
expire_caches = true;
cache_entry_ttl = "30m";
sync_response_cache_duration = "2m";
cache_autotuning = {
max_cache_memory_usage = "2G";
target_cache_memory_usage = "1G";
min_cache_ttl = "30s";
};
per_cache_factors = {
# Hot paths for /sync and room joins.
get_users_in_room = 3.0;
get_current_state_ids = 3.0;
get_unread_event_push_actions_by_room_for_user = 5.0;
};
};
# Fewer GC pauses at the cost of a little more memory.
gc_thresholds = [ 1500 20 10 ];
listeners = [ listeners = [
{ {
port = 8008; port = 8008;
+110
View File
@@ -0,0 +1,110 @@
"""Guards for the LAN-only Caddy sites.
The Hub (http://sovransystemsos.local), Ride The Lightning (:3051) and Mempool
(:60847) sites are for the home network. Caddy has to check where a request comes
from because forwarding ports 80/443 on the router lets other clients reach it too.
The domain sites for the operator's own public services must stay public.
These read modules/core/caddy.nix like the nix-file checks in test_security.py:
nothing is run and nothing touches the network.
"""
import ipaddress
import os
import re
import unittest
_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
# What Caddy's "private_ranges" shortcut expands to (see the remote_ip matcher docs).
_PRIVATE_RANGES = ["192.168.0.0/16", "172.16.0.0/12", "10.0.0.0/8",
"127.0.0.1/8", "fd00::/8", "::1"]
_LAN_SITES = ("http://sovransystemsos.local", ":3051", ":60847")
def _read(*parts):
with open(os.path.join(_ROOT, *parts), encoding="utf-8") as f:
return f.read()
def _site(src, address):
m = re.search(r"^" + re.escape(address) + r" \{\n(.*?)^\}$", src, re.S | re.M)
return m.group(1) if m else None
def _snippet(src):
m = re.search(r"^\(sovran_lan_only\) \{\n(.*?)^\}$", src, re.S | re.M)
return m.group(1) if m else None
def _allowed_networks(snippet):
m = re.search(r"^\s*@outside not remote_ip (.+)$", snippet, re.M)
assert m, "the @outside matcher is missing"
nets = []
for token in m.group(1).split():
for cidr in (_PRIVATE_RANGES if token == "private_ranges" else [token]):
nets.append(ipaddress.ip_network(cidr, strict=False))
return nets
def _is_allowed(nets, address):
ip = ipaddress.ip_address(address)
return any(ip.version == n.version and ip in n for n in nets)
class LanOnlySites(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.src = _read("modules", "core", "caddy.nix")
def test_lan_sites_use_the_guard_before_they_proxy(self):
for address in _LAN_SITES:
with self.subTest(site=address):
body = _site(self.src, address)
self.assertIsNotNone(body, f"{address} site not found")
self.assertIn("import sovran_lan_only", body)
self.assertLess(body.index("import sovran_lan_only"),
body.index("reverse_proxy"))
def test_only_the_lan_sites_use_the_guard(self):
self.assertEqual(self.src.count("import sovran_lan_only"), len(_LAN_SITES))
public = re.findall(r"^\$[A-Z]+ \{\n(.*?)^\}$", self.src, re.S | re.M)
self.assertGreaterEqual(len(public), 6, "domain sites not found")
for body in public:
self.assertNotIn("sovran_lan_only", body)
# the Matrix site that Element calling writes instead of the plain one
self.assertNotIn("sovran_lan_only", _read("modules", "element-calling.nix"))
def test_guard_closes_the_connection_for_everyone_else(self):
snippet = _snippet(self.src)
self.assertIsNotNone(snippet, "(sovran_lan_only) snippet not found")
self.assertRegex(snippet, r"(?m)^\s*abort @outside\s*$")
# defined before the first site that imports it
self.assertLess(self.src.index("(sovran_lan_only) {"),
self.src.index("import sovran_lan_only"))
def test_guard_ranges(self):
nets = _allowed_networks(_snippet(self.src))
for address in ("127.0.0.1", "::1", "10.0.0.1", "172.16.0.1", "172.31.255.254",
"192.168.1.10", "100.64.0.1", "100.127.255.254", "169.254.1.1",
"fd12:3456::1", "fe80::1", "fc00::1"):
with self.subTest(allowed=address):
self.assertTrue(_is_allowed(nets, address))
for address in ("8.8.8.8", "1.1.1.1", "203.0.113.9", "9.255.255.255", "11.0.0.1",
"172.15.255.255", "172.32.0.1", "192.169.0.1", "100.63.255.255",
"100.128.0.1", "169.253.255.255"):
with self.subTest(refused=address):
self.assertFalse(_is_allowed(nets, address))
def test_ipv6_global_addresses_are_not_filtered(self):
# Computers on the home network often connect over their own global IPv6
# address, which cannot be told apart from the internet's by address alone.
# If this is ever tightened, LAN clients on IPv6 networks lose the Hub.
nets = _allowed_networks(_snippet(self.src))
self.assertTrue(_is_allowed(nets, "2001:db8::5"))
if __name__ == "__main__":
unittest.main()
+249
View File
@@ -0,0 +1,249 @@
"""Tests for the DDNS runner (sovran_systemsos_web.ddns_update).
The runner asks Njal.la to use the address the request came from ("&auto"),
reads back the address Njal.la recorded and saves it for LiveKit and the Hub.
Tests must never:
- access the network (curl is replaced by a fake ``run``)
- write to system paths (the URL and IP files live in a temp dir)
"""
import contextlib
import io
import json
import os
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
_APP_PARENT = os.path.join(_REPO_ROOT, "app")
if _APP_PARENT not in sys.path:
sys.path.insert(0, _APP_PARENT)
from sovran_systemsos_web import ddns_update as d # noqa: E402
KEY = "SECRETKEY123"
AUTO_URL = f"https://njal.la/update/?h=sub.example.com&k={KEY}&auto"
LEGACY_URL = f"https://njal.la/update/?h=sub.example.com&k={KEY}&a=${{IP}}"
PUBLIC_IP = "93.184.216.34"
OTHER_IP = "8.8.4.4"
def reply(ip=PUBLIC_IP, status=200):
return json.dumps({"status": status, "message": "record updated", "value": {"A": ip}})
class FakeRun:
"""Stands in for subprocess.run; records every command it is given."""
def __init__(self, stdout=None, returncode=0, raises=None):
self.stdout = reply() if stdout is None else stdout
self.returncode = returncode
self.raises = raises
self.calls = []
def __call__(self, cmd, **kwargs):
self.calls.append(cmd)
if self.raises:
raise self.raises
return subprocess.CompletedProcess(cmd, self.returncode, stdout=self.stdout, stderr="")
class NormaliseUrlTests(unittest.TestCase):
def test_legacy_placeholder_becomes_auto(self):
self.assertEqual(d.normalise_url(LEGACY_URL), AUTO_URL)
def test_quiet_is_dropped_so_the_reply_can_be_read(self):
self.assertEqual(d.normalise_url(AUTO_URL + "&quiet"), AUTO_URL)
def test_plain_auto_is_unchanged(self):
self.assertEqual(d.normalise_url(AUTO_URL), AUTO_URL)
def test_explicit_address_is_unchanged(self):
url = "https://njal.la/update/?h=a.example.com&k=K&a=93.184.216.34"
self.assertEqual(d.normalise_url(url), url)
class IsPublicIpv4Tests(unittest.TestCase):
def test_public_addresses(self):
for ip in ("93.184.216.34", "8.8.8.8", " 1.1.1.1\n"):
self.assertTrue(d.is_public_ipv4(ip), ip)
def test_everything_else_is_rejected(self):
for ip in ("10.0.0.1", "192.168.1.5", "172.16.0.9", "127.0.0.1", "169.254.1.1",
"100.64.0.1", "0.0.0.0", "224.0.0.1", "::1", "2001:4860:4860::8888",
"not-an-ip", "", None):
self.assertFalse(d.is_public_ipv4(ip), ip)
class ParseReplyTests(unittest.TestCase):
def test_success_returns_the_recorded_address(self):
self.assertEqual(d.parse_reply(reply()), PUBLIC_IP)
def test_status_may_be_a_string(self):
self.assertEqual(d.parse_reply(reply(status="200")), PUBLIC_IP)
def test_error_status_is_rejected(self):
body = json.dumps({"status": 401, "message": "invalid host or key"})
self.assertIsNone(d.parse_reply(body))
def test_garbage_is_rejected(self):
for body in ("", "not json", "[]", "null", "{}", json.dumps({"status": 200})):
self.assertIsNone(d.parse_reply(body), body)
def test_non_public_or_non_ipv4_address_is_rejected(self):
for ip in ("10.1.2.3", "100.64.9.9", "127.0.0.1", "::1", "2001:4860:4860::8888", "x"):
self.assertIsNone(d.parse_reply(reply(ip)), ip)
class IpFileTests(unittest.TestCase):
def test_write_then_read(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "secrets", "external-ip")
d.write_ip_file(PUBLIC_IP, path)
self.assertEqual(d.read_ip_file(path), PUBLIC_IP)
self.assertEqual(open(path).read(), PUBLIC_IP) # no trailing newline
self.assertEqual(os.stat(path).st_mode & 0o777, 0o644)
def test_replace_is_atomic_and_leaves_no_temp_files(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "external-ip")
d.write_ip_file(PUBLIC_IP, path)
d.write_ip_file(OTHER_IP, path)
self.assertEqual(d.read_ip_file(path), OTHER_IP)
self.assertEqual(os.listdir(tmp), ["external-ip"])
def test_missing_file_reads_as_none(self):
with tempfile.TemporaryDirectory() as tmp:
self.assertIsNone(d.read_ip_file(os.path.join(tmp, "nope")))
class UpdateAllTests(unittest.TestCase):
def run_update(self, urls, fake):
out = io.StringIO()
with contextlib.redirect_stdout(out):
result = d.update_all(urls, run=fake)
return result, out.getvalue()
def test_curl_is_called_directly_with_ipv4_and_no_redirects(self):
fake = FakeRun()
result, _ = self.run_update([AUTO_URL], fake)
self.assertEqual(result, PUBLIC_IP)
self.assertEqual(len(fake.calls), 1)
cmd = fake.calls[0]
self.assertEqual(cmd[0], "curl")
for flag in ("--ipv4", "--no-location", "--fail", "--silent"):
self.assertIn(flag, cmd)
self.assertEqual(cmd[-1], AUTO_URL)
def test_legacy_entry_and_its_auto_twin_are_one_call(self):
fake = FakeRun()
self.run_update([LEGACY_URL, AUTO_URL], fake)
self.assertEqual(fake.calls[0][-1], AUTO_URL)
self.assertEqual(len(fake.calls), 1)
def test_url_for_another_host_is_never_called(self):
fake = FakeRun()
result, _ = self.run_update([f"https://evil.example/update/?h=x&k={KEY}&auto"], fake)
self.assertIsNone(result)
self.assertEqual(fake.calls, [])
def test_failed_curl_yields_nothing(self):
result, _ = self.run_update([AUTO_URL], FakeRun(returncode=22))
self.assertIsNone(result)
def test_reply_without_an_address_yields_nothing(self):
result, _ = self.run_update([AUTO_URL], FakeRun(stdout=json.dumps({"status": 200})))
self.assertIsNone(result)
def test_missing_curl_is_survived(self):
result, out = self.run_update([AUTO_URL], FakeRun(raises=FileNotFoundError("curl")))
self.assertIsNone(result)
self.assertIn("skipped", out)
def test_first_reported_address_wins(self):
calls = iter([reply(PUBLIC_IP), reply(OTHER_IP)])
def fake(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 0, stdout=next(calls), stderr="")
other = f"https://njal.la/update/?h=other.example.com&k={KEY}&auto"
out = io.StringIO()
with contextlib.redirect_stdout(out):
result = d.update_all([AUTO_URL, other], run=fake)
self.assertEqual(result, PUBLIC_IP)
def test_the_key_is_never_printed(self):
for fake in (FakeRun(), FakeRun(returncode=22), FakeRun(stdout="junk"),
FakeRun(raises=FileNotFoundError("curl"))):
_, out = self.run_update([AUTO_URL, LEGACY_URL], fake)
self.assertNotIn(KEY, out)
class MainTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.urls_file = os.path.join(self.tmp.name, "ddns_urls.json")
self.ip_file = os.path.join(self.tmp.name, "secrets", "external-ip")
for patch in (mock.patch.object(d, "URLS_FILE", self.urls_file),
mock.patch.object(d, "IP_FILE", self.ip_file)):
patch.start()
self.addCleanup(patch.stop)
def store(self, urls):
with open(self.urls_file, "w") as f:
json.dump(urls, f)
def main(self, fake):
out = io.StringIO()
with mock.patch.object(d.subprocess, "run", fake), contextlib.redirect_stdout(out):
code = d.main()
self.assertEqual(code, 0)
return out.getvalue()
def test_first_update_records_the_address(self):
self.store([LEGACY_URL])
out = self.main(FakeRun())
self.assertEqual(d.read_ip_file(self.ip_file), PUBLIC_IP)
self.assertIn("now " + PUBLIC_IP, out)
self.assertNotIn(KEY, out)
def test_unchanged_address_does_not_touch_the_file(self):
# A path unit restarts LiveKit whenever the file is written, so an
# unchanged address must not rewrite it.
self.store([AUTO_URL])
self.main(FakeRun())
before = os.stat(self.ip_file)
out = self.main(FakeRun())
after = os.stat(self.ip_file)
self.assertEqual((before.st_ino, before.st_mtime_ns), (after.st_ino, after.st_mtime_ns))
self.assertIn("unchanged", out)
def test_changed_address_is_recorded(self):
self.store([AUTO_URL])
self.main(FakeRun(stdout=reply(PUBLIC_IP)))
out = self.main(FakeRun(stdout=reply(OTHER_IP)))
self.assertEqual(d.read_ip_file(self.ip_file), OTHER_IP)
self.assertIn(f"now {OTHER_IP} (was {PUBLIC_IP})", out)
def test_failed_update_keeps_the_last_known_address(self):
self.store([AUTO_URL])
self.main(FakeRun(stdout=reply(PUBLIC_IP)))
self.main(FakeRun(returncode=7))
self.assertEqual(d.read_ip_file(self.ip_file), PUBLIC_IP)
def test_nothing_configured_does_nothing(self):
fake = FakeRun()
self.main(fake) # no URL file at all
self.store([])
self.main(fake) # empty list
self.assertEqual(fake.calls, [])
self.assertFalse(os.path.exists(self.ip_file))
if __name__ == "__main__":
unittest.main()
+68
View File
@@ -0,0 +1,68 @@
"""Guards for the home-IP warnings.
Server + Desktop publishes the home IP address (the domain points at it), so every
place that offers Server + Desktop must say so, and the README section they point
at must exist.
These read the shipped source files like the nix-file checks in test_security.py:
nothing is run and nothing touches the network.
"""
import os
import re
import unittest
_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
_PHRASE = "home ip address"
def _read(*parts):
with open(os.path.join(_ROOT, *parts), encoding="utf-8") as f:
return f.read()
def _github_slug(heading):
slug = re.sub(r"[^\w\- ]", "", heading.strip().lower())
return slug.replace(" ", "-")
class HomeIpWarnings(unittest.TestCase):
def test_installer_role_card(self):
src = _read("iso", "installer.py")
card = re.search(r'\("Server \+ Desktop",\s*"((?:[^"\\]|\\.)*)"', src, re.S)
self.assertIsNotNone(card, "Server + Desktop role card not found")
self.assertIn(_PHRASE, card.group(1).lower())
def test_hub_domain_setup_text(self):
# domain-prereqs.js is the single source for onboarding, feature setup
# and domain reconfiguration; the notice must follow every variant.
js = _read("app", "sovran_systemsos_web", "static", "js", "domain-prereqs.js")
body = js[js.index("function renderDomainNeedsHtml"):]
body = body[:body.index("\n}\n")]
self.assertIn(_PHRASE, body.lower())
self.assertGreater(body.lower().index(_PHRASE), body.rindex("} else {"),
"the notice must come after the last variant, not inside one")
def test_hub_upgrade_dialog(self):
html = _read("app", "sovran_systemsos_web", "templates", "index.html")
dialog = html[html.index('id="upgrade-modal"'):html.index("Security Reset overlay")]
self.assertIn(_PHRASE, " ".join(dialog.lower().split()))
def test_readme_and_security_policy(self):
self.assertIn(_PHRASE, _read("README.md").lower())
self.assertIn(_PHRASE, " ".join(_read("SECURITY.md").lower().split()))
def test_links_to_the_readme_section_resolve(self):
readme = _read("README.md")
slugs = {_github_slug(m.group(2))
for m in re.finditer(r"^(#{1,6})\s+(.+?)\s*$", readme, re.M)}
links = re.findall(r"\]\(#(server--desktop[^)]*)\)", readme)
links += re.findall(r"README\.md#(server--desktop[^)\s]*)", _read("SECURITY.md"))
self.assertTrue(links, "expected links to the home-IP section")
for anchor in links:
self.assertIn(anchor, slugs)
if __name__ == "__main__":
unittest.main()