sshd-localhost.nix turns sshd on for every role so that "ssh root@localhost"
works, and binds it to 127.0.0.1 only ("zero network exposure", as its
own comment says, and as sshd.nix expects: the sshd feature "extends this
to 0.0.0.0 and opens port 22 on the firewall when the user enables remote
SSH").
It never turned off services.openssh.openFirewall, which NixOS defaults
to true and applies whether or not sshd listens on the ports. So port 22
was open in the firewall on every role, Desktop Only included, with
nothing behind it. No listener means no live exposure today; it does mean
the firewall was not saying what the documentation says, and the day
something does bind 22 on a wider address (a listenAddresses change, a
second daemon) it would be reachable without anyone having opened it.
openFirewall is now mkDefault false there. Nothing that wants SSH
published loses it: sshd.nix (feature sshd) and remote-deploy.nix both
add 22 explicitly, and only when enabled. Found by evaluating the real
module set; grepping for allowedTCPPorts does not see a NixOS default.
Evaluated with nix eval, TCP firewall ports:
before after
Server + Desktop 22 80 443 3051 8937 80 443 3051 8937
Bitcoin Node Only 22 3051 8937 60847 3051 8937 60847
Desktop Only 22 (none)
Desktop + features.sshd 22 22
Desktop + deploy.enable 22 3389 22 3389
and sshd's listen addresses are unchanged: 127.0.0.1 by default,
127.0.0.1 and 0.0.0.0 with the sshd feature. Desktop Only now opens no
TCP port at all; UDP 5353 (mDNS) is the only port open there, and
SECURITY.md says so.
Add tests/test_ssh_exposure.py.
allowedUDPPorts was set to [ 3051 ] alongside allowedTCPPorts, which looks
like it was copied from the line above. Caddy serves Ride The Lightning
over TCP on 3051; nothing listens for UDP there, so the rule only opened a
port for no reason.
The comment above the pair also said "Hub management port"; 3051 is RTL.
Evaluated with nix eval, UDP firewall ports per role: Server + Desktop
80 443 3051 5353 -> 80 443 5353, Bitcoin Node Only 3051 5353 -> 5353,
Desktop Only 5353. (80 and 443 are Caddy's; 5353 is mDNS.)
generate_diceware_password() built the password from 3 words out of a 96
word list plus a single digit: 96^3 x 10 = 8,847,360 combinations, about
23 bits.
That one password is the desktop login, the 'free' account password, and
the only thing standing in front of the Hub, which runs as root and
displays the root password, the SSH passphrase, the RTL password and the
Vaultwarden admin token. 23 bits is thin for something that valuable, and
the rate limiting in front of it was weaker than intended (see "hub: make
the login lockout that LOGIN_FAIL_MAX described").
Now 4 words plus 2 digits: 96^4 x 100 = 8,493,465,600, about 33 bits,
for the cost of one more word to write down.
- iso/installer.py: generate_diceware_password().
- modules/credentials.nix: the three fallback generators in
root-password-setup, free-password-setup and free-password-migration,
so a machine provisioned without the installer gets the same strength.
Affects new installs only; existing passwords are untouched.
Checked by running the real thing. The installer function was exercised
2000 times: 96 words in the list, always word-word-word-word-NN, 33.0
bits. For the three services, the generator lines were taken from the
script the evaluated module really produces (nix eval on the nixpkgs
revision flake.lock pins) and run 1500 times each: 96 words in each
list, always word-word-word-word-NN, every two-digit suffix from 00 to 99
seen, and no repeated password.
c33457f put an address check (sovran_lan_only) on the Hub, RTL and
Mempool sites. It was written for ports 80/443 being forwarded and a
Host header selecting a site, and that only ever applied to the Hub. RTL
and Mempool are sites on ports of their own (:3051, :60847): a request
on 80/443 cannot select them, whatever Host it carries.
Checked with Caddy 2.9.1 and the Caddyfile this module generates for
Server + Desktop with every domain configured, serving the public sites
on a stand-in port: Host: sovransystemsos.local, localhost:8937,
127.0.0.1 and x:3051 all get an empty 200, and Host: matrix.example.org
gets the Synapse stand-in. With the Hub off Caddy the guard has nothing
left to guard, and it could not be made right for the two sites that
remain:
- IPv6. A laptop's global address on the LAN looks exactly like a
stranger's. The choice was between letting all of 2000::/3 through,
which is the whole IPv6 internet and is what c33457f does, and
refusing every LAN device that connects over a global address unless
the operator copies the ISP's prefix into a Nix option.
- They do not need it. RTL has a random 20-character password
(pwgen -s 20, about 119 bits) and, since 0.15.12, which Sovran_Bitcoin
pins, a 30-minute lockout keyed on the client address. Mempool shows
public chain data. If someone forwards 3051 or 60847 that is the same
exposure as any other port on the machine, and SECURITY.md says not to.
Remove the snippet and its two imports, and tests/test_caddy_lan_only.py
with them. What is still worth pinning moves to test_hub_direct.py: no
address filter anywhere in caddy.nix, the two sites are plain proxies to
their loopback ports, and no option for a declared prefix is left
half-wired.
Behaviour change: RTL and Mempool answer any client that can reach :3051
or :60847, as they did before c33457f. In practice that is the local
network, because nothing asks you to forward those ports.
Checked with the real generator and Caddy 2.9.1: Node Only generates
`auto_https off` and the two plain sites and validates. Run live next to
the real Hub, a LAN client gets the Hub, RTL and Mempool; a stranger's
address gets RTL and Mempool (by design) and a 403 from the Hub; port 80
is not listening on Node Only.
Caddy fronted the Hub at http://sovransystemsos.local, but the Hub
already listens on 0.0.0.0:8937 itself, and nothing Caddy added is
something it needs:
- Not the name. That is avahi's: mDNS advertises a hostname, not a port,
so the name resolves wherever the Hub listens.
- Not TLS (the site was plain http), not authentication, not cache
headers. The header block duplicated NoCacheMiddleware, and its
Clear-Site-Data ("cache") overrode the app's stronger ("cache",
"storage").
- Not access control, and this is the point. With ports 80/443 forwarded
for public services, a Host header on those ports reached the Hub. That
second door is how the reported bug happened, and c33457f guards it
with an address check instead of closing it.
The Hub is now served on port 8937 only, at
http://sovransystemsos.local:8937, and Caddy has no site for it. The
only thing Caddy answers on 80/443 is the public sites. Caddy keeps
Ride The Lightning (:3051) and Mempool (:60847), because those do need
it: Sovran_Bitcoin binds both to 127.0.0.1 and RTL's unit is sandboxed to
loopback besides, so Caddy is how the local network reaches them.
- caddy.nix: no Hub site. Caddy runs wherever RTL and Mempool do, which
includes Bitcoin Node Only. There it did not run at all (enable was
needsHttpsPorts || extraVhosts != ""), so :3051 and :60847 were open
in the firewall with nothing listening. Ports 80/443 still follow
needsHttpsPorts alone, so Node Only does not open them. The two sites
are written only where their service exists; they were unconditional.
- sovran-hub.nix: 8937 follows the new hub.directPort, 60847 follows
Mempool. It used to be `[ 8937 60847 ]` on every role, Desktop Only
included.
- roles.nix: hub.directPort defaults to !roles.desktop: open on Server +
Desktop and Bitcoin Node Only, closed on Desktop Only, where the Hub is
reached from the machine itself through the desktop window on
localhost.
- The bind stays 0.0.0.0, which is IPv4 only: with that bind [::1]:8937
is refused and "localhost" falls back to 127.0.0.1. That is on purpose
and is now said in the comment. An IPv6 listener would let in clients
whose global addresses the Hub cannot tell from a stranger's, which is
the question the previous commit declines to answer by guessing.
- README, SECURITY.md and two strings in index.html give the new URL.
Behaviour changes: the Hub's address gains :8937, and http://sovransystemsos.local
on port 80 no longer reaches it. Bitcoin Node Only now runs Caddy.
Evaluated with nix eval (nixpkgs as flake.lock pins it, Sovran_Bitcoin at
the locked revision), firewall TCP ports per role:
c33457f this commit
Server + Desktop 22 80 443 3051 8937 60847 22 80 443 3051 8937
Bitcoin Node Only 22 3051 8937 60847 22 3051 8937 60847 (Caddy now runs)
Desktop Only 22 8937 60847 22
Port 22 is open on every role although sshd listens on loopback only;
the last commit of this series deals with that.
The Caddyfile the module really generates (the evaluated generator
script, run, then `caddy validate` with Caddy 2.9.1): Node Only gets the
two sites and nothing else; Server + Desktop with every domain
configured gets the seven domain sites plus :3051 and :60847 and no
mention of the Hub; with Bitcoin off there are no local-network sites;
Node Only with Bitcoin off and no domains leaves Caddy off.
Add tests/test_hub_direct.py and keep tests/test_caddy_lan_only.py for
the two sites it still covers.
The reported bug was the Hub being reachable from outside the local
network when Server + Desktop is active. c33457f guards the Caddy site
for sovransystemsos.local, but the Hub is an application that also
listens on a port of its own (8937), and a check in Caddy does nothing
for a client that never goes through Caddy. Whether a client could reach
the Hub depended on which door it used.
The Hub now asks the question itself. LanOnlyMiddleware is registered
outermost, so a client that is not on this computer or the local network
gets a bare 403 before authentication is considered; it never sees the
login page.
- Local means loopback, 10/8, 172.16/12, 192.168/16, 100.64/10
(Tailscale and other CGNAT/VPN ranges) and 169.254/16 over IPv4, and
::1, fc00::/7 and fe80::/10 over IPv6.
- IPv6 global addresses are not on the list. A global address belonging
to a laptop on the LAN cannot be told apart from a stranger's by the
address alone, and 2000::/3 is every public IPv6 address there is.
- ::ffff:a.b.c.d is read as the IPv4 address inside it.
- sovran_systemsOS.hub.extraLanNetworks adds networks (IPv4 or IPv6 CIDR)
for setups whose own devices use addresses outside those ranges. It is
checked at build time. The app ignores an entry it cannot parse and
refuses 0.0.0.0/0 and ::/0: it must never widen its policy by
guessing, and "everyone" is hub.lanOnly = false, asked for by name.
- sovran_systemsOS.hub.lanOnly (default true) turns the check off.
- The first refusal from each address is logged, naming the option to
change, so an operator whose own device is refused can find out why.
The list is capped so a scanner cannot fill the journal or memory.
Behind Caddy the policy applies to the real client, not to Caddy: uvicorn
takes the address from X-Forwarded-For only when the peer is 127.0.0.1.
Checked, not only reviewed:
- The real app with the config.json the module really generates (nix
eval on the nixpkgs revision flake.lock pins, read back from the
derivation), on a real socket with the source address chosen per
request: 127.0.0.1, 192.168/16, 100.64/10 and a declared extra
network are served; 203.0.113.9, 8.8.4.4 and an address just outside
the declared /28 get 403 on /login, / and /api/ping. /auto-login still
answers 303 to loopback and 403 to everyone else.
- With c33457f's Caddy guard in front, an IPv6 client at 2001:db8::9
passes Caddy (it is inside 2000::/3) and is refused by the Hub; an
IPv4 stranger has the connection closed by Caddy; a LAN client is
served.
- hub.extraLanNetworks accepts 203.0.113.0/28, 2001:db8:abcd::/48 and
bare hosts, and fails the build for /33, 300.1.1.1/8, 0.0.0.0/0, ::/0,
2001:db8::/129 and junk, with a message that says what to write.
Add tests/test_lan_policy.py and tests/test_hub_lan_only.py, and a note
in SECURITY.md.
LOGIN_FAIL_MAX was declared as "max failures in window before extra
delay" and then never read anywhere in the file. _record_failure() only
ever slept a flat LOGIN_FAIL_DELAY. So the Hub's entire defence against
online guessing was a constant 2 second pause per wrong password: no
escalation, no lockout, no ban, and fail2ban is configured for SSH only.
Worse, the old 60 second window could not have worked even if the
constant had been wired up. With a delay per attempt, reaching 10
failures takes about 80 seconds, so the earliest failures aged out of the
window before the count could ever reach the limit.
- security_helpers.py: new LoginThrottle. The delay ramps with the
failure count (2s, 4s, ... capped at 10s), and once LOGIN_FAIL_MAX
failures land inside the window the address is refused outright for
LOGIN_LOCKOUT_SECONDS (5 minutes). A successful login clears the
address, so an operator who fumbles a password is not penalised later.
The sleep is never taken under the lock, so one slow client cannot
stall every other login. Tracked addresses are evicted, so a
distributed sweep cannot grow the table without bound.
The window moves from 60s to 900s so the whole ramp fits inside it.
clock and sleep are injectable, which is what makes it testable.
- server.py: /api/login checks the lockout before the scrypt hash, so a
locked-out client costs almost nothing to reject, and answers 429 with
a human-readable wait instead of a bare 401.
- tests/test_login_throttle.py: covers the ramp, the cap, the lockout
firing and expiring, per-address isolation, clearing on success,
eviction, and that the limit is actually reachable inside the window.
Verified against the real app with TestClient: 10 wrong passwords return
401 and the 11th returns 429 "Too many failed attempts. Try again in
about 5 minute(s)." A correct password clears the counter.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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
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.
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
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.
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
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.
- 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.
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).
- 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.
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.
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.
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.
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.