Compare commits
53
Commits
v1.1.4
...
staging-dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e56b07465 | ||
|
|
2867954808 | ||
|
|
c20a47bab7 | ||
|
|
708b502a17 | ||
|
|
cc566016d1 | ||
|
|
3a87302645 | ||
|
|
b79a6fd7f2 | ||
|
|
3694ea6489 | ||
|
|
6987d9bf2c | ||
|
|
ebcc17ae3c | ||
|
|
78bfc5b408 | ||
|
|
361b25a8bd | ||
|
|
7d784eb653 | ||
|
|
c33457fff2 | ||
|
|
34cfba4282 | ||
|
|
2d777450e1 | ||
|
|
726fec1990 | ||
|
|
70ccf1eba1 | ||
|
|
f6caa2ff32 | ||
|
|
8bc325b148 | ||
|
|
e31094c194 | ||
|
|
09d4cc9b83 | ||
|
|
3341659a0c | ||
|
|
32e1119e33 | ||
|
|
0f7ef8422d | ||
|
|
dd6042928a | ||
|
|
74405b2ffc | ||
|
|
258da6a337 | ||
|
|
73ab3f40c1 | ||
|
|
0768712bf7 | ||
|
|
2ac30dc10a | ||
|
|
499676569e | ||
|
|
35dbd198b8 | ||
|
|
e3f8a2579a | ||
|
|
9919966070 | ||
|
|
859f25f0c1 | ||
|
|
4c22c2363b | ||
|
|
bbf53d089a | ||
|
|
4476a5e0e2 | ||
|
|
36b77e3f0a | ||
|
|
78ae9e78ed | ||
|
|
61ef286420 | ||
|
|
ca1a5c9eb8 | ||
|
|
6d32bebdc6 | ||
|
|
e6078b9c89 | ||
|
|
ea9d4f21d7 | ||
|
|
d164d423ad | ||
|
|
2013df55bd | ||
|
|
1d46d036c0 | ||
|
|
9ae4e34fe0 | ||
|
|
d1e226a687 | ||
|
|
49e41eeea9 | ||
|
|
88be5b99dd |
@@ -7,6 +7,86 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
---
|
||||
|
||||
## [1.2.0] - 2026-10-02
|
||||
|
||||
### Added
|
||||
- Ssh: don't open port 22 for the loopback-only sshd
|
||||
- Bitcoin: drop the stray UDP 3051 firewall rule
|
||||
- Installer: raise generated password entropy from ~23 to ~33 bits
|
||||
- Caddy: stop filtering the RTL and Mempool sites by client address
|
||||
- Hub: serve the Hub on its own port instead of through Caddy
|
||||
- Hub: answer the local network only, whichever way a client arrives
|
||||
- Hub: make the login lockout that LOGIN_FAIL_MAX described
|
||||
- Caddy: serve the Hub, RTL and Mempool sites to local clients only
|
||||
- Docs, hub, installer: say Server + Desktop makes the home IP public
|
||||
- Ddns: take the public IP from Njal.la only and give it to LiveKit
|
||||
|
||||
### Changed
|
||||
- Updated nixpkgs and Sovran_Bitcoin update and the new Bisq 1.10.9
|
||||
[1.2.0]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.2.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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -14,16 +14,16 @@ digital life — your files, communications, passwords, and websites — all on
|
||||
hardware you control, running auditable open-source software you can trust.
|
||||
|
||||
Every installation is a private [NixOS](https://nixos.org) desktop with
|
||||
[Sparrow Wallet](https://sparrowwallet.com), [Bisq](https://bisq.network), and
|
||||
[Bisq 2](https://github.com/bisq-network/bisq2) ready to use. Move beyond
|
||||
**[Sparrow Wallet](https://sparrowwallet.com)**, **[Bisq](https://bisq.network)**,
|
||||
and **[Bisq 2](https://github.com/bisq-network/bisq2)** ready to use. Move beyond
|
||||
custodial exchanges from the first boot, and grow into your own Bitcoin node,
|
||||
Lightning infrastructure, private cloud, and communications platform when you
|
||||
are ready.
|
||||
|
||||
[Visit the Website](https://sovransystems.com) ·
|
||||
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.4.iso) ·
|
||||
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.0.iso) ·
|
||||
[Try it safely in a VM](#try-it-first-in-a-virtual-machine) ·
|
||||
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.4.iso.sha256) ·
|
||||
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.0.iso.sha256) ·
|
||||
[Build from Source](#build-from-source)
|
||||
|
||||
<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)
|
||||
- [Three modes](#three-modes)
|
||||
- [Use it your way](#use-it-your-way)
|
||||
- [The Sovran Hub](#the-sovran-hub)
|
||||
- [The Hub](#the-hub)
|
||||
- [Install Sovran_SystemsOS](#install-sovran_systemsos)
|
||||
- [For developers](#for-developers)
|
||||
- [Development workflow](#development-workflow)
|
||||
@@ -61,18 +61,18 @@ are ready.
|
||||
## Why Sovran_SystemsOS?
|
||||
|
||||
Bitcoin lets you hold and transfer value without asking a bank, exchange, or
|
||||
custodian for permission. But that freedom depends on the software and
|
||||
custodian for permission, but that freedom depends on the software and
|
||||
infrastructure you choose. Your wider digital life works the same way: files,
|
||||
messages, and passwords kept on someone else's servers are never fully yours.
|
||||
|
||||
Sovran_SystemsOS solves both with one operating system:
|
||||
|
||||
- **Hold your own keys** with Sparrow Wallet. Create and manage wallets,
|
||||
- **Hold your own keys** with **Sparrow Wallet**. Create and manage wallets,
|
||||
connect hardware signing devices, use multisignature setups, build and
|
||||
inspect transactions, and control UTXOs and coin selection.
|
||||
- **Buy and sell Bitcoin peer-to-peer** with Bisq and Bisq 2. No central
|
||||
company holds user funds, and no exchange account stands between buyers and
|
||||
sellers.
|
||||
- **Buy and sell Bitcoin peer-to-peer** with **Bisq** and **Bisq 2**. No
|
||||
central company holds user funds, and no exchange account stands between
|
||||
buyers and sellers.
|
||||
- **Verify your own Bitcoin** with a full node: [Bitcoin Core](https://bitcoin.org) and
|
||||
[Electrs](https://github.com/romanz/electrs), so your wallets connect to
|
||||
*your* node instead of a stranger's.
|
||||
@@ -90,16 +90,11 @@ Sovran_SystemsOS solves both with one operating system:
|
||||
- **Control everything from the Sovran Hub**, on the desktop or from any
|
||||
browser on your local network.
|
||||
|
||||
No custodian needs to hold your Bitcoin. No outside node needs to tell your
|
||||
wallet what happened on the network. No third-party cloud needs to control
|
||||
your data or services.
|
||||
|
||||
Other projects solve one piece of this puzzle: a Linux distribution that can
|
||||
run a wallet, a node project that runs Bitcoin services, a self-hosting stack
|
||||
that replaces a cloud app. Sovran_SystemsOS brings those worlds together and
|
||||
makes them approachable: Bitcoin tools from the first boot, a complete path
|
||||
from desktop to node to self-hosting, one control center, hardware you own,
|
||||
and a reproducible, auditable NixOS foundation.
|
||||
Other projects solve one piece of this puzzle: a distribution that runs a
|
||||
wallet, a node project that runs Bitcoin services, a self-hosting stack that
|
||||
replaces a cloud app. Sovran_SystemsOS brings them together on a reproducible,
|
||||
auditable NixOS foundation: Bitcoin tools from the first boot, one control
|
||||
center, and hardware you own.
|
||||
|
||||
> Sovran_SystemsOS provides tools for self-custody and peer-to-peer Bitcoin
|
||||
> use. Users remain responsible for protecting their keys, understanding their
|
||||
@@ -110,34 +105,24 @@ and a reproducible, auditable NixOS foundation.
|
||||
## Try it first in a virtual machine
|
||||
|
||||
**Curious, but not ready to replace your current operating system?** Start with
|
||||
Sovran_SystemsOS in a virtual machine (VM). A VM runs Sovran_SystemsOS in a
|
||||
window on your existing Windows, macOS, or Linux computer, using a virtual disk
|
||||
file instead of your computer's internal drive. You can explore the desktop,
|
||||
Sovran Hub, Sparrow, Bisq, and the installation experience before changing how
|
||||
you use any physical machine.
|
||||
Sovran_SystemsOS in a virtual machine (VM): a window on your existing Windows,
|
||||
macOS, or Linux computer, using a virtual disk file instead of your internal
|
||||
drive. Explore the desktop, Sovran Hub, Sparrow, Bisq, and the installation
|
||||
experience before changing how you use any physical machine. Closing or
|
||||
deleting the VM leaves your current operating system untouched, and installing
|
||||
on a dedicated computer later is still open to you.
|
||||
|
||||
This is a low-commitment way to decide whether Sovran_SystemsOS is right for
|
||||
you:
|
||||
|
||||
- **Keep your current OS.** Closing or deleting the VM leaves the host operating
|
||||
system in place.
|
||||
- **Learn at your own pace.** Familiarize yourself with the desktop and tools
|
||||
without needing to make it your daily computer on day one.
|
||||
- **Choose your next step with confidence.** When you are ready, install it on
|
||||
a dedicated computer, or keep using the VM as a learning environment.
|
||||
|
||||
You can use the same ISO with [VirtualBox](https://www.virtualbox.org), VMware,
|
||||
Use the same ISO with [VirtualBox](https://www.virtualbox.org), VMware,
|
||||
QEMU/KVM, Proxmox, and similar x86_64 VM software. For a first look, select
|
||||
**Desktop Only**, allocate at least **8 GB RAM**, and create a **256 GB or
|
||||
larger dynamically allocated virtual disk**. The full VM setup and installer
|
||||
requirements are in [Installing in a virtual machine](#installing-in-a-virtual-machine-optional).
|
||||
larger dynamically allocated virtual disk**. Full requirements are in
|
||||
[Installing in a virtual machine](#installing-in-a-virtual-machine-optional).
|
||||
|
||||
> **A VM is for evaluation and learning, not a substitute for a dedicated,
|
||||
> hardened setup.** Do not use a trial VM to hold meaningful Bitcoin, recovery
|
||||
> phrases, passwords, or other sensitive data. Avoid attaching physical drives
|
||||
> to the VM, and be deliberate about shared folders, clipboard sharing, and
|
||||
> network settings. The installer only changes the disk you select, but you
|
||||
> should always review VM disk selections before confirming an install.
|
||||
> hardened setup.** Do not hold meaningful Bitcoin, recovery phrases,
|
||||
> passwords, or other sensitive data in a trial VM. Avoid attaching physical
|
||||
> drives, and be deliberate about shared folders, clipboard sharing, and
|
||||
> network settings. Review VM disk selections before confirming an install.
|
||||
|
||||
---
|
||||
|
||||
@@ -201,24 +186,27 @@ Bitcoin and self-hosting infrastructure runs on the machine.
|
||||
| Mode | Best for | What you get |
|
||||
|---|---|---|
|
||||
| **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 |
|
||||
| **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 |
|
||||
| **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. **Publishes nothing unless you enable *Put BTCPay Server Online* or *Lightning Wallet Connections*:** [read this first](#server--desktop-and-your-home-ip-address) |
|
||||
| **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
|
||||
edition. It is a complete, private everyday computer with a clean GNOME
|
||||
desktop, Tor, and the Sovran Hub, giving you a lower-hardware path to
|
||||
self-custody and non-KYC Bitcoin tools from the first boot. You do not need a
|
||||
fully synchronized node to begin; move to Node mode when your hardware,
|
||||
storage, and needs are ready.
|
||||
edition: it is a complete, private everyday computer with a clean GNOME
|
||||
desktop, Tor, and the Sovran Hub, and a lower-hardware path to self-custody and
|
||||
non-KYC Bitcoin tools from the first boot. Move to Node mode when your
|
||||
hardware, storage, and needs are ready.
|
||||
|
||||
**Node: verify your own money.** Instead of asking someone else's server
|
||||
about your wallet and transactions, you operate the infrastructure that
|
||||
performs the verification. Your node verifies. Your wallet connects to it.
|
||||
Your keys remain yours.
|
||||
**Node: verify your own money.** You operate the infrastructure that performs
|
||||
the verification instead of asking someone else's server: your node verifies,
|
||||
your wallet connects to it, your keys stay yours.
|
||||
|
||||
**Server + Desktop: sovereignty beyond money.** Bitcoin sovereignty is the
|
||||
foundation. Server + Desktop applies the same principle to your data,
|
||||
communications, identity, and services.
|
||||
Node publishes nothing by default. Two Node features need a public domain and
|
||||
make your home IP address public when enabled: **Put BTCPay Server Online** and
|
||||
**Lightning Wallet Connections**. Everything else, Mempool and Ride The
|
||||
Lightning included, stays on your home network or reaches you through Tor — see
|
||||
[Server + Desktop and your home IP address](#server--desktop-and-your-home-ip-address).
|
||||
|
||||
**Server + Desktop: sovereignty beyond money.** The same principle, applied
|
||||
to your data, communications, identity, and services.
|
||||
|
||||
### Recommended hardware
|
||||
|
||||
@@ -234,9 +222,55 @@ communications, identity, and services.
|
||||
> the public internet, which means opening specific ports on your home network.
|
||||
> Before you start, confirm three things: you can log in to your router's admin
|
||||
> panel, the panel includes a port-forwarding section, and your internet
|
||||
> provider allows port forwarding. Most home routers and providers already
|
||||
> 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.
|
||||
> provider allows port forwarding.
|
||||
|
||||
### 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,** which
|
||||
> reveals your internet provider and approximate location and 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 Certificate Transparency logs, so hostnames such as
|
||||
> `vault.yourdomain.com` can be found and resolved to your IP address, even
|
||||
> if you never share them.
|
||||
|
||||
**Desktop** publishes nothing. **Node** publishes nothing unless you enable
|
||||
*Put BTCPay Server Online* or *Lightning Wallet Connections* — see
|
||||
[Three modes](#three-modes).
|
||||
|
||||
**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 that up, and the Hub's domain checks 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, which only accepts `njal.la` update URLs.
|
||||
- The `sovran-ddns-update` timer points your record at the address the request
|
||||
came from: after you save a domain, two minutes after boot, then every 15
|
||||
minutes. Element calling and the Hub use that same address. See
|
||||
`modules/core/njalla.nix`.
|
||||
- Once a service that needs a domain is on, the firewall opens TCP and UDP
|
||||
ports 80 and 443 for Caddy, which requests public HTTPS certificates for the
|
||||
domains you configure. The Hub lists any additional ports a feature needs.
|
||||
See `modules/core/caddy.nix`.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
@@ -256,34 +290,30 @@ the tools of your selected mode already in place.
|
||||
Prefer to keep using Windows, macOS, Linux, Android, or iOS? Install
|
||||
Sovran_SystemsOS on a separate computer and let it run quietly on your local
|
||||
network, with or without a monitor. From any other device on the same network,
|
||||
open a browser, visit `http://sovransystemsos.local`, and manage everything
|
||||
from [The Sovran Hub](#the-sovran-hub).
|
||||
|
||||
Your existing devices stay familiar. Sovran_SystemsOS provides the independent
|
||||
infrastructure behind them.
|
||||
open a browser, visit `http://sovransystemsos.local:8937`, and manage
|
||||
everything from [the Hub](#the-hub).
|
||||
|
||||
---
|
||||
|
||||
## The Sovran Hub
|
||||
## The Hub
|
||||
|
||||
### Your private infrastructure, controlled from any screen.
|
||||
|
||||
The Sovran 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
|
||||
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 is the command center built into Sovran_SystemsOS: a local desktop
|
||||
application and a private web interface served by your Sovran_SystemsOS
|
||||
machine. The device opening it needs only a modern browser and access to the
|
||||
same local network.
|
||||
|
||||
From one place, the Hub helps you:
|
||||
From one place, the Hub lets you:
|
||||
|
||||
- Open, monitor, start, and stop your services
|
||||
- See what is running and configure system features
|
||||
- Manage service domains and credentials
|
||||
- Configure system features and manage service domains and credentials
|
||||
- Reach your Bitcoin tools, private cloud, and communications
|
||||
- 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
|
||||
|
||||
@@ -294,13 +324,13 @@ From one place, the Hub helps you:
|
||||
│ │ │
|
||||
Windows laptop Phone or tablet Mac or Linux
|
||||
│ │ │
|
||||
└──────── Browser: sovransystemsos.local ────┘
|
||||
└─────── Browser: sovransystemsos.local:8937 ─┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ Sovran_SystemsOS │
|
||||
│ │
|
||||
│ • Sovran Hub │
|
||||
│ • The Hub │
|
||||
│ • Bitcoin node │
|
||||
│ • Sparrow Wallet │
|
||||
│ • Bisq and Bisq 2 │
|
||||
@@ -312,21 +342,36 @@ From one place, the Hub helps you:
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
Keep using the devices you already own. Sovran_SystemsOS becomes the private
|
||||
Bitcoin and digital infrastructure behind them.
|
||||
|
||||
> **Local access:** the Hub is available at
|
||||
> `http://sovransystemsos.local` to devices connected to the same local
|
||||
> network. It is protected by authentication and is not automatically exposed
|
||||
> to the public internet.
|
||||
> `http://sovransystemsos.local:8937` to devices connected to the same local
|
||||
> network (not on Desktop, which publishes nothing). It is protected by
|
||||
> authentication and answers only your local network.
|
||||
|
||||
### Tech Support
|
||||
|
||||
The Hub can give a Sovran Systems technician temporary access to your machine.
|
||||
Support runs over SSH, and **Tech Support requires SSH Remote Access, which is
|
||||
off by default.**
|
||||
|
||||
Turning SSH on makes the machine listen on all interfaces and opens **TCP port
|
||||
22** in the firewall. Letting support in from outside your home means
|
||||
forwarding port 22 on your router, which puts SSH on the public internet where
|
||||
scanners and brute-force attempts will find it. Turn SSH off when the session
|
||||
ends; the Hub prompts you to.
|
||||
|
||||
Support signs in as the restricted `sovran-support` account, not as root, with
|
||||
one key that expires after 24 hours. Wallet paths stay locked unless you unlock
|
||||
them for a limited time, and every session event is written to
|
||||
`/var/log/sovran-support-audit.log`. See
|
||||
[Restricted support access](SECURITY.md#restricted-support-access).
|
||||
|
||||
---
|
||||
|
||||
## Install Sovran_SystemsOS
|
||||
|
||||
Sovran_SystemsOS is free and open source. You can download the installer,
|
||||
verify it, write it to a USB drive, and install it yourself, staying in
|
||||
control from the very first step.
|
||||
Sovran_SystemsOS is free and open source: download the installer, verify it,
|
||||
write it to a USB drive, and install it yourself, in control from the first
|
||||
step.
|
||||
|
||||
An ISO is a complete installation image containing the operating system and
|
||||
installer. It is not copied to a USB drive like a document; it must be written
|
||||
@@ -345,18 +390,17 @@ with an imaging application such as [Balena Etcher](https://etcher.balena.io).
|
||||
|
||||
### 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.4.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.4.iso.sha256)
|
||||
- [Download Sovran_SystemsOS-1.2.0.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.0.iso)
|
||||
- [Download Sovran_SystemsOS-1.2.0.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.0.iso.sha256)
|
||||
|
||||
The download may take some time. Do not rename or modify the ISO before
|
||||
verifying it, and keep both files in the same folder.
|
||||
|
||||
### 2. Verify the checksum
|
||||
|
||||
A checksum is a digital fingerprint of a file. Verifying it confirms that the
|
||||
ISO downloaded completely, was not accidentally corrupted, and matches the
|
||||
published image. The checksum produced from your ISO must match the published
|
||||
checksum exactly.
|
||||
A checksum is a digital fingerprint of a file. Verifying it confirms the ISO
|
||||
downloaded completely and without corruption. The checksum produced from your
|
||||
ISO must match the published one exactly.
|
||||
|
||||
<details>
|
||||
<summary><strong>Linux</strong></summary>
|
||||
@@ -364,18 +408,15 @@ checksum exactly.
|
||||
Open a terminal in the download folder and run:
|
||||
|
||||
```bash
|
||||
sha256sum --check Sovran_SystemsOS-1.1.4.iso.sha256
|
||||
sha256sum --check Sovran_SystemsOS-1.2.0.iso.sha256
|
||||
```
|
||||
|
||||
A successful comparison reports:
|
||||
|
||||
```text
|
||||
Sovran_SystemsOS-1.1.4.iso: OK
|
||||
Sovran_SystemsOS-1.2.0.iso: OK
|
||||
```
|
||||
|
||||
You can also run `sha256sum Sovran_SystemsOS-1.1.4.iso` and compare the output
|
||||
against the checksum file manually.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -384,11 +425,11 @@ against the checksum file manually.
|
||||
Open Terminal in the download folder and run:
|
||||
|
||||
```bash
|
||||
shasum -a 256 Sovran_SystemsOS-1.1.4.iso
|
||||
shasum -a 256 Sovran_SystemsOS-1.2.0.iso
|
||||
```
|
||||
|
||||
Compare the value shown in Terminal with the value inside
|
||||
`Sovran_SystemsOS-1.1.4.iso.sha256`.
|
||||
`Sovran_SystemsOS-1.2.0.iso.sha256`.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -398,7 +439,7 @@ Compare the value shown in Terminal with the value inside
|
||||
Open PowerShell in the download folder and run:
|
||||
|
||||
```powershell
|
||||
Get-FileHash .\Sovran_SystemsOS-1.1.4.iso -Algorithm SHA256
|
||||
Get-FileHash .\Sovran_SystemsOS-1.2.0.iso -Algorithm SHA256
|
||||
```
|
||||
|
||||
Compare the value under `Hash` with the published checksum.
|
||||
@@ -413,7 +454,7 @@ match exactly.
|
||||
|
||||
1. Download and install [Balena Etcher](https://etcher.balena.io), then
|
||||
connect the USB drive.
|
||||
2. Choose **Flash from file** and select `Sovran_SystemsOS-1.1.4.iso`.
|
||||
2. Choose **Flash from file** and select `Sovran_SystemsOS-1.2.0.iso`.
|
||||
3. Choose **Select target**, select the USB drive, and review your selection
|
||||
carefully.
|
||||
4. Choose **Flash** and wait for the writing and verification process to
|
||||
@@ -448,14 +489,14 @@ similar x86_64 virtual machines. Use production-like resources where possible:
|
||||
allocated disks are fine; they do not consume the full size immediately.
|
||||
- Use NAT or bridged networking with internet access before opening the
|
||||
installer.
|
||||
- UEFI/EFI firmware is preferred. In UEFI VMs, the installer avoids depending
|
||||
on VM NVRAM boot-entry writes. If a VM boots the ISO in legacy BIOS mode,
|
||||
the installer automatically switches the installed system to GRUB.
|
||||
- UEFI/EFI firmware is preferred: in UEFI VMs the installer avoids depending
|
||||
on VM NVRAM boot-entry writes, and a VM that boots the ISO in legacy BIOS
|
||||
mode automatically installs GRUB instead.
|
||||
- For Node or Server + Desktop, attach a **second 2 TB virtual data disk**.
|
||||
If you only attach one disk, choose **Desktop Only**.
|
||||
- Present the install target as a normal virtual disk such as VirtIO, SATA,
|
||||
SCSI, or NVMe. USB-attached target disks are intentionally hidden by the
|
||||
installer to avoid erasing the installer USB by mistake.
|
||||
SCSI, or NVMe. USB-attached disks are hidden by the installer so you cannot
|
||||
erase the installer USB by mistake.
|
||||
|
||||
### 5. Install
|
||||
|
||||
@@ -476,28 +517,26 @@ Open the Hub directly from the Sovran_SystemsOS desktop, or from any other
|
||||
device on the same local network at:
|
||||
|
||||
```text
|
||||
http://sovransystemsos.local
|
||||
http://sovransystemsos.local:8937
|
||||
```
|
||||
|
||||
Sign in with your Sovran_SystemsOS credentials.
|
||||
Sign in with your Sovran_SystemsOS credentials. Desktop does not publish the Hub
|
||||
on the network, so in that mode open it from the desktop.
|
||||
|
||||
<details>
|
||||
<summary><strong>If sovransystemsos.local does not open</strong></summary>
|
||||
<summary><strong>If sovransystemsos.local:8937 does not open</strong></summary>
|
||||
|
||||
1. Make sure the Sovran_SystemsOS machine is powered on, and allow it a few
|
||||
minutes to finish starting.
|
||||
2. Make sure both devices are connected to the same local network, and that
|
||||
you entered the full address `http://sovransystemsos.local`.
|
||||
you entered the full address `http://sovransystemsos.local:8937`,
|
||||
including the `:8937`.
|
||||
3. Avoid guest Wi-Fi networks, which may prevent devices from seeing one
|
||||
another.
|
||||
4. Temporarily disconnect any VPN that may interfere with local-network
|
||||
access.
|
||||
5. Try another browser or device on the same network.
|
||||
|
||||
Some networks or devices may not support `.local` address discovery correctly.
|
||||
Network isolation, custom DNS settings, VPNs, and some routers can interfere
|
||||
with local-device discovery.
|
||||
|
||||
</details>
|
||||
|
||||
### Prefer guided help?
|
||||
@@ -699,9 +738,8 @@ rebuilds the machine into the selected declarative state.
|
||||
| Nextcloud, Vaultwarden, WordPress | `modules/nextcloud.nix`, `modules/vaultwarden.nix`, `modules/wordpress.nix`, `modules/php.nix` |
|
||||
| Optional remote desktop and public SSH | `modules/rdp.nix`, `modules/sshd.nix` |
|
||||
|
||||
Feature availability and defaults may change as Sovran_SystemsOS develops.
|
||||
Review the relevant Nix module before relying on a specific default in a
|
||||
production environment.
|
||||
Feature availability and defaults may change as Sovran_SystemsOS develops:
|
||||
review the relevant Nix module before relying on a default in production.
|
||||
|
||||
---
|
||||
|
||||
@@ -711,11 +749,11 @@ Wallet recovery words control the funds. Never share them with a website,
|
||||
support technician, cloud service, or chat application.
|
||||
|
||||
For meaningful balances, prefer a well-reviewed hardware signer and follow its
|
||||
verified backup process. A BIP39 passphrase is optional, advanced protection;
|
||||
it is not a replacement for the recovery words. If you use one, back it up
|
||||
separately—losing either item can make the wallet unrecoverable.
|
||||
verified backup process. A BIP39 passphrase is optional, advanced protection,
|
||||
not a replacement for recovery words: back it up separately, because losing
|
||||
either item can make the wallet unrecoverable.
|
||||
|
||||
Keep durable offline backups in separate secure locations. Test recovery before
|
||||
Keep durable offline backups in separate secure locations, test recovery before
|
||||
relying on a wallet, and begin with a small amount.
|
||||
|
||||
---
|
||||
@@ -725,17 +763,17 @@ relying on a wallet, and begin with a small amount.
|
||||
Sovran_SystemsOS uses layered controls:
|
||||
|
||||
- Pinned flake inputs and hash-pinned source archives
|
||||
- Bitcoin and Lightning modules maintained in this repository
|
||||
- Firewall enabled; public SSH and remote desktop disabled by default
|
||||
- Firewall enabled; SSH Remote Access and remote desktop disabled by default.
|
||||
Enabling SSH opens TCP port 22 and the Hub then asks you to forward it
|
||||
- Separate service users, systemd sandboxing, and loopback bindings where practical
|
||||
- Tor enforcement for supported Bitcoin services
|
||||
- Restricted, time-limited support access with scoped `sudo`
|
||||
- Operator-controlled public service exposure
|
||||
- Operator-controlled public service exposure (Server + Desktop Role or Node Role when enabled BTCpayserver and/or Lightning Wallet Connections
|
||||
[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,
|
||||
and operator guidance. No operating system can protect funds after recovery
|
||||
words, administrator credentials, or the root account are compromised. Apply
|
||||
updates and keep tested offline backups.
|
||||
and operator guidance.
|
||||
|
||||
---
|
||||
|
||||
@@ -745,11 +783,9 @@ Sovran_SystemsOS stands on the work of exceptional free and open-source projects
|
||||
|
||||
### NixOS
|
||||
|
||||
Deep gratitude goes to the [NixOS](https://nixos.org) team, the [Nixpkgs](https://github.com/NixOS/nixpkgs) maintainers, and the broader [Nix community](https://github.com/nix-community).
|
||||
Gratitude to the [NixOS](https://nixos.org) team, the [Nixpkgs](https://github.com/NixOS/nixpkgs) maintainers, and the broader [Nix community](https://github.com/nix-community).
|
||||
|
||||
NixOS provides the reproducible, declarative foundation that makes Sovran_SystemsOS possible. Its module system, package ecosystem, [flakes](https://nixos.wiki/wiki/Flakes), and generation-based system management allow an entire Bitcoin operating system to be described, audited, rebuilt, upgraded, and rolled back from source.
|
||||
|
||||
Sovran_SystemsOS would not have the same reliability, transparency, or reproducibility without their years of work.
|
||||
NixOS provides the reproducible, declarative foundation that makes Sovran_SystemsOS possible: its module system, package ecosystem, [flakes](https://nixos.wiki/wiki/Flakes), and generation-based system management allow an entire Bitcoin operating system to be described, audited, rebuilt, upgraded, and rolled back from source.
|
||||
|
||||
### nix-bitcoin
|
||||
|
||||
@@ -757,24 +793,17 @@ The in-repository Bitcoin stack began with code adapted from
|
||||
[nix-bitcoin](https://github.com/fort-nix/nix-bitcoin), primarily from commit
|
||||
[`360e30f`](https://github.com/fort-nix/nix-bitcoin/commit/360e30fee5ba32f9fecc89bc35628195d9d2dbbe).
|
||||
It has since been narrowed to Sovran's supported services and is maintained in
|
||||
this repository. nix-bitcoin is no longer a flake input or build dependency.
|
||||
|
||||
We remain grateful to the nix-bitcoin contributors for the declarative and
|
||||
security-focused foundation. Its MIT notice is retained in
|
||||
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md).
|
||||
this repository; nix-bitcoin is no longer a flake input or build dependency.
|
||||
Its MIT notice is retained in [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md).
|
||||
|
||||
### Emmanuel Rosa and btc-clients-nix
|
||||
|
||||
Special thanks go to [Emmanuel Rosa](https://github.com/emmanuelrosa) for [btc-clients-nix](https://github.com/emmanuelrosa/btc-clients-nix).
|
||||
|
||||
The project provides Nix packages for the Bitcoin desktop software central to the Sovran_SystemsOS experience:
|
||||
Thanks to [Emmanuel Rosa](https://github.com/emmanuelrosa) for [btc-clients-nix](https://github.com/emmanuelrosa/btc-clients-nix), which packages the Bitcoin desktop software included in every installation:
|
||||
|
||||
- [Sparrow Wallet](https://github.com/sparrowwallet/sparrow)
|
||||
- [Bisq](https://github.com/bisq-network/bisq)
|
||||
- [Bisq 2](https://github.com/bisq-network/bisq2)
|
||||
|
||||
This work helps make it possible for Sovran_SystemsOS to deliver self-custody and peer-to-peer Bitcoin tools as part of every installation.
|
||||
|
||||
### The upstream Bitcoin ecosystem
|
||||
|
||||
Sovran_SystemsOS also depends on the work of the developers and communities behind:
|
||||
@@ -802,27 +831,16 @@ Sovran_SystemsOS also depends on the work of the developers and communities behi
|
||||
- [WordPress](https://github.com/WordPress/WordPress)
|
||||
- Every other upstream project included in the system
|
||||
|
||||
Sovran Systems did not create these foundations. Our work is to bring them together into a cohesive operating system that helps more people use Bitcoin privately, independently, and with confidence.
|
||||
|
||||
Thank you to every developer, maintainer, reviewer, tester, documentarian, and user who keeps this ecosystem alive.
|
||||
Sovran Systems did not create these foundations; our work is to bring them together into one operating system that helps more people use Bitcoin privately and independently. Thank you to every developer, maintainer, reviewer, tester, documentarian, and user who keeps this ecosystem alive.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Sovran_SystemsOS is free and open-source software licensed under the
|
||||
[GNU Affero General Public License v3.0](LICENSE).
|
||||
|
||||
The AGPL-3.0 protects your freedom to:
|
||||
|
||||
- Use Sovran_SystemsOS
|
||||
- Study how the system works
|
||||
- Modify the source code
|
||||
- Share original or modified versions
|
||||
- Build and operate the system on your own hardware
|
||||
|
||||
If you distribute a modified version, you must make its corresponding source
|
||||
code available under the same license.
|
||||
[GNU Affero General Public License v3.0](LICENSE). You may use, study, modify,
|
||||
share, and self-host it; if you distribute a modified version, you must make
|
||||
its corresponding source code available under the same license.
|
||||
|
||||
Because Sovran_SystemsOS includes the browser-based Sovran Hub, operators who
|
||||
modify the covered software and make that modified version available for users
|
||||
@@ -830,14 +848,9 @@ to interact with over a network must offer those users access to its
|
||||
corresponding source code, as required by the AGPL-3.0.
|
||||
|
||||
Sovran_SystemsOS is provided **without warranty**, as described in the full
|
||||
license.
|
||||
|
||||
> Individual upstream applications, packages, artwork, fonts, and other
|
||||
> components included with or built by Sovran_SystemsOS may have their own
|
||||
> licenses and copyright holders. The AGPL-3.0 license for this repository does
|
||||
> not replace those licenses.
|
||||
|
||||
Read [`LICENSE`](LICENSE) and
|
||||
license. Individual upstream applications, packages, artwork, fonts, and other
|
||||
components may have their own licenses and copyright holders, which the
|
||||
AGPL-3.0 does not replace. Read [`LICENSE`](LICENSE) and
|
||||
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md).
|
||||
|
||||
---
|
||||
@@ -856,7 +869,7 @@ primary location for collaboration. Please read our
|
||||
## Privacy. Sovereignty. Bitcoin.
|
||||
|
||||
[Visit Sovran Systems](https://sovransystems.com) ·
|
||||
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.4.iso) ·
|
||||
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.0.iso) ·
|
||||
[View the License](LICENSE)
|
||||
|
||||
</div>
|
||||
|
||||
+52
-25
@@ -4,7 +4,7 @@
|
||||
|
||||
| Release | Supported |
|
||||
|---|:---:|
|
||||
| Latest `1.0.x` stable release | Yes |
|
||||
| Latest stable release | Yes |
|
||||
| `main` / `staging-dev` | Development only |
|
||||
| Older than `1.0.0` | No |
|
||||
|
||||
@@ -27,54 +27,81 @@ time for a fix and coordinated disclosure.
|
||||
|
||||
### Local-first operation
|
||||
|
||||
The Hub and core data run on operator-owned hardware. The Hub is intended for a
|
||||
trusted local network and must not be port-forwarded to the internet. Public
|
||||
services, DDNS, software updates, and optional third-party relays require
|
||||
external networks and are outside a “fully offline” model.
|
||||
The Hub and core data run on operator-owned hardware. The Hub is for a trusted
|
||||
local network and must not be port-forwarded to the internet. It uses HTTP, so
|
||||
authentication does not encrypt local network traffic.
|
||||
|
||||
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.
|
||||
The Hub is served on port 8937 and is not fronted by Caddy. Server + Desktop and
|
||||
Bitcoin Node Only open that port in the firewall, so local devices reach the Hub
|
||||
at `http://sovransystemsos.local:8937`. On Desktop Only the Hub is not published
|
||||
at all: reachable only from the machine itself, on localhost, with no TCP port
|
||||
open in the firewall (UDP 5353 for mDNS only).
|
||||
`sovran_systemsOS.hub.directPort = true` in `custom.nix` opens port 8937 if you
|
||||
want to reach a Desktop Only Hub from another device.
|
||||
|
||||
The Hub checks every client before showing a login page. It runs as root, so it
|
||||
answers only loopback, private, VPN and link-local addresses and turns everyone
|
||||
else away. The check goes by the address a connection comes from, so it is a
|
||||
second lock and not a reason to forward port 8937: don't. Addresses outside the
|
||||
local ranges go in `sovran_systemsOS.hub.extraLanNetworks` in `custom.nix`;
|
||||
`sovran_systemsOS.hub.lanOnly = false` turns the check off.
|
||||
|
||||
### Public services and your home IP address
|
||||
|
||||
Publishing public services on Server + Desktop points a DDNS record at your
|
||||
home's public IP address, which anyone can look up, and lists 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.
|
||||
|
||||
### Bitcoin stack
|
||||
|
||||
Bitcoin and Lightning modules are maintained in the standalone
|
||||
[Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) repository
|
||||
and consumed as a flake input. OS-specific customizations (Second_Drive paths,
|
||||
operator user, Hub integration) are bridged by
|
||||
`modules/sovran-bitcoin-integration.nix`. The `nix-bitcoin.*` option namespace
|
||||
and `/etc/nix-bitcoin-secrets` path remain only for upgrade compatibility.
|
||||
Bitcoin and Lightning modules live in the standalone
|
||||
[Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) repository,
|
||||
consumed as a flake input and bridged by
|
||||
`modules/sovran-bitcoin-integration.nix`. The `nix-bitcoin.*` namespace and
|
||||
`/etc/nix-bitcoin-secrets` path remain only for upgrade compatibility.
|
||||
|
||||
### Supply chain and integrity
|
||||
|
||||
`flake.lock` pins flake inputs, and fetched source archives use fixed hashes.
|
||||
Builds still depend on pinned Nixpkgs, NixVim, btc-clients-nix, upstream source
|
||||
archives, and any configured binary cache. Keeping the Bitcoin modules in this
|
||||
repository reduces an external dependency; it does not remove supply-chain
|
||||
risk.
|
||||
Builds still depend on pinned Nixpkgs and other inputs, upstream source
|
||||
archives, and any configured binary cache.
|
||||
|
||||
The Hub integrity check verifies Nix store contents and compares the running
|
||||
system with a build from local `/etc/nixos`. It does not authenticate the release
|
||||
publisher or protect against an attacker who already controls root and can
|
||||
change both the system and local configuration.
|
||||
The Hub integrity check verifies Nix store contents against a build from local
|
||||
`/etc/nixos`. It does not authenticate the release publisher or protect against
|
||||
an attacker who already controls root.
|
||||
|
||||
### Access and service isolation
|
||||
|
||||
- Firewall enabled by default
|
||||
- Public SSH and remote desktop disabled by default
|
||||
- SSH Remote Access and remote desktop disabled by default
|
||||
- Separate service users and systemd sandboxing where supported
|
||||
- Administrative service ports bound to loopback where practical
|
||||
- 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)
|
||||
|
||||
SSH Remote Access is the only supported administrative port that faces the
|
||||
network, and it is off by default. Enabling it listens on all interfaces and
|
||||
opens TCP port 22; the Hub then asks you to forward port 22, which puts SSH on
|
||||
the public internet. Key authentication only. Turn it off when you are done.
|
||||
|
||||
Tor reduces network exposure for configured Bitcoin services. It is not a
|
||||
guarantee against every IP leak, application bug, or traffic-analysis attack.
|
||||
|
||||
### Restricted support access
|
||||
|
||||
Support uses a per-session SSH key on the non-root `sovran-support` account.
|
||||
Tech Support runs over SSH, and the Hub refuses to start a session until SSH
|
||||
Remote Access is enabled (see above). Support uses a per-session SSH key on the
|
||||
non-root `sovran-support` account.
|
||||
|
||||
Sessions expire after 24 hours and have a small allowlist of `sudo` commands.
|
||||
Wallet paths receive deny ACLs unless the operator explicitly removes them.
|
||||
Disabling support removes the key and reapplies the ACLs.
|
||||
Disabling support removes the key and reapplies the ACLs but leaves SSH
|
||||
enabled.
|
||||
|
||||
Support events are written to `/var/log/sovran-support-audit.log`. This is a
|
||||
local audit log, not a cryptographically tamper-evident record.
|
||||
|
||||
@@ -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())
|
||||
@@ -15,6 +15,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
@@ -245,6 +246,218 @@ def _validate_ssh_pubkey(key: str) -> str:
|
||||
return key
|
||||
|
||||
|
||||
# ── Login throttling ──────────────────────────────────────────────────────────
|
||||
#
|
||||
# Delays applied after each failed login, and the lockout that follows once an
|
||||
# address has tripped LOGIN_FAIL_MAX inside the window.
|
||||
#
|
||||
# LOGIN_FAIL_WINDOW has to be longer than the time it takes to reach
|
||||
# LOGIN_FAIL_MAX failures under the ramping delay: with a 2s ramp capped at
|
||||
# LOGIN_FAIL_MAX_DELAY, 10 attempts take about 80 seconds, so a 60 second
|
||||
# window would silently expire the earliest failures and the counter could
|
||||
# never reach the limit. 900s (15 minutes) keeps the whole ramp inside it.
|
||||
LOGIN_FAIL_DELAY = 2.0 # base delay; the nth failure waits n x this
|
||||
LOGIN_FAIL_MAX_DELAY = 10.0 # ceiling for a single delay
|
||||
LOGIN_FAIL_WINDOW = 900.0 # rolling window failures are counted in
|
||||
LOGIN_FAIL_MAX = 10 # failures in the window that trigger a lockout
|
||||
LOGIN_LOCKOUT_SECONDS = 300.0 # how long the lockout lasts
|
||||
|
||||
# Cap on how many addresses are tracked, so a distributed sweep cannot grow
|
||||
# the table without bound.
|
||||
_LOGIN_THROTTLE_MAX_IPS = 4096
|
||||
|
||||
|
||||
class LoginThrottle:
|
||||
"""Per-address failed-login tracking with a ramping delay and a lockout.
|
||||
|
||||
The delay ramps so a script hammering the login form slows down as it goes,
|
||||
and once LOGIN_FAIL_MAX failures land inside the window the address is
|
||||
refused outright for LOGIN_LOCKOUT_SECONDS. A successful login clears the
|
||||
address so a legitimate user who fumbles a password is not penalised later.
|
||||
|
||||
``sleep`` and ``clock`` are injectable so tests run without waiting.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
fail_delay=LOGIN_FAIL_DELAY,
|
||||
max_delay=LOGIN_FAIL_MAX_DELAY,
|
||||
window=LOGIN_FAIL_WINDOW,
|
||||
max_failures=LOGIN_FAIL_MAX,
|
||||
lockout=LOGIN_LOCKOUT_SECONDS,
|
||||
max_tracked_ips=_LOGIN_THROTTLE_MAX_IPS,
|
||||
sleep=None,
|
||||
clock=None,
|
||||
):
|
||||
self._fail_delay = float(fail_delay)
|
||||
self._max_delay = float(max_delay)
|
||||
self._window = float(window)
|
||||
self._max_failures = int(max_failures)
|
||||
self._lockout = float(lockout)
|
||||
self._max_tracked_ips = int(max_tracked_ips)
|
||||
self._sleep = sleep if sleep is not None else time.sleep
|
||||
self._clock = clock if clock is not None else time.monotonic
|
||||
self._lock = threading.Lock()
|
||||
self._failures: dict[str, list[float]] = {}
|
||||
|
||||
# ── internals ────────────────────────────────────────────────────────────
|
||||
|
||||
def _prune(self, ip, now):
|
||||
"""Drop timestamps outside the window; return what is left."""
|
||||
keep = [t for t in self._failures.get(ip, ()) if now - t < self._window]
|
||||
if keep:
|
||||
self._failures[ip] = keep
|
||||
else:
|
||||
self._failures.pop(ip, None)
|
||||
return keep
|
||||
|
||||
def _evict(self, now):
|
||||
"""Forget addresses that can no longer affect anything."""
|
||||
horizon = max(self._window, self._lockout)
|
||||
for ip in [i for i, ts in self._failures.items()
|
||||
if ts and now - max(ts) > horizon]:
|
||||
self._failures.pop(ip, None)
|
||||
while len(self._failures) > self._max_tracked_ips:
|
||||
oldest = min(self._failures, key=lambda i: max(self._failures[i]))
|
||||
self._failures.pop(oldest, None)
|
||||
|
||||
# ── public API ───────────────────────────────────────────────────────────
|
||||
|
||||
def delay_for(self, count):
|
||||
"""Return the delay owed after *count* failures in the current window."""
|
||||
if count <= 0:
|
||||
return 0.0
|
||||
return min(self._fail_delay * count, self._max_delay)
|
||||
|
||||
def failure_count(self, ip):
|
||||
"""Return the failures currently counted against *ip*."""
|
||||
with self._lock:
|
||||
return len(self._prune(ip, self._clock()))
|
||||
|
||||
def is_locked_out(self, ip):
|
||||
"""Return True while *ip* is inside a lockout."""
|
||||
now = self._clock()
|
||||
with self._lock:
|
||||
failures = self._prune(ip, now)
|
||||
if len(failures) < self._max_failures:
|
||||
return False
|
||||
return (now - failures[-1]) < self._lockout
|
||||
|
||||
def remaining_lockout(self, ip):
|
||||
"""Return the seconds left in *ip*'s lockout, or 0.0 if not locked out."""
|
||||
now = self._clock()
|
||||
with self._lock:
|
||||
failures = self._prune(ip, now)
|
||||
if len(failures) < self._max_failures:
|
||||
return 0.0
|
||||
return max(0.0, self._lockout - (now - failures[-1]))
|
||||
|
||||
def record_failure(self, ip):
|
||||
"""Record a failure for *ip* and serve out the delay it has earned.
|
||||
|
||||
Returns the delay that was applied. The lock is never held across the
|
||||
sleep, so one slow client cannot stall every other login.
|
||||
"""
|
||||
now = self._clock()
|
||||
with self._lock:
|
||||
failures = list(self._prune(ip, now))
|
||||
failures.append(now)
|
||||
self._failures[ip] = failures
|
||||
count = len(failures)
|
||||
self._evict(now)
|
||||
delay = self.delay_for(count)
|
||||
if delay > 0:
|
||||
self._sleep(delay)
|
||||
return delay
|
||||
|
||||
def clear(self, ip):
|
||||
"""Forget *ip*, e.g. after a successful login."""
|
||||
with self._lock:
|
||||
self._failures.pop(ip, None)
|
||||
|
||||
def tracked_addresses(self):
|
||||
"""Return how many addresses are currently being tracked."""
|
||||
with self._lock:
|
||||
return len(self._failures)
|
||||
|
||||
|
||||
# ── Local-network client policy ───────────────────────────────────────────────
|
||||
#
|
||||
# The Hub runs as root: it can display stored credentials, reboot the machine
|
||||
# and rebuild the system. It answers this computer and the local network and
|
||||
# nobody else. Whether a packet may reach its port is the firewall's and the
|
||||
# router's business; this is the second lock, applied by the application itself
|
||||
# so that a port forward, a firewall mistake, or a machine that has a public
|
||||
# address does not put the login page in front of the internet.
|
||||
#
|
||||
# The Hub listens on IPv4 only (see sovran-hub.nix), so IPv6 clients never
|
||||
# reach it directly and the IPv6 ranges below only matter if that bind is ever
|
||||
# widened. Global IPv6 addresses (2000::/3) are deliberately not listed: a
|
||||
# global address belonging to a laptop on the LAN cannot be told apart from a
|
||||
# stranger's by the address alone, and allowing the range would let the whole
|
||||
# IPv6 internet through.
|
||||
LAN_ONLY_IPV4 = (
|
||||
"127.0.0.0/8", # this computer
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
"100.64.0.0/10", # Tailscale and other VPN/CGNAT ranges
|
||||
"169.254.0.0/16", # link-local
|
||||
)
|
||||
LAN_ONLY_IPV6 = (
|
||||
"::1/128",
|
||||
"fc00::/7", # unique-local (covers fd00::/8)
|
||||
"fe80::/10", # link-local
|
||||
)
|
||||
|
||||
|
||||
class LanPolicy:
|
||||
"""Decides whether a client address counts as local.
|
||||
|
||||
``extra_networks`` are CIDR blocks an operator has declared local in
|
||||
addition to the built-in ranges, of either address family. ``enabled=False``
|
||||
turns the check off entirely; it is the one explicit way to do that.
|
||||
"""
|
||||
|
||||
def __init__(self, extra_networks=(), enabled=True):
|
||||
self.enabled = bool(enabled)
|
||||
nets = [ipaddress.ip_network(c, strict=False)
|
||||
for c in LAN_ONLY_IPV4 + LAN_ONLY_IPV6]
|
||||
for cidr in (extra_networks or ()):
|
||||
try:
|
||||
net = ipaddress.ip_network(cidr, strict=False)
|
||||
except ValueError:
|
||||
# A malformed entry must never widen the policy. Ignore it and
|
||||
# stay at the strictest interpretation.
|
||||
continue
|
||||
if net.prefixlen == 0:
|
||||
# 0.0.0.0/0 and ::/0 are "everyone". That is lan_only = false,
|
||||
# and it should be asked for by name, not arrive as a "network".
|
||||
continue
|
||||
nets.append(net)
|
||||
self._nets = tuple(nets)
|
||||
|
||||
def allows(self, ip):
|
||||
"""Return True if *ip* may reach the service."""
|
||||
if not self.enabled:
|
||||
return True
|
||||
if not ip:
|
||||
return False
|
||||
try:
|
||||
addr = ipaddress.ip_address(ip)
|
||||
except ValueError:
|
||||
return False
|
||||
# A dual-stack socket reports IPv4 clients as ::ffff:a.b.c.d. The
|
||||
# address that matters is the IPv4 one inside it.
|
||||
if addr.version == 6 and addr.ipv4_mapped is not None:
|
||||
addr = addr.ipv4_mapped
|
||||
return any(addr.version == n.version and addr in n for n in self._nets)
|
||||
|
||||
@property
|
||||
def networks(self):
|
||||
return self._nets
|
||||
|
||||
|
||||
# ── Persistent Hub session store ─────────────────────────────────────────────
|
||||
|
||||
def load_session_store(path: str) -> dict[str, float]:
|
||||
|
||||
+202
-104
@@ -21,7 +21,6 @@ import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
@@ -41,6 +40,7 @@ from .config import load_config, load_versions
|
||||
from . import systemctl as sysctl
|
||||
from sovran_nwc import nwc_hub_manager as _nwc_mgr
|
||||
from . import support_ops as _support_ops
|
||||
from .ddns_update import normalise_url as _normalise_ddns_url
|
||||
from .security_helpers import (
|
||||
_nix_escape,
|
||||
NPUB_RE,
|
||||
@@ -55,6 +55,13 @@ from .security_helpers import (
|
||||
_bech32_convertbits_decode,
|
||||
load_session_store,
|
||||
save_session_store,
|
||||
LoginThrottle,
|
||||
LanPolicy,
|
||||
LOGIN_FAIL_DELAY,
|
||||
LOGIN_FAIL_MAX_DELAY,
|
||||
LOGIN_FAIL_WINDOW,
|
||||
LOGIN_FAIL_MAX,
|
||||
LOGIN_LOCKOUT_SECONDS,
|
||||
)
|
||||
from .update_state import effective_update_status
|
||||
|
||||
@@ -175,11 +182,19 @@ _sessions_lock = Lock()
|
||||
_SESSION_PERSIST_MIN_INTERVAL = 30.0 # seconds
|
||||
_sessions_last_persist = 0.0
|
||||
|
||||
# Failed login tracking: ip → list of failure timestamps
|
||||
_login_failures: dict[str, list[float]] = {}
|
||||
LOGIN_FAIL_DELAY = 2.0 # seconds to sleep after a failed attempt
|
||||
LOGIN_FAIL_WINDOW = 60.0 # rolling window (seconds) for counting failures
|
||||
LOGIN_FAIL_MAX = 10 # max failures in window before extra delay
|
||||
# Failed login tracking.
|
||||
#
|
||||
# LOGIN_FAIL_MAX used to be declared here and never read anywhere: the only
|
||||
# thing a failed attempt cost an attacker was a flat 2 second delay, and there
|
||||
# was no lockout, no escalation and no ban. The throttling now lives in
|
||||
# security_helpers.LoginThrottle, which ramps the delay and refuses an address
|
||||
# outright once it has tripped LOGIN_FAIL_MAX inside the window.
|
||||
#
|
||||
# The window moved from 60s to 900s. With the ramping delay, reaching
|
||||
# LOGIN_FAIL_MAX takes about 80 seconds, so a 60 second window expired the
|
||||
# earliest failures before the limit could ever be reached — the old constant
|
||||
# could not have worked even if it had been wired up.
|
||||
_login_throttle = LoginThrottle()
|
||||
|
||||
# Public paths that are accessible without a valid session
|
||||
_AUTH_EXEMPT_PATHS = {"/login", "/api/login", "/auto-login", "/api/ping"}
|
||||
@@ -234,10 +249,9 @@ _support_expiry_timer_lock = Lock()
|
||||
|
||||
CATEGORY_ORDER = [
|
||||
("infrastructure", "Infrastructure"),
|
||||
("bitcoin-base", "Bitcoin Base"),
|
||||
("bitcoin-apps", "Bitcoin Apps"),
|
||||
("bitcoin", "Bitcoin"),
|
||||
("communication", "Communication"),
|
||||
("apps", "Self-Hosted Apps"),
|
||||
("apps", "Personal Apps"),
|
||||
("nostr", "Nostr"),
|
||||
("support", "Support"),
|
||||
("feature-manager", "Feature Manager"),
|
||||
@@ -452,7 +466,7 @@ ROLE_LABELS = {
|
||||
ROLE_CATEGORIES: dict[str, set[str] | None] = {
|
||||
"server_plus_desktop": None,
|
||||
"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)
|
||||
@@ -771,19 +785,20 @@ def _ensure_onboarding_reopened_for_migration() -> None:
|
||||
logger.warning("Could not clear onboarding flag for migration flow: %s", exc)
|
||||
|
||||
|
||||
def _record_failure(client_ip: str) -> None:
|
||||
"""Record a failed login attempt and apply a rate-limit delay.
|
||||
def _record_failure(client_ip: str) -> float:
|
||||
"""Record a failed login attempt and apply the throttling delay.
|
||||
|
||||
Must always be called via loop.run_in_executor() so that the blocking
|
||||
time.sleep() does not stall the asyncio event loop.
|
||||
|
||||
Returns the delay that was applied.
|
||||
"""
|
||||
now = time.time()
|
||||
failures = _login_failures.setdefault(client_ip, [])
|
||||
# Prune old entries outside the window
|
||||
_login_failures[client_ip] = [t for t in failures if now - t < LOGIN_FAIL_WINDOW]
|
||||
_login_failures[client_ip].append(now)
|
||||
# Sleep in the thread-pool thread to slow brute-force without blocking the loop
|
||||
time.sleep(LOGIN_FAIL_DELAY)
|
||||
return _login_throttle.record_failure(client_ip)
|
||||
|
||||
|
||||
def _is_locked_out(client_ip: str) -> bool:
|
||||
"""Return True while *client_ip* is inside a lockout."""
|
||||
return _login_throttle.is_locked_out(client_ip)
|
||||
|
||||
|
||||
# ── Authentication middleware ─────────────────────────────────────
|
||||
@@ -807,8 +822,61 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# ── Local-network middleware ───────────────────────────────────
|
||||
#
|
||||
# The Hub runs as root. Whether a packet may reach its port is up to the
|
||||
# firewall and the router; this is the second lock, so a port forward or a
|
||||
# firewall mistake does not put the login page in front of the internet. It
|
||||
# runs before authentication: a client that is not on the local network never
|
||||
# sees the login page at all.
|
||||
#
|
||||
# Built from the Nix-generated config. lan_only defaults to True, so a Hub built
|
||||
# without the key still turns off-network clients away rather than failing open.
|
||||
_hub_cfg = load_config()
|
||||
_lan_policy = LanPolicy(
|
||||
enabled=bool(_hub_cfg.get("lan_only", True)),
|
||||
extra_networks=tuple(_hub_cfg.get("lan_extra_networks") or ()),
|
||||
)
|
||||
|
||||
|
||||
class LanOnlyMiddleware(BaseHTTPMiddleware):
|
||||
"""Refuse clients that are not on this computer or the local network."""
|
||||
|
||||
# Each refused address is logged once, and the list is capped: a scanner
|
||||
# must not be able to fill the journal or the process's memory.
|
||||
_MAX_LOGGED = 256
|
||||
|
||||
def __init__(self, app, policy):
|
||||
super().__init__(app)
|
||||
self._policy = policy
|
||||
self._logged: set = set()
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
client_ip = request.client.host if request.client else None
|
||||
if not self._policy.allows(client_ip):
|
||||
self._note_refusal(client_ip)
|
||||
return JSONResponse(
|
||||
{"detail": "Not available from this network"}, status_code=403,
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
def _note_refusal(self, client_ip):
|
||||
if client_ip in self._logged or len(self._logged) >= self._MAX_LOGGED:
|
||||
return
|
||||
self._logged.add(client_ip)
|
||||
logger.warning(
|
||||
"Refused a Hub request from %r: not this computer or a local "
|
||||
"network. If that address is yours, add its network to "
|
||||
"sovran_systemsOS.hub.extraLanNetworks.",
|
||||
client_ip,
|
||||
)
|
||||
|
||||
|
||||
app.add_middleware(AuthMiddleware)
|
||||
app.add_middleware(NoCacheMiddleware)
|
||||
# Registered last so it runs outermost: a client that is not on the local
|
||||
# network is turned away before authentication is considered at all.
|
||||
app.add_middleware(LanOnlyMiddleware, policy=_lan_policy)
|
||||
|
||||
_ICONS_DIR = os.environ.get(
|
||||
"SOVRAN_HUB_ICONS",
|
||||
@@ -908,11 +976,68 @@ def _get_remote_rev(branch=None):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_version(text):
|
||||
"""Return a (major, minor, patch) tuple from a VERSION string, or None."""
|
||||
try:
|
||||
match = re.search(r"(\d+)\.(\d+)\.(\d+)", str(text))
|
||||
if match:
|
||||
return tuple(int(g) for g in match.groups())
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _get_remote_version(branch=None):
|
||||
"""Read VERSION on the tracked branch, e.g. the stable release version."""
|
||||
try:
|
||||
ref = branch or "stable"
|
||||
url = (
|
||||
"https://git.sovransystems.com/api/v1/repos/"
|
||||
"Sovran_Systems/Sovran_SystemsOS/raw/VERSION?ref="
|
||||
+ urllib.parse.quote(ref)
|
||||
)
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return _parse_version(resp.read().decode())
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def check_for_updates() -> bool | None:
|
||||
"""Whether an update is available.
|
||||
|
||||
Primary signal: the flake lock's pinned Sovran_Systems rev differs from
|
||||
the remote branch head. BUT a failed update rewrites ``flake.lock`` (the
|
||||
``nix flake update`` step) without staging a generation (the
|
||||
``nixos-rebuild boot`` step failed), so after a failure the lock and the
|
||||
remote agree while the *running* system is still on the old version. In
|
||||
that case the rev comparison alone reports a false "up to date" and hides
|
||||
the failed update from the dashboard.
|
||||
|
||||
Backstop: compare the running Hub version against the branch VERSION.
|
||||
A newer released version while running an older one means the update did
|
||||
not apply (build failed, reboot skipped, generation rolled back) and must
|
||||
be offered again.
|
||||
"""
|
||||
locked_rev, branch = _get_locked_info()
|
||||
remote_rev = _get_remote_rev(branch)
|
||||
if locked_rev and remote_rev:
|
||||
return locked_rev != remote_rev
|
||||
rev_differs = locked_rev != remote_rev
|
||||
if rev_differs:
|
||||
return True
|
||||
# Revs match — make sure the pinned (failed) rev isn't masking an
|
||||
# older *running* system.
|
||||
running_ver = _parse_version(_get_sovran_version())
|
||||
remote_ver = _get_remote_version(branch)
|
||||
if running_ver and remote_ver and remote_ver > running_ver:
|
||||
return True
|
||||
return False
|
||||
# Couldn't compare revs — fall back to the version backstop.
|
||||
running_ver = _parse_version(_get_sovran_version())
|
||||
remote_ver = _get_remote_version(branch)
|
||||
if running_ver and remote_ver:
|
||||
return remote_ver > running_ver
|
||||
return None # inconclusive — couldn't read lock or reach remote
|
||||
|
||||
|
||||
@@ -952,44 +1077,21 @@ def _save_internal_ip(ip: str):
|
||||
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:
|
||||
"""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
|
||||
result in /var/lib/secrets/external-ip, and contacts at most one third
|
||||
party per refresh interval. This function only reads the cache and asks
|
||||
the detector to refresh when it is missing or stale — it performs no
|
||||
per-call external queries of its own.
|
||||
Nothing here looks the address up or contacts anyone. The DDNS update asks
|
||||
Njal.la to use the address the request came from, Njal.la reports it back,
|
||||
and the runner saves it to EXTERNAL_IP_FILE. Returns "unavailable" until
|
||||
the first successful update (and on machines with no DDNS URL, e.g. Desktop).
|
||||
"""
|
||||
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:
|
||||
with open(EXTERNAL_IP_FILE) as f:
|
||||
ip = f.read().strip()
|
||||
if ip:
|
||||
return ip
|
||||
except OSError:
|
||||
pass
|
||||
return "unavailable"
|
||||
ipaddress.ip_address(ip)
|
||||
return ip
|
||||
except (OSError, ValueError):
|
||||
return "unavailable"
|
||||
|
||||
|
||||
# ── Port status helpers (local-only, no external calls) ──────────
|
||||
@@ -2605,10 +2707,24 @@ async def api_login(req: LoginRequest, request: Request):
|
||||
"""Validate the Hub password and issue a session cookie."""
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
# Refuse outright while the address is locked out. This runs before the
|
||||
# scrypt hash, so a locked-out client costs almost nothing to reject.
|
||||
if _is_locked_out(client_ip):
|
||||
remaining = int(_login_throttle.remaining_lockout(client_ip) // 60) + 1
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"Too many failed attempts. Try again in about {remaining} minute(s).",
|
||||
)
|
||||
|
||||
ok = await loop.run_in_executor(None, _check_password, req.password)
|
||||
if not ok:
|
||||
await loop.run_in_executor(None, _record_failure, client_ip)
|
||||
raise HTTPException(status_code=401, detail="Incorrect password")
|
||||
|
||||
# A real login clears the address, so fumbling a password once in a while
|
||||
# does not accumulate towards a lockout.
|
||||
_login_throttle.clear(client_ip)
|
||||
token = _create_session()
|
||||
response = JSONResponse({"ok": True})
|
||||
response.set_cookie(
|
||||
@@ -3740,9 +3856,6 @@ async def api_network():
|
||||
# Keep the internal-ip file in sync for credential lookups
|
||||
_save_internal_ip(internal)
|
||||
_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}
|
||||
|
||||
|
||||
@@ -3885,6 +3998,11 @@ async def api_updates_check():
|
||||
# Avoid a slow remote update check when there is already an operation
|
||||
# the dashboard needs to surface.
|
||||
return {"available": True, "status": status.lower()}
|
||||
if status == "FAILED":
|
||||
# The last update did not complete (build failed). Keep offering the
|
||||
# update so the user can re-run it rather than silently landing on a
|
||||
# false "up to date".
|
||||
return {"available": True, "status": "failed"}
|
||||
|
||||
available = await loop.run_in_executor(None, check_for_updates)
|
||||
# None means inconclusive (check failed) — report as available so the UI doesn't block
|
||||
@@ -3962,8 +4080,15 @@ async def api_updates_run():
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Re-read status: a prior failed update leaves flake.lock advanced even
|
||||
# though no generation was staged, so the rev-based check below can say
|
||||
# "no updates" even though the system is still old. A failed update must
|
||||
# always be re-runnable to recover.
|
||||
persisted_status = await loop.run_in_executor(None, _read_update_status)
|
||||
last_failed = persisted_status == "FAILED"
|
||||
|
||||
available = await loop.run_in_executor(None, check_for_updates)
|
||||
if available is False: # only block when positively confirmed no updates
|
||||
if available is False and not last_failed: # only block when positively confirmed no updates
|
||||
# Clear stale status/log so they don't contaminate future modal opens.
|
||||
_write_update_status("IDLE")
|
||||
try:
|
||||
@@ -4655,51 +4780,26 @@ def _save_ddns_urls(urls: list[str]) -> 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
|
||||
subprocess for each stored DDNS update URL. No shell interpolation is
|
||||
performed and no user-controlled value is interpreted as shell syntax.
|
||||
Each URL is revalidated through ``_validate_ddns_url()`` after ``${IP}``
|
||||
substitution; URLs that fail validation are silently skipped.
|
||||
The runner (modules/core/njalla.nix -> ddns_update.py) is the only code that
|
||||
talks to Njal.la. It validates every stored URL, calls it with "&auto" so
|
||||
Njal.la uses the address the request came from, and records the address
|
||||
Njal.la reports back for LiveKit and the Hub (EXTERNAL_IP_FILE).
|
||||
|
||||
Called when a domain/DDNS entry is saved and when a DDNS-backed feature
|
||||
is enabled, so DNS is refreshed right away instead of waiting for the
|
||||
15-minute timer tick (see modules/core/njalla.nix).
|
||||
Called when a domain/DDNS entry is saved and when a DDNS-backed feature is
|
||||
enabled, so DNS is refreshed right away instead of waiting for the
|
||||
15-minute timer tick.
|
||||
"""
|
||||
urls = _load_ddns_urls()
|
||||
if not urls:
|
||||
if not _load_ddns_urls():
|
||||
return
|
||||
# Resolve current public IP (best-effort; skip if unavailable)
|
||||
public_ip = ""
|
||||
try:
|
||||
ip_result = subprocess.run(
|
||||
["dig", "@resolver4.opendns.com", "myip.opendns.com", "+short", "-4"],
|
||||
capture_output=True, text=True, timeout=10, check=False,
|
||||
subprocess.run(
|
||||
["systemctl", "start", "--no-block", "sovran-ddns-update.service"],
|
||||
capture_output=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(
|
||||
["curl", "--silent", "--max-time", "15", "--fail", "--no-location", url],
|
||||
timeout=20, check=False,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
pass
|
||||
|
||||
|
||||
def _reload_caddy_for_domain_change() -> None:
|
||||
@@ -4838,18 +4938,17 @@ async def api_domains_set(req: DomainSetRequest):
|
||||
# Strip surrounding quotes
|
||||
if len(ddns_url) >= 2 and ddns_url[0] in ('"', "'") and ddns_url[-1] == ddns_url[0]:
|
||||
ddns_url = ddns_url[1:-1]
|
||||
# Replace trailing &auto with the IP placeholder used by _run_njalla_ddns
|
||||
if ddns_url.endswith("&auto"):
|
||||
ddns_url = ddns_url[:-5] + "&a=${IP}"
|
||||
# Keep Njal.la's "&auto": Njal.la then uses the address the request comes
|
||||
# from, so nothing on this machine has to look the address up. Old
|
||||
# "&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.
|
||||
# The placeholder ${IP} is replaced temporarily so the validator sees a
|
||||
# real address; the original URL (with the placeholder) is kept for storage.
|
||||
try:
|
||||
_validate_ddns_url(ddns_url.replace("${IP}", "127.0.0.1"))
|
||||
_validate_ddns_url(ddns_url)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid DDNS URL: {exc}")
|
||||
# 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:
|
||||
existing_urls.append(ddns_url)
|
||||
try:
|
||||
@@ -6240,13 +6339,12 @@ async def _background_domain_reachability_checker():
|
||||
consecutive_failures = 0
|
||||
while True:
|
||||
try:
|
||||
# Keep the persisted external IP fresh (dynamic WAN IPs), so
|
||||
# services like LiveKit can read /var/lib/secrets/external-ip.
|
||||
# Pick up the address the Njal.la DDNS runner last recorded (a plain
|
||||
# file read; nothing is looked up from here).
|
||||
loop = asyncio.get_event_loop()
|
||||
external = await loop.run_in_executor(None, _get_external_ip)
|
||||
if external != "unavailable":
|
||||
_cached_external_ip = external
|
||||
_save_external_ip(external)
|
||||
|
||||
cfg = load_config()
|
||||
services = cfg.get("services", [])
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
/* Sovran_SystemsOS Hub — Web UI Stylesheet
|
||||
Dark theme — near-black with green accents matching the Sovran Hub icon
|
||||
v8 — Black-forward, green used for accents/borders/highlights only */
|
||||
The Hub redesign — softer dark theme, GNOME 50 / libadwaita surfaces,
|
||||
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 {
|
||||
box-sizing: border-box;
|
||||
@@ -9,22 +14,48 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-color: #080a09;
|
||||
--surface-color: rgba(14, 16, 15, 0.7);
|
||||
--card-color: rgba(20, 22, 21, 0.6);
|
||||
--border-color: rgba(255, 255, 255, 0.06);
|
||||
--text-primary: #ecf3ef;
|
||||
--text-secondary: #8aaa9a;
|
||||
--text-dim: #4a6658;
|
||||
--accent-color: #5EAD8A;
|
||||
--green: #6DBF8B;
|
||||
--yellow: #e5a50a;
|
||||
--red: #e01b24;
|
||||
--grey: #5E7A6A;
|
||||
--radius-card: 18px;
|
||||
--radius-btn: 8px;
|
||||
--shadow-card: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
--shadow-hover: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
color-scheme: dark;
|
||||
|
||||
/* ── The Hub palette ─────────────────────────────────────────── */
|
||||
--bg: #17191d;
|
||||
--surface: #1c1f24;
|
||||
--card: #23272c;
|
||||
--card-hover: #292e34;
|
||||
--elevated: #26292e;
|
||||
--inset: #121417;
|
||||
--border: rgba(255, 255, 255, 0.07);
|
||||
--border-strong: rgba(255, 255, 255, 0.14);
|
||||
--text: #e9edec;
|
||||
--text-2: #a9b0b3;
|
||||
--text-3: #7a8388;
|
||||
--accent: #3ecf8e;
|
||||
--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 {
|
||||
@@ -32,19 +63,27 @@ html, body {
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background:
|
||||
radial-gradient(ellipse at top, rgba(94, 173, 138, 0.04) 0%, transparent 50%),
|
||||
var(--bg-color);
|
||||
color: var(--text-primary);
|
||||
font-family: Inter, Cantarell, 'Segoe UI', system-ui, -apple-system, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -52,86 +91,103 @@ body {
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.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%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||
max-width: 402px;
|
||||
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 {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
height: 64px;
|
||||
margin-bottom: 16px;
|
||||
width: 78px;
|
||||
height: 78px;
|
||||
margin-bottom: 18px;
|
||||
filter: drop-shadow(0 8px 24px rgba(28, 196, 120, 0.3));
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.3rem;
|
||||
font-weight: 800;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 650;
|
||||
color: var(--text-2);
|
||||
margin-bottom: 6px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--card-color);
|
||||
color: var(--text-primary);
|
||||
padding: 11px 16px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 14px;
|
||||
background: var(--inset);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 0.92rem;
|
||||
outline: 0;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
border-color: rgba(66, 243, 154, 0.55);
|
||||
box-shadow: 0 0 0 3px rgba(66, 243, 154, 0.13);
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--accent-color);
|
||||
color: #0A1A10;
|
||||
font-size: 0.95rem;
|
||||
border-radius: 99px;
|
||||
background: linear-gradient(180deg, #3fd68e, #1ea263);
|
||||
color: #04220f;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
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 {
|
||||
opacity: 0.88;
|
||||
.btn-login:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.login-error {
|
||||
background-color: rgba(224, 27, 36, 0.12);
|
||||
border: 1px solid var(--red);
|
||||
color: #f87171;
|
||||
background: rgba(246, 97, 81, 0.10);
|
||||
border: 1px solid rgba(246, 97, 81, 0.35);
|
||||
color: #f8a39b;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -5,34 +5,134 @@ button {
|
||||
cursor: pointer;
|
||||
border: 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 {
|
||||
opacity: 0.45;
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 7px 16px;
|
||||
border-radius: var(--radius-btn);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
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 {
|
||||
background-color: var(--accent-color);
|
||||
color: #0A1A10;
|
||||
background: linear-gradient(180deg, #3fd68e, #1ea263);
|
||||
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) {
|
||||
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 {
|
||||
background-color: #4A9474;
|
||||
color: #E0F2EA;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--text-2);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -40,24 +140,22 @@ button: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 {
|
||||
background-color: #5EAD8A;
|
||||
color: #0A1A10;
|
||||
}
|
||||
|
||||
.btn-update.has-updates:hover:not(:disabled) {
|
||||
background-color: #78C8A2;
|
||||
background: rgba(66, 243, 154, 0.12);
|
||||
border-color: rgba(66, 243, 154, 0.4);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.update-badge {
|
||||
display: none;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background-color: var(--yellow);
|
||||
background-color: var(--amber);
|
||||
border-radius: 50%;
|
||||
animation: pulse-badge 1.4s ease-in-out infinite;
|
||||
}
|
||||
@@ -71,9 +169,11 @@ button:disabled {
|
||||
50% { opacity: 0.5; transform: scale(1.35); }
|
||||
}
|
||||
|
||||
/* ── Icon buttons ───────────────────────────────────────────────── */
|
||||
|
||||
.btn-icon {
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-2);
|
||||
padding: 6px;
|
||||
border-radius: 50%;
|
||||
font-size: 1.1rem;
|
||||
@@ -81,6 +181,6 @@ button:disabled {
|
||||
}
|
||||
|
||||
.btn-icon:hover:not(:disabled) {
|
||||
background-color: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ domain-field-label {
|
||||
|
||||
domain-field-input {
|
||||
width: 100%;
|
||||
background-color: #0c0f0e;
|
||||
background-color: var(--inset);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
@@ -75,7 +75,7 @@ domain-field-actions {
|
||||
|
||||
.domain-field-input {
|
||||
width: 100%;
|
||||
background-color: #0c0f0e;
|
||||
background-color: var(--inset);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
@@ -198,21 +198,21 @@ domain-field-actions {
|
||||
}
|
||||
|
||||
.port-proto-badge--tcp {
|
||||
color: #6dbf8b;
|
||||
background: rgba(109, 191, 139, 0.12);
|
||||
border: 1px solid rgba(109, 191, 139, 0.4);
|
||||
color: var(--accent);
|
||||
background: rgba(66, 243, 154, 0.12);
|
||||
border: 1px solid rgba(66, 243, 154, 0.4);
|
||||
}
|
||||
|
||||
.port-proto-badge--udp {
|
||||
color: #6aa9e0;
|
||||
color: var(--blue);
|
||||
background: rgba(106, 169, 224, 0.12);
|
||||
border: 1px solid rgba(106, 169, 224, 0.4);
|
||||
}
|
||||
|
||||
.port-proto-badge--both {
|
||||
color: #e5a50a;
|
||||
background: rgba(229, 165, 10, 0.12);
|
||||
border: 1px solid rgba(229, 165, 10, 0.45);
|
||||
color: var(--amber);
|
||||
background: rgba(233, 182, 74, 0.12);
|
||||
border: 1px solid rgba(233, 182, 74, 0.45);
|
||||
}
|
||||
|
||||
.port-proto-note {
|
||||
@@ -230,7 +230,7 @@ domain-field-actions {
|
||||
margin-bottom: 14px;
|
||||
padding: 10px 14px;
|
||||
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;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.6;
|
||||
|
||||
@@ -1,143 +1,196 @@
|
||||
/* ── Feature Manager styles ──────────────────────────────────────── */
|
||||
/* ── Sidebar: Feature Manager + Preferences ─────────────────────── */
|
||||
|
||||
.feature-manager-section {
|
||||
margin-bottom: 32px;
|
||||
.category-section.autolaunch-section,
|
||||
.category-section.feature-manager-section {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.feature-subcategory {
|
||||
margin-bottom: 16px;
|
||||
/* The old "Preferences" header + hr becomes a small sidebar label.
|
||||
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 {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 8px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.feature-cards-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
color: var(--text-3);
|
||||
padding: 12px 10px 6px;
|
||||
}
|
||||
|
||||
/* Feature card — sidebar preference row with a switch */
|
||||
.feature-card {
|
||||
background-color: var(--card-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
padding: 9px 10px;
|
||||
border-radius: 10px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.feature-card:hover { background: rgba(255, 255, 255, 0.05); }
|
||||
|
||||
.feature-card-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.feature-card-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.feature-card-info { min-width: 0; flex: 1; }
|
||||
|
||||
.feature-card-name {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.feature-card-desc {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin-top: 2px;
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-3);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.feature-card-status {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.66rem;
|
||||
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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
width: 46px;
|
||||
height: 26px;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
margin-top: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.feature-toggle-input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.feature-toggle-slider {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: var(--border-color);
|
||||
border-radius: 24px;
|
||||
transition: background-color 0.2s;
|
||||
border-radius: 99px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid var(--border-strong);
|
||||
transition: 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.feature-toggle-slider::before {
|
||||
.feature-toggle-slider::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
left: 3px;
|
||||
top: 3px;
|
||||
background-color: #fff;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
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 {
|
||||
background-color: var(--green);
|
||||
.feature-toggle-input:checked + .feature-toggle-slider {
|
||||
background: var(--accent);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.feature-toggle.active .feature-toggle-slider::before {
|
||||
transform: translateX(20px);
|
||||
.feature-toggle-input:checked + .feature-toggle-slider::after {
|
||||
left: 23px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.feature-domain-badge {
|
||||
display: flex;
|
||||
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;
|
||||
.feature-toggle-input:focus-visible + .feature-toggle-slider {
|
||||
box-shadow: 0 0 0 3px rgba(66, 243, 154, 0.3);
|
||||
}
|
||||
|
||||
@@ -1,69 +1,93 @@
|
||||
/* ── Header bar ─────────────────────────────────────────────────── */
|
||||
/* ── Topbar (replaces the old header-bar + ip-bar) ──────────────── */
|
||||
|
||||
.header-bar {
|
||||
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;
|
||||
.topbar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
height: 80px;
|
||||
width: auto;
|
||||
display: block;
|
||||
gap: 14px;
|
||||
padding: 13px 26px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.header-bar .title {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
.page-title {
|
||||
font-size: 1.02rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.title-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px 10px;
|
||||
}
|
||||
/* ── Search ─────────────────────────────────────────────────────── */
|
||||
|
||||
.header-buttons {
|
||||
.search-box {
|
||||
display: flex;
|
||||
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 {
|
||||
background-color: var(--accent-color);
|
||||
color: #0A1A10;
|
||||
display: inline-flex;
|
||||
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-weight: 700;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
letter-spacing: 0.03em;
|
||||
font-weight: 750;
|
||||
padding: 6px 12px;
|
||||
border-radius: 99px;
|
||||
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 {
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.72rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-2);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 600;
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding: 2px 9px;
|
||||
border-radius: 99px;
|
||||
border: 1px solid var(--border);
|
||||
letter-spacing: 0.02em;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -71,76 +95,13 @@
|
||||
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) {
|
||||
.btn-header-reboot {
|
||||
padding: 4px 8px;
|
||||
padding: 5px 10px;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.btn-logout {
|
||||
padding: 5px 10px;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,138 +1,533 @@
|
||||
/* ── Main content ───────────────────────────────────────────────── */
|
||||
/* ── App shell: sidebar + main column ───────────────────────────── */
|
||||
|
||||
.main-content {
|
||||
.app {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
max-width: 1400px;
|
||||
width: 100%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 26px 30px 70px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Sidebar ────────────────────────────────────────────────────── */
|
||||
|
||||
.sidebar {
|
||||
width: 270px;
|
||||
width: 250px;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
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;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 11px;
|
||||
width: 100%;
|
||||
background-color: var(--card-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
color: var(--text-primary);
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text-2);
|
||||
font-size: 0.87rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: border-style 0.15s, border-color 0.15s, background-color 0.15s;
|
||||
text-align: left;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.sidebar-support-btn:hover {
|
||||
border-color: var(--accent-color);
|
||||
border-style: solid;
|
||||
background-color: #162320;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sidebar-support-btn + .sidebar-support-btn {
|
||||
margin-top: 8px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.sidebar-support-icon {
|
||||
font-size: 1.5rem;
|
||||
font-size: 1rem;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-support-icon svg { width: 17px; height: 17px; }
|
||||
|
||||
.sidebar-support-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-support-title {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.87rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sidebar-support-hint {
|
||||
font-size: 0.72rem;
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 650;
|
||||
color: var(--text-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sidebar-divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin: 16px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 14px 0 0;
|
||||
}
|
||||
|
||||
/* ── Upgrade modal ──────────────────────────────────────────────── */
|
||||
.sidebar-spacer { flex: 1; min-height: 18px; }
|
||||
|
||||
.upgrade-dialog {
|
||||
max-width: 480px;
|
||||
/* ── Welcome dashboard (default view) ───────────────────────────── */
|
||||
|
||||
.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 {
|
||||
background-color: var(--card-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 14px;
|
||||
/* Soft drifting glow — calm, GPU-cheap (transforms only) */
|
||||
.welcome-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.upgrade-info-title {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
.orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
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;
|
||||
}
|
||||
|
||||
.upgrade-info-list {
|
||||
padding-left: 20px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
margin: 0;
|
||||
.splash-ring img { width: 60px; height: 60px; opacity: 0.95; }
|
||||
|
||||
.splash-ring-arc {
|
||||
position: absolute;
|
||||
inset: 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 {
|
||||
color: var(--accent-color);
|
||||
@keyframes splash-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.splash-title {
|
||||
font-size: 1.02rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.upgrade-rebuild-note {
|
||||
font-style: italic;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.82rem;
|
||||
.splash-sub {
|
||||
font-size: 0.84rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
@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 {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 24px 20px 48px;
|
||||
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-section {
|
||||
@@ -140,25 +535,30 @@
|
||||
}
|
||||
|
||||
.section-header {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
padding-left: 4px;
|
||||
color: var(--text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-bottom: 16px;
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, var(--border), transparent);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tiles-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(158px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
/* ── Empty state ────────────────────────────────────────────────── */
|
||||
@@ -166,10 +566,103 @@
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 64px 24px;
|
||||
color: var(--text-dim);
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 1rem;
|
||||
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;
|
||||
min-height: 100vh;
|
||||
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);
|
||||
padding: 24px 16px 48px;
|
||||
overflow-y: auto;
|
||||
@@ -70,13 +70,13 @@
|
||||
.onboarding-step-dot.active {
|
||||
background-color: var(--accent-color);
|
||||
border-color: var(--accent-color);
|
||||
color: #0A1A10;
|
||||
color: #04220f;
|
||||
}
|
||||
|
||||
.onboarding-step-dot.completed {
|
||||
background-color: var(--green);
|
||||
border-color: var(--green);
|
||||
color: #0A1A10;
|
||||
color: #04220f;
|
||||
}
|
||||
|
||||
.onboarding-step-connector {
|
||||
@@ -139,7 +139,7 @@
|
||||
/* Cards */
|
||||
|
||||
.onboarding-card {
|
||||
background-color: rgba(14, 16, 15, 0.65);
|
||||
background-color: rgba(18, 24, 20, 0.65);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
@@ -184,10 +184,10 @@
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-color);
|
||||
background-color: rgba(94, 173, 138, 0.10);
|
||||
background-color: rgba(62, 207, 142, 0.10);
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(94, 173, 138, 0.25);
|
||||
border: 1px solid rgba(62, 207, 142, 0.25);
|
||||
}
|
||||
|
||||
/* Step header */
|
||||
@@ -371,8 +371,8 @@
|
||||
|
||||
.onboarding-port-warn {
|
||||
padding: 10px 14px;
|
||||
background-color: rgba(229, 165, 10, 0.1);
|
||||
border: 1px solid rgba(229, 165, 10, 0.35);
|
||||
background-color: rgba(233, 182, 74, 0.1);
|
||||
border: 1px solid rgba(233, 182, 74, 0.35);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--yellow);
|
||||
@@ -409,8 +409,8 @@
|
||||
|
||||
.onboarding-creds-notice {
|
||||
padding: 12px 16px;
|
||||
background-color: rgba(94, 173, 138, 0.08);
|
||||
border: 1px solid rgba(94, 173, 138, 0.20);
|
||||
background-color: rgba(62, 207, 142, 0.08);
|
||||
border: 1px solid rgba(62, 207, 142, 0.20);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
@@ -503,8 +503,8 @@
|
||||
font-size: 0.72rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(94, 173, 138, 0.08);
|
||||
border: 1px solid rgba(94, 173, 138, 0.20);
|
||||
background-color: rgba(62, 207, 142, 0.08);
|
||||
border: 1px solid rgba(62, 207, 142, 0.20);
|
||||
color: var(--accent-color);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
@@ -512,7 +512,7 @@
|
||||
}
|
||||
|
||||
.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) */
|
||||
@@ -671,7 +671,7 @@
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -683,8 +683,8 @@
|
||||
|
||||
.onboarding-password-warning {
|
||||
padding: 10px 14px;
|
||||
background-color: rgba(229, 165, 10, 0.1);
|
||||
border: 1px solid rgba(229, 165, 10, 0.35);
|
||||
background-color: rgba(233, 182, 74, 0.1);
|
||||
border: 1px solid rgba(233, 182, 74, 0.35);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--yellow);
|
||||
@@ -694,8 +694,8 @@
|
||||
|
||||
.onboarding-password-success {
|
||||
padding: 12px 16px;
|
||||
background-color: rgba(94, 173, 138, 0.10);
|
||||
border: 1px solid rgba(94, 173, 138, 0.30);
|
||||
background-color: rgba(62, 207, 142, 0.10);
|
||||
border: 1px solid rgba(62, 207, 142, 0.30);
|
||||
border-radius: 8px;
|
||||
font-size: 0.92rem;
|
||||
color: var(--green);
|
||||
@@ -748,7 +748,7 @@
|
||||
}
|
||||
|
||||
.reboot-card {
|
||||
background-color: rgba(14, 16, 15, 0.8);
|
||||
background-color: rgba(18, 24, 20, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
@@ -823,57 +823,8 @@
|
||||
|
||||
/* ── Responsive ─────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
overflow: auto;
|
||||
}
|
||||
.main-content {
|
||||
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;
|
||||
}
|
||||
}
|
||||
/* Legacy pre-redesign narrow-screen rules removed: they targeted the old
|
||||
DOM (.main-content, .header-bar, .ip-bar) and their .sidebar /
|
||||
#tiles-area / .service-tile / .creds-qr-img overrides fought the new
|
||||
responsive layout in layout.css (sidebar icon rail, wrapping topbar).
|
||||
Narrow-viewport behavior now lives in layout.css + header.css. */
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
.security-warning-box {
|
||||
background-color: rgba(180, 40, 40, 0.10);
|
||||
border-left: 3px solid #c94040;
|
||||
border-left: 3px solid var(--red);
|
||||
border-radius: 6px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 14px;
|
||||
@@ -78,7 +78,7 @@
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #c94040;
|
||||
background-color: var(--red);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
@@ -90,7 +90,7 @@
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background-color: #a83030;
|
||||
background-color: var(--red);
|
||||
}
|
||||
|
||||
.btn-danger:disabled {
|
||||
@@ -105,8 +105,8 @@
|
||||
}
|
||||
|
||||
.security-status-info { color: var(--text-secondary); }
|
||||
.security-status-ok { color: #6DBF8B; }
|
||||
.security-status-error { color: #e05252; }
|
||||
.security-status-ok { color: var(--accent); }
|
||||
.security-status-error { color: var(--red); }
|
||||
|
||||
/* ── Verify System Integrity ─────────────────────────────────────── */
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
|
||||
.security-verify-link {
|
||||
font-size: 0.78rem;
|
||||
color: var(--accent-color, #6DBF8B);
|
||||
color: var(--accent-color, var(--accent));
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -174,13 +174,13 @@
|
||||
}
|
||||
|
||||
.security-verify-pass {
|
||||
background-color: rgba(109, 191, 139, 0.15);
|
||||
color: #6DBF8B;
|
||||
background-color: rgba(66, 243, 154, 0.15);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.security-verify-fail {
|
||||
background-color: rgba(224, 82, 82, 0.15);
|
||||
color: #e05252;
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.security-verify-errors {
|
||||
@@ -221,12 +221,11 @@
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(6, 8, 7, 0.94);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
background: radial-gradient(1000px 640px at 50% 18%, #202429, #0f1113 72%);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 26px;
|
||||
animation: security-reset-fade-in 0.35s ease-out;
|
||||
}
|
||||
|
||||
@@ -253,13 +252,13 @@
|
||||
}
|
||||
|
||||
.security-reset-password-box {
|
||||
font-family: monospace;
|
||||
font-family: var(--mono);
|
||||
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;
|
||||
background: var(--accent-dim);
|
||||
border: 1.5px solid rgba(66, 243, 154, 0.35);
|
||||
border-radius: 14px;
|
||||
padding: 14px 24px;
|
||||
letter-spacing: 0.04em;
|
||||
text-align: center;
|
||||
@@ -277,10 +276,11 @@
|
||||
}
|
||||
|
||||
.security-reset-reboot-btn {
|
||||
background-color: #6DBF8B;
|
||||
color: #0a0c0b;
|
||||
background: linear-gradient(180deg, #3fd68e, #1ea263);
|
||||
color: #04220f;
|
||||
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;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
@@ -290,7 +290,7 @@
|
||||
}
|
||||
|
||||
.security-reset-reboot-btn:hover:not(:disabled) {
|
||||
background-color: #5aab78;
|
||||
background-color: var(--accent);
|
||||
}
|
||||
|
||||
.security-reset-reboot-btn:disabled {
|
||||
@@ -303,11 +303,12 @@
|
||||
.security-first-login-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 18px;
|
||||
background-color: rgba(94, 173, 138, 0.08);
|
||||
border-bottom: 2px solid rgba(94, 173, 138, 0.25);
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 24px;
|
||||
background: rgba(233, 182, 74, 0.08);
|
||||
border: 1px solid rgba(233, 182, 74, 0.30);
|
||||
border-radius: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -333,19 +334,21 @@
|
||||
|
||||
.security-banner-dismiss {
|
||||
background: none;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 99px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
padding: 2px 7px;
|
||||
padding: 4px 10px;
|
||||
flex-shrink: 0;
|
||||
line-height: 1.4;
|
||||
transition: background-color 0.15s;
|
||||
transition: 0.15s;
|
||||
}
|
||||
|
||||
.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 ───────────────────────── */
|
||||
@@ -357,14 +360,14 @@
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 12px;
|
||||
background-color: rgba(180, 100, 0, 0.12);
|
||||
border-left: 3px solid #c97a00;
|
||||
border-left: 3px solid #c98d08;
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.security-inline-icon {
|
||||
font-size: 1rem;
|
||||
color: #e69000;
|
||||
color: #e0a010;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -378,9 +381,9 @@
|
||||
display: inline-block;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: #e69000;
|
||||
color: #e0a010;
|
||||
text-decoration: none;
|
||||
border: 1px solid #c97a00;
|
||||
border: 1px solid #c98d08;
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
align-self: flex-start;
|
||||
@@ -447,9 +450,9 @@
|
||||
|
||||
.pw-credentials-note {
|
||||
font-size: 0.78rem;
|
||||
color: #c97a00;
|
||||
color: #c98d08;
|
||||
background-color: rgba(180, 100, 0, 0.10);
|
||||
border-left: 2px solid #c97a00;
|
||||
border-left: 2px solid #c98d08;
|
||||
border-radius: 4px;
|
||||
padding: 7px 10px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
}
|
||||
|
||||
.support-steps code {
|
||||
background-color: rgba(94, 173, 138, 0.10);
|
||||
background-color: rgba(62, 207, 142, 0.10);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.82rem;
|
||||
@@ -116,7 +116,7 @@
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--accent-color);
|
||||
color: #0A1A10;
|
||||
color: #04220f;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
@@ -146,7 +146,7 @@
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--accent-color);
|
||||
color: #0A1A10;
|
||||
color: #04220f;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
margin-top: 16px;
|
||||
@@ -168,7 +168,7 @@
|
||||
}
|
||||
|
||||
.support-btn-auditlog:hover:not(:disabled) {
|
||||
background-color: #1c2a24;
|
||||
background-color: var(--card-hover);
|
||||
}
|
||||
|
||||
.support-fine-print {
|
||||
@@ -219,13 +219,13 @@
|
||||
}
|
||||
|
||||
.support-wallet-protected {
|
||||
background-color: rgba(109, 191, 139, 0.06);
|
||||
border-color: rgba(109, 191, 139, 0.3);
|
||||
background-color: rgba(66, 243, 154, 0.06);
|
||||
border-color: rgba(66, 243, 154, 0.3);
|
||||
}
|
||||
|
||||
.support-wallet-unlocked {
|
||||
background-color: rgba(229, 165, 10, 0.06);
|
||||
border-color: rgba(229, 165, 10, 0.3);
|
||||
background-color: rgba(233, 182, 74, 0.06);
|
||||
border-color: rgba(233, 182, 74, 0.3);
|
||||
}
|
||||
|
||||
.support-wallet-warning {
|
||||
@@ -290,7 +290,7 @@
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--yellow);
|
||||
color: #0A1A10;
|
||||
color: #04220f;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -310,7 +310,7 @@
|
||||
}
|
||||
|
||||
.support-btn-wallet-lock:hover:not(:disabled) {
|
||||
background-color: #529E7E;
|
||||
background-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Audit log ───────────────────────────────────────────────────── */
|
||||
@@ -324,7 +324,7 @@
|
||||
.support-audit-log {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background-color: #0c0f0e;
|
||||
background-color: var(--inset);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
@@ -1,473 +1,178 @@
|
||||
/* ── Service tile card (status-only) ─────────────────────────────── */
|
||||
|
||||
.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 tiles ──────────────────────────────────────────────── */
|
||||
|
||||
.service-tile {
|
||||
width: 160px;
|
||||
min-height: 130px;
|
||||
background-color: var(--card-color);
|
||||
border: 1px solid var(--border-color);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
padding: 21px 12px 17px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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;
|
||||
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 {
|
||||
transform: translateY(-4px);
|
||||
border-color: var(--border-strong);
|
||||
background: var(--card-hover);
|
||||
box-shadow: var(--shadow-hover);
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.service-tile.disabled {
|
||||
opacity: 0.45;
|
||||
.service-tile:active { transform: translateY(-1px) scale(0.985); }
|
||||
|
||||
.service-tile:focus-visible {
|
||||
outline: 2px solid rgba(66, 243, 154, 0.45);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.service-tile.disabled { opacity: 0.55; }
|
||||
|
||||
.tile-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.tile-icon-fallback {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--border-color);
|
||||
border-radius: 12px;
|
||||
color: var(--text-dim);
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 10px;
|
||||
color: var(--text-3);
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tile-name {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
margin-top: 13px;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 650;
|
||||
text-align: center;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.3;
|
||||
max-width: 140px;
|
||||
word-break: break-word;
|
||||
hyphens: auto;
|
||||
min-height: 1.3em;
|
||||
line-height: 1.25;
|
||||
min-height: 2.5em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Status pill — dot + label inside a rounded pill */
|
||||
.tile-status {
|
||||
font-size: 0.75rem;
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
margin-top: 9px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--text-secondary);
|
||||
gap: 6px;
|
||||
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 {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 2px;
|
||||
text-align: center;
|
||||
}
|
||||
.status-text { line-height: 1; }
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-3);
|
||||
flex-shrink: 0;
|
||||
background-color: var(--grey);
|
||||
}
|
||||
|
||||
.status-dot.active { background-color: var(--green); }
|
||||
.status-dot.inactive { background-color: var(--red); }
|
||||
.status-dot.loading { background-color: var(--yellow); animation: pulse-badge 1s infinite; }
|
||||
.status-dot.failed { background-color: var(--red); }
|
||||
.status-dot.disabled { background-color: var(--grey); }
|
||||
.status-dot.needs-attention { background-color: var(--yellow); }
|
||||
.status-dot.syncing { background-color: #f5a623; animation: pulse-badge 1.5s infinite; }
|
||||
.status-dot.checking-reachability { background-color: var(--accent-color); animation: pulse-badge 1s infinite; }
|
||||
.status-dot.active { background: var(--accent); }
|
||||
.status-dot.needs-attention { background: var(--amber); }
|
||||
.status-dot.failed { background: var(--red); }
|
||||
.status-dot.inactive,
|
||||
.status-dot.disabled { background: var(--text-3); }
|
||||
.status-dot.syncing,
|
||||
.status-dot.loading,
|
||||
.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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 12px;
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.tile-sync-label {
|
||||
font-size: 0.72rem;
|
||||
color: #f5a623;
|
||||
font-weight: 600;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--amber);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tile-sync-bar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
.tile-sync-bar-track {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background-color: var(--border-color);
|
||||
border-radius: 3px;
|
||||
height: 7px;
|
||||
border-radius: 99px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tile-sync-bar-fill {
|
||||
height: 100%;
|
||||
background-color: #f5a623;
|
||||
border-radius: 3px;
|
||||
transition: width 0.6s ease;
|
||||
min-width: 2px;
|
||||
border-radius: 99px;
|
||||
background: linear-gradient(90deg, #42f39a, #1aa45d);
|
||||
transition: width 0.6s cubic-bezier(0.3, 0.7, 0.3, 1);
|
||||
}
|
||||
|
||||
.tile-sync-percent {
|
||||
font-size: 0.72rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
color: #f5a623;
|
||||
white-space: nowrap;
|
||||
min-width: 2.5em;
|
||||
text-align: right;
|
||||
color: var(--amber);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tile-sync-eta {
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 6px;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.66rem;
|
||||
color: var(--text-3);
|
||||
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 = [
|
||||
"infrastructure",
|
||||
"bitcoin-base",
|
||||
"bitcoin-apps",
|
||||
"bitcoin",
|
||||
"communication",
|
||||
"apps",
|
||||
"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 = {
|
||||
"infrastructure": "🔧 Infrastructure",
|
||||
"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>'
|
||||
+ " 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
|
||||
// if ($updateBtn) $updateBtn.addEventListener("click", openUpdateModal); // moved to sidebar in tiles.js
|
||||
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 ($btnSave) $btnSave.addEventListener("click", saveErrorReport);
|
||||
if ($btnRetryUpdate) $btnRetryUpdate.addEventListener("click", retryUpdateStatus);
|
||||
if ($btnRetryRun) $btnRetryRun.addEventListener("click", retryUpdateRun);
|
||||
|
||||
// Browser timers and requests may be suspended while an RDP session/tab is in
|
||||
// the background. Reconcile immediately when the user returns instead of
|
||||
@@ -27,6 +30,7 @@ if ($logoutBtn) $logoutBtn.addEventListener("click", function () {
|
||||
|
||||
// Rebuild modal
|
||||
if ($rebuildClose) $rebuildClose.addEventListener("click", closeRebuildModal);
|
||||
if ($rebuildCloseHdr) $rebuildCloseHdr.addEventListener("click", closeRebuildModal);
|
||||
if ($rebuildReboot) $rebuildReboot.addEventListener("click", doReboot);
|
||||
if ($rebuildSave) $rebuildSave.addEventListener("click", saveRebuildErrorReport);
|
||||
if ($rebuildModal) $rebuildModal.addEventListener("click", function(e) { if (e.target === $rebuildModal) closeRebuildModal(); });
|
||||
@@ -174,9 +178,9 @@ function showSecurityBanner() {
|
||||
'</div>' +
|
||||
'<button class="security-banner-dismiss" id="security-banner-dismiss-btn" title="Dismiss">\u2715</button>';
|
||||
|
||||
var mainContent = document.querySelector(".main-content");
|
||||
if (mainContent) {
|
||||
mainContent.insertAdjacentElement("beforebegin", banner);
|
||||
var contentArea = document.querySelector(".content");
|
||||
if (contentArea) {
|
||||
contentArea.insertAdjacentElement("afterbegin", banner);
|
||||
} else {
|
||||
document.body.insertAdjacentElement("afterbegin", banner);
|
||||
}
|
||||
@@ -238,10 +242,11 @@ async function init() {
|
||||
}
|
||||
var badge = document.getElementById("role-badge");
|
||||
if (badge && cfg.role_label) badge.textContent = cfg.role_label;
|
||||
window._roleLabel = cfg.role_label || "";
|
||||
|
||||
await refreshServices();
|
||||
loadNetwork();
|
||||
checkUpdates();
|
||||
await refreshServices();
|
||||
|
||||
setInterval(refreshServices, POLL_INTERVAL_SERVICES);
|
||||
setInterval(checkUpdates, POLL_INTERVAL_UPDATES);
|
||||
@@ -251,9 +256,9 @@ async function init() {
|
||||
}
|
||||
loadAutolaunchToggle();
|
||||
} catch (_) {
|
||||
await refreshServices();
|
||||
loadNetwork();
|
||||
checkUpdates();
|
||||
await refreshServices();
|
||||
setInterval(refreshServices, POLL_INTERVAL_SERVICES);
|
||||
setInterval(checkUpdates, POLL_INTERVAL_UPDATES);
|
||||
loadAutolaunchToggle();
|
||||
|
||||
@@ -97,7 +97,6 @@ function openDomainSetupModal(feat, onSaved) {
|
||||
nwcWarning +
|
||||
renderDomainNeedsHtml({ serviceName: feat.name, hostExample: hostExample, purpose: purpose }) +
|
||||
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>' +
|
||||
'</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");
|
||||
|
||||
// Fill the router port-forwarding box with this computer's LAN IP (best-effort)
|
||||
renderRouterPortsBox("domain-router-box");
|
||||
}
|
||||
|
||||
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>' +
|
||||
'<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>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>' +
|
||||
'<div class="onboarding-port-warn" id="domain-router-box" style="margin-top:12px;"></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-ddns-input">Njal.la Dynamic DNS Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl "https://njal.la/update/?h=' + domainPlaceholder + '&k=abc123&auto"" /><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");
|
||||
|
||||
// Fill the router port-forwarding box with this computer's LAN IP (best-effort)
|
||||
renderRouterPortsBox("domain-router-box");
|
||||
}
|
||||
|
||||
function closeDomainSetupModal() {
|
||||
@@ -635,16 +628,16 @@ async function loadAutolaunchToggle() {
|
||||
}
|
||||
|
||||
function renderAutolaunchToggle(enabled) {
|
||||
// Remove existing section if any
|
||||
var old = $sidebarFeatures.querySelector(".autolaunch-section");
|
||||
// The preference lives in the Systems Operational modal (moved from the sidebar)
|
||||
var slot = document.getElementById("autolaunch-slot");
|
||||
if (!slot) return;
|
||||
var old = slot.querySelector(".autolaunch-section");
|
||||
if (old) old.parentNode.removeChild(old);
|
||||
|
||||
var section = document.createElement("div");
|
||||
section.className = "category-section autolaunch-section";
|
||||
|
||||
section.innerHTML =
|
||||
'<div class="section-header">Preferences</div>' +
|
||||
'<hr class="section-divider" />' +
|
||||
'<div class="feature-card">' +
|
||||
'<div class="feature-card-top">' +
|
||||
'<div class="feature-card-info">' +
|
||||
@@ -658,7 +651,7 @@ function renderAutolaunchToggle(enabled) {
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
$sidebarFeatures.appendChild(section);
|
||||
slot.appendChild(section);
|
||||
|
||||
var input = document.getElementById("autolaunch-toggle-input");
|
||||
var label = document.getElementById("autolaunch-toggle-label");
|
||||
|
||||
@@ -67,7 +67,7 @@ function renderPortForwardGuideHtml(ports, opts) {
|
||||
var noteClass = opts.noteClass || "port-req-hint";
|
||||
var ipHtml = opts.internalIp
|
||||
? '<code class="port-req-internal-ip">' + escHtml(opts.internalIp) + '</code>'
|
||||
: 'this computer’s <strong>internal IP</strong> (shown as “Internal IP” at the top of the Hub dashboard)';
|
||||
: 'this computer’s <strong>internal IP</strong>';
|
||||
|
||||
var rows = (ports || []).map(function(p) {
|
||||
return '<tr>' +
|
||||
@@ -78,26 +78,17 @@ function renderPortForwardGuideHtml(ports, opts) {
|
||||
}).join("");
|
||||
|
||||
var forWhat = opts.serviceName
|
||||
? 'For <strong>' + escHtml(opts.serviceName) + '</strong> to be reachable from outside your home network, open'
|
||||
: 'Open';
|
||||
? 'To make <strong>' + escHtml(opts.serviceName) + '</strong> reachable from outside your home, forward these ports to ' + ipHtml + ':'
|
||||
: 'Forward these ports to ' + ipHtml + ':';
|
||||
|
||||
return '<p class="' + introClass + '">' +
|
||||
forWhat + ' the ports below in your router’s <strong>port forwarding</strong> settings ' +
|
||||
'and point them at ' + ipHtml + '.' +
|
||||
return '<p class="' + introClass + '">' + forWhat + '</p>' +
|
||||
'<p class="port-req-steps" style="margin-top:6px;margin-bottom:10px;font-size:0.92em;color:#555;">' +
|
||||
'Set the internal and external port to the <strong>same number</strong>. Match <strong>TCP</strong> or <strong>UDP</strong> exactly. For ranges like <strong>40000-40099</strong>, use your router’s range fields (start 40000, end 40099).' +
|
||||
'</p>' +
|
||||
'<ul class="port-req-steps">' +
|
||||
'<li>Set the <strong>internal (private) port</strong> and the <strong>external (public) port</strong> to the <strong>same number</strong>.</li>' +
|
||||
'<li>Match the <strong>protocol</strong> exactly — a rule set to TCP will not pass UDP traffic. Where the table says <strong>TCP + UDP</strong>, create both rules (or pick “Both”/“TCP/UDP” if your router offers it).</li>' +
|
||||
'<li>For a range such as <strong>40000-40099</strong>, use your router’s port-range fields — start 40000, end 40099 — rather than one rule per port.</li>' +
|
||||
'</ul>' +
|
||||
'<table class="' + tableClass + '">' +
|
||||
'<thead><tr><th>Port(s)</th><th>Protocol</th><th>Used for</th></tr></thead>' +
|
||||
'<tbody>' + rows + '</tbody>' +
|
||||
'</table>' +
|
||||
'<p class="' + noteClass + '">' +
|
||||
'📱 <strong>How to confirm it worked:</strong> forwarding happens on your router, so it can only be verified from outside your network. ' +
|
||||
'Turn Wi-Fi off on your phone and open the service over mobile data — if it loads, your ports are open.' +
|
||||
'</p>';
|
||||
'</table>';
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
|
||||
@@ -2,6 +2,35 @@
|
||||
|
||||
// ── 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() {
|
||||
if (!$rebuildModal) return;
|
||||
_rebuildLog = "";
|
||||
@@ -13,11 +42,12 @@ function openRebuildModal() {
|
||||
if ($rebuildLog) { $rebuildLog.textContent = ""; $rebuildLog.style.display = "none"; }
|
||||
var action = _rebuildIsEnabling ? "Enabling" : "Disabling";
|
||||
var label = _rebuildFeatureName || "feature";
|
||||
if ($rebuildStatus) $rebuildStatus.textContent = action + " " + label + "…";
|
||||
_setRebuildStatus(action + " " + label + "…");
|
||||
if ($rebuildSpinner) $rebuildSpinner.classList.add("spinning");
|
||||
if ($rebuildReboot) $rebuildReboot.style.display = "none";
|
||||
if ($rebuildSave) $rebuildSave.style.display = "none";
|
||||
if ($rebuildClose) $rebuildClose.disabled = true;
|
||||
if ($rebuildCloseHdr) $rebuildCloseHdr.disabled = true;
|
||||
$rebuildModal.classList.add("open");
|
||||
// Delay first poll slightly to let the rebuild service start and clear stale log
|
||||
setTimeout(startRebuildPoll, 1500);
|
||||
@@ -76,7 +106,7 @@ async function pollRebuildStatus() {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
if (!_rebuildServerDown) { _rebuildServerDown = true; if ($rebuildStatus) $rebuildStatus.textContent = "Applying changes…"; }
|
||||
if (!_rebuildServerDown) { _rebuildServerDown = true; _setRebuildStatus("Applying changes…"); }
|
||||
} finally {
|
||||
_rebuildPollInFlight = false;
|
||||
}
|
||||
@@ -85,15 +115,16 @@ async function pollRebuildStatus() {
|
||||
function onRebuildDone(result) {
|
||||
if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning");
|
||||
if ($rebuildClose) $rebuildClose.disabled = false;
|
||||
if ($rebuildCloseHdr) $rebuildCloseHdr.disabled = false;
|
||||
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
|
||||
setTimeout(function() { window.location.reload(); }, 1200);
|
||||
} else if (result === "reboot_required") {
|
||||
if ($rebuildStatus) $rebuildStatus.textContent = "✓ Done — restart required";
|
||||
_setRebuildStatus("✓ Done — restart required");
|
||||
if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex";
|
||||
} else {
|
||||
if ($rebuildStatus) $rebuildStatus.textContent = "✗ Something went wrong";
|
||||
_setRebuildStatus("✗ Something went wrong");
|
||||
if ($rebuildSave) $rebuildSave.style.display = "inline-flex";
|
||||
if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex";
|
||||
}
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
function openSecurityModal() {
|
||||
if ($supportModal) $supportModal.classList.add("open");
|
||||
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) {
|
||||
$supportBody.innerHTML =
|
||||
|
||||
@@ -696,6 +696,17 @@ async function openServiceDetailModal(unit, name, icon) {
|
||||
: (data.health || data.status);
|
||||
var sc = statusClass(effectiveHealth);
|
||||
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">' +
|
||||
'<div class="svc-detail-section-title">Status</div>' +
|
||||
'<div class="svc-detail-status">' +
|
||||
@@ -707,19 +718,27 @@ async function openServiceDetailModal(unit, name, icon) {
|
||||
|
||||
// Section C: Domain diagnostics (domain services)
|
||||
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 = "";
|
||||
steps.forEach(function(step) {
|
||||
var iconLabel = "—";
|
||||
if (step.status === "ok") iconLabel = "✅";
|
||||
else if (step.status === "error") iconLabel = "❌";
|
||||
else if (step.status === "warning") iconLabel = "⚠️";
|
||||
else if (step.status === "skipped") iconLabel = "⏭️";
|
||||
var detail = escHtml(step.detail || "").replace(/\n/g, "<br>");
|
||||
stepsHtml += '<div class="svc-detail-troubleshoot" style="margin-bottom:10px">' +
|
||||
'<strong>' + iconLabel + ' Step ' + escHtml(String(step.step)) + ': ' + escHtml(step.label || "") + '</strong>' +
|
||||
(detail ? '<div style="margin-top:6px">' + detail + '</div>' : '') +
|
||||
'</div>';
|
||||
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">' +
|
||||
'<strong>' + iconLabel + ' ' + escHtml(step.label || "Domain not configured") + '</strong>' +
|
||||
(detail ? '<div style="margin-top:6px">' + detail + '</div>' : '') +
|
||||
'</div>';
|
||||
}
|
||||
});
|
||||
|
||||
var domainActionHtml = "";
|
||||
@@ -731,11 +750,22 @@ async function openServiceDetailModal(unit, name, icon) {
|
||||
}
|
||||
|
||||
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 +
|
||||
domainActionHtml +
|
||||
'</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) {
|
||||
var trimmedInternalIp = data.internal_ip ? String(data.internal_ip).trim() : "";
|
||||
addSetup('<div class="svc-detail-section">' +
|
||||
@@ -993,6 +1023,9 @@ async function openServiceDetailModal(unit, name, icon) {
|
||||
$credsBody.innerHTML = html;
|
||||
if (isNwc) _nwcWireTabs();
|
||||
_attachCopyHandlers($credsBody);
|
||||
if (document.getElementById("svc-node-router-box")) {
|
||||
renderRouterPortsBox("svc-node-router-box");
|
||||
}
|
||||
if (_isNwcServiceUnit(unit) && (effectiveEnabled || data.enabled)) {
|
||||
await _nwcInitWalletFlow(unit, name, icon);
|
||||
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 $btnSave = document.getElementById("btn-save-report");
|
||||
const $btnRetryUpdate = document.getElementById("btn-retry-update-status");
|
||||
const $btnRetryRun = document.getElementById("btn-retry-update");
|
||||
const $btnCloseModal = document.getElementById("btn-close-modal");
|
||||
const $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 $rebootMainCard = document.getElementById("reboot-main-card");
|
||||
@@ -79,6 +84,8 @@ const $rebuildLog = document.getElementById("rebuild-log");
|
||||
const $rebuildReboot = document.getElementById("rebuild-reboot-btn");
|
||||
const $rebuildSave = document.getElementById("rebuild-save-report");
|
||||
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
|
||||
const $domainSetupModal = document.getElementById("domain-setup-modal");
|
||||
|
||||
@@ -5,6 +5,14 @@
|
||||
async function openSupportModal() {
|
||||
if (!$supportModal) return;
|
||||
$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>';
|
||||
try {
|
||||
var status = await apiFetch("/api/support/status");
|
||||
|
||||
@@ -25,6 +25,7 @@ function buildTiles(services, categoryLabels) {
|
||||
continue;
|
||||
}
|
||||
var cat = svc.category || "other";
|
||||
if (CATEGORY_ALIASES[cat]) cat = CATEGORY_ALIASES[cat];
|
||||
if (!grouped[cat]) grouped[cat] = [];
|
||||
grouped[cat].push(svc);
|
||||
}
|
||||
@@ -52,6 +53,7 @@ function buildTiles(services, categoryLabels) {
|
||||
if ($tilesArea.children.length === 0) {
|
||||
$tilesArea.innerHTML = '<div class="empty-state"><p>No services configured.</p></div>';
|
||||
}
|
||||
if (typeof window.dashboardServicesUpdated === "function") window.dashboardServicesUpdated();
|
||||
}
|
||||
|
||||
function renderSidebarSupport(supportServices) {
|
||||
@@ -62,20 +64,22 @@ function renderSidebarSupport(supportServices) {
|
||||
sidebarUpdateBtn.className = "sidebar-support-btn";
|
||||
sidebarUpdateBtn.id = "sidebar-btn-update";
|
||||
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-title">Update System</span>' +
|
||||
'<span class="sidebar-support-hint" id="sidebar-update-hint">Check for updates</span>' +
|
||||
'</span>';
|
||||
sidebarUpdateBtn.addEventListener("click", function() { openUpdateModal(); });
|
||||
$sidebarSupport.appendChild(sidebarUpdateBtn);
|
||||
// checkUpdates may already have run before the sidebar was built
|
||||
applyUpdateSidebarState(window._lastUpdateCheck);
|
||||
|
||||
for (var i = 0; i < supportServices.length; i++) {
|
||||
var svc = supportServices[i];
|
||||
var btn = document.createElement("button");
|
||||
btn.className = "sidebar-support-btn";
|
||||
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-title">' + escHtml(svc.name || "Tech Support") + '</span>' +
|
||||
'<span class="sidebar-support-hint">Click for help</span>' +
|
||||
@@ -88,7 +92,7 @@ function renderSidebarSupport(supportServices) {
|
||||
var backupBtn = document.createElement("button");
|
||||
backupBtn.className = "sidebar-support-btn";
|
||||
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-title">Manual Backup</span>' +
|
||||
'<span class="sidebar-support-hint">Back up to external drive</span>' +
|
||||
@@ -100,7 +104,7 @@ function renderSidebarSupport(supportServices) {
|
||||
var securityBtn = document.createElement("button");
|
||||
securityBtn.className = "sidebar-support-btn";
|
||||
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-title">Security</span>' +
|
||||
'<span class="sidebar-support-hint">Reset & verify system</span>' +
|
||||
@@ -113,7 +117,7 @@ function renderSidebarSupport(supportServices) {
|
||||
var upgradeBtn = document.createElement("button");
|
||||
upgradeBtn.className = "sidebar-support-btn";
|
||||
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-title">Upgrade to Full Server</span>' +
|
||||
'<span class="sidebar-support-hint">Unlock all services</span>' +
|
||||
@@ -237,6 +241,7 @@ function updateTiles(services) {
|
||||
if (text) text.textContent = st;
|
||||
}
|
||||
}
|
||||
if (typeof window.dashboardServicesUpdated === "function") window.dashboardServicesUpdated();
|
||||
}
|
||||
|
||||
// ── Service polling ───────────────────────────────────────────────
|
||||
@@ -248,6 +253,10 @@ async function refreshServices() {
|
||||
var services = await apiFetch("/api/services");
|
||||
if (_firstLoad) { buildTiles(services, _categoryLabels); _firstLoad = false; }
|
||||
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); }
|
||||
}
|
||||
|
||||
@@ -267,31 +276,48 @@ async function loadNetwork() {
|
||||
|
||||
// ── Update check ──────────────────────────────────────────────────
|
||||
|
||||
// Paint the sidebar Update button from the last known update state.
|
||||
// Called after each check AND when the button is (re)built, so the hint
|
||||
// is correct regardless of which finishes first at startup.
|
||||
function applyUpdateSidebarState(data) {
|
||||
if (!data) return;
|
||||
var sidebarUpdateBtn = document.getElementById("sidebar-btn-update");
|
||||
var sidebarUpdateHint = document.getElementById("sidebar-update-hint");
|
||||
if (!sidebarUpdateBtn) return;
|
||||
var hasUpdates = !!data.available;
|
||||
var updateStatus = data.status || "idle";
|
||||
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";
|
||||
} else if (updateStatus === "running") {
|
||||
sidebarUpdateBtn.style.borderColor = "#78aeed";
|
||||
sidebarUpdateBtn.style.backgroundColor = "rgba(120, 174, 237, 0.10)";
|
||||
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Update in progress…";
|
||||
} else if (hasUpdates) {
|
||||
sidebarUpdateBtn.style.borderColor = "#3ecf8e";
|
||||
sidebarUpdateBtn.style.backgroundColor = "rgba(62, 207, 142, 0.10)";
|
||||
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Updates available!";
|
||||
} else {
|
||||
sidebarUpdateBtn.style.borderColor = "";
|
||||
sidebarUpdateBtn.style.backgroundColor = "";
|
||||
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "System is up to date";
|
||||
}
|
||||
}
|
||||
|
||||
async function checkUpdates() {
|
||||
try {
|
||||
var data = await apiFetch("/api/updates/check");
|
||||
var hasUpdates = !!data.available;
|
||||
var updateStatus = data.status || "idle";
|
||||
var sidebarUpdateBtn = document.getElementById("sidebar-btn-update");
|
||||
var sidebarUpdateHint = document.getElementById("sidebar-update-hint");
|
||||
if (sidebarUpdateBtn) {
|
||||
if (updateStatus === "reboot_required") {
|
||||
sidebarUpdateBtn.style.borderColor = "#e5a50a";
|
||||
sidebarUpdateBtn.style.backgroundColor = "rgba(229, 165, 10, 0.10)";
|
||||
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Restart required";
|
||||
} else if (updateStatus === "running") {
|
||||
sidebarUpdateBtn.style.borderColor = "#3584e4";
|
||||
sidebarUpdateBtn.style.backgroundColor = "rgba(53, 132, 228, 0.10)";
|
||||
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Update in progress…";
|
||||
} else if (hasUpdates) {
|
||||
sidebarUpdateBtn.style.borderColor = "#2ec27e";
|
||||
sidebarUpdateBtn.style.backgroundColor = "rgba(46, 194, 126, 0.08)";
|
||||
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Updates available!";
|
||||
} else {
|
||||
sidebarUpdateBtn.style.borderColor = "";
|
||||
sidebarUpdateBtn.style.backgroundColor = "";
|
||||
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "System is up to date";
|
||||
}
|
||||
}
|
||||
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 (_) {}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,88 @@
|
||||
|
||||
// ── 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() {
|
||||
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,
|
||||
// RDP reconnect, or suspended tab recover the authoritative systemd-backed
|
||||
// state instead of starting over or claiming the system is merely up to date.
|
||||
@@ -14,7 +93,10 @@ async function openUpdateModal() {
|
||||
{ cache: "no-store" },
|
||||
STATUS_POLL_FETCH_TIMEOUT
|
||||
);
|
||||
if (current.running || current.result === "reboot_required") {
|
||||
if (current.running || current.result === "reboot_required" || current.result === "failed") {
|
||||
// An in-progress update, a staged update awaiting reboot, or a prior
|
||||
// failed update — reattach to the persisted systemd/log state instead of
|
||||
// starting over or wrongly reporting "up to date".
|
||||
showExistingUpdate(current);
|
||||
return;
|
||||
}
|
||||
@@ -28,6 +110,7 @@ async function openUpdateModal() {
|
||||
STATUS_POLL_FETCH_TIMEOUT
|
||||
)
|
||||
.then(function(data) {
|
||||
markUpdateChecked();
|
||||
if (!data.available) {
|
||||
stopUpdatePoll();
|
||||
_updateLog = "";
|
||||
@@ -35,12 +118,13 @@ async function openUpdateModal() {
|
||||
_updateVisibleLogChars = 0;
|
||||
_updateFinished = true;
|
||||
_updateStatusUnavailable = false;
|
||||
if ($modalLog) $modalLog.textContent = "";
|
||||
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 ($btnReboot) $btnReboot.style.display = "none";
|
||||
if ($btnSave) $btnSave.style.display = "none";
|
||||
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
||||
if ($btnRetryRun) $btnRetryRun.style.display = "none";
|
||||
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
||||
$modal.classList.add("open");
|
||||
return;
|
||||
@@ -64,11 +148,12 @@ function prepareUpdateModal() {
|
||||
_updateStatusUnavailable = false;
|
||||
_updatePollFailures = 0;
|
||||
if ($modalLog) $modalLog.textContent = "";
|
||||
if ($modalStatus) $modalStatus.textContent = "Starting update…";
|
||||
_setUpdateStatus("Starting update…");
|
||||
if ($modalSpinner) $modalSpinner.classList.add("spinning");
|
||||
if ($btnReboot) $btnReboot.style.display = "none";
|
||||
if ($btnSave) $btnSave.style.display = "none";
|
||||
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
||||
if ($btnRetryRun) $btnRetryRun.style.display = "none";
|
||||
if ($btnCloseModal) $btnCloseModal.disabled = true;
|
||||
$modal.classList.add("open");
|
||||
}
|
||||
@@ -84,7 +169,7 @@ function showExistingUpdate(data) {
|
||||
_updateLogOffset = Number(data.offset) || 0;
|
||||
|
||||
if (data.running) {
|
||||
if ($modalStatus) $modalStatus.textContent = "Updating…";
|
||||
_setUpdateStatus("Updating…");
|
||||
startUpdatePoll();
|
||||
return;
|
||||
}
|
||||
@@ -149,17 +234,19 @@ function startUpdate() {
|
||||
)
|
||||
.then(function(data) {
|
||||
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 ($btnReboot) $btnReboot.style.display = "none";
|
||||
if ($btnSave) $btnSave.style.display = "none";
|
||||
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
||||
if ($btnRetryRun) $btnRetryRun.style.display = "none";
|
||||
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
||||
_updateFinished = true;
|
||||
return;
|
||||
}
|
||||
if (data.status === "already_running") appendLog("[Update already in progress, attaching…]\n\n");
|
||||
if ($modalStatus) $modalStatus.textContent = "Updating…";
|
||||
_setUpdateStatus("Updating…");
|
||||
startUpdatePoll();
|
||||
})
|
||||
.catch(function(err) {
|
||||
@@ -229,7 +316,7 @@ async function pollUpdateStatus() {
|
||||
return;
|
||||
}
|
||||
appendLog("[Update status reconnected]\n");
|
||||
if ($modalStatus) $modalStatus.textContent = "Updating…";
|
||||
_setUpdateStatus("Updating…");
|
||||
}
|
||||
if (data.log) appendLog(data.log);
|
||||
_updateLogOffset = data.offset;
|
||||
@@ -252,7 +339,7 @@ async function pollUpdateStatus() {
|
||||
if (!_serverWasDown) {
|
||||
_serverWasDown = true;
|
||||
appendLog("\n[Update status connection interrupted — retrying…]\n");
|
||||
if ($modalStatus) $modalStatus.textContent = "Reconnecting to update…";
|
||||
_setUpdateStatus("Reconnecting to update…");
|
||||
}
|
||||
} finally {
|
||||
_updatePollInFlight = false;
|
||||
@@ -264,7 +351,7 @@ function showUpdateStatusUnavailable() {
|
||||
_updateStatusUnavailable = true;
|
||||
stopUpdatePoll();
|
||||
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");
|
||||
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "inline-flex";
|
||||
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
||||
@@ -277,12 +364,22 @@ function retryUpdateStatus() {
|
||||
_updatePollFailures = 0;
|
||||
_serverWasDown = true;
|
||||
if ($modalSpinner) $modalSpinner.classList.add("spinning");
|
||||
if ($modalStatus) $modalStatus.textContent = "Reconnecting to update…";
|
||||
_setUpdateStatus("Reconnecting to update…");
|
||||
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
||||
if ($btnCloseModal) $btnCloseModal.disabled = true;
|
||||
startUpdatePoll();
|
||||
}
|
||||
|
||||
// Re-run a failed (or never-applied) update from scratch. The backend always
|
||||
// allows this after a FAILED attempt even though flake.lock may already be
|
||||
// advanced (the previous build never staged a bootable generation).
|
||||
function retryUpdateRun() {
|
||||
if ($btnRetryRun) $btnRetryRun.style.display = "none";
|
||||
if ($btnSave) $btnSave.style.display = "none";
|
||||
if ($btnReboot) $btnReboot.style.display = "none";
|
||||
_doOpenUpdateModal();
|
||||
}
|
||||
|
||||
function resumeUpdateStatusAfterInterruption() {
|
||||
if (!$modal || !$modal.classList.contains("open")) return;
|
||||
if (_updateStatusUnavailable) {
|
||||
@@ -298,15 +395,16 @@ function onUpdateDone(result) {
|
||||
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
||||
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
||||
if (result === true) {
|
||||
if ($modalStatus) $modalStatus.textContent = "✓ Update complete";
|
||||
_setUpdateStatus("✓ Update complete");
|
||||
if ($btnReboot) $btnReboot.style.display = "inline-flex";
|
||||
} 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";
|
||||
} 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 ($btnReboot) $btnReboot.style.display = "inline-flex";
|
||||
if ($btnReboot) $btnReboot.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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/buttons.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/header.css?v={{ asset_version }}" />
|
||||
@@ -18,65 +18,324 @@
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Header bar -->
|
||||
<header class="header-bar">
|
||||
<img src="/static/sovran-hub-icon.svg" alt="Sovran Hub" class="header-logo" />
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
|
||||
|
||||
<div class="title-group">
|
||||
<span class="title">Sovran_SystemsOS Hub</span>
|
||||
<!-- service glyphs -->
|
||||
<!-- ═══ 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 -->
|
||||
<span class="os-version-badge" id="os-version-badge" title="Sovran_SystemsOS Version">v{{ sovran_version }}</span>
|
||||
<!-- symbolic helpers (nav + modal internals) -->
|
||||
<!-- 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>
|
||||
|
||||
<nav id="sidebar-nav" aria-label="Service categories"></nav>
|
||||
|
||||
<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-logout" id="btn-logout" title="Sign out">Sign Out</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="content" id="content">
|
||||
<!-- ═══ WELCOME DASHBOARD (default view) ═══ -->
|
||||
<section class="welcome" id="welcome-view">
|
||||
<div class="welcome-bg" aria-hidden="true">
|
||||
<div class="orb orb-a"></div>
|
||||
<div class="orb orb-b"></div>
|
||||
<div class="orb orb-c"></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 & 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:8937</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>
|
||||
|
||||
<!-- ═══ SERVICES GRID ═══ -->
|
||||
<div id="tiles-area" style="display:none">
|
||||
<div class="dashboard-loading" role="status" aria-live="polite">
|
||||
<span class="dashboard-loading-spinner" aria-hidden="true"></span>
|
||||
<span>Loading service status…</span>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="header-buttons">
|
||||
<span class="role-badge" id="role-badge">Loading…</span>
|
||||
<button class="btn btn-header-reboot" id="btn-header-reboot" title="Restart the entire computer">Reboot</button>
|
||||
<button class="btn btn-logout" id="btn-logout" title="Sign out">Sign Out</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- IP bar -->
|
||||
<div class="ip-bar">
|
||||
<span>
|
||||
<span class="ip-label">Internal IP:</span>
|
||||
<span class="ip-value" id="ip-internal">…</span>
|
||||
</span>
|
||||
<span class="ip-separator">|</span>
|
||||
<span>
|
||||
<span class="ip-label">External IP:</span>
|
||||
<span class="ip-value" id="ip-external">…</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Service tiles -->
|
||||
<main class="main-content">
|
||||
<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">
|
||||
<span class="dashboard-loading-spinner" aria-hidden="true"></span>
|
||||
<span>Loading service status…</span>
|
||||
<!-- ═══ 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>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Update modal -->
|
||||
<div class="modal-overlay" id="update-modal" role="dialog" aria-modal="true" aria-labelledby="modal-title-text">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" id="modal-title-text">Sovran_SystemsOS Update</span>
|
||||
<div class="modal-spinner" id="modal-spinner"></div>
|
||||
<span class="modal-status" id="modal-status">Updating…</span>
|
||||
<div class="upd-header">
|
||||
<span class="widget-chip chip-sovran upd-chip"><svg class="upd-chip-svg"><use href="#g-update"/></svg></span>
|
||||
<span class="upd-title-wrap">
|
||||
<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 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>
|
||||
<div class="modal-log" id="modal-log" aria-live="polite"></div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-save" id="btn-save-report" style="display:none">Save Error Report</button>
|
||||
<button class="btn btn-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>
|
||||
<span class="modal-footer-spacer"></span>
|
||||
<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>
|
||||
@@ -98,7 +357,10 @@
|
||||
<div class="modal-overlay" id="support-modal" role="dialog" aria-modal="true" aria-labelledby="support-modal-title">
|
||||
<div class="creds-dialog">
|
||||
<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>
|
||||
</div>
|
||||
<div class="creds-body" id="support-body">
|
||||
@@ -170,15 +432,26 @@
|
||||
<!-- Rebuild Modal -->
|
||||
<div class="modal-overlay" id="rebuild-modal" role="dialog" aria-modal="true" aria-labelledby="rebuild-modal-title">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" id="rebuild-modal-title">Sovran_SystemsOS Rebuild</span>
|
||||
<div class="modal-spinner" id="rebuild-spinner"></div>
|
||||
<span class="modal-status" id="rebuild-status">Rebuilding…</span>
|
||||
<div class="upd-header">
|
||||
<span class="widget-chip chip-sovran upd-chip"><svg class="upd-chip-svg"><use href="#g-server"/></svg></span>
|
||||
<span class="upd-title-wrap">
|
||||
<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 class="modal-log" id="rebuild-log" aria-live="polite"></div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-save" id="rebuild-save-report" style="display:none">Save Error Report</button>
|
||||
<button class="btn btn-reboot" id="rebuild-reboot-btn" style="display:none">Restart Entire System</button>
|
||||
<span class="modal-footer-spacer"></span>
|
||||
<button class="btn btn-close-modal" id="rebuild-close-btn" disabled>Close</button>
|
||||
</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>
|
||||
</ul>
|
||||
</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">
|
||||
The Hub guides you through every step.
|
||||
</p>
|
||||
@@ -243,7 +520,7 @@
|
||||
<div class="security-reset-password-box" id="security-reset-new-password"> </div>
|
||||
<p class="security-reset-password-warning">
|
||||
✍️ <strong>Write this down now.</strong><br />
|
||||
You will need it to log in to your computer<br />and the Sovran Hub at <em>sovransystemsos.local</em>.
|
||||
You will need it to log in to your computer<br />and the Sovran Hub at <em>sovransystemsos.local:8937</em>.
|
||||
</p>
|
||||
<button class="security-reset-reboot-btn" id="security-reset-reboot-btn" disabled>
|
||||
I have written down my new password — Restart Entire System
|
||||
@@ -255,7 +532,7 @@
|
||||
<div class="reboot-overlay" id="reboot-overlay">
|
||||
<!-- Normal restarting card -->
|
||||
<div class="reboot-card" id="reboot-main-card">
|
||||
<div class="reboot-icon" aria-hidden="true">↻</div>
|
||||
<div class="reboot-icon" aria-hidden="true"></div>
|
||||
<h2 class="reboot-title">Restarting Entire System</h2>
|
||||
<p class="reboot-message">
|
||||
The entire computer is restarting, including the desktop and all hosted services.<br />
|
||||
@@ -270,7 +547,7 @@
|
||||
</div>
|
||||
<!-- Error card (shown if restart request fails definitively) -->
|
||||
<div class="reboot-card" id="reboot-error-card" style="display:none">
|
||||
<div class="reboot-icon" aria-hidden="true">⚠</div>
|
||||
<div class="reboot-icon" aria-hidden="true"></div>
|
||||
<h2 class="reboot-title">Restart could not be started</h2>
|
||||
<p class="reboot-message">
|
||||
The computer did not begin restarting. No services were intentionally stopped. Please try again.
|
||||
@@ -315,6 +592,7 @@
|
||||
<script src="/static/js/rebuild.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/features.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/security.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/dashboard.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/events.js?v={{ asset_version }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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/buttons.css?v={{ asset_version }}" />
|
||||
</head>
|
||||
@@ -11,8 +11,9 @@
|
||||
<div class="login-wrapper">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<img src="/static/sovran-hub-icon.svg" alt="Sovran Hub" class="login-logo" />
|
||||
<div class="login-title">Sovran Hub</div>
|
||||
<img src="/static/sovran-hub-icon.svg" alt="The Hub" class="login-logo" />
|
||||
<div class="login-title">The <em>Hub</em></div>
|
||||
<div class="login-sub">Sovran_SystemsOS</div>
|
||||
</div>
|
||||
|
||||
<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;">
|
||||
Your new login password is:
|
||||
</p>
|
||||
<div id="migration-password-value" style="font-family:monospace; font-size:1.35rem; font-weight:700; color:var(--text-primary); background:rgba(109, 191, 139, 0.10); border:1.5px solid rgba(109, 191, 139, 0.35); border-radius:8px; padding:14px 24px; letter-spacing:0.04em; text-align:center; word-break:break-all; margin-bottom:8px;">
|
||||
<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;">
|
||||
|
||||
</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.
|
||||
</div>
|
||||
<div id="migration-password-status" class="onboarding-save-status" style="margin-top:8px;"></div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"bitcoind.service": "27.1.0",
|
||||
"electrs.service": "0.10.6",
|
||||
"lnd.service": "0.18.0",
|
||||
"rtl.service": "0.15.10",
|
||||
"rtl.service": "0.15.12",
|
||||
"btcpayserver.service": "2.4.2",
|
||||
"albyhub.service": "1.24.0",
|
||||
"mempool.service": "3.2.1",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 392 KiB After Width: | Height: | Size: 59 KiB |
+37
-1
@@ -147,7 +147,7 @@
|
||||
hunspell hunspellDicts.en_US
|
||||
synadm brave-origin dua
|
||||
gparted pv unzip parted screen zenity
|
||||
libargon2 gnome-terminal libreoffice-fresh
|
||||
libargon2 gnome-terminal libreoffice-stable
|
||||
dig firefox wp-cli axel
|
||||
lk-jwt-service livekit-libwebrtc livekit
|
||||
matrix-synapse age onlyoffice-desktopeditors
|
||||
@@ -165,6 +165,14 @@
|
||||
programs.fish = { enable = true; promptInit = "fastfetch"; };
|
||||
|
||||
# ── 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 = {
|
||||
enable = true;
|
||||
authentication = lib.mkForce ''
|
||||
@@ -172,6 +180,34 @@
|
||||
host all all 127.0.0.1/32 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 ────────────────────────────────────────────────
|
||||
|
||||
Generated
+29
-46
@@ -5,11 +5,11 @@
|
||||
"nixpkgs": "nixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1787862680,
|
||||
"narHash": "sha256-mv+W62cI1Bjtkz9k8N8M7+7VSauv0/WmBrDYjcy5sE0=",
|
||||
"lastModified": 1790862821,
|
||||
"narHash": "sha256-IcLn2hPlxsbn2PKVlFD6gsDzkVurQ7b0KPtyORUGFcs=",
|
||||
"owner": "emmanuelrosa",
|
||||
"repo": "btc-clients-nix",
|
||||
"rev": "f00585b12e751ac738392e2cccfbe305d077e2d4",
|
||||
"rev": "99b0442dcc15198efcb62f3c1e7561a361749a6a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -26,11 +26,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1787559586,
|
||||
"narHash": "sha256-onL0VLf9vPllmT0H/OlURIU5r5t5WIEl7t4tVNKT0Nw=",
|
||||
"lastModified": 1788450739,
|
||||
"narHash": "sha256-glZLQlzIn1fXH6PazR2iUmTo7kzzyYSshrWhLS9TqCU=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "9d0d87172c374f89da73c1cfe6d81ae62feac1f1",
|
||||
"rev": "31729ca8cbdb4fa927b34e5f4353e6a83f39e993",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -41,11 +41,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1785590095,
|
||||
"narHash": "sha256-CNO2szJbdLjVN/Hi1BML9MSALz1GM2fIdwnzs404QO8=",
|
||||
"owner": "NixOS",
|
||||
"lastModified": 1790861130,
|
||||
"narHash": "sha256-cA8TrQntLbNO14wivJx7Gi2pZBhhBcMplgzIA3sk3TA=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e568f3b19d54b08f48bfae9b12b3e124d1a28002",
|
||||
"rev": "3d23ea05a8a3be3f078845c2753af10cef80e1a5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -56,27 +56,11 @@
|
||||
},
|
||||
"nixpkgs-stable": {
|
||||
"locked": {
|
||||
"lastModified": 1788297115,
|
||||
"narHash": "sha256-Z+vUNbfd2FIKkWOTkcT7RYlh3oFCnig/d2eXD1SWf2E=",
|
||||
"lastModified": 1790750587,
|
||||
"narHash": "sha256-VfjaoJ1Uyb7JZTrBgE5Jf2nhjtQPmD9KOd19HJwmZwM=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "a3116115851d68b8952a2a4221cc25a84e56b532",
|
||||
"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",
|
||||
"rev": "78e9c786dc08cd4f3420c2395cd977206a9b1da2",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -88,11 +72,11 @@
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1788179007,
|
||||
"narHash": "sha256-hn1oU2rue2SYK8dAr8+WNZWtbsz1S2W5mnHlSEuh3bo=",
|
||||
"lastModified": 1790822859,
|
||||
"narHash": "sha256-69xHQhAeMAD2wDXO7T2pcOZIF9Sga2W+JkmY2a11Ops=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "34ab99075ac4f7e40cf037eef32cb1c360bb85e9",
|
||||
"rev": "c59305bab2065cfecc4944690d9eedbb56f3a9fa",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -104,11 +88,11 @@
|
||||
},
|
||||
"nixpkgs_3": {
|
||||
"locked": {
|
||||
"lastModified": 1787631388,
|
||||
"narHash": "sha256-vMiXptXarfSdJb1Gkc+FYVOAibuBRj7qxGa8z68q1Uw=",
|
||||
"lastModified": 1789724158,
|
||||
"narHash": "sha256-nlKgrm0dsVhOSopKheVBCcOpiIticufPPVLdVOI0euA=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "ac6b2166e7a9375683b8e98f860f273222337b16",
|
||||
"rev": "0a3468a402c449992505b6a9fc5b06580141b750",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -120,11 +104,11 @@
|
||||
},
|
||||
"nixpkgs_4": {
|
||||
"locked": {
|
||||
"lastModified": 1788179007,
|
||||
"narHash": "sha256-hn1oU2rue2SYK8dAr8+WNZWtbsz1S2W5mnHlSEuh3bo=",
|
||||
"lastModified": 1790822859,
|
||||
"narHash": "sha256-69xHQhAeMAD2wDXO7T2pcOZIF9Sga2W+JkmY2a11Ops=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "34ab99075ac4f7e40cf037eef32cb1c360bb85e9",
|
||||
"rev": "c59305bab2065cfecc4944690d9eedbb56f3a9fa",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -141,11 +125,11 @@
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1788190018,
|
||||
"narHash": "sha256-59BAfH0txPAZrPBF4QJqwvUWppD+ICrcjA1LZAmPnrQ=",
|
||||
"lastModified": 1790541651,
|
||||
"narHash": "sha256-/496IQz8qNWHHLnc3Zvr0l4uxJ34igNhJhn7ZKAefFk=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixvim",
|
||||
"rev": "41844750e55f17b1385d5b09ca7ade5f11f49506",
|
||||
"rev": "5980a626794486abad69fca7667f9c11dfbc3bd7",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -165,15 +149,14 @@
|
||||
},
|
||||
"sovran-bitcoin": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs_4",
|
||||
"nixpkgs-stable": "nixpkgs-stable_2"
|
||||
"nixpkgs": "nixpkgs_4"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1788378694,
|
||||
"narHash": "sha256-s2yvGC1IHrQq2jP4KSbV2TA9Gh1T/YkcS+9neS/WcVI=",
|
||||
"lastModified": 1790877969,
|
||||
"narHash": "sha256-Ia88keP9t3B6ECVw+LP4xasPdvzw1TIWya1K+3dySu0=",
|
||||
"owner": "naturallaw777",
|
||||
"repo": "Sovran_Bitcoin",
|
||||
"rev": "b4678fcc712da9dd01c167f62275192183490085",
|
||||
"rev": "b0da63bd81f1a1b1a970068b3061c6c8389db9aa",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
nixvim.url = "github:nix-community/nixvim";
|
||||
btc-clients.url = "github:emmanuelrosa/btc-clients-nix";
|
||||
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";
|
||||
};
|
||||
|
||||
@@ -19,24 +17,6 @@
|
||||
system = prev.stdenv.hostPlatform.system;
|
||||
config.allowUnfree = true;
|
||||
};
|
||||
|
||||
# Pin LiveKit to 1.13.6: element-calling.nix sets
|
||||
# rtc.advertise_internal_ip, which gives LAN callers a host candidate
|
||||
# so calls work on Wi-Fi without the router needing NAT-hairpin. That
|
||||
# flag is only honoured when node_ip is set manually from LiveKit
|
||||
# v1.13.6 (mediatransportutil f234b53); nixpkgs-unstable currently
|
||||
# ships 1.13.5. Remove this override once nixpkgs-unstable reaches
|
||||
# >= 1.13.6.
|
||||
livekit = prev.livekit.overrideAttrs (old: {
|
||||
version = "1.13.6";
|
||||
src = prev.fetchFromGitHub {
|
||||
owner = "livekit";
|
||||
repo = "livekit";
|
||||
rev = "v1.13.6";
|
||||
hash = "sha256-sUAx6ooeEUUqot5xuZv7xiQa3DdRFVULteTwYgFUzCI=";
|
||||
};
|
||||
vendorHash = "sha256-nOGSmoNuQQm/sIVI1HojsiS4GkbhA68uYMQ6X7d4a5Q=";
|
||||
});
|
||||
};
|
||||
in
|
||||
{
|
||||
@@ -55,7 +35,6 @@
|
||||
{ nixpkgs.hostPlatform = "x86_64-linux"; nixpkgs.overlays = [ overlay-stable ]; }
|
||||
./iso/common.nix
|
||||
sovran-bitcoin.nixosModules.default
|
||||
./modules/sovran-bitcoin-integration.nix
|
||||
nixvim.nixosModules.nixvim
|
||||
];
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
+9
-4
@@ -54,9 +54,14 @@ DICEWARE_WORDS = [
|
||||
]
|
||||
|
||||
def generate_diceware_password():
|
||||
words = [secrets.choice(DICEWARE_WORDS) for _ in range(3)]
|
||||
digit = secrets.randbelow(10)
|
||||
return "-".join(words) + f"-{digit}"
|
||||
# 4 words from a 96 word list plus 2 digits: 96^4 x 100 = ~8.5e9, about
|
||||
# 33 bits. The old 3 words plus 1 digit was 96^3 x 10 = ~8.8e6, about 23
|
||||
# bits, for a password that is simultaneously the desktop login, the
|
||||
# 'free' account password and the only thing in front of a Hub that runs
|
||||
# as root and hands out every stored credential.
|
||||
words = [secrets.choice(DICEWARE_WORDS) for _ in range(4)]
|
||||
digits = f"{secrets.randbelow(100):02d}"
|
||||
return "-".join(words) + f"-{digits}"
|
||||
|
||||
try:
|
||||
logfile = open(LOG, "a")
|
||||
@@ -471,7 +476,7 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
# Role cards
|
||||
roles = [
|
||||
("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"),
|
||||
("Desktop Only",
|
||||
"A beautiful, easy-to-use desktop without the background server applications.",
|
||||
|
||||
+50
-35
@@ -14,13 +14,54 @@ let
|
||||
|| config.sovran_systemsOS.features.haven
|
||||
|| config.sovran_systemsOS.features."nwc-wallets"
|
||||
|| config.sovran_systemsOS.features.element-calling;
|
||||
|
||||
# RTL and Mempool listen on loopback only: Sovran_Bitcoin binds them to
|
||||
# 127.0.0.1, and RTL's unit is sandboxed to loopback besides. Caddy is how
|
||||
# the local network reaches them (:3051 and :60847), so it has to run
|
||||
# wherever they do. That includes Bitcoin Node Only, which has no
|
||||
# domain-based service and so no other reason to run Caddy.
|
||||
#
|
||||
# The Hub is not one of these. It listens on 0.0.0.0:8937 itself, so it is
|
||||
# served on its own port rather than through Caddy: the one service that
|
||||
# runs as root has nothing in front of it that it does not need, and the
|
||||
# public sites on ports 80/443 cannot be asked for it by Host header.
|
||||
servesRtl = config.sovran_systemsOS.services.bitcoin;
|
||||
servesMempool = servesRtl && config.sovran_systemsOS.features.mempool;
|
||||
|
||||
caddyEnabled = needsHttpsPorts || extraVhosts != "" || servesRtl;
|
||||
|
||||
# Sites for the local network, one per loopback-only service. Written after
|
||||
# the public domain sites; each exists only where its service does.
|
||||
#
|
||||
# They do not filter by client address. A request can only reach them on
|
||||
# their own ports, which no setup step asks you to forward, so forwarding
|
||||
# 80/443 for public services does not expose them (a Host header on those
|
||||
# ports cannot select a site that listens elsewhere). RTL has its own
|
||||
# password and lockout, and Mempool shows public chain data. An address
|
||||
# check here could not be made right for IPv6 anyway: a laptop's global
|
||||
# address on the LAN looks exactly like a stranger's.
|
||||
bitcoinUiSites =
|
||||
lib.optionalString servesRtl ''
|
||||
|
||||
:3051 {
|
||||
reverse_proxy :3050
|
||||
encode gzip zstd
|
||||
}
|
||||
''
|
||||
+ lib.optionalString servesMempool ''
|
||||
|
||||
:60847 {
|
||||
reverse_proxy :60845
|
||||
encode gzip zstd
|
||||
}
|
||||
'';
|
||||
in
|
||||
{
|
||||
services.caddy = {
|
||||
# Only enable Caddy when at least one domain-based service needs it or
|
||||
# the operator has defined custom vhosts. This prevents Caddy from
|
||||
# running on Desktop Only installs that have no web services configured.
|
||||
enable = needsHttpsPorts || extraVhosts != "";
|
||||
# Caddy runs when a domain-based service needs it, when the operator has
|
||||
# defined custom vhosts, or when it is the way to reach RTL and Mempool.
|
||||
# Desktop Only has none of those, so Caddy stays off there.
|
||||
enable = caddyEnabled;
|
||||
user = "caddy";
|
||||
group = "root";
|
||||
};
|
||||
@@ -202,37 +243,11 @@ $LIGHTNING {
|
||||
EOF
|
||||
fi
|
||||
|
||||
# ── Sovran Hub (LAN access via mDNS) ────────────
|
||||
cat >> /run/caddy/Caddyfile <<EOF
|
||||
|
||||
http://sovransystemsos.local {
|
||||
reverse_proxy localhost:8937
|
||||
header {
|
||||
Clear-Site-Data "\"cache\""
|
||||
Cache-Control "no-store, no-cache, must-revalidate, max-age=0"
|
||||
Pragma "no-cache"
|
||||
Expires "0"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# ── RTL (LAN access) ────────────────────────────
|
||||
cat >> /run/caddy/Caddyfile <<EOF
|
||||
|
||||
:3051 {
|
||||
reverse_proxy :3050
|
||||
encode gzip zstd
|
||||
}
|
||||
EOF
|
||||
|
||||
# ── Mempool (LAN access) ────────────────────────
|
||||
cat >> /run/caddy/Caddyfile <<EOF
|
||||
|
||||
:60847 {
|
||||
reverse_proxy :60845
|
||||
encode gzip zstd
|
||||
}
|
||||
EOF
|
||||
# ── RTL and Mempool (local network) ─────────────
|
||||
# Only where those services run; see bitcoinUiSites above.
|
||||
cat >> /run/caddy/Caddyfile <<'LAN_SITES_EOF'
|
||||
${bitcoinUiSites}
|
||||
LAN_SITES_EOF
|
||||
|
||||
# ── Custom vhosts from custom.nix ──────────────
|
||||
cat >> /run/caddy/Caddyfile <<'CUSTOM_VHOSTS_EOF'
|
||||
|
||||
+33
-89
@@ -1,43 +1,64 @@
|
||||
{ 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 ────────────────────────
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /var/lib/njalla 0750 root root -"
|
||||
];
|
||||
|
||||
# ── Install the shared validation helper so the DDNS runner can import it ─
|
||||
# The exact same _validate_ddns_url() function used by the Hub web application
|
||||
# is installed here as a read-only system file. The DDNS runner imports it
|
||||
# directly so the two code paths share one validator — no weaker inline copy.
|
||||
# ── Install the DDNS runner and the validator it shares with the Hub ─────
|
||||
# Both files come straight from the Hub's source tree and are installed side
|
||||
# by side as read-only system files. The runner imports the exact same
|
||||
# _validate_ddns_url() the Hub API uses — no weaker inline copy.
|
||||
environment.etc."sovran/security_helpers.py" = {
|
||||
source = ../../app/sovran_systemsos_web/security_helpers.py;
|
||||
mode = "0444";
|
||||
user = "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 ─────────────────────────────────────────────
|
||||
# Reads DDNS update URLs from the JSON store written by the Hub API and
|
||||
# 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 = {
|
||||
description = "Sovran Njal.la DDNS update (safe JSON-based runner)";
|
||||
wants = [ "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 = {
|
||||
Type = "oneshot";
|
||||
User = "root";
|
||||
ExecStart = "${pkgs.python3}/bin/python3 /var/lib/sovran/ddns-update.py";
|
||||
# Harden the service — it only needs network access and read access to
|
||||
# /var/lib/njalla/ddns_urls.json.
|
||||
ExecStart = "${pkgs.python3}/bin/python3 /etc/sovran/ddns-update.py";
|
||||
# Harden the service — it needs network access, the URL store, and the
|
||||
# file that receives the reported address.
|
||||
NoNewPrivileges = true;
|
||||
ProtectSystem = "strict";
|
||||
ReadWritePaths = [ "/var/lib/njalla" "/var/lib/secrets" ];
|
||||
ReadOnlyPaths = [ "/etc/sovran" ];
|
||||
ProtectHome = 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 script is owned by root and not world-writable.
|
||||
# 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.
|
||||
# The runner used to be written to /var/lib/sovran by this activation script,
|
||||
# next to the old public-ip.py detector. Remove those stale copies.
|
||||
system.activationScripts.sovran-ddns-update-script = ''
|
||||
install -d -m 0755 /var/lib/sovran
|
||||
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
|
||||
rm -f /var/lib/sovran/ddns-update.py /var/lib/sovran/public-ip.py
|
||||
'';
|
||||
}
|
||||
|
||||
@@ -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
|
||||
'';
|
||||
}
|
||||
+66
-3
@@ -61,6 +61,67 @@
|
||||
sshd = lib.mkEnableOption "SSH remote access";
|
||||
};
|
||||
|
||||
# ── Hub ───────────────────────────────────────────────────
|
||||
hub = {
|
||||
lanOnly = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = ''
|
||||
Refuse Hub requests from clients that are not on this computer or on
|
||||
the local network: loopback, private (10.0.0.0/8, 172.16.0.0/12,
|
||||
192.168.0.0/16), VPN/CGNAT (100.64.0.0/10) and link-local addresses,
|
||||
plus anything listed in sovran_systemsOS.hub.extraLanNetworks.
|
||||
|
||||
The Hub runs as root and can display stored credentials and reboot
|
||||
the machine. Whether a packet may reach its port is up to the
|
||||
firewall and your router; this check is the second lock, so that a
|
||||
port forward or a firewall mistake does not put the Hub's login page
|
||||
in front of the internet.
|
||||
|
||||
Set it to false only if this computer sits on a network that hands
|
||||
out public addresses to your own devices and you would rather not
|
||||
list them.
|
||||
'';
|
||||
};
|
||||
|
||||
directPort = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = !config.sovran_systemsOS.roles.desktop;
|
||||
defaultText = lib.literalExpression "!config.sovran_systemsOS.roles.desktop";
|
||||
description = ''
|
||||
Open port 8937 on the firewall, so that other devices on the local
|
||||
network can reach the Hub at http://sovransystemsos.local:8937.
|
||||
|
||||
On by default for Server + Desktop and Bitcoin Node Only. Off on
|
||||
Desktop Only, the role most likely to be used away from home: there
|
||||
nothing is published, and the Hub is reachable only from this
|
||||
computer, through the desktop application window on localhost. Set it
|
||||
to true in custom.nix if you do want to reach a Desktop Only Hub from
|
||||
another device.
|
||||
|
||||
The Hub runs as root, so it checks every client itself (see
|
||||
sovran_systemsOS.hub.lanOnly); the firewall opening only decides
|
||||
whether a packet may reach it at all.
|
||||
'';
|
||||
};
|
||||
|
||||
extraLanNetworks = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
example = [ "203.0.113.0/28" ];
|
||||
description = ''
|
||||
Extra networks, in CIDR notation, that the Hub should treat as local
|
||||
in addition to the built-in ranges. Needed only if devices on your
|
||||
local network use addresses outside the private ranges, for example a
|
||||
public IPv4 block your provider routes onto your LAN.
|
||||
|
||||
Keep each entry as narrow as you can: every address inside it is let
|
||||
through. To let everything through, set sovran_systemsOS.hub.lanOnly
|
||||
to false instead; 0.0.0.0/0 and ::/0 are not accepted here.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
# ── Web exposure (controls Caddy vhosts) ──────────────────
|
||||
web = {
|
||||
btcpayserver = lib.mkOption {
|
||||
@@ -107,9 +168,11 @@
|
||||
description = ''
|
||||
Optional pin: force LiveKit to advertise this public IPv4 in its
|
||||
host/TURN ICE candidates. Not required in normal operation — the
|
||||
module auto-detects the public IP at runtime (HTTPS egress
|
||||
detection, falling back to STUN). Set it only to override a
|
||||
mis-detected address (e.g. multi-WAN/VPN setups).
|
||||
address is the one Njal.la reports for the DDNS update (set up in
|
||||
the Hub's Domains page), and nothing on this system looks it up
|
||||
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).
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
+123
-21
@@ -51,8 +51,8 @@ let
|
||||
]; }
|
||||
{ name = "LND"; unit = "lnd.service"; type = "system"; icon = "lnd"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = []; }
|
||||
{ name = "Ride The Lightning"; unit = "rtl.service"; type = "system"; icon = "rtl"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
|
||||
{ label = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/rtl/hostname"; prefix = "http://"; }
|
||||
{ label = "Local Network — Access on your home network only"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":3051"; }
|
||||
{ label = "Tor 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/rtl/"; }
|
||||
{ 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"; }
|
||||
]; }
|
||||
@@ -115,6 +115,15 @@ let
|
||||
else if cfg.roles.node then "node"
|
||||
else "server_plus_desktop";
|
||||
|
||||
# IPv4 a.b.c.d[/0-32] or IPv6 [/0-128], and never a /0 (that is "everyone").
|
||||
octet = "(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])";
|
||||
lanNetworkOk = p:
|
||||
(
|
||||
builtins.match "${octet}(\\.${octet}){3}(/(3[0-2]|[12]?[0-9]))?" p != null
|
||||
|| builtins.match "[0-9a-fA-F:]*:[0-9a-fA-F:]*(/(12[0-8]|1[01][0-9]|[1-9]?[0-9]))?" p != null
|
||||
)
|
||||
&& builtins.match ".*/0" p == null;
|
||||
|
||||
generatedConfig = pkgs.writeText "sovran-hub-config.json"
|
||||
(builtins.toJSON {
|
||||
refresh_interval = 5;
|
||||
@@ -122,6 +131,9 @@ let
|
||||
role = activeRole;
|
||||
services = monitoredServices;
|
||||
feature_manager = true;
|
||||
# Read by LanOnlyMiddleware in server.py.
|
||||
lan_only = cfg.hub.lanOnly;
|
||||
lan_extra_networks = cfg.hub.extraLanNetworks;
|
||||
feature_states = {
|
||||
bitcoin-tor-gossip = cfg.features.bitcoin-tor-gossip;
|
||||
};
|
||||
@@ -150,6 +162,61 @@ let
|
||||
"haven-relay.service" = if pkgs ? haven-relay then pkgs.haven-relay.version else (if pkgs ? haven then pkgs.haven.version else "0.1.0");
|
||||
});
|
||||
|
||||
# Shared shell prelude used by both the update and rebuild wrapper scripts.
|
||||
# A flake/package fetch that is interrupted (network blip, reboot
|
||||
# mid-download, disk filled, hiccup on the remote) can leave a truncated
|
||||
# tarball or partial git clone in Nix's download caches. Nix then reuses the
|
||||
# corrupt archive on every retry and dies with "cannot read file from
|
||||
# tarball: Truncated tar archive detected" — a failure that is NOT fixed by
|
||||
# simply re-running, but IS fixed by clearing the fetch caches. run_step runs
|
||||
# a command and, on the first failure that matches a download/cache
|
||||
# signature, clears the caches and retries once. Real config errors never
|
||||
# match, so they still fail loudly. Each sourcing script must define $LOG.
|
||||
nix-self-heal-prelude = ''
|
||||
transient_failure() {
|
||||
grep -Eqi 'truncated tar|unexpected end of (file|archive)|unexpected eof|corrupt(ed)? (archive|nar|download|file)|could not (fetch|download)|download.*(failed|interrupted)|timed out|timeout|connection (reset|refused|timed out)|network is unreachable|temporary failure in name resolution|checksum mismatch|hash mismatch|nar hash|unable to download|store path.*is not valid|cannot read file from tarball|into the git cache' "$LOG"
|
||||
}
|
||||
|
||||
clear_fetch_caches() {
|
||||
echo "[SELF-HEAL] Clearing stale Nix download caches and verifying the Nix store…"
|
||||
# Re-fetchable caches only; /nix/store generations and the running system
|
||||
# are never touched here.
|
||||
rm -rf /root/.cache/nix/tarballs /root/.cache/nix/vcs-cache /root/.cache/nix/git* /root/.cache/nix/flakes 2>/dev/null || true
|
||||
# Fast closure-level repair only. A full --check-contents scan hashes
|
||||
# every store path and can take tens of minutes on a big node; the cache
|
||||
# clear above is the actual fix for truncated/corrupt downloads.
|
||||
nix-store --verify --repair >/dev/null 2>&1 || true
|
||||
echo "[SELF-HEAL] Caches cleared; retrying…"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# run_step LABEL CMD [ARGS...] — run a build step; on a transient
|
||||
# fetch/cache failure, heal once and retry. Returns the command exit code
|
||||
# but leaves error messaging to the caller.
|
||||
run_step() {
|
||||
label="$1"; shift
|
||||
rc=1
|
||||
for try in 1 2; do
|
||||
if [ "$try" -eq 2 ]; then
|
||||
echo "── $label — retry after cache repair ──"
|
||||
fi
|
||||
"$@"
|
||||
rc=$?
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$try" -eq 1 ] && transient_failure; then
|
||||
echo ""
|
||||
echo "[SELF-HEAL] $label failed on a download/cache error (see above)."
|
||||
clear_fetch_caches
|
||||
continue
|
||||
fi
|
||||
return "$rc"
|
||||
done
|
||||
return "$rc"
|
||||
}
|
||||
'';
|
||||
|
||||
# ── Update wrapper script ──────────────────────────────────────
|
||||
update-script = pkgs.writeShellScript "sovran-hub-update.sh" ''
|
||||
set -uo pipefail
|
||||
@@ -171,12 +238,14 @@ let
|
||||
|
||||
RC=0
|
||||
|
||||
${nix-self-heal-prelude}
|
||||
|
||||
echo "── Step 1/3: nix flake update ────────────────────"
|
||||
if ! nix flake update --flake /etc/nixos --print-build-logs \
|
||||
if ! run_step "nix flake update" nix flake update --flake /etc/nixos --print-build-logs \
|
||||
--option connect-timeout 10 \
|
||||
--option stalled-download-timeout 90 \
|
||||
--option download-attempts 7 \
|
||||
--option fallback true 2>&1; then
|
||||
--option fallback true; then
|
||||
echo "[ERROR] nix flake update failed"
|
||||
RC=1
|
||||
fi
|
||||
@@ -186,21 +255,21 @@ let
|
||||
echo "── Step 2/3: nixos-rebuild boot (stage next reboot) ──"
|
||||
# Stream output straight into $LOG (see rebuild-script) so the Hub UI
|
||||
# shows live progress instead of an empty log during long builds.
|
||||
nixos-rebuild boot --flake /etc/nixos --print-build-logs \
|
||||
--option connect-timeout 10 \
|
||||
--option stalled-download-timeout 90 \
|
||||
--option download-attempts 7 \
|
||||
--option fallback true
|
||||
BOOT_RC=$?
|
||||
if [ "$BOOT_RC" -ne 0 ]; then
|
||||
if run_step "nixos-rebuild boot" nixos-rebuild boot --flake /etc/nixos --print-build-logs \
|
||||
--option connect-timeout 10 \
|
||||
--option stalled-download-timeout 90 \
|
||||
--option download-attempts 7 \
|
||||
--option fallback true; then
|
||||
if ! readlink -f /nix/var/nix/profiles/system > "$GENERATION"; then
|
||||
# The marker is informational only. The Hub derives pending-reboot
|
||||
# state from the NixOS system profile itself, so failing to record
|
||||
# the marker must not fail an otherwise successful update.
|
||||
echo "[WARNING] update succeeded but its staged generation could not be recorded"
|
||||
rm -f "$GENERATION"
|
||||
fi
|
||||
else
|
||||
echo "[ERROR] nixos-rebuild boot failed"
|
||||
RC=1
|
||||
elif ! readlink -f /nix/var/nix/profiles/system > "$GENERATION"; then
|
||||
# The marker is informational only. The Hub derives pending-reboot
|
||||
# state from the NixOS system profile itself, so failing to record
|
||||
# the marker must not fail an otherwise successful update.
|
||||
echo "[WARNING] update succeeded but its staged generation could not be recorded"
|
||||
rm -f "$GENERATION"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
@@ -245,12 +314,15 @@ let
|
||||
echo " Sovran_SystemsOS Rebuild — $(date)"
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
${nix-self-heal-prelude}
|
||||
|
||||
echo "── Rebuilding system configuration ──────────────"
|
||||
# Stream output straight into $LOG (tee'd by the exec redirect above) so
|
||||
# the Hub UI shows live progress. Capturing the output in a variable
|
||||
# kept the log empty for the entire build+activation, which made long
|
||||
# rebuilds can otherwise look like a hang.
|
||||
nixos-rebuild switch --flake /etc/nixos --print-build-logs \
|
||||
run_step "nixos-rebuild switch" nixos-rebuild switch --flake /etc/nixos --print-build-logs \
|
||||
--option connect-timeout 10 \
|
||||
--option stalled-download-timeout 90 \
|
||||
--option download-attempts 7 \
|
||||
@@ -266,11 +338,11 @@ let
|
||||
echo ""
|
||||
echo " ✓ Build succeeded — a reboot is required to apply this rebuild"
|
||||
echo " (Critical system components changed; running nixos-rebuild boot instead)"
|
||||
if nixos-rebuild boot --flake /etc/nixos --print-build-logs \
|
||||
if run_step "nixos-rebuild boot" nixos-rebuild boot --flake /etc/nixos --print-build-logs \
|
||||
--option connect-timeout 10 \
|
||||
--option stalled-download-timeout 90 \
|
||||
--option download-attempts 7 \
|
||||
--option fallback true 2>&1; then
|
||||
--option fallback true; then
|
||||
echo "REBOOT_REQUIRED" > "$STATUS"
|
||||
else
|
||||
echo "[ERROR] nixos-rebuild boot also failed"
|
||||
@@ -278,6 +350,7 @@ let
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "[ERROR] nixos-rebuild switch failed"
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo " ✗ Rebuild failed — see errors above"
|
||||
@@ -414,6 +487,12 @@ os.environ["SOVRAN_HUB_ICONS"] = os.path.join("$out", "share", "sovran-hub", "i
|
||||
import uvicorn
|
||||
uvicorn.run(
|
||||
"sovran_systemsos_web.server:app",
|
||||
# IPv4 only, on purpose. The desktop launcher uses "localhost", which
|
||||
# falls back to 127.0.0.1, and other devices reach the Hub over IPv4 too.
|
||||
# An IPv6 listener would admit clients whose global addresses the Hub's own
|
||||
# check cannot tell from a stranger's (see LanPolicy). Which devices may
|
||||
# connect is up to the firewall (hub.directPort) and that check
|
||||
# (hub.lanOnly), not this bind.
|
||||
host="0.0.0.0",
|
||||
port=8937,
|
||||
log_level="info",
|
||||
@@ -442,6 +521,22 @@ in
|
||||
};
|
||||
|
||||
config = {
|
||||
# Catch a typo'd network at build time. The Hub ignores an entry it cannot
|
||||
# parse (it must never widen its policy by guessing), so without this the
|
||||
# only symptom would be a client that is refused for no visible reason.
|
||||
assertions = [
|
||||
{
|
||||
assertion = builtins.all lanNetworkOk cfg.hub.extraLanNetworks;
|
||||
message = ''
|
||||
sovran_systemsOS.hub.extraLanNetworks must be a list of IPv4 or IPv6
|
||||
networks in CIDR notation, for example [ "203.0.113.0/28" ]. A /0
|
||||
prefix is not accepted; set sovran_systemsOS.hub.lanOnly = false to
|
||||
let every client through. Got:
|
||||
${builtins.toJSON cfg.hub.extraLanNetworks}
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
systemd.services.sovran-hub-web = {
|
||||
description = "Sovran_SystemsOS Hub Web Interface";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
@@ -510,7 +605,14 @@ in
|
||||
|
||||
environment.systemPackages = [ sovran-hub-web ];
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 8937 60847 ];
|
||||
# The Hub is served on its own port, not through Caddy (see caddy.nix).
|
||||
# Nothing here filters by client address: that is the Hub's own check
|
||||
# (sovran_systemsOS.hub.lanOnly), and which networks can route to this
|
||||
# computer at all is the router's call.
|
||||
# 60847 is where Caddy serves Mempool, so it is open only when Mempool is.
|
||||
networking.firewall.allowedTCPPorts =
|
||||
lib.optionals cfg.hub.directPort [ 8937 ]
|
||||
++ lib.optionals (cfg.services.bitcoin && cfg.features.mempool) [ 60847 ];
|
||||
|
||||
# ── Auto-launch Hub in browser on login ───────────────────────
|
||||
environment.etc."xdg/autostart/sovran-hub-autolaunch.desktop".text = ''
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
|
||||
services.openssh = {
|
||||
enable = true;
|
||||
# sshd listens on 127.0.0.1 only here, so there is nothing for the firewall
|
||||
# to let in. NixOS opens sshd's ports by default (openFirewall = true)
|
||||
# whether or not sshd listens on them, which left port 22 open on every
|
||||
# role, Desktop Only included. The roles that do publish SSH open it
|
||||
# themselves: the sshd feature (sshd.nix) and remote deploy
|
||||
# (remote-deploy.nix) both add 22 explicitly.
|
||||
openFirewall = lib.mkDefault false;
|
||||
listenAddresses = lib.mkDefault [
|
||||
{ addr = "127.0.0.1"; port = 22; }
|
||||
];
|
||||
|
||||
+11
-8
@@ -91,7 +91,7 @@ in
|
||||
SECRET_FILE="/var/lib/secrets/root-password"
|
||||
if [ ! -f "$SECRET_FILE" ]; then
|
||||
mkdir -p /var/lib/secrets
|
||||
# Generate a diceware-style passphrase: word-word-word-N
|
||||
# Generate a diceware-style passphrase: word-word-word-word-NN
|
||||
WORDS="apple barn brook cabin cedar cloud coral crane delta eagle ember \
|
||||
fern field flame flora flint frost grove haven hedge holly heron \
|
||||
jade juniper kelp larch lemon lilac linden loch lotus maple marsh \
|
||||
@@ -106,8 +106,9 @@ in
|
||||
W1=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
W2=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
W3=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
DIGIT=$((RANDOM % 10))
|
||||
ROOT_PASS="$W1-$W2-$W3-$DIGIT"
|
||||
W4=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
DIGIT=$(printf '%02d' $((RANDOM % 100)))
|
||||
ROOT_PASS="$W1-$W2-$W3-$W4-$DIGIT"
|
||||
echo "$ROOT_PASS" > "$SECRET_FILE"
|
||||
chmod 600 "$SECRET_FILE"
|
||||
fi
|
||||
@@ -170,7 +171,7 @@ in
|
||||
fi
|
||||
|
||||
mkdir -p /var/lib/secrets
|
||||
# Generate a diceware-style passphrase: word-word-word-N
|
||||
# Generate a diceware-style passphrase: word-word-word-word-NN
|
||||
WORDS="apple barn brook cabin cedar cloud coral crane delta eagle ember \
|
||||
fern field flame flora flint frost grove haven hedge holly heron \
|
||||
jade juniper kelp larch lemon lilac linden loch lotus maple marsh \
|
||||
@@ -185,8 +186,9 @@ in
|
||||
W1=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
W2=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
W3=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
DIGIT=$((RANDOM % 10))
|
||||
FREE_PASS="$W1-$W2-$W3-$DIGIT"
|
||||
W4=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
DIGIT=$(printf '%02d' $((RANDOM % 100)))
|
||||
FREE_PASS="$W1-$W2-$W3-$W4-$DIGIT"
|
||||
echo "$FREE_PASS" > "$SECRET_FILE"
|
||||
chmod 600 "$SECRET_FILE"
|
||||
echo "free:$FREE_PASS" | chpasswd
|
||||
@@ -229,8 +231,9 @@ in
|
||||
W1=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
W2=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
W3=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
DIGIT=$((RANDOM % 10))
|
||||
FREE_PASS="$W1-$W2-$W3-$DIGIT"
|
||||
W4=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
DIGIT=$(printf '%02d' $((RANDOM % 100)))
|
||||
FREE_PASS="$W1-$W2-$W3-$W4-$DIGIT"
|
||||
|
||||
printf '%s\n' "$FREE_PASS" > "$SECRET_FILE"
|
||||
chmod 600 "$SECRET_FILE"
|
||||
|
||||
+43
-39
@@ -204,35 +204,37 @@ EOF
|
||||
# NAT with port-forwarding. It does not need to be assigned to this box,
|
||||
# and it may be dynamic.
|
||||
#
|
||||
# Reuse the shared detector (/var/lib/sovran/public-ip.py — see
|
||||
# modules/core/public-ip.nix) instead of running our own: one script,
|
||||
# one cache, privacy-first (STUN -> DNS -> opt-in HTTPS echo). Priority:
|
||||
# Nothing here looks the address up. Priority:
|
||||
# 1. sovran_systemsOS.elementCalling.externalIP (explicit pin, if set)
|
||||
# 2. /var/lib/secrets/external-ip (the shared cache)
|
||||
# 3. run the detector now (it refreshes the cache)
|
||||
# 4. STUN auto-detection (use_external_ip) as the fallback, with a
|
||||
# warning — this is where broken installs used to silently end up
|
||||
# advertising a private IP, causing "call connects but no video".
|
||||
# 2. /var/lib/secrets/external-ip — the address Njal.la reported for the
|
||||
# last DDNS update (modules/core/njalla.nix). The runner rewrites that
|
||||
# file only when the address changes, and livekit-external-ip.path
|
||||
# then re-runs this script.
|
||||
# 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 ""}'
|
||||
|
||||
PUBLIC_IP="$EXTERNAL_IP"
|
||||
if [ -z "$PUBLIC_IP" ] && [ -f /var/lib/secrets/external-ip ]; then
|
||||
PUBLIC_IP=$(tr -d '[:space:]' < /var/lib/secrets/external-ip 2>/dev/null)
|
||||
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
|
||||
|
||||
# Reject non-routable addresses (loopback, private, link-local, CGNAT).
|
||||
# A detected/pinned address like this must never be advertised.
|
||||
if [ -n "$PUBLIC_IP" ] && printf '%s' "$PUBLIC_IP" | grep -qE \
|
||||
'^(0\.|127\.|10\.|100\.64\.|169\.254\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)'; then
|
||||
echo "WARNING: external IP '$PUBLIC_IP' is not routable; falling back to STUN auto-detection." >&2
|
||||
PUBLIC_IP=""
|
||||
if 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
|
||||
echo "ERROR: $PUBLIC_IP is not a public address, so remote peers cannot reach LiveKit there." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$PUBLIC_IP" ]; then
|
||||
cat > /run/livekit/livekit.yaml <<EOF
|
||||
cat > /run/livekit/livekit.yaml <<EOF
|
||||
port: 7880
|
||||
rtc:
|
||||
use_external_ip: false
|
||||
@@ -244,22 +246,7 @@ rtc:
|
||||
includes:
|
||||
- $IFACE
|
||||
EOF
|
||||
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
|
||||
echo "LiveKit will advertise public IP: $PUBLIC_IP"
|
||||
|
||||
# Webhooks → lk-jwt-service. The JWT service validates the HMAC
|
||||
# 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
|
||||
# configs (new domains, externalIP, full-access list), mirroring the domain
|
||||
# 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.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 #######
|
||||
# Diagnostic only — never a hard dependency of livekit/caddy. Catches the
|
||||
# classic "call connects but no media" setup errors at boot instead of at
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
./core/no-sleep.nix
|
||||
./core/cpu-performance.nix
|
||||
./core/local-domain-loopback.nix
|
||||
./core/public-ip.nix
|
||||
|
||||
# ── Always on (no flag) ───────────────────────────────────
|
||||
./php.nix
|
||||
|
||||
+112
-3
@@ -3,10 +3,22 @@
|
||||
lib.mkIf config.sovran_systemsOS.services.nextcloud {
|
||||
|
||||
# ── PostgreSQL database ───────────────────────────────────
|
||||
# Cluster-wide tuning (shared_buffers, autovacuum) lives in
|
||||
# configuration.nix so it is shared with Matrix Synapse.
|
||||
services.postgresql = {
|
||||
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 ──────────────
|
||||
systemd.services.nextcloud-db-init = {
|
||||
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
|
||||
psql -U postgres -c "CREATE DATABASE nextclouddb WITH OWNER ncusr TEMPLATE template0 LC_COLLATE = 'C' LC_CTYPE = 'C';"
|
||||
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 ───────────────────────
|
||||
systemd.services.nextcloud-init = {
|
||||
description = "Download, extract, and fully configure Nextcloud";
|
||||
after = [ "network-online.target" "postgresql.service" "phpfpm-nextcloud.service" "nextcloud-db-init.service" ];
|
||||
wants = [ "network-online.target" ];
|
||||
after = [ "network-online.target" "postgresql.service" "phpfpm-nextcloud.service" "nextcloud-db-init.service" "redis-nextcloud.service" ];
|
||||
wants = [ "network-online.target" "redis-nextcloud.service" ];
|
||||
requires = [ "postgresql.service" "nextcloud-db-init.service" ];
|
||||
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 maintenance_window_start --type=integer --value=1
|
||||
php $INSTALL_DIR/occ config:system:set memcache.local --value='\OC\Memcache\APCu'
|
||||
php $INSTALL_DIR/occ config:system:set memcache.locking --value='\OC\Memcache\APCu'
|
||||
php $INSTALL_DIR/occ config:system:set 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 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 = [
|
||||
"*/5 * * * * caddy /run/current-system/sw/bin/php -f /var/lib/www/nextcloud/cron.php"
|
||||
];
|
||||
|
||||
+59
-17
@@ -1,29 +1,70 @@
|
||||
{ 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
|
||||
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 = ''
|
||||
|
||||
display_errors = On
|
||||
display_startup_errors = On
|
||||
; ── Error handling (production) ─────────────────────────────────
|
||||
display_errors = Off
|
||||
display_startup_errors = Off
|
||||
log_errors = On
|
||||
|
||||
; ── Limits ──────────────────────────────────────────────────────
|
||||
max_execution_time = 10000
|
||||
max_input_time = 3000
|
||||
memory_limit = 1G;
|
||||
opcache.enable=1;
|
||||
opcache.memory_consumption=512;
|
||||
opcache_revalidate_freq = 240;
|
||||
opcache.max_accelerated_files=20000;
|
||||
memory_limit = 1G
|
||||
post_max_size = 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
|
||||
redis.session.locking_enabled=1
|
||||
redis.session.lock_retries=-1
|
||||
redis.session.lock_wait_time=10000
|
||||
|
||||
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.lock_retries = -1
|
||||
redis.session.lock_wait_time = 10000
|
||||
'';
|
||||
};
|
||||
in
|
||||
@@ -46,12 +87,13 @@ in
|
||||
};
|
||||
|
||||
users.users.php.group = "php";
|
||||
|
||||
|
||||
users.groups.php = {};
|
||||
|
||||
environment.systemPackages = with pkgs; [
|
||||
|
||||
|
||||
custom-php
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -105,9 +105,11 @@ in {
|
||||
'';
|
||||
};
|
||||
|
||||
# ── 5. Firewall — Hub management port ──────────────────────────
|
||||
# ── 5. Firewall — RTL ──────────────────────────────────────────
|
||||
# RTL is a web app served by Caddy over TCP on 3051. The matching UDP rule
|
||||
# that used to sit here was carried over from the TCP line and opened a port
|
||||
# nothing listens on.
|
||||
networking.firewall.allowedTCPPorts = lib.mkIf cfg.services.bitcoin [ 3051 ];
|
||||
networking.firewall.allowedUDPPorts = lib.mkIf cfg.services.bitcoin [ 3051 ];
|
||||
|
||||
# ── 6. NWC / LNURL — Sovran Hub integration ───────────────────
|
||||
# Sovran_Bitcoin's albyhub.nix and lnurl.nix handle the base services.
|
||||
|
||||
@@ -94,16 +94,23 @@ EOF
|
||||
# ── Synapse service ─────────────────────────────────────────
|
||||
services.matrix-synapse = {
|
||||
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 = [
|
||||
"/run/matrix-synapse/runtime-config.yaml"
|
||||
];
|
||||
settings = {
|
||||
database = {
|
||||
name = "psycopg2";
|
||||
# Recycle pooled connections less often (fewer reconnects).
|
||||
txn_limit = 10000;
|
||||
args = {
|
||||
host = "localhost";
|
||||
database = "matrix-synapse";
|
||||
user = "matrix-synapse";
|
||||
cp_min = 5;
|
||||
cp_max = 15;
|
||||
};
|
||||
};
|
||||
push.include_content = false;
|
||||
@@ -120,6 +127,32 @@ EOF
|
||||
];
|
||||
presence.enabled = true;
|
||||
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 = [
|
||||
{
|
||||
port = 8008;
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Guards for serving the Hub on its own port instead of through Caddy.
|
||||
|
||||
The Hub is the one service that runs as root. It listens on 0.0.0.0:8937
|
||||
itself, so Caddy adds nothing it needs: not TLS (the site was plain http), not
|
||||
authentication, not cache headers (the app sets its own). What it did add was a
|
||||
second door: with ports 80/443 forwarded for public services, a Host header on
|
||||
those ports reached the Hub. The Hub is therefore served on port 8937 only, and
|
||||
Caddy keeps the two services it is actually needed for, because they listen on
|
||||
loopback only: Ride The Lightning (:3051) and Mempool (:60847).
|
||||
|
||||
Like the other nix-file checks these read the modules as text: 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__), ".."))
|
||||
|
||||
|
||||
def _read(*parts):
|
||||
with open(os.path.join(_ROOT, *parts), encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def _without_comments(src):
|
||||
"""The Nix source with `#` comment lines removed."""
|
||||
return "\n".join(l for l in src.splitlines() if not l.lstrip().startswith("#"))
|
||||
|
||||
|
||||
def _binding(src, name):
|
||||
"""The right-hand side of a top-level `name = ...;` binding in a let/attrset."""
|
||||
m = re.search(r"^\s*" + re.escape(name) + r"\s*=\s*(?P<v>.*?);\s*$", src, re.M | re.S)
|
||||
assert m, f"{name} not found"
|
||||
return m.group("v")
|
||||
|
||||
|
||||
class HubIsNotACaddySite(unittest.TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.caddy = _read("modules", "core", "caddy.nix")
|
||||
cls.code = _without_comments(cls.caddy)
|
||||
|
||||
def test_there_is_no_site_for_the_hub(self):
|
||||
self.assertNotRegex(self.code, r"sovransystemsos\.local")
|
||||
self.assertNotIn("8937", self.code)
|
||||
|
||||
def test_the_public_ports_still_belong_to_the_public_sites(self):
|
||||
# Caddy now also runs to bridge RTL and Mempool (even on Node Only),
|
||||
# which must not open 80/443 by itself: those follow the domain-based
|
||||
# services and nothing else.
|
||||
self.assertRegex(
|
||||
self.code,
|
||||
r"networking\.firewall\.allowedTCPPorts\s*=\s*lib\.mkIf\s+needsHttpsPorts\s*\[\s*80\s+443\s*\]",
|
||||
)
|
||||
|
||||
|
||||
class CaddyDoesNoAddressFiltering(unittest.TestCase):
|
||||
"""Caddy is a bridge for RTL and Mempool and a TLS front for public sites.
|
||||
|
||||
It used to carry a client-address guard (sovran_lan_only). That guard was
|
||||
never aimed at these two sites: the bug was a Host header on ports 80/443
|
||||
reaching the Hub, and RTL and Mempool sit on ports of their own. It also
|
||||
could not be made right for IPv6, where a laptop's global address on the
|
||||
LAN is indistinguishable from a stranger's.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.caddy = _read("modules", "core", "caddy.nix")
|
||||
cls.code = _without_comments(cls.caddy)
|
||||
|
||||
def test_there_is_no_address_filter(self):
|
||||
# (private_ranges is deliberately not on this list: the Nextcloud site
|
||||
# uses it for trusted_proxies, which is not a filter on who may connect.)
|
||||
for needle in ("sovran_lan_only", "remote_ip", "abort @"):
|
||||
with self.subTest(needle=needle):
|
||||
self.assertNotIn(needle, self.code)
|
||||
|
||||
def test_the_bitcoin_sites_are_plain_proxies(self):
|
||||
for site, upstream in ((":3051", ":3050"), (":60847", ":60845")):
|
||||
with self.subTest(site=site):
|
||||
m = re.search(r"^" + re.escape(site) + r" \{\n(.*?)^\}$", self.code, re.S | re.M)
|
||||
self.assertIsNotNone(m, f"{site} site not found")
|
||||
directives = [l.strip() for l in m.group(1).splitlines() if l.strip()]
|
||||
self.assertEqual(directives, [f"reverse_proxy {upstream}", "encode gzip zstd"])
|
||||
|
||||
def test_the_options_for_a_declared_prefix_are_gone(self):
|
||||
# Never needed once Caddy stops guessing: neither the option nor its
|
||||
# build-time assertion may linger half-wired.
|
||||
roles = _read("modules", "core", "roles.nix")
|
||||
self.assertNotIn("lanIPv6Prefixes", roles)
|
||||
self.assertNotIn("lanIPv6Prefixes", self.caddy)
|
||||
|
||||
|
||||
class CaddyRunsWhereItIsNeeded(unittest.TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.caddy = _read("modules", "core", "caddy.nix")
|
||||
cls.code = _without_comments(cls.caddy)
|
||||
|
||||
def test_it_runs_for_domains_vhosts_or_the_bitcoin_uis(self):
|
||||
self.assertRegex(
|
||||
self.code,
|
||||
r"caddyEnabled\s*=\s*needsHttpsPorts\s*\|\|\s*extraVhosts\s*!=\s*\"\"\s*\|\|\s*servesRtl\s*;",
|
||||
)
|
||||
self.assertRegex(self.code, r"enable\s*=\s*caddyEnabled\s*;")
|
||||
|
||||
def test_rtl_and_mempool_follow_their_services(self):
|
||||
self.assertRegex(self.code,
|
||||
r"servesRtl\s*=\s*config\.sovran_systemsOS\.services\.bitcoin\s*;")
|
||||
self.assertRegex(
|
||||
self.code,
|
||||
r"servesMempool\s*=\s*servesRtl\s*&&\s*config\.sovran_systemsOS\.features\.mempool\s*;",
|
||||
)
|
||||
|
||||
def test_each_site_exists_only_where_its_service_does(self):
|
||||
self.assertRegex(self.code, r"lib\.optionalString\s+servesRtl\s*''\s*\n+:3051 \{")
|
||||
self.assertRegex(self.code, r"lib\.optionalString\s+servesMempool\s*''\s*\n+:60847 \{")
|
||||
# ... and they are written into the Caddyfile from that one place
|
||||
self.assertIn("${bitcoinUiSites}", self.caddy)
|
||||
|
||||
def test_rtl_and_mempool_still_proxy_to_their_loopback_ports(self):
|
||||
self.assertRegex(self.code, r":3051 \{[^}]*reverse_proxy :3050")
|
||||
self.assertRegex(self.code, r":60847 \{[^}]*reverse_proxy :60845")
|
||||
|
||||
|
||||
class HubPortExposure(unittest.TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.hub = _read("modules", "core", "sovran-hub.nix")
|
||||
cls.roles = _read("modules", "core", "roles.nix")
|
||||
|
||||
def _firewall_value(self):
|
||||
m = re.search(
|
||||
r"^ networking\.firewall\.allowedTCPPorts =\s*(?P<value>.*?);\s*$",
|
||||
self.hub, re.S | re.M,
|
||||
)
|
||||
self.assertIsNotNone(m, "networking.firewall.allowedTCPPorts not found")
|
||||
return m.group("value")
|
||||
|
||||
def test_the_hub_port_is_never_opened_unconditionally(self):
|
||||
# Regression: this used to be `allowedTCPPorts = [ 8937 60847 ]` with
|
||||
# no mkIf and no option gate, on every role, Desktop Only included.
|
||||
value = self._firewall_value()
|
||||
self.assertNotRegex(value, r"^\s*\[")
|
||||
self.assertRegex(value, r"lib\.optionals\s+cfg\.hub\.directPort\s+\[\s*8937\s*\]")
|
||||
|
||||
def test_the_mempool_port_follows_mempool(self):
|
||||
self.assertRegex(
|
||||
self._firewall_value(),
|
||||
r"lib\.optionals\s+\(cfg\.services\.bitcoin\s*&&\s*cfg\.features\.mempool\)\s+\[\s*60847\s*\]",
|
||||
)
|
||||
|
||||
def test_nothing_else_is_opened_here(self):
|
||||
ports = re.findall(r"\[\s*(\d+)\s*\]", self._firewall_value())
|
||||
self.assertEqual(sorted(ports), ["60847", "8937"])
|
||||
|
||||
def test_direct_port_is_on_for_the_server_roles_and_off_for_desktop_only(self):
|
||||
m = re.search(r"directPort\s*=\s*lib\.mkOption\s*\{(.*?)\n \};", self.roles, re.S)
|
||||
self.assertIsNotNone(m, "hub.directPort option not found")
|
||||
self.assertRegex(m.group(1), r"default\s*=\s*!config\.sovran_systemsOS\.roles\.desktop\s*;")
|
||||
|
||||
def test_the_bind_is_ipv4_only_on_purpose(self):
|
||||
# IPv6 clients cannot reach the Hub, so the question of which IPv6
|
||||
# addresses are "local" never comes up. Widening the bind reopens it.
|
||||
self.assertIn('host="0.0.0.0"', self.hub)
|
||||
self.assertNotRegex(self.hub, r'host="::"')
|
||||
self.assertNotRegex(self.hub, r"both IPv4 and IPv6")
|
||||
|
||||
|
||||
class TheHubIsDocumentedAtItsPort(unittest.TestCase):
|
||||
|
||||
def test_no_document_still_sends_people_to_port_80(self):
|
||||
for name in (("README.md",), ("SECURITY.md",),
|
||||
("app", "sovran_systemsos_web", "templates", "index.html")):
|
||||
with self.subTest(file=name[-1]):
|
||||
text = _read(*name)
|
||||
self.assertNotRegex(text, r"sovransystemsos\.local(?!:8937)(?![a-z])",
|
||||
f"{name[-1]} sends people to sovransystemsos.local without :8937")
|
||||
|
||||
def test_the_documents_name_the_port(self):
|
||||
self.assertIn("http://sovransystemsos.local:8937", _read("README.md"))
|
||||
self.assertIn("http://sovransystemsos.local:8937", _read("SECURITY.md"))
|
||||
self.assertIn("hub.directPort", _read("SECURITY.md"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Guards for the Hub checking its own clients.
|
||||
|
||||
The Hub runs as root. Whether a packet may reach its port is up to the firewall
|
||||
and the router; the application adds a second lock by answering only this
|
||||
computer and the local network. These read the modules as text, like the other
|
||||
nix-file checks: 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__), ".."))
|
||||
|
||||
|
||||
def _read(*parts):
|
||||
with open(os.path.join(_ROOT, *parts), encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def _option(src, name):
|
||||
m = re.search(name + r"\s*=\s*lib\.mkOption\s*\{(.*?)\n \};", src, re.S)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
class HubChecksItsOwnClients(unittest.TestCase):
|
||||
|
||||
def test_lan_only_option_exists_and_defaults_on(self):
|
||||
body = _option(_read("modules", "core", "roles.nix"), "lanOnly")
|
||||
self.assertIsNotNone(body, "hub.lanOnly option not found")
|
||||
self.assertRegex(body, r"default\s*=\s*true")
|
||||
|
||||
def test_extra_networks_option_exists_and_defaults_empty(self):
|
||||
body = _option(_read("modules", "core", "roles.nix"), "extraLanNetworks")
|
||||
self.assertIsNotNone(body, "hub.extraLanNetworks option not found")
|
||||
self.assertRegex(body, r"default\s*=\s*\[\s*\]")
|
||||
|
||||
def test_policy_is_baked_into_the_generated_config(self):
|
||||
hub = _read("modules", "core", "sovran-hub.nix")
|
||||
self.assertRegex(hub, r"lan_only\s*=\s*cfg\.hub\.lanOnly\s*;")
|
||||
self.assertRegex(hub, r"lan_extra_networks\s*=\s*cfg\.hub\.extraLanNetworks\s*;")
|
||||
|
||||
def test_a_typo_is_caught_at_build_time(self):
|
||||
hub = _read("modules", "core", "sovran-hub.nix")
|
||||
self.assertIn("builtins.all lanNetworkOk cfg.hub.extraLanNetworks", hub)
|
||||
|
||||
def test_the_hub_enforces_it_in_its_own_middleware(self):
|
||||
server = _read("app", "sovran_systemsos_web", "server.py")
|
||||
self.assertIn("class LanOnlyMiddleware(BaseHTTPMiddleware)", server)
|
||||
self.assertIn("app.add_middleware(LanOnlyMiddleware", server)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Tests for the Hub's local-network policy and the middleware that enforces it.
|
||||
|
||||
The Hub runs as root, so it answers this computer and the local network and
|
||||
nobody else: LanPolicy in security_helpers decides, LanOnlyMiddleware in
|
||||
server.py enforces it before authentication is considered.
|
||||
|
||||
LanPolicy is exercised directly. The middleware is exercised over real HTTP
|
||||
where the environment allows it; server.py cannot be imported from this repo
|
||||
(it needs sovran_nwc from the Sovran_Bitcoin flake), so those tests skip rather
|
||||
than fail, and the wiring is additionally asserted from source so it is always
|
||||
checked.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
_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.security_helpers import ( # noqa: E402
|
||||
LanPolicy,
|
||||
LAN_ONLY_IPV4,
|
||||
LAN_ONLY_IPV6,
|
||||
)
|
||||
|
||||
_LOCAL = (
|
||||
"127.0.0.1", "10.0.0.1", "172.16.0.1", "172.31.255.254", "192.168.1.10",
|
||||
"100.64.0.1", "169.254.1.1",
|
||||
"::1", "fd12:3456::1", "fc00::1", "fe80::1",
|
||||
)
|
||||
|
||||
_REMOTE = (
|
||||
"8.8.8.8", "1.1.1.1", "203.0.113.9", "9.255.255.255",
|
||||
"172.15.255.255", "172.32.0.1", "192.169.0.1", "100.63.255.255",
|
||||
# public IPv6 — every address in 2000::/3 is on the internet
|
||||
"2001:4860:4860::8888", "2606:4700:4700::1111",
|
||||
"2a00:1450:4001::1", "2400:cb00::1",
|
||||
)
|
||||
|
||||
|
||||
class LanPolicyMatrix(unittest.TestCase):
|
||||
|
||||
def test_local_addresses_are_allowed(self):
|
||||
policy = LanPolicy()
|
||||
for address in _LOCAL:
|
||||
with self.subTest(local=address):
|
||||
self.assertTrue(policy.allows(address))
|
||||
|
||||
def test_remote_addresses_are_refused(self):
|
||||
policy = LanPolicy()
|
||||
for address in _REMOTE:
|
||||
with self.subTest(remote=address):
|
||||
self.assertFalse(policy.allows(address))
|
||||
|
||||
def test_ipv6_global_is_not_whitelisted(self):
|
||||
# 2000::/3 is the whole IPv6 global unicast space: allowing it would
|
||||
# let every public IPv6 address through.
|
||||
joined = " ".join(LAN_ONLY_IPV4 + LAN_ONLY_IPV6)
|
||||
self.assertNotIn("2000::/3", joined)
|
||||
for net in LanPolicy().networks:
|
||||
if net.version == 6:
|
||||
with self.subTest(range=str(net)):
|
||||
self.assertTrue(str(net).startswith(("::1", "fc00", "fe80")),
|
||||
f"{net} is not a local-only IPv6 range")
|
||||
|
||||
|
||||
class LanPolicyConfiguration(unittest.TestCase):
|
||||
|
||||
def test_declared_networks_are_allowed(self):
|
||||
policy = LanPolicy(extra_networks=["203.0.113.0/28", "2001:db8:abcd::/48"])
|
||||
self.assertTrue(policy.allows("203.0.113.9"))
|
||||
self.assertFalse(policy.allows("203.0.113.16"))
|
||||
self.assertTrue(policy.allows("2001:db8:abcd::5"))
|
||||
self.assertFalse(policy.allows("2001:db8:abce::5"))
|
||||
|
||||
def test_a_bare_address_is_a_single_host(self):
|
||||
policy = LanPolicy(extra_networks=["203.0.113.9"])
|
||||
self.assertTrue(policy.allows("203.0.113.9"))
|
||||
self.assertFalse(policy.allows("203.0.113.10"))
|
||||
|
||||
def test_disabled_allows_everything(self):
|
||||
policy = LanPolicy(enabled=False)
|
||||
for address in _REMOTE:
|
||||
with self.subTest(remote=address):
|
||||
self.assertTrue(policy.allows(address))
|
||||
|
||||
def test_malformed_network_does_not_widen_the_policy(self):
|
||||
# A typo must fail closed, not open the Hub to everything.
|
||||
policy = LanPolicy(extra_networks=["not-a-network", "203.0.113.0/28"])
|
||||
self.assertTrue(policy.allows("203.0.113.9"))
|
||||
self.assertFalse(policy.allows("8.8.8.8"))
|
||||
|
||||
def test_a_zero_length_prefix_is_not_a_network(self):
|
||||
# 0.0.0.0/0 and ::/0 mean "everyone". That is lan_only = false and it
|
||||
# has to be asked for by name rather than arrive as a "network".
|
||||
policy = LanPolicy(extra_networks=["0.0.0.0/0", "::/0"])
|
||||
for address in _REMOTE:
|
||||
with self.subTest(remote=address):
|
||||
self.assertFalse(policy.allows(address))
|
||||
|
||||
def test_missing_or_unparseable_client_is_refused(self):
|
||||
policy = LanPolicy()
|
||||
for address in (None, "", "testclient", "not-an-ip"):
|
||||
with self.subTest(client=address):
|
||||
self.assertFalse(policy.allows(address))
|
||||
|
||||
def test_a_dual_stack_socket_does_not_hide_the_ipv4_client(self):
|
||||
# With an IPv6 listener, IPv4 clients arrive as ::ffff:a.b.c.d. The
|
||||
# address that counts is the IPv4 one inside it, both ways round.
|
||||
policy = LanPolicy()
|
||||
for address in ("::ffff:192.168.1.5", "::ffff:127.0.0.1", "::ffff:10.1.2.3"):
|
||||
with self.subTest(local=address):
|
||||
self.assertTrue(policy.allows(address))
|
||||
for address in ("::ffff:8.8.8.8", "::ffff:203.0.113.9"):
|
||||
with self.subTest(remote=address):
|
||||
self.assertFalse(policy.allows(address))
|
||||
|
||||
|
||||
# ── Middleware ───────────────────────────────────────────────────────────────
|
||||
|
||||
try:
|
||||
from fastapi import FastAPI # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
from sovran_systemsos_web.server import LanOnlyMiddleware # noqa: E402
|
||||
HAVE_MIDDLEWARE = True
|
||||
except Exception: # fastapi / sovran_nwc unavailable from this repo
|
||||
HAVE_MIDDLEWARE = False
|
||||
|
||||
|
||||
def _app_with(policy):
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/ping")
|
||||
async def ping():
|
||||
return {"ok": True}
|
||||
|
||||
app.add_middleware(LanOnlyMiddleware, policy=policy)
|
||||
return app
|
||||
|
||||
|
||||
@unittest.skipUnless(HAVE_MIDDLEWARE, "server.py is not importable here")
|
||||
class LanOnlyMiddlewareOverHttp(unittest.TestCase):
|
||||
|
||||
def _status(self, policy, client_ip):
|
||||
client = TestClient(_app_with(policy), client=(client_ip, 51234))
|
||||
return client.get("/ping").status_code
|
||||
|
||||
def test_local_client_is_served(self):
|
||||
for address in ("127.0.0.1", "192.168.1.10", "10.0.0.1"):
|
||||
with self.subTest(local=address):
|
||||
self.assertEqual(self._status(LanPolicy(), address), 200)
|
||||
|
||||
def test_remote_client_is_refused(self):
|
||||
for address in ("203.0.113.9", "8.8.8.8", "2001:4860:4860::8888"):
|
||||
with self.subTest(remote=address):
|
||||
self.assertEqual(self._status(LanPolicy(), address), 403)
|
||||
|
||||
def test_refusal_says_nothing_about_the_configuration(self):
|
||||
# An outsider learns that the answer is no, not why or what to change.
|
||||
client = TestClient(_app_with(LanPolicy()), client=("203.0.113.9", 51234))
|
||||
response = client.get("/ping")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json(), {"detail": "Not available from this network"})
|
||||
|
||||
def test_disabled_policy_admits_remote_clients(self):
|
||||
self.assertEqual(self._status(LanPolicy(enabled=False), "203.0.113.9"), 200)
|
||||
|
||||
def test_a_refused_address_is_logged_once(self):
|
||||
# The operator whose own device is refused needs to find out why; a
|
||||
# scanner must not be able to fill the journal.
|
||||
client = TestClient(_app_with(LanPolicy()), client=("203.0.113.9", 51234))
|
||||
with self.assertLogs("sovran_systemsos_web.server", level="WARNING") as seen:
|
||||
for _ in range(5):
|
||||
client.get("/ping")
|
||||
self.assertEqual(len(seen.records), 1)
|
||||
message = seen.records[0].getMessage()
|
||||
self.assertIn("203.0.113.9", message)
|
||||
self.assertIn("sovran_systemsOS.hub.extraLanNetworks", message)
|
||||
|
||||
def test_a_served_client_is_not_logged(self):
|
||||
records = []
|
||||
|
||||
class _Collect(logging.Handler):
|
||||
def emit(self, record):
|
||||
records.append(record)
|
||||
|
||||
logger = logging.getLogger("sovran_systemsos_web.server")
|
||||
handler = _Collect(level=logging.WARNING)
|
||||
logger.addHandler(handler)
|
||||
try:
|
||||
client = TestClient(_app_with(LanPolicy()), client=("192.168.1.10", 51234))
|
||||
client.get("/ping")
|
||||
finally:
|
||||
logger.removeHandler(handler)
|
||||
self.assertEqual(records, [])
|
||||
|
||||
|
||||
# ── Wiring, checked from source so it always runs ─────────────────────────────
|
||||
|
||||
def _server_source():
|
||||
with open(os.path.join(_APP_PARENT, "sovran_systemsos_web", "server.py"),
|
||||
encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
class LanOnlyWiring(unittest.TestCase):
|
||||
|
||||
def test_middleware_is_registered_outermost(self):
|
||||
# Starlette makes the last-registered middleware the outermost one, so
|
||||
# an off-network client is turned away before auth is considered.
|
||||
src = _server_source()
|
||||
auth = src.index("app.add_middleware(AuthMiddleware)")
|
||||
nocache = src.index("app.add_middleware(NoCacheMiddleware)")
|
||||
lan = src.index("app.add_middleware(LanOnlyMiddleware")
|
||||
self.assertLess(auth, nocache)
|
||||
self.assertLess(nocache, lan)
|
||||
|
||||
def test_policy_comes_from_the_generated_config(self):
|
||||
src = _server_source()
|
||||
self.assertIn("LanPolicy(", src)
|
||||
self.assertIn('_hub_cfg.get("lan_only", True)', src)
|
||||
self.assertIn('_hub_cfg.get("lan_extra_networks")', src)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Tests for the Hub's login throttling.
|
||||
|
||||
These exercise the exact production implementation in
|
||||
sovran_systemsos_web.security_helpers.LoginThrottle. The clock and the sleep are
|
||||
injected, so the tests cover hours of lockout behaviour instantly.
|
||||
|
||||
No network access, no filesystem writes, no real delays.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
_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.security_helpers import ( # noqa: E402
|
||||
LoginThrottle,
|
||||
LOGIN_FAIL_DELAY,
|
||||
LOGIN_FAIL_MAX_DELAY,
|
||||
LOGIN_FAIL_WINDOW,
|
||||
LOGIN_FAIL_MAX,
|
||||
LOGIN_LOCKOUT_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
class FakeClock:
|
||||
"""A clock that only moves when the test says so."""
|
||||
|
||||
def __init__(self):
|
||||
self.now = 1000.0
|
||||
|
||||
def __call__(self):
|
||||
return self.now
|
||||
|
||||
def advance(self, seconds):
|
||||
self.now += seconds
|
||||
|
||||
|
||||
class FakeSleeper:
|
||||
"""Records the delays it was asked to apply instead of sleeping."""
|
||||
|
||||
def __init__(self, clock):
|
||||
self.clock = clock
|
||||
self.calls = []
|
||||
|
||||
def __call__(self, seconds):
|
||||
self.calls.append(seconds)
|
||||
self.clock.advance(seconds)
|
||||
|
||||
|
||||
def _make(**kwargs):
|
||||
clock = kwargs.pop("clock", None) or FakeClock()
|
||||
sleep = kwargs.pop("sleep", None) or FakeSleeper(clock)
|
||||
return LoginThrottle(clock=clock, sleep=sleep, **kwargs), clock, sleep
|
||||
|
||||
|
||||
def _trip(throttle, ip="203.0.113.9"):
|
||||
"""Fail LOGIN_FAIL_MAX times. The fake sleeper advances the clock for us."""
|
||||
for _ in range(LOGIN_FAIL_MAX):
|
||||
throttle.record_failure(ip)
|
||||
|
||||
|
||||
class DelayRamp(unittest.TestCase):
|
||||
|
||||
def test_delay_ramps_with_the_failure_count(self):
|
||||
throttle, _, _ = _make()
|
||||
self.assertEqual(throttle.delay_for(0), 0.0)
|
||||
self.assertEqual(throttle.delay_for(1), LOGIN_FAIL_DELAY)
|
||||
self.assertEqual(throttle.delay_for(3), LOGIN_FAIL_DELAY * 3)
|
||||
|
||||
def test_delay_is_capped(self):
|
||||
# Unbounded ramping would let a single client park a thread-pool worker
|
||||
# for minutes at a time.
|
||||
throttle, _, _ = _make()
|
||||
self.assertLessEqual(throttle.delay_for(999), LOGIN_FAIL_MAX_DELAY)
|
||||
self.assertEqual(throttle.delay_for(999), LOGIN_FAIL_MAX_DELAY)
|
||||
|
||||
def test_first_failure_is_not_delayed_much(self):
|
||||
throttle, _, sleep = _make()
|
||||
delay = throttle.record_failure("203.0.113.9")
|
||||
self.assertEqual(delay, LOGIN_FAIL_DELAY)
|
||||
self.assertEqual(sleep.calls, [LOGIN_FAIL_DELAY])
|
||||
|
||||
|
||||
class Lockout(unittest.TestCase):
|
||||
|
||||
def test_not_locked_out_initially(self):
|
||||
throttle, _, _ = _make()
|
||||
self.assertFalse(throttle.is_locked_out("203.0.113.9"))
|
||||
self.assertEqual(throttle.remaining_lockout("203.0.113.9"), 0.0)
|
||||
|
||||
def test_reaching_the_limit_locks_the_address_out(self):
|
||||
throttle, _, _ = _make()
|
||||
_trip(throttle)
|
||||
self.assertTrue(throttle.is_locked_out("203.0.113.9"))
|
||||
|
||||
def test_the_limit_is_reachable_inside_the_window(self):
|
||||
# Regression guard for the old 60s window: with a ramping delay it
|
||||
# takes ~80s to reach LOGIN_FAIL_MAX, so a 60s window expired the
|
||||
# earliest failures first and the lockout could never fire.
|
||||
throttle, clock, _ = _make()
|
||||
start = clock.now
|
||||
_trip(throttle)
|
||||
self.assertLess(clock.now - start, LOGIN_FAIL_WINDOW)
|
||||
self.assertEqual(throttle.failure_count("203.0.113.9"), LOGIN_FAIL_MAX)
|
||||
self.assertTrue(throttle.is_locked_out("203.0.113.9"))
|
||||
|
||||
def test_one_failure_short_of_the_limit_is_not_a_lockout(self):
|
||||
throttle, _, _ = _make()
|
||||
for _ in range(LOGIN_FAIL_MAX - 1):
|
||||
throttle.record_failure("203.0.113.9")
|
||||
self.assertFalse(throttle.is_locked_out("203.0.113.9"))
|
||||
|
||||
def test_lockout_expires(self):
|
||||
throttle, clock, _ = _make()
|
||||
_trip(throttle)
|
||||
self.assertTrue(throttle.is_locked_out("203.0.113.9"))
|
||||
clock.advance(LOGIN_LOCKOUT_SECONDS + 1)
|
||||
self.assertFalse(throttle.is_locked_out("203.0.113.9"))
|
||||
|
||||
def test_remaining_lockout_counts_down(self):
|
||||
throttle, clock, _ = _make()
|
||||
_trip(throttle)
|
||||
full = throttle.remaining_lockout("203.0.113.9")
|
||||
# the final record_failure applied a delay, which the fake clock has
|
||||
# already advanced, so what is left is the lockout minus that delay
|
||||
self.assertAlmostEqual(full, LOGIN_LOCKOUT_SECONDS,
|
||||
delta=LOGIN_FAIL_MAX_DELAY + 1.0)
|
||||
clock.advance(full / 2)
|
||||
self.assertLess(throttle.remaining_lockout("203.0.113.9"), full)
|
||||
self.assertGreater(throttle.remaining_lockout("203.0.113.9"), 0.0)
|
||||
|
||||
def test_further_failures_while_locked_out_extend_it(self):
|
||||
throttle, clock, _ = _make()
|
||||
_trip(throttle)
|
||||
clock.advance(LOGIN_LOCKOUT_SECONDS - 1)
|
||||
throttle.record_failure("203.0.113.9")
|
||||
self.assertTrue(throttle.is_locked_out("203.0.113.9"))
|
||||
|
||||
|
||||
class Isolation(unittest.TestCase):
|
||||
|
||||
def test_one_address_does_not_lock_out_another(self):
|
||||
throttle, _, _ = _make()
|
||||
_trip(throttle, "203.0.113.9")
|
||||
self.assertTrue(throttle.is_locked_out("203.0.113.9"))
|
||||
self.assertFalse(throttle.is_locked_out("198.51.100.7"))
|
||||
|
||||
def test_successful_login_clears_the_address(self):
|
||||
throttle, _, _ = _make()
|
||||
for _ in range(LOGIN_FAIL_MAX - 1):
|
||||
throttle.record_failure("203.0.113.9")
|
||||
throttle.clear("203.0.113.9")
|
||||
self.assertEqual(throttle.failure_count("203.0.113.9"), 0)
|
||||
self.assertFalse(throttle.is_locked_out("203.0.113.9"))
|
||||
|
||||
def test_old_failures_age_out_of_the_window(self):
|
||||
throttle, clock, _ = _make()
|
||||
throttle.record_failure("203.0.113.9")
|
||||
clock.advance(LOGIN_FAIL_WINDOW + 1)
|
||||
self.assertEqual(throttle.failure_count("203.0.113.9"), 0)
|
||||
|
||||
|
||||
class BoundedMemory(unittest.TestCase):
|
||||
|
||||
def test_tracked_addresses_are_evicted(self):
|
||||
throttle, clock, _ = _make(max_tracked_ips=8)
|
||||
for i in range(64):
|
||||
throttle.record_failure(f"198.51.100.{i}")
|
||||
clock.advance(LOGIN_FAIL_WINDOW + LOGIN_LOCKOUT_SECONDS + 1)
|
||||
throttle.record_failure("203.0.113.9")
|
||||
self.assertLessEqual(throttle.tracked_addresses(), 8)
|
||||
|
||||
def test_sleep_is_never_called_under_the_lock(self):
|
||||
# If the lock were held across the sleep, one slow client would stall
|
||||
# every other login — a self-inflicted DoS.
|
||||
throttle, clock, _ = _make()
|
||||
order = []
|
||||
|
||||
def spy(seconds):
|
||||
order.append("sleep:start")
|
||||
clock.advance(seconds)
|
||||
order.append("sleep:end")
|
||||
|
||||
throttle._sleep = spy
|
||||
throttle.record_failure("203.0.113.9")
|
||||
self.assertEqual(order, ["sleep:start", "sleep:end"])
|
||||
# A second address can still be recorded while the first is "sleeping".
|
||||
self.assertEqual(throttle.failure_count("198.51.100.7"), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Guards for when port 22 is open in the firewall.
|
||||
|
||||
sshd-localhost.nix gives every role "ssh root@localhost" by listening on
|
||||
127.0.0.1 only. NixOS opens sshd's ports in the firewall by default whether or
|
||||
not sshd listens on them, which left port 22 open on every role, Desktop Only
|
||||
included, with nothing behind it. The roles that really publish SSH open it
|
||||
explicitly, so the default has to stay off.
|
||||
|
||||
Like the other nix-file checks these read the modules as text: 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__), ".."))
|
||||
|
||||
|
||||
def _read(*parts):
|
||||
with open(os.path.join(_ROOT, *parts), encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def _without_comments(src):
|
||||
return "\n".join(l for l in src.splitlines() if not l.lstrip().startswith("#"))
|
||||
|
||||
|
||||
class LocalhostSshdDoesNotOpenThePort(unittest.TestCase):
|
||||
|
||||
def test_the_firewall_is_not_opened_by_default(self):
|
||||
code = _without_comments(_read("modules", "core", "sshd-localhost.nix"))
|
||||
self.assertRegex(code, r"openFirewall\s*=\s*lib\.mkDefault\s+false\s*;")
|
||||
|
||||
def test_it_still_listens_on_loopback_only(self):
|
||||
code = _without_comments(_read("modules", "core", "sshd-localhost.nix"))
|
||||
self.assertRegex(code, r'addr\s*=\s*"127\.0\.0\.1"')
|
||||
self.assertNotIn("0.0.0.0", code)
|
||||
|
||||
|
||||
class PublishedSshOpensItsOwnPort(unittest.TestCase):
|
||||
"""Turning the default off must not close the roles that want SSH open."""
|
||||
|
||||
def test_the_sshd_feature_opens_22_and_only_when_enabled(self):
|
||||
src = _without_comments(_read("modules", "sshd.nix"))
|
||||
self.assertRegex(src, r"lib\.mkIf\s+config\.sovran_systemsOS\.features\.sshd")
|
||||
self.assertRegex(src, r"networking\.firewall\.allowedTCPPorts\s*=\s*\[\s*22\s*\]")
|
||||
|
||||
def test_remote_deploy_opens_22_and_only_when_enabled(self):
|
||||
src = _without_comments(_read("modules", "core", "remote-deploy.nix"))
|
||||
self.assertRegex(src, r"lib\.mkIf\s+cfg\.enable")
|
||||
self.assertRegex(src, r"networking\.firewall\.allowedTCPPorts\s*=\s*\[\s*22\s*\]")
|
||||
|
||||
|
||||
class DesktopOnlyDocumentsWhatItOpens(unittest.TestCase):
|
||||
|
||||
def test_security_policy_says_desktop_opens_no_tcp_port(self):
|
||||
text = " ".join(_read("SECURITY.md").split()) # the file is line-wrapped
|
||||
self.assertIn("opens no TCP port", text)
|
||||
self.assertIn("UDP 5353", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user