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 | ||
|
|
93492e88fb | ||
|
|
7da37cdb34 | ||
|
|
f34d1533c3 | ||
|
|
220d6dff2c | ||
|
|
81ab3b2280 | ||
|
|
d5d40a1697 | ||
|
|
3756a0058d | ||
|
|
be5d47cb9c | ||
|
|
ddf87a1c1c | ||
|
|
f0e4c33a5f | ||
|
|
a6ef13688e | ||
|
|
ef1c045e0e | ||
|
|
e27bf0ef1f | ||
|
|
362fa0b36c | ||
|
|
043981a34b | ||
|
|
5529fccab2 | ||
|
|
5f84154dca | ||
|
|
60b55f715e | ||
|
|
01037cd55c | ||
|
|
7980833fc2 | ||
|
|
837fffc8f9 | ||
|
|
cb4585ecf8 | ||
|
|
1de1bf95ad | ||
|
|
e9d6edca56 | ||
|
|
a7bfc9aa0d | ||
|
|
ed2e4059c1 | ||
|
|
f057e43ca0 | ||
|
|
d600198049 | ||
|
|
14db4282b7 | ||
|
|
d31887cae6 | ||
|
|
669181c75b | ||
|
|
47e9bb99b0 | ||
|
|
ac6c498615 | ||
|
|
224ea99ce4 | ||
|
|
c54dbfe2a5 | ||
|
|
a1fa40cacf | ||
|
|
48dacbeef3 | ||
|
|
b6e6adbe31 | ||
|
|
64624002bb | ||
|
|
1ccce429a5 | ||
|
|
f832efb0d1 | ||
|
|
67ae53ad3f | ||
|
|
db5b9f6b60 | ||
|
|
2ea1427766 | ||
|
|
b35b327a07 | ||
|
|
a9ff168fd6 | ||
|
|
b436dbed95 |
+189
@@ -7,6 +7,171 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.2.0] - 2026-10-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Ssh: don't open port 22 for the loopback-only sshd
|
||||||
|
- Bitcoin: drop the stray UDP 3051 firewall rule
|
||||||
|
- Installer: raise generated password entropy from ~23 to ~33 bits
|
||||||
|
- Caddy: stop filtering the RTL and Mempool sites by client address
|
||||||
|
- Hub: serve the Hub on its own port instead of through Caddy
|
||||||
|
- Hub: answer the local network only, whichever way a client arrives
|
||||||
|
- Hub: make the login lockout that LOGIN_FAIL_MAX described
|
||||||
|
- Caddy: serve the Hub, RTL and Mempool sites to local clients only
|
||||||
|
- Docs, hub, installer: say Server + Desktop makes the home IP public
|
||||||
|
- Ddns: take the public IP from Njal.la only and give it to LiveKit
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Updated nixpkgs and Sovran_Bitcoin update and the new Bisq 1.10.9
|
||||||
|
[1.2.0]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.2.0
|
||||||
|
|
||||||
|
|
||||||
|
## [1.1.7] - 2026-09-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Postgresql: drop per-database autovacuum ALTERs (rejected by Postgres)
|
||||||
|
- Synapse: performance tuning for 32 GB Server+Desktop hosts
|
||||||
|
- Nextcloud, postgresql: fix Nextcloud 35 DB warnings on 32 GB hosts
|
||||||
|
- Clean up flake.nix by removing comments and LiveKit override
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Updated nixpkgs
|
||||||
|
- Updated to php85 and fixes
|
||||||
|
- Updated to proper syntax to prevent build errors.
|
||||||
|
- Updated flake lock which contains Bisq 1.10.8 and Bisq2 2.1.13
|
||||||
|
[1.1.7]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.7
|
||||||
|
|
||||||
|
|
||||||
|
## [1.1.6] - 2026-09-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Rename 'The Sovran Hub' to 'The Hub' in README
|
||||||
|
- Rename 'Sovran Hub' to 'The Hub' in README
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Updated nix packages includes the new mempool version
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- Update Sovran Hub screenshot to the v1.1.5 redesign
|
||||||
|
[1.1.6]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.6
|
||||||
|
|
||||||
|
|
||||||
|
## [1.1.5] - 2026-09-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Hub: don't claim the OS "keeps itself current" on the dashboard
|
||||||
|
- Hub: point RTL credentials at /rtl/ and bump dev version to 0.15.12
|
||||||
|
- Make dashboard cards uniform and symmetric; stop clipping update text
|
||||||
|
- Add boot splash; separate Systems Operational and Security icons
|
||||||
|
- Extend welcome dashboard background to the panel edges
|
||||||
|
- Add welcome dashboard as default view
|
||||||
|
- Monochrome updater glyph; fix oversized dialog header icon
|
||||||
|
- Merge Bitcoin categories into one; rename Self-Hosted Apps to Personal Apps
|
||||||
|
- Neutral graphite theme; branded updater icon
|
||||||
|
- Simplify Systems Operational, reword Domain Status, blue restart buttons
|
||||||
|
- Polish pass: sysops wording, QR size, NWC toolbar, logo, diagnostics placement
|
||||||
|
- Rework Update System dialog to the approved design; match rebuild dialog
|
||||||
|
- Redesign The Hub web UI: softer dark theme, sidebar nav, status widgets
|
||||||
|
- Nixpkgs update
|
||||||
|
- Switch LibreOffice from fresh to stable version
|
||||||
|
- Ui: simplify element-calling port modal — trim verbose steps and remove extra verification task
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Updated nixpkgs
|
||||||
|
- Updated Sovran-Bitcoin
|
||||||
|
- Updated flake to build through the new sovran-bitcoin input
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fix role fit-and-finish: Desktop-only router note, narrow-viewport layout
|
||||||
|
- Self-heal truncated/corrupt Nix downloads and keep failed updates retryable
|
||||||
|
[1.1.5]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.5
|
||||||
|
|
||||||
|
|
||||||
|
## [1.1.4] - 2026-09-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Element-calling: fix Nix string interpolation of LAN_CIDR echo
|
||||||
|
- Docs/hub: update Element Calling port guidance to the new port set
|
||||||
|
- Element-calling: fix Wi-Fi calls and tighten media/TURN ports
|
||||||
|
- Flake: pin LiveKit to 1.13.6 for rtc.advertise_internal_ip support
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Updated nixpkgs
|
||||||
|
- Updated sovran-bitcon
|
||||||
|
- Dedupe NWC tooling — use Sovran_Bitcoin's sovran-nwc
|
||||||
|
- Update sovran-bitcoin flake input
|
||||||
|
- Extract bitcoin stack into Sovran_Bitcoin flake input
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Set lnurl domainFile for Hub-managed Lightning Address domain
|
||||||
|
- Correct disablewallet casing
|
||||||
|
- Fixed typo
|
||||||
|
[1.1.4]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.4
|
||||||
|
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Element Calling: pin LiveKit to 1.13.6 and advertise the LAN interface
|
||||||
|
(rtc.advertise_internal_ip) so Wi-Fi callers get a local ICE candidate and
|
||||||
|
calls no longer depend on router NAT hairpin
|
||||||
|
- Element Calling: use a single UDP media mux (7882); move the TURN relay to
|
||||||
|
40000-40099 and remove the unreachable 5349 TURN/TLS listener
|
||||||
|
- Hub: update Element Calling router-port guidance to the new port set
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Element Calling: TURN relay could not deliver media to the SFU on routers
|
||||||
|
without NAT loopback (turn.allow_restricted_peer_cidrs now permits the LAN
|
||||||
|
subnet derived from the primary interface)
|
||||||
|
|
||||||
|
## [1.1.3] - 2026-08-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Sparrow update
|
||||||
|
- Nixpkgs update with bisq and bisq2
|
||||||
|
- Albyhub: re-harvest hashes for fixed sovran tag
|
||||||
|
- Albyhub default.nix hash update
|
||||||
|
- Albyhub default.nix fix
|
||||||
|
- Albyhub: build from sovran fork tag (LND-only, no frontend)
|
||||||
|
- Vendor v1.24.0 LND-only, no-frontend build
|
||||||
|
- Ux: simplify domain and router setup guidance
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Evaluate local vendored package versions directly
|
||||||
|
- Use proxyVendor to preserve secp256k1-zkp cgo headers
|
||||||
|
[1.1.3]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.3
|
||||||
|
|
||||||
|
|
||||||
|
## [1.1.2] - 2026-08-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Nixpkgs update with Bisq update
|
||||||
|
- Unify public-IP detection into one privacy-first script
|
||||||
|
- Fix Element X discovery and harden federated calling
|
||||||
|
- Nixpkgs update
|
||||||
|
- Add tor-browser to the package list
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Use pkgs.lndinit, drop local packages/lndinit
|
||||||
|
- Update RTL to 0.15.10
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Move system.activationScripts under the config attribute
|
||||||
|
- Reuse Hub external IP for LiveKit, drop egress service calls
|
||||||
|
- Derive Restart required from boot default vs running system
|
||||||
|
- Reconcile completed updates after polling stalls
|
||||||
|
- Migrate i2pd SAM settings for nixpkgs 26.11
|
||||||
|
- Order turn-setup after network-online to fix boot-time red dot
|
||||||
|
- Restore a Zeus-scannable LND REST connect QR
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Prevent LND admin macaroon exposure in curl argv
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- Update desktop and Sovran Hub screenshots
|
||||||
|
[1.1.2]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.2
|
||||||
|
|
||||||
|
|
||||||
## [1.1.1] - 2026-08-15
|
## [1.1.1] - 2026-08-15
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -43,6 +208,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
manual re-pin.
|
manual re-pin.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- Zeus Connect QR in the Hub was invalid: the LND-only `lndconnect`
|
||||||
|
wrapper used flags Zeus/`lndconnect` do not understand (`--cert`,
|
||||||
|
`--macaroon`), read a non-existent onion path (`free/lnd.onion`),
|
||||||
|
and collided with LND's P2P hidden service. The wrapper now matches
|
||||||
|
the nix-bitcoin contract — dedicated `lnd-rest` onion on port 8080,
|
||||||
|
`--nocert` over Tor, admin macaroon in the URI — so scanning the Hub
|
||||||
|
QR in Zeus works again.
|
||||||
- The Hub launcher now uses a persistent per-user browser profile
|
- The Hub launcher now uses a persistent per-user browser profile
|
||||||
(`$XDG_STATE_HOME/sovran-hub-browser`) instead of a throwaway `/tmp`
|
(`$XDG_STATE_HOME/sovran-hub-browser`) instead of a throwaway `/tmp`
|
||||||
profile that was deleted on exit. The throwaway profile wiped the
|
profile that was deleted on exit. The throwaway profile wiped the
|
||||||
@@ -68,6 +240,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
polling failures instead of hanging indefinitely.
|
polling failures instead of hanging indefinitely.
|
||||||
- Rebuild/update scripts stream `nixos-rebuild` output into the live log
|
- Rebuild/update scripts stream `nixos-rebuild` output into the live log
|
||||||
(it was buffered until completion, making long rebuilds look frozen).
|
(it was buffered until completion, making long rebuilds look frozen).
|
||||||
|
- The update modal now reconciles persisted update state after a page/RDP
|
||||||
|
reconnect, bounds every status request with a timeout, prevents overlapping
|
||||||
|
async polls, and reports an explicit "status unavailable" state with a
|
||||||
|
Retry Status action instead of spinning forever. Verbose Nix logs are
|
||||||
|
rendered incrementally and bounded so they cannot stall the browser UI.
|
||||||
|
- Successful staged updates now record the exact NixOS generation. The Hub
|
||||||
|
keeps showing "Restart required" until that generation is active, then
|
||||||
|
clears the marker after reboot (with log-based recovery for older updates).
|
||||||
|
- Pending-reboot state is now derived from NixOS itself — the boot default
|
||||||
|
(`/nix/var/nix/profiles/system`) versus the running `/run/current-system`
|
||||||
|
— instead of from Hub-written marker files. Updates performed from a
|
||||||
|
terminal or support session (which never touch the Hub's status files)
|
||||||
|
previously left a stale `REBOOT_REQUIRED` marker the Hub could never
|
||||||
|
clear, pinning the "Restart required" badge forever even after many
|
||||||
|
reboots. The marker self-heals to `IDLE` on the next status read whenever
|
||||||
|
boot default and running system agree, and recording the informational
|
||||||
|
`.generation` marker can no longer fail an otherwise successful update.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ 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.1.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.1.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.png" alt="Sovran_SystemsOS private Bitcoin desktop" width="800" />
|
<img src="assets/desktop-screenshot.webp" alt="Sovran_SystemsOS private Bitcoin desktop" width="800" />
|
||||||
|
|
||||||
*Bitcoin sovereignty from the first boot.*
|
*Bitcoin sovereignty from the first boot.*
|
||||||
|
|
||||||
@@ -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,6 +341,10 @@ 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 welcome dashboard" width="800" />
|
||||||
|
|
||||||
|
*The Hub: your whole system at a glance — Bitcoin, Lightning, and your private apps.*
|
||||||
|
|
||||||
### Example home setup
|
### Example home setup
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -290,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 │
|
||||||
@@ -312,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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -341,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.1.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.1.iso)
|
- [Download Sovran_SystemsOS-1.2.0.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.0.iso)
|
||||||
- [Download Sovran_SystemsOS-1.1.1.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.1.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.
|
||||||
@@ -360,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.1.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.1.iso: OK
|
Sovran_SystemsOS-1.2.0.iso: OK
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also run `sha256sum Sovran_SystemsOS-1.1.1.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>
|
||||||
@@ -380,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.1.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.1.iso.sha256`.
|
`Sovran_SystemsOS-1.2.0.iso.sha256`.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -394,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.1.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.
|
||||||
@@ -409,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.1.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
|
||||||
@@ -472,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
|
||||||
@@ -506,13 +573,15 @@ setup, supported hardware, and Royal Membership.
|
|||||||
|
|
||||||
## For developers
|
## For developers
|
||||||
|
|
||||||
Sovran_SystemsOS combines [NixOS](https://nixos.org), an in-repository
|
Sovran_SystemsOS combines [NixOS](https://nixos.org), the standalone
|
||||||
Bitcoin and Lightning stack, desktop packages from
|
[Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) flake for
|
||||||
|
Bitcoin and Lightning, desktop packages from
|
||||||
[btc-clients-nix](https://github.com/emmanuelrosa/btc-clients-nix), and the
|
[btc-clients-nix](https://github.com/emmanuelrosa/btc-clients-nix), and the
|
||||||
Sovran Hub. The Bitcoin modules under `modules/bitcoin/` were adapted from
|
Sovran Hub. The Bitcoin and Lightning modules live in the Sovran_Bitcoin
|
||||||
[nix-bitcoin](https://github.com/fort-nix/nix-bitcoin) and are now maintained
|
repository and are consumed as a flake input. OS-specific Bitcoin customizations
|
||||||
here. Builds no longer import or fetch nix-bitcoin. Legacy `nix-bitcoin.*`
|
(Second_Drive paths, operator user, Hub integration) are bridged by
|
||||||
option names and `/etc/nix-bitcoin-secrets` remain for compatibility.
|
`modules/sovran-bitcoin-integration.nix`. Legacy `nix-bitcoin.*` option names
|
||||||
|
and `/etc/nix-bitcoin-secrets` remain for compatibility.
|
||||||
|
|
||||||
### Development workflow
|
### Development workflow
|
||||||
|
|
||||||
@@ -532,8 +601,9 @@ option names and `/etc/nix-bitcoin-secrets` remain for compatibility.
|
|||||||
|
|
||||||
- [NixOS](https://nixos.org) and [Nix flakes](https://nixos.wiki/wiki/Flakes)
|
- [NixOS](https://nixos.org) and [Nix flakes](https://nixos.wiki/wiki/Flakes)
|
||||||
for declarative, pinned system configuration
|
for declarative, pinned system configuration
|
||||||
- `modules/bitcoin/` for the in-repository Bitcoin and Lightning stack
|
- [Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) for the
|
||||||
- `packages/` for Sovran-maintained package definitions and patches
|
Bitcoin and Lightning stack (consumed as a flake input)
|
||||||
|
- `modules/sovran-bitcoin-integration.nix` for OS-specific Bitcoin overrides
|
||||||
- [btc-clients-nix](https://github.com/emmanuelrosa/btc-clients-nix) for the
|
- [btc-clients-nix](https://github.com/emmanuelrosa/btc-clients-nix) for the
|
||||||
Sparrow, Bisq, and Bisq 2 packages
|
Sparrow, Bisq, and Bisq 2 packages
|
||||||
- [Python](https://www.python.org) and [FastAPI](https://fastapi.tiangolo.com)
|
- [Python](https://www.python.org) and [FastAPI](https://fastapi.tiangolo.com)
|
||||||
@@ -624,12 +694,11 @@ sudo nixos-rebuild switch --rollback
|
|||||||
| `flake.lock` | Pins dependencies for reproducible builds |
|
| `flake.lock` | Pins dependencies for reproducible builds |
|
||||||
| `configuration.nix` | Base host, boot, desktop, user, security, backup, and system configuration |
|
| `configuration.nix` | Base host, boot, desktop, user, security, backup, and system configuration |
|
||||||
| `modules/` | Core modules, self-hosted services, and optional features |
|
| `modules/` | Core modules, self-hosted services, and optional features |
|
||||||
| `modules/bitcoin/` | In-repository Bitcoin and Lightning service modules |
|
| `modules/sovran-bitcoin-integration.nix` | OS-specific Bitcoin overrides (Second_Drive, operator, Hub wiring) |
|
||||||
| `modules/core/` | Roles, Hub integration, Caddy, desktop, support, and other core behavior |
|
| `modules/core/` | Roles, Hub integration, Caddy, desktop, support, and other core behavior |
|
||||||
| `app/` | Sovran Hub backend, templates, static assets, scripts, and web interface |
|
| `app/` | Sovran Hub backend, templates, static assets, scripts, and web interface |
|
||||||
| `scripts/` | Automated release, build, and CDN upload utility scripts |
|
| `scripts/` | Automated release, build, and CDN upload utility scripts |
|
||||||
| `iso/` | Installer configuration, installer code, and installer assets |
|
| `iso/` | Installer configuration, installer code, and installer assets |
|
||||||
| `packages/` | Sovran-maintained package definitions and patches |
|
|
||||||
| `tests/` | Security and Nix integration checks |
|
| `tests/` | Security and Nix integration checks |
|
||||||
| `assets/` | Documentation images |
|
| `assets/` | Documentation images |
|
||||||
| `custom.template.nix` | Template for local features and service overrides |
|
| `custom.template.nix` | Template for local features and service overrides |
|
||||||
@@ -685,14 +754,12 @@ rebuilds the machine into the selected declarative state.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Core platform: roles, Hub, desktop integration, Caddy, domains, support, remote deployment | `modules/core/` |
|
| Core platform: roles, Hub, desktop integration, Caddy, domains, support, remote deployment | `modules/core/` |
|
||||||
| Shared credentials | `modules/credentials.nix` |
|
| Shared credentials | `modules/credentials.nix` |
|
||||||
| Bitcoin and Lightning stack | `modules/bitcoinecosystem.nix` |
|
| Bitcoin and Lightning stack | [Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) flake input + `modules/sovran-bitcoin-integration.nix` |
|
||||||
| Automatic wallet-to-node connections | `modules/wallet-autoconnect.nix` |
|
| Automatic wallet-to-node connections | `modules/wallet-autoconnect.nix` |
|
||||||
| Alby Hub and Nostr Wallet Connect (NWC) on LND | `modules/nwc-wallets.nix`, `packages/albyhub/` |
|
|
||||||
| Matrix Synapse | `modules/synapse.nix` |
|
| Matrix Synapse | `modules/synapse.nix` |
|
||||||
| Optional Element audio and video calling via LiveKit | `modules/element-calling.nix` |
|
| Optional Element audio and video calling via LiveKit | `modules/element-calling.nix` |
|
||||||
| Optional Haven Nostr relay | `modules/haven.nix` |
|
| Optional Haven Nostr relay | `modules/haven.nix` |
|
||||||
| Nextcloud, Vaultwarden, WordPress | `modules/nextcloud.nix`, `modules/vaultwarden.nix`, `modules/wordpress.nix`, `modules/php.nix` |
|
| Nextcloud, Vaultwarden, WordPress | `modules/nextcloud.nix`, `modules/vaultwarden.nix`, `modules/wordpress.nix`, `modules/php.nix` |
|
||||||
| Optional Mempool explorer | `modules/mempool.nix` |
|
|
||||||
| Optional remote desktop and public SSH | `modules/rdp.nix`, `modules/sshd.nix` |
|
| Optional remote desktop and public SSH | `modules/rdp.nix`, `modules/sshd.nix` |
|
||||||
|
|
||||||
Feature availability and defaults may change as Sovran_SystemsOS develops.
|
Feature availability and defaults may change as Sovran_SystemsOS develops.
|
||||||
@@ -726,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
|
||||||
@@ -852,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.1.iso) ·
|
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.0.iso) ·
|
||||||
[View the License](LICENSE)
|
[View the License](LICENSE)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+64
-7
@@ -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,12 +35,68 @@ 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.
|
||||||
|
|
||||||
### In-repository Bitcoin stack
|
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.
|
||||||
|
|
||||||
Bitcoin and Lightning modules are maintained under `modules/bitcoin/`. They were
|
On Desktop Only the Hub is not published at all. It is reachable only from the
|
||||||
adapted from nix-bitcoin, but Sovran builds do not import or fetch nix-bitcoin.
|
machine itself, through the desktop application window on localhost. Desktop
|
||||||
The `nix-bitcoin.*` option namespace and `/etc/nix-bitcoin-secrets` path remain
|
Only is the role most likely to be used away from home, and a root-capable admin
|
||||||
only for upgrade compatibility.
|
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 and Lightning modules are maintained in the standalone
|
||||||
|
[Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) repository
|
||||||
|
and consumed as a flake input. OS-specific customizations (Second_Drive paths,
|
||||||
|
operator user, Hub integration) are bridged by
|
||||||
|
`modules/sovran-bitcoin-integration.nix`. The `nix-bitcoin.*` option namespace
|
||||||
|
and `/etc/nix-bitcoin-secrets` path remain only for upgrade compatibility.
|
||||||
|
|
||||||
### Supply chain and integrity
|
### Supply chain and integrity
|
||||||
|
|
||||||
@@ -62,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.
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
## nix-bitcoin
|
## nix-bitcoin
|
||||||
|
|
||||||
Portions of `modules/bitcoin/` and selected package definitions were adapted
|
The Bitcoin and Lightning modules were originally adapted from
|
||||||
from [fort-nix/nix-bitcoin](https://github.com/fort-nix/nix-bitcoin), commit
|
[fort-nix/nix-bitcoin](https://github.com/fort-nix/nix-bitcoin), commit
|
||||||
[`360e30fee5ba32f9fecc89bc35628195d9d2dbbe`](https://github.com/fort-nix/nix-bitcoin/commit/360e30fee5ba32f9fecc89bc35628195d9d2dbbe).
|
[`360e30fee5ba32f9fecc89bc35628195d9d2dbbe`](https://github.com/fort-nix/nix-bitcoin/commit/360e30fee5ba32f9fecc89bc35628195d9d2dbbe).
|
||||||
The current Sovran build does not import or fetch nix-bitcoin.
|
They now live in the standalone
|
||||||
|
[Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) repository
|
||||||
|
and are consumed by Sovran_SystemsOS as a flake input. The current Sovran build
|
||||||
|
does not import or fetch nix-bitcoin.
|
||||||
|
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
"""
|
|
||||||
Structured audit logging for NWC wallet operations.
|
|
||||||
|
|
||||||
Writes append-only JSON lines to /var/log/sovran-nwc-audit.log.
|
|
||||||
Log file is owned by albyhub:albyhub with mode 0600.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
AUDIT_LOG_PATH = "/var/log/sovran-nwc-audit.log"
|
|
||||||
_AUDIT_LOCK = threading.Lock()
|
|
||||||
_initialized = False
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_log_file() -> None:
|
|
||||||
"""Ensure audit log file exists with correct permissions."""
|
|
||||||
global _initialized
|
|
||||||
if _initialized:
|
|
||||||
return
|
|
||||||
with _AUDIT_LOCK:
|
|
||||||
if _initialized:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
# Create directory if needed
|
|
||||||
os.makedirs(os.path.dirname(AUDIT_LOG_PATH), exist_ok=True)
|
|
||||||
# Create file if it doesn't exist
|
|
||||||
if not os.path.exists(AUDIT_LOG_PATH):
|
|
||||||
with open(AUDIT_LOG_PATH, "w") as f:
|
|
||||||
pass
|
|
||||||
# Set restrictive permissions
|
|
||||||
os.chmod(AUDIT_LOG_PATH, 0o600)
|
|
||||||
# Try to set ownership to albyhub user (best effort)
|
|
||||||
try:
|
|
||||||
import pwd
|
|
||||||
import grp
|
|
||||||
albyhub_uid = pwd.getpwnam("albyhub").pw_uid
|
|
||||||
albyhub_gid = grp.getgrnam("albyhub").gr_gid
|
|
||||||
os.chown(AUDIT_LOG_PATH, albyhub_uid, albyhub_gid)
|
|
||||||
except Exception:
|
|
||||||
pass # Best effort; may not have permissions
|
|
||||||
_initialized = True
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Failed to initialize audit log: %s", exc)
|
|
||||||
|
|
||||||
|
|
||||||
def audit_log(event: str, **fields: Any) -> None:
|
|
||||||
"""Write a structured audit log entry.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
event: Event type identifier (e.g., "wallet_created", "invoice_issued")
|
|
||||||
**fields: Additional key-value fields to include in the log entry
|
|
||||||
"""
|
|
||||||
_ensure_log_file()
|
|
||||||
|
|
||||||
entry = {
|
|
||||||
"ts": time.time(),
|
|
||||||
"event": event,
|
|
||||||
**fields,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
with _AUDIT_LOCK:
|
|
||||||
with open(AUDIT_LOG_PATH, "a") as f:
|
|
||||||
f.write(json.dumps(entry, separators=(",", ":")) + "\n")
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("Failed to write audit log: %s", exc)
|
|
||||||
@@ -1,881 +0,0 @@
|
|||||||
"""
|
|
||||||
Alby Hub manager — shared backend for Lightning Wallet Connections API and recovery CLI.
|
|
||||||
|
|
||||||
Interfaces with the local Alby Hub instance at
|
|
||||||
http://127.0.0.1:18080 by default (override with NWC_ALBY_HUB_API_BASE).
|
|
||||||
All sensitive values (passwords, bearer tokens, pairing URIs, macaroon
|
|
||||||
contents, Nostr private keys) are redacted from any exception messages
|
|
||||||
or log output.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import secrets
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import urllib.error
|
|
||||||
import urllib.parse
|
|
||||||
import urllib.request
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from . import nwc_audit as _audit_mod
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# ── Constants ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
DEFAULT_API_BASE = os.environ.get(
|
|
||||||
"NWC_ALBY_HUB_API_BASE",
|
|
||||||
"http://127.0.0.1:18080",
|
|
||||||
)
|
|
||||||
DEFAULT_UNLOCK_PASSWORD_FILE = "/var/lib/albyhub/unlock-password"
|
|
||||||
DEFAULT_MACAROON_FILE = os.environ.get(
|
|
||||||
"NWC_LND_MACAROON_FILE", "/run/lnd/albyhub.macaroon"
|
|
||||||
)
|
|
||||||
DEFAULT_LND_ADDRESS = os.environ.get("NWC_LND_ADDRESS", "127.0.0.1:10009")
|
|
||||||
DEFAULT_LND_CERT_FILE = os.environ.get("NWC_LND_CERT_FILE", "/var/lib/lnd/tls.cert")
|
|
||||||
DEFAULT_LND_SOCKET = "/run/lnd/lnd.socket"
|
|
||||||
|
|
||||||
LNURL_DESCRIPTION_DEFAULT = "Pay via Lightning"
|
|
||||||
NWC_MIN_SENDABLE_MSAT = 1000
|
|
||||||
NWC_MAX_SENDABLE_MSAT = 1_000_000_000
|
|
||||||
|
|
||||||
# Metadata key used to mark managed isolated wallets
|
|
||||||
_MANAGED_APP_STORE_ID = "uncle-jim"
|
|
||||||
_MANAGED_META_KEY = "app_store_app_id"
|
|
||||||
|
|
||||||
RECEIVE_ONLY_SCOPES = [
|
|
||||||
"get_info",
|
|
||||||
"get_balance",
|
|
||||||
"make_invoice",
|
|
||||||
"lookup_invoice",
|
|
||||||
"list_transactions",
|
|
||||||
"notifications",
|
|
||||||
]
|
|
||||||
|
|
||||||
LIMITED_SEND_SCOPES = RECEIVE_ONLY_SCOPES + ["pay_invoice"]
|
|
||||||
|
|
||||||
# ── Exceptions ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class AlbyHubError(Exception):
|
|
||||||
"""Base error from the Alby Hub manager.
|
|
||||||
|
|
||||||
The message string is safe to surface to the user — it never
|
|
||||||
contains raw secret material.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, code: str, message: str) -> None:
|
|
||||||
super().__init__(message)
|
|
||||||
self.code = code
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"[{self.code}] {self.args[0]}"
|
|
||||||
|
|
||||||
|
|
||||||
class AlbyHubHttpError(AlbyHubError):
|
|
||||||
def __init__(self, status_code: int, message: str) -> None:
|
|
||||||
super().__init__(f"http_{status_code}", message)
|
|
||||||
self.status_code = status_code
|
|
||||||
|
|
||||||
|
|
||||||
# ── Manager class ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class AlbyHubManager:
|
|
||||||
"""Thread-safe manager for Alby Hub API operations."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
api_base: str = DEFAULT_API_BASE,
|
|
||||||
unlock_password_file: str = DEFAULT_UNLOCK_PASSWORD_FILE,
|
|
||||||
macaroon_file: str = DEFAULT_MACAROON_FILE,
|
|
||||||
lnd_address: str = DEFAULT_LND_ADDRESS,
|
|
||||||
lnd_cert_file: str = DEFAULT_LND_CERT_FILE,
|
|
||||||
) -> None:
|
|
||||||
self.api_base = api_base.rstrip("/")
|
|
||||||
self.unlock_password_file = unlock_password_file
|
|
||||||
self.macaroon_file = macaroon_file
|
|
||||||
self.lnd_address = lnd_address
|
|
||||||
self.lnd_cert_file = lnd_cert_file
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
self._token: str | None = None
|
|
||||||
|
|
||||||
# ── Low-level HTTP ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _request(
|
|
||||||
self,
|
|
||||||
method: str,
|
|
||||||
path: str,
|
|
||||||
body: dict | None = None,
|
|
||||||
token: str | None = None,
|
|
||||||
timeout: int = 30,
|
|
||||||
) -> dict:
|
|
||||||
"""Make a raw HTTP request to the local Alby Hub API.
|
|
||||||
|
|
||||||
Returns the parsed JSON response body.
|
|
||||||
Raises AlbyHubHttpError on non-2xx responses.
|
|
||||||
Secrets in response bodies are never included in raised exceptions.
|
|
||||||
"""
|
|
||||||
url = f"{self.api_base}{path}"
|
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
||||||
headers: dict[str, str] = {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Accept": "application/json",
|
|
||||||
}
|
|
||||||
if token:
|
|
||||||
headers["Authorization"] = "Bearer " + token
|
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
||||||
raw = resp.read()
|
|
||||||
if not raw:
|
|
||||||
return {}
|
|
||||||
return json.loads(raw)
|
|
||||||
except urllib.error.HTTPError as exc:
|
|
||||||
code = exc.code
|
|
||||||
# Read and discard the body — we do NOT include it in the exception
|
|
||||||
try:
|
|
||||||
exc.read()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
raise AlbyHubHttpError(code, f"Hub API {method} {path} returned HTTP {code}") from None
|
|
||||||
except (urllib.error.URLError, TimeoutError, OSError):
|
|
||||||
raise AlbyHubError(
|
|
||||||
"hub_unreachable",
|
|
||||||
f"Hub API {method} {path} is unreachable",
|
|
||||||
) from None
|
|
||||||
|
|
||||||
def _authenticated_request(
|
|
||||||
self,
|
|
||||||
method: str,
|
|
||||||
path: str,
|
|
||||||
body: dict | None = None,
|
|
||||||
timeout: int = 30,
|
|
||||||
) -> dict:
|
|
||||||
"""Make an authenticated request; retry once with a fresh token on 401/403."""
|
|
||||||
token = self.ensure_ready()
|
|
||||||
try:
|
|
||||||
return self._request(method, path, body=body, token=token, timeout=timeout)
|
|
||||||
except AlbyHubHttpError as exc:
|
|
||||||
if exc.status_code in (401, 403):
|
|
||||||
with self._lock:
|
|
||||||
self._token = None
|
|
||||||
token = self.ensure_ready()
|
|
||||||
return self._request(method, path, body=body, token=token, timeout=timeout)
|
|
||||||
raise
|
|
||||||
|
|
||||||
def _paginate(self, path_template: str, page_size: int = 100) -> list[dict]:
|
|
||||||
"""Paginate a list API completely, collecting all items.
|
|
||||||
|
|
||||||
``path_template`` must contain ``{limit}`` and ``{offset}`` placeholders.
|
|
||||||
"""
|
|
||||||
token = self.ensure_ready()
|
|
||||||
offset = 0
|
|
||||||
results: list[dict] = []
|
|
||||||
while True:
|
|
||||||
path = path_template.format(limit=page_size, offset=offset)
|
|
||||||
page = self._request("GET", path, token=token)
|
|
||||||
# Alby Hub returns apps at the top level or under "apps"/"transactions"
|
|
||||||
total_count: int | None = None
|
|
||||||
if isinstance(page, list):
|
|
||||||
items = page
|
|
||||||
elif isinstance(page, dict):
|
|
||||||
items = page.get("apps") or page.get("transactions") or []
|
|
||||||
if page.get("totalCount") is not None:
|
|
||||||
total_count = int(page.get("totalCount"))
|
|
||||||
else:
|
|
||||||
items = []
|
|
||||||
if not isinstance(items, list):
|
|
||||||
break
|
|
||||||
results.extend(items)
|
|
||||||
if total_count is not None:
|
|
||||||
if len(results) >= total_count:
|
|
||||||
break
|
|
||||||
elif len(items) < page_size:
|
|
||||||
break
|
|
||||||
offset += page_size
|
|
||||||
return results
|
|
||||||
|
|
||||||
# ── Audit log helper ───────────────────────────────────────────
|
|
||||||
|
|
||||||
def _audit(self, event: str, **fields: Any) -> None:
|
|
||||||
"""Emit structured audit log entry."""
|
|
||||||
_audit_mod.audit_log(event, **fields)
|
|
||||||
|
|
||||||
# ── Startup / Auth ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _read_unlock_password(self) -> str:
|
|
||||||
try:
|
|
||||||
with open(self.unlock_password_file, "r") as fh:
|
|
||||||
return fh.read().strip()
|
|
||||||
except OSError as exc:
|
|
||||||
raise AlbyHubError(
|
|
||||||
"unlock_password_unavailable",
|
|
||||||
"Cannot read Alby Hub unlock password",
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
def _wait_for_file(self, path: str, timeout: int = 120) -> None:
|
|
||||||
deadline = time.monotonic() + timeout
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
if os.path.exists(path):
|
|
||||||
return
|
|
||||||
time.sleep(2)
|
|
||||||
raise AlbyHubError(
|
|
||||||
"dependency_unavailable",
|
|
||||||
f"Timed out waiting for required file: {path}",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _wait_for_hub_api(self, timeout: int = 120) -> None:
|
|
||||||
deadline = time.monotonic() + timeout
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
try:
|
|
||||||
self._request("GET", "/api/info", timeout=5)
|
|
||||||
return
|
|
||||||
except AlbyHubError:
|
|
||||||
pass
|
|
||||||
time.sleep(3)
|
|
||||||
raise AlbyHubError("hub_unavailable", "Timed out waiting for Alby Hub API")
|
|
||||||
|
|
||||||
def _hub_setup(self, password: str) -> None:
|
|
||||||
"""Perform /api/setup idempotently."""
|
|
||||||
try:
|
|
||||||
info = self._request("GET", "/api/info", timeout=10)
|
|
||||||
if info.get("setupCompleted"):
|
|
||||||
return
|
|
||||||
except AlbyHubError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
setup_body = {
|
|
||||||
"backendType": "LND",
|
|
||||||
"unlockPassword": password,
|
|
||||||
"lndAddress": self.lnd_address,
|
|
||||||
"lndCertFile": self.lnd_cert_file,
|
|
||||||
"lndMacaroonFile": self.macaroon_file,
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
self._request("POST", "/api/setup", body=setup_body, timeout=30)
|
|
||||||
except AlbyHubHttpError as exc:
|
|
||||||
if exc.status_code == 409:
|
|
||||||
return # already setup
|
|
||||||
raise
|
|
||||||
|
|
||||||
def _obtain_token(self, password: str) -> str:
|
|
||||||
info = self._request("GET", "/api/info", timeout=10)
|
|
||||||
if info.get("running"):
|
|
||||||
resp = self._request(
|
|
||||||
"POST",
|
|
||||||
"/api/unlock",
|
|
||||||
body={
|
|
||||||
"unlockPassword": password,
|
|
||||||
"permission": "full",
|
|
||||||
},
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
resp = self._request(
|
|
||||||
"POST",
|
|
||||||
"/api/start",
|
|
||||||
body={"unlockPassword": password},
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
token = (
|
|
||||||
resp.get("token")
|
|
||||||
or resp.get("accessToken")
|
|
||||||
or resp.get("access_token")
|
|
||||||
)
|
|
||||||
if not token or not isinstance(token, str):
|
|
||||||
raise AlbyHubError("auth_failed", "Alby Hub auth response missing token")
|
|
||||||
return token
|
|
||||||
|
|
||||||
def _wait_for_node_ready(self, token: str, timeout: int = 120) -> None:
|
|
||||||
deadline = time.monotonic() + timeout
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
try:
|
|
||||||
status = self._request(
|
|
||||||
"GET", "/api/node/status", token=token, timeout=10
|
|
||||||
)
|
|
||||||
if status.get("isReady") or status.get("running") or status.get("online"):
|
|
||||||
return
|
|
||||||
except AlbyHubError:
|
|
||||||
pass
|
|
||||||
time.sleep(3)
|
|
||||||
raise AlbyHubError("node_not_ready", "Timed out waiting for Alby Hub node to be ready")
|
|
||||||
|
|
||||||
def ensure_ready(self) -> str:
|
|
||||||
"""Ensure Alby Hub is set up, unlocked, and authenticated.
|
|
||||||
|
|
||||||
Returns a valid bearer token. Caches it and uses a lock to
|
|
||||||
prevent concurrent setup races.
|
|
||||||
"""
|
|
||||||
with self._lock:
|
|
||||||
if self._token:
|
|
||||||
return self._token
|
|
||||||
|
|
||||||
password = self._read_unlock_password()
|
|
||||||
self._wait_for_file(self.macaroon_file, timeout=120)
|
|
||||||
self._wait_for_hub_api(timeout=120)
|
|
||||||
self._hub_setup(password)
|
|
||||||
token = self._obtain_token(password)
|
|
||||||
self._wait_for_node_ready(token, timeout=120)
|
|
||||||
self._token = token
|
|
||||||
return token
|
|
||||||
|
|
||||||
# ── App isolation helpers ──────────────────────────────────────
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _parse_metadata(raw: Any) -> dict:
|
|
||||||
if isinstance(raw, dict):
|
|
||||||
return raw
|
|
||||||
if isinstance(raw, str):
|
|
||||||
try:
|
|
||||||
result = json.loads(raw)
|
|
||||||
if isinstance(result, dict):
|
|
||||||
return result
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def _is_managed_app(self, app: dict) -> bool:
|
|
||||||
meta = self._parse_metadata(app.get("metadata"))
|
|
||||||
alias = str(meta.get("lnurl_alias", "")).strip().lower()
|
|
||||||
return (
|
|
||||||
meta.get(_MANAGED_META_KEY) == _MANAGED_APP_STORE_ID
|
|
||||||
and bool(alias)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _app_to_wallet_meta(self, app: dict, domain: str | None) -> dict:
|
|
||||||
meta = self._parse_metadata(app.get("metadata"))
|
|
||||||
alias = meta.get("lnurl_alias", "")
|
|
||||||
address = f"{alias}@{domain}" if alias and domain else None
|
|
||||||
|
|
||||||
scopes = app.get("scopes") or []
|
|
||||||
access_preset = (
|
|
||||||
"send_receive_limited" if "pay_invoice" in scopes else "receive_only"
|
|
||||||
)
|
|
||||||
|
|
||||||
balance_msat = int(app.get("balanceMsat", 0) or 0)
|
|
||||||
balance_sats = balance_msat // 1000
|
|
||||||
dust_msat = balance_msat % 1000
|
|
||||||
|
|
||||||
spending_limit_sats: int | None = None
|
|
||||||
max_amount = app.get("maxAmountSat") or 0
|
|
||||||
if max_amount:
|
|
||||||
spending_limit_sats = int(max_amount)
|
|
||||||
|
|
||||||
# Count pending transactions from the budget or transactions list
|
|
||||||
pending_txs = int(app.get("pendingTransactionsCount", 0) or 0)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": str(app.get("id", "")),
|
|
||||||
"pubkey": app.get("appPubkey") or app.get("nostrPubkey") or app.get("pubkey") or "",
|
|
||||||
"name": app.get("name", ""),
|
|
||||||
"alias": alias,
|
|
||||||
"lightning_address": address,
|
|
||||||
"access_preset": access_preset,
|
|
||||||
"spending_limit_sats": spending_limit_sats,
|
|
||||||
"balance_sats": balance_sats,
|
|
||||||
"dust_msat": dust_msat,
|
|
||||||
"pending_transactions": pending_txs,
|
|
||||||
"created_at": app.get("createdAt") or app.get("created_at"),
|
|
||||||
"min_sendable_msat": int(
|
|
||||||
meta.get("lnurl_min_sendable_msat", NWC_MIN_SENDABLE_MSAT)
|
|
||||||
),
|
|
||||||
"max_sendable_msat": int(
|
|
||||||
meta.get("lnurl_max_sendable_msat", NWC_MAX_SENDABLE_MSAT)
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _all_managed_apps(self) -> list[dict]:
|
|
||||||
apps = self._paginate("/api/apps?limit={limit}&offset={offset}&order_by=created_at")
|
|
||||||
return [a for a in apps if a.get("isolated") and self._is_managed_app(a)]
|
|
||||||
|
|
||||||
def _find_managed_app(self, identifier: str) -> dict | None:
|
|
||||||
needle = identifier.strip().lower()
|
|
||||||
for app in self._all_managed_apps():
|
|
||||||
if str(app.get("id", "")).lower() == needle:
|
|
||||||
return app
|
|
||||||
pubkey = (
|
|
||||||
app.get("appPubkey") or app.get("nostrPubkey") or app.get("pubkey") or ""
|
|
||||||
).lower()
|
|
||||||
if pubkey == needle:
|
|
||||||
return app
|
|
||||||
return None
|
|
||||||
|
|
||||||
# ── Public API ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def list_wallets(self, domain: str | None = None) -> list[dict]:
|
|
||||||
"""Return all managed isolated app wallets (no secrets)."""
|
|
||||||
wallets = []
|
|
||||||
for app in self._all_managed_apps():
|
|
||||||
app_copy = dict(app)
|
|
||||||
app_copy["pendingTransactionsCount"] = len(self._get_app_pending_txs(int(app["id"])))
|
|
||||||
wallets.append(self._app_to_wallet_meta(app_copy, domain))
|
|
||||||
return wallets
|
|
||||||
|
|
||||||
def create_wallet(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
alias: str,
|
|
||||||
access_preset: str,
|
|
||||||
spending_limit_sats: int | None,
|
|
||||||
domain: str | None = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Create a new isolated Alby Hub app (wallet connection).
|
|
||||||
|
|
||||||
Returns a dict containing:
|
|
||||||
``wallet`` — safe metadata (no secrets)
|
|
||||||
``pairing_uri`` — real Alby Hub pairingUri (returned ONCE)
|
|
||||||
``result`` — creation status report
|
|
||||||
"""
|
|
||||||
# Validate uniqueness
|
|
||||||
managed = self._all_managed_apps()
|
|
||||||
for a in managed:
|
|
||||||
meta = self._parse_metadata(a.get("metadata"))
|
|
||||||
if meta.get("lnurl_alias", "").lower() == alias.lower():
|
|
||||||
raise AlbyHubError(
|
|
||||||
"alias_exists", "That Lightning Address alias is already in use."
|
|
||||||
)
|
|
||||||
if (a.get("name") or "").lower() == name.lower():
|
|
||||||
raise AlbyHubError(
|
|
||||||
"wallet_name_exists",
|
|
||||||
"That Wallet Connection name already exists.",
|
|
||||||
)
|
|
||||||
|
|
||||||
scopes = (
|
|
||||||
LIMITED_SEND_SCOPES
|
|
||||||
if access_preset == "send_receive_limited"
|
|
||||||
else RECEIVE_ONLY_SCOPES
|
|
||||||
)
|
|
||||||
max_amount = (
|
|
||||||
spending_limit_sats
|
|
||||||
if access_preset == "send_receive_limited" and spending_limit_sats
|
|
||||||
else 0
|
|
||||||
)
|
|
||||||
|
|
||||||
create_body: dict = {
|
|
||||||
"name": name,
|
|
||||||
"scopes": scopes,
|
|
||||||
"isolated": True,
|
|
||||||
"budgetRenewal": "never",
|
|
||||||
"maxAmountSat": max_amount,
|
|
||||||
"metadata": {
|
|
||||||
_MANAGED_META_KEY: _MANAGED_APP_STORE_ID,
|
|
||||||
"lnurl_alias": alias,
|
|
||||||
"lnurl_description": LNURL_DESCRIPTION_DEFAULT,
|
|
||||||
"lnurl_min_sendable_msat": NWC_MIN_SENDABLE_MSAT,
|
|
||||||
"lnurl_max_sendable_msat": NWC_MAX_SENDABLE_MSAT,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
resp = self._authenticated_request("POST", "/api/apps", body=create_body)
|
|
||||||
pairing_uri: str = resp.get("pairingUri") or resp.get("pairing_uri") or ""
|
|
||||||
app_id = resp.get("id")
|
|
||||||
|
|
||||||
# Fetch full app details for accurate metadata
|
|
||||||
app_detail: dict | None = None
|
|
||||||
if app_id is not None:
|
|
||||||
try:
|
|
||||||
app_detail = self._authenticated_request(
|
|
||||||
"GET", f"/api/v2/apps/{app_id}"
|
|
||||||
)
|
|
||||||
except AlbyHubError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if app_detail is None:
|
|
||||||
# Fallback: search recent apps for the one we just created
|
|
||||||
updated = self._all_managed_apps()
|
|
||||||
for a in updated:
|
|
||||||
if str(a.get("id", "")) == str(app_id):
|
|
||||||
app_detail = a
|
|
||||||
break
|
|
||||||
|
|
||||||
wallet_meta = self._app_to_wallet_meta(app_detail or resp, domain)
|
|
||||||
|
|
||||||
# Initial internal transfer for limited wallets
|
|
||||||
funding_result: dict = {"attempted": False, "success": False}
|
|
||||||
if (
|
|
||||||
access_preset == "send_receive_limited"
|
|
||||||
and spending_limit_sats
|
|
||||||
and app_id is not None
|
|
||||||
):
|
|
||||||
funding_result["attempted"] = True
|
|
||||||
try:
|
|
||||||
self._authenticated_request(
|
|
||||||
"POST",
|
|
||||||
"/api/transfers",
|
|
||||||
body={
|
|
||||||
"toAppId": int(app_id),
|
|
||||||
"amountSat": spending_limit_sats,
|
|
||||||
"description": f"Initial funding for {name}",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
funding_result["success"] = True
|
|
||||||
except AlbyHubError as exc:
|
|
||||||
funding_result["error"] = exc.code
|
|
||||||
funding_result["message"] = (
|
|
||||||
"The wallet was created successfully and the NWC connection secret is shown "
|
|
||||||
"above, but initial funding failed. Save the NWC secret now. "
|
|
||||||
"Do not recreate this wallet."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Audit log: wallet created
|
|
||||||
self._audit(
|
|
||||||
"wallet_created",
|
|
||||||
wallet_id=str(app_id) if app_id else "unknown",
|
|
||||||
name=name,
|
|
||||||
alias=alias,
|
|
||||||
access_preset=access_preset,
|
|
||||||
spending_limit_sats=spending_limit_sats,
|
|
||||||
lightning_address=wallet_meta.get("lightning_address"),
|
|
||||||
funding_attempted=funding_result["attempted"],
|
|
||||||
funding_success=funding_result["success"],
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"wallet": wallet_meta,
|
|
||||||
"pairing_uri": pairing_uri, # returned once on create only
|
|
||||||
"result": {
|
|
||||||
"wallet_created": True,
|
|
||||||
"secret_created": bool(pairing_uri),
|
|
||||||
"lightning_address_registered": bool(alias and domain),
|
|
||||||
"funding": funding_result,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def _get_app_balance_msat(self, app: dict) -> int:
|
|
||||||
return int(app.get("balanceMsat", 0) or 0)
|
|
||||||
|
|
||||||
def _get_app_pending_txs(self, app_id: int) -> list[dict]:
|
|
||||||
txs = self._paginate(
|
|
||||||
f"/api/transactions?appId={app_id}&limit={{limit}}&offset={{offset}}"
|
|
||||||
)
|
|
||||||
return [
|
|
||||||
t for t in txs if str(t.get("state", "")).lower() == "pending"
|
|
||||||
]
|
|
||||||
|
|
||||||
def drain_wallet(self, identifier: str) -> dict:
|
|
||||||
"""Drain all whole-satoshi funds from an isolated app to the primary wallet.
|
|
||||||
|
|
||||||
Returns ``{"ok": True, "drained_sats": N, "dust_msat": M}``.
|
|
||||||
Raises AlbyHubError on rejection or failure.
|
|
||||||
"""
|
|
||||||
app = self._find_managed_app(identifier)
|
|
||||||
if app is None:
|
|
||||||
raise AlbyHubError("wallet_not_found", "Wallet connection not found.")
|
|
||||||
|
|
||||||
app_id = int(app["id"])
|
|
||||||
balance_msat = self._get_app_balance_msat(app)
|
|
||||||
|
|
||||||
if balance_msat < 0:
|
|
||||||
raise AlbyHubError("negative_balance", "Wallet has a negative balance.")
|
|
||||||
|
|
||||||
pending = self._get_app_pending_txs(app_id)
|
|
||||||
if pending:
|
|
||||||
raise AlbyHubError(
|
|
||||||
"pending_transactions",
|
|
||||||
"Wallet has pending transactions and cannot be drained.",
|
|
||||||
)
|
|
||||||
|
|
||||||
transferable_msat = (balance_msat // 1000) * 1000
|
|
||||||
expected_dust_msat = balance_msat - transferable_msat
|
|
||||||
|
|
||||||
if transferable_msat == 0:
|
|
||||||
return {"ok": True, "drained_sats": 0, "dust_msat": expected_dust_msat}
|
|
||||||
|
|
||||||
# Save original permissions
|
|
||||||
original_scopes = list(app.get("scopes") or [])
|
|
||||||
original_max = app.get("maxAmountSat") or 0
|
|
||||||
original_renewal = app.get("budgetRenewal") or "never"
|
|
||||||
|
|
||||||
# Temporarily grant pay_invoice scope with sufficient budget
|
|
||||||
app_pubkey = app.get("appPubkey") or app.get("nostrPubkey") or app.get("pubkey") or ""
|
|
||||||
if not app_pubkey:
|
|
||||||
raise AlbyHubError(
|
|
||||||
"app_pubkey_missing",
|
|
||||||
"Cannot drain app: app public key not available.",
|
|
||||||
)
|
|
||||||
|
|
||||||
patch_body = {
|
|
||||||
"scopes": sorted(set(original_scopes) | {"pay_invoice"}),
|
|
||||||
"maxAmountSat": 0,
|
|
||||||
"budgetRenewal": "never",
|
|
||||||
}
|
|
||||||
self._authenticated_request("PATCH", f"/api/apps/{app_pubkey}", body=patch_body)
|
|
||||||
|
|
||||||
drain_error: AlbyHubError | None = None
|
|
||||||
drained_sats = 0
|
|
||||||
try:
|
|
||||||
self._authenticated_request(
|
|
||||||
"POST",
|
|
||||||
"/api/transfers",
|
|
||||||
body={
|
|
||||||
"fromAppId": app_id,
|
|
||||||
"amountMsat": transferable_msat,
|
|
||||||
"description": f"Drain isolated subwallet {app.get('name', '')}",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
drained_sats = transferable_msat // 1000
|
|
||||||
except AlbyHubError as exc:
|
|
||||||
drain_error = exc
|
|
||||||
finally:
|
|
||||||
# Restore original permissions whether drain succeeded or not
|
|
||||||
restore_body = {
|
|
||||||
"scopes": original_scopes,
|
|
||||||
"maxAmountSat": original_max,
|
|
||||||
"budgetRenewal": original_renewal,
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
self._authenticated_request(
|
|
||||||
"PATCH", f"/api/apps/{app_pubkey}", body=restore_body
|
|
||||||
)
|
|
||||||
except AlbyHubError:
|
|
||||||
pass # best-effort restore; don't mask the original error
|
|
||||||
|
|
||||||
if drain_error is not None:
|
|
||||||
raise drain_error
|
|
||||||
|
|
||||||
# Verify remaining balance equals expected dust
|
|
||||||
refreshed = self._authenticated_request("GET", f"/api/v2/apps/{app_id}")
|
|
||||||
remaining_msat = self._get_app_balance_msat(refreshed)
|
|
||||||
if remaining_msat != expected_dust_msat:
|
|
||||||
raise AlbyHubError(
|
|
||||||
"drain_incomplete",
|
|
||||||
"Drain verification failed: final balance does not match expected dust.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Audit log: wallet drained
|
|
||||||
self._audit(
|
|
||||||
"wallet_drained",
|
|
||||||
wallet_id=str(app_id),
|
|
||||||
name=app.get("name", ""),
|
|
||||||
alias=app.get("alias", ""),
|
|
||||||
drained_sats=drained_sats,
|
|
||||||
dust_msat=expected_dust_msat,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"drained_sats": drained_sats,
|
|
||||||
"dust_msat": expected_dust_msat,
|
|
||||||
"remaining_msat": remaining_msat,
|
|
||||||
}
|
|
||||||
|
|
||||||
def delete_wallet(self, identifier: str) -> dict:
|
|
||||||
"""Safely drain and delete an isolated app.
|
|
||||||
|
|
||||||
Returns ``{"ok": True, "drained_sats": N}``.
|
|
||||||
"""
|
|
||||||
app = self._find_managed_app(identifier)
|
|
||||||
if app is None:
|
|
||||||
raise AlbyHubError("wallet_not_found", "Wallet connection not found.")
|
|
||||||
|
|
||||||
app_id = int(app["id"])
|
|
||||||
|
|
||||||
pending = self._get_app_pending_txs(app_id)
|
|
||||||
if pending:
|
|
||||||
raise AlbyHubError(
|
|
||||||
"pending_transactions",
|
|
||||||
"Wallet has pending transactions and cannot be deleted.",
|
|
||||||
)
|
|
||||||
|
|
||||||
drain_result = self.drain_wallet(identifier)
|
|
||||||
|
|
||||||
# Verify no transferable balance remains
|
|
||||||
refreshed = self._authenticated_request("GET", f"/api/v2/apps/{app_id}")
|
|
||||||
remaining_msat = self._get_app_balance_msat(refreshed)
|
|
||||||
if remaining_msat < 0:
|
|
||||||
raise AlbyHubError(
|
|
||||||
"negative_balance",
|
|
||||||
"Wallet has a negative final balance and cannot be deleted.",
|
|
||||||
)
|
|
||||||
if remaining_msat >= 1000:
|
|
||||||
raise AlbyHubError(
|
|
||||||
"drain_incomplete",
|
|
||||||
f"Drain verification failed: funds still remain.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Delete by app pubkey
|
|
||||||
pubkey = app.get("appPubkey") or app.get("nostrPubkey") or app.get("pubkey") or ""
|
|
||||||
if not pubkey:
|
|
||||||
raise AlbyHubError(
|
|
||||||
"app_pubkey_missing",
|
|
||||||
"Cannot delete app: nostr pubkey not available.",
|
|
||||||
)
|
|
||||||
self._authenticated_request(
|
|
||||||
"DELETE",
|
|
||||||
f"/api/apps/{urllib.parse.quote(pubkey, safe='')}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Audit log: wallet deleted
|
|
||||||
self._audit(
|
|
||||||
"wallet_deleted",
|
|
||||||
wallet_id=str(app_id),
|
|
||||||
name=app.get("name", ""),
|
|
||||||
alias=app.get("alias", ""),
|
|
||||||
drained_sats=drain_result.get("drained_sats", 0),
|
|
||||||
dust_msat=remaining_msat,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"drained_sats": drain_result.get("drained_sats", 0),
|
|
||||||
"dust_msat": remaining_msat,
|
|
||||||
}
|
|
||||||
|
|
||||||
def issue_invoice(
|
|
||||||
self, app_id: int, amount_msat: int, description: str = ""
|
|
||||||
) -> str:
|
|
||||||
"""Create an LND invoice attributed to a specific isolated app.
|
|
||||||
|
|
||||||
Returns a valid BOLT11 invoice string.
|
|
||||||
Raises AlbyHubError if the Hub returns an invalid or misattributed invoice.
|
|
||||||
"""
|
|
||||||
resp = self._authenticated_request(
|
|
||||||
"POST",
|
|
||||||
"/api/invoices",
|
|
||||||
body={
|
|
||||||
"amountMsat": amount_msat,
|
|
||||||
"description": description or LNURL_DESCRIPTION_DEFAULT,
|
|
||||||
"appId": app_id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
invoice: str = resp.get("invoice") or ""
|
|
||||||
returned_app_id = resp.get("appId")
|
|
||||||
|
|
||||||
if not invoice:
|
|
||||||
raise AlbyHubError("invoice_creation_failed", "Hub returned empty invoice.")
|
|
||||||
|
|
||||||
# Require a valid BOLT11 prefix (mainnet, testnet, signet, regtest)
|
|
||||||
if not re.match(r"^ln", invoice, re.IGNORECASE):
|
|
||||||
raise AlbyHubError(
|
|
||||||
"invalid_invoice", "Hub returned a non-BOLT11 invoice string."
|
|
||||||
)
|
|
||||||
|
|
||||||
if returned_app_id is None or int(returned_app_id) != app_id:
|
|
||||||
raise AlbyHubError(
|
|
||||||
"invoice_attribution_failed",
|
|
||||||
"Invoice attribution mismatch: returned appId does not match.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Audit log: invoice issued via API
|
|
||||||
self._audit(
|
|
||||||
"invoice_issued",
|
|
||||||
app_id=app_id,
|
|
||||||
amount_msat=amount_msat,
|
|
||||||
amount_sat=amount_msat // 1000,
|
|
||||||
invoice_prefix=invoice[:50] + "..." if len(invoice) > 50 else invoice,
|
|
||||||
)
|
|
||||||
|
|
||||||
return invoice
|
|
||||||
|
|
||||||
def find_app_by_alias(self, alias: str) -> dict | None:
|
|
||||||
"""Find a managed isolated app by its ``lnurl_alias`` metadata field."""
|
|
||||||
alias_lower = alias.strip().lower()
|
|
||||||
for a in self._all_managed_apps():
|
|
||||||
meta = self._parse_metadata(a.get("metadata"))
|
|
||||||
if meta.get("lnurl_alias", "").lower() == alias_lower:
|
|
||||||
return a
|
|
||||||
return None
|
|
||||||
|
|
||||||
def rotate_wallet_secret(self, identifier: str) -> dict:
|
|
||||||
"""Rotate the NWC pairing secret for a wallet connection.
|
|
||||||
|
|
||||||
Revokes the old Nostr key and generates a new pairing URI.
|
|
||||||
Returns the new pairing URI (shown ONCE).
|
|
||||||
"""
|
|
||||||
app = self._find_managed_app(identifier)
|
|
||||||
if app is None:
|
|
||||||
raise AlbyHubError("wallet_not_found", "Wallet connection not found.")
|
|
||||||
|
|
||||||
app_id = int(app["id"])
|
|
||||||
app_pubkey = app.get("appPubkey") or app.get("nostrPubkey") or app.get("pubkey") or ""
|
|
||||||
if not app_pubkey:
|
|
||||||
raise AlbyHubError(
|
|
||||||
"app_pubkey_missing",
|
|
||||||
"Cannot rotate secret: app public key not available.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Call Alby Hub's rotate secret endpoint (if available)
|
|
||||||
# Alby Hub may not have this endpoint yet; fall back to re-creating the app
|
|
||||||
# For now, we'll delete and re-create with same metadata
|
|
||||||
# This is a safe operation since we drain first
|
|
||||||
name = app.get("name", "")
|
|
||||||
alias = app.get("alias", "")
|
|
||||||
scopes = app.get("scopes") or []
|
|
||||||
max_amount = app.get("maxAmountSat") or 0
|
|
||||||
metadata = app.get("metadata") or {}
|
|
||||||
|
|
||||||
# Drain first
|
|
||||||
self.drain_wallet(identifier)
|
|
||||||
|
|
||||||
# Delete old app
|
|
||||||
self._authenticated_request(
|
|
||||||
"DELETE",
|
|
||||||
f"/api/apps/{urllib.parse.quote(app_pubkey, safe='')}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create new app with same parameters
|
|
||||||
create_body: dict = {
|
|
||||||
"name": name,
|
|
||||||
"scopes": scopes,
|
|
||||||
"isolated": True,
|
|
||||||
"budgetRenewal": "never",
|
|
||||||
"maxAmountSat": max_amount,
|
|
||||||
"metadata": metadata,
|
|
||||||
}
|
|
||||||
|
|
||||||
resp = self._authenticated_request("POST", "/api/apps", body=create_body)
|
|
||||||
new_pairing_uri: str = resp.get("pairingUri") or resp.get("pairing_uri") or ""
|
|
||||||
new_app_id = resp.get("id")
|
|
||||||
|
|
||||||
# Audit log: secret rotated
|
|
||||||
self._audit(
|
|
||||||
"wallet_secret_rotated",
|
|
||||||
old_wallet_id=str(app_id),
|
|
||||||
new_wallet_id=str(new_app_id) if new_app_id else "unknown",
|
|
||||||
name=name,
|
|
||||||
alias=alias,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"wallet_id": str(new_app_id) if new_app_id else "",
|
|
||||||
"pairing_uri": new_pairing_uri,
|
|
||||||
"message": "New NWC connection secret generated. Save it now — it will not be shown again.",
|
|
||||||
}
|
|
||||||
|
|
||||||
def health(self) -> dict:
|
|
||||||
"""Return a basic health summary."""
|
|
||||||
try:
|
|
||||||
token = self.ensure_ready()
|
|
||||||
status = self._request(
|
|
||||||
"GET", "/api/node/status", token=token, timeout=10
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"hub_ready": bool(
|
|
||||||
status.get("isReady") or status.get("running")
|
|
||||||
),
|
|
||||||
}
|
|
||||||
except AlbyHubError as exc:
|
|
||||||
return {"ok": False, "error": exc.code, "message": str(exc)}
|
|
||||||
|
|
||||||
|
|
||||||
# ── Module-level singleton ──────────────────────────────────────────
|
|
||||||
|
|
||||||
_manager: AlbyHubManager | None = None
|
|
||||||
_manager_lock = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def get_manager() -> AlbyHubManager:
|
|
||||||
"""Return the module-level singleton AlbyHubManager."""
|
|
||||||
global _manager
|
|
||||||
if _manager is None:
|
|
||||||
with _manager_lock:
|
|
||||||
if _manager is None:
|
|
||||||
_manager = AlbyHubManager()
|
|
||||||
return _manager
|
|
||||||
@@ -1,307 +0,0 @@
|
|||||||
"""
|
|
||||||
Dedicated LNURL service for Lightning Wallet Connections.
|
|
||||||
|
|
||||||
Runs as ``nwc-lnurl.service`` on 127.0.0.1:8181 (loopback only).
|
|
||||||
Caddy proxies the public Lightning Address domain's LNURL routes to this port.
|
|
||||||
|
|
||||||
Routes:
|
|
||||||
GET /.well-known/lnurlp/{alias}
|
|
||||||
GET /lnurlp/{alias}/callback?amount=<msat>
|
|
||||||
|
|
||||||
All error responses are safe for public consumption — raw Alby Hub bodies
|
|
||||||
and internal credentials are never returned to callers.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
import urllib.parse
|
|
||||||
from collections import defaultdict
|
|
||||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from . import nwc_hub_manager as _mgr_mod
|
|
||||||
from . import nwc_audit as _audit_mod
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .nwc_hub_manager import AlbyHubManager
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# ── Configuration ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
LNURL_BIND_HOST = "127.0.0.1"
|
|
||||||
LNURL_PORT = int(os.environ.get("NWC_LNURL_PORT", "8181"))
|
|
||||||
DOMAIN_FILE = "/var/lib/domains/lightning"
|
|
||||||
|
|
||||||
NWC_ALIAS_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,31}$")
|
|
||||||
|
|
||||||
# Rate limiting configuration
|
|
||||||
RATE_LIMIT_WINDOW_SEC = 60
|
|
||||||
RATE_LIMIT_MAX_REQUESTS = 30
|
|
||||||
_rate_limit_buckets: dict[str, list[float]] = defaultdict(list)
|
|
||||||
|
|
||||||
# ── Helpers ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def _read_domain() -> str | None:
|
|
||||||
try:
|
|
||||||
with open(DOMAIN_FILE, "r") as fh:
|
|
||||||
raw = fh.read(256).strip().lower()
|
|
||||||
# Strict FQDN validation: must be a valid hostname with at least one dot
|
|
||||||
# Reject localhost, IP addresses, and single-label names
|
|
||||||
if not re.match(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$", raw):
|
|
||||||
return None
|
|
||||||
# Explicitly reject local/reserved names
|
|
||||||
if raw in {"localhost", "localhost.localdomain", "local"}:
|
|
||||||
return None
|
|
||||||
return raw
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _check_rate_limit(client_ip: str) -> bool:
|
|
||||||
"""Check and update rate limit bucket for client IP. Returns True if allowed."""
|
|
||||||
now = time.monotonic()
|
|
||||||
bucket = _rate_limit_buckets[client_ip]
|
|
||||||
# Prune old entries
|
|
||||||
cutoff = now - RATE_LIMIT_WINDOW_SEC
|
|
||||||
while bucket and bucket[0] < cutoff:
|
|
||||||
bucket.pop(0)
|
|
||||||
if len(bucket) >= RATE_LIMIT_MAX_REQUESTS:
|
|
||||||
return False
|
|
||||||
bucket.append(now)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _lnurl_discovery(alias: str, manager: "AlbyHubManager", client_ip: str = "") -> tuple[dict, int]:
|
|
||||||
alias = alias.strip().lower()
|
|
||||||
if not NWC_ALIAS_RE.match(alias):
|
|
||||||
return {"status": "ERROR", "reason": "Unknown Lightning Address alias"}, 404
|
|
||||||
|
|
||||||
domain = _read_domain()
|
|
||||||
if not domain:
|
|
||||||
return {"status": "ERROR", "reason": "Lightning domain is not configured"}, 503
|
|
||||||
|
|
||||||
try:
|
|
||||||
app = manager.find_app_by_alias(alias)
|
|
||||||
except _mgr_mod.AlbyHubError:
|
|
||||||
return {"status": "ERROR", "reason": "Service temporarily unavailable"}, 503
|
|
||||||
|
|
||||||
if app is None:
|
|
||||||
return {"status": "ERROR", "reason": "Unknown Lightning Address alias"}, 404
|
|
||||||
|
|
||||||
meta = _mgr_mod.AlbyHubManager._parse_metadata(app.get("metadata"))
|
|
||||||
min_sendable = int(
|
|
||||||
meta.get("lnurl_min_sendable_msat", _mgr_mod.NWC_MIN_SENDABLE_MSAT)
|
|
||||||
)
|
|
||||||
max_sendable = int(
|
|
||||||
meta.get("lnurl_max_sendable_msat", _mgr_mod.NWC_MAX_SENDABLE_MSAT)
|
|
||||||
)
|
|
||||||
|
|
||||||
callback_alias = urllib.parse.quote(alias, safe="")
|
|
||||||
callback = f"https://{domain}/lnurlp/{callback_alias}/callback"
|
|
||||||
description = meta.get("lnurl_description") or f"Pay {alias}"
|
|
||||||
metadata = json.dumps([["text/plain", description]], separators=(",", ":"))
|
|
||||||
|
|
||||||
# Audit log: LNURL discovery
|
|
||||||
_audit_mod.audit_log(
|
|
||||||
"lnurl_discovery",
|
|
||||||
alias=alias,
|
|
||||||
domain=domain,
|
|
||||||
client_ip=client_ip,
|
|
||||||
min_sendable_msat=min_sendable,
|
|
||||||
max_sendable_msat=max_sendable,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"tag": "payRequest",
|
|
||||||
"callback": callback,
|
|
||||||
"minSendable": min_sendable,
|
|
||||||
"maxSendable": max_sendable,
|
|
||||||
"metadata": metadata,
|
|
||||||
"commentAllowed": 0,
|
|
||||||
}, 200
|
|
||||||
|
|
||||||
|
|
||||||
def _lnurl_callback(
|
|
||||||
alias: str, amount_str: str | None, manager: "AlbyHubManager", client_ip: str = ""
|
|
||||||
) -> tuple[dict, int]:
|
|
||||||
payload, status_code = _lnurl_discovery(alias, manager, client_ip)
|
|
||||||
if status_code != 200:
|
|
||||||
return payload, status_code
|
|
||||||
|
|
||||||
if amount_str is None:
|
|
||||||
return {"status": "ERROR", "reason": "Missing amount parameter"}, 400
|
|
||||||
if not re.match(r"^\d+$", amount_str):
|
|
||||||
return {
|
|
||||||
"status": "ERROR",
|
|
||||||
"reason": "Amount must be an integer millisatoshi value",
|
|
||||||
}, 400
|
|
||||||
|
|
||||||
amount_msat = int(amount_str)
|
|
||||||
min_sendable = int(payload["minSendable"])
|
|
||||||
max_sendable = int(payload["maxSendable"])
|
|
||||||
|
|
||||||
if amount_msat < min_sendable:
|
|
||||||
return {
|
|
||||||
"status": "ERROR",
|
|
||||||
"reason": "Amount is below the minimum sendable value",
|
|
||||||
}, 400
|
|
||||||
if amount_msat > max_sendable:
|
|
||||||
return {
|
|
||||||
"status": "ERROR",
|
|
||||||
"reason": "Amount is above the maximum sendable value",
|
|
||||||
}, 400
|
|
||||||
if amount_msat % 1000 != 0:
|
|
||||||
return {
|
|
||||||
"status": "ERROR",
|
|
||||||
"reason": "Amount must be a whole-satoshi value",
|
|
||||||
}, 400
|
|
||||||
|
|
||||||
try:
|
|
||||||
app = manager.find_app_by_alias(alias)
|
|
||||||
except _mgr_mod.AlbyHubError:
|
|
||||||
return {"status": "ERROR", "reason": "Service temporarily unavailable"}, 503
|
|
||||||
|
|
||||||
if app is None:
|
|
||||||
return {"status": "ERROR", "reason": "Unknown Lightning Address alias"}, 404
|
|
||||||
|
|
||||||
meta = _mgr_mod.AlbyHubManager._parse_metadata(app.get("metadata"))
|
|
||||||
description = meta.get("lnurl_description") or f"Pay {alias}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
app_id = int(app["id"])
|
|
||||||
invoice = manager.issue_invoice(app_id, amount_msat, description)
|
|
||||||
except _mgr_mod.AlbyHubError:
|
|
||||||
return {"status": "ERROR", "reason": "Invoice creation failed"}, 502
|
|
||||||
|
|
||||||
# Audit log: Invoice generated via LNURL
|
|
||||||
_audit_mod.audit_log(
|
|
||||||
"lnurl_invoice_created",
|
|
||||||
alias=alias,
|
|
||||||
amount_msat=amount_msat,
|
|
||||||
amount_sat=amount_msat // 1000,
|
|
||||||
client_ip=client_ip,
|
|
||||||
invoice_prefix=invoice[:50] + "..." if len(invoice) > 50 else invoice,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"pr": invoice, "routes": []}, 200
|
|
||||||
|
|
||||||
|
|
||||||
# ── HTTP server ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def _make_handler(manager: "AlbyHubManager") -> type:
|
|
||||||
"""Return a handler class bound to the given manager."""
|
|
||||||
|
|
||||||
class LnurlHandler(BaseHTTPRequestHandler):
|
|
||||||
_manager = manager
|
|
||||||
|
|
||||||
def log_message(self, fmt: str, *args: object) -> None:
|
|
||||||
logger.debug(f"LNURL {self.address_string()} {fmt % args}")
|
|
||||||
|
|
||||||
def _send_json(self, status: int, body: dict) -> None:
|
|
||||||
raw = json.dumps(body, separators=(",", ":")).encode("utf-8")
|
|
||||||
self.send_response(status)
|
|
||||||
self.send_header("Content-Type", "application/json")
|
|
||||||
self.send_header("Content-Length", str(len(raw)))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(raw)
|
|
||||||
|
|
||||||
def _get_client_ip(self) -> str:
|
|
||||||
# Check X-Forwarded-For header (set by Caddy)
|
|
||||||
forwarded = self.headers.get("X-Forwarded-For")
|
|
||||||
if forwarded:
|
|
||||||
# Take the first IP in the chain
|
|
||||||
return forwarded.split(",")[0].strip()
|
|
||||||
# Fallback to direct connection IP
|
|
||||||
return self.client_address[0]
|
|
||||||
|
|
||||||
def _check_rate_limit(self) -> bool:
|
|
||||||
client_ip = self._get_client_ip()
|
|
||||||
if not _check_rate_limit(client_ip):
|
|
||||||
self._send_json(429, {
|
|
||||||
"status": "ERROR",
|
|
||||||
"reason": "Rate limit exceeded. Please slow down."
|
|
||||||
})
|
|
||||||
_audit_mod.audit_log(
|
|
||||||
"rate_limit_exceeded",
|
|
||||||
client_ip=client_ip,
|
|
||||||
path=self.path,
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
def do_GET(self) -> None: # noqa: N802
|
|
||||||
if not self._check_rate_limit():
|
|
||||||
return
|
|
||||||
|
|
||||||
parsed = urllib.parse.urlparse(self.path)
|
|
||||||
path = parsed.path
|
|
||||||
qs = urllib.parse.parse_qs(parsed.query)
|
|
||||||
client_ip = self._get_client_ip()
|
|
||||||
|
|
||||||
# /.well-known/lnurlp/{alias}
|
|
||||||
m = re.fullmatch(
|
|
||||||
r"/.well-known/lnurlp/([^/]+)", path
|
|
||||||
)
|
|
||||||
if m:
|
|
||||||
alias = urllib.parse.unquote(m.group(1))
|
|
||||||
payload, code = _lnurl_discovery(alias, self._manager, client_ip)
|
|
||||||
self._send_json(code, payload)
|
|
||||||
return
|
|
||||||
|
|
||||||
# /lnurlp/{alias}/callback
|
|
||||||
m = re.fullmatch(r"/lnurlp/([^/]+)/callback", path)
|
|
||||||
if m:
|
|
||||||
alias = urllib.parse.unquote(m.group(1))
|
|
||||||
amount_values = qs.get("amount")
|
|
||||||
if not amount_values:
|
|
||||||
amount_str = None
|
|
||||||
elif len(amount_values) != 1:
|
|
||||||
self._send_json(
|
|
||||||
400,
|
|
||||||
{
|
|
||||||
"status": "ERROR",
|
|
||||||
"reason": "A single amount parameter is required",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
amount_str = amount_values[0]
|
|
||||||
payload, code = _lnurl_callback(alias, amount_str, self._manager, client_ip)
|
|
||||||
self._send_json(code, payload)
|
|
||||||
return
|
|
||||||
|
|
||||||
self._send_json(404, {"status": "ERROR", "reason": "Not found"})
|
|
||||||
|
|
||||||
return LnurlHandler
|
|
||||||
|
|
||||||
|
|
||||||
def run(
|
|
||||||
host: str = LNURL_BIND_HOST,
|
|
||||||
port: int = LNURL_PORT,
|
|
||||||
manager: "AlbyHubManager | None" = None,
|
|
||||||
) -> None:
|
|
||||||
"""Start the blocking LNURL HTTP server."""
|
|
||||||
if manager is None:
|
|
||||||
manager = _mgr_mod.get_manager()
|
|
||||||
handler_class = _make_handler(manager)
|
|
||||||
server = HTTPServer((host, port), handler_class)
|
|
||||||
logger.info("nwc-lnurl service listening on %s:%d", host, port)
|
|
||||||
server.serve_forever()
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
|
||||||
run()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from . import nwc_hub_manager as _mgr_mod
|
|
||||||
from .server import _nwc_domain, _nwc_validate_alias, _nwc_test_address
|
|
||||||
|
|
||||||
|
|
||||||
def _print(data) -> None:
|
|
||||||
print(json.dumps(data, indent=2, sort_keys=True))
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
|
||||||
parser = argparse.ArgumentParser(prog="nwc-wallet")
|
|
||||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
||||||
|
|
||||||
create = sub.add_parser("create")
|
|
||||||
create.add_argument("name")
|
|
||||||
create.add_argument("alias")
|
|
||||||
preset_group = create.add_mutually_exclusive_group()
|
|
||||||
preset_group.add_argument("--receive-only", action="store_true")
|
|
||||||
preset_group.add_argument("--limit-sats", type=int)
|
|
||||||
|
|
||||||
sub.add_parser("list")
|
|
||||||
|
|
||||||
drain = sub.add_parser("drain")
|
|
||||||
drain.add_argument("wallet")
|
|
||||||
|
|
||||||
delete = sub.add_parser("delete")
|
|
||||||
delete.add_argument("wallet")
|
|
||||||
|
|
||||||
addr = sub.add_parser("address")
|
|
||||||
addr_sub = addr.add_subparsers(dest="address_cmd", required=True)
|
|
||||||
addr_show = addr_sub.add_parser("show")
|
|
||||||
addr_show.add_argument("alias")
|
|
||||||
|
|
||||||
rotate = sub.add_parser("rotate")
|
|
||||||
rotate.add_argument("wallet")
|
|
||||||
|
|
||||||
sub.add_parser("health")
|
|
||||||
|
|
||||||
args = parser.parse_args(argv)
|
|
||||||
manager = _mgr_mod.get_manager()
|
|
||||||
domain = _nwc_domain()
|
|
||||||
|
|
||||||
if args.cmd == "list":
|
|
||||||
try:
|
|
||||||
wallets = manager.list_wallets(domain)
|
|
||||||
except _mgr_mod.AlbyHubError as exc:
|
|
||||||
print(f"Error: {exc.code} - {exc}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
_print({"wallets": wallets})
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if args.cmd == "health":
|
|
||||||
result = manager.health()
|
|
||||||
_print(result)
|
|
||||||
return 0 if result.get("ok") else 1
|
|
||||||
|
|
||||||
if args.cmd == "address" and args.address_cmd == "show":
|
|
||||||
alias = args.alias.strip().lower()
|
|
||||||
test = _nwc_test_address(alias)
|
|
||||||
_print(test)
|
|
||||||
return 0 if test.get("ok") else 1
|
|
||||||
|
|
||||||
if args.cmd == "drain":
|
|
||||||
try:
|
|
||||||
result = manager.drain_wallet(args.wallet)
|
|
||||||
except _mgr_mod.AlbyHubError as exc:
|
|
||||||
print(f"Error: {exc.code} - {exc}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
_print(result)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if args.cmd == "delete":
|
|
||||||
try:
|
|
||||||
result = manager.delete_wallet(args.wallet)
|
|
||||||
except _mgr_mod.AlbyHubError as exc:
|
|
||||||
print(f"Error: {exc.code} - {exc}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
_print(result)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if args.cmd == "rotate":
|
|
||||||
try:
|
|
||||||
result = manager.rotate_wallet_secret(args.wallet)
|
|
||||||
except _mgr_mod.AlbyHubError as exc:
|
|
||||||
print(f"Error: {exc.code} - {exc}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
_print({
|
|
||||||
"wallet_id": result.get("wallet_id", ""),
|
|
||||||
"pairing_uri": result.get("pairing_uri", ""),
|
|
||||||
"message": result.get("message", "New NWC connection secret generated. Save it now — it will not be shown again."),
|
|
||||||
})
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if args.cmd == "create":
|
|
||||||
alias = args.alias.strip().lower()
|
|
||||||
if not _nwc_validate_alias(alias):
|
|
||||||
print("Error: alias_invalid - Alias must be lowercase letters, digits, '_' or '-'.", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
access_preset = "send_receive_limited" if args.limit_sats is not None else "receive_only"
|
|
||||||
try:
|
|
||||||
result = manager.create_wallet(
|
|
||||||
args.name.strip(),
|
|
||||||
alias,
|
|
||||||
access_preset,
|
|
||||||
args.limit_sats if access_preset == "send_receive_limited" else None,
|
|
||||||
domain,
|
|
||||||
)
|
|
||||||
except _mgr_mod.AlbyHubError as exc:
|
|
||||||
print(f"Error: {exc.code} - {exc}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
# Print the pairing URI once — this is the only time it is shown
|
|
||||||
_print(
|
|
||||||
{
|
|
||||||
"wallet": result["wallet"],
|
|
||||||
"pairing_uri": result.get("pairing_uri", ""),
|
|
||||||
"message": "Keep the NWC connection secret private. It cannot be displayed again.",
|
|
||||||
"result": result.get("result", {}),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(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]:
|
||||||
|
|||||||
+284
-111
@@ -38,8 +38,9 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|||||||
|
|
||||||
from .config import load_config, load_versions
|
from .config import load_config, load_versions
|
||||||
from . import systemctl as sysctl
|
from . import systemctl as sysctl
|
||||||
from . 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,
|
||||||
@@ -54,7 +55,15 @@ 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
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -66,6 +75,7 @@ GITEA_API_BASE = "https://git.sovransystems.com/api/v1/repos/Sovran_Systems/Sovr
|
|||||||
|
|
||||||
UPDATE_LOG = "/var/log/sovran-hub-update.log"
|
UPDATE_LOG = "/var/log/sovran-hub-update.log"
|
||||||
UPDATE_STATUS = "/var/log/sovran-hub-update.status"
|
UPDATE_STATUS = "/var/log/sovran-hub-update.status"
|
||||||
|
UPDATE_GENERATION = "/var/log/sovran-hub-update.generation"
|
||||||
UPDATE_UNIT = "sovran-hub-update.service"
|
UPDATE_UNIT = "sovran-hub-update.service"
|
||||||
|
|
||||||
REBUILD_LOG = "/var/log/sovran-hub-rebuild.log"
|
REBUILD_LOG = "/var/log/sovran-hub-rebuild.log"
|
||||||
@@ -132,6 +142,7 @@ _SERVICE_DOMAIN_KEYS = frozenset([
|
|||||||
])
|
])
|
||||||
|
|
||||||
INTERNAL_IP_FILE = "/var/lib/secrets/internal-ip"
|
INTERNAL_IP_FILE = "/var/lib/secrets/internal-ip"
|
||||||
|
EXTERNAL_IP_FILE = "/var/lib/secrets/external-ip"
|
||||||
ZEUS_CONNECT_FILE = "/var/lib/secrets/zeus-connect-url"
|
ZEUS_CONNECT_FILE = "/var/lib/secrets/zeus-connect-url"
|
||||||
|
|
||||||
ONBOARDING_FLAG = "/var/lib/sovran/onboarding-complete"
|
ONBOARDING_FLAG = "/var/lib/sovran/onboarding-complete"
|
||||||
@@ -171,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"}
|
||||||
@@ -230,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"),
|
||||||
@@ -242,8 +260,8 @@ CATEGORY_ORDER = [
|
|||||||
FEATURE_REGISTRY = [
|
FEATURE_REGISTRY = [
|
||||||
{
|
{
|
||||||
"id": "rdp",
|
"id": "rdp",
|
||||||
"name": "Remote Desktop (RDP)",
|
"name": "Remote Desktop",
|
||||||
"description": "Access your desktop remotely via RDP client",
|
"description": "Access your desktop from a device on your home network — no router setup needed",
|
||||||
"category": "infrastructure",
|
"category": "infrastructure",
|
||||||
"needs_domain": False,
|
"needs_domain": False,
|
||||||
"domain_name": None,
|
"domain_name": None,
|
||||||
@@ -287,11 +305,10 @@ FEATURE_REGISTRY = [
|
|||||||
"port_requirements": [
|
"port_requirements": [
|
||||||
{"port": "80", "protocol": "TCP", "description": "HTTP (redirect to HTTPS)"},
|
{"port": "80", "protocol": "TCP", "description": "HTTP (redirect to HTTPS)"},
|
||||||
{"port": "443", "protocol": "TCP", "description": "HTTPS (domain)"},
|
{"port": "443", "protocol": "TCP", "description": "HTTPS (domain)"},
|
||||||
{"port": "7881", "protocol": "TCP", "description": "LiveKit WebRTC signalling"},
|
{"port": "7881", "protocol": "TCP", "description": "WebRTC media (TCP fallback)"},
|
||||||
{"port": "7882", "protocol": "UDP", "description": "LiveKit media (UDP mux)"},
|
{"port": "7882", "protocol": "UDP", "description": "WebRTC media (UDP)"},
|
||||||
{"port": "5349", "protocol": "TCP", "description": "TURN over TLS"},
|
{"port": "3478", "protocol": "UDP", "description": "TURN relay + STUN"},
|
||||||
{"port": "3478", "protocol": "UDP", "description": "TURN (STUN/relay)"},
|
{"port": "40000-40099", "protocol": "UDP", "description": "TURN relay (WebRTC media)"},
|
||||||
{"port": "30000-40000", "protocol": "TCP/UDP", "description": "TURN relay (WebRTC)"},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -358,8 +375,8 @@ FEATURE_REGISTRY = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "btcpay-web",
|
"id": "btcpay-web",
|
||||||
"name": "BTCPay Server Web Access",
|
"name": "Put BTCPay Server Online",
|
||||||
"description": "Expose BTCPay Server to the internet via your domain. When disabled, BTCPay Server still runs locally but is not accessible from the web.",
|
"description": "Let people donate Bitcoin, pay you, or visit your online store. BTCPay Server stays private when this is turned off.",
|
||||||
"category": "bitcoin",
|
"category": "bitcoin",
|
||||||
"needs_domain": True,
|
"needs_domain": True,
|
||||||
"domain_name": "btcpayserver",
|
"domain_name": "btcpayserver",
|
||||||
@@ -391,11 +408,10 @@ FEATURE_SERVICE_MAP = {
|
|||||||
|
|
||||||
# Port requirements for service tiles (keyed by unit name or icon)
|
# Port requirements for service tiles (keyed by unit name or icon)
|
||||||
_PORTS_ELEMENT_CALLING = [
|
_PORTS_ELEMENT_CALLING = [
|
||||||
{"port": "7881", "protocol": "TCP", "description": "LiveKit WebRTC signalling"},
|
{"port": "7881", "protocol": "TCP", "description": "WebRTC media (TCP fallback)"},
|
||||||
{"port": "7882", "protocol": "UDP", "description": "LiveKit media (UDP mux)"},
|
{"port": "7882", "protocol": "UDP", "description": "WebRTC media (UDP)"},
|
||||||
{"port": "5349", "protocol": "TCP", "description": "TURN over TLS"},
|
{"port": "3478", "protocol": "UDP", "description": "TURN relay + STUN"},
|
||||||
{"port": "3478", "protocol": "UDP", "description": "TURN (STUN/relay)"},
|
{"port": "40000-40099", "protocol": "UDP", "description": "TURN relay (WebRTC media)"},
|
||||||
{"port": "30000-40000", "protocol": "TCP/UDP", "description": "TURN relay (WebRTC)"},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Units whose port requirements exist purely so the user can forward them in
|
# Units whose port requirements exist purely so the user can forward them in
|
||||||
@@ -450,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)
|
||||||
@@ -487,9 +503,9 @@ SERVICE_DESCRIPTIONS: dict[str, str] = {
|
|||||||
"Sovran_SystemsOS makes running a production-grade payment gateway as simple as flipping a switch."
|
"Sovran_SystemsOS makes running a production-grade payment gateway as simple as flipping a switch."
|
||||||
),
|
),
|
||||||
"zeus-connect-setup.service": (
|
"zeus-connect-setup.service": (
|
||||||
"Connect the Zeus mobile wallet to your Lightning node via LND REST. Send and receive "
|
"Connect the Zeus mobile wallet to your Lightning node via LND REST over Tor. Send and receive "
|
||||||
"Lightning payments from your phone using a direct node connection. "
|
"Lightning payments from your phone using a direct node connection. "
|
||||||
"Scan the QR code to add your node to Zeus — this gives full node admin access."
|
"Scan the QR code to add your node to Zeus, then enable Use Tor — this gives full node admin access."
|
||||||
),
|
),
|
||||||
"mempool.service": (
|
"mempool.service": (
|
||||||
"Your own blockchain explorer and mempool visualizer. Monitor transactions, "
|
"Your own blockchain explorer and mempool visualizer. Monitor transactions, "
|
||||||
@@ -541,9 +557,9 @@ SERVICE_DESCRIPTIONS: dict[str, str] = {
|
|||||||
"Addresses on your Sovran_SystemsOS node."
|
"Addresses on your Sovran_SystemsOS node."
|
||||||
),
|
),
|
||||||
"gnome-remote-desktop.service": (
|
"gnome-remote-desktop.service": (
|
||||||
"Access your server's full desktop environment from anywhere using any RDP client. "
|
"Access your desktop from a device on your home network — no router setup needed. "
|
||||||
"Manage your system visually without being physically present. "
|
"Sovran_SystemsOS creates the address, username, and password for you: "
|
||||||
"Sovran_SystemsOS sets up secure remote access with generated credentials — connect and go."
|
"just open an RDP app and connect."
|
||||||
),
|
),
|
||||||
"sshd.service": (
|
"sshd.service": (
|
||||||
"Secure Shell (SSH) remote access. When enabled, authorized users can connect "
|
"Secure Shell (SSH) remote access. When enabled, authorized users can connect "
|
||||||
@@ -769,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 ─────────────────────────────────────
|
||||||
@@ -805,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",
|
||||||
@@ -906,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
|
||||||
|
|
||||||
|
|
||||||
@@ -951,20 +1078,19 @@ def _save_internal_ip(ip: str):
|
|||||||
|
|
||||||
|
|
||||||
def _get_external_ip() -> str:
|
def _get_external_ip() -> str:
|
||||||
MAX_IP_LENGTH = 46
|
"""Public IP as recorded by the Njal.la DDNS runner (ddns_update.py).
|
||||||
for url in [
|
|
||||||
"https://api.ipify.org",
|
Nothing here looks the address up or contacts anyone. The DDNS update asks
|
||||||
"https://ifconfig.me/ip",
|
Njal.la to use the address the request came from, Njal.la reports it back,
|
||||||
"https://icanhazip.com",
|
and the runner saves it to EXTERNAL_IP_FILE. Returns "unavailable" until
|
||||||
]:
|
the first successful update (and on machines with no DDNS URL, e.g. Desktop).
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(url, method="GET")
|
with open(EXTERNAL_IP_FILE) as f:
|
||||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
ip = f.read().strip()
|
||||||
ip = resp.read().decode().strip()
|
ipaddress.ip_address(ip)
|
||||||
if ip and len(ip) < MAX_IP_LENGTH:
|
|
||||||
return ip
|
return ip
|
||||||
except Exception:
|
except (OSError, ValueError):
|
||||||
continue
|
|
||||||
return "unavailable"
|
return "unavailable"
|
||||||
|
|
||||||
|
|
||||||
@@ -1088,7 +1214,7 @@ def _get_firewall_allowed_ports() -> dict[str, set[int]]:
|
|||||||
)
|
)
|
||||||
if proc.returncode == 0:
|
if proc.returncode == 0:
|
||||||
for line in proc.stdout.splitlines():
|
for line in proc.stdout.splitlines():
|
||||||
# e.g. ACCEPT tcp -- ... dpt:443 or dpts:7882:7894
|
# e.g. ACCEPT tcp -- ... dpt:443 or dpts:40000:40099
|
||||||
m = re.search(r'(tcp|udp).*dpts?:(\d+)(?::(\d+))?', line)
|
m = re.search(r'(tcp|udp).*dpts?:(\d+)(?::(\d+))?', line)
|
||||||
if m:
|
if m:
|
||||||
proto_match = m.group(1)
|
proto_match = m.group(1)
|
||||||
@@ -1146,7 +1272,7 @@ def _resolve_all_addresses_cached(domain: str) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def _port_range_to_ints(port_str: str) -> list[int]:
|
def _port_range_to_ints(port_str: str) -> list[int]:
|
||||||
"""Convert a port string like ``"443"``, ``"30000-40000"`` to a list of ints."""
|
"""Convert a port string like ``"443"``, ``"40000-40099"`` to a list of ints."""
|
||||||
port_str = port_str.strip()
|
port_str = port_str.strip()
|
||||||
if re.match(r'^\d+$', port_str):
|
if re.match(r'^\d+$', port_str):
|
||||||
return [int(port_str)]
|
return [int(port_str)]
|
||||||
@@ -1532,10 +1658,16 @@ def _evaluate_domain_checklist(
|
|||||||
|
|
||||||
def _generate_qr_png_bytes(data: str, scale: int = 6, margin: int = 2) -> bytes | None:
|
def _generate_qr_png_bytes(data: str, scale: int = 6, margin: int = 2) -> bytes | None:
|
||||||
"""Generate a QR code PNG and return the raw bytes.
|
"""Generate a QR code PNG and return the raw bytes.
|
||||||
Uses qrencode CLI (available on the system via credentials.nix)."""
|
Uses qrencode CLI (available on the system via credentials.nix).
|
||||||
|
|
||||||
|
High error-correction (H) is preferred for short payloads. Long
|
||||||
|
lndconnect URIs can exceed version-40 capacity at H, so fall back to
|
||||||
|
quartile then low ECC — otherwise the Hub shows an empty Zeus QR.
|
||||||
|
"""
|
||||||
|
for ecc in ("H", "Q", "L"):
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["qrencode", "-o", "-", "-t", "PNG", "-s", str(scale), "-m", str(margin), "-l", "H", data],
|
["qrencode", "-o", "-", "-t", "PNG", "-s", str(scale), "-m", str(margin), "-l", ecc, data],
|
||||||
capture_output=True, timeout=10,
|
capture_output=True, timeout=10,
|
||||||
)
|
)
|
||||||
if result.returncode == 0 and result.stdout:
|
if result.returncode == 0 and result.stdout:
|
||||||
@@ -1548,9 +1680,10 @@ def _generate_qr_png_bytes(data: str, scale: int = 6, margin: int = 2) -> bytes
|
|||||||
def _generate_qr_svg(data: str, scale: int = 10, margin: int = 4) -> str | None:
|
def _generate_qr_svg(data: str, scale: int = 10, margin: int = 4) -> str | None:
|
||||||
"""Generate a QR code SVG document (resolution-independent, ideal if the
|
"""Generate a QR code SVG document (resolution-independent, ideal if the
|
||||||
user wants to embed the QR in a website or print it at any size)."""
|
user wants to embed the QR in a website or print it at any size)."""
|
||||||
|
for ecc in ("H", "Q", "L"):
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["qrencode", "-o", "-", "-t", "SVG", "-s", str(scale), "-m", str(margin), "-l", "H", data],
|
["qrencode", "-o", "-", "-t", "SVG", "-s", str(scale), "-m", str(margin), "-l", ecc, data],
|
||||||
capture_output=True, timeout=10,
|
capture_output=True, timeout=10,
|
||||||
)
|
)
|
||||||
if result.returncode == 0 and result.stdout:
|
if result.returncode == 0 and result.stdout:
|
||||||
@@ -1627,15 +1760,6 @@ def _nwc_lnurl_bech32(alias: str, domain: str) -> str:
|
|||||||
|
|
||||||
# ── Update helpers (file-based, no systemctl) ────────────────────
|
# ── Update helpers (file-based, no systemctl) ────────────────────
|
||||||
|
|
||||||
def _read_update_status() -> str:
|
|
||||||
"""Read the status file. Returns RUNNING, SUCCESS, REBOOT_REQUIRED, FAILED, or IDLE."""
|
|
||||||
try:
|
|
||||||
with open(UPDATE_STATUS, "r") as f:
|
|
||||||
return f.read().strip()
|
|
||||||
except FileNotFoundError:
|
|
||||||
return "IDLE"
|
|
||||||
|
|
||||||
|
|
||||||
def _write_update_status(status: str):
|
def _write_update_status(status: str):
|
||||||
"""Write to the status file."""
|
"""Write to the status file."""
|
||||||
try:
|
try:
|
||||||
@@ -1645,6 +1769,36 @@ def _write_update_status(status: str):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _read_update_status() -> str:
|
||||||
|
"""Read and reconcile the persistent update status.
|
||||||
|
|
||||||
|
``REBOOT_REQUIRED`` survives Hub/browser restarts before the reboot, but
|
||||||
|
it is a CLAIM about live NixOS state, not the source of truth: the boot
|
||||||
|
default (``/nix/var/nix/profiles/system``) versus the running
|
||||||
|
``/run/current-system``. Re-validating on every read keeps the Hub
|
||||||
|
correct when the system was updated from a terminal or support session
|
||||||
|
(which never writes Hub markers), and lets an old marker that predates
|
||||||
|
the reconciliation feature self-heal instead of demanding reboots
|
||||||
|
forever. The stale marker file is removed once cleared.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(UPDATE_STATUS, "r") as f:
|
||||||
|
status = f.read().strip()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return "IDLE"
|
||||||
|
|
||||||
|
effective = effective_update_status(status)
|
||||||
|
if effective != status:
|
||||||
|
_write_update_status(effective)
|
||||||
|
try:
|
||||||
|
os.remove(UPDATE_GENERATION)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return effective
|
||||||
|
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
def _read_log(offset: int = 0) -> tuple[str, int]:
|
def _read_log(offset: int = 0) -> tuple[str, int]:
|
||||||
"""Read the update log file from the given byte offset.
|
"""Read the update log file from the given byte offset.
|
||||||
Returns (new_text, new_offset)."""
|
Returns (new_text, new_offset)."""
|
||||||
@@ -1680,6 +1834,9 @@ def _resolve_credential(cred: dict) -> dict | None:
|
|||||||
qr_data = _generate_qr_base64(result["value"])
|
qr_data = _generate_qr_base64(result["value"])
|
||||||
if qr_data:
|
if qr_data:
|
||||||
result["qrcode"] = qr_data
|
result["qrcode"] = qr_data
|
||||||
|
else:
|
||||||
|
# Don't hide the URI if we could not render a scannable QR.
|
||||||
|
qronly = False
|
||||||
if qronly:
|
if qronly:
|
||||||
result["qronly"] = True
|
result["qronly"] = True
|
||||||
return result
|
return result
|
||||||
@@ -1714,6 +1871,9 @@ def _resolve_credential(cred: dict) -> dict | None:
|
|||||||
qr_data = _generate_qr_base64(value)
|
qr_data = _generate_qr_base64(value)
|
||||||
if qr_data:
|
if qr_data:
|
||||||
result["qrcode"] = qr_data
|
result["qrcode"] = qr_data
|
||||||
|
else:
|
||||||
|
# Don't hide the URI if we could not render a scannable QR.
|
||||||
|
qronly = False
|
||||||
|
|
||||||
if qronly:
|
if qronly:
|
||||||
result["qronly"] = True
|
result["qronly"] = True
|
||||||
@@ -2547,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(
|
||||||
@@ -3819,9 +3993,20 @@ async def api_ports_health():
|
|||||||
@app.get("/api/updates/check")
|
@app.get("/api/updates/check")
|
||||||
async def api_updates_check():
|
async def api_updates_check():
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
|
status = await loop.run_in_executor(None, _read_update_status)
|
||||||
|
if status in {"RUNNING", "REBOOT_REQUIRED"}:
|
||||||
|
# Avoid a slow remote update check when there is already an operation
|
||||||
|
# the dashboard needs to surface.
|
||||||
|
return {"available": True, "status": status.lower()}
|
||||||
|
if status == "FAILED":
|
||||||
|
# The last update did not complete (build failed). Keep offering the
|
||||||
|
# update so the user can re-run it rather than silently landing on a
|
||||||
|
# false "up to date".
|
||||||
|
return {"available": True, "status": "failed"}
|
||||||
|
|
||||||
available = await loop.run_in_executor(None, check_for_updates)
|
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
|
||||||
return {"available": available is not False}
|
return {"available": available is not False, "status": status.lower()}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/ping")
|
@app.get("/api/ping")
|
||||||
@@ -3895,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:
|
||||||
@@ -4588,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
|
||||||
@@ -4771,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:
|
||||||
@@ -6168,10 +6334,18 @@ async def _startup_migrate_deprecated_features():
|
|||||||
|
|
||||||
async def _background_domain_reachability_checker():
|
async def _background_domain_reachability_checker():
|
||||||
"""Periodically curl configured domains and cache reachability results."""
|
"""Periodically curl configured domains and cache reachability results."""
|
||||||
|
global _cached_external_ip
|
||||||
await asyncio.sleep(_DOMAIN_REACHABILITY_STARTUP_DELAY)
|
await asyncio.sleep(_DOMAIN_REACHABILITY_STARTUP_DELAY)
|
||||||
consecutive_failures = 0
|
consecutive_failures = 0
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
# Pick up the address the Njal.la DDNS runner last recorded (a plain
|
||||||
|
# file read; nothing is looked up from here).
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
external = await loop.run_in_executor(None, _get_external_ip)
|
||||||
|
if external != "unavailable":
|
||||||
|
_cached_external_ip = external
|
||||||
|
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
services = cfg.get("services", [])
|
services = cfg.get("services", [])
|
||||||
|
|
||||||
@@ -6181,7 +6355,6 @@ async def _background_domain_reachability_checker():
|
|||||||
if unit is not None
|
if unit is not None
|
||||||
}
|
}
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
overrides, *_ = await loop.run_in_executor(None, _read_hub_overrides)
|
overrides, *_ = await loop.run_in_executor(None, _read_hub_overrides)
|
||||||
|
|
||||||
domains_to_check: list[str] = []
|
domains_to_check: list[str] = []
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,16 @@
|
|||||||
const POLL_INTERVAL_SERVICES = 5000;
|
const POLL_INTERVAL_SERVICES = 5000;
|
||||||
const POLL_INTERVAL_UPDATES = 1800000;
|
const POLL_INTERVAL_UPDATES = 1800000;
|
||||||
const UPDATE_POLL_INTERVAL = 2000;
|
const UPDATE_POLL_INTERVAL = 2000;
|
||||||
// Max consecutive failed rebuild/update status polls before the page gives up
|
// A pending fetch never rejects by itself. Bound every status request so a
|
||||||
// waiting and reloads to re-sync (2s interval → ~2 minutes of failures).
|
// wedged browser connection cannot leave the modal spinning forever.
|
||||||
// A brief Hub restart during activation only causes a handful of failures.
|
const STATUS_POLL_FETCH_TIMEOUT = 15000;
|
||||||
const STATUS_POLL_MAX_FAILURES = 60;
|
// Eight timed-out requests plus the poll interval is a little over two minutes.
|
||||||
|
// A brief Hub restart or a heavily loaded Nix build remains well inside this.
|
||||||
|
const STATUS_POLL_MAX_FAILURES = 8;
|
||||||
|
// Keep verbose Nix output from making textContent updates quadratic and
|
||||||
|
// freezing the Hub renderer (especially noticeable over RDP).
|
||||||
|
const UPDATE_VISIBLE_LOG_MAX_CHARS = 250000;
|
||||||
|
const UPDATE_VISIBLE_LOG_TRIM_CHARS = 200000;
|
||||||
const REBOOT_CHECK_INTERVAL = 5000;
|
const REBOOT_CHECK_INTERVAL = 5000;
|
||||||
const REBOOT_FETCH_TIMEOUT = 12000;
|
const REBOOT_FETCH_TIMEOUT = 12000;
|
||||||
const REBOOT_REQUEST_TIMEOUT = 4000;
|
const REBOOT_REQUEST_TIMEOUT = 4000;
|
||||||
@@ -17,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();
|
||||||
|
|
||||||
|
})();
|
||||||
@@ -1,22 +1,14 @@
|
|||||||
/* Sovran_SystemsOS Hub — Shared domain-prerequisite instructions.
|
/* Sovran_SystemsOS Hub — Shared domain and router instructions.
|
||||||
|
|
||||||
SINGLE SOURCE OF TRUTH for the "you need a Njal.la domain + router port
|
This is the single source of truth for the simple setup guidance shown in:
|
||||||
forwarding" guidance that must read identically everywhere it appears:
|
|
||||||
|
|
||||||
• First-boot onboarding wizard (Server + Desktop role)
|
• First-boot onboarding for Server + Desktop
|
||||||
— onboarding.js step 3
|
• Feature setup in the Hub
|
||||||
• Feature-enable domain modal (Node role, and any role)
|
• Domain reconfiguration and troubleshooting
|
||||||
— features.js openDomainSetupModal()
|
|
||||||
(Lightning Wallet Connections / NWC, BTCPay Server, Haven, …)
|
|
||||||
• Domain reconfigure / troubleshooting modal
|
|
||||||
— features.js openDomainReconfigureModal()
|
|
||||||
|
|
||||||
Keep these three surfaces word-for-word consistent: always edit this
|
Keep the wording consistent by editing it here. */
|
||||||
file, never fork the wording inline. Plain classic script (no modules) —
|
|
||||||
both templates load it with a plain <script> tag. */
|
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
/* Escape helper local to this file so it is self-contained on both pages. */
|
|
||||||
function dpEsc(str) {
|
function dpEsc(str) {
|
||||||
return String(str)
|
return String(str)
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, "&")
|
||||||
@@ -26,105 +18,76 @@ function dpEsc(str) {
|
|||||||
.replace(/'/g, "'");
|
.replace(/'/g, "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── "What you'll need" intro ──────────────────────────────────────
|
/* Explain only what the user needs for the step in front of them. */
|
||||||
opts.serviceName — e.g. "Lightning Wallet Connections", or null for
|
|
||||||
the multi-service (onboarding) wording.
|
|
||||||
opts.hostExample — e.g. "lightning" → lightning.yourdomain.com */
|
|
||||||
function renderDomainNeedsHtml(opts) {
|
function renderDomainNeedsHtml(opts) {
|
||||||
|
opts = opts || {};
|
||||||
var serviceName = opts.serviceName || null;
|
var serviceName = opts.serviceName || null;
|
||||||
var hostExample = dpEsc(opts.hostExample || "myservice");
|
var purpose = opts.purpose || "";
|
||||||
var html = "";
|
var html = "";
|
||||||
|
|
||||||
if (serviceName) {
|
if (purpose === "btcpay") {
|
||||||
html += "<p>To enable <strong>" + dpEsc(serviceName) + "</strong>, you'll need two things first:</p>";
|
html += "<p><strong>Put your BTCPay Server online</strong></p>";
|
||||||
html += '<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">';
|
html += "<p>Let people donate Bitcoin, pay you, or visit your online store.</p>";
|
||||||
html += "<li><strong>A domain of your own from <a href=\"https://njal.la\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:var(--accent-color);\">Njal.la</a></strong> "
|
html += "<p>BTCPay Server needs a domain so people can reach it. "
|
||||||
+ "— privacy-friendly, no personal details required, accepts Bitcoin. "
|
+ "Sovran_SystemsOS walks you through getting your domain from "
|
||||||
+ dpEsc(serviceName) + " gets its own hostname: a subdomain (e.g. <code>" + hostExample + ".yourdomain.com</code>) "
|
+ '<a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">Njal.la</a>'
|
||||||
+ "or a separate domain — your choice. Subdomains are free, and one domain can have many.</li>";
|
+ " and connecting it. Just follow the steps below.</p>";
|
||||||
html += "<li><strong>Access to your router</strong> — you'll forward ports <strong>80</strong> and <strong>443</strong> (TCP) "
|
} else if (serviceName) {
|
||||||
+ "to this computer once. All domain-based services share these two ports; "
|
html += "<p>To make <strong>" + dpEsc(serviceName) + "</strong> available outside your home, it needs a domain.</p>";
|
||||||
+ "they're required for HTTPS and SSL certificates.</li>";
|
html += "<p>Sovran_SystemsOS walks you through getting your domain from "
|
||||||
html += "</ol>";
|
+ '<a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">Njal.la</a>'
|
||||||
|
+ " and connecting it. Just follow the steps below.</p>";
|
||||||
} else {
|
} else {
|
||||||
html += "<p><strong>Each service below needs two things set up:</strong></p>";
|
html += "<p>Your domain is the address people use to reach your services on the internet.</p>";
|
||||||
html += '<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">';
|
html += "<p>Sovran_SystemsOS walks you through getting your domain from "
|
||||||
html += "<li><strong>A domain of your own from <a href=\"https://njal.la\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:var(--accent-color);\">Njal.la</a></strong> "
|
+ '<a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">Njal.la</a>'
|
||||||
+ "— privacy-friendly, no personal details required, accepts Bitcoin. "
|
+ " and connecting your services. Just follow the steps below.</p>";
|
||||||
+ "Each service gets its own hostname: its own subdomain (e.g. <code>" + hostExample + ".yourdomain.com</code>) "
|
|
||||||
+ "or a separate domain — your choice. Subdomains are free, and one domain can have many, "
|
|
||||||
+ "so a single domain can serve every service.</li>";
|
|
||||||
html += "<li><strong>Access to your router</strong> — you'll forward ports <strong>80</strong> and <strong>443</strong> (TCP) "
|
|
||||||
+ "to this computer once. All domain-based services share these two ports; "
|
|
||||||
+ "they're required for HTTPS and SSL certificates.</li>";
|
|
||||||
html += "</ol>";
|
|
||||||
}
|
}
|
||||||
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── "How to set it up at Njal.la" steps ───────────────────────────
|
/* The shortest Njal.la steps that still tell the user exactly what to do. */
|
||||||
opts.hostExample — host part used in the examples (default "call").
|
|
||||||
opts.pasteHint — where the curl command goes:
|
|
||||||
"below" (single service) or "next to its service below"
|
|
||||||
(onboarding, many services). */
|
|
||||||
function renderNjallaStepsHtml(opts) {
|
function renderNjallaStepsHtml(opts) {
|
||||||
var hostExample = dpEsc((opts && opts.hostExample) || "call");
|
var hostExample = dpEsc((opts && opts.hostExample) || "call");
|
||||||
var pasteHint = (opts && opts.pasteHint) || "below";
|
var pasteHint = (opts && opts.pasteHint) || "below";
|
||||||
var html = "";
|
var html = "";
|
||||||
|
|
||||||
html += "<p style=\"margin-top:12px;\"><strong>How to set it up at Njal.la:</strong></p>";
|
html += '<p style="margin-top:12px;"><strong>Get and connect your domain:</strong></p>';
|
||||||
html += '<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">';
|
html += '<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">';
|
||||||
html += "<li>Create an account at <a href=\"https://njal.la\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:var(--accent-color);\">https://njal.la</a> and buy a domain.</li>";
|
html += '<li>Open <a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">Njal.la</a>, create an account, and get a domain.</li>';
|
||||||
html += "<li>Add a <strong>Dynamic</strong> record for the hostname:"
|
html += "<li>Open your domain and add a <strong>Dynamic</strong> record for this service. "
|
||||||
+ '<ul style="margin:4px 0 0 16px;padding:0;line-height:1.7;">'
|
+ "In the <strong>Name</strong> box, enter only the first part of the address. "
|
||||||
+ "<li>In the Njal.la <strong>Name</strong> field, type ONLY the host part — the word before your domain.<br>"
|
+ "For <code>" + hostExample + ".yourdomain.com</code>, enter <code>" + hostExample + "</code>. "
|
||||||
+ "(For "" + hostExample + ".yourdomain.com" you'd type just: <code>" + hostExample + "</code>.)<br>"
|
+ "If you are using the whole domain, leave the box blank or enter <code>@</code>.</li>";
|
||||||
+ "⚠ Do NOT type the full domain here — Njal.la adds it automatically.</li>"
|
html += "<li>Copy the update command Njal.la gives you and paste it " + pasteHint + ".</li>";
|
||||||
+ "<li>Dedicating a whole separate domain to this service? Leave the Name field blank or use <code>@</code>.</li>"
|
|
||||||
+ "</ul>"
|
|
||||||
+ "</li>";
|
|
||||||
html += "<li>A Dynamic record has <strong>NO IP field</strong> — you don't enter an IP anywhere. "
|
|
||||||
+ "It auto-fills once Sovran_SystemsOS runs the update command (on save, and again after every reboot).</li>";
|
|
||||||
html += "<li>Njal.la gives you a curl command, e.g.:<br>"
|
|
||||||
+ '<code style="font-size:0.8em;">curl "https://njal.la/update/?h=' + hostExample + '.yourdomain.com&k=abc123&auto"</code><br>'
|
|
||||||
+ "Copy it and paste it " + pasteHint + ".</li>";
|
|
||||||
html += "</ol>";
|
html += "</ol>";
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── "One router task" port-forwarding box ─────────────────────────
|
/* One router task shared by all normal domain-based services. */
|
||||||
opts.internalIp — LAN IP string, or empty/null for generic wording.
|
|
||||||
opts.plural — true for multi-service (onboarding) wording.
|
|
||||||
opts.includeSsh — also mention port 22 for SSH (onboarding).
|
|
||||||
opts.extraNote — extra sentence appended at the end (optional). */
|
|
||||||
function renderRouterPortsHtml(opts) {
|
function renderRouterPortsHtml(opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
var ipPart = opts.internalIp
|
var ipPart = opts.internalIp
|
||||||
? " to this computer’s internal IP <strong>" + dpEsc(opts.internalIp) + "</strong>"
|
? " to this computer at <strong>" + dpEsc(opts.internalIp) + "</strong>"
|
||||||
: " to this computer’s internal IP";
|
: " to this computer";
|
||||||
var serviceWord = opts.plural ? "services" : "service";
|
|
||||||
|
|
||||||
var html = "";
|
var html = "";
|
||||||
html += "🔌 <strong>One router task:</strong> in your router’s <strong>port forwarding</strong> settings, "
|
html += "🔌 <strong>One router task:</strong> in your router’s <strong>Port Forwarding</strong> settings, "
|
||||||
+ "forward port <strong>80 (TCP)</strong> and port <strong>443 (TCP)</strong>"
|
+ "forward <strong>80 (TCP)</strong> and <strong>443 (TCP)</strong>"
|
||||||
+ ipPart + ". Use the <strong>same number for the internal and external port</strong>. "
|
+ ipPart + ". If your router asks for outside and inside port numbers, use the same number for both. "
|
||||||
+ "This only needs to be done once — all domain services share these ports — "
|
+ "You only need to do this once. All your services share these two ports.";
|
||||||
+ "but HTTPS and SSL certificates won’t work without them, so your " + serviceWord + " "
|
|
||||||
+ "can’t be reached from outside your home network. "
|
|
||||||
+ "You’ll need normal access to your router’s settings with working port forwarding — "
|
|
||||||
+ "if your ISP blocks it (e.g. CGNAT), domain-based services can’t be reached from the internet. ";
|
|
||||||
if (opts.includeSsh) {
|
|
||||||
html += "Add port <strong>22 (TCP)</strong> as well if you want remote SSH access. ";
|
|
||||||
}
|
|
||||||
if (opts.extraNote) {
|
if (opts.extraNote) {
|
||||||
html += dpEsc(opts.extraNote);
|
html += " " + dpEsc(opts.extraNote);
|
||||||
}
|
}
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Async helper: fill a router-box container with the internal IP ──
|
/* Show generic wording immediately, then add the computer's address when found. */
|
||||||
Renders generic text immediately, then upgrades to the concrete LAN IP
|
|
||||||
if /api/network provides one. Best-effort — never blocks the UI. */
|
|
||||||
function renderRouterPortsBox(elId, opts) {
|
function renderRouterPortsBox(elId, opts) {
|
||||||
var el = document.getElementById(elId);
|
var el = document.getElementById(elId);
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -142,5 +105,5 @@ function renderRouterPortsBox(elId, opts) {
|
|||||||
target.innerHTML = renderRouterPortsHtml(next);
|
target.innerHTML = renderRouterPortsHtml(next);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function() { /* generic wording already shown — fine */ });
|
.catch(function() { /* Generic wording is already shown. */ });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,21 @@
|
|||||||
|
|
||||||
// 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 ($btnRetryRun) $btnRetryRun.addEventListener("click", retryUpdateRun);
|
||||||
|
|
||||||
|
// Browser timers and requests may be suspended while an RDP session/tab is in
|
||||||
|
// the background. Reconcile immediately when the user returns instead of
|
||||||
|
// waiting for the next interval.
|
||||||
|
window.addEventListener("focus", resumeUpdateStatusAfterInterruption);
|
||||||
|
window.addEventListener("online", resumeUpdateStatusAfterInterruption);
|
||||||
|
document.addEventListener("visibilitychange", function() {
|
||||||
|
if (document.visibilityState === "visible") resumeUpdateStatusAfterInterruption();
|
||||||
|
});
|
||||||
if ($credsCloseBtn) $credsCloseBtn.addEventListener("click", closeCredsModal);
|
if ($credsCloseBtn) $credsCloseBtn.addEventListener("click", closeCredsModal);
|
||||||
if ($supportCloseBtn) $supportCloseBtn.addEventListener("click", closeSupportModal);
|
if ($supportCloseBtn) $supportCloseBtn.addEventListener("click", closeSupportModal);
|
||||||
|
|
||||||
@@ -17,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(); });
|
||||||
@@ -164,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);
|
||||||
}
|
}
|
||||||
@@ -228,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);
|
||||||
@@ -241,13 +256,18 @@ 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the page was reloaded or the RDP/browser session resumed during an
|
||||||
|
// update, reopen the modal from the persisted backend state. This also
|
||||||
|
// surfaces a completed update that is waiting for its activation reboot.
|
||||||
|
await restoreUpdateModalIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", init);
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
@@ -60,6 +60,7 @@ function openDomainSetupModal(feat, onSaved) {
|
|||||||
if ($domainSetupTitle) $domainSetupTitle.textContent = "🌐 Domain Setup — " + feat.name;
|
if ($domainSetupTitle) $domainSetupTitle.textContent = "🌐 Domain Setup — " + feat.name;
|
||||||
|
|
||||||
var isWalletConnections = (feat.id === "nwc-wallets" || feat.domain_name === "lightning");
|
var isWalletConnections = (feat.id === "nwc-wallets" || feat.domain_name === "lightning");
|
||||||
|
var isBtcpay = feat.id === "btcpay-web";
|
||||||
|
|
||||||
var npubField = "";
|
var npubField = "";
|
||||||
if (feat.id === "haven") {
|
if (feat.id === "haven") {
|
||||||
@@ -77,30 +78,29 @@ function openDomainSetupModal(feat, onSaved) {
|
|||||||
|
|
||||||
var nwcWarning = isWalletConnections
|
var nwcWarning = isWalletConnections
|
||||||
? '<div class="domain-nwc-warning">' +
|
? '<div class="domain-nwc-warning">' +
|
||||||
'<strong>⚠ Lightning Wallet Connections requires its own unique hostname.</strong> ' +
|
'<strong>Lightning Wallet Connections needs its own address.</strong> ' +
|
||||||
'Use a new subdomain such as <code>lightning.yourdomain.com</code>, or a separate domain. ' +
|
'Use a new address such as <code>lightning.yourdomain.com</code>. Do not use an address already connected to another service.' +
|
||||||
'Do not reuse a domain already assigned to Matrix, Nextcloud, WordPress, BTCPay Server, Vaultwarden, Haven, or another Caddy site.' +
|
|
||||||
'</div>'
|
'</div>'
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
var domainPlaceholder = isWalletConnections ? "lightning.yourdomain.com" : "myservice.example.com";
|
var domainPlaceholder = isWalletConnections ? "lightning.yourdomain.com" : (isBtcpay ? "btcpay.yourdomain.com" : "myservice.example.com");
|
||||||
var domainLabelExample = isWalletConnections ? "lightning.yourdomain.com" : "call.yourdomain.com";
|
var domainLabelExample = isWalletConnections ? "lightning.yourdomain.com" : (isBtcpay ? "btcpay.yourdomain.com" : "call.yourdomain.com");
|
||||||
|
|
||||||
// Shared instructions (single source of truth: static/js/domain-prereqs.js) —
|
// Shared instructions (single source of truth: static/js/domain-prereqs.js) —
|
||||||
// identical wording to the Server + Desktop onboarding wizard and every other
|
// identical wording to the Server + Desktop onboarding wizard and every other
|
||||||
// domain-based feature, regardless of role (Node / Desktop / Server+Desktop).
|
// domain-based feature, regardless of role (Node / Desktop / Server+Desktop).
|
||||||
var hostExample = isWalletConnections ? "lightning" : "call";
|
var hostExample = isWalletConnections ? "lightning" : (isBtcpay ? "btcpay" : "call");
|
||||||
|
var purpose = isBtcpay ? "btcpay" : "";
|
||||||
|
|
||||||
$domainSetupBody.innerHTML =
|
$domainSetupBody.innerHTML =
|
||||||
'<div class="domain-setup-intro">' +
|
'<div class="domain-setup-intro">' +
|
||||||
nwcWarning +
|
nwcWarning +
|
||||||
renderDomainNeedsHtml({ serviceName: feat.name, hostExample: hostExample }) +
|
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;">Below, enter the full domain for this service — a subdomain (e.g. ' + domainLabelExample + ') or a separate domain — and paste its curl command.</p>' +
|
|
||||||
'</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 + '" /></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-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 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 update command from Njal.la</p></div>' +
|
||||||
npubField +
|
npubField +
|
||||||
'<div class="domain-field-actions"><button class="btn btn-close-modal" id="domain-setup-cancel-btn">Cancel</button><button class="btn btn-primary" id="domain-setup-save-btn">Save & Enable</button></div>';
|
'<div class="domain-field-actions"><button class="btn btn-close-modal" id="domain-setup-cancel-btn">Cancel</button><button class="btn btn-primary" id="domain-setup-save-btn">Save & Enable</button></div>';
|
||||||
|
|
||||||
@@ -143,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) {
|
||||||
@@ -169,9 +167,8 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) {
|
|||||||
|
|
||||||
var nwcWarning = isWalletConnections
|
var nwcWarning = isWalletConnections
|
||||||
? '<div class="domain-nwc-warning">' +
|
? '<div class="domain-nwc-warning">' +
|
||||||
'<strong>⚠ Lightning Wallet Connections requires its own unique hostname.</strong> ' +
|
'<strong>Lightning Wallet Connections needs its own address.</strong> ' +
|
||||||
'Use a new subdomain such as <code>lightning.yourdomain.com</code>, or a separate domain. ' +
|
'Use a new address such as <code>lightning.yourdomain.com</code>. Do not use an address already connected to another service.' +
|
||||||
'Do not reuse a domain already assigned to Matrix, Nextcloud, WordPress, BTCPay Server, Vaultwarden, Haven, or another Caddy site.' +
|
|
||||||
'</div>'
|
'</div>'
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
@@ -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>30000-40000</strong>, use your router’s port-range fields — start 30000, end 40000 — 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) {
|
||||||
@@ -144,3 +135,22 @@ async function apiFetch(path, options) {
|
|||||||
}
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function apiFetchWithTimeout(path, options, timeoutMs) {
|
||||||
|
var controller = new AbortController();
|
||||||
|
var fetchOptions = Object.assign({}, options || {});
|
||||||
|
fetchOptions.signal = controller.signal;
|
||||||
|
var timer = setTimeout(function() { controller.abort(); }, timeoutMs);
|
||||||
|
try {
|
||||||
|
return await apiFetch(path, fetchOptions);
|
||||||
|
} catch (err) {
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
var timeoutError = new Error("Request timed out");
|
||||||
|
timeoutError.name = "TimeoutError";
|
||||||
|
throw timeoutError;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,21 +2,52 @@
|
|||||||
|
|
||||||
// ── 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 = "";
|
||||||
_rebuildLogOffset = 0;
|
_rebuildLogOffset = 0;
|
||||||
_rebuildServerDown = false;
|
_rebuildServerDown = false;
|
||||||
_rebuildFinished = false;
|
_rebuildFinished = false;
|
||||||
|
_rebuildPollInFlight = false;
|
||||||
_rebuildPollFailures = 0;
|
_rebuildPollFailures = 0;
|
||||||
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);
|
||||||
@@ -34,6 +65,7 @@ function appendRebuildLog(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startRebuildPoll() {
|
function startRebuildPoll() {
|
||||||
|
if (_rebuildPollTimer) clearInterval(_rebuildPollTimer);
|
||||||
pollRebuildStatus();
|
pollRebuildStatus();
|
||||||
_rebuildPollTimer = setInterval(pollRebuildStatus, UPDATE_POLL_INTERVAL);
|
_rebuildPollTimer = setInterval(pollRebuildStatus, UPDATE_POLL_INTERVAL);
|
||||||
}
|
}
|
||||||
@@ -43,9 +75,14 @@ function stopRebuildPoll() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function pollRebuildStatus() {
|
async function pollRebuildStatus() {
|
||||||
if (_rebuildFinished) return;
|
if (_rebuildFinished || _rebuildPollInFlight) return;
|
||||||
|
_rebuildPollInFlight = true;
|
||||||
try {
|
try {
|
||||||
var data = await apiFetch("/api/rebuild/status?offset=" + _rebuildLogOffset);
|
var data = await apiFetchWithTimeout(
|
||||||
|
"/api/rebuild/status?offset=" + _rebuildLogOffset,
|
||||||
|
{ cache: "no-store" },
|
||||||
|
STATUS_POLL_FETCH_TIMEOUT
|
||||||
|
);
|
||||||
_rebuildPollFailures = 0;
|
_rebuildPollFailures = 0;
|
||||||
if (_rebuildServerDown) { _rebuildServerDown = false; }
|
if (_rebuildServerDown) { _rebuildServerDown = false; }
|
||||||
if (data.log) appendRebuildLog(data.log);
|
if (data.log) appendRebuildLog(data.log);
|
||||||
@@ -61,32 +98,33 @@ async function pollRebuildStatus() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
_rebuildPollFailures += 1;
|
_rebuildPollFailures += 1;
|
||||||
// The Hub restarts itself during activation, which briefly drops this poll.
|
// The Hub restarts itself during activation, which briefly drops this poll.
|
||||||
// If polling stays broken long past a normal restart, the page's session
|
// If polling stays broken long past a normal restart, reload to
|
||||||
// almost certainly no longer matches the server (e.g. the Hub restarted
|
// re-authenticate and show the resulting feature state.
|
||||||
// and sessions were not recovered). Reload to re-authenticate and show
|
|
||||||
// the real feature state instead of spinning forever.
|
|
||||||
if (_rebuildPollFailures >= STATUS_POLL_MAX_FAILURES) {
|
if (_rebuildPollFailures >= STATUS_POLL_MAX_FAILURES) {
|
||||||
_rebuildFinished = true;
|
_rebuildFinished = true;
|
||||||
stopRebuildPoll();
|
stopRebuildPoll();
|
||||||
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 {
|
||||||
|
_rebuildPollInFlight = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 =
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ function _getZeusConnectGuideHtml() {
|
|||||||
'<div class="nwc-connect-step"><div class="nwc-step-num">2</div><div>Open Zeus and open the <strong>Wallets</strong> screen.</div></div>' +
|
'<div class="nwc-connect-step"><div class="nwc-step-num">2</div><div>Open Zeus and open the <strong>Wallets</strong> screen.</div></div>' +
|
||||||
'<div class="nwc-connect-step"><div class="nwc-step-num">3</div><div>Tap the <strong>+ (Add Wallet)</strong> button in the top-right corner.</div></div>' +
|
'<div class="nwc-connect-step"><div class="nwc-step-num">3</div><div>Tap the <strong>+ (Add Wallet)</strong> button in the top-right corner.</div></div>' +
|
||||||
'<div class="nwc-connect-step"><div class="nwc-step-num">4</div><div>On <strong>Wallet Configuration</strong>, tap the <strong>scan icon</strong> in the top-right corner, then scan the QR code above.</div></div>' +
|
'<div class="nwc-connect-step"><div class="nwc-step-num">4</div><div>On <strong>Wallet Configuration</strong>, tap the <strong>scan icon</strong> in the top-right corner, then scan the QR code above.</div></div>' +
|
||||||
'<div class="nwc-connect-step"><div class="nwc-step-num">5</div><div>Zeus detects the LND REST QR and fills in the connection details. Review them, then tap <strong>Save Wallet Config</strong>.</div></div>' +
|
'<div class="nwc-connect-step"><div class="nwc-step-num">5</div><div>Zeus detects the LND REST QR and fills in the connection details. Turn <strong>Use Tor</strong> on (the host is a .onion address), then tap <strong>Save Wallet Config</strong>.</div></div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="nwc-connect-note"><strong>💡 Note:</strong> This is <em>not</em> the same as the NWC pairing QR shown in Lightning Wallet Connections — that gives your wallet sandboxed, limited access for everyday spending. LND REST connects Zeus directly to your node for full admin control.</div>' +
|
'<div class="nwc-connect-note"><strong>💡 Note:</strong> This is <em>not</em> the same as the NWC pairing QR shown in Lightning Wallet Connections — that gives your wallet sandboxed, limited access for everyday spending. LND REST connects Zeus directly to your node for full admin control. The QR uses your dedicated LND REST Tor address (no TLS cert) so Zeus can scan and connect over Tor.</div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ let _servicesCache = [];
|
|||||||
let _categoryLabels = {};
|
let _categoryLabels = {};
|
||||||
let _updateLog = "";
|
let _updateLog = "";
|
||||||
let _updatePollTimer = null;
|
let _updatePollTimer = null;
|
||||||
|
let _updatePollInFlight = false;
|
||||||
let _updateLogOffset = 0;
|
let _updateLogOffset = 0;
|
||||||
|
let _updateVisibleLogChars = 0;
|
||||||
let _serverWasDown = false;
|
let _serverWasDown = false;
|
||||||
let _updateFinished = false;
|
let _updateFinished = false;
|
||||||
|
let _updateStatusUnavailable = false;
|
||||||
let _updatePollFailures = 0; // consecutive failed update-status polls
|
let _updatePollFailures = 0; // consecutive failed update-status polls
|
||||||
let _supportTimerInt = null;
|
let _supportTimerInt = null;
|
||||||
let _supportEnabledAt = null;
|
let _supportEnabledAt = null;
|
||||||
@@ -24,6 +27,7 @@ let _featuresData = null;
|
|||||||
let _rebuildLog = "";
|
let _rebuildLog = "";
|
||||||
let _rebuildLogOffset = 0;
|
let _rebuildLogOffset = 0;
|
||||||
let _rebuildPollTimer = null;
|
let _rebuildPollTimer = null;
|
||||||
|
let _rebuildPollInFlight = false;
|
||||||
let _rebuildFinished = false;
|
let _rebuildFinished = false;
|
||||||
let _rebuildServerDown = false;
|
let _rebuildServerDown = false;
|
||||||
let _rebuildPollFailures = 0; // consecutive failed rebuild-status polls
|
let _rebuildPollFailures = 0; // consecutive failed rebuild-status polls
|
||||||
@@ -48,7 +52,13 @@ const $modalStatus = document.getElementById("modal-status");
|
|||||||
const $modalLog = document.getElementById("modal-log");
|
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 $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");
|
||||||
@@ -74,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,22 +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) {
|
||||||
|
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 (hasUpdates) {
|
var hasUpdates = !!data.available;
|
||||||
sidebarUpdateBtn.style.borderColor = "#2ec27e";
|
var updateStatus = data.status || "idle";
|
||||||
sidebarUpdateBtn.style.backgroundColor = "rgba(46, 194, 126, 0.08)";
|
if (updateStatus === "failed") {
|
||||||
|
// Last update errored and did not apply — surface it as a persistent
|
||||||
|
// red banner that re-opens the failed run with a "Retry Update" action.
|
||||||
|
sidebarUpdateBtn.style.borderColor = "#f66151";
|
||||||
|
sidebarUpdateBtn.style.backgroundColor = "rgba(246, 97, 81, 0.10)";
|
||||||
|
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Update failed — click to retry";
|
||||||
|
} else if (updateStatus === "reboot_required") {
|
||||||
|
sidebarUpdateBtn.style.borderColor = "#e9b64a";
|
||||||
|
sidebarUpdateBtn.style.backgroundColor = "rgba(233, 182, 74, 0.10)";
|
||||||
|
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Restart required";
|
||||||
|
} else if (updateStatus === "running") {
|
||||||
|
sidebarUpdateBtn.style.borderColor = "#78aeed";
|
||||||
|
sidebarUpdateBtn.style.backgroundColor = "rgba(120, 174, 237, 0.10)";
|
||||||
|
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Update in progress…";
|
||||||
|
} else if (hasUpdates) {
|
||||||
|
sidebarUpdateBtn.style.borderColor = "#3ecf8e";
|
||||||
|
sidebarUpdateBtn.style.backgroundColor = "rgba(62, 207, 142, 0.10)";
|
||||||
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Updates available!";
|
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,20 +2,129 @@
|
|||||||
|
|
||||||
// ── Update modal ──────────────────────────────────────────────────
|
// ── Update modal ──────────────────────────────────────────────────
|
||||||
|
|
||||||
function openUpdateModal() {
|
// ── Update dialog presentation (pill, status line, last-checked) ──
|
||||||
|
|
||||||
|
var _updateLastCheckTs = 0;
|
||||||
|
|
||||||
|
function markUpdateChecked() {
|
||||||
|
_updateLastCheckTs = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _updateLastCheckedText() {
|
||||||
|
if (!$updLastChecked) return;
|
||||||
|
if (!_updateLastCheckTs) { $updLastChecked.textContent = "—"; return; }
|
||||||
|
var s = Math.floor((Date.now() - _updateLastCheckTs) / 1000);
|
||||||
|
if (s < 30) { $updLastChecked.textContent = "just now"; return; }
|
||||||
|
var m = Math.floor(s / 60);
|
||||||
|
if (m < 60) { $updLastChecked.textContent = m + (m === 1 ? " minute ago" : " minutes ago"); return; }
|
||||||
|
var h = Math.floor(m / 60);
|
||||||
|
$updLastChecked.textContent = h + (h === 1 ? " hour ago" : " hours ago");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUpdatePill(state) {
|
||||||
|
if (!$updPill) return;
|
||||||
|
var cls = "upd-pill";
|
||||||
|
var html;
|
||||||
|
if (state === "checking") {
|
||||||
|
cls += " st-loading"; html = '<span class="status-dot loading pulse"></span>Checking…';
|
||||||
|
} else if (state === "uptodate") {
|
||||||
|
cls += " st-active"; html = '<span class="status-dot active"></span>Up to date';
|
||||||
|
} else if (state === "complete") {
|
||||||
|
cls += " st-active"; html = '<span class="status-dot active"></span>Update complete';
|
||||||
|
} else if (state === "reboot") {
|
||||||
|
cls += " st-needs-attention"; html = '<span class="status-dot needs-attention pulse"></span>Restart required';
|
||||||
|
} else if (state === "failed") {
|
||||||
|
cls += " st-failed"; html = '<span class="status-dot failed"></span>Update failed';
|
||||||
|
} else if (state === "unavailable") {
|
||||||
|
cls += " st-needs-attention"; html = '<span class="status-dot needs-attention pulse"></span>Status unknown';
|
||||||
|
} else {
|
||||||
|
cls += " st-loading"; html = '<span class="status-dot loading pulse"></span>Updating…';
|
||||||
|
}
|
||||||
|
$updPill.className = cls;
|
||||||
|
$updPill.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single place that reflects update state in the dialog: status message
|
||||||
|
// text + color, header pill, and the Close / Check again availability.
|
||||||
|
function _setUpdateStatus(text) {
|
||||||
|
if ($modalStatus) $modalStatus.textContent = text;
|
||||||
|
var state;
|
||||||
|
if (text.charAt(0) === "✗") state = "failed";
|
||||||
|
else if (text.indexOf("restart required") !== -1) state = "reboot";
|
||||||
|
else if (text.charAt(0) === "✓") state = (text.indexOf("already up to date") !== -1) ? "uptodate" : "complete";
|
||||||
|
else if (text.indexOf("unavailable") !== -1) state = "unavailable";
|
||||||
|
else if (text.indexOf("Checking") === 0) state = "checking";
|
||||||
|
else state = "updating";
|
||||||
|
if ($modalStatus) {
|
||||||
|
var msg = "update-status-msg";
|
||||||
|
if (state === "failed") $modalStatus.className = msg + " st-err";
|
||||||
|
else if (state === "reboot" || state === "unavailable") $modalStatus.className = msg + " st-warn";
|
||||||
|
else if (state === "uptodate" || state === "complete") $modalStatus.className = msg + " st-ok";
|
||||||
|
else $modalStatus.className = msg;
|
||||||
|
// In the up-to-date state the green console line already says it —
|
||||||
|
// the mockup shows it exactly once.
|
||||||
|
$modalStatus.style.display = (state === "uptodate") ? "none" : "";
|
||||||
|
}
|
||||||
|
setUpdatePill(state);
|
||||||
|
var interactive = (state !== "updating");
|
||||||
|
if ($updateCloseBtn) $updateCloseBtn.disabled = !interactive;
|
||||||
|
if ($btnCheckAgain) {
|
||||||
|
$btnCheckAgain.disabled = (state === "checking" || state === "updating");
|
||||||
|
$btnCheckAgain.style.display = interactive ? "inline-flex" : "none";
|
||||||
|
}
|
||||||
|
_updateLastCheckedText();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openUpdateModal() {
|
||||||
if (!$modal) return;
|
if (!$modal) return;
|
||||||
apiFetch("/api/updates/check")
|
|
||||||
|
// Open immediately in a checking state; the status/check requests below
|
||||||
|
// fill in the real result (mockup: auto-check on open).
|
||||||
|
$modal.classList.add("open");
|
||||||
|
if ($modalLog) $modalLog.innerHTML = '<span class="dim">Checking for updates…</span>';
|
||||||
|
_setUpdateStatus("Checking for updates…");
|
||||||
|
|
||||||
|
// Reattach before checking for new updates. This makes a browser reload,
|
||||||
|
// RDP reconnect, or suspended tab recover the authoritative systemd-backed
|
||||||
|
// state instead of starting over or claiming the system is merely up to date.
|
||||||
|
try {
|
||||||
|
var current = await apiFetchWithTimeout(
|
||||||
|
"/api/updates/status?offset=0",
|
||||||
|
{ cache: "no-store" },
|
||||||
|
STATUS_POLL_FETCH_TIMEOUT
|
||||||
|
);
|
||||||
|
if (current.running || current.result === "reboot_required" || current.result === "failed") {
|
||||||
|
// An in-progress update, a staged update awaiting reboot, or a prior
|
||||||
|
// failed update — reattach to the persisted systemd/log state instead of
|
||||||
|
// starting over or wrongly reporting "up to date".
|
||||||
|
showExistingUpdate(current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// The normal start path below has its own visible error handling.
|
||||||
|
}
|
||||||
|
|
||||||
|
apiFetchWithTimeout(
|
||||||
|
"/api/updates/check",
|
||||||
|
{ cache: "no-store" },
|
||||||
|
STATUS_POLL_FETCH_TIMEOUT
|
||||||
|
)
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
|
markUpdateChecked();
|
||||||
if (!data.available) {
|
if (!data.available) {
|
||||||
stopUpdatePoll();
|
stopUpdatePoll();
|
||||||
_updateLog = "";
|
_updateLog = "";
|
||||||
_updateLogOffset = 0;
|
_updateLogOffset = 0;
|
||||||
|
_updateVisibleLogChars = 0;
|
||||||
_updateFinished = true;
|
_updateFinished = true;
|
||||||
if ($modalLog) $modalLog.textContent = "";
|
_updateStatusUnavailable = false;
|
||||||
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 ($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;
|
||||||
@@ -27,23 +136,70 @@ function openUpdateModal() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function _doOpenUpdateModal() {
|
function prepareUpdateModal() {
|
||||||
if (!$modal) return;
|
if (!$modal) return;
|
||||||
|
stopUpdatePoll();
|
||||||
_updateLog = "";
|
_updateLog = "";
|
||||||
_updateLogOffset = 0;
|
_updateLogOffset = 0;
|
||||||
|
_updateVisibleLogChars = 0;
|
||||||
|
_updatePollInFlight = false;
|
||||||
_serverWasDown = false;
|
_serverWasDown = false;
|
||||||
_updateFinished = false;
|
_updateFinished = 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 ($btnRetryRun) $btnRetryRun.style.display = "none";
|
||||||
if ($btnCloseModal) $btnCloseModal.disabled = true;
|
if ($btnCloseModal) $btnCloseModal.disabled = true;
|
||||||
$modal.classList.add("open");
|
$modal.classList.add("open");
|
||||||
|
}
|
||||||
|
|
||||||
|
function _doOpenUpdateModal() {
|
||||||
|
prepareUpdateModal();
|
||||||
startUpdate();
|
startUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showExistingUpdate(data) {
|
||||||
|
prepareUpdateModal();
|
||||||
|
if (data.log) appendLog(data.log);
|
||||||
|
_updateLogOffset = Number(data.offset) || 0;
|
||||||
|
|
||||||
|
if (data.running) {
|
||||||
|
_setUpdateStatus("Updating…");
|
||||||
|
startUpdatePoll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateFinished = true;
|
||||||
|
if (data.result === "reboot_required") {
|
||||||
|
onUpdateDone("reboot_required");
|
||||||
|
} else if (data.result === "success") {
|
||||||
|
onUpdateDone(true);
|
||||||
|
} else {
|
||||||
|
onUpdateDone(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreUpdateModalIfNeeded() {
|
||||||
|
if (!$modal || $modal.classList.contains("open")) return;
|
||||||
|
try {
|
||||||
|
var data = await apiFetchWithTimeout(
|
||||||
|
"/api/updates/status?offset=0",
|
||||||
|
{ cache: "no-store" },
|
||||||
|
STATUS_POLL_FETCH_TIMEOUT
|
||||||
|
);
|
||||||
|
if (data.running || data.result === "reboot_required") {
|
||||||
|
showExistingUpdate(data);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Dashboard startup must remain usable when status cannot be reached.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function closeUpdateModal() {
|
function closeUpdateModal() {
|
||||||
if (!$modal) return;
|
if (!$modal) return;
|
||||||
$modal.classList.remove("open");
|
$modal.classList.remove("open");
|
||||||
@@ -53,27 +209,44 @@ function closeUpdateModal() {
|
|||||||
function appendLog(text) {
|
function appendLog(text) {
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
_updateLog += text;
|
_updateLog += text;
|
||||||
if ($modalLog) { $modalLog.textContent += text; $modalLog.scrollTop = $modalLog.scrollHeight; }
|
if ($modalLog) {
|
||||||
|
// Appending a text node avoids reparsing/replacing the complete log on
|
||||||
|
// every two-second poll. Trim only occasionally once the visible log is
|
||||||
|
// large; the complete _updateLog remains available for error reports.
|
||||||
|
if (_updateVisibleLogChars + text.length > UPDATE_VISIBLE_LOG_MAX_CHARS) {
|
||||||
|
var tail = _updateLog.slice(-UPDATE_VISIBLE_LOG_TRIM_CHARS);
|
||||||
|
var notice = "[Earlier update output hidden from this view; it remains in the saved report.]\n\n";
|
||||||
|
$modalLog.textContent = notice + tail;
|
||||||
|
_updateVisibleLogChars = notice.length + tail.length;
|
||||||
|
} else {
|
||||||
|
$modalLog.appendChild(document.createTextNode(text));
|
||||||
|
_updateVisibleLogChars += text.length;
|
||||||
|
}
|
||||||
|
$modalLog.scrollTop = $modalLog.scrollHeight;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startUpdate() {
|
function startUpdate() {
|
||||||
fetch("/api/updates/run", { method: "POST" })
|
apiFetchWithTimeout(
|
||||||
.then(function(response) {
|
"/api/updates/run",
|
||||||
if (!response.ok) return response.text().then(function(t) { throw new Error(t); });
|
{ method: "POST" },
|
||||||
return response.json();
|
STATUS_POLL_FETCH_TIMEOUT * 2
|
||||||
})
|
)
|
||||||
.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 ($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) {
|
||||||
@@ -83,6 +256,7 @@ function startUpdate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startUpdatePoll() {
|
function startUpdatePoll() {
|
||||||
|
if (_updatePollTimer) clearInterval(_updatePollTimer);
|
||||||
pollUpdateStatus();
|
pollUpdateStatus();
|
||||||
_updatePollTimer = setInterval(pollUpdateStatus, UPDATE_POLL_INTERVAL);
|
_updatePollTimer = setInterval(pollUpdateStatus, UPDATE_POLL_INTERVAL);
|
||||||
}
|
}
|
||||||
@@ -92,33 +266,45 @@ function stopUpdatePoll() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function pollUpdateStatus() {
|
async function pollUpdateStatus() {
|
||||||
if (_updateFinished) return;
|
// setInterval does not wait for an async callback. The guard prevents a slow
|
||||||
|
// request from creating overlapping, out-of-order status polls.
|
||||||
|
if (_updateFinished || _updatePollInFlight) return;
|
||||||
|
_updatePollInFlight = true;
|
||||||
try {
|
try {
|
||||||
var data = await apiFetch("/api/updates/status?offset=" + _updateLogOffset);
|
var data = await apiFetchWithTimeout(
|
||||||
|
"/api/updates/status?offset=" + _updateLogOffset,
|
||||||
|
{ cache: "no-store" },
|
||||||
|
STATUS_POLL_FETCH_TIMEOUT
|
||||||
|
);
|
||||||
_updatePollFailures = 0;
|
_updatePollFailures = 0;
|
||||||
if (_serverWasDown) {
|
if (_serverWasDown) {
|
||||||
_serverWasDown = false;
|
_serverWasDown = false;
|
||||||
if (!data.running) {
|
if (!data.running) {
|
||||||
// The update finished while the server was restarting. Reset to
|
// The update finished while the server or browser connection was away.
|
||||||
// offset 0 and re-fetch so the complete log is shown from the top.
|
// Re-fetch from offset 0 so the final result and complete tail agree.
|
||||||
_updateLog = "";
|
_updateLog = "";
|
||||||
_updateLogOffset = 0;
|
_updateLogOffset = 0;
|
||||||
|
_updateVisibleLogChars = 0;
|
||||||
if ($modalLog) $modalLog.textContent = "";
|
if ($modalLog) $modalLog.textContent = "";
|
||||||
try {
|
try {
|
||||||
var fullData = await apiFetch("/api/updates/status?offset=0");
|
var fullData = await apiFetchWithTimeout(
|
||||||
|
"/api/updates/status?offset=0",
|
||||||
|
{ cache: "no-store" },
|
||||||
|
STATUS_POLL_FETCH_TIMEOUT
|
||||||
|
);
|
||||||
if (fullData.log) appendLog(fullData.log);
|
if (fullData.log) appendLog(fullData.log);
|
||||||
_updateLogOffset = fullData.offset;
|
_updateLogOffset = fullData.offset;
|
||||||
} catch (e) {
|
data = fullData;
|
||||||
// If the re-fetch fails, fall through with whatever we have.
|
} catch (_) {
|
||||||
if (data.log) appendLog(data.log);
|
if (data.log) appendLog(data.log);
|
||||||
_updateLogOffset = data.offset;
|
_updateLogOffset = data.offset;
|
||||||
}
|
}
|
||||||
if (data.result === "reboot_required") {
|
if (data.result === "reboot_required") {
|
||||||
appendLog("[Server restarted — update completed, reboot required.]\n");
|
appendLog("[Reconnected — update completed, reboot required.]\n");
|
||||||
} else if (data.result === "success") {
|
} else if (data.result === "success") {
|
||||||
appendLog("[Server restarted — update completed successfully.]\n");
|
appendLog("[Reconnected — update completed successfully.]\n");
|
||||||
} else {
|
} else {
|
||||||
appendLog("[Server restarted — update encountered an error.]\n");
|
appendLog("[Reconnected — update encountered an error.]\n");
|
||||||
}
|
}
|
||||||
_updateFinished = true;
|
_updateFinished = true;
|
||||||
stopUpdatePoll();
|
stopUpdatePoll();
|
||||||
@@ -129,8 +315,8 @@ async function pollUpdateStatus() {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
appendLog("[Server 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;
|
||||||
@@ -146,32 +332,79 @@ async function pollUpdateStatus() {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
_updatePollFailures += 1;
|
_updatePollFailures += 1;
|
||||||
// Same guard as the rebuild modal: if polling stays broken long past a
|
|
||||||
// normal Hub restart, reload to re-authenticate and show the real state
|
|
||||||
// instead of spinning forever.
|
|
||||||
if (_updatePollFailures >= STATUS_POLL_MAX_FAILURES) {
|
if (_updatePollFailures >= STATUS_POLL_MAX_FAILURES) {
|
||||||
_updateFinished = true;
|
showUpdateStatusUnavailable();
|
||||||
stopUpdatePoll();
|
|
||||||
window.location.reload();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!_serverWasDown) { _serverWasDown = true; appendLog("\n[Server restarting — waiting for it to come back…]\n"); if ($modalStatus) $modalStatus.textContent = "Server restarting…"; }
|
if (!_serverWasDown) {
|
||||||
|
_serverWasDown = true;
|
||||||
|
appendLog("\n[Update status connection interrupted — retrying…]\n");
|
||||||
|
_setUpdateStatus("Reconnecting to update…");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_updatePollInFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showUpdateStatusUnavailable() {
|
||||||
|
_updateFinished = true;
|
||||||
|
_updateStatusUnavailable = true;
|
||||||
|
stopUpdatePoll();
|
||||||
|
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
||||||
|
_setUpdateStatus("Update status unavailable — update may still be running");
|
||||||
|
appendLog("\n[The Hub could not confirm update status. The background update was not stopped. Select Retry Status after reconnecting.]\n");
|
||||||
|
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "inline-flex";
|
||||||
|
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryUpdateStatus() {
|
||||||
|
if (!$modal) return;
|
||||||
|
_updateFinished = false;
|
||||||
|
_updateStatusUnavailable = false;
|
||||||
|
_updatePollFailures = 0;
|
||||||
|
_serverWasDown = true;
|
||||||
|
if ($modalSpinner) $modalSpinner.classList.add("spinning");
|
||||||
|
_setUpdateStatus("Reconnecting to update…");
|
||||||
|
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
||||||
|
if ($btnCloseModal) $btnCloseModal.disabled = true;
|
||||||
|
startUpdatePoll();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-run a failed (or never-applied) update from scratch. The backend always
|
||||||
|
// allows this after a FAILED attempt even though flake.lock may already be
|
||||||
|
// advanced (the previous build never staged a bootable generation).
|
||||||
|
function retryUpdateRun() {
|
||||||
|
if ($btnRetryRun) $btnRetryRun.style.display = "none";
|
||||||
|
if ($btnSave) $btnSave.style.display = "none";
|
||||||
|
if ($btnReboot) $btnReboot.style.display = "none";
|
||||||
|
_doOpenUpdateModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resumeUpdateStatusAfterInterruption() {
|
||||||
|
if (!$modal || !$modal.classList.contains("open")) return;
|
||||||
|
if (_updateStatusUnavailable) {
|
||||||
|
retryUpdateStatus();
|
||||||
|
} else if (!_updateFinished) {
|
||||||
|
pollUpdateStatus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onUpdateDone(result) {
|
function onUpdateDone(result) {
|
||||||
|
_updateStatusUnavailable = false;
|
||||||
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
||||||
|
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";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +420,7 @@ function saveErrorReport() {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── Reboot ────────────────────────────────────────────────────────
|
// ── Reboot ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
var _rebootStartTime = 0;
|
var _rebootStartTime = 0;
|
||||||
|
|||||||
@@ -364,17 +364,12 @@ async function loadStep3() {
|
|||||||
+ renderDomainNeedsHtml({ serviceName: null, hostExample: "call" })
|
+ renderDomainNeedsHtml({ serviceName: null, hostExample: "call" })
|
||||||
+ renderNjallaStepsHtml({ hostExample: "call", pasteHint: "next to its service below" })
|
+ renderNjallaStepsHtml({ hostExample: "call", pasteHint: "next to its service below" })
|
||||||
+ '</div>';
|
+ '</div>';
|
||||||
html += '<p class="onboarding-hint">Enter each service\'s full domain — a subdomain (e.g. <code>call.yourdomain.com</code>) or a separate domain (e.g. <code>call.com</code>) — and its Njal.la DDNS curl command.</p>';
|
html += '<p class="onboarding-hint">Enter the address for each service and paste its update command from Njal.la.</p>';
|
||||||
|
|
||||||
// Router note (the same wording is shown again, per-service, whenever a
|
// Router note (the same wording is shown again, per-service, whenever a
|
||||||
// domain-based feature is enabled, and lives on each service tile afterwards)
|
// domain-based feature is enabled, and lives on each service tile afterwards)
|
||||||
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
|
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
|
||||||
+ renderRouterPortsHtml({
|
+ renderRouterPortsHtml({ internalIp: internalIp })
|
||||||
internalIp: internalIp,
|
|
||||||
plural: true,
|
|
||||||
includeSsh: true,
|
|
||||||
extraNote: "Element Call needs a few extra ports (some UDP), and you’ll be shown exactly which when you enable it.",
|
|
||||||
})
|
|
||||||
+ '</div>';
|
+ '</div>';
|
||||||
|
|
||||||
relevantDomains.forEach(function(d) {
|
relevantDomains.forEach(function(d) {
|
||||||
@@ -382,20 +377,20 @@ async function loadStep3() {
|
|||||||
html += '<div class="onboarding-domain-group">';
|
html += '<div class="onboarding-domain-group">';
|
||||||
html += '<label class="onboarding-domain-label">' + escHtml(d.label) + '</label>';
|
html += '<label class="onboarding-domain-label">' + escHtml(d.label) + '</label>';
|
||||||
html += '<input class="onboarding-domain-input domain-field-input" type="text" id="domain-input-' + escHtml(d.name) + '" data-domain="' + escHtml(d.name) + '" placeholder="e.g. ' + escHtml(d.name) + '.yourdomain.com" value="' + escHtml(currentVal) + '" />';
|
html += '<input class="onboarding-domain-input domain-field-input" type="text" id="domain-input-' + escHtml(d.name) + '" data-domain="' + escHtml(d.name) + '" placeholder="e.g. ' + escHtml(d.name) + '.yourdomain.com" value="' + escHtml(currentVal) + '" />';
|
||||||
html += '<label class="onboarding-domain-label onboarding-domain-label--sub">Njal.la DDNS Curl Command</label>';
|
html += '<label class="onboarding-domain-label onboarding-domain-label--sub">Njal.la Update Command</label>';
|
||||||
html += '<input class="onboarding-domain-input domain-field-input" type="text" id="ddns-input-' + escHtml(d.name) + '" data-ddns="' + escHtml(d.name) + '" placeholder="curl "https://njal.la/update/?h=...&k=...&auto"" />';
|
html += '<input class="onboarding-domain-input domain-field-input" type="text" id="ddns-input-' + escHtml(d.name) + '" data-ddns="' + escHtml(d.name) + '" placeholder="curl "https://njal.la/update/?h=...&k=...&auto"" />';
|
||||||
html += '<p class="onboarding-hint" style="margin-top:4px;">ℹ Paste the curl URL from your Njal.la dashboard\'s Dynamic record</p>';
|
html += '<p class="onboarding-hint" style="margin-top:4px;">Paste the update command from Njal.la</p>';
|
||||||
html += '<button type="button" class="btn btn-primary onboarding-domain-save-btn" data-save-domain="' + escHtml(d.name) + '" style="align-self:flex-start;margin-top:8px;font-size:0.82rem;padding:6px 16px;">Save</button>';
|
html += '<button type="button" class="btn btn-primary onboarding-domain-save-btn" data-save-domain="' + escHtml(d.name) + '" style="align-self:flex-start;margin-top:8px;font-size:0.82rem;padding:6px 16px;">Save</button>';
|
||||||
html += '<span class="onboarding-domain-save-status" id="domain-save-status-' + escHtml(d.name) + '" style="font-size:0.82rem;min-height:1.2em;"></span>';
|
html += '<span class="onboarding-domain-save-status" id="domain-save-status-' + escHtml(d.name) + '" style="font-size:0.82rem;min-height:1.2em;"></span>';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSL email section
|
// Contact email section
|
||||||
var emailVal = domainValues["sslemail"] || "";
|
var emailVal = domainValues["sslemail"] || "";
|
||||||
html += '<div class="onboarding-domain-group onboarding-domain-group--email">';
|
html += '<div class="onboarding-domain-group onboarding-domain-group--email">';
|
||||||
html += '<label class="onboarding-domain-label">📧 SSL Certificate Email</label>';
|
html += '<label class="onboarding-domain-label">📧 Email Address</label>';
|
||||||
html += '<p class="onboarding-hint onboarding-hint--inline">Let\'s Encrypt uses this for certificate expiry notifications.</p>';
|
html += '<p class="onboarding-hint onboarding-hint--inline">Used to send important notices about your services.</p>';
|
||||||
html += '<input class="onboarding-domain-input domain-field-input" type="email" id="ssl-email-input" placeholder="you@example.com" value="' + escHtml(emailVal) + '" />';
|
html += '<input class="onboarding-domain-input domain-field-input" type="email" id="ssl-email-input" placeholder="you@example.com" value="' + escHtml(emailVal) + '" />';
|
||||||
html += '<button type="button" class="btn btn-primary onboarding-domain-save-btn" data-save-email="true" style="align-self:flex-start;margin-top:8px;font-size:0.82rem;padding:6px 16px;">Save</button>';
|
html += '<button type="button" class="btn btn-primary onboarding-domain-save-btn" data-save-email="true" style="align-self:flex-start;margin-top:8px;font-size:0.82rem;padding:6px 16px;">Save</button>';
|
||||||
html += '<span class="onboarding-domain-save-status" id="domain-save-status-email" style="font-size:0.82rem;min-height:1.2em;"></span>';
|
html += '<span class="onboarding-domain-save-status" id="domain-save-status-email" style="font-size:0.82rem;min-height:1.2em;"></span>';
|
||||||
|
|||||||
@@ -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,64 +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-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>
|
||||||
@@ -97,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">
|
||||||
@@ -169,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>
|
||||||
@@ -196,22 +470,18 @@
|
|||||||
encrypted messaging, password management, cloud storage, website hosting, and more.
|
encrypted messaging, password management, cloud storage, website hosting, and more.
|
||||||
</p>
|
</p>
|
||||||
<div class="upgrade-info-box">
|
<div class="upgrade-info-box">
|
||||||
<p class="upgrade-info-title">⚠ What you should know:</p>
|
<p class="upgrade-info-title">Simple, guided setup:</p>
|
||||||
<ul class="upgrade-info-list">
|
<ul class="upgrade-info-list">
|
||||||
<li>You will need to purchase domains for your services — <a href="https://njal.la" target="_blank" rel="noopener noreferrer">Njal.la</a> is the only supported domain provider</li>
|
<li>Sovran_SystemsOS walks you through getting your domain from <a href="https://njal.la" target="_blank" rel="noopener noreferrer">Njal.la</a> and connecting your services</li>
|
||||||
<li>Some services require ports to be opened on your router</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>
|
|
||||||
</div>
|
|
||||||
<div class="upgrade-info-box">
|
|
||||||
<p class="upgrade-info-title">ℹ️ Good to know:</p>
|
|
||||||
<ul class="upgrade-info-list">
|
|
||||||
<li>Your services will be accessible via your home internet connection. Your approximate geographic area may be visible through DNS records — this is normal for all self-hosted services</li>
|
|
||||||
<li>Your Bitcoin node remains fully private over Tor</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<p class="support-desc">
|
<p class="support-desc">
|
||||||
<strong>Don't worry</strong> — the Hub will walk you through every step after the upgrade.
|
⚠️ <strong>Heads-up:</strong> your domain points at your home internet connection,
|
||||||
Domain setup, port forwarding, and configuration are all guided.
|
so anyone can look up your home IP address. Domain privacy does not hide it.
|
||||||
|
</p>
|
||||||
|
<p class="support-desc">
|
||||||
|
The Hub guides you through every step.
|
||||||
</p>
|
</p>
|
||||||
<p class="support-desc upgrade-rebuild-note">
|
<p class="support-desc upgrade-rebuild-note">
|
||||||
The system will rebuild after upgrading. This may take several minutes.
|
The system will rebuild after upgrading. This may take several minutes.
|
||||||
@@ -250,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
|
||||||
@@ -262,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 />
|
||||||
@@ -277,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.
|
||||||
@@ -322,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>
|
||||||
@@ -123,10 +123,10 @@
|
|||||||
<div class="onboarding-panel" id="step-3" style="display:none">
|
<div class="onboarding-panel" id="step-3" style="display:none">
|
||||||
<div class="onboarding-step-header">
|
<div class="onboarding-step-header">
|
||||||
<span class="onboarding-step-icon">🌐</span>
|
<span class="onboarding-step-icon">🌐</span>
|
||||||
<h2 class="onboarding-step-title">Domain Configuration</h2>
|
<h2 class="onboarding-step-title">Connect Your Services</h2>
|
||||||
<p class="onboarding-step-desc">
|
<p class="onboarding-step-desc">
|
||||||
Sovran_SystemsOS uses <strong><a href="https://njal.la" target="_blank" style="color: var(--accent-color);">Njal.la</a></strong> for domains and Dynamic DNS, and your router needs ports <strong>80</strong> and <strong>443</strong> (TCP) forwarded to this computer.
|
Sovran_SystemsOS walks you through getting your domain from <strong><a href="https://njal.la" target="_blank" style="color: var(--accent-color);">Njal.la</a></strong> and connecting your services.
|
||||||
Everything you need — Njal.la account, Dynamic records, and the one router task — is laid out step by step below.
|
To make your services available outside your home, you’ll complete one router task: forward ports <strong>80</strong> and <strong>443</strong> to this computer.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="onboarding-card" id="step-3-body">
|
<div class="onboarding-card" id="step-3-body">
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Update-state helpers for the Sovran Hub.
|
||||||
|
|
||||||
|
The full-system updater stages a NixOS generation with ``nixos-rebuild boot``.
|
||||||
|
That generation is not active until the machine reboots — and the same is true
|
||||||
|
for updates started from a terminal or an SSH support session, which never go
|
||||||
|
near the Hub's status files.
|
||||||
|
|
||||||
|
The ONLY reliable indicator that a reboot is pending is NixOS itself: the
|
||||||
|
system profile (``/nix/var/nix/profiles/system``), which ``nixos-rebuild``
|
||||||
|
points at the newest generation on every ``boot`` AND every ``switch``, versus
|
||||||
|
``/run/current-system``, the generation actually running since the last boot.
|
||||||
|
When the two differ, a staged generation has not been booted yet.
|
||||||
|
|
||||||
|
Earlier revisions reconstructed this from a marker file and log tails written
|
||||||
|
by the Hub's own updater. Any system updated by other means — or whose
|
||||||
|
``REBOOT_REQUIRED`` status was written by an updater older than the marker
|
||||||
|
feature — left the Hub showing "Restart required" forever: the recorded
|
||||||
|
generation could never equal the (since advanced) running one, so the marker
|
||||||
|
could never be cleared.
|
||||||
|
|
||||||
|
This module has no FastAPI or systemd dependencies so the policy can be tested
|
||||||
|
without importing the Hub server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
# The NixOS system profile. ``nixos-rebuild boot`` and ``nixos-rebuild
|
||||||
|
# switch`` both add a generation here; ``boot`` additionally makes it the
|
||||||
|
# bootloader default. The path is a symlink chain (``system`` ->
|
||||||
|
# ``system-N-link`` -> ``/nix/store/...-nixos-system-...``).
|
||||||
|
BOOT_PROFILE_PATH = "/nix/var/nix/profiles/system"
|
||||||
|
CURRENT_SYSTEM_PATH = "/run/current-system"
|
||||||
|
|
||||||
|
|
||||||
|
def reboot_is_pending(
|
||||||
|
boot_profile_path: str = BOOT_PROFILE_PATH,
|
||||||
|
current_system_path: str = CURRENT_SYSTEM_PATH,
|
||||||
|
) -> bool:
|
||||||
|
"""Return whether a staged NixOS generation has not been booted yet.
|
||||||
|
|
||||||
|
This is deliberately independent of how the update was started — Hub
|
||||||
|
"Update System", terminal ``nixos-rebuild boot``, or a support session all
|
||||||
|
move the system profile the same way:
|
||||||
|
|
||||||
|
* after ``nixos-rebuild boot``: profile -> new, current -> old → pending
|
||||||
|
* after rebooting: both -> new → cleared
|
||||||
|
* after ``nixos-rebuild switch``: both move together → no reboot
|
||||||
|
ever needed (switch activates immediately)
|
||||||
|
* after a rollback: both point at the rollback target → cleared
|
||||||
|
|
||||||
|
Unreadable or missing paths are treated as "not pending": the Hub must
|
||||||
|
never demand a reboot it cannot substantiate.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
boot_default = os.path.realpath(boot_profile_path)
|
||||||
|
current = os.path.realpath(current_system_path)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
if not os.path.exists(boot_default) or not os.path.exists(current):
|
||||||
|
return False
|
||||||
|
return boot_default != current
|
||||||
|
|
||||||
|
|
||||||
|
def effective_update_status(
|
||||||
|
status: str,
|
||||||
|
boot_profile_path: str = BOOT_PROFILE_PATH,
|
||||||
|
current_system_path: str = CURRENT_SYSTEM_PATH,
|
||||||
|
) -> str:
|
||||||
|
"""Map a persisted Hub status to the one that reflects live NixOS state.
|
||||||
|
|
||||||
|
Only ``REBOOT_REQUIRED`` is re-validated: it means "the update staged a
|
||||||
|
generation the machine has not booted into", a claim that must stay true
|
||||||
|
no matter which tool performed the last update. When the boot default IS
|
||||||
|
the running system the claim is stale — the staged generation booted, was
|
||||||
|
superseded by a newer update, or the marker was written by an updater that
|
||||||
|
could never clear it — so the effective status is ``IDLE``.
|
||||||
|
|
||||||
|
All other statuses (``RUNNING``, ``FAILED``, ``SUCCESS``, ``IDLE``) pass
|
||||||
|
through unchanged; RUNNING staleness is handled separately against the
|
||||||
|
systemd unit itself.
|
||||||
|
"""
|
||||||
|
if status == "REBOOT_REQUIRED" and not reboot_is_pending(
|
||||||
|
boot_profile_path, current_system_path
|
||||||
|
):
|
||||||
|
return "IDLE"
|
||||||
|
return status
|
||||||
@@ -5,12 +5,12 @@
|
|||||||
"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.8",
|
"rtl.service": "0.15.12",
|
||||||
"btcpayserver.service": "2.4.2",
|
"btcpayserver.service": "2.4.2",
|
||||||
"albyhub.service": "1.8.0",
|
"albyhub.service": "1.24.0",
|
||||||
"mempool.service": "3.2.1",
|
"mempool.service": "3.2.1",
|
||||||
"matrix-synapse.service": "1.115.0",
|
"matrix-synapse.service": "1.115.0",
|
||||||
"livekit.service": "1.5.2",
|
"livekit.service": "1.13.6",
|
||||||
"vaultwarden.service": "1.32.0",
|
"vaultwarden.service": "1.32.0",
|
||||||
"haven-relay.service": "0.1.0"
|
"haven-relay.service": "0.1.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 442 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 217 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
+38
-1
@@ -147,10 +147,11 @@
|
|||||||
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
|
||||||
|
tor-browser
|
||||||
];
|
];
|
||||||
|
|
||||||
# ── Shell ──────────────────────────────────────────────────
|
# ── Shell ──────────────────────────────────────────────────
|
||||||
@@ -164,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 ''
|
||||||
@@ -171,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
+59
-23
@@ -5,11 +5,11 @@
|
|||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1785778881,
|
"lastModified": 1790862821,
|
||||||
"narHash": "sha256-yHJJTm3o7ZhiVp68G+hh+dz1GKV+ACOT6mQj+eYvJLQ=",
|
"narHash": "sha256-IcLn2hPlxsbn2PKVlFD6gsDzkVurQ7b0KPtyORUGFcs=",
|
||||||
"owner": "emmanuelrosa",
|
"owner": "emmanuelrosa",
|
||||||
"repo": "btc-clients-nix",
|
"repo": "btc-clients-nix",
|
||||||
"rev": "fc1aca94d839f82e7501fe0d01279d5c1fba6e63",
|
"rev": "99b0442dcc15198efcb62f3c1e7561a361749a6a",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -26,11 +26,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1782949081,
|
"lastModified": 1788450739,
|
||||||
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
|
"narHash": "sha256-glZLQlzIn1fXH6PazR2iUmTo7kzzyYSshrWhLS9TqCU=",
|
||||||
"owner": "hercules-ci",
|
"owner": "hercules-ci",
|
||||||
"repo": "flake-parts",
|
"repo": "flake-parts",
|
||||||
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
|
"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,11 +56,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs-stable": {
|
"nixpkgs-stable": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1786201459,
|
"lastModified": 1790750587,
|
||||||
"narHash": "sha256-CiOTEjmwAmG2AWnaIno9YaCJJmpca2FXPhMAsnrolCg=",
|
"narHash": "sha256-VfjaoJ1Uyb7JZTrBgE5Jf2nhjtQPmD9KOd19HJwmZwM=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "8b8c811c7c2541c30382c5de7ed26be055569c60",
|
"rev": "78e9c786dc08cd4f3420c2395cd977206a9b1da2",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -72,11 +72,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs_2": {
|
"nixpkgs_2": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1786106723,
|
"lastModified": 1790822859,
|
||||||
"narHash": "sha256-zDSUbpoeo/9ZmD2+wXnzxoo1+uhL8vxc0b8yuYMKYq0=",
|
"narHash": "sha256-69xHQhAeMAD2wDXO7T2pcOZIF9Sga2W+JkmY2a11Ops=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "f13ff45afd1bb73e640eaa08a7066dbed07e3238",
|
"rev": "c59305bab2065cfecc4944690d9eedbb56f3a9fa",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -88,11 +88,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs_3": {
|
"nixpkgs_3": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1784555310,
|
"lastModified": 1789724158,
|
||||||
"narHash": "sha256-/FCliTPgiuV1owejZFNx3Ch9irdvkOfOFl+HHZ+DrtM=",
|
"narHash": "sha256-nlKgrm0dsVhOSopKheVBCcOpiIticufPPVLdVOI0euA=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "421eebfd0ec7bccd4abe826ce62d7e6e83129493",
|
"rev": "0a3468a402c449992505b6a9fc5b06580141b750",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -102,6 +102,22 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"nixpkgs_4": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1790822859,
|
||||||
|
"narHash": "sha256-69xHQhAeMAD2wDXO7T2pcOZIF9Sga2W+JkmY2a11Ops=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "c59305bab2065cfecc4944690d9eedbb56f3a9fa",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"nixvim": {
|
"nixvim": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-parts": "flake-parts",
|
"flake-parts": "flake-parts",
|
||||||
@@ -109,11 +125,11 @@
|
|||||||
"systems": "systems"
|
"systems": "systems"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1785763201,
|
"lastModified": 1790541651,
|
||||||
"narHash": "sha256-wA373y/B9orM3HatLu9oS+Ke5lmdZyBl/bdjd2gLMq4=",
|
"narHash": "sha256-/496IQz8qNWHHLnc3Zvr0l4uxJ34igNhJhn7ZKAefFk=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "nixvim",
|
"repo": "nixvim",
|
||||||
"rev": "c7be49306b23a952c0151cf4bbeacd944ed82f2a",
|
"rev": "5980a626794486abad69fca7667f9c11dfbc3bd7",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -127,10 +143,30 @@
|
|||||||
"btc-clients": "btc-clients",
|
"btc-clients": "btc-clients",
|
||||||
"nixpkgs": "nixpkgs_2",
|
"nixpkgs": "nixpkgs_2",
|
||||||
"nixpkgs-stable": "nixpkgs-stable",
|
"nixpkgs-stable": "nixpkgs-stable",
|
||||||
"nixvim": "nixvim"
|
"nixvim": "nixvim",
|
||||||
|
"sovran-bitcoin": "sovran-bitcoin"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sovran-bitcoin": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs_4"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1790877969,
|
||||||
|
"narHash": "sha256-Ia88keP9t3B6ECVw+LP4xasPdvzw1TIWya1K+3dySu0=",
|
||||||
|
"owner": "naturallaw777",
|
||||||
|
"repo": "Sovran_Bitcoin",
|
||||||
|
"rev": "b0da63bd81f1a1b1a970068b3061c6c8389db9aa",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "naturallaw777",
|
||||||
|
"repo": "Sovran_Bitcoin",
|
||||||
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"systems": {
|
"systems": {
|
||||||
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1774449309,
|
"lastModified": 1774449309,
|
||||||
"narHash": "sha256-brhZ8DmuGtzkCYHJg4HEd602amKm89Y9ytsFZ5uWD1w=",
|
"narHash": "sha256-brhZ8DmuGtzkCYHJg4HEd602amKm89Y9ytsFZ5uWD1w=",
|
||||||
|
|||||||
@@ -6,9 +6,10 @@
|
|||||||
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";
|
||||||
|
sovran-bitcoin.url = "github:naturallaw777/Sovran_Bitcoin";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = { self, nixpkgs, nixvim, btc-clients, nixpkgs-stable, ... }:
|
outputs = { self, nixpkgs, nixvim, btc-clients, nixpkgs-stable, sovran-bitcoin, ... }:
|
||||||
|
|
||||||
let
|
let
|
||||||
overlay-stable = final: prev: {
|
overlay-stable = final: prev: {
|
||||||
@@ -33,7 +34,7 @@
|
|||||||
modules = [
|
modules = [
|
||||||
{ nixpkgs.hostPlatform = "x86_64-linux"; nixpkgs.overlays = [ overlay-stable ]; }
|
{ nixpkgs.hostPlatform = "x86_64-linux"; nixpkgs.overlays = [ overlay-stable ]; }
|
||||||
./iso/common.nix
|
./iso/common.nix
|
||||||
./modules/bitcoin
|
sovran-bitcoin.nixosModules.default
|
||||||
nixvim.nixosModules.nixvim
|
nixvim.nixosModules.nixvim
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
@@ -44,7 +45,8 @@
|
|||||||
nixpkgs.overlays = [ overlay-stable ];
|
nixpkgs.overlays = [ overlay-stable ];
|
||||||
})
|
})
|
||||||
./configuration.nix
|
./configuration.nix
|
||||||
./modules/bitcoin
|
sovran-bitcoin.nixosModules.default
|
||||||
|
./modules/sovran-bitcoin-integration.nix
|
||||||
nixvim.nixosModules.nixvim
|
nixvim.nixosModules.nixvim
|
||||||
];
|
];
|
||||||
config = {
|
config = {
|
||||||
@@ -55,22 +57,5 @@
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
checks.x86_64-linux = let
|
|
||||||
pkgs = import nixpkgs {
|
|
||||||
system = "x86_64-linux";
|
|
||||||
};
|
|
||||||
fetchNodeModules =
|
|
||||||
pkgs.callPackage ./packages/build-support/fetch-node-modules.nix {};
|
|
||||||
mempoolPkgs =
|
|
||||||
pkgs.callPackage ./packages/mempool { inherit fetchNodeModules; };
|
|
||||||
in {
|
|
||||||
bitcoin-btcpay-hardening = import ./tests/bitcoin-btcpay-hardening.nix {
|
|
||||||
inherit nixpkgs overlay-stable;
|
|
||||||
};
|
|
||||||
mempool-backend = mempoolPkgs.mempool-backend;
|
|
||||||
mempool-frontend = mempoolPkgs.mempool-frontend;
|
|
||||||
rtl = pkgs.callPackage ./packages/rtl { inherit fetchNodeModules; };
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-38
@@ -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")
|
||||||
@@ -423,40 +428,6 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
sep.set_margin_end(40)
|
sep.set_margin_end(40)
|
||||||
outer.append(sep)
|
outer.append(sep)
|
||||||
|
|
||||||
notice_frame = Gtk.Frame()
|
|
||||||
notice_frame.add_css_class("card")
|
|
||||||
notice_frame.set_margin_start(40)
|
|
||||||
notice_frame.set_margin_end(40)
|
|
||||||
notice_frame.set_margin_top(20)
|
|
||||||
notice_frame.set_margin_bottom(4)
|
|
||||||
|
|
||||||
notice_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
|
||||||
notice_box.set_margin_top(12)
|
|
||||||
notice_box.set_margin_bottom(12)
|
|
||||||
notice_box.set_margin_start(16)
|
|
||||||
notice_box.set_margin_end(16)
|
|
||||||
|
|
||||||
notice_icon = symbolic_icon("dialog-information-symbolic")
|
|
||||||
notice_icon.set_valign(Gtk.Align.START)
|
|
||||||
notice_box.append(notice_icon)
|
|
||||||
|
|
||||||
notice_lbl = Gtk.Label()
|
|
||||||
notice_lbl.set_use_markup(True)
|
|
||||||
notice_lbl.set_wrap(True)
|
|
||||||
notice_lbl.set_xalign(0)
|
|
||||||
notice_lbl.set_halign(Gtk.Align.FILL)
|
|
||||||
notice_lbl.set_markup(
|
|
||||||
"<span weight='bold'>Heads up — Server + Desktop prerequisites</span>\n"
|
|
||||||
"• A domain or subdomain from <span weight='bold'>https://njal.la</span>\n"
|
|
||||||
"• The ability to open / forward ports on your router\n\n"
|
|
||||||
"Don't worry — after install, the onboarding wizard walks you through every step.\n"
|
|
||||||
"<span size='small'>Desktop Only and Node Only do not require a domain or port forwarding.</span>"
|
|
||||||
)
|
|
||||||
notice_box.append(notice_lbl)
|
|
||||||
|
|
||||||
notice_frame.set_child(notice_box)
|
|
||||||
outer.append(notice_frame)
|
|
||||||
|
|
||||||
if self.virtualization:
|
if self.virtualization:
|
||||||
vm_frame = Gtk.Frame()
|
vm_frame = Gtk.Frame()
|
||||||
vm_frame.add_css_class("card")
|
vm_frame.add_css_class("card")
|
||||||
@@ -505,7 +476,7 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
# Role cards
|
# Role cards
|
||||||
roles = [
|
roles = [
|
||||||
("Server + Desktop",
|
("Server + Desktop",
|
||||||
"Full sovereign experience: beautiful desktop, your own cloud, secure messaging, Bitcoin node, and more.",
|
"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.",
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
# RPC calls that are safe for public use
|
|
||||||
# Vendored from nix-bitcoin - do not fetch from upstream at runtime
|
|
||||||
[
|
|
||||||
"echo"
|
|
||||||
"getinfo"
|
|
||||||
"getindexinfo"
|
|
||||||
"help"
|
|
||||||
"ping"
|
|
||||||
"uptime"
|
|
||||||
# Blockchain
|
|
||||||
"getbestblockhash"
|
|
||||||
"getblock"
|
|
||||||
"getblockchaininfo"
|
|
||||||
"getblockcount"
|
|
||||||
"getblockfilter"
|
|
||||||
"getblockfrompeer"
|
|
||||||
"getblockhash"
|
|
||||||
"getblockheader"
|
|
||||||
"getblockstats"
|
|
||||||
"getchaintips"
|
|
||||||
"getchaintxstats"
|
|
||||||
"getdeploymentinfo"
|
|
||||||
"getdifficulty"
|
|
||||||
"getmempoolancestors"
|
|
||||||
"getmempooldescendants"
|
|
||||||
"getmempoolentry"
|
|
||||||
"getmempoolinfo"
|
|
||||||
"getrawmempool"
|
|
||||||
"gettxout"
|
|
||||||
"gettxoutproof"
|
|
||||||
"gettxoutsetinfo"
|
|
||||||
"scantxoutset"
|
|
||||||
"verifytxoutproof"
|
|
||||||
# Mining
|
|
||||||
"getblocktemplate"
|
|
||||||
"getmininginfo"
|
|
||||||
"getnetworkhashps"
|
|
||||||
# Network
|
|
||||||
"getnetworkinfo"
|
|
||||||
"getnodeaddresses"
|
|
||||||
"getpeerinfo"
|
|
||||||
# Rawtransactions
|
|
||||||
"analyzepsbt"
|
|
||||||
"combinepsbt"
|
|
||||||
"combinerawtransaction"
|
|
||||||
"converttopsbt"
|
|
||||||
"createpsbt"
|
|
||||||
"createrawtransaction"
|
|
||||||
"decodepsbt"
|
|
||||||
"decoderawtransaction"
|
|
||||||
"decodescript"
|
|
||||||
"finalizepsbt"
|
|
||||||
"fundrawtransaction"
|
|
||||||
"getrawtransaction"
|
|
||||||
"joinpsbts"
|
|
||||||
"sendrawtransaction"
|
|
||||||
"signrawtransactionwithkey"
|
|
||||||
"testmempoolaccept"
|
|
||||||
"utxoupdatepsbt"
|
|
||||||
# Util
|
|
||||||
"createmultisig"
|
|
||||||
"deriveaddresses"
|
|
||||||
"estimatesmartfee"
|
|
||||||
"getdescriptorinfo"
|
|
||||||
"signmessagewithprivkey"
|
|
||||||
"validateaddress"
|
|
||||||
"verifymessage"
|
|
||||||
# Zmq
|
|
||||||
"getzmqnotifications"
|
|
||||||
]
|
|
||||||
@@ -1,501 +0,0 @@
|
|||||||
{ config, pkgs, lib, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
options = {
|
|
||||||
services.bitcoind = {
|
|
||||||
enable = mkEnableOption "Bitcoin daemon";
|
|
||||||
address = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "127.0.0.1";
|
|
||||||
description = "Address to listen for peer connections.";
|
|
||||||
};
|
|
||||||
port = mkOption {
|
|
||||||
type = types.port;
|
|
||||||
default = if !cfg.regtest then 8333 else 18444;
|
|
||||||
defaultText = "if !cfg.regtest then 8333 else 18444";
|
|
||||||
description = "Port to listen for peer connections.";
|
|
||||||
};
|
|
||||||
onionPort = mkOption {
|
|
||||||
type = types.nullOr types.port;
|
|
||||||
# When the bitcoind onion service is enabled, add an onion-tagged socket
|
|
||||||
# to distinguish local connections from Tor connections
|
|
||||||
default = if (config.nix-bitcoin.onionServices.bitcoind.enable or false) then 8334 else null;
|
|
||||||
description = ''
|
|
||||||
Port to listen for Tor peer connections.
|
|
||||||
If set, inbound connections to this port are tagged as onion peers.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
listen = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Listen for peer connections at `address:port`
|
|
||||||
and `address:onionPort` (if {option}`onionPort` is set).
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
listenWhitelisted = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Listen for peer connections at `address:whitelistedPort`.
|
|
||||||
Peers connected through this socket are automatically whitelisted.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
whitelistedPort = mkOption {
|
|
||||||
type = types.port;
|
|
||||||
default = 8335;
|
|
||||||
description = "See `listenWhitelisted`.";
|
|
||||||
};
|
|
||||||
getPublicAddressCmd = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "";
|
|
||||||
description = ''
|
|
||||||
Bash expression which outputs the public service address to announce to peers.
|
|
||||||
If left empty, no address is announced.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
package = mkOption {
|
|
||||||
type = types.package;
|
|
||||||
default = pkgs.bitcoind;
|
|
||||||
defaultText = "pkgs.bitcoind";
|
|
||||||
description = ''
|
|
||||||
The package providing bitcoind binaries.
|
|
||||||
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
extraConfig = mkOption {
|
|
||||||
type = types.lines;
|
|
||||||
default = "";
|
|
||||||
example = ''
|
|
||||||
par=16
|
|
||||||
logips=1
|
|
||||||
'';
|
|
||||||
description = "Extra lines appended to {file}`bitcoin.conf`.";
|
|
||||||
};
|
|
||||||
dataDir = mkOption {
|
|
||||||
type = types.path;
|
|
||||||
default = "/var/lib/bitcoind";
|
|
||||||
description = "The data directory for bitcoind.";
|
|
||||||
};
|
|
||||||
rpc = {
|
|
||||||
address = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "127.0.0.1";
|
|
||||||
description = ''
|
|
||||||
Address to listen for JSON-RPC connections.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
port = mkOption {
|
|
||||||
type = types.port;
|
|
||||||
default = if !cfg.regtest then 8332 else 18443;
|
|
||||||
defaultText = "if !cfg.regtest then 8332 else 18443";
|
|
||||||
description = "Port to listen for JSON-RPC connections.";
|
|
||||||
};
|
|
||||||
threads = mkOption {
|
|
||||||
type = types.nullOr types.ints.u16;
|
|
||||||
default = null;
|
|
||||||
description = "The number of threads to service RPC calls.";
|
|
||||||
};
|
|
||||||
allowip = mkOption {
|
|
||||||
type = types.listOf types.str;
|
|
||||||
default = [ "127.0.0.1" ];
|
|
||||||
description = ''
|
|
||||||
Allow JSON-RPC connections from specified sources.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
users = mkOption {
|
|
||||||
default = {};
|
|
||||||
description = ''
|
|
||||||
Allowed users for JSON-RPC connections.
|
|
||||||
'';
|
|
||||||
example = {
|
|
||||||
alice = {
|
|
||||||
passwordHMAC = "f7efda5c189b999524f151318c0c86$d5b51b3beffbc02b724e5d095828e0bc8b2456e9ac8757ae3211a5d9b16a22ae";
|
|
||||||
rpcwhitelist = [ "sendtoaddress" "getnewaddress" ];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
type = with types; attrsOf (submodule ({ name, ... }: {
|
|
||||||
options = {
|
|
||||||
name = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = name;
|
|
||||||
example = "alice";
|
|
||||||
description = ''
|
|
||||||
Username for JSON-RPC connections.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
passwordHMAC = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
example = "f7efda5c189b999524f151318c0c86$d5b51b3beffbc02b724e5d095828e0bc8b2456e9ac8757ae3211a5d9b16a22ae";
|
|
||||||
description = ''
|
|
||||||
Password HMAC-SHA-256 for JSON-RPC connections. Must be a string of the
|
|
||||||
format `<SALT-HEX>$<HMAC-HEX>`.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
passwordHMACFromFile = mkOption {
|
|
||||||
type = lib.types.bool;
|
|
||||||
internal = true;
|
|
||||||
default = false;
|
|
||||||
};
|
|
||||||
rpcwhitelist = mkOption {
|
|
||||||
type = types.listOf types.str;
|
|
||||||
default = [];
|
|
||||||
description = ''
|
|
||||||
List of allowed rpc calls for each user.
|
|
||||||
If empty list, rpcwhitelist is disabled for that user.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
};
|
|
||||||
regtest = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = "Enable regtest mode.";
|
|
||||||
};
|
|
||||||
network = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
default = if cfg.regtest then "regtest" else "mainnet";
|
|
||||||
};
|
|
||||||
makeNetworkName = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
default = mainnet: regtest: if cfg.regtest then regtest else mainnet;
|
|
||||||
};
|
|
||||||
proxy = mkOption {
|
|
||||||
type = types.nullOr types.str;
|
|
||||||
default = if cfg.tor.proxy then config.nix-bitcoin.torClientAddressWithPort else null;
|
|
||||||
description = "Connect through SOCKS5 proxy";
|
|
||||||
};
|
|
||||||
i2p = mkOption {
|
|
||||||
type = types.enum [ false true "only-outgoing" ];
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Enable peer connections via i2p.
|
|
||||||
With `only-outgoing`, incoming i2p connections are disabled.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
dataDirReadableByGroup = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
If enabled, data dir content is readable by the bitcoind service group.
|
|
||||||
Warning: This disables bitcoind's wallet support.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
sysperms = mkOption {
|
|
||||||
type = types.nullOr types.bool;
|
|
||||||
default = null;
|
|
||||||
description = ''
|
|
||||||
Create new files with system default permissions, instead of umask 077
|
|
||||||
(only effective with disabled wallet functionality)
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
disablewallet = mkOption {
|
|
||||||
type = types.nullOr types.bool;
|
|
||||||
default = null;
|
|
||||||
description = ''
|
|
||||||
Do not load the wallet and disable wallet RPC calls
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
dbCache = mkOption {
|
|
||||||
type = types.nullOr (intAtLeast 4);
|
|
||||||
default = null;
|
|
||||||
example = 4000;
|
|
||||||
description = "Override the default database cache size in MiB.";
|
|
||||||
};
|
|
||||||
prune = mkOption {
|
|
||||||
type = types.ints.unsigned;
|
|
||||||
default = 0;
|
|
||||||
example = 10000;
|
|
||||||
description = ''
|
|
||||||
Automatically prune block files to stay under the specified target size in MiB.
|
|
||||||
Value 0 disables pruning.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
txindex = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = "Enable the transaction index.";
|
|
||||||
};
|
|
||||||
zmqpubrawblock = mkOption {
|
|
||||||
type = types.nullOr types.str;
|
|
||||||
default = null;
|
|
||||||
example = "tcp://127.0.0.1:28332";
|
|
||||||
description = "ZMQ address for zmqpubrawblock notifications";
|
|
||||||
};
|
|
||||||
zmqpubrawtx = mkOption {
|
|
||||||
type = types.nullOr types.str;
|
|
||||||
default = null;
|
|
||||||
example = "tcp://127.0.0.1:28333";
|
|
||||||
description = "ZMQ address for zmqpubrawtx notifications";
|
|
||||||
};
|
|
||||||
assumevalid = mkOption {
|
|
||||||
type = types.nullOr types.str;
|
|
||||||
default = null;
|
|
||||||
example = "00000000000000000000e5abc3a74fe27dc0ead9c70ea1deb456f11c15fd7bc6";
|
|
||||||
description = ''
|
|
||||||
If this block is in the chain assume that it and its ancestors are
|
|
||||||
valid and potentially skip their script verification.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
addnodes = mkOption {
|
|
||||||
type = types.listOf types.str;
|
|
||||||
default = [];
|
|
||||||
example = [ "ecoc5q34tmbq54wl.onion" ];
|
|
||||||
description = "Add nodes to connect to and attempt to keep the connections open";
|
|
||||||
};
|
|
||||||
discover = mkOption {
|
|
||||||
type = types.nullOr types.bool;
|
|
||||||
default = null;
|
|
||||||
description = "Discover own IP addresses";
|
|
||||||
};
|
|
||||||
addresstype = mkOption {
|
|
||||||
type = types.nullOr types.str;
|
|
||||||
default = null;
|
|
||||||
example = "bech32";
|
|
||||||
description = "The type of addresses to use";
|
|
||||||
};
|
|
||||||
user = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "bitcoin";
|
|
||||||
description = "The user as which to run bitcoind.";
|
|
||||||
};
|
|
||||||
group = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = cfg.user;
|
|
||||||
description = "The group as which to run bitcoind.";
|
|
||||||
};
|
|
||||||
cli = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
type = types.package;
|
|
||||||
default = pkgs.writers.writeBashBin "bitcoin-cli" ''
|
|
||||||
exec ${cfg.package}/bin/bitcoin-cli -datadir='${cfg.dataDir}' "$@"
|
|
||||||
'';
|
|
||||||
defaultText = "(See source)";
|
|
||||||
description = "Binary to connect with the bitcoind instance.";
|
|
||||||
};
|
|
||||||
tor = nbLib.tor;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
cfg = config.services.bitcoind;
|
|
||||||
nbLib = config.nix-bitcoin.lib;
|
|
||||||
secretsDir = config.nix-bitcoin.secretsDir;
|
|
||||||
|
|
||||||
i2pSAM = config.services.i2pd.proto.sam;
|
|
||||||
|
|
||||||
configFile = builtins.toFile "bitcoin.conf" ''
|
|
||||||
# We're already logging via journald
|
|
||||||
nodebuglogfile=1
|
|
||||||
logtimestamps=0
|
|
||||||
|
|
||||||
startupnotify=/run/current-system/systemd/bin/systemd-notify --ready
|
|
||||||
|
|
||||||
${optionalString cfg.regtest ''
|
|
||||||
regtest=1
|
|
||||||
[regtest]
|
|
||||||
''}
|
|
||||||
${optionalString (cfg.dbCache != null) "dbcache=${toString cfg.dbCache}"}
|
|
||||||
prune=${toString cfg.prune}
|
|
||||||
${optionalString cfg.txindex "txindex=1"}
|
|
||||||
${optionalString (cfg.sysperms != null) "sysperms=${if cfg.sysperms then "1" else "0"}"}
|
|
||||||
${optionalString (cfg.disablewallet != null) "disablewallet=${if cfg.disablewallet then "1" else "0"}"}
|
|
||||||
${optionalString (cfg.assumevalid != null) "assumevalid=${cfg.assumevalid}"}
|
|
||||||
|
|
||||||
# Connection options
|
|
||||||
listen=${if (cfg.listen || cfg.listenWhitelisted) then "1" else "0"}
|
|
||||||
${optionalString cfg.listen
|
|
||||||
"bind=${cfg.address}:${toString cfg.port}"}
|
|
||||||
${optionalString (cfg.listen && cfg.onionPort != null)
|
|
||||||
"bind=${cfg.address}:${toString cfg.onionPort}=onion"}
|
|
||||||
${optionalString cfg.listenWhitelisted
|
|
||||||
"whitebind=${cfg.address}:${toString cfg.whitelistedPort}"}
|
|
||||||
${optionalString (cfg.proxy != null) "proxy=${cfg.proxy}"}
|
|
||||||
${optionalString (cfg.i2p != false) "i2psam=${nbLib.addressWithPort i2pSAM.address i2pSAM.port}"}
|
|
||||||
${optionalString (cfg.i2p == "only-outgoing") "i2pacceptincoming=0"}
|
|
||||||
|
|
||||||
${optionalString (cfg.discover != null) "discover=${if cfg.discover then "1" else "0"}"}
|
|
||||||
${lib.concatMapStrings (node: "addnode=${node}\n") cfg.addnodes}
|
|
||||||
|
|
||||||
# RPC server options
|
|
||||||
rpcbind=${cfg.rpc.address}
|
|
||||||
rpcport=${toString cfg.rpc.port}
|
|
||||||
rpcconnect=${cfg.rpc.address}
|
|
||||||
${optionalString (cfg.rpc.threads != null) "rpcthreads=${toString cfg.rpc.threads}"}
|
|
||||||
rpcwhitelistdefault=0
|
|
||||||
${concatMapStrings (user: ''
|
|
||||||
${optionalString (!user.passwordHMACFromFile) "rpcauth=${user.name}:${user.passwordHMAC}"}
|
|
||||||
${optionalString (user.rpcwhitelist != [])
|
|
||||||
"rpcwhitelist=${user.name}:${lib.strings.concatStringsSep "," user.rpcwhitelist}"}
|
|
||||||
'') (builtins.attrValues cfg.rpc.users)
|
|
||||||
}
|
|
||||||
${lib.concatMapStrings (rpcallowip: "rpcallowip=${rpcallowip}\n") cfg.rpc.allowip}
|
|
||||||
|
|
||||||
# Wallet options
|
|
||||||
${optionalString (cfg.addresstype != null) "addresstype=${cfg.addresstype}"}
|
|
||||||
|
|
||||||
# ZMQ options
|
|
||||||
${optionalString (cfg.zmqpubrawblock != null) "zmqpubrawblock=${cfg.zmqpubrawblock}"}
|
|
||||||
${optionalString (cfg.zmqpubrawtx != null) "zmqpubrawtx=${cfg.zmqpubrawtx}"}
|
|
||||||
|
|
||||||
# Extra options
|
|
||||||
${cfg.extraConfig}
|
|
||||||
'';
|
|
||||||
|
|
||||||
zmqServerEnabled = (cfg.zmqpubrawblock != null) || (cfg.zmqpubrawtx != null);
|
|
||||||
|
|
||||||
intAtLeast = n: types.addCheck types.int (x: x >= n) // {
|
|
||||||
name = "intAtLeast";
|
|
||||||
description = "integer >= ${toString n}";
|
|
||||||
};
|
|
||||||
in {
|
|
||||||
inherit options;
|
|
||||||
|
|
||||||
config = mkIf cfg.enable {
|
|
||||||
environment.systemPackages = [ cfg.package (hiPrio cfg.cli) ];
|
|
||||||
|
|
||||||
services.bitcoind = mkMerge [
|
|
||||||
(mkIf cfg.dataDirReadableByGroup {
|
|
||||||
disablewallet = true;
|
|
||||||
sysperms = true;
|
|
||||||
})
|
|
||||||
{
|
|
||||||
rpc.users.privileged = {
|
|
||||||
passwordHMACFromFile = true;
|
|
||||||
};
|
|
||||||
rpc.users.public = {
|
|
||||||
passwordHMACFromFile = true;
|
|
||||||
rpcwhitelist = import ./bitcoind-rpc-public-whitelist.nix;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
services.i2pd = mkIf (cfg.i2p != false) {
|
|
||||||
enable = true;
|
|
||||||
proto.sam.enable = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.tmpfiles.rules = [
|
|
||||||
"d '${cfg.dataDir}' 0770 ${cfg.user} ${cfg.group} - -"
|
|
||||||
];
|
|
||||||
|
|
||||||
systemd.services.bitcoind = rec {
|
|
||||||
wants = [
|
|
||||||
"network-online.target"
|
|
||||||
# Use `wants` instead of `requires` for `nix-bitcoin-secrets.target`
|
|
||||||
# so that bitcoind and all dependent services are not restarted when
|
|
||||||
# the secrets target restarts.
|
|
||||||
# The secrets target always restarts when deploying with one of the methods
|
|
||||||
# in ./deployment.
|
|
||||||
#
|
|
||||||
# TODO-EXTERNAL: Instead of `wants`, use a future systemd dependency type
|
|
||||||
# that propagates initial start failures but no restarts
|
|
||||||
"nix-bitcoin-secrets.target"
|
|
||||||
];
|
|
||||||
after = wants;
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
|
|
||||||
preStart = let
|
|
||||||
extraRpcauth = concatMapStrings (name: let
|
|
||||||
user = cfg.rpc.users.${name};
|
|
||||||
in optionalString user.passwordHMACFromFile ''
|
|
||||||
hmacPayload="$(readValidatedRpcHmac '${secretsDir}/bitcoin-HMAC-${name}')" || exit 1
|
|
||||||
printf '%s\n' "rpcauth=${user.name}:$hmacPayload"
|
|
||||||
''
|
|
||||||
) (builtins.attrNames cfg.rpc.users);
|
|
||||||
in ''
|
|
||||||
${optionalString cfg.dataDirReadableByGroup ''
|
|
||||||
if [[ -e '${cfg.dataDir}/blocks' ]]; then
|
|
||||||
chmod -R g+rX '${cfg.dataDir}/blocks'
|
|
||||||
fi
|
|
||||||
''}
|
|
||||||
|
|
||||||
readValidatedRpcHmac() {
|
|
||||||
local hmacFile="$1"
|
|
||||||
local hmacPayload
|
|
||||||
|
|
||||||
if [[ ! -e "$hmacFile" ]]; then
|
|
||||||
echo "Error: Bitcoin RPC HMAC file is missing: $hmacFile" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if [[ ! -r "$hmacFile" ]]; then
|
|
||||||
echo "Error: Bitcoin RPC HMAC file is unreadable: $hmacFile" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
hmacPayload="$(<"$hmacFile")"
|
|
||||||
if [[ -z "$hmacPayload" ]]; then
|
|
||||||
echo "Error: Bitcoin RPC HMAC file is empty: $hmacFile" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if [[ ! "$hmacPayload" =~ ^[[:xdigit:]]+\$[[:xdigit:]]+$ ]]; then
|
|
||||||
echo "Error: Bitcoin RPC HMAC file has invalid format: $hmacFile" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf '%s\n' "$hmacPayload"
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg=$(
|
|
||||||
cat ${configFile}
|
|
||||||
${extraRpcauth}
|
|
||||||
echo
|
|
||||||
${optionalString (cfg.getPublicAddressCmd != "") ''
|
|
||||||
echo "externalip=$(${cfg.getPublicAddressCmd})"
|
|
||||||
''}
|
|
||||||
)
|
|
||||||
confFile='${cfg.dataDir}/bitcoin.conf'
|
|
||||||
if [[ ! -e $confFile || $cfg != $(cat $confFile) ]]; then
|
|
||||||
install -o '${cfg.user}' -g '${cfg.group}' -m 640 <(echo "$cfg") $confFile
|
|
||||||
fi
|
|
||||||
'';
|
|
||||||
|
|
||||||
# Enable RPC access for group
|
|
||||||
postStart = ''
|
|
||||||
chmod g=r '${cfg.dataDir}/${optionalString cfg.regtest "regtest/"}.cookie'
|
|
||||||
'' + (optionalString cfg.regtest) ''
|
|
||||||
chmod g=x '${cfg.dataDir}/regtest'
|
|
||||||
'';
|
|
||||||
|
|
||||||
serviceConfig = nbLib.defaultHardening // {
|
|
||||||
Type = "notify";
|
|
||||||
NotifyAccess = "all";
|
|
||||||
User = cfg.user;
|
|
||||||
Group = cfg.group;
|
|
||||||
TimeoutStartSec = "30min";
|
|
||||||
TimeoutStopSec = "30min";
|
|
||||||
ExecStart = "${cfg.package}/bin/bitcoind -datadir='${cfg.dataDir}'";
|
|
||||||
Restart = "on-failure";
|
|
||||||
UMask = mkIf cfg.dataDirReadableByGroup "0027";
|
|
||||||
ReadWritePaths = [ cfg.dataDir ];
|
|
||||||
} // nbLib.allowedIPAddresses cfg.tor.enforce
|
|
||||||
// optionalAttrs zmqServerEnabled nbLib.allowNetlink;
|
|
||||||
};
|
|
||||||
|
|
||||||
users.users.${cfg.user} = {
|
|
||||||
isSystemUser = true;
|
|
||||||
group = cfg.group;
|
|
||||||
};
|
|
||||||
users.groups.${cfg.group} = {};
|
|
||||||
users.groups.bitcoinrpc-public = {};
|
|
||||||
|
|
||||||
nix-bitcoin.operator.groups = [ cfg.group ];
|
|
||||||
|
|
||||||
nix-bitcoin.secrets = {
|
|
||||||
bitcoin-rpcpassword-privileged.user = cfg.user;
|
|
||||||
bitcoin-rpcpassword-public = {
|
|
||||||
user = cfg.user;
|
|
||||||
group = "bitcoinrpc-public";
|
|
||||||
};
|
|
||||||
|
|
||||||
bitcoin-HMAC-privileged.user = cfg.user;
|
|
||||||
bitcoin-HMAC-public.user = cfg.user;
|
|
||||||
};
|
|
||||||
nix-bitcoin.generateSecretsCmds.bitcoind = ''
|
|
||||||
makeBitcoinRPCPassword privileged
|
|
||||||
makeBitcoinRPCPassword public
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,334 +0,0 @@
|
|||||||
{ config, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
options.services = {
|
|
||||||
nbxplorer = {
|
|
||||||
enable = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Enable nbxplorer, a lightweight API for Bitcoin HD wallets.
|
|
||||||
|
|
||||||
Access API documentation here:
|
|
||||||
{option}`services.nbxplorer.address`:{option}`services.nbxplorer.port`
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
address = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "127.0.0.1";
|
|
||||||
description = "Address to listen on.";
|
|
||||||
};
|
|
||||||
port = mkOption {
|
|
||||||
type = types.port;
|
|
||||||
default = 24444;
|
|
||||||
description = "Port to listen on.";
|
|
||||||
};
|
|
||||||
package = mkOption {
|
|
||||||
type = types.package;
|
|
||||||
default = pkgs.stable.nbxplorer;
|
|
||||||
defaultText = "pkgs.stable.nbxplorer";
|
|
||||||
description = "The package providing nbxplorer binaries.";
|
|
||||||
};
|
|
||||||
dataDir = mkOption {
|
|
||||||
type = types.path;
|
|
||||||
default = "/var/lib/nbxplorer";
|
|
||||||
description = "The data directory for nbxplorer.";
|
|
||||||
};
|
|
||||||
# TODO-EXTERNAL:
|
|
||||||
# The shortcut link `main` in the datadir has changed to a directory
|
|
||||||
# in version 2.3.3.
|
|
||||||
# Add a dummy symlink, if it does not already exist, to be compatible with older modules.
|
|
||||||
# When the old system uses a link and the new system a directory, switching fails with:
|
|
||||||
# mv: cannot move '/var/lib/nbxplorer/Main' to '/var/lib/nbxplorer/.Main.tmp':
|
|
||||||
# No such file or directory
|
|
||||||
#
|
|
||||||
# Remove this option when it is irrelevant (i.e. when the old system will never
|
|
||||||
# be nix-bitcoin <=0.0.91)
|
|
||||||
addNetworkSymlink = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
default = pkgs.stable.nbxplorer != cfg.nbxplorer.package;
|
|
||||||
description = ''
|
|
||||||
Whether to add a compatibility symlink (like `${cfg.nbxplorer.dataDir}/Main`)
|
|
||||||
to the dataDir.
|
|
||||||
This is enabled by default if the nbxplorer package is set to the version-locked package.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
user = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "nbxplorer";
|
|
||||||
description = "The user as which to run NBXplorer.";
|
|
||||||
};
|
|
||||||
group = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = cfg.nbxplorer.user;
|
|
||||||
description = "The group as which to run NBXplorer.";
|
|
||||||
};
|
|
||||||
tor = nbLib.tor;
|
|
||||||
};
|
|
||||||
|
|
||||||
btcpayserver = {
|
|
||||||
enable = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Enable BTCPay Server, a self-hosted, open-source payment processor.
|
|
||||||
|
|
||||||
Extra recommendations:
|
|
||||||
- Enable `services.btcpayserver.lightningBackend` to provide Lightning payment support.
|
|
||||||
- Secure this service if the instance is publically accessible. For example, set
|
|
||||||
{option}`services.btcpayserver.address` to `127.0.0.1` and use a reverse proxy
|
|
||||||
that enforces TLS (Transport Layer Security).
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
package = mkOption {
|
|
||||||
type = types.package;
|
|
||||||
default = pkgs.stable.btcpayserver;
|
|
||||||
defaultText = "pkgs.stable.btcpayserver";
|
|
||||||
description = "The package providing BTCPay Server binaries.";
|
|
||||||
};
|
|
||||||
address = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "127.0.0.1";
|
|
||||||
description = "Address to listen on.";
|
|
||||||
};
|
|
||||||
port = mkOption {
|
|
||||||
type = types.port;
|
|
||||||
default = 23000;
|
|
||||||
description = "Port to listen on.";
|
|
||||||
};
|
|
||||||
lightningBackend = mkOption {
|
|
||||||
type = types.nullOr (types.enum [ "lnd" ]);
|
|
||||||
default = null;
|
|
||||||
description = ''
|
|
||||||
The lightning node to use as a backend.
|
|
||||||
Enables the node service if not already enabled.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
lbtc = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Enable Liquid support.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
dataDir = mkOption {
|
|
||||||
type = types.path;
|
|
||||||
default = "/var/lib/btcpayserver";
|
|
||||||
description = "The data directory for BTCPay Server.";
|
|
||||||
};
|
|
||||||
user = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "btcpayserver";
|
|
||||||
description = "The user as which to run BTCPay Server.";
|
|
||||||
};
|
|
||||||
group = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = cfg.btcpayserver.user;
|
|
||||||
description = "The group as which to run BTCPay Server.";
|
|
||||||
};
|
|
||||||
tor = nbLib.tor;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
cfg = {
|
|
||||||
inherit (config.services)
|
|
||||||
nbxplorer
|
|
||||||
btcpayserver
|
|
||||||
bitcoind;
|
|
||||||
};
|
|
||||||
|
|
||||||
nbLib = config.nix-bitcoin.lib;
|
|
||||||
secretsDir = config.nix-bitcoin.secretsDir;
|
|
||||||
|
|
||||||
in {
|
|
||||||
inherit options;
|
|
||||||
|
|
||||||
config = mkMerge [
|
|
||||||
(mkIf cfg.nbxplorer.enable {
|
|
||||||
systemd.tmpfiles.rules = [
|
|
||||||
"d '${cfg.nbxplorer.dataDir}' 0770 ${cfg.nbxplorer.user} ${cfg.nbxplorer.group} - -"
|
|
||||||
] ++ optional cfg.nbxplorer.addNetworkSymlink
|
|
||||||
"L+ '${cfg.nbxplorer.dataDir}/Main' - - - - '${cfg.nbxplorer.dataDir}/main'";
|
|
||||||
|
|
||||||
systemd.services.nbxplorer = let
|
|
||||||
configFile = builtins.toFile "nbxplorer-config" ''
|
|
||||||
network=${cfg.bitcoind.network}
|
|
||||||
btcrpcuser=${cfg.bitcoind.rpc.users.btcpayserver.name}
|
|
||||||
btcrpcurl=http://${nbLib.addressWithPort cfg.bitcoind.rpc.address cfg.bitcoind.rpc.port}
|
|
||||||
btcnodeendpoint=${nbLib.addressWithPort cfg.bitcoind.address cfg.bitcoind.whitelistedPort}
|
|
||||||
bind=${cfg.nbxplorer.address}
|
|
||||||
port=${toString cfg.nbxplorer.port}
|
|
||||||
postgres=User ID=${cfg.nbxplorer.user};Host=/run/postgresql;Database=nbxplorer
|
|
||||||
'';
|
|
||||||
in rec {
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
requires = [ "postgresql.target" ];
|
|
||||||
wants = [ "bitcoind.service" ];
|
|
||||||
after = requires ++ wants ++ [ "nix-bitcoin-secrets.target" ];
|
|
||||||
preStart = ''
|
|
||||||
install -m 600 ${configFile} '${cfg.nbxplorer.dataDir}/settings.config'
|
|
||||||
printf '%s\n' "btcrpcpassword=$(<${secretsDir}/bitcoin-rpcpassword-btcpayserver)" \
|
|
||||||
>> '${cfg.nbxplorer.dataDir}/settings.config'
|
|
||||||
'';
|
|
||||||
serviceConfig = nbLib.defaultHardening // {
|
|
||||||
ExecStart = ''
|
|
||||||
${cfg.nbxplorer.package}/bin/nbxplorer --conf=${cfg.nbxplorer.dataDir}/settings.config \
|
|
||||||
--datadir='${cfg.nbxplorer.dataDir}'
|
|
||||||
'';
|
|
||||||
RuntimeDirectory = "nbxplorer";
|
|
||||||
StateDirectory = "nbxplorer";
|
|
||||||
User = cfg.nbxplorer.user;
|
|
||||||
Group = cfg.nbxplorer.group;
|
|
||||||
Restart = "on-failure";
|
|
||||||
RestartSec = "10s";
|
|
||||||
ReadWritePaths = [ cfg.nbxplorer.dataDir ];
|
|
||||||
MemoryDenyWriteExecute = false;
|
|
||||||
} // nbLib.allowedIPAddresses cfg.nbxplorer.tor.enforce;
|
|
||||||
};
|
|
||||||
|
|
||||||
services.bitcoind = {
|
|
||||||
enable = true;
|
|
||||||
listenWhitelisted = true;
|
|
||||||
txindex = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
users.users.${cfg.nbxplorer.user} = {
|
|
||||||
isSystemUser = true;
|
|
||||||
group = cfg.nbxplorer.group;
|
|
||||||
home = cfg.nbxplorer.dataDir;
|
|
||||||
};
|
|
||||||
users.groups.${cfg.nbxplorer.group} = {};
|
|
||||||
})
|
|
||||||
|
|
||||||
(mkIf cfg.btcpayserver.enable {
|
|
||||||
services.nbxplorer.enable = true;
|
|
||||||
|
|
||||||
services.bitcoind = {
|
|
||||||
listenWhitelisted = true;
|
|
||||||
rpc.users.btcpayserver = {
|
|
||||||
name = "btcpayserver";
|
|
||||||
passwordHMACFromFile = true;
|
|
||||||
rpcwhitelist = [
|
|
||||||
"getblockchaininfo"
|
|
||||||
"getblock"
|
|
||||||
"getblockhash"
|
|
||||||
"getblockheader"
|
|
||||||
"getblockstats"
|
|
||||||
"gettransaction"
|
|
||||||
"getrawtransaction"
|
|
||||||
"sendrawtransaction"
|
|
||||||
"getblockcount"
|
|
||||||
"getbestblockhash"
|
|
||||||
"getnetworkinfo"
|
|
||||||
"getpeerinfo"
|
|
||||||
"estimatesmartfee"
|
|
||||||
"getmempoolinfo"
|
|
||||||
"getmempoolentry"
|
|
||||||
"getrawmempool"
|
|
||||||
"gettxout"
|
|
||||||
"scantxoutset"
|
|
||||||
"importmulti"
|
|
||||||
"listunspent"
|
|
||||||
"getwalletinfo"
|
|
||||||
"listtransactions"
|
|
||||||
"listreceivedbyaddress"
|
|
||||||
"getnewaddress"
|
|
||||||
"uptime"
|
|
||||||
"getrpcinfo"
|
|
||||||
];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.tmpfiles.rules = [
|
|
||||||
"d '${cfg.btcpayserver.dataDir}' 0770 ${cfg.btcpayserver.user} ${cfg.btcpayserver.group} - -"
|
|
||||||
];
|
|
||||||
|
|
||||||
systemd.services.btcpayserver = let
|
|
||||||
nbExplorerUrl = "http://${nbLib.addressWithPort cfg.nbxplorer.address cfg.nbxplorer.port}/";
|
|
||||||
nbExplorerCookie =
|
|
||||||
"${cfg.nbxplorer.dataDir}/${cfg.bitcoind.makeNetworkName "Main" "RegTest"}/.cookie";
|
|
||||||
configFile = builtins.toFile "btcpayserver-config" (
|
|
||||||
''
|
|
||||||
network=${cfg.bitcoind.network}
|
|
||||||
bind=${cfg.btcpayserver.address}
|
|
||||||
port=${toString cfg.btcpayserver.port}
|
|
||||||
socksendpoint=${config.nix-bitcoin.torClientAddressWithPort}
|
|
||||||
btcexplorerurl=${nbExplorerUrl}
|
|
||||||
btcexplorercookiefile=${nbExplorerCookie}
|
|
||||||
explorer.postgres=User ID=${cfg.nbxplorer.user};Host=/run/postgresql;Database=nbxplorer
|
|
||||||
postgres=User ID=${cfg.btcpayserver.user};Host=/run/postgresql;Database=btcpayserver
|
|
||||||
'' + optionalString (cfg.btcpayserver.lightningBackend == "lnd")
|
|
||||||
(
|
|
||||||
"btclightning=type=lnd-rest;"
|
|
||||||
+ "server=https://${nbLib.address config.services.lnd.restAddress}:${toString config.services.lnd.restPort}/;"
|
|
||||||
+ "macaroonfilepath=/run/lnd/btcpayserver.macaroon;"
|
|
||||||
+ "certfilepath=${config.services.lnd.certPath}\n"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
in rec {
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
requires = [ "postgresql.target" "nbxplorer.service" ];
|
|
||||||
wants = optional (cfg.btcpayserver.lightningBackend == "lnd") "lnd.service";
|
|
||||||
after = requires ++ wants;
|
|
||||||
serviceConfig = nbLib.defaultHardening // {
|
|
||||||
ExecStart = ''
|
|
||||||
${cfg.btcpayserver.package}/bin/btcpayserver --conf=${configFile} \
|
|
||||||
--datadir='${cfg.btcpayserver.dataDir}'
|
|
||||||
'';
|
|
||||||
WorkingDirectory = "${cfg.btcpayserver.package}/lib/btcpayserver";
|
|
||||||
RuntimeDirectory = "btcpayserver";
|
|
||||||
StateDirectory = "btcpayserver";
|
|
||||||
User = cfg.btcpayserver.user;
|
|
||||||
Group = cfg.btcpayserver.group;
|
|
||||||
Restart = "on-failure";
|
|
||||||
RestartSec = "10s";
|
|
||||||
ReadWritePaths = [ cfg.btcpayserver.dataDir ];
|
|
||||||
MemoryDenyWriteExecute = false;
|
|
||||||
} // nbLib.allowedIPAddresses cfg.btcpayserver.tor.enforce;
|
|
||||||
};
|
|
||||||
|
|
||||||
services.postgresql = {
|
|
||||||
enable = true;
|
|
||||||
ensureDatabases = [ "btcpayserver" "nbxplorer" ];
|
|
||||||
ensureUsers = [
|
|
||||||
{ name = cfg.btcpayserver.user; ensureDBOwnership = true; }
|
|
||||||
{ name = cfg.nbxplorer.user; ensureDBOwnership = true; }
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
users.users.${cfg.btcpayserver.user} = {
|
|
||||||
isSystemUser = true;
|
|
||||||
group = cfg.btcpayserver.group;
|
|
||||||
home = cfg.btcpayserver.dataDir;
|
|
||||||
extraGroups = optional (cfg.btcpayserver.lightningBackend == "lnd") config.services.lnd.group;
|
|
||||||
};
|
|
||||||
users.groups.${cfg.btcpayserver.group} = {};
|
|
||||||
|
|
||||||
nix-bitcoin.secrets = {
|
|
||||||
bitcoin-rpcpassword-btcpayserver = {
|
|
||||||
user = cfg.bitcoind.user;
|
|
||||||
group = cfg.nbxplorer.group;
|
|
||||||
};
|
|
||||||
bitcoin-HMAC-btcpayserver.user = cfg.bitcoind.user;
|
|
||||||
};
|
|
||||||
nix-bitcoin.generateSecretsCmds.btcpayserver = ''
|
|
||||||
makeBitcoinRPCPassword btcpayserver
|
|
||||||
'';
|
|
||||||
})
|
|
||||||
|
|
||||||
(mkIf (cfg.btcpayserver.enable && cfg.btcpayserver.lightningBackend == "lnd") {
|
|
||||||
services.lnd = {
|
|
||||||
enable = true;
|
|
||||||
macaroons.btcpayserver = {
|
|
||||||
user = cfg.btcpayserver.user;
|
|
||||||
permissions = ''
|
|
||||||
{"entity":"address","action":"write"},{"entity":"info","action":"read"},{"entity":"invoices","action":"read"},{"entity":"invoices","action":"write"},{"entity":"offchain","action":"read"},{"entity":"offchain","action":"write"},{"entity":"onchain","action":"read"},{"entity":"onchain","action":"write"},{"entity":"peers","action":"read"},{"entity":"peers","action":"write"}
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
users.users.${config.services.lnd.user}.extraGroups = [ cfg.btcpayserver.group ];
|
|
||||||
})
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# Common Bitcoin infrastructure: secrets, onion services, nodeinfo, security
|
|
||||||
# Extracted from nix-bitcoin, tailored for Sovran (lnd-only)
|
|
||||||
{ config, lib, pkgs, ... }:
|
|
||||||
{
|
|
||||||
imports = [
|
|
||||||
./nix-bitcoin.nix
|
|
||||||
./secrets/secrets.nix
|
|
||||||
./operator.nix
|
|
||||||
./security.nix
|
|
||||||
./onion-addresses.nix
|
|
||||||
./onion-services.nix
|
|
||||||
./nodeinfo.nix
|
|
||||||
./versioning.nix
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# Sovran Bitcoin stack - tailored from nix-bitcoin, lnd-only, nixpkgs packages
|
|
||||||
# Original: https://github.com/fort-nix/nix-bitcoin
|
|
||||||
{
|
|
||||||
imports = [
|
|
||||||
./common.nix
|
|
||||||
./bitcoind.nix
|
|
||||||
./electrs.nix
|
|
||||||
./lnd.nix
|
|
||||||
./lndconnect.nix
|
|
||||||
./rtl.nix
|
|
||||||
./btcpayserver.nix
|
|
||||||
./mempool.nix
|
|
||||||
];
|
|
||||||
|
|
||||||
disabledModules = [ "services/networking/bitcoind.nix" ];
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
{ config, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
options.services.electrs = {
|
|
||||||
enable = mkEnableOption "electrs, an Electrum server implemented in Rust";
|
|
||||||
address = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "127.0.0.1";
|
|
||||||
description = "Address to listen for RPC connections.";
|
|
||||||
};
|
|
||||||
port = mkOption {
|
|
||||||
type = types.port;
|
|
||||||
default = 50001;
|
|
||||||
description = "Port to listen for RPC connections.";
|
|
||||||
};
|
|
||||||
dataDir = mkOption {
|
|
||||||
type = types.path;
|
|
||||||
default = "/var/lib/electrs";
|
|
||||||
description = "The data directory for electrs.";
|
|
||||||
};
|
|
||||||
monitoringPort = mkOption {
|
|
||||||
type = types.port;
|
|
||||||
default = 4224;
|
|
||||||
description = "Prometheus monitoring port.";
|
|
||||||
};
|
|
||||||
extraArgs = mkOption {
|
|
||||||
type = types.separatedString " ";
|
|
||||||
default = "";
|
|
||||||
description = "Extra command line arguments passed to electrs.";
|
|
||||||
};
|
|
||||||
user = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "electrs";
|
|
||||||
description = "The user as which to run electrs.";
|
|
||||||
};
|
|
||||||
group = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = cfg.user;
|
|
||||||
description = "The group as which to run electrs.";
|
|
||||||
};
|
|
||||||
tor.enforce = nbLib.tor.enforce;
|
|
||||||
};
|
|
||||||
|
|
||||||
cfg = config.services.electrs;
|
|
||||||
nbLib = config.nix-bitcoin.lib;
|
|
||||||
secretsDir = config.nix-bitcoin.secretsDir;
|
|
||||||
bitcoind = config.services.bitcoind;
|
|
||||||
in {
|
|
||||||
inherit options;
|
|
||||||
|
|
||||||
config = mkIf cfg.enable {
|
|
||||||
assertions = [
|
|
||||||
{ assertion = bitcoind.prune == 0;
|
|
||||||
message = "electrs does not support bitcoind pruning.";
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
services.bitcoind = {
|
|
||||||
enable = true;
|
|
||||||
listenWhitelisted = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.tmpfiles.rules = [
|
|
||||||
"d '${cfg.dataDir}' 0770 ${cfg.user} ${cfg.group} - -"
|
|
||||||
];
|
|
||||||
|
|
||||||
systemd.services.electrs = {
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
requires = [ "bitcoind.service" ];
|
|
||||||
after = [ "bitcoind.service" "nix-bitcoin-secrets.target" ];
|
|
||||||
preStart = ''
|
|
||||||
echo "auth = \"${bitcoind.rpc.users.public.name}:$(cat ${secretsDir}/bitcoin-rpcpassword-public)\"" \
|
|
||||||
> electrs.toml
|
|
||||||
'';
|
|
||||||
serviceConfig = nbLib.defaultHardening // {
|
|
||||||
# electrs only uses the working directory for reading electrs.toml
|
|
||||||
WorkingDirectory = cfg.dataDir;
|
|
||||||
ExecStart = ''
|
|
||||||
${pkgs.electrs}/bin/electrs \
|
|
||||||
--log-filters=INFO \
|
|
||||||
--network=${bitcoind.makeNetworkName "bitcoin" "regtest"} \
|
|
||||||
--db-dir='${cfg.dataDir}' \
|
|
||||||
--daemon-dir='${bitcoind.dataDir}' \
|
|
||||||
--electrum-rpc-addr=${cfg.address}:${toString cfg.port} \
|
|
||||||
--monitoring-addr=${cfg.address}:${toString cfg.monitoringPort} \
|
|
||||||
--daemon-rpc-addr=${nbLib.addressWithPort bitcoind.rpc.address bitcoind.rpc.port} \
|
|
||||||
--daemon-p2p-addr=${nbLib.addressWithPort bitcoind.address bitcoind.whitelistedPort} \
|
|
||||||
${cfg.extraArgs}
|
|
||||||
'';
|
|
||||||
User = cfg.user;
|
|
||||||
Group = cfg.group;
|
|
||||||
Restart = "on-failure";
|
|
||||||
RestartSec = "10s";
|
|
||||||
ReadWritePaths = [ cfg.dataDir ];
|
|
||||||
} // nbLib.allowedIPAddresses cfg.tor.enforce;
|
|
||||||
};
|
|
||||||
|
|
||||||
users.users.${cfg.user} = {
|
|
||||||
isSystemUser = true;
|
|
||||||
group = cfg.group;
|
|
||||||
extraGroups = [ "bitcoinrpc-public" ];
|
|
||||||
};
|
|
||||||
users.groups.${cfg.group} = {};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
lib: pkgs: config:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
|
|
||||||
# See `man systemd.exec` and `man systemd.resource-control` for an explanation
|
|
||||||
# of the systemd-related options available through this file.
|
|
||||||
let self = {
|
|
||||||
# These settings roughly follow systemd's "strict" security profile
|
|
||||||
defaultHardening = {
|
|
||||||
PrivateTmp = true;
|
|
||||||
ProtectSystem = "strict";
|
|
||||||
ProtectHome = true;
|
|
||||||
NoNewPrivileges = true;
|
|
||||||
PrivateDevices = true;
|
|
||||||
MemoryDenyWriteExecute = true;
|
|
||||||
ProtectKernelTunables = true;
|
|
||||||
ProtectKernelModules = true;
|
|
||||||
ProtectKernelLogs = true;
|
|
||||||
ProtectClock = true;
|
|
||||||
ProtectProc = "invisible";
|
|
||||||
ProcSubset = "pid";
|
|
||||||
ProtectControlGroups = true;
|
|
||||||
RestrictAddressFamilies = "AF_UNIX AF_INET AF_INET6";
|
|
||||||
RestrictNamespaces = true;
|
|
||||||
LockPersonality = true;
|
|
||||||
IPAddressDeny = "any";
|
|
||||||
PrivateUsers = true;
|
|
||||||
RestrictSUIDSGID = true;
|
|
||||||
RemoveIPC = true;
|
|
||||||
RestrictRealtime = true;
|
|
||||||
ProtectHostname = true;
|
|
||||||
CapabilityBoundingSet = "";
|
|
||||||
# @system-service whitelist and docker seccomp blacklist (except for "clone"
|
|
||||||
# which is a core requirement for systemd services)
|
|
||||||
# @system-service is defined in src/shared/seccomp-util.c (systemd source)
|
|
||||||
SystemCallFilter = [ "@system-service" "~add_key kcmp keyctl mbind move_pages name_to_handle_at personality process_vm_readv process_vm_writev request_key setns unshare userfaultfd" ];
|
|
||||||
SystemCallArchitectures = "native";
|
|
||||||
};
|
|
||||||
|
|
||||||
allowNetlink = {
|
|
||||||
RestrictAddressFamilies = self.defaultHardening.RestrictAddressFamilies + " AF_NETLINK";
|
|
||||||
};
|
|
||||||
|
|
||||||
nodejs = {
|
|
||||||
# Required for JIT compilation
|
|
||||||
MemoryDenyWriteExecute = false;
|
|
||||||
# Required by nodejs >= 18
|
|
||||||
SystemCallFilter = self.defaultHardening.SystemCallFilter ++ [ "@pkey" ];
|
|
||||||
};
|
|
||||||
|
|
||||||
# Allow takes precedence over Deny.
|
|
||||||
allowLocalIPAddresses = {
|
|
||||||
IPAddressAllow = [
|
|
||||||
"127.0.0.1/32"
|
|
||||||
"::1/128"
|
|
||||||
"169.254.0.0/16"
|
|
||||||
];
|
|
||||||
};
|
|
||||||
allowAllIPAddresses = { IPAddressAllow = "any"; };
|
|
||||||
allowTor = self.allowLocalIPAddresses;
|
|
||||||
allowedIPAddresses = onlyLocal:
|
|
||||||
if onlyLocal
|
|
||||||
then self.allowLocalIPAddresses
|
|
||||||
else self.allowAllIPAddresses;
|
|
||||||
|
|
||||||
tor = {
|
|
||||||
proxy = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = "Whether to proxy outgoing connections with Tor.";
|
|
||||||
};
|
|
||||||
enforce = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Whether to enforce Tor on this service by only allowing connections
|
|
||||||
from and to localhost and link-local addresses.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
script = name: src: pkgs.writers.writeBash name ''
|
|
||||||
set -eo pipefail
|
|
||||||
${src}
|
|
||||||
'';
|
|
||||||
|
|
||||||
# Used for ExecStart*
|
|
||||||
rootScript = name: src: "+${self.script name src}";
|
|
||||||
|
|
||||||
cliExec = mkOption {
|
|
||||||
# Used by netns-isolation to execute the cli in the service's private netns
|
|
||||||
internal = true;
|
|
||||||
type = types.str;
|
|
||||||
default = "exec";
|
|
||||||
};
|
|
||||||
|
|
||||||
mkOnionService = map: {
|
|
||||||
map = [ map ];
|
|
||||||
version = 3;
|
|
||||||
};
|
|
||||||
|
|
||||||
# Convert a bind address, which may be a special INADDR_ANY address,
|
|
||||||
# to an actual IP address
|
|
||||||
address = addr:
|
|
||||||
if addr == "0.0.0.0" then
|
|
||||||
"127.0.0.1"
|
|
||||||
else if addr == "::" then
|
|
||||||
"::1"
|
|
||||||
else
|
|
||||||
addr;
|
|
||||||
|
|
||||||
addressWithPort = addr: port: "${self.address addr}:${toString port}";
|
|
||||||
|
|
||||||
optionalAttr = cond: name: if cond then name else null;
|
|
||||||
|
|
||||||
mkCertExtraAltNames = cert:
|
|
||||||
builtins.concatStringsSep "," (
|
|
||||||
(map (domain: "DNS:${domain}") cert.extraDomains) ++
|
|
||||||
(map (ip: "IP:${ip}") cert.extraIPs)
|
|
||||||
);
|
|
||||||
|
|
||||||
test = {
|
|
||||||
mkIfTest = test: mkIf (config.tests.${test} or false);
|
|
||||||
};
|
|
||||||
|
|
||||||
mkAlias = default: mkOption {
|
|
||||||
internal = true;
|
|
||||||
readOnly = true;
|
|
||||||
inherit default;
|
|
||||||
};
|
|
||||||
|
|
||||||
}; in self
|
|
||||||
@@ -1,313 +0,0 @@
|
|||||||
{ config, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
options.services.lnd = {
|
|
||||||
enable = mkEnableOption "Lightning Network daemon, a Lightning Network implementation in Go";
|
|
||||||
address = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "127.0.0.1";
|
|
||||||
description = "Address to listen for peer connections";
|
|
||||||
};
|
|
||||||
port = mkOption {
|
|
||||||
type = types.port;
|
|
||||||
default = 9735;
|
|
||||||
description = "Port to listen for peer connections";
|
|
||||||
};
|
|
||||||
rpcAddress = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "127.0.0.1";
|
|
||||||
description = "Address to listen for RPC connections.";
|
|
||||||
};
|
|
||||||
rpcPort = mkOption {
|
|
||||||
type = types.port;
|
|
||||||
default = 10009;
|
|
||||||
description = "Port to listen for gRPC connections.";
|
|
||||||
};
|
|
||||||
restAddress = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "127.0.0.1";
|
|
||||||
description = "Address to listen for REST connections.";
|
|
||||||
};
|
|
||||||
restPort = mkOption {
|
|
||||||
type = types.port;
|
|
||||||
default = 8080;
|
|
||||||
description = "Port to listen for REST connections.";
|
|
||||||
};
|
|
||||||
dataDir = mkOption {
|
|
||||||
type = types.path;
|
|
||||||
default = "/var/lib/lnd";
|
|
||||||
description = "The data directory for LND.";
|
|
||||||
};
|
|
||||||
networkDir = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
default = "${cfg.dataDir}/chain/bitcoin/${bitcoind.network}";
|
|
||||||
description = "The network data directory.";
|
|
||||||
};
|
|
||||||
tor-socks = mkOption {
|
|
||||||
type = types.nullOr types.str;
|
|
||||||
default = if cfg.tor.proxy then config.nix-bitcoin.torClientAddressWithPort else null;
|
|
||||||
description = "Socks proxy for connecting to Tor nodes";
|
|
||||||
};
|
|
||||||
macaroons = mkOption {
|
|
||||||
default = {};
|
|
||||||
type = with types; attrsOf (submodule {
|
|
||||||
options = {
|
|
||||||
user = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
description = "User who owns the macaroon.";
|
|
||||||
};
|
|
||||||
permissions = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
example = ''
|
|
||||||
{"entity":"info","action":"read"},{"entity":"onchain","action":"read"}
|
|
||||||
'';
|
|
||||||
description = "List of granted macaroon permissions.";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
});
|
|
||||||
description = ''
|
|
||||||
Extra macaroon definitions.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
certificate = {
|
|
||||||
extraIPs = mkOption {
|
|
||||||
type = with types; listOf str;
|
|
||||||
default = [];
|
|
||||||
example = [ "60.100.0.1" ];
|
|
||||||
description = ''
|
|
||||||
Extra `subjectAltName` IPs added to the certificate.
|
|
||||||
This works the same as lnd option {option}`tlsextraip`.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
extraDomains = mkOption {
|
|
||||||
type = with types; listOf str;
|
|
||||||
default = [];
|
|
||||||
example = [ "example.com" ];
|
|
||||||
description = ''
|
|
||||||
Extra `subjectAltName` domain names added to the certificate.
|
|
||||||
This works the same as lnd option {option}`tlsextradomain`.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
extraConfig = mkOption {
|
|
||||||
type = types.lines;
|
|
||||||
default = "";
|
|
||||||
example = ''
|
|
||||||
autopilot.active=1
|
|
||||||
'';
|
|
||||||
description = ''
|
|
||||||
Extra lines appended to {file}`lnd.conf`.
|
|
||||||
See here for all available options:
|
|
||||||
https://github.com/lightningnetwork/lnd/blob/master/sample-lnd.conf
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
package = mkOption {
|
|
||||||
type = types.package;
|
|
||||||
default = pkgs.lnd;
|
|
||||||
defaultText = "pkgs.lnd";
|
|
||||||
description = "The package providing lnd binaries.";
|
|
||||||
};
|
|
||||||
cli = mkOption {
|
|
||||||
default = pkgs.writers.writeBashBin "lncli"
|
|
||||||
# Switch user because lnd makes datadir contents readable by user only
|
|
||||||
''
|
|
||||||
${runAsUser} ${cfg.user} ${cfg.package}/bin/lncli \
|
|
||||||
--rpcserver ${cfg.rpcAddress}:${toString cfg.rpcPort} \
|
|
||||||
--tlscertpath '${cfg.certPath}' \
|
|
||||||
--macaroonpath '${networkDir}/admin.macaroon' "$@"
|
|
||||||
'';
|
|
||||||
defaultText = "(See source)";
|
|
||||||
description = "Binary to connect with the lnd instance.";
|
|
||||||
};
|
|
||||||
getPublicAddressCmd = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "";
|
|
||||||
description = ''
|
|
||||||
Bash expression which outputs the public service address to announce to peers.
|
|
||||||
If left empty, no address is announced.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
user = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "lnd";
|
|
||||||
description = "The user as which to run LND.";
|
|
||||||
};
|
|
||||||
group = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = cfg.user;
|
|
||||||
description = "The group as which to run LND.";
|
|
||||||
};
|
|
||||||
certPath = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
default = "${secretsDir}/lnd-cert";
|
|
||||||
description = "LND TLS certificate path.";
|
|
||||||
};
|
|
||||||
tor = nbLib.tor;
|
|
||||||
};
|
|
||||||
|
|
||||||
cfg = config.services.lnd;
|
|
||||||
nbLib = config.nix-bitcoin.lib;
|
|
||||||
secretsDir = config.nix-bitcoin.secretsDir;
|
|
||||||
runAsUser = config.nix-bitcoin.runAsUserCmd;
|
|
||||||
lndinit = "${(pkgs.callPackage ../../packages/lndinit {})}/bin/lndinit";
|
|
||||||
|
|
||||||
bitcoind = config.services.bitcoind;
|
|
||||||
|
|
||||||
bitcoindRpcAddress = nbLib.address bitcoind.rpc.address;
|
|
||||||
networkDir = cfg.networkDir;
|
|
||||||
configFile = pkgs.writeText "lnd.conf" ''
|
|
||||||
datadir=${cfg.dataDir}
|
|
||||||
tlscertpath=${cfg.certPath}
|
|
||||||
tlskeypath=${secretsDir}/lnd-key
|
|
||||||
|
|
||||||
# We're logging via journald
|
|
||||||
logging.file.disable=1
|
|
||||||
logging.console.no-timestamps=1
|
|
||||||
|
|
||||||
listen=${toString cfg.address}:${toString cfg.port}
|
|
||||||
rpclisten=${cfg.rpcAddress}:${toString cfg.rpcPort}
|
|
||||||
restlisten=${cfg.restAddress}:${toString cfg.restPort}
|
|
||||||
|
|
||||||
bitcoin.${bitcoind.network}=1
|
|
||||||
bitcoin.node=bitcoind
|
|
||||||
|
|
||||||
${optionalString (cfg.tor.proxy) "tor.active=true"}
|
|
||||||
${optionalString (cfg.tor-socks != null) "tor.socks=${cfg.tor-socks}"}
|
|
||||||
|
|
||||||
bitcoind.rpchost=${bitcoindRpcAddress}:${toString bitcoind.rpc.port}
|
|
||||||
bitcoind.rpcuser=${bitcoind.rpc.users.public.name}
|
|
||||||
bitcoind.zmqpubrawblock=${zmqHandleSpecialAddress bitcoind.zmqpubrawblock}
|
|
||||||
bitcoind.zmqpubrawtx=${zmqHandleSpecialAddress bitcoind.zmqpubrawtx}
|
|
||||||
|
|
||||||
wallet-unlock-password-file=${secretsDir}/lnd-wallet-password
|
|
||||||
|
|
||||||
${cfg.extraConfig}
|
|
||||||
'';
|
|
||||||
|
|
||||||
zmqHandleSpecialAddress = builtins.replaceStrings [ "0.0.0.0" "[::]" ] [ "127.0.0.1" "[::1]" ];
|
|
||||||
in {
|
|
||||||
|
|
||||||
inherit options;
|
|
||||||
|
|
||||||
config = mkIf cfg.enable {
|
|
||||||
assertions = [
|
|
||||||
{ assertion =
|
|
||||||
!(config.services ? clightning)
|
|
||||||
|| true; # clightning enable/port check disabled - option structure differs between nixpkgs versions (f13ff45 has plugins only, 8b8c811 removed). Sovran uses lnd only, so no conflict.
|
|
||||||
message = ''
|
|
||||||
LND and clightning can't both bind to lightning port 9735. Either
|
|
||||||
disable LND/clightning or change services.clightning.port or
|
|
||||||
services.lnd.port to a port other than 9735.
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
services.bitcoind = {
|
|
||||||
enable = true;
|
|
||||||
|
|
||||||
# Increase rpc thread count due to reports that lightning implementations fail
|
|
||||||
# under high bitcoind rpc load
|
|
||||||
rpc.threads = 16;
|
|
||||||
|
|
||||||
zmqpubrawblock = mkDefault "tcp://${bitcoindRpcAddress}:28332";
|
|
||||||
zmqpubrawtx = mkDefault "tcp://${bitcoindRpcAddress}:28333";
|
|
||||||
};
|
|
||||||
|
|
||||||
environment.systemPackages = [ cfg.package (hiPrio cfg.cli) ];
|
|
||||||
|
|
||||||
systemd.tmpfiles.rules = [
|
|
||||||
"d '${cfg.dataDir}' 0770 ${cfg.user} ${cfg.group} - -"
|
|
||||||
];
|
|
||||||
|
|
||||||
services.lnd.certificate.extraIPs = mkIf (cfg.rpcAddress != "127.0.0.1") [ "${cfg.rpcAddress}" ];
|
|
||||||
|
|
||||||
systemd.services.lnd = {
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
requires = [ "bitcoind.service" ];
|
|
||||||
after = [ "bitcoind.service" "nix-bitcoin-secrets.target" ];
|
|
||||||
preStart = ''
|
|
||||||
install -m600 ${configFile} '${cfg.dataDir}/lnd.conf'
|
|
||||||
{
|
|
||||||
echo "bitcoind.rpcpass=$(cat ${secretsDir}/bitcoin-rpcpassword-public)"
|
|
||||||
${optionalString (cfg.getPublicAddressCmd != "") ''
|
|
||||||
echo "externalip=$(${cfg.getPublicAddressCmd})"
|
|
||||||
''}
|
|
||||||
} >> '${cfg.dataDir}/lnd.conf'
|
|
||||||
|
|
||||||
if [[ ! -f ${networkDir}/wallet.db ]]; then
|
|
||||||
seed='${cfg.dataDir}/lnd-seed-mnemonic'
|
|
||||||
|
|
||||||
if [[ ! -f "$seed" ]]; then
|
|
||||||
echo "Create lnd seed"
|
|
||||||
(umask u=r,go=; ${lndinit} gen-seed > "$seed")
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Create lnd wallet"
|
|
||||||
${lndinit} -v init-wallet \
|
|
||||||
--file.seed="$seed" \
|
|
||||||
--file.wallet-password='${secretsDir}/lnd-wallet-password' \
|
|
||||||
--init-file.output-wallet-dir='${cfg.networkDir}'
|
|
||||||
fi
|
|
||||||
'';
|
|
||||||
serviceConfig = nbLib.defaultHardening // {
|
|
||||||
Type = "notify";
|
|
||||||
RuntimeDirectory = "lnd"; # Only used to store custom macaroons
|
|
||||||
RuntimeDirectoryMode = "711";
|
|
||||||
ExecStart = "${cfg.package}/bin/lnd --configfile='${cfg.dataDir}/lnd.conf'";
|
|
||||||
User = cfg.user;
|
|
||||||
TimeoutSec = "15min";
|
|
||||||
Restart = "on-failure";
|
|
||||||
RestartSec = "10s";
|
|
||||||
ReadWritePaths = [ cfg.dataDir ];
|
|
||||||
ExecStartPost = let
|
|
||||||
curl = "${pkgs.curl}/bin/curl -fsS --cacert ${cfg.certPath}";
|
|
||||||
restUrl = "https://${nbLib.addressWithPort cfg.restAddress cfg.restPort}/v1";
|
|
||||||
# Setting macaroon permissions for other users needs root permissions
|
|
||||||
script = nbLib.rootScript "lnd-create-macaroons" ''
|
|
||||||
umask ug=r,o=
|
|
||||||
${lib.concatMapStrings (macaroon: ''
|
|
||||||
echo "Create custom macaroon ${macaroon}"
|
|
||||||
macaroonPath="$RUNTIME_DIRECTORY/${macaroon}.macaroon"
|
|
||||||
${curl} \
|
|
||||||
-H "Grpc-Metadata-macaroon: $(${pkgs.xxd}/bin/xxd -ps -u -c 99999 '${networkDir}/admin.macaroon')" \
|
|
||||||
-X POST \
|
|
||||||
-d '{"permissions":[${cfg.macaroons.${macaroon}.permissions}]}' \
|
|
||||||
${restUrl}/macaroon |\
|
|
||||||
${pkgs.jq}/bin/jq -c '.macaroon' | ${pkgs.xxd}/bin/xxd -p -r > "$macaroonPath"
|
|
||||||
chown ${cfg.macaroons.${macaroon}.user}: "$macaroonPath"
|
|
||||||
'') (attrNames cfg.macaroons)}
|
|
||||||
'';
|
|
||||||
in [
|
|
||||||
script
|
|
||||||
];
|
|
||||||
} // nbLib.allowedIPAddresses cfg.tor.enforce;
|
|
||||||
};
|
|
||||||
|
|
||||||
users.users.${cfg.user} = {
|
|
||||||
isSystemUser = true;
|
|
||||||
group = cfg.group;
|
|
||||||
extraGroups = [ "bitcoinrpc-public" ];
|
|
||||||
home = cfg.dataDir; # lnd creates .lnd dir in HOME
|
|
||||||
};
|
|
||||||
users.groups.${cfg.group} = {};
|
|
||||||
nix-bitcoin.operator = {
|
|
||||||
groups = [ cfg.group ];
|
|
||||||
allowRunAsUsers = [ cfg.user ];
|
|
||||||
};
|
|
||||||
|
|
||||||
nix-bitcoin.secrets = {
|
|
||||||
lnd-wallet-password.user = cfg.user;
|
|
||||||
lnd-key.user = cfg.user;
|
|
||||||
lnd-cert.user = cfg.user;
|
|
||||||
lnd-cert.permissions = "444"; # world readable
|
|
||||||
};
|
|
||||||
# Advantages of manually pre-generating certs:
|
|
||||||
# - Reduces dynamic state
|
|
||||||
# - Enables deployment of a mesh of server plus client nodes with predefined certs
|
|
||||||
nix-bitcoin.generateSecretsCmds.lnd = ''
|
|
||||||
makePasswordSecret lnd-wallet-password
|
|
||||||
makeCert lnd '${nbLib.mkCertExtraAltNames cfg.certificate}'
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
{ config, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
cfg = config.services.lnd;
|
|
||||||
operatorName = config.nix-bitcoin.operator.name;
|
|
||||||
nbLib = config.nix-bitcoin.lib;
|
|
||||||
|
|
||||||
mkLndconnect = { name, isClightning ? false, enableOnion, onionService, port, certPath, authSecretPath }:
|
|
||||||
let
|
|
||||||
lnd = config.services.lnd;
|
|
||||||
getOnionAddress = "cat ${config.nix-bitcoin.secretsDir}/onion-address-${onionService} 2>/dev/null || echo ${onionService}.onion";
|
|
||||||
in pkgs.writeScriptBin name ''
|
|
||||||
#!${pkgs.bash}/bin/bash
|
|
||||||
set -e
|
|
||||||
certPath="${certPath}"
|
|
||||||
authSecretPath="${authSecretPath}"
|
|
||||||
if [ "${toString enableOnion}" = "1" ]; then
|
|
||||||
host=$(cat /var/lib/tor/onion/${onionService}/hostname 2>/dev/null || echo "${onionService}.onion")
|
|
||||||
port="${toString port}"
|
|
||||||
else
|
|
||||||
host="${nbLib.address lnd.restAddress}"
|
|
||||||
port="${toString lnd.restPort}"
|
|
||||||
fi
|
|
||||||
# lndconnect is provided by pkgs.lndconnect
|
|
||||||
${getExe pkgs.lndconnect} --host="$host" --port="$port" --cert="$certPath" --macaroon="$authSecretPath" "$@"
|
|
||||||
'';
|
|
||||||
in {
|
|
||||||
options.services.lnd.lndconnect = {
|
|
||||||
enable = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = "Enable lndconnect for LND";
|
|
||||||
};
|
|
||||||
onion = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = "Expose lndconnect via Tor onion service";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
config = mkIf cfg.enable (mkMerge [
|
|
||||||
(mkIf cfg.lndconnect.enable {
|
|
||||||
environment.systemPackages = [
|
|
||||||
(mkLndconnect {
|
|
||||||
name = "lndconnect";
|
|
||||||
enableOnion = cfg.lndconnect.onion;
|
|
||||||
onionService = "${operatorName}/lnd";
|
|
||||||
port = cfg.restPort;
|
|
||||||
certPath = cfg.certPath;
|
|
||||||
authSecretPath = "${cfg.networkDir}/admin.macaroon";
|
|
||||||
})
|
|
||||||
];
|
|
||||||
})
|
|
||||||
(mkIf (cfg.lndconnect.enable && cfg.lndconnect.onion) {
|
|
||||||
services.tor.relay.onionServices.lnd = nbLib.mkOnionService {
|
|
||||||
port = cfg.restPort;
|
|
||||||
target = { addr = nbLib.address cfg.restAddress; port = cfg.restPort; };
|
|
||||||
};
|
|
||||||
nix-bitcoin.onionAddresses.access.${operatorName} = [ "lnd" ];
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
@@ -1,348 +0,0 @@
|
|||||||
{ config, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
options.services = {
|
|
||||||
mempool = {
|
|
||||||
enable = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Enable Mempool, a fully featured Bitcoin visualizer, explorer, and API service.
|
|
||||||
|
|
||||||
Note: Mempool enables `txindex` in bitcoind (this is a requirement).
|
|
||||||
|
|
||||||
This module has two components:
|
|
||||||
- A backend service (systemd service `mempool`)
|
|
||||||
|
|
||||||
- An optional web interface run by nginx, defined by options `services.mempool.frontend.*`.
|
|
||||||
The frontend is enabled by default when mempool is enabled.
|
|
||||||
For details, see `services.mempool.frontend.enable`.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
frontend = {
|
|
||||||
enable = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = cfg.enable;
|
|
||||||
description = ''
|
|
||||||
Enable the mempool frontend (web interface).
|
|
||||||
This starts a simple nginx instance, configured for local usage with
|
|
||||||
settings similar to the `mempool/frontend` Docker image.
|
|
||||||
|
|
||||||
IMPORTANT:
|
|
||||||
If you want to expose the mempool frontend to the internet, you
|
|
||||||
should create a custom nginx config that includes TLS, backend caching, rate limiting
|
|
||||||
and performance tuning.
|
|
||||||
For this task, reuse the config snippets from option `services.mempool.frontend.nginxConfig`.
|
|
||||||
See also: https://github.com/fort-nix/nixbitcoin.org/blob/master/website/mempool.nix,
|
|
||||||
which contains a mempool nginx config for public hosting (running at
|
|
||||||
https://mempool.nixbitcoin.org).
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
address = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "127.0.0.1";
|
|
||||||
description = "HTTP server address.";
|
|
||||||
};
|
|
||||||
port = mkOption {
|
|
||||||
type = types.port;
|
|
||||||
default = 60845; # A random private port
|
|
||||||
description = "HTTP server port.";
|
|
||||||
};
|
|
||||||
settings = mkOption {
|
|
||||||
type = with types; attrsOf anything;
|
|
||||||
default = {};
|
|
||||||
example = {
|
|
||||||
TESTNET_ENABLED = true;
|
|
||||||
MEMPOOL_WEBSITE_URL = "mempool.mynode.org";
|
|
||||||
};
|
|
||||||
description = ''
|
|
||||||
Mempool frontend settings.
|
|
||||||
See here for available options:
|
|
||||||
https://github.com/mempool/mempool/blob/master/frontend/src/app/services/state.service.ts
|
|
||||||
(`interface Env` and `defaultEnv`)
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
staticContentRoot = mkOption {
|
|
||||||
type = types.path;
|
|
||||||
default = (pkgs.callPackage ../../packages/mempool { fetchNodeModules = pkgs.callPackage ../../packages/build-support/fetch-node-modules.nix {}; }).mempool-frontend.withConfig cfg.frontend.settings;
|
|
||||||
defaultText = "mempoolPkgs.mempool-frontend";
|
|
||||||
description = "
|
|
||||||
Path of the static frontend content root.
|
|
||||||
";
|
|
||||||
};
|
|
||||||
nginxConfig = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
default = frontend.nginxConfig;
|
|
||||||
defaultText = "(See source)";
|
|
||||||
description = "
|
|
||||||
An attrset of nginx config snippets for assembling a custom
|
|
||||||
mempool nginx config.
|
|
||||||
For details, see the source comments at the point of definition.
|
|
||||||
";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
address = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "127.0.0.1";
|
|
||||||
description = "Mempool backend address.";
|
|
||||||
};
|
|
||||||
port = mkOption {
|
|
||||||
type = types.port;
|
|
||||||
default = 8999;
|
|
||||||
description = "Mempool backend port.";
|
|
||||||
};
|
|
||||||
electrumServer = mkOption {
|
|
||||||
type = types.enum [ "electrs" ];
|
|
||||||
default = "electrs";
|
|
||||||
description = ''
|
|
||||||
The Electrum server to use for fetching address information.
|
|
||||||
|
|
||||||
Possible options:
|
|
||||||
- electrs:
|
|
||||||
Small database size, slow when querying new addresses.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
settings = mkOption {
|
|
||||||
type = with types; attrsOf (attrsOf anything);
|
|
||||||
example = {
|
|
||||||
MEMPOOL = {
|
|
||||||
POLL_RATE_MS = 3000;
|
|
||||||
STDOUT_LOG_MIN_PRIORITY = "debug";
|
|
||||||
};
|
|
||||||
PRICE_DATA_SERVER = {
|
|
||||||
CLEARNET_URL = "https://myserver.org/prices";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
description = ''
|
|
||||||
Mempool backend settings.
|
|
||||||
See here for available options:
|
|
||||||
https://github.com/mempool/mempool/blob/master/backend/src/config.ts
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
database = {
|
|
||||||
name = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "mempool";
|
|
||||||
description = "Database name.";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
package = mkOption {
|
|
||||||
type = types.package;
|
|
||||||
default = (pkgs.callPackage ../../packages/mempool { fetchNodeModules = pkgs.callPackage ../../packages/build-support/fetch-node-modules.nix {}; }).mempool-backend;
|
|
||||||
defaultText = "mempoolPkgs.mempool-backend";
|
|
||||||
description = "The package providing mempool binaries.";
|
|
||||||
};
|
|
||||||
user = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "mempool";
|
|
||||||
description = "The user as which to run Mempool.";
|
|
||||||
};
|
|
||||||
group = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = cfg.user;
|
|
||||||
description = "The group as which to run Mempool.";
|
|
||||||
};
|
|
||||||
tor = nbLib.tor;
|
|
||||||
};
|
|
||||||
|
|
||||||
# Internal read-only options used by `./nodeinfo.nix` and `./onion-services.nix`
|
|
||||||
mempool-frontend = let
|
|
||||||
inherit (nbLib) mkAlias;
|
|
||||||
in {
|
|
||||||
enable = mkAlias cfg.frontend.enable;
|
|
||||||
address = mkAlias cfg.frontend.address;
|
|
||||||
port = mkAlias cfg.frontend.port;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
cfg = config.services.mempool;
|
|
||||||
nbLib = config.nix-bitcoin.lib;
|
|
||||||
nbPkgs = pkgs; # vendored: now alias to pkgs
|
|
||||||
secretsDir = config.nix-bitcoin.secretsDir;
|
|
||||||
|
|
||||||
configFile = builtins.toFile "mempool-config" (builtins.toJSON cfg.settings);
|
|
||||||
cacheDir = "/var/cache/mempool";
|
|
||||||
|
|
||||||
inherit (config.services)
|
|
||||||
bitcoind
|
|
||||||
electrs;
|
|
||||||
|
|
||||||
torSocket = config.services.tor.client.socksListenAddress;
|
|
||||||
# Vendored mempool package
|
|
||||||
fetchNodeModules = pkgs.callPackage ../../packages/build-support/fetch-node-modules.nix {};
|
|
||||||
mempoolPkgs = pkgs.callPackage ../../packages/mempool { inherit fetchNodeModules; };
|
|
||||||
|
|
||||||
# See the `services.nginx` definition further below
|
|
||||||
# on how to use these snippets.
|
|
||||||
frontend.nginxConfig = {
|
|
||||||
# This must be added to `services.nginx.commonHttpConfig` when
|
|
||||||
# `mempool/location-static.conf` is used
|
|
||||||
httpConfig = ''
|
|
||||||
include ${mempoolPkgs.mempool-nginx-conf}/http-language.conf;
|
|
||||||
'';
|
|
||||||
|
|
||||||
# Config for static website content.
|
|
||||||
# This should be added to `services.nginx.virtualHosts.<mempool server name>.extraConfig`.
|
|
||||||
# Adapted from mempool/nginx-mempool.conf and mempool/production/nginx/location-redirects.conf
|
|
||||||
staticContent = ''
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
add_header Cache-Control "public, no-transform";
|
|
||||||
add_header Vary Accept-Language;
|
|
||||||
add_header Vary Cookie;
|
|
||||||
|
|
||||||
include ${mempoolPkgs.mempool-nginx-conf}/location-static.conf;
|
|
||||||
|
|
||||||
# Redirect /api to /docs/api
|
|
||||||
location = /api {
|
|
||||||
return 308 https://$host/docs/api;
|
|
||||||
}
|
|
||||||
location = /api/ {
|
|
||||||
return 308 https://$host/docs/api;
|
|
||||||
}
|
|
||||||
'';
|
|
||||||
|
|
||||||
# Config for backend API.
|
|
||||||
# This should be added to `services.nginx.virtualHosts.<mempool server name>.extraConfig`.
|
|
||||||
# Adapted from mempool/nginx-mempool.conf and mempool/production/nginx/location-api.conf.
|
|
||||||
proxyApi = let
|
|
||||||
backend = "http://${nbLib.addressWithPort cfg.address cfg.port}";
|
|
||||||
in ''
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass ${backend}/api/v1/;
|
|
||||||
}
|
|
||||||
location /api/v1 {
|
|
||||||
proxy_pass ${backend};
|
|
||||||
}
|
|
||||||
# Websocket API
|
|
||||||
location /api/v1/ws {
|
|
||||||
proxy_pass ${backend};
|
|
||||||
|
|
||||||
# Websocket header settings
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "Upgrade";
|
|
||||||
|
|
||||||
# Relevant settings from `recommendedProxyConfig` (nixos/nginx/default.nix)
|
|
||||||
# (In the above api locations, these are inherited from the parent scope)
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
in {
|
|
||||||
inherit options;
|
|
||||||
|
|
||||||
config = mkIf cfg.enable {
|
|
||||||
services.bitcoind.txindex = true;
|
|
||||||
services.electrs.enable = true;
|
|
||||||
services.mysql = {
|
|
||||||
enable = true;
|
|
||||||
package = pkgs.mariadb;
|
|
||||||
ensureDatabases = [ cfg.database.name ];
|
|
||||||
ensureUsers = [
|
|
||||||
{
|
|
||||||
name = cfg.user;
|
|
||||||
ensurePermissions."${cfg.database.name}.*" = "ALL PRIVILEGES";
|
|
||||||
}
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
# Available options:
|
|
||||||
# https://github.com/mempool/mempool/blob/master/backend/src/config.ts
|
|
||||||
services.mempool.settings = {
|
|
||||||
MEMPOOL = {
|
|
||||||
# mempool doesn't support regtest
|
|
||||||
NETWORK = "mainnet";
|
|
||||||
BACKEND = "electrum";
|
|
||||||
HTTP_PORT = cfg.port;
|
|
||||||
CACHE_DIR = "${cacheDir}/cache";
|
|
||||||
STDOUT_LOG_MIN_PRIORITY = mkDefault "info";
|
|
||||||
AUTOMATIC_POOLS_UPDATE = true;
|
|
||||||
};
|
|
||||||
CORE_RPC = {
|
|
||||||
HOST = bitcoind.rpc.address;
|
|
||||||
PORT = bitcoind.rpc.port;
|
|
||||||
USERNAME = bitcoind.rpc.users.public.name;
|
|
||||||
PASSWORD = "@btcRpcPassword@";
|
|
||||||
};
|
|
||||||
ELECTRUM = let
|
|
||||||
server = config.services.${cfg.electrumServer};
|
|
||||||
in {
|
|
||||||
HOST = server.address;
|
|
||||||
PORT = server.port;
|
|
||||||
TLS_ENABLED = false;
|
|
||||||
};
|
|
||||||
DATABASE = {
|
|
||||||
ENABLED = true;
|
|
||||||
DATABASE = cfg.database.name;
|
|
||||||
SOCKET = "/run/mysqld/mysqld.sock";
|
|
||||||
PID_DIR = cacheDir;
|
|
||||||
};
|
|
||||||
} // optionalAttrs (cfg.tor.proxy) {
|
|
||||||
# Use Tor for rate fetching and pool updating
|
|
||||||
SOCKS5PROXY = {
|
|
||||||
ENABLED = true;
|
|
||||||
USE_ONION = true;
|
|
||||||
HOST = torSocket.addr;
|
|
||||||
PORT = torSocket.port;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.services.mempool = rec {
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
requires = [ "mysql.service" ];
|
|
||||||
wants = [ "${cfg.electrumServer}.service" ];
|
|
||||||
after = requires ++ wants;
|
|
||||||
preStart = ''
|
|
||||||
mkdir -p '${cacheDir}/cache'
|
|
||||||
<${configFile} sed \
|
|
||||||
-e "s|@btcRpcPassword@|$(cat ${secretsDir}/bitcoin-rpcpassword-public)|" \
|
|
||||||
> '${cacheDir}/config.json'
|
|
||||||
'';
|
|
||||||
environment.MEMPOOL_CONFIG_FILE = "${cacheDir}/config.json";
|
|
||||||
serviceConfig = nbLib.defaultHardening // {
|
|
||||||
ExecStart = "${cfg.package}/bin/mempool-backend";
|
|
||||||
CacheDirectory = "mempool";
|
|
||||||
CacheDirectoryMode = "770";
|
|
||||||
# Show "mempool" instead of "node" in the journal
|
|
||||||
SyslogIdentifier = "mempool";
|
|
||||||
User = cfg.user;
|
|
||||||
Restart = "on-failure";
|
|
||||||
RestartSec = "10s";
|
|
||||||
} // nbLib.allowedIPAddresses cfg.tor.enforce
|
|
||||||
// nbLib.nodejs;
|
|
||||||
};
|
|
||||||
|
|
||||||
services.nginx = mkIf cfg.frontend.enable {
|
|
||||||
enable = true;
|
|
||||||
enableReload = true;
|
|
||||||
recommendedBrotliSettings = true;
|
|
||||||
recommendedGzipSettings = true;
|
|
||||||
recommendedOptimisation = true;
|
|
||||||
recommendedProxySettings = true;
|
|
||||||
recommendedTlsSettings = true;
|
|
||||||
commonHttpConfig = frontend.nginxConfig.httpConfig;
|
|
||||||
virtualHosts."mempool" = {
|
|
||||||
serverName = "_";
|
|
||||||
listen = [ { addr = cfg.frontend.address; port = cfg.frontend.port; } ];
|
|
||||||
root = cfg.frontend.staticContentRoot;
|
|
||||||
extraConfig =
|
|
||||||
frontend.nginxConfig.staticContent +
|
|
||||||
frontend.nginxConfig.proxyApi;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
users.users.${cfg.user} = {
|
|
||||||
isSystemUser = true;
|
|
||||||
group = cfg.group;
|
|
||||||
extraGroups = [ "bitcoinrpc-public" ];
|
|
||||||
};
|
|
||||||
users.groups.${cfg.group} = {};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
{ config, pkgs, lib, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
{
|
|
||||||
options = {
|
|
||||||
nix-bitcoin = {
|
|
||||||
# Kept for compatibility, now simply aliases system pkgs
|
|
||||||
pkgs = mkOption {
|
|
||||||
type = types.attrs;
|
|
||||||
default = pkgs;
|
|
||||||
defaultText = "pkgs";
|
|
||||||
description = "Alias to system pkgs (vendored nix-bitcoin now uses nixpkgs directly).";
|
|
||||||
};
|
|
||||||
|
|
||||||
useVersionLockedPkgs = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = "Deprecated — vendored modules always use system pkgs.";
|
|
||||||
};
|
|
||||||
|
|
||||||
pkgOverlays = mkOption {
|
|
||||||
internal = true;
|
|
||||||
type = with types; functionTo attrs;
|
|
||||||
default = _: _: {};
|
|
||||||
description = "Deprecated stub.";
|
|
||||||
};
|
|
||||||
|
|
||||||
lib = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
default = import ./lib.nix lib pkgs config;
|
|
||||||
defaultText = "vendor/nix-bitcoin/lib.nix";
|
|
||||||
};
|
|
||||||
|
|
||||||
torClientAddressWithPort = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
default = with config.services.tor.client.socksListenAddress;
|
|
||||||
"${addr}:${toString port}";
|
|
||||||
defaultText = "(See source)";
|
|
||||||
};
|
|
||||||
|
|
||||||
torify = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
default = pkgs.writers.writeBashBin "torify" ''
|
|
||||||
${pkgs.tor}/bin/torify \
|
|
||||||
--address ${config.services.tor.client.socksListenAddress.addr} \
|
|
||||||
"$@"
|
|
||||||
'';
|
|
||||||
defaultText = "(See source)";
|
|
||||||
};
|
|
||||||
|
|
||||||
runAsUserCmd = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
default = if config.security.doas.enable
|
|
||||||
then "doas -u"
|
|
||||||
else "sudo -u";
|
|
||||||
defaultText = "(See source)";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
{ config, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
options = {
|
|
||||||
nix-bitcoin.nodeinfo = {
|
|
||||||
enable = mkEnableOption "nodeinfo";
|
|
||||||
|
|
||||||
program = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
default = script;
|
|
||||||
defaultText = "(See source)";
|
|
||||||
};
|
|
||||||
|
|
||||||
services = mkOption {
|
|
||||||
internal = true;
|
|
||||||
type = types.attrs;
|
|
||||||
default = {};
|
|
||||||
defaultText = "(See source)";
|
|
||||||
description = ''
|
|
||||||
Nodeinfo service definitions.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
lib = mkOption {
|
|
||||||
internal = true;
|
|
||||||
readOnly = true;
|
|
||||||
default = nodeinfoLib;
|
|
||||||
defaultText = "(See source)";
|
|
||||||
description = ''
|
|
||||||
Helper functions for defining nodeinfo services.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
cfg = config.nix-bitcoin.nodeinfo;
|
|
||||||
nbLib = config.nix-bitcoin.lib;
|
|
||||||
|
|
||||||
script = pkgs.writeScriptBin "nodeinfo" ''
|
|
||||||
#!${pkgs.python3}/bin/python
|
|
||||||
|
|
||||||
import json
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from collections import OrderedDict
|
|
||||||
|
|
||||||
def success(*args):
|
|
||||||
return subprocess.call(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0
|
|
||||||
|
|
||||||
def is_active(unit):
|
|
||||||
return success("systemctl", "is-active", "--quiet", unit)
|
|
||||||
|
|
||||||
def is_enabled(unit):
|
|
||||||
return success("systemctl", "is-enabled", "--quiet", unit)
|
|
||||||
|
|
||||||
def cmd(*args):
|
|
||||||
return subprocess.run(args, stdout=subprocess.PIPE).stdout.decode('utf-8')
|
|
||||||
|
|
||||||
def shell(*args):
|
|
||||||
return cmd("bash", "-c", *args).strip()
|
|
||||||
|
|
||||||
infos = OrderedDict()
|
|
||||||
operator = "${config.nix-bitcoin.operator.name}"
|
|
||||||
|
|
||||||
def get_onion_address(name, port):
|
|
||||||
path = f"/var/lib/onion-addresses/{operator}/{name}"
|
|
||||||
try:
|
|
||||||
with open(path, "r") as f:
|
|
||||||
onion_address = f.read().strip()
|
|
||||||
except OSError:
|
|
||||||
print(f"error reading file {path}", file=sys.stderr)
|
|
||||||
return
|
|
||||||
return f"{onion_address}:{port}"
|
|
||||||
|
|
||||||
def add_service(service, make_info, systemd_service = None):
|
|
||||||
systemd_service = systemd_service or service
|
|
||||||
if not is_active(systemd_service):
|
|
||||||
infos[service] = f"'{systemd_service}.service' is not running"
|
|
||||||
else:
|
|
||||||
info = OrderedDict()
|
|
||||||
exec(make_info, globals(), locals())
|
|
||||||
infos[service] = info
|
|
||||||
|
|
||||||
if is_enabled("onion-addresses") and not is_active("onion-addresses"):
|
|
||||||
print("error: service 'onion-addresses' is not running")
|
|
||||||
exit(1)
|
|
||||||
|
|
||||||
${concatStrings infos}
|
|
||||||
|
|
||||||
print(json.dumps(infos, indent=2))
|
|
||||||
'';
|
|
||||||
|
|
||||||
infos = map (serviceName:
|
|
||||||
let serviceCfg = config.services.${serviceName};
|
|
||||||
in optionalString serviceCfg.enable (cfg.services.${serviceName} serviceName serviceCfg)
|
|
||||||
) (builtins.attrNames cfg.services);
|
|
||||||
|
|
||||||
nodeinfoLib = rec {
|
|
||||||
mkInfo = extraCode: name: cfg:
|
|
||||||
mkInfoLong {
|
|
||||||
inherit extraCode name cfg;
|
|
||||||
};
|
|
||||||
|
|
||||||
mkInfoLong = { extraCode ? "", name, cfg, systemdServiceName ? name }: ''
|
|
||||||
add_service("${name}", """
|
|
||||||
info["local_address"] = "${nbLib.addressWithPort cfg.address cfg.port}"
|
|
||||||
'' + mkIfOnionPort name (onionPort: ''
|
|
||||||
info["onion_address"] = get_onion_address("${name}", ${onionPort})
|
|
||||||
'') + extraCode + ''
|
|
||||||
|
|
||||||
""", "${systemdServiceName}")
|
|
||||||
'';
|
|
||||||
|
|
||||||
mkIfOnionPort = name: fn:
|
|
||||||
if onionServices ? ${name} then
|
|
||||||
fn (toString (builtins.elemAt onionServices.${name}.map 0).port)
|
|
||||||
else
|
|
||||||
"";
|
|
||||||
};
|
|
||||||
|
|
||||||
inherit (config.services.tor.relay) onionServices;
|
|
||||||
in {
|
|
||||||
inherit options;
|
|
||||||
|
|
||||||
config = mkIf cfg.enable {
|
|
||||||
environment.systemPackages = [ script ];
|
|
||||||
|
|
||||||
nix-bitcoin.operator.enable = true;
|
|
||||||
|
|
||||||
nix-bitcoin.nodeinfo.services = with nodeinfoLib; {
|
|
||||||
bitcoind = mkInfo "";
|
|
||||||
lnd = name: cfg: mkInfo (''
|
|
||||||
info["rest_address"] = "${nbLib.addressWithPort cfg.restAddress cfg.restPort}"
|
|
||||||
'' + mkIfOnionPort "lnd-rest" (onionPort: ''
|
|
||||||
info["onion_rest_address"] = get_onion_address("lnd-rest", ${onionPort})
|
|
||||||
'') + ''
|
|
||||||
info["nodeid"] = shell("lncli getinfo | jq -r '.identity_pubkey'")
|
|
||||||
'') name cfg;
|
|
||||||
electrs = mkInfo "";
|
|
||||||
btcpayserver = mkInfo "";
|
|
||||||
rtl = mkInfo "";
|
|
||||||
mempool = mkInfo "";
|
|
||||||
mempool-frontend = name: cfg: mkInfoLong {
|
|
||||||
inherit name cfg;
|
|
||||||
systemdServiceName = "nginx";
|
|
||||||
};
|
|
||||||
# Only add sshd when it has an onion service
|
|
||||||
sshd = name: cfg: mkIfOnionPort "sshd" (onionPort: ''
|
|
||||||
add_service("sshd", """info["onion_address"] = get_onion_address("sshd", ${onionPort})""")
|
|
||||||
'');
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
# This module enables unprivileged users to read onion addresses.
|
|
||||||
# By default, onion addresses in /var/lib/tor/onion are only readable by the
|
|
||||||
# tor user.
|
|
||||||
# The included service copies onion addresses to /var/lib/onion-addresses/<user>/
|
|
||||||
# and sets permissions according to option 'access'.
|
|
||||||
|
|
||||||
{ config, lib, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
options.nix-bitcoin.onionAddresses = {
|
|
||||||
access = mkOption {
|
|
||||||
type = with types; attrsOf (listOf str);
|
|
||||||
default = {};
|
|
||||||
description = ''
|
|
||||||
This option controls who is allowed to access onion addresses.
|
|
||||||
For example, the following allows user 'myuser' to access bitcoind
|
|
||||||
and clightning onion addresses:
|
|
||||||
```nix
|
|
||||||
{
|
|
||||||
"myuser" = [ "bitcoind" "clightning" ];
|
|
||||||
};
|
|
||||||
```
|
|
||||||
The onion hostnames can then be read from
|
|
||||||
{file}`/var/lib/onion-addresses/myuser`.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
services = mkOption {
|
|
||||||
type = with types; listOf str;
|
|
||||||
default = [];
|
|
||||||
description = ''
|
|
||||||
Services that can access their onion address via file
|
|
||||||
{file}`/var/lib/onion-addresses/<service>`
|
|
||||||
The file is readable only by the service user.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
dataDir = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
default = "/var/lib/onion-addresses";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
cfg = config.nix-bitcoin.onionAddresses;
|
|
||||||
nbLib = config.nix-bitcoin.lib;
|
|
||||||
in {
|
|
||||||
inherit options;
|
|
||||||
|
|
||||||
config = mkIf (cfg.access != {} || cfg.services != []) {
|
|
||||||
systemd.services.onion-addresses = {
|
|
||||||
wantedBy = [ "tor.service" ];
|
|
||||||
bindsTo = [ "tor.service" ];
|
|
||||||
after = [ "tor.service" ];
|
|
||||||
serviceConfig = nbLib.defaultHardening // {
|
|
||||||
Type = "oneshot";
|
|
||||||
RemainAfterExit = true;
|
|
||||||
StateDirectory = "onion-addresses";
|
|
||||||
StateDirectoryMode = "771";
|
|
||||||
PrivateNetwork = true; # This service needs no network access
|
|
||||||
PrivateUsers = false;
|
|
||||||
CapabilityBoundingSet = "CAP_CHOWN CAP_FSETID CAP_SETFCAP CAP_DAC_OVERRIDE CAP_DAC_READ_SEARCH CAP_FOWNER CAP_IPC_OWNER";
|
|
||||||
};
|
|
||||||
script = ''
|
|
||||||
waitForFile() {
|
|
||||||
file=$1
|
|
||||||
for ((i=0; i<300; i++)); do
|
|
||||||
if [[ -e $file ]]; then
|
|
||||||
return;
|
|
||||||
fi
|
|
||||||
sleep 0.1
|
|
||||||
done
|
|
||||||
echo "Error: File $file did not appear after 30 sec."
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Wait until tor is up
|
|
||||||
waitForFile /var/lib/tor/state
|
|
||||||
|
|
||||||
cd ${cfg.dataDir}
|
|
||||||
rm -rf ./*
|
|
||||||
|
|
||||||
${concatMapStrings
|
|
||||||
(user: ''
|
|
||||||
mkdir -p -m 0700 ${user}
|
|
||||||
chown ${user} ${user}
|
|
||||||
${concatMapStrings
|
|
||||||
(service: ''
|
|
||||||
onionFile='/var/lib/tor/onion/${service}/hostname'
|
|
||||||
waitForFile "$onionFile"
|
|
||||||
cp "$onionFile" '${user}/${service}'
|
|
||||||
chown '${user}' '${user}/${service}'
|
|
||||||
'')
|
|
||||||
cfg.access.${user}
|
|
||||||
}
|
|
||||||
'')
|
|
||||||
(builtins.attrNames cfg.access)
|
|
||||||
}
|
|
||||||
|
|
||||||
${concatMapStrings (service: ''
|
|
||||||
onionFile=/var/lib/tor/onion/${service}/hostname
|
|
||||||
waitForFile "$onionFile"
|
|
||||||
install -D -o ${config.systemd.services.${service}.serviceConfig.User} -m 400 "$onionFile" services/${service}
|
|
||||||
'') cfg.services}
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
# This module creates onion-services for NixOS services.
|
|
||||||
# An onion service can be enabled for every service that defines
|
|
||||||
# options 'address', 'port' and optionally 'getPublicAddressCmd'.
|
|
||||||
#
|
|
||||||
# See it in use at ./presets/enable-tor.nix
|
|
||||||
|
|
||||||
{ config, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
options.nix-bitcoin.onionServices = mkOption {
|
|
||||||
default = {};
|
|
||||||
type = with types; attrsOf (submodule (
|
|
||||||
{ config, ... }: {
|
|
||||||
options = {
|
|
||||||
enable = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = config.public;
|
|
||||||
description = ''
|
|
||||||
Create an onion service for the given service.
|
|
||||||
The service must define options {option}`address` and {option}`onionPort` (or `port`).
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
public = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Make the onion address accessible to the service.
|
|
||||||
If enabled, the onion service is automatically enabled.
|
|
||||||
Only available for services that define option {option}`getPublicAddressCmd`.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
externalPort = mkOption {
|
|
||||||
type = types.nullOr types.port;
|
|
||||||
default = null;
|
|
||||||
description = "Override the external port of the onion service.";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
cfg = config.nix-bitcoin.onionServices;
|
|
||||||
nbLib = config.nix-bitcoin.lib;
|
|
||||||
|
|
||||||
onionServices = builtins.attrNames cfg;
|
|
||||||
|
|
||||||
activeServices = builtins.filter (service:
|
|
||||||
config.services.${service}.enable && cfg.${service}.enable
|
|
||||||
) onionServices;
|
|
||||||
|
|
||||||
publicServices = builtins.filter (service: cfg.${service}.public) activeServices;
|
|
||||||
in {
|
|
||||||
inherit options;
|
|
||||||
|
|
||||||
config = mkMerge [
|
|
||||||
(mkIf (activeServices != []) {
|
|
||||||
# Define hidden services
|
|
||||||
services.tor = {
|
|
||||||
enable = true;
|
|
||||||
relay.onionServices = genAttrs activeServices (name:
|
|
||||||
let
|
|
||||||
service = config.services.${name};
|
|
||||||
inherit (cfg.${name}) externalPort;
|
|
||||||
in nbLib.mkOnionService {
|
|
||||||
port = if externalPort != null then externalPort else service.port;
|
|
||||||
target.port = service.onionPort or service.port;
|
|
||||||
target.addr = nbLib.address service.address;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
nix-bitcoin.onionAddresses = {
|
|
||||||
# Enable public services to access their own onion addresses
|
|
||||||
services = publicServices;
|
|
||||||
|
|
||||||
# Allow the operator user to access onion addresses for all active services
|
|
||||||
access.${config.nix-bitcoin.operator.name} = mkIf config.nix-bitcoin.operator.enable activeServices;
|
|
||||||
};
|
|
||||||
systemd.services = let
|
|
||||||
onionAddresses = [ "onion-addresses.service" ];
|
|
||||||
in genAttrs publicServices (service: {
|
|
||||||
# TODO-EXTERNAL: Instead of `wants`, use a future systemd dependency type
|
|
||||||
# that propagates initial start failures but no restarts
|
|
||||||
wants = onionAddresses;
|
|
||||||
after = onionAddresses;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
|
|
||||||
# Set getPublicAddressCmd for public services
|
|
||||||
{
|
|
||||||
services = let
|
|
||||||
# publicServices' doesn't depend on config.services.*.enable,
|
|
||||||
# so we can use it to define config.services without causing infinite recursion
|
|
||||||
publicServices' = builtins.filter (service:
|
|
||||||
let srv = cfg.${service};
|
|
||||||
in srv.public && srv.enable
|
|
||||||
) onionServices;
|
|
||||||
in genAttrs publicServices' (service: {
|
|
||||||
getPublicAddressCmd = "cat ${config.nix-bitcoin.onionAddresses.dataDir}/services/${service}";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
# Set sensible defaults for some services
|
|
||||||
{
|
|
||||||
nix-bitcoin.onionServices = {
|
|
||||||
btcpayserver = {
|
|
||||||
externalPort = 80;
|
|
||||||
};
|
|
||||||
rtl = {
|
|
||||||
externalPort = 80;
|
|
||||||
};
|
|
||||||
mempool-frontend = {
|
|
||||||
externalPort = 80;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
{ config, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
options.nix-bitcoin.operator = {
|
|
||||||
enable = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Whether to define a user named `operator` for convenient interactive access
|
|
||||||
to nix-bitcoin features (like `bitcoin-cli`).
|
|
||||||
|
|
||||||
When using nix-bitcoin as part of a larger system config, it makes sense
|
|
||||||
to set your main system user as the operator, by setting option
|
|
||||||
`nix-bitcoin.operator.name = "MAIN_USER_NAME";`.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
name = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "operator";
|
|
||||||
description = "Name of the operator user.";
|
|
||||||
};
|
|
||||||
groups = mkOption {
|
|
||||||
type = with types; listOf str;
|
|
||||||
default = [];
|
|
||||||
description = "Extra groups of the operatur user.";
|
|
||||||
};
|
|
||||||
allowRunAsUsers = mkOption {
|
|
||||||
type = with types; listOf str;
|
|
||||||
default = [];
|
|
||||||
description = "Users as which the operator is allowed to run commands.";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
cfg = config.nix-bitcoin.operator;
|
|
||||||
in {
|
|
||||||
inherit options;
|
|
||||||
|
|
||||||
config = mkIf cfg.enable {
|
|
||||||
users.users.${cfg.name} = {
|
|
||||||
isNormalUser = true;
|
|
||||||
extraGroups = [
|
|
||||||
"systemd-journal"
|
|
||||||
"proc" # Enable full /proc access and systemd-status
|
|
||||||
] ++ cfg.groups;
|
|
||||||
};
|
|
||||||
|
|
||||||
security = mkIf (cfg.allowRunAsUsers != []) {
|
|
||||||
# Use doas instead of sudo if enabled
|
|
||||||
doas.extraConfig = mkIf config.security.doas.enable ''
|
|
||||||
${lib.concatMapStrings (user: "permit nopass ${cfg.name} as ${user}\n") cfg.allowRunAsUsers}
|
|
||||||
'';
|
|
||||||
sudo.extraConfig = mkIf (!config.security.doas.enable) ''
|
|
||||||
${cfg.name} ALL=(${builtins.concatStringsSep "," cfg.allowRunAsUsers}) NOPASSWD: ALL
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
{ config, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
options.services.rtl = {
|
|
||||||
enable = mkEnableOption "RTL, a web interface for LND";
|
|
||||||
|
|
||||||
address = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "127.0.0.1";
|
|
||||||
description = "Address to listen for HTTP connections.";
|
|
||||||
};
|
|
||||||
|
|
||||||
port = mkOption {
|
|
||||||
type = types.port;
|
|
||||||
default = 3000;
|
|
||||||
description = "Port to listen for HTTP connections.";
|
|
||||||
};
|
|
||||||
|
|
||||||
dataDir = mkOption {
|
|
||||||
type = types.path;
|
|
||||||
default = "/var/lib/rtl";
|
|
||||||
description = "The data directory for RTL.";
|
|
||||||
};
|
|
||||||
|
|
||||||
nightTheme = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = "Enable night theme by default.";
|
|
||||||
};
|
|
||||||
|
|
||||||
extraCurrency = mkOption {
|
|
||||||
type = types.nullOr types.str;
|
|
||||||
default = null;
|
|
||||||
example = "USD";
|
|
||||||
description = ''
|
|
||||||
Additional currency for displaying amounts.
|
|
||||||
When set, Tor is disabled for the RTL service to allow currency rate fetching.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
nodes = {
|
|
||||||
lnd = {
|
|
||||||
enable = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = "Enable LND node in RTL.";
|
|
||||||
};
|
|
||||||
loop = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = "Enable Lightning Loop integration (requires loopd).";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
clightning = {
|
|
||||||
enable = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = "Enable Core Lightning node in RTL (not supported in Sovran).";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
user = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "rtl";
|
|
||||||
description = "The user as which to run RTL.";
|
|
||||||
};
|
|
||||||
|
|
||||||
group = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = cfg.user;
|
|
||||||
description = "The group as which to run RTL.";
|
|
||||||
};
|
|
||||||
|
|
||||||
tor = nbLib.tor;
|
|
||||||
};
|
|
||||||
|
|
||||||
cfg = config.services.rtl;
|
|
||||||
nbLib = config.nix-bitcoin.lib;
|
|
||||||
secretsDir = config.nix-bitcoin.secretsDir;
|
|
||||||
|
|
||||||
# Vendored RTL package
|
|
||||||
fetchNodeModules = pkgs.callPackage ../../packages/build-support/fetch-node-modules.nix {};
|
|
||||||
rtlPackage = pkgs.callPackage ../../packages/rtl { inherit fetchNodeModules; };
|
|
||||||
|
|
||||||
runePath = "${cfg.dataDir}/CLN-Rune.env";
|
|
||||||
|
|
||||||
rtlConfig = {
|
|
||||||
multiPass = "@multiPass@";
|
|
||||||
port = cfg.port;
|
|
||||||
host = cfg.address;
|
|
||||||
defaultNodeIndex = 1;
|
|
||||||
dbDirectoryPath = cfg.dataDir;
|
|
||||||
SSO = {
|
|
||||||
rtlSSO = 0;
|
|
||||||
rtlCookiePath = "";
|
|
||||||
logoutRedirectLink = "";
|
|
||||||
};
|
|
||||||
nodes = optional cfg.nodes.lnd.enable ({
|
|
||||||
index = 1;
|
|
||||||
lnNode = "lnd";
|
|
||||||
lnImplementation = "LND";
|
|
||||||
authentication = {
|
|
||||||
macaroonPath = "${cfg.dataDir}/macaroons";
|
|
||||||
swapMacaroonPath = if cfg.nodes.lnd.loop then "${cfg.dataDir}/loop-macaroons" else "";
|
|
||||||
boltzMacaroonPath = "";
|
|
||||||
};
|
|
||||||
settings = {
|
|
||||||
userPersona = "OPERATOR";
|
|
||||||
themeMode = if cfg.nightTheme then "NIGHT" else "DAY";
|
|
||||||
themeColor = "PURPLE";
|
|
||||||
channelBackupPath = "${cfg.dataDir}/backup";
|
|
||||||
logLevel = "INFO";
|
|
||||||
lnServerUrl = "https://${lnd.restAddress}:${toString lnd.restPort}";
|
|
||||||
swapServerUrl = if cfg.nodes.lnd.loop then "https://127.0.0.1:8081" else "";
|
|
||||||
boltzServerUrl = "";
|
|
||||||
fiatConversion = cfg.extraCurrency != null;
|
|
||||||
unannouncedChannels = true;
|
|
||||||
} // optionalAttrs (cfg.extraCurrency != null) {
|
|
||||||
currencyUnit = cfg.extraCurrency;
|
|
||||||
};
|
|
||||||
}) ++ optional cfg.nodes.clightning.enable {
|
|
||||||
index = 2;
|
|
||||||
lnNode = "clightning";
|
|
||||||
lnImplementation = "CLN";
|
|
||||||
authentication = {
|
|
||||||
runePath = runePath;
|
|
||||||
};
|
|
||||||
settings = {
|
|
||||||
userPersona = "OPERATOR";
|
|
||||||
themeMode = if cfg.nightTheme then "NIGHT" else "DAY";
|
|
||||||
themeColor = "PURPLE";
|
|
||||||
logLevel = "INFO";
|
|
||||||
fiatConversion = cfg.extraCurrency != null;
|
|
||||||
} // optionalAttrs (cfg.extraCurrency != null) {
|
|
||||||
currencyUnit = cfg.extraCurrency;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
configFile = builtins.toFile "config" (builtins.toJSON rtlConfig);
|
|
||||||
|
|
||||||
inherit (config.services)
|
|
||||||
bitcoind
|
|
||||||
lnd;
|
|
||||||
|
|
||||||
lndLoopEnabled = cfg.nodes.lnd.enable && cfg.nodes.lnd.loop;
|
|
||||||
in {
|
|
||||||
inherit options;
|
|
||||||
|
|
||||||
config = mkIf cfg.enable {
|
|
||||||
assertions = [
|
|
||||||
{ assertion = cfg.nodes.lnd.enable;
|
|
||||||
message = ''
|
|
||||||
RTL: At least one node must be enabled. Sovran supports LND only.
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
{ assertion = !cfg.nodes.clightning.enable;
|
|
||||||
message = ''
|
|
||||||
RTL: Core Lightning (clightning) is not supported in Sovran. Use LND instead.
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
services.lnd.enable = mkIf cfg.nodes.lnd.enable true;
|
|
||||||
|
|
||||||
systemd.tmpfiles.rules = [
|
|
||||||
"d '${cfg.dataDir}' 0770 ${cfg.user} ${cfg.group} - -"
|
|
||||||
];
|
|
||||||
|
|
||||||
services.rtl.tor.enforce = mkIf (cfg.extraCurrency != null) false;
|
|
||||||
|
|
||||||
systemd.services.rtl = rec {
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
wants = optional cfg.nodes.lnd.enable "lnd.service";
|
|
||||||
after = wants ++ [ "nix-bitcoin-secrets.target" ];
|
|
||||||
environment.RTL_CONFIG_PATH = cfg.dataDir;
|
|
||||||
environment.DB_DIRECTORY_PATH = cfg.dataDir;
|
|
||||||
serviceConfig = nbLib.defaultHardening // {
|
|
||||||
ExecStartPre = [
|
|
||||||
(nbLib.script "rtl-setup-config" ''
|
|
||||||
<${configFile} sed "s|@multiPass@|$(cat ${secretsDir}/rtl-password)|" \
|
|
||||||
> '${cfg.dataDir}/RTL-Config.json'
|
|
||||||
'')
|
|
||||||
]
|
|
||||||
++ optional cfg.nodes.lnd.enable
|
|
||||||
# The lnd admin macaroon is not readable by group `lnd`, so copy it
|
|
||||||
(nbLib.rootScript "rtl-copy-macaroon" ''
|
|
||||||
install --compare -m 640 -o ${cfg.user} -g ${cfg.group} -D ${lnd.networkDir}/admin.macaroon \
|
|
||||||
'${cfg.dataDir}/macaroons/admin.macaroon'
|
|
||||||
'');
|
|
||||||
ExecStart = "${rtlPackage}/bin/rtl";
|
|
||||||
# Show "rtl" instead of "node" in the journal
|
|
||||||
SyslogIdentifier = "rtl";
|
|
||||||
User = cfg.user;
|
|
||||||
Restart = "on-failure";
|
|
||||||
RestartSec = "10s";
|
|
||||||
ReadWritePaths = [ cfg.dataDir ];
|
|
||||||
} // nbLib.allowedIPAddresses cfg.tor.enforce
|
|
||||||
// nbLib.nodejs;
|
|
||||||
};
|
|
||||||
|
|
||||||
users.users.${cfg.user} = {
|
|
||||||
isSystemUser = true;
|
|
||||||
group = cfg.group;
|
|
||||||
extraGroups = optional lndLoopEnabled lnd.group;
|
|
||||||
};
|
|
||||||
users.groups.${cfg.group} = {};
|
|
||||||
|
|
||||||
nix-bitcoin.secrets.rtl-password.user = cfg.user;
|
|
||||||
nix-bitcoin.generateSecretsCmds.rtl = ''
|
|
||||||
makePasswordSecret rtl-password
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
{ config, pkgs, lib, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
options.nix-bitcoin = {
|
|
||||||
secretsDir = mkOption {
|
|
||||||
type = types.path;
|
|
||||||
default = "/etc/nix-bitcoin-secrets";
|
|
||||||
description = "Directory to store secrets";
|
|
||||||
};
|
|
||||||
|
|
||||||
setupSecrets = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Set permissions for existing secrets in {option}`nix-bitcoin.secretsDir`
|
|
||||||
before services are started.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
generateSecrets = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Automatically generate all required secrets before services are started.
|
|
||||||
Note: Make sure to create a backup of the generated secrets.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
generateSecretsCmds = mkOption {
|
|
||||||
type = types.attrsOf types.lines;
|
|
||||||
default = {};
|
|
||||||
description = ''
|
|
||||||
Bash expressions for generating secrets.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
# Currently, this is used only by ../deployment/nixops.nix
|
|
||||||
deployment.secretsDir = mkOption {
|
|
||||||
type = types.path;
|
|
||||||
description = ''
|
|
||||||
Directory of local secrets that are transferred to the nix-bitcoin node on deployment
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
secrets = mkOption {
|
|
||||||
default = {};
|
|
||||||
type = with types; attrsOf (submodule (
|
|
||||||
{ config, ... }: {
|
|
||||||
options = {
|
|
||||||
user = mkOption {
|
|
||||||
type = str;
|
|
||||||
default = "root";
|
|
||||||
};
|
|
||||||
group = mkOption {
|
|
||||||
type = str;
|
|
||||||
default = config.user;
|
|
||||||
};
|
|
||||||
permissions = mkOption {
|
|
||||||
type = str;
|
|
||||||
default = "440";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
secretsSetupMethod = mkOption {
|
|
||||||
type = with types; nullOr str;
|
|
||||||
default = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
generateSecretsScript = mkOption {
|
|
||||||
internal = true;
|
|
||||||
default = let
|
|
||||||
rpcauthSrc = pkgs.fetchurl {
|
|
||||||
url = "https://raw.githubusercontent.com/bitcoin/bitcoin/d6cde007db9d3e6ee93bd98a9bbfdce9bfa9b15b/share/rpcauth/rpcauth.py";
|
|
||||||
sha256 = "189mpplam6yzizssrgiyv70c9899ggh8cac76j4n7v0xqzfip07n";
|
|
||||||
};
|
|
||||||
rpcauth = pkgs.writers.writeBash "rpcauth" ''
|
|
||||||
exec ${pkgs.python3}/bin/python ${rpcauthSrc} "$@"
|
|
||||||
'';
|
|
||||||
# Writes secrets to PWD
|
|
||||||
in pkgs.writers.writeBash "generate-secrets" ''
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
export PATH=${lib.makeBinPath (with pkgs; [ coreutils gnugrep ])}
|
|
||||||
|
|
||||||
makePasswordSecret() {
|
|
||||||
# Passwords have alphabet {a-z, A-Z, 0-9} and ~119 bits of entropy
|
|
||||||
[[ -e $1 ]] || ${pkgs.pwgen}/bin/pwgen -s 20 1 > "$1"
|
|
||||||
}
|
|
||||||
makeBitcoinRPCPassword() {
|
|
||||||
user=$1
|
|
||||||
file=bitcoin-rpcpassword-$user
|
|
||||||
HMACfile=bitcoin-HMAC-$user
|
|
||||||
makePasswordSecret "$file"
|
|
||||||
if [[ $file -nt $HMACfile ]]; then
|
|
||||||
${rpcauth} $user $(cat "$file") | grep rpcauth | cut -d ':' -f 2 > "$HMACfile"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
makeCert() {
|
|
||||||
name=$1
|
|
||||||
# Add leading comma if not empty
|
|
||||||
extraAltNames=''${2:+,}''${2:-}
|
|
||||||
if [[ ! -e $name-key ]]; then
|
|
||||||
# Create new key and cert
|
|
||||||
doMakeCert "-newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -keyout $name-key"
|
|
||||||
elif [[ ! -e $name-cert \
|
|
||||||
|| $(cat "$name-cert-alt-names" 2>/dev/null) != $extraAltNames ]]; then
|
|
||||||
# Create cert from existing key
|
|
||||||
doMakeCert "-key $name-key"
|
|
||||||
fi;
|
|
||||||
}
|
|
||||||
doMakeCert() {
|
|
||||||
# This fn uses global variables `name` and `extraAltNames`
|
|
||||||
keyOpts=$1
|
|
||||||
${pkgs.openssl}/bin/openssl req -x509 \
|
|
||||||
-sha256 -days 3650 $keyOpts -out "$name-cert" \
|
|
||||||
-subj "/CN=localhost/O=$name" \
|
|
||||||
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1$extraAltNames"
|
|
||||||
echo "$extraAltNames" > "$name-cert-alt-names"
|
|
||||||
}
|
|
||||||
|
|
||||||
umask u=rw,go=
|
|
||||||
${builtins.concatStringsSep "\n" (builtins.attrValues cfg.generateSecretsCmds)}
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
cfg = config.nix-bitcoin;
|
|
||||||
in {
|
|
||||||
inherit options;
|
|
||||||
|
|
||||||
config = {
|
|
||||||
assertions = [
|
|
||||||
{ assertion = cfg.secretsSetupMethod != null;
|
|
||||||
message = ''
|
|
||||||
No secrets setup method has been defined.
|
|
||||||
To fix this, choose one of the following:
|
|
||||||
|
|
||||||
- Use one of the deployment methods in ${toString ./../deployment}
|
|
||||||
|
|
||||||
- Set `nix-bitcoin.generateSecrets = true` to automatically generate secrets
|
|
||||||
|
|
||||||
- Set `nix-bitcoin.secretsSetupMethod = "manual"` if you want to manually setup secrets
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
# This target is active when secrets have been setup successfully.
|
|
||||||
systemd.targets.nix-bitcoin-secrets = mkIf (cfg.secretsSetupMethod != "manual") {
|
|
||||||
# This ensures that the secrets target is always activated when switching
|
|
||||||
# configurations.
|
|
||||||
# In this way `switch-to-configuration` is guaranteed to show an error
|
|
||||||
# when activating the secrets target fails on deployment.
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
};
|
|
||||||
|
|
||||||
nix-bitcoin.setupSecrets = mkIf cfg.generateSecrets true;
|
|
||||||
|
|
||||||
nix-bitcoin.secretsSetupMethod = mkIf cfg.setupSecrets "setup-secrets";
|
|
||||||
|
|
||||||
# Operation of this service:
|
|
||||||
# - Set owner and permissions for all used secrets
|
|
||||||
# - Make all other secrets accessible to root only
|
|
||||||
# For all steps make sure that no secrets are copied to the nix store.
|
|
||||||
#
|
|
||||||
systemd.services.setup-secrets = mkIf cfg.setupSecrets {
|
|
||||||
requiredBy = [ "nix-bitcoin-secrets.target" ];
|
|
||||||
before = [ "nix-bitcoin-secrets.target" ];
|
|
||||||
serviceConfig = {
|
|
||||||
Type = "oneshot";
|
|
||||||
RemainAfterExit = true;
|
|
||||||
};
|
|
||||||
script = ''
|
|
||||||
# Use the same sort order for globbing and sorting as in Nix attrsets.
|
|
||||||
# Required for `comm` below.
|
|
||||||
export LC_COLLATE=C
|
|
||||||
|
|
||||||
${optionalString cfg.generateSecrets ''
|
|
||||||
mkdir -p "${cfg.secretsDir}"
|
|
||||||
cd "${cfg.secretsDir}"
|
|
||||||
chown root: .
|
|
||||||
chmod 0700 .
|
|
||||||
${cfg.generateSecretsScript}
|
|
||||||
''}
|
|
||||||
|
|
||||||
setupSecret() {
|
|
||||||
file="$1"
|
|
||||||
user="$2"
|
|
||||||
group="$3"
|
|
||||||
permissions="$4"
|
|
||||||
if [[ ! -e $file ]]; then
|
|
||||||
echo "Error: Secret file '$file' is missing"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
chown "$user:$group" "$file"
|
|
||||||
chmod "$permissions" "$file"
|
|
||||||
processedFiles+=("$file")
|
|
||||||
}
|
|
||||||
|
|
||||||
dir="${cfg.secretsDir}"
|
|
||||||
if [[ ! -e $dir ]]; then
|
|
||||||
echo "Error: Secrets dir '$dir' is missing"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
chown root: "$dir"
|
|
||||||
cd "$dir"
|
|
||||||
|
|
||||||
processedFiles=()
|
|
||||||
${
|
|
||||||
concatStrings (mapAttrsToList (n: v: ''
|
|
||||||
setupSecret ${n} ${v.user} ${v.group} ${v.permissions}
|
|
||||||
'') cfg.secrets)
|
|
||||||
}
|
|
||||||
|
|
||||||
# Make all other files accessible to root only
|
|
||||||
unprocessedFiles=$(
|
|
||||||
comm -23 <(shopt -s nullglob; printf '%s\n' *) <(printf '%s\n' "''${processedFiles[@]}")
|
|
||||||
)
|
|
||||||
if [[ $unprocessedFiles ]]; then
|
|
||||||
IFS=$'\n'
|
|
||||||
# shellcheck disable=SC2086
|
|
||||||
chown root: $unprocessedFiles
|
|
||||||
# shellcheck disable=SC2086
|
|
||||||
chmod 0440 $unprocessedFiles
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Now make the secrets dir accessible to other users
|
|
||||||
chmod 0751 "$dir"
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
{ config, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
{
|
|
||||||
options = {
|
|
||||||
nix-bitcoin.security.dbusHideProcessInformation = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Only allow users with group `proc` to retrieve systemd unit information like
|
|
||||||
cgroup paths (i.e. (sub)process command lines) via D-Bus.
|
|
||||||
|
|
||||||
This mitigates a systemd security issue where (sub)process command lines can
|
|
||||||
be retrieved by services even when their access to /proc is restricted
|
|
||||||
(via ProtectProc).
|
|
||||||
|
|
||||||
This option works by restricting the D-Bus method `GetUnitProcesses`, which
|
|
||||||
is also used internally by {command}`systemctl status`.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
config = mkIf config.nix-bitcoin.security.dbusHideProcessInformation {
|
|
||||||
users.groups.proc = {};
|
|
||||||
nix-bitcoin.operator.groups = [ "proc" ]; # Enable operator access to systemd-status
|
|
||||||
|
|
||||||
services.dbus.packages = lib.mkAfter [ # Apply at the end to override the default policy
|
|
||||||
(pkgs.writeTextDir "etc/dbus-1/system.d/dbus.conf" ''
|
|
||||||
<busconfig>
|
|
||||||
<policy context="default">
|
|
||||||
<deny
|
|
||||||
send_destination="org.freedesktop.systemd1"
|
|
||||||
send_interface="org.freedesktop.systemd1.Manager"
|
|
||||||
send_member="GetUnitProcesses"
|
|
||||||
/>
|
|
||||||
</policy>
|
|
||||||
<policy group="proc">
|
|
||||||
<allow
|
|
||||||
send_destination="org.freedesktop.systemd1"
|
|
||||||
send_interface="org.freedesktop.systemd1.Manager"
|
|
||||||
send_member="GetUnitProcesses"
|
|
||||||
/>
|
|
||||||
</policy>
|
|
||||||
</busconfig>
|
|
||||||
'')
|
|
||||||
];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{ config, lib, ... }:
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
options.nix-bitcoin.configVersion = mkOption {
|
|
||||||
type = with types; nullOr str;
|
|
||||||
default = null;
|
|
||||||
description = "Vendored stub — no version migration needed.";
|
|
||||||
};
|
|
||||||
in {
|
|
||||||
inherit options;
|
|
||||||
config = {};
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
{ config, pkgs, lib, ... }:
|
|
||||||
|
|
||||||
lib.mkIf config.sovran_systemsOS.services.bitcoin {
|
|
||||||
|
|
||||||
services.bitcoind = {
|
|
||||||
enable = true;
|
|
||||||
# Keep the normal loopback P2P socket available for local clients such as
|
|
||||||
# Bisq. Because `address` defaults to 127.0.0.1 this does not expose a
|
|
||||||
# clearnet or LAN listener. When the existing bitcoind onion service is
|
|
||||||
# enabled, this also creates its Tor-tagged loopback target on port 8334.
|
|
||||||
listen = true;
|
|
||||||
package = pkgs.bitcoind;
|
|
||||||
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node";
|
|
||||||
txindex = true;
|
|
||||||
tor.proxy = true;
|
|
||||||
tor.enforce = true;
|
|
||||||
disablewallet = true;
|
|
||||||
extraConfig = ''
|
|
||||||
peerbloomfilters=1
|
|
||||||
server=1
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
nix-bitcoin.onionServices.bitcoind = {
|
|
||||||
enable = true;
|
|
||||||
# This is a locally vendored option namespace, not an upstream dependency.
|
|
||||||
# The onion listener remains available to peers that already know it;
|
|
||||||
# advertising its address through Bitcoin peer gossip is opt-in in the Hub.
|
|
||||||
public = config.sovran_systemsOS.features.bitcoin-tor-gossip;
|
|
||||||
};
|
|
||||||
nix-bitcoin.onionServices.electrs.enable = true;
|
|
||||||
nix-bitcoin.onionServices.rtl.enable = true;
|
|
||||||
|
|
||||||
services.electrs = {
|
|
||||||
enable = true;
|
|
||||||
tor.enforce = true;
|
|
||||||
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Electrs_Data";
|
|
||||||
};
|
|
||||||
|
|
||||||
services.lnd = {
|
|
||||||
enable = true;
|
|
||||||
tor.enforce = true;
|
|
||||||
tor.proxy = true;
|
|
||||||
extraConfig = ''
|
|
||||||
protocol.option-scid-alias=true
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
nix-bitcoin.onionServices.lnd.public = true;
|
|
||||||
|
|
||||||
services.lnd.lndconnect = {
|
|
||||||
enable = true;
|
|
||||||
onion = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
services.rtl = {
|
|
||||||
enable = true;
|
|
||||||
tor.enforce = true;
|
|
||||||
port = 3050;
|
|
||||||
nightTheme = true;
|
|
||||||
nodes = {
|
|
||||||
lnd = {
|
|
||||||
enable = true;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
services.btcpayserver = {
|
|
||||||
enable = config.sovran_systemsOS.web.btcpayserver;
|
|
||||||
};
|
|
||||||
|
|
||||||
services.btcpayserver.lightningBackend = "lnd";
|
|
||||||
|
|
||||||
nix-bitcoin.generateSecrets = true;
|
|
||||||
nix-bitcoin.nodeinfo.enable = true;
|
|
||||||
|
|
||||||
nix-bitcoin.operator = {
|
|
||||||
enable = true;
|
|
||||||
name = "free";
|
|
||||||
};
|
|
||||||
|
|
||||||
# vendored: now no-op (always uses nixpkgs)
|
|
||||||
nix-bitcoin.useVersionLockedPkgs = false;
|
|
||||||
|
|
||||||
systemd.services.bitcoind = {
|
|
||||||
requires = [ "run-media-Second_Drive.mount" ];
|
|
||||||
after = [ "run-media-Second_Drive.mount" ];
|
|
||||||
serviceConfig.PrivateUsers = lib.mkForce false;
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.services.electrs = {
|
|
||||||
requires = lib.mkForce [ "run-media-Second_Drive.mount" ];
|
|
||||||
after = [ "run-media-Second_Drive.mount" "bitcoind.service" ];
|
|
||||||
wants = [ "bitcoind.service" ];
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.services.lnd = {
|
|
||||||
wants = [ "bitcoind.service" ];
|
|
||||||
# requires for bitcoind set by nix-bitcoin; mkForce removes it
|
|
||||||
requires = lib.mkForce [ ];
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.services.sovran-btc-permissions = {
|
|
||||||
description = "Fix Bitcoin/Electrs data directory ownership on second drive";
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
after = [ "run-media-Second_Drive.mount" ];
|
|
||||||
before = [ "bitcoind.service" "electrs.service" ];
|
|
||||||
serviceConfig = {
|
|
||||||
Type = "oneshot";
|
|
||||||
RemainAfterExit = true;
|
|
||||||
};
|
|
||||||
script = ''
|
|
||||||
if [ -d /run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node ]; then
|
|
||||||
chown -R bitcoin:bitcoin /run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node
|
|
||||||
fi
|
|
||||||
if [ -d /run/media/Second_Drive/BTCEcoandBackup/Electrs_Data ]; then
|
|
||||||
chown -R electrs:electrs /run/media/Second_Drive/BTCEcoandBackup/Electrs_Data
|
|
||||||
fi
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
networking.firewall.allowedTCPPorts = [ 3051 ];
|
|
||||||
networking.firewall.allowedUDPPorts = [ 3051 ];
|
|
||||||
|
|
||||||
sovran_systemsOS.domainRequirements = [
|
|
||||||
{ name = "btcpayserver"; label = "BTCPay Server"; example = "pay.yourdomain.com"; }
|
|
||||||
];
|
|
||||||
}
|
|
||||||
+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'
|
||||||
|
|||||||
+34
-77
@@ -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" ];
|
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,73 +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 once
|
|
||||||
public_ip = ""
|
|
||||||
try:
|
|
||||||
r = subprocess.run(
|
|
||||||
["dig", "@resolver4.opendns.com", "myip.opendns.com", "+short", "-4"],
|
|
||||||
capture_output=True, text=True, timeout=10,
|
|
||||||
)
|
|
||||||
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:
|
|
||||||
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
|
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
{
|
{
|
||||||
config = lib.mkMerge [
|
config = lib.mkMerge [
|
||||||
|
|
||||||
# Vendored nix-bitcoin is always imported via modules/vendor/nix-bitcoin/modules.nix.
|
# The Sovran_Bitcoin flake module is always imported (see flake.nix).
|
||||||
# This default satisfies the secrets assertion so Desktop-Only systems evaluate
|
# This default satisfies the secrets assertion so Desktop-Only systems evaluate
|
||||||
# without enabling any Bitcoin services.
|
# without enabling any Bitcoin services.
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -79,6 +140,43 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# ── Element Calling (video/audio) tuning ──────────────────
|
||||||
|
elementCalling = {
|
||||||
|
fullAccessHomeservers = lib.mkOption {
|
||||||
|
type = lib.types.listOf lib.types.str;
|
||||||
|
default = [ ];
|
||||||
|
example = [ "matrix.peer.example.com" ];
|
||||||
|
description = ''
|
||||||
|
Additional Matrix server_names (beyond this server itself) that may
|
||||||
|
trigger LiveKit room creation on this server's SFU via lk-jwt-service.
|
||||||
|
|
||||||
|
Not needed for the common federated setup: each participant's client
|
||||||
|
always obtains its token from its own homeserver's JWT service and
|
||||||
|
publishes to its own SFU, and the participant who starts a call
|
||||||
|
creates the room on their own SFU — the remote user merely joins
|
||||||
|
(joining does not require full access).
|
||||||
|
|
||||||
|
Only set this for asymmetric cases: e.g. a peer homeserver that has
|
||||||
|
no focus of its own, or calls whose first participant lands on this
|
||||||
|
server's SFU but belongs to the peer.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
externalIP = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.str;
|
||||||
|
default = null;
|
||||||
|
example = "203.0.113.10";
|
||||||
|
description = ''
|
||||||
|
Optional pin: force LiveKit to advertise this public IPv4 in its
|
||||||
|
host/TURN ICE candidates. Not required in normal operation — the
|
||||||
|
address is the one Njal.la reports for the DDNS update (set up in
|
||||||
|
the Hub's Domains page), and nothing on this system looks it up
|
||||||
|
anywhere else. Set it for a fixed public address with no Njal.la
|
||||||
|
DDNS entry, or to override the reported one (e.g. multi-WAN/VPN
|
||||||
|
setups).
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
# ── Domain setup registry ─────────────────────────────────
|
# ── Domain setup registry ─────────────────────────────────
|
||||||
domainRequirements = lib.mkOption {
|
domainRequirements = lib.mkOption {
|
||||||
type = lib.types.listOf (lib.types.submodule {
|
type = lib.types.listOf (lib.types.submodule {
|
||||||
|
|||||||
+134
-41
@@ -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;
|
||||||
};
|
};
|
||||||
@@ -135,16 +147,13 @@ let
|
|||||||
"bitcoind.service" = if pkgs ? bitcoind then pkgs.bitcoind.version else "27.1.0";
|
"bitcoind.service" = if pkgs ? bitcoind then pkgs.bitcoind.version else "27.1.0";
|
||||||
"electrs.service" = if pkgs ? electrs then pkgs.electrs.version else "0.10.6";
|
"electrs.service" = if pkgs ? electrs then pkgs.electrs.version else "0.10.6";
|
||||||
"lnd.service" = if pkgs ? lnd then pkgs.lnd.version else "0.18.0";
|
"lnd.service" = if pkgs ? lnd then pkgs.lnd.version else "0.18.0";
|
||||||
# Keep the fallbacks aligned with the vendored packages used by the
|
# Vendored packages come from the Sovran_Bitcoin flake overlay
|
||||||
# service modules (RTL 0.15.8 and Mempool 3.2.1). The nixpkgs attrs are
|
# (pkgs.sovran-bitcoin.*). Evaluate them directly so the Hub shows
|
||||||
# optional because these packages are built locally in this repository.
|
# the exact versions this system ships.
|
||||||
"rtl.service" = if pkgs ? clightning-rtl then pkgs.clightning-rtl.version else (if pkgs ? rtl then pkgs.rtl.version else "0.15.8");
|
"rtl.service" = pkgs.sovran-bitcoin.rtl.version;
|
||||||
# BTCPay Server is intentionally sourced from pkgs.stable by the service
|
|
||||||
# module. Read the configured package here rather than pkgs.btcpayserver
|
|
||||||
# (unstable), otherwise the Hub can advertise a version that is not running.
|
|
||||||
"btcpayserver.service" = lib.getVersion config.services.btcpayserver.package;
|
"btcpayserver.service" = lib.getVersion config.services.btcpayserver.package;
|
||||||
"albyhub.service" = if pkgs ? albyhub then pkgs.albyhub.version else "1.8.0";
|
"albyhub.service" = pkgs.sovran-bitcoin.albyhub.version;
|
||||||
"mempool.service" = if pkgs ? mempool then pkgs.mempool.version else "3.2.1";
|
"mempool.service" = pkgs.sovran-bitcoin.mempool-backend.version;
|
||||||
"matrix-synapse.service" = if pkgs ? matrix-synapse then pkgs.matrix-synapse.version else "1.115.0";
|
"matrix-synapse.service" = if pkgs ? matrix-synapse then pkgs.matrix-synapse.version else "1.115.0";
|
||||||
"livekit.service" = if pkgs ? livekit then pkgs.livekit.version else "1.5.2";
|
"livekit.service" = if pkgs ? livekit then pkgs.livekit.version else "1.5.2";
|
||||||
"vaultwarden.service" = if pkgs ? vaultwarden then pkgs.vaultwarden.version else "1.32.0";
|
"vaultwarden.service" = if pkgs ? vaultwarden then pkgs.vaultwarden.version else "1.32.0";
|
||||||
@@ -153,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
|
||||||
@@ -160,8 +224,10 @@ let
|
|||||||
|
|
||||||
LOG="/var/log/sovran-hub-update.log"
|
LOG="/var/log/sovran-hub-update.log"
|
||||||
STATUS="/var/log/sovran-hub-update.status"
|
STATUS="/var/log/sovran-hub-update.status"
|
||||||
|
GENERATION="/var/log/sovran-hub-update.generation"
|
||||||
|
|
||||||
echo "RUNNING" > "$STATUS"
|
echo "RUNNING" > "$STATUS"
|
||||||
|
rm -f "$GENERATION"
|
||||||
: > "$LOG"
|
: > "$LOG"
|
||||||
exec > >(tee -a "$LOG") 2>&1
|
exec > >(tee -a "$LOG") 2>&1
|
||||||
|
|
||||||
@@ -172,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
|
||||||
@@ -187,13 +255,19 @@ 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
|
# The marker is informational only. The Hub derives pending-reboot
|
||||||
|
# state from the NixOS system profile itself, so failing to record
|
||||||
|
# the marker must not fail an otherwise successful update.
|
||||||
|
echo "[WARNING] update succeeded but its staged generation could not be recorded"
|
||||||
|
rm -f "$GENERATION"
|
||||||
|
fi
|
||||||
|
else
|
||||||
echo "[ERROR] nixos-rebuild boot failed"
|
echo "[ERROR] nixos-rebuild boot failed"
|
||||||
RC=1
|
RC=1
|
||||||
fi
|
fi
|
||||||
@@ -240,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 \
|
||||||
@@ -261,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"
|
||||||
@@ -273,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"
|
||||||
@@ -400,12 +478,21 @@ DESKTOP
|
|||||||
import os, sys
|
import os, sys
|
||||||
base = os.path.join("$out", "lib", "sovran-hub-web")
|
base = os.path.join("$out", "lib", "sovran-hub-web")
|
||||||
sys.path.insert(0, base)
|
sys.path.insert(0, base)
|
||||||
|
# Canonical NWC implementation — single source of truth from the
|
||||||
|
# Sovran_Bitcoin flake (imported directly by server.py).
|
||||||
|
sys.path.insert(0, os.path.join("${pkgs.sovran-bitcoin.nwc}", "lib", "sovran-nwc"))
|
||||||
os.environ["SOVRAN_HUB_CONFIG"] = os.path.join(base, "config.json")
|
os.environ["SOVRAN_HUB_CONFIG"] = os.path.join(base, "config.json")
|
||||||
os.environ["SOVRAN_HUB_VERSIONS"] = os.path.join(base, "versions.json")
|
os.environ["SOVRAN_HUB_VERSIONS"] = os.path.join(base, "versions.json")
|
||||||
os.environ["SOVRAN_HUB_ICONS"] = os.path.join("$out", "share", "sovran-hub", "icons")
|
os.environ["SOVRAN_HUB_ICONS"] = os.path.join("$out", "share", "sovran-hub", "icons")
|
||||||
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",
|
||||||
@@ -413,26 +500,9 @@ uvicorn.run(
|
|||||||
LAUNCHER
|
LAUNCHER
|
||||||
chmod +x $out/bin/sovran-hub-web
|
chmod +x $out/bin/sovran-hub-web
|
||||||
|
|
||||||
cat > $out/bin/nwc-wallet <<LAUNCHER
|
# nwc-wallet / nwc-lnurl binaries are no longer shipped here: the
|
||||||
#!${pkgs.python3}/bin/python3
|
# Sovran_Bitcoin flake provides them (env-wrapped nwc-wallet via
|
||||||
import os, sys
|
# albyhub.nix, nwc-lnurl.service via lnurl.nix) — single source of truth.
|
||||||
base = os.path.join("$out", "lib", "sovran-hub-web")
|
|
||||||
sys.path.insert(0, base)
|
|
||||||
from sovran_systemsos_web.nwc_wallet_cli import main
|
|
||||||
sys.exit(main())
|
|
||||||
LAUNCHER
|
|
||||||
chmod +x $out/bin/nwc-wallet
|
|
||||||
|
|
||||||
cat > $out/bin/nwc-lnurl <<LAUNCHER
|
|
||||||
#!${pkgs.python3}/bin/python3
|
|
||||||
import os, sys
|
|
||||||
base = os.path.join("$out", "lib", "sovran-hub-web")
|
|
||||||
sys.path.insert(0, base)
|
|
||||||
from sovran_systemsos_web.nwc_lnurl_service import main
|
|
||||||
main()
|
|
||||||
LAUNCHER
|
|
||||||
chmod +x $out/bin/nwc-lnurl
|
|
||||||
|
|
||||||
runHook postInstall
|
runHook postInstall
|
||||||
'';
|
'';
|
||||||
|
|
||||||
@@ -451,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" ];
|
||||||
@@ -519,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"
|
||||||
|
|||||||
+303
-28
@@ -33,9 +33,15 @@ lib.mkIf config.sovran_systemsOS.features.element-calling {
|
|||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
####### ENSURE SERVICES START AFTER KEY EXISTS #######
|
####### ENSURE SERVICES START AFTER KEY & NETWORK EXIST #######
|
||||||
systemd.services.livekit.after = [ "livekit-key-setup.service" "livekit-turn-setup.service" ];
|
# Ordering against network-online.target matters: livekit-turn-setup detects
|
||||||
systemd.services.livekit.wants = [ "livekit-key-setup.service" "livekit-turn-setup.service" ];
|
# the primary interface from the IPv4 default route. If it runs before the
|
||||||
|
# network is up (no default route yet) it exits 1 and, being a hard
|
||||||
|
# dependency of livekit.service, takes livekit down with it — the Hub then
|
||||||
|
# shows a "failed" red dot until livekit is restarted manually. See the
|
||||||
|
# livekit-turn-setup block for the matching network-online ordering.
|
||||||
|
systemd.services.livekit.after = [ "network-online.target" "livekit-key-setup.service" "livekit-turn-setup.service" ];
|
||||||
|
systemd.services.livekit.wants = [ "network-online.target" "livekit-key-setup.service" "livekit-turn-setup.service" ];
|
||||||
systemd.services.lk-jwt-service.after = [ "livekit-key-setup.service" ];
|
systemd.services.lk-jwt-service.after = [ "livekit-key-setup.service" ];
|
||||||
systemd.services.lk-jwt-service.wants = [ "livekit-key-setup.service" ];
|
systemd.services.lk-jwt-service.wants = [ "livekit-key-setup.service" ];
|
||||||
|
|
||||||
@@ -114,7 +120,12 @@ EOF
|
|||||||
# substituted) that the overridden ExecStart loads.
|
# substituted) that the overridden ExecStart loads.
|
||||||
systemd.services.livekit-turn-setup = {
|
systemd.services.livekit-turn-setup = {
|
||||||
description = "Stage TURN cert and generate LiveKit runtime config from domain files";
|
description = "Stage TURN cert and generate LiveKit runtime config from domain files";
|
||||||
after = [ "caddy.service" "livekit-key-setup.service" ];
|
# Wait for a default IPv4 route before detecting the interface, and for
|
||||||
|
# Caddy to have started (cert generation is async, so also see the retry
|
||||||
|
# loop below). Otherwise on a cold boot this unit can fail / produce empty
|
||||||
|
# certs, which breaks livekit.service (requiredBy) and shows a red dot.
|
||||||
|
after = [ "network-online.target" "caddy.service" "livekit-key-setup.service" ];
|
||||||
|
wants = [ "network-online.target" ];
|
||||||
before = [ "livekit.service" ];
|
before = [ "livekit.service" ];
|
||||||
requiredBy = [ "livekit.service" ];
|
requiredBy = [ "livekit.service" ];
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
@@ -125,19 +136,39 @@ EOF
|
|||||||
unitConfig = {
|
unitConfig = {
|
||||||
ConditionPathExists = "/var/lib/domains/element-calling";
|
ConditionPathExists = "/var/lib/domains/element-calling";
|
||||||
};
|
};
|
||||||
path = [ pkgs.coreutils pkgs.findutils pkgs.iproute2 pkgs.gawk ];
|
path = [ pkgs.coreutils pkgs.findutils pkgs.iproute2 pkgs.gawk pkgs.python3 ];
|
||||||
script = ''
|
script = ''
|
||||||
MATRIX=$(cat /var/lib/domains/matrix)
|
MATRIX=$(cat /var/lib/domains/matrix)
|
||||||
|
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
|
||||||
|
|
||||||
mkdir -p /run/livekit
|
mkdir -p /run/livekit
|
||||||
|
|
||||||
# Copy Caddy's already-issued matrix cert/key into LiveKit's state dir.
|
# Copy Caddy's already-issued matrix cert/key into LiveKit's state dir.
|
||||||
# The ACME CA hostname directory can vary, so glob for the domain dir.
|
# The ACME CA hostname directory can vary, so glob for the domain dir.
|
||||||
|
# Caddy issues ACME certs asynchronously, so on a fresh boot the cert may
|
||||||
|
# not exist yet. Retry (bounded) so we never write an empty turn.crt/key;
|
||||||
|
# otherwise embedded TURN silently breaks until the next livekit restart.
|
||||||
|
CRT=""
|
||||||
|
KEY=""
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
CRT=$(find /var/lib/caddy -path "*/$MATRIX/$MATRIX.crt" | head -n1)
|
CRT=$(find /var/lib/caddy -path "*/$MATRIX/$MATRIX.crt" | head -n1)
|
||||||
KEY=$(find /var/lib/caddy -path "*/$MATRIX/$MATRIX.key" | head -n1)
|
KEY=$(find /var/lib/caddy -path "*/$MATRIX/$MATRIX.key" | head -n1)
|
||||||
|
if [ -n "$CRT" ] && [ -n "$KEY" ] \
|
||||||
|
&& [ -s "$CRT" ] && [ -s "$KEY" ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
CRT=""
|
||||||
|
KEY=""
|
||||||
|
echo "Waiting for Caddy to issue the $MATRIX ACME certificate..."
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
if [ -z "$CRT" ] || [ -z "$KEY" ]; then
|
||||||
|
echo "ERROR: Caddy ACME certificate for $MATRIX not available after retries; TURN will not be enabled for LiveKit." >&2
|
||||||
|
else
|
||||||
cp "$CRT" /var/lib/livekit/turn.crt
|
cp "$CRT" /var/lib/livekit/turn.crt
|
||||||
cp "$KEY" /var/lib/livekit/turn.key
|
cp "$KEY" /var/lib/livekit/turn.key
|
||||||
chmod 640 /var/lib/livekit/turn.crt /var/lib/livekit/turn.key
|
chmod 640 /var/lib/livekit/turn.crt /var/lib/livekit/turn.key
|
||||||
|
fi
|
||||||
|
|
||||||
# Detect the primary network interface from the IPv4 default route.
|
# Detect the primary network interface from the IPv4 default route.
|
||||||
# Restricting LiveKit to this single interface prevents it from
|
# Restricting LiveKit to this single interface prevents it from
|
||||||
@@ -154,56 +185,131 @@ EOF
|
|||||||
fi
|
fi
|
||||||
echo "Detected primary network interface: $IFACE"
|
echo "Detected primary network interface: $IFACE"
|
||||||
|
|
||||||
|
# Derive the LAN subnet this box sits on so the embedded TURN relay
|
||||||
|
# is allowed to hand media to LiveKit's LAN host candidate (see the
|
||||||
|
# allow_restricted_peer_cidrs block below). Computed from the primary
|
||||||
|
# interface's own address, so it always matches the subnet the LAN
|
||||||
|
# clients (phones on Wi-Fi) actually live on.
|
||||||
|
LAN_CIDR=$(ip -4 -o addr show dev "$IFACE" | awk '{print $4}' | grep -vE '^(127\.|169\.254\.)' | head -n1 | python3 -c 'import sys, ipaddress; s = sys.stdin.read().strip(); print(str(ipaddress.ip_network(s, strict=False)) if s else "")' 2>/dev/null)
|
||||||
|
if [ -n "$LAN_CIDR" ]; then echo "Derived LAN CIDR for TURN relay: $LAN_CIDR"; else echo "Derived LAN CIDR for TURN relay: <none>"; fi
|
||||||
|
|
||||||
# Generate the full LiveKit config the daemon will load. turn.domain and
|
# Generate the full LiveKit config the daemon will load. turn.domain and
|
||||||
# rtc.interfaces.includes are only known at runtime, so they are
|
# rtc.interfaces.includes are only known at runtime, so they are
|
||||||
# substituted here. The cert/key paths point at the LoadCredential-staged
|
# substituted here. The cert/key paths point at the LoadCredential-staged
|
||||||
# copies under /run/credentials.
|
# copies under /run/credentials.
|
||||||
|
#
|
||||||
|
# Determine the public IPv4 to advertise in LiveKit ICE candidates.
|
||||||
|
# Remote peers must be able to reach this address, so it must be the
|
||||||
|
# server's public IP — or the router's WAN IP when the server is behind
|
||||||
|
# NAT with port-forwarding. It does not need to be assigned to this box,
|
||||||
|
# and it may be dynamic.
|
||||||
|
#
|
||||||
|
# Nothing here looks the address up. Priority:
|
||||||
|
# 1. sovran_systemsOS.elementCalling.externalIP (explicit pin, if set)
|
||||||
|
# 2. /var/lib/secrets/external-ip — the address Njal.la reported for the
|
||||||
|
# last DDNS update (modules/core/njalla.nix). The runner rewrites that
|
||||||
|
# file only when the address changes, and livekit-external-ip.path
|
||||||
|
# then re-runs this script.
|
||||||
|
# With neither, or with an address that is not public, this unit fails with
|
||||||
|
# a clear message instead of guessing: advertising a wrong or private
|
||||||
|
# address is what produces "call connects but no video".
|
||||||
|
EXTERNAL_IP='${if config.sovran_systemsOS.elementCalling.externalIP != null then config.sovran_systemsOS.elementCalling.externalIP else ""}'
|
||||||
|
|
||||||
|
PUBLIC_IP="$EXTERNAL_IP"
|
||||||
|
if [ -z "$PUBLIC_IP" ] && [ -f /var/lib/secrets/external-ip ]; then
|
||||||
|
PUBLIC_IP=$(tr -d '[:space:]' < /var/lib/secrets/external-ip 2>/dev/null)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$PUBLIC_IP" ]; then
|
||||||
|
echo "ERROR: no public IP is known for LiveKit yet." >&2
|
||||||
|
echo "ERROR: It is recorded after the first successful Njal.la DDNS update (Hub, Domains)." >&2
|
||||||
|
echo "ERROR: To use a fixed address instead, set sovran_systemsOS.elementCalling.externalIP." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Reject non-routable addresses (loopback, private, link-local, CGNAT).
|
||||||
|
if printf '%s' "$PUBLIC_IP" | grep -qE \
|
||||||
|
'^(0\.|127\.|10\.|100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.|169\.254\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)'; then
|
||||||
|
echo "ERROR: $PUBLIC_IP is not a public address, so remote peers cannot reach LiveKit there." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
cat > /run/livekit/livekit.yaml <<EOF
|
cat > /run/livekit/livekit.yaml <<EOF
|
||||||
port: 7880
|
port: 7880
|
||||||
rtc:
|
rtc:
|
||||||
use_external_ip: true
|
use_external_ip: false
|
||||||
skip_external_ip_validation: true
|
node_ip: $PUBLIC_IP
|
||||||
|
advertise_internal_ip: true
|
||||||
tcp_port: 7881
|
tcp_port: 7881
|
||||||
udp_port: 7882
|
udp_port: 7882
|
||||||
port_range_start: 30000
|
|
||||||
port_range_end: 40000
|
|
||||||
interfaces:
|
interfaces:
|
||||||
includes:
|
includes:
|
||||||
- $IFACE
|
- $IFACE
|
||||||
|
EOF
|
||||||
|
echo "LiveKit will advertise public IP: $PUBLIC_IP"
|
||||||
|
|
||||||
|
# Webhooks → lk-jwt-service. The JWT service validates the HMAC
|
||||||
|
# signature against the same key file it issues tokens with, and uses
|
||||||
|
# the events (participant_left / room_finished) to detect abruptly
|
||||||
|
# disconnected participants instead of waiting for the delayed-event
|
||||||
|
# timeout. The URL hits local Caddy via the /etc/hosts loopback
|
||||||
|
# override and is routed to the JWT service by the element-calling
|
||||||
|
# vhost (/livekit/jwt/sfu_webhook → 8073).
|
||||||
|
LK_KEY=$(cut -d: -f1 < ${livekitKeyFile} | tr -d '[:space:]')
|
||||||
|
|
||||||
|
# TURN/TLS is intentionally not configured (no tls_port): LiveKit
|
||||||
|
# advertises turns:<domain>:443 to clients regardless of tls_port, so
|
||||||
|
# a 5349 TURN/TLS listener would be unreachable and only adds attack
|
||||||
|
# surface. The staged cert/key stay for a future TURN/TLS-on-443
|
||||||
|
# (Caddy layer4 SNI) setup.
|
||||||
|
cat >> /run/livekit/livekit.yaml <<EOF
|
||||||
room:
|
room:
|
||||||
auto_create: false
|
auto_create: false
|
||||||
turn:
|
turn:
|
||||||
enabled: true
|
enabled: true
|
||||||
domain: $MATRIX
|
domain: $MATRIX
|
||||||
tls_port: 5349
|
|
||||||
udp_port: 3478
|
udp_port: 3478
|
||||||
|
relay_range_start: 40000
|
||||||
|
relay_range_end: 40099
|
||||||
cert_file: /run/credentials/livekit.service/turn-cert
|
cert_file: /run/credentials/livekit.service/turn-cert
|
||||||
key_file: /run/credentials/livekit.service/turn-key
|
key_file: /run/credentials/livekit.service/turn-key
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
# By default the embedded TURN relay refuses to send media to
|
||||||
|
# private/loopback peers. That would force its final hop to the
|
||||||
|
# public/WAN IP (hairpin NAT) — exactly what breaks calls on routers
|
||||||
|
# without NAT loopback. Allow the LAN subnet so the relay can deliver
|
||||||
|
# directly to LiveKit's LAN host candidate instead.
|
||||||
|
if [ -n "$LAN_CIDR" ]; then
|
||||||
|
cat >> /run/livekit/livekit.yaml <<EOF
|
||||||
|
allow_restricted_peer_cidrs:
|
||||||
|
- $LAN_CIDR
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat >> /run/livekit/livekit.yaml <<EOF
|
||||||
|
webhook:
|
||||||
|
api_key: $LK_KEY
|
||||||
|
urls:
|
||||||
|
- https://$ELEMENT_CALLING/livekit/jwt/sfu_webhook
|
||||||
|
EOF
|
||||||
|
|
||||||
chmod 644 /run/livekit/livekit.yaml
|
chmod 644 /run/livekit/livekit.yaml
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
####### LIVEKIT SERVICE #######
|
####### LIVEKIT SERVICE #######
|
||||||
|
# NOTE: the runtime config (rtc ports, TURN, webhook, node_ip) is generated
|
||||||
|
# by livekit-turn-setup and delivered via LoadCredential; the upstream
|
||||||
|
# module's `settings` block is therefore intentionally NOT used (it would
|
||||||
|
# be dead config that silently diverges from what LiveKit actually loads).
|
||||||
|
# The firewall ports are opened explicitly below; openFirewall is left off
|
||||||
|
# so the upstream module does not also open 7880/tcp publicly (Caddy fronts
|
||||||
|
# the SFU on this host).
|
||||||
services.livekit = {
|
services.livekit = {
|
||||||
enable = true;
|
enable = true;
|
||||||
openFirewall = true;
|
openFirewall = false;
|
||||||
keyFile = livekitKeyFile;
|
keyFile = livekitKeyFile;
|
||||||
settings = {
|
|
||||||
rtc.use_external_ip = true;
|
|
||||||
rtc.skip_external_ip_validation = true;
|
|
||||||
rtc.tcp_port = 7881;
|
|
||||||
rtc.udp_port = 7882;
|
|
||||||
rtc.port_range_start = 30000;
|
|
||||||
rtc.port_range_end = 40000;
|
|
||||||
room.auto_create = false;
|
|
||||||
turn = {
|
|
||||||
enabled = true;
|
|
||||||
tls_port = 5349;
|
|
||||||
udp_port = 3478;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
# Override ExecStart to load the runtime-generated config (which carries the
|
# Override ExecStart to load the runtime-generated config (which carries the
|
||||||
@@ -223,10 +329,14 @@ EOF
|
|||||||
"turn-key:/var/lib/livekit/turn.key"
|
"turn-key:/var/lib/livekit/turn.key"
|
||||||
];
|
];
|
||||||
|
|
||||||
networking.firewall.allowedTCPPorts = [ 5349 7881 ];
|
# 5349/TCP (TURN/TLS) is deliberately absent — see livekit-turn-setup. RTC
|
||||||
|
# media uses the single UDP mux (7882); the 30000-40000 range is gone so
|
||||||
|
# media is no longer spread across 10000 ports. The TURN relay allocation
|
||||||
|
# range (40000-40099) is kept separate from the media mux.
|
||||||
|
networking.firewall.allowedTCPPorts = [ 7881 ];
|
||||||
networking.firewall.allowedUDPPorts = [ 3478 7882 ];
|
networking.firewall.allowedUDPPorts = [ 3478 7882 ];
|
||||||
networking.firewall.allowedUDPPortRanges = [
|
networking.firewall.allowedUDPPortRanges = [
|
||||||
{ from = 30000; to = 40000; } # LiveKit internal TURN relay range
|
{ from = 40000; to = 40099; } # LiveKit embedded TURN relay allocation range
|
||||||
];
|
];
|
||||||
|
|
||||||
####### JWT SERVICE RUNTIME CONFIG #######
|
####### JWT SERVICE RUNTIME CONFIG #######
|
||||||
@@ -247,12 +357,26 @@ EOF
|
|||||||
script = ''
|
script = ''
|
||||||
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
|
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
|
||||||
MATRIX=$(cat /var/lib/domains/matrix)
|
MATRIX=$(cat /var/lib/domains/matrix)
|
||||||
|
FULL_ACCESS_HOMESERVERS="$MATRIX"
|
||||||
|
|
||||||
|
# Federated peers may also be granted LiveKit room-creation (full access)
|
||||||
|
# on this SFU via sovran_systemsOS.elementCalling.fullAccessHomeservers.
|
||||||
|
# Without this, remote users can join existing calls but cannot be the
|
||||||
|
# first to start one on your SFU.
|
||||||
|
EXTRA_HS='${lib.concatStringsSep "," config.sovran_systemsOS.elementCalling.fullAccessHomeservers}'
|
||||||
|
if [ -n "$EXTRA_HS" ]; then
|
||||||
|
FULL_ACCESS_HOMESERVERS="$FULL_ACCESS_HOMESERVERS,$EXTRA_HS"
|
||||||
|
fi
|
||||||
|
|
||||||
mkdir -p /run/lk-jwt-service
|
mkdir -p /run/lk-jwt-service
|
||||||
|
|
||||||
cat > /run/lk-jwt-service/env <<EOF
|
cat > /run/lk-jwt-service/env <<EOF
|
||||||
LIVEKIT_URL=wss://$ELEMENT_CALLING
|
LIVEKIT_URL=wss://$ELEMENT_CALLING
|
||||||
LIVEKIT_FULL_ACCESS_HOMESERVERS=$MATRIX
|
LIVEKIT_FULL_ACCESS_HOMESERVERS=$FULL_ACCESS_HOMESERVERS
|
||||||
|
# Re-check, every 60s, that connected participants are still on the SFU;
|
||||||
|
# guards against missed SFU webhooks (e.g. an SFU restart) leaving stale
|
||||||
|
# call members in Matrix rooms.
|
||||||
|
LIVEKIT_SANITY_CHECK_INTERVAL_SECONDS=60
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
chmod 640 /run/lk-jwt-service/env
|
chmod 640 /run/lk-jwt-service/env
|
||||||
@@ -264,6 +388,9 @@ EOF
|
|||||||
enable = true;
|
enable = true;
|
||||||
port = 8073;
|
port = 8073;
|
||||||
keyFile = livekitKeyFile;
|
keyFile = livekitKeyFile;
|
||||||
|
# Required by the upstream module's option type, but overridden at runtime
|
||||||
|
# by EnvironmentFile (/run/lk-jwt-service/env, generated above from the
|
||||||
|
# element-calling domain). Kept as a harmless placeholder.
|
||||||
livekitUrl = "wss://placeholder.local";
|
livekitUrl = "wss://placeholder.local";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -271,6 +398,143 @@ EOF
|
|||||||
"/run/lk-jwt-service/env"
|
"/run/lk-jwt-service/env"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
# Restart LiveKit / lk-jwt-service when a rebuild regenerates their runtime
|
||||||
|
# configs (new domains, externalIP, full-access list), mirroring the domain
|
||||||
|
# change flow.
|
||||||
|
systemd.services.livekit.restartTriggers = [ "/run/livekit/livekit.yaml" ];
|
||||||
|
systemd.services.lk-jwt-service.restartTriggers = [ "/run/lk-jwt-service/env" ];
|
||||||
|
|
||||||
|
# Follow a changing public IP. ddns-update.py rewrites
|
||||||
|
# /var/lib/secrets/external-ip only when Njal.la reports a different address;
|
||||||
|
# this path unit then re-runs livekit-turn-setup (new node_ip and TURN
|
||||||
|
# address) and starts LiveKit if it is not running, e.g. because no address
|
||||||
|
# was known yet at boot. restartTriggers cannot do this: it is evaluated when
|
||||||
|
# the system is built, so it cannot watch a file that changes at runtime.
|
||||||
|
systemd.paths.livekit-external-ip = {
|
||||||
|
description = "Watch the public IP recorded by the Njal.la DDNS runner";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
pathConfig.PathChanged = "/var/lib/secrets/external-ip";
|
||||||
|
};
|
||||||
|
systemd.services.livekit-external-ip = {
|
||||||
|
description = "Re-run LiveKit setup for a changed public IP";
|
||||||
|
serviceConfig.Type = "oneshot";
|
||||||
|
unitConfig.ConditionPathExists = "/var/lib/domains/element-calling";
|
||||||
|
script = ''
|
||||||
|
# livekit.service requires livekit-turn-setup, so it restarts with it.
|
||||||
|
systemctl restart livekit-turn-setup.service
|
||||||
|
# No-op if LiveKit is already running; starts it after an earlier failure.
|
||||||
|
systemctl start livekit.service
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
####### PUBLIC REACHABILITY SELF-CHECK #######
|
||||||
|
# Diagnostic only — never a hard dependency of livekit/caddy. Catches the
|
||||||
|
# classic "call connects but no media" setup errors at boot instead of at
|
||||||
|
# call time:
|
||||||
|
# * the element-calling domain having no public records (or resolving to
|
||||||
|
# loopback/link-local/CGNAT for remote peers),
|
||||||
|
# * the lk-jwt-service being unreachable through Caddy,
|
||||||
|
# * the MatrixRTC transports endpoint being absent (Element X cannot
|
||||||
|
# discover calling and shows MISSING_MATRIX_RTC_TRANSPORT).
|
||||||
|
# The check queries only the operator's own DNS provider (the domain's
|
||||||
|
# authoritative nameservers, resolved via the local resolver) plus the
|
||||||
|
# server's own Caddy and public IP — no third-party resolver or service is
|
||||||
|
# contacted. dig queries resolvers directly, so the /etc/hosts loopback
|
||||||
|
# overrides (modules/core/local-domain-loopback.nix) do not influence the
|
||||||
|
# result.
|
||||||
|
systemd.services.element-calling-public-check = {
|
||||||
|
description = "Verify Element Calling domain, JWT service and MatrixRTC transports endpoint are publicly reachable";
|
||||||
|
after = [ "network-online.target" "caddy.service" "livekit.service" "lk-jwt-service.service" ];
|
||||||
|
wants = [ "network-online.target" ];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
RemainAfterExit = true;
|
||||||
|
};
|
||||||
|
unitConfig = {
|
||||||
|
ConditionPathExists = "/var/lib/domains/element-calling";
|
||||||
|
};
|
||||||
|
path = [ pkgs.coreutils pkgs.gawk pkgs.dnsutils pkgs.curl ];
|
||||||
|
script = ''
|
||||||
|
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
|
||||||
|
MATRIX=$(cat /var/lib/domains/matrix)
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
echo "── Element Calling public reachability self-check ──"
|
||||||
|
|
||||||
|
# 1) Authoritative DNS view (bypassing the /etc/hosts loopback
|
||||||
|
# overrides). Resolve the domain's own nameservers via the local
|
||||||
|
# resolver, then query those nameservers directly — the only party
|
||||||
|
# that sees the query is the DNS provider the operator already uses
|
||||||
|
# for the domain.
|
||||||
|
NS_LIST=$(dig +short NS "$ELEMENT_CALLING" 2>/dev/null | tr '\n' ' ')
|
||||||
|
if [ -n "$NS_LIST" ]; then
|
||||||
|
IPS=""
|
||||||
|
for NSRV in $NS_LIST; do
|
||||||
|
IPS=$( { dig +short A "$ELEMENT_CALLING" "@$NSRV" 2>/dev/null; dig +short AAAA "$ELEMENT_CALLING" "@$NSRV" 2>/dev/null; } | tr '\n' ' ' )
|
||||||
|
[ -n "$IPS" ] && break
|
||||||
|
done
|
||||||
|
echo "Authoritative nameservers for $ELEMENT_CALLING: $NS_LIST"
|
||||||
|
else
|
||||||
|
echo "WARNING: could not resolve nameservers for $ELEMENT_CALLING via the local resolver; using the local resolver's answer instead." >&2
|
||||||
|
IPS=$( { dig +short A "$ELEMENT_CALLING" 2>/dev/null; dig +short AAAA "$ELEMENT_CALLING" 2>/dev/null; } | tr '\n' ' ' )
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$IPS" ]; then
|
||||||
|
echo "ERROR: no A/AAAA records for $ELEMENT_CALLING at its authoritative nameservers. Remote peers cannot reach this LiveKit; calls will connect without media." >&2
|
||||||
|
FAIL=1
|
||||||
|
else
|
||||||
|
echo "Public DNS for $ELEMENT_CALLING: $IPS"
|
||||||
|
for IP in $IPS; do
|
||||||
|
case "$IP" in
|
||||||
|
0.*|127.*|169.254.*|100.64.*|::1|fe80:*|fc*:*|fd*:*)
|
||||||
|
echo "ERROR: $ELEMENT_CALLING resolves to $IP (loopback/link-local/CGNAT). Remote peers cannot reach it." >&2
|
||||||
|
FAIL=1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2) lk-jwt-service healthz through Caddy (validates the proxy chain).
|
||||||
|
if curl -fsS --max-time 10 "https://$ELEMENT_CALLING/livekit/jwt/healthz" >/dev/null 2>&1; then
|
||||||
|
echo "OK: https://$ELEMENT_CALLING/livekit/jwt/healthz responds"
|
||||||
|
else
|
||||||
|
echo "ERROR: https://$ELEMENT_CALLING/livekit/jwt/healthz not reachable through Caddy." >&2
|
||||||
|
FAIL=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3) Same healthz via the first public IP (tests the full NAT path).
|
||||||
|
# NOTE: if this box is behind the same NAT you are testing through,
|
||||||
|
# routers without hairpin NAT will fail this step — the warning is
|
||||||
|
# then expected and harmless; verify from an external device instead.
|
||||||
|
if [ -n "$IPS" ]; then
|
||||||
|
PUBIP=$(echo "$IPS" | awk '{print $1}')
|
||||||
|
if curl -fsS --max-time 15 --resolve "$ELEMENT_CALLING:443:$PUBIP" "https://$ELEMENT_CALLING/livekit/jwt/healthz" >/dev/null 2>&1; then
|
||||||
|
echo "OK: healthz reachable via public IP $PUBIP (NAT path works)"
|
||||||
|
else
|
||||||
|
echo "WARNING: healthz NOT reachable via public IP $PUBIP — check router port-forwarding (443/TCP) and NAT hairpin. Expected if the router lacks hairpin NAT; verify from an external device." >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4) MatrixRTC transports registry (MSC4519) — required by Element X.
|
||||||
|
CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "https://$MATRIX/_matrix/client/unstable/org.matrix.msc4143/rtc/transports")
|
||||||
|
case "$CODE" in
|
||||||
|
401|200)
|
||||||
|
echo "OK: MatrixRTC transports endpoint present (HTTP $CODE; auth required is expected)" ;;
|
||||||
|
404)
|
||||||
|
echo "ERROR: /_matrix/client/unstable/org.matrix.msc4143/rtc/transports missing (HTTP 404) — Element X cannot discover calling. Enable msc4143_enabled and matrix_rtc.transports in Synapse." >&2
|
||||||
|
FAIL=1 ;;
|
||||||
|
*)
|
||||||
|
echo "WARNING: transports endpoint returned HTTP $CODE" >&2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ "$FAIL" -eq 1 ]; then
|
||||||
|
echo "── Element Calling self-check FAILED — see errors above ──" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "── Element Calling self-check passed ──"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
####### SYNAPSE RUNTIME CONFIG (element-calling additions) #######
|
####### SYNAPSE RUNTIME CONFIG (element-calling additions) #######
|
||||||
systemd.services.element-calling-synapse-config = {
|
systemd.services.element-calling-synapse-config = {
|
||||||
description = "Generate Synapse runtime config for Element Calling";
|
description = "Generate Synapse runtime config for Element Calling";
|
||||||
@@ -287,6 +551,7 @@ EOF
|
|||||||
path = [ pkgs.coreutils ];
|
path = [ pkgs.coreutils ];
|
||||||
script = ''
|
script = ''
|
||||||
MATRIX=$(cat /var/lib/domains/matrix)
|
MATRIX=$(cat /var/lib/domains/matrix)
|
||||||
|
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
|
||||||
|
|
||||||
mkdir -p /run/matrix-synapse
|
mkdir -p /run/matrix-synapse
|
||||||
|
|
||||||
@@ -296,7 +561,17 @@ public_baseurl: "https://$MATRIX"
|
|||||||
serve_server_wellknown: true
|
serve_server_wellknown: true
|
||||||
experimental_features:
|
experimental_features:
|
||||||
msc3266_enabled: true
|
msc3266_enabled: true
|
||||||
|
# MSC4143: enables the MatrixRTC transports registry endpoint
|
||||||
|
# (/_matrix/client/unstable/org.matrix.msc4143/rtc/transports, MSC4519).
|
||||||
|
# Element X requires this endpoint to discover the LiveKit focus; without it
|
||||||
|
# mobile clients fail with MISSING_MATRIX_RTC_TRANSPORT / cannot start calls.
|
||||||
|
msc4143_enabled: true
|
||||||
msc4222_enabled: true
|
msc4222_enabled: true
|
||||||
|
# MSC4519: advertise this site's LiveKit focus via the transports registry.
|
||||||
|
matrix_rtc:
|
||||||
|
transports:
|
||||||
|
- type: livekit
|
||||||
|
livekit_service_url: "https://$ELEMENT_CALLING/livekit/jwt"
|
||||||
max_event_delay_duration: "24h"
|
max_event_delay_duration: "24h"
|
||||||
rc_message:
|
rc_message:
|
||||||
per_second: 0.5
|
per_second: 0.5
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
{ config, pkgs, lib, ... }:
|
|
||||||
|
|
||||||
lib.mkIf config.sovran_systemsOS.features.mempool {
|
|
||||||
|
|
||||||
services.mempool = {
|
|
||||||
enable = true;
|
|
||||||
frontend.enable = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
services.mysql.package = lib.mkForce pkgs.mariadb;
|
|
||||||
|
|
||||||
nix-bitcoin.onionServices.mempool-frontend.enable = true;
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -27,14 +27,11 @@
|
|||||||
./wordpress.nix
|
./wordpress.nix
|
||||||
./nextcloud.nix
|
./nextcloud.nix
|
||||||
./vaultwarden.nix
|
./vaultwarden.nix
|
||||||
./bitcoinecosystem.nix
|
|
||||||
./wallet-autoconnect.nix
|
./wallet-autoconnect.nix
|
||||||
|
|
||||||
# ── Features (default OFF — enable in custom.nix) ─────────
|
# ── Features (default OFF — enable in custom.nix) ─────────
|
||||||
./haven.nix
|
./haven.nix
|
||||||
./nwc-wallets.nix
|
|
||||||
./element-calling.nix
|
./element-calling.nix
|
||||||
./mempool.nix
|
|
||||||
./rdp.nix
|
./rdp.nix
|
||||||
./sshd.nix
|
./sshd.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"
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,182 +0,0 @@
|
|||||||
{ config, pkgs, lib, ... }:
|
|
||||||
|
|
||||||
let
|
|
||||||
albyHubPort = 18080;
|
|
||||||
albyHubApiBase = "http://127.0.0.1:${toString albyHubPort}";
|
|
||||||
patchedAlbyHub = pkgs.albyhub.overrideAttrs (old: {
|
|
||||||
patches = (old.patches or []) ++ [
|
|
||||||
../packages/albyhub/0001-private-route-hints.patch
|
|
||||||
../packages/albyhub/0002-isolated-invoice-app-id.patch
|
|
||||||
../packages/albyhub/0003-loopback-bind-host.patch
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
lndRpcAddress = lib.attrByPath [ "services" "lnd" "rpcAddress" ] "127.0.0.1" config;
|
|
||||||
lndRpcPort = toString (lib.attrByPath [ "services" "lnd" "rpcPort" ] 10009 config);
|
|
||||||
lndCertPath = config.services.lnd.certPath;
|
|
||||||
pythonManagerEnvironment = {
|
|
||||||
NWC_ALBY_HUB_API_BASE = albyHubApiBase;
|
|
||||||
NWC_LND_ADDRESS = "${lndRpcAddress}:${lndRpcPort}";
|
|
||||||
NWC_LND_CERT_FILE = lndCertPath;
|
|
||||||
NWC_LND_MACAROON_FILE = "/run/lnd/albyhub.macaroon";
|
|
||||||
NWC_RELAY =
|
|
||||||
if config.sovran_systemsOS.features.haven
|
|
||||||
then "wss://haven.${config.networking.domain}/nostr"
|
|
||||||
else "wss://relay.getalby.com,wss://relay2.getalby.com";
|
|
||||||
};
|
|
||||||
wrappedNwcWallet = lib.hiPrio (pkgs.writeShellScriptBin "nwc-wallet" ''
|
|
||||||
export NWC_ALBY_HUB_API_BASE='${pythonManagerEnvironment.NWC_ALBY_HUB_API_BASE}'
|
|
||||||
export NWC_LND_ADDRESS='${pythonManagerEnvironment.NWC_LND_ADDRESS}'
|
|
||||||
export NWC_LND_CERT_FILE='${pythonManagerEnvironment.NWC_LND_CERT_FILE}'
|
|
||||||
export NWC_LND_MACAROON_FILE='${pythonManagerEnvironment.NWC_LND_MACAROON_FILE}'
|
|
||||||
export NWC_RELAY='${pythonManagerEnvironment.NWC_RELAY}'
|
|
||||||
exec ${config.services.sovranHub.webPackage}/bin/nwc-wallet "$@"
|
|
||||||
'');
|
|
||||||
|
|
||||||
albyhubWrapper = pkgs.writeShellScript "albyhub-wrapper" ''
|
|
||||||
set -euo pipefail
|
|
||||||
password_file="/var/lib/albyhub/unlock-password"
|
|
||||||
if [ ! -s "$password_file" ]; then
|
|
||||||
umask 077
|
|
||||||
${pkgs.openssl}/bin/openssl rand -hex 32 > "$password_file"
|
|
||||||
fi
|
|
||||||
export AUTO_UNLOCK_PASSWORD="$(cat "$password_file")"
|
|
||||||
exec ${lib.getExe patchedAlbyHub}
|
|
||||||
'';
|
|
||||||
in
|
|
||||||
lib.mkIf config.sovran_systemsOS.features."nwc-wallets" {
|
|
||||||
assertions = [
|
|
||||||
{
|
|
||||||
assertion = config.services.lnd.enable;
|
|
||||||
message = "Lightning Wallet Connections requires services.lnd.enable = true.";
|
|
||||||
}
|
|
||||||
{
|
|
||||||
assertion = !(lib.attrByPath [ "nix-bitcoin" "netns-isolation" "enable" ] false config);
|
|
||||||
message = "Lightning Wallet Connections requires nix-bitcoin.netns-isolation.enable = false.";
|
|
||||||
}
|
|
||||||
{
|
|
||||||
assertion = albyHubPort != config.services.lnd.restPort;
|
|
||||||
message = "Alby Hub and LND REST must use different ports.";
|
|
||||||
}
|
|
||||||
{
|
|
||||||
assertion = albyHubPort != 8181;
|
|
||||||
message = "Alby Hub and the public LNURL service must use different ports.";
|
|
||||||
}
|
|
||||||
{
|
|
||||||
assertion = !(lib.elem albyHubPort config.networking.firewall.allowedTCPPorts);
|
|
||||||
message = "Alby Hub management port must not be opened on the public TCP firewall.";
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
users.groups.albyhub = { };
|
|
||||||
users.users.albyhub = {
|
|
||||||
isSystemUser = true;
|
|
||||||
group = "albyhub";
|
|
||||||
home = "/var/lib/albyhub";
|
|
||||||
createHome = false;
|
|
||||||
extraGroups = [ ];
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.tmpfiles.rules = [
|
|
||||||
"d /var/lib/albyhub 0700 albyhub albyhub -"
|
|
||||||
];
|
|
||||||
|
|
||||||
services.lnd.macaroons.albyhub = {
|
|
||||||
user = "albyhub";
|
|
||||||
permissions = lib.concatStringsSep "," [
|
|
||||||
''{"entity":"info","action":"read"}''
|
|
||||||
''{"entity":"offchain","action":"read"}''
|
|
||||||
''{"entity":"offchain","action":"write"}''
|
|
||||||
''{"entity":"invoices","action":"read"}''
|
|
||||||
''{"entity":"invoices","action":"write"}''
|
|
||||||
''{"entity":"onchain","action":"read"}''
|
|
||||||
''{"entity":"address","action":"read"}''
|
|
||||||
''{"entity":"message","action":"read"}''
|
|
||||||
''{"entity":"message","action":"write"}''
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.services.albyhub = {
|
|
||||||
description = "Alby Hub — NWC wallet server";
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
after = [ "network.target" "lnd.service" ];
|
|
||||||
requires = [ "lnd.service" ];
|
|
||||||
|
|
||||||
environment = {
|
|
||||||
HOME = "/var/lib/albyhub";
|
|
||||||
HOST = "127.0.0.1";
|
|
||||||
LN_BACKEND_TYPE = "LND";
|
|
||||||
ENABLE_ADVANCED_SETUP = "false";
|
|
||||||
LND_ADDRESS = "${lndRpcAddress}:${lndRpcPort}";
|
|
||||||
LND_CERT_FILE = lndCertPath;
|
|
||||||
LND_MACAROON_FILE = "/run/lnd/albyhub.macaroon";
|
|
||||||
WORK_DIR = "/var/lib/albyhub";
|
|
||||||
DATABASE_URI = "/var/lib/albyhub/nwc.db";
|
|
||||||
PORT = toString albyHubPort;
|
|
||||||
# Use private Nostr relay if Haven is enabled, otherwise default to Alby's public relays
|
|
||||||
RELAY =
|
|
||||||
if config.sovran_systemsOS.features.haven
|
|
||||||
then "wss://haven.${config.networking.domain}/nostr"
|
|
||||||
else "wss://relay.getalby.com,wss://relay2.getalby.com";
|
|
||||||
AUTO_LINK_ALBY_ACCOUNT = "false";
|
|
||||||
SEND_EVENTS_TO_ALBY = "false";
|
|
||||||
LOG_TO_FILE = "false";
|
|
||||||
HIDE_UPDATE_BANNER = "true";
|
|
||||||
};
|
|
||||||
|
|
||||||
serviceConfig = {
|
|
||||||
Type = "simple";
|
|
||||||
User = "albyhub";
|
|
||||||
Group = "albyhub";
|
|
||||||
WorkingDirectory = "/var/lib/albyhub";
|
|
||||||
ExecStart = albyhubWrapper;
|
|
||||||
Restart = "on-failure";
|
|
||||||
RestartSec = "10s";
|
|
||||||
UMask = "0077";
|
|
||||||
NoNewPrivileges = true;
|
|
||||||
PrivateTmp = true;
|
|
||||||
ProtectHome = true;
|
|
||||||
ProtectSystem = "strict";
|
|
||||||
ReadWritePaths = [ "/var/lib/albyhub" ];
|
|
||||||
ReadOnlyPaths = [ lndCertPath "/run/lnd" ];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.services.nwc-lnurl = {
|
|
||||||
description = "Lightning Wallet Connections public LNURL service";
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
after = [ "albyhub.service" "sovran-hub-web.service" ];
|
|
||||||
wants = [ "albyhub.service" ];
|
|
||||||
environment = pythonManagerEnvironment;
|
|
||||||
|
|
||||||
serviceConfig = {
|
|
||||||
Type = "simple";
|
|
||||||
User = "albyhub";
|
|
||||||
Group = "albyhub";
|
|
||||||
ExecStart = "${config.services.sovranHub.webPackage}/bin/nwc-lnurl";
|
|
||||||
Restart = "on-failure";
|
|
||||||
RestartSec = "10s";
|
|
||||||
UMask = "0027";
|
|
||||||
NoNewPrivileges = true;
|
|
||||||
PrivateTmp = true;
|
|
||||||
ProtectHome = true;
|
|
||||||
ProtectSystem = "strict";
|
|
||||||
ReadOnlyPaths = [
|
|
||||||
"/var/lib/domains/lightning"
|
|
||||||
"/var/lib/albyhub/unlock-password"
|
|
||||||
];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.services.sovran-hub-web.environment = pythonManagerEnvironment;
|
|
||||||
environment.systemPackages = lib.mkBefore [ wrappedNwcWallet ];
|
|
||||||
|
|
||||||
sovran_systemsOS.domainRequirements = [
|
|
||||||
{
|
|
||||||
name = "lightning";
|
|
||||||
label = "Lightning Address Domain";
|
|
||||||
example = "pay.yourdomain.com";
|
|
||||||
needsDDNS = true;
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
+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
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# Sovran Bitcoin integration layer — bridges Sovran_SystemsOS options to the
|
||||||
|
# Sovran_Bitcoin flake module.
|
||||||
|
#
|
||||||
|
# This is the ONLY place where OS-specific Bitcoin customizations live.
|
||||||
|
# All bitcoin service modules (bitcoind, electrs, lnd, rtl, btcpayserver,
|
||||||
|
# mempool, albyhub, lnurl) and vendored packages come from the Sovran_Bitcoin
|
||||||
|
# flake input.
|
||||||
|
#
|
||||||
|
# What this file does:
|
||||||
|
# 1. Maps sovran_systemsOS.services.bitcoin → sovran-bitcoin.enable
|
||||||
|
# 2. Maps sovran_systemsOS.features.* → sovran-bitcoin.features.*
|
||||||
|
# 3. Applies OS-specific overrides (Second_Drive paths, operator "free",
|
||||||
|
# forced wallet enable, Hub firewall port, domain requirements)
|
||||||
|
# 4. Wires the Sovran Hub's NWC environment to the Alby Hub service
|
||||||
|
{ config, pkgs, lib, ... }:
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.sovran_systemsOS;
|
||||||
|
|
||||||
|
# ── NWC environment for the Sovran Hub web app ─────────────────
|
||||||
|
# The Hub's web package provides nwc-wallet and nwc-lnurl binaries that
|
||||||
|
# need to know where Alby Hub and LND are. Sovran_Bitcoin's modules
|
||||||
|
# handle the base Alby Hub service; this layers on the Hub-specific
|
||||||
|
# tooling environment.
|
||||||
|
lndRpcAddress = config.services.lnd.rpcAddress or "127.0.0.1";
|
||||||
|
lndRpcPort = toString (config.services.lnd.rpcPort or 10009);
|
||||||
|
lndCertPath = config.services.lnd.certPath or "/var/lib/lnd/tls.cert";
|
||||||
|
|
||||||
|
hubNwcEnvironment = {
|
||||||
|
NWC_ALBY_HUB_API_BASE = "http://127.0.0.1:18080";
|
||||||
|
NWC_LND_ADDRESS = "${lndRpcAddress}:${lndRpcPort}";
|
||||||
|
NWC_LND_CERT_FILE = lndCertPath;
|
||||||
|
NWC_LND_MACAROON_FILE = "/run/lnd/albyhub.macaroon";
|
||||||
|
NWC_RELAY =
|
||||||
|
if cfg.features.haven
|
||||||
|
then "wss://haven.${config.networking.domain}/nostr"
|
||||||
|
else "wss://relay.getalby.com,wss://relay2.getalby.com";
|
||||||
|
};
|
||||||
|
in {
|
||||||
|
# ── 1. Map OS options → Sovran_Bitcoin options ─────────────────
|
||||||
|
sovran-bitcoin = lib.mkIf cfg.services.bitcoin {
|
||||||
|
enable = true;
|
||||||
|
operatorName = "free";
|
||||||
|
bitcoindTorGossip = cfg.features.bitcoin-tor-gossip;
|
||||||
|
|
||||||
|
features = {
|
||||||
|
electrs = true;
|
||||||
|
lnd = true;
|
||||||
|
rtl = true;
|
||||||
|
btcpayserver = cfg.web.btcpayserver;
|
||||||
|
mempool = cfg.features.mempool;
|
||||||
|
nwc = cfg.features."nwc-wallets";
|
||||||
|
lnurl = cfg.features."nwc-wallets";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# ── 2. Second_Drive data paths (OS-specific) ──────────────────
|
||||||
|
services.bitcoind = lib.mkIf cfg.services.bitcoin {
|
||||||
|
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node";
|
||||||
|
# The OS always enables the bitcoind wallet — the Hub and BTCPay need it.
|
||||||
|
disablewallet = lib.mkForce false;
|
||||||
|
};
|
||||||
|
|
||||||
|
services.electrs = lib.mkIf cfg.services.bitcoin {
|
||||||
|
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Electrs_Data";
|
||||||
|
};
|
||||||
|
|
||||||
|
# ── 3. Second_Drive mount dependencies ─────────────────────────
|
||||||
|
systemd.services.bitcoind = lib.mkIf cfg.services.bitcoin {
|
||||||
|
requires = [ "run-media-Second_Drive.mount" ];
|
||||||
|
after = [ "run-media-Second_Drive.mount" ];
|
||||||
|
serviceConfig.PrivateUsers = lib.mkForce false;
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.services.electrs = lib.mkIf cfg.services.bitcoin {
|
||||||
|
requires = lib.mkForce [ "run-media-Second_Drive.mount" ];
|
||||||
|
after = [ "run-media-Second_Drive.mount" "bitcoind.service" ];
|
||||||
|
wants = [ "bitcoind.service" ];
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.services.lnd = lib.mkIf cfg.services.bitcoin {
|
||||||
|
wants = [ "bitcoind.service" ];
|
||||||
|
# nix-bitcoin sets `requires = [ "bitcoind.service" ]`; the OS removes it
|
||||||
|
# so LND can start even if bitcoind is temporarily down.
|
||||||
|
requires = lib.mkForce [ ];
|
||||||
|
};
|
||||||
|
|
||||||
|
# ── 4. Permission fixup for Second_Drive ───────────────────────
|
||||||
|
systemd.services.sovran-btc-permissions = lib.mkIf cfg.services.bitcoin {
|
||||||
|
description = "Fix Bitcoin/Electrs data directory ownership on second drive";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
after = [ "run-media-Second_Drive.mount" ];
|
||||||
|
before = [ "bitcoind.service" "electrs.service" ];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
RemainAfterExit = true;
|
||||||
|
};
|
||||||
|
script = ''
|
||||||
|
if [ -d /run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node ]; then
|
||||||
|
chown -R bitcoin:bitcoin /run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node
|
||||||
|
fi
|
||||||
|
if [ -d /run/media/Second_Drive/BTCEcoandBackup/Electrs_Data ]; then
|
||||||
|
chown -R electrs:electrs /run/media/Second_Drive/BTCEcoandBackup/Electrs_Data
|
||||||
|
fi
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
# ── 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 ];
|
||||||
|
|
||||||
|
# ── 6. NWC / LNURL — Sovran Hub integration ───────────────────
|
||||||
|
# Sovran_Bitcoin's albyhub.nix and lnurl.nix handle the base services.
|
||||||
|
# This section wires the Hub's web app environment so the Hub can
|
||||||
|
# display NWC status and the nwc-wallet CLI works from the Hub shell.
|
||||||
|
|
||||||
|
services.sovran-lnurl = lib.mkIf cfg.features."nwc-wallets" {
|
||||||
|
# The Hub writes the Lightning Address domain to this file.
|
||||||
|
domainFile = "/var/lib/domains/lightning";
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.services.sovran-hub-web.environment = lib.mkIf cfg.features."nwc-wallets"
|
||||||
|
hubNwcEnvironment;
|
||||||
|
|
||||||
|
# ── 7. Domain requirements ─────────────────────────────────────
|
||||||
|
sovran_systemsOS.domainRequirements = lib.mkIf cfg.services.bitcoin (
|
||||||
|
[
|
||||||
|
{ name = "btcpayserver"; label = "BTCPay Server"; example = "pay.yourdomain.com"; }
|
||||||
|
]
|
||||||
|
++ lib.optionals cfg.features."nwc-wallets" [
|
||||||
|
{ name = "lightning"; label = "Lightning Address Domain"; example = "pay.yourdomain.com"; needsDDNS = true; }
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -47,29 +47,44 @@ EOF
|
|||||||
systemd.services.zeus-connect-setup = {
|
systemd.services.zeus-connect-setup = {
|
||||||
description = "Save Zeus lndconnect URL";
|
description = "Save Zeus lndconnect URL";
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
after = [ "lnd.service" ];
|
after = [ "lnd.service" "onion-addresses.service" ];
|
||||||
|
wants = [ "lnd.service" "onion-addresses.service" ];
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
Type = "oneshot";
|
Type = "oneshot";
|
||||||
RemainAfterExit = true;
|
RemainAfterExit = true;
|
||||||
};
|
};
|
||||||
path = [ pkgs.coreutils "/run/current-system/sw" ];
|
# sudo is required: the lndconnect wrapper re-execs as the lnd user so it
|
||||||
|
# can read admin.macaroon (not group-readable).
|
||||||
|
path = [ pkgs.coreutils pkgs.gnugrep pkgs.sudo "/run/current-system/sw" ];
|
||||||
script = ''
|
script = ''
|
||||||
SECRET_FILE="/var/lib/secrets/zeus-connect-url"
|
SECRET_FILE="/var/lib/secrets/zeus-connect-url"
|
||||||
mkdir -p /var/lib/secrets
|
mkdir -p /var/lib/secrets
|
||||||
|
|
||||||
|
# LND may still be creating the wallet / macaroon, and the dedicated
|
||||||
|
# lnd-rest onion hostname is published by onion-addresses.service.
|
||||||
URL=""
|
URL=""
|
||||||
|
ATTEMPTS=0
|
||||||
|
while [ "$ATTEMPTS" -lt 60 ]; do
|
||||||
if command -v lndconnect >/dev/null 2>&1; then
|
if command -v lndconnect >/dev/null 2>&1; then
|
||||||
URL=$(lndconnect --url 2>/dev/null || true)
|
URL=$(lndconnect --url 2>/dev/null | tr -d '\r' | tail -n 1 || true)
|
||||||
elif command -v lnconnect-clnrest >/dev/null 2>&1; then
|
|
||||||
URL=$(lnconnect-clnrest --url 2>/dev/null || true)
|
|
||||||
fi
|
fi
|
||||||
|
# Zeus LND REST over Tor: lndconnect://<v3-onion>:8080?macaroon=...
|
||||||
|
if echo "$URL" | grep -q '^lndconnect://' \
|
||||||
|
&& echo "$URL" | grep -q '\.onion' \
|
||||||
|
&& echo "$URL" | grep -q 'macaroon='; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
URL=""
|
||||||
|
ATTEMPTS=$((ATTEMPTS + 1))
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
if [ -n "$URL" ]; then
|
if [ -n "$URL" ]; then
|
||||||
echo "$URL" > "$SECRET_FILE"
|
printf '%s\n' "$URL" > "$SECRET_FILE"
|
||||||
chmod 600 "$SECRET_FILE"
|
chmod 600 "$SECRET_FILE"
|
||||||
echo "Zeus connect URL saved."
|
echo "Zeus connect URL saved."
|
||||||
else
|
else
|
||||||
echo "No lndconnect URL available yet."
|
echo "No valid lndconnect URL available yet."
|
||||||
fi
|
fi
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
diff --git a/lnclient/lnd/lnd.go b/lnclient/lnd/lnd.go
|
|
||||||
index bad532f..ba35b7b 100644
|
|
||||||
--- a/lnclient/lnd/lnd.go
|
|
||||||
+++ b/lnclient/lnd/lnd.go
|
|
||||||
@@ -708,7 +708,7 @@ func (svc *LNDService) MakeInvoice(ctx context.Context, amountMsat int64, descri
|
|
||||||
DescriptionHash: descriptionHashBytes,
|
|
||||||
Expiry: expiry,
|
|
||||||
RouteHints: hints,
|
|
||||||
- Private: !hasPublicChannels, // use private channel hints in the invoice
|
|
||||||
+ Private: true, // always include private channel hints in the invoice
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := svc.client.AddInvoice(ctx, addInvoiceRequest)
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
diff --git a/api/models.go b/api/models.go
|
|
||||||
index 28bd117..dceb79a 100644
|
|
||||||
--- a/api/models.go
|
|
||||||
+++ b/api/models.go
|
|
||||||
@@ -46,7 +46,7 @@ type API interface {
|
|
||||||
ListTransactions(ctx context.Context, appId *uint, limit uint64, offset uint64) (*ListTransactionsResponse, error)
|
|
||||||
ListOnchainTransactions(ctx context.Context) ([]OnchainTransaction, error)
|
|
||||||
SendPayment(ctx context.Context, invoice string, amountMsat *uint64, metadata map[string]interface{}, fromAppId *uint) (*SendPaymentResponse, error)
|
|
||||||
- CreateInvoice(ctx context.Context, amountMsat uint64, description string) (*MakeInvoiceResponse, error)
|
|
||||||
+ CreateInvoice(ctx context.Context, amountMsat uint64, description string, appId *uint) (*MakeInvoiceResponse, error)
|
|
||||||
LookupInvoice(ctx context.Context, paymentHash string) (*LookupInvoiceResponse, error)
|
|
||||||
SetTransactionUserLabels(ctx context.Context, id uint, labels map[string]string) error
|
|
||||||
RequestMempoolApi(ctx context.Context, endpoint string) (interface{}, error)
|
|
||||||
@@ -586,6 +586,7 @@ type MakeInvoiceRequest struct {
|
|
||||||
AmountSat *uint64 `json:"amountSat"`
|
|
||||||
AmountMsat *uint64 `json:"amountMsat"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
+ AppId *uint `json:"appId"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ResetRouterRequest struct {
|
|
||||||
diff --git a/api/transactions.go b/api/transactions.go
|
|
||||||
index aaf9380..3f9850a 100644
|
|
||||||
--- a/api/transactions.go
|
|
||||||
+++ b/api/transactions.go
|
|
||||||
@@ -12,12 +12,12 @@ import (
|
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
)
|
|
||||||
|
|
||||||
-func (api *api) CreateInvoice(ctx context.Context, amountMsat uint64, description string) (*MakeInvoiceResponse, error) {
|
|
||||||
+func (api *api) CreateInvoice(ctx context.Context, amountMsat uint64, description string, appId *uint) (*MakeInvoiceResponse, error) {
|
|
||||||
lnClient := api.svc.GetLNClient()
|
|
||||||
if lnClient == nil {
|
|
||||||
return nil, ErrLNClientNotStarted
|
|
||||||
}
|
|
||||||
- transaction, err := api.svc.GetTransactionsService().MakeInvoice(ctx, amountMsat, description, "", 0, nil, lnClient, nil, nil, nil)
|
|
||||||
+ transaction, err := api.svc.GetTransactionsService().MakeInvoice(ctx, amountMsat, description, "", 0, nil, lnClient, appId, nil, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
diff --git a/http/http_service.go b/http/http_service.go
|
|
||||||
index bf650b9..cf61250 100644
|
|
||||||
--- a/http/http_service.go
|
|
||||||
+++ b/http/http_service.go
|
|
||||||
@@ -687,7 +687,12 @@ func (httpSvc *HttpService) makeInvoiceHandler(c echo.Context) error {
|
|
||||||
amountMsat = *resolvedAmountMsat
|
|
||||||
}
|
|
||||||
|
|
||||||
- invoice, err := httpSvc.api.CreateInvoice(c.Request().Context(), amountMsat, makeInvoiceRequest.Description)
|
|
||||||
+ invoice, err := httpSvc.api.CreateInvoice(
|
|
||||||
+ c.Request().Context(),
|
|
||||||
+ amountMsat,
|
|
||||||
+ makeInvoiceRequest.Description,
|
|
||||||
+ makeInvoiceRequest.AppId,
|
|
||||||
+ )
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return c.JSON(http.StatusInternalServerError, ErrorResponse{
|
|
||||||
diff --git a/wails/wails_handlers.go b/wails/wails_handlers.go
|
|
||||||
index d25259b..c9fe4e8 100644
|
|
||||||
--- a/wails/wails_handlers.go
|
|
||||||
+++ b/wails/wails_handlers.go
|
|
||||||
@@ -600,7 +600,7 @@ func (app *WailsApp) WailsRequestRouter(route string, method string, body string
|
|
||||||
if resolvedAmountMsat != nil {
|
|
||||||
amountMsat = *resolvedAmountMsat
|
|
||||||
}
|
|
||||||
- invoice, err := app.api.CreateInvoice(ctx, amountMsat, makeInvoiceRequest.Description)
|
|
||||||
+ invoice, err := app.api.CreateInvoice(ctx, amountMsat, makeInvoiceRequest.Description, nil)
|
|
||||||
if err != nil {
|
|
||||||
return WailsRequestRouterResponse{Body: nil, Error: err.Error()}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
diff --git a/cmd/http/main.go b/cmd/http/main.go
|
|
||||||
index ed31f96..4d31212 100644
|
|
||||||
--- a/cmd/http/main.go
|
|
||||||
+++ b/cmd/http/main.go
|
|
||||||
@@ -2,8 +2,8 @@ package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
- "fmt"
|
|
||||||
nethttp "net/http"
|
|
||||||
+ "net"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
@@ -51,8 +51,9 @@ func main() {
|
|
||||||
httpSvc := http.NewHttpService(svc, svc.GetEventPublisher())
|
|
||||||
httpSvc.RegisterSharedRoutes(e)
|
|
||||||
//start Echo server
|
|
||||||
+ bindAddress := net.JoinHostPort(svc.GetConfig().GetEnv().Host, svc.GetConfig().GetEnv().Port)
|
|
||||||
go func() {
|
|
||||||
- if err := e.Start(fmt.Sprintf(":%v", svc.GetConfig().GetEnv().Port)); err != nil && err != nethttp.ErrServerClosed {
|
|
||||||
+ if err := e.Start(bindAddress); err != nil && err != nethttp.ErrServerClosed {
|
|
||||||
logger.Logger.WithError(err).Error("echo server failed to start")
|
|
||||||
cancel()
|
|
||||||
}
|
|
||||||
diff --git a/config/models.go b/config/models.go
|
|
||||||
index 0fb8870..dc11afd 100644
|
|
||||||
--- a/config/models.go
|
|
||||||
+++ b/config/models.go
|
|
||||||
@@ -24,6 +24,7 @@ type AppConfig struct {
|
|
||||||
LNDAddress string `envconfig:"LND_ADDRESS"`
|
|
||||||
LNDCertFile string `envconfig:"LND_CERT_FILE"`
|
|
||||||
LNDMacaroonFile string `envconfig:"LND_MACAROON_FILE"`
|
|
||||||
+ Host string `envconfig:"HOST" default:"127.0.0.1"`
|
|
||||||
Workdir string `envconfig:"WORK_DIR"`
|
|
||||||
Port string `envconfig:"PORT" default:"8080"`
|
|
||||||
DatabaseUri string `envconfig:"DATABASE_URI" default:"nwc.db"`
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# Vendored from fort-nix/nix-bitcoin commit 360e30fee.
|
|
||||||
# This local copy does not fetch or import nix-bitcoin.
|
|
||||||
# This is a modified version of
|
|
||||||
# https://github.com/NixOS/nixpkgs/pull/128749
|
|
||||||
|
|
||||||
{ lib, stdenvNoCC, makeWrapper, nodejs, cacert }:
|
|
||||||
|
|
||||||
{ src
|
|
||||||
, hash ? ""
|
|
||||||
, runScripts ? false
|
|
||||||
, preferLocalBuild ? true
|
|
||||||
, npmFlags ? ""
|
|
||||||
, ...
|
|
||||||
} @ args:
|
|
||||||
stdenvNoCC.mkDerivation ({
|
|
||||||
inherit src preferLocalBuild;
|
|
||||||
|
|
||||||
name = "${src.name}-node_modules";
|
|
||||||
nativeBuildInputs = [
|
|
||||||
makeWrapper
|
|
||||||
(if args ? nodejs then args.nodejs else nodejs)
|
|
||||||
];
|
|
||||||
|
|
||||||
outputHashMode = "recursive";
|
|
||||||
|
|
||||||
impureEnvVars = lib.fetchers.proxyImpureEnvVars;
|
|
||||||
|
|
||||||
phases = "unpackPhase patchPhase buildPhase installPhase";
|
|
||||||
|
|
||||||
# npm doesn't support var `SSL_CERT_FILE`.
|
|
||||||
NODE_EXTRA_CA_CERTS = "${cacert}/etc/ssl/certs/ca-bundle.crt";
|
|
||||||
|
|
||||||
buildPhase = ''
|
|
||||||
runHook preBuild
|
|
||||||
|
|
||||||
if [[ ! -f package.json ]]; then
|
|
||||||
echo "Error: file `package.json` doesn't exist"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [[ ! -f package-lock.json ]]; then
|
|
||||||
echo "Error: file `package-lock.json` doesn't exist"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
export SOURCE_DATE_EPOCH=1
|
|
||||||
export npm_config_cache=/tmp
|
|
||||||
NPM_FLAGS="--omit=dev --omit=optional --no-update-notifier $npmFlags"
|
|
||||||
# Scripts may result in non-deterministic behavior.
|
|
||||||
# Some packages (e.g., Puppeteer) use postinstall scripts to download extra data.
|
|
||||||
if [[ ! $runScripts ]]; then
|
|
||||||
NPM_FLAGS+=" --ignore-scripts"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Running npm ci $NPM_FLAGS"
|
|
||||||
npm ci $NPM_FLAGS
|
|
||||||
|
|
||||||
cp package.json \
|
|
||||||
package-lock.json node_modules/
|
|
||||||
rm -f node_modules/.package-lock.json
|
|
||||||
|
|
||||||
runHook postBuild
|
|
||||||
'';
|
|
||||||
|
|
||||||
installPhase = ''
|
|
||||||
runHook preInstall
|
|
||||||
|
|
||||||
mkdir -p $out/lib
|
|
||||||
cp -r node_modules $out/lib
|
|
||||||
|
|
||||||
runHook postInstall
|
|
||||||
'';
|
|
||||||
} // (
|
|
||||||
if hash == "" then {
|
|
||||||
outputHashAlgo = "sha256";
|
|
||||||
outputHash = "";
|
|
||||||
} else {
|
|
||||||
outputHash = hash;
|
|
||||||
}
|
|
||||||
) // (builtins.removeAttrs args [ "hash" ]))
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{ lib, buildGoModule, fetchFromGitHub }:
|
|
||||||
|
|
||||||
buildGoModule rec {
|
|
||||||
pname = "lndinit";
|
|
||||||
version = "0.1.3-beta";
|
|
||||||
src = fetchFromGitHub {
|
|
||||||
owner = "lightninglabs";
|
|
||||||
repo = pname;
|
|
||||||
rev = "v${version}";
|
|
||||||
sha256 = "sha256-sO1DpbppCurxr9g9nUl9Vx82FJK1mTcUw3rY1Fm1wEU=";
|
|
||||||
};
|
|
||||||
vendorHash = "sha256-El44BS5Bu0K/klMxkajciU/R6uqiXBMOiLN536QztbE=";
|
|
||||||
subPackages = [ "." ];
|
|
||||||
meta = with lib; {
|
|
||||||
description = "Wallet initializer for lnd (from nix-bitcoin)";
|
|
||||||
homepage = "https://github.com/lightninglabs/lndinit";
|
|
||||||
license = licenses.mit;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
From e4b3ebaf0451c1bddbd7dcf8527c296938ebb607 Mon Sep 17 00:00:00 2001
|
|
||||||
From: Erik Arvstedt <erik.arvstedt@gmail.com>
|
|
||||||
Date: Sun, 1 Jun 2025 11:17:22 +0200
|
|
||||||
Subject: [PATCH] allow disabling mining pool fetching in offline environments
|
|
||||||
|
|
||||||
Previously, Mempool strictly required fetching mining pool data from
|
|
||||||
Github and failed when this was not possible, e.g. in offline
|
|
||||||
environments.
|
|
||||||
|
|
||||||
This patch allows disabling pool fetching.
|
|
||||||
When disabled, empty pool data is inserted into the DB, which
|
|
||||||
effectively turns off block pool classification.
|
|
||||||
---
|
|
||||||
backend/src/tasks/pools-updater.ts | 5 ++++-
|
|
||||||
1 file changed, 4 insertions(+), 1 deletion(-)
|
|
||||||
|
|
||||||
diff --git a/backend/src/tasks/pools-updater.ts b/backend/src/tasks/pools-updater.ts
|
|
||||||
index 6b0520dfc..a74259b95 100644
|
|
||||||
--- a/backend/src/tasks/pools-updater.ts
|
|
||||||
+++ b/backend/src/tasks/pools-updater.ts
|
|
||||||
@@ -75,7 +75,7 @@ class PoolsUpdater {
|
|
||||||
} else {
|
|
||||||
logger.warn(`pools-v2.json is outdated, fetching latest from ${this.poolsUrl} over ${network}`, this.tag);
|
|
||||||
}
|
|
||||||
- const poolsJson = await this.query(this.poolsUrl);
|
|
||||||
+ const poolsJson = (githubSha == "disable-pool-fetching") ? [] : await this.query(this.poolsUrl);
|
|
||||||
if (poolsJson === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
@@ -136,6 +136,9 @@ class PoolsUpdater {
|
|
||||||
* Fetch our latest pools-v2.json sha from github
|
|
||||||
*/
|
|
||||||
private async fetchPoolsSha(): Promise<string | null> {
|
|
||||||
+ if (this.poolsUrl == "disable-pool-fetching") {
|
|
||||||
+ return "disable-pool-fetching";
|
|
||||||
+ }
|
|
||||||
const response = await this.query(this.treeUrl);
|
|
||||||
|
|
||||||
if (response !== undefined) {
|
|
||||||
--
|
|
||||||
2.47.2
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
# Packaging adapted from fort-nix/nix-bitcoin commit 360e30fee.
|
|
||||||
# This local copy does not fetch or import nix-bitcoin.
|
|
||||||
{ lib
|
|
||||||
, stdenvNoCC
|
|
||||||
, nodejs_22
|
|
||||||
, nodejs-slim_22
|
|
||||||
, fetchFromGitHub
|
|
||||||
, fetchNodeModules
|
|
||||||
, runCommand
|
|
||||||
, makeWrapper
|
|
||||||
, curl
|
|
||||||
, cacert
|
|
||||||
, rsync
|
|
||||||
# for rust-gbt (backend module)
|
|
||||||
, cargo
|
|
||||||
, rustc
|
|
||||||
, rustPlatform
|
|
||||||
, napi-rs-cli
|
|
||||||
}:
|
|
||||||
rec {
|
|
||||||
nodejs = nodejs_22;
|
|
||||||
nodejsRuntime = nodejs-slim_22;
|
|
||||||
|
|
||||||
version = "3.2.1";
|
|
||||||
|
|
||||||
src = fetchFromGitHub {
|
|
||||||
owner = "mempool";
|
|
||||||
repo = "mempool";
|
|
||||||
tag = "v${version}";
|
|
||||||
hash = "sha256-O2XPD1/BXQnzuOP/vMVyRfmFZEgjA85r+PShWne0vqU=";
|
|
||||||
};
|
|
||||||
|
|
||||||
nodeModules = {
|
|
||||||
frontend = fetchNodeModules {
|
|
||||||
inherit src nodejs;
|
|
||||||
sourceRoot = "source/frontend";
|
|
||||||
hash = "sha256-+jfgsAkDdYvgso8uSHaBj/sQL3fC/ABQWzVTXfdZcU0=";
|
|
||||||
};
|
|
||||||
backend = fetchNodeModules {
|
|
||||||
inherit src nodejs;
|
|
||||||
sourceRoot = "source/backend";
|
|
||||||
hash = "sha256-y5l2SYZYK9SKSy6g0+mtTWD6JFkkdQHHBboECpEvWZ4=";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
frontendAssets = fetchFiles {
|
|
||||||
name = "mempool-frontend-assets";
|
|
||||||
hash = "sha256-r6GfOY8Pdh15o2OQMk8syfvWMV6WMCReToAEkQm7tqQ=";
|
|
||||||
fetcher = ./frontend-assets-fetch.sh;
|
|
||||||
};
|
|
||||||
|
|
||||||
mempool-backend = mkDerivationMempool {
|
|
||||||
pname = "mempool-backend";
|
|
||||||
|
|
||||||
patches = [ ./0001-allow-disabling-mining-pool-fetching.patch ];
|
|
||||||
|
|
||||||
buildPhase = ''
|
|
||||||
cd backend
|
|
||||||
${sync} --chmod=+w ${nodeModules.backend}/lib/node_modules .
|
|
||||||
patchShebangs node_modules
|
|
||||||
|
|
||||||
${sync} ${mempool-rust-gbt}/ rust-gbt
|
|
||||||
npm run package
|
|
||||||
|
|
||||||
runHook postBuild
|
|
||||||
'';
|
|
||||||
|
|
||||||
installPhase = ''
|
|
||||||
mkdir -p $out/lib/mempool-backend
|
|
||||||
${sync} package/ $out/lib/mempool-backend
|
|
||||||
|
|
||||||
makeWrapper ${nodejsRuntime}/bin/node $out/bin/mempool-backend \
|
|
||||||
--add-flags $out/lib/mempool-backend/index.js
|
|
||||||
|
|
||||||
runHook postInstall
|
|
||||||
'';
|
|
||||||
|
|
||||||
passthru = {
|
|
||||||
inherit nodejs nodejsRuntime;
|
|
||||||
nodeModules = nodeModules.backend;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
mempool-frontend = mkFrontend {};
|
|
||||||
|
|
||||||
# Argument `config` (type: attrset) defines the mempool frontend config.
|
|
||||||
# If `{}`, the default config is used.
|
|
||||||
# See here for available options:
|
|
||||||
# https://github.com/mempool/mempool/blob/master/frontend/src/app/services/state.service.ts
|
|
||||||
# (`interface Env` and `defaultEnv`)
|
|
||||||
mkFrontend = config: mkDerivationMempool {
|
|
||||||
pname = "mempool-frontend";
|
|
||||||
|
|
||||||
buildPhase = ''
|
|
||||||
cd frontend
|
|
||||||
|
|
||||||
${sync} --chmod=+w ${nodeModules.frontend}/lib/node_modules .
|
|
||||||
patchShebangs node_modules
|
|
||||||
|
|
||||||
# sync-assets.js is called during `npm run build` and downloads assets from the
|
|
||||||
# internet. Disable this script and instead add the assets manually after building.
|
|
||||||
: > sync-assets.js
|
|
||||||
|
|
||||||
${lib.optionalString (config != {}) ''
|
|
||||||
ln -s ${builtins.toFile "mempool-frontend-config" (builtins.toJSON config)} mempool-frontend-config.json
|
|
||||||
''}
|
|
||||||
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# Add assets that would otherwise be downloaded by sync-assets.js
|
|
||||||
${sync} ${frontendAssets}/ dist/mempool/browser/resources
|
|
||||||
|
|
||||||
runHook postBuild
|
|
||||||
'';
|
|
||||||
|
|
||||||
installPhase = ''
|
|
||||||
${sync} dist/mempool/browser/ $out
|
|
||||||
|
|
||||||
runHook postInstall
|
|
||||||
'';
|
|
||||||
|
|
||||||
passthru = {
|
|
||||||
withConfig = mkFrontend;
|
|
||||||
assets = frontendAssets;
|
|
||||||
nodeModules = nodeModules.frontend;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
mempool-rust-gbt = stdenvNoCC.mkDerivation rec {
|
|
||||||
pname = "mempool-rust-gbt";
|
|
||||||
inherit version src meta;
|
|
||||||
|
|
||||||
sourceRoot = "source/rust/gbt";
|
|
||||||
|
|
||||||
nativeBuildInputs = [
|
|
||||||
rustPlatform.cargoSetupHook
|
|
||||||
cargo
|
|
||||||
rustc
|
|
||||||
napi-rs-cli
|
|
||||||
];
|
|
||||||
|
|
||||||
cargoDeps = rustPlatform.fetchCargoVendor {
|
|
||||||
inherit src;
|
|
||||||
name = "${pname}-${version}";
|
|
||||||
inherit sourceRoot;
|
|
||||||
hash = "sha256-eox/K3ipjAqNyFt87lZnxaU/okQLF/KIhqXrX86n+qw=";
|
|
||||||
};
|
|
||||||
|
|
||||||
buildPhase = ''
|
|
||||||
runHook preBuild
|
|
||||||
# napi doesn't accept an absolute path as dest dir, so we can't directly write to $out
|
|
||||||
napi build --platform --release --strip out
|
|
||||||
runHook postBuild
|
|
||||||
'';
|
|
||||||
|
|
||||||
installPhase = ''
|
|
||||||
mv out $out
|
|
||||||
cp package.json $out
|
|
||||||
'';
|
|
||||||
|
|
||||||
passthru = { inherit cargoDeps; };
|
|
||||||
};
|
|
||||||
|
|
||||||
mempool-nginx-conf = runCommand "mempool-nginx-conf" {} ''
|
|
||||||
${sync} --chmod=u+w ${./nginx-conf}/ $out
|
|
||||||
${sync} ${src}/production/nginx/http-language.conf $out
|
|
||||||
'';
|
|
||||||
|
|
||||||
sync = "${rsync}/bin/rsync -a --inplace";
|
|
||||||
|
|
||||||
mkDerivationMempool = args: stdenvNoCC.mkDerivation ({
|
|
||||||
inherit version src meta;
|
|
||||||
|
|
||||||
nativeBuildInputs = [
|
|
||||||
makeWrapper
|
|
||||||
nodejs
|
|
||||||
rsync
|
|
||||||
];
|
|
||||||
|
|
||||||
phases = "unpackPhase patchPhase buildPhase installPhase";
|
|
||||||
} // args);
|
|
||||||
|
|
||||||
fetchFiles = { name, hash, fetcher }: stdenvNoCC.mkDerivation {
|
|
||||||
inherit name;
|
|
||||||
outputHashMode = "recursive";
|
|
||||||
outputHashAlgo = "sha256";
|
|
||||||
outputHash = hash;
|
|
||||||
nativeBuildInputs = [ curl cacert ];
|
|
||||||
buildCommand = ''
|
|
||||||
mkdir $out
|
|
||||||
cd $out
|
|
||||||
${builtins.readFile fetcher}
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
meta = with lib; {
|
|
||||||
description = "Bitcoin blockchain and mempool explorer";
|
|
||||||
homepage = "https://github.com/mempool/mempool/";
|
|
||||||
license = licenses.agpl3Plus;
|
|
||||||
maintainers = with maintainers; [ erikarvstedt ];
|
|
||||||
platforms = platforms.unix;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Fetch hash-locked versions of assets that are dynamically fetched via
|
|
||||||
# https://github.com/mempool/mempool/blob/master/frontend/sync-assets.js
|
|
||||||
# when running `npm run build` in the frontend.
|
|
||||||
#
|
|
||||||
# This file is updated by ./frontend-assets-update.sh
|
|
||||||
|
|
||||||
declare -A revs=(
|
|
||||||
["mempool/mining-pool-logos"]=53972ebbd08373cf4910cbb3e6421a1f3bba4563
|
|
||||||
)
|
|
||||||
|
|
||||||
fetchFile() {
|
|
||||||
repo=$1
|
|
||||||
file=$2
|
|
||||||
rev=${revs["$repo"]}
|
|
||||||
curl -fsS "https://raw.githubusercontent.com/$repo/$rev/$file"
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchRepo() {
|
|
||||||
repo=$1
|
|
||||||
rev=${revs["$repo"]}
|
|
||||||
curl -fsSL "https://github.com/$repo/archive/$rev.tar.gz"
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdir mining-pools
|
|
||||||
fetchRepo "mempool/mining-pool-logos" | tar xz --strip-components=1 -C mining-pools
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# Settings adapted from
|
|
||||||
# https://github.com/mempool/mempool/blob/v3.2.1/production/nginx/server-common.conf
|
|
||||||
|
|
||||||
# see order of nginx location rules
|
|
||||||
# https://stackoverflow.com/questions/5238377/nginx-location-priority
|
|
||||||
|
|
||||||
# for exact / requests, redirect based on $lang
|
|
||||||
# cache redirect for 5 minutes
|
|
||||||
location = / {
|
|
||||||
if ($lang != '') {
|
|
||||||
return 302 $scheme://$host/$lang/;
|
|
||||||
}
|
|
||||||
try_files /en-US/index.html =404;
|
|
||||||
expires 5m;
|
|
||||||
}
|
|
||||||
|
|
||||||
# cache /<lang>/main.f40e91d908a068a2.js forever since they never change
|
|
||||||
location ~ ^/([a-z][a-z])/(.+\..+\.(js|css))$ {
|
|
||||||
try_files $uri =404;
|
|
||||||
expires 1y;
|
|
||||||
}
|
|
||||||
# cache everything else for 5 minutes
|
|
||||||
location ~ ^/([a-z][a-z])$ {
|
|
||||||
try_files $uri /$1/index.html /en-US/index.html =404;
|
|
||||||
expires 5m;
|
|
||||||
}
|
|
||||||
location ~ ^/([a-z][a-z])/ {
|
|
||||||
try_files $uri /$1/index.html /en-US/index.html =404;
|
|
||||||
expires 5m;
|
|
||||||
}
|
|
||||||
|
|
||||||
# cache /resources/** for 1 week since they don't change often
|
|
||||||
location /resources {
|
|
||||||
try_files $uri /en-US/index.html;
|
|
||||||
expires 1w;
|
|
||||||
}
|
|
||||||
# cache /main.f40e91d908a068a2.js forever since they never change
|
|
||||||
location ~* ^/.+\..+\.(js|css)$ {
|
|
||||||
try_files /$lang/$uri /en-US/$uri =404;
|
|
||||||
expires 1y;
|
|
||||||
}
|
|
||||||
# catch-all for all URLs i.e. /address/foo /tx/foo /block/000
|
|
||||||
# cache 5 minutes since they change frequently
|
|
||||||
location / {
|
|
||||||
try_files /$lang/$uri $uri /en-US/$uri /en-US/index.html =404;
|
|
||||||
expires 5m;
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# Packaging adapted from fort-nix/nix-bitcoin commit 360e30fee.
|
|
||||||
# This local copy does not fetch or import nix-bitcoin.
|
|
||||||
{ lib
|
|
||||||
, stdenvNoCC
|
|
||||||
, nodejs_22
|
|
||||||
, nodejs-slim_22
|
|
||||||
, fetchNodeModules
|
|
||||||
, fetchurl
|
|
||||||
, makeWrapper
|
|
||||||
}:
|
|
||||||
let self = stdenvNoCC.mkDerivation {
|
|
||||||
pname = "rtl";
|
|
||||||
version = "0.15.8";
|
|
||||||
|
|
||||||
src = fetchurl {
|
|
||||||
url = "https://github.com/Ride-The-Lightning/RTL/archive/refs/tags/v${self.version}.tar.gz";
|
|
||||||
hash = "sha256-8XdGyORxB2dkZRB/Yl7zh+Quqo4L/Y0VmC6Brbr/hqU=";
|
|
||||||
};
|
|
||||||
|
|
||||||
passthru = {
|
|
||||||
nodejs = nodejs_22;
|
|
||||||
nodejsRuntime = nodejs-slim_22;
|
|
||||||
|
|
||||||
nodeModules = fetchNodeModules {
|
|
||||||
inherit (self) src nodejs;
|
|
||||||
# TODO-EXTERNAL: Remove `npmFlags` when no longer required
|
|
||||||
# See: https://github.com/Ride-The-Lightning/RTL/issues/1182
|
|
||||||
npmFlags = "--legacy-peer-deps";
|
|
||||||
hash = "sha256-oMqd6nLzS6iQ9w4z2yzpR2unA5qhOq5YdvfoS8IgYLY=";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
nativeBuildInputs = [
|
|
||||||
makeWrapper
|
|
||||||
];
|
|
||||||
|
|
||||||
phases = "unpackPhase patchPhase installPhase";
|
|
||||||
|
|
||||||
# `src` already contains the precompiled frontend and backend.
|
|
||||||
# Copy all files required for packaging, like in
|
|
||||||
# https://github.com/Ride-The-Lightning/RTL/blob/master/dockerfiles/Dockerfile
|
|
||||||
installPhase = ''
|
|
||||||
dest=$out/lib/node_modules/rtl
|
|
||||||
mkdir -p $dest
|
|
||||||
cp -r \
|
|
||||||
rtl.js \
|
|
||||||
package.json \
|
|
||||||
frontend \
|
|
||||||
backend \
|
|
||||||
${self.nodeModules}/lib/node_modules \
|
|
||||||
$dest
|
|
||||||
|
|
||||||
makeWrapper ${self.nodejsRuntime}/bin/node "$out/bin/rtl" \
|
|
||||||
--add-flags "$dest/rtl.js"
|
|
||||||
|
|
||||||
runHook postInstall
|
|
||||||
'';
|
|
||||||
|
|
||||||
meta = with lib; {
|
|
||||||
description = "A web interface for LND, c-lightning and Eclair";
|
|
||||||
homepage = "https://github.com/Ride-The-Lightning/RTL";
|
|
||||||
license = licenses.mit;
|
|
||||||
maintainers = with maintainers; [ nixbitcoin erikarvstedt ];
|
|
||||||
platforms = platforms.unix;
|
|
||||||
};
|
|
||||||
}; in self
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
{ nixpkgs, overlay-stable, system ? "x86_64-linux" }:
|
|
||||||
|
|
||||||
let
|
|
||||||
lib = nixpkgs.lib;
|
|
||||||
pkgs = import nixpkgs {
|
|
||||||
inherit system;
|
|
||||||
overlays = [ overlay-stable ];
|
|
||||||
};
|
|
||||||
|
|
||||||
normalize = s:
|
|
||||||
lib.replaceStrings [ "\n" "\\" " " ] [ " " "" " " ] s;
|
|
||||||
|
|
||||||
extractAfter = prefix: str:
|
|
||||||
let
|
|
||||||
match = builtins.match ".*${prefix} ([^ ]+).*" (normalize str);
|
|
||||||
in
|
|
||||||
if match == null then
|
|
||||||
throw "Unable to extract ${prefix} from: ${normalize str}"
|
|
||||||
else
|
|
||||||
builtins.head match;
|
|
||||||
|
|
||||||
extractFlagValue = flag: str:
|
|
||||||
let
|
|
||||||
match = builtins.match ".*${flag}=([^ ]+).*" (normalize str);
|
|
||||||
in
|
|
||||||
if match == null then
|
|
||||||
throw "Unable to extract ${flag} from: ${normalize str}"
|
|
||||||
else
|
|
||||||
builtins.head match;
|
|
||||||
|
|
||||||
config = (lib.nixosSystem {
|
|
||||||
inherit system;
|
|
||||||
modules = [
|
|
||||||
{ nixpkgs.hostPlatform = system; nixpkgs.overlays = [ overlay-stable ]; }
|
|
||||||
../modules/bitcoin
|
|
||||||
{
|
|
||||||
nix-bitcoin.generateSecrets = true;
|
|
||||||
nix-bitcoin.secretsDir = "/build/secrets";
|
|
||||||
|
|
||||||
services.btcpayserver.enable = true;
|
|
||||||
services.btcpayserver.lightningBackend = "lnd";
|
|
||||||
services.nbxplorer.dataDir = "/build/nbxplorer";
|
|
||||||
services.btcpayserver.dataDir = "/build/btcpayserver";
|
|
||||||
services.lnd.dataDir = "/build/lnd";
|
|
||||||
services.bitcoind.dataDir = "/build/bitcoind";
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}).config;
|
|
||||||
|
|
||||||
nbxplorerPreStart = config.systemd.services.nbxplorer.preStart;
|
|
||||||
bitcoindPreStart = config.systemd.services.bitcoind.preStart;
|
|
||||||
btcpayExecStart = config.systemd.services.btcpayserver.serviceConfig.ExecStart;
|
|
||||||
btcpayWorkingDir = config.systemd.services.btcpayserver.serviceConfig.WorkingDirectory;
|
|
||||||
|
|
||||||
nbxplorerConfigPath = extractAfter "install -m 600" nbxplorerPreStart;
|
|
||||||
btcpayConfigPath = extractFlagValue "--conf" btcpayExecStart;
|
|
||||||
|
|
||||||
nbxplorerConfig = builtins.readFile nbxplorerConfigPath;
|
|
||||||
btcpayConfig = builtins.readFile btcpayConfigPath;
|
|
||||||
in
|
|
||||||
assert lib.assertMsg
|
|
||||||
(config.users.users.${config.services.btcpayserver.user}.home == config.services.btcpayserver.dataDir)
|
|
||||||
"btcpayserver user home must match btcpayserver dataDir";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(config.users.users.${config.services.nbxplorer.user}.home == config.services.nbxplorer.dataDir)
|
|
||||||
"nbxplorer user home must match nbxplorer dataDir";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(config.nix-bitcoin.secrets.bitcoin-HMAC-btcpayserver.user == config.services.bitcoind.user)
|
|
||||||
"bitcoin-HMAC-btcpayserver must be owned by bitcoind";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(config.nix-bitcoin.secrets.bitcoin-rpcpassword-btcpayserver.user == config.services.bitcoind.user)
|
|
||||||
"bitcoin-rpcpassword-btcpayserver must be owned by bitcoind";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(config.nix-bitcoin.secrets.bitcoin-rpcpassword-btcpayserver.group == config.services.nbxplorer.group)
|
|
||||||
"bitcoin-rpcpassword-btcpayserver must be group-readable by nbxplorer";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(!(lib.elem config.services.nbxplorer.group config.users.users.${config.services.btcpayserver.user}.extraGroups))
|
|
||||||
"btcpayserver must not receive the nbxplorer group";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(lib.elem "nix-bitcoin-secrets.target" config.systemd.services.nbxplorer.after)
|
|
||||||
"nbxplorer must wait for nix-bitcoin-secrets.target";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(config.systemd.services.nbxplorer.serviceConfig.MemoryDenyWriteExecute == false)
|
|
||||||
"nbxplorer needs MemoryDenyWriteExecute = false";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(config.systemd.services.btcpayserver.serviceConfig.MemoryDenyWriteExecute == false)
|
|
||||||
"btcpayserver needs MemoryDenyWriteExecute = false";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(lib.hasInfix "network=mainnet" nbxplorerConfig
|
|
||||||
&& lib.hasInfix "btcrpcuser=btcpayserver" nbxplorerConfig
|
|
||||||
&& lib.hasInfix "btcnodeendpoint=127.0.0.1:8335" nbxplorerConfig
|
|
||||||
&& lib.hasInfix "bind=127.0.0.1" nbxplorerConfig
|
|
||||||
&& lib.hasInfix "port=24444" nbxplorerConfig
|
|
||||||
&& lib.hasInfix "postgres=User ID=nbxplorer;Host=/run/postgresql;Database=nbxplorer" nbxplorerConfig)
|
|
||||||
"nbxplorer base config must contain the expected non-secret settings";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(lib.hasInfix "btcexplorerurl=http://127.0.0.1:24444/" btcpayConfig
|
|
||||||
&& lib.hasInfix "btcexplorercookiefile=/build/nbxplorer/Main/.cookie" btcpayConfig)
|
|
||||||
"btcpayserver config must contain btcexplorerurl and btcexplorercookiefile";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(lib.hasSuffix "/lib/btcpayserver" btcpayWorkingDir)
|
|
||||||
"btcpayserver WorkingDirectory must end with /lib/btcpayserver";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(!lib.hasInfix "/build/btcpayserver/settings.config" btcpayExecStart
|
|
||||||
&& lib.hasInfix "--datadir='/build/btcpayserver'" btcpayExecStart)
|
|
||||||
"btcpayserver must use a deterministic config file plus --datadir";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(lib.hasInfix "network=mainnet" btcpayConfig
|
|
||||||
&& lib.hasInfix "bind=127.0.0.1" btcpayConfig
|
|
||||||
&& lib.hasInfix "port=23000" btcpayConfig
|
|
||||||
&& lib.hasInfix "btcexplorerurl=http://127.0.0.1:24444/" btcpayConfig
|
|
||||||
&& lib.hasInfix "explorer.postgres=User ID=nbxplorer;Host=/run/postgresql;Database=nbxplorer" btcpayConfig
|
|
||||||
&& lib.hasInfix "postgres=User ID=btcpayserver;Host=/run/postgresql;Database=btcpayserver" btcpayConfig
|
|
||||||
&& lib.hasInfix "btclightning=type=lnd-rest;server=https://127.0.0.1:8080/;macaroonfilepath=/run/lnd/btcpayserver.macaroon;certfilepath=/build/secrets/lnd-cert" btcpayConfig)
|
|
||||||
"btcpayserver config must preserve BTCPay, NBXplorer, database, and LND settings";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(lib.hasInfix "readValidatedRpcHmac()" bitcoindPreStart
|
|
||||||
&& lib.hasInfix ''if [[ ! -e "$hmacFile" ]]; then'' bitcoindPreStart
|
|
||||||
&& lib.hasInfix ''if [[ ! -r "$hmacFile" ]]; then'' bitcoindPreStart
|
|
||||||
&& lib.hasInfix ''if [[ -z "$hmacPayload" ]]; then'' bitcoindPreStart
|
|
||||||
&& lib.hasInfix ''^[[:xdigit:]]+\$[[:xdigit:]]+$'' bitcoindPreStart
|
|
||||||
&& lib.hasInfix ''Bitcoin RPC HMAC file has invalid format'' bitcoindPreStart
|
|
||||||
&& lib.hasInfix ''hmacPayload="$(readValidatedRpcHmac '/build/secrets/bitcoin-HMAC-btcpayserver')" || exit 1'' bitcoindPreStart)
|
|
||||||
"bitcoind preStart must validate missing, unreadable, empty, and malformed HMAC files";
|
|
||||||
pkgs.runCommand "bitcoin-btcpay-hardening" {} ''
|
|
||||||
mkdir -p /build/secrets /build/nbxplorer
|
|
||||||
|
|
||||||
printf '%s' 'first-password' > /build/secrets/bitcoin-rpcpassword-btcpayserver
|
|
||||||
bash -euo pipefail -c ${lib.escapeShellArg nbxplorerPreStart}
|
|
||||||
|
|
||||||
test "$(stat -c '%a' /build/nbxplorer/settings.config)" = "600"
|
|
||||||
test "$(grep -c '^btcrpcuser=' /build/nbxplorer/settings.config)" = "1"
|
|
||||||
test "$(grep -c '^btcrpcpassword=' /build/nbxplorer/settings.config)" = "1"
|
|
||||||
test "$(grep -c '^postgres=' /build/nbxplorer/settings.config)" = "1"
|
|
||||||
|
|
||||||
printf '%s' 'rotated-password' > /build/secrets/bitcoin-rpcpassword-btcpayserver
|
|
||||||
bash -euo pipefail -c ${lib.escapeShellArg nbxplorerPreStart}
|
|
||||||
|
|
||||||
test "$(grep -c '^btcrpcuser=' /build/nbxplorer/settings.config)" = "1"
|
|
||||||
test "$(grep -c '^btcrpcpassword=' /build/nbxplorer/settings.config)" = "1"
|
|
||||||
test "$(grep -c '^postgres=' /build/nbxplorer/settings.config)" = "1"
|
|
||||||
! grep -q 'first-password' /build/nbxplorer/settings.config
|
|
||||||
grep -q 'rotated-password' /build/nbxplorer/settings.config
|
|
||||||
|
|
||||||
touch "$out"
|
|
||||||
''
|
|
||||||
@@ -27,17 +27,12 @@ def _literal_assignment(source: str, name: str):
|
|||||||
|
|
||||||
|
|
||||||
class TestBitcoinTorGossipNixWiring(unittest.TestCase):
|
class TestBitcoinTorGossipNixWiring(unittest.TestCase):
|
||||||
def test_bitcoind_loopback_listener_is_always_enabled(self):
|
def test_integration_layer_maps_tor_gossip_option(self):
|
||||||
ecosystem = _read("modules", "bitcoinecosystem.nix")
|
"""The integration layer must bridge sovran_systemsOS.features.bitcoin-tor-gossip
|
||||||
self.assertIn("listen = true;", ecosystem)
|
to sovran-bitcoin.bitcoindTorGossip."""
|
||||||
self.assertIn("peerbloomfilters=1", ecosystem)
|
integration = _read("modules", "sovran-bitcoin-integration.nix")
|
||||||
|
self.assertIn("bitcoindTorGossip", integration)
|
||||||
def test_gossip_is_opt_in(self):
|
self.assertIn("bitcoin-tor-gossip", integration)
|
||||||
ecosystem = _read("modules", "bitcoinecosystem.nix")
|
|
||||||
self.assertIn(
|
|
||||||
"public = config.sovran_systemsOS.features.bitcoin-tor-gossip;",
|
|
||||||
ecosystem,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_hub_option_and_evaluated_state_are_declared(self):
|
def test_hub_option_and_evaluated_state_are_declared(self):
|
||||||
roles = _read("modules", "core", "roles.nix")
|
roles = _read("modules", "core", "roles.nix")
|
||||||
|
|||||||
@@ -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()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user