Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
## [1.1.4] - 2026-09-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ Lightning infrastructure, private cloud, and communications platform when you
|
|||||||
are ready.
|
are ready.
|
||||||
|
|
||||||
[Visit the Website](https://sovransystems.com) ·
|
[Visit the Website](https://sovransystems.com) ·
|
||||||
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.4.iso) ·
|
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.0.iso) ·
|
||||||
[Try it safely in a VM](#try-it-first-in-a-virtual-machine) ·
|
[Try it safely in a VM](#try-it-first-in-a-virtual-machine) ·
|
||||||
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.4.iso.sha256) ·
|
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.0.iso.sha256) ·
|
||||||
[Build from Source](#build-from-source)
|
[Build from Source](#build-from-source)
|
||||||
|
|
||||||
<img src="assets/desktop-screenshot.webp" alt="Sovran_SystemsOS private Bitcoin desktop" width="800" />
|
<img src="assets/desktop-screenshot.webp" alt="Sovran_SystemsOS private Bitcoin desktop" width="800" />
|
||||||
@@ -45,7 +45,7 @@ are ready.
|
|||||||
- [What is included](#what-is-included)
|
- [What is included](#what-is-included)
|
||||||
- [Three modes](#three-modes)
|
- [Three modes](#three-modes)
|
||||||
- [Use it your way](#use-it-your-way)
|
- [Use it your way](#use-it-your-way)
|
||||||
- [The Sovran Hub](#the-sovran-hub)
|
- [The Hub](#the-hub)
|
||||||
- [Install Sovran_SystemsOS](#install-sovran_systemsos)
|
- [Install Sovran_SystemsOS](#install-sovran_systemsos)
|
||||||
- [For developers](#for-developers)
|
- [For developers](#for-developers)
|
||||||
- [Development workflow](#development-workflow)
|
- [Development workflow](#development-workflow)
|
||||||
@@ -202,7 +202,7 @@ Bitcoin and self-hosting infrastructure runs on the machine.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Desktop** | Everyday users and computers with modest hardware | Sparrow, Bisq, and Bisq 2 for self-custody and peer-to-peer Bitcoin use |
|
| **Desktop** | Everyday users and computers with modest hardware | Sparrow, Bisq, and Bisq 2 for self-custody and peer-to-peer Bitcoin use |
|
||||||
| **Node** | People ready to verify and operate their own Bitcoin infrastructure | Everything in Desktop, plus the full Bitcoin stack: Bitcoin Core, Electrs, LND, Ride The Lightning, BTCPay Server, and wallet-to-node connections |
|
| **Node** | People ready to verify and operate their own Bitcoin infrastructure | Everything in Desktop, plus the full Bitcoin stack: Bitcoin Core, Electrs, LND, Ride The Lightning, BTCPay Server, and wallet-to-node connections |
|
||||||
| **Server + Desktop** | Bitcoiners who want the same sovereignty over their communications, cloud, passwords, and web services | The complete Node stack, plus the private self-hosted services |
|
| **Server + Desktop** | Bitcoiners who want the same sovereignty over their communications, cloud, passwords, and web services | The complete Node stack, plus the private self-hosted services. **Makes your home IP address public:** [read this first](#server--desktop-and-your-home-ip-address) |
|
||||||
|
|
||||||
**Desktop: start with your keys.** Desktop is not a reduced or Bitcoin-free
|
**Desktop: start with your keys.** Desktop is not a reduced or Bitcoin-free
|
||||||
edition. It is a complete, private everyday computer with a clean GNOME
|
edition. It is a complete, private everyday computer with a clean GNOME
|
||||||
@@ -237,6 +237,66 @@ communications, identity, and services.
|
|||||||
> provider allows port forwarding. Most home routers and providers already
|
> provider allows port forwarding. Most home routers and providers already
|
||||||
> support this. If you are unsure, a quick search for your router model and
|
> support this. If you are unsure, a quick search for your router model and
|
||||||
> "port forwarding" will usually turn up a step-by-step guide.
|
> "port forwarding" will usually turn up a step-by-step guide.
|
||||||
|
>
|
||||||
|
> **This mode also makes your home IP address public.** Read
|
||||||
|
> [what that means](#server--desktop-and-your-home-ip-address) before you
|
||||||
|
> choose it.
|
||||||
|
|
||||||
|
### Server + Desktop and your home IP address
|
||||||
|
|
||||||
|
> **⚠️ Server + Desktop makes your home IP address public.**
|
||||||
|
> Public services need a domain name that points at your home internet
|
||||||
|
> connection. When you finish the guided domain setup, Sovran_SystemsOS puts
|
||||||
|
> your home's public IP address in a Dynamic DNS record at
|
||||||
|
> [Njal.la](https://njal.la) and keeps it up to date, and you forward ports 80
|
||||||
|
> and 443 on your router to this computer. From then on:
|
||||||
|
>
|
||||||
|
> - **Anyone can look up your domain and see your home IP address.** An IP
|
||||||
|
> address typically reveals your internet provider and your approximate
|
||||||
|
> location, and it ties everything you publish on that domain to your home
|
||||||
|
> connection.
|
||||||
|
> - **Domain privacy does not hide it.** Registrar privacy protects the
|
||||||
|
> registrant's identity, not the IP address in your DNS records.
|
||||||
|
> - **Your connection is open to the whole internet on those ports.** Scanners
|
||||||
|
> and bots constantly probe public IP addresses, so expect automated probing
|
||||||
|
> and login attempts against every service you publish.
|
||||||
|
> - **Your service names are discoverable.** Public HTTPS certificates are
|
||||||
|
> listed in public Certificate Transparency logs, so hostnames such as
|
||||||
|
> `vault.yourdomain.com` can be found, and then resolved to your IP address,
|
||||||
|
> even if you never share them.
|
||||||
|
|
||||||
|
Nothing is published until you finish domain setup and port forwarding, but that
|
||||||
|
setup is the point of this mode, so assume your IP address will be public.
|
||||||
|
**Desktop** publishes nothing. **Node** publishes nothing unless you turn on a
|
||||||
|
feature that needs a domain: *Put BTCPay Server Online* or *Lightning Wallet
|
||||||
|
Connections*.
|
||||||
|
|
||||||
|
**If you do not want your home IP address to be public,** choose Desktop or
|
||||||
|
Node. Advanced users can put a VPS, reverse proxy, or tunnel in front of their
|
||||||
|
services so DNS points there instead of at their home. Sovran_SystemsOS does not
|
||||||
|
set this up for you, and the Hub's domain checks currently expect DNS to point
|
||||||
|
at your home IP address.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>What happens technically</strong></summary>
|
||||||
|
|
||||||
|
- You create a **Dynamic** DNS record at Njal.la and paste its update command
|
||||||
|
into the Hub. The Hub only accepts `njal.la` update URLs.
|
||||||
|
- The `sovran-ddns-update` timer asks Njal.la to point your record at the
|
||||||
|
address the request came from. It does this right after you save a domain,
|
||||||
|
two minutes after boot, and then every 15 minutes.
|
||||||
|
- Njal.la reports that address back, and Sovran_SystemsOS keeps it for Element
|
||||||
|
calling and the Hub. Nothing else looks up your public IP address: no STUN
|
||||||
|
server, public DNS resolver, or "what is my IP" service is involved. See
|
||||||
|
`modules/core/njalla.nix`.
|
||||||
|
- Once a service that needs a domain is turned on, the firewall opens TCP and
|
||||||
|
UDP ports 80 and 443 for Caddy, which requests public HTTPS certificates for
|
||||||
|
the domains you configure. See `modules/core/caddy.nix`.
|
||||||
|
- Optional features can need more ports. Element calling, for example, needs
|
||||||
|
TCP 7881 and UDP 3478, 7882, and 40000–40099. The Hub lists the ports each
|
||||||
|
feature needs.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -256,19 +316,19 @@ the tools of your selected mode already in place.
|
|||||||
Prefer to keep using Windows, macOS, Linux, Android, or iOS? Install
|
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
|
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,
|
network, with or without a monitor. From any other device on the same network,
|
||||||
open a browser, visit `http://sovransystemsos.local`, and manage everything
|
open a browser, visit `http://sovransystemsos.local:8937`, and manage
|
||||||
from [The Sovran Hub](#the-sovran-hub).
|
everything from [The Sovran Hub](#the-sovran-hub).
|
||||||
|
|
||||||
Your existing devices stay familiar. Sovran_SystemsOS provides the independent
|
Your existing devices stay familiar. Sovran_SystemsOS provides the independent
|
||||||
infrastructure behind them.
|
infrastructure behind them.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## The Sovran Hub
|
## The Hub
|
||||||
|
|
||||||
### Your private infrastructure, controlled from any screen.
|
### Your private infrastructure, controlled from any screen.
|
||||||
|
|
||||||
The Sovran Hub is the command center built into Sovran_SystemsOS. It is both a
|
The Hub is the command center built into Sovran_SystemsOS. It is both a
|
||||||
local desktop application and a private web interface served directly by your
|
local desktop application and a private web interface served directly by your
|
||||||
Sovran_SystemsOS machine. Nothing needs to be installed on the device opening
|
Sovran_SystemsOS machine. Nothing needs to be installed on the device opening
|
||||||
the Hub: you only need a modern browser and access to the same local network.
|
the Hub: you only need a modern browser and access to the same local network.
|
||||||
@@ -281,9 +341,9 @@ From one place, the Hub helps you:
|
|||||||
- Reach your Bitcoin tools, private cloud, and communications
|
- Reach your Bitcoin tools, private cloud, and communications
|
||||||
- Perform supported system operations without everyday terminal commands
|
- Perform supported system operations without everyday terminal commands
|
||||||
|
|
||||||
<img src="assets/sovran-hub-screenshot.webp" alt="The Sovran Hub dashboard" width="800" />
|
<img src="assets/sovran-hub-screenshot.webp" alt="The Sovran Hub welcome dashboard" width="800" />
|
||||||
|
|
||||||
*The Sovran Hub: manage your private infrastructure from one place.*
|
*The Hub: your whole system at a glance — Bitcoin, Lightning, and your private apps.*
|
||||||
|
|
||||||
### Example home setup
|
### Example home setup
|
||||||
|
|
||||||
@@ -294,13 +354,13 @@ From one place, the Hub helps you:
|
|||||||
│ │ │
|
│ │ │
|
||||||
Windows laptop Phone or tablet Mac or Linux
|
Windows laptop Phone or tablet Mac or Linux
|
||||||
│ │ │
|
│ │ │
|
||||||
└──────── Browser: sovransystemsos.local ────┘
|
└─────── Browser: sovransystemsos.local:8937 ─┘
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌──────────────────────────┐
|
┌──────────────────────────┐
|
||||||
│ Sovran_SystemsOS │
|
│ Sovran_SystemsOS │
|
||||||
│ │
|
│ │
|
||||||
│ • Sovran Hub │
|
│ • The Hub │
|
||||||
│ • Bitcoin node │
|
│ • Bitcoin node │
|
||||||
│ • Sparrow Wallet │
|
│ • Sparrow Wallet │
|
||||||
│ • Bisq and Bisq 2 │
|
│ • Bisq and Bisq 2 │
|
||||||
@@ -316,9 +376,10 @@ Keep using the devices you already own. Sovran_SystemsOS becomes the private
|
|||||||
Bitcoin and digital infrastructure behind them.
|
Bitcoin and digital infrastructure behind them.
|
||||||
|
|
||||||
> **Local access:** the Hub is available at
|
> **Local access:** the Hub is available at
|
||||||
> `http://sovransystemsos.local` to devices connected to the same local
|
> `http://sovransystemsos.local:8937` to devices connected to the same local
|
||||||
> network. It is protected by authentication and is not automatically exposed
|
> network (not on Desktop, which publishes nothing). It is protected by
|
||||||
> to the public internet.
|
> authentication, answers only your local network, and is not automatically
|
||||||
|
> exposed to the public internet.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -345,8 +406,8 @@ with an imaging application such as [Balena Etcher](https://etcher.balena.io).
|
|||||||
|
|
||||||
### 1. Download the ISO and checksum
|
### 1. Download the ISO and checksum
|
||||||
|
|
||||||
- [Download Sovran_SystemsOS-1.1.4.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.4.iso)
|
- [Download Sovran_SystemsOS-1.2.0.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.0.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.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
|
The download may take some time. Do not rename or modify the ISO before
|
||||||
verifying it, and keep both files in the same folder.
|
verifying it, and keep both files in the same folder.
|
||||||
@@ -364,16 +425,16 @@ checksum exactly.
|
|||||||
Open a terminal in the download folder and run:
|
Open a terminal in the download folder and run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sha256sum --check Sovran_SystemsOS-1.1.4.iso.sha256
|
sha256sum --check Sovran_SystemsOS-1.2.0.iso.sha256
|
||||||
```
|
```
|
||||||
|
|
||||||
A successful comparison reports:
|
A successful comparison reports:
|
||||||
|
|
||||||
```text
|
```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
|
You can also run `sha256sum Sovran_SystemsOS-1.2.0.iso` and compare the output
|
||||||
against the checksum file manually.
|
against the checksum file manually.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
@@ -384,11 +445,11 @@ against the checksum file manually.
|
|||||||
Open Terminal in the download folder and run:
|
Open Terminal in the download folder and run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
shasum -a 256 Sovran_SystemsOS-1.1.4.iso
|
shasum -a 256 Sovran_SystemsOS-1.2.0.iso
|
||||||
```
|
```
|
||||||
|
|
||||||
Compare the value shown in Terminal with the value inside
|
Compare the value shown in Terminal with the value inside
|
||||||
`Sovran_SystemsOS-1.1.4.iso.sha256`.
|
`Sovran_SystemsOS-1.2.0.iso.sha256`.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -398,7 +459,7 @@ Compare the value shown in Terminal with the value inside
|
|||||||
Open PowerShell in the download folder and run:
|
Open PowerShell in the download folder and run:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Get-FileHash .\Sovran_SystemsOS-1.1.4.iso -Algorithm SHA256
|
Get-FileHash .\Sovran_SystemsOS-1.2.0.iso -Algorithm SHA256
|
||||||
```
|
```
|
||||||
|
|
||||||
Compare the value under `Hash` with the published checksum.
|
Compare the value under `Hash` with the published checksum.
|
||||||
@@ -413,7 +474,7 @@ match exactly.
|
|||||||
|
|
||||||
1. Download and install [Balena Etcher](https://etcher.balena.io), then
|
1. Download and install [Balena Etcher](https://etcher.balena.io), then
|
||||||
connect the USB drive.
|
connect the USB drive.
|
||||||
2. Choose **Flash from file** and select `Sovran_SystemsOS-1.1.4.iso`.
|
2. Choose **Flash from file** and select `Sovran_SystemsOS-1.2.0.iso`.
|
||||||
3. Choose **Select target**, select the USB drive, and review your selection
|
3. Choose **Select target**, select the USB drive, and review your selection
|
||||||
carefully.
|
carefully.
|
||||||
4. Choose **Flash** and wait for the writing and verification process to
|
4. Choose **Flash** and wait for the writing and verification process to
|
||||||
@@ -476,18 +537,20 @@ Open the Hub directly from the Sovran_SystemsOS desktop, or from any other
|
|||||||
device on the same local network at:
|
device on the same local network at:
|
||||||
|
|
||||||
```text
|
```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>
|
<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
|
1. Make sure the Sovran_SystemsOS machine is powered on, and allow it a few
|
||||||
minutes to finish starting.
|
minutes to finish starting.
|
||||||
2. Make sure both devices are connected to the same local network, and that
|
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
|
3. Avoid guest Wi-Fi networks, which may prevent devices from seeing one
|
||||||
another.
|
another.
|
||||||
4. Temporarily disconnect any VPN that may interfere with local-network
|
4. Temporarily disconnect any VPN that may interfere with local-network
|
||||||
@@ -730,7 +793,9 @@ Sovran_SystemsOS uses layered controls:
|
|||||||
- Separate service users, systemd sandboxing, and loopback bindings where practical
|
- Separate service users, systemd sandboxing, and loopback bindings where practical
|
||||||
- Tor enforcement for supported Bitcoin services
|
- Tor enforcement for supported Bitcoin services
|
||||||
- Restricted, time-limited support access with scoped `sudo`
|
- Restricted, time-limited support access with scoped `sudo`
|
||||||
- Operator-controlled public service exposure
|
- Operator-controlled public service exposure (Server + Desktop
|
||||||
|
[makes your home IP address public](#server--desktop-and-your-home-ip-address)
|
||||||
|
once you set up a domain)
|
||||||
|
|
||||||
See [`SECURITY.md`](SECURITY.md) for the threat model, limitations, reporting,
|
See [`SECURITY.md`](SECURITY.md) for the threat model, limitations, reporting,
|
||||||
and operator guidance. No operating system can protect funds after recovery
|
and operator guidance. No operating system can protect funds after recovery
|
||||||
@@ -856,7 +921,7 @@ primary location for collaboration. Please read our
|
|||||||
## Privacy. Sovereignty. Bitcoin.
|
## Privacy. Sovereignty. Bitcoin.
|
||||||
|
|
||||||
[Visit Sovran Systems](https://sovransystems.com) ·
|
[Visit Sovran Systems](https://sovransystems.com) ·
|
||||||
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.4.iso) ·
|
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.0.iso) ·
|
||||||
[View the License](LICENSE)
|
[View the License](LICENSE)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+57
-2
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
| Release | Supported |
|
| Release | Supported |
|
||||||
|---|:---:|
|
|---|:---:|
|
||||||
| Latest `1.0.x` stable release | Yes |
|
| Latest stable release | Yes |
|
||||||
| `main` / `staging-dev` | Development only |
|
| `main` / `staging-dev` | Development only |
|
||||||
| Older than `1.0.0` | No |
|
| Older than `1.0.0` | No |
|
||||||
|
|
||||||
@@ -35,6 +35,60 @@ external networks and are outside a “fully offline” model.
|
|||||||
The local Hub currently uses HTTP. Authentication does not encrypt local network
|
The local Hub currently uses HTTP. Authentication does not encrypt local network
|
||||||
traffic, so use a trusted LAN and avoid public or guest Wi-Fi.
|
traffic, so use a trusted LAN and avoid public or guest Wi-Fi.
|
||||||
|
|
||||||
|
The Hub is served on port 8937, on its own: Caddy does not front it. Server +
|
||||||
|
Desktop and Bitcoin Node Only open that port in the firewall, so other devices
|
||||||
|
on your local network reach the Hub at `http://sovransystemsos.local:8937`.
|
||||||
|
Forwarding ports 80 and 443 for public services does not put the Hub in front of
|
||||||
|
the internet, because the only thing Caddy answers on those ports is the public
|
||||||
|
sites.
|
||||||
|
|
||||||
|
On Desktop Only the Hub is not published at all. It is reachable only from the
|
||||||
|
machine itself, through the desktop application window on localhost. Desktop
|
||||||
|
Only is the role most likely to be used away from home, and a root-capable admin
|
||||||
|
UI has no business listening on a coffee-shop network. The firewall there opens
|
||||||
|
no TCP port at all; the only port open is UDP 5353, for mDNS.
|
||||||
|
`sovran_systemsOS.hub.directPort = true` in `custom.nix` opens port 8937 if you
|
||||||
|
do want to reach a Desktop Only Hub from another device.
|
||||||
|
|
||||||
|
The Hub also checks every client itself, before it shows a login page. It runs
|
||||||
|
as root, so it answers only this computer and the local network (loopback,
|
||||||
|
private, VPN and link-local addresses) and turns everyone else away, however
|
||||||
|
they reached it. The Hub listens on IPv4 only, so IPv6 clients do not reach it
|
||||||
|
at all; if that ever changes, global IPv6 addresses would be turned away,
|
||||||
|
because a laptop on your network and a stranger on the internet look the same
|
||||||
|
by address alone. If your devices use addresses outside the local ranges, list
|
||||||
|
their networks in `sovran_systemsOS.hub.extraLanNetworks` in `custom.nix`;
|
||||||
|
`sovran_systemsOS.hub.lanOnly = false` turns the check off.
|
||||||
|
|
||||||
|
The check goes by the address a connection comes from. A router that rewrites
|
||||||
|
that address when it forwards a port makes an outsider look local, so the check
|
||||||
|
is a second lock and not a reason to forward port 8937: don't.
|
||||||
|
|
||||||
|
Ride The Lightning (port 3051) and Mempool (port 60847) listen on loopback only,
|
||||||
|
and Caddy is how your local network reaches them. Caddy does not filter them by
|
||||||
|
client address: forwarding ports 80 and 443 for public services does not reach
|
||||||
|
them, because they answer on ports of their own, which nothing asks you to
|
||||||
|
forward. Do not forward 3051 or 60847. If you do, Ride The Lightning still asks
|
||||||
|
for its own random password and locks out repeated failures, and Mempool shows
|
||||||
|
public blockchain data, but neither should face the internet.
|
||||||
|
|
||||||
|
### Public services and your home IP address
|
||||||
|
|
||||||
|
Server + Desktop publishes services under your own domain. The Dynamic DNS
|
||||||
|
record at Njal.la then points at your home's public IP address, which anyone
|
||||||
|
can look up (domain privacy does not hide it), and ports 80 and 443 are open
|
||||||
|
to the whole internet. Public HTTPS certificates also list your service
|
||||||
|
hostnames in Certificate Transparency logs. Desktop publishes nothing. Node
|
||||||
|
publishes nothing unless *Put BTCPay Server Online* or *Lightning Wallet
|
||||||
|
Connections* is on. See
|
||||||
|
[Server + Desktop and your home IP address](README.md#server--desktop-and-your-home-ip-address)
|
||||||
|
for what this means and the alternatives.
|
||||||
|
|
||||||
|
Sovran_SystemsOS does not ask a STUN server, public DNS resolver, or “what is
|
||||||
|
my IP” service for your address. The DDNS update asks Njal.la to use the
|
||||||
|
address the request came from, and the address Njal.la reports back is the one
|
||||||
|
Element calling and the Hub use.
|
||||||
|
|
||||||
### Bitcoin stack
|
### Bitcoin stack
|
||||||
|
|
||||||
Bitcoin and Lightning modules are maintained in the standalone
|
Bitcoin and Lightning modules are maintained in the standalone
|
||||||
@@ -64,7 +118,8 @@ change both the system and local configuration.
|
|||||||
- Separate service users and systemd sandboxing where supported
|
- Separate service users and systemd sandboxing where supported
|
||||||
- Administrative service ports bound to loopback where practical
|
- Administrative service ports bound to loopback where practical
|
||||||
- Tor enforced for supported Bitcoin traffic and onion services
|
- Tor enforced for supported Bitcoin traffic and onion services
|
||||||
- Public web services exposed only when enabled by the operator
|
- Public web services exposed only when enabled by the operator (this makes
|
||||||
|
your home IP address public)
|
||||||
|
|
||||||
Tor reduces network exposure for configured Bitcoin services. It is not a
|
Tor reduces network exposure for configured Bitcoin services. It is not a
|
||||||
guarantee against every IP leak, application bug, or traffic-analysis attack.
|
guarantee against every IP leak, application bug, or traffic-analysis attack.
|
||||||
|
|||||||
@@ -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 os
|
||||||
import re
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
@@ -245,6 +246,218 @@ def _validate_ssh_pubkey(key: str) -> str:
|
|||||||
return key
|
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 ─────────────────────────────────────────────
|
# ── Persistent Hub session store ─────────────────────────────────────────────
|
||||||
|
|
||||||
def load_session_store(path: str) -> dict[str, float]:
|
def load_session_store(path: str) -> dict[str, float]:
|
||||||
|
|||||||
+198
-100
@@ -21,7 +21,6 @@ import subprocess
|
|||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import sys
|
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
@@ -41,6 +40,7 @@ from .config import load_config, load_versions
|
|||||||
from . import systemctl as sysctl
|
from . import systemctl as sysctl
|
||||||
from sovran_nwc import nwc_hub_manager as _nwc_mgr
|
from sovran_nwc import nwc_hub_manager as _nwc_mgr
|
||||||
from . import support_ops as _support_ops
|
from . import support_ops as _support_ops
|
||||||
|
from .ddns_update import normalise_url as _normalise_ddns_url
|
||||||
from .security_helpers import (
|
from .security_helpers import (
|
||||||
_nix_escape,
|
_nix_escape,
|
||||||
NPUB_RE,
|
NPUB_RE,
|
||||||
@@ -55,6 +55,13 @@ from .security_helpers import (
|
|||||||
_bech32_convertbits_decode,
|
_bech32_convertbits_decode,
|
||||||
load_session_store,
|
load_session_store,
|
||||||
save_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
|
from .update_state import effective_update_status
|
||||||
|
|
||||||
@@ -175,11 +182,19 @@ _sessions_lock = Lock()
|
|||||||
_SESSION_PERSIST_MIN_INTERVAL = 30.0 # seconds
|
_SESSION_PERSIST_MIN_INTERVAL = 30.0 # seconds
|
||||||
_sessions_last_persist = 0.0
|
_sessions_last_persist = 0.0
|
||||||
|
|
||||||
# Failed login tracking: ip → list of failure timestamps
|
# Failed login tracking.
|
||||||
_login_failures: dict[str, list[float]] = {}
|
#
|
||||||
LOGIN_FAIL_DELAY = 2.0 # seconds to sleep after a failed attempt
|
# LOGIN_FAIL_MAX used to be declared here and never read anywhere: the only
|
||||||
LOGIN_FAIL_WINDOW = 60.0 # rolling window (seconds) for counting failures
|
# thing a failed attempt cost an attacker was a flat 2 second delay, and there
|
||||||
LOGIN_FAIL_MAX = 10 # max failures in window before extra delay
|
# 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
|
# Public paths that are accessible without a valid session
|
||||||
_AUTH_EXEMPT_PATHS = {"/login", "/api/login", "/auto-login", "/api/ping"}
|
_AUTH_EXEMPT_PATHS = {"/login", "/api/login", "/auto-login", "/api/ping"}
|
||||||
@@ -234,10 +249,9 @@ _support_expiry_timer_lock = Lock()
|
|||||||
|
|
||||||
CATEGORY_ORDER = [
|
CATEGORY_ORDER = [
|
||||||
("infrastructure", "Infrastructure"),
|
("infrastructure", "Infrastructure"),
|
||||||
("bitcoin-base", "Bitcoin Base"),
|
("bitcoin", "Bitcoin"),
|
||||||
("bitcoin-apps", "Bitcoin Apps"),
|
|
||||||
("communication", "Communication"),
|
("communication", "Communication"),
|
||||||
("apps", "Self-Hosted Apps"),
|
("apps", "Personal Apps"),
|
||||||
("nostr", "Nostr"),
|
("nostr", "Nostr"),
|
||||||
("support", "Support"),
|
("support", "Support"),
|
||||||
("feature-manager", "Feature Manager"),
|
("feature-manager", "Feature Manager"),
|
||||||
@@ -452,7 +466,7 @@ ROLE_LABELS = {
|
|||||||
ROLE_CATEGORIES: dict[str, set[str] | None] = {
|
ROLE_CATEGORIES: dict[str, set[str] | None] = {
|
||||||
"server_plus_desktop": None,
|
"server_plus_desktop": None,
|
||||||
"desktop": {"infrastructure", "support", "feature-manager"},
|
"desktop": {"infrastructure", "support", "feature-manager"},
|
||||||
"node": {"infrastructure", "bitcoin-base", "bitcoin-apps", "support", "feature-manager"},
|
"node": {"infrastructure", "bitcoin", "support", "feature-manager"},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Features shown per role (None = show all)
|
# Features shown per role (None = show all)
|
||||||
@@ -771,19 +785,20 @@ def _ensure_onboarding_reopened_for_migration() -> None:
|
|||||||
logger.warning("Could not clear onboarding flag for migration flow: %s", exc)
|
logger.warning("Could not clear onboarding flag for migration flow: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
def _record_failure(client_ip: str) -> None:
|
def _record_failure(client_ip: str) -> float:
|
||||||
"""Record a failed login attempt and apply a rate-limit delay.
|
"""Record a failed login attempt and apply the throttling delay.
|
||||||
|
|
||||||
Must always be called via loop.run_in_executor() so that the blocking
|
Must always be called via loop.run_in_executor() so that the blocking
|
||||||
time.sleep() does not stall the asyncio event loop.
|
time.sleep() does not stall the asyncio event loop.
|
||||||
|
|
||||||
|
Returns the delay that was applied.
|
||||||
"""
|
"""
|
||||||
now = time.time()
|
return _login_throttle.record_failure(client_ip)
|
||||||
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]
|
def _is_locked_out(client_ip: str) -> bool:
|
||||||
_login_failures[client_ip].append(now)
|
"""Return True while *client_ip* is inside a lockout."""
|
||||||
# Sleep in the thread-pool thread to slow brute-force without blocking the loop
|
return _login_throttle.is_locked_out(client_ip)
|
||||||
time.sleep(LOGIN_FAIL_DELAY)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Authentication middleware ─────────────────────────────────────
|
# ── Authentication middleware ─────────────────────────────────────
|
||||||
@@ -807,8 +822,61 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||||||
return await call_next(request)
|
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(AuthMiddleware)
|
||||||
app.add_middleware(NoCacheMiddleware)
|
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(
|
_ICONS_DIR = os.environ.get(
|
||||||
"SOVRAN_HUB_ICONS",
|
"SOVRAN_HUB_ICONS",
|
||||||
@@ -908,11 +976,68 @@ def _get_remote_rev(branch=None):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_version(text):
|
||||||
|
"""Return a (major, minor, patch) tuple from a VERSION string, or None."""
|
||||||
|
try:
|
||||||
|
match = re.search(r"(\d+)\.(\d+)\.(\d+)", str(text))
|
||||||
|
if match:
|
||||||
|
return tuple(int(g) for g in match.groups())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_remote_version(branch=None):
|
||||||
|
"""Read VERSION on the tracked branch, e.g. the stable release version."""
|
||||||
|
try:
|
||||||
|
ref = branch or "stable"
|
||||||
|
url = (
|
||||||
|
"https://git.sovransystems.com/api/v1/repos/"
|
||||||
|
"Sovran_Systems/Sovran_SystemsOS/raw/VERSION?ref="
|
||||||
|
+ urllib.parse.quote(ref)
|
||||||
|
)
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
return _parse_version(resp.read().decode())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def check_for_updates() -> bool | None:
|
def check_for_updates() -> bool | None:
|
||||||
|
"""Whether an update is available.
|
||||||
|
|
||||||
|
Primary signal: the flake lock's pinned Sovran_Systems rev differs from
|
||||||
|
the remote branch head. BUT a failed update rewrites ``flake.lock`` (the
|
||||||
|
``nix flake update`` step) without staging a generation (the
|
||||||
|
``nixos-rebuild boot`` step failed), so after a failure the lock and the
|
||||||
|
remote agree while the *running* system is still on the old version. In
|
||||||
|
that case the rev comparison alone reports a false "up to date" and hides
|
||||||
|
the failed update from the dashboard.
|
||||||
|
|
||||||
|
Backstop: compare the running Hub version against the branch VERSION.
|
||||||
|
A newer released version while running an older one means the update did
|
||||||
|
not apply (build failed, reboot skipped, generation rolled back) and must
|
||||||
|
be offered again.
|
||||||
|
"""
|
||||||
locked_rev, branch = _get_locked_info()
|
locked_rev, branch = _get_locked_info()
|
||||||
remote_rev = _get_remote_rev(branch)
|
remote_rev = _get_remote_rev(branch)
|
||||||
if locked_rev and remote_rev:
|
if locked_rev and remote_rev:
|
||||||
return locked_rev != remote_rev
|
rev_differs = locked_rev != remote_rev
|
||||||
|
if rev_differs:
|
||||||
|
return True
|
||||||
|
# Revs match — make sure the pinned (failed) rev isn't masking an
|
||||||
|
# older *running* system.
|
||||||
|
running_ver = _parse_version(_get_sovran_version())
|
||||||
|
remote_ver = _get_remote_version(branch)
|
||||||
|
if running_ver and remote_ver and remote_ver > running_ver:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
# Couldn't compare revs — fall back to the version backstop.
|
||||||
|
running_ver = _parse_version(_get_sovran_version())
|
||||||
|
remote_ver = _get_remote_version(branch)
|
||||||
|
if running_ver and remote_ver:
|
||||||
|
return remote_ver > running_ver
|
||||||
return None # inconclusive — couldn't read lock or reach remote
|
return None # inconclusive — couldn't read lock or reach remote
|
||||||
|
|
||||||
|
|
||||||
@@ -952,43 +1077,20 @@ def _save_internal_ip(ip: str):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _save_external_ip(ip: str):
|
|
||||||
"""Write the external IP to a file so other services (e.g. LiveKit) can
|
|
||||||
reference it without running their own detection."""
|
|
||||||
if ip and ip != "unavailable":
|
|
||||||
try:
|
|
||||||
os.makedirs(os.path.dirname(EXTERNAL_IP_FILE), exist_ok=True)
|
|
||||||
with open(EXTERNAL_IP_FILE, "w") as f:
|
|
||||||
f.write(ip)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _get_external_ip() -> str:
|
def _get_external_ip() -> str:
|
||||||
"""Public IP via the shared detector (/var/lib/sovran/public-ip.py).
|
"""Public IP as recorded by the Njal.la DDNS runner (ddns_update.py).
|
||||||
|
|
||||||
The detector owns discovery (STUN -> DNS -> opt-in HTTPS echo), caches the
|
Nothing here looks the address up or contacts anyone. The DDNS update asks
|
||||||
result in /var/lib/secrets/external-ip, and contacts at most one third
|
Njal.la to use the address the request came from, Njal.la reports it back,
|
||||||
party per refresh interval. This function only reads the cache and asks
|
and the runner saves it to EXTERNAL_IP_FILE. Returns "unavailable" until
|
||||||
the detector to refresh when it is missing or stale — it performs no
|
the first successful update (and on machines with no DDNS URL, e.g. Desktop).
|
||||||
per-call external queries of its own.
|
|
||||||
"""
|
"""
|
||||||
try:
|
|
||||||
r = subprocess.run(
|
|
||||||
[sys.executable, "/var/lib/sovran/public-ip.py", "check"],
|
|
||||||
capture_output=True, text=True, timeout=20,
|
|
||||||
)
|
|
||||||
if r.returncode == 0 and r.stdout.strip():
|
|
||||||
return r.stdout.strip().splitlines()[0]
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
try:
|
||||||
with open(EXTERNAL_IP_FILE) as f:
|
with open(EXTERNAL_IP_FILE) as f:
|
||||||
ip = f.read().strip()
|
ip = f.read().strip()
|
||||||
if ip:
|
ipaddress.ip_address(ip)
|
||||||
return ip
|
return ip
|
||||||
except OSError:
|
except (OSError, ValueError):
|
||||||
pass
|
|
||||||
return "unavailable"
|
return "unavailable"
|
||||||
|
|
||||||
|
|
||||||
@@ -2605,10 +2707,24 @@ async def api_login(req: LoginRequest, request: Request):
|
|||||||
"""Validate the Hub password and issue a session cookie."""
|
"""Validate the Hub password and issue a session cookie."""
|
||||||
client_ip = request.client.host if request.client else "unknown"
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
loop = asyncio.get_event_loop()
|
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)
|
ok = await loop.run_in_executor(None, _check_password, req.password)
|
||||||
if not ok:
|
if not ok:
|
||||||
await loop.run_in_executor(None, _record_failure, client_ip)
|
await loop.run_in_executor(None, _record_failure, client_ip)
|
||||||
raise HTTPException(status_code=401, detail="Incorrect password")
|
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()
|
token = _create_session()
|
||||||
response = JSONResponse({"ok": True})
|
response = JSONResponse({"ok": True})
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
@@ -3740,9 +3856,6 @@ async def api_network():
|
|||||||
# Keep the internal-ip file in sync for credential lookups
|
# Keep the internal-ip file in sync for credential lookups
|
||||||
_save_internal_ip(internal)
|
_save_internal_ip(internal)
|
||||||
_cached_external_ip = external
|
_cached_external_ip = external
|
||||||
# Persist the external IP so other services (e.g. LiveKit) can reuse the
|
|
||||||
# Hub's detection instead of running their own.
|
|
||||||
_save_external_ip(external)
|
|
||||||
return {"internal_ip": internal, "external_ip": external}
|
return {"internal_ip": internal, "external_ip": external}
|
||||||
|
|
||||||
|
|
||||||
@@ -3885,6 +3998,11 @@ async def api_updates_check():
|
|||||||
# Avoid a slow remote update check when there is already an operation
|
# Avoid a slow remote update check when there is already an operation
|
||||||
# the dashboard needs to surface.
|
# the dashboard needs to surface.
|
||||||
return {"available": True, "status": status.lower()}
|
return {"available": True, "status": status.lower()}
|
||||||
|
if status == "FAILED":
|
||||||
|
# The last update did not complete (build failed). Keep offering the
|
||||||
|
# update so the user can re-run it rather than silently landing on a
|
||||||
|
# false "up to date".
|
||||||
|
return {"available": True, "status": "failed"}
|
||||||
|
|
||||||
available = await loop.run_in_executor(None, check_for_updates)
|
available = await loop.run_in_executor(None, check_for_updates)
|
||||||
# None means inconclusive (check failed) — report as available so the UI doesn't block
|
# None means inconclusive (check failed) — report as available so the UI doesn't block
|
||||||
@@ -3962,8 +4080,15 @@ async def api_updates_run():
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Re-read status: a prior failed update leaves flake.lock advanced even
|
||||||
|
# though no generation was staged, so the rev-based check below can say
|
||||||
|
# "no updates" even though the system is still old. A failed update must
|
||||||
|
# always be re-runnable to recover.
|
||||||
|
persisted_status = await loop.run_in_executor(None, _read_update_status)
|
||||||
|
last_failed = persisted_status == "FAILED"
|
||||||
|
|
||||||
available = await loop.run_in_executor(None, check_for_updates)
|
available = await loop.run_in_executor(None, check_for_updates)
|
||||||
if available is False: # only block when positively confirmed no updates
|
if available is False and not last_failed: # only block when positively confirmed no updates
|
||||||
# Clear stale status/log so they don't contaminate future modal opens.
|
# Clear stale status/log so they don't contaminate future modal opens.
|
||||||
_write_update_status("IDLE")
|
_write_update_status("IDLE")
|
||||||
try:
|
try:
|
||||||
@@ -4655,48 +4780,23 @@ def _save_ddns_urls(urls: list[str]) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _run_njalla_ddns() -> None:
|
def _run_njalla_ddns() -> None:
|
||||||
"""Update Njal.la DDNS records immediately (best-effort).
|
"""Ask the DDNS runner to update Njal.la right away (best-effort, non-blocking).
|
||||||
|
|
||||||
Resolves the current public IP once, then invokes ``curl`` directly as a
|
The runner (modules/core/njalla.nix -> ddns_update.py) is the only code that
|
||||||
subprocess for each stored DDNS update URL. No shell interpolation is
|
talks to Njal.la. It validates every stored URL, calls it with "&auto" so
|
||||||
performed and no user-controlled value is interpreted as shell syntax.
|
Njal.la uses the address the request came from, and records the address
|
||||||
Each URL is revalidated through ``_validate_ddns_url()`` after ``${IP}``
|
Njal.la reports back for LiveKit and the Hub (EXTERNAL_IP_FILE).
|
||||||
substitution; URLs that fail validation are silently skipped.
|
|
||||||
|
|
||||||
Called when a domain/DDNS entry is saved and when a DDNS-backed feature
|
Called when a domain/DDNS entry is saved and when a DDNS-backed feature is
|
||||||
is enabled, so DNS is refreshed right away instead of waiting for the
|
enabled, so DNS is refreshed right away instead of waiting for the
|
||||||
15-minute timer tick (see modules/core/njalla.nix).
|
15-minute timer tick.
|
||||||
"""
|
"""
|
||||||
urls = _load_ddns_urls()
|
if not _load_ddns_urls():
|
||||||
if not urls:
|
|
||||||
return
|
return
|
||||||
# Resolve current public IP (best-effort; skip if unavailable)
|
|
||||||
public_ip = ""
|
|
||||||
try:
|
try:
|
||||||
ip_result = subprocess.run(
|
|
||||||
["dig", "@resolver4.opendns.com", "myip.opendns.com", "+short", "-4"],
|
|
||||||
capture_output=True, text=True, timeout=10, check=False,
|
|
||||||
)
|
|
||||||
raw_ip = ip_result.stdout.strip().splitlines()[0] if ip_result.stdout.strip() else ""
|
|
||||||
# Validate strictly as a proper IPv4/IPv6 address before substitution
|
|
||||||
ipaddress.ip_address(raw_ip)
|
|
||||||
public_ip = raw_ip
|
|
||||||
except Exception:
|
|
||||||
public_ip = ""
|
|
||||||
|
|
||||||
if not public_ip:
|
|
||||||
return # skip to avoid sending bare ${IP} to curl
|
|
||||||
|
|
||||||
for raw_url in urls:
|
|
||||||
try:
|
|
||||||
# Replace the placeholder with the validated IP (safe string replacement)
|
|
||||||
url = raw_url.replace("${IP}", public_ip)
|
|
||||||
# Revalidate after substitution — enforces /update/ path, no $, etc.
|
|
||||||
_validate_ddns_url(url)
|
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["curl", "--silent", "--max-time", "15", "--fail", "--no-location", url],
|
["systemctl", "start", "--no-block", "sovran-ddns-update.service"],
|
||||||
timeout=20, check=False,
|
capture_output=True, timeout=10, check=False,
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -4838,18 +4938,17 @@ async def api_domains_set(req: DomainSetRequest):
|
|||||||
# Strip surrounding quotes
|
# Strip surrounding quotes
|
||||||
if len(ddns_url) >= 2 and ddns_url[0] in ('"', "'") and ddns_url[-1] == ddns_url[0]:
|
if len(ddns_url) >= 2 and ddns_url[0] in ('"', "'") and ddns_url[-1] == ddns_url[0]:
|
||||||
ddns_url = ddns_url[1:-1]
|
ddns_url = ddns_url[1:-1]
|
||||||
# Replace trailing &auto with the IP placeholder used by _run_njalla_ddns
|
# Keep Njal.la's "&auto": Njal.la then uses the address the request comes
|
||||||
if ddns_url.endswith("&auto"):
|
# from, so nothing on this machine has to look the address up. Old
|
||||||
ddns_url = ddns_url[:-5] + "&a=${IP}"
|
# "&a=${IP}" pastes and "&quiet" are normalised exactly as the runner does.
|
||||||
|
ddns_url = _normalise_ddns_url(ddns_url)
|
||||||
# Validate URL strictly — reject injection attempts before persisting.
|
# Validate URL strictly — reject injection attempts before persisting.
|
||||||
# The placeholder ${IP} is replaced temporarily so the validator sees a
|
|
||||||
# real address; the original URL (with the placeholder) is kept for storage.
|
|
||||||
try:
|
try:
|
||||||
_validate_ddns_url(ddns_url.replace("${IP}", "127.0.0.1"))
|
_validate_ddns_url(ddns_url)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid DDNS URL: {exc}")
|
raise HTTPException(status_code=400, detail=f"Invalid DDNS URL: {exc}")
|
||||||
# Persist the URL in the JSON store (never in executable shell source)
|
# Persist the URL in the JSON store (never in executable shell source)
|
||||||
existing_urls = _load_ddns_urls()
|
existing_urls = list(dict.fromkeys(_normalise_ddns_url(u) for u in _load_ddns_urls()))
|
||||||
if ddns_url not in existing_urls:
|
if ddns_url not in existing_urls:
|
||||||
existing_urls.append(ddns_url)
|
existing_urls.append(ddns_url)
|
||||||
try:
|
try:
|
||||||
@@ -6240,13 +6339,12 @@ async def _background_domain_reachability_checker():
|
|||||||
consecutive_failures = 0
|
consecutive_failures = 0
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
# Keep the persisted external IP fresh (dynamic WAN IPs), so
|
# Pick up the address the Njal.la DDNS runner last recorded (a plain
|
||||||
# services like LiveKit can read /var/lib/secrets/external-ip.
|
# file read; nothing is looked up from here).
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
external = await loop.run_in_executor(None, _get_external_ip)
|
external = await loop.run_in_executor(None, _get_external_ip)
|
||||||
if external != "unavailable":
|
if external != "unavailable":
|
||||||
_cached_external_ip = external
|
_cached_external_ip = external
|
||||||
_save_external_ip(external)
|
|
||||||
|
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
services = cfg.get("services", [])
|
services = cfg.get("services", [])
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
/* Sovran_SystemsOS Hub — Web UI Stylesheet
|
/* Sovran_SystemsOS Hub — Web UI Stylesheet
|
||||||
Dark theme — near-black with green accents matching the Sovran Hub icon
|
The Hub redesign — softer dark theme, GNOME 50 / libadwaita surfaces,
|
||||||
v8 — Black-forward, green used for accents/borders/highlights only */
|
Sovran green reserved for status and actions.
|
||||||
|
|
||||||
|
Design tokens are defined once here. Legacy variable names from the
|
||||||
|
previous theme are aliased to the new values so every stylesheet
|
||||||
|
(support, security, domain-setup, onboarding, …) re-skins without
|
||||||
|
needing per-rule edits. */
|
||||||
|
|
||||||
*, *::before, *::after {
|
*, *::before, *::after {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -9,22 +14,48 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg-color: #080a09;
|
color-scheme: dark;
|
||||||
--surface-color: rgba(14, 16, 15, 0.7);
|
|
||||||
--card-color: rgba(20, 22, 21, 0.6);
|
/* ── The Hub palette ─────────────────────────────────────────── */
|
||||||
--border-color: rgba(255, 255, 255, 0.06);
|
--bg: #17191d;
|
||||||
--text-primary: #ecf3ef;
|
--surface: #1c1f24;
|
||||||
--text-secondary: #8aaa9a;
|
--card: #23272c;
|
||||||
--text-dim: #4a6658;
|
--card-hover: #292e34;
|
||||||
--accent-color: #5EAD8A;
|
--elevated: #26292e;
|
||||||
--green: #6DBF8B;
|
--inset: #121417;
|
||||||
--yellow: #e5a50a;
|
--border: rgba(255, 255, 255, 0.07);
|
||||||
--red: #e01b24;
|
--border-strong: rgba(255, 255, 255, 0.14);
|
||||||
--grey: #5E7A6A;
|
--text: #e9edec;
|
||||||
--radius-card: 18px;
|
--text-2: #a9b0b3;
|
||||||
--radius-btn: 8px;
|
--text-3: #7a8388;
|
||||||
--shadow-card: 0 4px 16px rgba(0, 0, 0, 0.4);
|
--accent: #3ecf8e;
|
||||||
--shadow-hover: 0 8px 32px rgba(0, 0, 0, 0.5);
|
--accent-strong: #42f39a;
|
||||||
|
--accent-deep: #1aa45d;
|
||||||
|
--accent-dim: rgba(66, 243, 154, 0.12);
|
||||||
|
--amber: #e9b64a;
|
||||||
|
--red: #f66151;
|
||||||
|
--blue: #78aeed;
|
||||||
|
--mono: 'JetBrains Mono', 'Fira Code', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||||
|
--radius-card: 20px;
|
||||||
|
--radius-dialog: 26px;
|
||||||
|
--shadow-card: 0 10px 30px rgba(0, 0, 0, 0.25);
|
||||||
|
--shadow-hover: 0 16px 36px rgba(0, 0, 0, 0.38);
|
||||||
|
--shadow-pop: 0 30px 80px rgba(0, 0, 0, 0.5);
|
||||||
|
|
||||||
|
/* ── Legacy aliases (previous theme) — keep every old sheet working */
|
||||||
|
--bg-color: var(--bg);
|
||||||
|
--surface-color: var(--surface);
|
||||||
|
--card-color: var(--card);
|
||||||
|
--border-color: var(--border);
|
||||||
|
--text-primary: var(--text);
|
||||||
|
--text-secondary: var(--text-2);
|
||||||
|
--text-dim: var(--text-3);
|
||||||
|
--accent-color: var(--accent);
|
||||||
|
--green: var(--accent);
|
||||||
|
--yellow: var(--amber);
|
||||||
|
--red: var(--red);
|
||||||
|
--grey: var(--text-3);
|
||||||
|
--radius-btn: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
@@ -32,19 +63,27 @@ html, body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
font-family: Inter, Cantarell, 'Segoe UI', system-ui, -apple-system, 'Helvetica Neue', Arial, sans-serif;
|
||||||
background:
|
background: var(--bg);
|
||||||
radial-gradient(ellipse at top, rgba(94, 173, 138, 0.04) 0%, transparent 50%),
|
color: var(--text);
|
||||||
var(--bg-color);
|
font-size: 15px;
|
||||||
color: var(--text-primary);
|
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Login page ──────────────────────────────────────────────────── */
|
button { font-family: inherit; }
|
||||||
|
|
||||||
|
::selection { background: rgba(66, 243, 154, 0.25); }
|
||||||
|
|
||||||
|
/* ── Scrollbars ─────────────────────────────────────────────────── */
|
||||||
|
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||||
|
::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.10); border-radius: 99px; border: 2px solid transparent; background-clip: padding-box; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.18); background-clip: padding-box; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
|
||||||
|
/* ── Login page ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.login-wrapper {
|
.login-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -52,86 +91,103 @@ body {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card {
|
.login-card {
|
||||||
background-color: rgba(14, 16, 15, 0.75);
|
|
||||||
backdrop-filter: blur(16px);
|
|
||||||
-webkit-backdrop-filter: blur(16px);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
border-radius: 20px;
|
|
||||||
padding: 48px 40px;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 400px;
|
max-width: 402px;
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
text-align: center;
|
||||||
|
background: var(--elevated);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 28px;
|
||||||
|
padding: 46px 42px 38px;
|
||||||
|
box-shadow: var(--shadow-pop);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-header {
|
.login-header {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 32px;
|
margin-bottom: 26px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-logo {
|
.login-logo {
|
||||||
height: 64px;
|
width: 78px;
|
||||||
margin-bottom: 16px;
|
height: 78px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
filter: drop-shadow(0 8px 24px rgba(28, 196, 120, 0.3));
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-title {
|
.login-title {
|
||||||
font-size: 1.25rem;
|
font-size: 1.3rem;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
color: var(--text-primary);
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title em { font-style: normal; color: var(--accent); }
|
||||||
|
|
||||||
|
.login-sub {
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 7px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-form {
|
.login-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group label {
|
.form-group label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 0.82rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 600;
|
font-weight: 650;
|
||||||
color: var(--text-secondary);
|
color: var(--text-2);
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input {
|
.form-group input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 14px;
|
padding: 11px 16px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-strong);
|
||||||
border-radius: var(--radius-btn);
|
border-radius: 14px;
|
||||||
background-color: var(--card-color);
|
background: var(--inset);
|
||||||
color: var(--text-primary);
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
|
outline: 0;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input:focus {
|
.form-group input:focus {
|
||||||
outline: none;
|
border-color: rgba(66, 243, 154, 0.55);
|
||||||
border-color: var(--accent-color);
|
box-shadow: 0 0 0 3px rgba(66, 243, 154, 0.13);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-login {
|
.btn-login {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: var(--radius-btn);
|
border-radius: 99px;
|
||||||
background-color: var(--accent-color);
|
background: linear-gradient(180deg, #3fd68e, #1ea263);
|
||||||
color: #0A1A10;
|
color: #04220f;
|
||||||
font-size: 0.95rem;
|
font-size: 0.92rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.28), 0 6px 18px rgba(35, 199, 124, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-login:hover {
|
.btn-login:hover:not(:disabled) {
|
||||||
opacity: 0.88;
|
filter: brightness(1.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-error {
|
.login-error {
|
||||||
background-color: rgba(224, 27, 36, 0.12);
|
background: rgba(246, 97, 81, 0.10);
|
||||||
border: 1px solid var(--red);
|
border: 1px solid rgba(246, 97, 81, 0.35);
|
||||||
color: #f87171;
|
color: #f8a39b;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,34 +5,134 @@ button {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: opacity 0.15s, box-shadow 0.15s, background-color 0.15s;
|
transition: background 0.16s, border-color 0.16s, color 0.16s, transform 0.16s, filter 0.16s, opacity 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:disabled {
|
button:disabled {
|
||||||
opacity: 0.45;
|
opacity: 0.55;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
padding: 7px 16px;
|
display: inline-flex;
|
||||||
border-radius: var(--radius-btn);
|
align-items: center;
|
||||||
font-size: 0.88rem;
|
justify-content: center;
|
||||||
font-weight: 600;
|
gap: 8px;
|
||||||
|
border-radius: 99px;
|
||||||
|
padding: 8px 18px;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn svg { width: 15px; height: 15px; }
|
||||||
|
.btn:active { transform: scale(0.97); }
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background-color: var(--accent-color);
|
background: linear-gradient(180deg, #3fd68e, #1ea263);
|
||||||
color: #0A1A10;
|
color: #04220f;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.28), 0 6px 18px rgba(35, 199, 124, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
.btn-primary:hover:not(:disabled) {
|
||||||
opacity: 0.88;
|
filter: brightness(1.08);
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Update System button: uses accent green by default */
|
.btn-primary:active:not(:disabled) { transform: scale(0.97); }
|
||||||
|
|
||||||
|
/* Ghost — the default secondary action (legacy: btn-close-modal / btn-save) */
|
||||||
|
.btn-ghost,
|
||||||
|
.btn-close-modal,
|
||||||
|
.btn-save {
|
||||||
|
background: transparent;
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover:not(:disabled),
|
||||||
|
.btn-close-modal:hover:not(:disabled),
|
||||||
|
.btn-save:hover:not(:disabled) {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: rgba(255, 255, 255, 0.26);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blue — restart / retry actions (amber reads as an error) */
|
||||||
|
.btn-amber,
|
||||||
|
.btn-reboot,
|
||||||
|
.btn-restart-amber,
|
||||||
|
.btn-warning {
|
||||||
|
background: rgba(120, 174, 237, 0.10);
|
||||||
|
border-color: rgba(120, 174, 237, 0.35);
|
||||||
|
color: #a5cbf2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-amber:hover:not(:disabled),
|
||||||
|
.btn-reboot:hover:not(:disabled),
|
||||||
|
.btn-restart-amber:hover:not(:disabled),
|
||||||
|
.btn-warning:hover:not(:disabled) {
|
||||||
|
background: rgba(120, 174, 237, 0.18);
|
||||||
|
border-color: rgba(120, 174, 237, 0.55);
|
||||||
|
color: #c2dcf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: rgba(246, 97, 81, 0.10);
|
||||||
|
border-color: rgba(246, 97, 81, 0.35);
|
||||||
|
color: #f8a39b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: rgba(246, 97, 81, 0.18);
|
||||||
|
border-color: rgba(246, 97, 81, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm { padding: 6px 14px; font-size: 0.77rem; }
|
||||||
|
|
||||||
|
/* ── Header / topbar buttons ────────────────────────────────────── */
|
||||||
|
|
||||||
|
.btn-header-reboot {
|
||||||
|
background: rgba(120, 174, 237, 0.10);
|
||||||
|
border: 1px solid rgba(120, 174, 237, 0.35);
|
||||||
|
color: #a5cbf2;
|
||||||
|
font-size: 0.77rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 99px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-header-reboot:hover:not(:disabled) {
|
||||||
|
background: rgba(120, 174, 237, 0.18);
|
||||||
|
border-color: rgba(120, 174, 237, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-logout {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 0.77rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 99px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-logout:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: rgba(255, 255, 255, 0.26);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Update System button (sidebar) ─────────────────────────────── */
|
||||||
|
|
||||||
.btn-update {
|
.btn-update {
|
||||||
background-color: #4A9474;
|
background: transparent;
|
||||||
color: #E0F2EA;
|
border: 1px solid var(--border-strong);
|
||||||
|
color: var(--text-2);
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -40,24 +140,22 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-update:hover:not(:disabled) {
|
.btn-update:hover:not(:disabled) {
|
||||||
opacity: 0.88;
|
color: var(--text);
|
||||||
|
border-color: rgba(255, 255, 255, 0.26);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Update System button: brighter green when updates are available */
|
|
||||||
.btn-update.has-updates {
|
.btn-update.has-updates {
|
||||||
background-color: #5EAD8A;
|
background: rgba(66, 243, 154, 0.12);
|
||||||
color: #0A1A10;
|
border-color: rgba(66, 243, 154, 0.4);
|
||||||
}
|
color: var(--accent);
|
||||||
|
|
||||||
.btn-update.has-updates:hover:not(:disabled) {
|
|
||||||
background-color: #78C8A2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.update-badge {
|
.update-badge {
|
||||||
display: none;
|
display: none;
|
||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
background-color: var(--yellow);
|
background-color: var(--amber);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: pulse-badge 1.4s ease-in-out infinite;
|
animation: pulse-badge 1.4s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
@@ -71,9 +169,11 @@ button:disabled {
|
|||||||
50% { opacity: 0.5; transform: scale(1.35); }
|
50% { opacity: 0.5; transform: scale(1.35); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Icon buttons ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
.btn-icon {
|
.btn-icon {
|
||||||
background: none;
|
background: none;
|
||||||
color: var(--text-secondary);
|
color: var(--text-2);
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
@@ -81,6 +181,6 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon:hover:not(:disabled) {
|
.btn-icon:hover:not(:disabled) {
|
||||||
background-color: var(--border-color);
|
background-color: rgba(255, 255, 255, 0.06);
|
||||||
color: var(--text-primary);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ domain-field-label {
|
|||||||
|
|
||||||
domain-field-input {
|
domain-field-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: #0c0f0e;
|
background-color: var(--inset);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -75,7 +75,7 @@ domain-field-actions {
|
|||||||
|
|
||||||
.domain-field-input {
|
.domain-field-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: #0c0f0e;
|
background-color: var(--inset);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -198,21 +198,21 @@ domain-field-actions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.port-proto-badge--tcp {
|
.port-proto-badge--tcp {
|
||||||
color: #6dbf8b;
|
color: var(--accent);
|
||||||
background: rgba(109, 191, 139, 0.12);
|
background: rgba(66, 243, 154, 0.12);
|
||||||
border: 1px solid rgba(109, 191, 139, 0.4);
|
border: 1px solid rgba(66, 243, 154, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.port-proto-badge--udp {
|
.port-proto-badge--udp {
|
||||||
color: #6aa9e0;
|
color: var(--blue);
|
||||||
background: rgba(106, 169, 224, 0.12);
|
background: rgba(106, 169, 224, 0.12);
|
||||||
border: 1px solid rgba(106, 169, 224, 0.4);
|
border: 1px solid rgba(106, 169, 224, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.port-proto-badge--both {
|
.port-proto-badge--both {
|
||||||
color: #e5a50a;
|
color: var(--amber);
|
||||||
background: rgba(229, 165, 10, 0.12);
|
background: rgba(233, 182, 74, 0.12);
|
||||||
border: 1px solid rgba(229, 165, 10, 0.45);
|
border: 1px solid rgba(233, 182, 74, 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
.port-proto-note {
|
.port-proto-note {
|
||||||
@@ -230,7 +230,7 @@ domain-field-actions {
|
|||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
background: rgba(255, 180, 0, 0.10);
|
background: rgba(255, 180, 0, 0.10);
|
||||||
border: 1px solid var(--warning-color, #f59e0b);
|
border: 1px solid var(--warning-color, var(--amber));
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
|||||||
@@ -1,143 +1,196 @@
|
|||||||
/* ── Feature Manager styles ──────────────────────────────────────── */
|
/* ── Sidebar: Feature Manager + Preferences ─────────────────────── */
|
||||||
|
|
||||||
.feature-manager-section {
|
.category-section.autolaunch-section,
|
||||||
margin-bottom: 32px;
|
.category-section.feature-manager-section {
|
||||||
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-subcategory {
|
/* The old "Preferences" header + hr becomes a small sidebar label.
|
||||||
margin-bottom: 16px;
|
features.js renders: .section-header + hr.section-divider + cards */
|
||||||
|
.autolaunch-section .section-header,
|
||||||
|
.feature-manager-section .section-header {
|
||||||
|
font-size: 0.66rem;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
margin: 0;
|
||||||
|
padding: 14px 10px 7px;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.autolaunch-section .section-divider,
|
||||||
|
.feature-manager-section .section-divider {
|
||||||
|
background: none;
|
||||||
|
height: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-cards-wrap { display: flex; flex-direction: column; }
|
||||||
|
|
||||||
|
.feature-subcategory { display: flex; flex-direction: column; }
|
||||||
|
|
||||||
.feature-subcategory-header {
|
.feature-subcategory-header {
|
||||||
font-size: 0.78rem;
|
font-size: 0.66rem;
|
||||||
font-weight: 700;
|
font-weight: 750;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
color: var(--text-3);
|
||||||
color: var(--text-dim);
|
padding: 12px 10px 6px;
|
||||||
margin-bottom: 8px;
|
|
||||||
padding-left: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-cards-wrap {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Feature card — sidebar preference row with a switch */
|
||||||
.feature-card {
|
.feature-card {
|
||||||
background-color: var(--card-color);
|
display: flex;
|
||||||
border: 1px solid var(--border-color);
|
gap: 10px;
|
||||||
border-radius: 12px;
|
align-items: flex-start;
|
||||||
padding: 14px 16px;
|
padding: 9px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.feature-card:hover { background: rgba(255, 255, 255, 0.05); }
|
||||||
|
|
||||||
.feature-card-top {
|
.feature-card-top {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
margin-bottom: 8px;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-card-info {
|
.feature-card-info { min-width: 0; flex: 1; }
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-card-name {
|
.feature-card-name {
|
||||||
font-size: 0.9rem;
|
font-size: 0.84rem;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text);
|
||||||
margin-bottom: 4px;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-card-desc {
|
.feature-card-desc {
|
||||||
font-size: 0.78rem;
|
margin-top: 2px;
|
||||||
color: var(--text-secondary);
|
font-size: 0.68rem;
|
||||||
line-height: 1.5;
|
color: var(--text-3);
|
||||||
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-card-status {
|
.feature-card-status {
|
||||||
font-size: 0.72rem;
|
font-size: 0.66rem;
|
||||||
color: var(--text-dim);
|
font-weight: 700;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 99px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card-status.status-on {
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-dim);
|
||||||
|
border-color: rgba(66, 243, 154, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-conflict-warning {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--amber);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Domain badges inside feature cards */
|
||||||
|
.feature-domain-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.64rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 99px;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.feature-domain-badge.configured {
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-domain-badge.not-configured {
|
||||||
|
color: var(--amber);
|
||||||
|
background: rgba(233, 182, 74, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-domain-icon { font-size: 0.8rem; }
|
||||||
|
|
||||||
|
.feature-domain-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 0.66rem;
|
||||||
|
font-weight: 650;
|
||||||
|
margin-top: 5px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-domain-label--ok { color: var(--accent); }
|
||||||
|
.feature-domain-label--warn { color: var(--amber); }
|
||||||
|
.feature-domain-label--error { color: var(--red); }
|
||||||
|
.feature-domain-label--checking { color: var(--blue); }
|
||||||
|
|
||||||
|
/* ── Toggle switch (libadwaita style) ───────────────────────────── */
|
||||||
|
|
||||||
.feature-toggle {
|
.feature-toggle {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 44px;
|
width: 46px;
|
||||||
height: 24px;
|
height: 26px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-top: 2px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-toggle-input {
|
.feature-toggle-input {
|
||||||
opacity: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-toggle-slider {
|
.feature-toggle-slider {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background-color: var(--border-color);
|
border-radius: 99px;
|
||||||
border-radius: 24px;
|
background: rgba(255, 255, 255, 0.12);
|
||||||
transition: background-color 0.2s;
|
border: 1px solid var(--border-strong);
|
||||||
|
transition: 0.2s;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-toggle-slider::before {
|
.feature-toggle-slider::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 18px;
|
top: 2px;
|
||||||
height: 18px;
|
left: 2px;
|
||||||
left: 3px;
|
width: 20px;
|
||||||
top: 3px;
|
height: 20px;
|
||||||
background-color: #fff;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
transition: transform 0.2s;
|
background: #cfd8d2;
|
||||||
|
transition: 0.2s cubic-bezier(0.3, 0.8, 0.4, 1.2);
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-toggle.active .feature-toggle-slider {
|
.feature-toggle-input:checked + .feature-toggle-slider {
|
||||||
background-color: var(--green);
|
background: var(--accent);
|
||||||
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-toggle.active .feature-toggle-slider::before {
|
.feature-toggle-input:checked + .feature-toggle-slider::after {
|
||||||
transform: translateX(20px);
|
left: 23px;
|
||||||
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-domain-badge {
|
.feature-toggle-input:focus-visible + .feature-toggle-slider {
|
||||||
display: flex;
|
box-shadow: 0 0 0 3px rgba(66, 243, 154, 0.3);
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
margin-top: 6px;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-domain-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-domain-label {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-domain-label--checking {
|
|
||||||
color: var(--text-dim);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-domain-label--ok {
|
|
||||||
color: var(--green);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-domain-label--warn {
|
|
||||||
color: var(--yellow);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-domain-label--error {
|
|
||||||
color: var(--red);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +1,93 @@
|
|||||||
/* ── Header bar ─────────────────────────────────────────────────── */
|
/* ── Topbar (replaces the old header-bar + ip-bar) ──────────────── */
|
||||||
|
|
||||||
.header-bar {
|
.topbar {
|
||||||
backdrop-filter: blur(14px);
|
|
||||||
-webkit-backdrop-filter: blur(14px);
|
|
||||||
background-color: rgba(10, 12, 11, 0.82);
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
padding: 8px 24px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 14px;
|
||||||
gap: 16px;
|
padding: 13px 26px;
|
||||||
position: sticky;
|
background: var(--surface);
|
||||||
top: 0;
|
border-bottom: 1px solid var(--border);
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-logo {
|
|
||||||
height: 80px;
|
|
||||||
width: auto;
|
|
||||||
display: block;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-bar .title {
|
.page-title {
|
||||||
font-size: 1.15rem;
|
font-size: 1.02rem;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
color: var(--text-primary);
|
letter-spacing: -0.01em;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-group {
|
/* ── Search ─────────────────────────────────────────────────────── */
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 6px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-buttons {
|
.search-box {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 9px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 99px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
flex: 1;
|
||||||
|
max-width: 400px;
|
||||||
|
min-width: 140px;
|
||||||
|
margin-left: 18px;
|
||||||
|
color: var(--text-3);
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-box:focus-within {
|
||||||
|
border-color: rgba(66, 243, 154, 0.35);
|
||||||
|
box-shadow: 0 0 0 3px rgba(66, 243, 154, 0.09);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box svg { width: 15px; height: 15px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.search-box input {
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
color: var(--text);
|
||||||
|
width: 100%;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.87rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input::placeholder { color: var(--text-3); }
|
||||||
|
|
||||||
|
.top-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 9px;
|
||||||
|
margin-left: auto;
|
||||||
|
order: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Legacy badges (still used inside dialogs) ──────────────────── */
|
||||||
|
|
||||||
.role-badge {
|
.role-badge {
|
||||||
background-color: var(--accent-color);
|
display: inline-flex;
|
||||||
color: #0A1A10;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 7px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
color: var(--text-2);
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 700;
|
font-weight: 750;
|
||||||
padding: 3px 10px;
|
padding: 6px 12px;
|
||||||
border-radius: 20px;
|
border-radius: 99px;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── OS Version Badge — identical styling to the version badge ────
|
|
||||||
── shown next to titles in the service modal windows ──────────── */
|
|
||||||
.os-version-badge {
|
.os-version-badge {
|
||||||
background-color: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
color: var(--text-secondary);
|
color: var(--text-2);
|
||||||
font-size: 0.72rem;
|
font-size: 0.66rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 2px 10px;
|
padding: 2px 9px;
|
||||||
border-radius: 12px;
|
border-radius: 99px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid var(--border);
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -71,76 +95,13 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── IP bar ─────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.ip-bar {
|
|
||||||
background-color: rgba(10, 12, 11, 0.7);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
-webkit-backdrop-filter: blur(10px);
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
|
||||||
padding: 8px 24px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 32px;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ip-bar .ip-label {
|
|
||||||
color: var(--text-dim);
|
|
||||||
margin-right: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ip-bar .ip-value {
|
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
|
||||||
color: var(--accent-color);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ip-separator {
|
|
||||||
color: var(--border-color);
|
|
||||||
}
|
|
||||||
.btn-logout {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: var(--radius-btn);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.15s, color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-logout:hover {
|
|
||||||
border-color: var(--accent-color);
|
|
||||||
color: var(--accent-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Header reboot button ───────────────────────────────────────── */
|
|
||||||
|
|
||||||
.btn-header-reboot {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid rgba(184, 125, 0, 0.35);
|
|
||||||
color: #c98d08;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: var(--radius-btn);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.15s, color 0.15s, background-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-header-reboot:hover {
|
|
||||||
border-color: #b87d00;
|
|
||||||
color: #e0a010;
|
|
||||||
background-color: rgba(184, 125, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.btn-header-reboot {
|
.btn-header-reboot {
|
||||||
padding: 4px 8px;
|
padding: 5px 10px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
.btn-logout {
|
||||||
|
padding: 5px 10px;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,138 +1,533 @@
|
|||||||
/* ── Main content ───────────────────────────────────────────────── */
|
/* ── App shell: sidebar + main column ───────────────────────────── */
|
||||||
|
|
||||||
.main-content {
|
.app {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
display: flex;
|
||||||
max-width: 1400px;
|
flex-direction: column;
|
||||||
width: 100%;
|
min-width: 0;
|
||||||
margin-left: auto;
|
}
|
||||||
margin-right: auto;
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 26px 30px 70px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Sidebar ────────────────────────────────────────────────────── */
|
/* ── Sidebar ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 270px;
|
width: 250px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
height: 100%;
|
background: var(--surface);
|
||||||
overflow-y: auto;
|
border-right: 1px solid var(--border);
|
||||||
border-right: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
background-color: rgba(12, 14, 13, 0.65);
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
-webkit-backdrop-filter: blur(12px);
|
|
||||||
padding: 20px 14px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0;
|
padding: 20px 14px 14px;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Sidebar: Tech Support button ───────────────────────────────── */
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 2px 8px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
filter: drop-shadow(0 4px 14px rgba(28, 196, 120, 0.25));
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-text { display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
|
||||||
|
.brand-title {
|
||||||
|
font-size: 1.02rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-title em { font-style: normal; color: var(--accent); }
|
||||||
|
|
||||||
|
.brand-sub {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-family: var(--mono);
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-label {
|
||||||
|
font-size: 0.66rem;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
padding: 14px 10px 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Category nav (dashboard.js) */
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 11px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 0.87rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item svg { width: 17px; height: 17px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: var(--text);
|
||||||
|
box-shadow: inset 2.5px 0 0 var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-text {
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-count {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.67rem;
|
||||||
|
font-weight: 750;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-3);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 99px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active .nav-count {
|
||||||
|
background: rgba(255, 255, 255, 0.10);
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* System items (tiles.js renders these as .sidebar-support-btn) */
|
||||||
.sidebar-support-btn {
|
.sidebar-support-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 11px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: var(--card-color);
|
padding: 8px 10px;
|
||||||
border: 1px solid var(--border-color);
|
border-radius: 10px;
|
||||||
border-radius: 12px;
|
border: 0;
|
||||||
padding: 12px 14px;
|
background: none;
|
||||||
color: var(--text-primary);
|
color: var(--text-2);
|
||||||
|
font-size: 0.87rem;
|
||||||
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-style 0.15s, border-color 0.15s, background-color 0.15s;
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-support-btn:hover {
|
.sidebar-support-btn:hover {
|
||||||
border-color: var(--accent-color);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
border-style: solid;
|
color: var(--text);
|
||||||
background-color: #162320;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-support-btn + .sidebar-support-btn {
|
.sidebar-support-btn + .sidebar-support-btn {
|
||||||
margin-top: 8px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-support-icon {
|
.sidebar-support-icon {
|
||||||
font-size: 1.5rem;
|
font-size: 1rem;
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-support-icon svg { width: 17px; height: 17px; }
|
||||||
|
|
||||||
.sidebar-support-text {
|
.sidebar-support-text {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 1px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-support-title {
|
.sidebar-support-title {
|
||||||
font-size: 0.88rem;
|
font-size: 0.87rem;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
line-height: 1.3;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-support-hint {
|
.sidebar-support-hint {
|
||||||
font-size: 0.72rem;
|
font-size: 0.68rem;
|
||||||
color: var(--accent-color);
|
font-weight: 650;
|
||||||
font-weight: 600;
|
color: var(--text-3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-divider {
|
.sidebar-divider {
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border);
|
||||||
margin: 16px 0;
|
margin: 14px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Upgrade modal ──────────────────────────────────────────────── */
|
.sidebar-spacer { flex: 1; min-height: 18px; }
|
||||||
|
|
||||||
.upgrade-dialog {
|
/* ── Welcome dashboard (default view) ───────────────────────────── */
|
||||||
max-width: 480px;
|
|
||||||
|
.welcome {
|
||||||
|
position: relative;
|
||||||
|
/* Full-bleed: cancel .content's padding so the glow reaches
|
||||||
|
every edge of the panel (sidebar border, topbar, viewport). */
|
||||||
|
margin: -26px -30px -70px;
|
||||||
|
padding: 70px 38px 70px;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: calc(100% + 96px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.upgrade-info-box {
|
/* Soft drifting glow — calm, GPU-cheap (transforms only) */
|
||||||
background-color: var(--card-color);
|
.welcome-bg {
|
||||||
border: 1px solid var(--border-color);
|
position: absolute;
|
||||||
border-radius: 10px;
|
inset: 0;
|
||||||
padding: 14px 18px;
|
pointer-events: none;
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.upgrade-info-title {
|
.orb {
|
||||||
font-size: 0.88rem;
|
position: absolute;
|
||||||
font-weight: 700;
|
border-radius: 50%;
|
||||||
color: var(--text-primary);
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orb-a {
|
||||||
|
width: 560px; height: 560px;
|
||||||
|
left: -140px; top: -180px;
|
||||||
|
background: radial-gradient(circle, rgba(62, 207, 142, 0.10), transparent 70%);
|
||||||
|
animation: orb-drift-a 26s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orb-b {
|
||||||
|
width: 680px; height: 680px;
|
||||||
|
right: -200px; top: 20%;
|
||||||
|
background: radial-gradient(circle, rgba(120, 174, 237, 0.07), transparent 70%);
|
||||||
|
animation: orb-drift-b 34s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orb-c {
|
||||||
|
width: 460px; height: 460px;
|
||||||
|
left: 34%; bottom: -200px;
|
||||||
|
background: radial-gradient(circle, rgba(66, 243, 154, 0.06), transparent 70%);
|
||||||
|
animation: orb-drift-c 42s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes orb-drift-a { to { transform: translate(70px, 50px) scale(1.14); } }
|
||||||
|
@keyframes orb-drift-b { to { transform: translate(-60px, -70px) scale(1.10); } }
|
||||||
|
@keyframes orb-drift-c { to { transform: translate(50px, -40px) scale(1.18); } }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.orb { animation: none; }
|
||||||
|
.welcome-inner { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Boot splash ────────────────────────────────────────────────── */
|
||||||
|
/* Covers the shell while the first services/config data loads; the
|
||||||
|
inline script in index.html lifts it (window.__liftAppSplash). */
|
||||||
|
#app-splash {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2000;
|
||||||
|
background: var(--bg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: opacity 0.45s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app-splash.done { opacity: 0; pointer-events: none; }
|
||||||
|
|
||||||
|
.splash-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splash-ring {
|
||||||
|
position: relative;
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upgrade-info-list {
|
.splash-ring img { width: 60px; height: 60px; opacity: 0.95; }
|
||||||
padding-left: 20px;
|
|
||||||
font-size: 0.85rem;
|
.splash-ring-arc {
|
||||||
color: var(--text-secondary);
|
position: absolute;
|
||||||
line-height: 1.7;
|
inset: 0;
|
||||||
margin: 0;
|
border-radius: 50%;
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.10);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
animation: splash-spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upgrade-info-list a {
|
@keyframes splash-spin { to { transform: rotate(360deg); } }
|
||||||
color: var(--accent-color);
|
|
||||||
|
.splash-title {
|
||||||
|
font-size: 1.02rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.upgrade-rebuild-note {
|
.splash-sub {
|
||||||
font-style: italic;
|
font-size: 0.84rem;
|
||||||
color: var(--text-dim);
|
color: var(--text-3);
|
||||||
font-size: 0.82rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.splash-ring-arc { animation-duration: 2.5s; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-inner {
|
||||||
|
position: relative;
|
||||||
|
max-width: 1040px;
|
||||||
|
margin: 0 auto;
|
||||||
|
animation: welcome-in 0.5s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes welcome-in {
|
||||||
|
from { opacity: 0; transform: translateY(14px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-greeting {
|
||||||
|
font-size: 0.98rem;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-title {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: clamp(1.7rem, 2.8vw, 2.25rem);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.18;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-title em { font-style: normal; color: var(--accent); }
|
||||||
|
|
||||||
|
.welcome-meta {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-meta-sep { margin: 0 8px; opacity: 0.6; }
|
||||||
|
|
||||||
|
.welcome-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
grid-auto-rows: 1fr; /* both rows share the taller height → uniform cards */
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Three cards (Desktop-only role, no Bitcoin card) → one symmetric row */
|
||||||
|
@media (min-width: 681px) {
|
||||||
|
.welcome-cards:not(:has(#w-btc)) {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* wrappers whose children become grid items */
|
||||||
|
.welcome-dyn { display: contents; }
|
||||||
|
|
||||||
|
.w-network .net-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-network .net-k {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--text-3);
|
||||||
|
width: 30px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-network .ip-value {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-network .sub { margin-top: 8px; }
|
||||||
|
|
||||||
|
.welcome-browse {
|
||||||
|
margin-top: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 17px 19px;
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: center;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03), var(--shadow-card);
|
||||||
|
transition: border-color 0.18s;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 88px; /* uniform card height everywhere */
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget:hover { border-color: var(--border-strong); }
|
||||||
|
.widget.clickable:hover { transform: translateY(-1px); }
|
||||||
|
|
||||||
|
.widget.clickable { cursor: pointer; }
|
||||||
|
.widget.clickable:focus-visible {
|
||||||
|
outline: 2px solid rgba(66, 243, 154, 0.45);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-chip {
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
border-radius: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 6px 16px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-chip svg { width: 22px; height: 22px; }
|
||||||
|
.widget-chip img { width: 46px; height: 46px; display: block; object-fit: contain; }
|
||||||
|
|
||||||
|
.widget-chip.chip-green {
|
||||||
|
background: linear-gradient(160deg, #2f9f6b, #17683f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-chip.chip-btc {
|
||||||
|
background: linear-gradient(160deg, #f7931a, #b96a0a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-chip.chip-amber {
|
||||||
|
background: linear-gradient(160deg, #b98426, #8a5f16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-chip.chip-sovran {
|
||||||
|
background: linear-gradient(160deg, #42f39a, #1aa45d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-chip.chip-neutral {
|
||||||
|
background: linear-gradient(160deg, #2e333a, #23272c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-body { min-width: 0; }
|
||||||
|
|
||||||
|
.widget h3 {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget .sub {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--text-2);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget .sub b { color: var(--text); font-weight: 650; }
|
||||||
|
.widget .sub .good { color: var(--accent); font-weight: 650; }
|
||||||
|
.widget .sub .warn { color: var(--amber); font-weight: 650; }
|
||||||
|
|
||||||
|
.w-bar {
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: rgba(255, 255, 255, 0.07);
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 8px 0 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: linear-gradient(90deg, #42f39a, #1aa45d);
|
||||||
|
transition: width 0.6s cubic-bezier(0.3, 0.7, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ── Tiles area ─────────────────────────────────────────────────── */
|
/* ── Tiles area ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
#tiles-area {
|
#tiles-area {
|
||||||
flex: 1;
|
|
||||||
height: 100%;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 24px 20px 48px;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 72px 24px;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-loading-spinner {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2.5px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
/* ── Category sections ──────────────────────────────────────────── */
|
/* ── Category sections ──────────────────────────────────────────── */
|
||||||
|
|
||||||
.category-section {
|
.category-section {
|
||||||
@@ -140,25 +535,30 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.section-header {
|
.section-header {
|
||||||
font-size: 0.82rem;
|
display: flex;
|
||||||
font-weight: 700;
|
align-items: center;
|
||||||
letter-spacing: 0.08em;
|
gap: 10px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--text-secondary);
|
color: var(--text-2);
|
||||||
margin-bottom: 4px;
|
white-space: nowrap;
|
||||||
padding-left: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-divider {
|
.section-divider {
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 1px solid var(--border-color);
|
flex: 1;
|
||||||
margin-bottom: 16px;
|
height: 1px;
|
||||||
|
background: linear-gradient(90deg, var(--border), transparent);
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiles-grid {
|
.tiles-grid {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: repeat(auto-fill, minmax(158px, 1fr));
|
||||||
gap: 14px;
|
gap: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Empty state ────────────────────────────────────────────────── */
|
/* ── Empty state ────────────────────────────────────────────────── */
|
||||||
@@ -166,10 +566,103 @@
|
|||||||
.empty-state {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 64px 24px;
|
padding: 64px 24px;
|
||||||
color: var(--text-dim);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state p {
|
.empty-state p {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Upgrade modal (kept from previous layout) ──────────────────── */
|
||||||
|
|
||||||
|
.upgrade-dialog {
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upgrade-info-box {
|
||||||
|
background: rgba(0, 0, 0, 0.16);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin: 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upgrade-info-title {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upgrade-info-list {
|
||||||
|
padding-left: 20px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-2);
|
||||||
|
line-height: 1.7;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upgrade-info-list a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upgrade-rebuild-note {
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── First-login security banner (inside .content) ──────────────── */
|
||||||
|
|
||||||
|
.security-first-login-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
background: rgba(233, 182, 74, 0.08);
|
||||||
|
border: 1px solid rgba(233, 182, 74, 0.30);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Narrow viewports (phone / half-screen RDP) ──────────────────── */
|
||||||
|
/* The sidebar becomes an icon rail so the content keeps room, and the
|
||||||
|
topbar wraps its search onto a second row instead of overflowing. */
|
||||||
|
@media (max-width: 920px) {
|
||||||
|
.sidebar {
|
||||||
|
width: 76px;
|
||||||
|
padding: 16px 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand { justify-content: center; padding: 2px 0 18px; gap: 0; }
|
||||||
|
.brand-text,
|
||||||
|
.nav-label,
|
||||||
|
.nav-text,
|
||||||
|
.nav-count,
|
||||||
|
.sidebar-support-text,
|
||||||
|
.sidebar-divider,
|
||||||
|
#sidebar-features { display: none; }
|
||||||
|
|
||||||
|
.nav-item { justify-content: center; gap: 0; padding: 10px 0; }
|
||||||
|
.sidebar-support-btn { justify-content: center; gap: 0; padding: 10px 0; }
|
||||||
|
|
||||||
|
.content { padding: 18px 18px 40px; }
|
||||||
|
|
||||||
|
/* keep the welcome background full-bleed against the new padding */
|
||||||
|
.welcome {
|
||||||
|
margin: -18px -18px -40px;
|
||||||
|
padding: 48px 20px 40px;
|
||||||
|
min-height: calc(100% + 58px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.topbar { flex-wrap: wrap; row-gap: 10px; padding: 12px 16px; }
|
||||||
|
.search-box { order: 5; flex-basis: 100%; }
|
||||||
|
|
||||||
|
.welcome { padding: 40px 16px 36px; }
|
||||||
|
.welcome-cards { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background:
|
background:
|
||||||
radial-gradient(ellipse at top, rgba(94, 173, 138, 0.04) 0%, transparent 50%),
|
radial-gradient(ellipse at top, rgba(62, 207, 142, 0.04) 0%, transparent 50%),
|
||||||
var(--bg-color);
|
var(--bg-color);
|
||||||
padding: 24px 16px 48px;
|
padding: 24px 16px 48px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -70,13 +70,13 @@
|
|||||||
.onboarding-step-dot.active {
|
.onboarding-step-dot.active {
|
||||||
background-color: var(--accent-color);
|
background-color: var(--accent-color);
|
||||||
border-color: var(--accent-color);
|
border-color: var(--accent-color);
|
||||||
color: #0A1A10;
|
color: #04220f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding-step-dot.completed {
|
.onboarding-step-dot.completed {
|
||||||
background-color: var(--green);
|
background-color: var(--green);
|
||||||
border-color: var(--green);
|
border-color: var(--green);
|
||||||
color: #0A1A10;
|
color: #04220f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding-step-connector {
|
.onboarding-step-connector {
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
/* Cards */
|
/* Cards */
|
||||||
|
|
||||||
.onboarding-card {
|
.onboarding-card {
|
||||||
background-color: rgba(14, 16, 15, 0.65);
|
background-color: rgba(18, 24, 20, 0.65);
|
||||||
backdrop-filter: blur(14px);
|
backdrop-filter: blur(14px);
|
||||||
-webkit-backdrop-filter: blur(14px);
|
-webkit-backdrop-filter: blur(14px);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
@@ -184,10 +184,10 @@
|
|||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--accent-color);
|
color: var(--accent-color);
|
||||||
background-color: rgba(94, 173, 138, 0.10);
|
background-color: rgba(62, 207, 142, 0.10);
|
||||||
padding: 3px 10px;
|
padding: 3px 10px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
border: 1px solid rgba(94, 173, 138, 0.25);
|
border: 1px solid rgba(62, 207, 142, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Step header */
|
/* Step header */
|
||||||
@@ -371,8 +371,8 @@
|
|||||||
|
|
||||||
.onboarding-port-warn {
|
.onboarding-port-warn {
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
background-color: rgba(229, 165, 10, 0.1);
|
background-color: rgba(233, 182, 74, 0.1);
|
||||||
border: 1px solid rgba(229, 165, 10, 0.35);
|
border: 1px solid rgba(233, 182, 74, 0.35);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--yellow);
|
color: var(--yellow);
|
||||||
@@ -409,8 +409,8 @@
|
|||||||
|
|
||||||
.onboarding-creds-notice {
|
.onboarding-creds-notice {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
background-color: rgba(94, 173, 138, 0.08);
|
background-color: rgba(62, 207, 142, 0.08);
|
||||||
border: 1px solid rgba(94, 173, 138, 0.20);
|
border: 1px solid rgba(62, 207, 142, 0.20);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -503,8 +503,8 @@
|
|||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background-color: rgba(94, 173, 138, 0.08);
|
background-color: rgba(62, 207, 142, 0.08);
|
||||||
border: 1px solid rgba(94, 173, 138, 0.20);
|
border: 1px solid rgba(62, 207, 142, 0.20);
|
||||||
color: var(--accent-color);
|
color: var(--accent-color);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -512,7 +512,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.onboarding-cred-reveal-btn:hover {
|
.onboarding-cred-reveal-btn:hover {
|
||||||
background-color: rgba(94, 173, 138, 0.15);
|
background-color: rgba(62, 207, 142, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Completion checklist (Step 5) */
|
/* Completion checklist (Step 5) */
|
||||||
@@ -671,7 +671,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.onboarding-password-toggle:hover {
|
.onboarding-password-toggle:hover {
|
||||||
background-color: rgba(94, 173, 138, 0.10);
|
background-color: rgba(62, 207, 142, 0.10);
|
||||||
border-color: var(--accent-color);
|
border-color: var(--accent-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -683,8 +683,8 @@
|
|||||||
|
|
||||||
.onboarding-password-warning {
|
.onboarding-password-warning {
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
background-color: rgba(229, 165, 10, 0.1);
|
background-color: rgba(233, 182, 74, 0.1);
|
||||||
border: 1px solid rgba(229, 165, 10, 0.35);
|
border: 1px solid rgba(233, 182, 74, 0.35);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--yellow);
|
color: var(--yellow);
|
||||||
@@ -694,8 +694,8 @@
|
|||||||
|
|
||||||
.onboarding-password-success {
|
.onboarding-password-success {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
background-color: rgba(94, 173, 138, 0.10);
|
background-color: rgba(62, 207, 142, 0.10);
|
||||||
border: 1px solid rgba(94, 173, 138, 0.30);
|
border: 1px solid rgba(62, 207, 142, 0.30);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
color: var(--green);
|
color: var(--green);
|
||||||
@@ -748,7 +748,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.reboot-card {
|
.reboot-card {
|
||||||
background-color: rgba(14, 16, 15, 0.8);
|
background-color: rgba(18, 24, 20, 0.8);
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
-webkit-backdrop-filter: blur(20px);
|
-webkit-backdrop-filter: blur(20px);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
@@ -823,57 +823,8 @@
|
|||||||
|
|
||||||
/* ── Responsive ─────────────────────────────────────────────────── */
|
/* ── Responsive ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
/* Legacy pre-redesign narrow-screen rules removed: they targeted the old
|
||||||
body {
|
DOM (.main-content, .header-bar, .ip-bar) and their .sidebar /
|
||||||
overflow: auto;
|
#tiles-area / .service-tile / .creds-qr-img overrides fought the new
|
||||||
}
|
responsive layout in layout.css (sidebar icon rail, wrapping topbar).
|
||||||
.main-content {
|
Narrow-viewport behavior now lives in layout.css + header.css. */
|
||||||
flex-direction: column;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
.sidebar {
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
border-right: none;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
padding: 14px 12px;
|
|
||||||
}
|
|
||||||
#tiles-area {
|
|
||||||
height: auto;
|
|
||||||
overflow-y: visible;
|
|
||||||
padding: 16px 12px 40px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.header-bar {
|
|
||||||
padding: 10px 14px;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.header-bar .title {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
.ip-bar {
|
|
||||||
gap: 16px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
padding: 8px 14px;
|
|
||||||
}
|
|
||||||
.tiles-grid {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.service-tile {
|
|
||||||
width: 140px;
|
|
||||||
min-height: 130px;
|
|
||||||
}
|
|
||||||
.reboot-card {
|
|
||||||
padding: 36px 28px;
|
|
||||||
margin: 0 16px;
|
|
||||||
}
|
|
||||||
.creds-dialog {
|
|
||||||
margin: 0 12px;
|
|
||||||
}
|
|
||||||
.creds-qr-img {
|
|
||||||
width: 200px;
|
|
||||||
height: 200px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
|
|
||||||
.security-warning-box {
|
.security-warning-box {
|
||||||
background-color: rgba(180, 40, 40, 0.10);
|
background-color: rgba(180, 40, 40, 0.10);
|
||||||
border-left: 3px solid #c94040;
|
border-left: 3px solid var(--red);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
background-color: #c94040;
|
background-color: var(--red);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger:hover:not(:disabled) {
|
.btn-danger:hover:not(:disabled) {
|
||||||
background-color: #a83030;
|
background-color: var(--red);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger:disabled {
|
.btn-danger:disabled {
|
||||||
@@ -105,8 +105,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.security-status-info { color: var(--text-secondary); }
|
.security-status-info { color: var(--text-secondary); }
|
||||||
.security-status-ok { color: #6DBF8B; }
|
.security-status-ok { color: var(--accent); }
|
||||||
.security-status-error { color: #e05252; }
|
.security-status-error { color: var(--red); }
|
||||||
|
|
||||||
/* ── Verify System Integrity ─────────────────────────────────────── */
|
/* ── Verify System Integrity ─────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@
|
|||||||
|
|
||||||
.security-verify-link {
|
.security-verify-link {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--accent-color, #6DBF8B);
|
color: var(--accent-color, var(--accent));
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,13 +174,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.security-verify-pass {
|
.security-verify-pass {
|
||||||
background-color: rgba(109, 191, 139, 0.15);
|
background-color: rgba(66, 243, 154, 0.15);
|
||||||
color: #6DBF8B;
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.security-verify-fail {
|
.security-verify-fail {
|
||||||
background-color: rgba(224, 82, 82, 0.15);
|
background-color: rgba(224, 82, 82, 0.15);
|
||||||
color: #e05252;
|
color: var(--red);
|
||||||
}
|
}
|
||||||
|
|
||||||
.security-verify-errors {
|
.security-verify-errors {
|
||||||
@@ -221,12 +221,11 @@
|
|||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background-color: rgba(6, 8, 7, 0.94);
|
background: radial-gradient(1000px 640px at 50% 18%, #202429, #0f1113 72%);
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
-webkit-backdrop-filter: blur(8px);
|
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
padding: 26px;
|
||||||
animation: security-reset-fade-in 0.35s ease-out;
|
animation: security-reset-fade-in 0.35s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,13 +252,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.security-reset-password-box {
|
.security-reset-password-box {
|
||||||
font-family: monospace;
|
font-family: var(--mono);
|
||||||
font-size: 1.35rem;
|
font-size: 1.35rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background: rgba(109, 191, 139, 0.10);
|
background: var(--accent-dim);
|
||||||
border: 1.5px solid rgba(109, 191, 139, 0.35);
|
border: 1.5px solid rgba(66, 243, 154, 0.35);
|
||||||
border-radius: 8px;
|
border-radius: 14px;
|
||||||
padding: 14px 24px;
|
padding: 14px 24px;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -277,10 +276,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.security-reset-reboot-btn {
|
.security-reset-reboot-btn {
|
||||||
background-color: #6DBF8B;
|
background: linear-gradient(180deg, #3fd68e, #1ea263);
|
||||||
color: #0a0c0b;
|
color: #04220f;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 7px;
|
border-radius: 99px;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.28), 0 6px 18px rgba(35, 199, 124, 0.22);
|
||||||
padding: 11px 22px;
|
padding: 11px 22px;
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -290,7 +290,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.security-reset-reboot-btn:hover:not(:disabled) {
|
.security-reset-reboot-btn:hover:not(:disabled) {
|
||||||
background-color: #5aab78;
|
background-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.security-reset-reboot-btn:disabled {
|
.security-reset-reboot-btn:disabled {
|
||||||
@@ -303,11 +303,12 @@
|
|||||||
.security-first-login-banner {
|
.security-first-login-banner {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px 18px;
|
padding: 12px 16px;
|
||||||
background-color: rgba(94, 173, 138, 0.08);
|
margin-bottom: 24px;
|
||||||
border-bottom: 2px solid rgba(94, 173, 138, 0.25);
|
background: rgba(233, 182, 74, 0.08);
|
||||||
|
border: 1px solid rgba(233, 182, 74, 0.30);
|
||||||
|
border-radius: 16px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,19 +334,21 @@
|
|||||||
|
|
||||||
.security-banner-dismiss {
|
.security-banner-dismiss {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-strong);
|
||||||
border-radius: 4px;
|
border-radius: 99px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
padding: 2px 7px;
|
padding: 4px 10px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
transition: background-color 0.15s;
|
transition: 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.security-banner-dismiss:hover {
|
.security-banner-dismiss:hover {
|
||||||
background-color: rgba(0,0,0,0.08);
|
color: var(--text);
|
||||||
|
border-color: rgba(255, 255, 255, 0.26);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Legacy security inline warning banner ───────────────────────── */
|
/* ── Legacy security inline warning banner ───────────────────────── */
|
||||||
@@ -357,14 +360,14 @@
|
|||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
background-color: rgba(180, 100, 0, 0.12);
|
background-color: rgba(180, 100, 0, 0.12);
|
||||||
border-left: 3px solid #c97a00;
|
border-left: 3px solid #c98d08;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.security-inline-icon {
|
.security-inline-icon {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: #e69000;
|
color: #e0a010;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,9 +381,9 @@
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #e69000;
|
color: #e0a010;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border: 1px solid #c97a00;
|
border: 1px solid #c98d08;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
@@ -447,9 +450,9 @@
|
|||||||
|
|
||||||
.pw-credentials-note {
|
.pw-credentials-note {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: #c97a00;
|
color: #c98d08;
|
||||||
background-color: rgba(180, 100, 0, 0.10);
|
background-color: rgba(180, 100, 0, 0.10);
|
||||||
border-left: 2px solid #c97a00;
|
border-left: 2px solid #c98d08;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 7px 10px;
|
padding: 7px 10px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
|
|||||||
@@ -104,7 +104,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.support-steps code {
|
.support-steps code {
|
||||||
background-color: rgba(94, 173, 138, 0.10);
|
background-color: rgba(62, 207, 142, 0.10);
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
@@ -116,7 +116,7 @@
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: var(--radius-btn);
|
border-radius: var(--radius-btn);
|
||||||
background-color: var(--accent-color);
|
background-color: var(--accent-color);
|
||||||
color: #0A1A10;
|
color: #04220f;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: var(--radius-btn);
|
border-radius: var(--radius-btn);
|
||||||
background-color: var(--accent-color);
|
background-color: var(--accent-color);
|
||||||
color: #0A1A10;
|
color: #04220f;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.support-btn-auditlog:hover:not(:disabled) {
|
.support-btn-auditlog:hover:not(:disabled) {
|
||||||
background-color: #1c2a24;
|
background-color: var(--card-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.support-fine-print {
|
.support-fine-print {
|
||||||
@@ -219,13 +219,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.support-wallet-protected {
|
.support-wallet-protected {
|
||||||
background-color: rgba(109, 191, 139, 0.06);
|
background-color: rgba(66, 243, 154, 0.06);
|
||||||
border-color: rgba(109, 191, 139, 0.3);
|
border-color: rgba(66, 243, 154, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.support-wallet-unlocked {
|
.support-wallet-unlocked {
|
||||||
background-color: rgba(229, 165, 10, 0.06);
|
background-color: rgba(233, 182, 74, 0.06);
|
||||||
border-color: rgba(229, 165, 10, 0.3);
|
border-color: rgba(233, 182, 74, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.support-wallet-warning {
|
.support-wallet-warning {
|
||||||
@@ -290,7 +290,7 @@
|
|||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-radius: var(--radius-btn);
|
border-radius: var(--radius-btn);
|
||||||
background-color: var(--yellow);
|
background-color: var(--yellow);
|
||||||
color: #0A1A10;
|
color: #04220f;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
@@ -310,7 +310,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.support-btn-wallet-lock:hover:not(:disabled) {
|
.support-btn-wallet-lock:hover:not(:disabled) {
|
||||||
background-color: #529E7E;
|
background-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Audit log ───────────────────────────────────────────────────── */
|
/* ── Audit log ───────────────────────────────────────────────────── */
|
||||||
@@ -324,7 +324,7 @@
|
|||||||
.support-audit-log {
|
.support-audit-log {
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background-color: #0c0f0e;
|
background-color: var(--inset);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,473 +1,178 @@
|
|||||||
/* ── Service tile card (status-only) ─────────────────────────────── */
|
/* ── Service tiles ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
.dashboard-loading {
|
|
||||||
min-height: 180px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 10px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-loading-spinner {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border: 2px solid var(--border-color);
|
|
||||||
border-top-color: var(--accent-color);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: dashboard-loading-spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes dashboard-loading-spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.service-tile {
|
.service-tile {
|
||||||
width: 160px;
|
background: var(--card);
|
||||||
min-height: 130px;
|
border: 1px solid var(--border);
|
||||||
background-color: var(--card-color);
|
border-radius: 20px;
|
||||||
border: 1px solid var(--border-color);
|
overflow: hidden;
|
||||||
backdrop-filter: blur(8px);
|
padding: 21px 12px 17px;
|
||||||
-webkit-backdrop-filter: blur(8px);
|
|
||||||
border-radius: var(--radius-card);
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
|
||||||
padding: 20px 12px 18px;
|
|
||||||
gap: 0;
|
|
||||||
transition: box-shadow 0.2s, border-color 0.2s;
|
|
||||||
position: relative;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
transition: transform 0.18s, border-color 0.18s, background 0.18s, box-shadow 0.18s, opacity 0.18s;
|
||||||
|
animation: tileIn 0.45s cubic-bezier(0.2, 0.8, 0.3, 1) backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tileIn {
|
||||||
|
from { opacity: 0; transform: translateY(10px) scale(0.96); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.service-tile:hover {
|
.service-tile:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
background: var(--card-hover);
|
||||||
box-shadow: var(--shadow-hover);
|
box-shadow: var(--shadow-hover);
|
||||||
border-color: var(--accent-color);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.service-tile.disabled {
|
.service-tile:active { transform: translateY(-1px) scale(0.985); }
|
||||||
opacity: 0.45;
|
|
||||||
|
.service-tile:focus-visible {
|
||||||
|
outline: 2px solid rgba(66, 243, 154, 0.45);
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.service-tile.disabled { opacity: 0.55; }
|
||||||
|
|
||||||
.tile-icon {
|
.tile-icon {
|
||||||
width: 48px;
|
width: 62px;
|
||||||
height: 48px;
|
height: 62px;
|
||||||
|
display: block;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile-icon-fallback {
|
.tile-icon-fallback {
|
||||||
width: 48px;
|
width: 62px;
|
||||||
height: 48px;
|
height: 62px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background-color: var(--border-color);
|
color: var(--text-3);
|
||||||
border-radius: 12px;
|
font-size: 1.2rem;
|
||||||
color: var(--text-dim);
|
font-weight: 700;
|
||||||
font-size: 1.5rem;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile-name {
|
.tile-name {
|
||||||
font-size: 0.88rem;
|
margin-top: 13px;
|
||||||
font-weight: 600;
|
font-size: 0.86rem;
|
||||||
|
font-weight: 650;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-primary);
|
line-height: 1.25;
|
||||||
line-height: 1.3;
|
min-height: 2.5em;
|
||||||
max-width: 140px;
|
|
||||||
word-break: break-word;
|
|
||||||
hyphens: auto;
|
|
||||||
min-height: 1.3em;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
color: var(--text);
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Status pill — dot + label inside a rounded pill */
|
||||||
.tile-status {
|
.tile-status {
|
||||||
font-size: 0.75rem;
|
margin-top: 9px;
|
||||||
margin-top: 8px;
|
display: inline-flex;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 6px;
|
||||||
color: var(--text-secondary);
|
font-size: 0.69rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 99px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: var(--text-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile-version {
|
.status-text { line-height: 1; }
|
||||||
font-size: 0.7rem;
|
|
||||||
color: var(--text-dim);
|
|
||||||
margin-top: 2px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot {
|
.status-dot {
|
||||||
width: 8px;
|
width: 6px;
|
||||||
height: 8px;
|
height: 6px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
background: var(--text-3);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background-color: var(--grey);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot.active { background-color: var(--green); }
|
.status-dot.active { background: var(--accent); }
|
||||||
.status-dot.inactive { background-color: var(--red); }
|
.status-dot.needs-attention { background: var(--amber); }
|
||||||
.status-dot.loading { background-color: var(--yellow); animation: pulse-badge 1s infinite; }
|
.status-dot.failed { background: var(--red); }
|
||||||
.status-dot.failed { background-color: var(--red); }
|
.status-dot.inactive,
|
||||||
.status-dot.disabled { background-color: var(--grey); }
|
.status-dot.disabled { background: var(--text-3); }
|
||||||
.status-dot.needs-attention { background-color: var(--yellow); }
|
.status-dot.syncing,
|
||||||
.status-dot.syncing { background-color: #f5a623; animation: pulse-badge 1.5s infinite; }
|
.status-dot.loading,
|
||||||
.status-dot.checking-reachability { background-color: var(--accent-color); animation: pulse-badge 1s infinite; }
|
.status-dot.checking-reachability { background: var(--blue); animation: tile-dot-pulse 1.4s ease-in-out infinite; }
|
||||||
|
.status-dot.unknown { background: var(--text-3); }
|
||||||
|
|
||||||
/* ── Bitcoin IBD sync progress bar ──────────────────────────────── */
|
@keyframes tile-dot-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.35; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progressive enhancement: tint the whole pill on modern browsers */
|
||||||
|
.tile-status:has(.status-dot.active) { background: var(--accent-dim); color: var(--accent); }
|
||||||
|
.tile-status:has(.status-dot.needs-attention) { background: rgba(233, 182, 74, 0.10); color: var(--amber); }
|
||||||
|
.tile-status:has(.status-dot.failed) { background: rgba(246, 97, 81, 0.10); color: var(--red); }
|
||||||
|
.tile-status:has(.status-dot.syncing),
|
||||||
|
.tile-status:has(.status-dot.loading),
|
||||||
|
.tile-status:has(.status-dot.checking-reachability) { background: rgba(120, 174, 237, 0.10); color: var(--blue); }
|
||||||
|
|
||||||
|
.support-status-label {
|
||||||
|
font-size: 0.69rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bitcoin IBD sync tile ──────────────────────────────────────── */
|
||||||
|
|
||||||
.tile-sync-container {
|
.tile-sync-container {
|
||||||
display: flex;
|
margin-top: 12px;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 6px;
|
padding: 0 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile-sync-label {
|
.tile-sync-label {
|
||||||
font-size: 0.72rem;
|
font-size: 0.66rem;
|
||||||
color: #f5a623;
|
font-weight: 750;
|
||||||
font-weight: 600;
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--amber);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile-sync-bar-row {
|
.tile-sync-bar-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 8px;
|
||||||
width: 100%;
|
margin-top: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile-sync-bar-track {
|
.tile-sync-bar-track {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 6px;
|
height: 7px;
|
||||||
background-color: var(--border-color);
|
border-radius: 99px;
|
||||||
border-radius: 3px;
|
background: rgba(255, 255, 255, 0.07);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile-sync-bar-fill {
|
.tile-sync-bar-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: #f5a623;
|
border-radius: 99px;
|
||||||
border-radius: 3px;
|
background: linear-gradient(90deg, #42f39a, #1aa45d);
|
||||||
transition: width 0.6s ease;
|
transition: width 0.6s cubic-bezier(0.3, 0.7, 0.3, 1);
|
||||||
min-width: 2px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile-sync-percent {
|
.tile-sync-percent {
|
||||||
font-size: 0.72rem;
|
font-family: var(--mono);
|
||||||
|
font-size: 0.7rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #f5a623;
|
color: var(--amber);
|
||||||
white-space: nowrap;
|
flex-shrink: 0;
|
||||||
min-width: 2.5em;
|
|
||||||
text-align: right;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile-sync-eta {
|
.tile-sync-eta {
|
||||||
font-size: 0.68rem;
|
margin-top: 6px;
|
||||||
color: var(--text-dim);
|
font-family: var(--mono);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
color: var(--text-3);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* ── Service detail modal sections ───────────────────────────────── */
|
|
||||||
|
|
||||||
.svc-detail-section {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
padding-bottom: 16px;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.svc-detail-section:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
margin-bottom: 0;
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svc-detail-section-title {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
color: var(--text-dim);
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svc-detail-desc {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svc-detail-status {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Service detail: Domain ──────────────────────────────────────── */
|
|
||||||
|
|
||||||
.svc-detail-domain-value {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile-domain-label--ok {
|
|
||||||
color: var(--green);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile-domain-label--warn {
|
|
||||||
color: var(--yellow);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile-domain-label--error {
|
|
||||||
color: var(--red);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Service detail: Port table ──────────────────────────────────── */
|
|
||||||
|
|
||||||
.svc-detail-port-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svc-detail-port-table th {
|
|
||||||
text-align: left;
|
|
||||||
color: var(--text-dim);
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.svc-detail-port-table td {
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-bottom: 1px solid rgba(30, 45, 39, 0.6);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.svc-detail-port-table tr:last-child td {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svc-detail-port-table-port {
|
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--accent-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.svc-detail-port-table-proto {
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.svc-detail-port-table-desc {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.svc-detail-port-table-status {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.port-status-listening { color: var(--green); }
|
|
||||||
.port-status-open { color: var(--yellow); }
|
|
||||||
.port-status-closed { color: var(--red); }
|
|
||||||
.port-status-unknown { color: var(--text-dim); }
|
|
||||||
|
|
||||||
/* ── Service detail: Troubleshoot box ────────────────────────────── */
|
|
||||||
|
|
||||||
.svc-detail-troubleshoot {
|
|
||||||
margin-top: 12px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
background-color: rgba(229, 165, 10, 0.08);
|
|
||||||
border: 1px solid rgba(229, 165, 10, 0.3);
|
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svc-detail-troubleshoot strong {
|
|
||||||
color: var(--yellow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.svc-detail-troubleshoot ol {
|
|
||||||
margin-top: 8px;
|
|
||||||
padding-left: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svc-detail-troubleshoot li {
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svc-detail-troubleshoot code {
|
|
||||||
background-color: rgba(94, 173, 138, 0.10);
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--accent-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.svc-detail-troubleshoot a {
|
|
||||||
color: var(--accent-color);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svc-detail-troubleshoot a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Service detail: Domain configure button ─────────────────────── */
|
|
||||||
|
|
||||||
.svc-detail-domain-btn {
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Service detail: Addon feature toggle ────────────────────────── */
|
|
||||||
|
|
||||||
.svc-detail-addon-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svc-detail-addon-status {
|
|
||||||
font-size: 0.88rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.addon-status--on {
|
|
||||||
color: var(--green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.addon-status--off {
|
|
||||||
color: var(--text-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.svc-detail-option-card {
|
|
||||||
padding: 16px;
|
|
||||||
background: rgba(94, 173, 138, 0.06);
|
|
||||||
border: 1px solid rgba(94, 173, 138, 0.24);
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svc-detail-option-list {
|
|
||||||
margin: 10px 0 0;
|
|
||||||
padding-left: 20px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.84rem;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svc-detail-option-list li {
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svc-detail-option-privacy {
|
|
||||||
margin-top: 12px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-left: 3px solid var(--accent-color);
|
|
||||||
background: rgba(94, 173, 138, 0.08);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svc-detail-option-privacy strong {
|
|
||||||
color: var(--accent-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.svc-detail-related-feature-btn:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-conflict-warning {
|
|
||||||
margin-top: 8px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
background-color: rgba(229, 165, 10, 0.1);
|
|
||||||
border: 1px solid rgba(229, 165, 10, 0.3);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--yellow);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-warning {
|
|
||||||
background-color: #d97706;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-warning:hover:not(:disabled) {
|
|
||||||
background-color: #b45309;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svc-detail-restart-section {
|
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
padding-top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svc-detail-restart-btn {
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svc-detail-restart-result {
|
|
||||||
margin-top: 12px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.88rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svc-detail-restart-result.success {
|
|
||||||
background-color: rgba(109, 191, 139, 0.12);
|
|
||||||
border: 1px solid var(--green);
|
|
||||||
color: var(--green);
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svc-detail-restart-result.error {
|
|
||||||
background-color: rgba(239, 68, 68, 0.12);
|
|
||||||
border: 1px solid #ef4444;
|
|
||||||
color: #f87171;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* ── Desktop launch buttons ──────────────────────────────────────── */
|
|
||||||
|
|
||||||
.svc-detail-launch-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svc-detail-launch-btn {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
padding: 8px 18px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,13 +23,19 @@ const SUPPORT_TIMER_INTERVAL = 1000;
|
|||||||
|
|
||||||
const CATEGORY_ORDER = [
|
const CATEGORY_ORDER = [
|
||||||
"infrastructure",
|
"infrastructure",
|
||||||
"bitcoin-base",
|
"bitcoin",
|
||||||
"bitcoin-apps",
|
|
||||||
"communication",
|
"communication",
|
||||||
"apps",
|
"apps",
|
||||||
"nostr",
|
"nostr",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/* The service catalog still distinguishes the Bitcoin foundation services
|
||||||
|
from the Bitcoin apps; the Hub shows them as one "Bitcoin" section. */
|
||||||
|
const CATEGORY_ALIASES = {
|
||||||
|
"bitcoin-base": "bitcoin",
|
||||||
|
"bitcoin-apps": "bitcoin",
|
||||||
|
};
|
||||||
|
|
||||||
const FEATURE_SUBCATEGORY_LABELS = {
|
const FEATURE_SUBCATEGORY_LABELS = {
|
||||||
"infrastructure": "🔧 Infrastructure",
|
"infrastructure": "🔧 Infrastructure",
|
||||||
"bitcoin": "₿ Bitcoin",
|
"bitcoin": "₿ Bitcoin",
|
||||||
|
|||||||
@@ -0,0 +1,451 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/* ── The Hub dashboard chrome ──────────────────────────────────────
|
||||||
|
Welcome dashboard (default view), category navigation, service
|
||||||
|
search, status cards, and the Systems Operational modal.
|
||||||
|
|
||||||
|
Everything lives in an IIFE so no globals leak into the other Hub
|
||||||
|
scripts; tiles.js calls window.dashboardServicesUpdated() whenever
|
||||||
|
the service list refreshes. */
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
|
||||||
|
var $nav = document.getElementById("sidebar-nav");
|
||||||
|
var $pageTitle = document.getElementById("page-title");
|
||||||
|
var $search = document.getElementById("search-input");
|
||||||
|
var $sysModal = document.getElementById("systems-modal");
|
||||||
|
var $sysBody = document.getElementById("systems-body");
|
||||||
|
var $welcome = document.getElementById("welcome-view");
|
||||||
|
var $tilesArea = document.getElementById("tiles-area");
|
||||||
|
var $wcSystems = document.getElementById("wc-systems");
|
||||||
|
var $wcMore = document.getElementById("wc-more");
|
||||||
|
var $greeting = document.getElementById("welcome-greeting");
|
||||||
|
var $welcomeRole = document.getElementById("welcome-role");
|
||||||
|
var $browseBtn = document.getElementById("welcome-browse-btn");
|
||||||
|
|
||||||
|
var _view = "dashboard"; // "dashboard" | "services"
|
||||||
|
var _cat = "all";
|
||||||
|
var _query = "";
|
||||||
|
|
||||||
|
var CAT_ICONS = {
|
||||||
|
"all": "g-grid",
|
||||||
|
"infrastructure": "g-server",
|
||||||
|
"bitcoin": "g-btc-sym",
|
||||||
|
"communication": "g-chat",
|
||||||
|
"apps": "g-dots",
|
||||||
|
"nostr": "g-antenna",
|
||||||
|
"other": "g-dots"
|
||||||
|
};
|
||||||
|
|
||||||
|
var CAT_FALLBACK_LABELS = {
|
||||||
|
"infrastructure": "Infrastructure",
|
||||||
|
"bitcoin": "Bitcoin",
|
||||||
|
"communication": "Communication",
|
||||||
|
"apps": "Personal Apps",
|
||||||
|
"nostr": "Nostr",
|
||||||
|
"other": "Other"
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Units that serve a domain — used to find a live diagnostics
|
||||||
|
checklist for the Systems Operational modal (order matters only
|
||||||
|
for which service is polled first). */
|
||||||
|
var DOMAIN_UNITS = [
|
||||||
|
"matrix-synapse.service",
|
||||||
|
"btcpayserver.service",
|
||||||
|
"vaultwarden.service",
|
||||||
|
"phpfpm-nextcloud.service",
|
||||||
|
"phpfpm-wordpress.service",
|
||||||
|
"haven-relay.service",
|
||||||
|
"livekit.service",
|
||||||
|
"albyhub.service"
|
||||||
|
];
|
||||||
|
|
||||||
|
function icon(id) {
|
||||||
|
return '<svg><use href="#' + id + '"/></svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleServices() {
|
||||||
|
var services = (typeof _servicesCache !== "undefined" && _servicesCache) ? _servicesCache : [];
|
||||||
|
return services.filter(function (s) {
|
||||||
|
return s.category !== "support" && s.type !== "support";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Views ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function setTitle(t) {
|
||||||
|
if ($pageTitle) $pageTitle.textContent = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncNav() {
|
||||||
|
if (!$nav) return;
|
||||||
|
$nav.querySelectorAll(".nav-item").forEach(function (b) {
|
||||||
|
var isActive;
|
||||||
|
if (b.dataset.cat === "__dash") isActive = (_view === "dashboard");
|
||||||
|
else isActive = (_view === "services" && b.dataset.cat === _cat);
|
||||||
|
b.classList.toggle("active", isActive);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDashboard() {
|
||||||
|
_view = "dashboard";
|
||||||
|
_cat = "all";
|
||||||
|
if ($welcome) $welcome.style.display = "";
|
||||||
|
if ($tilesArea) $tilesArea.style.display = "none";
|
||||||
|
setTitle("Dashboard");
|
||||||
|
syncNav();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showServices(cat) {
|
||||||
|
_view = "services";
|
||||||
|
if (cat) _cat = cat;
|
||||||
|
if ($welcome) $welcome.style.display = "none";
|
||||||
|
if ($tilesArea) $tilesArea.style.display = "";
|
||||||
|
setTitle(_cat === "all" ? "All services" : catLabel(_cat));
|
||||||
|
syncNav();
|
||||||
|
applyFilter();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Category navigation ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
function renderNav() {
|
||||||
|
if (!$nav) return;
|
||||||
|
var counts = {};
|
||||||
|
var order = [];
|
||||||
|
visibleServices().forEach(function (s) {
|
||||||
|
var cat = s.category || "other";
|
||||||
|
if (CATEGORY_ALIASES[cat]) cat = CATEGORY_ALIASES[cat];
|
||||||
|
if (!counts[cat]) { counts[cat] = 0; order.push(cat); }
|
||||||
|
counts[cat]++;
|
||||||
|
});
|
||||||
|
var total = visibleServices().length;
|
||||||
|
|
||||||
|
var html = '<div class="nav-label">Menu</div>';
|
||||||
|
html += '<button class="nav-item' + (_view === "dashboard" ? " active" : "") + '" data-cat="__dash" type="button">' +
|
||||||
|
icon("g-home") +
|
||||||
|
'<span class="nav-text">Dashboard</span>' +
|
||||||
|
'</button>';
|
||||||
|
html += '<div class="nav-label">Services</div>';
|
||||||
|
html += navItem("all", "All services", total);
|
||||||
|
order.forEach(function (cat) {
|
||||||
|
html += navItem(cat, catLabel(cat), counts[cat]);
|
||||||
|
});
|
||||||
|
$nav.innerHTML = html;
|
||||||
|
|
||||||
|
$nav.querySelectorAll(".nav-item").forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
if (btn.dataset.cat === "__dash") showDashboard();
|
||||||
|
else showServices(btn.dataset.cat);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function navItem(cat, label, count) {
|
||||||
|
return '<button class="nav-item' + (_view === "services" && cat === _cat ? " active" : "") + '" data-cat="' + escHtml(cat) + '" type="button">' +
|
||||||
|
icon(CAT_ICONS[cat] || "g-dots") +
|
||||||
|
'<span class="nav-text">' + escHtml(label) + '</span>' +
|
||||||
|
'<span class="nav-count">' + count + '</span>' +
|
||||||
|
'</button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function catLabel(cat) {
|
||||||
|
if (typeof _categoryLabels !== "undefined" && _categoryLabels[cat]) return _categoryLabels[cat];
|
||||||
|
return CAT_FALLBACK_LABELS[cat] || cat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Filtering (applies in the services view) ─────────────────── */
|
||||||
|
|
||||||
|
function applyFilter() {
|
||||||
|
if (_view !== "services") return;
|
||||||
|
var area = $tilesArea;
|
||||||
|
if (!area) return;
|
||||||
|
var q = _query.trim().toLowerCase();
|
||||||
|
area.querySelectorAll(".category-section").forEach(function (section) {
|
||||||
|
var catMatch = (_cat === "all") || (section.dataset.category === _cat);
|
||||||
|
var anyVisible = false;
|
||||||
|
section.querySelectorAll(".service-tile").forEach(function (tile) {
|
||||||
|
var nameEl = tile.querySelector(".tile-name");
|
||||||
|
var name = nameEl ? nameEl.textContent.toLowerCase() : "";
|
||||||
|
var match = catMatch && (!q || name.indexOf(q) !== -1);
|
||||||
|
tile.style.display = match ? "" : "none";
|
||||||
|
if (match) anyVisible = true;
|
||||||
|
});
|
||||||
|
section.style.display = (catMatch && (anyVisible || !q)) ? "" : "none";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($search) {
|
||||||
|
$search.addEventListener("input", function () {
|
||||||
|
// Searching implies browsing services — leave the welcome view.
|
||||||
|
if (_view === "dashboard" && $search.value) showServices("all");
|
||||||
|
_query = $search.value;
|
||||||
|
applyFilter();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($browseBtn) {
|
||||||
|
$browseBtn.addEventListener("click", function () { showServices("all"); });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Welcome header ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function updateWelcomeMeta() {
|
||||||
|
if ($greeting) {
|
||||||
|
var h = new Date().getHours();
|
||||||
|
var g;
|
||||||
|
if (h >= 5 && h < 12) g = "Good morning";
|
||||||
|
else if (h >= 12 && h < 17) g = "Good afternoon";
|
||||||
|
else g = "Good evening";
|
||||||
|
$greeting.textContent = g;
|
||||||
|
}
|
||||||
|
if ($welcomeRole) {
|
||||||
|
$welcomeRole.textContent = (typeof window._roleLabel !== "undefined" && window._roleLabel) ? window._roleLabel : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status cards ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function serviceCounts() {
|
||||||
|
var services = visibleServices();
|
||||||
|
var running = 0, attention = 0, off = 0;
|
||||||
|
var attentionNames = [], offNames = [];
|
||||||
|
services.forEach(function (s) {
|
||||||
|
if (!s.enabled) { off++; offNames.push(s.name); return; }
|
||||||
|
var h = s.health || s.status;
|
||||||
|
if (h === "needs_attention" || h === "failed") { attention++; attentionNames.push(s.name); }
|
||||||
|
else running++;
|
||||||
|
});
|
||||||
|
return { services: services, running: running, attention: attention, off: off, attentionNames: attentionNames, offNames: offNames };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWidgets() {
|
||||||
|
if (!$wcSystems) return;
|
||||||
|
var c = serviceCounts();
|
||||||
|
if (!c.services.length) { $wcSystems.innerHTML = ""; if ($wcMore) $wcMore.innerHTML = ""; return; }
|
||||||
|
|
||||||
|
/* Systems operational */
|
||||||
|
var sub = '<b>' + c.running + '</b> running';
|
||||||
|
if (c.attention) sub += ' · <span class="warn">' + c.attention + ' needs attention</span>';
|
||||||
|
if (c.off) sub += ' · ' + c.off + ' off';
|
||||||
|
var attentionTitle = c.attention ? "Systems need attention" : "Systems operational";
|
||||||
|
$wcSystems.innerHTML =
|
||||||
|
'<div class="widget clickable" id="w-systems" role="button" tabindex="0" title="System status and router setup">' +
|
||||||
|
'<div class="widget-chip chip-green">' + icon("g-pulse") + '</div>' +
|
||||||
|
'<div class="w-body"><h3>' + attentionTitle + '</h3><div class="sub">' + sub + '</div></div>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
var wSys = document.getElementById("w-systems");
|
||||||
|
if (wSys) {
|
||||||
|
wSys.addEventListener("click", openSystemsModal);
|
||||||
|
wSys.addEventListener("keydown", function (e) { if (e.key === "Enter") openSystemsModal(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$wcMore) return;
|
||||||
|
var more = "";
|
||||||
|
|
||||||
|
/* Bitcoin Core sync */
|
||||||
|
var btc = null;
|
||||||
|
c.services.forEach(function (s) {
|
||||||
|
if (s.sync_ibd && s.enabled) btc = s;
|
||||||
|
});
|
||||||
|
if (btc) {
|
||||||
|
var pct = Math.round((btc.sync_progress || 0) * 100);
|
||||||
|
var blocks = btc.sync_blocks ? btc.sync_blocks.toLocaleString() : "—";
|
||||||
|
var eta = (typeof _calcBtcEta === "function") ? _calcBtcEta(btc.unit + "::" + btc.name, btc.sync_progress || 0) : "";
|
||||||
|
more +=
|
||||||
|
'<div class="widget clickable" id="w-btc" role="button" tabindex="0" title="' + escHtml(btc.name) + ' details">' +
|
||||||
|
'<div class="widget-chip chip-btc"><img src="/static/icons/' + escHtml(btc.icon) + '.svg" alt="" style="width:46px;height:46px;display:block;object-fit:contain"/></div>' +
|
||||||
|
'<div class="w-body"><h3>' + escHtml(btc.name) + ' — syncing timechain</h3>' +
|
||||||
|
'<div class="w-bar"><div class="w-bar-fill" style="width:' + pct + '%"></div></div>' +
|
||||||
|
'<div class="sub">Block <b>' + blocks + '</b> · ' + pct + '% · <span class="warn">' + escHtml(eta) + '</span></div></div>' +
|
||||||
|
'</div>';
|
||||||
|
} else {
|
||||||
|
var btcDone = null;
|
||||||
|
c.services.forEach(function (s) { if (s.unit === "bitcoind.service" && s.enabled) btcDone = s; });
|
||||||
|
if (btcDone) {
|
||||||
|
var blk = btcDone.sync_blocks ? btcDone.sync_blocks.toLocaleString() : "";
|
||||||
|
more +=
|
||||||
|
'<div class="widget clickable" id="w-btc" role="button" tabindex="0" title="' + escHtml(btcDone.name) + ' details">' +
|
||||||
|
'<div class="widget-chip chip-btc"><img src="/static/icons/' + escHtml(btcDone.icon) + '.svg" alt="" style="width:46px;height:46px;display:block;object-fit:contain"/></div>' +
|
||||||
|
'<div class="w-body"><h3>' + escHtml(btcDone.name) + '</h3><div class="sub"><span class="good">Fully synced</span>' + (blk ? ' · Block <b>' + blk + '</b>' : '') + '</div></div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Updates — mirror the sidebar's read of the update state, so the card
|
||||||
|
never claims "up to date" while an update failed or needs a restart */
|
||||||
|
var upd = (typeof window._lastUpdateCheck === "object" && window._lastUpdateCheck) ? window._lastUpdateCheck : null;
|
||||||
|
var updStatus = (upd && upd.status) || "idle";
|
||||||
|
var updTitle, updSub, updChip;
|
||||||
|
if (updStatus === "failed") {
|
||||||
|
updTitle = "Update failed"; updSub = "Click to retry the update"; updChip = "chip-amber";
|
||||||
|
} else if (updStatus === "reboot_required") {
|
||||||
|
updTitle = "Restart required"; updSub = "Click to restart and finish the update"; updChip = "chip-amber";
|
||||||
|
} else if (updStatus === "running") {
|
||||||
|
updTitle = "Update in progress"; updSub = "Installing the new system generation"; updChip = "chip-amber";
|
||||||
|
} else if (upd && upd.available) {
|
||||||
|
updTitle = "Updates available"; updSub = "Click to review and update"; updChip = "chip-amber";
|
||||||
|
} else if (!upd) {
|
||||||
|
/* First check hasn't returned yet — never claim "up to date" before
|
||||||
|
a check has actually completed */
|
||||||
|
updTitle = "Checking for updates"; updSub = "Comparing with the latest Sovran_SystemsOS release"; updChip = "chip-green";
|
||||||
|
} else {
|
||||||
|
/* Updates are applied by the user, not automatically — the card must
|
||||||
|
not imply the OS updates itself */
|
||||||
|
updTitle = "System is up to date"; updSub = "Last check found no updates · Click to check again"; updChip = "chip-green";
|
||||||
|
}
|
||||||
|
more +=
|
||||||
|
'<div class="widget clickable" id="w-updates" role="button" tabindex="0" title="Check for system updates">' +
|
||||||
|
'<div class="widget-chip ' + updChip + '">' + icon("g-update") + '</div>' +
|
||||||
|
'<div class="w-body"><h3>' + updTitle + '</h3>' +
|
||||||
|
'<div class="sub">' + updSub + '</div></div>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
$wcMore.innerHTML = more;
|
||||||
|
|
||||||
|
var wBtc = document.getElementById("w-btc");
|
||||||
|
if (wBtc) {
|
||||||
|
var svc = btc || btcDone;
|
||||||
|
if (svc) {
|
||||||
|
wBtc.addEventListener("click", function () { openServiceDetailModal(svc.unit, svc.name, svc.icon); });
|
||||||
|
wBtc.addEventListener("keydown", function (e) { if (e.key === "Enter") openServiceDetailModal(svc.unit, svc.name, svc.icon); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var wUpd = document.getElementById("w-updates");
|
||||||
|
if (wUpd) {
|
||||||
|
wUpd.addEventListener("click", function () { openUpdateModal(); });
|
||||||
|
wUpd.addEventListener("keydown", function (e) { if (e.key === "Enter") openUpdateModal(); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Systems Operational modal ────────────────────────────────── */
|
||||||
|
|
||||||
|
function isNodeRole() {
|
||||||
|
return (typeof _currentRole !== "undefined" && _currentRole === "node");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasEnabledDomainService(services) {
|
||||||
|
return DOMAIN_UNITS.some(function (u) {
|
||||||
|
return services.some(function (s) { return s.unit === u && s.enabled; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function whoUsesPorts() {
|
||||||
|
if (isNodeRole()) {
|
||||||
|
return 'On this <strong>Bitcoin Node</strong> install, <strong>BTCPay Server</strong> and <strong>Lightning Wallet Connections (LNURL)</strong> are the domain services that use these ports.';
|
||||||
|
}
|
||||||
|
return 'All your domain services share ports 80 and 443 — Matrix, BTCPay Server, VaultWarden, Nextcloud, WordPress, Haven Relay, Lightning Wallet Connections, and Element Calling.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function step(n, title, sub, value) {
|
||||||
|
return '<div class="sysstep"><div class="sysnum">' + n + '</div><div class="sysstep-x">' +
|
||||||
|
'<div class="sysstep-t">' + title + (sub ? ' <span class="sysstep-sub">· ' + sub + '</span>' : '') + '</div>' +
|
||||||
|
(value ? '<div class="sysval"><span class="sysval-text">' + value + '</span></div>' : '') +
|
||||||
|
'</div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSystemsModal() {
|
||||||
|
if (!$sysModal || !$sysBody) return;
|
||||||
|
var c = serviceCounts();
|
||||||
|
|
||||||
|
var html = "";
|
||||||
|
|
||||||
|
/* System status */
|
||||||
|
html += '<div class="sysmodal-card">' +
|
||||||
|
'<div class="sysmodal-card-title">' + icon("g-pulse") + 'System Status</div>' +
|
||||||
|
step(1, "Services running", "", String(c.running)) +
|
||||||
|
step(2, "Needs attention", "", c.attention ? escHtml(c.attentionNames.join(", ")) : "None") +
|
||||||
|
step(3, "Turned off", "", c.off ? escHtml(c.offNames.join(", ")) : "None") +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
/* Router — a simple open / not-open verdict. How to open the ports is
|
||||||
|
covered during onboarding, so the modal does not repeat instructions.
|
||||||
|
When no domain service is enabled (a fresh Desktop-only install, a
|
||||||
|
Node with BTCPay/LNURL off, or everything turned off), there is
|
||||||
|
nothing to check on the router — say so instead of listing services
|
||||||
|
this machine does not have. */
|
||||||
|
if (!hasEnabledDomainService(c.services)) {
|
||||||
|
var noRouterDesc = isNodeRole()
|
||||||
|
? 'Ports 80 and 443 only need to be forwarded on your router if you turn on <strong>BTCPay Server</strong> or <strong>Lightning Wallet Connections (LNURL)</strong>. If you enable one of them, come back here to check your ports.'
|
||||||
|
: 'None of your services need ports forwarded from your router right now. If you turn on a service that uses a domain, come back here to check your ports.';
|
||||||
|
html += '<div class="sysmodal-card">' +
|
||||||
|
'<div class="sysmodal-card-title">' + icon("g-wifi") + 'Router</div>' +
|
||||||
|
'<div class="sysnote"><div class="sysnote-title">' + icon("g-check") + 'No router setup needed yet</div>' +
|
||||||
|
'<div class="sysnote-desc">' + noRouterDesc + '</div></div>' +
|
||||||
|
'</div>';
|
||||||
|
} else {
|
||||||
|
html += '<div class="sysmodal-card" id="sys-ports-card" style="display:none">' +
|
||||||
|
'<div class="sysmodal-card-title">' + icon("g-wifi") + 'Router</div>' +
|
||||||
|
'<div id="sys-ports-status"><div class="sysfineprint">Checking…</div></div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Who uses these ports (only meaningful when a domain service is
|
||||||
|
actually enabled — otherwise the router note above covers it) */
|
||||||
|
if (hasEnabledDomainService(c.services)) {
|
||||||
|
html += '<div class="sysnote" style="margin-top:14px">' +
|
||||||
|
'<div class="sysnote-title">' + icon("g-antenna") + 'Who uses these ports</div>' +
|
||||||
|
'<div class="sysnote-desc">' + whoUsesPorts() + '</div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$sysBody.innerHTML = html;
|
||||||
|
$sysModal.classList.add("open");
|
||||||
|
|
||||||
|
/* Poll the first configured domain service and reduce its diagnostics
|
||||||
|
to one verdict: the ports are open or they are not. */
|
||||||
|
var portsCard = document.getElementById("sys-ports-card");
|
||||||
|
var portsEl = document.getElementById("sys-ports-status");
|
||||||
|
var units = DOMAIN_UNITS.filter(function (u) {
|
||||||
|
return c.services.some(function (s) { return s.unit === u && s.enabled; });
|
||||||
|
});
|
||||||
|
if (!units.length || !portsCard || !portsEl) return;
|
||||||
|
portsCard.style.display = "";
|
||||||
|
|
||||||
|
apiFetch("/api/service-detail/" + encodeURIComponent(units[0]))
|
||||||
|
.then(function (data) {
|
||||||
|
var steps = (data && data.domain_check_steps) || [];
|
||||||
|
var portsStep = null;
|
||||||
|
steps.forEach(function (s) {
|
||||||
|
if (Number(s.step) === 3 || /ports?\s*80/i.test(s.label || "")) portsStep = s;
|
||||||
|
});
|
||||||
|
if (!portsStep) { portsCard.style.display = "none"; return; }
|
||||||
|
if (portsStep.status === "ok") {
|
||||||
|
portsEl.innerHTML = '<div class="svc-detail-status" style="font-size:0.92rem"><span class="status-dot active"></span>Ports 80 and 443 are open</div>';
|
||||||
|
} else {
|
||||||
|
portsEl.innerHTML = '<div class="svc-detail-status" style="font-size:0.92rem"><span class="status-dot failed"></span>Ports 80 and 443 are not open</div>';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
portsCard.style.display = "none";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSystemsModal() {
|
||||||
|
if ($sysModal) $sysModal.classList.remove("open");
|
||||||
|
}
|
||||||
|
|
||||||
|
var sysClose = document.getElementById("systems-close-btn");
|
||||||
|
if (sysClose) sysClose.addEventListener("click", closeSystemsModal);
|
||||||
|
if ($sysModal) {
|
||||||
|
$sysModal.addEventListener("click", function (e) {
|
||||||
|
if (e.target === $sysModal) closeSystemsModal();
|
||||||
|
});
|
||||||
|
$sysModal.addEventListener("keydown", function (e) {
|
||||||
|
if (e.key === "Escape") closeSystemsModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Public hook for tiles.js ────────────────────────────────── */
|
||||||
|
|
||||||
|
window.dashboardServicesUpdated = function () {
|
||||||
|
renderNav();
|
||||||
|
updateWelcomeMeta();
|
||||||
|
renderWidgets();
|
||||||
|
applyFilter();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial view
|
||||||
|
showDashboard();
|
||||||
|
updateWelcomeMeta();
|
||||||
|
|
||||||
|
})();
|
||||||
@@ -43,6 +43,11 @@ function renderDomainNeedsHtml(opts) {
|
|||||||
+ '<a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">Njal.la</a>'
|
+ '<a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">Njal.la</a>'
|
||||||
+ " and connecting your services. Just follow the steps below.</p>";
|
+ " and connecting your services. Just follow the steps below.</p>";
|
||||||
}
|
}
|
||||||
|
// Every variant above ends with a domain that points at the user's home
|
||||||
|
// connection. Say what that publishes (README: "Server + Desktop and your
|
||||||
|
// home IP address").
|
||||||
|
html += "<p>⚠️ <strong>Heads-up:</strong> your domain points at your home internet connection, "
|
||||||
|
+ "so anyone can look up your home IP address. Domain privacy does not hide it.</p>";
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,12 @@
|
|||||||
|
|
||||||
// if ($updateBtn) $updateBtn.addEventListener("click", openUpdateModal); // moved to sidebar in tiles.js
|
// if ($updateBtn) $updateBtn.addEventListener("click", openUpdateModal); // moved to sidebar in tiles.js
|
||||||
if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal);
|
if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal);
|
||||||
|
if ($btnCheckAgain) $btnCheckAgain.addEventListener("click", function() { openUpdateModal(); });
|
||||||
|
if ($updateCloseBtn) $updateCloseBtn.addEventListener("click", closeUpdateModal);
|
||||||
if ($btnReboot) $btnReboot.addEventListener("click", doReboot);
|
if ($btnReboot) $btnReboot.addEventListener("click", doReboot);
|
||||||
if ($btnSave) $btnSave.addEventListener("click", saveErrorReport);
|
if ($btnSave) $btnSave.addEventListener("click", saveErrorReport);
|
||||||
if ($btnRetryUpdate) $btnRetryUpdate.addEventListener("click", retryUpdateStatus);
|
if ($btnRetryUpdate) $btnRetryUpdate.addEventListener("click", retryUpdateStatus);
|
||||||
|
if ($btnRetryRun) $btnRetryRun.addEventListener("click", retryUpdateRun);
|
||||||
|
|
||||||
// Browser timers and requests may be suspended while an RDP session/tab is in
|
// Browser timers and requests may be suspended while an RDP session/tab is in
|
||||||
// the background. Reconcile immediately when the user returns instead of
|
// the background. Reconcile immediately when the user returns instead of
|
||||||
@@ -27,6 +30,7 @@ if ($logoutBtn) $logoutBtn.addEventListener("click", function () {
|
|||||||
|
|
||||||
// Rebuild modal
|
// Rebuild modal
|
||||||
if ($rebuildClose) $rebuildClose.addEventListener("click", closeRebuildModal);
|
if ($rebuildClose) $rebuildClose.addEventListener("click", closeRebuildModal);
|
||||||
|
if ($rebuildCloseHdr) $rebuildCloseHdr.addEventListener("click", closeRebuildModal);
|
||||||
if ($rebuildReboot) $rebuildReboot.addEventListener("click", doReboot);
|
if ($rebuildReboot) $rebuildReboot.addEventListener("click", doReboot);
|
||||||
if ($rebuildSave) $rebuildSave.addEventListener("click", saveRebuildErrorReport);
|
if ($rebuildSave) $rebuildSave.addEventListener("click", saveRebuildErrorReport);
|
||||||
if ($rebuildModal) $rebuildModal.addEventListener("click", function(e) { if (e.target === $rebuildModal) closeRebuildModal(); });
|
if ($rebuildModal) $rebuildModal.addEventListener("click", function(e) { if (e.target === $rebuildModal) closeRebuildModal(); });
|
||||||
@@ -174,9 +178,9 @@ function showSecurityBanner() {
|
|||||||
'</div>' +
|
'</div>' +
|
||||||
'<button class="security-banner-dismiss" id="security-banner-dismiss-btn" title="Dismiss">\u2715</button>';
|
'<button class="security-banner-dismiss" id="security-banner-dismiss-btn" title="Dismiss">\u2715</button>';
|
||||||
|
|
||||||
var mainContent = document.querySelector(".main-content");
|
var contentArea = document.querySelector(".content");
|
||||||
if (mainContent) {
|
if (contentArea) {
|
||||||
mainContent.insertAdjacentElement("beforebegin", banner);
|
contentArea.insertAdjacentElement("afterbegin", banner);
|
||||||
} else {
|
} else {
|
||||||
document.body.insertAdjacentElement("afterbegin", banner);
|
document.body.insertAdjacentElement("afterbegin", banner);
|
||||||
}
|
}
|
||||||
@@ -238,10 +242,11 @@ async function init() {
|
|||||||
}
|
}
|
||||||
var badge = document.getElementById("role-badge");
|
var badge = document.getElementById("role-badge");
|
||||||
if (badge && cfg.role_label) badge.textContent = cfg.role_label;
|
if (badge && cfg.role_label) badge.textContent = cfg.role_label;
|
||||||
|
window._roleLabel = cfg.role_label || "";
|
||||||
|
|
||||||
await refreshServices();
|
|
||||||
loadNetwork();
|
loadNetwork();
|
||||||
checkUpdates();
|
checkUpdates();
|
||||||
|
await refreshServices();
|
||||||
|
|
||||||
setInterval(refreshServices, POLL_INTERVAL_SERVICES);
|
setInterval(refreshServices, POLL_INTERVAL_SERVICES);
|
||||||
setInterval(checkUpdates, POLL_INTERVAL_UPDATES);
|
setInterval(checkUpdates, POLL_INTERVAL_UPDATES);
|
||||||
@@ -251,9 +256,9 @@ async function init() {
|
|||||||
}
|
}
|
||||||
loadAutolaunchToggle();
|
loadAutolaunchToggle();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
await refreshServices();
|
|
||||||
loadNetwork();
|
loadNetwork();
|
||||||
checkUpdates();
|
checkUpdates();
|
||||||
|
await refreshServices();
|
||||||
setInterval(refreshServices, POLL_INTERVAL_SERVICES);
|
setInterval(refreshServices, POLL_INTERVAL_SERVICES);
|
||||||
setInterval(checkUpdates, POLL_INTERVAL_UPDATES);
|
setInterval(checkUpdates, POLL_INTERVAL_UPDATES);
|
||||||
loadAutolaunchToggle();
|
loadAutolaunchToggle();
|
||||||
|
|||||||
@@ -97,7 +97,6 @@ function openDomainSetupModal(feat, onSaved) {
|
|||||||
nwcWarning +
|
nwcWarning +
|
||||||
renderDomainNeedsHtml({ serviceName: feat.name, hostExample: hostExample, purpose: purpose }) +
|
renderDomainNeedsHtml({ serviceName: feat.name, hostExample: hostExample, purpose: purpose }) +
|
||||||
renderNjallaStepsHtml({ hostExample: hostExample, pasteHint: "below" }) +
|
renderNjallaStepsHtml({ hostExample: hostExample, pasteHint: "below" }) +
|
||||||
'<div class="onboarding-port-warn" id="domain-router-box" style="margin-top:12px;"></div>' +
|
|
||||||
'<p style="margin-top:10px;">Enter the address for this service and paste the update command from Njal.la.</p>' +
|
'<p style="margin-top:10px;">Enter the address for this service and paste the update command from Njal.la.</p>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service address (e.g. ' + domainLabelExample + '):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="' + domainPlaceholder + '" /></div>' +
|
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service address (e.g. ' + domainLabelExample + '):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="' + domainPlaceholder + '" /></div>' +
|
||||||
@@ -144,8 +143,6 @@ function openDomainSetupModal(feat, onSaved) {
|
|||||||
|
|
||||||
$domainSetupModal.classList.add("open");
|
$domainSetupModal.classList.add("open");
|
||||||
|
|
||||||
// Fill the router port-forwarding box with this computer's LAN IP (best-effort)
|
|
||||||
renderRouterPortsBox("domain-router-box");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDomainReconfigureModal(feat, existingDomain, onSaved) {
|
function openDomainReconfigureModal(feat, existingDomain, onSaved) {
|
||||||
@@ -193,9 +190,7 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) {
|
|||||||
'<span style="display:inline-block;margin-top:4px;padding:4px 10px;background:var(--card-color);border:1px solid var(--border-color);border-radius:6px;font-family:monospace;font-size:1em;font-weight:700;">' + escHtml(externalIp) + '</span></li>' +
|
'<span style="display:inline-block;margin-top:4px;padding:4px 10px;background:var(--card-color);border:1px solid var(--border-color);border-radius:6px;font-family:monospace;font-size:1em;font-weight:700;">' + escHtml(externalIp) + '</span></li>' +
|
||||||
'<li>If the IP is wrong or the record is missing, update it</li>' +
|
'<li>If the IP is wrong or the record is missing, update it</li>' +
|
||||||
'<li>If you changed the DDNS curl command, paste the updated one below</li>' +
|
'<li>If you changed the DDNS curl command, paste the updated one below</li>' +
|
||||||
'<li>Confirm ports <strong>80</strong> and <strong>443</strong> (TCP) are still forwarded on your router to this computer — see the reminder below:</li>' +
|
|
||||||
'</ol>' +
|
'</ol>' +
|
||||||
'<div class="onboarding-port-warn" id="domain-router-box" style="margin-top:12px;"></div>' +
|
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service domain (e.g. ' + domainLabelExample + '):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="' + domainPlaceholder + '" value="' + escHtml(currentDomain) + '" /></div>' +
|
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service domain (e.g. ' + domainLabelExample + '):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="' + domainPlaceholder + '" value="' + escHtml(currentDomain) + '" /></div>' +
|
||||||
'<div class="domain-field-group"><label class="domain-field-label" for="domain-ddns-input">Njal.la Dynamic DNS Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl "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>' +
|
'<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");
|
$domainSetupModal.classList.add("open");
|
||||||
|
|
||||||
// Fill the router port-forwarding box with this computer's LAN IP (best-effort)
|
|
||||||
renderRouterPortsBox("domain-router-box");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeDomainSetupModal() {
|
function closeDomainSetupModal() {
|
||||||
@@ -635,16 +628,16 @@ async function loadAutolaunchToggle() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderAutolaunchToggle(enabled) {
|
function renderAutolaunchToggle(enabled) {
|
||||||
// Remove existing section if any
|
// The preference lives in the Systems Operational modal (moved from the sidebar)
|
||||||
var old = $sidebarFeatures.querySelector(".autolaunch-section");
|
var slot = document.getElementById("autolaunch-slot");
|
||||||
|
if (!slot) return;
|
||||||
|
var old = slot.querySelector(".autolaunch-section");
|
||||||
if (old) old.parentNode.removeChild(old);
|
if (old) old.parentNode.removeChild(old);
|
||||||
|
|
||||||
var section = document.createElement("div");
|
var section = document.createElement("div");
|
||||||
section.className = "category-section autolaunch-section";
|
section.className = "category-section autolaunch-section";
|
||||||
|
|
||||||
section.innerHTML =
|
section.innerHTML =
|
||||||
'<div class="section-header">Preferences</div>' +
|
|
||||||
'<hr class="section-divider" />' +
|
|
||||||
'<div class="feature-card">' +
|
'<div class="feature-card">' +
|
||||||
'<div class="feature-card-top">' +
|
'<div class="feature-card-top">' +
|
||||||
'<div class="feature-card-info">' +
|
'<div class="feature-card-info">' +
|
||||||
@@ -658,7 +651,7 @@ function renderAutolaunchToggle(enabled) {
|
|||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
|
|
||||||
$sidebarFeatures.appendChild(section);
|
slot.appendChild(section);
|
||||||
|
|
||||||
var input = document.getElementById("autolaunch-toggle-input");
|
var input = document.getElementById("autolaunch-toggle-input");
|
||||||
var label = document.getElementById("autolaunch-toggle-label");
|
var label = document.getElementById("autolaunch-toggle-label");
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ function renderPortForwardGuideHtml(ports, opts) {
|
|||||||
var noteClass = opts.noteClass || "port-req-hint";
|
var noteClass = opts.noteClass || "port-req-hint";
|
||||||
var ipHtml = opts.internalIp
|
var ipHtml = opts.internalIp
|
||||||
? '<code class="port-req-internal-ip">' + escHtml(opts.internalIp) + '</code>'
|
? '<code class="port-req-internal-ip">' + escHtml(opts.internalIp) + '</code>'
|
||||||
: 'this computer’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) {
|
var rows = (ports || []).map(function(p) {
|
||||||
return '<tr>' +
|
return '<tr>' +
|
||||||
@@ -78,26 +78,17 @@ function renderPortForwardGuideHtml(ports, opts) {
|
|||||||
}).join("");
|
}).join("");
|
||||||
|
|
||||||
var forWhat = opts.serviceName
|
var forWhat = opts.serviceName
|
||||||
? 'For <strong>' + escHtml(opts.serviceName) + '</strong> to be reachable from outside your home network, open'
|
? 'To make <strong>' + escHtml(opts.serviceName) + '</strong> reachable from outside your home, forward these ports to ' + ipHtml + ':'
|
||||||
: 'Open';
|
: 'Forward these ports to ' + ipHtml + ':';
|
||||||
|
|
||||||
return '<p class="' + introClass + '">' +
|
return '<p class="' + introClass + '">' + forWhat + '</p>' +
|
||||||
forWhat + ' the ports below in your router’s <strong>port forwarding</strong> settings ' +
|
'<p class="port-req-steps" style="margin-top:6px;margin-bottom:10px;font-size:0.92em;color:#555;">' +
|
||||||
'and point them at ' + ipHtml + '.' +
|
'Set the internal and external port to the <strong>same number</strong>. Match <strong>TCP</strong> or <strong>UDP</strong> exactly. For ranges like <strong>40000-40099</strong>, use your router’s range fields (start 40000, end 40099).' +
|
||||||
'</p>' +
|
'</p>' +
|
||||||
'<ul class="port-req-steps">' +
|
|
||||||
'<li>Set the <strong>internal (private) port</strong> and the <strong>external (public) port</strong> to the <strong>same number</strong>.</li>' +
|
|
||||||
'<li>Match the <strong>protocol</strong> exactly — a rule set to TCP will not pass UDP traffic. Where the table says <strong>TCP + UDP</strong>, create both rules (or pick “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 + '">' +
|
'<table class="' + tableClass + '">' +
|
||||||
'<thead><tr><th>Port(s)</th><th>Protocol</th><th>Used for</th></tr></thead>' +
|
'<thead><tr><th>Port(s)</th><th>Protocol</th><th>Used for</th></tr></thead>' +
|
||||||
'<tbody>' + rows + '</tbody>' +
|
'<tbody>' + rows + '</tbody>' +
|
||||||
'</table>' +
|
'</table>';
|
||||||
'<p class="' + noteClass + '">' +
|
|
||||||
'📱 <strong>How to confirm it worked:</strong> forwarding happens on your router, so it can only be verified from outside your network. ' +
|
|
||||||
'Turn Wi-Fi off on your phone and open the service over mobile data — if it loads, your ports are open.' +
|
|
||||||
'</p>';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDuration(seconds) {
|
function formatDuration(seconds) {
|
||||||
|
|||||||
@@ -2,6 +2,35 @@
|
|||||||
|
|
||||||
// ── Rebuild modal ─────────────────────────────────────────────────
|
// ── Rebuild modal ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Status line + header pill for the rebuild dialog (same presentation
|
||||||
|
// contract as the update dialog's _setUpdateStatus).
|
||||||
|
function _setRebuildStatus(text) {
|
||||||
|
if ($rebuildStatus) $rebuildStatus.textContent = text;
|
||||||
|
if ($rebuildPill) {
|
||||||
|
var cls = "upd-pill";
|
||||||
|
var html;
|
||||||
|
if (text.charAt(0) === "✓") {
|
||||||
|
if (text.indexOf("restart required") !== -1) {
|
||||||
|
cls += " st-needs-attention"; html = '<span class="status-dot needs-attention pulse"></span>Restart required';
|
||||||
|
} else {
|
||||||
|
cls += " st-active"; html = '<span class="status-dot active"></span>Done';
|
||||||
|
}
|
||||||
|
} else if (text.charAt(0) === "✗") {
|
||||||
|
cls += " st-failed"; html = '<span class="status-dot failed"></span>Failed';
|
||||||
|
} else {
|
||||||
|
cls += " st-loading"; html = '<span class="status-dot loading pulse"></span>Applying…';
|
||||||
|
}
|
||||||
|
$rebuildPill.className = cls;
|
||||||
|
$rebuildPill.innerHTML = html;
|
||||||
|
}
|
||||||
|
if ($rebuildStatus) {
|
||||||
|
var msg = "update-status-msg";
|
||||||
|
if (text.charAt(0) === "✓") $rebuildStatus.className = (text.indexOf("restart required") !== -1) ? msg + " st-warn" : msg + " st-ok";
|
||||||
|
else if (text.charAt(0) === "✗") $rebuildStatus.className = msg + " st-err";
|
||||||
|
else $rebuildStatus.className = msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openRebuildModal() {
|
function openRebuildModal() {
|
||||||
if (!$rebuildModal) return;
|
if (!$rebuildModal) return;
|
||||||
_rebuildLog = "";
|
_rebuildLog = "";
|
||||||
@@ -13,11 +42,12 @@ function openRebuildModal() {
|
|||||||
if ($rebuildLog) { $rebuildLog.textContent = ""; $rebuildLog.style.display = "none"; }
|
if ($rebuildLog) { $rebuildLog.textContent = ""; $rebuildLog.style.display = "none"; }
|
||||||
var action = _rebuildIsEnabling ? "Enabling" : "Disabling";
|
var action = _rebuildIsEnabling ? "Enabling" : "Disabling";
|
||||||
var label = _rebuildFeatureName || "feature";
|
var label = _rebuildFeatureName || "feature";
|
||||||
if ($rebuildStatus) $rebuildStatus.textContent = action + " " + label + "…";
|
_setRebuildStatus(action + " " + label + "…");
|
||||||
if ($rebuildSpinner) $rebuildSpinner.classList.add("spinning");
|
if ($rebuildSpinner) $rebuildSpinner.classList.add("spinning");
|
||||||
if ($rebuildReboot) $rebuildReboot.style.display = "none";
|
if ($rebuildReboot) $rebuildReboot.style.display = "none";
|
||||||
if ($rebuildSave) $rebuildSave.style.display = "none";
|
if ($rebuildSave) $rebuildSave.style.display = "none";
|
||||||
if ($rebuildClose) $rebuildClose.disabled = true;
|
if ($rebuildClose) $rebuildClose.disabled = true;
|
||||||
|
if ($rebuildCloseHdr) $rebuildCloseHdr.disabled = true;
|
||||||
$rebuildModal.classList.add("open");
|
$rebuildModal.classList.add("open");
|
||||||
// Delay first poll slightly to let the rebuild service start and clear stale log
|
// Delay first poll slightly to let the rebuild service start and clear stale log
|
||||||
setTimeout(startRebuildPoll, 1500);
|
setTimeout(startRebuildPoll, 1500);
|
||||||
@@ -76,7 +106,7 @@ async function pollRebuildStatus() {
|
|||||||
window.location.reload();
|
window.location.reload();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!_rebuildServerDown) { _rebuildServerDown = true; if ($rebuildStatus) $rebuildStatus.textContent = "Applying changes…"; }
|
if (!_rebuildServerDown) { _rebuildServerDown = true; _setRebuildStatus("Applying changes…"); }
|
||||||
} finally {
|
} finally {
|
||||||
_rebuildPollInFlight = false;
|
_rebuildPollInFlight = false;
|
||||||
}
|
}
|
||||||
@@ -85,15 +115,16 @@ async function pollRebuildStatus() {
|
|||||||
function onRebuildDone(result) {
|
function onRebuildDone(result) {
|
||||||
if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning");
|
if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning");
|
||||||
if ($rebuildClose) $rebuildClose.disabled = false;
|
if ($rebuildClose) $rebuildClose.disabled = false;
|
||||||
|
if ($rebuildCloseHdr) $rebuildCloseHdr.disabled = false;
|
||||||
if (result === true) {
|
if (result === true) {
|
||||||
if ($rebuildStatus) $rebuildStatus.textContent = "✓ Done";
|
_setRebuildStatus("✓ Done");
|
||||||
// Auto-reload the page after a short delay so tiles and toggles reflect the new state
|
// Auto-reload the page after a short delay so tiles and toggles reflect the new state
|
||||||
setTimeout(function() { window.location.reload(); }, 1200);
|
setTimeout(function() { window.location.reload(); }, 1200);
|
||||||
} else if (result === "reboot_required") {
|
} else if (result === "reboot_required") {
|
||||||
if ($rebuildStatus) $rebuildStatus.textContent = "✓ Done — restart required";
|
_setRebuildStatus("✓ Done — restart required");
|
||||||
if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex";
|
if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex";
|
||||||
} else {
|
} else {
|
||||||
if ($rebuildStatus) $rebuildStatus.textContent = "✗ Something went wrong";
|
_setRebuildStatus("✗ Something went wrong");
|
||||||
if ($rebuildSave) $rebuildSave.style.display = "inline-flex";
|
if ($rebuildSave) $rebuildSave.style.display = "inline-flex";
|
||||||
if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex";
|
if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,12 @@
|
|||||||
function openSecurityModal() {
|
function openSecurityModal() {
|
||||||
if ($supportModal) $supportModal.classList.add("open");
|
if ($supportModal) $supportModal.classList.add("open");
|
||||||
var title = document.getElementById("support-modal-title");
|
var title = document.getElementById("support-modal-title");
|
||||||
if (title) title.textContent = "\uD83D\uDEE1 Security";
|
if (title) title.textContent = "Security";
|
||||||
|
var chip = document.getElementById("support-modal-chip");
|
||||||
|
if (chip) {
|
||||||
|
var use = chip.querySelector("use");
|
||||||
|
if (use) use.setAttribute("href", "#g-shield-check");
|
||||||
|
}
|
||||||
|
|
||||||
if ($supportBody) {
|
if ($supportBody) {
|
||||||
$supportBody.innerHTML =
|
$supportBody.innerHTML =
|
||||||
|
|||||||
@@ -696,6 +696,17 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
: (data.health || data.status);
|
: (data.health || data.status);
|
||||||
var sc = statusClass(effectiveHealth);
|
var sc = statusClass(effectiveHealth);
|
||||||
var st = statusText(effectiveHealth, effectiveEnabled);
|
var st = statusText(effectiveHealth, effectiveEnabled);
|
||||||
|
if ($credsTitle) {
|
||||||
|
var existingPill = $credsTitle.querySelector(".creds-title-status-pill");
|
||||||
|
if (!existingPill) {
|
||||||
|
var pill = document.createElement("span");
|
||||||
|
pill.className = "creds-title-status-pill";
|
||||||
|
$credsTitle.appendChild(pill);
|
||||||
|
existingPill = pill;
|
||||||
|
}
|
||||||
|
existingPill.className = "creds-title-status-pill st-" + sc;
|
||||||
|
existingPill.textContent = st;
|
||||||
|
}
|
||||||
addSetup('<div class="svc-detail-section">' +
|
addSetup('<div class="svc-detail-section">' +
|
||||||
'<div class="svc-detail-section-title">Status</div>' +
|
'<div class="svc-detail-section-title">Status</div>' +
|
||||||
'<div class="svc-detail-status">' +
|
'<div class="svc-detail-status">' +
|
||||||
@@ -707,19 +718,27 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
|
|
||||||
// Section C: Domain diagnostics (domain services)
|
// Section C: Domain diagnostics (domain services)
|
||||||
if (data.needs_domain) {
|
if (data.needs_domain) {
|
||||||
var steps = data.domain_check_steps || [];
|
// The Hub shows only the domain-active step here; the full DNS and
|
||||||
|
// port diagnostics live in the Systems Operational modal.
|
||||||
|
var steps = (data.domain_check_steps || []).filter(function (s) {
|
||||||
|
return Number(s.step) === 1;
|
||||||
|
});
|
||||||
var stepsHtml = "";
|
var stepsHtml = "";
|
||||||
steps.forEach(function(step) {
|
steps.forEach(function(step) {
|
||||||
var iconLabel = "—";
|
|
||||||
if (step.status === "ok") iconLabel = "✅";
|
|
||||||
else if (step.status === "error") iconLabel = "❌";
|
|
||||||
else if (step.status === "warning") iconLabel = "⚠️";
|
|
||||||
else if (step.status === "skipped") iconLabel = "⏭️";
|
|
||||||
var detail = escHtml(step.detail || "").replace(/\n/g, "<br>");
|
var detail = escHtml(step.detail || "").replace(/\n/g, "<br>");
|
||||||
|
if (step.status === "ok") {
|
||||||
|
// Not a checklist anymore — just note that the domain works.
|
||||||
|
stepsHtml += '<div class="svc-detail-status"><span class="status-dot active"></span>Domain is active</div>' +
|
||||||
|
(detail ? '<div class="svc-detail-desc" style="margin-top:6px">' + detail + '</div>' : '');
|
||||||
|
} else {
|
||||||
|
var iconLabel = "❌";
|
||||||
|
if (step.status === "warning") iconLabel = "⚠️";
|
||||||
|
else if (step.status === "skipped") iconLabel = "⏭️";
|
||||||
stepsHtml += '<div class="svc-detail-troubleshoot" style="margin-bottom:10px">' +
|
stepsHtml += '<div class="svc-detail-troubleshoot" style="margin-bottom:10px">' +
|
||||||
'<strong>' + iconLabel + ' Step ' + escHtml(String(step.step)) + ': ' + escHtml(step.label || "") + '</strong>' +
|
'<strong>' + iconLabel + ' ' + escHtml(step.label || "Domain not configured") + '</strong>' +
|
||||||
(detail ? '<div style="margin-top:6px">' + detail + '</div>' : '') +
|
(detail ? '<div style="margin-top:6px">' + detail + '</div>' : '') +
|
||||||
'</div>';
|
'</div>';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
var domainActionHtml = "";
|
var domainActionHtml = "";
|
||||||
@@ -731,11 +750,22 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
addSetup('<div class="svc-detail-section">' +
|
addSetup('<div class="svc-detail-section">' +
|
||||||
'<div class="svc-detail-section-title">Domain Diagnostic Checklist</div>' +
|
'<div class="svc-detail-section-title">Domain Status</div>' +
|
||||||
stepsHtml +
|
stepsHtml +
|
||||||
domainActionHtml +
|
domainActionHtml +
|
||||||
'</div>');
|
'</div>');
|
||||||
|
|
||||||
|
// Node-only role: BTCPay Server and Lightning Wallet Connections are
|
||||||
|
// the domain services — surface the router task here. (Desktop + Server
|
||||||
|
// set this up during onboarding; Systems Operational shows it too.)
|
||||||
|
if (typeof _currentRole !== "undefined" && _currentRole === "node" &&
|
||||||
|
(unit === "btcpayserver.service" || unit === "albyhub.service")) {
|
||||||
|
addSetup('<div class="svc-detail-section">' +
|
||||||
|
'<div class="svc-detail-section-title">Ports to Forward in Your Router</div>' +
|
||||||
|
'<div class="onboarding-port-warn" id="svc-node-router-box"></div>' +
|
||||||
|
'</div>');
|
||||||
|
}
|
||||||
|
|
||||||
if (data.router_ports && data.router_ports.length > 0) {
|
if (data.router_ports && data.router_ports.length > 0) {
|
||||||
var trimmedInternalIp = data.internal_ip ? String(data.internal_ip).trim() : "";
|
var trimmedInternalIp = data.internal_ip ? String(data.internal_ip).trim() : "";
|
||||||
addSetup('<div class="svc-detail-section">' +
|
addSetup('<div class="svc-detail-section">' +
|
||||||
@@ -993,6 +1023,9 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
$credsBody.innerHTML = html;
|
$credsBody.innerHTML = html;
|
||||||
if (isNwc) _nwcWireTabs();
|
if (isNwc) _nwcWireTabs();
|
||||||
_attachCopyHandlers($credsBody);
|
_attachCopyHandlers($credsBody);
|
||||||
|
if (document.getElementById("svc-node-router-box")) {
|
||||||
|
renderRouterPortsBox("svc-node-router-box");
|
||||||
|
}
|
||||||
if (_isNwcServiceUnit(unit) && (effectiveEnabled || data.enabled)) {
|
if (_isNwcServiceUnit(unit) && (effectiveEnabled || data.enabled)) {
|
||||||
await _nwcInitWalletFlow(unit, name, icon);
|
await _nwcInitWalletFlow(unit, name, icon);
|
||||||
var nwcRtlBtn = document.getElementById("nwc-open-rtl-btn");
|
var nwcRtlBtn = document.getElementById("nwc-open-rtl-btn");
|
||||||
|
|||||||
@@ -53,7 +53,12 @@ const $modalLog = document.getElementById("modal-log");
|
|||||||
const $btnReboot = document.getElementById("btn-reboot");
|
const $btnReboot = document.getElementById("btn-reboot");
|
||||||
const $btnSave = document.getElementById("btn-save-report");
|
const $btnSave = document.getElementById("btn-save-report");
|
||||||
const $btnRetryUpdate = document.getElementById("btn-retry-update-status");
|
const $btnRetryUpdate = document.getElementById("btn-retry-update-status");
|
||||||
|
const $btnRetryRun = document.getElementById("btn-retry-update");
|
||||||
const $btnCloseModal = document.getElementById("btn-close-modal");
|
const $btnCloseModal = document.getElementById("btn-close-modal");
|
||||||
|
const $btnCheckAgain = document.getElementById("btn-check-again");
|
||||||
|
const $updateCloseBtn = document.getElementById("update-close-btn");
|
||||||
|
const $updPill = document.getElementById("upd-pill");
|
||||||
|
const $updLastChecked = document.getElementById("upd-last-checked");
|
||||||
|
|
||||||
const $rebootOverlay = document.getElementById("reboot-overlay");
|
const $rebootOverlay = document.getElementById("reboot-overlay");
|
||||||
const $rebootMainCard = document.getElementById("reboot-main-card");
|
const $rebootMainCard = document.getElementById("reboot-main-card");
|
||||||
@@ -79,6 +84,8 @@ const $rebuildLog = document.getElementById("rebuild-log");
|
|||||||
const $rebuildReboot = document.getElementById("rebuild-reboot-btn");
|
const $rebuildReboot = document.getElementById("rebuild-reboot-btn");
|
||||||
const $rebuildSave = document.getElementById("rebuild-save-report");
|
const $rebuildSave = document.getElementById("rebuild-save-report");
|
||||||
const $rebuildClose = document.getElementById("rebuild-close-btn");
|
const $rebuildClose = document.getElementById("rebuild-close-btn");
|
||||||
|
const $rebuildCloseHdr = document.getElementById("rebuild-close-hdr");
|
||||||
|
const $rebuildPill = document.getElementById("rebuild-pill");
|
||||||
|
|
||||||
// Feature Manager — domain setup modal
|
// Feature Manager — domain setup modal
|
||||||
const $domainSetupModal = document.getElementById("domain-setup-modal");
|
const $domainSetupModal = document.getElementById("domain-setup-modal");
|
||||||
|
|||||||
@@ -5,6 +5,14 @@
|
|||||||
async function openSupportModal() {
|
async function openSupportModal() {
|
||||||
if (!$supportModal) return;
|
if (!$supportModal) return;
|
||||||
$supportModal.classList.add("open");
|
$supportModal.classList.add("open");
|
||||||
|
// The dialog is shared with Security — always restore its identity
|
||||||
|
var title = document.getElementById("support-modal-title");
|
||||||
|
if (title) title.textContent = "Tech Support";
|
||||||
|
var chip = document.getElementById("support-modal-chip");
|
||||||
|
if (chip) {
|
||||||
|
var use = chip.querySelector("use");
|
||||||
|
if (use) use.setAttribute("href", "#g-lifebuoy");
|
||||||
|
}
|
||||||
$supportBody.innerHTML = '<p class="creds-loading">Checking support status…</p>';
|
$supportBody.innerHTML = '<p class="creds-loading">Checking support status…</p>';
|
||||||
try {
|
try {
|
||||||
var status = await apiFetch("/api/support/status");
|
var status = await apiFetch("/api/support/status");
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ function buildTiles(services, categoryLabels) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
var cat = svc.category || "other";
|
var cat = svc.category || "other";
|
||||||
|
if (CATEGORY_ALIASES[cat]) cat = CATEGORY_ALIASES[cat];
|
||||||
if (!grouped[cat]) grouped[cat] = [];
|
if (!grouped[cat]) grouped[cat] = [];
|
||||||
grouped[cat].push(svc);
|
grouped[cat].push(svc);
|
||||||
}
|
}
|
||||||
@@ -52,6 +53,7 @@ function buildTiles(services, categoryLabels) {
|
|||||||
if ($tilesArea.children.length === 0) {
|
if ($tilesArea.children.length === 0) {
|
||||||
$tilesArea.innerHTML = '<div class="empty-state"><p>No services configured.</p></div>';
|
$tilesArea.innerHTML = '<div class="empty-state"><p>No services configured.</p></div>';
|
||||||
}
|
}
|
||||||
|
if (typeof window.dashboardServicesUpdated === "function") window.dashboardServicesUpdated();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSidebarSupport(supportServices) {
|
function renderSidebarSupport(supportServices) {
|
||||||
@@ -62,20 +64,22 @@ function renderSidebarSupport(supportServices) {
|
|||||||
sidebarUpdateBtn.className = "sidebar-support-btn";
|
sidebarUpdateBtn.className = "sidebar-support-btn";
|
||||||
sidebarUpdateBtn.id = "sidebar-btn-update";
|
sidebarUpdateBtn.id = "sidebar-btn-update";
|
||||||
sidebarUpdateBtn.innerHTML =
|
sidebarUpdateBtn.innerHTML =
|
||||||
'<img class="sidebar-support-icon" src="/static/icons/update.svg" alt="Update" style="width:1.5rem;height:1.5rem;">' +
|
'<span class="sidebar-support-icon"><svg><use href="#g-update"/></svg></span>' +
|
||||||
'<span class="sidebar-support-text">' +
|
'<span class="sidebar-support-text">' +
|
||||||
'<span class="sidebar-support-title">Update System</span>' +
|
'<span class="sidebar-support-title">Update System</span>' +
|
||||||
'<span class="sidebar-support-hint" id="sidebar-update-hint">Check for updates</span>' +
|
'<span class="sidebar-support-hint" id="sidebar-update-hint">Check for updates</span>' +
|
||||||
'</span>';
|
'</span>';
|
||||||
sidebarUpdateBtn.addEventListener("click", function() { openUpdateModal(); });
|
sidebarUpdateBtn.addEventListener("click", function() { openUpdateModal(); });
|
||||||
$sidebarSupport.appendChild(sidebarUpdateBtn);
|
$sidebarSupport.appendChild(sidebarUpdateBtn);
|
||||||
|
// checkUpdates may already have run before the sidebar was built
|
||||||
|
applyUpdateSidebarState(window._lastUpdateCheck);
|
||||||
|
|
||||||
for (var i = 0; i < supportServices.length; i++) {
|
for (var i = 0; i < supportServices.length; i++) {
|
||||||
var svc = supportServices[i];
|
var svc = supportServices[i];
|
||||||
var btn = document.createElement("button");
|
var btn = document.createElement("button");
|
||||||
btn.className = "sidebar-support-btn";
|
btn.className = "sidebar-support-btn";
|
||||||
btn.innerHTML =
|
btn.innerHTML =
|
||||||
'<span class="sidebar-support-icon">🛟</span>' +
|
'<span class="sidebar-support-icon"><svg><use href="#g-lifebuoy"/></svg></span>' +
|
||||||
'<span class="sidebar-support-text">' +
|
'<span class="sidebar-support-text">' +
|
||||||
'<span class="sidebar-support-title">' + escHtml(svc.name || "Tech Support") + '</span>' +
|
'<span class="sidebar-support-title">' + escHtml(svc.name || "Tech Support") + '</span>' +
|
||||||
'<span class="sidebar-support-hint">Click for help</span>' +
|
'<span class="sidebar-support-hint">Click for help</span>' +
|
||||||
@@ -88,7 +92,7 @@ function renderSidebarSupport(supportServices) {
|
|||||||
var backupBtn = document.createElement("button");
|
var backupBtn = document.createElement("button");
|
||||||
backupBtn.className = "sidebar-support-btn";
|
backupBtn.className = "sidebar-support-btn";
|
||||||
backupBtn.innerHTML =
|
backupBtn.innerHTML =
|
||||||
'<span class="sidebar-support-icon">💾</span>' +
|
'<span class="sidebar-support-icon"><svg><use href="#g-box"/></svg></span>' +
|
||||||
'<span class="sidebar-support-text">' +
|
'<span class="sidebar-support-text">' +
|
||||||
'<span class="sidebar-support-title">Manual Backup</span>' +
|
'<span class="sidebar-support-title">Manual Backup</span>' +
|
||||||
'<span class="sidebar-support-hint">Back up to external drive</span>' +
|
'<span class="sidebar-support-hint">Back up to external drive</span>' +
|
||||||
@@ -100,7 +104,7 @@ function renderSidebarSupport(supportServices) {
|
|||||||
var securityBtn = document.createElement("button");
|
var securityBtn = document.createElement("button");
|
||||||
securityBtn.className = "sidebar-support-btn";
|
securityBtn.className = "sidebar-support-btn";
|
||||||
securityBtn.innerHTML =
|
securityBtn.innerHTML =
|
||||||
'<span class="sidebar-support-icon">\uD83D\uDEE1</span>' +
|
'<span class="sidebar-support-icon"><svg><use href="#g-shield-check"/></svg></span>' +
|
||||||
'<span class="sidebar-support-text">' +
|
'<span class="sidebar-support-text">' +
|
||||||
'<span class="sidebar-support-title">Security</span>' +
|
'<span class="sidebar-support-title">Security</span>' +
|
||||||
'<span class="sidebar-support-hint">Reset & verify system</span>' +
|
'<span class="sidebar-support-hint">Reset & verify system</span>' +
|
||||||
@@ -113,7 +117,7 @@ function renderSidebarSupport(supportServices) {
|
|||||||
var upgradeBtn = document.createElement("button");
|
var upgradeBtn = document.createElement("button");
|
||||||
upgradeBtn.className = "sidebar-support-btn";
|
upgradeBtn.className = "sidebar-support-btn";
|
||||||
upgradeBtn.innerHTML =
|
upgradeBtn.innerHTML =
|
||||||
'<span class="sidebar-support-icon">🚀</span>' +
|
'<span class="sidebar-support-icon"><svg><use href="#g-antenna"/></svg></span>' +
|
||||||
'<span class="sidebar-support-text">' +
|
'<span class="sidebar-support-text">' +
|
||||||
'<span class="sidebar-support-title">Upgrade to Full Server</span>' +
|
'<span class="sidebar-support-title">Upgrade to Full Server</span>' +
|
||||||
'<span class="sidebar-support-hint">Unlock all services</span>' +
|
'<span class="sidebar-support-hint">Unlock all services</span>' +
|
||||||
@@ -237,6 +241,7 @@ function updateTiles(services) {
|
|||||||
if (text) text.textContent = st;
|
if (text) text.textContent = st;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (typeof window.dashboardServicesUpdated === "function") window.dashboardServicesUpdated();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Service polling ───────────────────────────────────────────────
|
// ── Service polling ───────────────────────────────────────────────
|
||||||
@@ -248,6 +253,10 @@ async function refreshServices() {
|
|||||||
var services = await apiFetch("/api/services");
|
var services = await apiFetch("/api/services");
|
||||||
if (_firstLoad) { buildTiles(services, _categoryLabels); _firstLoad = false; }
|
if (_firstLoad) { buildTiles(services, _categoryLabels); _firstLoad = false; }
|
||||||
else { updateTiles(services); }
|
else { updateTiles(services); }
|
||||||
|
// Service data has rendered — the dashboard is ready; lift the boot
|
||||||
|
// splash (no-op once lifted). Note: dashboardServicesUpdated may also
|
||||||
|
// fire from checkUpdates before this resolves, so the lift lives here.
|
||||||
|
if (typeof window.__liftAppSplash === "function") window.__liftAppSplash();
|
||||||
} catch (err) { console.warn("Failed to fetch services:", err); }
|
} catch (err) { console.warn("Failed to fetch services:", err); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,31 +276,48 @@ async function loadNetwork() {
|
|||||||
|
|
||||||
// ── Update check ──────────────────────────────────────────────────
|
// ── Update check ──────────────────────────────────────────────────
|
||||||
|
|
||||||
async function checkUpdates() {
|
// Paint the sidebar Update button from the last known update state.
|
||||||
try {
|
// Called after each check AND when the button is (re)built, so the hint
|
||||||
var data = await apiFetch("/api/updates/check");
|
// is correct regardless of which finishes first at startup.
|
||||||
var hasUpdates = !!data.available;
|
function applyUpdateSidebarState(data) {
|
||||||
var updateStatus = data.status || "idle";
|
if (!data) return;
|
||||||
var sidebarUpdateBtn = document.getElementById("sidebar-btn-update");
|
var sidebarUpdateBtn = document.getElementById("sidebar-btn-update");
|
||||||
var sidebarUpdateHint = document.getElementById("sidebar-update-hint");
|
var sidebarUpdateHint = document.getElementById("sidebar-update-hint");
|
||||||
if (sidebarUpdateBtn) {
|
if (!sidebarUpdateBtn) return;
|
||||||
if (updateStatus === "reboot_required") {
|
var hasUpdates = !!data.available;
|
||||||
sidebarUpdateBtn.style.borderColor = "#e5a50a";
|
var updateStatus = data.status || "idle";
|
||||||
sidebarUpdateBtn.style.backgroundColor = "rgba(229, 165, 10, 0.10)";
|
if (updateStatus === "failed") {
|
||||||
|
// Last update errored and did not apply — surface it as a persistent
|
||||||
|
// red banner that re-opens the failed run with a "Retry Update" action.
|
||||||
|
sidebarUpdateBtn.style.borderColor = "#f66151";
|
||||||
|
sidebarUpdateBtn.style.backgroundColor = "rgba(246, 97, 81, 0.10)";
|
||||||
|
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Update failed — click to retry";
|
||||||
|
} else if (updateStatus === "reboot_required") {
|
||||||
|
sidebarUpdateBtn.style.borderColor = "#e9b64a";
|
||||||
|
sidebarUpdateBtn.style.backgroundColor = "rgba(233, 182, 74, 0.10)";
|
||||||
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Restart required";
|
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Restart required";
|
||||||
} else if (updateStatus === "running") {
|
} else if (updateStatus === "running") {
|
||||||
sidebarUpdateBtn.style.borderColor = "#3584e4";
|
sidebarUpdateBtn.style.borderColor = "#78aeed";
|
||||||
sidebarUpdateBtn.style.backgroundColor = "rgba(53, 132, 228, 0.10)";
|
sidebarUpdateBtn.style.backgroundColor = "rgba(120, 174, 237, 0.10)";
|
||||||
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Update in progress…";
|
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Update in progress…";
|
||||||
} else if (hasUpdates) {
|
} else if (hasUpdates) {
|
||||||
sidebarUpdateBtn.style.borderColor = "#2ec27e";
|
sidebarUpdateBtn.style.borderColor = "#3ecf8e";
|
||||||
sidebarUpdateBtn.style.backgroundColor = "rgba(46, 194, 126, 0.08)";
|
sidebarUpdateBtn.style.backgroundColor = "rgba(62, 207, 142, 0.10)";
|
||||||
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Updates available!";
|
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Updates available!";
|
||||||
} else {
|
} else {
|
||||||
sidebarUpdateBtn.style.borderColor = "";
|
sidebarUpdateBtn.style.borderColor = "";
|
||||||
sidebarUpdateBtn.style.backgroundColor = "";
|
sidebarUpdateBtn.style.backgroundColor = "";
|
||||||
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "System is up to date";
|
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "System is up to date";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function checkUpdates() {
|
||||||
|
try {
|
||||||
|
var data = await apiFetch("/api/updates/check");
|
||||||
|
window._lastUpdateCheck = data;
|
||||||
|
if (typeof markUpdateChecked === "function") markUpdateChecked();
|
||||||
|
applyUpdateSidebarState(data);
|
||||||
|
// The welcome dashboard's updates card reads this state
|
||||||
|
if (typeof window.dashboardServicesUpdated === "function") window.dashboardServicesUpdated();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,88 @@
|
|||||||
|
|
||||||
// ── Update modal ──────────────────────────────────────────────────
|
// ── Update modal ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ── Update dialog presentation (pill, status line, last-checked) ──
|
||||||
|
|
||||||
|
var _updateLastCheckTs = 0;
|
||||||
|
|
||||||
|
function markUpdateChecked() {
|
||||||
|
_updateLastCheckTs = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _updateLastCheckedText() {
|
||||||
|
if (!$updLastChecked) return;
|
||||||
|
if (!_updateLastCheckTs) { $updLastChecked.textContent = "—"; return; }
|
||||||
|
var s = Math.floor((Date.now() - _updateLastCheckTs) / 1000);
|
||||||
|
if (s < 30) { $updLastChecked.textContent = "just now"; return; }
|
||||||
|
var m = Math.floor(s / 60);
|
||||||
|
if (m < 60) { $updLastChecked.textContent = m + (m === 1 ? " minute ago" : " minutes ago"); return; }
|
||||||
|
var h = Math.floor(m / 60);
|
||||||
|
$updLastChecked.textContent = h + (h === 1 ? " hour ago" : " hours ago");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUpdatePill(state) {
|
||||||
|
if (!$updPill) return;
|
||||||
|
var cls = "upd-pill";
|
||||||
|
var html;
|
||||||
|
if (state === "checking") {
|
||||||
|
cls += " st-loading"; html = '<span class="status-dot loading pulse"></span>Checking…';
|
||||||
|
} else if (state === "uptodate") {
|
||||||
|
cls += " st-active"; html = '<span class="status-dot active"></span>Up to date';
|
||||||
|
} else if (state === "complete") {
|
||||||
|
cls += " st-active"; html = '<span class="status-dot active"></span>Update complete';
|
||||||
|
} else if (state === "reboot") {
|
||||||
|
cls += " st-needs-attention"; html = '<span class="status-dot needs-attention pulse"></span>Restart required';
|
||||||
|
} else if (state === "failed") {
|
||||||
|
cls += " st-failed"; html = '<span class="status-dot failed"></span>Update failed';
|
||||||
|
} else if (state === "unavailable") {
|
||||||
|
cls += " st-needs-attention"; html = '<span class="status-dot needs-attention pulse"></span>Status unknown';
|
||||||
|
} else {
|
||||||
|
cls += " st-loading"; html = '<span class="status-dot loading pulse"></span>Updating…';
|
||||||
|
}
|
||||||
|
$updPill.className = cls;
|
||||||
|
$updPill.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single place that reflects update state in the dialog: status message
|
||||||
|
// text + color, header pill, and the Close / Check again availability.
|
||||||
|
function _setUpdateStatus(text) {
|
||||||
|
if ($modalStatus) $modalStatus.textContent = text;
|
||||||
|
var state;
|
||||||
|
if (text.charAt(0) === "✗") state = "failed";
|
||||||
|
else if (text.indexOf("restart required") !== -1) state = "reboot";
|
||||||
|
else if (text.charAt(0) === "✓") state = (text.indexOf("already up to date") !== -1) ? "uptodate" : "complete";
|
||||||
|
else if (text.indexOf("unavailable") !== -1) state = "unavailable";
|
||||||
|
else if (text.indexOf("Checking") === 0) state = "checking";
|
||||||
|
else state = "updating";
|
||||||
|
if ($modalStatus) {
|
||||||
|
var msg = "update-status-msg";
|
||||||
|
if (state === "failed") $modalStatus.className = msg + " st-err";
|
||||||
|
else if (state === "reboot" || state === "unavailable") $modalStatus.className = msg + " st-warn";
|
||||||
|
else if (state === "uptodate" || state === "complete") $modalStatus.className = msg + " st-ok";
|
||||||
|
else $modalStatus.className = msg;
|
||||||
|
// In the up-to-date state the green console line already says it —
|
||||||
|
// the mockup shows it exactly once.
|
||||||
|
$modalStatus.style.display = (state === "uptodate") ? "none" : "";
|
||||||
|
}
|
||||||
|
setUpdatePill(state);
|
||||||
|
var interactive = (state !== "updating");
|
||||||
|
if ($updateCloseBtn) $updateCloseBtn.disabled = !interactive;
|
||||||
|
if ($btnCheckAgain) {
|
||||||
|
$btnCheckAgain.disabled = (state === "checking" || state === "updating");
|
||||||
|
$btnCheckAgain.style.display = interactive ? "inline-flex" : "none";
|
||||||
|
}
|
||||||
|
_updateLastCheckedText();
|
||||||
|
}
|
||||||
|
|
||||||
async function openUpdateModal() {
|
async function openUpdateModal() {
|
||||||
if (!$modal) return;
|
if (!$modal) return;
|
||||||
|
|
||||||
|
// Open immediately in a checking state; the status/check requests below
|
||||||
|
// fill in the real result (mockup: auto-check on open).
|
||||||
|
$modal.classList.add("open");
|
||||||
|
if ($modalLog) $modalLog.innerHTML = '<span class="dim">Checking for updates…</span>';
|
||||||
|
_setUpdateStatus("Checking for updates…");
|
||||||
|
|
||||||
// Reattach before checking for new updates. This makes a browser reload,
|
// Reattach before checking for new updates. This makes a browser reload,
|
||||||
// RDP reconnect, or suspended tab recover the authoritative systemd-backed
|
// RDP reconnect, or suspended tab recover the authoritative systemd-backed
|
||||||
// state instead of starting over or claiming the system is merely up to date.
|
// state instead of starting over or claiming the system is merely up to date.
|
||||||
@@ -14,7 +93,10 @@ async function openUpdateModal() {
|
|||||||
{ cache: "no-store" },
|
{ cache: "no-store" },
|
||||||
STATUS_POLL_FETCH_TIMEOUT
|
STATUS_POLL_FETCH_TIMEOUT
|
||||||
);
|
);
|
||||||
if (current.running || current.result === "reboot_required") {
|
if (current.running || current.result === "reboot_required" || current.result === "failed") {
|
||||||
|
// An in-progress update, a staged update awaiting reboot, or a prior
|
||||||
|
// failed update — reattach to the persisted systemd/log state instead of
|
||||||
|
// starting over or wrongly reporting "up to date".
|
||||||
showExistingUpdate(current);
|
showExistingUpdate(current);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -28,6 +110,7 @@ async function openUpdateModal() {
|
|||||||
STATUS_POLL_FETCH_TIMEOUT
|
STATUS_POLL_FETCH_TIMEOUT
|
||||||
)
|
)
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
|
markUpdateChecked();
|
||||||
if (!data.available) {
|
if (!data.available) {
|
||||||
stopUpdatePoll();
|
stopUpdatePoll();
|
||||||
_updateLog = "";
|
_updateLog = "";
|
||||||
@@ -35,12 +118,13 @@ async function openUpdateModal() {
|
|||||||
_updateVisibleLogChars = 0;
|
_updateVisibleLogChars = 0;
|
||||||
_updateFinished = true;
|
_updateFinished = true;
|
||||||
_updateStatusUnavailable = false;
|
_updateStatusUnavailable = false;
|
||||||
if ($modalLog) $modalLog.textContent = "";
|
if ($modalLog) $modalLog.innerHTML = '<span class="ok">✓ System is already up to date</span>';
|
||||||
if ($modalStatus) $modalStatus.textContent = "✓ System is already up to date";
|
_setUpdateStatus("✓ System is already up to date");
|
||||||
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
||||||
if ($btnReboot) $btnReboot.style.display = "none";
|
if ($btnReboot) $btnReboot.style.display = "none";
|
||||||
if ($btnSave) $btnSave.style.display = "none";
|
if ($btnSave) $btnSave.style.display = "none";
|
||||||
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
||||||
|
if ($btnRetryRun) $btnRetryRun.style.display = "none";
|
||||||
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
||||||
$modal.classList.add("open");
|
$modal.classList.add("open");
|
||||||
return;
|
return;
|
||||||
@@ -64,11 +148,12 @@ function prepareUpdateModal() {
|
|||||||
_updateStatusUnavailable = false;
|
_updateStatusUnavailable = false;
|
||||||
_updatePollFailures = 0;
|
_updatePollFailures = 0;
|
||||||
if ($modalLog) $modalLog.textContent = "";
|
if ($modalLog) $modalLog.textContent = "";
|
||||||
if ($modalStatus) $modalStatus.textContent = "Starting update…";
|
_setUpdateStatus("Starting update…");
|
||||||
if ($modalSpinner) $modalSpinner.classList.add("spinning");
|
if ($modalSpinner) $modalSpinner.classList.add("spinning");
|
||||||
if ($btnReboot) $btnReboot.style.display = "none";
|
if ($btnReboot) $btnReboot.style.display = "none";
|
||||||
if ($btnSave) $btnSave.style.display = "none";
|
if ($btnSave) $btnSave.style.display = "none";
|
||||||
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
||||||
|
if ($btnRetryRun) $btnRetryRun.style.display = "none";
|
||||||
if ($btnCloseModal) $btnCloseModal.disabled = true;
|
if ($btnCloseModal) $btnCloseModal.disabled = true;
|
||||||
$modal.classList.add("open");
|
$modal.classList.add("open");
|
||||||
}
|
}
|
||||||
@@ -84,7 +169,7 @@ function showExistingUpdate(data) {
|
|||||||
_updateLogOffset = Number(data.offset) || 0;
|
_updateLogOffset = Number(data.offset) || 0;
|
||||||
|
|
||||||
if (data.running) {
|
if (data.running) {
|
||||||
if ($modalStatus) $modalStatus.textContent = "Updating…";
|
_setUpdateStatus("Updating…");
|
||||||
startUpdatePoll();
|
startUpdatePoll();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -149,17 +234,19 @@ function startUpdate() {
|
|||||||
)
|
)
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
if (data.status === "no_updates") {
|
if (data.status === "no_updates") {
|
||||||
if ($modalStatus) $modalStatus.textContent = "✓ System is already up to date";
|
if ($modalLog) $modalLog.innerHTML = '<span class="ok">✓ System is already up to date</span>';
|
||||||
|
_setUpdateStatus("✓ System is already up to date");
|
||||||
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
||||||
if ($btnReboot) $btnReboot.style.display = "none";
|
if ($btnReboot) $btnReboot.style.display = "none";
|
||||||
if ($btnSave) $btnSave.style.display = "none";
|
if ($btnSave) $btnSave.style.display = "none";
|
||||||
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
||||||
|
if ($btnRetryRun) $btnRetryRun.style.display = "none";
|
||||||
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
||||||
_updateFinished = true;
|
_updateFinished = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (data.status === "already_running") appendLog("[Update already in progress, attaching…]\n\n");
|
if (data.status === "already_running") appendLog("[Update already in progress, attaching…]\n\n");
|
||||||
if ($modalStatus) $modalStatus.textContent = "Updating…";
|
_setUpdateStatus("Updating…");
|
||||||
startUpdatePoll();
|
startUpdatePoll();
|
||||||
})
|
})
|
||||||
.catch(function(err) {
|
.catch(function(err) {
|
||||||
@@ -229,7 +316,7 @@ async function pollUpdateStatus() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
appendLog("[Update status reconnected]\n");
|
appendLog("[Update status reconnected]\n");
|
||||||
if ($modalStatus) $modalStatus.textContent = "Updating…";
|
_setUpdateStatus("Updating…");
|
||||||
}
|
}
|
||||||
if (data.log) appendLog(data.log);
|
if (data.log) appendLog(data.log);
|
||||||
_updateLogOffset = data.offset;
|
_updateLogOffset = data.offset;
|
||||||
@@ -252,7 +339,7 @@ async function pollUpdateStatus() {
|
|||||||
if (!_serverWasDown) {
|
if (!_serverWasDown) {
|
||||||
_serverWasDown = true;
|
_serverWasDown = true;
|
||||||
appendLog("\n[Update status connection interrupted — retrying…]\n");
|
appendLog("\n[Update status connection interrupted — retrying…]\n");
|
||||||
if ($modalStatus) $modalStatus.textContent = "Reconnecting to update…";
|
_setUpdateStatus("Reconnecting to update…");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
_updatePollInFlight = false;
|
_updatePollInFlight = false;
|
||||||
@@ -264,7 +351,7 @@ function showUpdateStatusUnavailable() {
|
|||||||
_updateStatusUnavailable = true;
|
_updateStatusUnavailable = true;
|
||||||
stopUpdatePoll();
|
stopUpdatePoll();
|
||||||
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
||||||
if ($modalStatus) $modalStatus.textContent = "Update status unavailable — update may still be running";
|
_setUpdateStatus("Update status unavailable — update may still be running");
|
||||||
appendLog("\n[The Hub could not confirm update status. The background update was not stopped. Select Retry Status after reconnecting.]\n");
|
appendLog("\n[The Hub could not confirm update status. The background update was not stopped. Select Retry Status after reconnecting.]\n");
|
||||||
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "inline-flex";
|
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "inline-flex";
|
||||||
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
||||||
@@ -277,12 +364,22 @@ function retryUpdateStatus() {
|
|||||||
_updatePollFailures = 0;
|
_updatePollFailures = 0;
|
||||||
_serverWasDown = true;
|
_serverWasDown = true;
|
||||||
if ($modalSpinner) $modalSpinner.classList.add("spinning");
|
if ($modalSpinner) $modalSpinner.classList.add("spinning");
|
||||||
if ($modalStatus) $modalStatus.textContent = "Reconnecting to update…";
|
_setUpdateStatus("Reconnecting to update…");
|
||||||
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
||||||
if ($btnCloseModal) $btnCloseModal.disabled = true;
|
if ($btnCloseModal) $btnCloseModal.disabled = true;
|
||||||
startUpdatePoll();
|
startUpdatePoll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-run a failed (or never-applied) update from scratch. The backend always
|
||||||
|
// allows this after a FAILED attempt even though flake.lock may already be
|
||||||
|
// advanced (the previous build never staged a bootable generation).
|
||||||
|
function retryUpdateRun() {
|
||||||
|
if ($btnRetryRun) $btnRetryRun.style.display = "none";
|
||||||
|
if ($btnSave) $btnSave.style.display = "none";
|
||||||
|
if ($btnReboot) $btnReboot.style.display = "none";
|
||||||
|
_doOpenUpdateModal();
|
||||||
|
}
|
||||||
|
|
||||||
function resumeUpdateStatusAfterInterruption() {
|
function resumeUpdateStatusAfterInterruption() {
|
||||||
if (!$modal || !$modal.classList.contains("open")) return;
|
if (!$modal || !$modal.classList.contains("open")) return;
|
||||||
if (_updateStatusUnavailable) {
|
if (_updateStatusUnavailable) {
|
||||||
@@ -298,15 +395,16 @@ function onUpdateDone(result) {
|
|||||||
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
||||||
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
||||||
if (result === true) {
|
if (result === true) {
|
||||||
if ($modalStatus) $modalStatus.textContent = "✓ Update complete";
|
_setUpdateStatus("✓ Update complete");
|
||||||
if ($btnReboot) $btnReboot.style.display = "inline-flex";
|
if ($btnReboot) $btnReboot.style.display = "inline-flex";
|
||||||
} else if (result === "reboot_required") {
|
} else if (result === "reboot_required") {
|
||||||
if ($modalStatus) $modalStatus.textContent = "✓ Update complete — restart required";
|
_setUpdateStatus("✓ Update complete — restart required");
|
||||||
if ($btnReboot) $btnReboot.style.display = "inline-flex";
|
if ($btnReboot) $btnReboot.style.display = "inline-flex";
|
||||||
} else {
|
} else {
|
||||||
if ($modalStatus) $modalStatus.textContent = "✗ Update failed";
|
_setUpdateStatus("✗ Update failed — your system was not changed. Run the update again or save the error report for support.");
|
||||||
|
if ($btnRetryRun) $btnRetryRun.style.display = "inline-flex";
|
||||||
if ($btnSave) $btnSave.style.display = "inline-flex";
|
if ($btnSave) $btnSave.style.display = "inline-flex";
|
||||||
if ($btnReboot) $btnReboot.style.display = "inline-flex";
|
if ($btnReboot) $btnReboot.style.display = "none";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Sovran_SystemsOS Hub</title>
|
<title>Sovran_SystemsOS — The Hub</title>
|
||||||
<link rel="stylesheet" href="/static/css/base.css?v={{ asset_version }}" />
|
<link rel="stylesheet" href="/static/css/base.css?v={{ asset_version }}" />
|
||||||
<link rel="stylesheet" href="/static/css/buttons.css?v={{ asset_version }}" />
|
<link rel="stylesheet" href="/static/css/buttons.css?v={{ asset_version }}" />
|
||||||
<link rel="stylesheet" href="/static/css/header.css?v={{ asset_version }}" />
|
<link rel="stylesheet" href="/static/css/header.css?v={{ asset_version }}" />
|
||||||
@@ -18,65 +18,324 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- Header bar -->
|
<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
|
||||||
<header class="header-bar">
|
|
||||||
<img src="/static/sovran-hub-icon.svg" alt="Sovran Hub" class="header-logo" />
|
|
||||||
|
|
||||||
<div class="title-group">
|
<!-- service glyphs -->
|
||||||
<span class="title">Sovran_SystemsOS Hub</span>
|
<!-- ═══ Real service logos — extracted verbatim from app/icons/*.svg ═══
|
||||||
|
(internal ids namespaced per icon to avoid collisions) -->
|
||||||
|
|
||||||
<!-- OS Version Badge — styled identically to the version badge in service modals -->
|
<!-- symbolic helpers (nav + modal internals) -->
|
||||||
<span class="os-version-badge" id="os-version-badge" title="Sovran_SystemsOS Version">v{{ sovran_version }}</span>
|
<!-- Bitcoin nav icon: the OFFICIAL logo silhouette (paths taken verbatim
|
||||||
|
from app/icons/bitcoin-core.svg) rendered monochrome in currentColor
|
||||||
|
so it matches the symbolic sidebar set -->
|
||||||
|
<symbol id="g-btc-sym" viewBox="-34 -34 580 580">
|
||||||
|
<path fill="currentColor" d="M317.871 7.656c-137.12-34.192-276.024 49.28-310.2 186.44-34.208 137.136 49.256 276.048 186.36 310.24 137.16 34.199 276.063-49.265 310.256-186.408 34.192-137.152-49.264-276.08-186.416-310.272m50.936 211.872c-3.688 24.936-17.512 37.008-35.864 41.24 25.2 13.12 38.024 33.239 25.809 68.12-15.16 43.319-51.176 46.976-99.072 37.912l-11.624 46.584-28.088-7 11.472-45.96a1076 1076 0 0 1-22.384-5.809l-11.512 46.177-28.056-7 11.624-46.673c-6.561-1.68-13.225-3.464-20.024-5.168l-36.552-9.111 13.943-32.152s20.696 5.504 20.416 5.096c7.952 1.969 11.48-3.216 12.872-6.672l18.368-73.64.048-.2 13.104-52.568c.344-5.968-1.712-13.496-13.088-16.336.439-.296-20.4-5.072-20.4-5.072l7.472-30 38.736 9.673-.032.144c5.824 1.448 11.824 2.824 17.937 4.216L245.423 89.2l28.072 7-11.28 45.224c7.536 1.721 15.12 3.456 22.504 5.297l11.2-44.929 28.088 7-11.504 46.145c35.464 12.215 61.401 30.527 56.304 64.591"/>
|
||||||
|
<path fill="currentColor" d="m254.647 174.6-13.983 56.08c15.855 3.951 64.735 20.071 72.656-11.656 8.248-33.096-42.817-40.472-58.673-44.424"/>
|
||||||
|
<path fill="currentColor" d="m233.608 258.984-15.425 61.832c19.04 4.729 77.769 23.584 86.448-11.296 9.072-36.376-51.984-45.784-71.023-50.536"/>
|
||||||
|
</symbol>
|
||||||
|
|
||||||
|
<symbol id="g-chat" viewBox="0 0 24 24">
|
||||||
|
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M20 5.5v7a2.5 2.5 0 0 1-2.5 2.5H10l-4 3.2c-.6.5-1 .3-1-.5V5.5A2.5 2.5 0 0 1 7.5 3h10A2.5 2.5 0 0 1 20 5.5z"/>
|
||||||
|
<circle cx="8.7" cy="9.2" r="0.5" fill="currentColor"/><circle cx="12" cy="9.2" r="0.5" fill="currentColor"/><circle cx="15.3" cy="9.2" r="0.5" fill="currentColor"/>
|
||||||
|
</g>
|
||||||
|
</symbol>
|
||||||
|
|
||||||
|
<symbol id="g-antenna" viewBox="0 0 24 24">
|
||||||
|
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round">
|
||||||
|
<path d="M5.4 9.9a9.3 9.3 0 0 1 13.2 0"/>
|
||||||
|
<path d="M8.1 12.9a5.6 5.6 0 0 1 7.8 0"/>
|
||||||
|
<circle cx="12" cy="16.6" r="1.5" fill="currentColor" stroke="none"/>
|
||||||
|
<path d="M12 16.6V21"/>
|
||||||
|
</g>
|
||||||
|
</symbol>
|
||||||
|
|
||||||
|
<symbol id="g-lock" viewBox="0 0 24 24">
|
||||||
|
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="5.5" y="10.5" width="13" height="9.5" rx="2.6"/>
|
||||||
|
<path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5"/>
|
||||||
|
<circle cx="12" cy="15" r="1.3" fill="currentColor" stroke="none"/>
|
||||||
|
</g>
|
||||||
|
</symbol>
|
||||||
|
<!-- nav + ui symbolic icons -->
|
||||||
|
<symbol id="g-home" viewBox="0 0 24 24">
|
||||||
|
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M4 11.2L12 4.5l8 6.7"/>
|
||||||
|
<path d="M6 10.5v8a1.2 1.2 0 0 0 1.2 1.2h9.6a1.2 1.2 0 0 0 1.2-1.2v-8"/>
|
||||||
|
<path d="M10 19.7v-5.4h4v5.4"/>
|
||||||
|
</g>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="g-grid" viewBox="0 0 24 24">
|
||||||
|
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round">
|
||||||
|
<rect x="3.5" y="3.5" width="7.2" height="7.2" rx="2"/><rect x="13.3" y="3.5" width="7.2" height="7.2" rx="2"/>
|
||||||
|
<rect x="3.5" y="13.3" width="7.2" height="7.2" rx="2"/><rect x="13.3" y="13.3" width="7.2" height="7.2" rx="2"/>
|
||||||
|
</g>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="g-server" viewBox="0 0 24 24">
|
||||||
|
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round">
|
||||||
|
<rect x="3.5" y="3.5" width="17" height="7.4" rx="2"/><rect x="3.5" y="13.1" width="17" height="7.4" rx="2"/>
|
||||||
|
<circle cx="7.2" cy="7.2" r="0.6" fill="currentColor"/><circle cx="7.2" cy="16.8" r="0.6" fill="currentColor"/>
|
||||||
|
</g>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="g-dots" viewBox="0 0 24 24">
|
||||||
|
<g fill="currentColor">
|
||||||
|
<circle cx="5" cy="5" r="1.7"/><circle cx="12" cy="5" r="1.7"/><circle cx="19" cy="5" r="1.7"/>
|
||||||
|
<circle cx="5" cy="12" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="19" cy="12" r="1.7"/>
|
||||||
|
<circle cx="5" cy="19" r="1.7"/><circle cx="12" cy="19" r="1.7"/><circle cx="19" cy="19" r="1.7"/>
|
||||||
|
</g>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="g-refresh" viewBox="0 0 24 24">
|
||||||
|
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||||
|
<path d="M20 12a8 8 0 1 1-2.9-6.2"/>
|
||||||
|
<path d="M20.6 2.6l-.5 4.9-4.6-1.7z" fill="currentColor" stroke="none"/>
|
||||||
|
</g>
|
||||||
|
</symbol>
|
||||||
|
<!-- Update System: down arrow into a tray (get / install updates) -->
|
||||||
|
<symbol id="g-update" viewBox="0 0 24 24">
|
||||||
|
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 4v10.5"/>
|
||||||
|
<path d="M7.8 10.3L12 14.5l4.2-4.2"/>
|
||||||
|
<path d="M4.5 16.5v2a2.5 2.5 0 0 0 2.5 2.5h10a2.5 2.5 0 0 0 2.5-2.5v-2"/>
|
||||||
|
</g>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="g-box" viewBox="0 0 24 24">
|
||||||
|
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3.5" y="4" width="17" height="4.4" rx="1.6"/>
|
||||||
|
<path d="M5.2 8.4v9a2.4 2.4 0 0 0 2.4 2.4h8.8a2.4 2.4 0 0 0 2.4-2.4v-9"/>
|
||||||
|
<path d="M10 12.5h4"/>
|
||||||
|
</g>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="g-shield-check" viewBox="0 0 24 24">
|
||||||
|
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 2.5 4.5 5.5v6c0 4.6 3.1 8 7.5 9.9 4.4-1.9 7.5-5.3 7.5-9.9v-6z"/>
|
||||||
|
<path d="M8.6 12l2.4 2.4 4.4-4.6"/>
|
||||||
|
</g>
|
||||||
|
</symbol>
|
||||||
|
<!-- Systems Operational: activity pulse (system health) — distinct from
|
||||||
|
the Security shield above -->
|
||||||
|
<symbol id="g-pulse" viewBox="0 0 24 24">
|
||||||
|
<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M3 12h4l3-7 4 14 3-7h4"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="g-lifebuoy" viewBox="0 0 24 24">
|
||||||
|
<g fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
|
||||||
|
<circle cx="12" cy="12" r="8.6"/><circle cx="12" cy="12" r="3.6"/>
|
||||||
|
<path d="M6 6l3.2 3.2M18 6l-3.2 3.2M6 18l3.2-3.2M18 18l-3.2-3.2"/>
|
||||||
|
</g>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="g-power" viewBox="0 0 24 24">
|
||||||
|
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||||
|
<path d="M7.2 6.1a7 7 0 1 0 9.6 0"/>
|
||||||
|
<path d="M12 2.8v8.4"/>
|
||||||
|
</g>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="g-logout" viewBox="0 0 24 24">
|
||||||
|
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M14 4h4.5A1.5 1.5 0 0 1 20 5.5v13a1.5 1.5 0 0 1-1.5 1.5H14"/>
|
||||||
|
<path d="M4 12h10M10.5 8.5 14 12l-3.5 3.5"/>
|
||||||
|
</g>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="g-search" viewBox="0 0 24 24">
|
||||||
|
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||||
|
<circle cx="11" cy="11" r="6.5"/>
|
||||||
|
<path d="M15.8 15.8 21 21"/>
|
||||||
|
</g>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="g-copy" viewBox="0 0 24 24">
|
||||||
|
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="9" y="9" width="11" height="11" rx="2.2"/>
|
||||||
|
<path d="M5.5 15H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v.5"/>
|
||||||
|
</g>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="g-check" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.5l5 5 10-11"/></symbol>
|
||||||
|
<symbol id="g-chev" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" d="M9.5 5.5 16 12l-6.5 6.5"/></symbol>
|
||||||
|
<symbol id="g-x" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" d="M6 6l12 12M18 6 6 18"/></symbol>
|
||||||
|
<symbol id="g-wifi" viewBox="0 0 24 24">
|
||||||
|
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round">
|
||||||
|
<path d="M4.5 10.2a11 11 0 0 1 15 0"/>
|
||||||
|
<path d="M7.3 13.3a7 7 0 0 1 9.4 0"/>
|
||||||
|
<circle cx="12" cy="17" r="1.4" fill="currentColor" stroke="none"/>
|
||||||
|
</g>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="g-key" viewBox="0 0 24 24">
|
||||||
|
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="7.5" cy="15.5" r="4"/>
|
||||||
|
<path d="M10.4 12.6 20 3M16.2 6.8l2.4 2.4M18.8 4.2l2.2 2.2"/>
|
||||||
|
</g>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="g-alert" viewBox="0 0 24 24">
|
||||||
|
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 3.6 22.2 20.4H1.8z"/>
|
||||||
|
<path d="M12 9.8v4.6"/><circle cx="12" cy="17.2" r="0.6" fill="currentColor" stroke="none"/>
|
||||||
|
</g>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- ═══ BOOT SPLASH ═══ covers the shell while the first data loads;
|
||||||
|
dashboard.js lifts it once the welcome cards are rendered -->
|
||||||
|
<div id="app-splash" role="status" aria-live="polite">
|
||||||
|
<div class="splash-card">
|
||||||
|
<div class="splash-ring">
|
||||||
|
<img src="/static/sovran-hub-icon.svg" alt="" width="64" height="64" />
|
||||||
|
<span class="splash-ring-arc" aria-hidden="true"></span>
|
||||||
|
</div>
|
||||||
|
<div class="splash-title">Starting The Hub</div>
|
||||||
|
<div class="splash-sub" id="splash-sub">Gathering your services…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var splash = document.getElementById("app-splash");
|
||||||
|
var sub = document.getElementById("splash-sub");
|
||||||
|
var lifted = false;
|
||||||
|
function lift() {
|
||||||
|
if (lifted || !splash) return;
|
||||||
|
lifted = true;
|
||||||
|
splash.classList.add("done");
|
||||||
|
setTimeout(function () {
|
||||||
|
if (splash && splash.parentNode) splash.parentNode.removeChild(splash);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
window.__liftAppSplash = lift;
|
||||||
|
// Reassure the user if the backend is slow (e.g. right after a reboot)
|
||||||
|
setTimeout(function () {
|
||||||
|
if (!lifted && sub) sub.textContent = "Still starting\u2026 this can take a moment after a reboot.";
|
||||||
|
}, 8000);
|
||||||
|
// Never trap the user behind the splash
|
||||||
|
setTimeout(lift, 25000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="app">
|
||||||
|
|
||||||
|
<!-- ═══ SIDEBAR ═══ -->
|
||||||
|
<aside class="sidebar" id="sidebar">
|
||||||
|
<div class="brand">
|
||||||
|
<img src="/static/sovran-hub-icon.svg" class="brand-logo" alt="The Hub" />
|
||||||
|
<div class="brand-text">
|
||||||
|
<div class="brand-title">The <em>Hub</em></div>
|
||||||
|
<div class="brand-sub">Sovran_SystemsOS v{{ sovran_version }}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-buttons">
|
<nav id="sidebar-nav" aria-label="Service categories"></nav>
|
||||||
<span class="role-badge" id="role-badge">Loading…</span>
|
|
||||||
|
<div class="nav-label">System</div>
|
||||||
|
<div id="sidebar-support"></div>
|
||||||
|
|
||||||
|
<!-- Feature Manager + Preferences (features.js) -->
|
||||||
|
<div id="sidebar-features"></div>
|
||||||
|
|
||||||
|
<div class="sidebar-spacer"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- ═══ MAIN ═══ -->
|
||||||
|
<div class="main">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="page-title" id="page-title">Dashboard</div>
|
||||||
|
|
||||||
|
<div class="search-box">
|
||||||
|
<svg><use href="#g-search"/></svg>
|
||||||
|
<input id="search-input" type="text" placeholder="Search services…" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="top-actions">
|
||||||
<button class="btn btn-header-reboot" id="btn-header-reboot" title="Restart the entire computer">Reboot</button>
|
<button class="btn btn-header-reboot" id="btn-header-reboot" title="Restart the entire computer">Reboot</button>
|
||||||
<button class="btn btn-logout" id="btn-logout" title="Sign out">Sign Out</button>
|
<button class="btn btn-logout" id="btn-logout" title="Sign out">Sign Out</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- IP bar -->
|
<main class="content" id="content">
|
||||||
<div class="ip-bar">
|
<!-- ═══ WELCOME DASHBOARD (default view) ═══ -->
|
||||||
<span>
|
<section class="welcome" id="welcome-view">
|
||||||
<span class="ip-label">Internal IP:</span>
|
<div class="welcome-bg" aria-hidden="true">
|
||||||
<span class="ip-value" id="ip-internal">…</span>
|
<div class="orb orb-a"></div>
|
||||||
</span>
|
<div class="orb orb-b"></div>
|
||||||
<span class="ip-separator">|</span>
|
<div class="orb orb-c"></div>
|
||||||
<span>
|
|
||||||
<span class="ip-label">External IP:</span>
|
|
||||||
<span class="ip-value" id="ip-external">…</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="welcome-inner">
|
||||||
|
<div class="welcome-greeting" id="welcome-greeting">Hello</div>
|
||||||
|
<h1 class="welcome-title">Welcome to Your <em>Sovereign</em> Digital & 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>
|
||||||
|
|
||||||
<!-- Service tiles -->
|
<!-- ═══ SERVICES GRID ═══ -->
|
||||||
<main class="main-content">
|
<div id="tiles-area" style="display:none">
|
||||||
<aside class="sidebar" id="sidebar">
|
|
||||||
<div id="sidebar-support"></div>
|
|
||||||
<div id="sidebar-features"></div>
|
|
||||||
</aside>
|
|
||||||
<div id="tiles-area">
|
|
||||||
<div class="dashboard-loading" role="status" aria-live="polite">
|
<div class="dashboard-loading" role="status" aria-live="polite">
|
||||||
<span class="dashboard-loading-spinner" aria-hidden="true"></span>
|
<span class="dashboard-loading-spinner" aria-hidden="true"></span>
|
||||||
<span>Loading service status…</span>
|
<span>Loading service status…</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ SYSTEMS OPERATIONAL MODAL (dashboard.js) ═══ -->
|
||||||
|
<div class="modal-overlay" id="systems-modal" role="dialog" aria-modal="true" aria-labelledby="systems-modal-title">
|
||||||
|
<div class="creds-dialog">
|
||||||
|
<div class="creds-header">
|
||||||
|
<span class="creds-title" id="systems-modal-title">
|
||||||
|
<span class="widget-chip chip-green" style="width:54px;height:54px;border-radius:16px"><svg style="width:27px;height:27px"><use href="#g-pulse"/></svg></span>
|
||||||
|
<span>Systems Operational</span>
|
||||||
|
</span>
|
||||||
|
<button class="creds-close-btn" id="systems-close-btn" title="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="creds-body" id="systems-body">
|
||||||
|
<p class="creds-loading">Loading…</p>
|
||||||
|
</div>
|
||||||
|
<!-- Auto-launch preference (features.js) — moved here from the sidebar -->
|
||||||
|
<div class="sysprefs" id="autolaunch-slot"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Update modal -->
|
<!-- Update modal -->
|
||||||
<div class="modal-overlay" id="update-modal" role="dialog" aria-modal="true" aria-labelledby="modal-title-text">
|
<div class="modal-overlay" id="update-modal" role="dialog" aria-modal="true" aria-labelledby="modal-title-text">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-header">
|
<div class="upd-header">
|
||||||
<span class="modal-title" id="modal-title-text">Sovran_SystemsOS Update</span>
|
<span class="widget-chip chip-sovran upd-chip"><svg class="upd-chip-svg"><use href="#g-update"/></svg></span>
|
||||||
<div class="modal-spinner" id="modal-spinner"></div>
|
<span class="upd-title-wrap">
|
||||||
<span class="modal-status" id="modal-status">Updating…</span>
|
<span class="upd-title" id="modal-title-text">Sovran_SystemsOS Update</span>
|
||||||
|
<span class="upd-sub">
|
||||||
|
<span class="ver-chip">Sovran_SystemsOS v{{ sovran_version }}</span>
|
||||||
|
<span class="upd-pill" id="upd-pill"></span>
|
||||||
|
<span class="modal-spinner" id="modal-spinner"></span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<button class="creds-close-btn" id="update-close-btn" title="Close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="modal-body-scroll">
|
||||||
|
<div class="sysmodal-card">
|
||||||
|
<div class="sysmodal-card-title"><svg><use href="#g-server"/></svg>System Details</div>
|
||||||
|
<div class="sysstep"><div class="sysnum">1</div><div class="sysstep-x"><div class="sysstep-t">Current version</div><div class="sysval"><span class="sysval-text">{{ sovran_version }}</span></div></div></div>
|
||||||
|
<div class="sysstep"><div class="sysnum">2</div><div class="sysstep-x"><div class="sysstep-t">Release channel</div><div class="sysval"><span class="sysval-text">stable</span></div></div></div>
|
||||||
|
<div class="sysstep"><div class="sysnum">3</div><div class="sysstep-x"><div class="sysstep-t">Last checked</div><div class="sysval"><span class="sysval-text" id="upd-last-checked">—</span></div></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="update-status-msg" id="modal-status">Checking for updates…</div>
|
||||||
<div class="modal-log" id="modal-log" aria-live="polite"></div>
|
<div class="modal-log" id="modal-log" aria-live="polite"></div>
|
||||||
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-save" id="btn-save-report" style="display:none">Save Error Report</button>
|
<button class="btn btn-save" id="btn-save-report" style="display:none">Save Error Report</button>
|
||||||
<button class="btn btn-save" id="btn-retry-update-status" style="display:none">Retry Status</button>
|
<button class="btn btn-save" id="btn-retry-update-status" style="display:none">Retry Status</button>
|
||||||
|
<button class="btn btn-reboot" id="btn-retry-update" style="display:none">Retry Update</button>
|
||||||
<button class="btn btn-reboot" id="btn-reboot" style="display:none">Restart Entire System</button>
|
<button class="btn btn-reboot" id="btn-reboot" style="display:none">Restart Entire System</button>
|
||||||
|
<span class="modal-footer-spacer"></span>
|
||||||
<button class="btn btn-close-modal" id="btn-close-modal" disabled>Close</button>
|
<button class="btn btn-close-modal" id="btn-close-modal" disabled>Close</button>
|
||||||
|
<button class="btn btn-primary" id="btn-check-again"><svg><use href="#g-refresh"/></svg>Check again</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,7 +357,10 @@
|
|||||||
<div class="modal-overlay" id="support-modal" role="dialog" aria-modal="true" aria-labelledby="support-modal-title">
|
<div class="modal-overlay" id="support-modal" role="dialog" aria-modal="true" aria-labelledby="support-modal-title">
|
||||||
<div class="creds-dialog">
|
<div class="creds-dialog">
|
||||||
<div class="creds-header">
|
<div class="creds-header">
|
||||||
<span class="creds-title" id="support-modal-title">Tech Support</span>
|
<span class="creds-title">
|
||||||
|
<span class="widget-chip chip-neutral" id="support-modal-chip" style="width:54px;height:54px;border-radius:16px"><svg style="width:27px;height:27px"><use href="#g-lifebuoy"/></svg></span>
|
||||||
|
<span id="support-modal-title">Tech Support</span>
|
||||||
|
</span>
|
||||||
<button class="creds-close-btn" id="support-close-btn" title="Close">✕</button>
|
<button class="creds-close-btn" id="support-close-btn" title="Close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="creds-body" id="support-body">
|
<div class="creds-body" id="support-body">
|
||||||
@@ -170,15 +432,26 @@
|
|||||||
<!-- Rebuild Modal -->
|
<!-- Rebuild Modal -->
|
||||||
<div class="modal-overlay" id="rebuild-modal" role="dialog" aria-modal="true" aria-labelledby="rebuild-modal-title">
|
<div class="modal-overlay" id="rebuild-modal" role="dialog" aria-modal="true" aria-labelledby="rebuild-modal-title">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-header">
|
<div class="upd-header">
|
||||||
<span class="modal-title" id="rebuild-modal-title">Sovran_SystemsOS Rebuild</span>
|
<span class="widget-chip chip-sovran upd-chip"><svg class="upd-chip-svg"><use href="#g-server"/></svg></span>
|
||||||
<div class="modal-spinner" id="rebuild-spinner"></div>
|
<span class="upd-title-wrap">
|
||||||
<span class="modal-status" id="rebuild-status">Rebuilding…</span>
|
<span class="upd-title" id="rebuild-modal-title">Sovran_SystemsOS Rebuild</span>
|
||||||
|
<span class="upd-sub">
|
||||||
|
<span class="ver-chip">Sovran_SystemsOS v{{ sovran_version }}</span>
|
||||||
|
<span class="upd-pill" id="rebuild-pill"></span>
|
||||||
|
<span class="modal-spinner" id="rebuild-spinner"></span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<button class="creds-close-btn" id="rebuild-close-hdr" title="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body-scroll">
|
||||||
|
<div class="update-status-msg" id="rebuild-status">Rebuilding…</div>
|
||||||
|
<div class="modal-log" id="rebuild-log" aria-live="polite" style="display:none"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-log" id="rebuild-log" aria-live="polite"></div>
|
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-save" id="rebuild-save-report" style="display:none">Save Error Report</button>
|
<button class="btn btn-save" id="rebuild-save-report" style="display:none">Save Error Report</button>
|
||||||
<button class="btn btn-reboot" id="rebuild-reboot-btn" style="display:none">Restart Entire System</button>
|
<button class="btn btn-reboot" id="rebuild-reboot-btn" style="display:none">Restart Entire System</button>
|
||||||
|
<span class="modal-footer-spacer"></span>
|
||||||
<button class="btn btn-close-modal" id="rebuild-close-btn" disabled>Close</button>
|
<button class="btn btn-close-modal" id="rebuild-close-btn" disabled>Close</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -203,6 +476,10 @@
|
|||||||
<li>To make your services available outside your home, complete one router task: forward ports <strong>80 and 443</strong> to this computer</li>
|
<li>To make your services available outside your home, complete one router task: forward ports <strong>80 and 443</strong> to this computer</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="support-desc">
|
||||||
|
⚠️ <strong>Heads-up:</strong> your domain points at your home internet connection,
|
||||||
|
so anyone can look up your home IP address. Domain privacy does not hide it.
|
||||||
|
</p>
|
||||||
<p class="support-desc">
|
<p class="support-desc">
|
||||||
The Hub guides you through every step.
|
The Hub guides you through every step.
|
||||||
</p>
|
</p>
|
||||||
@@ -243,7 +520,7 @@
|
|||||||
<div class="security-reset-password-box" id="security-reset-new-password"> </div>
|
<div class="security-reset-password-box" id="security-reset-new-password"> </div>
|
||||||
<p class="security-reset-password-warning">
|
<p class="security-reset-password-warning">
|
||||||
✍️ <strong>Write this down now.</strong><br />
|
✍️ <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>
|
</p>
|
||||||
<button class="security-reset-reboot-btn" id="security-reset-reboot-btn" disabled>
|
<button class="security-reset-reboot-btn" id="security-reset-reboot-btn" disabled>
|
||||||
I have written down my new password — Restart Entire System
|
I have written down my new password — Restart Entire System
|
||||||
@@ -255,7 +532,7 @@
|
|||||||
<div class="reboot-overlay" id="reboot-overlay">
|
<div class="reboot-overlay" id="reboot-overlay">
|
||||||
<!-- Normal restarting card -->
|
<!-- Normal restarting card -->
|
||||||
<div class="reboot-card" id="reboot-main-card">
|
<div class="reboot-card" id="reboot-main-card">
|
||||||
<div class="reboot-icon" aria-hidden="true">↻</div>
|
<div class="reboot-icon" aria-hidden="true"></div>
|
||||||
<h2 class="reboot-title">Restarting Entire System</h2>
|
<h2 class="reboot-title">Restarting Entire System</h2>
|
||||||
<p class="reboot-message">
|
<p class="reboot-message">
|
||||||
The entire computer is restarting, including the desktop and all hosted services.<br />
|
The entire computer is restarting, including the desktop and all hosted services.<br />
|
||||||
@@ -270,7 +547,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Error card (shown if restart request fails definitively) -->
|
<!-- Error card (shown if restart request fails definitively) -->
|
||||||
<div class="reboot-card" id="reboot-error-card" style="display:none">
|
<div class="reboot-card" id="reboot-error-card" style="display:none">
|
||||||
<div class="reboot-icon" aria-hidden="true">⚠</div>
|
<div class="reboot-icon" aria-hidden="true"></div>
|
||||||
<h2 class="reboot-title">Restart could not be started</h2>
|
<h2 class="reboot-title">Restart could not be started</h2>
|
||||||
<p class="reboot-message">
|
<p class="reboot-message">
|
||||||
The computer did not begin restarting. No services were intentionally stopped. Please try again.
|
The computer did not begin restarting. No services were intentionally stopped. Please try again.
|
||||||
@@ -315,6 +592,7 @@
|
|||||||
<script src="/static/js/rebuild.js?v={{ asset_version }}"></script>
|
<script src="/static/js/rebuild.js?v={{ asset_version }}"></script>
|
||||||
<script src="/static/js/features.js?v={{ asset_version }}"></script>
|
<script src="/static/js/features.js?v={{ asset_version }}"></script>
|
||||||
<script src="/static/js/security.js?v={{ asset_version }}"></script>
|
<script src="/static/js/security.js?v={{ asset_version }}"></script>
|
||||||
|
<script src="/static/js/dashboard.js?v={{ asset_version }}"></script>
|
||||||
<script src="/static/js/events.js?v={{ asset_version }}"></script>
|
<script src="/static/js/events.js?v={{ asset_version }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Sovran Hub — Login</title>
|
<title>The Hub — Login</title>
|
||||||
<link rel="stylesheet" href="/static/css/base.css?v={{ asset_version }}" />
|
<link rel="stylesheet" href="/static/css/base.css?v={{ asset_version }}" />
|
||||||
<link rel="stylesheet" href="/static/css/buttons.css?v={{ asset_version }}" />
|
<link rel="stylesheet" href="/static/css/buttons.css?v={{ asset_version }}" />
|
||||||
</head>
|
</head>
|
||||||
@@ -11,8 +11,9 @@
|
|||||||
<div class="login-wrapper">
|
<div class="login-wrapper">
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<div class="login-header">
|
<div class="login-header">
|
||||||
<img src="/static/sovran-hub-icon.svg" alt="Sovran Hub" class="login-logo" />
|
<img src="/static/sovran-hub-icon.svg" alt="The Hub" class="login-logo" />
|
||||||
<div class="login-title">Sovran Hub</div>
|
<div class="login-title">The <em>Hub</em></div>
|
||||||
|
<div class="login-sub">Sovran_SystemsOS</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="login-form" id="login-form" onsubmit="return false;">
|
<form class="login-form" id="login-form" onsubmit="return false;">
|
||||||
|
|||||||
@@ -50,10 +50,10 @@
|
|||||||
<p class="onboarding-body-text" style="text-align:center; margin-bottom:4px;">
|
<p class="onboarding-body-text" style="text-align:center; margin-bottom:4px;">
|
||||||
Your new login password is:
|
Your new login password is:
|
||||||
</p>
|
</p>
|
||||||
<div id="migration-password-value" style="font-family:monospace; font-size:1.35rem; font-weight:700; color:var(--text-primary); background:rgba(109, 191, 139, 0.10); border:1.5px solid rgba(109, 191, 139, 0.35); border-radius:8px; padding:14px 24px; letter-spacing:0.04em; text-align:center; word-break:break-all; margin-bottom:8px;">
|
<div id="migration-password-value" style="font-family:monospace; font-size:1.35rem; font-weight:700; color:var(--text-primary); background:rgba(62, 207, 142, 0.10); border:1.5px solid rgba(62, 207, 142, 0.35); border-radius:8px; padding:14px 24px; letter-spacing:0.04em; text-align:center; word-break:break-all; margin-bottom:8px;">
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div style="padding:10px 14px; background-color:rgba(229, 165, 10, 0.1); border:1px solid rgba(229, 165, 10, 0.35); border-radius:8px; font-size:0.92rem; color:var(--yellow); line-height:1.55;">
|
<div style="padding:10px 14px; background-color:rgba(233, 182, 74, 0.1); border:1px solid rgba(233, 182, 74, 0.35); border-radius:8px; font-size:0.92rem; color:var(--yellow); line-height:1.55;">
|
||||||
⚠ Write this password down! You will need it to log in next time. This is also your Sovran Hub login password.
|
⚠ Write this password down! You will need it to log in next time. This is also your Sovran Hub login password.
|
||||||
</div>
|
</div>
|
||||||
<div id="migration-password-status" class="onboarding-save-status" style="margin-top:8px;"></div>
|
<div id="migration-password-status" class="onboarding-save-status" style="margin-top:8px;"></div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"bitcoind.service": "27.1.0",
|
"bitcoind.service": "27.1.0",
|
||||||
"electrs.service": "0.10.6",
|
"electrs.service": "0.10.6",
|
||||||
"lnd.service": "0.18.0",
|
"lnd.service": "0.18.0",
|
||||||
"rtl.service": "0.15.10",
|
"rtl.service": "0.15.12",
|
||||||
"btcpayserver.service": "2.4.2",
|
"btcpayserver.service": "2.4.2",
|
||||||
"albyhub.service": "1.24.0",
|
"albyhub.service": "1.24.0",
|
||||||
"mempool.service": "3.2.1",
|
"mempool.service": "3.2.1",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 392 KiB After Width: | Height: | Size: 59 KiB |
+37
-1
@@ -147,7 +147,7 @@
|
|||||||
hunspell hunspellDicts.en_US
|
hunspell hunspellDicts.en_US
|
||||||
synadm brave-origin dua
|
synadm brave-origin dua
|
||||||
gparted pv unzip parted screen zenity
|
gparted pv unzip parted screen zenity
|
||||||
libargon2 gnome-terminal libreoffice-fresh
|
libargon2 gnome-terminal libreoffice-stable
|
||||||
dig firefox wp-cli axel
|
dig firefox wp-cli axel
|
||||||
lk-jwt-service livekit-libwebrtc livekit
|
lk-jwt-service livekit-libwebrtc livekit
|
||||||
matrix-synapse age onlyoffice-desktopeditors
|
matrix-synapse age onlyoffice-desktopeditors
|
||||||
@@ -165,6 +165,14 @@
|
|||||||
programs.fish = { enable = true; promptInit = "fastfetch"; };
|
programs.fish = { enable = true; promptInit = "fastfetch"; };
|
||||||
|
|
||||||
# ── PostgreSQL base ────────────────────────────────────────
|
# ── PostgreSQL base ────────────────────────────────────────
|
||||||
|
# Shared cluster for Nextcloud (nextclouddb) + Matrix Synapse.
|
||||||
|
# Sized for the README's Server + Desktop recommendation (32 GB RAM,
|
||||||
|
# 500 GB NVMe OS + 2 TB NVMe timechain). Postgres shares the box with
|
||||||
|
# Bitcoin Core, Electrs, LND, MariaDB, PHP-FPM and GNOME, so
|
||||||
|
# shared_buffers stays below the 25%-of-RAM dedicated-server rule.
|
||||||
|
# Fixes Nextcloud 35 Database checks (pg.cache_hit_ratio,
|
||||||
|
# pg.dead_tuples). Override in custom.nix for other hosts, e.g.:
|
||||||
|
# services.postgresql.settings.shared_buffers = lib.mkForce "512MB";
|
||||||
services.postgresql = {
|
services.postgresql = {
|
||||||
enable = true;
|
enable = true;
|
||||||
authentication = lib.mkForce ''
|
authentication = lib.mkForce ''
|
||||||
@@ -172,6 +180,34 @@
|
|||||||
host all all 127.0.0.1/32 trust
|
host all all 127.0.0.1/32 trust
|
||||||
host all all ::1/128 trust
|
host all all ::1/128 trust
|
||||||
'';
|
'';
|
||||||
|
settings = {
|
||||||
|
# Memory — fixes low buffer cache hit ratio (stock default is
|
||||||
|
# 128MB shared_buffers). effective_cache_size is only a planner
|
||||||
|
# hint, not an allocation, so it can be generous.
|
||||||
|
# NOTE: changing shared_buffers requires a Postgres restart.
|
||||||
|
shared_buffers = "2GB";
|
||||||
|
effective_cache_size = "12GB";
|
||||||
|
maintenance_work_mem = "512MB";
|
||||||
|
work_mem = "32MB";
|
||||||
|
wal_buffers = "64MB";
|
||||||
|
|
||||||
|
# Checkpoints — spread write bursts out on NVMe. Reload-only.
|
||||||
|
min_wal_size = "1GB";
|
||||||
|
max_wal_size = "4GB";
|
||||||
|
checkpoint_completion_target = 0.9;
|
||||||
|
|
||||||
|
# Autovacuum — the stock 60s naptime can't keep up with
|
||||||
|
# Nextcloud's and Synapse's write-heavy tables (filecache,
|
||||||
|
# activity, jobs, state). Reload-only.
|
||||||
|
autovacuum_naptime = "30s";
|
||||||
|
autovacuum_vacuum_scale_factor = 0.05;
|
||||||
|
autovacuum_analyze_scale_factor = 0.025;
|
||||||
|
autovacuum_max_workers = 4;
|
||||||
|
|
||||||
|
# NVMe planner assumptions (README: NVMe OS + data disks).
|
||||||
|
random_page_cost = "1.1";
|
||||||
|
effective_io_concurrency = 200;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
# ── Backups ────────────────────────────────────────────────
|
# ── Backups ────────────────────────────────────────────────
|
||||||
|
|||||||
Generated
+29
-46
@@ -5,11 +5,11 @@
|
|||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1787862680,
|
"lastModified": 1790862821,
|
||||||
"narHash": "sha256-mv+W62cI1Bjtkz9k8N8M7+7VSauv0/WmBrDYjcy5sE0=",
|
"narHash": "sha256-IcLn2hPlxsbn2PKVlFD6gsDzkVurQ7b0KPtyORUGFcs=",
|
||||||
"owner": "emmanuelrosa",
|
"owner": "emmanuelrosa",
|
||||||
"repo": "btc-clients-nix",
|
"repo": "btc-clients-nix",
|
||||||
"rev": "f00585b12e751ac738392e2cccfbe305d077e2d4",
|
"rev": "99b0442dcc15198efcb62f3c1e7561a361749a6a",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -26,11 +26,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1787559586,
|
"lastModified": 1788450739,
|
||||||
"narHash": "sha256-onL0VLf9vPllmT0H/OlURIU5r5t5WIEl7t4tVNKT0Nw=",
|
"narHash": "sha256-glZLQlzIn1fXH6PazR2iUmTo7kzzyYSshrWhLS9TqCU=",
|
||||||
"owner": "hercules-ci",
|
"owner": "hercules-ci",
|
||||||
"repo": "flake-parts",
|
"repo": "flake-parts",
|
||||||
"rev": "9d0d87172c374f89da73c1cfe6d81ae62feac1f1",
|
"rev": "31729ca8cbdb4fa927b34e5f4353e6a83f39e993",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -41,11 +41,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1785590095,
|
"lastModified": 1790861130,
|
||||||
"narHash": "sha256-CNO2szJbdLjVN/Hi1BML9MSALz1GM2fIdwnzs404QO8=",
|
"narHash": "sha256-cA8TrQntLbNO14wivJx7Gi2pZBhhBcMplgzIA3sk3TA=",
|
||||||
"owner": "NixOS",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "e568f3b19d54b08f48bfae9b12b3e124d1a28002",
|
"rev": "3d23ea05a8a3be3f078845c2753af10cef80e1a5",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -56,27 +56,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs-stable": {
|
"nixpkgs-stable": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1788297115,
|
"lastModified": 1790750587,
|
||||||
"narHash": "sha256-Z+vUNbfd2FIKkWOTkcT7RYlh3oFCnig/d2eXD1SWf2E=",
|
"narHash": "sha256-VfjaoJ1Uyb7JZTrBgE5Jf2nhjtQPmD9KOd19HJwmZwM=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "a3116115851d68b8952a2a4221cc25a84e56b532",
|
"rev": "78e9c786dc08cd4f3420c2395cd977206a9b1da2",
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "nixos",
|
|
||||||
"ref": "nixos-26.05",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs-stable_2": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1788297115,
|
|
||||||
"narHash": "sha256-Z+vUNbfd2FIKkWOTkcT7RYlh3oFCnig/d2eXD1SWf2E=",
|
|
||||||
"owner": "nixos",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"rev": "a3116115851d68b8952a2a4221cc25a84e56b532",
|
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -88,11 +72,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs_2": {
|
"nixpkgs_2": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1788179007,
|
"lastModified": 1790822859,
|
||||||
"narHash": "sha256-hn1oU2rue2SYK8dAr8+WNZWtbsz1S2W5mnHlSEuh3bo=",
|
"narHash": "sha256-69xHQhAeMAD2wDXO7T2pcOZIF9Sga2W+JkmY2a11Ops=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "34ab99075ac4f7e40cf037eef32cb1c360bb85e9",
|
"rev": "c59305bab2065cfecc4944690d9eedbb56f3a9fa",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -104,11 +88,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs_3": {
|
"nixpkgs_3": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1787631388,
|
"lastModified": 1789724158,
|
||||||
"narHash": "sha256-vMiXptXarfSdJb1Gkc+FYVOAibuBRj7qxGa8z68q1Uw=",
|
"narHash": "sha256-nlKgrm0dsVhOSopKheVBCcOpiIticufPPVLdVOI0euA=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "ac6b2166e7a9375683b8e98f860f273222337b16",
|
"rev": "0a3468a402c449992505b6a9fc5b06580141b750",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -120,11 +104,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs_4": {
|
"nixpkgs_4": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1788179007,
|
"lastModified": 1790822859,
|
||||||
"narHash": "sha256-hn1oU2rue2SYK8dAr8+WNZWtbsz1S2W5mnHlSEuh3bo=",
|
"narHash": "sha256-69xHQhAeMAD2wDXO7T2pcOZIF9Sga2W+JkmY2a11Ops=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "34ab99075ac4f7e40cf037eef32cb1c360bb85e9",
|
"rev": "c59305bab2065cfecc4944690d9eedbb56f3a9fa",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -141,11 +125,11 @@
|
|||||||
"systems": "systems"
|
"systems": "systems"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1788190018,
|
"lastModified": 1790541651,
|
||||||
"narHash": "sha256-59BAfH0txPAZrPBF4QJqwvUWppD+ICrcjA1LZAmPnrQ=",
|
"narHash": "sha256-/496IQz8qNWHHLnc3Zvr0l4uxJ34igNhJhn7ZKAefFk=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "nixvim",
|
"repo": "nixvim",
|
||||||
"rev": "41844750e55f17b1385d5b09ca7ade5f11f49506",
|
"rev": "5980a626794486abad69fca7667f9c11dfbc3bd7",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -165,15 +149,14 @@
|
|||||||
},
|
},
|
||||||
"sovran-bitcoin": {
|
"sovran-bitcoin": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs": "nixpkgs_4",
|
"nixpkgs": "nixpkgs_4"
|
||||||
"nixpkgs-stable": "nixpkgs-stable_2"
|
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1788378694,
|
"lastModified": 1790877969,
|
||||||
"narHash": "sha256-s2yvGC1IHrQq2jP4KSbV2TA9Gh1T/YkcS+9neS/WcVI=",
|
"narHash": "sha256-Ia88keP9t3B6ECVw+LP4xasPdvzw1TIWya1K+3dySu0=",
|
||||||
"owner": "naturallaw777",
|
"owner": "naturallaw777",
|
||||||
"repo": "Sovran_Bitcoin",
|
"repo": "Sovran_Bitcoin",
|
||||||
"rev": "b4678fcc712da9dd01c167f62275192183490085",
|
"rev": "b0da63bd81f1a1b1a970068b3061c6c8389db9aa",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -6,8 +6,6 @@
|
|||||||
nixvim.url = "github:nix-community/nixvim";
|
nixvim.url = "github:nix-community/nixvim";
|
||||||
btc-clients.url = "github:emmanuelrosa/btc-clients-nix";
|
btc-clients.url = "github:emmanuelrosa/btc-clients-nix";
|
||||||
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05";
|
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05";
|
||||||
|
|
||||||
# Bitcoin / Lightning stack — standalone flake, consumed as a module.
|
|
||||||
sovran-bitcoin.url = "github:naturallaw777/Sovran_Bitcoin";
|
sovran-bitcoin.url = "github:naturallaw777/Sovran_Bitcoin";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -19,24 +17,6 @@
|
|||||||
system = prev.stdenv.hostPlatform.system;
|
system = prev.stdenv.hostPlatform.system;
|
||||||
config.allowUnfree = true;
|
config.allowUnfree = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
# Pin LiveKit to 1.13.6: element-calling.nix sets
|
|
||||||
# rtc.advertise_internal_ip, which gives LAN callers a host candidate
|
|
||||||
# so calls work on Wi-Fi without the router needing NAT-hairpin. That
|
|
||||||
# flag is only honoured when node_ip is set manually from LiveKit
|
|
||||||
# v1.13.6 (mediatransportutil f234b53); nixpkgs-unstable currently
|
|
||||||
# ships 1.13.5. Remove this override once nixpkgs-unstable reaches
|
|
||||||
# >= 1.13.6.
|
|
||||||
livekit = prev.livekit.overrideAttrs (old: {
|
|
||||||
version = "1.13.6";
|
|
||||||
src = prev.fetchFromGitHub {
|
|
||||||
owner = "livekit";
|
|
||||||
repo = "livekit";
|
|
||||||
rev = "v1.13.6";
|
|
||||||
hash = "sha256-sUAx6ooeEUUqot5xuZv7xiQa3DdRFVULteTwYgFUzCI=";
|
|
||||||
};
|
|
||||||
vendorHash = "sha256-nOGSmoNuQQm/sIVI1HojsiS4GkbhA68uYMQ6X7d4a5Q=";
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
@@ -55,7 +35,6 @@
|
|||||||
{ nixpkgs.hostPlatform = "x86_64-linux"; nixpkgs.overlays = [ overlay-stable ]; }
|
{ nixpkgs.hostPlatform = "x86_64-linux"; nixpkgs.overlays = [ overlay-stable ]; }
|
||||||
./iso/common.nix
|
./iso/common.nix
|
||||||
sovran-bitcoin.nixosModules.default
|
sovran-bitcoin.nixosModules.default
|
||||||
./modules/sovran-bitcoin-integration.nix
|
|
||||||
nixvim.nixosModules.nixvim
|
nixvim.nixosModules.nixvim
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
@@ -78,14 +57,5 @@
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
checks.x86_64-linux = let
|
|
||||||
pkgs = import nixpkgs {
|
|
||||||
system = "x86_64-linux";
|
|
||||||
};
|
|
||||||
in {
|
|
||||||
# Bitcoin hardening and package checks now live in the Sovran_Bitcoin flake.
|
|
||||||
# Run them with: nix build github:naturallaw777/Sovran_Bitcoin#checks.x86_64-linux
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-4
@@ -54,9 +54,14 @@ DICEWARE_WORDS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
def generate_diceware_password():
|
def generate_diceware_password():
|
||||||
words = [secrets.choice(DICEWARE_WORDS) for _ in range(3)]
|
# 4 words from a 96 word list plus 2 digits: 96^4 x 100 = ~8.5e9, about
|
||||||
digit = secrets.randbelow(10)
|
# 33 bits. The old 3 words plus 1 digit was 96^3 x 10 = ~8.8e6, about 23
|
||||||
return "-".join(words) + f"-{digit}"
|
# 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:
|
try:
|
||||||
logfile = open(LOG, "a")
|
logfile = open(LOG, "a")
|
||||||
@@ -471,7 +476,7 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
# Role cards
|
# Role cards
|
||||||
roles = [
|
roles = [
|
||||||
("Server + Desktop",
|
("Server + Desktop",
|
||||||
"Full sovereignty: host your own websites, cloud, chat, passwords, and Bitcoin services instead of relying on Big Tech. Sovran_SystemsOS walks you through getting your domain from Njal.la and connecting everything. One router task is required: forward ports 80 and 443 to this computer.",
|
"Full sovereignty: host your own websites, cloud, chat, passwords, and Bitcoin services instead of relying on Big Tech. Sovran_SystemsOS walks you through getting your domain from Njal.la and connecting everything. One router task is required: forward ports 80 and 443 to this computer. Heads-up: this makes your home IP address public.",
|
||||||
"Server+Desktop"),
|
"Server+Desktop"),
|
||||||
("Desktop Only",
|
("Desktop Only",
|
||||||
"A beautiful, easy-to-use desktop without the background server applications.",
|
"A beautiful, easy-to-use desktop without the background server applications.",
|
||||||
|
|||||||
+50
-35
@@ -14,13 +14,54 @@ let
|
|||||||
|| config.sovran_systemsOS.features.haven
|
|| config.sovran_systemsOS.features.haven
|
||||||
|| config.sovran_systemsOS.features."nwc-wallets"
|
|| config.sovran_systemsOS.features."nwc-wallets"
|
||||||
|| config.sovran_systemsOS.features.element-calling;
|
|| 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
|
in
|
||||||
{
|
{
|
||||||
services.caddy = {
|
services.caddy = {
|
||||||
# Only enable Caddy when at least one domain-based service needs it or
|
# Caddy runs when a domain-based service needs it, when the operator has
|
||||||
# the operator has defined custom vhosts. This prevents Caddy from
|
# defined custom vhosts, or when it is the way to reach RTL and Mempool.
|
||||||
# running on Desktop Only installs that have no web services configured.
|
# Desktop Only has none of those, so Caddy stays off there.
|
||||||
enable = needsHttpsPorts || extraVhosts != "";
|
enable = caddyEnabled;
|
||||||
user = "caddy";
|
user = "caddy";
|
||||||
group = "root";
|
group = "root";
|
||||||
};
|
};
|
||||||
@@ -202,37 +243,11 @@ $LIGHTNING {
|
|||||||
EOF
|
EOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Sovran Hub (LAN access via mDNS) ────────────
|
# ── RTL and Mempool (local network) ─────────────
|
||||||
cat >> /run/caddy/Caddyfile <<EOF
|
# Only where those services run; see bitcoinUiSites above.
|
||||||
|
cat >> /run/caddy/Caddyfile <<'LAN_SITES_EOF'
|
||||||
http://sovransystemsos.local {
|
${bitcoinUiSites}
|
||||||
reverse_proxy localhost:8937
|
LAN_SITES_EOF
|
||||||
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
|
|
||||||
|
|
||||||
# ── Custom vhosts from custom.nix ──────────────
|
# ── Custom vhosts from custom.nix ──────────────
|
||||||
cat >> /run/caddy/Caddyfile <<'CUSTOM_VHOSTS_EOF'
|
cat >> /run/caddy/Caddyfile <<'CUSTOM_VHOSTS_EOF'
|
||||||
|
|||||||
+33
-89
@@ -1,43 +1,64 @@
|
|||||||
{ config, pkgs, lib, ... }:
|
{ config, pkgs, lib, ... }:
|
||||||
|
|
||||||
{
|
{
|
||||||
|
# The public-IP detector (STUN / OpenDNS / HTTPS echo) is gone: the public
|
||||||
|
# address is whatever Njal.la reports back for the DDNS update below, and
|
||||||
|
# nothing else on the system looks it up. Fail with a pointer, instead of
|
||||||
|
# silently ignoring them, if a custom.nix still sets one of its old options.
|
||||||
|
imports = map (opt:
|
||||||
|
lib.mkRemovedOptionModule [ "sovran_systemsOS" "publicIP" opt ]
|
||||||
|
"Sovran no longer looks up the public IP: the Njal.la DDNS update reports it (modules/core/njalla.nix). To force an address for Element Calling, set sovran_systemsOS.elementCalling.externalIP."
|
||||||
|
) [ "stunServer" "stunPort" "dnsResolver" "httpsEcho" "cacheTTL" ];
|
||||||
|
|
||||||
# ── Ensure njalla directory exists on every build ────────────────────────
|
# ── Ensure njalla directory exists on every build ────────────────────────
|
||||||
systemd.tmpfiles.rules = [
|
systemd.tmpfiles.rules = [
|
||||||
"d /var/lib/njalla 0750 root root -"
|
"d /var/lib/njalla 0750 root root -"
|
||||||
];
|
];
|
||||||
|
|
||||||
# ── Install the shared validation helper so the DDNS runner can import it ─
|
# ── Install the DDNS runner and the validator it shares with the Hub ─────
|
||||||
# The exact same _validate_ddns_url() function used by the Hub web application
|
# Both files come straight from the Hub's source tree and are installed side
|
||||||
# is installed here as a read-only system file. The DDNS runner imports it
|
# by side as read-only system files. The runner imports the exact same
|
||||||
# directly so the two code paths share one validator — no weaker inline copy.
|
# _validate_ddns_url() the Hub API uses — no weaker inline copy.
|
||||||
environment.etc."sovran/security_helpers.py" = {
|
environment.etc."sovran/security_helpers.py" = {
|
||||||
source = ../../app/sovran_systemsos_web/security_helpers.py;
|
source = ../../app/sovran_systemsos_web/security_helpers.py;
|
||||||
mode = "0444";
|
mode = "0444";
|
||||||
user = "root";
|
user = "root";
|
||||||
group = "root";
|
group = "root";
|
||||||
};
|
};
|
||||||
|
environment.etc."sovran/ddns-update.py" = {
|
||||||
|
source = ../../app/sovran_systemsos_web/ddns_update.py;
|
||||||
|
mode = "0444";
|
||||||
|
user = "root";
|
||||||
|
group = "root";
|
||||||
|
};
|
||||||
|
|
||||||
# ── Safe DDNS update service ─────────────────────────────────────────────
|
# ── Safe DDNS update service ─────────────────────────────────────────────
|
||||||
# Reads DDNS update URLs from the JSON store written by the Hub API and
|
# Reads DDNS update URLs from the JSON store written by the Hub API and
|
||||||
# invokes curl directly — no shell interpolation, no script execution.
|
# invokes curl directly — no shell interpolation, no script execution.
|
||||||
# Replaces the legacy root cron job that ran /var/lib/njalla/njalla.sh.
|
# Njal.la is asked to use the address the request came from ("&auto") and
|
||||||
|
# reports it back; the runner saves it to /var/lib/secrets/external-ip,
|
||||||
|
# where LiveKit and the Hub read it. See app/sovran_systemsos_web/ddns_update.py.
|
||||||
systemd.services.sovran-ddns-update = {
|
systemd.services.sovran-ddns-update = {
|
||||||
description = "Sovran Njal.la DDNS update (safe JSON-based runner)";
|
description = "Sovran Njal.la DDNS update (safe JSON-based runner)";
|
||||||
wants = [ "network-online.target" ];
|
wants = [ "network-online.target" ];
|
||||||
after = [ "network-online.target" ];
|
after = [ "network-online.target" ];
|
||||||
|
# curl is not in a NixOS unit's default PATH (coreutils, findutils, grep,
|
||||||
|
# sed, systemd): without this the runner cannot start it.
|
||||||
|
path = [ pkgs.curl ];
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
Type = "oneshot";
|
Type = "oneshot";
|
||||||
User = "root";
|
User = "root";
|
||||||
ExecStart = "${pkgs.python3}/bin/python3 /var/lib/sovran/ddns-update.py";
|
ExecStart = "${pkgs.python3}/bin/python3 /etc/sovran/ddns-update.py";
|
||||||
# Harden the service — it only needs network access and read access to
|
# Harden the service — it needs network access, the URL store, and the
|
||||||
# /var/lib/njalla/ddns_urls.json.
|
# file that receives the reported address.
|
||||||
NoNewPrivileges = true;
|
NoNewPrivileges = true;
|
||||||
ProtectSystem = "strict";
|
ProtectSystem = "strict";
|
||||||
ReadWritePaths = [ "/var/lib/njalla" "/var/lib/secrets" ];
|
ReadWritePaths = [ "/var/lib/njalla" "/var/lib/secrets" ];
|
||||||
ReadOnlyPaths = [ "/etc/sovran" ];
|
ReadOnlyPaths = [ "/etc/sovran" ];
|
||||||
ProtectHome = true;
|
ProtectHome = true;
|
||||||
PrivateTmp = true;
|
PrivateTmp = true;
|
||||||
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
|
# AF_UNIX: name lookups can go through nscd / systemd-resolved sockets.
|
||||||
|
RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -52,86 +73,9 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
# Install the Python runner script at build time so the service can find it.
|
# The runner used to be written to /var/lib/sovran by this activation script,
|
||||||
# The script is owned by root and not world-writable.
|
# next to the old public-ip.py detector. Remove those stale copies.
|
||||||
# Uses _validate_ddns_url() from /etc/sovran/security_helpers.py — the same
|
|
||||||
# production validator used by the Hub API — before executing any curl call.
|
|
||||||
# No shell is used; no redirects; no script execution.
|
|
||||||
# ${IP} placeholder is preserved in stored URLs and substituted at runtime;
|
|
||||||
# the URL is validated after substitution so any remaining $ is rejected.
|
|
||||||
system.activationScripts.sovran-ddns-update-script = ''
|
system.activationScripts.sovran-ddns-update-script = ''
|
||||||
install -d -m 0755 /var/lib/sovran
|
rm -f /var/lib/sovran/ddns-update.py /var/lib/sovran/public-ip.py
|
||||||
cat > /var/lib/sovran/ddns-update.py <<'PYEOF'
|
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""Sovran safe DDNS update runner.
|
|
||||||
|
|
||||||
Reads ddns_urls.json, substitutes the public IP for the ''${IP} placeholder,
|
|
||||||
validates each URL using the production _validate_ddns_url() from
|
|
||||||
/etc/sovran/security_helpers.py, then calls curl per URL.
|
|
||||||
No shell interpolation. No redirects. No script execution.
|
|
||||||
"""
|
|
||||||
import ipaddress, json, os, subprocess, sys
|
|
||||||
|
|
||||||
sys.path.insert(0, '/etc/sovran')
|
|
||||||
try:
|
|
||||||
from security_helpers import _validate_ddns_url
|
|
||||||
except ImportError:
|
|
||||||
sys.exit(1) # validator missing — fail so systemd logs the misconfiguration
|
|
||||||
|
|
||||||
URLS_FILE = "/var/lib/njalla/ddns_urls.json"
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(URLS_FILE) as f:
|
|
||||||
urls = json.load(f)
|
|
||||||
if not isinstance(urls, list):
|
|
||||||
raise ValueError("not a list")
|
|
||||||
except Exception:
|
|
||||||
sys.exit(0) # no URLs configured — nothing to do
|
|
||||||
|
|
||||||
# Resolve current public IP via the shared detector — one script, one cache
|
|
||||||
# (STUN -> DNS -> opt-in HTTPS echo; see /var/lib/sovran/public-ip.py).
|
|
||||||
# The detector refreshes /var/lib/secrets/external-ip, which the Hub and
|
|
||||||
# LiveKit read as well, so the whole system shares a single detected value.
|
|
||||||
public_ip = ""
|
|
||||||
try:
|
|
||||||
r = subprocess.run(
|
|
||||||
[sys.executable, "/var/lib/sovran/public-ip.py", "check"],
|
|
||||||
capture_output=True, text=True, timeout=20,
|
|
||||||
)
|
|
||||||
raw = r.stdout.strip().splitlines()[0] if r.stdout.strip() else ""
|
|
||||||
ipaddress.ip_address(raw) # validates — raises if not a real IP
|
|
||||||
public_ip = raw
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if not public_ip:
|
|
||||||
# Last resort: the shared cache file, if the detector is unavailable.
|
|
||||||
try:
|
|
||||||
with open("/var/lib/secrets/external-ip") as f:
|
|
||||||
raw = f.read().strip()
|
|
||||||
ipaddress.ip_address(raw)
|
|
||||||
public_ip = raw
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if not public_ip:
|
|
||||||
sys.exit(0) # no IP resolved — skip to avoid sending bare ''${IP}
|
|
||||||
|
|
||||||
for raw_url in urls:
|
|
||||||
try:
|
|
||||||
# Substitute ''${IP} placeholder then validate through production validator.
|
|
||||||
# After substitution there must be no $ left; _validate_ddns_url rejects
|
|
||||||
# any remaining $ expression.
|
|
||||||
url = raw_url.replace("''${IP}", public_ip)
|
|
||||||
_validate_ddns_url(url)
|
|
||||||
subprocess.run(
|
|
||||||
["curl", "--silent", "--max-time", "15", "--fail", "--no-location", url],
|
|
||||||
timeout=20, check=False,
|
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
PYEOF
|
|
||||||
chmod 0500 /var/lib/sovran/ddns-update.py
|
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
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 exposure (controls Caddy vhosts) ──────────────────
|
||||||
web = {
|
web = {
|
||||||
btcpayserver = lib.mkOption {
|
btcpayserver = lib.mkOption {
|
||||||
@@ -107,9 +168,11 @@
|
|||||||
description = ''
|
description = ''
|
||||||
Optional pin: force LiveKit to advertise this public IPv4 in its
|
Optional pin: force LiveKit to advertise this public IPv4 in its
|
||||||
host/TURN ICE candidates. Not required in normal operation — the
|
host/TURN ICE candidates. Not required in normal operation — the
|
||||||
module auto-detects the public IP at runtime (HTTPS egress
|
address is the one Njal.la reports for the DDNS update (set up in
|
||||||
detection, falling back to STUN). Set it only to override a
|
the Hub's Domains page), and nothing on this system looks it up
|
||||||
mis-detected address (e.g. multi-WAN/VPN setups).
|
anywhere else. Set it for a fixed public address with no Njal.la
|
||||||
|
DDNS entry, or to override the reported one (e.g. multi-WAN/VPN
|
||||||
|
setups).
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+117
-15
@@ -51,8 +51,8 @@ let
|
|||||||
]; }
|
]; }
|
||||||
{ name = "LND"; unit = "lnd.service"; type = "system"; icon = "lnd"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = []; }
|
{ name = "LND"; unit = "lnd.service"; type = "system"; icon = "lnd"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = []; }
|
||||||
{ name = "Ride The Lightning"; unit = "rtl.service"; type = "system"; icon = "rtl"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
|
{ name = "Ride The Lightning"; unit = "rtl.service"; type = "system"; icon = "rtl"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
|
||||||
{ label = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/rtl/hostname"; prefix = "http://"; }
|
{ label = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/rtl/hostname"; prefix = "http://"; suffix = "/rtl/"; }
|
||||||
{ label = "Local Network — Access on your home network only"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":3051"; }
|
{ label = "Local Network — Access on your home network only"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":3051/rtl/"; }
|
||||||
{ label = "Password"; file = "/etc/nix-bitcoin-secrets/rtl-password"; }
|
{ label = "Password"; file = "/etc/nix-bitcoin-secrets/rtl-password"; }
|
||||||
{ label = "How to Access"; value = "• Tor Address: Open in Tor Browser from any device, anywhere in the world\n• Local Network: Open in any browser, but only when connected to your home network"; }
|
{ label = "How to Access"; value = "• Tor Address: Open in Tor Browser from any device, anywhere in the world\n• Local Network: Open in any browser, but only when connected to your home network"; }
|
||||||
]; }
|
]; }
|
||||||
@@ -115,6 +115,15 @@ let
|
|||||||
else if cfg.roles.node then "node"
|
else if cfg.roles.node then "node"
|
||||||
else "server_plus_desktop";
|
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"
|
generatedConfig = pkgs.writeText "sovran-hub-config.json"
|
||||||
(builtins.toJSON {
|
(builtins.toJSON {
|
||||||
refresh_interval = 5;
|
refresh_interval = 5;
|
||||||
@@ -122,6 +131,9 @@ let
|
|||||||
role = activeRole;
|
role = activeRole;
|
||||||
services = monitoredServices;
|
services = monitoredServices;
|
||||||
feature_manager = true;
|
feature_manager = true;
|
||||||
|
# Read by LanOnlyMiddleware in server.py.
|
||||||
|
lan_only = cfg.hub.lanOnly;
|
||||||
|
lan_extra_networks = cfg.hub.extraLanNetworks;
|
||||||
feature_states = {
|
feature_states = {
|
||||||
bitcoin-tor-gossip = cfg.features.bitcoin-tor-gossip;
|
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");
|
"haven-relay.service" = if pkgs ? haven-relay then pkgs.haven-relay.version else (if pkgs ? haven then pkgs.haven.version else "0.1.0");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
# Shared shell prelude used by both the update and rebuild wrapper scripts.
|
||||||
|
# A flake/package fetch that is interrupted (network blip, reboot
|
||||||
|
# mid-download, disk filled, hiccup on the remote) can leave a truncated
|
||||||
|
# tarball or partial git clone in Nix's download caches. Nix then reuses the
|
||||||
|
# corrupt archive on every retry and dies with "cannot read file from
|
||||||
|
# tarball: Truncated tar archive detected" — a failure that is NOT fixed by
|
||||||
|
# simply re-running, but IS fixed by clearing the fetch caches. run_step runs
|
||||||
|
# a command and, on the first failure that matches a download/cache
|
||||||
|
# signature, clears the caches and retries once. Real config errors never
|
||||||
|
# match, so they still fail loudly. Each sourcing script must define $LOG.
|
||||||
|
nix-self-heal-prelude = ''
|
||||||
|
transient_failure() {
|
||||||
|
grep -Eqi 'truncated tar|unexpected end of (file|archive)|unexpected eof|corrupt(ed)? (archive|nar|download|file)|could not (fetch|download)|download.*(failed|interrupted)|timed out|timeout|connection (reset|refused|timed out)|network is unreachable|temporary failure in name resolution|checksum mismatch|hash mismatch|nar hash|unable to download|store path.*is not valid|cannot read file from tarball|into the git cache' "$LOG"
|
||||||
|
}
|
||||||
|
|
||||||
|
clear_fetch_caches() {
|
||||||
|
echo "[SELF-HEAL] Clearing stale Nix download caches and verifying the Nix store…"
|
||||||
|
# Re-fetchable caches only; /nix/store generations and the running system
|
||||||
|
# are never touched here.
|
||||||
|
rm -rf /root/.cache/nix/tarballs /root/.cache/nix/vcs-cache /root/.cache/nix/git* /root/.cache/nix/flakes 2>/dev/null || true
|
||||||
|
# Fast closure-level repair only. A full --check-contents scan hashes
|
||||||
|
# every store path and can take tens of minutes on a big node; the cache
|
||||||
|
# clear above is the actual fix for truncated/corrupt downloads.
|
||||||
|
nix-store --verify --repair >/dev/null 2>&1 || true
|
||||||
|
echo "[SELF-HEAL] Caches cleared; retrying…"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# run_step LABEL CMD [ARGS...] — run a build step; on a transient
|
||||||
|
# fetch/cache failure, heal once and retry. Returns the command exit code
|
||||||
|
# but leaves error messaging to the caller.
|
||||||
|
run_step() {
|
||||||
|
label="$1"; shift
|
||||||
|
rc=1
|
||||||
|
for try in 1 2; do
|
||||||
|
if [ "$try" -eq 2 ]; then
|
||||||
|
echo "── $label — retry after cache repair ──"
|
||||||
|
fi
|
||||||
|
"$@"
|
||||||
|
rc=$?
|
||||||
|
if [ "$rc" -eq 0 ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ "$try" -eq 1 ] && transient_failure; then
|
||||||
|
echo ""
|
||||||
|
echo "[SELF-HEAL] $label failed on a download/cache error (see above)."
|
||||||
|
clear_fetch_caches
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
return "$rc"
|
||||||
|
done
|
||||||
|
return "$rc"
|
||||||
|
}
|
||||||
|
'';
|
||||||
|
|
||||||
# ── Update wrapper script ──────────────────────────────────────
|
# ── Update wrapper script ──────────────────────────────────────
|
||||||
update-script = pkgs.writeShellScript "sovran-hub-update.sh" ''
|
update-script = pkgs.writeShellScript "sovran-hub-update.sh" ''
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
@@ -171,12 +238,14 @@ let
|
|||||||
|
|
||||||
RC=0
|
RC=0
|
||||||
|
|
||||||
|
${nix-self-heal-prelude}
|
||||||
|
|
||||||
echo "── Step 1/3: nix flake update ────────────────────"
|
echo "── Step 1/3: nix flake update ────────────────────"
|
||||||
if ! nix flake update --flake /etc/nixos --print-build-logs \
|
if ! run_step "nix flake update" nix flake update --flake /etc/nixos --print-build-logs \
|
||||||
--option connect-timeout 10 \
|
--option connect-timeout 10 \
|
||||||
--option stalled-download-timeout 90 \
|
--option stalled-download-timeout 90 \
|
||||||
--option download-attempts 7 \
|
--option download-attempts 7 \
|
||||||
--option fallback true 2>&1; then
|
--option fallback true; then
|
||||||
echo "[ERROR] nix flake update failed"
|
echo "[ERROR] nix flake update failed"
|
||||||
RC=1
|
RC=1
|
||||||
fi
|
fi
|
||||||
@@ -186,22 +255,22 @@ let
|
|||||||
echo "── Step 2/3: nixos-rebuild boot (stage next reboot) ──"
|
echo "── Step 2/3: nixos-rebuild boot (stage next reboot) ──"
|
||||||
# Stream output straight into $LOG (see rebuild-script) so the Hub UI
|
# Stream output straight into $LOG (see rebuild-script) so the Hub UI
|
||||||
# shows live progress instead of an empty log during long builds.
|
# shows live progress instead of an empty log during long builds.
|
||||||
nixos-rebuild boot --flake /etc/nixos --print-build-logs \
|
if run_step "nixos-rebuild boot" nixos-rebuild boot --flake /etc/nixos --print-build-logs \
|
||||||
--option connect-timeout 10 \
|
--option connect-timeout 10 \
|
||||||
--option stalled-download-timeout 90 \
|
--option stalled-download-timeout 90 \
|
||||||
--option download-attempts 7 \
|
--option download-attempts 7 \
|
||||||
--option fallback true
|
--option fallback true; then
|
||||||
BOOT_RC=$?
|
if ! readlink -f /nix/var/nix/profiles/system > "$GENERATION"; then
|
||||||
if [ "$BOOT_RC" -ne 0 ]; then
|
|
||||||
echo "[ERROR] nixos-rebuild boot failed"
|
|
||||||
RC=1
|
|
||||||
elif ! readlink -f /nix/var/nix/profiles/system > "$GENERATION"; then
|
|
||||||
# The marker is informational only. The Hub derives pending-reboot
|
# The marker is informational only. The Hub derives pending-reboot
|
||||||
# state from the NixOS system profile itself, so failing to record
|
# state from the NixOS system profile itself, so failing to record
|
||||||
# the marker must not fail an otherwise successful update.
|
# the marker must not fail an otherwise successful update.
|
||||||
echo "[WARNING] update succeeded but its staged generation could not be recorded"
|
echo "[WARNING] update succeeded but its staged generation could not be recorded"
|
||||||
rm -f "$GENERATION"
|
rm -f "$GENERATION"
|
||||||
fi
|
fi
|
||||||
|
else
|
||||||
|
echo "[ERROR] nixos-rebuild boot failed"
|
||||||
|
RC=1
|
||||||
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -245,12 +314,15 @@ let
|
|||||||
echo " Sovran_SystemsOS Rebuild — $(date)"
|
echo " Sovran_SystemsOS Rebuild — $(date)"
|
||||||
echo "══════════════════════════════════════════════════"
|
echo "══════════════════════════════════════════════════"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
${nix-self-heal-prelude}
|
||||||
|
|
||||||
echo "── Rebuilding system configuration ──────────────"
|
echo "── Rebuilding system configuration ──────────────"
|
||||||
# Stream output straight into $LOG (tee'd by the exec redirect above) so
|
# Stream output straight into $LOG (tee'd by the exec redirect above) so
|
||||||
# the Hub UI shows live progress. Capturing the output in a variable
|
# the Hub UI shows live progress. Capturing the output in a variable
|
||||||
# kept the log empty for the entire build+activation, which made long
|
# kept the log empty for the entire build+activation, which made long
|
||||||
# rebuilds can otherwise look like a hang.
|
# rebuilds can otherwise look like a hang.
|
||||||
nixos-rebuild switch --flake /etc/nixos --print-build-logs \
|
run_step "nixos-rebuild switch" nixos-rebuild switch --flake /etc/nixos --print-build-logs \
|
||||||
--option connect-timeout 10 \
|
--option connect-timeout 10 \
|
||||||
--option stalled-download-timeout 90 \
|
--option stalled-download-timeout 90 \
|
||||||
--option download-attempts 7 \
|
--option download-attempts 7 \
|
||||||
@@ -266,11 +338,11 @@ let
|
|||||||
echo ""
|
echo ""
|
||||||
echo " ✓ Build succeeded — a reboot is required to apply this rebuild"
|
echo " ✓ Build succeeded — a reboot is required to apply this rebuild"
|
||||||
echo " (Critical system components changed; running nixos-rebuild boot instead)"
|
echo " (Critical system components changed; running nixos-rebuild boot instead)"
|
||||||
if nixos-rebuild boot --flake /etc/nixos --print-build-logs \
|
if run_step "nixos-rebuild boot" nixos-rebuild boot --flake /etc/nixos --print-build-logs \
|
||||||
--option connect-timeout 10 \
|
--option connect-timeout 10 \
|
||||||
--option stalled-download-timeout 90 \
|
--option stalled-download-timeout 90 \
|
||||||
--option download-attempts 7 \
|
--option download-attempts 7 \
|
||||||
--option fallback true 2>&1; then
|
--option fallback true; then
|
||||||
echo "REBOOT_REQUIRED" > "$STATUS"
|
echo "REBOOT_REQUIRED" > "$STATUS"
|
||||||
else
|
else
|
||||||
echo "[ERROR] nixos-rebuild boot also failed"
|
echo "[ERROR] nixos-rebuild boot also failed"
|
||||||
@@ -278,6 +350,7 @@ let
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
|
echo "[ERROR] nixos-rebuild switch failed"
|
||||||
echo ""
|
echo ""
|
||||||
echo "══════════════════════════════════════════════════"
|
echo "══════════════════════════════════════════════════"
|
||||||
echo " ✗ Rebuild failed — see errors above"
|
echo " ✗ Rebuild failed — see errors above"
|
||||||
@@ -414,6 +487,12 @@ os.environ["SOVRAN_HUB_ICONS"] = os.path.join("$out", "share", "sovran-hub", "i
|
|||||||
import uvicorn
|
import uvicorn
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
"sovran_systemsos_web.server:app",
|
"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",
|
host="0.0.0.0",
|
||||||
port=8937,
|
port=8937,
|
||||||
log_level="info",
|
log_level="info",
|
||||||
@@ -442,6 +521,22 @@ in
|
|||||||
};
|
};
|
||||||
|
|
||||||
config = {
|
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 = {
|
systemd.services.sovran-hub-web = {
|
||||||
description = "Sovran_SystemsOS Hub Web Interface";
|
description = "Sovran_SystemsOS Hub Web Interface";
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
@@ -510,7 +605,14 @@ in
|
|||||||
|
|
||||||
environment.systemPackages = [ sovran-hub-web ];
|
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 ───────────────────────
|
# ── Auto-launch Hub in browser on login ───────────────────────
|
||||||
environment.etc."xdg/autostart/sovran-hub-autolaunch.desktop".text = ''
|
environment.etc."xdg/autostart/sovran-hub-autolaunch.desktop".text = ''
|
||||||
|
|||||||
@@ -9,6 +9,13 @@
|
|||||||
|
|
||||||
services.openssh = {
|
services.openssh = {
|
||||||
enable = true;
|
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 [
|
listenAddresses = lib.mkDefault [
|
||||||
{ addr = "127.0.0.1"; port = 22; }
|
{ addr = "127.0.0.1"; port = 22; }
|
||||||
];
|
];
|
||||||
|
|||||||
+11
-8
@@ -91,7 +91,7 @@ in
|
|||||||
SECRET_FILE="/var/lib/secrets/root-password"
|
SECRET_FILE="/var/lib/secrets/root-password"
|
||||||
if [ ! -f "$SECRET_FILE" ]; then
|
if [ ! -f "$SECRET_FILE" ]; then
|
||||||
mkdir -p /var/lib/secrets
|
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 \
|
WORDS="apple barn brook cabin cedar cloud coral crane delta eagle ember \
|
||||||
fern field flame flora flint frost grove haven hedge holly heron \
|
fern field flame flora flint frost grove haven hedge holly heron \
|
||||||
jade juniper kelp larch lemon lilac linden loch lotus maple marsh \
|
jade juniper kelp larch lemon lilac linden loch lotus maple marsh \
|
||||||
@@ -106,8 +106,9 @@ in
|
|||||||
W1=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
W1=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||||
W2=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
W2=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||||
W3=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
W3=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||||
DIGIT=$((RANDOM % 10))
|
W4=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||||
ROOT_PASS="$W1-$W2-$W3-$DIGIT"
|
DIGIT=$(printf '%02d' $((RANDOM % 100)))
|
||||||
|
ROOT_PASS="$W1-$W2-$W3-$W4-$DIGIT"
|
||||||
echo "$ROOT_PASS" > "$SECRET_FILE"
|
echo "$ROOT_PASS" > "$SECRET_FILE"
|
||||||
chmod 600 "$SECRET_FILE"
|
chmod 600 "$SECRET_FILE"
|
||||||
fi
|
fi
|
||||||
@@ -170,7 +171,7 @@ in
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p /var/lib/secrets
|
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 \
|
WORDS="apple barn brook cabin cedar cloud coral crane delta eagle ember \
|
||||||
fern field flame flora flint frost grove haven hedge holly heron \
|
fern field flame flora flint frost grove haven hedge holly heron \
|
||||||
jade juniper kelp larch lemon lilac linden loch lotus maple marsh \
|
jade juniper kelp larch lemon lilac linden loch lotus maple marsh \
|
||||||
@@ -185,8 +186,9 @@ in
|
|||||||
W1=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
W1=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||||
W2=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
W2=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||||
W3=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
W3=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||||
DIGIT=$((RANDOM % 10))
|
W4=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||||
FREE_PASS="$W1-$W2-$W3-$DIGIT"
|
DIGIT=$(printf '%02d' $((RANDOM % 100)))
|
||||||
|
FREE_PASS="$W1-$W2-$W3-$W4-$DIGIT"
|
||||||
echo "$FREE_PASS" > "$SECRET_FILE"
|
echo "$FREE_PASS" > "$SECRET_FILE"
|
||||||
chmod 600 "$SECRET_FILE"
|
chmod 600 "$SECRET_FILE"
|
||||||
echo "free:$FREE_PASS" | chpasswd
|
echo "free:$FREE_PASS" | chpasswd
|
||||||
@@ -229,8 +231,9 @@ in
|
|||||||
W1=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
W1=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||||
W2=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
W2=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||||
W3=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
W3=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||||
DIGIT=$((RANDOM % 10))
|
W4=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||||
FREE_PASS="$W1-$W2-$W3-$DIGIT"
|
DIGIT=$(printf '%02d' $((RANDOM % 100)))
|
||||||
|
FREE_PASS="$W1-$W2-$W3-$W4-$DIGIT"
|
||||||
|
|
||||||
printf '%s\n' "$FREE_PASS" > "$SECRET_FILE"
|
printf '%s\n' "$FREE_PASS" > "$SECRET_FILE"
|
||||||
chmod 600 "$SECRET_FILE"
|
chmod 600 "$SECRET_FILE"
|
||||||
|
|||||||
+41
-37
@@ -204,34 +204,36 @@ EOF
|
|||||||
# NAT with port-forwarding. It does not need to be assigned to this box,
|
# NAT with port-forwarding. It does not need to be assigned to this box,
|
||||||
# and it may be dynamic.
|
# and it may be dynamic.
|
||||||
#
|
#
|
||||||
# Reuse the shared detector (/var/lib/sovran/public-ip.py — see
|
# Nothing here looks the address up. Priority:
|
||||||
# modules/core/public-ip.nix) instead of running our own: one script,
|
|
||||||
# one cache, privacy-first (STUN -> DNS -> opt-in HTTPS echo). Priority:
|
|
||||||
# 1. sovran_systemsOS.elementCalling.externalIP (explicit pin, if set)
|
# 1. sovran_systemsOS.elementCalling.externalIP (explicit pin, if set)
|
||||||
# 2. /var/lib/secrets/external-ip (the shared cache)
|
# 2. /var/lib/secrets/external-ip — the address Njal.la reported for the
|
||||||
# 3. run the detector now (it refreshes the cache)
|
# last DDNS update (modules/core/njalla.nix). The runner rewrites that
|
||||||
# 4. STUN auto-detection (use_external_ip) as the fallback, with a
|
# file only when the address changes, and livekit-external-ip.path
|
||||||
# warning — this is where broken installs used to silently end up
|
# then re-runs this script.
|
||||||
# advertising a private IP, causing "call connects but no video".
|
# With neither, or with an address that is not public, this unit fails with
|
||||||
|
# a clear message instead of guessing: advertising a wrong or private
|
||||||
|
# address is what produces "call connects but no video".
|
||||||
EXTERNAL_IP='${if config.sovran_systemsOS.elementCalling.externalIP != null then config.sovran_systemsOS.elementCalling.externalIP else ""}'
|
EXTERNAL_IP='${if config.sovran_systemsOS.elementCalling.externalIP != null then config.sovran_systemsOS.elementCalling.externalIP else ""}'
|
||||||
|
|
||||||
PUBLIC_IP="$EXTERNAL_IP"
|
PUBLIC_IP="$EXTERNAL_IP"
|
||||||
if [ -z "$PUBLIC_IP" ] && [ -f /var/lib/secrets/external-ip ]; then
|
if [ -z "$PUBLIC_IP" ] && [ -f /var/lib/secrets/external-ip ]; then
|
||||||
PUBLIC_IP=$(tr -d '[:space:]' < /var/lib/secrets/external-ip 2>/dev/null)
|
PUBLIC_IP=$(tr -d '[:space:]' < /var/lib/secrets/external-ip 2>/dev/null)
|
||||||
fi
|
fi
|
||||||
if [ -z "$PUBLIC_IP" ] && [ -x /var/lib/sovran/public-ip.py ]; then
|
|
||||||
PUBLIC_IP=$(python3 /var/lib/sovran/public-ip.py check 2>/dev/null | head -n1)
|
if [ -z "$PUBLIC_IP" ]; then
|
||||||
|
echo "ERROR: no public IP is known for LiveKit yet." >&2
|
||||||
|
echo "ERROR: It is recorded after the first successful Njal.la DDNS update (Hub, Domains)." >&2
|
||||||
|
echo "ERROR: To use a fixed address instead, set sovran_systemsOS.elementCalling.externalIP." >&2
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Reject non-routable addresses (loopback, private, link-local, CGNAT).
|
# Reject non-routable addresses (loopback, private, link-local, CGNAT).
|
||||||
# A detected/pinned address like this must never be advertised.
|
if printf '%s' "$PUBLIC_IP" | grep -qE \
|
||||||
if [ -n "$PUBLIC_IP" ] && printf '%s' "$PUBLIC_IP" | grep -qE \
|
'^(0\.|127\.|10\.|100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.|169\.254\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)'; then
|
||||||
'^(0\.|127\.|10\.|100\.64\.|169\.254\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)'; then
|
echo "ERROR: $PUBLIC_IP is not a public address, so remote peers cannot reach LiveKit there." >&2
|
||||||
echo "WARNING: external IP '$PUBLIC_IP' is not routable; falling back to STUN auto-detection." >&2
|
exit 1
|
||||||
PUBLIC_IP=""
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -n "$PUBLIC_IP" ]; then
|
|
||||||
cat > /run/livekit/livekit.yaml <<EOF
|
cat > /run/livekit/livekit.yaml <<EOF
|
||||||
port: 7880
|
port: 7880
|
||||||
rtc:
|
rtc:
|
||||||
@@ -245,21 +247,6 @@ rtc:
|
|||||||
- $IFACE
|
- $IFACE
|
||||||
EOF
|
EOF
|
||||||
echo "LiveKit will advertise public IP: $PUBLIC_IP"
|
echo "LiveKit will advertise public IP: $PUBLIC_IP"
|
||||||
else
|
|
||||||
cat > /run/livekit/livekit.yaml <<EOF
|
|
||||||
port: 7880
|
|
||||||
rtc:
|
|
||||||
use_external_ip: true
|
|
||||||
skip_external_ip_validation: true
|
|
||||||
advertise_internal_ip: true
|
|
||||||
tcp_port: 7881
|
|
||||||
udp_port: 7882
|
|
||||||
interfaces:
|
|
||||||
includes:
|
|
||||||
- $IFACE
|
|
||||||
EOF
|
|
||||||
echo "WARNING: could not determine a public IP for LiveKit; using STUN auto-detection. If calls connect without media, check STUN egress or set sovran_systemsOS.elementCalling.externalIP." >&2
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Webhooks → lk-jwt-service. The JWT service validates the HMAC
|
# Webhooks → lk-jwt-service. The JWT service validates the HMAC
|
||||||
# signature against the same key file it issues tokens with, and uses
|
# signature against the same key file it issues tokens with, and uses
|
||||||
@@ -414,15 +401,32 @@ EOF
|
|||||||
# Restart LiveKit / lk-jwt-service when a rebuild regenerates their runtime
|
# Restart LiveKit / lk-jwt-service when a rebuild regenerates their runtime
|
||||||
# configs (new domains, externalIP, full-access list), mirroring the domain
|
# configs (new domains, externalIP, full-access list), mirroring the domain
|
||||||
# change flow.
|
# change flow.
|
||||||
# Re-run the config generator and restart LiveKit when a rebuild regenerates
|
|
||||||
# the runtime config, or when the Hub persists a new external IP (dynamic
|
|
||||||
# WAN IPs), so the advertised ICE candidate stays current without a manual
|
|
||||||
# restart. The trigger chain: external-ip change → livekit-turn-setup
|
|
||||||
# re-runs → rewrites livekit.yaml → livekit restarts with the new config.
|
|
||||||
systemd.services.livekit-turn-setup.restartTriggers = [ "/var/lib/secrets/external-ip" ];
|
|
||||||
systemd.services.livekit.restartTriggers = [ "/run/livekit/livekit.yaml" ];
|
systemd.services.livekit.restartTriggers = [ "/run/livekit/livekit.yaml" ];
|
||||||
systemd.services.lk-jwt-service.restartTriggers = [ "/run/lk-jwt-service/env" ];
|
systemd.services.lk-jwt-service.restartTriggers = [ "/run/lk-jwt-service/env" ];
|
||||||
|
|
||||||
|
# Follow a changing public IP. ddns-update.py rewrites
|
||||||
|
# /var/lib/secrets/external-ip only when Njal.la reports a different address;
|
||||||
|
# this path unit then re-runs livekit-turn-setup (new node_ip and TURN
|
||||||
|
# address) and starts LiveKit if it is not running, e.g. because no address
|
||||||
|
# was known yet at boot. restartTriggers cannot do this: it is evaluated when
|
||||||
|
# the system is built, so it cannot watch a file that changes at runtime.
|
||||||
|
systemd.paths.livekit-external-ip = {
|
||||||
|
description = "Watch the public IP recorded by the Njal.la DDNS runner";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
pathConfig.PathChanged = "/var/lib/secrets/external-ip";
|
||||||
|
};
|
||||||
|
systemd.services.livekit-external-ip = {
|
||||||
|
description = "Re-run LiveKit setup for a changed public IP";
|
||||||
|
serviceConfig.Type = "oneshot";
|
||||||
|
unitConfig.ConditionPathExists = "/var/lib/domains/element-calling";
|
||||||
|
script = ''
|
||||||
|
# livekit.service requires livekit-turn-setup, so it restarts with it.
|
||||||
|
systemctl restart livekit-turn-setup.service
|
||||||
|
# No-op if LiveKit is already running; starts it after an earlier failure.
|
||||||
|
systemctl start livekit.service
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
####### PUBLIC REACHABILITY SELF-CHECK #######
|
####### PUBLIC REACHABILITY SELF-CHECK #######
|
||||||
# Diagnostic only — never a hard dependency of livekit/caddy. Catches the
|
# Diagnostic only — never a hard dependency of livekit/caddy. Catches the
|
||||||
# classic "call connects but no media" setup errors at boot instead of at
|
# classic "call connects but no media" setup errors at boot instead of at
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
./core/no-sleep.nix
|
./core/no-sleep.nix
|
||||||
./core/cpu-performance.nix
|
./core/cpu-performance.nix
|
||||||
./core/local-domain-loopback.nix
|
./core/local-domain-loopback.nix
|
||||||
./core/public-ip.nix
|
|
||||||
|
|
||||||
# ── Always on (no flag) ───────────────────────────────────
|
# ── Always on (no flag) ───────────────────────────────────
|
||||||
./php.nix
|
./php.nix
|
||||||
|
|||||||
+112
-3
@@ -3,10 +3,22 @@
|
|||||||
lib.mkIf config.sovran_systemsOS.services.nextcloud {
|
lib.mkIf config.sovran_systemsOS.services.nextcloud {
|
||||||
|
|
||||||
# ── PostgreSQL database ───────────────────────────────────
|
# ── PostgreSQL database ───────────────────────────────────
|
||||||
|
# Cluster-wide tuning (shared_buffers, autovacuum) lives in
|
||||||
|
# configuration.nix so it is shared with Matrix Synapse.
|
||||||
services.postgresql = {
|
services.postgresql = {
|
||||||
enable = true;
|
enable = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# ── Redis for Nextcloud distributed cache + file locking ───
|
||||||
|
# Nextcloud does not recommend APCu for memcache.locking in production.
|
||||||
|
# TCP on localhost avoids unix-socket permission juggling with the caddy user.
|
||||||
|
# Scoped to Nextcloud only — Synapse / MariaDB / Bitcoin are unaffected.
|
||||||
|
services.redis.servers.nextcloud = {
|
||||||
|
enable = true;
|
||||||
|
bind = "127.0.0.1";
|
||||||
|
port = 6379;
|
||||||
|
};
|
||||||
|
|
||||||
# ── Auto-generate DB password and initialize ──────────────
|
# ── Auto-generate DB password and initialize ──────────────
|
||||||
systemd.services.nextcloud-db-init = {
|
systemd.services.nextcloud-db-init = {
|
||||||
description = "Initialize Nextcloud PostgreSQL database with auto-generated password";
|
description = "Initialize Nextcloud PostgreSQL database with auto-generated password";
|
||||||
@@ -47,14 +59,20 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud {
|
|||||||
if ! psql -U postgres -lqt | cut -d \| -f 1 | grep -qw "nextclouddb"; then
|
if ! psql -U postgres -lqt | cut -d \| -f 1 | grep -qw "nextclouddb"; then
|
||||||
psql -U postgres -c "CREATE DATABASE nextclouddb WITH OWNER ncusr TEMPLATE template0 LC_COLLATE = 'C' LC_CTYPE = 'C';"
|
psql -U postgres -c "CREATE DATABASE nextclouddb WITH OWNER ncusr TEMPLATE template0 LC_COLLATE = 'C' LC_CTYPE = 'C';"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# NOTE: autovacuum GUCs are SIGHUP-context, so they cannot be set
|
||||||
|
# per-database — ALTER DATABASE ... SET rejects them with
|
||||||
|
# 'parameter "..." cannot be changed now'. They are set
|
||||||
|
# cluster-wide in configuration.nix instead, which already covers
|
||||||
|
# both nextclouddb and matrix-synapse.
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
# ── Fully automated Nextcloud setup ───────────────────────
|
# ── Fully automated Nextcloud setup ───────────────────────
|
||||||
systemd.services.nextcloud-init = {
|
systemd.services.nextcloud-init = {
|
||||||
description = "Download, extract, and fully configure Nextcloud";
|
description = "Download, extract, and fully configure Nextcloud";
|
||||||
after = [ "network-online.target" "postgresql.service" "phpfpm-nextcloud.service" "nextcloud-db-init.service" ];
|
after = [ "network-online.target" "postgresql.service" "phpfpm-nextcloud.service" "nextcloud-db-init.service" "redis-nextcloud.service" ];
|
||||||
wants = [ "network-online.target" ];
|
wants = [ "network-online.target" "redis-nextcloud.service" ];
|
||||||
requires = [ "postgresql.service" "nextcloud-db-init.service" ];
|
requires = [ "postgresql.service" "nextcloud-db-init.service" ];
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
|
||||||
@@ -150,7 +168,11 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud {
|
|||||||
php $INSTALL_DIR/occ config:system:set default_phone_region --value='US'
|
php $INSTALL_DIR/occ config:system:set default_phone_region --value='US'
|
||||||
php $INSTALL_DIR/occ config:system:set maintenance_window_start --type=integer --value=1
|
php $INSTALL_DIR/occ config:system:set maintenance_window_start --type=integer --value=1
|
||||||
php $INSTALL_DIR/occ config:system:set memcache.local --value='\OC\Memcache\APCu'
|
php $INSTALL_DIR/occ config:system:set memcache.local --value='\OC\Memcache\APCu'
|
||||||
php $INSTALL_DIR/occ config:system:set memcache.locking --value='\OC\Memcache\APCu'
|
php $INSTALL_DIR/occ config:system:set memcache.distributed --value='\OC\Memcache\Redis'
|
||||||
|
php $INSTALL_DIR/occ config:system:set memcache.locking --value='\OC\Memcache\Redis'
|
||||||
|
php $INSTALL_DIR/occ config:system:set redis host --value='127.0.0.1'
|
||||||
|
php $INSTALL_DIR/occ config:system:set redis port --type=integer --value=6379
|
||||||
|
php $INSTALL_DIR/occ config:system:set redis timeout --value='1.5'
|
||||||
php $INSTALL_DIR/occ config:system:set server_id --value='$SERVER_ID'
|
php $INSTALL_DIR/occ config:system:set server_id --value='$SERVER_ID'
|
||||||
php $INSTALL_DIR/occ background:cron
|
php $INSTALL_DIR/occ background:cron
|
||||||
"
|
"
|
||||||
@@ -247,6 +269,93 @@ CREDS
|
|||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# ── Migrate existing installs to Redis locking ────────────
|
||||||
|
# nextcloud-init only runs on fresh installs (ConditionPathExists
|
||||||
|
# !config.php), so pre-existing / pre-Sovran installs would keep
|
||||||
|
# APCu locking forever. This one-shot is idempotent and safe to
|
||||||
|
# re-run on every boot — occ just overwrites the same values.
|
||||||
|
systemd.services.nextcloud-redis-migrate = {
|
||||||
|
description = "Point existing Nextcloud installs at Redis locking";
|
||||||
|
after = [ "postgresql.service" "redis-nextcloud.service" "phpfpm-nextcloud.service" ];
|
||||||
|
wants = [ "redis-nextcloud.service" ];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
unitConfig = {
|
||||||
|
ConditionPathExists = [
|
||||||
|
"/var/lib/www/nextcloud/occ"
|
||||||
|
"/var/lib/www/nextcloud/config/config.php"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
RemainAfterExit = true;
|
||||||
|
};
|
||||||
|
path = with pkgs; [ coreutils shadow ];
|
||||||
|
script = ''
|
||||||
|
set -euo pipefail
|
||||||
|
INSTALL_DIR="/var/lib/www/nextcloud"
|
||||||
|
# Wait briefly for Redis (TCP localhost:6379).
|
||||||
|
for i in $(seq 1 15); do
|
||||||
|
if (echo > /dev/tcp/127.0.0.1/6379) >/dev/null 2>&1; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
/run/wrappers/bin/su -s /bin/sh caddy -c "
|
||||||
|
php $INSTALL_DIR/occ config:system:set memcache.local --value='\OC\Memcache\APCu'
|
||||||
|
php $INSTALL_DIR/occ config:system:set memcache.distributed --value='\OC\Memcache\Redis'
|
||||||
|
php $INSTALL_DIR/occ config:system:set memcache.locking --value='\OC\Memcache\Redis'
|
||||||
|
php $INSTALL_DIR/occ config:system:set redis host --value='127.0.0.1'
|
||||||
|
php $INSTALL_DIR/occ config:system:set redis port --type=integer --value=6379
|
||||||
|
php $INSTALL_DIR/occ config:system:set redis timeout --value='1.5'
|
||||||
|
"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
# ── Recurring DB maintenance (Nextcloud 35 checks) ───────────
|
||||||
|
# nextcloud-init runs db:add-missing-indices exactly once. Upgrades
|
||||||
|
# (e.g. to NC35) and later app installs (Mail, Guests) add tables
|
||||||
|
# like oc_mail_tags / oc_guests_users that then seq-scan forever.
|
||||||
|
# Weekly: VACUUM ANALYZE (dead tuples) + backfill missing indices.
|
||||||
|
# Scoped to nextclouddb only — matrix-synapse is untouched.
|
||||||
|
systemd.services.nextcloud-db-maintenance = {
|
||||||
|
description = "Nextcloud DB maintenance: VACUUM + missing indices";
|
||||||
|
after = [ "postgresql.service" "redis-nextcloud.service" "phpfpm-nextcloud.service" ];
|
||||||
|
wants = [ "postgresql.service" ];
|
||||||
|
unitConfig = {
|
||||||
|
ConditionPathExists = [
|
||||||
|
"/var/lib/www/nextcloud/occ"
|
||||||
|
"/var/lib/www/nextcloud/config/config.php"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
};
|
||||||
|
path = [ config.services.postgresql.package pkgs.coreutils pkgs.shadow ];
|
||||||
|
script = ''
|
||||||
|
set -euo pipefail
|
||||||
|
INSTALL_DIR="/var/lib/www/nextcloud"
|
||||||
|
echo "Vacuuming nextclouddb..."
|
||||||
|
psql -U postgres -d nextclouddb -c "VACUUM (ANALYZE);"
|
||||||
|
echo "Backfilling Nextcloud indices..."
|
||||||
|
/run/wrappers/bin/su -s /bin/sh caddy -c "
|
||||||
|
php $INSTALL_DIR/occ db:add-missing-indices
|
||||||
|
php $INSTALL_DIR/occ db:add-missing-columns
|
||||||
|
php $INSTALL_DIR/occ db:add-missing-primary-keys
|
||||||
|
"
|
||||||
|
echo "Nextcloud DB maintenance complete."
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.timers.nextcloud-db-maintenance = {
|
||||||
|
description = "Weekly Nextcloud DB maintenance";
|
||||||
|
wantedBy = [ "timers.target" ];
|
||||||
|
timerConfig = {
|
||||||
|
OnCalendar = "Sun 03:30";
|
||||||
|
Persistent = true;
|
||||||
|
RandomizedDelaySec = "30m";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
services.cron.systemCronJobs = [
|
services.cron.systemCronJobs = [
|
||||||
"*/5 * * * * caddy /run/current-system/sw/bin/php -f /var/lib/www/nextcloud/cron.php"
|
"*/5 * * * * caddy /run/current-system/sw/bin/php -f /var/lib/www/nextcloud/cron.php"
|
||||||
];
|
];
|
||||||
|
|||||||
+56
-14
@@ -1,29 +1,70 @@
|
|||||||
{ config, pkgs, lib, ... }:
|
{ config, pkgs, lib, ... }:
|
||||||
|
|
||||||
|
# ── Shared PHP for Nextcloud + WordPress ──────────────────────────────────────
|
||||||
|
#
|
||||||
|
# One interpreter (with one extension set and one php.ini) is shared by the
|
||||||
|
# phpfpm-nextcloud and phpfpm-wordpress pools, the Nextcloud cron job and the
|
||||||
|
# occ / wp-cli helper scripts. Every consumer must reference
|
||||||
|
# config.sovran_systemsOS.phpPackage (or /run/current-system/sw/bin/php) so
|
||||||
|
# that the CLI and the FPM pools always run the *same* PHP.
|
||||||
|
#
|
||||||
|
# Version policy (September 2026):
|
||||||
|
# • Nextcloud 35 supports PHP 8.3 / 8.4 / 8.5 and recommends 8.5. Its setup
|
||||||
|
# check flags 8.3 as "deprecated since Nextcloud 35" and warns that
|
||||||
|
# Nextcloud 36 may require at least 8.4.
|
||||||
|
# • WordPress 6.9 / 7.0 fully support PHP 8.4 and 8.5.
|
||||||
|
# • PHP 8.3 has been security-only since 2025-12-31; PHP 8.4 leaves active
|
||||||
|
# support on 2026-12-31; PHP 8.5 is actively supported until 2027-12-31.
|
||||||
|
#
|
||||||
|
# To fall back to PHP 8.4 (nixpkgs' current default `pkgs.php`) change only
|
||||||
|
# the `phpBase` line below.
|
||||||
|
|
||||||
let
|
let
|
||||||
|
phpBase = pkgs.php85;
|
||||||
|
|
||||||
|
custom-php = phpBase.buildEnv {
|
||||||
|
# `enabled` is nixpkgs' default extension set. It already contains every
|
||||||
|
# module Nextcloud lists as required or recommended (ctype, curl, dom,
|
||||||
|
# fileinfo, gd, intl, mbstring, openssl, posix, session, simplexml,
|
||||||
|
# xmlreader, xmlwriter, zip, zlib, pdo_pgsql, pdo_mysql, bcmath, gmp,
|
||||||
|
# exif, sodium, sysvsem, pcntl, ...). OPcache is compiled into PHP >= 8.5
|
||||||
|
# and no longer appears as a separate extension.
|
||||||
|
extensions = { enabled, all }: enabled ++ (with all; [
|
||||||
|
bz2 # Nextcloud: bz2 archive support
|
||||||
|
apcu # Nextcloud: memcache.local (apc.enable_cli=1 below is mandatory for occ + cron)
|
||||||
|
redis # Nextcloud: memcache.distributed / file locking once a Redis server is configured
|
||||||
|
imagick # Nextcloud: previews + theming (nixpkgs ImageMagick is built with SVG support)
|
||||||
|
memcached # WordPress object-cache plugins (legacy option for Nextcloud)
|
||||||
|
]);
|
||||||
|
|
||||||
custom-php = pkgs.php83.buildEnv {
|
|
||||||
extensions = { enabled, all }: enabled ++ (with all; [ bz2 apcu redis imagick memcached ]);
|
|
||||||
extraConfig = ''
|
extraConfig = ''
|
||||||
|
; ── Error handling (production) ─────────────────────────────────
|
||||||
|
display_errors = Off
|
||||||
|
display_startup_errors = Off
|
||||||
|
log_errors = On
|
||||||
|
|
||||||
display_errors = On
|
; ── Limits ──────────────────────────────────────────────────────
|
||||||
display_startup_errors = On
|
|
||||||
max_execution_time = 10000
|
max_execution_time = 10000
|
||||||
max_input_time = 3000
|
max_input_time = 3000
|
||||||
memory_limit = 1G;
|
memory_limit = 1G
|
||||||
opcache.enable=1;
|
|
||||||
opcache.memory_consumption=512;
|
|
||||||
opcache_revalidate_freq = 240;
|
|
||||||
opcache.max_accelerated_files=20000;
|
|
||||||
post_max_size = 3G
|
post_max_size = 3G
|
||||||
upload_max_filesize = 3G
|
upload_max_filesize = 3G
|
||||||
apc.enable_cli=1
|
|
||||||
opcache.interned_strings_buffer = 192
|
|
||||||
redis.session.locking_enabled=1
|
|
||||||
redis.session.lock_retries=-1
|
|
||||||
redis.session.lock_wait_time=10000
|
|
||||||
|
|
||||||
|
; ── OPcache (Nextcloud "Server tuning" recommendations) ─────────
|
||||||
|
opcache.enable = 1
|
||||||
|
opcache.memory_consumption = 512
|
||||||
|
opcache.interned_strings_buffer = 192
|
||||||
|
opcache.max_accelerated_files = 20000
|
||||||
|
opcache.revalidate_freq = 240
|
||||||
|
opcache.save_comments = 1
|
||||||
|
|
||||||
|
; ── APCu ────────────────────────────────────────────────────────
|
||||||
|
apc.enable_cli = 1
|
||||||
|
|
||||||
|
; ── phpredis session locking (only used with session.save_handler = redis)
|
||||||
|
redis.session.locking_enabled = 1
|
||||||
|
redis.session.lock_retries = -1
|
||||||
|
redis.session.lock_wait_time = 10000
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
in
|
in
|
||||||
@@ -55,3 +96,4 @@ in
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.allowedTCPPorts = lib.mkIf cfg.services.bitcoin [ 3051 ];
|
||||||
networking.firewall.allowedUDPPorts = lib.mkIf cfg.services.bitcoin [ 3051 ];
|
|
||||||
|
|
||||||
# ── 6. NWC / LNURL — Sovran Hub integration ───────────────────
|
# ── 6. NWC / LNURL — Sovran Hub integration ───────────────────
|
||||||
# Sovran_Bitcoin's albyhub.nix and lnurl.nix handle the base services.
|
# Sovran_Bitcoin's albyhub.nix and lnurl.nix handle the base services.
|
||||||
|
|||||||
@@ -94,16 +94,23 @@ EOF
|
|||||||
# ── Synapse service ─────────────────────────────────────────
|
# ── Synapse service ─────────────────────────────────────────
|
||||||
services.matrix-synapse = {
|
services.matrix-synapse = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
# cache-memory provides cache-size statistics for the autotuning below
|
||||||
|
# (in addition to the NixOS defaults).
|
||||||
|
extras = [ "systemd" "postgres" "url-preview" "cache-memory" ];
|
||||||
extraConfigFiles = [
|
extraConfigFiles = [
|
||||||
"/run/matrix-synapse/runtime-config.yaml"
|
"/run/matrix-synapse/runtime-config.yaml"
|
||||||
];
|
];
|
||||||
settings = {
|
settings = {
|
||||||
database = {
|
database = {
|
||||||
name = "psycopg2";
|
name = "psycopg2";
|
||||||
|
# Recycle pooled connections less often (fewer reconnects).
|
||||||
|
txn_limit = 10000;
|
||||||
args = {
|
args = {
|
||||||
host = "localhost";
|
host = "localhost";
|
||||||
database = "matrix-synapse";
|
database = "matrix-synapse";
|
||||||
user = "matrix-synapse";
|
user = "matrix-synapse";
|
||||||
|
cp_min = 5;
|
||||||
|
cp_max = 15;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
push.include_content = false;
|
push.include_content = false;
|
||||||
@@ -120,6 +127,32 @@ EOF
|
|||||||
];
|
];
|
||||||
presence.enabled = true;
|
presence.enabled = true;
|
||||||
enable_registration = false;
|
enable_registration = false;
|
||||||
|
# ── Performance (32 GB Server + Desktop) ─────────────────
|
||||||
|
# Synapse trades RAM for fewer Postgres round-trips; most RAM goes
|
||||||
|
# to caches. Stock is global_factor 0.5 + 10K event cache, which
|
||||||
|
# leaves syncs hitting the database on every request.
|
||||||
|
# Deliberately unchanged: presence and URL previews stay enabled —
|
||||||
|
# disabling them is faster but changes user-visible behavior.
|
||||||
|
event_cache_size = "100K";
|
||||||
|
caches = {
|
||||||
|
global_factor = 4.0;
|
||||||
|
expire_caches = true;
|
||||||
|
cache_entry_ttl = "30m";
|
||||||
|
sync_response_cache_duration = "2m";
|
||||||
|
cache_autotuning = {
|
||||||
|
max_cache_memory_usage = "2G";
|
||||||
|
target_cache_memory_usage = "1G";
|
||||||
|
min_cache_ttl = "30s";
|
||||||
|
};
|
||||||
|
per_cache_factors = {
|
||||||
|
# Hot paths for /sync and room joins.
|
||||||
|
get_users_in_room = 3.0;
|
||||||
|
get_current_state_ids = 3.0;
|
||||||
|
get_unread_event_push_actions_by_room_for_user = 5.0;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
# Fewer GC pauses at the cost of a little more memory.
|
||||||
|
gc_thresholds = [ 1500 20 10 ];
|
||||||
listeners = [
|
listeners = [
|
||||||
{
|
{
|
||||||
port = 8008;
|
port = 8008;
|
||||||
|
|||||||
@@ -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