Compare commits
No files matched your search
-539
@@ -1,539 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Sovran_SystemsOS will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [1.2.1] - 2026-10-18
|
||||
|
||||
### Added
|
||||
- Mullvad: install the client on the Desktop Role only
|
||||
- Password-model.nix: fix Nix string escaping in the generate fallback
|
||||
- Password & keyring cleanup: one password, one truth, no prompts
|
||||
- Update security details in SECURITY.md
|
||||
- Enhance README formatting and emphasis on features
|
||||
- Revise SECURITY.md for clarity on Hub operations
|
||||
|
||||
### Changed
|
||||
- Update flake.lock (nixpkgs, Sovran_Bitcoin 1.1.1, Bisq2 2.1.14) and integration
|
||||
|
||||
### Fixed
|
||||
- Fix README formatting and clarify service exposure
|
||||
- Fix formatting and clarify server access instructions
|
||||
[1.2.1]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.2.1
|
||||
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Mullvad: install the VPN app and daemon, as NixOS ships them, on the Desktop Role only
|
||||
|
||||
### Changed
|
||||
- Updated `flake.lock` (`nixpkgs`, `nixpkgs-stable`, `nixvim`, `btc-clients` with Bisq2 `2.1.14`, and `Sovran_Bitcoin` `1.1.1` with RTL `0.15.13` and Nix-native `sovran-nwc` `2.0.0`)
|
||||
- Updated fallback service version map (`app/sovran_systemsos_web/versions.json`)
|
||||
- Fixed deprecated `pkgs.system` reference in `flake.nix` and guarded `config.networking.domain` in `modules/sovran-bitcoin-integration.nix` when `haven` and `nwc-wallets` are both enabled
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
- Update release-stable.sh
|
||||
- Enhance release script with preflight checks and metadata
|
||||
- Migrate dock/folder/mime entries from brave to brave-origin on upgrade
|
||||
- Skip Brave Origin startup dialog in Hub launcher
|
||||
- Switch default browser to Brave Origin (brave-origin)
|
||||
- Add Bitcoin Core Tor IBD gossip control
|
||||
- Replace Bitcoin Knots with Bitcoin Core
|
||||
|
||||
### Fixed
|
||||
- Persistent browser profile so logout survives window reopen
|
||||
- Recover from expired sessions and preserve logout
|
||||
- Prevent Bitcoin Core switch from hanging the Hub UI
|
||||
|
||||
### Documentation
|
||||
- Update README ISO download links to v1.1.0
|
||||
[1.1.1]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.1
|
||||
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- Replaced Brave Browser with Brave Origin (the new `brave-origin` nixpkgs
|
||||
package) as the desktop's default browser: installed package, dock
|
||||
favorite, Browsers app-folder entry, `xdg.mime.defaultApplications`, and
|
||||
the `$BROWSER` session variable now all point at Brave Origin, and the
|
||||
Sovran Hub launcher runs it.
|
||||
- On installs upgrading from the old Brave package, the desktop theme init
|
||||
now migrates `brave-browser.desktop` → `brave-origin.desktop` in the dock
|
||||
favorites, the Browsers app folder, and the user's `mimeapps.list`, so
|
||||
the dock icon and default-browser handler survive the switch without a
|
||||
manual re-pin.
|
||||
|
||||
### 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
|
||||
(`$XDG_STATE_HOME/sovran-hub-browser`) instead of a throwaway `/tmp`
|
||||
profile that was deleted on exit. The throwaway profile wiped the
|
||||
`hub_manual_logout` marker cookie on every close, so `/auto-login`
|
||||
silently logged the user straight back in after they signed out and
|
||||
reopened the Hub window. The persistent profile makes explicit logout
|
||||
stick (until the next password login) while still skipping Brave Origin's
|
||||
one-time startup dialog.
|
||||
- The Hub now redirects to the login page when an API request finds an
|
||||
expired browser session, instead of leaving the dashboard tile grid in a
|
||||
loading state while `/api/services` continues returning 401 every five
|
||||
seconds.
|
||||
- Explicitly logging out now suppresses the desktop launcher's local
|
||||
auto-login after the Hub window is closed and reopened. A successful
|
||||
password login clears that preference.
|
||||
- Sessions are now persisted to `/var/lib/secrets/hub-sessions.json` so the
|
||||
browser login survives the Hub service restart that `nixos-rebuild switch`
|
||||
performs during activation. Previously the in-memory session store was
|
||||
wiped by that restart, the `/api/rebuild/status` poll started returning
|
||||
401, and the rebuild modal spun forever showing "Applying changes…" while
|
||||
the switch result was never displayed.
|
||||
- Rebuild and update modals now bail out and reload the page after sustained
|
||||
polling failures instead of hanging indefinitely.
|
||||
- Rebuild/update scripts stream `nixos-rebuild` output into the live log
|
||||
(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.
|
||||
|
||||
---
|
||||
|
||||
## [1.1.0] - 2026-08-11
|
||||
|
||||
### Added
|
||||
- Speed up Hub service status loading
|
||||
- Update Documentation
|
||||
- Njalla.nix: fail on ImportError; fix redundant except clause
|
||||
- Correctly escape DDNS placeholder in Nix string
|
||||
- Security hardening: fix all 8 blocking findings for PR #419
|
||||
- Security hardening: fix DDNS injection, Nix injection, reboot auth, support key, sudo rules
|
||||
- Harden btcpay and nbxplorer config handling
|
||||
- Add provenance headers and fix flake checks for PR409
|
||||
- Restore upstream nix-bitcoin fetchNodeModules for mempool and RTL packages
|
||||
- Refactor Nix build configuration for mempool
|
||||
- Update npm dependencies and patch handling in default.nix
|
||||
- Update npmDepsHash for backend and frontend packages
|
||||
- Mempool: use postPatch to copy lock file for npmDeps fetcher
|
||||
- Mempool: use preBuild cd instead of postUnpack so npmDeps fetcher finds lock files
|
||||
- Mempool: fix npmDeps fetcher — postUnpack instead of sourceRoot, postPatch instead of patches
|
||||
- Mempool: switch backend and frontend to buildNpmPackage with placeholder npmDepsHash
|
||||
- Update npmDepsHash with correct hash value
|
||||
- RTL: switch to buildNpmPackage with placeholder npmDepsHash
|
||||
- Add bitcoind.rpc.users.btcpayserver for NBXplorer
|
||||
- Restore original flake.nix with nixosModules.Sovran_SystemsOS export
|
||||
- Add vendored RTL package and fix rtl.nix to use it
|
||||
- Vendor mempool packages and wire mempool module to vendored pkgs
|
||||
- Refactor onion service configuration for LND
|
||||
- Add missing bitcoind-rpc-public-whitelist.nix
|
||||
- Remove joinmarket-ob-watcher from onion-services defaults
|
||||
- Tailor: make bitcoin modules truly Sovran-only (lnd-only) and delete stubs.nix
|
||||
- Vendor: replace nix-bitcoin flake input with minimal vendored modules (nixpkgs-only)
|
||||
- Remove duplicate 1.0.6 release notes from CHANGELOG
|
||||
|
||||
### Changed
|
||||
- Clean mempool module comment typo
|
||||
- Move vendor/nix-bitcoin to modules/bitcoin, remove overlays
|
||||
- Nix flake update - drop nix-bitcoin
|
||||
|
||||
### Fixed
|
||||
- Correct RTL and Mempool Hub versions
|
||||
- Fix DDNS URL validation: replace ${IP} temporarily for validator, keep placeholder for storage
|
||||
- Fix all 8 security hardening blockers for PR #423
|
||||
- Fix Nix interpolation in DDNS runner
|
||||
- Fix IP validation in DDNS and document journalctl sudo rule
|
||||
- Report configured BTCPay Server version
|
||||
- Fix Matrix SIGPIPE and RTL v0.15.8 config schema regressions
|
||||
- Add NBXplorer cookie auth and WorkingDirectory for BTCPay service
|
||||
- Fix BTCPay startup regression: add home dirs to service users
|
||||
- Address btcpay hardening review feedback
|
||||
- Register bitcoin-HMAC-btcpayserver as managed secret owned by bitcoind user
|
||||
- Fix RTL: use fetchNodeModules instead of npm ci in buildPhase
|
||||
- Fix PostgreSQL ensureUsers: use ensureDBOwnership instead of ensureClauses
|
||||
- Fix lnd macaroons: replace invalid 'enable' with 'user'
|
||||
- Fix postgresql ensurePermissions -> ensureClauses for nixpkgs unstable
|
||||
- Fix btcpayserver.nix to use pkgs.stable overlay
|
||||
- Fix btcpayserver syntax error and pin to nixpkgs-stable (2.4.2)
|
||||
- Fix mempool package: remove fetchNodeModules dependency
|
||||
- Fix typo in onion-addresses service check
|
||||
- Fix formatting of extraGroups in btcpayserver.nix
|
||||
- Add missing semicolon after extraGroups in btcpayserver.nix
|
||||
- Remove services.clightning.enable assignment that fails on f13ff45
|
||||
- Remove clightning and clightning-rest from stubs to avoid duplicate on f13ff45
|
||||
- Add stubs for nixpkgs-unstable 2026-08 where services.clightning removed
|
||||
- Keep ISO artifacts out of the repo and auto-update README on release
|
||||
[1.1.0]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.0
|
||||
|
||||
|
||||
## [1.0.6] - 2026-08-07
|
||||
|
||||
### Added
|
||||
- Iso: fix replaceStrings length mismatch in cleanVersion
|
||||
- Promote virtual machine trial option in README
|
||||
- Improve installer VM compatibility
|
||||
- Trim redundant dev-vs-stable Gitea explanations in README
|
||||
- Add Arean.Ai to AI tools used for development
|
||||
- Prefix OS version badge with 'v' (v1.0.5)
|
||||
- Match OS version badge styling to service modal version badges
|
||||
- Place OS version badge inline right after the Hub title
|
||||
- Center OS version badge under the Hub title
|
||||
- Strengthen entropy section: add DYOR emphasis and hardware wallet verification note
|
||||
- Add 'About Bitcoin wallet entropy' section to README
|
||||
- README: balance intro between Bitcoin sovereignty and sovereign computing
|
||||
|
||||
### Changed
|
||||
- Removed temp patch file
|
||||
|
||||
### Fixed
|
||||
- Resolve tag range detection and improve release diagnostics
|
||||
- Add token scope diagnostics and un-silence gh release errors
|
||||
- Harden error handling and sanitize exception details across security endpoints (CWE-209)
|
||||
- Sanitize exception handling in verify-integrity and security-reset (CWE-209)
|
||||
- Sanitize api_security_reset errors to prevent exception information exposure (CWE-209)
|
||||
- Use canonical prefix containment check for CodeQL path-injection
|
||||
- Pass sanitized abs_path to os.chown to resolve CodeQL path injection at 4338
|
||||
- Add CodeQL-recognized path sanitization for domain_name
|
||||
- Remove domain substring check for CodeQL incomplete-url
|
||||
- Use sentinel for njalla header check (CodeQL incomplete-url-substring)
|
||||
- Prevent reflected XSS in lnurl-qr print endpoint
|
||||
- Hash root password instead of storing in clear text (CWE-312)
|
||||
- Separate web auth hash from system password file
|
||||
- Fix Matrix Hub admin API credentials
|
||||
- Fix CWE-78: replace subprocess call with Synapse Admin API in create-user endpoint
|
||||
|
||||
### Documentation
|
||||
- Add SECURITY.md detailing security policy and best practices
|
||||
- Versioned CDN downloads + add CDN upload script
|
||||
- Correct active development workflow
|
||||
- Make repo references mirror-neutral for Gitea readers
|
||||
- Clarify GitHub is the dev mirror of the Gitea stable repo
|
||||
[1.0.6]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.0.6
|
||||
|
||||
## [1.0.5] - 2026-08-04
|
||||
|
||||
### Added
|
||||
- LND Update to v0.21.1-beta
|
||||
- Nixpkgs Update
|
||||
- Nixpkgs Update
|
||||
- Update LND REST Zeus Connect instructions and design to match NWC exactly
|
||||
- UI: Align LND REST and NWC Zeus connection instructions styling
|
||||
- Make Zeus LND REST instructions coherent with NWC Zeus connect guide
|
||||
- Clarify Zeus NWC wallet setup
|
||||
- Make Manual Backup match the system role (Desktop Only scope)
|
||||
- Reload Caddy immediately when a domain (or ACME email) is saved
|
||||
- Unify Njal.la + router port-forwarding guidance across onboarding and feature enable modals
|
||||
- Enhance Lightning Wallet Connect modal with professional benefits grid and refined messaging
|
||||
|
||||
### Fixed
|
||||
- Resolve 'vdev' version badge and align it under the Hub title
|
||||
- Align release script with Gitea staging-dev and stable branch workflow
|
||||
- Auto-detect git remotes and clean up temp files in release-stable.sh
|
||||
|
||||
### Documentation
|
||||
- Write real v1.0.4 changelog and auto-generate release notes in release script
|
||||
[1.0.5]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.0.5
|
||||
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- Report the version of the configured BTCPay Server package in the Hub instead of the different package version from unstable nixpkgs.
|
||||
- Installer VM compatibility: legacy BIOS VM boots now install with GRUB, UEFI VM installs avoid depending on NVRAM boot-entry writes, VM users get clearer disk/resource guidance, and the internet check falls back to HTTPS when ICMP is blocked.
|
||||
- **Manual Backup now matches the system role**: on the Desktop Only role the Hub's
|
||||
"What gets backed up" list no longer shows Node / Server + Desktop items
|
||||
(nix-bitcoin secrets, `/var/lib` system service data, and the database/blockchain
|
||||
caveats), and the backup script skips those stages entirely — Desktop Only backups
|
||||
now mirror only the NixOS configuration (`/etc/nixos`) and home directory (`/home`).
|
||||
The free-space estimate, stage numbering, backup manifest, and completion message
|
||||
are all role-aware; Node and Server + Desktop backups are unchanged.
|
||||
|
||||
---
|
||||
|
||||
## [1.0.4] - 2026-07-29
|
||||
|
||||
### Added
|
||||
- **Lightning Wallet Connections (NWC)** — Hub-managed Nostr Wallet Connect powered by Alby Hub + LND:
|
||||
- Create, view, and delete wallet connections directly from the Sovran Hub with a tabbed modal UI
|
||||
- Downloadable/printable LNURL QR codes for connecting external wallets
|
||||
- Channel liquidity guide and onboarding guidance for new LND nodes
|
||||
- Unique-hostname enforcement with conflict validation and UI guidance
|
||||
- Official NWC branding and logo
|
||||
- **Version visibility across the Hub**:
|
||||
- Sovran_SystemsOS version badge displayed under the Hub title
|
||||
- Version numbers shown on all Hub service tiles and next to service modal titles
|
||||
- Deployed PHP app versions surfaced in service titles
|
||||
- Build-time version reference file (`VERSION`) so version lookups are instantaneous and consistent
|
||||
- **Automated stable release workflow** (`scripts/release-stable.sh`) with versioned ISO naming from the `VERSION` file
|
||||
- `CONTRIBUTING.md` and expanded project documentation
|
||||
|
||||
### Changed
|
||||
- **Manual Backup overhauled**: replaced tar+DB+LND archive approach with a reliable ext4 + rsync workflow, including
|
||||
mount checks, path safety, atomic completion markers, stale-marker cleanup, and behavioral test coverage
|
||||
- **Port-forwarding UX simplified**: removed onboarding Step 4 and the misleading local "ready" status;
|
||||
Njal.la DDNS now runs automatically when the feature is enabled
|
||||
- README restructured for clarity, links, and accuracy; added router/ISP port-forwarding requirements
|
||||
for Server + Desktop mode; acknowledged LiveKit and Alby Hub
|
||||
- Updated nixpkgs and Bitcoin clients
|
||||
- Repository cleanup: removed unused `.github`, `.tests`, `nix/`, and `docs/ai` directories
|
||||
|
||||
### Fixed
|
||||
- NWC wallet certificate path wiring (now uses the nix-bitcoin LND cert path)
|
||||
- Deterministic LND/Alby Hub port collision
|
||||
- Alby Hub executable resolution and v1.23.0 patches regenerated against exact upstream source
|
||||
- Manual Backup exit-code failures (bash/gawk in service PATH, tar tolerance, flock, browser-cache exclusions)
|
||||
- rsync destination-directory failures in backups (auto `mkdir -p`, mount check, 19 behavioral tests)
|
||||
- `sovran-hosts-update` converted to `writeShellApplication` with explicit runtime inputs
|
||||
- Incorrect `lib.mkIf` usage in the NWC wallets module
|
||||
- Duplicate systemd LND strings
|
||||
- `git fetch` tag-clobber errors against Gitea (now uses `--force`)
|
||||
|
||||
### Security
|
||||
- Hardened Lightning Wallet Connections (NWC): restricted `ReadOnlyPaths` for the unlock password,
|
||||
fixed an Authorization-header bug, eliminated stack-trace exposure flagged by CodeQL,
|
||||
and tightened credential access and amount validation
|
||||
|
||||
[1.0.4]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.0.4
|
||||
|
||||
|
||||
## [1.0.3] - 2026-07-29
|
||||
|
||||
### Added
|
||||
- Incremental stable updates
|
||||
|
||||
## [1.0.2] - 2026-07-29
|
||||
|
||||
### Added
|
||||
- Incremental stable updates
|
||||
|
||||
## [1.0.1] - 2026-07-29
|
||||
|
||||
### Added
|
||||
- Incremental stable updates
|
||||
|
||||
## [1.0.0] - 2026-07-29
|
||||
|
||||
### Added
|
||||
- Initial stable release of Sovran_SystemsOS
|
||||
- Full Bitcoin self-custody desktop environment
|
||||
- Integrated Sparrow Wallet, Bisq, and Bisq 2
|
||||
- Comprehensive Sovran Hub for service management
|
||||
- NixOS-based operating system with privacy and sovereignty focus
|
||||
- Support for Bitcoin Core, Electrs, LND, Ride The Lightning, BTCPay Server, and more
|
||||
- Server + Desktop hybrid mode with Matrix, Nextcloud, VaultWarden, and other self-hosted services
|
||||
- Automated installer with graphical GNOME desktop
|
||||
- Tor integration and onion services for all major components
|
||||
- Remote desktop (GNOME Remote Desktop) support
|
||||
- Role-based system configuration (desktop, server, server+desktop)
|
||||
- Detailed service credential management in the Sovran Hub
|
||||
|
||||
### Changed
|
||||
- Moved from development/main branch to dedicated `stable` branch for production releases
|
||||
|
||||
### Security
|
||||
- All services run with least-privilege principles where possible
|
||||
- Secrets stored in `/var/lib/secrets/`
|
||||
- Strong emphasis on user-controlled keys and self-sovereignty
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- (Nothing yet)
|
||||
|
||||
[1.0.3]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.0.3
|
||||
[1.0.2]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.0.2
|
||||
[1.0.1]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.0.1
|
||||
[1.0.0]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.0.0
|
||||
-112
@@ -1,112 +0,0 @@
|
||||
# Contributing to Sovran Systems OS
|
||||
|
||||
First off, thank you for considering contributing to Sovran_SystemsOS! 🎉
|
||||
|
||||
## Development and Release Repositories
|
||||
|
||||
The GitHub repository is the primary location for Sovran_SystemsOS development
|
||||
and collaboration and serves as the project's connection to the GitHub
|
||||
ecosystem. Most work is contributed through feature branches and pull requests
|
||||
that target GitHub `main`. Development may also begin in a local branch or on
|
||||
`staging-dev` at the project's self-hosted Gitea instance:
|
||||
https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS.
|
||||
|
||||
GitHub `main` and Gitea `staging-dev` are kept synchronized as the active
|
||||
development line, although one may briefly lead the other while changes are
|
||||
being integrated. The canonical, release-ready code is maintained on Gitea's
|
||||
[`stable` branch](https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/src/branch/stable),
|
||||
in keeping with the self-sovereignty ethos of Sovran_SystemsOS.
|
||||
|
||||
The workflow is:
|
||||
|
||||
1. Most development and project collaboration — including issues, pull
|
||||
requests, and reviews — happens through GitHub, with completed work
|
||||
integrated into `main`.
|
||||
2. Work may also originate locally or on Gitea `staging-dev`. Accepted changes
|
||||
are synchronized between GitHub `main` and Gitea `staging-dev`.
|
||||
3. When changes are complete and tested, they are promoted to Gitea `stable`,
|
||||
which is the code used by released Sovran_SystemsOS builds.
|
||||
|
||||
Please note: GitHub `main` and Gitea `staging-dev` may contain new features and
|
||||
code not yet in `stable`, and that code may not be fully tested.
|
||||
|
||||
Moreover, Sovran_SystemsOS has been improved with the help of AI. We have used Copilot and Arena.ai to work through significant coding challenges and troubleshooting hurdles. We will continue to use AI to help keep Sovran_SystemsOS stable and maintained.
|
||||
|
||||
## How Can I Contribute?
|
||||
|
||||
### 🐛 Reporting Bugs
|
||||
- Open an [Issue](https://github.com/naturallaw777/Sovran_SystemsOS/issues)
|
||||
- Use a clear and descriptive title
|
||||
- Describe the steps to reproduce the bug
|
||||
- Include your OS version, hardware specs, and any relevant logs
|
||||
|
||||
### 💡 Suggesting Features
|
||||
- Open an [Issue](https://github.com/naturallaw777/Sovran_SystemsOS/issues) with the tag `enhancement`
|
||||
- Explain the feature and why it would be useful
|
||||
- Be as detailed as possible
|
||||
|
||||
### 🔧 Submitting Code Changes
|
||||
|
||||
#### 1. Fork the Repository
|
||||
Click the "Fork" button at the top right of the GitHub repository.
|
||||
|
||||
#### 2. Clone Your Fork
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/Sovran_SystemsOS.git
|
||||
cd Sovran_SystemsOS
|
||||
```
|
||||
|
||||
#### 3. Create a Feature Branch
|
||||
```bash
|
||||
git checkout -b feature/your-feature-name
|
||||
```
|
||||
|
||||
#### 4. Make Your Changes
|
||||
- Write clean, well-commented code
|
||||
- Follow the existing code style and structure
|
||||
- Test your changes thoroughly
|
||||
|
||||
#### 5. Commit Your Changes
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Brief description of your changes"
|
||||
```
|
||||
|
||||
#### 6. Push to Your Fork
|
||||
```bash
|
||||
git push origin feature/your-feature-name
|
||||
```
|
||||
|
||||
#### 7. Open a Pull Request
|
||||
- Go to the original repository on GitHub
|
||||
- Click "New Pull Request"
|
||||
- Select your fork and branch
|
||||
- Provide a clear title and description of your changes
|
||||
- Reference any related Issues (e.g., "Closes #12")
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
- **One PR per feature/fix.** Do not bundle unrelated changes.
|
||||
- **Keep PRs small and focused.** Smaller PRs are easier to review and merge.
|
||||
- **Write meaningful commit messages.**
|
||||
- **Do not push directly to `main`.** Always use a feature branch and open a PR.
|
||||
- **Be patient.** PRs will be reviewed as soon as possible.
|
||||
|
||||
## Security-Sensitive Changes
|
||||
|
||||
- Report vulnerabilities privately as described in [`SECURITY.md`](SECURITY.md).
|
||||
- Pin upstream revisions and hashes; do not use floating source references.
|
||||
- Update [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md) when importing code.
|
||||
- Add regression tests and describe any threat-model change in the pull request.
|
||||
|
||||
## Code Style
|
||||
|
||||
- Follow the existing patterns in the codebase
|
||||
- Comment your code where necessary
|
||||
- Keep functions small and focused
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
By participating in this project, you agree to be respectful and constructive.
|
||||
We are building something meaningful — let's do it together with integrity.
|
||||
|
||||
@@ -1,889 +1,151 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="iso/assets/sovran-hub-icon.svg" alt="Sovran Systems logo" width="160" />
|
||||
<img src="iso/assets/sovran-hub-icon.svg" alt="Sovran Systems" width="160" />
|
||||
|
||||
# Sovran_SystemsOS
|
||||
|
||||
### Bitcoin sovereignty. Sovereign computing. One system.
|
||||
`Base Development` · NixOS Flake · AGPL-3.0
|
||||
|
||||
Sovran_SystemsOS is a free and open-source operating system built for two
|
||||
inseparable freedoms: **Bitcoin self-custody** and **sovereign computing**.
|
||||
Hold your own keys, trade peer-to-peer, and verify your own money with your
|
||||
own node. Then claim that same uncompromising ownership over the rest of your
|
||||
digital life — your files, communications, passwords, and websites — all on
|
||||
hardware you control, running auditable open-source software you can trust.
|
||||
|
||||
Every installation is a private [NixOS](https://nixos.org) desktop with
|
||||
**[Sparrow Wallet](https://sparrowwallet.com)**, **[Bisq](https://bisq.network)**,
|
||||
and **[Bisq 2](https://github.com/bisq-network/bisq2)** ready to use. Move beyond
|
||||
custodial exchanges from the first boot, and grow into your own Bitcoin node,
|
||||
Lightning infrastructure, private cloud, and communications platform when you
|
||||
are ready.
|
||||
|
||||
[Visit the Website](https://sovransystems.com) ·
|
||||
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.1.iso) ·
|
||||
[Try it safely in a VM](#try-it-first-in-a-virtual-machine) ·
|
||||
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.1.iso.sha256) ·
|
||||
[Build from Source](#build-from-source)
|
||||
|
||||
<img src="assets/desktop-screenshot.webp" alt="Sovran_SystemsOS private Bitcoin desktop" width="800" />
|
||||
|
||||
*Bitcoin sovereignty from the first boot.*
|
||||
[Sovran Systems](https://sovransystems.com)
|
||||
|
||||
</div>
|
||||
|
||||
> **📌 Active development on GitHub `main` / Gitea `staging-dev` — releases on
|
||||
> [Gitea `stable`](https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/src/branch/stable).**
|
||||
> See [Development workflow](#development-workflow) for details.
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [What This Repo Is](#what-this-repo-is)
|
||||
2. [Architecture](#architecture)
|
||||
3. [Module Catalog](#module-catalog)
|
||||
4. [The Three Modes (internal reference)](#the-three-modes-internal-reference)
|
||||
5. [Build & Deploy Reference](#build--deploy-reference)
|
||||
6. [Networking & Reverse Proxy](#networking--reverse-proxy)
|
||||
7. [Security Posture](#security-posture)
|
||||
8. [Backups & Recovery](#backups--recovery)
|
||||
9. [License](#license)
|
||||
|
||||
---
|
||||
|
||||
## Contents
|
||||
## What This Repo Is
|
||||
|
||||
- [Why Sovran_SystemsOS?](#why-sovran_systemsos)
|
||||
- [Try it first in a virtual machine](#try-it-first-in-a-virtual-machine)
|
||||
- [What is included](#what-is-included)
|
||||
- [Three modes](#three-modes)
|
||||
- [Use it your way](#use-it-your-way)
|
||||
- [The Hub](#the-hub)
|
||||
- [Install Sovran_SystemsOS](#install-sovran_systemsos)
|
||||
- [For developers](#for-developers)
|
||||
- [Development workflow](#development-workflow)
|
||||
- [Build from source](#build-from-source)
|
||||
- [Publishing a release](#publishing-a-release)
|
||||
- [About Bitcoin wallet entropy](#about-bitcoin-wallet-entropy)
|
||||
- [Security approach](#security-approach)
|
||||
- [Acknowledgements](#acknowledgements)
|
||||
- [License](#license) · [Contributing](#contributing)
|
||||
Sovran_SystemsOS is defined entirely as a **Nix flake** (`flake.nix`) and built from source. There is no pre-built binary — the System Installer is produced from this tree. Everything the system does is declared here.
|
||||
|
||||
---
|
||||
The control center is the **Hub** — a built-in panel that lets the operator launch, monitor, and toggle services without touching a terminal. Under the hood, the Hub writes to `custom.nix`, which feeds back into the flake.
|
||||
|
||||
## Why Sovran_SystemsOS?
|
||||
## Architecture
|
||||
|
||||
Bitcoin lets you hold and transfer value without asking a bank, exchange, or
|
||||
custodian for permission, but that freedom depends on the software and
|
||||
infrastructure you choose. Your wider digital life works the same way: files,
|
||||
messages, and passwords kept on someone else's servers are never fully yours.
|
||||
|
||||
Sovran_SystemsOS solves both with one operating system:
|
||||
|
||||
- **Hold your own keys** with **Sparrow Wallet**. Create and manage wallets,
|
||||
connect hardware signing devices, use multisignature setups, build and
|
||||
inspect transactions, and control UTXOs and coin selection.
|
||||
- **Buy and sell Bitcoin peer-to-peer** with **Bisq** and **Bisq 2**. No
|
||||
central company holds user funds, and no exchange account stands between
|
||||
buyers and sellers.
|
||||
- **Verify your own Bitcoin** with a full node: [Bitcoin Core](https://bitcoin.org) and
|
||||
[Electrs](https://github.com/romanz/electrs), so your wallets connect to
|
||||
*your* node instead of a stranger's.
|
||||
- **Use Lightning** with [LND](https://github.com/lightningnetwork/lnd) and
|
||||
[Ride The Lightning](https://github.com/Ride-The-Lightning/RTL).
|
||||
- **Accept Bitcoin directly** with [BTCPay Server](https://btcpayserver.org),
|
||||
with no payment processor in the middle.
|
||||
- **Own the rest of your digital life** with
|
||||
[Nextcloud](https://nextcloud.com) files and calendars,
|
||||
[Matrix](https://matrix.org) communications, a
|
||||
[Vaultwarden](https://github.com/dani-garcia/vaultwarden) password vault,
|
||||
and your own [WordPress](https://wordpress.org) website.
|
||||
- **Protect your network privacy** with [Tor](https://www.torproject.org)
|
||||
integration across the Bitcoin stack.
|
||||
- **Control everything from the Sovran Hub**, on the desktop or from any
|
||||
browser on your local network.
|
||||
|
||||
Other projects solve one piece of this puzzle: a distribution that runs a
|
||||
wallet, a node project that runs Bitcoin services, a self-hosting stack that
|
||||
replaces a cloud app. Sovran_SystemsOS brings them together on a reproducible,
|
||||
auditable NixOS foundation: Bitcoin tools from the first boot, one control
|
||||
center, and hardware you own.
|
||||
|
||||
> Sovran_SystemsOS provides tools for self-custody and peer-to-peer Bitcoin
|
||||
> use. Users remain responsible for protecting their keys, understanding their
|
||||
> trades, following applicable laws, and maintaining secure backups.
|
||||
|
||||
---
|
||||
|
||||
## Try it first in a virtual machine
|
||||
|
||||
**Curious, but not ready to replace your current operating system?** Start with
|
||||
Sovran_SystemsOS in a virtual machine (VM): a window on your existing Windows,
|
||||
macOS, or Linux computer, using a virtual disk file instead of your internal
|
||||
drive. Explore the desktop, Sovran Hub, Sparrow, Bisq, and the installation
|
||||
experience before changing how you use any physical machine. Closing or
|
||||
deleting the VM leaves your current operating system untouched, and installing
|
||||
on a dedicated computer later is still open to you.
|
||||
|
||||
Use the same ISO with [VirtualBox](https://www.virtualbox.org), VMware,
|
||||
QEMU/KVM, Proxmox, and similar x86_64 VM software. For a first look, select
|
||||
**Desktop Only**, allocate at least **8 GB RAM**, and create a **256 GB or
|
||||
larger dynamically allocated virtual disk**. Full requirements are in
|
||||
[Installing in a virtual machine](#installing-in-a-virtual-machine-optional).
|
||||
|
||||
> **A VM is for evaluation and learning, not a substitute for a dedicated,
|
||||
> hardened setup.** Do not hold meaningful Bitcoin, recovery phrases,
|
||||
> passwords, or other sensitive data in a trial VM. Avoid attaching physical
|
||||
> drives, and be deliberate about shared folders, clipboard sharing, and
|
||||
> network settings. Review VM disk selections before confirming an install.
|
||||
|
||||
---
|
||||
|
||||
## What is included
|
||||
|
||||
Depending on the selected mode and enabled features, Sovran_SystemsOS brings
|
||||
together a growing collection of private, open-source tools. The Sovran Hub
|
||||
presents and manages the features available on your system.
|
||||
|
||||
### Your money — Bitcoin sovereignty
|
||||
|
||||
- Bitcoin Core, Electrs, and Tor integration
|
||||
- LND and Ride The Lightning, with [Alby Hub](https://albyhub.com) for Nostr
|
||||
Wallet Connect (NWC) connections
|
||||
- BTCPay Server
|
||||
- Sparrow Wallet, Bisq, and Bisq 2, with automatic wallet-to-node connections
|
||||
- Optional: a self-hosted [Mempool](https://github.com/mempool/mempool)
|
||||
explorer
|
||||
|
||||
Run your own Bitcoin infrastructure. Verify your own money. Trust no one.
|
||||
|
||||
### Your voice — private communications
|
||||
|
||||
- The [Synapse](https://github.com/element-hq/synapse) homeserver (Matrix) and
|
||||
the [Element](https://element.io) client
|
||||
- Optional Matrix-native Element audio and video calling, powered by
|
||||
[LiveKit](https://livekit.io)
|
||||
- Optional [Haven](https://github.com/bitvora/haven)
|
||||
[Nostr](https://github.com/nostr-protocol/nostr) relay
|
||||
|
||||
Communicate without making Big Tech the owner of your identity or
|
||||
conversations.
|
||||
|
||||
### Your cloud — self-hosting and storage
|
||||
|
||||
- Nextcloud files, calendars, and contacts
|
||||
- Vaultwarden password vault
|
||||
- WordPress websites
|
||||
- [Caddy](https://caddyserver.com) with private service domains
|
||||
- Optional remote desktop
|
||||
|
||||
Keep your files, passwords, calendar, contacts, website, and services on
|
||||
hardware you control.
|
||||
|
||||
### Your desktop
|
||||
|
||||
- [GNOME](https://www.gnome.org) desktop
|
||||
- [Brave Origin](https://brave.com/origin/) and
|
||||
[Firefox](https://www.mozilla.org/firefox) browsers
|
||||
- [Mullvad VPN](https://mullvad.net) desktop app and daemon, on Desktop Only
|
||||
- File management, email, calendar, and office applications
|
||||
- System monitoring and administration tools
|
||||
|
||||
---
|
||||
|
||||
## Three modes
|
||||
|
||||
Every mode shares the same foundation: the private NixOS and GNOME desktop,
|
||||
the Sovran Hub, and Sparrow, Bisq, and Bisq 2. What changes is how much
|
||||
Bitcoin and self-hosting infrastructure runs on the machine.
|
||||
|
||||
| Mode | Best for | What you get |
|
||||
|---|---|---|
|
||||
| **Desktop** | Everyday users and computers with modest hardware | Sparrow, Bisq, and Bisq 2 for self-custody and peer-to-peer Bitcoin use, plus the Mullvad VPN app |
|
||||
| **Node** | People ready to verify and operate their own Bitcoin infrastructure | Everything in Desktop, plus the full Bitcoin stack: Bitcoin Core, Electrs, LND, Ride The Lightning, BTCPay Server, and wallet-to-node connections. **Publishes nothing unless you enable *Put BTCPay Server Online* or *Lightning Wallet Connections*:** [read this first](#server--desktop-and-your-home-ip-address) |
|
||||
| **Server + Desktop** | Bitcoiners who want the same sovereignty over their communications, cloud, passwords, and web services | The complete Node stack, plus the private self-hosted services. **Makes your home IP address public:** [read this first](#server--desktop-and-your-home-ip-address) |
|
||||
|
||||
**Desktop: start with your keys.** Desktop is not a reduced or Bitcoin-free
|
||||
edition: it is a complete, private everyday computer with a clean GNOME
|
||||
desktop, Tor, the Sovran Hub, and the Mullvad VPN app and daemon — which no
|
||||
other role installs — and a lower-hardware path to self-custody and non-KYC
|
||||
Bitcoin tools from the first boot. Move to Node mode when your hardware,
|
||||
storage, and needs are ready.
|
||||
|
||||
Mullvad is installed the way NixOS ships it and nothing in it is configured for
|
||||
you: create an account at [mullvad.net](https://mullvad.net), sign in from the
|
||||
app, and set the relay and the app's own switches to taste. It is the one thing
|
||||
that reaches the network from this machine as an application rather than as a
|
||||
service, so Server + Desktop and Bitcoin Node — built to serve — do not carry
|
||||
it. On Desktop it is simply there; remove it with
|
||||
`services.mullvad-vpn.enable = lib.mkForce false;` in `custom.nix` if you would
|
||||
rather not have it.
|
||||
|
||||
**Node: verify your own money.** You operate the infrastructure that performs
|
||||
the verification instead of asking someone else's server: your node verifies,
|
||||
your wallet connects to it, your keys stay yours.
|
||||
|
||||
Node publishes nothing by default. Two Node features need a public domain and
|
||||
make your home IP address public when enabled: **Put BTCPay Server Online** and
|
||||
**Lightning Wallet Connections**. Everything else, Mempool and Ride The
|
||||
Lightning included, stays on your home network or reaches you through Tor — see
|
||||
[Server + Desktop and your home IP address](#server--desktop-and-your-home-ip-address).
|
||||
|
||||
**Server + Desktop: sovereignty beyond money.** The same principle, applied
|
||||
to your data, communications, identity, and services.
|
||||
|
||||
### Recommended hardware
|
||||
|
||||
| | **Desktop** | **Node** | **Server + Desktop** |
|
||||
|---|---|---|---|
|
||||
| Processor | 64-bit Intel or AMD, ~2015 or newer | Intel or AMD x86, ~3 years old or newer | Same as Node |
|
||||
| RAM | 8 GB | 16 GB | 32 GB |
|
||||
| Storage | 256 GB SSD | 500 GB NVMe (OS) + 2 TB NVMe (timechain) | Same as Node |
|
||||
| Network | Any broadband | Unmetered, ~200 Mbps down / 50 Mbps up | Same as Node |
|
||||
| Also needed | — | — | A domain for publicly accessible services |
|
||||
|
||||
> **Before choosing Server + Desktop:** this mode makes services reachable from
|
||||
> the public internet, which means opening specific ports on your home network.
|
||||
> Before you start, confirm three things: you can log in to your router's admin
|
||||
> panel, the panel includes a port-forwarding section, and your internet
|
||||
> provider allows port forwarding.
|
||||
|
||||
### Server + Desktop and your home IP address
|
||||
|
||||
> **⚠️ Server + Desktop makes your home IP address public.**
|
||||
> Public services need a domain name that points at your home internet
|
||||
> connection. When you finish the guided domain setup, Sovran_SystemsOS puts
|
||||
> your home's public IP address in a Dynamic DNS record at
|
||||
> [Njal.la](https://njal.la) and keeps it up to date, and you forward ports 80
|
||||
> and 443 on your router to this computer. From then on:
|
||||
>
|
||||
> - **Anyone can look up your domain and see your home IP address,** which
|
||||
> reveals your internet provider and approximate location and ties everything
|
||||
> you publish on that domain to your home connection. Domain privacy does not
|
||||
> hide it: registrar privacy protects the registrant's identity, not the IP
|
||||
> address in your DNS records.
|
||||
> - **Your connection is open to the whole internet on those ports.** Scanners
|
||||
> and bots constantly probe public IP addresses, so expect automated probing
|
||||
> and login attempts against every service you publish.
|
||||
> - **Your service names are discoverable.** Public HTTPS certificates are
|
||||
> listed in Certificate Transparency logs, so hostnames such as
|
||||
> `vault.yourdomain.com` can be found and resolved to your IP address, even
|
||||
> if you never share them.
|
||||
|
||||
**Desktop** publishes nothing. **Node** publishes nothing unless you enable
|
||||
*Put BTCPay Server Online* or *Lightning Wallet Connections* — see
|
||||
[Three modes](#three-modes).
|
||||
|
||||
**If you do not want your home IP address to be public,** choose Desktop or
|
||||
Node. Advanced users can put a VPS, reverse proxy, or tunnel in front of their
|
||||
services so DNS points there instead of at their home; Sovran_SystemsOS does
|
||||
not set that up, and the Hub's domain checks expect DNS to point at your home
|
||||
IP address.
|
||||
|
||||
<details>
|
||||
<summary><strong>What happens technically</strong></summary>
|
||||
|
||||
- You create a **Dynamic** DNS record at Njal.la and paste its update command
|
||||
into the Hub, which only accepts `njal.la` update URLs.
|
||||
- The `sovran-ddns-update` timer points your record at the address the request
|
||||
came from: after you save a domain, two minutes after boot, then every 15
|
||||
minutes. Element calling and the Hub use that same address. See
|
||||
`modules/core/njalla.nix`.
|
||||
- Once a service that needs a domain is on, the firewall opens TCP and UDP
|
||||
ports 80 and 443 for Caddy, which requests public HTTPS certificates for the
|
||||
domains you configure. The Hub lists any additional ports a feature needs.
|
||||
See `modules/core/caddy.nix`.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Use it your way
|
||||
|
||||
You do not have to replace the operating system on your current computer to
|
||||
benefit from Sovran_SystemsOS.
|
||||
|
||||
### As your everyday computer
|
||||
|
||||
Install Sovran_SystemsOS on a desktop, laptop, or mini PC and use its clean
|
||||
GNOME desktop as your daily operating system, with the Bitcoin software and
|
||||
the tools of your selected mode already in place.
|
||||
|
||||
### As a private Bitcoin and home server
|
||||
|
||||
Prefer to keep using Windows, macOS, Linux, Android, or iOS? Install
|
||||
Sovran_SystemsOS on a separate computer and let it run quietly on your local
|
||||
network, with or without a monitor. From any other device on the same network,
|
||||
open a browser, visit `http://sovransystemsos.local:8937`, and manage
|
||||
everything from [the Hub](#the-hub).
|
||||
|
||||
---
|
||||
|
||||
## The Hub
|
||||
|
||||
### Your private infrastructure, controlled from any screen.
|
||||
|
||||
The Hub is the command center built into Sovran_SystemsOS: a local desktop
|
||||
application and a private web interface served by your Sovran_SystemsOS
|
||||
machine. The device opening it needs only a modern browser and access to the
|
||||
same local network.
|
||||
|
||||
From one place, the Hub lets you:
|
||||
|
||||
- Open, monitor, start, and stop your services
|
||||
- Configure system features and manage service domains and credentials
|
||||
- Reach your Bitcoin tools, private cloud, and communications
|
||||
- Perform supported system operations without everyday terminal commands
|
||||
|
||||
<img src="assets/sovran-hub-screenshot.webp" alt="The Sovran Hub welcome dashboard" width="800" />
|
||||
|
||||
*The Hub: your whole system at a glance — Bitcoin, Lightning, and your private apps.*
|
||||
|
||||
### Example home setup
|
||||
|
||||
```text
|
||||
Your local network
|
||||
│
|
||||
┌──────────────────────┼──────────────────────┐
|
||||
│ │ │
|
||||
Windows laptop Phone or tablet Mac or Linux
|
||||
│ │ │
|
||||
└─────── Browser: sovransystemsos.local:8937 ─┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ Sovran_SystemsOS │
|
||||
│ │
|
||||
│ • The Hub │
|
||||
│ • Bitcoin node │
|
||||
│ • Sparrow Wallet │
|
||||
│ • Bisq and Bisq 2 │
|
||||
│ • Lightning │
|
||||
│ • Private cloud │
|
||||
│ • Communications │
|
||||
│ • Password vault │
|
||||
│ • Hosted services │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
> **Local access:** the Hub is available at
|
||||
> `http://sovransystemsos.local:8937` to devices connected to the same local
|
||||
> network (not on Desktop, which publishes nothing). It is protected by
|
||||
> authentication and answers only your local network.
|
||||
|
||||
### Tech Support
|
||||
|
||||
The Hub can give a Sovran Systems technician temporary access to your machine.
|
||||
Support runs over SSH, and **Tech Support requires SSH Remote Access, which is
|
||||
off by default.**
|
||||
|
||||
Turning SSH on makes the machine listen on all interfaces and opens **TCP port
|
||||
22** in the firewall. Letting support in from outside your home means
|
||||
forwarding port 22 on your router, which puts SSH on the public internet where
|
||||
scanners and brute-force attempts will find it. Turn SSH off when the session
|
||||
ends; the Hub prompts you to.
|
||||
|
||||
Support signs in as the restricted `sovran-support` account, not as root, with
|
||||
one key that expires after 24 hours. Wallet paths stay locked unless you unlock
|
||||
them for a limited time, and every session event is written to
|
||||
`/var/log/sovran-support-audit.log`. See
|
||||
[Restricted support access](SECURITY.md#restricted-support-access).
|
||||
|
||||
---
|
||||
|
||||
## Install Sovran_SystemsOS
|
||||
|
||||
Sovran_SystemsOS is free and open source: download the installer, verify it,
|
||||
write it to a USB drive, and install it yourself, in control from the first
|
||||
step.
|
||||
|
||||
An ISO is a complete installation image containing the operating system and
|
||||
installer. It is not copied to a USB drive like a document; it must be written
|
||||
with an imaging application such as [Balena Etcher](https://etcher.balena.io).
|
||||
|
||||
**Before you begin, you will need:**
|
||||
|
||||
- A compatible 64-bit computer, and a backup of anything important on it
|
||||
- A USB drive that can be erased
|
||||
- Another computer for downloading and preparing the installer
|
||||
- An internet connection
|
||||
|
||||
> **Important:** Installing an operating system can erase the selected
|
||||
> destination drive. Back up important files and review every disk selection
|
||||
> carefully before continuing.
|
||||
|
||||
### 1. Download the ISO and checksum
|
||||
|
||||
- [Download Sovran_SystemsOS-1.2.1.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.1.iso)
|
||||
- [Download Sovran_SystemsOS-1.2.1.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.1.iso.sha256)
|
||||
|
||||
The download may take some time. Do not rename or modify the ISO before
|
||||
verifying it, and keep both files in the same folder.
|
||||
|
||||
### 2. Verify the checksum
|
||||
|
||||
A checksum is a digital fingerprint of a file. Verifying it confirms the ISO
|
||||
downloaded completely and without corruption. The checksum produced from your
|
||||
ISO must match the published one exactly.
|
||||
|
||||
<details>
|
||||
<summary><strong>Linux</strong></summary>
|
||||
|
||||
Open a terminal in the download folder and run:
|
||||
|
||||
```bash
|
||||
sha256sum --check Sovran_SystemsOS-1.2.1.iso.sha256
|
||||
```
|
||||
|
||||
A successful comparison reports:
|
||||
|
||||
```text
|
||||
Sovran_SystemsOS-1.2.1.iso: OK
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>macOS</strong></summary>
|
||||
|
||||
Open Terminal in the download folder and run:
|
||||
|
||||
```bash
|
||||
shasum -a 256 Sovran_SystemsOS-1.2.1.iso
|
||||
```
|
||||
|
||||
Compare the value shown in Terminal with the value inside
|
||||
`Sovran_SystemsOS-1.2.1.iso.sha256`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Windows</strong></summary>
|
||||
|
||||
Open PowerShell in the download folder and run:
|
||||
|
||||
```powershell
|
||||
Get-FileHash .\Sovran_SystemsOS-1.2.1.iso -Algorithm SHA256
|
||||
```
|
||||
|
||||
Compare the value under `Hash` with the published checksum.
|
||||
|
||||
</details>
|
||||
|
||||
**If the values do not match, do not install.** Delete the downloaded ISO,
|
||||
download it again, and repeat the verification. Continue only after the values
|
||||
match exactly.
|
||||
|
||||
### 3. Write the ISO to a USB drive
|
||||
|
||||
1. Download and install [Balena Etcher](https://etcher.balena.io), then
|
||||
connect the USB drive.
|
||||
2. Choose **Flash from file** and select `Sovran_SystemsOS-1.2.1.iso`.
|
||||
3. Choose **Select target**, select the USB drive, and review your selection
|
||||
carefully.
|
||||
4. Choose **Flash** and wait for the writing and verification process to
|
||||
finish.
|
||||
|
||||
> **Warning:** Flashing erases the selected drive. Verify that you selected
|
||||
> the USB drive and not another storage device.
|
||||
|
||||
After flashing, your computer may report that it cannot read the USB drive or
|
||||
may show several unfamiliar partitions. This is normal for a bootable Linux
|
||||
installer. Do not format the drive.
|
||||
|
||||
### 4. Boot from the USB drive
|
||||
|
||||
1. Leave the USB drive connected and restart the destination computer.
|
||||
2. Open the computer's boot-device menu. Common keys include `F12`, `F11`,
|
||||
`F10`, `F9`, `Esc`, and `Delete`; the correct key is often shown briefly
|
||||
when the computer powers on.
|
||||
3. Select the USB drive and start the Sovran_SystemsOS installer.
|
||||
|
||||
If the normal operating system starts instead, restart and try the boot-menu
|
||||
key again.
|
||||
|
||||
### Installing in a virtual machine (optional)
|
||||
|
||||
Sovran_SystemsOS can be tested in VirtualBox, VMware, QEMU/KVM, Proxmox, and
|
||||
similar x86_64 virtual machines. Use production-like resources where possible:
|
||||
|
||||
- Allocate **8 GB RAM or more** for Desktop Only. Node and Server + Desktop
|
||||
should follow the normal 16 GB / 32 GB recommendations.
|
||||
- Create a **256 GB or larger virtual OS disk**. Thin-provisioned / dynamically
|
||||
allocated disks are fine; they do not consume the full size immediately.
|
||||
- Use NAT or bridged networking with internet access before opening the
|
||||
installer.
|
||||
- UEFI/EFI firmware is preferred: in UEFI VMs the installer avoids depending
|
||||
on VM NVRAM boot-entry writes, and a VM that boots the ISO in legacy BIOS
|
||||
mode automatically installs GRUB instead.
|
||||
- For Node or Server + Desktop, attach a **second 2 TB virtual data disk**.
|
||||
If you only attach one disk, choose **Desktop Only**.
|
||||
- Present the install target as a normal virtual disk such as VirtIO, SATA,
|
||||
SCSI, or NVMe. USB-attached disks are hidden by the installer so you cannot
|
||||
erase the installer USB by mistake.
|
||||
|
||||
### 5. Install
|
||||
|
||||
Follow the on-screen installer. Before confirming:
|
||||
|
||||
- Verify that you selected the correct destination drive.
|
||||
- Understand that existing partitions and data may be erased.
|
||||
- Disconnect unrelated external drives if you are unsure which drive is which.
|
||||
- Confirm that the computer is connected to reliable power.
|
||||
|
||||
When installation is complete, restart the computer, remove the USB drive when
|
||||
instructed, allow Sovran_SystemsOS to start from the installed drive, and
|
||||
complete the initial setup.
|
||||
|
||||
### 6. Open the Sovran Hub
|
||||
|
||||
Open the Hub directly from the Sovran_SystemsOS desktop, or from any other
|
||||
device on the same local network at:
|
||||
|
||||
```text
|
||||
http://sovransystemsos.local:8937
|
||||
```
|
||||
|
||||
Sign in with your Sovran_SystemsOS credentials. Desktop does not publish the Hub
|
||||
on the network, so in that mode open it from the desktop.
|
||||
|
||||
<details>
|
||||
<summary><strong>If sovransystemsos.local:8937 does not open</strong></summary>
|
||||
|
||||
1. Make sure the Sovran_SystemsOS machine is powered on, and allow it a few
|
||||
minutes to finish starting.
|
||||
2. Make sure both devices are connected to the same local network, and that
|
||||
you entered the full address `http://sovransystemsos.local:8937`,
|
||||
including the `:8937`.
|
||||
3. Avoid guest Wi-Fi networks, which may prevent devices from seeing one
|
||||
another.
|
||||
4. Temporarily disconnect any VPN that may interfere with local-network
|
||||
access.
|
||||
5. Try another browser or device on the same network.
|
||||
|
||||
</details>
|
||||
|
||||
### Prefer guided help?
|
||||
|
||||
Not technical? You do not have to figure everything out alone. Visit the
|
||||
[Sovran Systems website](https://sovransystems.com) to learn about guided
|
||||
setup, supported hardware, and Royal Membership.
|
||||
|
||||
---
|
||||
|
||||
## For developers
|
||||
|
||||
Sovran_SystemsOS combines [NixOS](https://nixos.org), the standalone
|
||||
[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
|
||||
Sovran Hub. The Bitcoin and Lightning modules live in the Sovran_Bitcoin
|
||||
repository and are consumed as a flake input. OS-specific Bitcoin customizations
|
||||
(Second_Drive paths, operator user, Hub integration) are bridged by
|
||||
`modules/sovran-bitcoin-integration.nix`. Legacy `nix-bitcoin.*` option names
|
||||
and `/etc/nix-bitcoin-secrets` remain for compatibility.
|
||||
|
||||
### Development workflow
|
||||
|
||||
| Branch | Host | Purpose |
|
||||
|---------------|---------------------------------------------------|-----------------------------|
|
||||
| `main` | [GitHub](https://github.com/naturallaw777/Sovran_SystemsOS) | Active development & PRs |
|
||||
| `staging-dev` | [Gitea](https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS) | Synced with `main` |
|
||||
| `stable` | [Gitea](https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/src/branch/stable) | Tested, release-ready builds |
|
||||
|
||||
**Flow:** develop → sync `main` ↔ `staging-dev` → promote to `stable`
|
||||
|
||||
> ⚠️ `main` and `staging-dev` may contain unreleased or less-tested code. For
|
||||
> stable, audited code, use Gitea
|
||||
> [`stable`](https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/src/branch/stable).
|
||||
|
||||
### Technology
|
||||
|
||||
- [NixOS](https://nixos.org) and [Nix flakes](https://nixos.wiki/wiki/Flakes)
|
||||
for declarative, pinned system configuration
|
||||
- [Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) for the
|
||||
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
|
||||
Sparrow, Bisq, and Bisq 2 packages
|
||||
- [Python](https://www.python.org) and [FastAPI](https://fastapi.tiangolo.com)
|
||||
for the Sovran Hub backend
|
||||
- JavaScript, HTML, and CSS for the Hub interface
|
||||
- [GNOME](https://www.gnome.org) desktop environment
|
||||
- [Caddy](https://caddyserver.com) for local and public service routing
|
||||
- [Tor](https://www.torproject.org) for Bitcoin network privacy
|
||||
- [Mullvad VPN](https://mullvad.net), installed as the stock NixOS service on
|
||||
the Desktop Role only (`pkgs.mullvad` daemon and `pkgs.mullvad-vpn` app)
|
||||
- [AGPL-3.0](LICENSE) licensing
|
||||
|
||||
### Build from source
|
||||
|
||||
You need a system with Nix installed and flakes enabled.
|
||||
|
||||
Clone the **stable** branch from Gitea (recommended for builds):
|
||||
|
||||
```bash
|
||||
git clone -b stable https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS.git
|
||||
cd Sovran_SystemsOS
|
||||
```
|
||||
|
||||
Or clone the **active development** line from GitHub:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/naturallaw777/Sovran_SystemsOS.git
|
||||
cd Sovran_SystemsOS
|
||||
```
|
||||
|
||||
Then build the installer ISO:
|
||||
|
||||
```bash
|
||||
nix build \
|
||||
.#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage
|
||||
```
|
||||
|
||||
The resulting build output will be available through the `result` symlink.
|
||||
|
||||
### Publishing a release
|
||||
|
||||
Releases are managed with `scripts/release-stable.sh` and `scripts/upload-cdn.sh`:
|
||||
|
||||
1. Run the stable release script to bump version, tag, update changelog, and push/create releases:
|
||||
```bash
|
||||
./scripts/release-stable.sh [version]
|
||||
```
|
||||
2. Build the installer ISO:
|
||||
```bash
|
||||
nix build .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage
|
||||
```
|
||||
3. Copy, checksum, verify, and optionally upload to CDN:
|
||||
```bash
|
||||
./scripts/upload-cdn.sh --upload
|
||||
```
|
||||
|
||||
### Common development commands
|
||||
|
||||
Run these commands from the flake root.
|
||||
|
||||
```bash
|
||||
# Build the installer ISO
|
||||
nix build \
|
||||
.#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage
|
||||
|
||||
# Build the system configuration
|
||||
nixos-rebuild build --flake .#nixos
|
||||
|
||||
# Test without making the change permanent
|
||||
sudo nixos-rebuild test --flake .#nixos
|
||||
|
||||
# Activate the new configuration
|
||||
sudo nixos-rebuild switch --flake .#nixos
|
||||
|
||||
# Stage the configuration for the next boot
|
||||
sudo nixos-rebuild boot --flake .#nixos
|
||||
|
||||
# Update pinned flake inputs (review and test before committing flake.lock)
|
||||
nix flake update
|
||||
|
||||
# Roll back the last activated generation
|
||||
sudo nixos-rebuild switch --rollback
|
||||
```
|
||||
|
||||
### Repository map
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `flake.nix` | Declares flake inputs, the running system, and installer outputs |
|
||||
| `flake.lock` | Pins dependencies for reproducible builds |
|
||||
| `configuration.nix` | Base host, boot, desktop, user, security, backup, and system configuration |
|
||||
| `modules/` | Core modules, self-hosted services, and optional features |
|
||||
| `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 |
|
||||
| `app/` | Sovran Hub backend, templates, static assets, scripts, and web interface |
|
||||
| `scripts/` | Automated release, build, and CDN upload utility scripts |
|
||||
| `iso/` | Installer configuration, installer code, and installer assets |
|
||||
| `tests/` | Security and Nix integration checks |
|
||||
| `assets/` | Documentation images |
|
||||
| `custom.template.nix` | Template for local features and service overrides |
|
||||
|
||||
On installed systems, `flake.nix` also imports `role-state.nix` (the selected
|
||||
mode), `custom.nix` (generated from `custom.template.nix`), and
|
||||
`hardware-configuration.nix`. These are generated per machine, so they are
|
||||
gitignored and not part of this repository.
|
||||
|
||||
### Architecture overview
|
||||
|
||||
Sovran_SystemsOS is assembled from a reproducible Nix flake.
|
||||
|
||||
```text
|
||||
┌─────────────────────────┐
|
||||
│ flake.nix │
|
||||
│ │
|
||||
│ Inputs and system │
|
||||
│ build outputs │
|
||||
└────────────┬────────────┘
|
||||
│
|
||||
│ inputs: nixpkgs, │
|
||||
│ nix-bitcoin, nixvim, │
|
||||
│ btc-clients │
|
||||
└───────────┬─────────────┘
|
||||
│ nixosModules.Sovran_SystemsOS
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ configuration.nix │
|
||||
│ │
|
||||
│ Host, desktop, users, │
|
||||
│ boot, security, backup │
|
||||
└────────────┬────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ modules/modules.nix │
|
||||
│ │
|
||||
│ Core modules, services, │
|
||||
│ and optional features │
|
||||
└────────────┬────────────┘
|
||||
│
|
||||
┌──────────────────┼──────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌────────────┐ ┌────────────┐ ┌────────────┐
|
||||
│ Sovran Hub │ │ Bitcoin │ │ Private │
|
||||
│ and desktop│ │ ecosystem │ │ services │
|
||||
└────────────┘ └────────────┘ └────────────┘
|
||||
┌──────────────────────────┐ imports ┌──────────────────────────┐
|
||||
│ configuration.nix │────────────▶│ modules/modules.nix │
|
||||
│ boot / fs / users / │ │ core/* + services + opt │
|
||||
│ desktop / nix settings │ │ features │
|
||||
└──────────────────────────┘ └──────────┬───────────────┘
|
||||
▲ │
|
||||
│ ./role-state.nix (mode/role) ▼
|
||||
│ ./custom.nix (user overrides) ┌────────────────────┐
|
||||
│ │ modules/*.nix │
|
||||
└───────── sovran-hub writes ───────▶│ synapse / wordpress│
|
||||
│ nextcloud / etc. │
|
||||
└────────────────────┘
|
||||
```
|
||||
|
||||
The Hub writes supported user choices into the local configuration. NixOS then
|
||||
rebuilds the machine into the selected declarative state.
|
||||
- **`flake.nix`** declares two NixOS configurations:
|
||||
- `nixosConfigurations.nixos` — the running system.
|
||||
- `nixosConfigurations.sovran_systemsos-iso` — the System Installer.
|
||||
- **`configuration.nix`** owns host concerns (boot, filesystems, users, desktop, locale, Nix settings, firewall, audio, backups).
|
||||
- **`modules/modules.nix`** is the service router. Every other module is opt-in via flags read from `role-state.nix` and `custom.nix`.
|
||||
|
||||
### Module overview
|
||||
## Module Catalog
|
||||
|
||||
| Area | Modules |
|
||||
Defaults follow the import order in `modules/modules.nix`. Toggles live in `custom.nix` (the Hub writes them) and `role-state.nix`.
|
||||
|
||||
| Module | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `core/*` | **on** | Roles, Caddy, Njalla, Hub, desktop, perf, ssh-bootstrap |
|
||||
| `php.nix`, `credentials.nix` | **on** | Required by web services & secrets |
|
||||
| `synapse.nix` | **on** | Matrix homeserver |
|
||||
| `wordpress.nix` | **on** | WordPress + PHP-FPM vhost |
|
||||
| `nextcloud.nix` | **on** | Files / calendar / contacts |
|
||||
| `vaultwarden.nix` | **on** | Bitwarden-compatible secrets vault |
|
||||
| `bitcoinecosystem.nix` | **on** | bitcoind/electrs/LND/RTL/BTCPay (over Tor) |
|
||||
| `wallet-autoconnect.nix` | **on** | Sparrow/Bisq ↔ node handshake |
|
||||
| `haven.nix` | off | Nostr relay |
|
||||
| `element-calling.nix` | off | LiveKit + JWT for E2E calling |
|
||||
| `mempool.nix` | off | Mempool.space dashboard |
|
||||
| `bitcoin-core.nix` | off | Switch node to Bitcoin Core (replaces default Bitcoin Knots + BIP110) |
|
||||
| `rdp.nix` | off | xrdp remote desktop |
|
||||
| `sshd.nix` | off | Public-facing OpenSSH |
|
||||
|
||||
> Tor is wired directly into the Bitcoin stack. In `modules/bitcoinecosystem.nix`, `bitcoind`, `electrs`, and `lnd` all set `tor.enforce = true` and `tor.proxy = true`, and onion services are exposed for them.
|
||||
|
||||
## The Three Modes (internal reference)
|
||||
|
||||
Selected by `role-state.nix`, resolved by `modules/core/role-logic.nix`. All three configurations are produced from this same flake.
|
||||
|
||||
| Mode | What's enabled on top of the base NixOS + GNOME |
|
||||
|---|---|
|
||||
| Core platform: roles, Hub, desktop integration, Caddy, domains, support, remote deployment | `modules/core/` |
|
||||
| Shared credentials | `modules/credentials.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` |
|
||||
| Matrix Synapse | `modules/synapse.nix` |
|
||||
| Optional Element audio and video calling via LiveKit | `modules/element-calling.nix` |
|
||||
| Optional Haven Nostr relay | `modules/haven.nix` |
|
||||
| Nextcloud, Vaultwarden, WordPress | `modules/nextcloud.nix`, `modules/vaultwarden.nix`, `modules/wordpress.nix`, `modules/php.nix` |
|
||||
| Optional remote desktop and public SSH | `modules/rdp.nix`, `modules/sshd.nix` |
|
||||
| Mullvad VPN app (Desktop Role only) | `modules/mullvad.nix` |
|
||||
| **Desktop** | Private daily-driver. Sparrow + Bisq included. |
|
||||
| **Node** | Desktop + full Bitcoin stack (bitcoind/electrs/LND/RTL/BTCPay over Tor). |
|
||||
| **Server+Desktop** | Node + self-hosting services (Synapse, Nextcloud, WordPress, Vaultwarden, Element Calling, etc.). |
|
||||
|
||||
Feature availability and defaults may change as Sovran_SystemsOS develops:
|
||||
review the relevant Nix module before relying on a default in production.
|
||||
## Build & Deploy Reference
|
||||
|
||||
---
|
||||
Internal commands. Run from the flake root.
|
||||
|
||||
## About Bitcoin wallet entropy
|
||||
| Action | Command |
|
||||
|---|---|
|
||||
| Build the System Installer | `nix build .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage` |
|
||||
| Switch now | `sudo nixos-rebuild switch --flake .#nixos` |
|
||||
| Test in current boot only | `sudo nixos-rebuild test --flake .#nixos` |
|
||||
| Stage for next boot | `sudo nixos-rebuild boot --flake .#nixos` |
|
||||
| Build only (no activation) | `nixos-rebuild build --flake .#nixos` |
|
||||
| Update pinned inputs | `nix flake update` (then rebuild) |
|
||||
| Rollback last switch | `sudo nixos-rebuild switch --rollback` |
|
||||
| Garbage-collect (>7 days) | Automatic weekly; manual: `sudo nix-collect-garbage -d` |
|
||||
|
||||
Wallet recovery words control the funds. Never share them with a website,
|
||||
support technician, cloud service, or chat application.
|
||||
## Networking & Reverse Proxy
|
||||
|
||||
For meaningful balances, prefer a well-reviewed hardware signer and follow its
|
||||
verified backup process. A BIP39 passphrase is optional, advanced protection,
|
||||
not a replacement for recovery words: back it up separately, because losing
|
||||
either item can make the wallet unrecoverable.
|
||||
- **Firewall on by default** (`networking.firewall.enable = true`). Port are opened by the module that needs it.
|
||||
- **Caddy** (`modules/core/caddy.nix`) terminates TLS for all HTTP services.
|
||||
- **Njalla** dynamic DNS (`modules/core/njalla.nix`) keeps records in sync via a 15-minute cron job.
|
||||
- **Tor** is enabled with `torsocks` available. The Bitcoin stack uses it directly — see [Security Posture](#security-posture).
|
||||
- **SSH:** localhost-only by default (`core/sshd-localhost.nix`).
|
||||
|
||||
Keep durable offline backups in separate secure locations, test recovery before
|
||||
relying on a wallet, and begin with a small amount.
|
||||
## Security Posture
|
||||
|
||||
---
|
||||
Facts about the defaults, straight from `configuration.nix` and the modules:
|
||||
|
||||
## Security approach
|
||||
- **Reproducible builds.** Every artifact derives from `flake.lock`. The same commit produces the same OS.
|
||||
- **Bitcoin stack over Tor.** In `modules/bitcoinecosystem.nix`, `bitcoind`, `electrs`, and `lnd` all set `tor.enforce = true`, and onion services are exposed for `bitcoind`, `electrs`, `lnd`, and friends.
|
||||
- **Firewall on, public sshd off, RDP off, auto-login off, fail2bain active**
|
||||
- **Kernel surface trimmed.** `boot.blacklistedKernelModules = [ "rxrpc" ];`
|
||||
- **Weekly garbage collection** with `--delete-older-than 7d`.
|
||||
|
||||
Sovran_SystemsOS uses layered controls:
|
||||
## Backups & Recovery
|
||||
|
||||
- Pinned flake inputs and hash-pinned source archives
|
||||
- Firewall enabled; SSH Remote Access and remote desktop disabled by default.
|
||||
Enabling SSH opens TCP port 22 and the Hub then asks you to forward it
|
||||
- Separate service users, systemd sandboxing, and loopback bindings where practical
|
||||
- Tor enforcement for supported Bitcoin services
|
||||
- Restricted, time-limited support access with scoped `sudo`
|
||||
- Operator-controlled public service exposure (Server + Desktop Role or Node Role when enabled BTCpayserver and/or Lightning Wallet Connections
|
||||
[makes your home IP address public](#server--desktop-and-your-home-ip-address)
|
||||
once you set up a domain)
|
||||
`services.rsnapshot` snapshots hourly and daily to `/run/media/Second_Drive/BTCEcoandBackup/NixOS_Snapshot_Backup`:
|
||||
|
||||
See [`SECURITY.md`](SECURITY.md) for the threat model, limitations, reporting,
|
||||
and operator guidance.
|
||||
```
|
||||
backup /home/ localhost/
|
||||
backup /var/lib/ localhost/
|
||||
backup /etc/nixos/ localhost/
|
||||
backup /etc/nix-bitcoin-secrets/ localhost/
|
||||
retain hourly 5
|
||||
retain daily 5
|
||||
cron hourly 0 * * * *
|
||||
cron daily 50 21 * * *
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
Sovran_SystemsOS stands on the work of exceptional free and open-source projects and contributors.
|
||||
|
||||
### NixOS
|
||||
|
||||
Gratitude to the [NixOS](https://nixos.org) team, the [Nixpkgs](https://github.com/NixOS/nixpkgs) maintainers, and the broader [Nix community](https://github.com/nix-community).
|
||||
|
||||
NixOS provides the reproducible, declarative foundation that makes Sovran_SystemsOS possible: its module system, package ecosystem, [flakes](https://nixos.wiki/wiki/Flakes), and generation-based system management allow an entire Bitcoin operating system to be described, audited, rebuilt, upgraded, and rolled back from source.
|
||||
|
||||
### nix-bitcoin
|
||||
|
||||
The in-repository Bitcoin stack began with code adapted from
|
||||
[nix-bitcoin](https://github.com/fort-nix/nix-bitcoin), primarily from commit
|
||||
[`360e30f`](https://github.com/fort-nix/nix-bitcoin/commit/360e30fee5ba32f9fecc89bc35628195d9d2dbbe).
|
||||
It has since been narrowed to Sovran's supported services and is maintained in
|
||||
this repository; nix-bitcoin is no longer a flake input or build dependency.
|
||||
Its MIT notice is retained in [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md).
|
||||
|
||||
### Emmanuel Rosa and btc-clients-nix
|
||||
|
||||
Thanks to [Emmanuel Rosa](https://github.com/emmanuelrosa) for [btc-clients-nix](https://github.com/emmanuelrosa/btc-clients-nix), which packages the Bitcoin desktop software included in every installation:
|
||||
|
||||
- [Sparrow Wallet](https://github.com/sparrowwallet/sparrow)
|
||||
- [Bisq](https://github.com/bisq-network/bisq)
|
||||
- [Bisq 2](https://github.com/bisq-network/bisq2)
|
||||
|
||||
### The upstream Bitcoin ecosystem
|
||||
|
||||
Sovran_SystemsOS also depends on the work of the developers and communities behind:
|
||||
|
||||
- [Bitcoin Core](https://github.com/bitcoin/bitcoin)
|
||||
- [Sparrow Wallet](https://github.com/sparrowwallet/sparrow)
|
||||
- [Bisq](https://github.com/bisq-network/bisq)
|
||||
- [Bisq 2](https://github.com/bisq-network/bisq2)
|
||||
- [Electrs](https://github.com/romanz/electrs)
|
||||
- [LND](https://github.com/lightningnetwork/lnd)
|
||||
- [Ride The Lightning](https://github.com/Ride-The-Lightning/RTL)
|
||||
- [Alby Hub](https://github.com/getAlby/hub) for Nostr Wallet Connect (NWC)
|
||||
connections
|
||||
- [BTCPay Server](https://github.com/btcpayserver/btcpayserver)
|
||||
- [Tor](https://www.torproject.org)
|
||||
- [NixOS](https://github.com/NixOS/nixpkgs)
|
||||
- [GNOME](https://gitlab.gnome.org/GNOME)
|
||||
- [Caddy](https://github.com/caddyserver/caddy)
|
||||
- [Nextcloud](https://github.com/nextcloud/server)
|
||||
- [Matrix Synapse](https://github.com/element-hq/synapse)
|
||||
- [Element](https://github.com/element-hq/element-web)
|
||||
- [LiveKit](https://github.com/livekit/livekit) for Element audio and video
|
||||
calling
|
||||
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden)
|
||||
- [WordPress](https://github.com/WordPress/WordPress)
|
||||
- Every other upstream project included in the system
|
||||
|
||||
Sovran Systems did not create these foundations; our work is to bring them together into one operating system that helps more people use Bitcoin privately and independently. Thank you to every developer, maintainer, reviewer, tester, documentarian, and user who keeps this ecosystem alive.
|
||||
|
||||
---
|
||||
The second drive is mounted by label (`BTCEcoandBackup`) with `nofail` so a missing drive doesn't block boot.
|
||||
|
||||
## License
|
||||
|
||||
Sovran_SystemsOS is free and open-source software licensed under the
|
||||
[GNU Affero General Public License v3.0](LICENSE). You may use, study, modify,
|
||||
share, and self-host it; if you distribute a modified version, you must make
|
||||
its corresponding source code available under the same license.
|
||||
|
||||
Because Sovran_SystemsOS includes the browser-based Sovran Hub, operators who
|
||||
modify the covered software and make that modified version available for users
|
||||
to interact with over a network must offer those users access to its
|
||||
corresponding source code, as required by the AGPL-3.0.
|
||||
|
||||
Sovran_SystemsOS is provided **without warranty**, as described in the full
|
||||
license. Individual upstream applications, packages, artwork, fonts, and other
|
||||
components may have their own licenses and copyright holders, which the
|
||||
AGPL-3.0 does not replace. Read [`LICENSE`](LICENSE) and
|
||||
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md).
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! The
|
||||
[GitHub repository](https://github.com/naturallaw777/Sovran_SystemsOS) is the
|
||||
primary location for collaboration. Please read our
|
||||
[Contributing Guidelines](CONTRIBUTING.md) before submitting a pull request.
|
||||
|
||||
<div align="center">
|
||||
|
||||
---
|
||||
|
||||
## Privacy. Sovereignty. Bitcoin.
|
||||
|
||||
[Visit Sovran Systems](https://sovransystems.com) ·
|
||||
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.1.iso) ·
|
||||
[View the License](LICENSE)
|
||||
|
||||
</div>
|
||||
Licensed under the **GNU Affero General Public License v3.0** — see [`LICENSE`](./LICENSE).
|
||||
-149
@@ -1,149 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported versions
|
||||
|
||||
| Release | Supported |
|
||||
|---|:---:|
|
||||
| Latest stable release | Yes |
|
||||
| `main` / `staging-dev` | Development only |
|
||||
| Older than `1.0.0` | No |
|
||||
|
||||
Install the newest stable point release to receive security fixes.
|
||||
|
||||
## Report a vulnerability
|
||||
|
||||
**Do not open a public issue or pull request.** Report privately through:
|
||||
|
||||
- [GitHub Private Vulnerability Reporting](https://github.com/naturallaw777/Sovran_SystemsOS/security/advisories/new)
|
||||
- Email: [support@sovransystems.com](mailto:support@sovransystems.com)
|
||||
|
||||
Include the affected version, impact, reproduction steps, and a minimal proof of
|
||||
concept. Never send wallet recovery words, private keys, or live credentials.
|
||||
|
||||
We aim to acknowledge reports within two business days. Please allow reasonable
|
||||
time for a fix and coordinated disclosure.
|
||||
|
||||
## Security model
|
||||
|
||||
### Local-first operation
|
||||
|
||||
The Hub and core data run on operator-owned hardware. The Hub is for a trusted
|
||||
local network and must not be port-forwarded to the internet. It uses HTTP, so
|
||||
authentication does not encrypt local network traffic.
|
||||
|
||||
The Hub is served on port 8937 and is not fronted by Caddy. Server + Desktop and
|
||||
Bitcoin Node Only open that port in the firewall, so local devices reach the Hub
|
||||
at `http://sovransystemsos.local:8937`. On Desktop Only the Hub is not published
|
||||
at all: reachable only from the machine itself, on localhost, with no TCP port
|
||||
open in the firewall (UDP 5353 for mDNS only).
|
||||
`sovran_systemsOS.hub.directPort = true` in `custom.nix` opens port 8937 if you
|
||||
want to reach a Desktop Only Hub from another device.
|
||||
|
||||
The Hub checks every client before showing a login page. It runs as root, so it
|
||||
answers only loopback, private, VPN and link-local addresses and turns everyone
|
||||
else away. The check goes by the address a connection comes from, so it is a
|
||||
second lock and not a reason to forward port 8937: don't. Addresses outside the
|
||||
local ranges go in `sovran_systemsOS.hub.extraLanNetworks` in `custom.nix`;
|
||||
`sovran_systemsOS.hub.lanOnly = false` turns the check off.
|
||||
|
||||
### Public services and your home IP address
|
||||
|
||||
Publishing public services on Server + Desktop points a DDNS record at your
|
||||
home's public IP address, which anyone can look up, and lists your service
|
||||
hostnames in Certificate Transparency logs. Desktop publishes nothing. Node
|
||||
publishes nothing unless *Put BTCPay Server Online* or *Lightning Wallet
|
||||
Connections* is on. See
|
||||
[Server + Desktop and your home IP address](README.md#server--desktop-and-your-home-ip-address)
|
||||
for what this means and the alternatives.
|
||||
|
||||
### Bitcoin stack
|
||||
|
||||
Bitcoin and Lightning modules live in the standalone
|
||||
[Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) repository,
|
||||
consumed as a flake input and bridged by
|
||||
`modules/sovran-bitcoin-integration.nix`. The `nix-bitcoin.*` namespace and
|
||||
`/etc/nix-bitcoin-secrets` path remain only for upgrade compatibility.
|
||||
|
||||
### Supply chain and integrity
|
||||
|
||||
`flake.lock` pins flake inputs, and fetched source archives use fixed hashes.
|
||||
Builds still depend on pinned Nixpkgs and other inputs, upstream source
|
||||
archives, and any configured binary cache.
|
||||
|
||||
The Hub integrity check verifies Nix store contents against a build from local
|
||||
`/etc/nixos`. It does not authenticate the release publisher or protect against
|
||||
an attacker who already controls root.
|
||||
|
||||
### Access and service isolation
|
||||
|
||||
- Firewall enabled by default
|
||||
- SSH Remote Access and remote desktop disabled by default
|
||||
- Separate service users and systemd sandboxing where supported
|
||||
- Administrative service ports bound to loopback where practical
|
||||
- Tor enforced for supported Bitcoin traffic and onion services
|
||||
- Public web services exposed only when enabled by the operator (this makes
|
||||
your home IP address public)
|
||||
|
||||
SSH Remote Access is the only supported administrative port that faces the
|
||||
network, and it is off by default. Enabling it listens on all interfaces and
|
||||
opens TCP port 22; the Hub then asks you to forward port 22, which puts SSH on
|
||||
the public internet. Key authentication only. Turn it off when you are done.
|
||||
|
||||
Tor reduces network exposure for configured Bitcoin services. It is not a
|
||||
guarantee against every IP leak, application bug, or traffic-analysis attack.
|
||||
|
||||
### Mullvad VPN (Desktop Only)
|
||||
|
||||
The Mullvad VPN client — daemon and app — is installed on the Desktop Role and
|
||||
on no other role: Server + Desktop and Bitcoin Node keep the one network
|
||||
configuration their services need. `modules/mullvad.nix` is a role gate around
|
||||
the stock NixOS service, with nothing configured by this system on top of it.
|
||||
|
||||
Two behaviours are the app's own, and worth knowing before you connect:
|
||||
|
||||
- While the tunnel is up, Mullvad's kill switch blocks everything outside it,
|
||||
the local network included, so Remote Desktop and other local devices stop
|
||||
answering until you switch local network sharing on in the app.
|
||||
- Name resolution follows the tunnel for as long as the client is enabled: the
|
||||
NixOS service turns on systemd-resolved and points NetworkManager at it.
|
||||
|
||||
Neither is set or restricted here; both are visible and changeable in the app.
|
||||
The firewall rules, the Hub's exposure checks, the Bitcoin stack and Tor are
|
||||
untouched by this module.
|
||||
|
||||
### Restricted support access
|
||||
|
||||
Tech Support runs over SSH, and the Hub refuses to start a session until SSH
|
||||
Remote Access is enabled (see above). Support uses a per-session SSH key on the
|
||||
non-root `sovran-support` account.
|
||||
|
||||
Sessions expire after 24 hours and have a small allowlist of `sudo` commands.
|
||||
Wallet paths receive deny ACLs unless the operator explicitly removes them.
|
||||
Disabling support removes the key and reapplies the ACLs but leaves SSH
|
||||
enabled.
|
||||
|
||||
Support events are written to `/var/log/sovran-support-audit.log`. This is a
|
||||
local audit log, not a cryptographically tamper-evident record.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Sovran_SystemsOS cannot protect against:
|
||||
|
||||
- Compromised root or administrator credentials
|
||||
- Stolen recovery words, private keys, or backups
|
||||
- Malicious or compromised hardware, firmware, or build infrastructure
|
||||
- Services the operator deliberately exposes or weakens
|
||||
- Physical access without appropriate disk and firmware protections
|
||||
|
||||
## Operator basics
|
||||
|
||||
- Verify downloads and stop if the checksum does not match.
|
||||
- Apply stable security updates promptly.
|
||||
- Use unique passwords and keep SSH/RDP off when not needed.
|
||||
- Prefer a well-reviewed hardware signer for meaningful Bitcoin balances.
|
||||
- Keep tested, offline backups in separate secure locations.
|
||||
- Never share recovery words or private keys with support.
|
||||
- Disable support access when the session ends and review the audit log.
|
||||
|
||||
No software can provide absolute security. Review your configuration and threat
|
||||
model before storing important funds or data.
|
||||
@@ -1,36 +0,0 @@
|
||||
# Third-Party Notices
|
||||
|
||||
## nix-bitcoin
|
||||
|
||||
The Bitcoin and Lightning modules were originally adapted from
|
||||
[fort-nix/nix-bitcoin](https://github.com/fort-nix/nix-bitcoin), commit
|
||||
[`360e30fee5ba32f9fecc89bc35628195d9d2dbbe`](https://github.com/fort-nix/nix-bitcoin/commit/360e30fee5ba32f9fecc89bc35628195d9d2dbbe).
|
||||
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
|
||||
|
||||
Copyright (c) 2019 nix-bitcoin developers
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
Other packaged applications retain their own upstream licenses and copyright
|
||||
notices.
|
||||
@@ -0,0 +1,244 @@
|
||||
<svg width="256" height="256" version="1.1" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<image width="256" height="256" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAYAAABccqhmAAAABmJLR0QA/wD/AP+gvaeTAAAgAElE
|
||||
QVR4nO2dd3gU1frHv+9sTy9AKh2RIqh0kHJFpVoQO1IURa6CCFak6CrFdr1KU1HUa8NKEREQRQUR
|
||||
ASlSlR5KEkpIQtr2Ob8/Aj9Isrszuzu7s7s5n+fJo+zOnHmTmfOdU94CcDgcDofD4XA4HA6Hw+Fw
|
||||
OJyohtQ2gBM4zAytNTWtPkiozxjqChDqMiCVIKYyJtQBiakA6QGYiGAEAMaQAJDmfAsuIpSc/9wK
|
||||
wALABrCzBJxlEM4SEwtE4AwJKICDHTOeO3mCzHCq9CtzFIILQITAvoLGdjqrqSiiLQmsFYDGYKwR
|
||||
iBqBIRuANsQmOUE4AcZyQJQDRocZw98Cox2GohOHyAwxxPZw/IALQBjCzK31lpTidgKJnRmoDYAr
|
||||
AbQCEKOyaXKpAKM9ANtBwC4RtNlkSN9Ko7c61DaMUxUuAGEAm52SYIOxEyN0B3DN+R+TymYpjYMI
|
||||
O0XgdzBa79SzXxJG5xWobVRthwuACrCvoKnIz7iKNHQ9MdwEoCsAQW27VGAvMXwH4CeDIWMtHyGE
|
||||
Hi4AIaL4rQbJBtF1M0Q2gAg3MCBZbZvCjEIAq0FshdVh+C55Qk6x2gbVBrgABBE2PzPGZsN1IAxj
|
||||
wC0A9GrbFCHYGPAjMfraCMtSGldYorZB0QoXAIVh5tZ6S2rxLQLYEAb0Ayq33Th+YyXQChHiQpM+
|
||||
cxmfJigLFwCFsM7Nag6RjWSE+wHUU9ueaISAIgb6WhCdcwyPndqttj3RABeAAGCzmxmsKL8dJIwC
|
||||
WE/wv2eoYABbC9B7xrPJ35B5j11tgyIV/sD6AZudkmAjw/0M9CSAbLXtqeWcAvCO3SHMTnz8RKHa
|
||||
xkQaXAB8wDInrTEjzXhieABArNr2cKpQBtBCIrxuHJu7X21jIgUuADKwzclsIQLPA7gDgEZtezhe
|
||||
cYHwBYFe5EIgDRcAL1jeSG8EreZZgI1E6H3tOYEhErAIgjDJOObEQbWNCVe4ALjBMjejIUSaAsII
|
||||
ADq17eEEhAOgD5nApseMyTuutjHhBheAS2CvpcVajZqnADwDvn8fbdgZ4R2TaJ3KHYsuwgUAAGMg
|
||||
65ysYSD2MoAMte3hBJU8IvaCoSB/AQ9Z5gKAijmZ1xDwJoAOatvCCSVsM2PCYzHjcjeqbYma1FoB
|
||||
YPObJFoc1heJYSxqZyQeB2Bg9KndSeNrqw9BrRQA69ysGxljb4M78XAqyWdgj8Y8mr9IbUNCTa0S
|
||||
gLJ5ddM1Lv2rIDZMbVs44QcDlkMr/Dvm4RO5atsSKmqNAFTMzbpTYOwdHofPkaCQMRodMy73G7UN
|
||||
CQVRLwDslTrx1hjDfwD2kNq2cCIIRp8YNfZHaMyZMrVNCSZRLQAVs7M7EYmfAWimti2cCISQwxiG
|
||||
xjya97vapgSLqBQAZoZgTc2aDLDnwF14OYHhALHnjWPyXyYCU9sYpYk6AagM1TV+xIBBatvCiSq+
|
||||
t2m0w5IeOVaktiFKElUCYHsru63oEhcDaKq2LZyo5KAAYbDh0RO71DZEKaLGAaZiduY9okvcAN75
|
||||
OcGjmQhxk2Vu1gi1DVGKiB8BMDMEW2rmSwx4Wm1bOLUJesk4NndypK8LRLQAsNnNDFaq+BDAPWrb
|
||||
Eu6cLQdmrBFRZgNeGSggOVKKjIUxBFpkcNAwevyERW1b/CViBaBkblaqjrElAHqobUu4s3Q3g/kH
|
||||
EcXnH9N6cYTXbyb0aBKxtz+c2Ohk4s3x406eUdsQf4jIJ8A6u35TRq4VAJqrbUs4Y3EAz60S8fWO
|
||||
mqNUIuCBzgIm9iZoo2YlSDUOkQsDjePz9qltiK9EnACUz81sJ4j4AYQ6atsSzhwrAh76WsQ/p71P
|
||||
Ua+7jDB3sAATz3sUKKddAvWNG5P7l9qG+EJECUD5vPSOgiisApCiti3hzM48YOSXLhSUyzu+bSbw
|
||||
v7s1SOHrAgHBgGIQDYgZm/uH2rbIJWIEwDI3oydAy8EQr7Yt4czvRxhGfSWiwscCWk1Tgc+GapDO
|
||||
/7qBUk4Mg4zj8n5S2xA5RMTszzonvT8YreKd3zt/5TI89LXvnR8ADp0Fhi904Zw1one1woFYRlhu
|
||||
m5t5i9qGyCHsBcA6J70/g7AUgEltW8KZAwXA8M9FlAdQJGv/GWDsYgaHi4tAgBhEhq+s8zL6qW2I
|
||||
FGEtABVzMq9hEL4GL6vtlTI78PA3LpRYA2/rt8MMk1dwAVAAPRNpsWVO9r/UNsQbYSsAFbOzuhBh
|
||||
JXgJLkmeXCbiYIH0cckxQPO60sd9tYNh0U4uAgpgAsTllnmZ3dU2xBNhKQD22VlXErHv+ZxfmqW7
|
||||
GVb9I91Z4w3Ax/do8PlQDZqkSq/9vrCa4WQJFwEFiIWI5eVzM9upbYg7wk4ArHOzmrvAfgLf6pOk
|
||||
2AK8uFo6tb1WAObfIaBNBpAaCyy4S0CcxKSqxMrw3A9cABQiUWBYaZ2XHXaJacJKAErmZqUyxpZz
|
||||
Jx95vP6riMIK6eOevpbQrdHFt36TFOCVG6Vv/ep9DCtljC44sqjHRHFV6ex0GZOw0BE2AsA+bGTU
|
||||
gS0DcJnatkQCJ84BX2yX7py9mxFGdal5mwe2ItzWVvr2v/ozg7PW189RjKZaCIvZh43CpuxcWAgA
|
||||
YyBrmf19MHRT25ZIYdY6EQ6JjhmnB2YMEEAepvxTrifJqMAjhQzf7uajAMUgdLeV2T9m5vDoe2Fh
|
||||
hG1u5ssAhqhtR6RQUA4s3SXdKR/rSchI8Px9cgwwoaf0I/DBZi4ASsKAO6ypWdPVtgMIAwGomJ15
|
||||
D0/m4Rtf/sUk3/7JMcDQ9tK39552hOxE78fsOcmw7QQXAWVhz1pmZw1X2wpVM+aez+H3npo2RCIi
|
||||
YxjSzvtWXpeGJCvCTycAD3UV8Nwq74qybA9Du+yICR2JDIi9ZZuVts3w2Kndqpmg1oWL32qQbHA5
|
||||
/wTP4ac6pTag6ywXyry4EdeLI2x6zPN6AsdvDlid+k7JE3KK1bi4KlMAZoZgcDk/Ae/8YUG8Aejf
|
||||
0nvPPl3G8LdEbgGOX1xm0Km3KKjKRSuLdmCgGtfmuKdfC+lX+4YjXACCATHcZE3NVGUdLOQDuvNJ
|
||||
PX4HENE5aDYdQ8RHzXWoTzCeXwWyOYEr/+OC1en5+FuuIMwadPGdYXEAh88yxBsIqbFALA/ZCgQn
|
||||
I6F7zNgTm0J50ZAuArJ5deOsovAZIrzzA5XRd3K88MKZ9+8ScN1lle8AgxZok0n485hnUfv7VNXv
|
||||
Sm0MAxdcXDxMiQEapRBapxOuymToUJ/QMJkvGshES0z8jL1S52p6pqA0VBcN6RTAKmrnIUo8/d4c
|
||||
JCDBGNkP9x9Hq/77qkzvv8/RIoBdogH14qjK36CwAth2guGTLSKeWMbQa56I69524ZWfRew9Fdmj
|
||||
pRDR1BqjfyOUFwyZAFTMybgNINX3PZWiZxPC1yMEyT30cGZ7tb39xhLhVzYnaox6GiZ779iHzgJv
|
||||
b2AY8J6IQR+K+GantA9DLeeBijmZd4XqYiERgPI3GmYIoKjb77+8LvD1CA3qJ6ltiX/knqvaebNk
|
||||
iFlBNQFIjZU/Cvorl+HJZSL+Nc+Fhdt45iFPEPBW2Zv10kJxrZAIgEbrnMuA5FBcK9RkJACfDBFQ
|
||||
Ly7ypgMF5YDrkrdxSoz072CxV+20SX6EteSeAyatENH3XYb1fGfBHSkajXZWKC4UdAGwzs26kYEN
|
||||
DvZ11KRRCmHuYIJGdcdq33AxVHHsEUi6M9pcVUVCr/H/+ofPMgxbKGLSChEWPxKZRjl3hSKxaFAf
|
||||
WTa/SSJj7J1gXiNc6NSAMOaayFIAgwYQLunPLlF6BKAVqopEoPN5xoCF2xhu+cCFAzLSmtUmRIa3
|
||||
it5oFNQJZlCfWKvd+hqArGBeI5wYcw1FVF79RFPVDn+mXHoEoNdUPcfmxW/AF/afAW77n4gNOXxK
|
||||
cAmZBo09qFGDQROAijmZ1wB4MFjthyMGLTC2e+SsBbSqtsyULyMHYPX8AWdliIZcSqwMIxaKPAvR
|
||||
JRDh4Yq52Z2D1X5QBICZIRCjNxBBlYeUYnBbATER4ubUttq+/z+nvd8uIqButRzNueeUtckhAuOW
|
||||
MPxykIvAeQRi4izGgtOXguIJaE3NHAGwjsFoO9yJ0QG9mhFW/i3/AW6VTljxoPrrBzvzvU/oU2Iq
|
||||
RzmX4s112F8cLoYxixi+HqFB63Tl249AOlvnZtwL5H+qdMOKP3XslTrxAGYo3W4kcWkCTjmEwzCp
|
||||
xArsOen9mBb1alq6ebwGK0cJMPcV0LUhVVlUDIQKB/DIIhElvFTZeehl9lqa4jUyFBcAm0k/GUCG
|
||||
0u1GEm0i8K219hCr4hPgjsvr1fxMIKBlGuG+joTPhwlY/6gGj/ciJClQyO1oEcOUlVwAzpNlNWqe
|
||||
UbpRRQXAMjejISM8pmSbkchldX17DYbDI75URuLPDjIyAmUmAON6CPhtrAbje1LAEYLL9jD8zp2F
|
||||
LvBkxeysbCUbVHYNgNFUAGGT8lgtYvWVP3ILdfoiFw4XwwurK9OCeWNibwEJMu/E6TKGtYe8tycQ
|
||||
0LWhfEvjDcD4ngJubs0wbomI3RLTC2+YfxCx6iFNxDlaBQETkTgZwMNKNaiYAFhn12/K4IqaYJ9A
|
||||
qRNLKLfLe3P58n7TaQjbc0XsOen9rMvqMtzfUV6H/XgLJHP/t8mQTiHujiaphCUjNXh2eWUgkD8c
|
||||
KABW72foLyNpSfRDD1jfSnvN+Mipw0q0ppimMojPIwri/JXCl6Gvr491zybSx3y2VYQoo7+V2oCP
|
||||
t0gfeHNrGYZ5QCcAr94k4JYr/O/Ab2/gIYTn0TFRM1mpxhQRAOvcrOYgdo8SbUULugB85KUY0FL6
|
||||
th0sAFbI2Iqc/weTXGnXCMBAiZyBUggEvH6zgM4N/Dt/Zx5kVUCuFTCMsL6ZebkSTSkiAIyxF6Fy
|
||||
ivFwQ6+RP9z1dWDcJgNoJqN64n/Xeg+5zT0HLNgo/Wa9oTkhPSHw4bdWAF6/RVPDl0Auq/fxxcDz
|
||||
aJgWU5VoKGABsMxJawzgdgVsiSp82Q/3p2vdeaX0WYfPMizwkmHuhdWiLEeeBzsrN/fOTpRXsMQd
|
||||
aw5wAfh/GO62vpUmYzLonYAFgEHzOIAgDng57rj7apIs8Q1U1hA8Uliz46w9xGS9UTs1qEweqiTD
|
||||
2vt33p6T0r4KtQgNnNpHA20kIAEofqtBMgH3BWoEx3cSjIS720nfPqsTGLdEhN1V9fPujQmDZCzK
|
||||
PdNb+b23RikkawpTHasTyClS3JyIhREbde6/2RKJ3LwT0N01uFyPAIgLpA2O/zzSjRBvkD5uVz4w
|
||||
48eqr06NAPznZgEDvCzu9WtBaB+kcmBSCUg94W40U4uJ1enFUYE04LcAsNnNDAAbE8jFOYGREgOM
|
||||
6iKvI320pTJb76VoBWDWIHJbFShOD5j7Bs/zxt88iiUWZe2IdIhhHDO39tvf0u87bEX57ajlPv/h
|
||||
wOiuAhqnyBOBF1Yz/FRtIU2nIcy9VcDIagt9T/UWgprcxF8X4RKbsnZEAZmWlKLb/D3Zf4knesjv
|
||||
czmKYdAC0/qTrKKdThEYs0jEusNVRUAjAM/dIODlgQJ0GkKvpoTh7YPrdeetEKk3qqck4wBE/ife
|
||||
8UsArHOzmgPo4e9FOcrSvTFhqES58AvYnMDor0W3W2p3X034eAjh9ZuDXwX4lJ+1b5JN3B3YDdda
|
||||
Z2X7VXDHvxEAY6MQHmHsnPNMvkHAZTJX1i2OShH4fHtNEejakFBH8ajzmuT7KQCJCoQZRyHEBHGk
|
||||
Pyf6LADM3FrPAB70E2YYtcD8OzWyIwCdIvDs9yJe/lmERGCh4jhcDFuP+3fRFC4AnrifzW/vcyyO
|
||||
zwJgSS2+BYCb1BActWmSAswbLEDrw119ZwPDk9+xoKT28sS2XEKpH4t5eg3QzMdcC7WINIsj70Zf
|
||||
T/J9CsDYvT6fwwkZPZoQpvbx7bYu2inixgWhy8v/zQ7/3PmuygqsEEm0IwBD/DhHPmx2SgIR+vp6
|
||||
EU5oGdGBMMzHVfyDBcDgD10+JTP1h7wSYOku/67RqQHPCOINxmgAm1fXJ8c8n/6iVpgGgWf8iQhe
|
||||
6Cfgtra+dZhSG/DIYhFTVzFUBKlU18yfRL+rCfVvoawtUUiMxaW7yZcTfHtCiN3p0/GckHGqlFXZ
|
||||
WxcIePVGwq1tfMxPyIBPtojoO9+FzceUHQ0s2cWwfK9/bXZuALRO5/N/KQSCT31UtgCcr1F2g88W
|
||||
cYKOUwTGLmEY9aVYpVSXRgBeu0nAwFa+d5zjxcCQT0X851cWcP0/oDLl+HOr/G/ovo58+C8HBvRn
|
||||
85vIKPReiey/qlHnuAVAgDleOcHgP7+K+PMYwx9HGR5dIlbJ71fp7+9fOi6nCMxdL+KmBYEl9Txc
|
||||
CAz/3OXXyj9QGZLcl+cDlIvBarPI3g2QL6siG+CXOZygsisfeG/jxWH16n0MTy8Xq8TNawXgzVsE
|
||||
PCQzcKg6/5xmGPSBCy//XDOsWIqdecCdH7lwttyvS0OvAWYO0ChWcKRWIFB/2YfKOYh9BQ0I1/tv
|
||||
EScYuETgme/FGkkyFu9keGypWCUdGBEw6XoBz/Xxz83XKVb6DNy4wIWdefLOWb2P4e5PXCjws/MD
|
||||
lTb7kzugViOiLzPL69uyDrKcyuwCIKDEAxzlWbKbYa+H9ODL9zI89HVNB5+RnQiv3lgZ9OMP+88A
|
||||
g//nwuZjno9xuBjeXMfw72/EgHYThneorDjE8RFCnYq66bLyLskSACLWLzCLOErjEIE31npfVPvl
|
||||
IMOIha4akXd3XElYeC8hxY88/ykxwFu3CejkIbvv4bMMgz5keHOdvLTknri1DeGFIOYjiHYEJsia
|
||||
BsgTAMifU3BCw/I9TFZp7rwSuPX179iAsOR+34bXPZsQVo4S0Ody92/lRTtF3PS+dNESKe7vGJqI
|
||||
xKiGQdZLW1IASuZmpTKGqwO3iKMkH8ko5nFhB8BT2rCGyYTF92nQvbH3nmbSAdP6C/joHgFp8TWP
|
||||
LawAHv5GxBPLmOxyaO7QCMBzfQQ831fgi36B00nOdqCkAOhF1k3OcZzQcbgQ+CtXWgDGdBfQTiKn
|
||||
X4IR+OgewWMZsaapwOL7BAxr7z7pyJoDDP3eFbHyn8De+unxwOdDNRjZifd8hdDYnJauUgfJKdFw
|
||||
jQLGcBRk2W5ph5r0BMLD3eR1Jo0APN9XQKNUhmmrL/oRDG5LmNFfgMlNkGmxBZi6SsR3ewL3Fuzb
|
||||
onJhMpE7mSsKY7gGwCpvx0gKAOMCEHb8clD6mPE9CEYfK/CM6EBokiJgzGIRIzsJGN/TvYCsOcAw
|
||||
8XsRZ8p8a786KTGViUdvbs3f+sGBJPuu10eEmVvrrVTkZxkHTjAos0NykS0rEbhdRuUgd/RoQvhj
|
||||
nMZt0k67C3jpJxH/28ICSiJCBAy6gvDcDYJfFYc5MmHowsyt9WTe43FlxqsAWOoUtycGnoMljNhz
|
||||
UrqU96AryKekINVx1/kPFwKPLg58hb9JSmWkYo8m/K0fAkwVdc9eCeBPTwd4FQCBiZ0YT/0XVuw/
|
||||
I90Bb7lC2TXbRTtFPLcqsBX+GB3wUFcBY7oTdHxJOWRoXEJn+CsAjKitz6VrOUElp9D79xkJQPO6
|
||||
ylyrzA5MXiHi293+PwREwOA2Aib2JviWqoKjBIzQ1tv33peJmPeTOaGnsMJ7Z2ydpsyI7Xgx8OCX
|
||||
Luw7438brdIIL/QldGzg3iabExAZ3O4ycBSjjbcvPQ7G2FfQAGiluDmcgCixev++mQJv/y3HGQZ9
|
||||
6H/nN+mA8T0FfPuA4LHzrz/CMOA9F/rMd2H9ET7MDCJtvAUGeRwB2PIym0EDvkYbZkj51/tbcusC
|
||||
S3YxPP2d/2m7+rUgmPt6Lit2pJBh2mqGnw9e/EWGLRRxaxvC8324L0AQiLWl1G8MHD/k7kuPAiBq
|
||||
qC3xBYCwQ8pFVgjAgf6TrQzPr/IviCc1FpjeX0B/D4k7yuzA7HUiPtxcM8MQY5UhzBtyRLxxC6Fr
|
||||
Q77wrCQucrUB4FYAPA4NiFjLoFnE8RupyjgVdv9E+50NDFNX+tf5B7Qk/DRa47Hz//APw3Vvi3h3
|
||||
o/f0YidLGIZ+JuLNdaxGjgOO/wiMeZzKe1sEbBwEWzgBUieWAC8jM39y+/93LcPs33zvcYlG4OWB
|
||||
gtvy4gBwpqwyD6AvcQIuEXhznYiNRwmzbyXUi+OjgcAhj33Z844s83wSRz0aJnvvTP+c9u0V/sFm
|
||||
/zr/1VmE5Q9qPHb+FX8z9HnX5XeQ0MajDDe97znhCccHCI08feXFJYN5PImjHs3reH8jHiuSPwr4
|
||||
dKuIaT/61vk1AjC+J+GbEQLqJ7k/5tnvRTyySERRhU9N1+BUKcPdn1YmO+UEhG8jAGaGFkBW0Mzh
|
||||
+M0VGSRZHmuZDMedVf8wPLfKN5/+eAPw3p0CxvcUoPHy6njqWgEd68tv1xslVobhnwUeblzLaXB+
|
||||
W78Gbm+jNTWtPuSFCnNCjElXWSPPG9/srFofoDp7TzE8/q1vC34NkwmL7tOgdzPpOXlKDPD5MA1G
|
||||
dFBm/u4QgXFLGNYe4iLgJzprfqbbp8a9jjPykPGNEw5c39y7M31+CfDxFvdD+8IKYNRXviXr7NqQ
|
||||
sOwBwScXY61QGfQzc4D/CUgvxeGqTDK67QQXAb8g1tDdx+6nAKTh5b/DmBtbuc/OcynzfmduvQbH
|
||||
LBJl5RK8QJ/LCR8N8d9BZ0g7wgd3EeIUKCljcQCjvhZxsjTwtmobDHAr324FQABSg2sOJxAyEyoT
|
||||
dHqj2OK+FNewDuR1/n4pN7cmzBssveYgRY8mhC+Ga/zKQlyds+XAE8vEgPIR1EbIQ592PwJg4KUY
|
||||
whxPOfwuZeluhmXVUnYNaEl45UbppJv3tiO8OUiZ4TsAXJEOfDVcg9TYwNv6/QjDp1u5p5BPCO77
|
||||
tFsB8KQWnPChV1PClZnSnXPichF/n6oqAre3JTzf13Pa7XvbEab3Vz4zb7M6wMf3aDxmKfaFl9Yw
|
||||
nPBhKlPbIZAPIwAuAGHPhVJfUlQ4gAe+FJFfUvXzER0I0/pRjU5+Q3PCC/2Cl5O/dTow/w4hoIxF
|
||||
QOXv9aZEYRTORTz1afe3gRgvAxYBdG5QWUFHirwS4J5PxRo1+oa2F6oM86/OIsy+NfDOKUW3RoQJ
|
||||
vQK/yJLdDMeLFTCoFuCjAPAw4Ejh+T6CrEw7OYUMIz53ocRadTpwc2vC/DsIbTIq6wOEKjnHw90I
|
||||
XQKM+nOJ4GsBMvGU29ODHwAU2LThhIIkEzB3sEbWYt2ek8B9X7AaItC7GeHbkRokhDAWXyDgxX6B
|
||||
rzMs3sUCqkFYi3C78uJpHKbAMg0nVHRuADzXR15P2naC4Z5PWQ0/fTVKcTWvC9wUYE2AM2XAjjyu
|
||||
ANIw+QLA+Agg4hjWnmS73u45ydyuCajBv7sGrjwbjypgSJRDILd92v02IHEBiETMfQXcfbW8DvXP
|
||||
aYY7P3Kp7lXXMo2QLVnC0jvbZdRJrO0w36YA7tWCE94QATP6CxjgIUa/OocLgbs+9s01OBhcKyPA
|
||||
yBuHCrgASEM+rQFwIhSNAMwaRBjYSl6nOlrEcMdHLuQUqteJrs4KTAByz4G7BvuJBwFgAdSA4aiN
|
||||
TkOYPUjAbW3lday8EuDuT9QbCWQGOAWwOit/ON5gNnefeloE5AIQQZRYWQ13X40AvHaTgDtkFgk9
|
||||
WQoM/UydhUFfqxi7w+JDeHNthAD5AkDEBSCSmL0euOsThs3HqoqAQMCrNwoYLnN34Eghw/CFIs5Z
|
||||
QzueVuLtLTfCsbbCPIzqPf3Z3KoFJ/w4WsTw8Z8iSqyVKbVXVUudRQS80FfAsPbyRGDvKYbxS0Pr
|
||||
XFMYYO5AAIjR8UUA75D8EQD4CCBieGMtg91V+f92FzB2sYilu2uKwIv95E8HfjnIMOu30HWoE8WB
|
||||
XcuohWJhy1GMDwLAoIAmc4LN8WJg+d6qnccpVibMWLSzpgi8PFDAjTJ3B+b8JtaYUgSLvacCOz/R
|
||||
xDu/FIxgcfe5p12As8E0hqMM720U4XQTC+MSgae+E/HNzpoLg28MEmQF4YisMpeAvzUC5cIY8Ofx
|
||||
wISmMY9dlYQY3CaL95QQhAtAmGNxAEt3e/5eZMDT34n45WDVzqUTgHdu95zT/1IOFwL/2xxcBdh7
|
||||
igW8/XhZXT4CkMJTn/aQEETgAhDmrPy7ZlRfdfQaoEW9mp0jyQS8c7tG1vbbnPUM5UFcEfpie+DT
|
||||
jGY8gZ0kzBcBICb6UWGOE0qqL/S548EuAjIS3H/XOh0Y31N676zECny1IzhrAQXlqDFN8YfOPIm9
|
||||
NOTDFED0cDAnPCi3V9bO84ZRCzzY2fvQ+IHOkJXr/7MgJd2Y9qMYsANPejxwOZ8CSCL6tAbAGBeA
|
||||
AAnm+vm6wxe3/jwxqA0hSaKUuE5DmNZfOuf3wQJg/xkfDJTBsj0M38oYxUhxbTPpGgkcQGDuF/Y9
|
||||
5QQ8FlRragE2Z/Ceyg050sfcIzMsuHMDeUPonw8oJ2lbjjM8s1yZUcVgGTkROQDTUI67z90KgPHs
|
||||
qeMAeHhFAHirzRcom4967zzZiUDbDPkdY2h76bWAbQrF3K87zDDi88CH/kBlLoGODbgAyMBhqpuX
|
||||
5+4Lt+vAZIbTMhcnwDzXFed4p9QWnEnAOSuTLP/dr6Vvab17X1YZPnwhpNakAwyXPBkJRiBFYjoh
|
||||
hcPF8PaGSg9Dl0JLCnJjHDg4RnfC7aTR80YQYzkANQqKOVGOSwROlxGCsRKw7zQk/fS7ui0D6ZlY
|
||||
PTBvcPCiaX49BMz8SVR0HaFZHeD2tsq1F+Uc8fSFZwEgygnqSlYUc7qs8o0XDPad9t6uQECH+kG5
|
||||
tE+UWBlW7QM++lPEnpPKtz/lBg10AdYsrEX4IQBeTuJ4Z1d+8JQzp8j7yKJBMiHRGPqhcYmVYf8Z
|
||||
YNdJYO1BERuPBi9Jx4CWhH81DU7bUQljOZ6+8igAjLE9BD7H8gdfk1TanQzHitx/VycOiLmkWMep
|
||||
Uu9tV9/XL7MDf8m0p9TqfdIisso1iBIrnf8vkF8C7D8TuDuvXBokAy8P5A8P8RsAABVFSURBVM+l
|
||||
LwgC7fH0nUcBEEjYxXiiNb9Yf8S3v9uBAqDnPPcb+5/eK6B744sP/Jky7203Sq767x25lXkClEWd
|
||||
58KoBebeGtoCJtGAyFw7PX3nceXHUJB7EEAYZI6PLI4WMezKV669rGr58k6VeX/7pcVX/Xduifvj
|
||||
Ig2dALx1u4C2mWpbEmEQSo1jT+V4+tqjAJAZIsA8Dh047vlmh3JtEQGZCVU7vNQIoE5s1ePzoqCE
|
||||
tk4A5gwW0DvA9OG1EobdRJ6HbBJ7P7RLaXuimXNWho+2KDfcNmqr7sdbHJCMzKte3FMqYjDcSTIB
|
||||
nw7VoF8L3vn9gpjH4T/gfRcAxLCT8b+7bN7eULkwphTVt7nkeBdWPyeSl3Fa1CO8czuhEU/44Tck
|
||||
kpesERICIArCJmK8/LIcduYBCzYq29sM1e6OXYZvQZDcD0KKVgAe7EJ4vJcAPd/rDwgXsY3evvcq
|
||||
ACZd2jarPb8CQIyiVkUZpTZg/FKX2/RcgWCt5i9v1EoPx2zVzok1BMcjMVh0a0SYfD2hdTofeipA
|
||||
eczZ/L+8HeB1DYBGb3UA2KKoSVGGQwTGLBJxuFD5tsvsqOI3HyOjYmORpWpnb5Ds4cAw48pMwsdD
|
||||
BCwcKvDOrxQMG8nsPahPRlIo+h1gPZWyKZq4kIZ73eHgvGEZqxxdXIjr1wqVgTne1hnyq237ycn9
|
||||
pxY6AbjhcsKQdlTF14GjGL9LHSApAMTwO18IrMk5K/DQVy5sCnLmhOPFqJLYo34SYc9Jz4JzrLjq
|
||||
v69IJ+gEBD27r1wMWqB7Y8J1lxH6tSCk8Mll0CAlBMDmpD/0OiaCVxKuwj2fiAHns5dDTiFDm0ti
|
||||
+xsmw2twTXUnpHgD0LEBYUOOOusA6fGVcfvts4FODSodeZSoBciRxGUwGDdJHSR5KxIfP1FomZO5
|
||||
DUAHRcyKEro1JlyVFfzrJFaLw2+TQVjxt+fOfLSIobACVd6swzpcTA/mFIEKBbP8mnSVW48mHaFO
|
||||
LENaHCE1FmiUAjRJJcS7rUrPCTqETTT6sKQbmFwtXgkuAFWYcj0BKgRLtc3w/j1jwK8HGQZfUhq8
|
||||
fwtC/5A40vC5YtjAaJWcw2QN65kIWY1xgk+H+iQ5hP5Rwfx9nMhEhLhSznGyBMCUkbcJvFpQWGDQ
|
||||
Ap0lSnv9coDhnIIeiZwIg6Eg5mz+NjmHypoC0J1wWebgJwB3BWQYRxEGtiKsPeT5LW91At/sEPFA
|
||||
Z3nrtkUV0lGDBMb35yMFolWVwXzSyF+PJfY9GHEBCAP6Xg5MXek9NuDjLcCIjpW+A1I8+Z2INRLT
|
||||
hh5NCJ8M4QIQCTDGVsg9VvbWnlG0fQuADyzDgEQj4ebW3jvj0SJWo0S4O/48xiQ7PwAMkVlngKM6
|
||||
VhOs38s9WLYA0LjCEkb40T+bOEpzX0fpijiz1omo8JJ/3yUCL6yWHimmxROub+6jgRxVINAKGlco
|
||||
Ow2MT849JLIvfTeJEwxapxOuv8y7AuSVVIqAJz7awrBbRsbesd0JOg0fAUQCIsGnPuqTABgtjmUA
|
||||
LD5ZxAkaj/ciaCTu4PubGHa6qQlzpJDhv79Kv/2zE4G7+fA/UqgwWZyyh/+AryOAZwpKCSRrf5ET
|
||||
fFqmkeRKv1MExix2VQkgqgxiYiiT4RE46XoBOu4EHil8R0+d8imPp8+3VoS40NdzOMFjQk9Cw2Tv
|
||||
b+jjxcC4pSIcLgbGgInfi14Dii7QrwVhQEv+9o8UBMLnPp/j6wkmfeYyAEGo9cLxB5MOeGmg9ILg
|
||||
rwcZnl7OMHMNw2IZuwOpscC0fvzVH0Gc1OsyZG//XcDnO0yjtzqI8Imv53GCR7dGhPs7Sb+pl+xi
|
||||
eG+j9LxfpyHMGyygbpwS1nFCAtEH5xP4+IR/Ei9q5iOS8kzVAiZfp1za7MnXE7pIuBtzwgpGRB/6
|
||||
c6JfAmAcd/wQQOv8OZcTHDTnc+e3Sgus447qIuC+jrzzRxQMPxvHnDjoz6kBTPLYu/6fywkGsXrg
|
||||
/bsEZCT4d/6DXQRMvp53/kiDCVjg77l+C4BRn/E1gBP+ns8JDhkJwNL7BbT0YSRABIzvKZzPccCJ
|
||||
MHJNBcmL/T3ZbwE4v+Awz9/zOcEjLZ7wxTAB7bKlO7ROAP5zE2F8T975IxHGMIvMe/zO8RTQPo/V
|
||||
qX8HQFkgbXCCQ6IR+OxeAX0u99yxsxOBRfdpcFtbvt0XkRBKbS79e4E0EdCdT56QU8xA7wfSBid4
|
||||
mHTA/NsFTO9PNWoG9m9JWDFKw6vtRjDEMD95Qk6x9JFe2gjUCMsb6Y2gFQ7Al9wCnJBzvBiY8K2I
|
||||
/WcYJvYWMKQdH/JHOA6IaGZ6LC+gxPSKPAWWOZmfARiiRFuc4OFwMVQ4CIlGtS3hBA772PRo/ohA
|
||||
W1Fk8keiYAa8lyDiqI9Owzt/lOAiEmYo0ZBi40DHn7M3CGlXdVWqPQ6H4x7x9F+/6TqMU6Rcn2Lz
|
||||
9lMlSVPSr75mjUYqQJ3D4fiNyyWi8NDBF5VqT9GVoPffX5dzXGzZUMk2ORzORRpo9h4eObJXU6Xa
|
||||
U3Tl3pKz/q4/zqZvtDNeD4rDURq9YEVayobblWxT0fH62GmTNnWM37BeyTY5HE4lneI2/PrwtGe3
|
||||
K9mm4hP2VqacW1N1BXxHgMNRkBRNgesy01HF63IoLgBDzOaCDrF/cO9ADkdBOsZveGu42Xxa6XaD
|
||||
smRvNRQ80thwkMcIcDgK0Nh4sNRqODs+GG0HRQDMZrPYJmbrBJJXnozD4XiAIOLKmC1jzGZzUDpT
|
||||
UB3Cn35q4e4/y65pHcxrcDjRTKf433a88urQq4LVflC9dlon7umboilwBfMaHE60kqorcDbR7u0f
|
||||
zGsEVQBGTJmR2y1h3fRgXoPDiVa6xq1/ZfTMmfnBvEZIYkLHPbnk+K7ydtmhuBaHEw1cGbc1583X
|
||||
BjcO9nVC4rjfIm7HwFhNKU8jrhAxRgHxMRd/BD/volLtAJAsTMKRT4K2RGwRu3NgKK4Vstv22qQ3
|
||||
Xl5RNPiZUF0vWkmME/DF9CwY9Bdv3eiXTuLAcd/SwtVJ0mDhi5nQai+2M9ychxOn5flwEQHXto/B
|
||||
dR1j0bKRHolxGjhdDGeKXNi814Jl68qQk+9znQoOgP7JS994euZjj4fiWiEL3Xtq5oSJ7eI37Q/V
|
||||
9aKV4QMSq3R+fxl5U1KVzu8LSfEazH4iDVNG1kHXNiYkxWtABOi0hMy6WgzqFY93J6VjaP/EgO2s
|
||||
bXSI++PvUHV+IIQCAABXxuztkabP8zuDaW2nW1sTBvWKD7id3h1i0LdLrF/nxhgFvP5YPbRu4j3g
|
||||
S6shjLwpEcMHcBGQS5ou39E86dC1obxmSAVguNl8ukvs2ge0xEMFfKVrGxOmjqwT8Fy7V7sYPDM8
|
||||
1e92hg9IQONMnfSB5xk2IBFNsuQfX1vRkhNdY38bPWry5FOhvG7Is3eMnznx024Jv6wO9XUjFa2W
|
||||
8OAtSZg2um5AQ3+DjvDI7cl47oE60Pk59E9L0WLwtVVHIIwB7y4pxh3P5uLB6fnYvs9a5XuNAIy+
|
||||
Ndlvu2sLPRJ/Xv7YS0/5Vd8vEFRJ30PGE/3bxPyVq8a1I4n2LYx4Z2I6hvRN8HuFngjo1NqE+c+m
|
||||
4/be8QGNIK5tHwOtpmoD3/1Whi9+LMHZcy4cznPg+XcLUFpe1Wu1fQsjkhM0/l84ymlt2nFSNOTe
|
||||
osa1VREAs9kstjZs6pihz7Wpcf1wx6gnzH82Ha+Nq4cmPgy3qxMfK2DB5Ay8PKYuGqQHPgzv1S6m
|
||||
xmcr/6ga81VmEfHrtooqnwkC0OMqU8DXj0bS9Hn2dqadnYLl6y+Fagn8Rs+cmd8tZs1gk1DB/QOq
|
||||
odcRLquvr/H5vqN2n7b7YgyC2/n67kM2n7fodFpCs2o2uUTgUG7NdvYcrqnrLRvxLFHVMQoW1j1x
|
||||
zZ0jZ0w5rpYNqmbwHPvS1BXXJq56lYhrgDcYA5avL8P4/55Ccan/LwrGgMW/lOKJWadRWuFbO/Xr
|
||||
aVE932t+gRNOZ817d+JMzUXeRhl8IfBSiBiuTfzhzbEvTvlWTTtUr+bz1MwJE6c8ndLz99J/8ZTi
|
||||
bjhw3I53lxRj6z9W6YO9sPeIDfOXFGPXQf9mXfXdTCEKit3v5hQU1fy8fprqj1pY0S3+1/Wh3O/3
|
||||
RFjcFW3Mke5Xi7EHt5d3DLrvcyTAGPD7TguWry/D5j0WMD8HSC6RYe32CixfX4atfwcmIAmxNQeL
|
||||
Nrt7w+yOmp/HGAVoteR2xFDbuDJ22zGtKaeX2nYAKk8BLmA2m8UuFbvbXmb6p0htW8KB0goRU985
|
||||
g027/e/8AFBQ7MIL7xUE3PkBwGRwIwBuOjoA2D24eZgMPGCgqWn/uQ5xO9qotehXnbAQAAC48y1z
|
||||
WbuYzZ3TuadgWGJ044Pg7k0PAHYPb3l3IlKbqKc76eiU8GenoWZzidq2XCCs7si/p089cE3izwMS
|
||||
tOfCQh05fuJh1CLU4gFAkrZIvC5xzQ0PmSeFVTxMWAkAAIx9cfKaPokr7+Lhw+GFu+G+J49Cvc79
|
||||
5xXW2qnrMUI565mwcuhD0yatVduW6oSdAADAmOnPfNMn+YcHTYKFi0CYYLXV7LyeOrqnzy222nc7
|
||||
TUIF65P63f0TZjz7udq2uCMsBQAAxk174oO+ScsfMZBFbVM4AMrcaLHehxGAzcHgqGU7AHqy4bqk
|
||||
5Y8/9uIzH6ltiyfCVgAA4LEZT75zffKKqTx6UH3yztT0+EtJdO/fX8fN58dO1q7kIDpyoG/y8slP
|
||||
zHjqTbVt8UZYCwAAPDnj8el9k7+dqiceNqAmx0/VFOHMulq3wUXZ9Wq6lxytRdmB9IIV/ZKXTX58
|
||||
xuMz1bZFirAXAKBSBAakfDeWxw2oR5lFRH61ko8GHbl18XUX/7//WO3Y3TUKFtY38bsnI6HzAxEi
|
||||
AADw2PQn5vVJ/HZ4nFDCRUAlNuysuR7Tr2tclX9rtYR/ta+abYgxYN1f0b+WEyuUsT6Jyx56fOaT
|
||||
r6tti1wiRgCAymQi/ZOW38X9BNThl60VNT4bfG08eneIAVFl+PHTQ1NQL7nqGsDuwzacLozudZwk
|
||||
bZHYJ3n57RNmPr1AbVt8ISxiAXzhkRnPfv3uVHb615J/rc63Z9WMmeUEjb1HbFi3vQI9r76YF0Aj
|
||||
AFNG1sHTwxh0WqqxJsAYsGBpcYgtDS1punxH97g1A8dOn/yj2rb4SkSNAC7w0LRJa3vp17RqZtwX
|
||||
3U9WGDLv6yK3b3O9rmbnByrDmHcdit4F3MbGg6W9U36+YuzMyOv8QIQKAACMfm3qoV6JGxpeFbcl
|
||||
R21bahNnil14fNZpHMmTXtX//vcyzPqyMARWqUOb2G0neiT+1iDc3Ht9IaITtS3+9Vdbvz6t5mQL
|
||||
5/octzeqr7Y9oaBxhg52J0N+gfP/f9bvqEBJuW/LIk2z9LDYqrazblsFKqzSa6ylFSJW/F6Oc+Ui
|
||||
EuM0qJN08TGyOxg277Vi9ldFWPRzaUDRjOEKEcM1Cb+uj43dd+UEszmiVzejJjzjtSmzZv5S1Gei
|
||||
RYyJmt8pUtBpCcnxApwuoKjUFZWd/gJGwcKuTfzhzXBI5qEEUdVZ5k2Z1u/30t5L8+3ZPAEdR3HS
|
||||
9Hn2nvG/3PXI9ElL1bZFKaJKAABg/qRJGXtsnf/cVXFVltq2cKKHVqYdp9qbdnZUM4FnMIjYRUBP
|
||||
jJ45Mz8lfnuDXgmrf+AxBJxA0ZIT1yatXl43YVtmtHV+IApHAJcya/Irw/4o67nglD2T+wtwfCZd
|
||||
n2fvHLv2gfEzJ36qti3BIqoFAAAWmM0pByqa/by5tMeVatvCiRyuivvzcKukfd1CXasv1ES9AFzg
|
||||
1WffeG1d6Q1PlLvia83vzPGdBG2JeE3CmlefnjH+WbVtCQW1qjO8Pcl8xX7HFd/+Vdaxidq2cMKP
|
||||
NrHbTrSK29H332bzXrVtCRW1SgAu8PqkN8x/lPWcfNZRJ+JiITjKk6w56+qasPY/T82cMFFtW0JN
|
||||
rRQAAPjYbK63v6LptxtKu3dh0bcZwpEBEUP72I1/N9PtvG70zJn5atujBrVWAC4we+rrI/8qvWrW
|
||||
EVuzOOmjOdFCE+OBkrYxW8c+NuOZT9S2RU0iOhZACVauW739ln5NXmmlP5pQ5ErtaBHd1MDiRA3J
|
||||
2gJXz8Q1C2Jj/+kxcdqUHWrboza1fgRwKQvN5jr7rdkfbyrt3s8qmvjfJorQC1Z0jN28sWHisUHR
|
||||
vrXnC/whd8O8qS91zLE0/N9fFZ1bORlfJ4xktOTE1bGbdjcyHr3vkWnPblXbnnCDC4AX3ps8vct+
|
||||
e7MPtpd1bOmKvORJtRotOXFF7PbDzXT7Hhwzc/IvatsTrnABkMHcSTNuOORo8e6usqsacSEIb7Tk
|
||||
RNuY7Yea6PeN4h1fGi4APjBv8vTu+Y6Gr20vb9+5Qozlf7swwiRYWJvYLTub6XL+PWrGlI1q2xMp
|
||||
8IfYDz6aPjnraFn9OTvLO9x81lm31u+kqEmStkhsE7vtt7ra3PsfnT71iNr2RBpcAALgq0fMcUcS
|
||||
U186ZGt+70Hr5cmM8T9nKCBiaGb8p6iJ4cCnyYazE0ebzTXzlXNkwZ9YhZj73IzrTtszpu4tb9ud
|
||||
jwqCQ5K2SGxt2rU5Q3tsEp/fKwMXAIWZbzbHnHMkTc21NRr6T0WrbDvj2ckCQS9Y0cK093i28din
|
||||
dbRFL95vNlvVtima4AIQRBaYzSnFjqQJufbsIX+XX9HExkxqmxQR6MiO5qZ/TmUZjn6bgjxzbfXT
|
||||
DwVcAELER9MnZ+VXpD170pE9MMfSrME5VyJ3Ob6ERM05sYlx/9E0Xf73SZQ/k3f60MAFQAV+MZu1
|
||||
O+zGu4vFOkPy7VmdD1ubp9Q2j0OBXKivP1aRrT++M8Vw6pOrNWXvXms28ySOIYYLQBjw3owZaaVW
|
||||
w20ljqQbCxxp7Y5Ym9aLNj8DvWBFtv54SYYuf2+S7uxP8fbS/41+beohte2q7UTVQxYtfGg2G0ts
|
||||
sfeUs/j+hc6kNoXOutn5jqzYSAlQMgoWlqHLLU/VnTmerCneHas/932CYPmSL+CFHxHxQHEAs9ks
|
||||
1LXrelUgpm+5K75duRjfoMSZWLfYlRxf6KyjC/UUQktOpGgLHEnawtIETcmZWKH0WIy2bGuSULEq
|
||||
T2P/zWw28xLuEQAXgCjgK7NZf86hbVdBpvZ2UahvF/UZdhjS7aIh1S4akmzMFOdgWp2LaTROptcB
|
||||
gJUZtIxpCACIXMxINicA6MhuF8gl6sjpMJClzKCxFenIVqiH7aResOfrBfF4DLNsTdQ5t91pNtvV
|
||||
/L05HA6Hw+FwOBwOh8PhcDgcDocjwf8BnHZ+mE3sgIkAAAAASUVORK5CYII=
|
||||
"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 18 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="120" fill="none"><g clip-path="url(#a)"><path fill="#472459" d="M127.2 65.67c-5.13 5.11-12.23 3.88-17.35-1.25l-41.4-41.6 19.4-19.47a11.35 11.35 0 0 1 18.4 3.37l21.58 56.22a2.5 2.5 0 0 1-.57 2.67l-13.63 13.57 13.58-13.51Z"/><path fill="url(#b)" d="m109.85 64.42-54.16-54.4a13.13 13.13 0 0 0-18.57-.05L3.87 43.07a13.13 13.13 0 0 0-.05 18.57l54.16 54.4a13.13 13.13 0 0 0 18.57.04l10.7-10.64 3.12-3.12-10-10.03a19.04 19.04 0 0 1-23.83-2.56l-6.8-6.85a2.46 2.46 0 0 1 0-3.5l3.35-3.32-8.4-8.46a3.67 3.67 0 0 1-.34-4.9 3.57 3.57 0 0 1 5.29-.23l8.51 8.56 6.68-6.63-8.41-8.46a3.67 3.67 0 0 1-.33-4.9 3.58 3.58 0 0 1 5.3-.24l8.5 8.57 3.36-3.34a2.47 2.47 0 0 1 3.5.01l6.8 6.84a19.03 19.03 0 0 1 2.41 23.86l10 10.03 5.69-5.67 8.16-8.12 17.4-17.31c-5.15 5.11-12.25 3.88-17.36-1.25Z"/></g><defs><linearGradient id="b" x1="63.6" x2="63.6" y1="6.15" y2="119.91" gradientUnits="userSpaceOnUse"><stop stop-color="#FFCA4A"/><stop offset="1" stop-color="#F7931A"/></linearGradient><clipPath id="a"><path fill="#fff" d="M0 0h128v119.91H0z"/></clipPath></defs></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,4 +1,4 @@
|
||||
"""Load the Nix-generated config and build-time versions for Sovran_SystemsOS_Hub."""
|
||||
"""Load the Nix-generated config for Sovran_SystemsOS_Hub."""
|
||||
|
||||
import json
|
||||
import os
|
||||
@@ -18,23 +18,3 @@ def load_config() -> dict:
|
||||
return json.load(fh)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return {"refresh_interval": 5, "command_method": "systemctl", "services": []}
|
||||
|
||||
|
||||
def load_versions() -> dict:
|
||||
"""Read the Nix-generated or fallback build-time package versions."""
|
||||
path = os.environ.get("SOVRAN_HUB_VERSIONS")
|
||||
if not path:
|
||||
# Candidate 1: parent folder (same as config.json, typical for Nix layout)
|
||||
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
candidate1 = os.path.join(parent_dir, "versions.json")
|
||||
if os.path.exists(candidate1):
|
||||
path = candidate1
|
||||
else:
|
||||
# Candidate 2: local package folder (dev folder structure)
|
||||
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "versions.json")
|
||||
|
||||
try:
|
||||
with open(path, "r") as fh:
|
||||
return json.load(fh)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return {}
|
||||
@@ -1,177 +0,0 @@
|
||||
#!/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,29 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# ── Sovran Hub External Backup Script ────────────────────────────
|
||||
# Backs up Sovran_SystemsOS data to an external USB hard drive using rsync.
|
||||
# Backs up Sovran_SystemsOS data to an external USB hard drive.
|
||||
# Designed for the Hub web UI (no GUI dependencies).
|
||||
#
|
||||
# On Server + Desktop and Node systems, your Sovran Pro already backs up
|
||||
# your data automatically to its internal second drive (BTCEcoandBackup at
|
||||
# /run/media/Second_Drive); this script stores a copy in a third location.
|
||||
# Desktop Only systems have no internal second drive, so the external copy
|
||||
# is the second location.
|
||||
#
|
||||
# What gets mirrored depends on the system role:
|
||||
# - Node / Server + Desktop: /etc/nixos, /etc/nix-bitcoin-secrets,
|
||||
# /home, and /var/lib (minus databases, blockchain data, logs, caches).
|
||||
# - Desktop Only: /etc/nixos and /home. Desktop Only runs no server or
|
||||
# Bitcoin services, so there are no nix-bitcoin secrets or system
|
||||
# service data to back up.
|
||||
#
|
||||
# The external drive must be formatted as ext4. Files are stored as
|
||||
# directly browsable files under Sovran_SystemsOS_Backup/current/.
|
||||
# Later runs update the same mirror and only transfer changed or new
|
||||
# files, making repeat backups fast.
|
||||
#
|
||||
# PostgreSQL and MariaDB/MySQL databases are NOT included. Bitcoin
|
||||
# blockchain and Electrs index data are NOT included (they live on
|
||||
# the internal second drive).
|
||||
# Your Sovran Pro already backs up your data automatically to its
|
||||
# internal second drive (BTCEcoandBackup at /run/media/Second_Drive).
|
||||
# This script creates an additional copy on an external USB drive —
|
||||
# storing your data in a third location for maximum protection.
|
||||
#
|
||||
# Usage:
|
||||
# BACKUP_TARGET=/run/media/<user>/<drive> bash sovran-hub-backup.sh
|
||||
@@ -34,28 +17,18 @@ set -euo pipefail
|
||||
BACKUP_LOG="/var/log/sovran-hub-backup.log"
|
||||
BACKUP_STATUS="/var/log/sovran-hub-backup.status"
|
||||
MEDIA_ROOT="/run/media"
|
||||
MIN_FREE_GB=10
|
||||
HUB_CONFIG_JSON="/var/lib/sovran-hub/config.json"
|
||||
ROLE_STATE_NIX="/etc/nixos/role-state.nix"
|
||||
SECOND_DRIVE_MOUNT="/run/media/Second_Drive"
|
||||
SAFETY_MARGIN_BYTES=$((1024 * 1024 * 1024))
|
||||
|
||||
# ── Internal drive labels/paths to NEVER use as backup targets ───
|
||||
INTERNAL_LABELS=("BTCEcoandBackup" "sovran_systemsos")
|
||||
INTERNAL_MOUNTS=("$SECOND_DRIVE_MOUNT" "/boot/efi" "/")
|
||||
|
||||
FAILED_ALREADY=0
|
||||
BACKUP_COMPLETE=0
|
||||
RSYNC_WARNINGS=()
|
||||
|
||||
# Stable rsync mirror sub-path under the target drive. Not timestamped
|
||||
# so later runs update the same destination and only transfer new or changed files.
|
||||
BACKUP_SUBPATH="Sovran_SystemsOS_Backup/current"
|
||||
INTERNAL_MOUNTS=("/run/media/Second_Drive" "/boot/efi" "/")
|
||||
|
||||
# ── Logging helpers ──────────────────────────────────────────────
|
||||
|
||||
log() {
|
||||
local msg
|
||||
msg="[$(date '+%Y-%m-%d %H:%M:%S')] $*"
|
||||
local msg="[$(date '+%Y-%m-%d %H:%M:%S')] $*"
|
||||
echo "$msg" | tee -a "$BACKUP_LOG"
|
||||
}
|
||||
|
||||
@@ -64,47 +37,16 @@ set_status() {
|
||||
}
|
||||
|
||||
fail() {
|
||||
FAILED_ALREADY=1
|
||||
log "ERROR: $*"
|
||||
set_status "FAILED"
|
||||
exit 1
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
|
||||
# Release the concurrency lock file descriptor if it was opened
|
||||
if [[ -n "${LOCK_FD:-}" ]]; then
|
||||
exec {LOCK_FD}>&- 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [[ "$BACKUP_COMPLETE" -eq 1 && "$rc" -eq 0 ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ "$FAILED_ALREADY" -eq 0 ]]; then
|
||||
log "ERROR: Backup terminated unexpectedly (exit code $rc)."
|
||||
set_status "FAILED"
|
||||
fi
|
||||
|
||||
# Mark the backup directory as incomplete so failed runs are identifiable
|
||||
if [[ -n "${BACKUP_DIR:-}" && -d "${BACKUP_DIR:-}" && ! -f "${BACKUP_DIR:-}/BACKUP_COMPLETE" ]]; then
|
||||
touch "${BACKUP_DIR}/INCOMPLETE" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
trap 'exit 1' INT TERM
|
||||
|
||||
require_cmd() {
|
||||
local cmd="$1"
|
||||
command -v "$cmd" >/dev/null 2>&1 || fail "Required command not found: $cmd"
|
||||
}
|
||||
|
||||
# ── Check whether a mount point is an internal drive ────────────
|
||||
|
||||
is_internal() {
|
||||
local mnt="$1"
|
||||
# Reject known internal mount points and their subdirectories
|
||||
for internal in "${INTERNAL_MOUNTS[@]}"; do
|
||||
if [[ "$mnt" == "$internal" || "$mnt" == "${internal}/"* ]]; then
|
||||
return 0
|
||||
@@ -117,18 +59,22 @@ is_internal() {
|
||||
|
||||
find_external_drive() {
|
||||
local target=""
|
||||
|
||||
# lsblk JSON output: NAME,LABEL,MOUNTPOINT,HOTPLUG,RM,TYPE
|
||||
if command -v lsblk &>/dev/null; then
|
||||
while IFS=$'\t' read -r dev_type hotplug removable label mountpoint; do
|
||||
# Must be a partition or disk, and be removable/hotplug
|
||||
[[ "$dev_type" == "part" || "$dev_type" == "disk" ]] || continue
|
||||
[[ "$hotplug" == "1" || "$removable" == "1" ]] || continue
|
||||
[[ -n "$mountpoint" ]] || continue
|
||||
|
||||
# Filter out internal labels
|
||||
local skip=0
|
||||
for lbl in "${INTERNAL_LABELS[@]}"; do
|
||||
[[ "$label" == "$lbl" ]] && skip=1 && break
|
||||
done
|
||||
[[ "$skip" -eq 1 ]] && continue
|
||||
|
||||
# Filter out internal mount points
|
||||
is_internal "$mountpoint" && continue
|
||||
|
||||
if mountpoint -q "$mountpoint" 2>/dev/null; then
|
||||
@@ -138,16 +84,14 @@ find_external_drive() {
|
||||
done < <(lsblk -J -o NAME,LABEL,MOUNTPOINT,HOTPLUG,RM,TYPE 2>/dev/null | \
|
||||
python3 -c "
|
||||
import sys, json
|
||||
|
||||
data = json.load(sys.stdin)
|
||||
def flatten(devs):
|
||||
for d in devs:
|
||||
yield d
|
||||
for c in d.get('children', []):
|
||||
yield from flatten([c])
|
||||
|
||||
data = json.load(sys.stdin)
|
||||
for d in flatten(data.get('blockdevices', [])):
|
||||
print('\\t'.join([
|
||||
print('\t'.join([
|
||||
d.get('type') or '',
|
||||
str(d.get('hotplug') or '0'),
|
||||
str(d.get('rm') or '0'),
|
||||
@@ -155,10 +99,24 @@ for d in flatten(data.get('blockdevices', [])):
|
||||
d.get('mountpoint') or '',
|
||||
]))
|
||||
" 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
# Fallback: walk /run/media/ if lsblk produced nothing
|
||||
if [[ -z "$target" && -d "$MEDIA_ROOT" ]]; then
|
||||
while IFS= read -r -d '' mnt; do
|
||||
is_internal "$mnt" && continue
|
||||
# Check label via lsblk on the device backing this mount
|
||||
local dev
|
||||
dev=$(findmnt -n -o SOURCE "$mnt" 2>/dev/null || true)
|
||||
if [[ -n "$dev" ]]; then
|
||||
local lbl
|
||||
lbl=$(lsblk -n -o LABEL "$dev" 2>/dev/null || true)
|
||||
local skip=0
|
||||
for internal_lbl in "${INTERNAL_LABELS[@]}"; do
|
||||
[[ "$lbl" == "$internal_lbl" ]] && skip=1 && break
|
||||
done
|
||||
[[ "$skip" -eq 1 ]] && continue
|
||||
fi
|
||||
if mountpoint -q "$mnt" 2>/dev/null; then
|
||||
target="$mnt"
|
||||
break
|
||||
@@ -170,10 +128,16 @@ for d in flatten(data.get('blockdevices', [])):
|
||||
}
|
||||
|
||||
# ── Detect the configured system role ───────────────────────────
|
||||
#
|
||||
# Priority:
|
||||
# 1. Hub config JSON (/var/lib/sovran-hub/config.json) — "role" key
|
||||
# 2. role-state.nix (/etc/nixos/role-state.nix) — grep for true flag
|
||||
# 3. Default: server_plus_desktop
|
||||
|
||||
detect_role() {
|
||||
local role="server_plus_desktop"
|
||||
|
||||
# 1. Try the Hub config JSON
|
||||
if [[ -f "$HUB_CONFIG_JSON" ]] && command -v python3 &>/dev/null; then
|
||||
local r
|
||||
r=$(python3 -c \
|
||||
@@ -185,6 +149,7 @@ detect_role() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2. Fall back to parsing role-state.nix
|
||||
if [[ -f "$ROLE_STATE_NIX" ]]; then
|
||||
if grep -q 'roles\.desktop = lib\.mkDefault true' "$ROLE_STATE_NIX" 2>/dev/null; then
|
||||
role="desktop"
|
||||
@@ -196,119 +161,6 @@ detect_role() {
|
||||
echo "$role"
|
||||
}
|
||||
|
||||
validate_target_mount() {
|
||||
local target="$1"
|
||||
[[ "$target" == "${MEDIA_ROOT}/"* ]] || fail "Target '$target' must be mounted under $MEDIA_ROOT."
|
||||
[[ -d "$target" ]] || fail "Target path '$target' does not exist."
|
||||
mountpoint -q "$target" || fail "Target path '$target' is not a mount point."
|
||||
|
||||
local fstype=""
|
||||
fstype=$(findmnt -n -o FSTYPE -T "$target" 2>/dev/null || true)
|
||||
[[ -n "$fstype" ]] || fail "Could not determine filesystem type for '$target'."
|
||||
|
||||
if [[ "$fstype" != "ext4" ]]; then
|
||||
fail "Target '$target' must be formatted as ext4 (detected filesystem: $fstype). Manual Backup requires an ext4-formatted external drive for Linux metadata preservation. exFAT, FAT32, and NTFS are not supported."
|
||||
fi
|
||||
|
||||
local write_test
|
||||
write_test="$target/.sovran-write-test-$$"
|
||||
if ! ( : > "$write_test" && echo "ok" >> "$write_test" && rm -f "$write_test" ); then
|
||||
fail "Target '$target' is not writable."
|
||||
fi
|
||||
|
||||
log "Verified backup target filesystem: $fstype"
|
||||
}
|
||||
|
||||
estimate_path_bytes() {
|
||||
local path="$1"
|
||||
shift || true
|
||||
[[ -e "$path" ]] || {
|
||||
echo 0
|
||||
return
|
||||
}
|
||||
|
||||
local size
|
||||
size=$(du -s -B1 -x "$@" "$path" 2>/dev/null | awk '{print $1}' || true)
|
||||
[[ -n "$size" ]] || size=0
|
||||
echo "$size"
|
||||
}
|
||||
|
||||
# ── Sync one source tree to its backup destination ───────────────
|
||||
# Usage: sync_tree <label> <allow_vanished> <source> <destination> [rsync options...]
|
||||
#
|
||||
# allow_vanished: "yes" means rsync exit 24 (vanished files) is nonfatal.
|
||||
# Used for /home only — files may disappear while the desktop is active.
|
||||
# All other nonzero exit codes are always fatal.
|
||||
#
|
||||
# Before every rsync call this helper:
|
||||
# 1. Re-verifies $TARGET is still a mount point (fails if drive disconnected).
|
||||
# 2. Verifies the destination path remains beneath $BACKUP_DIR and $BACKUP_DIR
|
||||
# remains beneath $TARGET (safe-path check).
|
||||
# 3. Creates the full destination directory hierarchy with mkdir -p so that
|
||||
# rsync never fails trying to create a directory whose parent is absent.
|
||||
sync_tree() {
|
||||
local label="$1"
|
||||
local allow_vanished="$2"
|
||||
local source="$3"
|
||||
local destination="$4"
|
||||
shift 4
|
||||
# Remaining "$@" are rsync options (--exclude, etc.)
|
||||
|
||||
# ── Re-verify the external drive is still mounted ────────────────
|
||||
mountpoint -q "$TARGET" 2>/dev/null || \
|
||||
fail "Stage $label: external drive '$TARGET' is no longer mounted. Refusing to write."
|
||||
|
||||
# ── Verify path safety ────────────────────────────────────────────
|
||||
# BACKUP_DIR must remain beneath TARGET.
|
||||
case "$BACKUP_DIR" in
|
||||
"$TARGET"/*) ;;
|
||||
*) fail "Stage $label: BACKUP_DIR '$BACKUP_DIR' is outside TARGET '$TARGET'." ;;
|
||||
esac
|
||||
# Destination must remain beneath BACKUP_DIR.
|
||||
case "$destination" in
|
||||
"$BACKUP_DIR"/*|"$BACKUP_DIR") ;;
|
||||
*) fail "Stage $label: destination '$destination' is outside BACKUP_DIR '$BACKUP_DIR'. Refusing to write." ;;
|
||||
esac
|
||||
|
||||
# ── Create complete destination directory hierarchy ───────────────
|
||||
# This is the fix for the production failure:
|
||||
# rsync: [Receiver] mkdir ".../current/etc/nixos" failed: No such file or directory
|
||||
# mkdir -p creates all intermediate parents (e.g. current/etc/) before rsync runs.
|
||||
mkdir -p -- "$destination" || \
|
||||
fail "Stage $label: failed to create destination directory '$destination' (source: '$source')."
|
||||
|
||||
local rsync_err_tmp
|
||||
rsync_err_tmp="$(mktemp /tmp/sovran-rsync-err.XXXXXX)"
|
||||
|
||||
local rc=0
|
||||
rsync \
|
||||
--archive \
|
||||
--acls \
|
||||
--xattrs \
|
||||
--hard-links \
|
||||
--numeric-ids \
|
||||
--one-file-system \
|
||||
--partial \
|
||||
"$@" "$source" "$destination" 2>"$rsync_err_tmp" || rc=$?
|
||||
|
||||
if [[ -s "$rsync_err_tmp" ]]; then
|
||||
while IFS= read -r rline; do
|
||||
log "rsync: $rline"
|
||||
done < "$rsync_err_tmp"
|
||||
fi
|
||||
rm -f "$rsync_err_tmp"
|
||||
|
||||
if [[ "$rc" -eq 0 ]]; then
|
||||
return 0
|
||||
elif [[ "$allow_vanished" == "yes" && "$rc" -eq 24 ]]; then
|
||||
log "NOTE: $label — some files vanished during sync (normal on an active desktop). Your important data is backed up."
|
||||
RSYNC_WARNINGS+=("$label: some files vanished during sync (rsync exit 24 — normal on active desktop)")
|
||||
return 0
|
||||
else
|
||||
fail "rsync failed for $label (exit code $rc). See the rsync errors above."
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Initialise log file ──────────────────────────────────────────
|
||||
|
||||
: > "$BACKUP_LOG"
|
||||
@@ -317,30 +169,6 @@ set_status "RUNNING"
|
||||
log "=== Sovran_SystemsOS External Hub Backup ==="
|
||||
log "Starting backup process…"
|
||||
|
||||
# ── Acquire exclusive run lock ────────────────────────────────────
|
||||
# Prevents two simultaneous backup runs (e.g. from double-click or
|
||||
# stale RUNNING status after a Hub restart).
|
||||
|
||||
LOCK_FILE="/var/lock/sovran-hub-backup.lock"
|
||||
# Note: exec {LOCK_FD}>>file requires bash 4.1+ (NixOS provides bash 5.x).
|
||||
exec {LOCK_FD}>>"$LOCK_FILE" 2>/dev/null || \
|
||||
fail "Cannot open lock file: $LOCK_FILE. Ensure /var/lock is writable."
|
||||
flock --nonblock "$LOCK_FD" 2>/dev/null || \
|
||||
fail "Another backup is already running. Wait for it to complete or check $BACKUP_STATUS."
|
||||
|
||||
require_cmd rsync
|
||||
require_cmd findmnt
|
||||
require_cmd lsblk
|
||||
require_cmd mountpoint
|
||||
require_cmd df
|
||||
require_cmd du
|
||||
require_cmd awk
|
||||
require_cmd find
|
||||
require_cmd hostname
|
||||
require_cmd date
|
||||
require_cmd python3
|
||||
require_cmd flock
|
||||
|
||||
# ── Detect system role ───────────────────────────────────────────
|
||||
|
||||
ROLE="$(detect_role)"
|
||||
@@ -352,23 +180,11 @@ case "$ROLE" in
|
||||
esac
|
||||
log "Detected role: $ROLE_LABEL"
|
||||
|
||||
# Backup scope depends on the role. Desktop Only systems run no server or
|
||||
# Bitcoin services, so only the NixOS configuration and home directory are
|
||||
# mirrored (2 stages). Node and Server + Desktop systems also mirror the
|
||||
# nix-bitcoin secrets and /var/lib system service data (4 stages).
|
||||
if [[ "$ROLE" == "desktop" ]]; then
|
||||
TOTAL_STAGES=2
|
||||
HOME_STAGE_NUM=2
|
||||
log "Desktop Only role: backing up the NixOS configuration (/etc/nixos) and home directory (/home) only."
|
||||
else
|
||||
TOTAL_STAGES=4
|
||||
HOME_STAGE_NUM=3
|
||||
fi
|
||||
|
||||
# ── Detect target drive ──────────────────────────────────────────
|
||||
|
||||
if [[ -n "${BACKUP_TARGET:-}" ]]; then
|
||||
TARGET="$BACKUP_TARGET"
|
||||
# Safety: never allow internal drives even if explicitly passed
|
||||
if is_internal "$TARGET"; then
|
||||
fail "Target '$TARGET' is an internal system drive and cannot be used for external backup."
|
||||
fi
|
||||
@@ -377,261 +193,129 @@ else
|
||||
log "Auto-detecting external USB drives…"
|
||||
TARGET="$(find_external_drive)"
|
||||
if [[ -z "$TARGET" ]]; then
|
||||
fail "No external USB drive detected. Please plug in an ext4-formatted USB drive and try again."
|
||||
fail "No external USB drive detected. " \
|
||||
"Please plug in an exFAT-formatted USB drive (≥500 GB) and try again."
|
||||
fi
|
||||
log "Detected external drive: $TARGET"
|
||||
fi
|
||||
|
||||
validate_target_mount "$TARGET"
|
||||
# ── Verify mount point ───────────────────────────────────────────
|
||||
|
||||
# ── Set up stable backup destination ────────────────────────────
|
||||
# Subsequent runs update the same mirror, transferring only new or changed files.
|
||||
[[ -d "$TARGET" ]] || fail "Target path '$TARGET' does not exist."
|
||||
mountpoint -q "$TARGET" || fail "Target path '$TARGET' is not a mount point."
|
||||
|
||||
BACKUP_DIR="${TARGET}/${BACKUP_SUBPATH}"
|
||||
mkdir -p -- "$BACKUP_DIR"
|
||||
# ── Check free disk space (require ≥ 10 GB) ──────────────────────
|
||||
|
||||
# Remove any stale BACKUP_COMPLETE left by a previous successful run.
|
||||
# The new run will re-earn it only after all stages succeed.
|
||||
rm -f "$BACKUP_DIR/BACKUP_COMPLETE"
|
||||
FREE_KB=$(df -k --output=avail "$TARGET" | tail -1)
|
||||
FREE_GB=$(( FREE_KB / 1024 / 1024 ))
|
||||
log "Free space on drive: ${FREE_GB} GB"
|
||||
(( FREE_GB >= MIN_FREE_GB )) || \
|
||||
fail "Not enough free space on drive (${FREE_GB} GB available, ${MIN_FREE_GB} GB required)."
|
||||
|
||||
# Write an INCOMPLETE marker immediately; replaced by BACKUP_COMPLETE only
|
||||
# after all rsync stages and manifest write succeed. Failed or interrupted
|
||||
# runs keep this marker so they are clearly identifiable.
|
||||
touch "$BACKUP_DIR/INCOMPLETE"
|
||||
# ── Create timestamped backup directory ─────────────────────────
|
||||
|
||||
TIMESTAMP="$(date '+%Y%m%d_%H%M%S')"
|
||||
BACKUP_DIR="${TARGET}/Sovran_SystemsOS_Backup/${TIMESTAMP}"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
log "Backup destination: $BACKUP_DIR"
|
||||
|
||||
# ── Estimate required free space ─────────────────────────────────
|
||||
# PostgreSQL/MariaDB raw directories and Bitcoin/Electrs data are excluded
|
||||
# from the estimate to avoid inflating the required size.
|
||||
|
||||
ETC_NIXOS_BYTES=$(estimate_path_bytes /etc/nixos)
|
||||
HOME_BYTES=$(estimate_path_bytes /home --exclude='*/.cache' --exclude='*/.local/share/Trash' --exclude='*/Trash')
|
||||
|
||||
# nix-bitcoin secrets and /var/lib system service data exist only on the
|
||||
# Node and Server + Desktop roles — they are skipped entirely on Desktop Only.
|
||||
SECRETS_BYTES=0
|
||||
VAR_LIB_BYTES=0
|
||||
if [[ "$ROLE" != "desktop" ]]; then
|
||||
SECRETS_BYTES=$(estimate_path_bytes /etc/nix-bitcoin-secrets)
|
||||
VAR_LIB_BYTES=$(estimate_path_bytes /var/lib \
|
||||
--exclude='postgresql' \
|
||||
--exclude='mysql' \
|
||||
--exclude='mariadb' \
|
||||
--exclude='bitcoind' \
|
||||
--exclude='electrs' \
|
||||
--exclude='*/log' \
|
||||
--exclude='*/logs' \
|
||||
--exclude='*/cache' \
|
||||
--exclude='*/tmp')
|
||||
fi
|
||||
|
||||
ESTIMATED_BYTES=$(( ETC_NIXOS_BYTES + HOME_BYTES + SECRETS_BYTES + VAR_LIB_BYTES ))
|
||||
# Require 20% growth headroom plus a fixed 1 GiB safety margin.
|
||||
# Later incremental runs need far less space, but a conservative first-run
|
||||
# check protects against running out of space mid-backup.
|
||||
REQUIRED_BYTES=$(( ESTIMATED_BYTES + (ESTIMATED_BYTES / 5) + SAFETY_MARGIN_BYTES ))
|
||||
|
||||
FREE_BYTES=$(df -B1 --output=avail "$TARGET" | tail -1 | tr -d ' ')
|
||||
FREE_GB=$(( FREE_BYTES / 1024 / 1024 / 1024 ))
|
||||
REQUIRED_GB=$(( REQUIRED_BYTES / 1024 / 1024 / 1024 ))
|
||||
|
||||
log "Estimated backup size: $(( ESTIMATED_BYTES / 1024 / 1024 / 1024 )) GB"
|
||||
log "Required free space (with safety margin): ${REQUIRED_GB} GB"
|
||||
log "Free space on drive: ${FREE_GB} GB"
|
||||
|
||||
(( FREE_BYTES >= REQUIRED_BYTES )) || \
|
||||
fail "Not enough free space on drive (${FREE_GB} GB available, ${REQUIRED_GB} GB required)."
|
||||
|
||||
# ── Stage 1: NixOS configuration ────────────────────────────────
|
||||
# ── Stage 1/4: NixOS configuration ──────────────────────────────
|
||||
|
||||
log ""
|
||||
log "── Stage 1/${TOTAL_STAGES}: NixOS configuration (/etc/nixos) ──────────────"
|
||||
log "── Stage 1/4: NixOS configuration (/etc/nixos) ──────────────"
|
||||
if [[ -d /etc/nixos ]]; then
|
||||
sync_tree "/etc/nixos" no /etc/nixos/ "$BACKUP_DIR/etc/nixos/"
|
||||
rsync -a --info=progress2 /etc/nixos/ "$BACKUP_DIR/nixos/" 2>&1 | tee -a "$BACKUP_LOG" || \
|
||||
fail "Stage 1 failed while copying /etc/nixos"
|
||||
log "Stage 1 complete."
|
||||
else
|
||||
log "WARNING: /etc/nixos not found — skipping."
|
||||
fi
|
||||
|
||||
# ── Stage 2: Secrets ────────────────────────────────────────────
|
||||
# Only applies to the Node and Server + Desktop roles. Desktop Only systems
|
||||
# run no nix-bitcoin services, so there are no secrets to back up and this
|
||||
# stage does not exist for them.
|
||||
# ── Stage 2/4: Secrets ──────────────────────────────────────────
|
||||
|
||||
if [[ "$ROLE" != "desktop" ]]; then
|
||||
log ""
|
||||
log "── Stage 2/${TOTAL_STAGES}: Secrets (/etc/nix-bitcoin-secrets) ───────────"
|
||||
log "── Stage 2/4: Secrets ───────────────────────────────────────"
|
||||
mkdir -p "$BACKUP_DIR/secrets"
|
||||
|
||||
if [[ "$ROLE" == "desktop" ]]; then
|
||||
log "Skipping /etc/nix-bitcoin-secrets — not applicable for Desktop Only role."
|
||||
else
|
||||
if [[ -e /etc/nix-bitcoin-secrets ]]; then
|
||||
sync_tree "/etc/nix-bitcoin-secrets" no /etc/nix-bitcoin-secrets/ "$BACKUP_DIR/etc/nix-bitcoin-secrets/"
|
||||
rsync -a --info=progress2 /etc/nix-bitcoin-secrets "$BACKUP_DIR/secrets/" 2>&1 | tee -a "$BACKUP_LOG" || \
|
||||
log "WARNING: Could not copy /etc/nix-bitcoin-secrets — continuing."
|
||||
else
|
||||
log " (not found: /etc/nix-bitcoin-secrets — skipping)"
|
||||
fi
|
||||
log "Stage 2 complete."
|
||||
fi
|
||||
|
||||
# ── Home directory ──────────────────────────────────────────────
|
||||
# Stage 2/2 on Desktop Only, stage 3/4 on Node and Server + Desktop.
|
||||
# Rsync exit code 24 (vanished source files) is treated as nonfatal here
|
||||
# because the desktop may be active and files can disappear between the
|
||||
# directory scan and the copy. All other nonzero exit codes remain fatal.
|
||||
log "Stage 2 complete."
|
||||
|
||||
# ── Stage 3/4: Home directory ────────────────────────────────────
|
||||
|
||||
log ""
|
||||
log "── Stage ${HOME_STAGE_NUM}/${TOTAL_STAGES}: Home directory (/home) ───────────────────────"
|
||||
log "── Stage 3/4: Home directory (/home) ───────────────────────"
|
||||
if [[ -d /home ]]; then
|
||||
sync_tree "/home" yes /home/ "$BACKUP_DIR/home/" \
|
||||
rsync -a --info=progress2 \
|
||||
--exclude='.cache/' \
|
||||
--exclude='.local/share/Trash/' \
|
||||
--exclude='Trash/' \
|
||||
--exclude='.mozilla/firefox/*/cache2/' \
|
||||
--exclude='.mozilla/firefox/*/startupCache/' \
|
||||
--exclude='.mozilla/firefox/*/thumbnails/' \
|
||||
--exclude='.config/google-chrome/*/Cache/' \
|
||||
--exclude='.config/google-chrome/*/Code Cache/' \
|
||||
--exclude='.config/chromium/*/Cache/' \
|
||||
--exclude='.config/chromium/*/Code Cache/' \
|
||||
--exclude='.config/BraveSoftware/Brave-Browser/*/Cache/' \
|
||||
--exclude='.config/BraveSoftware/Brave-Browser/*/Code Cache/' \
|
||||
--exclude='.config/BraveSoftware/Brave-Origin/*/Cache/' \
|
||||
--exclude='.config/BraveSoftware/Brave-Origin/*/Code Cache/' \
|
||||
--exclude='.local/share/baloo/' \
|
||||
--exclude='.thumbnails/' \
|
||||
--exclude='.xsession-errors' \
|
||||
--exclude='.xsession-errors.old'
|
||||
log "Stage ${HOME_STAGE_NUM} complete."
|
||||
--exclude='*/Trash/' \
|
||||
/home/ "$BACKUP_DIR/home/" 2>&1 | tee -a "$BACKUP_LOG" || \
|
||||
fail "Stage 3 failed while copying /home"
|
||||
log "Stage 3 complete."
|
||||
else
|
||||
log "WARNING: /home not found — skipping."
|
||||
fi
|
||||
|
||||
# ── Stage 4: System data ────────────────────────────────────────
|
||||
# Only applies to the Node and Server + Desktop roles — Desktop Only systems
|
||||
# run no server services, so /var/lib holds no service data worth mirroring.
|
||||
# PostgreSQL/MariaDB raw database directories are excluded. Application
|
||||
# databases must be backed up separately with native database tools.
|
||||
# Bitcoin/Electrs data are excluded; they live on the internal second drive.
|
||||
# ── Stage 4/4: System data ───────────────────────────────────────
|
||||
|
||||
if [[ "$ROLE" != "desktop" ]]; then
|
||||
log ""
|
||||
log "── Stage 4/${TOTAL_STAGES}: System data (/var/lib) ───────────────────────"
|
||||
log "── Stage 4/4: System data (/var/lib) ────────────────────────"
|
||||
if [[ "$ROLE" == "desktop" ]]; then
|
||||
if [[ -d /var/lib ]]; then
|
||||
sync_tree "/var/lib" no /var/lib/ "$BACKUP_DIR/var/lib/" \
|
||||
--exclude='postgresql/' \
|
||||
--exclude='mysql/' \
|
||||
--exclude='mariadb/' \
|
||||
--exclude='bitcoind/' \
|
||||
--exclude='electrs/' \
|
||||
--exclude='*/log/' \
|
||||
rsync -a --info=progress2 \
|
||||
--filter='- /lnd/***' \
|
||||
--exclude='logs/' \
|
||||
--exclude='log/' \
|
||||
--exclude='*/logs/' \
|
||||
--exclude='*/cache/' \
|
||||
--exclude='*/tmp/'
|
||||
log "Stage 4 complete."
|
||||
--exclude='*/log/' \
|
||||
/var/lib/ "$BACKUP_DIR/var-lib/" 2>&1 | tee -a "$BACKUP_LOG" || \
|
||||
fail "Stage 4 failed while copying /var/lib for Desktop Only role"
|
||||
log "Stage 4 complete (Desktop Only role excludes /var/lib/lnd)."
|
||||
else
|
||||
log "WARNING: /var/lib not found — skipping."
|
||||
fi
|
||||
elif [[ -d /var/lib ]]; then
|
||||
rsync -a --info=progress2 \
|
||||
--exclude='logs/' \
|
||||
--exclude='log/' \
|
||||
--exclude='*/logs/' \
|
||||
--exclude='*/log/' \
|
||||
/var/lib/ "$BACKUP_DIR/var-lib/" 2>&1 | tee -a "$BACKUP_LOG" || \
|
||||
fail "Stage 4 failed while copying /var/lib"
|
||||
log "Stage 4 complete."
|
||||
else
|
||||
log "WARNING: /var/lib not found — skipping."
|
||||
fi
|
||||
|
||||
# ── Generate manifest ────────────────────────────────────────────
|
||||
|
||||
log ""
|
||||
log "Generating BACKUP_MANIFEST.txt …"
|
||||
MANIFEST_FILE="$BACKUP_DIR/BACKUP_MANIFEST.txt"
|
||||
|
||||
{
|
||||
echo "Sovran_SystemsOS Backup Manifest"
|
||||
echo "Updated: $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
||||
echo "Generated: $(date)"
|
||||
echo "Hostname: $(hostname)"
|
||||
echo "Role: $ROLE_LABEL"
|
||||
echo "Target: $TARGET"
|
||||
echo ""
|
||||
echo "Backup type: Live rsync mirror (directly browsable files)"
|
||||
echo "Location: ${BACKUP_DIR}"
|
||||
echo ""
|
||||
echo "Source paths mirrored:"
|
||||
echo "- /etc/nixos → current/etc/nixos/"
|
||||
if [[ "$ROLE" != "desktop" ]]; then
|
||||
echo "- /etc/nix-bitcoin-secrets (when present) → current/etc/nix-bitcoin-secrets/"
|
||||
fi
|
||||
echo "- /home → current/home/"
|
||||
if [[ "$ROLE" != "desktop" ]]; then
|
||||
echo "- /var/lib → current/var/lib/"
|
||||
fi
|
||||
echo ""
|
||||
echo "Exclusions:"
|
||||
if [[ "$ROLE" != "desktop" ]]; then
|
||||
echo "- /var/lib/postgresql (PostgreSQL raw database files — not included)"
|
||||
echo "- /var/lib/mysql, /var/lib/mariadb (MariaDB raw database files — not included)"
|
||||
echo "- /var/lib/bitcoind (Bitcoin blockchain — excluded; lives on internal second drive)"
|
||||
echo "- /var/lib/electrs (Electrs index — excluded; lives on internal second drive)"
|
||||
echo "- /run/media/Second_Drive (internal second drive — never traversed)"
|
||||
echo "- /var/lib/*/log, /var/lib/*/logs, /var/lib/*/cache, /var/lib/*/tmp"
|
||||
fi
|
||||
echo "- Browser disk caches, thumbnail caches, trash directories, X session error logs"
|
||||
echo ""
|
||||
echo "Important limitations:"
|
||||
if [[ "$ROLE" != "desktop" ]]; then
|
||||
echo "- PostgreSQL and MariaDB/MySQL application databases are NOT included in this"
|
||||
echo " backup. If you use Nextcloud, Matrix/Synapse, or other database-backed"
|
||||
echo " applications, their data must be backed up separately using native tools."
|
||||
echo "- Bitcoin blockchain data and Electrs indexes are NOT included; they are"
|
||||
echo " reconstructable or stored on the internal second drive."
|
||||
fi
|
||||
echo "- This is a live file-level mirror, not a transactional database backup."
|
||||
echo " Files being written during the backup may be in an inconsistent state."
|
||||
echo ""
|
||||
echo "Restore guidance:"
|
||||
echo "- Files are directly browsable on the backup drive under: ${BACKUP_DIR}"
|
||||
echo "- To restore a directory:"
|
||||
echo " sudo rsync -aAXH --numeric-ids current/etc/nixos/ /etc/nixos/"
|
||||
echo " sudo rsync -aAXH --numeric-ids current/home/ /home/"
|
||||
if [[ "$ROLE" != "desktop" ]]; then
|
||||
echo " sudo rsync -aAXH --numeric-ids current/var/lib/ /var/lib/"
|
||||
fi
|
||||
echo "- To copy individual files:"
|
||||
echo " sudo cp -a current/home/username/ /home/username/"
|
||||
echo "- When restoring /etc/nixos to replacement hardware, regenerate"
|
||||
echo " hardware-configuration.nix for the new hardware before rebuilding."
|
||||
echo ""
|
||||
echo "Nonfatal warnings:"
|
||||
if [[ "${#RSYNC_WARNINGS[@]}" -eq 0 ]]; then
|
||||
echo "- none"
|
||||
else
|
||||
for warning in "${RSYNC_WARNINGS[@]}"; do
|
||||
echo "- $warning"
|
||||
done
|
||||
fi
|
||||
if [[ "$ROLE" != "desktop" ]]; then
|
||||
echo ""
|
||||
echo "Note: Bitcoin blockchain and Electrs index data are intentionally excluded"
|
||||
echo "from manual external backup because they already live on the internal second drive"
|
||||
echo "(/run/media/Second_Drive) and are reconstructable/internal-backup data."
|
||||
fi
|
||||
} > "$MANIFEST_FILE"
|
||||
|
||||
log "Manifest written to $MANIFEST_FILE"
|
||||
echo "Contents:"
|
||||
find "$BACKUP_DIR" -mindepth 1 -maxdepth 2 | sort
|
||||
} > "$BACKUP_DIR/BACKUP_MANIFEST.txt"
|
||||
log "Manifest written to $BACKUP_DIR/BACKUP_MANIFEST.txt"
|
||||
|
||||
# ── Done ─────────────────────────────────────────────────────────
|
||||
|
||||
log ""
|
||||
if [[ "${#RSYNC_WARNINGS[@]}" -gt 0 ]]; then
|
||||
log "Backup completed with nonfatal warnings:"
|
||||
for warning in "${RSYNC_WARNINGS[@]}"; do
|
||||
log " WARNING: $warning"
|
||||
done
|
||||
log "Your important data is backed up. The warnings above indicate files that"
|
||||
log "vanished during backup, which is normal on an active desktop."
|
||||
log ""
|
||||
fi
|
||||
if [[ "$ROLE" == "desktop" ]]; then
|
||||
log "All Finished! Your data is now backed up to a second, external location."
|
||||
else
|
||||
log "All Finished! Your data is now backed up to a third location."
|
||||
fi
|
||||
log "Files are directly browsable on the drive under: ${BACKUP_DIR}"
|
||||
log "Please eject the drive safely before removing it from your Sovran Pro."
|
||||
|
||||
# Remove incomplete marker and write completion marker only after all work succeeds.
|
||||
# A later successful run will update the same mirror and replace any INCOMPLETE state.
|
||||
rm -f "$BACKUP_DIR/INCOMPLETE"
|
||||
date -u '+%Y-%m-%dT%H:%M:%SZ' > "$BACKUP_DIR/BACKUP_COMPLETE"
|
||||
|
||||
BACKUP_COMPLETE=1
|
||||
set_status "SUCCESS"
|
||||
@@ -1,526 +0,0 @@
|
||||
"""Sovran Hub — pure security validation helpers.
|
||||
|
||||
This module contains the dependency-light security helper functions used by
|
||||
the Hub server. Keeping them here allows tests to import and exercise the
|
||||
exact production implementations rather than maintaining separate copies.
|
||||
|
||||
All functions in this module depend only on the Python standard library.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
# ── Nix string escaping ────────────────────────────────────────────────────────
|
||||
|
||||
def _nix_escape(value: str) -> str:
|
||||
"""Escape *value* for use inside a Nix double-quoted string literal.
|
||||
|
||||
Handles backslashes, double-quotes, newlines, carriage returns, tabs, and
|
||||
Nix-specific anti-quotation sequences (``${...}``). The returned value is
|
||||
safe to embed as ``"<returned_value>"`` in generated Nix source.
|
||||
"""
|
||||
value = value.replace("\\", "\\\\")
|
||||
value = value.replace('"', '\\"')
|
||||
value = value.replace("\n", "\\n")
|
||||
value = value.replace("\r", "\\r")
|
||||
value = value.replace("\t", "\\t")
|
||||
value = value.replace("${", "\\${")
|
||||
return value
|
||||
|
||||
|
||||
# ── Nostr npub validation (NIP-19 / Bech32) ───────────────────────────────────
|
||||
|
||||
# Fast pre-filter: "npub1" followed by exactly 58 lower-case bech32 characters.
|
||||
NPUB_RE = re.compile(r"^npub1[023456789acdefghjklmnpqrstuvwxyz]{58}$")
|
||||
|
||||
_BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
_BECH32_GENERATOR = (0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3)
|
||||
|
||||
|
||||
def _bech32_polymod(values: list[int]) -> int:
|
||||
chk = 1
|
||||
for value in values:
|
||||
top = chk >> 25
|
||||
chk = ((chk & 0x1FFFFFF) << 5) ^ value
|
||||
for i in range(5):
|
||||
if (top >> i) & 1:
|
||||
chk ^= _BECH32_GENERATOR[i]
|
||||
return chk
|
||||
|
||||
|
||||
def _bech32_hrp_expand(hrp: str) -> list[int]:
|
||||
return [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp]
|
||||
|
||||
|
||||
def _bech32_create_checksum(hrp: str, data: list[int]) -> list[int]:
|
||||
values = _bech32_hrp_expand(hrp) + data
|
||||
polymod = _bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ 1
|
||||
return [(polymod >> (5 * (5 - i))) & 31 for i in range(6)]
|
||||
|
||||
|
||||
def _bech32_convertbits_decode(data: list[int]) -> list[int] | None:
|
||||
"""Convert a 5-bit integer sequence to 8-bit bytes, stripping padding."""
|
||||
acc = 0
|
||||
bits = 0
|
||||
ret: list[int] = []
|
||||
for value in data:
|
||||
acc = (acc << 5) | value
|
||||
bits += 5
|
||||
while bits >= 8:
|
||||
bits -= 8
|
||||
ret.append((acc >> bits) & 0xFF)
|
||||
if bits >= 5 or ((acc << (8 - bits)) & 0xFF):
|
||||
return None # invalid padding
|
||||
return ret
|
||||
|
||||
|
||||
def _bech32_decode(bech: str) -> tuple[str, bytes] | None:
|
||||
"""Decode a bech32 string. Returns ``(hrp, payload_bytes)`` or ``None``.
|
||||
|
||||
Verifies:
|
||||
- Lowercase-only (mixed case rejected per BIP-173).
|
||||
- Only valid bech32 charset characters.
|
||||
- Valid checksum.
|
||||
- Exactly one separator (``1``).
|
||||
- Minimum data part length (≥ 8 chars = 6 checksum + ≥ 2 data).
|
||||
"""
|
||||
if bech != bech.lower():
|
||||
return None # mixed case
|
||||
sep = bech.rfind("1")
|
||||
if sep < 1 or sep + 7 > len(bech):
|
||||
return None
|
||||
hrp = bech[:sep]
|
||||
data_part = bech[sep + 1:]
|
||||
if any(c not in _BECH32_CHARSET for c in data_part):
|
||||
return None
|
||||
decoded = [_BECH32_CHARSET.index(c) for c in data_part]
|
||||
if _bech32_polymod(_bech32_hrp_expand(hrp) + decoded) != 1:
|
||||
return None # bad checksum
|
||||
converted = _bech32_convertbits_decode(decoded[:-6])
|
||||
if converted is None:
|
||||
return None
|
||||
return hrp, bytes(converted)
|
||||
|
||||
|
||||
def _validate_npub(value: str) -> bool:
|
||||
"""Return ``True`` iff *value* is a valid NIP-19 Nostr npub.
|
||||
|
||||
Checks:
|
||||
- Lowercase ``npub`` HRP.
|
||||
- Valid bech32 charset (no uppercase, no invalid chars).
|
||||
- Valid bech32 checksum.
|
||||
- Exactly 32 decoded payload bytes (256-bit public key).
|
||||
- Retains the original regex as a fast pre-filter.
|
||||
"""
|
||||
if not NPUB_RE.fullmatch(value):
|
||||
return False
|
||||
result = _bech32_decode(value)
|
||||
if result is None:
|
||||
return False
|
||||
hrp, payload = result
|
||||
return hrp == "npub" and len(payload) == 32
|
||||
|
||||
|
||||
# ── DDNS URL validation ────────────────────────────────────────────────────────
|
||||
|
||||
_DDNS_URL_MAX_LEN = 2048
|
||||
_DDNS_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]")
|
||||
|
||||
# Allowlist: only the official Njal.la provider hostnames are accepted for
|
||||
# DDNS update URLs. Any other host would allow SSRF against the Hub's
|
||||
# internal network.
|
||||
_DDNS_ALLOWED_HOSTNAMES: frozenset[str] = frozenset(["njal.la", "www.njal.la"])
|
||||
|
||||
|
||||
def _validate_ddns_url(url: str) -> str:
|
||||
"""Validate *url* as a safe DDNS update URL and return it normalised.
|
||||
|
||||
Rules:
|
||||
- Must be a valid URL parseable by urllib.parse.
|
||||
- Scheme must be ``https`` (case-insensitive).
|
||||
- No userinfo (credentials must not be embedded in the URL).
|
||||
- No fragment.
|
||||
- No control characters.
|
||||
- Must not exceed ``_DDNS_URL_MAX_LEN`` bytes.
|
||||
- Hostname must be the exact Njal.la provider hostname (njal.la or www.njal.la).
|
||||
- Port must be absent or the default HTTPS port 443.
|
||||
- No percent-encoded null bytes.
|
||||
|
||||
Raises ``ValueError`` with a safe (non-secret) message on failure.
|
||||
"""
|
||||
if not url:
|
||||
raise ValueError("DDNS URL must not be empty")
|
||||
if len(url) > _DDNS_URL_MAX_LEN:
|
||||
raise ValueError("DDNS URL exceeds maximum length")
|
||||
if _DDNS_CONTROL_RE.search(url):
|
||||
raise ValueError("DDNS URL contains control characters")
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
except Exception:
|
||||
raise ValueError("DDNS URL could not be parsed")
|
||||
if parsed.scheme.lower() != "https":
|
||||
raise ValueError("DDNS URL must use the https scheme")
|
||||
if parsed.username or parsed.password:
|
||||
raise ValueError("DDNS URL must not contain credentials")
|
||||
if parsed.fragment:
|
||||
raise ValueError("DDNS URL must not contain a fragment")
|
||||
if parsed.port is not None and parsed.port != 443:
|
||||
raise ValueError("DDNS URL must use the default HTTPS port")
|
||||
hostname = parsed.hostname or ""
|
||||
if not hostname:
|
||||
raise ValueError("DDNS URL must contain a hostname")
|
||||
# Reject raw IP addresses
|
||||
try:
|
||||
ipaddress.ip_address(hostname)
|
||||
raise ValueError("DDNS URL hostname must not be a raw IP address")
|
||||
except ValueError as exc:
|
||||
if "raw IP" in str(exc):
|
||||
raise
|
||||
# Allowlist: only Njal.la
|
||||
if hostname.lower() not in _DDNS_ALLOWED_HOSTNAMES:
|
||||
raise ValueError(
|
||||
f"DDNS URL hostname is not an allowed Njal.la host "
|
||||
f"(got {hostname!r})"
|
||||
)
|
||||
if "%00" in url.lower():
|
||||
raise ValueError("DDNS URL must not contain encoded null bytes")
|
||||
# Reject any remaining $ expressions — after ${IP} substitution there
|
||||
# must be none. Callers that store ${IP} placeholder URLs must substitute
|
||||
# before calling this function.
|
||||
if "$" in url:
|
||||
raise ValueError("DDNS URL must not contain $ expressions")
|
||||
# Require the exact /update/ path used by Njal.la
|
||||
if parsed.path != "/update/":
|
||||
raise ValueError("DDNS URL path must be exactly /update/")
|
||||
return url
|
||||
|
||||
|
||||
# ── SSH public-key validation ─────────────────────────────────────────────────
|
||||
|
||||
_SSH_PUBKEY_ALGORITHMS = frozenset([
|
||||
"ssh-ed25519",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"ecdsa-sha2-nistp384",
|
||||
"ecdsa-sha2-nistp521",
|
||||
"sk-ssh-ed25519@openssh.com",
|
||||
])
|
||||
|
||||
|
||||
def _validate_ssh_pubkey(key: str) -> str:
|
||||
"""Validate *key* as a single OpenSSH public key and return it normalised.
|
||||
|
||||
Accepts only single-line keys with a supported algorithm, valid base64
|
||||
payload, and an optional comment. Rejects options, multiple lines,
|
||||
control characters, and unsupported algorithms.
|
||||
|
||||
Raises ``ValueError`` with a safe message on failure.
|
||||
"""
|
||||
key = key.strip()
|
||||
if not key:
|
||||
raise ValueError("SSH public key must not be empty")
|
||||
if _DDNS_CONTROL_RE.search(key):
|
||||
raise ValueError("SSH public key contains control characters")
|
||||
if "\n" in key or "\r" in key:
|
||||
raise ValueError("SSH public key must be a single line")
|
||||
parts = key.split()
|
||||
if len(parts) < 2:
|
||||
raise ValueError("SSH public key is malformed")
|
||||
algo, b64 = parts[0], parts[1]
|
||||
if algo not in _SSH_PUBKEY_ALGORITHMS:
|
||||
raise ValueError(f"Unsupported SSH key algorithm: {algo!r}")
|
||||
try:
|
||||
decoded = base64.b64decode(b64, validate=True)
|
||||
except Exception:
|
||||
raise ValueError("SSH public key payload is not valid base64")
|
||||
if len(decoded) < 20:
|
||||
raise ValueError("SSH public key payload is too short")
|
||||
return key
|
||||
|
||||
|
||||
# ── Login throttling ──────────────────────────────────────────────────────────
|
||||
#
|
||||
# Delays applied after each failed login, and the lockout that follows once an
|
||||
# address has tripped LOGIN_FAIL_MAX inside the window.
|
||||
#
|
||||
# LOGIN_FAIL_WINDOW has to be longer than the time it takes to reach
|
||||
# LOGIN_FAIL_MAX failures under the ramping delay: with a 2s ramp capped at
|
||||
# LOGIN_FAIL_MAX_DELAY, 10 attempts take about 80 seconds, so a 60 second
|
||||
# window would silently expire the earliest failures and the counter could
|
||||
# never reach the limit. 900s (15 minutes) keeps the whole ramp inside it.
|
||||
LOGIN_FAIL_DELAY = 2.0 # base delay; the nth failure waits n x this
|
||||
LOGIN_FAIL_MAX_DELAY = 10.0 # ceiling for a single delay
|
||||
LOGIN_FAIL_WINDOW = 900.0 # rolling window failures are counted in
|
||||
LOGIN_FAIL_MAX = 10 # failures in the window that trigger a lockout
|
||||
LOGIN_LOCKOUT_SECONDS = 300.0 # how long the lockout lasts
|
||||
|
||||
# Cap on how many addresses are tracked, so a distributed sweep cannot grow
|
||||
# the table without bound.
|
||||
_LOGIN_THROTTLE_MAX_IPS = 4096
|
||||
|
||||
|
||||
class LoginThrottle:
|
||||
"""Per-address failed-login tracking with a ramping delay and a lockout.
|
||||
|
||||
The delay ramps so a script hammering the login form slows down as it goes,
|
||||
and once LOGIN_FAIL_MAX failures land inside the window the address is
|
||||
refused outright for LOGIN_LOCKOUT_SECONDS. A successful login clears the
|
||||
address so a legitimate user who fumbles a password is not penalised later.
|
||||
|
||||
``sleep`` and ``clock`` are injectable so tests run without waiting.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
fail_delay=LOGIN_FAIL_DELAY,
|
||||
max_delay=LOGIN_FAIL_MAX_DELAY,
|
||||
window=LOGIN_FAIL_WINDOW,
|
||||
max_failures=LOGIN_FAIL_MAX,
|
||||
lockout=LOGIN_LOCKOUT_SECONDS,
|
||||
max_tracked_ips=_LOGIN_THROTTLE_MAX_IPS,
|
||||
sleep=None,
|
||||
clock=None,
|
||||
):
|
||||
self._fail_delay = float(fail_delay)
|
||||
self._max_delay = float(max_delay)
|
||||
self._window = float(window)
|
||||
self._max_failures = int(max_failures)
|
||||
self._lockout = float(lockout)
|
||||
self._max_tracked_ips = int(max_tracked_ips)
|
||||
self._sleep = sleep if sleep is not None else time.sleep
|
||||
self._clock = clock if clock is not None else time.monotonic
|
||||
self._lock = threading.Lock()
|
||||
self._failures: dict[str, list[float]] = {}
|
||||
|
||||
# ── internals ────────────────────────────────────────────────────────────
|
||||
|
||||
def _prune(self, ip, now):
|
||||
"""Drop timestamps outside the window; return what is left."""
|
||||
keep = [t for t in self._failures.get(ip, ()) if now - t < self._window]
|
||||
if keep:
|
||||
self._failures[ip] = keep
|
||||
else:
|
||||
self._failures.pop(ip, None)
|
||||
return keep
|
||||
|
||||
def _evict(self, now):
|
||||
"""Forget addresses that can no longer affect anything."""
|
||||
horizon = max(self._window, self._lockout)
|
||||
for ip in [i for i, ts in self._failures.items()
|
||||
if ts and now - max(ts) > horizon]:
|
||||
self._failures.pop(ip, None)
|
||||
while len(self._failures) > self._max_tracked_ips:
|
||||
oldest = min(self._failures, key=lambda i: max(self._failures[i]))
|
||||
self._failures.pop(oldest, None)
|
||||
|
||||
# ── public API ───────────────────────────────────────────────────────────
|
||||
|
||||
def delay_for(self, count):
|
||||
"""Return the delay owed after *count* failures in the current window."""
|
||||
if count <= 0:
|
||||
return 0.0
|
||||
return min(self._fail_delay * count, self._max_delay)
|
||||
|
||||
def failure_count(self, ip):
|
||||
"""Return the failures currently counted against *ip*."""
|
||||
with self._lock:
|
||||
return len(self._prune(ip, self._clock()))
|
||||
|
||||
def is_locked_out(self, ip):
|
||||
"""Return True while *ip* is inside a lockout."""
|
||||
now = self._clock()
|
||||
with self._lock:
|
||||
failures = self._prune(ip, now)
|
||||
if len(failures) < self._max_failures:
|
||||
return False
|
||||
return (now - failures[-1]) < self._lockout
|
||||
|
||||
def remaining_lockout(self, ip):
|
||||
"""Return the seconds left in *ip*'s lockout, or 0.0 if not locked out."""
|
||||
now = self._clock()
|
||||
with self._lock:
|
||||
failures = self._prune(ip, now)
|
||||
if len(failures) < self._max_failures:
|
||||
return 0.0
|
||||
return max(0.0, self._lockout - (now - failures[-1]))
|
||||
|
||||
def record_failure(self, ip):
|
||||
"""Record a failure for *ip* and serve out the delay it has earned.
|
||||
|
||||
Returns the delay that was applied. The lock is never held across the
|
||||
sleep, so one slow client cannot stall every other login.
|
||||
"""
|
||||
now = self._clock()
|
||||
with self._lock:
|
||||
failures = list(self._prune(ip, now))
|
||||
failures.append(now)
|
||||
self._failures[ip] = failures
|
||||
count = len(failures)
|
||||
self._evict(now)
|
||||
delay = self.delay_for(count)
|
||||
if delay > 0:
|
||||
self._sleep(delay)
|
||||
return delay
|
||||
|
||||
def clear(self, ip):
|
||||
"""Forget *ip*, e.g. after a successful login."""
|
||||
with self._lock:
|
||||
self._failures.pop(ip, None)
|
||||
|
||||
def tracked_addresses(self):
|
||||
"""Return how many addresses are currently being tracked."""
|
||||
with self._lock:
|
||||
return len(self._failures)
|
||||
|
||||
|
||||
# ── Local-network client policy ───────────────────────────────────────────────
|
||||
#
|
||||
# The Hub runs as root: it can display stored credentials, reboot the machine
|
||||
# and rebuild the system. It answers this computer and the local network and
|
||||
# nobody else. Whether a packet may reach its port is the firewall's and the
|
||||
# router's business; this is the second lock, applied by the application itself
|
||||
# so that a port forward, a firewall mistake, or a machine that has a public
|
||||
# address does not put the login page in front of the internet.
|
||||
#
|
||||
# The Hub listens on IPv4 only (see sovran-hub.nix), so IPv6 clients never
|
||||
# reach it directly and the IPv6 ranges below only matter if that bind is ever
|
||||
# widened. Global IPv6 addresses (2000::/3) are deliberately not listed: a
|
||||
# global address belonging to a laptop on the LAN cannot be told apart from a
|
||||
# stranger's by the address alone, and allowing the range would let the whole
|
||||
# IPv6 internet through.
|
||||
LAN_ONLY_IPV4 = (
|
||||
"127.0.0.0/8", # this computer
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
"100.64.0.0/10", # Tailscale and other VPN/CGNAT ranges
|
||||
"169.254.0.0/16", # link-local
|
||||
)
|
||||
LAN_ONLY_IPV6 = (
|
||||
"::1/128",
|
||||
"fc00::/7", # unique-local (covers fd00::/8)
|
||||
"fe80::/10", # link-local
|
||||
)
|
||||
|
||||
|
||||
class LanPolicy:
|
||||
"""Decides whether a client address counts as local.
|
||||
|
||||
``extra_networks`` are CIDR blocks an operator has declared local in
|
||||
addition to the built-in ranges, of either address family. ``enabled=False``
|
||||
turns the check off entirely; it is the one explicit way to do that.
|
||||
"""
|
||||
|
||||
def __init__(self, extra_networks=(), enabled=True):
|
||||
self.enabled = bool(enabled)
|
||||
nets = [ipaddress.ip_network(c, strict=False)
|
||||
for c in LAN_ONLY_IPV4 + LAN_ONLY_IPV6]
|
||||
for cidr in (extra_networks or ()):
|
||||
try:
|
||||
net = ipaddress.ip_network(cidr, strict=False)
|
||||
except ValueError:
|
||||
# A malformed entry must never widen the policy. Ignore it and
|
||||
# stay at the strictest interpretation.
|
||||
continue
|
||||
if net.prefixlen == 0:
|
||||
# 0.0.0.0/0 and ::/0 are "everyone". That is lan_only = false,
|
||||
# and it should be asked for by name, not arrive as a "network".
|
||||
continue
|
||||
nets.append(net)
|
||||
self._nets = tuple(nets)
|
||||
|
||||
def allows(self, ip):
|
||||
"""Return True if *ip* may reach the service."""
|
||||
if not self.enabled:
|
||||
return True
|
||||
if not ip:
|
||||
return False
|
||||
try:
|
||||
addr = ipaddress.ip_address(ip)
|
||||
except ValueError:
|
||||
return False
|
||||
# A dual-stack socket reports IPv4 clients as ::ffff:a.b.c.d. The
|
||||
# address that matters is the IPv4 one inside it.
|
||||
if addr.version == 6 and addr.ipv4_mapped is not None:
|
||||
addr = addr.ipv4_mapped
|
||||
return any(addr.version == n.version and addr in n for n in self._nets)
|
||||
|
||||
@property
|
||||
def networks(self):
|
||||
return self._nets
|
||||
|
||||
|
||||
# ── Persistent Hub session store ─────────────────────────────────────────────
|
||||
|
||||
def load_session_store(path: str) -> dict[str, float]:
|
||||
"""Load persisted Hub sessions from *path*.
|
||||
|
||||
Returns a mapping of session token → expiry timestamp (epoch seconds).
|
||||
Expired entries are discarded. A missing, unreadable or malformed file
|
||||
yields an empty mapping — losing sessions is a UX inconvenience (the user
|
||||
must log in again), never a fatal error.
|
||||
|
||||
Persistence exists so that authenticated sessions survive a restart of
|
||||
the Hub service itself. ``nixos-rebuild switch`` restarts
|
||||
``sovran-hub-web.service`` during activation (its unit definition changes
|
||||
with every feature toggle), and without persistence the in-progress
|
||||
rebuild/update status polling loses authentication and the UI hangs.
|
||||
"""
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
data = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
now = time.time()
|
||||
sessions: dict[str, float] = {}
|
||||
for token, expiry in data.items():
|
||||
if not isinstance(token, str) or not token:
|
||||
continue
|
||||
if isinstance(expiry, bool) or not isinstance(expiry, (int, float)):
|
||||
continue
|
||||
if expiry > now:
|
||||
sessions[token] = float(expiry)
|
||||
return sessions
|
||||
|
||||
|
||||
def save_session_store(path: str, sessions: dict[str, float]) -> bool:
|
||||
"""Atomically persist *sessions* (token → expiry) to *path* with mode 0600.
|
||||
|
||||
Writes to a temp file in the same directory and renames it into place so
|
||||
the store is never left partially written. Returns True on success,
|
||||
False otherwise (persistence is best-effort).
|
||||
"""
|
||||
directory = os.path.dirname(path) or "."
|
||||
fd = None
|
||||
tmp_path = None
|
||||
try:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
fd, tmp_path = tempfile.mkstemp(dir=directory, prefix=".hub_sessions_tmp")
|
||||
with os.fdopen(fd, "w") as f:
|
||||
fd = None # os.fdopen takes ownership of the descriptor
|
||||
json.dump(sessions, f)
|
||||
os.chmod(tmp_path, 0o600)
|
||||
os.replace(tmp_path, path)
|
||||
return True
|
||||
except OSError:
|
||||
if fd is not None:
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
if tmp_path is not None:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
+870
-2247
File diff suppressed because it is too large.
Load diff
@@ -1,11 +1,6 @@
|
||||
/* Sovran_SystemsOS Hub — Web UI Stylesheet
|
||||
The Hub redesign — softer dark theme, GNOME 50 / libadwaita surfaces,
|
||||
Sovran green reserved for status and actions.
|
||||
|
||||
Design tokens are defined once here. Legacy variable names from the
|
||||
previous theme are aliased to the new values so every stylesheet
|
||||
(support, security, domain-setup, onboarding, …) re-skins without
|
||||
needing per-rule edits. */
|
||||
Dark theme — near-black with green accents matching the Sovran Hub icon
|
||||
v8 — Black-forward, green used for accents/borders/highlights only */
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
@@ -14,48 +9,22 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
|
||||
/* ── The Hub palette ─────────────────────────────────────────── */
|
||||
--bg: #17191d;
|
||||
--surface: #1c1f24;
|
||||
--card: #23272c;
|
||||
--card-hover: #292e34;
|
||||
--elevated: #26292e;
|
||||
--inset: #121417;
|
||||
--border: rgba(255, 255, 255, 0.07);
|
||||
--border-strong: rgba(255, 255, 255, 0.14);
|
||||
--text: #e9edec;
|
||||
--text-2: #a9b0b3;
|
||||
--text-3: #7a8388;
|
||||
--accent: #3ecf8e;
|
||||
--accent-strong: #42f39a;
|
||||
--accent-deep: #1aa45d;
|
||||
--accent-dim: rgba(66, 243, 154, 0.12);
|
||||
--amber: #e9b64a;
|
||||
--red: #f66151;
|
||||
--blue: #78aeed;
|
||||
--mono: 'JetBrains Mono', 'Fira Code', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
--radius-card: 20px;
|
||||
--radius-dialog: 26px;
|
||||
--shadow-card: 0 10px 30px rgba(0, 0, 0, 0.25);
|
||||
--shadow-hover: 0 16px 36px rgba(0, 0, 0, 0.38);
|
||||
--shadow-pop: 0 30px 80px rgba(0, 0, 0, 0.5);
|
||||
|
||||
/* ── Legacy aliases (previous theme) — keep every old sheet working */
|
||||
--bg-color: var(--bg);
|
||||
--surface-color: var(--surface);
|
||||
--card-color: var(--card);
|
||||
--border-color: var(--border);
|
||||
--text-primary: var(--text);
|
||||
--text-secondary: var(--text-2);
|
||||
--text-dim: var(--text-3);
|
||||
--accent-color: var(--accent);
|
||||
--green: var(--accent);
|
||||
--yellow: var(--amber);
|
||||
--red: var(--red);
|
||||
--grey: var(--text-3);
|
||||
--radius-btn: 12px;
|
||||
--bg-color: #080a09;
|
||||
--surface-color: rgba(14, 16, 15, 0.7);
|
||||
--card-color: rgba(20, 22, 21, 0.6);
|
||||
--border-color: rgba(255, 255, 255, 0.06);
|
||||
--text-primary: #ecf3ef;
|
||||
--text-secondary: #8aaa9a;
|
||||
--text-dim: #4a6658;
|
||||
--accent-color: #5EAD8A;
|
||||
--green: #6DBF8B;
|
||||
--yellow: #e5a50a;
|
||||
--red: #e01b24;
|
||||
--grey: #5E7A6A;
|
||||
--radius-card: 18px;
|
||||
--radius-btn: 8px;
|
||||
--shadow-card: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
--shadow-hover: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
html, body {
|
||||
@@ -63,27 +32,19 @@ html, body {
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Inter, Cantarell, 'Segoe UI', system-ui, -apple-system, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background:
|
||||
radial-gradient(ellipse at top, rgba(94, 173, 138, 0.04) 0%, transparent 50%),
|
||||
var(--bg-color);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
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 page ──────────────────────────────────────────────────── */
|
||||
|
||||
.login-wrapper {
|
||||
display: flex;
|
||||
@@ -91,103 +52,86 @@ button { font-family: inherit; }
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background-color: rgba(14, 16, 15, 0.75);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 20px;
|
||||
padding: 48px 40px;
|
||||
width: 100%;
|
||||
max-width: 402px;
|
||||
text-align: center;
|
||||
background: var(--elevated);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 28px;
|
||||
padding: 46px 42px 38px;
|
||||
box-shadow: var(--shadow-pop);
|
||||
max-width: 400px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 26px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
width: 78px;
|
||||
height: 78px;
|
||||
margin-bottom: 18px;
|
||||
filter: drop-shadow(0 8px 24px rgba(28, 196, 120, 0.3));
|
||||
height: 64px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.login-title em { font-style: normal; color: var(--accent); }
|
||||
|
||||
.login-sub {
|
||||
color: var(--text-2);
|
||||
font-size: 0.85rem;
|
||||
margin: 7px 0 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 650;
|
||||
color: var(--text-2);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 11px 16px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 14px;
|
||||
background: var(--inset);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--card-color);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.92rem;
|
||||
outline: 0;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
border-color: rgba(66, 243, 154, 0.55);
|
||||
box-shadow: 0 0 0 3px rgba(66, 243, 154, 0.13);
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border-radius: 99px;
|
||||
background: linear-gradient(180deg, #3fd68e, #1ea263);
|
||||
color: #04220f;
|
||||
font-size: 0.92rem;
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--accent-color);
|
||||
color: #0A1A10;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
margin-top: 8px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.28), 0 6px 18px rgba(35, 199, 124, 0.22);
|
||||
}
|
||||
|
||||
.btn-login:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
.btn-login:hover {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
background: rgba(246, 97, 81, 0.10);
|
||||
border: 1px solid rgba(246, 97, 81, 0.35);
|
||||
color: #f8a39b;
|
||||
background-color: rgba(224, 27, 36, 0.12);
|
||||
border: 1px solid var(--red);
|
||||
color: #f87171;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -5,134 +5,34 @@ button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
outline: none;
|
||||
transition: background 0.16s, border-color 0.16s, color 0.16s, transform 0.16s, filter 0.16s, opacity 0.15s;
|
||||
transition: opacity 0.15s, box-shadow 0.15s, background-color 0.15s;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.55;
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border-radius: 99px;
|
||||
padding: 8px 18px;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
padding: 7px 16px;
|
||||
border-radius: var(--radius-btn);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn svg { width: 15px; height: 15px; }
|
||||
.btn:active { transform: scale(0.97); }
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(180deg, #3fd68e, #1ea263);
|
||||
color: #04220f;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.28), 0 6px 18px rgba(35, 199, 124, 0.22);
|
||||
background-color: var(--accent-color);
|
||||
color: #0A1A10;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
transform: translateY(-1px);
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.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) ─────────────────────────────── */
|
||||
|
||||
/* Update System button: uses accent green by default */
|
||||
.btn-update {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--text-2);
|
||||
background-color: #4A9474;
|
||||
color: #E0F2EA;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -140,22 +40,24 @@ button:disabled {
|
||||
}
|
||||
|
||||
.btn-update:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.26);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
/* Update System button: brighter green when updates are available */
|
||||
.btn-update.has-updates {
|
||||
background: rgba(66, 243, 154, 0.12);
|
||||
border-color: rgba(66, 243, 154, 0.4);
|
||||
color: var(--accent);
|
||||
background-color: #5EAD8A;
|
||||
color: #0A1A10;
|
||||
}
|
||||
|
||||
.btn-update.has-updates:hover:not(:disabled) {
|
||||
background-color: #78C8A2;
|
||||
}
|
||||
|
||||
.update-badge {
|
||||
display: none;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background-color: var(--amber);
|
||||
background-color: var(--yellow);
|
||||
border-radius: 50%;
|
||||
animation: pulse-badge 1.4s ease-in-out infinite;
|
||||
}
|
||||
@@ -169,11 +71,9 @@ button:disabled {
|
||||
50% { opacity: 0.5; transform: scale(1.35); }
|
||||
}
|
||||
|
||||
/* ── Icon buttons ───────────────────────────────────────────────── */
|
||||
|
||||
.btn-icon {
|
||||
background: none;
|
||||
color: var(--text-2);
|
||||
color: var(--text-secondary);
|
||||
padding: 6px;
|
||||
border-radius: 50%;
|
||||
font-size: 1.1rem;
|
||||
@@ -181,6 +81,6 @@ button:disabled {
|
||||
}
|
||||
|
||||
.btn-icon:hover:not(:disabled) {
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text);
|
||||
background-color: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ domain-field-label {
|
||||
|
||||
domain-field-input {
|
||||
width: 100%;
|
||||
background-color: var(--inset);
|
||||
background-color: #0c0f0e;
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
@@ -75,7 +75,7 @@ domain-field-actions {
|
||||
|
||||
.domain-field-input {
|
||||
width: 100%;
|
||||
background-color: var(--inset);
|
||||
background-color: #0c0f0e;
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
@@ -171,67 +171,3 @@ domain-field-actions {
|
||||
.port-req-status {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Port-forwarding instructions ─────────────────────────────────── */
|
||||
|
||||
.port-req-steps {
|
||||
margin: 4px 0 12px;
|
||||
padding-left: 20px;
|
||||
font-size: 0.84rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.port-req-steps li {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.port-proto-badge {
|
||||
display: inline-block;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 2px 7px;
|
||||
border-radius: 5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.port-proto-badge--tcp {
|
||||
color: var(--accent);
|
||||
background: rgba(66, 243, 154, 0.12);
|
||||
border: 1px solid rgba(66, 243, 154, 0.4);
|
||||
}
|
||||
|
||||
.port-proto-badge--udp {
|
||||
color: var(--blue);
|
||||
background: rgba(106, 169, 224, 0.12);
|
||||
border: 1px solid rgba(106, 169, 224, 0.4);
|
||||
}
|
||||
|
||||
.port-proto-badge--both {
|
||||
color: var(--amber);
|
||||
background: rgba(233, 182, 74, 0.12);
|
||||
border: 1px solid rgba(233, 182, 74, 0.45);
|
||||
}
|
||||
|
||||
.port-proto-note {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-dim);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
/* ── Wallet Connections unique-hostname warning ───────────────────── */
|
||||
|
||||
.domain-nwc-warning {
|
||||
margin-bottom: 14px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(255, 180, 0, 0.10);
|
||||
border: 1px solid var(--warning-color, var(--amber));
|
||||
border-radius: 8px;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -1,196 +1,143 @@
|
||||
/* ── Sidebar: Feature Manager + Preferences ─────────────────────── */
|
||||
/* ── Feature Manager styles ──────────────────────────────────────── */
|
||||
|
||||
.category-section.autolaunch-section,
|
||||
.category-section.feature-manager-section {
|
||||
margin-bottom: 0;
|
||||
.feature-manager-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
/* The old "Preferences" header + hr becomes a small sidebar label.
|
||||
features.js renders: .section-header + hr.section-divider + cards */
|
||||
.autolaunch-section .section-header,
|
||||
.feature-manager-section .section-header {
|
||||
font-size: 0.66rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
margin: 0;
|
||||
padding: 14px 10px 7px;
|
||||
display: block;
|
||||
.feature-subcategory {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.autolaunch-section .section-divider,
|
||||
.feature-manager-section .section-divider {
|
||||
background: none;
|
||||
height: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.feature-cards-wrap { display: flex; flex-direction: column; }
|
||||
|
||||
.feature-subcategory { display: flex; flex-direction: column; }
|
||||
|
||||
.feature-subcategory-header {
|
||||
font-size: 0.66rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
padding: 12px 10px 6px;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 8px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
/* Feature card — sidebar preference row with a switch */
|
||||
.feature-card {
|
||||
.feature-cards-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
padding: 9px 10px;
|
||||
border-radius: 10px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.feature-card:hover { background: rgba(255, 255, 255, 0.05); }
|
||||
.feature-card {
|
||||
background-color: var(--card-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.feature-card-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.feature-card-info { min-width: 0; flex: 1; }
|
||||
.feature-card-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.feature-card-name {
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
line-height: 1.3;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.feature-card-desc {
|
||||
margin-top: 2px;
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-3);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.feature-card-status {
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.feature-card-status.status-on {
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
border-color: rgba(66, 243, 154, 0.3);
|
||||
}
|
||||
|
||||
.feature-conflict-warning {
|
||||
margin-top: 8px;
|
||||
font-size: 0.68rem;
|
||||
color: var(--amber);
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
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;
|
||||
.feature-card-status {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.feature-domain-badge.configured {
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.feature-domain-badge.not-configured {
|
||||
color: var(--amber);
|
||||
background: rgba(233, 182, 74, 0.10);
|
||||
}
|
||||
|
||||
.feature-domain-icon { font-size: 0.8rem; }
|
||||
|
||||
.feature-domain-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 650;
|
||||
margin-top: 5px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.feature-domain-label--ok { color: var(--accent); }
|
||||
.feature-domain-label--warn { color: var(--amber); }
|
||||
.feature-domain-label--error { color: var(--red); }
|
||||
.feature-domain-label--checking { color: var(--blue); }
|
||||
|
||||
/* ── Toggle switch (libadwaita style) ───────────────────────────── */
|
||||
|
||||
.feature-toggle {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 46px;
|
||||
height: 26px;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
margin-top: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.feature-toggle-input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
width: 0;
|
||||
height: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.feature-toggle-slider {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 99px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid var(--border-strong);
|
||||
transition: 0.2s;
|
||||
pointer-events: none;
|
||||
background-color: var(--border-color);
|
||||
border-radius: 24px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.feature-toggle-slider::after {
|
||||
.feature-toggle-slider::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
left: 3px;
|
||||
top: 3px;
|
||||
background-color: #fff;
|
||||
border-radius: 50%;
|
||||
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);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.feature-toggle-input:checked + .feature-toggle-slider {
|
||||
background: var(--accent);
|
||||
border-color: transparent;
|
||||
.feature-toggle.active .feature-toggle-slider {
|
||||
background-color: var(--green);
|
||||
}
|
||||
|
||||
.feature-toggle-input:checked + .feature-toggle-slider::after {
|
||||
left: 23px;
|
||||
background: #fff;
|
||||
.feature-toggle.active .feature-toggle-slider::before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.feature-toggle-input:focus-visible + .feature-toggle-slider {
|
||||
box-shadow: 0 0 0 3px rgba(66, 243, 154, 0.3);
|
||||
.feature-domain-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.feature-domain-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.feature-domain-label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.feature-domain-label--checking {
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.feature-domain-label--ok {
|
||||
color: var(--green);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.feature-domain-label--warn {
|
||||
color: var(--yellow);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.feature-domain-label--error {
|
||||
color: var(--red);
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -1,107 +1,93 @@
|
||||
/* ── Topbar (replaces the old header-bar + ip-bar) ──────────────── */
|
||||
/* ── Header bar ─────────────────────────────────────────────────── */
|
||||
|
||||
.topbar {
|
||||
.header-bar {
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
background-color: rgba(10, 12, 11, 0.82);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding: 8px 24px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 13px 26px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
height: 80px;
|
||||
width: auto;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.02rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
.header-bar .title {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Search ─────────────────────────────────────────────────────── */
|
||||
|
||||
.search-box {
|
||||
.header-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: inline-flex;
|
||||
background-color: var(--accent-color);
|
||||
color: #0A1A10;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* ── 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: 7px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--text-2);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 750;
|
||||
padding: 6px 12px;
|
||||
border-radius: 99px;
|
||||
letter-spacing: 0.02em;
|
||||
gap: 32px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.os-version-badge {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-2);
|
||||
font-size: 0.66rem;
|
||||
.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;
|
||||
padding: 2px 9px;
|
||||
border-radius: 99px;
|
||||
border: 1px solid var(--border);
|
||||
letter-spacing: 0.02em;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.btn-header-reboot {
|
||||
padding: 5px 10px;
|
||||
font-size: 0.72rem;
|
||||
.ip-separator {
|
||||
color: var(--border-color);
|
||||
}
|
||||
.btn-logout {
|
||||
padding: 5px 10px;
|
||||
font-size: 0.72rem;
|
||||
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);
|
||||
}
|
||||
@@ -1,533 +1,138 @@
|
||||
/* ── App shell: sidebar + main column ───────────────────────────── */
|
||||
/* ── Main content ───────────────────────────────────────────────── */
|
||||
|
||||
.app {
|
||||
.main-content {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.main {
|
||||
align-items: flex-start;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 26px 30px 70px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
max-width: 1400px;
|
||||
width: 100%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* ── Sidebar ────────────────────────────────────────────────────── */
|
||||
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
width: 270px;
|
||||
flex-shrink: 0;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background-color: rgba(12, 14, 13, 0.65);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
padding: 20px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px 14px 14px;
|
||||
overflow-y: auto;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 2px 8px 20px;
|
||||
}
|
||||
/* ── Sidebar: Tech Support button ───────────────────────────────── */
|
||||
|
||||
.brand-logo {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
flex-shrink: 0;
|
||||
filter: drop-shadow(0 4px 14px rgba(28, 196, 120, 0.25));
|
||||
}
|
||||
|
||||
.brand-text { display: flex; flex-direction: column; min-width: 0; }
|
||||
|
||||
.brand-title {
|
||||
font-size: 1.02rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.brand-title em { font-style: normal; color: var(--accent); }
|
||||
|
||||
.brand-sub {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-3);
|
||||
font-family: var(--mono);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 0.66rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
padding: 14px 10px 7px;
|
||||
}
|
||||
|
||||
/* Category nav (dashboard.js) */
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text-2);
|
||||
font-size: 0.87rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.nav-item svg { width: 17px; height: 17px; flex-shrink: 0; }
|
||||
|
||||
.nav-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text);
|
||||
box-shadow: inset 2.5px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
.nav-text {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.nav-count {
|
||||
margin-left: auto;
|
||||
font-size: 0.67rem;
|
||||
font-weight: 750;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-3);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
}
|
||||
|
||||
.nav-item.active .nav-count {
|
||||
background: rgba(255, 255, 255, 0.10);
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
/* System items (tiles.js renders these as .sidebar-support-btn) */
|
||||
.sidebar-support-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text-2);
|
||||
font-size: 0.87rem;
|
||||
font-weight: 600;
|
||||
background-color: var(--card-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: border-style 0.15s, border-color 0.15s, background-color 0.15s;
|
||||
text-align: left;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.sidebar-support-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text);
|
||||
border-color: var(--accent-color);
|
||||
border-style: solid;
|
||||
background-color: #162320;
|
||||
}
|
||||
|
||||
.sidebar-support-btn + .sidebar-support-btn {
|
||||
margin-top: 2px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.sidebar-support-icon {
|
||||
font-size: 1rem;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-support-icon svg { width: 17px; height: 17px; }
|
||||
|
||||
.sidebar-support-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sidebar-support-title {
|
||||
font-size: 0.87rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sidebar-support-hint {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 650;
|
||||
color: var(--text-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 0.72rem;
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sidebar-divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 14px 0 0;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.sidebar-spacer { flex: 1; min-height: 18px; }
|
||||
/* ── Upgrade modal ──────────────────────────────────────────────── */
|
||||
|
||||
/* ── Welcome dashboard (default view) ───────────────────────────── */
|
||||
|
||||
.welcome {
|
||||
position: relative;
|
||||
/* Full-bleed: cancel .content's padding so the glow reaches
|
||||
every edge of the panel (sidebar border, topbar, viewport). */
|
||||
margin: -26px -30px -70px;
|
||||
padding: 70px 38px 70px;
|
||||
overflow: hidden;
|
||||
min-height: calc(100% + 96px);
|
||||
.upgrade-dialog {
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
/* Soft drifting glow — calm, GPU-cheap (transforms only) */
|
||||
.welcome-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
.upgrade-info-box {
|
||||
background-color: var(--card-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.orb-a {
|
||||
width: 560px; height: 560px;
|
||||
left: -140px; top: -180px;
|
||||
background: radial-gradient(circle, rgba(62, 207, 142, 0.10), transparent 70%);
|
||||
animation: orb-drift-a 26s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.orb-b {
|
||||
width: 680px; height: 680px;
|
||||
right: -200px; top: 20%;
|
||||
background: radial-gradient(circle, rgba(120, 174, 237, 0.07), transparent 70%);
|
||||
animation: orb-drift-b 34s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.orb-c {
|
||||
width: 460px; height: 460px;
|
||||
left: 34%; bottom: -200px;
|
||||
background: radial-gradient(circle, rgba(66, 243, 154, 0.06), transparent 70%);
|
||||
animation: orb-drift-c 42s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes orb-drift-a { to { transform: translate(70px, 50px) scale(1.14); } }
|
||||
@keyframes orb-drift-b { to { transform: translate(-60px, -70px) scale(1.10); } }
|
||||
@keyframes orb-drift-c { to { transform: translate(50px, -40px) scale(1.18); } }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.orb { animation: none; }
|
||||
.welcome-inner { animation: none; }
|
||||
}
|
||||
|
||||
/* ── Boot splash ────────────────────────────────────────────────── */
|
||||
/* Covers the shell while the first services/config data loads; the
|
||||
inline script in index.html lifts it (window.__liftAppSplash). */
|
||||
#app-splash {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity 0.45s ease;
|
||||
}
|
||||
|
||||
#app-splash.done { opacity: 0; pointer-events: none; }
|
||||
|
||||
.splash-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.splash-ring {
|
||||
position: relative;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
.upgrade-info-title {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.splash-ring img { width: 60px; height: 60px; opacity: 0.95; }
|
||||
|
||||
.splash-ring-arc {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 3px solid rgba(255, 255, 255, 0.10);
|
||||
border-top-color: var(--accent);
|
||||
animation: splash-spin 1s linear infinite;
|
||||
.upgrade-info-list {
|
||||
padding-left: 20px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@keyframes splash-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.splash-title {
|
||||
font-size: 1.02rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
.upgrade-info-list a {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.splash-sub {
|
||||
font-size: 0.84rem;
|
||||
color: var(--text-3);
|
||||
.upgrade-rebuild-note {
|
||||
font-style: italic;
|
||||
color: var(--text-dim);
|
||||
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 {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 24px 20px 48px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 72px 24px;
|
||||
color: var(--text-3);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dashboard-loading-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2.5px solid rgba(255, 255, 255, 0.12);
|
||||
border-top-color: var(--accent);
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── Category sections ──────────────────────────────────────────── */
|
||||
|
||||
.category-section {
|
||||
@@ -535,30 +140,25 @@
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-2);
|
||||
white-space: nowrap;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
border: none;
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, var(--border), transparent);
|
||||
margin: 0;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tiles-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(158px, 1fr));
|
||||
gap: 15px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* ── Empty state ────────────────────────────────────────────────── */
|
||||
@@ -566,103 +166,10 @@
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 64px 24px;
|
||||
color: var(--text-3);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ── Upgrade modal (kept from previous layout) ──────────────────── */
|
||||
|
||||
.upgrade-dialog {
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.upgrade-info-box {
|
||||
background: rgba(0, 0, 0, 0.16);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 16px;
|
||||
padding: 14px 16px;
|
||||
margin: 14px 0;
|
||||
}
|
||||
|
||||
.upgrade-info-title {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.upgrade-info-list {
|
||||
padding-left: 20px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-2);
|
||||
line-height: 1.7;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.upgrade-info-list a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.upgrade-rebuild-note {
|
||||
font-style: italic;
|
||||
color: var(--text-3);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
/* ── First-login security banner (inside .content) ──────────────── */
|
||||
|
||||
.security-first-login-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: rgba(233, 182, 74, 0.08);
|
||||
border: 1px solid rgba(233, 182, 74, 0.30);
|
||||
border-radius: 16px;
|
||||
padding: 12px 16px;
|
||||
color: var(--text-2);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* ── Narrow viewports (phone / half-screen RDP) ──────────────────── */
|
||||
/* The sidebar becomes an icon rail so the content keeps room, and the
|
||||
topbar wraps its search onto a second row instead of overflowing. */
|
||||
@media (max-width: 920px) {
|
||||
.sidebar {
|
||||
width: 76px;
|
||||
padding: 16px 8px 12px;
|
||||
}
|
||||
|
||||
.brand { justify-content: center; padding: 2px 0 18px; gap: 0; }
|
||||
.brand-text,
|
||||
.nav-label,
|
||||
.nav-text,
|
||||
.nav-count,
|
||||
.sidebar-support-text,
|
||||
.sidebar-divider,
|
||||
#sidebar-features { display: none; }
|
||||
|
||||
.nav-item { justify-content: center; gap: 0; padding: 10px 0; }
|
||||
.sidebar-support-btn { justify-content: center; gap: 0; padding: 10px 0; }
|
||||
|
||||
.content { padding: 18px 18px 40px; }
|
||||
|
||||
/* keep the welcome background full-bleed against the new padding */
|
||||
.welcome {
|
||||
margin: -18px -18px -40px;
|
||||
padding: 48px 20px 40px;
|
||||
min-height: calc(100% + 58px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.topbar { flex-wrap: wrap; row-gap: 10px; padding: 12px 16px; }
|
||||
.search-box { order: 5; flex-basis: 100%; }
|
||||
|
||||
.welcome { padding: 40px 16px 36px; }
|
||||
.welcome-cards { grid-template-columns: 1fr; }
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -7,7 +7,7 @@
|
||||
justify-content: flex-start;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(ellipse at top, rgba(62, 207, 142, 0.04) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at top, rgba(94, 173, 138, 0.04) 0%, transparent 50%),
|
||||
var(--bg-color);
|
||||
padding: 24px 16px 48px;
|
||||
overflow-y: auto;
|
||||
@@ -70,13 +70,13 @@
|
||||
.onboarding-step-dot.active {
|
||||
background-color: var(--accent-color);
|
||||
border-color: var(--accent-color);
|
||||
color: #04220f;
|
||||
color: #0A1A10;
|
||||
}
|
||||
|
||||
.onboarding-step-dot.completed {
|
||||
background-color: var(--green);
|
||||
border-color: var(--green);
|
||||
color: #04220f;
|
||||
color: #0A1A10;
|
||||
}
|
||||
|
||||
.onboarding-step-connector {
|
||||
@@ -139,7 +139,7 @@
|
||||
/* Cards */
|
||||
|
||||
.onboarding-card {
|
||||
background-color: rgba(18, 24, 20, 0.65);
|
||||
background-color: rgba(14, 16, 15, 0.65);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
@@ -184,10 +184,10 @@
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-color);
|
||||
background-color: rgba(62, 207, 142, 0.10);
|
||||
background-color: rgba(94, 173, 138, 0.10);
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(62, 207, 142, 0.25);
|
||||
border: 1px solid rgba(94, 173, 138, 0.25);
|
||||
}
|
||||
|
||||
/* Step header */
|
||||
@@ -371,8 +371,8 @@
|
||||
|
||||
.onboarding-port-warn {
|
||||
padding: 10px 14px;
|
||||
background-color: rgba(233, 182, 74, 0.1);
|
||||
border: 1px solid rgba(233, 182, 74, 0.35);
|
||||
background-color: rgba(229, 165, 10, 0.1);
|
||||
border: 1px solid rgba(229, 165, 10, 0.35);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--yellow);
|
||||
@@ -409,8 +409,8 @@
|
||||
|
||||
.onboarding-creds-notice {
|
||||
padding: 12px 16px;
|
||||
background-color: rgba(62, 207, 142, 0.08);
|
||||
border: 1px solid rgba(62, 207, 142, 0.20);
|
||||
background-color: rgba(94, 173, 138, 0.08);
|
||||
border: 1px solid rgba(94, 173, 138, 0.20);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
@@ -503,8 +503,8 @@
|
||||
font-size: 0.72rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(62, 207, 142, 0.08);
|
||||
border: 1px solid rgba(62, 207, 142, 0.20);
|
||||
background-color: rgba(94, 173, 138, 0.08);
|
||||
border: 1px solid rgba(94, 173, 138, 0.20);
|
||||
color: var(--accent-color);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
@@ -512,7 +512,7 @@
|
||||
}
|
||||
|
||||
.onboarding-cred-reveal-btn:hover {
|
||||
background-color: rgba(62, 207, 142, 0.15);
|
||||
background-color: rgba(94, 173, 138, 0.15);
|
||||
}
|
||||
|
||||
/* Completion checklist (Step 5) */
|
||||
@@ -671,7 +671,7 @@
|
||||
}
|
||||
|
||||
.onboarding-password-toggle:hover {
|
||||
background-color: rgba(62, 207, 142, 0.10);
|
||||
background-color: rgba(94, 173, 138, 0.10);
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
@@ -683,8 +683,8 @@
|
||||
|
||||
.onboarding-password-warning {
|
||||
padding: 10px 14px;
|
||||
background-color: rgba(233, 182, 74, 0.1);
|
||||
border: 1px solid rgba(233, 182, 74, 0.35);
|
||||
background-color: rgba(229, 165, 10, 0.1);
|
||||
border: 1px solid rgba(229, 165, 10, 0.35);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--yellow);
|
||||
@@ -694,8 +694,8 @@
|
||||
|
||||
.onboarding-password-success {
|
||||
padding: 12px 16px;
|
||||
background-color: rgba(62, 207, 142, 0.10);
|
||||
border: 1px solid rgba(62, 207, 142, 0.30);
|
||||
background-color: rgba(94, 173, 138, 0.10);
|
||||
border: 1px solid rgba(94, 173, 138, 0.30);
|
||||
border-radius: 8px;
|
||||
font-size: 0.92rem;
|
||||
color: var(--green);
|
||||
@@ -748,7 +748,7 @@
|
||||
}
|
||||
|
||||
.reboot-card {
|
||||
background-color: rgba(18, 24, 20, 0.8);
|
||||
background-color: rgba(14, 16, 15, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
@@ -823,8 +823,57 @@
|
||||
|
||||
/* ── Responsive ─────────────────────────────────────────────────── */
|
||||
|
||||
/* Legacy pre-redesign narrow-screen rules removed: they targeted the old
|
||||
DOM (.main-content, .header-bar, .ip-bar) and their .sidebar /
|
||||
#tiles-area / .service-tile / .creds-qr-img overrides fought the new
|
||||
responsive layout in layout.css (sidebar icon rail, wrapping topbar).
|
||||
Narrow-viewport behavior now lives in layout.css + header.css. */
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
overflow: auto;
|
||||
}
|
||||
.main-content {
|
||||
flex-direction: column;
|
||||
overflow: visible;
|
||||
}
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 14px 12px;
|
||||
}
|
||||
#tiles-area {
|
||||
height: auto;
|
||||
overflow-y: visible;
|
||||
padding: 16px 12px 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.header-bar {
|
||||
padding: 10px 14px;
|
||||
gap: 10px;
|
||||
}
|
||||
.header-bar .title {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.ip-bar {
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
.tiles-grid {
|
||||
justify-content: center;
|
||||
}
|
||||
.service-tile {
|
||||
width: 140px;
|
||||
min-height: 130px;
|
||||
}
|
||||
.reboot-card {
|
||||
padding: 36px 28px;
|
||||
margin: 0 16px;
|
||||
}
|
||||
.creds-dialog {
|
||||
margin: 0 12px;
|
||||
}
|
||||
.creds-qr-img {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
.security-warning-box {
|
||||
background-color: rgba(180, 40, 40, 0.10);
|
||||
border-left: 3px solid var(--red);
|
||||
border-left: 3px solid #c94040;
|
||||
border-radius: 6px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 14px;
|
||||
@@ -78,7 +78,7 @@
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--red);
|
||||
background-color: #c94040;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
@@ -90,7 +90,7 @@
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background-color: var(--red);
|
||||
background-color: #a83030;
|
||||
}
|
||||
|
||||
.btn-danger:disabled {
|
||||
@@ -105,8 +105,8 @@
|
||||
}
|
||||
|
||||
.security-status-info { color: var(--text-secondary); }
|
||||
.security-status-ok { color: var(--accent); }
|
||||
.security-status-error { color: var(--red); }
|
||||
.security-status-ok { color: #6DBF8B; }
|
||||
.security-status-error { color: #e05252; }
|
||||
|
||||
/* ── Verify System Integrity ─────────────────────────────────────── */
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
|
||||
.security-verify-link {
|
||||
font-size: 0.78rem;
|
||||
color: var(--accent-color, var(--accent));
|
||||
color: var(--accent-color, #6DBF8B);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -174,13 +174,13 @@
|
||||
}
|
||||
|
||||
.security-verify-pass {
|
||||
background-color: rgba(66, 243, 154, 0.15);
|
||||
color: var(--accent);
|
||||
background-color: rgba(109, 191, 139, 0.15);
|
||||
color: #6DBF8B;
|
||||
}
|
||||
|
||||
.security-verify-fail {
|
||||
background-color: rgba(224, 82, 82, 0.15);
|
||||
color: var(--red);
|
||||
color: #e05252;
|
||||
}
|
||||
|
||||
.security-verify-errors {
|
||||
@@ -221,11 +221,12 @@
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: radial-gradient(1000px 640px at 50% 18%, #202429, #0f1113 72%);
|
||||
background-color: rgba(6, 8, 7, 0.94);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 26px;
|
||||
animation: security-reset-fade-in 0.35s ease-out;
|
||||
}
|
||||
|
||||
@@ -252,13 +253,13 @@
|
||||
}
|
||||
|
||||
.security-reset-password-box {
|
||||
font-family: var(--mono);
|
||||
font-family: monospace;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
background: var(--accent-dim);
|
||||
border: 1.5px solid rgba(66, 243, 154, 0.35);
|
||||
border-radius: 14px;
|
||||
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;
|
||||
@@ -276,11 +277,10 @@
|
||||
}
|
||||
|
||||
.security-reset-reboot-btn {
|
||||
background: linear-gradient(180deg, #3fd68e, #1ea263);
|
||||
color: #04220f;
|
||||
background-color: #6DBF8B;
|
||||
color: #0a0c0b;
|
||||
border: none;
|
||||
border-radius: 99px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.28), 0 6px 18px rgba(35, 199, 124, 0.22);
|
||||
border-radius: 7px;
|
||||
padding: 11px 22px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
@@ -290,7 +290,7 @@
|
||||
}
|
||||
|
||||
.security-reset-reboot-btn:hover:not(:disabled) {
|
||||
background-color: var(--accent);
|
||||
background-color: #5aab78;
|
||||
}
|
||||
|
||||
.security-reset-reboot-btn:disabled {
|
||||
@@ -303,12 +303,11 @@
|
||||
.security-first-login-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 24px;
|
||||
background: rgba(233, 182, 74, 0.08);
|
||||
border: 1px solid rgba(233, 182, 74, 0.30);
|
||||
border-radius: 16px;
|
||||
padding: 12px 18px;
|
||||
background-color: rgba(94, 173, 138, 0.08);
|
||||
border-bottom: 2px solid rgba(94, 173, 138, 0.25);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -334,21 +333,19 @@
|
||||
|
||||
.security-banner-dismiss {
|
||||
background: none;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 99px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 10px;
|
||||
padding: 2px 7px;
|
||||
flex-shrink: 0;
|
||||
line-height: 1.4;
|
||||
transition: 0.15s;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.security-banner-dismiss:hover {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.26);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
background-color: rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
/* ── Legacy security inline warning banner ───────────────────────── */
|
||||
@@ -360,14 +357,14 @@
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 12px;
|
||||
background-color: rgba(180, 100, 0, 0.12);
|
||||
border-left: 3px solid #c98d08;
|
||||
border-left: 3px solid #c97a00;
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.security-inline-icon {
|
||||
font-size: 1rem;
|
||||
color: #e0a010;
|
||||
color: #e69000;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -381,9 +378,9 @@
|
||||
display: inline-block;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: #e0a010;
|
||||
color: #e69000;
|
||||
text-decoration: none;
|
||||
border: 1px solid #c98d08;
|
||||
border: 1px solid #c97a00;
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
align-self: flex-start;
|
||||
@@ -450,9 +447,9 @@
|
||||
|
||||
.pw-credentials-note {
|
||||
font-size: 0.78rem;
|
||||
color: #c98d08;
|
||||
color: #c97a00;
|
||||
background-color: rgba(180, 100, 0, 0.10);
|
||||
border-left: 2px solid #c98d08;
|
||||
border-left: 2px solid #c97a00;
|
||||
border-radius: 4px;
|
||||
padding: 7px 10px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
}
|
||||
|
||||
.support-steps code {
|
||||
background-color: rgba(62, 207, 142, 0.10);
|
||||
background-color: rgba(94, 173, 138, 0.10);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.82rem;
|
||||
@@ -116,7 +116,7 @@
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--accent-color);
|
||||
color: #04220f;
|
||||
color: #0A1A10;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
@@ -146,7 +146,7 @@
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--accent-color);
|
||||
color: #04220f;
|
||||
color: #0A1A10;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
margin-top: 16px;
|
||||
@@ -168,7 +168,7 @@
|
||||
}
|
||||
|
||||
.support-btn-auditlog:hover:not(:disabled) {
|
||||
background-color: var(--card-hover);
|
||||
background-color: #1c2a24;
|
||||
}
|
||||
|
||||
.support-fine-print {
|
||||
@@ -219,13 +219,13 @@
|
||||
}
|
||||
|
||||
.support-wallet-protected {
|
||||
background-color: rgba(66, 243, 154, 0.06);
|
||||
border-color: rgba(66, 243, 154, 0.3);
|
||||
background-color: rgba(109, 191, 139, 0.06);
|
||||
border-color: rgba(109, 191, 139, 0.3);
|
||||
}
|
||||
|
||||
.support-wallet-unlocked {
|
||||
background-color: rgba(233, 182, 74, 0.06);
|
||||
border-color: rgba(233, 182, 74, 0.3);
|
||||
background-color: rgba(229, 165, 10, 0.06);
|
||||
border-color: rgba(229, 165, 10, 0.3);
|
||||
}
|
||||
|
||||
.support-wallet-warning {
|
||||
@@ -290,7 +290,7 @@
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--yellow);
|
||||
color: #04220f;
|
||||
color: #0A1A10;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -310,7 +310,7 @@
|
||||
}
|
||||
|
||||
.support-btn-wallet-lock:hover:not(:disabled) {
|
||||
background-color: var(--accent);
|
||||
background-color: #529E7E;
|
||||
}
|
||||
|
||||
/* ── Audit log ───────────────────────────────────────────────────── */
|
||||
@@ -324,7 +324,7 @@
|
||||
.support-audit-log {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background-color: var(--inset);
|
||||
background-color: #0c0f0e;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
@@ -1,178 +1,473 @@
|
||||
/* ── Service tiles ──────────────────────────────────────────────── */
|
||||
/* ── Service tile card (status-only) ─────────────────────────────── */
|
||||
|
||||
.service-tile {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
padding: 21px 12px 17px;
|
||||
width: 160px;
|
||||
min-height: 130px;
|
||||
background-color: var(--card-color);
|
||||
border: 1px solid var(--border-color);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
justify-content: center;
|
||||
padding: 20px 12px 18px;
|
||||
gap: 0;
|
||||
transition: box-shadow 0.2s, border-color 0.2s;
|
||||
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); }
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.service-tile:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: var(--border-strong);
|
||||
background: var(--card-hover);
|
||||
box-shadow: var(--shadow-hover);
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.service-tile:active { transform: translateY(-1px) scale(0.985); }
|
||||
|
||||
.service-tile:focus-visible {
|
||||
outline: 2px solid rgba(66, 243, 154, 0.45);
|
||||
outline-offset: 2px;
|
||||
.service-tile.disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.service-tile.disabled { opacity: 0.55; }
|
||||
|
||||
.tile-icon {
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
display: block;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: contain;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.tile-icon-fallback {
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-3);
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
background-color: var(--border-color);
|
||||
border-radius: 12px;
|
||||
color: var(--text-dim);
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.tile-name {
|
||||
margin-top: 13px;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 650;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
line-height: 1.25;
|
||||
min-height: 2.5em;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.3;
|
||||
max-width: 140px;
|
||||
word-break: break-word;
|
||||
hyphens: auto;
|
||||
min-height: 1.3em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Status pill — dot + label inside a rounded pill */
|
||||
.tile-status {
|
||||
margin-top: 9px;
|
||||
display: inline-flex;
|
||||
font-size: 0.75rem;
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.69rem;
|
||||
font-weight: 700;
|
||||
padding: 3px 10px;
|
||||
border-radius: 99px;
|
||||
letter-spacing: 0.01em;
|
||||
white-space: nowrap;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-2);
|
||||
gap: 5px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-text { line-height: 1; }
|
||||
.tile-version {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 2px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-3);
|
||||
flex-shrink: 0;
|
||||
background-color: var(--grey);
|
||||
}
|
||||
|
||||
.status-dot.active { background: var(--accent); }
|
||||
.status-dot.needs-attention { background: var(--amber); }
|
||||
.status-dot.failed { background: var(--red); }
|
||||
.status-dot.inactive,
|
||||
.status-dot.disabled { background: var(--text-3); }
|
||||
.status-dot.syncing,
|
||||
.status-dot.loading,
|
||||
.status-dot.checking-reachability { background: var(--blue); animation: tile-dot-pulse 1.4s ease-in-out infinite; }
|
||||
.status-dot.unknown { background: var(--text-3); }
|
||||
.status-dot.active { background-color: var(--green); }
|
||||
.status-dot.inactive { background-color: var(--red); }
|
||||
.status-dot.loading { background-color: var(--yellow); animation: pulse-badge 1s infinite; }
|
||||
.status-dot.failed { background-color: var(--red); }
|
||||
.status-dot.disabled { background-color: var(--grey); }
|
||||
.status-dot.needs-attention { background-color: var(--yellow); }
|
||||
.status-dot.syncing { background-color: #f5a623; animation: pulse-badge 1.5s infinite; }
|
||||
.status-dot.checking-reachability { background-color: var(--accent-color); animation: pulse-badge 1s infinite; }
|
||||
|
||||
@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 ──────────────────────────────────────── */
|
||||
/* ── Bitcoin IBD sync progress bar ──────────────────────────────── */
|
||||
|
||||
.tile-sync-container {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
padding: 0 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.tile-sync-label {
|
||||
font-size: 0.66rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--amber);
|
||||
font-size: 0.72rem;
|
||||
color: #f5a623;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tile-sync-bar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 7px;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tile-sync-bar-track {
|
||||
flex: 1;
|
||||
height: 7px;
|
||||
border-radius: 99px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
height: 6px;
|
||||
background-color: var(--border-color);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tile-sync-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);
|
||||
background-color: #f5a623;
|
||||
border-radius: 3px;
|
||||
transition: width 0.6s ease;
|
||||
min-width: 2px;
|
||||
}
|
||||
|
||||
.tile-sync-percent {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.7rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: var(--amber);
|
||||
flex-shrink: 0;
|
||||
color: #f5a623;
|
||||
white-space: nowrap;
|
||||
min-width: 2.5em;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.tile-sync-eta {
|
||||
margin-top: 6px;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.66rem;
|
||||
color: var(--text-3);
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── BIP-110 status badge (tile + detail modal) ───────────────────── */
|
||||
|
||||
.tile-bip110-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 0.64rem;
|
||||
font-weight: 600;
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
margin-top: 4px;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.tile-bip110-badge--active {
|
||||
background: rgba(109, 191, 139, 0.18);
|
||||
color: var(--green);
|
||||
border: 1px solid rgba(109, 191, 139, 0.3);
|
||||
}
|
||||
|
||||
.tile-bip110-badge--locked_in {
|
||||
background: rgba(94, 173, 138, 0.15);
|
||||
color: var(--accent-color);
|
||||
border: 1px solid rgba(94, 173, 138, 0.3);
|
||||
}
|
||||
|
||||
.tile-bip110-badge--signaling {
|
||||
background: rgba(94, 173, 138, 0.12);
|
||||
color: var(--accent-color);
|
||||
border: 1px solid rgba(94, 173, 138, 0.2);
|
||||
}
|
||||
|
||||
.tile-bip110-badge--not_signaling {
|
||||
background: rgba(229, 165, 10, 0.12);
|
||||
color: var(--yellow);
|
||||
border: 1px solid rgba(229, 165, 10, 0.25);
|
||||
}
|
||||
|
||||
.tile-bip110-badge--unsupported {
|
||||
background: rgba(94, 122, 106, 0.12);
|
||||
color: var(--grey);
|
||||
border: 1px solid rgba(94, 122, 106, 0.2);
|
||||
}
|
||||
|
||||
.tile-bip110-badge--unknown {
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.bip110-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bip110-source-label {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── 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);
|
||||
}
|
||||
|
||||
.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,16 +5,6 @@
|
||||
const POLL_INTERVAL_SERVICES = 5000;
|
||||
const POLL_INTERVAL_UPDATES = 1800000;
|
||||
const UPDATE_POLL_INTERVAL = 2000;
|
||||
// A pending fetch never rejects by itself. Bound every status request so a
|
||||
// wedged browser connection cannot leave the modal spinning forever.
|
||||
const STATUS_POLL_FETCH_TIMEOUT = 15000;
|
||||
// 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_FETCH_TIMEOUT = 12000;
|
||||
const REBOOT_REQUEST_TIMEOUT = 4000;
|
||||
@@ -23,19 +13,13 @@ const SUPPORT_TIMER_INTERVAL = 1000;
|
||||
|
||||
const CATEGORY_ORDER = [
|
||||
"infrastructure",
|
||||
"bitcoin",
|
||||
"bitcoin-base",
|
||||
"bitcoin-apps",
|
||||
"communication",
|
||||
"apps",
|
||||
"nostr",
|
||||
];
|
||||
|
||||
/* The service catalog still distinguishes the Bitcoin foundation services
|
||||
from the Bitcoin apps; the Hub shows them as one "Bitcoin" section. */
|
||||
const CATEGORY_ALIASES = {
|
||||
"bitcoin-base": "bitcoin",
|
||||
"bitcoin-apps": "bitcoin",
|
||||
};
|
||||
|
||||
const FEATURE_SUBCATEGORY_LABELS = {
|
||||
"infrastructure": "🔧 Infrastructure",
|
||||
"bitcoin": "₿ Bitcoin",
|
||||
|
||||
@@ -1,451 +0,0 @@
|
||||
"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,109 +0,0 @@
|
||||
/* Sovran_SystemsOS Hub — Shared domain and router instructions.
|
||||
|
||||
This is the single source of truth for the simple setup guidance shown in:
|
||||
|
||||
• First-boot onboarding for Server + Desktop
|
||||
• Feature setup in the Hub
|
||||
• Domain reconfiguration and troubleshooting
|
||||
|
||||
Keep the wording consistent by editing it here. */
|
||||
"use strict";
|
||||
|
||||
function dpEsc(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/* Explain only what the user needs for the step in front of them. */
|
||||
function renderDomainNeedsHtml(opts) {
|
||||
opts = opts || {};
|
||||
var serviceName = opts.serviceName || null;
|
||||
var purpose = opts.purpose || "";
|
||||
var html = "";
|
||||
|
||||
if (purpose === "btcpay") {
|
||||
html += "<p><strong>Put your BTCPay Server online</strong></p>";
|
||||
html += "<p>Let people donate Bitcoin, pay you, or visit your online store.</p>";
|
||||
html += "<p>BTCPay Server needs a domain so people can reach it. "
|
||||
+ "Sovran_SystemsOS walks you through getting your domain from "
|
||||
+ '<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 if (serviceName) {
|
||||
html += "<p>To make <strong>" + dpEsc(serviceName) + "</strong> available outside your home, it needs a domain.</p>";
|
||||
html += "<p>Sovran_SystemsOS walks you through getting your domain from "
|
||||
+ '<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 {
|
||||
html += "<p>Your domain is the address people use to reach your services on the internet.</p>";
|
||||
html += "<p>Sovran_SystemsOS walks you through getting your domain from "
|
||||
+ '<a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">Njal.la</a>'
|
||||
+ " and connecting your services. Just follow the steps below.</p>";
|
||||
}
|
||||
// Every variant above ends with a domain that points at the user's home
|
||||
// connection. Say what that publishes (README: "Server + Desktop and your
|
||||
// home IP address").
|
||||
html += "<p>⚠️ <strong>Heads-up:</strong> your domain points at your home internet connection, "
|
||||
+ "so anyone can look up your home IP address. Domain privacy does not hide it.</p>";
|
||||
return html;
|
||||
}
|
||||
|
||||
/* The shortest Njal.la steps that still tell the user exactly what to do. */
|
||||
function renderNjallaStepsHtml(opts) {
|
||||
var hostExample = dpEsc((opts && opts.hostExample) || "call");
|
||||
var pasteHint = (opts && opts.pasteHint) || "below";
|
||||
var html = "";
|
||||
|
||||
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 += '<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>Open your domain and add a <strong>Dynamic</strong> record for this service. "
|
||||
+ "In the <strong>Name</strong> box, enter only the first part of the address. "
|
||||
+ "For <code>" + hostExample + ".yourdomain.com</code>, enter <code>" + hostExample + "</code>. "
|
||||
+ "If you are using the whole domain, leave the box blank or enter <code>@</code>.</li>";
|
||||
html += "<li>Copy the update command Njal.la gives you and paste it " + pasteHint + ".</li>";
|
||||
html += "</ol>";
|
||||
return html;
|
||||
}
|
||||
|
||||
/* One router task shared by all normal domain-based services. */
|
||||
function renderRouterPortsHtml(opts) {
|
||||
opts = opts || {};
|
||||
var ipPart = opts.internalIp
|
||||
? " to this computer at <strong>" + dpEsc(opts.internalIp) + "</strong>"
|
||||
: " to this computer";
|
||||
|
||||
var html = "";
|
||||
html += "🔌 <strong>One router task:</strong> in your router’s <strong>Port Forwarding</strong> settings, "
|
||||
+ "forward <strong>80 (TCP)</strong> and <strong>443 (TCP)</strong>"
|
||||
+ ipPart + ". If your router asks for outside and inside port numbers, use the same number for both. "
|
||||
+ "You only need to do this once. All your services share these two ports.";
|
||||
if (opts.extraNote) {
|
||||
html += " " + dpEsc(opts.extraNote);
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
/* Show generic wording immediately, then add the computer's address when found. */
|
||||
function renderRouterPortsBox(elId, opts) {
|
||||
var el = document.getElementById(elId);
|
||||
if (!el) return;
|
||||
el.innerHTML = renderRouterPortsHtml(opts || {});
|
||||
fetch("/api/network")
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
var target = document.getElementById(elId);
|
||||
if (!target) return;
|
||||
var ip = data && data.internal_ip;
|
||||
if (ip && ip !== "unavailable") {
|
||||
var next = {};
|
||||
for (var k in (opts || {})) next[k] = opts[k];
|
||||
next.internalIp = String(ip).trim();
|
||||
target.innerHTML = renderRouterPortsHtml(next);
|
||||
}
|
||||
})
|
||||
.catch(function() { /* Generic wording is already shown. */ });
|
||||
}
|
||||
@@ -4,21 +4,8 @@
|
||||
|
||||
// if ($updateBtn) $updateBtn.addEventListener("click", openUpdateModal); // moved to sidebar in tiles.js
|
||||
if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal);
|
||||
if ($btnCheckAgain) $btnCheckAgain.addEventListener("click", function() { openUpdateModal(); });
|
||||
if ($updateCloseBtn) $updateCloseBtn.addEventListener("click", closeUpdateModal);
|
||||
if ($btnReboot) $btnReboot.addEventListener("click", doReboot);
|
||||
if ($btnSave) $btnSave.addEventListener("click", saveErrorReport);
|
||||
if ($btnRetryUpdate) $btnRetryUpdate.addEventListener("click", retryUpdateStatus);
|
||||
if ($btnRetryRun) $btnRetryRun.addEventListener("click", retryUpdateRun);
|
||||
|
||||
// Browser timers and requests may be suspended while an RDP session/tab is in
|
||||
// the background. Reconcile immediately when the user returns instead of
|
||||
// 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 ($supportCloseBtn) $supportCloseBtn.addEventListener("click", closeSupportModal);
|
||||
|
||||
@@ -30,7 +17,6 @@ if ($logoutBtn) $logoutBtn.addEventListener("click", function () {
|
||||
|
||||
// Rebuild modal
|
||||
if ($rebuildClose) $rebuildClose.addEventListener("click", closeRebuildModal);
|
||||
if ($rebuildCloseHdr) $rebuildCloseHdr.addEventListener("click", closeRebuildModal);
|
||||
if ($rebuildReboot) $rebuildReboot.addEventListener("click", doReboot);
|
||||
if ($rebuildSave) $rebuildSave.addEventListener("click", saveRebuildErrorReport);
|
||||
if ($rebuildModal) $rebuildModal.addEventListener("click", function(e) { if (e.target === $rebuildModal) closeRebuildModal(); });
|
||||
@@ -58,28 +44,6 @@ if ($upgradeCloseBtn) $upgradeCloseBtn.addEventListener("click", closeUpgradeMod
|
||||
if ($upgradeCancelBtn) $upgradeCancelBtn.addEventListener("click", closeUpgradeModal);
|
||||
if ($upgradeModal) $upgradeModal.addEventListener("click", function(e) { if (e.target === $upgradeModal) closeUpgradeModal(); });
|
||||
|
||||
// Restart confirm dialog
|
||||
if ($restartConfirmCancel) $restartConfirmCancel.addEventListener("click", closeRestartConfirmDialog);
|
||||
if ($restartConfirmModal) $restartConfirmModal.addEventListener("click", function(e) { if (e.target === $restartConfirmModal) closeRestartConfirmDialog(); });
|
||||
if ($restartConfirmModal) $restartConfirmModal.addEventListener("keydown", function(e) { if (e.key === "Escape") closeRestartConfirmDialog(); });
|
||||
|
||||
// Header Reboot button
|
||||
if ($headerRebootBtn) $headerRebootBtn.addEventListener("click", function() { openRestartConfirmDialog(); });
|
||||
if ($restartConfirmOk) $restartConfirmOk.addEventListener("click", function() {
|
||||
if ($restartConfirmOk.disabled) return;
|
||||
$restartConfirmOk.disabled = true;
|
||||
closeRestartConfirmDialog();
|
||||
doReboot();
|
||||
});
|
||||
|
||||
// Reboot error card buttons
|
||||
var $rebootErrorCloseBtn = document.getElementById("reboot-error-close-btn");
|
||||
var $rebootErrorRetryBtn = document.getElementById("reboot-error-retry-btn");
|
||||
if ($rebootErrorCloseBtn) $rebootErrorCloseBtn.addEventListener("click", function() {
|
||||
if ($rebootOverlay) $rebootOverlay.classList.remove("visible");
|
||||
});
|
||||
if ($rebootErrorRetryBtn) $rebootErrorRetryBtn.addEventListener("click", doReboot);
|
||||
|
||||
// ── Upgrade modal functions ───────────────────────────────────────
|
||||
|
||||
function openUpgradeModal() {
|
||||
@@ -90,37 +54,6 @@ function closeUpgradeModal() {
|
||||
if ($upgradeModal) $upgradeModal.classList.remove("open");
|
||||
}
|
||||
|
||||
// ── Restart confirm dialog functions ─────────────────────────────
|
||||
|
||||
var _restartDialogOpener = null;
|
||||
|
||||
function openRestartConfirmDialog() {
|
||||
if (!$restartConfirmModal) return;
|
||||
_restartDialogOpener = document.activeElement;
|
||||
|
||||
// Detect conflicting operations
|
||||
var isOperationInProgress = !!_updatePollTimer || !!_rebuildPollTimer;
|
||||
if ($restartConflictBox) $restartConflictBox.style.display = isOperationInProgress ? "" : "none";
|
||||
if ($restartConfirmOk) $restartConfirmOk.disabled = isOperationInProgress;
|
||||
|
||||
$restartConfirmModal.classList.add("open");
|
||||
|
||||
// Focus Cancel initially for safety
|
||||
var cancelBtn = document.getElementById("restart-confirm-cancel-btn");
|
||||
if (cancelBtn) setTimeout(function() { cancelBtn.focus(); }, 50);
|
||||
}
|
||||
|
||||
function closeRestartConfirmDialog() {
|
||||
if ($restartConfirmModal) $restartConfirmModal.classList.remove("open");
|
||||
// Re-enable confirm button for next open
|
||||
if ($restartConfirmOk) $restartConfirmOk.disabled = false;
|
||||
// Return focus to the element that opened the dialog
|
||||
if (_restartDialogOpener && _restartDialogOpener.focus) {
|
||||
try { _restartDialogOpener.focus(); } catch (_) {}
|
||||
_restartDialogOpener = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function doUpgradeToServer() {
|
||||
var confirmBtn = $upgradeConfirmBtn;
|
||||
if (confirmBtn) { confirmBtn.disabled = true; confirmBtn.textContent = "Upgrading…"; }
|
||||
@@ -178,9 +111,9 @@ function showSecurityBanner() {
|
||||
'</div>' +
|
||||
'<button class="security-banner-dismiss" id="security-banner-dismiss-btn" title="Dismiss">\u2715</button>';
|
||||
|
||||
var contentArea = document.querySelector(".content");
|
||||
if (contentArea) {
|
||||
contentArea.insertAdjacentElement("afterbegin", banner);
|
||||
var mainContent = document.querySelector(".main-content");
|
||||
if (mainContent) {
|
||||
mainContent.insertAdjacentElement("beforebegin", banner);
|
||||
} else {
|
||||
document.body.insertAdjacentElement("afterbegin", banner);
|
||||
}
|
||||
@@ -201,39 +134,30 @@ function showSecurityBanner() {
|
||||
// ── Init ──────────────────────────────────────────────────────────
|
||||
|
||||
async function init() {
|
||||
// These lightweight requests are independent. Running them together avoids
|
||||
// making the dashboard wait through three serial network round-trips before
|
||||
// it can even start loading the service tiles.
|
||||
var onboardingStatus = null;
|
||||
var bannerData = null;
|
||||
var cfg;
|
||||
// Check onboarding status first — redirect to wizard if not complete
|
||||
try {
|
||||
var startupResults = await Promise.all([
|
||||
apiFetch("/api/onboarding/status").catch(function() { return null; }),
|
||||
apiFetch("/api/security/banner-status").catch(function() { return null; }),
|
||||
apiFetch("/api/config"),
|
||||
]);
|
||||
onboardingStatus = startupResults[0];
|
||||
bannerData = startupResults[1];
|
||||
cfg = startupResults[2];
|
||||
} catch (_) {
|
||||
// If config cannot be loaded, continue with the normal service fallback.
|
||||
cfg = null;
|
||||
}
|
||||
|
||||
if (onboardingStatus && !onboardingStatus.complete) {
|
||||
var onboardingStatus = await apiFetch("/api/onboarding/status");
|
||||
if (!onboardingStatus.complete) {
|
||||
window.location.href = "/onboarding";
|
||||
return;
|
||||
}
|
||||
} catch (_) {
|
||||
// If we can't reach the endpoint, continue to normal dashboard
|
||||
}
|
||||
|
||||
// Show first-login security banner only for machines that went through onboarding
|
||||
// (legacy machines without the onboarding flag will never see this)
|
||||
try {
|
||||
var bannerData = await apiFetch("/api/security/banner-status");
|
||||
if (bannerData && bannerData.show) {
|
||||
showSecurityBanner();
|
||||
}
|
||||
} catch (_) {
|
||||
// Non-fatal — silently ignore
|
||||
}
|
||||
|
||||
try {
|
||||
if (!cfg) throw new Error("Hub config unavailable");
|
||||
var cfg = await apiFetch("/api/config");
|
||||
_currentRole = cfg.role || "server_plus_desktop";
|
||||
if (cfg.category_order) {
|
||||
for (var i = 0; i < cfg.category_order.length; i++) {
|
||||
@@ -242,11 +166,10 @@ async function init() {
|
||||
}
|
||||
var badge = document.getElementById("role-badge");
|
||||
if (badge && cfg.role_label) badge.textContent = cfg.role_label;
|
||||
window._roleLabel = cfg.role_label || "";
|
||||
|
||||
await refreshServices();
|
||||
loadNetwork();
|
||||
checkUpdates();
|
||||
await refreshServices();
|
||||
|
||||
setInterval(refreshServices, POLL_INTERVAL_SERVICES);
|
||||
setInterval(checkUpdates, POLL_INTERVAL_UPDATES);
|
||||
@@ -256,18 +179,13 @@ async function init() {
|
||||
}
|
||||
loadAutolaunchToggle();
|
||||
} catch (_) {
|
||||
await refreshServices();
|
||||
loadNetwork();
|
||||
checkUpdates();
|
||||
await refreshServices();
|
||||
setInterval(refreshServices, POLL_INTERVAL_SERVICES);
|
||||
setInterval(checkUpdates, POLL_INTERVAL_UPDATES);
|
||||
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);
|
||||
@@ -59,9 +59,6 @@ function openDomainSetupModal(feat, onSaved) {
|
||||
if (!$domainSetupModal) return;
|
||||
if ($domainSetupTitle) $domainSetupTitle.textContent = "🌐 Domain Setup — " + feat.name;
|
||||
|
||||
var isWalletConnections = (feat.id === "nwc-wallets" || feat.domain_name === "lightning");
|
||||
var isBtcpay = feat.id === "btcpay-web";
|
||||
|
||||
var npubField = "";
|
||||
if (feat.id === "haven") {
|
||||
var currentNpub = "";
|
||||
@@ -76,31 +73,23 @@ function openDomainSetupModal(feat, onSaved) {
|
||||
npubField = '<div class="domain-field-group"><label class="domain-field-label" for="domain-npub-input">Nostr Public Key (npub1...):</label><input class="domain-field-input" type="text" id="domain-npub-input" placeholder="npub1..." value="' + escHtml(currentNpub) + '" /></div>';
|
||||
}
|
||||
|
||||
var nwcWarning = isWalletConnections
|
||||
? '<div class="domain-nwc-warning">' +
|
||||
'<strong>Lightning Wallet Connections needs its own address.</strong> ' +
|
||||
'Use a new address such as <code>lightning.yourdomain.com</code>. Do not use an address already connected to another service.' +
|
||||
'</div>'
|
||||
: '';
|
||||
|
||||
var domainPlaceholder = isWalletConnections ? "lightning.yourdomain.com" : (isBtcpay ? "btcpay.yourdomain.com" : "myservice.example.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) —
|
||||
// identical wording to the Server + Desktop onboarding wizard and every other
|
||||
// domain-based feature, regardless of role (Node / Desktop / Server+Desktop).
|
||||
var hostExample = isWalletConnections ? "lightning" : (isBtcpay ? "btcpay" : "call");
|
||||
var purpose = isBtcpay ? "btcpay" : "";
|
||||
var externalIp = _cachedExternalIp || "your external IP";
|
||||
|
||||
$domainSetupBody.innerHTML =
|
||||
'<div class="domain-setup-intro">' +
|
||||
nwcWarning +
|
||||
renderDomainNeedsHtml({ serviceName: feat.name, hostExample: hostExample, purpose: purpose }) +
|
||||
renderNjallaStepsHtml({ hostExample: hostExample, pasteHint: "below" }) +
|
||||
'<p style="margin-top:10px;">Enter the address for this service and paste the update command from Njal.la.</p>' +
|
||||
'<p><strong>Before continuing:</strong></p>' +
|
||||
'<ol>' +
|
||||
'<li>Create an account at <a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">https://njal.la</a></li>' +
|
||||
'<li>Purchase a new domain on Njal.la, or create a subdomain from a domain you already own. Tip: Subdomains are free to create — you only need to purchase one domain, and you can add as many subdomains as you need at no extra cost.</li>' +
|
||||
'<li>In the Njal.la web interface, create a <strong>Dynamic</strong> record pointing to this machine\'s external IP address:<br>' +
|
||||
'<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>Njal.la will give you a curl command like:<br>' +
|
||||
'<code style="font-size:0.8em;">curl "https://njal.la/update/?h=sub.domain.com&k=abc123&auto"</code></li>' +
|
||||
'<li>Enter the subdomain and paste that curl command below</li>' +
|
||||
'</ol>' +
|
||||
'</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 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>' +
|
||||
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Subdomain (e.g. myservice.example.com):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="myservice.example.com" /></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=myservice.example.com&k=abc123&auto"" /><p class="domain-field-hint">ℹ Paste the full curl command from your Njal.la dashboard\'s Dynamic record</p></div>' +
|
||||
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>';
|
||||
|
||||
@@ -114,7 +103,7 @@ function openDomainSetupModal(feat, onSaved) {
|
||||
ddnsUrl = ddnsUrl.trim();
|
||||
npub = npub.trim();
|
||||
|
||||
if (!subdomain) { alert("Please enter a domain."); return; }
|
||||
if (!subdomain) { alert("Please enter a subdomain."); return; }
|
||||
if (feat.id === "haven" && !npub) { alert("Please enter your Nostr public key."); return; }
|
||||
|
||||
var saveBtn = document.getElementById("domain-setup-save-btn");
|
||||
@@ -136,21 +125,17 @@ function openDomainSetupModal(feat, onSaved) {
|
||||
} catch (err) {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = "Save & Enable";
|
||||
var msg = (err && err.message) ? err.message : "Failed to save domain. Please try again.";
|
||||
alert(msg);
|
||||
alert("Failed to save domain. Please try again.");
|
||||
}
|
||||
});
|
||||
|
||||
$domainSetupModal.classList.add("open");
|
||||
|
||||
}
|
||||
|
||||
function openDomainReconfigureModal(feat, existingDomain, onSaved) {
|
||||
if (!$domainSetupModal) return;
|
||||
if ($domainSetupTitle) $domainSetupTitle.textContent = "🔄 Reconfigure Domain — " + feat.name;
|
||||
|
||||
var isWalletConnections = (feat.id === "nwc-wallets" || feat.domain_name === "lightning");
|
||||
|
||||
var npubField = "";
|
||||
if (feat.id === "haven") {
|
||||
var currentNpub = "";
|
||||
@@ -165,35 +150,24 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) {
|
||||
npubField = '<div class="domain-field-group"><label class="domain-field-label" for="domain-npub-input">Nostr Public Key (npub1...):</label><input class="domain-field-input" type="text" id="domain-npub-input" placeholder="npub1..." value="' + escHtml(currentNpub) + '" /></div>';
|
||||
}
|
||||
|
||||
var nwcWarning = isWalletConnections
|
||||
? '<div class="domain-nwc-warning">' +
|
||||
'<strong>Lightning Wallet Connections needs its own address.</strong> ' +
|
||||
'Use a new address such as <code>lightning.yourdomain.com</code>. Do not use an address already connected to another service.' +
|
||||
'</div>'
|
||||
: '';
|
||||
|
||||
var domainPlaceholder = isWalletConnections ? "lightning.yourdomain.com" : "myservice.example.com";
|
||||
var domainLabelExample = isWalletConnections ? "lightning.yourdomain.com" : "call.yourdomain.com";
|
||||
|
||||
var externalIp = _cachedExternalIp || "your external IP";
|
||||
var currentDomain = existingDomain || "";
|
||||
|
||||
$domainSetupBody.innerHTML =
|
||||
'<div class="domain-setup-intro">' +
|
||||
nwcWarning +
|
||||
'<p>Your domain <strong>' + escHtml(currentDomain || "this domain") + '</strong> is configured but isn\'t resolving correctly.</p>' +
|
||||
'<p><strong>Troubleshooting steps:</strong></p>' +
|
||||
'<ol>' +
|
||||
'<li>Log into your Njal.la dashboard at <a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">https://njal.la</a></li>' +
|
||||
'<li>Find the DNS record for <strong>' + escHtml(currentDomain || "your domain") + '</strong>. In Njal.la\'s Name field, note that only the host part is stored (the word before the domain) — not the full domain.</li>' +
|
||||
'<li>Find the DNS record for <strong>' + escHtml(currentDomain || "your domain") + '</strong></li>' +
|
||||
'<li>Verify it has a <strong>Dynamic</strong> record pointing to your current external IP:<br>' +
|
||||
'<span style="display:inline-block;margin-top:4px;padding:4px 10px;background:var(--card-color);border:1px solid var(--border-color);border-radius:6px;font-family:monospace;font-size:1em;font-weight:700;">' + escHtml(externalIp) + '</span></li>' +
|
||||
'<li>If the IP is wrong or the record is missing, update it</li>' +
|
||||
'<li>If you changed the DDNS curl command, paste the updated one below</li>' +
|
||||
'</ol>' +
|
||||
'</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-subdomain-input">Subdomain (e.g. myservice.example.com):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="myservice.example.com" 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=myservice.example.com&k=abc123&auto"" /><p class="domain-field-hint">ℹ Paste the full curl command from your Njal.la dashboard\'s Dynamic record</p></div>' +
|
||||
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 & Update</button></div>';
|
||||
|
||||
@@ -229,13 +203,11 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) {
|
||||
} catch (err) {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = "Save & Update";
|
||||
var msg = (err && err.message) ? err.message : "Failed to save domain. Please try again.";
|
||||
alert(msg);
|
||||
alert("Failed to save domain. Please try again.");
|
||||
}
|
||||
});
|
||||
|
||||
$domainSetupModal.classList.add("open");
|
||||
|
||||
}
|
||||
|
||||
function closeDomainSetupModal() {
|
||||
@@ -252,12 +224,28 @@ function openPortRequirementsModal(featureName, ports, onContinue) {
|
||||
: '';
|
||||
|
||||
function renderPortRequirements(internalIp) {
|
||||
var rows = ports.map(function(p) {
|
||||
return '<tr><td class="port-req-port">' + escHtml(p.port) + '</td>' +
|
||||
'<td class="port-req-proto">' + escHtml(p.protocol) + '</td>' +
|
||||
'<td class="port-req-desc">' + escHtml(p.description) + '</td></tr>';
|
||||
}).join("");
|
||||
var ipLine = internalIp
|
||||
? '<p class="port-req-intro">Forward each port below <strong>to this machine\'s internal IP: <code class="port-req-internal-ip">' + escHtml(internalIp) + '</code></strong></p>'
|
||||
: "<p class=\"port-req-intro\">Forward each port below to this machine's internal LAN IP in your router's port forwarding settings.</p>";
|
||||
|
||||
$portReqBody.innerHTML =
|
||||
renderPortForwardGuideHtml(ports, {
|
||||
internalIp: internalIp,
|
||||
serviceName: featureName,
|
||||
}) +
|
||||
'<p class="port-req-hint">💡 This list is always available again on the <strong>' + escHtml(featureName) + '</strong> tile.</p>' +
|
||||
'<p class="port-req-intro"><strong>Port Forwarding Required</strong></p>' +
|
||||
'<p class="port-req-intro">For <strong>' + escHtml(featureName) + "</strong> to work with clients outside your local network, " +
|
||||
"you must configure <strong>port forwarding</strong> in your router's admin panel.</p>" +
|
||||
ipLine +
|
||||
'<table class="port-req-table">' +
|
||||
'<thead><tr><th>Port(s)</th><th>Protocol</th><th>Purpose</th></tr></thead>' +
|
||||
'<tbody>' + rows + '</tbody>' +
|
||||
'</table>' +
|
||||
"<p class=\"port-req-hint\"><strong>How to verify:</strong> Router-side forwarding cannot be checked from inside your network. " +
|
||||
"To confirm ports are forwarded correctly, test from a device on a different network (e.g. a phone on mobile data) " +
|
||||
"or check your router's port forwarding page.</p>" +
|
||||
'<p class="port-req-hint">ℹ Search "<em>how to set up port forwarding on [your router model]</em>" for step-by-step instructions.</p>' +
|
||||
'<div class="domain-field-actions">' +
|
||||
'<button class="btn btn-close-modal" id="port-req-dismiss-btn">Dismiss</button>' +
|
||||
continueBtn +
|
||||
@@ -335,11 +323,8 @@ async function performFeatureToggle(featId, enabled, extra) {
|
||||
function handleFeatureToggle(feat, newEnabled) {
|
||||
if (!newEnabled) {
|
||||
// Disable: ask confirmation
|
||||
var disableMessage = (feat.id === "bitcoin-tor-gossip")
|
||||
? "This will stop advertising your Bitcoin Core onion address to other peers. Nodes that already know the address can still connect through Tor, and no clearnet port will be opened. The system will rebuild. Continue?"
|
||||
: "This will disable " + feat.name + ". The system will rebuild. Continue?";
|
||||
openFeatureConfirm(
|
||||
disableMessage,
|
||||
"This will disable " + feat.name + ". The system will rebuild. Continue?",
|
||||
function() { performFeatureToggle(feat.id, false, {}); }
|
||||
);
|
||||
return;
|
||||
@@ -393,15 +378,44 @@ function handleFeatureToggle(feat, newEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Always show the full list of ports to forward. Port forwarding happens on
|
||||
// the router, which this computer cannot inspect — a local check would only
|
||||
// hide ports the user still has to open.
|
||||
// Check which ports are actually closed before showing the modal
|
||||
fetch("/api/ports/status", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ports: ports }),
|
||||
})
|
||||
.then(function(r) {
|
||||
if (!r.ok) throw new Error("Port status request failed: " + r.status);
|
||||
return r.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
var portStatuses = {};
|
||||
(data.ports || []).forEach(function(p) {
|
||||
portStatuses[p.port + "/" + p.protocol] = p.status;
|
||||
});
|
||||
|
||||
var closedPorts = ports.filter(function(p) {
|
||||
var key = p.port + "/" + p.protocol;
|
||||
var status = portStatuses[key] || "unknown";
|
||||
return status !== "listening" && status !== "firewall_open";
|
||||
});
|
||||
|
||||
if (closedPorts.length === 0) {
|
||||
proceedAfterPortCheck();
|
||||
} else {
|
||||
openPortRequirementsModal(feat.name, closedPorts, proceedAfterPortCheck);
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.warn("Failed to fetch port status for feature enable flow:", err);
|
||||
// Safe fallback if status check fails
|
||||
openPortRequirementsModal(feat.name, ports, proceedAfterPortCheck);
|
||||
});
|
||||
}
|
||||
|
||||
if (feat.id === "bitcoin-tor-gossip") {
|
||||
var torGossipConfirmMsg = "This will advertise your Bitcoin Core .onion P2P address through Bitcoin peer gossip. More Tor-capable nodes may discover your node and request historical blocks during IBD, which can use significant upload bandwidth. Your home IP remains hidden and no clearnet port or router forwarding is opened. Continue?";
|
||||
openFeatureConfirm(torGossipConfirmMsg, proceedAfterConflictCheck);
|
||||
if (feat.id === "bitcoin-core") {
|
||||
var confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Core will replace Bitcoin Knots + BIP110 as the active node. Your timechain data will be preserved — you will not need to re-download the timechain. Continue?";
|
||||
openFeatureConfirm(confirmMsg, proceedAfterConflictCheck);
|
||||
} else if (conflictNames.length > 0) {
|
||||
openFeatureConfirm("This will disable " + conflictNames.join(", ") + ". Continue?", proceedAfterConflictCheck);
|
||||
} else {
|
||||
@@ -628,16 +642,16 @@ async function loadAutolaunchToggle() {
|
||||
}
|
||||
|
||||
function renderAutolaunchToggle(enabled) {
|
||||
// The preference lives in the Systems Operational modal (moved from the sidebar)
|
||||
var slot = document.getElementById("autolaunch-slot");
|
||||
if (!slot) return;
|
||||
var old = slot.querySelector(".autolaunch-section");
|
||||
// Remove existing section if any
|
||||
var old = $sidebarFeatures.querySelector(".autolaunch-section");
|
||||
if (old) old.parentNode.removeChild(old);
|
||||
|
||||
var section = document.createElement("div");
|
||||
section.className = "category-section autolaunch-section";
|
||||
|
||||
section.innerHTML =
|
||||
'<div class="section-header">Preferences</div>' +
|
||||
'<hr class="section-divider" />' +
|
||||
'<div class="feature-card">' +
|
||||
'<div class="feature-card-top">' +
|
||||
'<div class="feature-card-info">' +
|
||||
@@ -651,7 +665,7 @@ function renderAutolaunchToggle(enabled) {
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
slot.appendChild(section);
|
||||
$sidebarFeatures.appendChild(section);
|
||||
|
||||
var input = document.getElementById("autolaunch-toggle-input");
|
||||
var label = document.getElementById("autolaunch-toggle-label");
|
||||
|
||||
@@ -40,57 +40,6 @@ function linkify(str) {
|
||||
return escHtml(str).replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer" class="creds-link">$1</a>');
|
||||
}
|
||||
|
||||
// ── Router port-forwarding guide ──────────────────────────────────
|
||||
// Whether a port is truly reachable can only be judged from OUTSIDE the
|
||||
// network, so we never show a local "ready" verdict here. We only tell the
|
||||
// user exactly what to enter in their router.
|
||||
|
||||
// Render the protocol cell so TCP / UDP / both is unmistakable.
|
||||
function portProtocolHtml(protocol) {
|
||||
var p = String(protocol || "TCP").toUpperCase();
|
||||
var isTcp = p.indexOf("TCP") !== -1;
|
||||
var isUdp = p.indexOf("UDP") !== -1;
|
||||
if (isTcp && isUdp) {
|
||||
return '<span class="port-proto-badge port-proto-badge--both">TCP + UDP</span>' +
|
||||
'<span class="port-proto-note">both required</span>';
|
||||
}
|
||||
if (isUdp) return '<span class="port-proto-badge port-proto-badge--udp">UDP</span>';
|
||||
return '<span class="port-proto-badge port-proto-badge--tcp">TCP</span>';
|
||||
}
|
||||
|
||||
// ports: [{ port, protocol, description }]
|
||||
// opts: { internalIp, serviceName, tableClass, introClass, noteClass }
|
||||
function renderPortForwardGuideHtml(ports, opts) {
|
||||
opts = opts || {};
|
||||
var tableClass = opts.tableClass || "port-req-table";
|
||||
var introClass = opts.introClass || "port-req-intro";
|
||||
var noteClass = opts.noteClass || "port-req-hint";
|
||||
var ipHtml = opts.internalIp
|
||||
? '<code class="port-req-internal-ip">' + escHtml(opts.internalIp) + '</code>'
|
||||
: 'this computer’s <strong>internal IP</strong>';
|
||||
|
||||
var rows = (ports || []).map(function(p) {
|
||||
return '<tr>' +
|
||||
'<td class="port-req-port">' + escHtml(p.port) + '</td>' +
|
||||
'<td class="port-req-proto">' + portProtocolHtml(p.protocol) + '</td>' +
|
||||
'<td class="port-req-desc">' + escHtml(p.description || "") + '</td>' +
|
||||
'</tr>';
|
||||
}).join("");
|
||||
|
||||
var forWhat = opts.serviceName
|
||||
? 'To make <strong>' + escHtml(opts.serviceName) + '</strong> reachable from outside your home, forward these ports to ' + ipHtml + ':'
|
||||
: 'Forward these ports to ' + ipHtml + ':';
|
||||
|
||||
return '<p class="' + introClass + '">' + forWhat + '</p>' +
|
||||
'<p class="port-req-steps" style="margin-top:6px;margin-bottom:10px;font-size:0.92em;color:#555;">' +
|
||||
'Set the internal and external port to the <strong>same number</strong>. Match <strong>TCP</strong> or <strong>UDP</strong> exactly. For ranges like <strong>40000-40099</strong>, use your router’s range fields (start 40000, end 40099).' +
|
||||
'</p>' +
|
||||
'<table class="' + tableClass + '">' +
|
||||
'<thead><tr><th>Port(s)</th><th>Protocol</th><th>Used for</th></tr></thead>' +
|
||||
'<tbody>' + rows + '</tbody>' +
|
||||
'</table>';
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
@@ -102,55 +51,26 @@ function formatDuration(seconds) {
|
||||
|
||||
// ── Fetch wrappers ────────────────────────────────────────────────
|
||||
|
||||
// Avoid issuing multiple redirects when several startup requests discover an
|
||||
// expired session at the same time.
|
||||
let _authRedirectInProgress = false;
|
||||
|
||||
async function apiFetch(path, options) {
|
||||
const res = await fetch(path, options || {});
|
||||
if (res.status === 401) {
|
||||
if (!_authRedirectInProgress) {
|
||||
_authRedirectInProgress = true;
|
||||
window.location.replace("/login");
|
||||
}
|
||||
throw new Error("Unauthenticated");
|
||||
}
|
||||
if (!res.ok) {
|
||||
let detail = res.status + " " + res.statusText;
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body && body.detail) {
|
||||
if (typeof body.detail === "string") {
|
||||
detail = body.detail;
|
||||
} else if (body.detail && typeof body.detail.message === "string") {
|
||||
detail = body.detail.message;
|
||||
}
|
||||
} else if (body && typeof body.message === "string") {
|
||||
detail = body.message;
|
||||
} else if (body && typeof body.error === "string") {
|
||||
detail = body.error;
|
||||
}
|
||||
} catch (e) {}
|
||||
try { const body = await res.json(); if (body && body.detail) detail = body.detail; } catch (e) {}
|
||||
throw new Error(detail);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// ── BIP-110 badge state config ────────────────────────────────────
|
||||
// Shared lookup used by tiles.js and service-detail.js.
|
||||
// Keys match the "state" values returned by /api/bitcoin/bip110.
|
||||
|
||||
var BIP110_BADGE_CONFIG = {
|
||||
active: { cls: 'tile-bip110-badge--active', label: 'Active', title: 'BIP-110 is active on this node' },
|
||||
locked_in: { cls: 'tile-bip110-badge--locked_in', label: 'Locked In', title: 'BIP-110 is locked in and will activate shortly' },
|
||||
signaling: { cls: 'tile-bip110-badge--signaling', label: 'Signaling', title: 'Node is signaling readiness for BIP-110' },
|
||||
not_signaling: { cls: 'tile-bip110-badge--not_signaling',label: 'Not Signaling', title: 'Node supports BIP-110 but is not signaling this period' },
|
||||
unsupported: { cls: 'tile-bip110-badge--unsupported', label: 'Not Supported', title: 'This node build does not include BIP-110' },
|
||||
unknown: { cls: 'tile-bip110-badge--unknown', label: '\u2014', title: 'Status unavailable (node syncing or RPC not ready)' }
|
||||
};
|
||||
@@ -2,52 +2,20 @@
|
||||
|
||||
// ── Rebuild modal ─────────────────────────────────────────────────
|
||||
|
||||
// Status line + header pill for the rebuild dialog (same presentation
|
||||
// contract as the update dialog's _setUpdateStatus).
|
||||
function _setRebuildStatus(text) {
|
||||
if ($rebuildStatus) $rebuildStatus.textContent = text;
|
||||
if ($rebuildPill) {
|
||||
var cls = "upd-pill";
|
||||
var html;
|
||||
if (text.charAt(0) === "✓") {
|
||||
if (text.indexOf("restart required") !== -1) {
|
||||
cls += " st-needs-attention"; html = '<span class="status-dot needs-attention pulse"></span>Restart required';
|
||||
} else {
|
||||
cls += " st-active"; html = '<span class="status-dot active"></span>Done';
|
||||
}
|
||||
} else if (text.charAt(0) === "✗") {
|
||||
cls += " st-failed"; html = '<span class="status-dot failed"></span>Failed';
|
||||
} else {
|
||||
cls += " st-loading"; html = '<span class="status-dot loading pulse"></span>Applying…';
|
||||
}
|
||||
$rebuildPill.className = cls;
|
||||
$rebuildPill.innerHTML = html;
|
||||
}
|
||||
if ($rebuildStatus) {
|
||||
var msg = "update-status-msg";
|
||||
if (text.charAt(0) === "✓") $rebuildStatus.className = (text.indexOf("restart required") !== -1) ? msg + " st-warn" : msg + " st-ok";
|
||||
else if (text.charAt(0) === "✗") $rebuildStatus.className = msg + " st-err";
|
||||
else $rebuildStatus.className = msg;
|
||||
}
|
||||
}
|
||||
|
||||
function openRebuildModal() {
|
||||
if (!$rebuildModal) return;
|
||||
_rebuildLog = "";
|
||||
_rebuildLogOffset = 0;
|
||||
_rebuildServerDown = false;
|
||||
_rebuildFinished = false;
|
||||
_rebuildPollInFlight = false;
|
||||
_rebuildPollFailures = 0;
|
||||
if ($rebuildLog) { $rebuildLog.textContent = ""; $rebuildLog.style.display = "none"; }
|
||||
var action = _rebuildIsEnabling ? "Enabling" : "Disabling";
|
||||
var label = _rebuildFeatureName || "feature";
|
||||
_setRebuildStatus(action + " " + label + "…");
|
||||
if ($rebuildStatus) $rebuildStatus.textContent = action + " " + label + "…";
|
||||
if ($rebuildSpinner) $rebuildSpinner.classList.add("spinning");
|
||||
if ($rebuildReboot) $rebuildReboot.style.display = "none";
|
||||
if ($rebuildSave) $rebuildSave.style.display = "none";
|
||||
if ($rebuildClose) $rebuildClose.disabled = true;
|
||||
if ($rebuildCloseHdr) $rebuildCloseHdr.disabled = true;
|
||||
$rebuildModal.classList.add("open");
|
||||
// Delay first poll slightly to let the rebuild service start and clear stale log
|
||||
setTimeout(startRebuildPoll, 1500);
|
||||
@@ -65,7 +33,6 @@ function appendRebuildLog(text) {
|
||||
}
|
||||
|
||||
function startRebuildPoll() {
|
||||
if (_rebuildPollTimer) clearInterval(_rebuildPollTimer);
|
||||
pollRebuildStatus();
|
||||
_rebuildPollTimer = setInterval(pollRebuildStatus, UPDATE_POLL_INTERVAL);
|
||||
}
|
||||
@@ -75,15 +42,9 @@ function stopRebuildPoll() {
|
||||
}
|
||||
|
||||
async function pollRebuildStatus() {
|
||||
if (_rebuildFinished || _rebuildPollInFlight) return;
|
||||
_rebuildPollInFlight = true;
|
||||
if (_rebuildFinished) return;
|
||||
try {
|
||||
var data = await apiFetchWithTimeout(
|
||||
"/api/rebuild/status?offset=" + _rebuildLogOffset,
|
||||
{ cache: "no-store" },
|
||||
STATUS_POLL_FETCH_TIMEOUT
|
||||
);
|
||||
_rebuildPollFailures = 0;
|
||||
var data = await apiFetch("/api/rebuild/status?offset=" + _rebuildLogOffset);
|
||||
if (_rebuildServerDown) { _rebuildServerDown = false; }
|
||||
if (data.log) appendRebuildLog(data.log);
|
||||
_rebuildLogOffset = data.offset;
|
||||
@@ -96,35 +57,22 @@ async function pollRebuildStatus() {
|
||||
onRebuildDone(data.result === "success");
|
||||
}
|
||||
} catch (err) {
|
||||
_rebuildPollFailures += 1;
|
||||
// The Hub restarts itself during activation, which briefly drops this poll.
|
||||
// If polling stays broken long past a normal restart, reload to
|
||||
// re-authenticate and show the resulting feature state.
|
||||
if (_rebuildPollFailures >= STATUS_POLL_MAX_FAILURES) {
|
||||
_rebuildFinished = true;
|
||||
stopRebuildPoll();
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
if (!_rebuildServerDown) { _rebuildServerDown = true; _setRebuildStatus("Applying changes…"); }
|
||||
} finally {
|
||||
_rebuildPollInFlight = false;
|
||||
if (!_rebuildServerDown) { _rebuildServerDown = true; if ($rebuildStatus) $rebuildStatus.textContent = "Applying changes…"; }
|
||||
}
|
||||
}
|
||||
|
||||
function onRebuildDone(result) {
|
||||
if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning");
|
||||
if ($rebuildClose) $rebuildClose.disabled = false;
|
||||
if ($rebuildCloseHdr) $rebuildCloseHdr.disabled = false;
|
||||
if (result === true) {
|
||||
_setRebuildStatus("✓ Done");
|
||||
if ($rebuildStatus) $rebuildStatus.textContent = "✓ Done";
|
||||
// Auto-reload the page after a short delay so tiles and toggles reflect the new state
|
||||
setTimeout(function() { window.location.reload(); }, 1200);
|
||||
} else if (result === "reboot_required") {
|
||||
_setRebuildStatus("✓ Done — restart required");
|
||||
if ($rebuildStatus) $rebuildStatus.textContent = "✓ Done — reboot required";
|
||||
if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex";
|
||||
} else {
|
||||
_setRebuildStatus("✗ Something went wrong");
|
||||
if ($rebuildStatus) $rebuildStatus.textContent = "✗ Something went wrong";
|
||||
if ($rebuildSave) $rebuildSave.style.display = "inline-flex";
|
||||
if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex";
|
||||
}
|
||||
|
||||
@@ -5,12 +5,7 @@
|
||||
function openSecurityModal() {
|
||||
if ($supportModal) $supportModal.classList.add("open");
|
||||
var title = document.getElementById("support-modal-title");
|
||||
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 (title) title.textContent = "\uD83D\uDEE1 Security";
|
||||
|
||||
if ($supportBody) {
|
||||
$supportBody.innerHTML =
|
||||
@@ -150,25 +145,28 @@ function openSecurityModal() {
|
||||
if (rebootBtn) {
|
||||
// Keep button disabled for 5 seconds to prevent accidental clicks
|
||||
var countdown = 5;
|
||||
rebootBtn.textContent = "I have written down my new password \u2014 Restart Entire System (" + countdown + ")";
|
||||
rebootBtn.textContent = "I have written down my new password \u2014 Reboot now (" + countdown + ")";
|
||||
var timer = setInterval(function() {
|
||||
countdown--;
|
||||
if (countdown <= 0) {
|
||||
clearInterval(timer);
|
||||
rebootBtn.disabled = false;
|
||||
rebootBtn.textContent = "I have written down my new password \u2014 Restart Entire System";
|
||||
rebootBtn.textContent = "I have written down my new password \u2014 Reboot now";
|
||||
} else {
|
||||
rebootBtn.textContent = "I have written down my new password \u2014 Restart Entire System (" + countdown + ")";
|
||||
rebootBtn.textContent = "I have written down my new password \u2014 Reboot now (" + countdown + ")";
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
rebootBtn.addEventListener("click", function() {
|
||||
rebootBtn.disabled = true;
|
||||
rebootBtn.textContent = "Restarting\u2026";
|
||||
// Hide the security reset overlay so the shared reboot overlay is visible
|
||||
var $secResetOverlay2 = document.getElementById("security-reset-overlay");
|
||||
if ($secResetOverlay2) $secResetOverlay2.classList.remove("visible");
|
||||
doReboot();
|
||||
rebootBtn.textContent = "Rebooting\u2026";
|
||||
if ($rebootOverlay) $rebootOverlay.classList.add("visible");
|
||||
_rebootStartTime = Date.now();
|
||||
_serverWentDown = false;
|
||||
setTimeout(waitForServerReboot, REBOOT_INITIAL_DELAY);
|
||||
var rebootCtrl = new AbortController();
|
||||
setTimeout(function() { rebootCtrl.abort(); }, REBOOT_REQUEST_TIMEOUT);
|
||||
fetch("/api/reboot", { method: "POST", signal: rebootCtrl.signal }).catch(function() {});
|
||||
}, { once: true });
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -6,13 +6,9 @@ let _servicesCache = [];
|
||||
let _categoryLabels = {};
|
||||
let _updateLog = "";
|
||||
let _updatePollTimer = null;
|
||||
let _updatePollInFlight = false;
|
||||
let _updateLogOffset = 0;
|
||||
let _updateVisibleLogChars = 0;
|
||||
let _serverWasDown = false;
|
||||
let _updateFinished = false;
|
||||
let _updateStatusUnavailable = false;
|
||||
let _updatePollFailures = 0; // consecutive failed update-status polls
|
||||
let _supportTimerInt = null;
|
||||
let _supportEnabledAt = null;
|
||||
let _supportStatus = null; // last fetched /api/support/status payload
|
||||
@@ -27,10 +23,8 @@ let _featuresData = null;
|
||||
let _rebuildLog = "";
|
||||
let _rebuildLogOffset = 0;
|
||||
let _rebuildPollTimer = null;
|
||||
let _rebuildPollInFlight = false;
|
||||
let _rebuildFinished = false;
|
||||
let _rebuildServerDown = false;
|
||||
let _rebuildPollFailures = 0; // consecutive failed rebuild-status polls
|
||||
let _pendingToggle = null; // {feature, extra} waiting for domain/confirm
|
||||
let _rebuildFeatureName = "";
|
||||
let _rebuildIsEnabling = true;
|
||||
@@ -52,18 +46,9 @@ const $modalStatus = document.getElementById("modal-status");
|
||||
const $modalLog = document.getElementById("modal-log");
|
||||
const $btnReboot = document.getElementById("btn-reboot");
|
||||
const $btnSave = document.getElementById("btn-save-report");
|
||||
const $btnRetryUpdate = document.getElementById("btn-retry-update-status");
|
||||
const $btnRetryRun = document.getElementById("btn-retry-update");
|
||||
const $btnCloseModal = document.getElementById("btn-close-modal");
|
||||
const $btnCheckAgain = document.getElementById("btn-check-again");
|
||||
const $updateCloseBtn = document.getElementById("update-close-btn");
|
||||
const $updPill = document.getElementById("upd-pill");
|
||||
const $updLastChecked = document.getElementById("upd-last-checked");
|
||||
|
||||
const $rebootOverlay = document.getElementById("reboot-overlay");
|
||||
const $rebootMainCard = document.getElementById("reboot-main-card");
|
||||
const $rebootErrorCard = document.getElementById("reboot-error-card");
|
||||
const $rebootSubmessage = document.getElementById("reboot-submessage");
|
||||
|
||||
const $credsModal = document.getElementById("creds-modal");
|
||||
const $credsTitle = document.getElementById("creds-modal-title");
|
||||
@@ -84,8 +69,6 @@ const $rebuildLog = document.getElementById("rebuild-log");
|
||||
const $rebuildReboot = document.getElementById("rebuild-reboot-btn");
|
||||
const $rebuildSave = document.getElementById("rebuild-save-report");
|
||||
const $rebuildClose = document.getElementById("rebuild-close-btn");
|
||||
const $rebuildCloseHdr = document.getElementById("rebuild-close-hdr");
|
||||
const $rebuildPill = document.getElementById("rebuild-pill");
|
||||
|
||||
// Feature Manager — domain setup modal
|
||||
const $domainSetupModal = document.getElementById("domain-setup-modal");
|
||||
@@ -118,14 +101,5 @@ const $upgradeConfirmBtn = document.getElementById("upgrade-confirm-btn");
|
||||
const $upgradeCancelBtn = document.getElementById("upgrade-cancel-btn");
|
||||
const $upgradeCloseBtn = document.getElementById("upgrade-close-btn");
|
||||
|
||||
// Restart confirm dialog
|
||||
const $restartConfirmModal = document.getElementById("restart-confirm-modal");
|
||||
const $restartConfirmOk = document.getElementById("restart-confirm-ok-btn");
|
||||
const $restartConfirmCancel = document.getElementById("restart-confirm-cancel-btn");
|
||||
const $restartConflictBox = document.getElementById("restart-conflict-box");
|
||||
|
||||
// Header reboot button
|
||||
const $headerRebootBtn = document.getElementById("btn-header-reboot");
|
||||
|
||||
// System status banner
|
||||
// (removed — health is now shown per-tile via the composite health field)
|
||||
@@ -5,14 +5,6 @@
|
||||
async function openSupportModal() {
|
||||
if (!$supportModal) return;
|
||||
$supportModal.classList.add("open");
|
||||
// The dialog is shared with Security — always restore its identity
|
||||
var title = document.getElementById("support-modal-title");
|
||||
if (title) title.textContent = "Tech Support";
|
||||
var chip = document.getElementById("support-modal-chip");
|
||||
if (chip) {
|
||||
var use = chip.querySelector("use");
|
||||
if (use) use.setAttribute("href", "#g-lifebuoy");
|
||||
}
|
||||
$supportBody.innerHTML = '<p class="creds-loading">Checking support status…</p>';
|
||||
try {
|
||||
var status = await apiFetch("/api/support/status");
|
||||
@@ -118,26 +110,12 @@ function renderSupportInactive() {
|
||||
'</div>',
|
||||
'<div class="support-steps"><div class="support-steps-title">What happens:</div><ol>',
|
||||
'<li>A restricted <code>sovran-support</code> user is created with limited access</li>',
|
||||
'<li>Support\'s SSH key is added only to that restricted account — not to root</li>',
|
||||
'<li>Our SSH key is added only to that restricted account</li>',
|
||||
'<li>Wallet files are locked via access controls — not visible to support</li>',
|
||||
'<li>You control if and when wallet access is granted (time-limited)</li>',
|
||||
'<li>All session events are logged for your audit</li>',
|
||||
'<li>Access expires automatically after 24 hours</li>',
|
||||
'</ol></div>',
|
||||
'<div class="support-key-section">',
|
||||
'<label class="support-key-label" for="support-ssh-pubkey">',
|
||||
'<strong>Paste the support SSH public key provided by Sovran Systems:</strong>',
|
||||
'</label>',
|
||||
'<textarea id="support-ssh-pubkey" class="support-key-input" rows="3" ',
|
||||
'placeholder="ssh-ed25519 AAAA… support-session" ',
|
||||
'spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off"></textarea>',
|
||||
'<p class="support-key-hint">',
|
||||
'The key must start with <code>ssh-ed25519</code> or <code>ecdsa-sha2-nistp256</code>. ',
|
||||
'Do not paste your own private key — only paste the one-time public key sent by Sovran Systems support.',
|
||||
'</p>',
|
||||
'</div>',
|
||||
'<button class="btn support-btn-enable" id="btn-support-enable">Enable Support Access</button>',
|
||||
'<p id="support-key-error" class="support-key-error" style="display:none;color:#c0392b;margin-top:8px;"></p>',
|
||||
'<p class="support-fine-print">You can revoke access at any time. When you end the session, you\'ll be able to disable SSH to return to the default secure state.</p>',
|
||||
'</div>',
|
||||
].join("");
|
||||
@@ -249,43 +227,16 @@ function renderSupportRemoved(verified) {
|
||||
|
||||
async function enableSupport() {
|
||||
var btn = document.getElementById("btn-support-enable");
|
||||
var errEl = document.getElementById("support-key-error");
|
||||
var textarea = document.getElementById("support-ssh-pubkey");
|
||||
if (errEl) { errEl.style.display = "none"; errEl.textContent = ""; }
|
||||
|
||||
var sshKey = textarea ? textarea.value.trim() : "";
|
||||
if (!sshKey) {
|
||||
if (errEl) { errEl.textContent = "Please paste the SSH public key provided by Sovran Systems support."; errEl.style.display = "block"; }
|
||||
return;
|
||||
}
|
||||
// Client-side pre-validation: key must start with a known algorithm prefix
|
||||
var validPrefixes = ["ssh-ed25519 ", "ecdsa-sha2-nistp256 ", "ecdsa-sha2-nistp384 ", "ecdsa-sha2-nistp521 ", "sk-ssh-ed25519@openssh.com "];
|
||||
var hasValidPrefix = validPrefixes.some(function(p) { return sshKey.startsWith(p); });
|
||||
if (!hasValidPrefix) {
|
||||
if (errEl) { errEl.textContent = "Invalid key format. The key must start with ssh-ed25519 or ecdsa-sha2-nistp256. Do not paste a private key."; errEl.style.display = "block"; }
|
||||
return;
|
||||
}
|
||||
if (sshKey.indexOf("\n") !== -1) {
|
||||
if (errEl) { errEl.textContent = "The key must be a single line. Please check the pasted value."; errEl.style.display = "block"; }
|
||||
return;
|
||||
}
|
||||
|
||||
if (btn) { btn.disabled = true; btn.textContent = "Enabling…"; }
|
||||
try {
|
||||
await apiFetch("/api/support/enable", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ssh_public_key: sshKey }),
|
||||
});
|
||||
await apiFetch("/api/support/enable", { method: "POST" });
|
||||
var status = await apiFetch("/api/support/status");
|
||||
_supportStatus = status;
|
||||
_supportEnabledAt = status.enabled_at;
|
||||
renderSupportActive(status);
|
||||
} catch (err) {
|
||||
if (btn) { btn.disabled = false; btn.textContent = "Enable Support Access"; }
|
||||
var detail = (err && err.detail) ? err.detail : "Failed to enable support access. Please check the key and try again.";
|
||||
if (errEl) { errEl.textContent = detail; errEl.style.display = "block"; }
|
||||
else { alert(detail); }
|
||||
alert("Failed to enable support access. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -522,27 +473,11 @@ function renderBackupReady(drives) {
|
||||
].join("");
|
||||
}
|
||||
|
||||
// ── Role-aware backup description ─────────────────────────────
|
||||
// Desktop Only systems run no server or Bitcoin services and have no
|
||||
// internal second drive, so the backup mirrors only the NixOS configuration
|
||||
// and home directory. nix-bitcoin secrets, /var/lib system service data,
|
||||
// and the database/blockchain caveats apply only to the Node and
|
||||
// Server + Desktop roles.
|
||||
var isDesktopOnly = (_currentRole === "desktop");
|
||||
$supportBody.innerHTML = [
|
||||
'<div class="support-section">',
|
||||
'<div class="support-icon-big">\ud83d\udcbe</div>',
|
||||
'<h3 class="support-heading">Manual Backup</h3>',
|
||||
|
||||
var introHtml;
|
||||
if (isDesktopOnly) {
|
||||
introHtml = [
|
||||
'<div class="support-wallet-box support-wallet-protected" style="margin-bottom:16px;">',
|
||||
'<p class="support-wallet-desc">',
|
||||
'This manual backup lets you create a copy of your system on an external USB drive \u2014 ',
|
||||
'storing your data in a second location, outside the computer, for maximum protection ',
|
||||
'against hardware failure or physical damage.',
|
||||
'</p>',
|
||||
'</div>',
|
||||
].join("");
|
||||
} else {
|
||||
introHtml = [
|
||||
'<div class="support-wallet-box support-wallet-protected" style="margin-bottom:16px;">',
|
||||
'<p class="support-wallet-desc">',
|
||||
'Your Sovran Pro already backs up your data automatically to its internal second drive. ',
|
||||
@@ -551,70 +486,32 @@ function renderBackupReady(drives) {
|
||||
'against hardware failure or physical damage.',
|
||||
'</p>',
|
||||
'</div>',
|
||||
].join("");
|
||||
}
|
||||
|
||||
var backupItemsHtml;
|
||||
if (isDesktopOnly) {
|
||||
backupItemsHtml = [
|
||||
'<li>NixOS configuration (<code>/etc/nixos</code>)</li>',
|
||||
'<li>Home directory (<code>/home</code>)</li>',
|
||||
].join("");
|
||||
} else {
|
||||
backupItemsHtml = [
|
||||
'<li>NixOS configuration (<code>/etc/nixos</code>)</li>',
|
||||
'<li>nix-bitcoin secrets (<code>/etc/nix-bitcoin-secrets</code>)</li>',
|
||||
'<li>System service data (<code>/var/lib</code>) — excluding databases and blockchain data (see note below)</li>',
|
||||
'<li>Home directory (<code>/home</code>)</li>',
|
||||
].join("");
|
||||
}
|
||||
|
||||
// The database/blockchain caveat is only relevant when server services exist.
|
||||
var dbNoteHtml = "";
|
||||
if (!isDesktopOnly) {
|
||||
dbNoteHtml = [
|
||||
'<div class="support-wallet-box support-wallet-warning">',
|
||||
'<div class="support-wallet-header">',
|
||||
'<span class="support-wallet-icon">\u2139\ufe0f</span>',
|
||||
'<span class="support-wallet-title">Database and Blockchain Data</span>',
|
||||
'</div>',
|
||||
'<p class="support-wallet-desc">Application databases stored in PostgreSQL or MariaDB/MySQL are <strong>not included</strong> in Manual Backup. Bitcoin blockchain and Electrs index data are also excluded (they are stored on the internal second drive). If you use Nextcloud, Matrix, or other database-backed applications, back up those databases separately with their native tools.</p>',
|
||||
'</div>',
|
||||
].join("");
|
||||
}
|
||||
|
||||
$supportBody.innerHTML = [
|
||||
'<div class="support-section">',
|
||||
'<div class="support-icon-big">\ud83d\udcbe</div>',
|
||||
'<h3 class="support-heading">Manual Backup</h3>',
|
||||
|
||||
introHtml,
|
||||
|
||||
'<div class="support-steps">',
|
||||
'<div class="support-steps-title">Requirements</div>',
|
||||
'<ol class="support-backup-steps">',
|
||||
'<li>USB hard drive plugged into one of the open USB ports on your Sovran Pro</li>',
|
||||
'<li>Enough free space for your data (the backup checks this before starting)</li>',
|
||||
'<li>Drive must be formatted as <strong>ext4</strong> (a Linux filesystem). Drives with exFAT, FAT32, or NTFS are not supported. To format a drive as ext4, use a Linux tool such as GParted or <code>mkfs.ext4</code> — note that formatting erases all data on the drive.</li>',
|
||||
'<li>The drive is intended for Linux/Sovran recovery. It may not be directly readable by Windows or macOS without additional software.</li>',
|
||||
'<li>At least 500 GB of free space on the drive</li>',
|
||||
'<li>Drive must be formatted as <strong>exFAT</strong></li>',
|
||||
'</ol>',
|
||||
'</div>',
|
||||
|
||||
'<div class="support-steps">',
|
||||
'<div class="support-steps-title">What gets backed up</div>',
|
||||
'<ol class="support-backup-steps">',
|
||||
backupItemsHtml,
|
||||
'<li>NixOS configuration (<code>/etc/nixos</code>)</li>',
|
||||
'<li>nix-bitcoin secrets (<code>/etc/nix-bitcoin-secrets</code>)</li>',
|
||||
'<li>System service data (<code>/var/lib</code>) including Vaultwarden, bitcoind, LND, sovran-hub, domains, and secrets</li>',
|
||||
'<li>Home directory (<code>/home</code>)</li>',
|
||||
'</ol>',
|
||||
'</div>',
|
||||
|
||||
dbNoteHtml,
|
||||
|
||||
'<div class="support-wallet-box support-wallet-protected">',
|
||||
'<div class="support-wallet-box support-wallet-warning">',
|
||||
'<div class="support-wallet-header">',
|
||||
'<span class="support-wallet-icon">\u23f1\ufe0f</span>',
|
||||
'<span class="support-wallet-title">Time Estimate</span>',
|
||||
'</div>',
|
||||
'<p class="support-wallet-desc">The first backup may take a while depending on how much data you have. Later backups are much faster because only changed or new files are copied. Files are stored directly on the drive and can be browsed without any special software.</p>',
|
||||
'<p class="support-wallet-desc">This backup can take <strong>up to 4 hours</strong> depending on the amount of data stored on your Sovran Pro and the speed of your external hard drive. Be patient\u2026</p>',
|
||||
'</div>',
|
||||
|
||||
driveSelector,
|
||||
@@ -687,10 +584,9 @@ async function pollBackupStatus() {
|
||||
logDiv.scrollTop = logDiv.scrollHeight;
|
||||
}
|
||||
_backupLogOffset = data.offset;
|
||||
const result = (data.result || "").toLowerCase();
|
||||
if (result === "success" || result === "failed") {
|
||||
if (!data.running) {
|
||||
stopBackupPoll();
|
||||
renderBackupDone(result === "success");
|
||||
renderBackupDone(data.result === "success");
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
@@ -722,7 +618,7 @@ function renderBackupDone(success) {
|
||||
'<div class="support-section">',
|
||||
'<div class="support-icon-big">\u26a0\ufe0f</div>',
|
||||
'<h3 class="support-heading">Backup Failed</h3>',
|
||||
'<p class="support-desc">The backup did not complete successfully. Please check that the USB drive is still connected, has enough free space, and is formatted as ext4. Then try again.</p>',
|
||||
'<p class="support-desc">The backup did not complete successfully. Please check that the USB drive is still connected, has enough free space, and is formatted as exFAT. Then try again.</p>',
|
||||
'<div class="modal-log" id="backup-log-fail" style="text-align:left;"></div>',
|
||||
'<button class="btn support-btn-done" id="btn-backup-close">Close</button>',
|
||||
'</div>',
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
// Keyed by tileId: { progress: float, timestamp: ms }
|
||||
var _btcSyncPrev = {};
|
||||
|
||||
// ── BIP-110 badge helper ──────────────────────────────────────────
|
||||
|
||||
function _renderBip110Badge(bip110) {
|
||||
if (!bip110) return '';
|
||||
var state = bip110.state || 'unknown';
|
||||
var cfg = BIP110_BADGE_CONFIG[state] || BIP110_BADGE_CONFIG.unknown;
|
||||
return '<div class="tile-bip110-badge ' + cfg.cls + '" title="' + escHtml(cfg.title) + '">' + escHtml(cfg.label) + '</div>';
|
||||
}
|
||||
|
||||
function _firstElementFromHtml(html) {
|
||||
var tmp = document.createElement("div");
|
||||
@@ -25,7 +33,6 @@ function buildTiles(services, categoryLabels) {
|
||||
continue;
|
||||
}
|
||||
var cat = svc.category || "other";
|
||||
if (CATEGORY_ALIASES[cat]) cat = CATEGORY_ALIASES[cat];
|
||||
if (!grouped[cat]) grouped[cat] = [];
|
||||
grouped[cat].push(svc);
|
||||
}
|
||||
@@ -53,7 +60,6 @@ function buildTiles(services, categoryLabels) {
|
||||
if ($tilesArea.children.length === 0) {
|
||||
$tilesArea.innerHTML = '<div class="empty-state"><p>No services configured.</p></div>';
|
||||
}
|
||||
if (typeof window.dashboardServicesUpdated === "function") window.dashboardServicesUpdated();
|
||||
}
|
||||
|
||||
function renderSidebarSupport(supportServices) {
|
||||
@@ -64,22 +70,20 @@ function renderSidebarSupport(supportServices) {
|
||||
sidebarUpdateBtn.className = "sidebar-support-btn";
|
||||
sidebarUpdateBtn.id = "sidebar-btn-update";
|
||||
sidebarUpdateBtn.innerHTML =
|
||||
'<span class="sidebar-support-icon"><svg><use href="#g-update"/></svg></span>' +
|
||||
'<img class="sidebar-support-icon" src="/static/icons/update.svg" alt="Update" style="width:1.5rem;height:1.5rem;">' +
|
||||
'<span class="sidebar-support-text">' +
|
||||
'<span class="sidebar-support-title">Update System</span>' +
|
||||
'<span class="sidebar-support-hint" id="sidebar-update-hint">Check for updates</span>' +
|
||||
'</span>';
|
||||
sidebarUpdateBtn.addEventListener("click", function() { openUpdateModal(); });
|
||||
$sidebarSupport.appendChild(sidebarUpdateBtn);
|
||||
// checkUpdates may already have run before the sidebar was built
|
||||
applyUpdateSidebarState(window._lastUpdateCheck);
|
||||
|
||||
for (var i = 0; i < supportServices.length; i++) {
|
||||
var svc = supportServices[i];
|
||||
var btn = document.createElement("button");
|
||||
btn.className = "sidebar-support-btn";
|
||||
btn.innerHTML =
|
||||
'<span class="sidebar-support-icon"><svg><use href="#g-lifebuoy"/></svg></span>' +
|
||||
'<span class="sidebar-support-icon">🛟</span>' +
|
||||
'<span class="sidebar-support-text">' +
|
||||
'<span class="sidebar-support-title">' + escHtml(svc.name || "Tech Support") + '</span>' +
|
||||
'<span class="sidebar-support-hint">Click for help</span>' +
|
||||
@@ -92,7 +96,7 @@ function renderSidebarSupport(supportServices) {
|
||||
var backupBtn = document.createElement("button");
|
||||
backupBtn.className = "sidebar-support-btn";
|
||||
backupBtn.innerHTML =
|
||||
'<span class="sidebar-support-icon"><svg><use href="#g-box"/></svg></span>' +
|
||||
'<span class="sidebar-support-icon">💾</span>' +
|
||||
'<span class="sidebar-support-text">' +
|
||||
'<span class="sidebar-support-title">Manual Backup</span>' +
|
||||
'<span class="sidebar-support-hint">Back up to external drive</span>' +
|
||||
@@ -104,7 +108,7 @@ function renderSidebarSupport(supportServices) {
|
||||
var securityBtn = document.createElement("button");
|
||||
securityBtn.className = "sidebar-support-btn";
|
||||
securityBtn.innerHTML =
|
||||
'<span class="sidebar-support-icon"><svg><use href="#g-shield-check"/></svg></span>' +
|
||||
'<span class="sidebar-support-icon">\uD83D\uDEE1</span>' +
|
||||
'<span class="sidebar-support-text">' +
|
||||
'<span class="sidebar-support-title">Security</span>' +
|
||||
'<span class="sidebar-support-hint">Reset & verify system</span>' +
|
||||
@@ -117,7 +121,7 @@ function renderSidebarSupport(supportServices) {
|
||||
var upgradeBtn = document.createElement("button");
|
||||
upgradeBtn.className = "sidebar-support-btn";
|
||||
upgradeBtn.innerHTML =
|
||||
'<span class="sidebar-support-icon"><svg><use href="#g-antenna"/></svg></span>' +
|
||||
'<span class="sidebar-support-icon">🚀</span>' +
|
||||
'<span class="sidebar-support-text">' +
|
||||
'<span class="sidebar-support-title">Upgrade to Full Server</span>' +
|
||||
'<span class="sidebar-support-hint">Unlock all services</span>' +
|
||||
@@ -152,10 +156,13 @@ function buildTile(svc) {
|
||||
var pct = Math.round((svc.sync_progress || 0) * 100);
|
||||
var id = tileId(svc);
|
||||
var eta = _calcBtcEta(id, svc.sync_progress || 0);
|
||||
var ver = svc.version || svc.bitcoin_version || '';
|
||||
var versionLabel = ver ? '<div class="tile-version">' + escHtml(ver) + '</div>' : '';
|
||||
tile.innerHTML =
|
||||
'<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'">' +
|
||||
'<div class="tile-icon-fallback" style="display:none">?</div>' +
|
||||
'<div class="tile-name">' + escHtml(svc.name) + '</div>' +
|
||||
versionLabel +
|
||||
'<div class="tile-sync-container">' +
|
||||
'<div class="tile-sync-label">\u23F3 Syncing Timechain</div>' +
|
||||
'<div class="tile-sync-bar-row">' +
|
||||
@@ -171,7 +178,10 @@ function buildTile(svc) {
|
||||
return tile;
|
||||
}
|
||||
|
||||
tile.innerHTML = '<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'"><div class="tile-icon-fallback" style="display:none">?</div><div class="tile-name">' + escHtml(svc.name) + '</div><div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></div>';
|
||||
var ver = svc.version || svc.bitcoin_version || '';
|
||||
var versionLabel = ver ? '<div class="tile-version">' + escHtml(ver) + '</div>' : '';
|
||||
var bip110Badge = (svc.icon === 'bip110') ? _renderBip110Badge(svc.bip110) : '';
|
||||
tile.innerHTML = '<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'"><div class="tile-icon-fallback" style="display:none">?</div><div class="tile-name">' + escHtml(svc.name) + '</div>' + versionLabel + bip110Badge + '<div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></div>';
|
||||
|
||||
tile.style.cursor = "pointer";
|
||||
tile.addEventListener("click", function() {
|
||||
@@ -225,6 +235,22 @@ function updateTiles(services) {
|
||||
if (fill) fill.style.width = pct + "%";
|
||||
if (pctEl) pctEl.textContent = pct + "%";
|
||||
if (etaEl) etaEl.textContent = etaText;
|
||||
// Update or insert version label
|
||||
var syncVer = svc.version || svc.bitcoin_version || '';
|
||||
if (syncVer) {
|
||||
var syncVerEl = tile.querySelector(".tile-version");
|
||||
if (syncVerEl) {
|
||||
syncVerEl.textContent = syncVer;
|
||||
} else {
|
||||
var syncNameEl = tile.querySelector(".tile-name");
|
||||
if (syncNameEl) {
|
||||
var newSyncVerEl = document.createElement("div");
|
||||
newSyncVerEl.className = "tile-version";
|
||||
newSyncVerEl.textContent = syncVer;
|
||||
syncNameEl.insertAdjacentElement("afterend", newSyncVerEl);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// IBD finished or not syncing — if tile had sync layout rebuild it normally
|
||||
if (tile.querySelector(".tile-sync-container")) {
|
||||
@@ -239,9 +265,41 @@ function updateTiles(services) {
|
||||
var text = tile.querySelector(".status-text");
|
||||
if (dot) dot.className = "status-dot " + sc;
|
||||
if (text) text.textContent = st;
|
||||
// Update or insert version label for all service tiles
|
||||
var tileVer = svc.version || svc.bitcoin_version || '';
|
||||
if (tileVer) {
|
||||
var verEl = tile.querySelector(".tile-version");
|
||||
if (verEl) {
|
||||
verEl.textContent = tileVer;
|
||||
} else {
|
||||
var nameEl = tile.querySelector(".tile-name");
|
||||
if (nameEl) {
|
||||
var newVerEl = document.createElement("div");
|
||||
newVerEl.className = "tile-version";
|
||||
newVerEl.textContent = tileVer;
|
||||
nameEl.insertAdjacentElement("afterend", newVerEl);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Update BIP-110 badge for bip110 tiles
|
||||
if (svc.icon === 'bip110') {
|
||||
var badgeHtml = _renderBip110Badge(svc.bip110);
|
||||
var badgeEl = tile.querySelector(".tile-bip110-badge");
|
||||
if (badgeEl) {
|
||||
// Replace existing badge in-place
|
||||
var newBadge = _firstElementFromHtml(badgeHtml);
|
||||
if (newBadge) { badgeEl.replaceWith(newBadge); } else { badgeEl.remove(); }
|
||||
} else if (badgeHtml) {
|
||||
// Insert badge after version label (or after tile-name if no version)
|
||||
var anchorEl = tile.querySelector(".tile-version") || tile.querySelector(".tile-name");
|
||||
if (anchorEl) {
|
||||
var newBadgeEl = _firstElementFromHtml(badgeHtml);
|
||||
if (newBadgeEl) anchorEl.insertAdjacentElement("afterend", newBadgeEl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof window.dashboardServicesUpdated === "function") window.dashboardServicesUpdated();
|
||||
}
|
||||
|
||||
// ── Service polling ───────────────────────────────────────────────
|
||||
@@ -253,10 +311,6 @@ async function refreshServices() {
|
||||
var services = await apiFetch("/api/services");
|
||||
if (_firstLoad) { buildTiles(services, _categoryLabels); _firstLoad = false; }
|
||||
else { updateTiles(services); }
|
||||
// Service data has rendered — the dashboard is ready; lift the boot
|
||||
// splash (no-op once lifted). Note: dashboardServicesUpdated may also
|
||||
// fire from checkUpdates before this resolves, so the lift lives here.
|
||||
if (typeof window.__liftAppSplash === "function") window.__liftAppSplash();
|
||||
} catch (err) { console.warn("Failed to fetch services:", err); }
|
||||
}
|
||||
|
||||
@@ -276,33 +330,16 @@ async function loadNetwork() {
|
||||
|
||||
// ── Update check ──────────────────────────────────────────────────
|
||||
|
||||
// Paint the sidebar Update button from the last known update state.
|
||||
// Called after each check AND when the button is (re)built, so the hint
|
||||
// is correct regardless of which finishes first at startup.
|
||||
function applyUpdateSidebarState(data) {
|
||||
if (!data) return;
|
||||
async function checkUpdates() {
|
||||
try {
|
||||
var data = await apiFetch("/api/updates/check");
|
||||
var hasUpdates = !!data.available;
|
||||
var sidebarUpdateBtn = document.getElementById("sidebar-btn-update");
|
||||
var sidebarUpdateHint = document.getElementById("sidebar-update-hint");
|
||||
if (!sidebarUpdateBtn) return;
|
||||
var hasUpdates = !!data.available;
|
||||
var updateStatus = data.status || "idle";
|
||||
if (updateStatus === "failed") {
|
||||
// Last update errored and did not apply — surface it as a persistent
|
||||
// red banner that re-opens the failed run with a "Retry Update" action.
|
||||
sidebarUpdateBtn.style.borderColor = "#f66151";
|
||||
sidebarUpdateBtn.style.backgroundColor = "rgba(246, 97, 81, 0.10)";
|
||||
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Update failed — click to retry";
|
||||
} else if (updateStatus === "reboot_required") {
|
||||
sidebarUpdateBtn.style.borderColor = "#e9b64a";
|
||||
sidebarUpdateBtn.style.backgroundColor = "rgba(233, 182, 74, 0.10)";
|
||||
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Restart required";
|
||||
} else if (updateStatus === "running") {
|
||||
sidebarUpdateBtn.style.borderColor = "#78aeed";
|
||||
sidebarUpdateBtn.style.backgroundColor = "rgba(120, 174, 237, 0.10)";
|
||||
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Update in progress…";
|
||||
} else if (hasUpdates) {
|
||||
sidebarUpdateBtn.style.borderColor = "#3ecf8e";
|
||||
sidebarUpdateBtn.style.backgroundColor = "rgba(62, 207, 142, 0.10)";
|
||||
if (sidebarUpdateBtn) {
|
||||
if (hasUpdates) {
|
||||
sidebarUpdateBtn.style.borderColor = "#2ec27e";
|
||||
sidebarUpdateBtn.style.backgroundColor = "rgba(46, 194, 126, 0.08)";
|
||||
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Updates available!";
|
||||
} else {
|
||||
sidebarUpdateBtn.style.borderColor = "";
|
||||
@@ -310,14 +347,5 @@ function applyUpdateSidebarState(data) {
|
||||
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 (_) {}
|
||||
}
|
||||
@@ -2,129 +2,20 @@
|
||||
|
||||
// ── Update modal ──────────────────────────────────────────────────
|
||||
|
||||
// ── Update dialog presentation (pill, status line, last-checked) ──
|
||||
|
||||
var _updateLastCheckTs = 0;
|
||||
|
||||
function markUpdateChecked() {
|
||||
_updateLastCheckTs = Date.now();
|
||||
}
|
||||
|
||||
function _updateLastCheckedText() {
|
||||
if (!$updLastChecked) return;
|
||||
if (!_updateLastCheckTs) { $updLastChecked.textContent = "—"; return; }
|
||||
var s = Math.floor((Date.now() - _updateLastCheckTs) / 1000);
|
||||
if (s < 30) { $updLastChecked.textContent = "just now"; return; }
|
||||
var m = Math.floor(s / 60);
|
||||
if (m < 60) { $updLastChecked.textContent = m + (m === 1 ? " minute ago" : " minutes ago"); return; }
|
||||
var h = Math.floor(m / 60);
|
||||
$updLastChecked.textContent = h + (h === 1 ? " hour ago" : " hours ago");
|
||||
}
|
||||
|
||||
function setUpdatePill(state) {
|
||||
if (!$updPill) return;
|
||||
var cls = "upd-pill";
|
||||
var html;
|
||||
if (state === "checking") {
|
||||
cls += " st-loading"; html = '<span class="status-dot loading pulse"></span>Checking…';
|
||||
} else if (state === "uptodate") {
|
||||
cls += " st-active"; html = '<span class="status-dot active"></span>Up to date';
|
||||
} else if (state === "complete") {
|
||||
cls += " st-active"; html = '<span class="status-dot active"></span>Update complete';
|
||||
} else if (state === "reboot") {
|
||||
cls += " st-needs-attention"; html = '<span class="status-dot needs-attention pulse"></span>Restart required';
|
||||
} else if (state === "failed") {
|
||||
cls += " st-failed"; html = '<span class="status-dot failed"></span>Update failed';
|
||||
} else if (state === "unavailable") {
|
||||
cls += " st-needs-attention"; html = '<span class="status-dot needs-attention pulse"></span>Status unknown';
|
||||
} else {
|
||||
cls += " st-loading"; html = '<span class="status-dot loading pulse"></span>Updating…';
|
||||
}
|
||||
$updPill.className = cls;
|
||||
$updPill.innerHTML = html;
|
||||
}
|
||||
|
||||
// Single place that reflects update state in the dialog: status message
|
||||
// text + color, header pill, and the Close / Check again availability.
|
||||
function _setUpdateStatus(text) {
|
||||
if ($modalStatus) $modalStatus.textContent = text;
|
||||
var state;
|
||||
if (text.charAt(0) === "✗") state = "failed";
|
||||
else if (text.indexOf("restart required") !== -1) state = "reboot";
|
||||
else if (text.charAt(0) === "✓") state = (text.indexOf("already up to date") !== -1) ? "uptodate" : "complete";
|
||||
else if (text.indexOf("unavailable") !== -1) state = "unavailable";
|
||||
else if (text.indexOf("Checking") === 0) state = "checking";
|
||||
else state = "updating";
|
||||
if ($modalStatus) {
|
||||
var msg = "update-status-msg";
|
||||
if (state === "failed") $modalStatus.className = msg + " st-err";
|
||||
else if (state === "reboot" || state === "unavailable") $modalStatus.className = msg + " st-warn";
|
||||
else if (state === "uptodate" || state === "complete") $modalStatus.className = msg + " st-ok";
|
||||
else $modalStatus.className = msg;
|
||||
// In the up-to-date state the green console line already says it —
|
||||
// the mockup shows it exactly once.
|
||||
$modalStatus.style.display = (state === "uptodate") ? "none" : "";
|
||||
}
|
||||
setUpdatePill(state);
|
||||
var interactive = (state !== "updating");
|
||||
if ($updateCloseBtn) $updateCloseBtn.disabled = !interactive;
|
||||
if ($btnCheckAgain) {
|
||||
$btnCheckAgain.disabled = (state === "checking" || state === "updating");
|
||||
$btnCheckAgain.style.display = interactive ? "inline-flex" : "none";
|
||||
}
|
||||
_updateLastCheckedText();
|
||||
}
|
||||
|
||||
async function openUpdateModal() {
|
||||
function openUpdateModal() {
|
||||
if (!$modal) return;
|
||||
|
||||
// Open immediately in a checking state; the status/check requests below
|
||||
// fill in the real result (mockup: auto-check on open).
|
||||
$modal.classList.add("open");
|
||||
if ($modalLog) $modalLog.innerHTML = '<span class="dim">Checking for updates…</span>';
|
||||
_setUpdateStatus("Checking for updates…");
|
||||
|
||||
// Reattach before checking for new updates. This makes a browser reload,
|
||||
// RDP reconnect, or suspended tab recover the authoritative systemd-backed
|
||||
// state instead of starting over or claiming the system is merely up to date.
|
||||
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
|
||||
)
|
||||
apiFetch("/api/updates/check")
|
||||
.then(function(data) {
|
||||
markUpdateChecked();
|
||||
if (!data.available) {
|
||||
stopUpdatePoll();
|
||||
_updateLog = "";
|
||||
_updateLogOffset = 0;
|
||||
_updateVisibleLogChars = 0;
|
||||
_updateFinished = true;
|
||||
_updateStatusUnavailable = false;
|
||||
if ($modalLog) $modalLog.innerHTML = '<span class="ok">✓ System is already up to date</span>';
|
||||
_setUpdateStatus("✓ System is already up to date");
|
||||
if ($modalLog) $modalLog.textContent = "";
|
||||
if ($modalStatus) $modalStatus.textContent = "✓ System is already up to date";
|
||||
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
||||
if ($btnReboot) $btnReboot.style.display = "none";
|
||||
if ($btnSave) $btnSave.style.display = "none";
|
||||
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
||||
if ($btnRetryRun) $btnRetryRun.style.display = "none";
|
||||
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
||||
$modal.classList.add("open");
|
||||
return;
|
||||
@@ -136,70 +27,22 @@ async function openUpdateModal() {
|
||||
});
|
||||
}
|
||||
|
||||
function prepareUpdateModal() {
|
||||
function _doOpenUpdateModal() {
|
||||
if (!$modal) return;
|
||||
stopUpdatePoll();
|
||||
_updateLog = "";
|
||||
_updateLogOffset = 0;
|
||||
_updateVisibleLogChars = 0;
|
||||
_updatePollInFlight = false;
|
||||
_serverWasDown = false;
|
||||
_updateFinished = false;
|
||||
_updateStatusUnavailable = false;
|
||||
_updatePollFailures = 0;
|
||||
if ($modalLog) $modalLog.textContent = "";
|
||||
_setUpdateStatus("Starting update…");
|
||||
if ($modalStatus) $modalStatus.textContent = "Starting update…";
|
||||
if ($modalSpinner) $modalSpinner.classList.add("spinning");
|
||||
if ($btnReboot) $btnReboot.style.display = "none";
|
||||
if ($btnSave) $btnSave.style.display = "none";
|
||||
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
||||
if ($btnRetryRun) $btnRetryRun.style.display = "none";
|
||||
if ($btnCloseModal) $btnCloseModal.disabled = true;
|
||||
$modal.classList.add("open");
|
||||
}
|
||||
|
||||
function _doOpenUpdateModal() {
|
||||
prepareUpdateModal();
|
||||
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() {
|
||||
if (!$modal) return;
|
||||
$modal.classList.remove("open");
|
||||
@@ -209,44 +52,27 @@ function closeUpdateModal() {
|
||||
function appendLog(text) {
|
||||
if (!text) return;
|
||||
_updateLog += text;
|
||||
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;
|
||||
}
|
||||
if ($modalLog) { $modalLog.textContent += text; $modalLog.scrollTop = $modalLog.scrollHeight; }
|
||||
}
|
||||
|
||||
function startUpdate() {
|
||||
apiFetchWithTimeout(
|
||||
"/api/updates/run",
|
||||
{ method: "POST" },
|
||||
STATUS_POLL_FETCH_TIMEOUT * 2
|
||||
)
|
||||
fetch("/api/updates/run", { method: "POST" })
|
||||
.then(function(response) {
|
||||
if (!response.ok) return response.text().then(function(t) { throw new Error(t); });
|
||||
return response.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (data.status === "no_updates") {
|
||||
if ($modalLog) $modalLog.innerHTML = '<span class="ok">✓ System is already up to date</span>';
|
||||
_setUpdateStatus("✓ System is already up to date");
|
||||
if ($modalStatus) $modalStatus.textContent = "✓ System is already up to date";
|
||||
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
||||
if ($btnReboot) $btnReboot.style.display = "none";
|
||||
if ($btnSave) $btnSave.style.display = "none";
|
||||
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
||||
if ($btnRetryRun) $btnRetryRun.style.display = "none";
|
||||
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
||||
_updateFinished = true;
|
||||
return;
|
||||
}
|
||||
if (data.status === "already_running") appendLog("[Update already in progress, attaching…]\n\n");
|
||||
_setUpdateStatus("Updating…");
|
||||
if ($modalStatus) $modalStatus.textContent = "Updating…";
|
||||
startUpdatePoll();
|
||||
})
|
||||
.catch(function(err) {
|
||||
@@ -256,7 +82,6 @@ function startUpdate() {
|
||||
}
|
||||
|
||||
function startUpdatePoll() {
|
||||
if (_updatePollTimer) clearInterval(_updatePollTimer);
|
||||
pollUpdateStatus();
|
||||
_updatePollTimer = setInterval(pollUpdateStatus, UPDATE_POLL_INTERVAL);
|
||||
}
|
||||
@@ -266,45 +91,32 @@ function stopUpdatePoll() {
|
||||
}
|
||||
|
||||
async function pollUpdateStatus() {
|
||||
// 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;
|
||||
if (_updateFinished) return;
|
||||
try {
|
||||
var data = await apiFetchWithTimeout(
|
||||
"/api/updates/status?offset=" + _updateLogOffset,
|
||||
{ cache: "no-store" },
|
||||
STATUS_POLL_FETCH_TIMEOUT
|
||||
);
|
||||
_updatePollFailures = 0;
|
||||
var data = await apiFetch("/api/updates/status?offset=" + _updateLogOffset);
|
||||
if (_serverWasDown) {
|
||||
_serverWasDown = false;
|
||||
if (!data.running) {
|
||||
// The update finished while the server or browser connection was away.
|
||||
// Re-fetch from offset 0 so the final result and complete tail agree.
|
||||
// The update finished while the server was restarting. Reset to
|
||||
// offset 0 and re-fetch so the complete log is shown from the top.
|
||||
_updateLog = "";
|
||||
_updateLogOffset = 0;
|
||||
_updateVisibleLogChars = 0;
|
||||
if ($modalLog) $modalLog.textContent = "";
|
||||
try {
|
||||
var fullData = await apiFetchWithTimeout(
|
||||
"/api/updates/status?offset=0",
|
||||
{ cache: "no-store" },
|
||||
STATUS_POLL_FETCH_TIMEOUT
|
||||
);
|
||||
var fullData = await apiFetch("/api/updates/status?offset=0");
|
||||
if (fullData.log) appendLog(fullData.log);
|
||||
_updateLogOffset = fullData.offset;
|
||||
data = fullData;
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
// If the re-fetch fails, fall through with whatever we have.
|
||||
if (data.log) appendLog(data.log);
|
||||
_updateLogOffset = data.offset;
|
||||
}
|
||||
if (data.result === "reboot_required") {
|
||||
appendLog("[Reconnected — update completed, reboot required.]\n");
|
||||
appendLog("[Server restarted — update completed, reboot required.]\n");
|
||||
} else if (data.result === "success") {
|
||||
appendLog("[Reconnected — update completed successfully.]\n");
|
||||
appendLog("[Server restarted — update completed successfully.]\n");
|
||||
} else {
|
||||
appendLog("[Reconnected — update encountered an error.]\n");
|
||||
appendLog("[Server restarted — update encountered an error.]\n");
|
||||
}
|
||||
_updateFinished = true;
|
||||
stopUpdatePoll();
|
||||
@@ -315,8 +127,8 @@ async function pollUpdateStatus() {
|
||||
}
|
||||
return;
|
||||
}
|
||||
appendLog("[Update status reconnected]\n");
|
||||
_setUpdateStatus("Updating…");
|
||||
appendLog("[Server reconnected]\n");
|
||||
if ($modalStatus) $modalStatus.textContent = "Updating…";
|
||||
}
|
||||
if (data.log) appendLog(data.log);
|
||||
_updateLogOffset = data.offset;
|
||||
@@ -331,80 +143,23 @@ async function pollUpdateStatus() {
|
||||
onUpdateDone(false);
|
||||
}
|
||||
} catch (err) {
|
||||
_updatePollFailures += 1;
|
||||
if (_updatePollFailures >= STATUS_POLL_MAX_FAILURES) {
|
||||
showUpdateStatusUnavailable();
|
||||
return;
|
||||
}
|
||||
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();
|
||||
if (!_serverWasDown) { _serverWasDown = true; appendLog("\n[Server restarting — waiting for it to come back…]\n"); if ($modalStatus) $modalStatus.textContent = "Server restarting…"; }
|
||||
}
|
||||
}
|
||||
|
||||
function onUpdateDone(result) {
|
||||
_updateStatusUnavailable = false;
|
||||
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
||||
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
||||
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
||||
if (result === true) {
|
||||
_setUpdateStatus("✓ Update complete");
|
||||
if ($modalStatus) $modalStatus.textContent = "✓ Update complete";
|
||||
if ($btnReboot) $btnReboot.style.display = "inline-flex";
|
||||
} else if (result === "reboot_required") {
|
||||
_setUpdateStatus("✓ Update complete — restart required");
|
||||
if ($modalStatus) $modalStatus.textContent = "✓ Update complete — reboot required";
|
||||
if ($btnReboot) $btnReboot.style.display = "inline-flex";
|
||||
} else {
|
||||
_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 ($modalStatus) $modalStatus.textContent = "✗ Update failed";
|
||||
if ($btnSave) $btnSave.style.display = "inline-flex";
|
||||
if ($btnReboot) $btnReboot.style.display = "none";
|
||||
if ($btnReboot) $btnReboot.style.display = "inline-flex";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,55 +175,27 @@ function saveErrorReport() {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
|
||||
// ── Reboot ────────────────────────────────────────────────────────
|
||||
|
||||
var _rebootStartTime = 0;
|
||||
var _serverWentDown = false;
|
||||
var _rebootFailed = false;
|
||||
|
||||
function _setRebootStatus(msg) {
|
||||
if ($rebootSubmessage) $rebootSubmessage.textContent = msg;
|
||||
}
|
||||
|
||||
function doReboot() {
|
||||
if ($modal) $modal.classList.remove("open");
|
||||
if ($rebuildModal) $rebuildModal.classList.remove("open");
|
||||
stopUpdatePoll();
|
||||
stopRebuildPoll();
|
||||
// Reset overlay to main card
|
||||
if ($rebootMainCard) $rebootMainCard.style.display = "";
|
||||
if ($rebootErrorCard) $rebootErrorCard.style.display = "none";
|
||||
_setRebootStatus("Sending restart request\u2026");
|
||||
if ($rebootOverlay) $rebootOverlay.classList.add("visible");
|
||||
_rebootStartTime = Date.now();
|
||||
_serverWentDown = false;
|
||||
_rebootFailed = false;
|
||||
var rebootCtrl = new AbortController();
|
||||
setTimeout(function() { rebootCtrl.abort(); }, REBOOT_REQUEST_TIMEOUT);
|
||||
fetch("/api/reboot", { method: "POST", signal: rebootCtrl.signal })
|
||||
.then(function(res) {
|
||||
if (!res.ok) {
|
||||
// Definitive HTTP error — server rejected the request before going down
|
||||
_rebootFailed = true;
|
||||
if ($rebootMainCard) $rebootMainCard.style.display = "none";
|
||||
if ($rebootErrorCard) $rebootErrorCard.style.display = "";
|
||||
// Leave overlay visible so the error card is shown
|
||||
}
|
||||
// HTTP 2xx: request accepted, proceed with polling
|
||||
})
|
||||
.catch(function() {
|
||||
// Connection dropped or request aborted — the server is likely already going
|
||||
// down as part of the restart. Treat as success and continue polling.
|
||||
});
|
||||
fetch("/api/reboot", { method: "POST", signal: rebootCtrl.signal }).catch(function() {});
|
||||
// Wait before the first check — NixOS shutdown after an update can take 20-40s
|
||||
setTimeout(waitForServerReboot, REBOOT_INITIAL_DELAY);
|
||||
}
|
||||
|
||||
function waitForServerReboot() {
|
||||
if (_rebootFailed) return;
|
||||
// Update status on first check (server hasn't gone down yet)
|
||||
if (!_serverWentDown) _setRebootStatus("Waiting for the computer to shut down\u2026");
|
||||
var controller = new AbortController();
|
||||
var timeoutId = setTimeout(function() { controller.abort(); }, REBOOT_FETCH_TIMEOUT);
|
||||
|
||||
@@ -478,23 +205,18 @@ function waitForServerReboot() {
|
||||
if (_serverWentDown) {
|
||||
// Server is responding after having been down — reboot is complete.
|
||||
// Any response (even 401/500) means the server process is back.
|
||||
_setRebootStatus("System is back online. Reconnecting\u2026");
|
||||
window.location.reload();
|
||||
} else if ((Date.now() - _rebootStartTime) < 90000) {
|
||||
// Server still responding but hasn't gone down yet — keep waiting
|
||||
setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
|
||||
} else {
|
||||
// Been over 90 seconds and server is responding — just reload
|
||||
_setRebootStatus("System is back online. Reconnecting\u2026");
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
clearTimeout(timeoutId);
|
||||
if (!_serverWentDown) {
|
||||
_serverWentDown = true;
|
||||
_setRebootStatus("The computer is restarting\u2026");
|
||||
}
|
||||
setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
|
||||
});
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
/* Sovran_SystemsOS Hub — First-Boot Onboarding Wizard
|
||||
Drives the 4-step post-install setup flow. */
|
||||
Drives the 5-step post-install setup flow. */
|
||||
"use strict";
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────
|
||||
|
||||
const TOTAL_STEPS = 4;
|
||||
const TOTAL_STEPS = 5;
|
||||
|
||||
// Steps to skip per role (step 3 involves domain setup)
|
||||
// Steps to skip per role (steps 3 and 4 involve domain/port setup)
|
||||
// Step 2 (timezone/locale) is NEVER skipped — all roles need it.
|
||||
const ROLE_SKIP_STEPS = {
|
||||
"desktop": [3],
|
||||
"node": [3],
|
||||
"desktop": [3, 4],
|
||||
"node": [3, 4],
|
||||
};
|
||||
|
||||
// ── Role state (loaded at init) ───────────────────────────────────
|
||||
@@ -66,7 +66,7 @@ function setStatus(elId, msg, type) {
|
||||
el.className = "onboarding-save-status" + (type ? " onboarding-save-status--" + type : "");
|
||||
}
|
||||
|
||||
function updateCompleteChecklist() {
|
||||
function updateStep5Checklist() {
|
||||
var checklist = document.getElementById("onboarding-checklist");
|
||||
if (!checklist) return;
|
||||
var existing = document.getElementById("onboarding-migration-check");
|
||||
@@ -135,7 +135,8 @@ function showStep(step) {
|
||||
// Lazy-load step content
|
||||
if (step === 2) loadStep2();
|
||||
if (step === 3) loadStep3();
|
||||
// Step 4 (Complete) is static — no lazy-load needed
|
||||
if (step === 4) loadStep4();
|
||||
// Step 5 (Complete) is static — no lazy-load needed
|
||||
}
|
||||
|
||||
// Return the next step number, skipping over role-excluded steps
|
||||
@@ -318,26 +319,21 @@ async function loadStep3() {
|
||||
if (!body) return;
|
||||
|
||||
try {
|
||||
// Fetch services and domains in parallel (network info is best-effort below)
|
||||
// Fetch services, domains, and network info in parallel
|
||||
var results = await Promise.all([
|
||||
apiFetch("/api/services"),
|
||||
apiFetch("/api/domains/status"),
|
||||
apiFetch("/api/network"),
|
||||
]);
|
||||
_servicesData = results[0];
|
||||
_domainsData = results[1];
|
||||
var networkData = results[2];
|
||||
} catch (err) {
|
||||
body.innerHTML = '<p class="onboarding-error">⚠ Could not load service data: ' + escHtml(err.message) + '</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Best-effort internal IP for the router note — never blocks this step
|
||||
var internalIp = "";
|
||||
try {
|
||||
var networkData = await apiFetch("/api/network");
|
||||
if (networkData && networkData.internal_ip && networkData.internal_ip !== "unavailable") {
|
||||
internalIp = String(networkData.internal_ip).trim();
|
||||
}
|
||||
} catch (_) {}
|
||||
var externalIp = (networkData && networkData.external_ip) || "Unknown (could not retrieve)";
|
||||
|
||||
// Build set of enabled service units
|
||||
var enabledUnits = new Set();
|
||||
@@ -350,47 +346,43 @@ async function loadStep3() {
|
||||
return enabledUnits.has(d.unit);
|
||||
});
|
||||
|
||||
var domainValues = (_domainsData && _domainsData.domains) || {};
|
||||
|
||||
var html = "";
|
||||
|
||||
if (relevantDomains.length === 0) {
|
||||
html += '<p class="onboarding-body-text">No domain-based services are enabled for your role. You can skip this step.</p>';
|
||||
} else {
|
||||
// Shared instructions (single source of truth: static/js/domain-prereqs.js) —
|
||||
// identical wording to the feature-enable domain modal shown for NWC,
|
||||
// BTCPay Server, and every other domain-based feature, in every role.
|
||||
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
|
||||
+ renderDomainNeedsHtml({ serviceName: null, hostExample: "call" })
|
||||
+ renderNjallaStepsHtml({ hostExample: "call", pasteHint: "next to its service below" })
|
||||
+ '<strong>Before you continue:</strong>'
|
||||
+ '<ol style="margin:8px 0 0 16px; padding:0; line-height:1.7;">'
|
||||
+ '<li>Create an account at <a href="https://njal.la" target="_blank" style="color:var(--accent-color);">https://njal.la</a></li>'
|
||||
+ '<li>Purchase a new domain on Njal.la, or create a subdomain from a domain you already own. Tip: Subdomains are free to create — you only need to purchase one domain, and you can add as many subdomains as you need at no extra cost.</li>'
|
||||
+ '<li>In the Njal.la web interface, create a <strong>Dynamic</strong> record pointing to this machine\'s external IP address:<br>'
|
||||
+ '<span style="display:inline-block;margin-top:4px;padding:4px 12px;background:var(--card-color);border:1px solid var(--border-color);border-radius:6px;font-family:monospace;font-size:1.1em;font-weight:700;letter-spacing:0.03em;">' + escHtml(externalIp) + '</span></li>'
|
||||
+ '<li>Njal.la will give you a curl command like:<br>'
|
||||
+ '<code style="font-size:0.8em;">curl "https://njal.la/update/?h=sub.domain.com&k=abc123&auto"</code></li>'
|
||||
+ '<li>Enter the subdomain and paste that curl command below for each service</li>'
|
||||
+ '</ol>'
|
||||
+ '</div>';
|
||||
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
|
||||
// domain-based feature is enabled, and lives on each service tile afterwards)
|
||||
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
|
||||
+ renderRouterPortsHtml({ internalIp: internalIp })
|
||||
+ '</div>';
|
||||
|
||||
html += '<p class="onboarding-hint">Enter each fully-qualified subdomain (e.g. <code>matrix.yourdomain.com</code>) and its Njal.la DDNS curl command.</p>';
|
||||
relevantDomains.forEach(function(d) {
|
||||
var currentVal = domainValues[d.name] || "";
|
||||
var currentVal = (_domainsData && _domainsData[d.name]) || "";
|
||||
html += '<div class="onboarding-domain-group">';
|
||||
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 += '<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 += '<p class="onboarding-hint" style="margin-top:4px;">Paste the update command from Njal.la</p>';
|
||||
html += '<label class="onboarding-domain-label onboarding-domain-label--sub">Njal.la DDNS Curl 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=' + escHtml(d.name) + '.yourdomain.com&k=abc123&auto"" />';
|
||||
html += '<p class="onboarding-hint" style="margin-top:4px;">ℹ Paste the curl URL from your Njal.la dashboard\'s Dynamic record</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 += '<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>';
|
||||
});
|
||||
}
|
||||
|
||||
// Contact email section
|
||||
var emailVal = domainValues["sslemail"] || "";
|
||||
// SSL email section
|
||||
var emailVal = (_domainsData && _domainsData["sslemail"]) || "";
|
||||
html += '<div class="onboarding-domain-group onboarding-domain-group--email">';
|
||||
html += '<label class="onboarding-domain-label">📧 Email Address</label>';
|
||||
html += '<p class="onboarding-hint onboarding-hint--inline">Used to send important notices about your services.</p>';
|
||||
html += '<label class="onboarding-domain-label">📧 SSL Certificate Email</label>';
|
||||
html += '<p class="onboarding-hint onboarding-hint--inline">Let\'s Encrypt uses this for certificate expiry notifications.</p>';
|
||||
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 += '<span class="onboarding-domain-save-status" id="domain-save-status-email" style="font-size:0.82rem;min-height:1.2em;"></span>';
|
||||
@@ -515,10 +507,94 @@ async function saveStep3() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Step 4: Complete ──────────────────────────────────────────────
|
||||
// ── Step 4: Port Forwarding ───────────────────────────────────────
|
||||
|
||||
async function loadStep4() {
|
||||
var body = document.getElementById("step-4-body");
|
||||
if (!body) return;
|
||||
body.innerHTML = '<p class="onboarding-loading">Checking ports…</p>';
|
||||
|
||||
var networkData = null;
|
||||
|
||||
try {
|
||||
networkData = await apiFetch("/api/network");
|
||||
} catch (err) {
|
||||
body.innerHTML = '<p class="onboarding-error">⚠ Could not load network data: ' + escHtml(err.message) + '</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
var internalIp = (networkData && networkData.internal_ip) || "unknown";
|
||||
|
||||
var ip = escHtml(internalIp);
|
||||
|
||||
var html = '<p class="onboarding-port-note" style="margin-bottom:14px;">'
|
||||
+ '⚠ <strong>Each port only needs to be forwarded once — all services share the same ports.</strong>'
|
||||
+ '</p>';
|
||||
|
||||
html += '<div class="onboarding-port-ip">';
|
||||
html += ' <span class="onboarding-port-ip-label">Forward ports to this machine\'s internal IP:</span>';
|
||||
html += ' <span class="port-req-internal-ip">' + ip + '</span>';
|
||||
html += '</div>';
|
||||
|
||||
// Required ports table
|
||||
html += '<div class="onboarding-port-section" style="margin-bottom:20px;">';
|
||||
html += '<div class="onboarding-port-section-title" style="font-weight:700;margin-bottom:8px;">Required Ports — open these on your router:</div>';
|
||||
html += '<table class="onboarding-port-table">';
|
||||
html += '<thead><tr><th>Port</th><th>Protocol</th><th>Forward to</th><th>Purpose</th></tr></thead>';
|
||||
html += '<tbody>';
|
||||
html += '<tr><td class="port-req-port">80</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">HTTP</td></tr>';
|
||||
html += '<tr><td class="port-req-port">443</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">HTTPS</td></tr>';
|
||||
html += '<tr><td class="port-req-port">22</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">SSH Remote Access</td></tr>';
|
||||
html += '<tr><td class="port-req-port">8448</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">Matrix Federation</td></tr>';
|
||||
html += '</tbody></table>';
|
||||
html += '</div>';
|
||||
|
||||
// Optional ports table
|
||||
html += '<div class="onboarding-port-section" style="margin-bottom:20px;">';
|
||||
html += '<div class="onboarding-port-section-title" style="font-weight:700;margin-bottom:4px;">Optional — Only needed if you enable Element Calling:</div>';
|
||||
html += '<div style="font-size:0.88em;margin-bottom:8px;color:var(--color-text-muted,#888);">These 5 additional port openings are required on top of the 4 required ports above.</div>';
|
||||
html += '<table class="onboarding-port-table">';
|
||||
html += '<thead><tr><th>Port</th><th>Protocol</th><th>Forward to</th><th>Purpose</th></tr></thead>';
|
||||
html += '<tbody>';
|
||||
html += '<tr><td class="port-req-port">7881</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">LiveKit WebRTC signalling</td></tr>';
|
||||
html += '<tr><td class="port-req-port">7882</td><td class="port-req-proto">UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">LiveKit media (UDP mux)</td></tr>';
|
||||
html += '<tr><td class="port-req-port">5349</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">TURN over TLS</td></tr>';
|
||||
html += '<tr><td class="port-req-port">3478</td><td class="port-req-proto">UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">TURN (STUN/relay)</td></tr>';
|
||||
html += '<tr><td class="port-req-port">30000–40000</td><td class="port-req-proto">TCP/UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">TURN relay (WebRTC)</td></tr>';
|
||||
html += '</tbody></table>';
|
||||
html += '</div>';
|
||||
|
||||
// Totals
|
||||
html += '<div class="onboarding-port-totals">';
|
||||
html += '<strong>Total port openings: 4</strong> (without Element Calling)<br>';
|
||||
html += '<strong>Total port openings: 9</strong> (with Element Calling — 4 required + 5 optional)';
|
||||
html += '</div>';
|
||||
|
||||
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
|
||||
+ '⚠ <strong>Ports 80 and 443 must be forwarded first.</strong> '
|
||||
+ 'Caddy uses these to obtain SSL certificates from Let\'s Encrypt. '
|
||||
+ 'If they are closed, HTTPS will not work and your services will be unreachable from outside your network.'
|
||||
+ '</div>';
|
||||
|
||||
html += '<details class="onboarding-port-details" style="margin-bottom:16px;">'
|
||||
+ '<summary class="onboarding-port-details-summary">How to set up port forwarding</summary>'
|
||||
+ '<ol style="margin:12px 0 0 16px; padding:0; line-height:1.8;">'
|
||||
+ '<li>Open your router\'s admin panel — usually <code>http://192.168.1.1</code> or <code>http://192.168.0.1</code></li>'
|
||||
+ '<li>Look for <strong>"Port Forwarding"</strong>, <strong>"NAT"</strong>, or <strong>"Virtual Server"</strong> in the settings</li>'
|
||||
+ '<li>Create a new rule for each port listed above</li>'
|
||||
+ '<li>Set the destination/internal IP to <strong>' + ip + '</strong></li>'
|
||||
+ '<li>Set both internal and external port to the same number</li>'
|
||||
+ '<li>Save and apply changes</li>'
|
||||
+ '</ol>'
|
||||
+ '</details>';
|
||||
|
||||
body.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── Step 5: Complete ──────────────────────────────────────────────
|
||||
|
||||
async function completeOnboarding() {
|
||||
var btn = document.getElementById("step-4-finish");
|
||||
var btn = document.getElementById("step-5-finish");
|
||||
if (btn) { btn.disabled = true; btn.textContent = "Finishing…"; }
|
||||
|
||||
try {
|
||||
@@ -541,7 +617,7 @@ function wireNavButtons() {
|
||||
try {
|
||||
await apiFetch("/api/migration/password-acknowledge", { method: "POST" });
|
||||
_migrationOccurred = true;
|
||||
updateCompleteChecklist();
|
||||
updateStep5Checklist();
|
||||
showStep1FromMigration();
|
||||
} catch (err) {
|
||||
setStatus("migration-password-status", "⚠ " + err.message, "error");
|
||||
@@ -577,9 +653,13 @@ function wireNavButtons() {
|
||||
showStep(nextStep(3));
|
||||
});
|
||||
|
||||
// Step 4: finish
|
||||
var s4finish = document.getElementById("step-4-finish");
|
||||
if (s4finish) s4finish.addEventListener("click", completeOnboarding);
|
||||
// Step 4 → 5 (port forwarding — no save needed)
|
||||
var s4next = document.getElementById("step-4-next");
|
||||
if (s4next) s4next.addEventListener("click", function() { showStep(nextStep(4)); });
|
||||
|
||||
// Step 5: finish
|
||||
var s5finish = document.getElementById("step-5-finish");
|
||||
if (s5finish) s5finish.addEventListener("click", completeOnboarding);
|
||||
|
||||
// Back buttons
|
||||
document.querySelectorAll(".onboarding-btn-back").forEach(function(btn) {
|
||||
@@ -611,13 +691,13 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
try {
|
||||
var migration = await apiFetch("/api/migration/password-status");
|
||||
if (migration && migration.pending) {
|
||||
updateCompleteChecklist();
|
||||
updateStep5Checklist();
|
||||
showMigrationStep(migration.password || "");
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
updateCompleteChecklist();
|
||||
updateStep5Checklist();
|
||||
showStep(1);
|
||||
loadStep1();
|
||||
});
|
||||
@@ -1,283 +0,0 @@
|
||||
"""Sovran Hub — injectable support session operations.
|
||||
|
||||
Functions here handle legacy migration, root-key removal, and support-session
|
||||
expiry. All filesystem paths, clocks, and callback functions are injectable
|
||||
so that the test suite can exercise the exact production implementations with
|
||||
temporary files and mocks rather than maintaining separate copies.
|
||||
|
||||
All functions depend only on the Python standard library and the co-located
|
||||
``security_helpers`` module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import time as _time_module
|
||||
from typing import Callable
|
||||
|
||||
# ── Legacy Njalla curl line regex ─────────────────────────────────────────────
|
||||
#
|
||||
# Matches both forms written by old Hub versions:
|
||||
# curl [flags] https://njal.la/... (unquoted)
|
||||
# curl [flags] "https://njal.la/..." (quoted — historical form)
|
||||
#
|
||||
# Optional flags (in order): --silent, --max-time N, --fail
|
||||
#
|
||||
# Rejected outright: semicolons, pipes, backticks, redirects, newlines,
|
||||
# ${...} except the literal ${IP} placeholder, and any extra arguments.
|
||||
_LEGACY_NJALLA_CURL_RE = re.compile(
|
||||
r'^curl\s+(?:--silent\s+)?(?:--max-time\s+\d+\s+)?(?:--fail\s+)?'
|
||||
r'(?:'
|
||||
r'"(https://(?:www\.)?njal\.la/(?:[^\s;|`$\x00-\x1f"]|\$\{IP\})+)"' # group 1: quoted
|
||||
r'|(https://(?:www\.)?njal\.la/(?:[^\s;|`$\x00-\x1f"]|\$\{IP\})+)' # group 2: unquoted
|
||||
r')$'
|
||||
)
|
||||
|
||||
# The exact base64 blob of the historical fleet-wide root support key that
|
||||
# was shipped with old releases of Sovran_SystemsOS and must be removed from
|
||||
# /root/.ssh/authorized_keys on upgrade.
|
||||
LEGACY_ROOT_KEY_BLOB = (
|
||||
"AAAAC3NzaC1lZDI1NTE5AAAAIPxPF2Qm11FQxC20wydKtlmn/Bo07YnDda3b9/CyXxQP"
|
||||
)
|
||||
|
||||
|
||||
def remove_legacy_root_key(
|
||||
authorized_keys_path: str,
|
||||
target_blob: str,
|
||||
*,
|
||||
audit_fn: Callable[[str, str], None] | None = None,
|
||||
) -> bool:
|
||||
"""Remove the exact historical fleet-wide support key from an authorized_keys file.
|
||||
|
||||
Identifies the key by its exact base64 blob (``parts[1]``), regardless of
|
||||
algorithm prefix or comment field. All other keys, blank lines, and comment
|
||||
lines are preserved unchanged. The file is written back atomically.
|
||||
|
||||
Args:
|
||||
authorized_keys_path: Path to the authorized_keys file to modify.
|
||||
target_blob: The exact base64 key blob to remove. Only lines whose
|
||||
second whitespace-delimited field matches this value are removed;
|
||||
no substring or comment matching is performed.
|
||||
audit_fn: Optional callback ``(event: str, details: str)`` for audit
|
||||
logging. The full key blob is **never** passed to this callback.
|
||||
|
||||
Returns:
|
||||
``True`` if the file was modified (at least one line removed),
|
||||
``False`` if unchanged or absent.
|
||||
"""
|
||||
|
||||
def _audit(event: str, details: str = "") -> None:
|
||||
if audit_fn:
|
||||
audit_fn(event, details)
|
||||
|
||||
try:
|
||||
with open(authorized_keys_path, "r") as f:
|
||||
lines = f.readlines()
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
kept: list[str] = []
|
||||
removed_count = 0
|
||||
for line in lines:
|
||||
stripped = line.rstrip("\n")
|
||||
parts = stripped.split()
|
||||
# Key lines have at least two space-separated fields: algorithm + blob.
|
||||
# Remove only lines whose blob (parts[1]) matches exactly — no
|
||||
# substring matching, no comment matching.
|
||||
if len(parts) >= 2 and parts[1] == target_blob:
|
||||
removed_count += 1
|
||||
# Audit without logging the key blob itself.
|
||||
_audit("LEGACY_ROOT_KEY_REMOVED", "removed exact historical root support key")
|
||||
else:
|
||||
kept.append(line)
|
||||
|
||||
if removed_count == 0:
|
||||
return False
|
||||
|
||||
# Atomic write: mkstemp in same directory + os.replace
|
||||
auth_dir = os.path.dirname(os.path.abspath(authorized_keys_path))
|
||||
fd, tmp = tempfile.mkstemp(dir=auth_dir, prefix=".authorized_keys_tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.writelines(kept)
|
||||
os.chmod(tmp, 0o600)
|
||||
os.replace(tmp, authorized_keys_path)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
_audit(
|
||||
"LEGACY_ROOT_KEY_CLEANUP_COMPLETE",
|
||||
f"removed={removed_count} keys_retained={len(kept)}",
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def migrate_legacy_njalla_script(
|
||||
script_path: str,
|
||||
validate_fn: Callable[[str], str],
|
||||
save_fn: Callable[[list[str]], None],
|
||||
load_fn: Callable[[], list[str]],
|
||||
*,
|
||||
audit_fn: Callable[[str, str], None] | None = None,
|
||||
) -> None:
|
||||
"""Safely migrate legacy curl DDNS lines from a njalla.sh script to JSON store.
|
||||
|
||||
Reads the script **without** executing or sourcing it. Parses only the
|
||||
exact narrow curl-pattern lines (quoted or unquoted) written by old Hub
|
||||
versions. Any other line is silently discarded — never executed or logged
|
||||
(it may contain secret tokens).
|
||||
|
||||
On successful persistence the script is archived with mode ``0o000`` so
|
||||
it can no longer be executed. If persistence fails the script is left
|
||||
**untouched**.
|
||||
|
||||
Args:
|
||||
script_path: Path to the legacy njalla.sh file.
|
||||
validate_fn: URL validation function; raises ``ValueError`` on invalid
|
||||
URLs. Callers must substitute the ``${IP}`` placeholder before
|
||||
calling — this function passes ``url.replace("${IP}", "127.0.0.1")``
|
||||
to the validator.
|
||||
save_fn: Callable that atomically writes a ``list[str]`` URL list to
|
||||
the persistent JSON store.
|
||||
load_fn: Callable that returns the current ``list[str]`` URL list from
|
||||
the persistent store.
|
||||
audit_fn: Optional callback ``(event: str, details: str)`` for audit
|
||||
logging. Token-bearing URLs are **never** passed to this callback.
|
||||
"""
|
||||
|
||||
def _audit(event: str, details: str = "") -> None:
|
||||
if audit_fn:
|
||||
audit_fn(event, details)
|
||||
|
||||
try:
|
||||
with open(script_path, "r") as f:
|
||||
content = f.read()
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except OSError:
|
||||
return
|
||||
|
||||
existing_urls = load_fn()
|
||||
|
||||
new_urls: list[str] = []
|
||||
for raw_line in content.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or line.startswith("IP=") or line.startswith("#!/"):
|
||||
continue
|
||||
m = _LEGACY_NJALLA_CURL_RE.match(line)
|
||||
if not m:
|
||||
# Unrecognised line — discard silently, do NOT log (may contain tokens)
|
||||
continue
|
||||
# group(1) = quoted form, group(2) = unquoted form
|
||||
raw_url = m.group(1) or m.group(2)
|
||||
# Substitute placeholder so host/scheme/path validation works
|
||||
url_to_validate = raw_url.replace("${IP}", "127.0.0.1")
|
||||
try:
|
||||
validate_fn(url_to_validate)
|
||||
except ValueError:
|
||||
continue # Silently discard invalid/non-Njal.la URLs
|
||||
if raw_url not in existing_urls and raw_url not in new_urls:
|
||||
new_urls.append(raw_url)
|
||||
|
||||
if new_urls:
|
||||
combined = existing_urls + new_urls
|
||||
try:
|
||||
save_fn(combined)
|
||||
except Exception:
|
||||
# Persistence failed — leave the script untouched, return without
|
||||
# archiving so the migration can be retried.
|
||||
return
|
||||
_audit("NJALLA_MIGRATION", f"migrated {len(new_urls)} DDNS URLs from legacy script")
|
||||
|
||||
# Archive: remove all permission bits so cron/any mechanism cannot run it
|
||||
try:
|
||||
os.chmod(script_path, 0o000)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def expire_if_stale(
|
||||
status_file: str,
|
||||
*,
|
||||
clock_fn: Callable[[], float] | None = None,
|
||||
disable_fn: Callable[[], bool] | None = None,
|
||||
audit_fn: Callable[[str, str], None] | None = None,
|
||||
session_id: str | None = None,
|
||||
expected_expiry: float | None = None,
|
||||
max_session_seconds: float = 86400.0,
|
||||
) -> bool:
|
||||
"""Expire a support session if its deadline has passed.
|
||||
|
||||
**Stale-timer guard:** when ``session_id`` and/or ``expected_expiry`` are
|
||||
provided (used by the server-side timer callback), the stored session
|
||||
metadata is compared field-by-field. A mismatch means a replacement
|
||||
session has been started after this timer was scheduled; in that case the
|
||||
function returns ``False`` without touching anything.
|
||||
|
||||
Args:
|
||||
status_file: Path to the JSON session metadata file.
|
||||
clock_fn: Callable returning current Unix time (default: ``time.time``).
|
||||
disable_fn: Callable that performs the full disable sequence — removes
|
||||
the support key, removes wallet-unlock metadata, restores deny
|
||||
ACLs, clears session metadata, and audits the event. If ``None``,
|
||||
expiry is detected but no action is taken (useful for tests that
|
||||
want to inspect detection only).
|
||||
audit_fn: Optional callback ``(event: str, details: str)`` for audit
|
||||
logging.
|
||||
session_id: If given, expiry is skipped unless the stored
|
||||
``session_id`` field matches exactly.
|
||||
expected_expiry: If given, expiry is skipped unless the stored
|
||||
``expires_at`` field matches exactly.
|
||||
max_session_seconds: Legacy fallback: maximum age (from ``enabled_at``)
|
||||
when ``expires_at`` is absent.
|
||||
|
||||
Returns:
|
||||
``True`` if a session was expired, ``False`` otherwise.
|
||||
"""
|
||||
_now = clock_fn if clock_fn is not None else _time_module.time
|
||||
|
||||
def _audit(event: str, details: str = "") -> None:
|
||||
if audit_fn:
|
||||
audit_fn(event, details)
|
||||
|
||||
def _disable() -> bool:
|
||||
return disable_fn() if disable_fn is not None else True
|
||||
|
||||
try:
|
||||
with open(status_file, "r") as f:
|
||||
info = json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return False
|
||||
|
||||
# Stale-timer guard
|
||||
if session_id is not None and info.get("session_id") != session_id:
|
||||
return False
|
||||
if expected_expiry is not None and info.get("expires_at") != expected_expiry:
|
||||
return False
|
||||
|
||||
expires_at = info.get("expires_at")
|
||||
now = _now()
|
||||
|
||||
if expires_at is None:
|
||||
enabled_at = info.get("enabled_at", 0)
|
||||
if enabled_at and (now - enabled_at) > max_session_seconds:
|
||||
_audit("SUPPORT_EXPIRED", "legacy session without expires_at exceeded max duration")
|
||||
_disable()
|
||||
return True
|
||||
return False
|
||||
|
||||
if now >= expires_at:
|
||||
_audit("SUPPORT_EXPIRED", f"session expired at {expires_at:.0f}")
|
||||
_disable()
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -1,231 +0,0 @@
|
||||
"""system_password.py -- the one place that knows about the 'free' (and root) password.
|
||||
|
||||
Rules
|
||||
* /etc/shadow is the ONLY source of truth for authentication: Hub login == GDM login.
|
||||
* /var/lib/secrets/free-password (0600 root) is a DISPLAY copy for the Hub's credentials
|
||||
view. Only set_free_password() writes it, and it is verified against /etc/shadow before
|
||||
it is shown, so the Hub can never show a stale password.
|
||||
* Legacy / unknown-password machines go through the *migration* flow the onboarding already
|
||||
has: generate a new diceware password, show it, and apply it on acknowledgement. Until
|
||||
then the old password keeps working everywhere (shadow is untouched).
|
||||
* No keyring code anywhere: the GNOME login keyring is decoupled from the login password
|
||||
(blank-password keyring, pam_gnome_keyring off), so nothing has to be wiped or re-keyed.
|
||||
|
||||
Needs libxcrypt. On NixOS the Hub service gets: environment.LIBCRYPT = "${pkgs.libxcrypt}/lib/libcrypt.so.1"
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
import hmac
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
import threading
|
||||
from typing import Callable
|
||||
|
||||
USER = "free"
|
||||
SECRETS_DIR = "/var/lib/secrets"
|
||||
DISPLAY_FILE = f"{SECRETS_DIR}/free-password"
|
||||
ROOT_DISPLAY_FILE = f"{SECRETS_DIR}/root-password"
|
||||
MIGRATION_FILE = f"{SECRETS_DIR}/free-password-migration-newpass" # same path the onboarding UI already uses
|
||||
LIVE_ISO_MARKER = "/iso" # NixOS live media mounts the image here; never start a migration there
|
||||
|
||||
_lock = threading.Lock() # crypt() returns a static buffer; serialise calls
|
||||
_libcrypt = None
|
||||
|
||||
|
||||
# ── low level ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _crypt():
|
||||
global _libcrypt
|
||||
if _libcrypt is None:
|
||||
path = os.environ.get("LIBCRYPT") or ctypes.util.find_library("crypt") or "libcrypt.so.1"
|
||||
lib = ctypes.CDLL(path)
|
||||
lib.crypt.restype = ctypes.c_char_p
|
||||
lib.crypt.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
|
||||
_libcrypt = lib
|
||||
return _libcrypt
|
||||
|
||||
|
||||
def shadow_hash(user: str = USER) -> str | None:
|
||||
"""Password hash from /etc/shadow, or None if the account has none / is locked."""
|
||||
try:
|
||||
with open("/etc/shadow", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
fields = line.rstrip("\n").split(":")
|
||||
if fields[0] == user:
|
||||
h = fields[1]
|
||||
return h if h and h[0] not in "!*" else None
|
||||
except OSError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def verify_system_password(password: str, user: str = USER) -> bool:
|
||||
"""True iff `password` is the account's current login password
|
||||
(yescrypt, sha512crypt, ... whatever chpasswd produced)."""
|
||||
h = shadow_hash(user)
|
||||
if not h or not password:
|
||||
return False
|
||||
with _lock:
|
||||
out = _crypt().crypt(password.encode(), h.encode())
|
||||
return out is not None and hmac.compare_digest(out, h.encode())
|
||||
|
||||
|
||||
def _write_secret(path: str, value: str) -> None:
|
||||
os.makedirs(SECRETS_DIR, mode=0o700, exist_ok=True)
|
||||
tmp = path + ".tmp"
|
||||
with open(os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), "w", encoding="utf-8") as f:
|
||||
f.write(value + "\n")
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def _read_secret(path: str) -> str | None:
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
v = f.read().rstrip("\n")
|
||||
return v or None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _chpasswd(user: str, password: str) -> None:
|
||||
import shutil
|
||||
bin_ = shutil.which("chpasswd") or "/run/current-system/sw/bin/chpasswd"
|
||||
subprocess.run([bin_], input=f"{user}:{password}\n", text=True, check=True, capture_output=True)
|
||||
|
||||
|
||||
# ── the single writers ─────────────────────────────────────────────────────
|
||||
|
||||
def set_free_password(new_password: str) -> None:
|
||||
"""System password first, then the display copy. Used by: change-password, reset,
|
||||
migration acknowledgement, the CLI."""
|
||||
if not new_password:
|
||||
raise ValueError("empty password")
|
||||
_chpasswd(USER, new_password)
|
||||
_write_secret(DISPLAY_FILE, new_password)
|
||||
|
||||
|
||||
def set_root_password(new_password: str) -> None:
|
||||
"""Same contract for root (the credentials view lists it too)."""
|
||||
if not new_password:
|
||||
raise ValueError("empty password")
|
||||
_chpasswd("root", new_password)
|
||||
_write_secret(ROOT_DISPLAY_FILE, new_password)
|
||||
|
||||
|
||||
# ── what the Hub shows ─────────────────────────────────────────────────────
|
||||
|
||||
def read_display_password() -> str | None:
|
||||
"""The 'free' password to SHOW -- only if it really is the current one."""
|
||||
pw = _read_secret(DISPLAY_FILE)
|
||||
return pw if pw and verify_system_password(pw) else None
|
||||
|
||||
|
||||
def read_root_display_password() -> str | None:
|
||||
pw = _read_secret(ROOT_DISPLAY_FILE)
|
||||
if not pw or ":" in pw: # ':' = scrypt hash written by older reset code -> not showable
|
||||
return None
|
||||
return pw if verify_system_password(pw, "root") else None
|
||||
|
||||
|
||||
def _display_state(path: str) -> str:
|
||||
""""missing" | "legacy" | "present" -- "present" only means a plaintext copy
|
||||
exists; it can still be stale (use read_display_password() to check that)."""
|
||||
pw = _read_secret(path)
|
||||
if pw is None:
|
||||
return "missing"
|
||||
return "legacy" if ":" in pw else "present"
|
||||
|
||||
|
||||
# ── migration flow (legacy machines) ────────────────────────────────────────
|
||||
|
||||
def migration_pending() -> str | None:
|
||||
"""The generated password awaiting acknowledgement, if any."""
|
||||
return _read_secret(MIGRATION_FILE)
|
||||
|
||||
|
||||
def ensure_migration_pending(generate: Callable[[], str]) -> bool:
|
||||
"""Legacy detection. Call it where onboarding status is computed.
|
||||
|
||||
Condition: the account HAS a password (so this is not the first boot) but the Hub
|
||||
has NO display copy of it at all (file missing or still the old hash format).
|
||||
Then generate a new password once and park it for the onboarding "migration
|
||||
password" step. Nothing is applied until the user acknowledges, so the old
|
||||
password keeps working at GDM and in the Hub. Returns True when a migration is
|
||||
pending (caller re-opens onboarding).
|
||||
|
||||
A copy that exists but merely fails to verify (password changed in GNOME
|
||||
Settings, `passwd`, or the pre-upgrade Hub) is DRIFT, not a legacy install:
|
||||
it never triggers a migration and never changes any password -- it is healed
|
||||
automatically at the next login via record_known_password(pw, only_if_stale=True).
|
||||
|
||||
Never fires on the live ISO (/iso) or while shadow is still locked."""
|
||||
if os.path.exists(LIVE_ISO_MARKER):
|
||||
return False
|
||||
if migration_pending():
|
||||
return True
|
||||
if shadow_hash() is None:
|
||||
return False
|
||||
if _display_state(DISPLAY_FILE) == "present":
|
||||
return False # copy exists: current, or just stale (drift)
|
||||
_write_secret(MIGRATION_FILE, generate())
|
||||
return True
|
||||
|
||||
|
||||
def acknowledge_migration() -> bool:
|
||||
"""User has written the new password down: make it real, then clear the pending file."""
|
||||
pw = migration_pending()
|
||||
if not pw:
|
||||
return False
|
||||
set_free_password(pw)
|
||||
os.remove(MIGRATION_FILE)
|
||||
return True
|
||||
|
||||
|
||||
def record_known_password(password: str, only_if_stale: bool = False) -> bool:
|
||||
""""Keep my current password" (migration step): if the user types the correct
|
||||
current password, store it as the display copy and cancel the migration.
|
||||
|
||||
only_if_stale=True is the login-handler heal: write ONLY when an existing
|
||||
plaintext copy is stale, never create a missing one (a missing copy is the
|
||||
legacy case and belongs to the migration flow)."""
|
||||
if not verify_system_password(password):
|
||||
return False
|
||||
if only_if_stale and _display_state(DISPLAY_FILE) != "present":
|
||||
return False
|
||||
if _read_secret(DISPLAY_FILE) != password:
|
||||
_write_secret(DISPLAY_FILE, password)
|
||||
try:
|
||||
os.remove(MIGRATION_FILE)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
def generate_diceware_password(words: list[str] | None = None, n: int = 4) -> str:
|
||||
"""word-word-word-word-NN, same shape the installer/reset produce (CSPRNG)."""
|
||||
words = words or ("apple barn brook cabin cedar cloud coral crane delta eagle ember fern field "
|
||||
"flame flora flint frost grove haven hedge holly heron jade juniper kelp larch "
|
||||
"lemon lilac linden loch lotus maple marsh meadow mist mossy mount oak ocean "
|
||||
"olive petal pine pixel plum pond prism quartz raven ridge river robin rocky "
|
||||
"rose rowan sage sand sierra silver slate snow solar spark spruce stone storm "
|
||||
"summit swift thorn tide timber torch trout vale vault vine walnut wave willow "
|
||||
"wren amber aspen birch blaze bloom bluff coast copper crest dune elder fjord "
|
||||
"forge glade glen glow gulf").split()
|
||||
return "-".join(secrets.choice(words) for _ in range(n)) + f"-{secrets.randbelow(100):02d}"
|
||||
|
||||
|
||||
if __name__ == "__main__": # tiny CLI for credentials.nix: echo NEW | system_password.py set-free
|
||||
import sys
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||
if cmd in ("set-free", "set-root"):
|
||||
pw = sys.stdin.readline().rstrip("\n")
|
||||
(set_free_password if cmd == "set-free" else set_root_password)(pw)
|
||||
print("ok")
|
||||
elif cmd == "verify":
|
||||
sys.exit(0 if verify_system_password(sys.stdin.readline().rstrip("\n")) else 1)
|
||||
else:
|
||||
print("usage: system_password.py set-free|set-root|verify (password on stdin)", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
@@ -18,45 +18,6 @@ def is_active(unit: str, scope: Literal["system", "user"] = "system") -> str:
|
||||
return _run(["systemctl", f"--{scope}", "is-active", unit]) or "unknown"
|
||||
|
||||
|
||||
def active_states(units_by_scope: dict[str, list[str]], timeout: float = 5) -> dict[tuple[str, str], str]:
|
||||
"""Return active states for many units with one systemctl call per scope.
|
||||
|
||||
The dashboard polls service state frequently. Spawning one ``systemctl``
|
||||
process per tile makes a slow or unavailable system bus multiply the delay
|
||||
(and can make the first dashboard render wait through many timeouts). The
|
||||
``is-active`` command accepts multiple units, so batch them and cap the
|
||||
whole batch at a single timeout.
|
||||
"""
|
||||
states: dict[tuple[str, str], str] = {}
|
||||
valid_scopes = {"system", "user"}
|
||||
|
||||
for scope, units in units_by_scope.items():
|
||||
unique_units = list(dict.fromkeys(unit for unit in units if unit))
|
||||
if not unique_units:
|
||||
continue
|
||||
if scope not in valid_scopes:
|
||||
for unit in unique_units:
|
||||
states[(scope, unit)] = "unknown"
|
||||
continue
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["systemctl", f"--{scope}", "is-active", "--", *unique_units],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
lines = result.stdout.splitlines()
|
||||
for index, unit in enumerate(unique_units):
|
||||
state = lines[index].strip() if index < len(lines) else ""
|
||||
states[(scope, unit)] = state or "unknown"
|
||||
except Exception:
|
||||
for unit in unique_units:
|
||||
states[(scope, unit)] = "unknown"
|
||||
|
||||
return states
|
||||
|
||||
|
||||
def is_enabled(unit: str, scope: Literal["system", "user"] = "system") -> str:
|
||||
return _run(["systemctl", f"--{scope}", "is-enabled", unit]) or "unknown"
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sovran_SystemsOS — The Hub</title>
|
||||
<title>Sovran_SystemsOS Hub</title>
|
||||
<link rel="stylesheet" href="/static/css/base.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/buttons.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/header.css?v={{ asset_version }}" />
|
||||
@@ -18,324 +18,51 @@
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
|
||||
|
||||
<!-- service glyphs -->
|
||||
<!-- ═══ Real service logos — extracted verbatim from app/icons/*.svg ═══
|
||||
(internal ids namespaced per icon to avoid collisions) -->
|
||||
|
||||
<!-- symbolic helpers (nav + modal internals) -->
|
||||
<!-- Bitcoin nav icon: the OFFICIAL logo silhouette (paths taken verbatim
|
||||
from app/icons/bitcoin-core.svg) rendered monochrome in currentColor
|
||||
so it matches the symbolic sidebar set -->
|
||||
<symbol id="g-btc-sym" viewBox="-34 -34 580 580">
|
||||
<path fill="currentColor" d="M317.871 7.656c-137.12-34.192-276.024 49.28-310.2 186.44-34.208 137.136 49.256 276.048 186.36 310.24 137.16 34.199 276.063-49.265 310.256-186.408 34.192-137.152-49.264-276.08-186.416-310.272m50.936 211.872c-3.688 24.936-17.512 37.008-35.864 41.24 25.2 13.12 38.024 33.239 25.809 68.12-15.16 43.319-51.176 46.976-99.072 37.912l-11.624 46.584-28.088-7 11.472-45.96a1076 1076 0 0 1-22.384-5.809l-11.512 46.177-28.056-7 11.624-46.673c-6.561-1.68-13.225-3.464-20.024-5.168l-36.552-9.111 13.943-32.152s20.696 5.504 20.416 5.096c7.952 1.969 11.48-3.216 12.872-6.672l18.368-73.64.048-.2 13.104-52.568c.344-5.968-1.712-13.496-13.088-16.336.439-.296-20.4-5.072-20.4-5.072l7.472-30 38.736 9.673-.032.144c5.824 1.448 11.824 2.824 17.937 4.216L245.423 89.2l28.072 7-11.28 45.224c7.536 1.721 15.12 3.456 22.504 5.297l11.2-44.929 28.088 7-11.504 46.145c35.464 12.215 61.401 30.527 56.304 64.591"/>
|
||||
<path fill="currentColor" d="m254.647 174.6-13.983 56.08c15.855 3.951 64.735 20.071 72.656-11.656 8.248-33.096-42.817-40.472-58.673-44.424"/>
|
||||
<path fill="currentColor" d="m233.608 258.984-15.425 61.832c19.04 4.729 77.769 23.584 86.448-11.296 9.072-36.376-51.984-45.784-71.023-50.536"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="g-chat" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 5.5v7a2.5 2.5 0 0 1-2.5 2.5H10l-4 3.2c-.6.5-1 .3-1-.5V5.5A2.5 2.5 0 0 1 7.5 3h10A2.5 2.5 0 0 1 20 5.5z"/>
|
||||
<circle cx="8.7" cy="9.2" r="0.5" fill="currentColor"/><circle cx="12" cy="9.2" r="0.5" fill="currentColor"/><circle cx="15.3" cy="9.2" r="0.5" fill="currentColor"/>
|
||||
</g>
|
||||
</symbol>
|
||||
|
||||
<symbol id="g-antenna" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round">
|
||||
<path d="M5.4 9.9a9.3 9.3 0 0 1 13.2 0"/>
|
||||
<path d="M8.1 12.9a5.6 5.6 0 0 1 7.8 0"/>
|
||||
<circle cx="12" cy="16.6" r="1.5" fill="currentColor" stroke="none"/>
|
||||
<path d="M12 16.6V21"/>
|
||||
</g>
|
||||
</symbol>
|
||||
|
||||
<symbol id="g-lock" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="5.5" y="10.5" width="13" height="9.5" rx="2.6"/>
|
||||
<path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5"/>
|
||||
<circle cx="12" cy="15" r="1.3" fill="currentColor" stroke="none"/>
|
||||
</g>
|
||||
</symbol>
|
||||
<!-- nav + ui symbolic icons -->
|
||||
<symbol id="g-home" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 11.2L12 4.5l8 6.7"/>
|
||||
<path d="M6 10.5v8a1.2 1.2 0 0 0 1.2 1.2h9.6a1.2 1.2 0 0 0 1.2-1.2v-8"/>
|
||||
<path d="M10 19.7v-5.4h4v5.4"/>
|
||||
</g>
|
||||
</symbol>
|
||||
<symbol id="g-grid" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round">
|
||||
<rect x="3.5" y="3.5" width="7.2" height="7.2" rx="2"/><rect x="13.3" y="3.5" width="7.2" height="7.2" rx="2"/>
|
||||
<rect x="3.5" y="13.3" width="7.2" height="7.2" rx="2"/><rect x="13.3" y="13.3" width="7.2" height="7.2" rx="2"/>
|
||||
</g>
|
||||
</symbol>
|
||||
<symbol id="g-server" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round">
|
||||
<rect x="3.5" y="3.5" width="17" height="7.4" rx="2"/><rect x="3.5" y="13.1" width="17" height="7.4" rx="2"/>
|
||||
<circle cx="7.2" cy="7.2" r="0.6" fill="currentColor"/><circle cx="7.2" cy="16.8" r="0.6" fill="currentColor"/>
|
||||
</g>
|
||||
</symbol>
|
||||
<symbol id="g-dots" viewBox="0 0 24 24">
|
||||
<g fill="currentColor">
|
||||
<circle cx="5" cy="5" r="1.7"/><circle cx="12" cy="5" r="1.7"/><circle cx="19" cy="5" r="1.7"/>
|
||||
<circle cx="5" cy="12" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="19" cy="12" r="1.7"/>
|
||||
<circle cx="5" cy="19" r="1.7"/><circle cx="12" cy="19" r="1.7"/><circle cx="19" cy="19" r="1.7"/>
|
||||
</g>
|
||||
</symbol>
|
||||
<symbol id="g-refresh" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M20 12a8 8 0 1 1-2.9-6.2"/>
|
||||
<path d="M20.6 2.6l-.5 4.9-4.6-1.7z" fill="currentColor" stroke="none"/>
|
||||
</g>
|
||||
</symbol>
|
||||
<!-- Update System: down arrow into a tray (get / install updates) -->
|
||||
<symbol id="g-update" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 4v10.5"/>
|
||||
<path d="M7.8 10.3L12 14.5l4.2-4.2"/>
|
||||
<path d="M4.5 16.5v2a2.5 2.5 0 0 0 2.5 2.5h10a2.5 2.5 0 0 0 2.5-2.5v-2"/>
|
||||
</g>
|
||||
</symbol>
|
||||
<symbol id="g-box" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3.5" y="4" width="17" height="4.4" rx="1.6"/>
|
||||
<path d="M5.2 8.4v9a2.4 2.4 0 0 0 2.4 2.4h8.8a2.4 2.4 0 0 0 2.4-2.4v-9"/>
|
||||
<path d="M10 12.5h4"/>
|
||||
</g>
|
||||
</symbol>
|
||||
<symbol id="g-shield-check" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2.5 4.5 5.5v6c0 4.6 3.1 8 7.5 9.9 4.4-1.9 7.5-5.3 7.5-9.9v-6z"/>
|
||||
<path d="M8.6 12l2.4 2.4 4.4-4.6"/>
|
||||
</g>
|
||||
</symbol>
|
||||
<!-- Systems Operational: activity pulse (system health) — distinct from
|
||||
the Security shield above -->
|
||||
<symbol id="g-pulse" viewBox="0 0 24 24">
|
||||
<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M3 12h4l3-7 4 14 3-7h4"/>
|
||||
</symbol>
|
||||
<symbol id="g-lifebuoy" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
|
||||
<circle cx="12" cy="12" r="8.6"/><circle cx="12" cy="12" r="3.6"/>
|
||||
<path d="M6 6l3.2 3.2M18 6l-3.2 3.2M6 18l3.2-3.2M18 18l-3.2-3.2"/>
|
||||
</g>
|
||||
</symbol>
|
||||
<symbol id="g-power" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M7.2 6.1a7 7 0 1 0 9.6 0"/>
|
||||
<path d="M12 2.8v8.4"/>
|
||||
</g>
|
||||
</symbol>
|
||||
<symbol id="g-logout" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 4h4.5A1.5 1.5 0 0 1 20 5.5v13a1.5 1.5 0 0 1-1.5 1.5H14"/>
|
||||
<path d="M4 12h10M10.5 8.5 14 12l-3.5 3.5"/>
|
||||
</g>
|
||||
</symbol>
|
||||
<symbol id="g-search" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<circle cx="11" cy="11" r="6.5"/>
|
||||
<path d="M15.8 15.8 21 21"/>
|
||||
</g>
|
||||
</symbol>
|
||||
<symbol id="g-copy" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="9" width="11" height="11" rx="2.2"/>
|
||||
<path d="M5.5 15H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v.5"/>
|
||||
</g>
|
||||
</symbol>
|
||||
<symbol id="g-check" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.5l5 5 10-11"/></symbol>
|
||||
<symbol id="g-chev" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" d="M9.5 5.5 16 12l-6.5 6.5"/></symbol>
|
||||
<symbol id="g-x" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" d="M6 6l12 12M18 6 6 18"/></symbol>
|
||||
<symbol id="g-wifi" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round">
|
||||
<path d="M4.5 10.2a11 11 0 0 1 15 0"/>
|
||||
<path d="M7.3 13.3a7 7 0 0 1 9.4 0"/>
|
||||
<circle cx="12" cy="17" r="1.4" fill="currentColor" stroke="none"/>
|
||||
</g>
|
||||
</symbol>
|
||||
<symbol id="g-key" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="7.5" cy="15.5" r="4"/>
|
||||
<path d="M10.4 12.6 20 3M16.2 6.8l2.4 2.4M18.8 4.2l2.2 2.2"/>
|
||||
</g>
|
||||
</symbol>
|
||||
<symbol id="g-alert" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3.6 22.2 20.4H1.8z"/>
|
||||
<path d="M12 9.8v4.6"/><circle cx="12" cy="17.2" r="0.6" fill="currentColor" stroke="none"/>
|
||||
</g>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
||||
<!-- ═══ BOOT SPLASH ═══ covers the shell while the first data loads;
|
||||
dashboard.js lifts it once the welcome cards are rendered -->
|
||||
<div id="app-splash" role="status" aria-live="polite">
|
||||
<div class="splash-card">
|
||||
<div class="splash-ring">
|
||||
<img src="/static/sovran-hub-icon.svg" alt="" width="64" height="64" />
|
||||
<span class="splash-ring-arc" aria-hidden="true"></span>
|
||||
</div>
|
||||
<div class="splash-title">Starting The Hub</div>
|
||||
<div class="splash-sub" id="splash-sub">Gathering your services…</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var splash = document.getElementById("app-splash");
|
||||
var sub = document.getElementById("splash-sub");
|
||||
var lifted = false;
|
||||
function lift() {
|
||||
if (lifted || !splash) return;
|
||||
lifted = true;
|
||||
splash.classList.add("done");
|
||||
setTimeout(function () {
|
||||
if (splash && splash.parentNode) splash.parentNode.removeChild(splash);
|
||||
}, 500);
|
||||
}
|
||||
window.__liftAppSplash = lift;
|
||||
// Reassure the user if the backend is slow (e.g. right after a reboot)
|
||||
setTimeout(function () {
|
||||
if (!lifted && sub) sub.textContent = "Still starting\u2026 this can take a moment after a reboot.";
|
||||
}, 8000);
|
||||
// Never trap the user behind the splash
|
||||
setTimeout(lift, 25000);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<div class="app">
|
||||
|
||||
<!-- ═══ SIDEBAR ═══ -->
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="brand">
|
||||
<img src="/static/sovran-hub-icon.svg" class="brand-logo" alt="The Hub" />
|
||||
<div class="brand-text">
|
||||
<div class="brand-title">The <em>Hub</em></div>
|
||||
<div class="brand-sub">Sovran_SystemsOS v{{ sovran_version }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav id="sidebar-nav" aria-label="Service categories"></nav>
|
||||
|
||||
<div class="nav-label">System</div>
|
||||
<div id="sidebar-support"></div>
|
||||
|
||||
<!-- Feature Manager + Preferences (features.js) -->
|
||||
<div id="sidebar-features"></div>
|
||||
|
||||
<div class="sidebar-spacer"></div>
|
||||
</aside>
|
||||
|
||||
<!-- ═══ MAIN ═══ -->
|
||||
<div class="main">
|
||||
<header class="topbar">
|
||||
<div class="page-title" id="page-title">Dashboard</div>
|
||||
|
||||
<div class="search-box">
|
||||
<svg><use href="#g-search"/></svg>
|
||||
<input id="search-input" type="text" placeholder="Search services…" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<div class="top-actions">
|
||||
<button class="btn btn-header-reboot" id="btn-header-reboot" title="Restart the entire computer">Reboot</button>
|
||||
<!-- Header bar -->
|
||||
<header class="header-bar">
|
||||
<img src="/static/sovran-hub-icon.svg" alt="Sovran Hub" class="header-logo" />
|
||||
<span class="title">Sovran_SystemsOS Hub</span>
|
||||
<div class="header-buttons">
|
||||
<span class="role-badge" id="role-badge">Loading…</span>
|
||||
<button class="btn btn-logout" id="btn-logout" title="Sign out">Sign Out</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="content" id="content">
|
||||
<!-- ═══ WELCOME DASHBOARD (default view) ═══ -->
|
||||
<section class="welcome" id="welcome-view">
|
||||
<div class="welcome-bg" aria-hidden="true">
|
||||
<div class="orb orb-a"></div>
|
||||
<div class="orb orb-b"></div>
|
||||
<div class="orb orb-c"></div>
|
||||
</div>
|
||||
<div class="welcome-inner">
|
||||
<div class="welcome-greeting" id="welcome-greeting">Hello</div>
|
||||
<h1 class="welcome-title">Welcome to Your <em>Sovereign</em> Digital & Financial Life</h1>
|
||||
<div class="welcome-meta">Sovran_SystemsOS v{{ sovran_version }}<span class="welcome-meta-sep">·</span><span id="welcome-role"></span></div>
|
||||
<div class="welcome-cards" id="welcome-cards">
|
||||
<div class="welcome-dyn" id="wc-systems"></div>
|
||||
<div class="widget w-network" id="w-network">
|
||||
<div class="widget-chip chip-green"><svg><use href="#g-wifi"/></svg></div>
|
||||
<div class="w-body">
|
||||
<h3>Network</h3>
|
||||
<div class="net-row"><span class="net-k">LAN</span><span class="ip-value" id="ip-internal">…</span></div>
|
||||
<div class="net-row"><span class="net-k">WAN</span><span class="ip-value" id="ip-external">…</span></div>
|
||||
<div class="sub">sovransystemsos.local:8937</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="welcome-dyn" id="wc-more"></div>
|
||||
</div>
|
||||
<button class="btn btn-ghost welcome-browse" id="welcome-browse-btn">Browse all services →</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══ SERVICES GRID ═══ -->
|
||||
<div id="tiles-area" style="display:none">
|
||||
<div class="dashboard-loading" role="status" aria-live="polite">
|
||||
<span class="dashboard-loading-spinner" aria-hidden="true"></span>
|
||||
<span>Loading service status…</span>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ═══ 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>
|
||||
<!-- IP bar -->
|
||||
<div class="ip-bar">
|
||||
<span>
|
||||
<span class="ip-label">Internal IP:</span>
|
||||
<span class="ip-value" id="ip-internal">…</span>
|
||||
</span>
|
||||
<span class="ip-separator">|</span>
|
||||
<span>
|
||||
<span class="ip-label">External IP:</span>
|
||||
<span class="ip-value" id="ip-external">…</span>
|
||||
</span>
|
||||
<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>
|
||||
|
||||
<!-- Service tiles -->
|
||||
<main class="main-content">
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div id="sidebar-support"></div>
|
||||
<div id="sidebar-features"></div>
|
||||
</aside>
|
||||
<div id="tiles-area"></div>
|
||||
</main>
|
||||
|
||||
<!-- Update modal -->
|
||||
<div class="modal-overlay" id="update-modal" role="dialog" aria-modal="true" aria-labelledby="modal-title-text">
|
||||
<div class="modal-dialog">
|
||||
<div class="upd-header">
|
||||
<span class="widget-chip chip-sovran upd-chip"><svg class="upd-chip-svg"><use href="#g-update"/></svg></span>
|
||||
<span class="upd-title-wrap">
|
||||
<span class="upd-title" id="modal-title-text">Sovran_SystemsOS Update</span>
|
||||
<span class="upd-sub">
|
||||
<span class="ver-chip">Sovran_SystemsOS v{{ sovran_version }}</span>
|
||||
<span class="upd-pill" id="upd-pill"></span>
|
||||
<span class="modal-spinner" id="modal-spinner"></span>
|
||||
</span>
|
||||
</span>
|
||||
<button class="creds-close-btn" id="update-close-btn" title="Close">✕</button>
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" id="modal-title-text">Sovran_SystemsOS Update</span>
|
||||
<div class="modal-spinner" id="modal-spinner"></div>
|
||||
<span class="modal-status" id="modal-status">Updating…</span>
|
||||
</div>
|
||||
<div class="modal-body-scroll">
|
||||
<div class="sysmodal-card">
|
||||
<div class="sysmodal-card-title"><svg><use href="#g-server"/></svg>System Details</div>
|
||||
<div class="sysstep"><div class="sysnum">1</div><div class="sysstep-x"><div class="sysstep-t">Current version</div><div class="sysval"><span class="sysval-text">{{ sovran_version }}</span></div></div></div>
|
||||
<div class="sysstep"><div class="sysnum">2</div><div class="sysstep-x"><div class="sysstep-t">Release channel</div><div class="sysval"><span class="sysval-text">stable</span></div></div></div>
|
||||
<div class="sysstep"><div class="sysnum">3</div><div class="sysstep-x"><div class="sysstep-t">Last checked</div><div class="sysval"><span class="sysval-text" id="upd-last-checked">—</span></div></div></div>
|
||||
</div>
|
||||
<div class="update-status-msg" id="modal-status">Checking for updates…</div>
|
||||
<div class="modal-log" id="modal-log" aria-live="polite"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-save" id="btn-save-report" style="display:none">Save Error Report</button>
|
||||
<button class="btn btn-save" id="btn-retry-update-status" style="display:none">Retry Status</button>
|
||||
<button class="btn btn-reboot" id="btn-retry-update" style="display:none">Retry Update</button>
|
||||
<button class="btn btn-reboot" id="btn-reboot" style="display:none">Restart Entire System</button>
|
||||
<span class="modal-footer-spacer"></span>
|
||||
<button class="btn btn-reboot" id="btn-reboot" style="display:none">Reboot</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>
|
||||
@@ -357,10 +84,7 @@
|
||||
<div class="modal-overlay" id="support-modal" role="dialog" aria-modal="true" aria-labelledby="support-modal-title">
|
||||
<div class="creds-dialog">
|
||||
<div class="creds-header">
|
||||
<span class="creds-title">
|
||||
<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>
|
||||
<span class="creds-title" id="support-modal-title">Tech Support</span>
|
||||
<button class="creds-close-btn" id="support-close-btn" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="creds-body" id="support-body">
|
||||
@@ -432,26 +156,15 @@
|
||||
<!-- Rebuild Modal -->
|
||||
<div class="modal-overlay" id="rebuild-modal" role="dialog" aria-modal="true" aria-labelledby="rebuild-modal-title">
|
||||
<div class="modal-dialog">
|
||||
<div class="upd-header">
|
||||
<span class="widget-chip chip-sovran upd-chip"><svg class="upd-chip-svg"><use href="#g-server"/></svg></span>
|
||||
<span class="upd-title-wrap">
|
||||
<span class="upd-title" id="rebuild-modal-title">Sovran_SystemsOS Rebuild</span>
|
||||
<span class="upd-sub">
|
||||
<span class="ver-chip">Sovran_SystemsOS v{{ sovran_version }}</span>
|
||||
<span class="upd-pill" id="rebuild-pill"></span>
|
||||
<span class="modal-spinner" id="rebuild-spinner"></span>
|
||||
</span>
|
||||
</span>
|
||||
<button class="creds-close-btn" id="rebuild-close-hdr" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="modal-body-scroll">
|
||||
<div class="update-status-msg" id="rebuild-status">Rebuilding…</div>
|
||||
<div class="modal-log" id="rebuild-log" aria-live="polite" style="display:none"></div>
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" id="rebuild-modal-title">Sovran_SystemsOS Rebuild</span>
|
||||
<div class="modal-spinner" id="rebuild-spinner"></div>
|
||||
<span class="modal-status" id="rebuild-status">Rebuilding…</span>
|
||||
</div>
|
||||
<div class="modal-log" id="rebuild-log" aria-live="polite"></div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-save" id="rebuild-save-report" style="display:none">Save Error Report</button>
|
||||
<button class="btn btn-reboot" id="rebuild-reboot-btn" style="display:none">Restart Entire System</button>
|
||||
<span class="modal-footer-spacer"></span>
|
||||
<button class="btn btn-reboot" id="rebuild-reboot-btn" style="display:none">Reboot</button>
|
||||
<button class="btn btn-close-modal" id="rebuild-close-btn" disabled>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -470,18 +183,22 @@
|
||||
encrypted messaging, password management, cloud storage, website hosting, and more.
|
||||
</p>
|
||||
<div class="upgrade-info-box">
|
||||
<p class="upgrade-info-title">Simple, guided setup:</p>
|
||||
<p class="upgrade-info-title">⚠ What you should know:</p>
|
||||
<ul class="upgrade-info-list">
|
||||
<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>To make your services available outside your home, complete one router task: forward ports <strong>80 and 443</strong> to this computer</li>
|
||||
<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>Some services require ports to be opened on your router</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>
|
||||
</div>
|
||||
<p class="support-desc">
|
||||
⚠️ <strong>Heads-up:</strong> your domain points at your home internet connection,
|
||||
so anyone can look up your home IP address. Domain privacy does not hide it.
|
||||
</p>
|
||||
<p class="support-desc">
|
||||
The Hub guides you through every step.
|
||||
<strong>Don't worry</strong> — the Hub will walk you through every step after the upgrade.
|
||||
Domain setup, port forwarding, and configuration are all guided.
|
||||
</p>
|
||||
<p class="support-desc upgrade-rebuild-note">
|
||||
The system will rebuild after upgrading. This may take several minutes.
|
||||
@@ -520,71 +237,35 @@
|
||||
<div class="security-reset-password-box" id="security-reset-new-password"> </div>
|
||||
<p class="security-reset-password-warning">
|
||||
✍️ <strong>Write this down now.</strong><br />
|
||||
You will need it to log in to your computer<br />and the Sovran Hub at <em>sovransystemsos.local:8937</em>.
|
||||
You will need it to log in to your computer<br />and the Sovran Hub at <em>sovransystemsos.local</em>.
|
||||
</p>
|
||||
<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 — Reboot now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reboot overlay -->
|
||||
<div class="reboot-overlay" id="reboot-overlay">
|
||||
<!-- Normal restarting card -->
|
||||
<div class="reboot-card" id="reboot-main-card">
|
||||
<div class="reboot-icon" aria-hidden="true"></div>
|
||||
<h2 class="reboot-title">Restarting Entire System</h2>
|
||||
<div class="reboot-card">
|
||||
<div class="reboot-icon">↻</div>
|
||||
<h2 class="reboot-title">System Rebooting</h2>
|
||||
<p class="reboot-message">
|
||||
The entire computer is restarting, including the desktop and all hosted services.<br />
|
||||
This page will reconnect automatically when Sovran_SystemsOS is back online.
|
||||
Sovran_SystemsOS is now restarting.<br />
|
||||
This page will automatically reconnect once the system is back online.
|
||||
</p>
|
||||
<div class="reboot-dots" aria-hidden="true">
|
||||
<div class="reboot-dots">
|
||||
<span class="reboot-dot"></span>
|
||||
<span class="reboot-dot"></span>
|
||||
<span class="reboot-dot"></span>
|
||||
</div>
|
||||
<p class="reboot-submessage" id="reboot-submessage" aria-live="polite">Sending restart request…</p>
|
||||
</div>
|
||||
<!-- Error card (shown if restart request fails definitively) -->
|
||||
<div class="reboot-card" id="reboot-error-card" style="display:none">
|
||||
<div class="reboot-icon" aria-hidden="true"></div>
|
||||
<h2 class="reboot-title">Restart could not be started</h2>
|
||||
<p class="reboot-message">
|
||||
The computer did not begin restarting. No services were intentionally stopped. Please try again.
|
||||
</p>
|
||||
<div class="reboot-error-actions">
|
||||
<button class="btn btn-close-modal" id="reboot-error-close-btn">Close</button>
|
||||
<button class="btn btn-restart-amber" id="reboot-error-retry-btn">Try Again</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Restart Confirm Dialog -->
|
||||
<div class="modal-overlay" id="restart-confirm-modal" role="dialog" aria-modal="true" aria-labelledby="restart-confirm-title">
|
||||
<div class="creds-dialog domain-narrow-dialog">
|
||||
<div class="creds-header">
|
||||
<span class="creds-title" id="restart-confirm-title">Restart the entire computer?</span>
|
||||
</div>
|
||||
<div class="creds-body">
|
||||
<div id="restart-conflict-box" class="restart-conflict-box" style="display:none">
|
||||
<p class="restart-conflict-title">The system cannot restart right now.</p>
|
||||
<p class="restart-conflict-desc">A system update, rebuild, backup, restore, or security operation is currently running. Wait for it to finish, then try again.</p>
|
||||
</div>
|
||||
<p class="support-desc"><strong>This will reboot the physical machine running Sovran_SystemsOS — not just the Hub.</strong></p>
|
||||
<p class="support-desc">The desktop and all hosted services will stop temporarily and restart with the computer. Anyone currently using these services will be disconnected.</p>
|
||||
<p class="support-desc">The system usually returns within 1–3 minutes. This page will reconnect automatically.</p>
|
||||
<div class="domain-field-actions">
|
||||
<button class="btn btn-close-modal" id="restart-confirm-cancel-btn">Cancel</button>
|
||||
<button class="btn btn-restart-amber" id="restart-confirm-ok-btn">Restart Entire System</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="reboot-submessage">Stay tuned…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/constants.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/state.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/helpers.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/domain-prereqs.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/tiles.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/service-detail.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/support.js?v={{ asset_version }}"></script>
|
||||
@@ -592,7 +273,6 @@
|
||||
<script src="/static/js/rebuild.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/features.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/security.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/dashboard.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/events.js?v={{ asset_version }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>The Hub — Login</title>
|
||||
<title>Sovran Hub — Login</title>
|
||||
<link rel="stylesheet" href="/static/css/base.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/buttons.css?v={{ asset_version }}" />
|
||||
</head>
|
||||
@@ -11,9 +11,8 @@
|
||||
<div class="login-wrapper">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<img src="/static/sovran-hub-icon.svg" alt="The Hub" class="login-logo" />
|
||||
<div class="login-title">The <em>Hub</em></div>
|
||||
<div class="login-sub">Sovran_SystemsOS</div>
|
||||
<img src="/static/sovran-hub-icon.svg" alt="Sovran Hub" class="login-logo" />
|
||||
<div class="login-title">Sovran Hub</div>
|
||||
</div>
|
||||
|
||||
<form class="login-form" id="login-form" onsubmit="return false;">
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
<span class="onboarding-step-dot" data-step="3">3</span>
|
||||
<span class="onboarding-step-connector"></span>
|
||||
<span class="onboarding-step-dot" data-step="4">4</span>
|
||||
<span class="onboarding-step-connector"></span>
|
||||
<span class="onboarding-step-dot" data-step="5">5</span>
|
||||
</div>
|
||||
|
||||
<!-- Step panels -->
|
||||
@@ -50,10 +52,10 @@
|
||||
<p class="onboarding-body-text" style="text-align:center; margin-bottom:4px;">
|
||||
Your new login password is:
|
||||
</p>
|
||||
<div id="migration-password-value" style="font-family:monospace; font-size:1.35rem; font-weight:700; color:var(--text-primary); background:rgba(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 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>
|
||||
<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;">
|
||||
<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;">
|
||||
⚠ Write this password down! You will need it to log in next time. This is also your Sovran Hub login password.
|
||||
</div>
|
||||
<div id="migration-password-status" class="onboarding-save-status" style="margin-top:8px;"></div>
|
||||
@@ -123,10 +125,12 @@
|
||||
<div class="onboarding-panel" id="step-3" style="display:none">
|
||||
<div class="onboarding-step-header">
|
||||
<span class="onboarding-step-icon">🌐</span>
|
||||
<h2 class="onboarding-step-title">Connect Your Services</h2>
|
||||
<h2 class="onboarding-step-title">Domain Configuration</h2>
|
||||
<p class="onboarding-step-desc">
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
First, create an account at <strong>Njal.la</strong> and purchase a new domain, or create a subdomain from a domain you already own. Tip: Subdomains are free to create — you only need to purchase one domain, and you can add as many subdomains as you need at no extra cost.
|
||||
Then, in the Njal.la web interface, create a <strong>Dynamic</strong> record pointing to this machine's external IP address (shown below).
|
||||
Finally, paste the DDNS curl command from your Njal.la dashboard for each service below.
|
||||
</p>
|
||||
</div>
|
||||
<div class="onboarding-card" id="step-3-body">
|
||||
@@ -141,8 +145,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Step 4: Complete ── -->
|
||||
<!-- ── Step 4: Port Forwarding ── -->
|
||||
<div class="onboarding-panel" id="step-4" style="display:none">
|
||||
<div class="onboarding-step-header">
|
||||
<span class="onboarding-step-icon">🔌</span>
|
||||
<h2 class="onboarding-step-title">Port Forwarding Check</h2>
|
||||
<p class="onboarding-step-desc">
|
||||
Forward these ports on your router to this machine. Each port only needs to be opened once — they are shared across all your services.
|
||||
<strong>Ports 80 and 443 must be open for SSL certificates to work.</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div class="onboarding-card" id="step-4-body">
|
||||
<p class="onboarding-loading">Checking ports…</p>
|
||||
</div>
|
||||
<div class="onboarding-footer">
|
||||
<button class="btn btn-close-modal onboarding-btn-back" data-prev="3">← Back</button>
|
||||
<button class="btn btn-primary onboarding-btn-next" id="step-4-next">
|
||||
Continue →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Step 5: Complete ── -->
|
||||
<div class="onboarding-panel" id="step-5" style="display:none">
|
||||
<div class="onboarding-hero">
|
||||
<div class="onboarding-logo">✅</div>
|
||||
<h1 class="onboarding-title">Your Sovran_SystemsOS is Ready!</h1>
|
||||
@@ -156,11 +181,12 @@
|
||||
<ul class="onboarding-checklist" id="onboarding-checklist">
|
||||
<li>✅ Timezone & locale configured</li>
|
||||
<li>✅ Domain configuration saved</li>
|
||||
<li>✅ Port forwarding reviewed</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="onboarding-footer">
|
||||
<button class="btn btn-close-modal onboarding-btn-back" data-prev="3">← Back</button>
|
||||
<button class="btn btn-primary" id="step-4-finish">
|
||||
<button class="btn btn-close-modal onboarding-btn-back" data-prev="4">← Back</button>
|
||||
<button class="btn btn-primary" id="step-5-finish">
|
||||
Go to Dashboard →
|
||||
</button>
|
||||
</div>
|
||||
@@ -169,7 +195,6 @@
|
||||
</div><!-- /panel-wrap -->
|
||||
</div><!-- /shell -->
|
||||
|
||||
<script src="/static/js/domain-prereqs.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/onboarding.js?v={{ onboarding_js_hash }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,88 +0,0 @@
|
||||
"""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
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"caddy.service": "2.11.7",
|
||||
"tor.service": "0.4.9.14",
|
||||
"gnome-remote-desktop.service": "50.2",
|
||||
"bitcoind.service": "31.1",
|
||||
"electrs.service": "0.12.0",
|
||||
"lnd.service": "0.21.4-beta",
|
||||
"rtl.service": "0.15.13",
|
||||
"btcpayserver.service": "2.4.5",
|
||||
"albyhub.service": "2.0.0",
|
||||
"mempool.service": "3.3.1",
|
||||
"matrix-synapse.service": "1.162.0",
|
||||
"livekit.service": "1.13.8",
|
||||
"vaultwarden.service": "1.37.4",
|
||||
"haven-relay.service": "1.2.2"
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import types
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
|
||||
def _install_web_stubs():
|
||||
if "fastapi" in sys.modules:
|
||||
return
|
||||
|
||||
class _HTTPException(Exception):
|
||||
def __init__(self, status_code=None, detail=None):
|
||||
super().__init__(detail)
|
||||
self.status_code = status_code
|
||||
self.detail = detail
|
||||
|
||||
class _FastAPI:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def mount(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
def add_middleware(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
def __getattr__(self, _name):
|
||||
def _decorator_factory(*args, **kwargs):
|
||||
def _decorator(func):
|
||||
return func
|
||||
|
||||
return _decorator
|
||||
|
||||
return _decorator_factory
|
||||
|
||||
class _BaseModel:
|
||||
pass
|
||||
|
||||
class _StaticFiles:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
class _Jinja2Templates:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
class _BaseHTTPMiddleware:
|
||||
pass
|
||||
|
||||
fastapi_module = types.ModuleType("fastapi")
|
||||
fastapi_module.FastAPI = _FastAPI
|
||||
fastapi_module.HTTPException = _HTTPException
|
||||
sys.modules["fastapi"] = fastapi_module
|
||||
|
||||
responses_module = types.ModuleType("fastapi.responses")
|
||||
responses_module.HTMLResponse = object
|
||||
responses_module.JSONResponse = object
|
||||
responses_module.RedirectResponse = object
|
||||
sys.modules["fastapi.responses"] = responses_module
|
||||
|
||||
staticfiles_module = types.ModuleType("fastapi.staticfiles")
|
||||
staticfiles_module.StaticFiles = _StaticFiles
|
||||
sys.modules["fastapi.staticfiles"] = staticfiles_module
|
||||
|
||||
templating_module = types.ModuleType("fastapi.templating")
|
||||
templating_module.Jinja2Templates = _Jinja2Templates
|
||||
sys.modules["fastapi.templating"] = templating_module
|
||||
|
||||
requests_module = types.ModuleType("fastapi.requests")
|
||||
requests_module.Request = object
|
||||
sys.modules["fastapi.requests"] = requests_module
|
||||
|
||||
pydantic_module = types.ModuleType("pydantic")
|
||||
pydantic_module.BaseModel = _BaseModel
|
||||
sys.modules["pydantic"] = pydantic_module
|
||||
|
||||
starlette_base_module = types.ModuleType("starlette.middleware.base")
|
||||
starlette_base_module.BaseHTTPMiddleware = _BaseHTTPMiddleware
|
||||
sys.modules["starlette.middleware.base"] = starlette_base_module
|
||||
|
||||
starlette_middleware_module = types.ModuleType("starlette.middleware")
|
||||
starlette_middleware_module.base = starlette_base_module
|
||||
sys.modules["starlette.middleware"] = starlette_middleware_module
|
||||
|
||||
starlette_module = types.ModuleType("starlette")
|
||||
starlette_module.middleware = starlette_middleware_module
|
||||
sys.modules["starlette"] = starlette_module
|
||||
|
||||
|
||||
_install_web_stubs()
|
||||
from sovran_systemsos_web import server
|
||||
|
||||
|
||||
class Bip110StatusTests(unittest.TestCase):
|
||||
def _status(self, deploy_info, net_info):
|
||||
with patch.object(server, "_get_bitcoin_deployment_info", return_value=deploy_info), patch.object(
|
||||
server, "_get_bitcoin_version_info", return_value=net_info
|
||||
):
|
||||
return server._get_bip110_status()
|
||||
|
||||
def test_started_reduced_data_reports_signaling(self):
|
||||
deploy_info = {
|
||||
"deployments": {
|
||||
"reduced_data": {
|
||||
"type": "bip9",
|
||||
"active": False,
|
||||
"bip9": {
|
||||
"bit": 4,
|
||||
"status": "started",
|
||||
"statistics": {"elapsed": 833, "count": 4, "threshold": 1109},
|
||||
"signalling": "--#--",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = self._status(deploy_info, {"subversion": "/Satoshi:29.0.0/"})
|
||||
self.assertEqual(
|
||||
result,
|
||||
{"supported": True, "signaling": True, "state": "signaling", "source": "getdeploymentinfo"},
|
||||
)
|
||||
|
||||
def test_active_reduced_data_reports_active(self):
|
||||
deploy_info = {
|
||||
"deployments": {"reduced_data": {"active": True, "bip9": {"bit": 4, "status": "active"}}}
|
||||
}
|
||||
|
||||
result = self._status(deploy_info, {"subversion": "/Satoshi:29.0.0/"})
|
||||
self.assertEqual(result["state"], "active")
|
||||
self.assertTrue(result["supported"])
|
||||
self.assertTrue(result["signaling"])
|
||||
self.assertEqual(result["source"], "getdeploymentinfo")
|
||||
|
||||
def test_locked_in_reduced_data_reports_locked_in(self):
|
||||
deploy_info = {
|
||||
"deployments": {"reduced_data": {"active": False, "bip9": {"bit": 4, "status": "locked_in"}}}
|
||||
}
|
||||
|
||||
result = self._status(deploy_info, {"subversion": "/Satoshi:29.0.0/"})
|
||||
self.assertEqual(result["state"], "locked_in")
|
||||
self.assertTrue(result["supported"])
|
||||
self.assertTrue(result["signaling"])
|
||||
self.assertEqual(result["source"], "getdeploymentinfo")
|
||||
|
||||
def test_no_bip110_deployment_and_plain_subversion_reports_unsupported(self):
|
||||
deploy_info = {
|
||||
"deployments": {
|
||||
"taproot": {"type": "bip9", "active": True, "bip9": {"bit": 2, "status": "active"}},
|
||||
}
|
||||
}
|
||||
result = self._status(deploy_info, {"subversion": "/Satoshi:27.0.0/"})
|
||||
self.assertEqual(
|
||||
result,
|
||||
{"supported": False, "signaling": False, "state": "unsupported", "source": "subversion"},
|
||||
)
|
||||
|
||||
def test_node_unreachable_reports_unknown(self):
|
||||
result = self._status(None, None)
|
||||
self.assertEqual(result, {"supported": False, "signaling": False, "state": "unknown", "source": "none"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 217 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 59 KiB |
+22
-44
@@ -74,6 +74,18 @@
|
||||
services.printing.enable = true;
|
||||
systemd.enableEmergencyMode = false;
|
||||
environment.gnome.excludePackages = [ pkgs.gnome-tour ];
|
||||
security.pam.services.gdm-password.enableGnomeKeyring = true;
|
||||
security.pam.services.gdm-autologin.enableGnomeKeyring = true;
|
||||
|
||||
# Declaratively guarantee the GNOME Keyring default pointer exists.
|
||||
# Defining the full path ensures root doesn't accidentally lock the user out of .local
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /home/free/.local 0700 free users -"
|
||||
"d /home/free/.local/share 0700 free users -"
|
||||
"d /home/free/.local/share/keyrings 0700 free users -"
|
||||
"f /home/free/.local/share/keyrings/default 0600 free users - login\n"
|
||||
];
|
||||
|
||||
|
||||
# ── Audio ──────────────────────────────────────────────────
|
||||
services.pulseaudio.enable = false;
|
||||
@@ -133,13 +145,12 @@
|
||||
ranger fastfetch gedit openssl pwgen
|
||||
aspell aspellDicts.en lm_sensors
|
||||
hunspell hunspellDicts.en_US
|
||||
synadm brave-origin dua
|
||||
synadm brave dua
|
||||
gparted pv unzip parted screen zenity
|
||||
libargon2 gnome-terminal libreoffice-stable
|
||||
libargon2 gnome-terminal libreoffice-fresh
|
||||
dig firefox wp-cli axel
|
||||
lk-jwt-service livekit-libwebrtc livekit
|
||||
matrix-synapse age onlyoffice-desktopeditors
|
||||
tor-browser
|
||||
lk-jwt-service livekit-libwebrtc livekit-cli livekit
|
||||
matrix-synapse age
|
||||
];
|
||||
|
||||
# ── Shell ──────────────────────────────────────────────────
|
||||
@@ -153,14 +164,6 @@
|
||||
programs.fish = { enable = true; promptInit = "fastfetch"; };
|
||||
|
||||
# ── PostgreSQL base ────────────────────────────────────────
|
||||
# Shared cluster for Nextcloud (nextclouddb) + Matrix Synapse.
|
||||
# Sized for the README's Server + Desktop recommendation (32 GB RAM,
|
||||
# 500 GB NVMe OS + 2 TB NVMe timechain). Postgres shares the box with
|
||||
# Bitcoin Core, Electrs, LND, MariaDB, PHP-FPM and GNOME, so
|
||||
# shared_buffers stays below the 25%-of-RAM dedicated-server rule.
|
||||
# Fixes Nextcloud 35 Database checks (pg.cache_hit_ratio,
|
||||
# pg.dead_tuples). Override in custom.nix for other hosts, e.g.:
|
||||
# services.postgresql.settings.shared_buffers = lib.mkForce "512MB";
|
||||
services.postgresql = {
|
||||
enable = true;
|
||||
authentication = lib.mkForce ''
|
||||
@@ -168,34 +171,6 @@
|
||||
host all all 127.0.0.1/32 trust
|
||||
host all all ::1/128 trust
|
||||
'';
|
||||
settings = {
|
||||
# Memory — fixes low buffer cache hit ratio (stock default is
|
||||
# 128MB shared_buffers). effective_cache_size is only a planner
|
||||
# hint, not an allocation, so it can be generous.
|
||||
# NOTE: changing shared_buffers requires a Postgres restart.
|
||||
shared_buffers = "2GB";
|
||||
effective_cache_size = "12GB";
|
||||
maintenance_work_mem = "512MB";
|
||||
work_mem = "32MB";
|
||||
wal_buffers = "64MB";
|
||||
|
||||
# Checkpoints — spread write bursts out on NVMe. Reload-only.
|
||||
min_wal_size = "1GB";
|
||||
max_wal_size = "4GB";
|
||||
checkpoint_completion_target = 0.9;
|
||||
|
||||
# Autovacuum — the stock 60s naptime can't keep up with
|
||||
# Nextcloud's and Synapse's write-heavy tables (filecache,
|
||||
# activity, jobs, state). Reload-only.
|
||||
autovacuum_naptime = "30s";
|
||||
autovacuum_vacuum_scale_factor = 0.05;
|
||||
autovacuum_analyze_scale_factor = 0.025;
|
||||
autovacuum_max_workers = 4;
|
||||
|
||||
# NVMe planner assumptions (README: NVMe OS + data disks).
|
||||
random_page_cost = "1.1";
|
||||
effective_io_concurrency = 200;
|
||||
};
|
||||
};
|
||||
|
||||
# ── Backups ────────────────────────────────────────────────
|
||||
@@ -217,9 +192,12 @@ backup /etc/nix-bitcoin-secrets/ localhost/
|
||||
};
|
||||
|
||||
# ── Cron ───────────────────────────────────────────────────
|
||||
# The legacy njalla.sh root cron job has been replaced by the systemd timer
|
||||
# defined in modules/core/njalla.nix (sovran-ddns-update.timer). Cron is
|
||||
# retained so that rsnapshot and other module-defined cron jobs continue to run.
|
||||
services.cron = {
|
||||
enable = true;
|
||||
systemCronJobs = [
|
||||
"*/15 * * * * root /run/current-system/sw/bin/bash /var/lib/njalla/njalla.sh"
|
||||
];
|
||||
};
|
||||
|
||||
# ── Tor ────────────────────────────────────────────────────
|
||||
services.tor = { enable = true; client.enable = true; torsocks.enable = true; };
|
||||
|
||||
@@ -22,11 +22,6 @@
|
||||
|
||||
# ─── Add your custom NixOS configuration below ───────────
|
||||
|
||||
# ─── Mullvad VPN (Desktop Only) ───────────────────────────
|
||||
# Installed on the Desktop Role as the stock NixOS service; account, relay
|
||||
# and settings belong to the app. To take it off a Desktop machine:
|
||||
# services.mullvad-vpn.enable = lib.mkForce false;
|
||||
|
||||
# ─── Custom Caddy virtual hosts ──────────────────────────
|
||||
# Uncomment and edit below to add your own Caddy sites:
|
||||
#
|
||||
|
||||
Generated
+162
-67
@@ -5,11 +5,11 @@
|
||||
"nixpkgs": "nixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1792157233,
|
||||
"narHash": "sha256-XQnyEc+Pj0Tg21ckbPMri0fiK/exCzhBw6wNgd1INqw=",
|
||||
"lastModified": 1780397635,
|
||||
"narHash": "sha256-6WH7LKD6i91VLWoz4mEpoULtqVinCEZxG7ZjJPMSi3k=",
|
||||
"owner": "emmanuelrosa",
|
||||
"repo": "btc-clients-nix",
|
||||
"rev": "990d29e23437f30b8573f640d3e323167de1dc7b",
|
||||
"rev": "feacd7684dc6bfcd49c57764944a2049bbd71924",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -18,6 +18,32 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"extra-container": {
|
||||
"inputs": {
|
||||
"flake-utils": [
|
||||
"nix-bitcoin",
|
||||
"flake-utils"
|
||||
],
|
||||
"nixpkgs": [
|
||||
"nix-bitcoin",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1766155727,
|
||||
"narHash": "sha256-XGp4HHH6D6ZKiO5RnMzqYJYnZB538EnEflvlTsOKpvo=",
|
||||
"owner": "erikarvstedt",
|
||||
"repo": "extra-container",
|
||||
"rev": "b450bdb24fca1076973c852d87bcb49b8eb5fd49",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "erikarvstedt",
|
||||
"ref": "0.14",
|
||||
"repo": "extra-container",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": [
|
||||
@@ -26,11 +52,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1790898470,
|
||||
"narHash": "sha256-zTwuwezD0w3hpga6a6P8emidzAZFyWqlNeRBlpWOOl8=",
|
||||
"lastModified": 1778716662,
|
||||
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "024633cd702b10285db5cb19b40ad48d2399ba60",
|
||||
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -39,13 +65,54 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix-bitcoin": {
|
||||
"inputs": {
|
||||
"extra-container": "extra-container",
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs_2",
|
||||
"nixpkgs-25_05": "nixpkgs-25_05",
|
||||
"nixpkgs-unstable": "nixpkgs-unstable"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1779253922,
|
||||
"narHash": "sha256-k5DpYVfyy27ELuEiV+51EfVg7B6vKUW63NWeA6eKGd0=",
|
||||
"owner": "fort-nix",
|
||||
"repo": "nix-bitcoin",
|
||||
"rev": "1496f842477976c085cd96f1837ea12444014088",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "fort-nix",
|
||||
"ref": "release",
|
||||
"repo": "nix-bitcoin",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1792155732,
|
||||
"narHash": "sha256-3Dk56anLQ1V1VklQFKFD6ul3cKQk40sThurHlPHIhOo=",
|
||||
"owner": "nixos",
|
||||
"lastModified": 1780218263,
|
||||
"narHash": "sha256-T/f0pPDrH3Qc1VXyQXbK7yfHWRn90l3xwplc/nsxin4=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "7694c4f6b8537d50062ff36c21ef36de3c9322d7",
|
||||
"rev": "7fc393d1b46fa000d48ff14e8b6a3c9985f03af0",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -54,13 +121,29 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-25_05": {
|
||||
"locked": {
|
||||
"lastModified": 1767313136,
|
||||
"narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-25.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-stable": {
|
||||
"locked": {
|
||||
"lastModified": 1792030654,
|
||||
"narHash": "sha256-OaQwAtPMTjQiY2sT/2/62UbSPhzP/0aDnWzMm/NCiUw=",
|
||||
"lastModified": 1780453794,
|
||||
"narHash": "sha256-bXMRa9VTsHSPXL4Cw8R6JJLQeY3Y/IP4+YJCYVmQ7FY=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "26f4107e47e3ad0ffd0db220469ba83b6f6d96e8",
|
||||
"rev": "6b316287bae2ee04c9b93c8c858d930fd07d7338",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -70,29 +153,13 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"nixpkgs-unstable": {
|
||||
"locked": {
|
||||
"lastModified": 1792094642,
|
||||
"narHash": "sha256-zp8ToDMnqZcu4o4IH9pdwXFXGp7yLm/M0dEnNWnyd7I=",
|
||||
"lastModified": 1778869304,
|
||||
"narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "fe13242281a67641c0102cdb1087f0ebe586c7df",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_3": {
|
||||
"locked": {
|
||||
"lastModified": 1791487292,
|
||||
"narHash": "sha256-tqM7xDEsaeK1p6VvRygeZ9LkQRpUUkbRT02BHv6hnTM=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "8edc0c72e3a38faf5434e40d1f19431125fe4b30",
|
||||
"rev": "d233902339c02a9c334e7e593de68855ad26c4cb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -102,13 +169,29 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_4": {
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1791956154,
|
||||
"narHash": "sha256-jBYGPi4udQ9uo4KQp9m2J0546nJVQitG15gvZSaVVAs=",
|
||||
"lastModified": 1778737229,
|
||||
"narHash": "sha256-6xWoytx8jFW4PF1GjRm/i/53trbpKGfz6zjzQGBr4cI=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "55bfb4d7437bd4e09ab7cba58a43bd68eb310044",
|
||||
"rev": "d7a713c0b7e47c908258e71cba7a2d77cc8d71d5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-25.11",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_3": {
|
||||
"locked": {
|
||||
"lastModified": 1780243769,
|
||||
"narHash": "sha256-x5UQuRsH3MqI0U9afaXSNqzTPSeZlRLvFAav2Ux1pNw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "331800de5053fcebacf6813adb5db9c9dca22a0c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -118,18 +201,34 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_4": {
|
||||
"locked": {
|
||||
"lastModified": 1780336545,
|
||||
"narHash": "sha256-vhVhuXzFrIOfcssC/9hDHx7MHzDKjF3keHuREOQqQiQ=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "4df1b885d76a54e1aa1a318f8d16fd6005b6401f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixvim": {
|
||||
"inputs": {
|
||||
"flake-parts": "flake-parts",
|
||||
"nixpkgs": "nixpkgs_3",
|
||||
"systems": "systems"
|
||||
"nixpkgs": "nixpkgs_4",
|
||||
"systems": "systems_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1792203519,
|
||||
"narHash": "sha256-yJ072hxngo/srNdLTp4BlFeo0hedwnKicI3TGlSXLFE=",
|
||||
"lastModified": 1780646548,
|
||||
"narHash": "sha256-Ckyl/l1XBmEwnaHcHD8PvBZk1uph0NqwbJ//CAvB7iE=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixvim",
|
||||
"rev": "453dc9c5037e6d1bec81d2a51ddacd58a2f44e9c",
|
||||
"rev": "816a15282e58678dde831477964987d0262d4293",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -141,38 +240,34 @@
|
||||
"root": {
|
||||
"inputs": {
|
||||
"btc-clients": "btc-clients",
|
||||
"nixpkgs": "nixpkgs_2",
|
||||
"nix-bitcoin": "nix-bitcoin",
|
||||
"nixpkgs": "nixpkgs_3",
|
||||
"nixpkgs-stable": "nixpkgs-stable",
|
||||
"nixvim": "nixvim",
|
||||
"sovran-bitcoin": "sovran-bitcoin"
|
||||
}
|
||||
},
|
||||
"sovran-bitcoin": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs_4"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1792361662,
|
||||
"narHash": "sha256-EySy/HbufH9trjH0kEuZ2nVVk0+Sn5FoxJI7YtHrspo=",
|
||||
"owner": "naturallaw777",
|
||||
"repo": "Sovran_Bitcoin",
|
||||
"rev": "5c13bf5e01beccc4a225a36cde833f00b2595718",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "naturallaw777",
|
||||
"repo": "Sovran_Bitcoin",
|
||||
"type": "github"
|
||||
"nixvim": "nixvim"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1791244623,
|
||||
"narHash": "sha256-uuanbkFk8a313Zfrxdcz8Us/yQtaxNnKJtX8v0ZPpd8=",
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "d36eeed142f88b5a636f2e3856a53c61654cf29b",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems_2": {
|
||||
"locked": {
|
||||
"lastModified": 1774449309,
|
||||
"narHash": "sha256-brhZ8DmuGtzkCYHJg4HEd602amKm89Y9ytsFZ5uWD1w=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "c29398b59d2048c4ab79345812849c9bd15e9150",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
nix-bitcoin.url = "github:fort-nix/nix-bitcoin/release";
|
||||
nixvim.url = "github:nix-community/nixvim";
|
||||
btc-clients.url = "github:emmanuelrosa/btc-clients-nix";
|
||||
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05";
|
||||
sovran-bitcoin.url = "github:naturallaw777/Sovran_Bitcoin";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, nixvim, btc-clients, nixpkgs-stable, sovran-bitcoin, ... }:
|
||||
outputs = { self, nixpkgs, nix-bitcoin, nixvim, btc-clients, nixpkgs-stable, ... }:
|
||||
|
||||
let
|
||||
overlay-stable = final: prev: {
|
||||
@@ -22,7 +22,7 @@
|
||||
{
|
||||
nixosConfigurations.nixos = nixpkgs.lib.nixosSystem {
|
||||
modules = [
|
||||
{ nixpkgs.hostPlatform = "x86_64-linux"; nixpkgs.overlays = [ overlay-stable ]; }
|
||||
{ nixpkgs.hostPlatform = "x86_64-linux"; }
|
||||
self.nixosModules.Sovran_SystemsOS
|
||||
./hardware-configuration.nix
|
||||
./role-state.nix
|
||||
@@ -32,9 +32,10 @@
|
||||
|
||||
nixosConfigurations.sovran_systemsos-iso = nixpkgs.lib.nixosSystem {
|
||||
modules = [
|
||||
{ nixpkgs.hostPlatform = "x86_64-linux"; nixpkgs.overlays = [ overlay-stable ]; }
|
||||
{ nixpkgs.hostPlatform = "x86_64-linux"; }
|
||||
({ config, pkgs, ... }: { nixpkgs.overlays = [ overlay-stable ]; })
|
||||
./iso/common.nix
|
||||
sovran-bitcoin.nixosModules.default
|
||||
nix-bitcoin.nixosModules.default
|
||||
nixvim.nixosModules.nixvim
|
||||
];
|
||||
};
|
||||
@@ -45,15 +46,14 @@
|
||||
nixpkgs.overlays = [ overlay-stable ];
|
||||
})
|
||||
./configuration.nix
|
||||
sovran-bitcoin.nixosModules.default
|
||||
./modules/sovran-bitcoin-integration.nix
|
||||
nix-bitcoin.nixosModules.default
|
||||
nixvim.nixosModules.nixvim
|
||||
];
|
||||
config = {
|
||||
environment.systemPackages = with pkgs; [
|
||||
btc-clients.packages.${pkgs.stdenv.hostPlatform.system}.bisq
|
||||
btc-clients.packages.${pkgs.stdenv.hostPlatform.system}.bisq2
|
||||
btc-clients.packages.${pkgs.stdenv.hostPlatform.system}.sparrow
|
||||
btc-clients.packages.${pkgs.system}.bisq
|
||||
btc-clients.packages.${pkgs.system}.bisq2
|
||||
btc-clients.packages.${pkgs.system}.sparrow
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
+2
-10
@@ -3,14 +3,6 @@
|
||||
let
|
||||
sovranSource = builtins.path { path = ../.; name = "sovran-systemsos"; };
|
||||
|
||||
# Read version from VERSION file if it exists, otherwise fall back to "dev"
|
||||
versionFile = if builtins.pathExists ../VERSION
|
||||
then builtins.readFile ../VERSION
|
||||
else "dev";
|
||||
|
||||
# Clean version (remove 'v' prefix and newlines)
|
||||
cleanVersion = builtins.replaceStrings ["v" "\n" "\r"] ["" "" ""] versionFile;
|
||||
|
||||
pythonEnv = pkgs.python3.withPackages (ps: [ ps.pygobject3 ps.pycairo ]);
|
||||
|
||||
installerPy = pkgs.writeShellScriptBin "sovran-install" ''
|
||||
@@ -24,10 +16,10 @@ in
|
||||
{
|
||||
imports = [
|
||||
"${modulesPath}/installer/cd-dvd/installation-cd-graphical-gnome.nix"
|
||||
./branding.nix
|
||||
];
|
||||
|
||||
# Dynamic ISO filename with version
|
||||
image.baseName = lib.mkForce "Sovran_SystemsOS-${cleanVersion}";
|
||||
image.baseName = lib.mkForce "Sovran_SystemsOS";
|
||||
isoImage.splashImage = ./assets/splash-logo.png;
|
||||
|
||||
services.gnome.gnome-initial-setup.enable = false;
|
||||
|
||||
+57
-191
@@ -54,14 +54,9 @@ DICEWARE_WORDS = [
|
||||
]
|
||||
|
||||
def generate_diceware_password():
|
||||
# 4 words from a 96 word list plus 2 digits: 96^4 x 100 = ~8.5e9, about
|
||||
# 33 bits. The old 3 words plus 1 digit was 96^3 x 10 = ~8.8e6, about 23
|
||||
# bits, for a password that is simultaneously the desktop login, the
|
||||
# 'free' account password and the only thing in front of a Hub that runs
|
||||
# as root and hands out every stored credential.
|
||||
words = [secrets.choice(DICEWARE_WORDS) for _ in range(4)]
|
||||
digits = f"{secrets.randbelow(100):02d}"
|
||||
return "-".join(words) + f"-{digits}"
|
||||
words = [secrets.choice(DICEWARE_WORDS) for _ in range(3)]
|
||||
digit = secrets.randbelow(10)
|
||||
return "-".join(words) + f"-{digit}"
|
||||
|
||||
try:
|
||||
logfile = open(LOG, "a")
|
||||
@@ -108,81 +103,25 @@ def human_size(nbytes):
|
||||
return f"{nbytes:.1f} PB"
|
||||
|
||||
def check_internet():
|
||||
"""Return True if the machine can reach the internet.
|
||||
|
||||
Some VM NAT setups and managed networks block ICMP even when HTTPS works,
|
||||
so keep the existing ping checks but fall back to small HTTPS requests.
|
||||
"""
|
||||
checks = [
|
||||
["ping", "-c", "1", "-W", "5", "nixos.org"],
|
||||
["curl", "--location", "--fail", "--silent", "--show-error", "--connect-timeout", "5", "--max-time", "10", "--output", "/dev/null", "https://cache.nixos.org/nix-cache-info"],
|
||||
["curl", "--location", "--fail", "--silent", "--show-error", "--connect-timeout", "5", "--max-time", "10", "--output", "/dev/null", "https://nixos.org/"],
|
||||
["ping", "-c", "1", "-W", "5", "1.1.1.1"],
|
||||
]
|
||||
|
||||
for cmd in checks:
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
return True
|
||||
log(f"Connectivity check failed ({' '.join(cmd)}): {result.stderr.strip() or result.stdout.strip()}")
|
||||
except Exception as e:
|
||||
log(f"Connectivity check failed ({' '.join(cmd)}): {e}")
|
||||
|
||||
return False
|
||||
|
||||
def detect_virtualization():
|
||||
"""Return the VM technology name when running inside a VM, else None."""
|
||||
"""Return True if the machine can reach the internet."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["systemd-detect-virt", "--vm"],
|
||||
["ping", "-c", "1", "-W", "5", "nixos.org"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.returncode == 0:
|
||||
name = result.stdout.strip()
|
||||
if name == "none":
|
||||
return None
|
||||
return name or "virtual machine"
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback for minimal environments where systemd-detect-virt is absent.
|
||||
dmi_paths = [
|
||||
"/sys/class/dmi/id/product_name",
|
||||
"/sys/class/dmi/id/sys_vendor",
|
||||
"/sys/class/dmi/id/board_vendor",
|
||||
]
|
||||
markers = (
|
||||
"kvm", "qemu", "virtualbox", "vmware", "hyper-v",
|
||||
"bhyve", "parallels", "xen", "bochs", "virtual",
|
||||
)
|
||||
for dmi_path in dmi_paths:
|
||||
# Fallback: try a second host in case DNS for nixos.org is down
|
||||
try:
|
||||
value = open(dmi_path, "r", encoding="utf-8", errors="ignore").read().strip()
|
||||
except OSError:
|
||||
continue
|
||||
lowered = value.lower()
|
||||
if any(marker in lowered for marker in markers):
|
||||
return value or "virtual machine"
|
||||
|
||||
return None
|
||||
|
||||
def detect_boot_mode():
|
||||
"""Return the firmware mode used to boot the installer ISO."""
|
||||
return "uefi" if os.path.isdir("/sys/firmware/efi") else "bios"
|
||||
|
||||
def boot_mode_label(mode):
|
||||
if mode == "uefi":
|
||||
return "UEFI (systemd-boot)"
|
||||
return "Legacy BIOS (GRUB)"
|
||||
|
||||
def nix_string(value):
|
||||
"""Quote a Python string for a simple Nix string literal."""
|
||||
escaped = value.replace("\\", "\\\\")
|
||||
escaped = escaped.replace('"', '\\"')
|
||||
escaped = escaped.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
|
||||
escaped = escaped.replace("${", "\\${")
|
||||
return f'"{escaped}"'
|
||||
result = subprocess.run(
|
||||
["ping", "-c", "1", "-W", "5", "1.1.1.1"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return result.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def symbolic_icon(name):
|
||||
"""Create a crisp symbolic icon suitable for use as an ActionRow prefix."""
|
||||
@@ -220,10 +159,6 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
self.data_size = None
|
||||
self.data_drive_has_timechain = False
|
||||
self.free_password = None
|
||||
self.virtualization = detect_virtualization()
|
||||
self.boot_mode = detect_boot_mode()
|
||||
|
||||
log(f"Installer environment: boot_mode={self.boot_mode}, virtualization={self.virtualization or 'none'}")
|
||||
|
||||
# Root navigation view
|
||||
self.nav = Adw.NavigationView()
|
||||
@@ -408,7 +343,7 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
if os.path.exists(LOGO):
|
||||
try:
|
||||
img = Gtk.Image.new_from_file(LOGO)
|
||||
img.set_pixel_size(320 if self.virtualization else 480)
|
||||
img.set_pixel_size(480)
|
||||
hero.append(img)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -428,41 +363,39 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
sep.set_margin_end(40)
|
||||
outer.append(sep)
|
||||
|
||||
if self.virtualization:
|
||||
vm_frame = Gtk.Frame()
|
||||
vm_frame.add_css_class("card")
|
||||
vm_frame.set_margin_start(40)
|
||||
vm_frame.set_margin_end(40)
|
||||
vm_frame.set_margin_top(12)
|
||||
vm_frame.set_margin_bottom(4)
|
||||
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)
|
||||
|
||||
vm_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
||||
vm_box.set_margin_top(12)
|
||||
vm_box.set_margin_bottom(12)
|
||||
vm_box.set_margin_start(16)
|
||||
vm_box.set_margin_end(16)
|
||||
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)
|
||||
|
||||
vm_icon = symbolic_icon("computer-symbolic")
|
||||
vm_icon.set_valign(Gtk.Align.START)
|
||||
vm_box.append(vm_icon)
|
||||
notice_icon = symbolic_icon("dialog-information-symbolic")
|
||||
notice_icon.set_valign(Gtk.Align.START)
|
||||
notice_box.append(notice_icon)
|
||||
|
||||
vm_lbl = Gtk.Label()
|
||||
vm_lbl.set_use_markup(True)
|
||||
vm_lbl.set_wrap(True)
|
||||
vm_lbl.set_xalign(0)
|
||||
vm_lbl.set_halign(Gtk.Align.FILL)
|
||||
vm_lbl.set_markup(
|
||||
f"<span weight='bold'>Virtual machine detected: {GLib.markup_escape_text(self.virtualization)}</span>\n"
|
||||
f"Boot mode: <span weight='bold'>{boot_mode_label(self.boot_mode)}</span>. "
|
||||
"For the smoothest VM test, use 8+ GB RAM, NAT/bridged networking, "
|
||||
"and a thin-provisioned virtual OS disk of at least 256 GB. "
|
||||
"For Node or Server + Desktop, attach a second 2 TB virtual data disk; "
|
||||
"otherwise choose Desktop Only."
|
||||
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>"
|
||||
)
|
||||
vm_box.append(vm_lbl)
|
||||
notice_box.append(notice_lbl)
|
||||
|
||||
vm_frame.set_child(vm_box)
|
||||
outer.append(vm_frame)
|
||||
notice_frame.set_child(notice_box)
|
||||
outer.append(notice_frame)
|
||||
|
||||
# Role label
|
||||
role_lbl = Gtk.Label()
|
||||
@@ -476,10 +409,10 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
# Role cards
|
||||
roles = [
|
||||
("Server + Desktop",
|
||||
"Full sovereignty: host your own websites, cloud, chat, passwords, and Bitcoin services instead of relying on Big Tech. Sovran_SystemsOS walks you through getting your domain from Njal.la and connecting everything. One router task is required: forward ports 80 and 443 to this computer. Heads-up: this makes your home IP address public.",
|
||||
"Full sovereign experience: beautiful desktop, your own cloud, secure messaging, Bitcoin node, and more.",
|
||||
"Server+Desktop"),
|
||||
("Desktop Only",
|
||||
"A beautiful, easy-to-use desktop without the background server applications. Includes the Mullvad VPN app.",
|
||||
"A beautiful, easy-to-use desktop without the background server applications.",
|
||||
"Desktop Only"),
|
||||
("Node Only",
|
||||
"Full Bitcoin node with Lightning and non-KYC buying and selling. No desktop.",
|
||||
@@ -515,10 +448,7 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
|
||||
available = has_second_drive or key not in NEEDS_DATA_DRIVE
|
||||
if not available:
|
||||
drive_hint = "⚠ Requires a second internal drive (not detected)"
|
||||
if self.virtualization:
|
||||
drive_hint += " — in a VM, attach a second virtual disk or choose Desktop Only"
|
||||
card.set_subtitle(desc + f"\n{drive_hint}")
|
||||
card.set_subtitle(desc + "\n⚠ Requires a second internal drive (not detected)")
|
||||
card.set_sensitive(False)
|
||||
else:
|
||||
card.set_subtitle(desc)
|
||||
@@ -601,10 +531,9 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
# ── OS Drive group ────────────────────────────────────────────
|
||||
os_group = Adw.PreferencesGroup()
|
||||
os_group.set_title("OS Drive (NixOS Boot + Root)")
|
||||
os_description = "Choose the drive for the NixOS installation. Minimum 256 GB required."
|
||||
if self.virtualization:
|
||||
os_description += " In a VM, a thin-provisioned virtual disk is OK."
|
||||
os_group.set_description(os_description)
|
||||
os_group.set_description(
|
||||
"Choose the drive for the NixOS installation. Minimum 256 GB required."
|
||||
)
|
||||
os_group.set_margin_top(24)
|
||||
os_group.set_margin_start(40)
|
||||
os_group.set_margin_end(40)
|
||||
@@ -617,8 +546,6 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
row.set_title(f"/dev/{name}")
|
||||
type_label = tran.upper() if tran else "Disk"
|
||||
meets = "✓ Meets 256 GB minimum" if size >= BYTES_256GB else "✗ Below 256 GB minimum"
|
||||
if self.virtualization and size < BYTES_256GB:
|
||||
meets += " (expand the virtual disk)"
|
||||
row.set_subtitle(f"{human_size(size)} · {type_label} — {meets}")
|
||||
row.add_prefix(symbolic_icon("drive-harddisk-symbolic"))
|
||||
|
||||
@@ -643,14 +570,11 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
if self.role != "Desktop Only":
|
||||
data_group = Adw.PreferencesGroup()
|
||||
data_group.set_title("Bitcoin Timechain & Backups Drive")
|
||||
data_description = (
|
||||
data_group.set_description(
|
||||
"💡 Tip: Always assign your LARGEST drive here. "
|
||||
"The full Bitcoin timechain is over 700 GB and grows continuously — "
|
||||
"a 2 TB or larger drive is required."
|
||||
)
|
||||
if self.virtualization:
|
||||
data_description += " In a VM, attach a second 2 TB thin-provisioned virtual disk for Node or Server + Desktop."
|
||||
data_group.set_description(data_description)
|
||||
data_group.set_margin_top(20)
|
||||
data_group.set_margin_start(40)
|
||||
data_group.set_margin_end(40)
|
||||
@@ -675,8 +599,6 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
row.set_title(f"/dev/{name}")
|
||||
type_label = tran.upper() if tran else "Disk"
|
||||
meets = "✓ Meets 2 TB minimum" if size >= BYTES_2TB else "✗ Below 2 TB minimum"
|
||||
if self.virtualization and size < BYTES_2TB:
|
||||
meets += " (use a 2 TB thin-provisioned virtual disk)"
|
||||
row.set_subtitle(f"{human_size(size)} · {type_label} — {meets}")
|
||||
row.add_prefix(symbolic_icon("drive-harddisk-symbolic"))
|
||||
|
||||
@@ -733,16 +655,10 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
dlg = Adw.MessageDialog()
|
||||
dlg.set_transient_for(self)
|
||||
dlg.set_heading("OS Drive Too Small")
|
||||
body = (
|
||||
dlg.set_body(
|
||||
f"The selected OS drive (/dev/{os_name}, {human_size(os_size)}) "
|
||||
f"does not meet the 256 GB minimum. Please choose a larger drive."
|
||||
)
|
||||
if self.virtualization:
|
||||
body += (
|
||||
"\n\nVM tip: expand the virtual OS disk to at least 256 GB. "
|
||||
"Thin-provisioned disks usually do not consume the full size immediately."
|
||||
)
|
||||
dlg.set_body(body)
|
||||
dlg.add_response("ok", "OK")
|
||||
dlg.present()
|
||||
return
|
||||
@@ -752,18 +668,12 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
dlg = Adw.MessageDialog()
|
||||
dlg.set_transient_for(self)
|
||||
dlg.set_heading("Bitcoin Drive Too Small")
|
||||
body = (
|
||||
dlg.set_body(
|
||||
f"The selected Bitcoin Timechain & Backups drive "
|
||||
f"(/dev/{data_name}, {human_size(data_size)}) "
|
||||
f"does not meet the 2 TB minimum. "
|
||||
f"Please choose a larger drive or select \"None\"."
|
||||
)
|
||||
if self.virtualization:
|
||||
body += (
|
||||
"\n\nVM tip: attach a second 2 TB thin-provisioned virtual disk "
|
||||
"for Node or Server + Desktop test installs."
|
||||
)
|
||||
dlg.set_body(body)
|
||||
dlg.add_response("ok", "OK")
|
||||
dlg.present()
|
||||
return
|
||||
@@ -919,12 +829,7 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
# ── Worker: partition ─────────────────────────────────────────────────
|
||||
|
||||
def partition_path(self, dev_path, num):
|
||||
base = os.path.basename(dev_path)
|
||||
needs_p = (
|
||||
base.startswith(("nvme", "mmcblk", "loop", "md")) or
|
||||
(base[-1:].isdigit())
|
||||
)
|
||||
return f"{dev_path}p{num}" if needs_p else f"{dev_path}{num}"
|
||||
return f"{dev_path}p{num}" if "nvme" in dev_path else f"{dev_path}{num}"
|
||||
|
||||
def detect_existing_timechain_data(self, data_path, buf=None):
|
||||
data_p1 = self.partition_path(data_path, 1)
|
||||
@@ -995,19 +900,12 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
# ── Partition boot disk ──
|
||||
if self.boot_mode == "uefi":
|
||||
GLib.idle_add(append_text, buf, "\n=== Partitioning boot disk (UEFI) ===\n")
|
||||
# ── Partition boot disk: 512M ESP + rest as root ──
|
||||
GLib.idle_add(append_text, buf, "\n=== Partitioning boot disk ===\n")
|
||||
run_stream(["sudo", "sgdisk",
|
||||
"-n", "1:1M:+512M", "-t", "1:EF00", "-c", "1:ESP",
|
||||
"-n", "2:0:0", "-t", "2:8300", "-c", "2:root",
|
||||
boot_path], buf)
|
||||
else:
|
||||
GLib.idle_add(append_text, buf, "\n=== Partitioning boot disk (Legacy BIOS) ===\n")
|
||||
run_stream(["sudo", "sgdisk",
|
||||
"-n", "1:1M:+1M", "-t", "1:EF02", "-c", "1:BIOS-boot",
|
||||
"-n", "2:0:0", "-t", "2:8300", "-c", "2:root",
|
||||
boot_path], buf)
|
||||
|
||||
run_stream(["sudo", "partprobe", boot_path], buf)
|
||||
time.sleep(2)
|
||||
@@ -1027,10 +925,7 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
boot_p1 = self.partition_path(boot_path, 1)
|
||||
boot_p2 = self.partition_path(boot_path, 2)
|
||||
|
||||
if self.boot_mode == "uefi":
|
||||
run_stream(["sudo", "mkfs.vfat", "-F", "32", boot_p1], buf)
|
||||
else:
|
||||
GLib.idle_add(append_text, buf, "Skipping ESP format for Legacy BIOS install.\n")
|
||||
run_stream(["sudo", "mkfs.ext4", "-F", "-L", "sovran_systemsos", boot_p2], buf)
|
||||
|
||||
if data_path and not self.data_drive_has_timechain:
|
||||
@@ -1040,11 +935,8 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
# ── Mount filesystems ──
|
||||
GLib.idle_add(append_text, buf, "\n=== Mounting filesystems ===\n")
|
||||
run_stream(["sudo", "mount", boot_p2, "/mnt"], buf)
|
||||
if self.boot_mode == "uefi":
|
||||
run_stream(["sudo", "mkdir", "-p", "/mnt/boot/efi"], buf)
|
||||
run_stream(["sudo", "mount", "-o", "umask=0077,defaults", boot_p1, "/mnt/boot/efi"], buf)
|
||||
else:
|
||||
GLib.idle_add(append_text, buf, "Legacy BIOS install: /boot/efi mount not required.\n")
|
||||
|
||||
if data_path:
|
||||
data_p1 = self.partition_path(data_path, 1)
|
||||
@@ -1074,33 +966,12 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
is_server = str(self.role == "Server+Desktop").lower()
|
||||
is_desktop = str(self.role == "Desktop Only").lower()
|
||||
is_node = str(self.role == "Node (Bitcoin-only)").lower()
|
||||
boot_overrides = ""
|
||||
if self.boot_mode == "bios":
|
||||
grub_device = nix_string(f"/dev/{self.boot_disk}")
|
||||
boot_overrides = f"""
|
||||
|
||||
# The installer ISO was booted in Legacy BIOS mode, so install GRUB to the
|
||||
# target disk instead of using the default UEFI systemd-boot configuration.
|
||||
boot.loader.systemd-boot.enable = lib.mkForce false;
|
||||
boot.loader.efi.canTouchEfiVariables = lib.mkForce false;
|
||||
boot.loader.grub.enable = lib.mkForce true;
|
||||
boot.loader.grub.device = lib.mkForce {grub_device};
|
||||
fileSystems."/boot/efi".enable = lib.mkForce false;
|
||||
"""
|
||||
elif self.virtualization:
|
||||
boot_overrides = """
|
||||
|
||||
# VM UEFI firmware can reject or forget NVRAM boot-entry writes. Keep the
|
||||
# normal UEFI systemd-boot layout, but use the fallback removable path instead
|
||||
# of depending on EFI variable updates.
|
||||
boot.loader.efi.canTouchEfiVariables = lib.mkForce false;
|
||||
"""
|
||||
content = f"""# THIS FILE IS AUTO-GENERATED BY THE INSTALLER. DO NOT EDIT.
|
||||
{{ config, lib, ... }}:
|
||||
{{
|
||||
sovran_systemsOS.roles.server_plus_desktop = lib.mkDefault {is_server};
|
||||
sovran_systemsOS.roles.desktop = lib.mkDefault {is_desktop};
|
||||
sovran_systemsOS.roles.node = lib.mkDefault {is_node};{boot_overrides}
|
||||
sovran_systemsOS.roles.node = lib.mkDefault {is_node};
|
||||
}}
|
||||
"""
|
||||
proc = subprocess.run(
|
||||
@@ -1137,11 +1008,6 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
boot_row.set_subtitle(f"/dev/{self.boot_disk} — {human_size(self.boot_size)}")
|
||||
details.add(boot_row)
|
||||
|
||||
boot_mode_row = Adw.ActionRow()
|
||||
boot_mode_row.set_title("Boot Mode")
|
||||
boot_mode_row.set_subtitle(boot_mode_label(self.boot_mode))
|
||||
details.add(boot_mode_row)
|
||||
|
||||
if self.data_disk:
|
||||
data_row = Adw.ActionRow()
|
||||
data_row.set_title("Data Disk")
|
||||
@@ -1303,7 +1169,7 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
|
||||
btn_box.set_halign(Gtk.Align.CENTER)
|
||||
btn_box.set_margin_bottom(32)
|
||||
reboot_btn = Gtk.Button(label="I Have Written Down My Password — Restart Entire System")
|
||||
reboot_btn = Gtk.Button(label="I Have Written Down My Password — Reboot Now")
|
||||
reboot_btn.add_css_class("suggested-action")
|
||||
reboot_btn.add_css_class("pill")
|
||||
reboot_btn.connect("clicked", lambda b: subprocess.run(["sudo", "reboot"]))
|
||||
|
||||
@@ -12,11 +12,6 @@ Options:
|
||||
--deploy-key "ssh-ed25519 AAAA..." SSH pubkey for remote access after install
|
||||
--headscale-server URL Headscale login server for post-install Tailnet
|
||||
--headscale-key KEY Headscale pre-auth key for the installed OS
|
||||
|
||||
VM notes:
|
||||
Desktop test installs still need a 256 GB OS disk (thin-provisioned is OK).
|
||||
Node/server installs need a separate 2 TB data disk. UEFI is preferred; UEFI
|
||||
VMs avoid NVRAM boot-entry writes, and legacy BIOS VMs use GRUB.
|
||||
USAGE
|
||||
}
|
||||
|
||||
@@ -30,8 +25,6 @@ DEPLOY_KEY=""
|
||||
HEADSCALE_SERVER=""
|
||||
HEADSCALE_KEY=""
|
||||
DATA_DISK_HAS_TIMECHAIN=false
|
||||
BOOT_MODE=""
|
||||
VIRTUALIZATION=""
|
||||
|
||||
FLAKE="/etc/sovran/flake"
|
||||
LOG="/tmp/sovran-headless-install.log"
|
||||
@@ -76,22 +69,6 @@ case "$ROLE" in
|
||||
*) die "--role must be one of: server, desktop, node" ;;
|
||||
esac
|
||||
|
||||
# ── Detect firmware / VM environment ─────────────────────────────────────────
|
||||
if [[ -d /sys/firmware/efi ]]; then
|
||||
BOOT_MODE="uefi"
|
||||
else
|
||||
BOOT_MODE="bios"
|
||||
fi
|
||||
|
||||
if command -v systemd-detect-virt >/dev/null 2>&1; then
|
||||
if VIRT_RESULT=$(systemd-detect-virt --vm 2>/dev/null); then
|
||||
[[ "$VIRT_RESULT" != "none" ]] && VIRTUALIZATION="$VIRT_RESULT"
|
||||
fi
|
||||
fi
|
||||
|
||||
log "Boot mode: ${BOOT_MODE} ($([[ "$BOOT_MODE" == uefi ]] && echo systemd-boot || echo GRUB))"
|
||||
[[ -n "$VIRTUALIZATION" ]] && log "Virtual machine detected: ${VIRTUALIZATION}"
|
||||
|
||||
# ── Validate disk existence and size ─────────────────────────────────────────
|
||||
log "=== Validating disks ==="
|
||||
|
||||
@@ -119,26 +96,13 @@ fi
|
||||
# ── Helper: partition suffix ──────────────────────────────────────────────────
|
||||
part_suffix() {
|
||||
local dev="$1" n="$2"
|
||||
local base
|
||||
base=$(basename "$dev")
|
||||
if [[ "$base" == nvme* || "$base" == mmcblk* || "$base" == loop* || "$base" == md* || "$base" =~ [0-9]$ ]]; then
|
||||
if [[ "$dev" == *nvme* ]]; then
|
||||
echo "${dev}p${n}"
|
||||
else
|
||||
echo "${dev}${n}"
|
||||
fi
|
||||
}
|
||||
|
||||
nix_string() {
|
||||
local value="$1"
|
||||
value="${value//\\/\\\\}"
|
||||
value="${value//\"/\\\"}"
|
||||
value="${value//$'\n'/\\n}"
|
||||
value="${value//$'\r'/\\r}"
|
||||
value="${value//$'\t'/\\t}"
|
||||
value="${value//\$\{/\\\$\{}"
|
||||
printf '"%s"' "$value"
|
||||
}
|
||||
|
||||
# ── Detect existing Bitcoin timechain data on data disk ───────────────────────
|
||||
if [[ -n "$DATA_DISK" ]]; then
|
||||
DATA_P1=$(part_suffix "$DATA_DISK" 1)
|
||||
@@ -178,19 +142,12 @@ partprobe "$DISK"
|
||||
sleep 2
|
||||
|
||||
# ── Step 2: Partition OS disk ─────────────────────────────────────────────────
|
||||
if [[ "$BOOT_MODE" == "uefi" ]]; then
|
||||
log "=== Partitioning OS disk (UEFI) ==="
|
||||
log "=== Partitioning OS disk ==="
|
||||
|
||||
sgdisk \
|
||||
-n "1:1M:+512M" -t "1:EF00" -c "1:ESP" \
|
||||
-n "2:0:0" -t "2:8300" -c "2:root" \
|
||||
"$DISK"
|
||||
else
|
||||
log "=== Partitioning OS disk (Legacy BIOS) ==="
|
||||
sgdisk \
|
||||
-n "1:1M:+1M" -t "1:EF02" -c "1:BIOS-boot" \
|
||||
-n "2:0:0" -t "2:8300" -c "2:root" \
|
||||
"$DISK"
|
||||
fi
|
||||
|
||||
partprobe "$DISK"
|
||||
sleep 2
|
||||
@@ -211,11 +168,7 @@ log "=== Formatting partitions ==="
|
||||
BOOT_P1=$(part_suffix "$DISK" 1)
|
||||
BOOT_P2=$(part_suffix "$DISK" 2)
|
||||
|
||||
if [[ "$BOOT_MODE" == "uefi" ]]; then
|
||||
mkfs.vfat -F 32 "$BOOT_P1"
|
||||
else
|
||||
log "Skipping ESP format for Legacy BIOS install"
|
||||
fi
|
||||
mkfs.ext4 -F -L sovran_systemsos "$BOOT_P2"
|
||||
|
||||
if [[ -n "$DATA_DISK" && "$DATA_DISK_HAS_TIMECHAIN" != true ]]; then
|
||||
@@ -227,12 +180,8 @@ fi
|
||||
log "=== Mounting filesystems ==="
|
||||
|
||||
mount "$BOOT_P2" /mnt
|
||||
if [[ "$BOOT_MODE" == "uefi" ]]; then
|
||||
mkdir -p /mnt/boot/efi
|
||||
mount -o umask=0077,defaults "$BOOT_P1" /mnt/boot/efi
|
||||
else
|
||||
log "Legacy BIOS install: /boot/efi mount not required"
|
||||
fi
|
||||
|
||||
if [[ -n "$DATA_DISK" ]]; then
|
||||
DATA_P1=$(part_suffix "$DATA_DISK" 1)
|
||||
@@ -270,41 +219,16 @@ case "$ROLE" in
|
||||
IS_SERVER=false; IS_DESKTOP=false; IS_NODE=true ;;
|
||||
esac
|
||||
|
||||
{
|
||||
cat <<EOF
|
||||
cat > /mnt/etc/nixos/role-state.nix <<EOF
|
||||
# THIS FILE IS AUTO-GENERATED BY THE INSTALLER. DO NOT EDIT.
|
||||
{ config, lib, ... }:
|
||||
{
|
||||
sovran_systemsOS.roles.server_plus_desktop = lib.mkDefault ${IS_SERVER};
|
||||
sovran_systemsOS.roles.desktop = lib.mkDefault ${IS_DESKTOP};
|
||||
sovran_systemsOS.roles.node = lib.mkDefault ${IS_NODE};
|
||||
}
|
||||
EOF
|
||||
|
||||
if [[ "$BOOT_MODE" == "bios" ]]; then
|
||||
GRUB_DEVICE=$(nix_string "$DISK")
|
||||
cat <<EOF
|
||||
|
||||
# The installer ISO was booted in Legacy BIOS mode, so install GRUB to the
|
||||
# target disk instead of using the default UEFI systemd-boot configuration.
|
||||
boot.loader.systemd-boot.enable = lib.mkForce false;
|
||||
boot.loader.efi.canTouchEfiVariables = lib.mkForce false;
|
||||
boot.loader.grub.enable = lib.mkForce true;
|
||||
boot.loader.grub.device = lib.mkForce ${GRUB_DEVICE};
|
||||
fileSystems."/boot/efi".enable = lib.mkForce false;
|
||||
EOF
|
||||
elif [[ -n "$VIRTUALIZATION" ]]; then
|
||||
cat <<EOF
|
||||
|
||||
# VM UEFI firmware can reject or forget NVRAM boot-entry writes. Keep the
|
||||
# normal UEFI systemd-boot layout, but use the fallback removable path instead
|
||||
# of depending on EFI variable updates.
|
||||
boot.loader.efi.canTouchEfiVariables = lib.mkForce false;
|
||||
EOF
|
||||
fi
|
||||
|
||||
echo "}"
|
||||
} > /mnt/etc/nixos/role-state.nix
|
||||
|
||||
# ── Step 10: Write custom.nix with deploy config ──────────────────────────────
|
||||
log "=== Writing custom.nix ==="
|
||||
|
||||
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
lib.mkIf config.sovran_systemsOS.features.bitcoin-core {
|
||||
|
||||
services.bitcoind.package = lib.mkForce config.nix-bitcoin.pkgs.bitcoind;
|
||||
|
||||
}
|
||||
Executable
+116
@@ -0,0 +1,116 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
lib.mkIf config.sovran_systemsOS.services.bitcoin {
|
||||
|
||||
services.bitcoind = {
|
||||
enable = true;
|
||||
package = pkgs.bitcoind-knots;
|
||||
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;
|
||||
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";
|
||||
};
|
||||
|
||||
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"; }
|
||||
];
|
||||
}
|
||||
+30
-64
@@ -12,56 +12,11 @@ let
|
||||
|| config.sovran_systemsOS.services.nextcloud
|
||||
|| config.sovran_systemsOS.services.vaultwarden
|
||||
|| config.sovran_systemsOS.features.haven
|
||||
|| config.sovran_systemsOS.features."nwc-wallets"
|
||||
|| config.sovran_systemsOS.features.element-calling;
|
||||
|
||||
# RTL and Mempool listen on loopback only: Sovran_Bitcoin binds them to
|
||||
# 127.0.0.1, and RTL's unit is sandboxed to loopback besides. Caddy is how
|
||||
# the local network reaches them (:3051 and :60847), so it has to run
|
||||
# wherever they do. That includes Bitcoin Node Only, which has no
|
||||
# domain-based service and so no other reason to run Caddy.
|
||||
#
|
||||
# The Hub is not one of these. It listens on 0.0.0.0:8937 itself, so it is
|
||||
# served on its own port rather than through Caddy: the one service that
|
||||
# runs as root has nothing in front of it that it does not need, and the
|
||||
# public sites on ports 80/443 cannot be asked for it by Host header.
|
||||
servesRtl = config.sovran_systemsOS.services.bitcoin;
|
||||
servesMempool = servesRtl && config.sovran_systemsOS.features.mempool;
|
||||
|
||||
caddyEnabled = needsHttpsPorts || extraVhosts != "" || servesRtl;
|
||||
|
||||
# Sites for the local network, one per loopback-only service. Written after
|
||||
# the public domain sites; each exists only where its service does.
|
||||
#
|
||||
# They do not filter by client address. A request can only reach them on
|
||||
# their own ports, which no setup step asks you to forward, so forwarding
|
||||
# 80/443 for public services does not expose them (a Host header on those
|
||||
# ports cannot select a site that listens elsewhere). RTL has its own
|
||||
# password and lockout, and Mempool shows public chain data. An address
|
||||
# check here could not be made right for IPv6 anyway: a laptop's global
|
||||
# address on the LAN looks exactly like a stranger's.
|
||||
bitcoinUiSites =
|
||||
lib.optionalString servesRtl ''
|
||||
|
||||
:3051 {
|
||||
reverse_proxy :3050
|
||||
encode gzip zstd
|
||||
}
|
||||
''
|
||||
+ lib.optionalString servesMempool ''
|
||||
|
||||
:60847 {
|
||||
reverse_proxy :60845
|
||||
encode gzip zstd
|
||||
}
|
||||
'';
|
||||
in
|
||||
{
|
||||
services.caddy = {
|
||||
# Caddy runs when a domain-based service needs it, when the operator has
|
||||
# defined custom vhosts, or when it is the way to reach RTL and Mempool.
|
||||
# Desktop Only has none of those, so Caddy stays off there.
|
||||
enable = caddyEnabled;
|
||||
enable = true;
|
||||
user = "caddy";
|
||||
group = "root";
|
||||
};
|
||||
@@ -112,7 +67,6 @@ in
|
||||
BTCPAY=$(read_domain btcpayserver)
|
||||
VAULTWARDEN=$(read_domain vaultwarden)
|
||||
HAVEN=$(read_domain haven)
|
||||
LIGHTNING=$(read_domain lightning)
|
||||
ACME_EMAIL=$(read_domain sslemail)
|
||||
|
||||
# Start with global config — use ACME only when domain-based services are active
|
||||
@@ -140,10 +94,10 @@ EOF
|
||||
$MATRIX {
|
||||
reverse_proxy /_matrix/* http://localhost:8008
|
||||
reverse_proxy /_synapse/client/* http://localhost:8008
|
||||
handle /.well-known/matrix/server {
|
||||
header Content-Type application/json
|
||||
respond \`{"m.server":"$MATRIX:443"}\` 200
|
||||
}
|
||||
|
||||
$MATRIX:8448 {
|
||||
reverse_proxy http://localhost:8008
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
@@ -229,25 +183,37 @@ $HAVEN {
|
||||
EOF
|
||||
fi
|
||||
|
||||
# ── Lightning Wallet Connections LNURL ──────────────────────
|
||||
if [ -n "$LIGHTNING" ]; then
|
||||
# ── Sovran Hub (LAN access via mDNS) ────────────
|
||||
cat >> /run/caddy/Caddyfile <<EOF
|
||||
|
||||
$LIGHTNING {
|
||||
# LNURL discovery and callback are served by the dedicated
|
||||
# nwc-lnurl service on loopback port 8181. Only these paths
|
||||
# are proxied; the Alby Hub management port (18080) is never exposed.
|
||||
reverse_proxy /.well-known/lnurlp/* http://127.0.0.1:8181
|
||||
reverse_proxy /lnurlp/* http://127.0.0.1:8181
|
||||
http://sovransystemsos.local {
|
||||
reverse_proxy localhost:8937
|
||||
header {
|
||||
Clear-Site-Data "\"cache\""
|
||||
Cache-Control "no-store, no-cache, must-revalidate, max-age=0"
|
||||
Pragma "no-cache"
|
||||
Expires "0"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
|
||||
# ── RTL and Mempool (local network) ─────────────
|
||||
# Only where those services run; see bitcoinUiSites above.
|
||||
cat >> /run/caddy/Caddyfile <<'LAN_SITES_EOF'
|
||||
${bitcoinUiSites}
|
||||
LAN_SITES_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 ──────────────
|
||||
cat >> /run/caddy/Caddyfile <<'CUSTOM_VHOSTS_EOF'
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
# ── Server-local domain loopback overrides ────────────────────────────────────
|
||||
#
|
||||
# Some routers (especially newer ISP-provided devices) do not support NAT
|
||||
# loopback (hairpin NAT). When a request originates on this computer and
|
||||
# targets a public domain name that resolves to the router's WAN address, the
|
||||
# router may refuse to loop the connection back in — causing Nextcloud, WordPress
|
||||
# background jobs, and other server-side callbacks to fail even when the service
|
||||
# is fully operational from the internet.
|
||||
#
|
||||
# This module installs a one-shot systemd service,
|
||||
# ``sovran-hosts-update.service``, that reads the configured service domains
|
||||
# from ``/var/lib/domains/`` at boot (and whenever triggered by the Hub after a
|
||||
# domain is saved) and writes ``127.0.0.1`` entries for them into a dedicated
|
||||
# Sovran-managed block in ``/etc/hosts``.
|
||||
#
|
||||
# With those entries in place:
|
||||
# • Requests originating on this computer resolve the public domain name to
|
||||
# 127.0.0.1, reach Caddy directly, and never touch the router.
|
||||
# • Caddy still receives the correct public hostname via TLS SNI so virtual-
|
||||
# host routing and certificate validation continue to work.
|
||||
# • The Sovran Hub can verify Caddy reachability locally without needing NAT
|
||||
# loopback.
|
||||
#
|
||||
# Limitation: this does not help other devices on your home network (phones,
|
||||
# laptops). Those devices resolve domains via the router's DNS and still depend
|
||||
# on NAT loopback (or require manual router DNS overrides). For now, only
|
||||
# server-originated requests benefit from this override.
|
||||
#
|
||||
# On NixOS, /etc/hosts is normally a symlink into the Nix store and is
|
||||
# regenerated by the system activation script. The ``system.activationScripts``
|
||||
# hook below converts it to a writable file each time the system is activated
|
||||
# (i.e. after every ``nixos-rebuild switch``) and then injects the Sovran block.
|
||||
# The same wrapped Nix-store executable is reused by both the activation hook
|
||||
# and the ``sovran-hosts-update.service`` unit, ensuring a deterministic runtime
|
||||
# PATH in every execution context.
|
||||
|
||||
let
|
||||
# ── Wrapped Nix-store executable ──────────────────────────────────────────
|
||||
# Built with pkgs.writeShellApplication so that all required runtime tools
|
||||
# (awk, grep, coreutils) are declared explicitly and injected into PATH by
|
||||
# Nix. Both the systemd service and the activation hook reference this same
|
||||
# store-path executable — there is no second raw script body.
|
||||
hostsUpdateScript = pkgs.writeShellApplication {
|
||||
name = "sovran-hosts-update";
|
||||
|
||||
# Declare every external command the script calls. These packages are
|
||||
# added to the script's runtime PATH by writeShellApplication; nothing from
|
||||
# the system PATH is relied upon.
|
||||
runtimeInputs = [
|
||||
pkgs.coreutils # readlink, cp, mv, chmod, mktemp, rm, tr, head
|
||||
pkgs.gawk # awk
|
||||
pkgs.gnugrep # grep
|
||||
];
|
||||
|
||||
text = ''
|
||||
# Regenerate the Sovran-managed loopback block in /etc/hosts.
|
||||
# Safe to run multiple times — idempotent.
|
||||
|
||||
DOMAINS_DIR="/var/lib/domains"
|
||||
HOSTS_FILE="/etc/hosts"
|
||||
BEGIN_MARKER="# Sovran managed begin — server-local loopback overrides"
|
||||
END_MARKER="# Sovran managed end"
|
||||
|
||||
# ── Step 1: ensure /etc/hosts is a regular writable file ──────────────
|
||||
# On NixOS /etc/hosts starts as a symlink to the Nix store. We replace
|
||||
# it with a copy so we can append our block without touching the store.
|
||||
if [ -L "$HOSTS_FILE" ]; then
|
||||
TARGET=$(readlink -f "$HOSTS_FILE")
|
||||
cp --no-preserve=all "$TARGET" "$HOSTS_FILE.sovran-tmp"
|
||||
mv "$HOSTS_FILE.sovran-tmp" "$HOSTS_FILE"
|
||||
chmod 644 "$HOSTS_FILE"
|
||||
fi
|
||||
|
||||
# ── Step 2: remove any existing Sovran block ──────────────────────────
|
||||
# Use a temp file so the operation is atomic.
|
||||
# awk -v passes marker strings safely without shell interpolation.
|
||||
TMP=$(mktemp "$HOSTS_FILE.XXXXXX")
|
||||
trap 'rm -f "$TMP"' EXIT
|
||||
awk -v begin="$BEGIN_MARKER" -v end="$END_MARKER" '
|
||||
$0 == begin { skip=1; next }
|
||||
$0 == end { skip=0; next }
|
||||
!skip
|
||||
' "$HOSTS_FILE" > "$TMP"
|
||||
|
||||
# ── Step 3: collect valid configured service domains ──────────────────
|
||||
# NOTE: The hostname validation regex below must stay in sync with
|
||||
# _SAFE_DOMAIN_RE in app/sovran_systemsos_web/server.py.
|
||||
ENTRIES=""
|
||||
for KEY in matrix wordpress nextcloud btcpayserver vaultwarden haven element-calling lightning; do
|
||||
FILE="$DOMAINS_DIR/$KEY"
|
||||
[ -f "$FILE" ] || continue
|
||||
# Read the domain value (strip all whitespace, limit to 253 chars)
|
||||
DOMAIN=$(tr -d '[:space:]' < "$FILE" | head -c 253)
|
||||
[ -z "$DOMAIN" ] && continue
|
||||
# Validate: must match a reasonable hostname pattern (no injection)
|
||||
if ! printf '%s' "$DOMAIN" | grep -qE \
|
||||
'^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$'; then
|
||||
echo "sovran-hosts-update: skipping invalid domain value for $KEY: $DOMAIN" >&2
|
||||
continue
|
||||
fi
|
||||
ENTRIES="$ENTRIES
|
||||
127.0.0.1 $DOMAIN
|
||||
::1 $DOMAIN"
|
||||
done
|
||||
|
||||
# ── Step 4: append the Sovran block if there are any entries ──────────
|
||||
if [ -n "$ENTRIES" ]; then
|
||||
{
|
||||
printf '\n%s\n' "$BEGIN_MARKER"
|
||||
printf '%s\n' "# These entries route configured service domains to local Caddy."
|
||||
printf '%s\n' "# They are managed automatically — do not edit this block."
|
||||
printf '%s\n' "$ENTRIES"
|
||||
printf '%s\n' "$END_MARKER"
|
||||
} >> "$TMP"
|
||||
fi
|
||||
|
||||
# ── Step 5: atomically replace /etc/hosts ─────────────────────────────
|
||||
mv "$TMP" "$HOSTS_FILE"
|
||||
chmod 644 "$HOSTS_FILE"
|
||||
'';
|
||||
};
|
||||
|
||||
in
|
||||
{
|
||||
# ── /etc/sovran-hosts-update.sh — operator discoverability symlink ─────────
|
||||
# Retain the familiar /etc path so administrators can inspect or manually
|
||||
# invoke the helper. The target is the wrapped Nix-store executable, so
|
||||
# there is no second raw script body to keep in sync.
|
||||
environment.etc."sovran-hosts-update.sh".source = lib.getExe hostsUpdateScript;
|
||||
|
||||
# ── Systemd service ────────────────────────────────────────────────────────
|
||||
|
||||
systemd.services.sovran-hosts-update = {
|
||||
description = "Update /etc/hosts with Sovran server-local loopback overrides";
|
||||
documentation = [ "https://github.com/naturallaw777/sovran-systems" ];
|
||||
|
||||
# Run before Caddy so loopback entries are ready when it starts.
|
||||
before = [
|
||||
"caddy.service"
|
||||
"network-online.target"
|
||||
];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
# Point directly at the wrapped Nix-store executable, not the /etc path.
|
||||
ExecStart = lib.getExe hostsUpdateScript;
|
||||
};
|
||||
};
|
||||
|
||||
# ── Activation script (runs after every nixos-rebuild switch) ─────────────
|
||||
# This ensures the loopback block survives rebuilds that restore the /etc/hosts
|
||||
# symlink. The "users" and "etc" scripts must complete first.
|
||||
# The same wrapped Nix-store executable used by the systemd service is
|
||||
# referenced here, guaranteeing identical runtime dependencies in both
|
||||
# execution contexts.
|
||||
|
||||
system.activationScripts.sovranDomainLoopback = {
|
||||
text = ''
|
||||
if [ -d /var/lib/domains ]; then
|
||||
if ! ${lib.getExe hostsUpdateScript}; then
|
||||
echo "warning: sovran-hosts-update: failed to update /etc/hosts loopback entries" >&2
|
||||
fi
|
||||
fi
|
||||
'';
|
||||
deps = [ "etc" "users" ];
|
||||
};
|
||||
}
|
||||
+17
-66
@@ -1,81 +1,32 @@
|
||||
{ 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 and base script exist on every build ──
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /var/lib/njalla 0750 root root -"
|
||||
];
|
||||
|
||||
# ── Install the DDNS runner and the validator it shares with the Hub ─────
|
||||
# Both files come straight from the Hub's source tree and are installed side
|
||||
# by side as read-only system files. The runner imports the exact same
|
||||
# _validate_ddns_url() the Hub API uses — no weaker inline copy.
|
||||
environment.etc."sovran/security_helpers.py" = {
|
||||
source = ../../app/sovran_systemsos_web/security_helpers.py;
|
||||
mode = "0444";
|
||||
user = "root";
|
||||
group = "root";
|
||||
};
|
||||
environment.etc."sovran/ddns-update.py" = {
|
||||
source = ../../app/sovran_systemsos_web/ddns_update.py;
|
||||
mode = "0444";
|
||||
user = "root";
|
||||
group = "root";
|
||||
};
|
||||
|
||||
# ── Safe DDNS update service ─────────────────────────────────────────────
|
||||
# Reads DDNS update URLs from the JSON store written by the Hub API and
|
||||
# invokes curl directly — no shell interpolation, no script execution.
|
||||
# Njal.la is asked to use the address the request came from ("&auto") and
|
||||
# reports it back; the runner saves it to /var/lib/secrets/external-ip,
|
||||
# where LiveKit and the Hub read it. See app/sovran_systemsos_web/ddns_update.py.
|
||||
systemd.services.sovran-ddns-update = {
|
||||
description = "Sovran Njal.la DDNS update (safe JSON-based runner)";
|
||||
wants = [ "network-online.target" ];
|
||||
after = [ "network-online.target" ];
|
||||
# curl is not in a NixOS unit's default PATH (coreutils, findutils, grep,
|
||||
# sed, systemd): without this the runner cannot start it.
|
||||
path = [ pkgs.curl ];
|
||||
# ── Create base njalla.sh if it doesn't exist yet ────────────
|
||||
systemd.services.njalla-init = {
|
||||
description = "Initialize Njal.la DDNS script if missing";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
User = "root";
|
||||
ExecStart = "${pkgs.python3}/bin/python3 /etc/sovran/ddns-update.py";
|
||||
# Harden the service — it needs network access, the URL store, and the
|
||||
# file that receives the reported address.
|
||||
NoNewPrivileges = true;
|
||||
ProtectSystem = "strict";
|
||||
ReadWritePaths = [ "/var/lib/njalla" "/var/lib/secrets" ];
|
||||
ReadOnlyPaths = [ "/etc/sovran" ];
|
||||
ProtectHome = true;
|
||||
PrivateTmp = true;
|
||||
# AF_UNIX: name lookups can go through nscd / systemd-resolved sockets.
|
||||
RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
unitConfig = {
|
||||
ConditionPathExists = "!/var/lib/njalla/njalla.sh";
|
||||
};
|
||||
script = ''
|
||||
cat > /var/lib/njalla/njalla.sh <<'SCRIPT'
|
||||
#!/usr/bin/env bash
|
||||
IP=$(dig @resolver4.opendns.com myip.opendns.com +short -4)
|
||||
|
||||
# Run the update every 15 minutes
|
||||
systemd.timers.sovran-ddns-update = {
|
||||
description = "Sovran Njal.la DDNS update timer";
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig = {
|
||||
OnBootSec = "2min";
|
||||
OnUnitActiveSec = "15min";
|
||||
Persistent = true;
|
||||
};
|
||||
};
|
||||
## Add DDNS entries below — one curl per line
|
||||
## Managed via Sovran Hub web interface
|
||||
SCRIPT
|
||||
|
||||
# The runner used to be written to /var/lib/sovran by this activation script,
|
||||
# next to the old public-ip.py detector. Remove those stale copies.
|
||||
system.activationScripts.sovran-ddns-update-script = ''
|
||||
rm -f /var/lib/sovran/ddns-update.py /var/lib/sovran/public-ip.py
|
||||
chmod 700 /var/lib/njalla/njalla.sh
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -3,13 +3,6 @@
|
||||
{
|
||||
config = lib.mkMerge [
|
||||
|
||||
# The Sovran_Bitcoin flake module is always imported (see flake.nix).
|
||||
# This default satisfies the secrets assertion so Desktop-Only systems evaluate
|
||||
# without enabling any Bitcoin services.
|
||||
{
|
||||
nix-bitcoin.generateSecrets = lib.mkDefault true;
|
||||
}
|
||||
|
||||
# ── Server+Desktop Role (default) ─────────────────────────
|
||||
(lib.mkIf config.sovran_systemsOS.roles.server_plus_desktop {
|
||||
sovran_systemsOS.web.btcpayserver = lib.mkDefault true;
|
||||
@@ -19,25 +12,15 @@
|
||||
(lib.mkIf config.sovran_systemsOS.roles.desktop {
|
||||
services.desktopManager.gnome.enable = true;
|
||||
|
||||
# Force all server/node services and features off so they cannot be
|
||||
# accidentally enabled via custom.nix or option defaults on Desktop Only.
|
||||
sovran_systemsOS.services = {
|
||||
synapse = lib.mkForce false;
|
||||
bitcoin = lib.mkForce false;
|
||||
vaultwarden = lib.mkForce false;
|
||||
wordpress = lib.mkForce false;
|
||||
nextcloud = lib.mkForce false;
|
||||
synapse = lib.mkDefault false;
|
||||
bitcoin = lib.mkDefault false;
|
||||
vaultwarden = lib.mkDefault false;
|
||||
wordpress = lib.mkDefault false;
|
||||
nextcloud = lib.mkDefault false;
|
||||
};
|
||||
|
||||
sovran_systemsOS.features = {
|
||||
haven = lib.mkForce false;
|
||||
mempool = lib.mkForce false;
|
||||
element-calling = lib.mkForce false;
|
||||
bitcoin-tor-gossip = lib.mkForce false;
|
||||
"nwc-wallets" = lib.mkForce false;
|
||||
};
|
||||
|
||||
sovran_systemsOS.web.btcpayserver = lib.mkForce false;
|
||||
sovran_systemsOS.web.btcpayserver = lib.mkDefault false;
|
||||
})
|
||||
|
||||
# ── Bitcoin Node Only Role ────────────────────────────────
|
||||
|
||||
+21
-107
@@ -45,80 +45,21 @@
|
||||
haven = lib.mkEnableOption "Haven NOSTR relay";
|
||||
mempool = lib.mkEnableOption "Bitcoin Mempool Explorer";
|
||||
element-calling = lib.mkEnableOption "Element Video and Audio Calling";
|
||||
bitcoin-tor-gossip = lib.mkEnableOption "Advertise the Bitcoin Core onion service through Bitcoin peer gossip";
|
||||
# Compatibility shim for Hub-managed settings from releases where Core
|
||||
# was an optional replacement for the default node. Core is now always
|
||||
# selected when the Bitcoin service is enabled.
|
||||
bitcoin-core = lib.mkOption {
|
||||
bitcoin-core = lib.mkEnableOption "Bitcoin Core";
|
||||
rdp = lib.mkEnableOption "Gnome Remote Desktop";
|
||||
sshd = lib.mkEnableOption "SSH remote access";
|
||||
|
||||
# Deprecated: BIP-110 is now built into mainline Bitcoin Knots and is the
|
||||
# default node. This option is retained ONLY so that existing machines with
|
||||
# `sovran_systemsOS.features.bip110 = lib.mkForce true;` left in their local
|
||||
# custom.nix continue to evaluate. It has no effect and will be removed in a
|
||||
# future release once the Hub has cleaned up old custom.nix files.
|
||||
bip110 = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.bool;
|
||||
default = null;
|
||||
internal = true;
|
||||
visible = false;
|
||||
description = "Deprecated no-op: Bitcoin Core is the default node implementation.";
|
||||
};
|
||||
"nwc-wallets" = lib.mkEnableOption "Lightning Wallet Connections";
|
||||
rdp = lib.mkEnableOption "Gnome Remote Desktop";
|
||||
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.
|
||||
'';
|
||||
description = "(Deprecated, no-op) BIP-110 is now built into Bitcoin Knots.";
|
||||
};
|
||||
};
|
||||
|
||||
@@ -140,43 +81,6 @@
|
||||
};
|
||||
};
|
||||
|
||||
# ── 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 ─────────────────────────────────
|
||||
domainRequirements = lib.mkOption {
|
||||
type = lib.types.listOf (lib.types.submodule {
|
||||
@@ -198,4 +102,14 @@
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf (config.sovran_systemsOS.features.bip110 != null) {
|
||||
warnings = [
|
||||
''
|
||||
sovran_systemsOS.features.bip110 is deprecated and has no effect:
|
||||
BIP-110 is now built into mainline Bitcoin Knots, which is the default node.
|
||||
You can safely remove the `sovran_systemsOS.features.bip110` line from
|
||||
/etc/nixos/custom.nix. The Sovran Hub will also remove it automatically.
|
||||
''
|
||||
];
|
||||
};
|
||||
}
|
||||
+52
-227
@@ -3,16 +3,6 @@
|
||||
let
|
||||
cfg = config.sovran_systemsOS;
|
||||
|
||||
# Read the OS version from the repo-root VERSION file at eval time so the
|
||||
# Hub always ships a real, baked-in version string instead of relying on
|
||||
# a runtime file lookup (which returns "dev" when /etc/nixos/VERSION is
|
||||
# missing, e.g. in a dev checkout — this is the "vdev" bug in the Hub UI).
|
||||
versionFile = ../../VERSION;
|
||||
sovranVersion =
|
||||
if builtins.pathExists versionFile
|
||||
then builtins.replaceStrings [ "v" "\n" "\r" ] [ "" "" "" ] (builtins.readFile versionFile)
|
||||
else "1.0.0";
|
||||
|
||||
monitoredServices =
|
||||
# ── Infrastructure — System Passwords (always present) ─────
|
||||
[
|
||||
@@ -37,10 +27,13 @@ let
|
||||
{ label = "How to Connect"; value = "1. Install an RDP client (e.g. Remmina, Microsoft Remote Desktop)\n2. Create a new RDP connection\n3. Enter the Address above as the host\n4. Enter the Username and Password above"; }
|
||||
]; }
|
||||
]
|
||||
# ── Bitcoin Base ────────────────────────────────────────────
|
||||
# ── Bitcoin Base (node implementations) ────────────────────
|
||||
++ lib.optionals cfg.services.bitcoin [
|
||||
{ name = "Bitcoin Core"; unit = "bitcoind.service"; type = "system"; icon = "bitcoin-core"; enabled = cfg.services.bitcoin; category = "bitcoin-base"; credentials = [
|
||||
{ label = "Tor Bitcoin P2P Address — Reachable only through Tor"; file = "/var/lib/tor/onion/bitcoind/hostname"; suffix = ":8333"; }
|
||||
{ name = "Bitcoin Knots + BIP110"; unit = "bitcoind.service"; type = "system"; icon = "bip110"; enabled = cfg.services.bitcoin && !cfg.features.bitcoin-core; category = "bitcoin-base"; credentials = [
|
||||
{ label = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; }
|
||||
]; }
|
||||
{ name = "Bitcoin Core"; unit = "bitcoind.service"; type = "system"; icon = "bitcoin-core"; enabled = cfg.features.bitcoin-core; category = "bitcoin-base"; credentials = [
|
||||
{ label = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; }
|
||||
]; }
|
||||
]
|
||||
# ── Bitcoin Apps (services on top of the node) ─────────────
|
||||
@@ -51,8 +44,8 @@ let
|
||||
]; }
|
||||
{ name = "LND"; unit = "lnd.service"; type = "system"; icon = "lnd"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = []; }
|
||||
{ name = "Ride The Lightning"; unit = "rtl.service"; type = "system"; icon = "rtl"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
|
||||
{ label = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/rtl/hostname"; prefix = "http://"; suffix = "/rtl/"; }
|
||||
{ label = "Local Network — Access on your home network only"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":3051/rtl/"; }
|
||||
{ label = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/rtl/hostname"; prefix = "http://"; }
|
||||
{ label = "Local Network — Access on your home network only"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":3051"; }
|
||||
{ label = "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"; }
|
||||
]; }
|
||||
@@ -62,14 +55,12 @@ let
|
||||
]; }
|
||||
{ name = "Zeus Connect"; unit = "zeus-connect-setup.service"; type = "system"; icon = "zeus"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
|
||||
{ label = "QR Code"; file = "/var/lib/secrets/zeus-connect-url"; qrcode = true; qronly = true; }
|
||||
{ label = "How to Connect"; value = "1. Download Zeus from App Store or Google Play\n2. Open Zeus → Scan Node Config\n3. Scan the QR code above"; }
|
||||
]; }
|
||||
{ name = "Sparrow Auto-Link"; unit = "sparrow-autoconnect.service"; type = "system"; icon = "sparrow"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
|
||||
{ label = "Server"; value = "tcp://127.0.0.1:50001 (Electrs)"; }
|
||||
{ label = "Status"; value = "Auto-configured on first boot"; }
|
||||
]; }
|
||||
{ name = "Lightning Wallet Connections"; unit = "albyhub.service"; type = "system"; icon = "nwc"; enabled = cfg.features."nwc-wallets"; category = "bitcoin-apps"; credentials = [
|
||||
{ label = "Lightning Address Domain"; file = "/var/lib/domains/lightning"; }
|
||||
]; }
|
||||
{ name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; enabled = cfg.features.mempool; category = "bitcoin-apps"; credentials = [
|
||||
{ label = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/mempool-frontend/hostname"; prefix = "http://"; }
|
||||
{ label = "Local Network — Access on your home network only"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":60847"; }
|
||||
@@ -85,7 +76,7 @@ let
|
||||
{ label = "Test Username"; file = "/var/lib/secrets/matrix-test-username"; }
|
||||
{ label = "Test Password"; file = "/var/lib/secrets/matrix-test-password"; }
|
||||
]; }
|
||||
{ name = "LiveKit (Element Call)"; unit = "livekit.service"; type = "system"; icon = "element-calling"; enabled = cfg.features.element-calling; category = "communication"; credentials = []; }
|
||||
{ name = "Element-Call"; unit = "livekit.service"; type = "system"; icon = "element-calling"; enabled = cfg.features.element-calling; category = "communication"; credentials = []; }
|
||||
]
|
||||
# ── Self-Hosted Apps (server+desktop only) ─────────────────
|
||||
++ lib.optionals cfg.roles.server_plus_desktop [
|
||||
@@ -115,15 +106,6 @@ let
|
||||
else if cfg.roles.node then "node"
|
||||
else "server_plus_desktop";
|
||||
|
||||
# IPv4 a.b.c.d[/0-32] or IPv6 [/0-128], and never a /0 (that is "everyone").
|
||||
octet = "(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])";
|
||||
lanNetworkOk = p:
|
||||
(
|
||||
builtins.match "${octet}(\\.${octet}){3}(/(3[0-2]|[12]?[0-9]))?" p != null
|
||||
|| builtins.match "[0-9a-fA-F:]*:[0-9a-fA-F:]*(/(12[0-8]|1[01][0-9]|[1-9]?[0-9]))?" p != null
|
||||
)
|
||||
&& builtins.match ".*/0" p == null;
|
||||
|
||||
generatedConfig = pkgs.writeText "sovran-hub-config.json"
|
||||
(builtins.toJSON {
|
||||
refresh_interval = 5;
|
||||
@@ -131,92 +113,8 @@ let
|
||||
role = activeRole;
|
||||
services = monitoredServices;
|
||||
feature_manager = true;
|
||||
# Read by LanOnlyMiddleware in server.py.
|
||||
lan_only = cfg.hub.lanOnly;
|
||||
lan_extra_networks = cfg.hub.extraLanNetworks;
|
||||
feature_states = {
|
||||
bitcoin-tor-gossip = cfg.features.bitcoin-tor-gossip;
|
||||
};
|
||||
sovran_version = sovranVersion;
|
||||
});
|
||||
|
||||
generatedVersions = pkgs.writeText "sovran-hub-versions.json" (builtins.toJSON {
|
||||
"caddy.service" = if pkgs ? caddy then pkgs.caddy.version else "2.8.4";
|
||||
"tor.service" = if pkgs ? tor then pkgs.tor.version else "0.4.8.12";
|
||||
"gnome-remote-desktop.service" = if pkgs ? gnome-remote-desktop then pkgs.gnome-remote-desktop.version else "46.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";
|
||||
"lnd.service" = if pkgs ? lnd then pkgs.lnd.version else "0.18.0";
|
||||
# Vendored packages come from the Sovran_Bitcoin flake overlay
|
||||
# (pkgs.sovran-bitcoin.*). Evaluate them directly so the Hub shows
|
||||
# the exact versions this system ships.
|
||||
"rtl.service" = pkgs.sovran-bitcoin.rtl.version;
|
||||
"btcpayserver.service" = lib.getVersion config.services.btcpayserver.package;
|
||||
"albyhub.service" = pkgs.sovran-bitcoin.albyhub.version;
|
||||
"mempool.service" = pkgs.sovran-bitcoin.mempool-backend.version;
|
||||
"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";
|
||||
"vaultwarden.service" = if pkgs ? vaultwarden then pkgs.vaultwarden.version else "1.32.0";
|
||||
# Nextcloud and WordPress are downloaded into /var/lib/www, so their
|
||||
# deployed versions are read from their own PHP version files by the Hub.
|
||||
"haven-relay.service" = if pkgs ? haven-relay then pkgs.haven-relay.version else (if pkgs ? haven then pkgs.haven.version else "0.1.0");
|
||||
});
|
||||
|
||||
# Shared shell prelude used by both the update and rebuild wrapper scripts.
|
||||
# A flake/package fetch that is interrupted (network blip, reboot
|
||||
# mid-download, disk filled, hiccup on the remote) can leave a truncated
|
||||
# tarball or partial git clone in Nix's download caches. Nix then reuses the
|
||||
# corrupt archive on every retry and dies with "cannot read file from
|
||||
# tarball: Truncated tar archive detected" — a failure that is NOT fixed by
|
||||
# simply re-running, but IS fixed by clearing the fetch caches. run_step runs
|
||||
# a command and, on the first failure that matches a download/cache
|
||||
# signature, clears the caches and retries once. Real config errors never
|
||||
# match, so they still fail loudly. Each sourcing script must define $LOG.
|
||||
nix-self-heal-prelude = ''
|
||||
transient_failure() {
|
||||
grep -Eqi 'truncated tar|unexpected end of (file|archive)|unexpected eof|corrupt(ed)? (archive|nar|download|file)|could not (fetch|download)|download.*(failed|interrupted)|timed out|timeout|connection (reset|refused|timed out)|network is unreachable|temporary failure in name resolution|checksum mismatch|hash mismatch|nar hash|unable to download|store path.*is not valid|cannot read file from tarball|into the git cache' "$LOG"
|
||||
}
|
||||
|
||||
clear_fetch_caches() {
|
||||
echo "[SELF-HEAL] Clearing stale Nix download caches and verifying the Nix store…"
|
||||
# Re-fetchable caches only; /nix/store generations and the running system
|
||||
# are never touched here.
|
||||
rm -rf /root/.cache/nix/tarballs /root/.cache/nix/vcs-cache /root/.cache/nix/git* /root/.cache/nix/flakes 2>/dev/null || true
|
||||
# Fast closure-level repair only. A full --check-contents scan hashes
|
||||
# every store path and can take tens of minutes on a big node; the cache
|
||||
# clear above is the actual fix for truncated/corrupt downloads.
|
||||
nix-store --verify --repair >/dev/null 2>&1 || true
|
||||
echo "[SELF-HEAL] Caches cleared; retrying…"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# run_step LABEL CMD [ARGS...] — run a build step; on a transient
|
||||
# fetch/cache failure, heal once and retry. Returns the command exit code
|
||||
# but leaves error messaging to the caller.
|
||||
run_step() {
|
||||
label="$1"; shift
|
||||
rc=1
|
||||
for try in 1 2; do
|
||||
if [ "$try" -eq 2 ]; then
|
||||
echo "── $label — retry after cache repair ──"
|
||||
fi
|
||||
"$@"
|
||||
rc=$?
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$try" -eq 1 ] && transient_failure; then
|
||||
echo ""
|
||||
echo "[SELF-HEAL] $label failed on a download/cache error (see above)."
|
||||
clear_fetch_caches
|
||||
continue
|
||||
fi
|
||||
return "$rc"
|
||||
done
|
||||
return "$rc"
|
||||
}
|
||||
'';
|
||||
|
||||
# ── Update wrapper script ──────────────────────────────────────
|
||||
update-script = pkgs.writeShellScript "sovran-hub-update.sh" ''
|
||||
set -uo pipefail
|
||||
@@ -224,10 +122,8 @@ let
|
||||
|
||||
LOG="/var/log/sovran-hub-update.log"
|
||||
STATUS="/var/log/sovran-hub-update.status"
|
||||
GENERATION="/var/log/sovran-hub-update.generation"
|
||||
|
||||
echo "RUNNING" > "$STATUS"
|
||||
rm -f "$GENERATION"
|
||||
: > "$LOG"
|
||||
exec > >(tee -a "$LOG") 2>&1
|
||||
|
||||
@@ -238,37 +134,45 @@ let
|
||||
|
||||
RC=0
|
||||
|
||||
${nix-self-heal-prelude}
|
||||
|
||||
echo "── Step 1/3: nix flake update ────────────────────"
|
||||
if ! run_step "nix flake update" nix flake update --flake /etc/nixos --print-build-logs \
|
||||
if ! nix flake update --flake /etc/nixos --print-build-logs \
|
||||
--option connect-timeout 10 \
|
||||
--option stalled-download-timeout 90 \
|
||||
--option download-attempts 7 \
|
||||
--option fallback true; then
|
||||
--option fallback true 2>&1; then
|
||||
echo "[ERROR] nix flake update failed"
|
||||
RC=1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
if [ "$RC" -eq 0 ]; then
|
||||
echo "── Step 2/3: nixos-rebuild boot (stage next reboot) ──"
|
||||
# Stream output straight into $LOG (see rebuild-script) so the Hub UI
|
||||
# shows live progress instead of an empty log during long builds.
|
||||
if run_step "nixos-rebuild boot" nixos-rebuild boot --flake /etc/nixos --print-build-logs \
|
||||
echo "── Step 2/3: nixos-rebuild ──────────────────────────"
|
||||
SWITCH_OUT=$(nixos-rebuild switch --flake /etc/nixos --print-build-logs \
|
||||
--option connect-timeout 10 \
|
||||
--option stalled-download-timeout 90 \
|
||||
--option download-attempts 7 \
|
||||
--option fallback true; then
|
||||
if ! readlink -f /nix/var/nix/profiles/system > "$GENERATION"; then
|
||||
# The marker is informational only. The Hub derives pending-reboot
|
||||
# state from the NixOS system profile itself, so failing to record
|
||||
# the marker must not fail an otherwise successful update.
|
||||
echo "[WARNING] update succeeded but its staged generation could not be recorded"
|
||||
rm -f "$GENERATION"
|
||||
--option fallback true 2>&1)
|
||||
SWITCH_RC=$?
|
||||
echo "$SWITCH_OUT"
|
||||
if [ "$SWITCH_RC" -eq 0 ]; then
|
||||
echo "[OK] switch succeeded"
|
||||
elif echo "$SWITCH_OUT" | grep -q "switchInhibitors\|Pre-switch checks failed"; then
|
||||
echo ""
|
||||
echo " ✓ Build succeeded — a reboot is required to apply this update"
|
||||
echo " (Critical system components changed; running nixos-rebuild boot instead)"
|
||||
if nixos-rebuild boot --flake /etc/nixos --print-build-logs \
|
||||
--option connect-timeout 10 \
|
||||
--option stalled-download-timeout 90 \
|
||||
--option download-attempts 7 \
|
||||
--option fallback true 2>&1; then
|
||||
echo "REBOOT_REQUIRED" > "$STATUS"
|
||||
exit 0
|
||||
else
|
||||
echo "[ERROR] nixos-rebuild boot also failed"
|
||||
RC=1
|
||||
fi
|
||||
else
|
||||
echo "[ERROR] nixos-rebuild boot failed"
|
||||
echo "[ERROR] nixos-rebuild switch failed"
|
||||
RC=1
|
||||
fi
|
||||
echo ""
|
||||
@@ -284,10 +188,9 @@ let
|
||||
|
||||
if [ "$RC" -eq 0 ]; then
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo " ✓ Update staged successfully"
|
||||
echo " Reboot required to activate the new system"
|
||||
echo " ✓ Update completed successfully"
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo "REBOOT_REQUIRED" > "$STATUS"
|
||||
echo "SUCCESS" > "$STATUS"
|
||||
else
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo " ✗ Update failed — see errors above"
|
||||
@@ -314,35 +217,29 @@ let
|
||||
echo " Sovran_SystemsOS Rebuild — $(date)"
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
${nix-self-heal-prelude}
|
||||
|
||||
echo "── Rebuilding system configuration ──────────────"
|
||||
# Stream output straight into $LOG (tee'd by the exec redirect above) so
|
||||
# the Hub UI shows live progress. Capturing the output in a variable
|
||||
# kept the log empty for the entire build+activation, which made long
|
||||
# rebuilds can otherwise look like a hang.
|
||||
run_step "nixos-rebuild switch" nixos-rebuild switch --flake /etc/nixos --print-build-logs \
|
||||
SWITCH_OUT=$(nixos-rebuild switch --flake /etc/nixos --print-build-logs \
|
||||
--option connect-timeout 10 \
|
||||
--option stalled-download-timeout 90 \
|
||||
--option download-attempts 7 \
|
||||
--option fallback true
|
||||
--option fallback true 2>&1)
|
||||
SWITCH_RC=$?
|
||||
echo "$SWITCH_OUT"
|
||||
if [ "$SWITCH_RC" -eq 0 ]; then
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo " ✓ Rebuild completed successfully"
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo "SUCCESS" > "$STATUS"
|
||||
elif grep -q "switchInhibitors\|Pre-switch checks failed" "$LOG"; then
|
||||
elif echo "$SWITCH_OUT" | grep -q "switchInhibitors\|Pre-switch checks failed"; then
|
||||
echo ""
|
||||
echo " ✓ Build succeeded — a reboot is required to apply this rebuild"
|
||||
echo " (Critical system components changed; running nixos-rebuild boot instead)"
|
||||
if run_step "nixos-rebuild boot" nixos-rebuild boot --flake /etc/nixos --print-build-logs \
|
||||
if nixos-rebuild boot --flake /etc/nixos --print-build-logs \
|
||||
--option connect-timeout 10 \
|
||||
--option stalled-download-timeout 90 \
|
||||
--option download-attempts 7 \
|
||||
--option fallback true; then
|
||||
--option fallback true 2>&1; then
|
||||
echo "REBOOT_REQUIRED" > "$STATUS"
|
||||
else
|
||||
echo "[ERROR] nixos-rebuild boot also failed"
|
||||
@@ -350,7 +247,6 @@ let
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "[ERROR] nixos-rebuild switch failed"
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo " ✗ Rebuild failed — see errors above"
|
||||
@@ -360,31 +256,17 @@ let
|
||||
fi
|
||||
'';
|
||||
|
||||
# ── Brave Origin launcher wrapper: a *persistent* per-user profile dir.
|
||||
# It must NOT be wiped on exit: the Hub's logout marker cookie
|
||||
# (hub_manual_logout) and the session cookie live in this profile.
|
||||
# Launching from a fresh/ephemeral profile every time throws away the
|
||||
# marker, so /auto-login would mint a new session and silently log the
|
||||
# user straight back in after they signed out and reopened the window.
|
||||
# A stable directory also keeps the Wayland app_id deterministic so
|
||||
# GNOME Shell can match the window to the .desktop entry (dock icon).
|
||||
# ── Brave launcher wrapper: stable profile dir so Wayland app_id is
|
||||
# deterministic and GNOME Shell can match the window to the .desktop
|
||||
# entry (fixes generic gear icon appearing in the dock).
|
||||
hub-brave-wrapper = pkgs.writeShellScript "sovran-hub-brave.sh" ''
|
||||
export PATH="${lib.makeBinPath [ pkgs.brave-origin pkgs.coreutils ]}:$PATH"
|
||||
# Per-user, persistent browser state. $XDG_STATE_HOME keeps it out of the
|
||||
# way of backups and survives reboots and window close/reopen.
|
||||
if [ -n "$XDG_STATE_HOME" ]; then
|
||||
HUB_DATA="$XDG_STATE_HOME/sovran-hub-browser"
|
||||
else
|
||||
HUB_DATA="$HOME/.local/state/sovran-hub-browser"
|
||||
fi
|
||||
export PATH="${lib.makeBinPath [ pkgs.brave pkgs.coreutils ]}:$PATH"
|
||||
HUB_DATA="/tmp/sovran-hub-brave-$(id -u)"
|
||||
mkdir -p "$HUB_DATA"
|
||||
trap '[ -n "$HUB_DATA" ] && rm -rf "$HUB_DATA"' EXIT INT TERM
|
||||
export BAMF_DESKTOP_FILE_HINT="/run/current-system/sw/share/applications/sovran-hub.desktop"
|
||||
export GIO_LAUNCHED_DESKTOP_FILE="/run/current-system/sw/share/applications/sovran-hub.desktop"
|
||||
# With a persistent profile Brave Origin's one-time "Proceed with Origin
|
||||
# for free on Linux" onboarding dialog only appears once; keep skipping it
|
||||
# anyway so it can never block auto-login (Linux-only switch).
|
||||
brave-origin --app=http://localhost:8937/auto-login \
|
||||
--skip-origin-startup-dialog \
|
||||
brave --app=http://localhost:8937/auto-login \
|
||||
--class=sovran-hub \
|
||||
--user-data-dir="$HUB_DATA" \
|
||||
--password-store=basic \
|
||||
@@ -419,8 +301,7 @@ let
|
||||
|
||||
sovran-hub-web = pkgs.python3Packages.buildPythonApplication {
|
||||
pname = "sovran-systemsos-hub-web";
|
||||
# Keep the package metadata in lockstep with the version shown in the Hub.
|
||||
version = sovranVersion;
|
||||
version = "1.0.0";
|
||||
format = "other";
|
||||
|
||||
src = ../../app;
|
||||
@@ -443,8 +324,6 @@ let
|
||||
cp -r sovran_systemsos_web $out/lib/sovran-hub-web/
|
||||
|
||||
cp ${generatedConfig} $out/lib/sovran-hub-web/config.json
|
||||
cp ${generatedVersions} $out/lib/sovran-hub-web/versions.json
|
||||
printf '%s' "${sovranVersion}" > $out/lib/sovran-hub-web/VERSION
|
||||
|
||||
install -d $out/share/sovran-hub/icons
|
||||
cp icons/* $out/share/sovran-hub/icons/ 2>/dev/null || true
|
||||
@@ -478,21 +357,11 @@ DESKTOP
|
||||
import os, sys
|
||||
base = os.path.join("$out", "lib", "sovran-hub-web")
|
||||
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_VERSIONS"] = os.path.join(base, "versions.json")
|
||||
os.environ["SOVRAN_HUB_ICONS"] = os.path.join("$out", "share", "sovran-hub", "icons")
|
||||
import uvicorn
|
||||
uvicorn.run(
|
||||
"sovran_systemsos_web.server:app",
|
||||
# IPv4 only, on purpose. The desktop launcher uses "localhost", which
|
||||
# falls back to 127.0.0.1, and other devices reach the Hub over IPv4 too.
|
||||
# An IPv6 listener would admit clients whose global addresses the Hub's own
|
||||
# check cannot tell from a stranger's (see LanPolicy). Which devices may
|
||||
# connect is up to the firewall (hub.directPort) and that check
|
||||
# (hub.lanOnly), not this bind.
|
||||
host="0.0.0.0",
|
||||
port=8937,
|
||||
log_level="info",
|
||||
@@ -500,9 +369,6 @@ uvicorn.run(
|
||||
LAUNCHER
|
||||
chmod +x $out/bin/sovran-hub-web
|
||||
|
||||
# nwc-wallet / nwc-lnurl binaries are no longer shipped here: the
|
||||
# Sovran_Bitcoin flake provides them (env-wrapped nwc-wallet via
|
||||
# albyhub.nix, nwc-lnurl.service via lnurl.nix) — single source of truth.
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
@@ -514,29 +380,7 @@ LAUNCHER
|
||||
|
||||
in
|
||||
{
|
||||
options.services.sovranHub.webPackage = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = sovran-hub-web;
|
||||
description = "The sovran-hub-web Python application package. Other modules use this to reference Hub-installed scripts without duplicating the Python path setup.";
|
||||
};
|
||||
|
||||
config = {
|
||||
# Catch a typo'd network at build time. The Hub ignores an entry it cannot
|
||||
# parse (it must never widen its policy by guessing), so without this the
|
||||
# only symptom would be a client that is refused for no visible reason.
|
||||
assertions = [
|
||||
{
|
||||
assertion = builtins.all lanNetworkOk cfg.hub.extraLanNetworks;
|
||||
message = ''
|
||||
sovran_systemsOS.hub.extraLanNetworks must be a list of IPv4 or IPv6
|
||||
networks in CIDR notation, for example [ "203.0.113.0/28" ]. A /0
|
||||
prefix is not accepted; set sovran_systemsOS.hub.lanOnly = false to
|
||||
let every client through. Got:
|
||||
${builtins.toJSON cfg.hub.extraLanNetworks}
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
systemd.services.sovran-hub-web = {
|
||||
description = "Sovran_SystemsOS Hub Web Interface";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
@@ -554,25 +398,13 @@ in
|
||||
};
|
||||
|
||||
path = [
|
||||
pkgs.bash
|
||||
pkgs.gawk
|
||||
pkgs.qrencode
|
||||
pkgs.curl
|
||||
pkgs.iproute2
|
||||
pkgs.nftables
|
||||
pkgs.iptables
|
||||
pkgs.hostname
|
||||
pkgs.coreutils
|
||||
pkgs.findutils
|
||||
pkgs.gnugrep
|
||||
pkgs.rsync
|
||||
pkgs.acl
|
||||
pkgs.util-linux
|
||||
]
|
||||
++ lib.optional cfg.services.bitcoin config.services.bitcoind.package
|
||||
++ lib.optionals cfg.services.bitcoin [ pkgs.lnd ]
|
||||
++ lib.optionals (cfg.services.nextcloud || cfg.services.synapse) [ config.services.postgresql.package ]
|
||||
++ lib.optionals config.services.mysql.enable [ config.services.mysql.package ];
|
||||
] ++ lib.optional cfg.services.bitcoin config.services.bitcoind.package;
|
||||
};
|
||||
|
||||
systemd.services.sovran-hub-update = {
|
||||
@@ -605,14 +437,7 @@ in
|
||||
|
||||
environment.systemPackages = [ sovran-hub-web ];
|
||||
|
||||
# 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 ];
|
||||
networking.firewall.allowedTCPPorts = [ 8937 60847 ];
|
||||
|
||||
# ── Auto-launch Hub in browser on login ───────────────────────
|
||||
environment.etc."xdg/autostart/sovran-hub-autolaunch.desktop".text = ''
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sovran restricted journal helper.
|
||||
|
||||
A root-owned, non-user-writable diagnostic tool that wraps journalctl with
|
||||
a strict allowlist of safe flags. Replaces the ``journalctl *`` sudo rule
|
||||
in tech-support.nix.
|
||||
|
||||
Accepted flags:
|
||||
--unit / -u <name> must be one of the explicitly approved service units
|
||||
--lines / -n <N> positive integer (max 10000)
|
||||
--priority / -p <level> 0-7 or emerg/alert/crit/err/warning/notice/info/debug
|
||||
--since <datetime> ISO 8601 date/datetime (no paths, no filesystem roots)
|
||||
--until <datetime> ISO 8601 date/datetime (no paths, no filesystem roots)
|
||||
--output / -o <format> short | short-iso | cat | json | verbose
|
||||
|
||||
At least one ``--unit`` flag is required; whole-journal queries are rejected.
|
||||
All other flags, paths, directories, roots, namespaces, and output
|
||||
destinations are rejected with a non-zero exit code.
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# ── Allowlists ────────────────────────────────────────────────────────────────
|
||||
|
||||
# Explicit approved units. Only these four services may be queried through
|
||||
# the restricted journal helper. Any other unit is rejected.
|
||||
_APPROVED_UNITS: frozenset[str] = frozenset([
|
||||
"sovran-hub-web.service",
|
||||
"caddy.service",
|
||||
"bitcoind.service",
|
||||
"lnd.service",
|
||||
])
|
||||
|
||||
_ALLOWED_PRIORITIES = frozenset([
|
||||
"0", "1", "2", "3", "4", "5", "6", "7",
|
||||
"emerg", "alert", "crit", "err", "warning", "notice", "info", "debug",
|
||||
])
|
||||
|
||||
_ALLOWED_OUTPUT_FORMATS = frozenset([
|
||||
"short", "short-iso", "cat", "json", "verbose",
|
||||
])
|
||||
|
||||
# ISO 8601 date or datetime: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS (no paths)
|
||||
_DATETIME_RE = re.compile(r'^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?)?$')
|
||||
|
||||
_MAX_LINES = 10000
|
||||
|
||||
# ── Argument parser ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _die(msg: str) -> None:
|
||||
print(f"sovran-journal-helper: {msg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _validate_unit(val: str) -> str:
|
||||
if val not in _APPROVED_UNITS:
|
||||
_die(
|
||||
f"rejected unit name: {val!r} "
|
||||
f"(allowed: {', '.join(sorted(_APPROVED_UNITS))})"
|
||||
)
|
||||
return val
|
||||
|
||||
|
||||
def _validate_lines(val: str) -> str:
|
||||
try:
|
||||
n = int(val)
|
||||
except ValueError:
|
||||
_die(f"rejected: --lines must be a positive integer, got {val!r}")
|
||||
if n <= 0 or n > _MAX_LINES:
|
||||
_die(f"rejected: --lines must be between 1 and {_MAX_LINES}, got {n}")
|
||||
return str(n)
|
||||
|
||||
|
||||
def _validate_priority(val: str) -> str:
|
||||
if val not in _ALLOWED_PRIORITIES:
|
||||
_die(f"rejected priority: {val!r}")
|
||||
return val
|
||||
|
||||
|
||||
def _validate_datetime(val: str) -> str:
|
||||
if not _DATETIME_RE.match(val):
|
||||
_die(f"rejected: datetime {val!r} (must be YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)")
|
||||
return val
|
||||
|
||||
|
||||
def _validate_output(val: str) -> str:
|
||||
if val not in _ALLOWED_OUTPUT_FORMATS:
|
||||
_die(f"rejected output format: {val!r}")
|
||||
return val
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = sys.argv[1:]
|
||||
cmd = ["journalctl"]
|
||||
unit_count = 0
|
||||
|
||||
i = 0
|
||||
while i < len(args):
|
||||
arg = args[i]
|
||||
|
||||
if arg in ("--unit", "-u"):
|
||||
i += 1
|
||||
if i >= len(args):
|
||||
_die("--unit requires a value")
|
||||
cmd += ["--unit", _validate_unit(args[i])]
|
||||
unit_count += 1
|
||||
elif arg.startswith("--unit="):
|
||||
cmd += ["--unit", _validate_unit(arg[len("--unit="):])]
|
||||
unit_count += 1
|
||||
|
||||
elif arg in ("--lines", "-n"):
|
||||
i += 1
|
||||
if i >= len(args):
|
||||
_die("--lines requires a value")
|
||||
cmd += ["--lines", _validate_lines(args[i])]
|
||||
elif arg.startswith("--lines="):
|
||||
cmd += ["--lines", _validate_lines(arg[len("--lines="):])]
|
||||
elif re.match(r'^-n\d+$', arg):
|
||||
cmd += ["--lines", _validate_lines(arg[2:])]
|
||||
|
||||
elif arg in ("--priority", "-p"):
|
||||
i += 1
|
||||
if i >= len(args):
|
||||
_die("--priority requires a value")
|
||||
cmd += ["--priority", _validate_priority(args[i])]
|
||||
elif arg.startswith("--priority="):
|
||||
cmd += ["--priority", _validate_priority(arg[len("--priority="):])]
|
||||
|
||||
elif arg == "--since":
|
||||
i += 1
|
||||
if i >= len(args):
|
||||
_die("--since requires a value")
|
||||
cmd += ["--since", _validate_datetime(args[i])]
|
||||
elif arg.startswith("--since="):
|
||||
cmd += ["--since", _validate_datetime(arg[len("--since="):])]
|
||||
|
||||
elif arg == "--until":
|
||||
i += 1
|
||||
if i >= len(args):
|
||||
_die("--until requires a value")
|
||||
cmd += ["--until", _validate_datetime(args[i])]
|
||||
elif arg.startswith("--until="):
|
||||
cmd += ["--until", _validate_datetime(arg[len("--until="):])]
|
||||
|
||||
elif arg in ("--output", "-o"):
|
||||
i += 1
|
||||
if i >= len(args):
|
||||
_die("--output requires a value")
|
||||
cmd += ["--output", _validate_output(args[i])]
|
||||
elif arg.startswith("--output="):
|
||||
cmd += ["--output", _validate_output(arg[len("--output="):])]
|
||||
|
||||
else:
|
||||
_die(
|
||||
f"rejected flag: {arg!r}. "
|
||||
"Allowed flags: --unit, --lines, --priority, --since, --until, --output"
|
||||
)
|
||||
|
||||
i += 1
|
||||
|
||||
if unit_count == 0:
|
||||
_die(
|
||||
"at least one --unit flag is required; "
|
||||
f"allowed units: {', '.join(sorted(_APPROVED_UNITS))}"
|
||||
)
|
||||
|
||||
result = subprocess.run(cmd)
|
||||
sys.exit(result.returncode)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -44,35 +44,6 @@ let
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Brave Origin migration (existing installs upgrading from `brave`) ──
|
||||
# The old package's brave-browser.desktop no longer exists, so GNOME drops
|
||||
# it from the dock and the Browsers folder. Swap the id in place, but only
|
||||
# when the stale value is still present (never touch user customizations).
|
||||
if [ -f "$USER_DB" ]; then
|
||||
FAVS="$(${pkgs.dconf}/bin/dconf read /org/gnome/shell/favorite-apps 2>/dev/null || true)"
|
||||
if [ -n "$FAVS" ]; then
|
||||
NEW_FAVS="''${FAVS//brave-browser.desktop/brave-origin.desktop}"
|
||||
if [ "$NEW_FAVS" != "$FAVS" ]; then
|
||||
${pkgs.dconf}/bin/dconf write /org/gnome/shell/favorite-apps "$NEW_FAVS"
|
||||
fi
|
||||
fi
|
||||
|
||||
BROWSER_APPS="$(${pkgs.dconf}/bin/dconf read /org/gnome/desktop/app-folders/folders/Browsers/apps 2>/dev/null || true)"
|
||||
if [ -n "$BROWSER_APPS" ]; then
|
||||
NEW_BROWSER_APPS="''${BROWSER_APPS//brave-browser.desktop/brave-origin.desktop}"
|
||||
if [ "$NEW_BROWSER_APPS" != "$BROWSER_APPS" ]; then
|
||||
${pkgs.dconf}/bin/dconf write /org/gnome/desktop/app-folders/folders/Browsers/apps "$NEW_BROWSER_APPS"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# A previous fresh install wrote the user's mimeapps.list with the old id;
|
||||
# XDG gives it precedence over the new system-wide default, so rewrite it.
|
||||
MIME_LIST="$HOME/.config/mimeapps.list"
|
||||
if [ -f "$MIME_LIST" ] && ${pkgs.gnugrep}/bin/grep -q 'brave-browser\.desktop' "$MIME_LIST"; then
|
||||
${pkgs.gnused}/bin/sed -i 's/brave-browser\.desktop/brave-origin.desktop/g' "$MIME_LIST"
|
||||
fi
|
||||
|
||||
# Already applied — skip
|
||||
if [ -f "$STAMP" ]; then
|
||||
exit 0
|
||||
@@ -90,11 +61,11 @@ let
|
||||
mkdir -p "$HOME/.config"
|
||||
cat > "$HOME/.config/mimeapps.list" << EOF
|
||||
[Default Applications]
|
||||
text/html=brave-origin.desktop
|
||||
x-scheme-handler/http=brave-origin.desktop
|
||||
x-scheme-handler/https=brave-origin.desktop
|
||||
x-scheme-handler/about=brave-origin.desktop
|
||||
x-scheme-handler/unknown=brave-origin.desktop
|
||||
text/html=brave-browser.desktop
|
||||
x-scheme-handler/http=brave-browser.desktop
|
||||
x-scheme-handler/https=brave-browser.desktop
|
||||
x-scheme-handler/about=brave-browser.desktop
|
||||
x-scheme-handler/unknown=brave-browser.desktop
|
||||
EOF
|
||||
|
||||
${pkgs.dconf}/bin/dconf load / << EOF
|
||||
@@ -133,7 +104,7 @@ search-filter-time-type='last_modified'
|
||||
[org/gnome/shell]
|
||||
disabled-extensions=['just-perfection-desktop@just-perfection']
|
||||
enabled-extensions=['appindicatorsupport@rgcjonas.gmail.com', 'dash-to-dock-cosmic-@halfmexicanhalfamazing@gmail.com', 'Vitals@CoreCoding.com', 'dash-to-dock@micxgx.gmail.com', 'pop-shell@system76.com', 'date-menu-formatter@marcinjakubowski.github.com', 'light-style@gnome-shell-extensions.gcampax.github.com']
|
||||
favorite-apps=['brave-origin.desktop', 'org.gnome.Settings.desktop', 'org.gnome.Nautilus.desktop', 'sovran-hub.desktop', 'org.gnome.Software.desktop', 'org.gnome.Geary.desktop', 'org.gnome.Contacts.desktop', 'org.gnome.Calendar.desktop', 'sparrow.desktop', 'Bisq.desktop', 'bisq2.desktop']
|
||||
favorite-apps=['brave-browser.desktop', 'org.gnome.Settings.desktop', 'org.gnome.Nautilus.desktop', 'sovran-hub.desktop', 'org.gnome.Software.desktop', 'org.gnome.Geary.desktop', 'org.gnome.Contacts.desktop', 'org.gnome.Calendar.desktop', 'sparrow.desktop', 'Bisq.desktop', 'bisq2.desktop']
|
||||
welcome-dialog-last-shown-version='48.4'
|
||||
|
||||
[org/gnome/desktop/app-folders]
|
||||
@@ -141,7 +112,7 @@ folder-children=['Browsers', 'Office', 'Terminal', 'Chat', 'Bitcoin', 'Media', '
|
||||
|
||||
[org/gnome/desktop/app-folders/folders/Browsers]
|
||||
name='Browsers'
|
||||
apps=['brave-origin.desktop', 'firefox.desktop', 'org.gnome.Epiphany.desktop']
|
||||
apps=['brave-browser.desktop', 'firefox.desktop', 'org.gnome.Epiphany.desktop']
|
||||
|
||||
[org/gnome/desktop/app-folders/folders/Office]
|
||||
name='Office'
|
||||
@@ -298,7 +269,7 @@ in
|
||||
];
|
||||
|
||||
favorite-apps = [
|
||||
"brave-origin.desktop"
|
||||
"brave-browser.desktop"
|
||||
"org.gnome.Settings.desktop"
|
||||
"org.gnome.Nautilus.desktop"
|
||||
"sovran-hub.desktop"
|
||||
@@ -321,7 +292,7 @@ in
|
||||
"org/gnome/desktop/app-folders/folders/Browsers" = {
|
||||
name = "Browsers";
|
||||
apps = [
|
||||
"brave-origin.desktop"
|
||||
"brave-browser.desktop"
|
||||
"firefox.desktop"
|
||||
"org.gnome.Epiphany.desktop"
|
||||
];
|
||||
@@ -462,13 +433,13 @@ in
|
||||
];
|
||||
|
||||
xdg.mime.defaultApplications = {
|
||||
"text/html" = "brave-origin.desktop";
|
||||
"x-scheme-handler/http" = "brave-origin.desktop";
|
||||
"x-scheme-handler/https" = "brave-origin.desktop";
|
||||
"x-scheme-handler/about" = "brave-origin.desktop";
|
||||
"x-scheme-handler/unknown" = "brave-origin.desktop";
|
||||
"text/html" = "brave-browser.desktop";
|
||||
"x-scheme-handler/http" = "brave-browser.desktop";
|
||||
"x-scheme-handler/https" = "brave-browser.desktop";
|
||||
"x-scheme-handler/about" = "brave-browser.desktop";
|
||||
"x-scheme-handler/unknown" = "brave-browser.desktop";
|
||||
};
|
||||
|
||||
environment.sessionVariables.BROWSER = "brave-origin";
|
||||
environment.sessionVariables.BROWSER = "brave-browser";
|
||||
|
||||
}
|
||||
@@ -31,7 +31,7 @@ lib.mkIf userExists {
|
||||
};
|
||||
|
||||
systemd.services.factory-ssh-keygen = {
|
||||
description = "Generate or repair factory SSH key for ${userName}";
|
||||
description = "Generate factory SSH key for ${userName} if missing";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "ssh-passphrase-setup.service" ];
|
||||
requires = [ "ssh-passphrase-setup.service" ];
|
||||
@@ -39,47 +39,14 @@ lib.mkIf userExists {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
path = [ pkgs.openssh pkgs.coreutils pkgs.util-linux ];
|
||||
path = [ pkgs.openssh pkgs.coreutils ];
|
||||
script = ''
|
||||
set -eu
|
||||
|
||||
if [ ! -f "${keyPath}" ]; then
|
||||
PASSPHRASE=$(cat /var/lib/secrets/ssh-passphrase)
|
||||
lock_file="${keyPath}.lock"
|
||||
|
||||
exec 9>"$lock_file"
|
||||
|
||||
if ! flock -n 9; then
|
||||
echo "Factory SSH key setup is already running." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
generate_factory_key() {
|
||||
ssh-keygen -q -N "$PASSPHRASE" -t ed25519 -f "${keyPath}"
|
||||
chown ${userName}:users "${keyPath}" "${keyPath}.pub"
|
||||
chmod 600 "${keyPath}"
|
||||
chmod 644 "${keyPath}.pub"
|
||||
}
|
||||
|
||||
if [ ! -f "${keyPath}" ]; then
|
||||
generate_factory_key
|
||||
elif ! ssh-keygen -y -P "$PASSPHRASE" -f "${keyPath}" >/dev/null 2>&1; then
|
||||
backup_suffix="$(date -u +%Y%m%d_%H%M%S)-$$"
|
||||
backup_path="${keyPath}.bak-$backup_suffix"
|
||||
backup_index=0
|
||||
|
||||
while [ -e "$backup_path" ] || [ -e "$backup_path.pub" ]; do
|
||||
backup_index=$((backup_index + 1))
|
||||
backup_path="${keyPath}.bak-$backup_suffix-$backup_index"
|
||||
done
|
||||
|
||||
echo "Existing factory SSH key does not match current passphrase; backing it up to $backup_path and generating a replacement."
|
||||
mv "${keyPath}" "$backup_path"
|
||||
|
||||
if [ -f "${keyPath}.pub" ]; then
|
||||
mv "${keyPath}.pub" "$backup_path.pub"
|
||||
fi
|
||||
|
||||
generate_factory_key
|
||||
fi
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -9,13 +9,6 @@
|
||||
|
||||
services.openssh = {
|
||||
enable = true;
|
||||
# sshd listens on 127.0.0.1 only here, so there is nothing for the firewall
|
||||
# to let in. NixOS opens sshd's ports by default (openFirewall = true)
|
||||
# whether or not sshd listens on them, which left port 22 open on every
|
||||
# role, Desktop Only included. The roles that do publish SSH open it
|
||||
# themselves: the sshd feature (sshd.nix) and remote deploy
|
||||
# (remote-deploy.nix) both add 22 explicitly.
|
||||
openFirewall = lib.mkDefault false;
|
||||
listenAddresses = lib.mkDefault [
|
||||
{ addr = "127.0.0.1"; port = 22; }
|
||||
];
|
||||
|
||||
@@ -11,10 +11,11 @@
|
||||
# (u:sovran-support:---) by the Hub API as soon as a session is started.
|
||||
# • The Hub web UI lets the user grant time-limited access to wallet files
|
||||
# and view a full audit log of every session event.
|
||||
# • Scoped sudo rules allow support staff to restart specific services and
|
||||
# read logs — without full root, wallet access, Nix editing, or rebuilds.
|
||||
# • journalctl access is provided only through the root-owned
|
||||
# sovran-journal-helper script (see below) with an allowlist of safe flags.
|
||||
# • Scoped sudo rules allow support staff to edit custom.nix, trigger rebuilds,
|
||||
# restart services, and read logs — without full root or wallet access.
|
||||
#
|
||||
# The `acl` package provides the `setfacl` / `getfacl` utilities required by
|
||||
# the Hub's _apply_wallet_acls() and _revoke_wallet_acls() helpers.
|
||||
{
|
||||
# ── System packages ────────────────────────────────────────────────────────
|
||||
environment.systemPackages = [ pkgs.acl ];
|
||||
@@ -41,40 +42,18 @@
|
||||
"d /var/lib/sovran-support/.ssh 0700 sovran-support sovran-support -"
|
||||
];
|
||||
|
||||
# ── Restricted journal helper ─────────────────────────────────────────────
|
||||
# The helper is root-owned, not writable by any user, and accepts only a
|
||||
# narrow allowlist of safe journalctl flags. It is the sole mechanism by
|
||||
# which the support user may read journal logs.
|
||||
environment.etc."sovran/sovran-journal-helper.py" = {
|
||||
source = ./sovran-journal-helper.py;
|
||||
mode = "0500";
|
||||
user = "root";
|
||||
group = "root";
|
||||
};
|
||||
|
||||
# ── Scoped sudo rules for support staff ───────────────────────────────────
|
||||
# Grants only the minimum privileges needed for diagnostic support.
|
||||
# Editing Nix configuration and running nixos-rebuild are intentionally
|
||||
# excluded: combining those two permissions provides a trivial path to
|
||||
# arbitrary root code execution. Systemctl access is limited to a small
|
||||
# allowlist of named service restart operations. journalctl is available
|
||||
# only through the restricted helper above.
|
||||
# Grants only the minimum privileges needed for a support session.
|
||||
# Support staff cannot stop/disable/mask services or access wallet files.
|
||||
security.sudo.extraRules = [
|
||||
{
|
||||
users = [ "sovran-support" ];
|
||||
commands = [
|
||||
{ command = "/run/current-system/sw/bin/systemctl restart sovran-hub-web.service"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/systemctl restart caddy.service"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/systemctl restart bitcoind.service"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/systemctl restart lnd.service"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/systemctl status sovran-hub-web.service"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/systemctl status caddy.service"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/systemctl status bitcoind.service"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/systemctl status lnd.service"; options = [ "NOPASSWD" ]; }
|
||||
# Restricted journal helper: accepts only safe flags (--unit, --lines,
|
||||
# --priority, --since, --until, --output). Rejects paths, directories,
|
||||
# namespaces, roots, and arbitrary output destinations.
|
||||
{ command = "/run/current-system/sw/bin/python3 /etc/sovran/sovran-journal-helper.py *"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/nano /etc/nixos/custom.nix"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/nano /etc/nixos/configuration.nix"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/nixos-rebuild switch --flake /etc/nixos"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/systemctl restart *"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/journalctl *"; options = [ "NOPASSWD" ]; }
|
||||
];
|
||||
}
|
||||
];
|
||||
|
||||
@@ -33,6 +33,9 @@ let
|
||||
echo "$NEW_PASS" > "$SECRET_FILE"
|
||||
chmod 600 "$SECRET_FILE"
|
||||
echo "Password for 'free' updated and saved."
|
||||
# Delete the old GNOME Keyring databases so a fresh one is created on next GDM login.
|
||||
rm -f /home/free/.local/share/keyrings/*.keyring
|
||||
echo "GNOME Keyring files cleared — a fresh keyring will be created on next login."
|
||||
'';
|
||||
in
|
||||
{
|
||||
@@ -73,4 +76,161 @@ in
|
||||
command passwd $argv
|
||||
end
|
||||
'';
|
||||
|
||||
# ── 1. Auto-Generate Root Password (Runs once) ─────────────
|
||||
systemd.services.root-password-setup = {
|
||||
description = "Generate and set a random root password";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
path = [ pkgs.shadow pkgs.coreutils ];
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
SECRET_FILE="/var/lib/secrets/root-password"
|
||||
if [ ! -f "$SECRET_FILE" ]; then
|
||||
mkdir -p /var/lib/secrets
|
||||
# Generate a diceware-style passphrase: word-word-word-N
|
||||
WORDS="apple barn brook cabin cedar cloud coral crane delta eagle ember \
|
||||
fern field flame flora flint frost grove haven hedge holly heron \
|
||||
jade juniper kelp larch lemon lilac linden loch lotus maple marsh \
|
||||
meadow mist mossy mount oak ocean olive petal pine pixel plum pond \
|
||||
prism quartz raven ridge river robin rocky rose rowan sage sand \
|
||||
sierra silver slate snow solar spark spruce stone storm summit \
|
||||
swift thorn tide timber torch trout vale vault vine walnut wave \
|
||||
willow wren amber aspen birch blaze bloom bluff coast copper crest \
|
||||
dune elder fjord forge glade glen glow gulf"
|
||||
WORD_ARRAY=($WORDS)
|
||||
COUNT=''${#WORD_ARRAY[@]}
|
||||
W1=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
W2=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
W3=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
DIGIT=$((RANDOM % 10))
|
||||
ROOT_PASS="$W1-$W2-$W3-$DIGIT"
|
||||
echo "$ROOT_PASS" > "$SECRET_FILE"
|
||||
chmod 600 "$SECRET_FILE"
|
||||
fi
|
||||
echo "root:$(cat "$SECRET_FILE")" | chpasswd
|
||||
'';
|
||||
};
|
||||
|
||||
# ── 1b. Generate random 'free' password on first boot ──────
|
||||
systemd.services.free-password-setup = {
|
||||
description = "Generate and set a random 'free' user password";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
before = [ "display-manager.service" ];
|
||||
after = [ "systemd-user-sessions.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
path = [ pkgs.shadow pkgs.coreutils ];
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
SECRET_FILE="/var/lib/secrets/free-password"
|
||||
PENDING_FILE="/var/lib/secrets/free-password-migration-pending"
|
||||
|
||||
if [ -f "$SECRET_FILE" ]; then
|
||||
echo "free:$(cat "$SECRET_FILE")" | chpasswd
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SHADOW_HASH=""
|
||||
while IFS=: read -r user hash _; do
|
||||
if [ "$user" = "free" ]; then
|
||||
SHADOW_HASH="$hash"
|
||||
break
|
||||
fi
|
||||
done < /etc/shadow
|
||||
|
||||
HAS_REAL_HASH=0
|
||||
case "$SHADOW_HASH" in
|
||||
""|"!"|"*"|"!!"|"!"*|"*"*)
|
||||
HAS_REAL_HASH=0
|
||||
;;
|
||||
*)
|
||||
HAS_REAL_HASH=1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$HAS_REAL_HASH" -eq 1 ]; then
|
||||
mkdir -p /var/lib/secrets
|
||||
touch "$PENDING_FILE"
|
||||
chmod 600 "$PENDING_FILE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p /var/lib/secrets
|
||||
# Generate a diceware-style passphrase: word-word-word-N
|
||||
WORDS="apple barn brook cabin cedar cloud coral crane delta eagle ember \
|
||||
fern field flame flora flint frost grove haven hedge holly heron \
|
||||
jade juniper kelp larch lemon lilac linden loch lotus maple marsh \
|
||||
meadow mist mossy mount oak ocean olive petal pine pixel plum pond \
|
||||
prism quartz raven ridge river robin rocky rose rowan sage sand \
|
||||
sierra silver slate snow solar spark spruce stone storm summit \
|
||||
swift thorn tide timber torch trout vale vault vine walnut wave \
|
||||
willow wren amber aspen birch blaze bloom bluff coast copper crest \
|
||||
dune elder fjord forge glade glen glow gulf"
|
||||
WORD_ARRAY=($WORDS)
|
||||
COUNT=''${#WORD_ARRAY[@]}
|
||||
W1=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
W2=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
W3=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
DIGIT=$((RANDOM % 10))
|
||||
FREE_PASS="$W1-$W2-$W3-$DIGIT"
|
||||
echo "$FREE_PASS" > "$SECRET_FILE"
|
||||
chmod 600 "$SECRET_FILE"
|
||||
echo "free:$FREE_PASS" | chpasswd
|
||||
'';
|
||||
};
|
||||
|
||||
systemd.services.free-password-migration = {
|
||||
description = "Generate and set 'free' password for migrated machines";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
before = [ "display-manager.service" ];
|
||||
after = [ "systemd-user-sessions.service" "free-password-setup.service" ];
|
||||
requires = [ "free-password-setup.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
path = [ pkgs.shadow pkgs.coreutils ];
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
|
||||
PENDING_FILE="/var/lib/secrets/free-password-migration-pending"
|
||||
SECRET_FILE="/var/lib/secrets/free-password"
|
||||
NEWPASS_FILE="/var/lib/secrets/free-password-migration-newpass"
|
||||
|
||||
[ -f "$PENDING_FILE" ] || exit 0
|
||||
|
||||
mkdir -p /var/lib/secrets
|
||||
|
||||
WORDS="apple barn brook cabin cedar cloud coral crane delta eagle ember \
|
||||
fern field flame flora flint frost grove haven hedge holly heron \
|
||||
jade juniper kelp larch lemon lilac linden loch lotus maple marsh \
|
||||
meadow mist mossy mount oak ocean olive petal pine pixel plum pond \
|
||||
prism quartz raven ridge river robin rocky rose rowan sage sand \
|
||||
sierra silver slate snow solar spark spruce stone storm summit \
|
||||
swift thorn tide timber torch trout vale vault vine walnut wave \
|
||||
willow wren amber aspen birch blaze bloom bluff coast copper crest \
|
||||
dune elder fjord forge glade glen glow gulf"
|
||||
WORD_ARRAY=($WORDS)
|
||||
COUNT=''${#WORD_ARRAY[@]}
|
||||
W1=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
W2=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
W3=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
DIGIT=$((RANDOM % 10))
|
||||
FREE_PASS="$W1-$W2-$W3-$DIGIT"
|
||||
|
||||
printf '%s\n' "$FREE_PASS" > "$SECRET_FILE"
|
||||
chmod 600 "$SECRET_FILE"
|
||||
|
||||
printf '%s\n' "$FREE_PASS" > "$NEWPASS_FILE"
|
||||
chmod 600 "$NEWPASS_FILE"
|
||||
rm -f "$PENDING_FILE"
|
||||
'';
|
||||
};
|
||||
|
||||
}
|
||||
+34
-391
@@ -33,15 +33,9 @@ lib.mkIf config.sovran_systemsOS.features.element-calling {
|
||||
'';
|
||||
};
|
||||
|
||||
####### ENSURE SERVICES START AFTER KEY & NETWORK EXIST #######
|
||||
# Ordering against network-online.target matters: livekit-turn-setup detects
|
||||
# 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" ];
|
||||
####### ENSURE SERVICES START AFTER KEY EXISTS #######
|
||||
systemd.services.livekit.after = [ "livekit-key-setup.service" ];
|
||||
systemd.services.livekit.wants = [ "livekit-key-setup.service" ];
|
||||
systemd.services.lk-jwt-service.after = [ "livekit-key-setup.service" ];
|
||||
systemd.services.lk-jwt-service.wants = [ "livekit-key-setup.service" ];
|
||||
|
||||
@@ -74,59 +68,35 @@ $MATRIX {
|
||||
header /.well-known/matrix/* Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
|
||||
header /.well-known/matrix/* Access-Control-Allow-Headers "X-Requested-With, Content-Type, Authorization"
|
||||
respond /.well-known/matrix/client \`{ "m.homeserver": {"base_url": "https://$MATRIX" }, "org.matrix.msc4143.rtc_foci": [{ "type":"livekit", "livekit_service_url":"https://$ELEMENT_CALLING/livekit/jwt" }] }\`
|
||||
respond /.well-known/matrix/server \`{"m.server":"$MATRIX:443"}\`
|
||||
}
|
||||
|
||||
$MATRIX:8448 {
|
||||
reverse_proxy http://localhost:8008
|
||||
}
|
||||
|
||||
$ELEMENT_CALLING {
|
||||
# Route all current lk-jwt-service authorization endpoints to port 8073,
|
||||
# stripping the /livekit/jwt prefix that Caddy adds on the public URL.
|
||||
@lk_jwt path /livekit/jwt/sfu/get* /livekit/jwt/get_token* /livekit/jwt/healthz* /livekit/jwt/sfu_webhook* /livekit/jwt/delegate_delayed_leave*
|
||||
handle @lk_jwt {
|
||||
handle /livekit/jwt/sfu/get {
|
||||
uri strip_prefix /livekit/jwt
|
||||
reverse_proxy [::1]:8073 {
|
||||
header_up Host {host}
|
||||
header_up X-Forwarded-Server {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
}
|
||||
}
|
||||
handle {
|
||||
reverse_proxy localhost:7880 {
|
||||
header_up Host {host}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
read_timeout 300s
|
||||
write_timeout 300s
|
||||
}
|
||||
}
|
||||
reverse_proxy localhost:7880
|
||||
}
|
||||
}
|
||||
EOF
|
||||
'';
|
||||
};
|
||||
|
||||
####### LIVEKIT TURN SETUP (runtime cert + config) #######
|
||||
# Replaces the old dead livekit-runtime-config.service. At runtime this:
|
||||
# * reads the matrix domain from /var/lib/domains/matrix (never hardcoded)
|
||||
# * copies Caddy's already-issued matrix cert/key into /var/lib/livekit
|
||||
# so LoadCredential can stage them for the (DynamicUser) livekit unit
|
||||
# * detects the primary network interface from the IPv4 default route so
|
||||
# LiveKit only advertises real ICE candidates — not VPN/container/private
|
||||
# addresses from interfaces like Tailscale or Docker bridges
|
||||
# * writes a complete LiveKit config (with turn.domain and interface
|
||||
# substituted) that the overridden ExecStart loads.
|
||||
systemd.services.livekit-turn-setup = {
|
||||
description = "Stage TURN cert and generate LiveKit runtime config from domain files";
|
||||
# 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" ];
|
||||
####### LIVEKIT RUNTIME CONFIG #######
|
||||
systemd.services.livekit-runtime-config = {
|
||||
description = "Generate LiveKit runtime config from domain files";
|
||||
before = [ "livekit.service" ];
|
||||
after = [ "livekit-key-setup.service" ];
|
||||
requiredBy = [ "livekit.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
@@ -136,207 +106,47 @@ EOF
|
||||
unitConfig = {
|
||||
ConditionPathExists = "/var/lib/domains/element-calling";
|
||||
};
|
||||
path = [ pkgs.coreutils pkgs.findutils pkgs.iproute2 pkgs.gawk pkgs.python3 ];
|
||||
path = [ pkgs.coreutils ];
|
||||
script = ''
|
||||
MATRIX=$(cat /var/lib/domains/matrix)
|
||||
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
|
||||
|
||||
mkdir -p /run/livekit
|
||||
|
||||
# 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.
|
||||
# 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)
|
||||
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 "$KEY" /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.
|
||||
# Restricting LiveKit to this single interface prevents it from
|
||||
# advertising VPN/container/private ICE candidates (e.g. Tailscale,
|
||||
# Docker bridges) that remote peers cannot reach, which causes all
|
||||
# ICE negotiation attempts to fail with responsesReceived: 0.
|
||||
IFACE=$(ip -4 route show default | awk '/^default/ { for(i=1;i<=NF;i++) if($i=="dev" && (i+1)<=NF) { print $(i+1); exit } }')
|
||||
if [ -z "$IFACE" ]; then
|
||||
echo "ERROR: Could not detect a default-route network interface from 'ip -4 route show default'." >&2
|
||||
echo "ERROR: Cannot generate a valid LiveKit config without a real interface to bind ICE candidates to." >&2
|
||||
echo "ERROR: Ensure a default IPv4 route is configured, e.g.: ip route add default via <gateway> dev <interface>" >&2
|
||||
echo "ERROR: Inspect the current routing table with: ip -4 route show" >&2
|
||||
exit 1
|
||||
fi
|
||||
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
|
||||
# rtc.interfaces.includes are only known at runtime, so they are
|
||||
# substituted here. The cert/key paths point at the LoadCredential-staged
|
||||
# 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
|
||||
port: 7880
|
||||
rtc:
|
||||
use_external_ip: false
|
||||
node_ip: $PUBLIC_IP
|
||||
advertise_internal_ip: true
|
||||
tcp_port: 7881
|
||||
udp_port: 7882
|
||||
interfaces:
|
||||
includes:
|
||||
- $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:
|
||||
auto_create: false
|
||||
cat > /run/livekit/runtime-config.yaml <<EOF
|
||||
turn:
|
||||
enabled: true
|
||||
domain: $MATRIX
|
||||
udp_port: 3478
|
||||
relay_range_start: 40000
|
||||
relay_range_end: 40099
|
||||
cert_file: /run/credentials/livekit.service/turn-cert
|
||||
key_file: /run/credentials/livekit.service/turn-key
|
||||
cert_file: /var/lib/livekit/$MATRIX.crt
|
||||
key_file: /var/lib/livekit/$MATRIX.key
|
||||
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 640 /run/livekit/runtime-config.yaml
|
||||
'';
|
||||
};
|
||||
|
||||
####### 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 = {
|
||||
enable = true;
|
||||
openFirewall = false;
|
||||
openFirewall = true;
|
||||
keyFile = livekitKeyFile;
|
||||
settings = {
|
||||
rtc.use_external_ip = true;
|
||||
rtc.udp_port = 7882;
|
||||
room.auto_create = false;
|
||||
turn = {
|
||||
enabled = true;
|
||||
tls_port = 5349;
|
||||
udp_port = 3478;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# Override ExecStart to load the runtime-generated config (which carries the
|
||||
# runtime-only turn.domain), mirroring the Caddy ExecStart override pattern in
|
||||
# modules/core/caddy.nix. Deliver the TURN cert/key via LoadCredential so they
|
||||
# are readable under the upstream unit's DynamicUser=true sandbox without
|
||||
# weakening it. Everything else about the standard unit is left intact.
|
||||
systemd.services.livekit.serviceConfig.ExecStart = lib.mkForce [
|
||||
""
|
||||
"${pkgs.livekit}/bin/livekit-server --config /run/credentials/livekit.service/livekit-config --key-file /run/credentials/livekit.service/livekit-secrets"
|
||||
];
|
||||
|
||||
systemd.services.livekit.serviceConfig.LoadCredential = [
|
||||
"livekit-config:/run/livekit/livekit.yaml"
|
||||
"livekit-secrets:${livekitKeyFile}"
|
||||
"turn-cert:/var/lib/livekit/turn.crt"
|
||||
"turn-key:/var/lib/livekit/turn.key"
|
||||
];
|
||||
|
||||
# 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.allowedTCPPorts = [ 5349 7881 ];
|
||||
networking.firewall.allowedUDPPorts = [ 3478 7882 ];
|
||||
networking.firewall.allowedUDPPortRanges = [
|
||||
{ from = 40000; to = 40099; } # LiveKit embedded TURN relay allocation range
|
||||
{ from = 30000; to = 40000; }
|
||||
];
|
||||
networking.firewall.allowedTCPPortRanges = [
|
||||
{ from = 30000; to = 40000; }
|
||||
];
|
||||
|
||||
####### JWT SERVICE RUNTIME CONFIG #######
|
||||
@@ -356,27 +166,11 @@ EOF
|
||||
path = [ pkgs.coreutils ];
|
||||
script = ''
|
||||
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
|
||||
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
|
||||
|
||||
cat > /run/lk-jwt-service/env <<EOF
|
||||
LIVEKIT_URL=wss://$ELEMENT_CALLING
|
||||
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
|
||||
|
||||
chmod 640 /run/lk-jwt-service/env
|
||||
@@ -388,9 +182,6 @@ EOF
|
||||
enable = true;
|
||||
port = 8073;
|
||||
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";
|
||||
};
|
||||
|
||||
@@ -398,143 +189,6 @@ EOF
|
||||
"/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) #######
|
||||
systemd.services.element-calling-synapse-config = {
|
||||
description = "Generate Synapse runtime config for Element Calling";
|
||||
@@ -551,7 +205,6 @@ EOF
|
||||
path = [ pkgs.coreutils ];
|
||||
script = ''
|
||||
MATRIX=$(cat /var/lib/domains/matrix)
|
||||
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
|
||||
|
||||
mkdir -p /run/matrix-synapse
|
||||
|
||||
@@ -561,17 +214,7 @@ public_baseurl: "https://$MATRIX"
|
||||
serve_server_wellknown: true
|
||||
experimental_features:
|
||||
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
|
||||
# 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"
|
||||
rc_message:
|
||||
per_second: 0.5
|
||||
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
{ 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;
|
||||
|
||||
}
|
||||
+3
-5
@@ -16,27 +16,25 @@
|
||||
./core/remote-deploy.nix
|
||||
./core/no-sleep.nix
|
||||
./core/cpu-performance.nix
|
||||
./core/local-domain-loopback.nix
|
||||
|
||||
# ── Always on (no flag) ───────────────────────────────────
|
||||
./php.nix
|
||||
./credentials.nix
|
||||
./password-model.nix
|
||||
|
||||
# ── Services (default ON — disable in custom.nix) ─────────
|
||||
./synapse.nix
|
||||
./wordpress.nix
|
||||
./nextcloud.nix
|
||||
./vaultwarden.nix
|
||||
./bitcoinecosystem.nix
|
||||
./wallet-autoconnect.nix
|
||||
|
||||
# ── Features (default OFF — enable in custom.nix) ─────────
|
||||
./haven.nix
|
||||
./element-calling.nix
|
||||
./mempool.nix
|
||||
./bitcoin-core.nix
|
||||
./rdp.nix
|
||||
./sshd.nix
|
||||
|
||||
# ── Desktop Role only (gated inside the module) ───────────
|
||||
./mullvad.nix
|
||||
];
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
# ── modules/mullvad.nix ───────────────────────────────────────────────────────
|
||||
# Mullvad VPN client — Desktop Role only.
|
||||
#
|
||||
# A plain install: the daemon (pkgs.mullvad) and the graphical app
|
||||
# (pkgs.mullvad-vpn) exactly as NixOS ships them, enabled and nothing more. The
|
||||
# account, the relay, the kill switch and every other setting are the app's
|
||||
# business; nothing here configures them, and nothing here is wired into the
|
||||
# Hub or into any other part of the system.
|
||||
#
|
||||
# The role gate is the whole point: Server + Desktop and Bitcoin Node are
|
||||
# machines that serve, and a VPN client with its own DNS and routing policy is
|
||||
# not something their services asked for. Only roles.desktop builds it.
|
||||
#
|
||||
# Turning it off on a Desktop machine: uncomment
|
||||
# services.mullvad-vpn.enable = lib.mkForce false;
|
||||
# in custom.nix (the mkForce is needed because this module sets it too).
|
||||
|
||||
{ config, lib, ... }:
|
||||
|
||||
lib.mkIf config.sovran_systemsOS.roles.desktop {
|
||||
|
||||
services.mullvad-vpn = {
|
||||
# The daemon and its systemd service.
|
||||
enable = true;
|
||||
# The desktop app.
|
||||
gui.enable = true;
|
||||
};
|
||||
}
|
||||
+3
-112
@@ -3,22 +3,10 @@
|
||||
lib.mkIf config.sovran_systemsOS.services.nextcloud {
|
||||
|
||||
# ── PostgreSQL database ───────────────────────────────────
|
||||
# Cluster-wide tuning (shared_buffers, autovacuum) lives in
|
||||
# configuration.nix so it is shared with Matrix Synapse.
|
||||
services.postgresql = {
|
||||
enable = true;
|
||||
};
|
||||
|
||||
# ── Redis for Nextcloud distributed cache + file locking ───
|
||||
# Nextcloud does not recommend APCu for memcache.locking in production.
|
||||
# TCP on localhost avoids unix-socket permission juggling with the caddy user.
|
||||
# Scoped to Nextcloud only — Synapse / MariaDB / Bitcoin are unaffected.
|
||||
services.redis.servers.nextcloud = {
|
||||
enable = true;
|
||||
bind = "127.0.0.1";
|
||||
port = 6379;
|
||||
};
|
||||
|
||||
# ── Auto-generate DB password and initialize ──────────────
|
||||
systemd.services.nextcloud-db-init = {
|
||||
description = "Initialize Nextcloud PostgreSQL database with auto-generated password";
|
||||
@@ -59,20 +47,14 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud {
|
||||
if ! psql -U postgres -lqt | cut -d \| -f 1 | grep -qw "nextclouddb"; then
|
||||
psql -U postgres -c "CREATE DATABASE nextclouddb WITH OWNER ncusr TEMPLATE template0 LC_COLLATE = 'C' LC_CTYPE = 'C';"
|
||||
fi
|
||||
|
||||
# NOTE: autovacuum GUCs are SIGHUP-context, so they cannot be set
|
||||
# per-database — ALTER DATABASE ... SET rejects them with
|
||||
# 'parameter "..." cannot be changed now'. They are set
|
||||
# cluster-wide in configuration.nix instead, which already covers
|
||||
# both nextclouddb and matrix-synapse.
|
||||
'';
|
||||
};
|
||||
|
||||
# ── Fully automated Nextcloud setup ───────────────────────
|
||||
systemd.services.nextcloud-init = {
|
||||
description = "Download, extract, and fully configure Nextcloud";
|
||||
after = [ "network-online.target" "postgresql.service" "phpfpm-nextcloud.service" "nextcloud-db-init.service" "redis-nextcloud.service" ];
|
||||
wants = [ "network-online.target" "redis-nextcloud.service" ];
|
||||
after = [ "network-online.target" "postgresql.service" "phpfpm-nextcloud.service" "nextcloud-db-init.service" ];
|
||||
wants = [ "network-online.target" ];
|
||||
requires = [ "postgresql.service" "nextcloud-db-init.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
@@ -168,11 +150,7 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud {
|
||||
php $INSTALL_DIR/occ config:system:set default_phone_region --value='US'
|
||||
php $INSTALL_DIR/occ config:system:set maintenance_window_start --type=integer --value=1
|
||||
php $INSTALL_DIR/occ config:system:set memcache.local --value='\OC\Memcache\APCu'
|
||||
php $INSTALL_DIR/occ config:system:set memcache.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 memcache.locking --value='\OC\Memcache\APCu'
|
||||
php $INSTALL_DIR/occ config:system:set server_id --value='$SERVER_ID'
|
||||
php $INSTALL_DIR/occ background:cron
|
||||
"
|
||||
@@ -269,93 +247,6 @@ CREDS
|
||||
'';
|
||||
};
|
||||
|
||||
# ── Migrate existing installs to Redis locking ────────────
|
||||
# nextcloud-init only runs on fresh installs (ConditionPathExists
|
||||
# !config.php), so pre-existing / pre-Sovran installs would keep
|
||||
# APCu locking forever. This one-shot is idempotent and safe to
|
||||
# re-run on every boot — occ just overwrites the same values.
|
||||
systemd.services.nextcloud-redis-migrate = {
|
||||
description = "Point existing Nextcloud installs at Redis locking";
|
||||
after = [ "postgresql.service" "redis-nextcloud.service" "phpfpm-nextcloud.service" ];
|
||||
wants = [ "redis-nextcloud.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
unitConfig = {
|
||||
ConditionPathExists = [
|
||||
"/var/lib/www/nextcloud/occ"
|
||||
"/var/lib/www/nextcloud/config/config.php"
|
||||
];
|
||||
};
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
path = with pkgs; [ coreutils shadow ];
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
INSTALL_DIR="/var/lib/www/nextcloud"
|
||||
# Wait briefly for Redis (TCP localhost:6379).
|
||||
for i in $(seq 1 15); do
|
||||
if (echo > /dev/tcp/127.0.0.1/6379) >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
/run/wrappers/bin/su -s /bin/sh caddy -c "
|
||||
php $INSTALL_DIR/occ config:system:set memcache.local --value='\OC\Memcache\APCu'
|
||||
php $INSTALL_DIR/occ config:system:set memcache.distributed --value='\OC\Memcache\Redis'
|
||||
php $INSTALL_DIR/occ config:system:set memcache.locking --value='\OC\Memcache\Redis'
|
||||
php $INSTALL_DIR/occ config:system:set redis host --value='127.0.0.1'
|
||||
php $INSTALL_DIR/occ config:system:set redis port --type=integer --value=6379
|
||||
php $INSTALL_DIR/occ config:system:set redis timeout --value='1.5'
|
||||
"
|
||||
'';
|
||||
};
|
||||
|
||||
# ── Recurring DB maintenance (Nextcloud 35 checks) ───────────
|
||||
# nextcloud-init runs db:add-missing-indices exactly once. Upgrades
|
||||
# (e.g. to NC35) and later app installs (Mail, Guests) add tables
|
||||
# like oc_mail_tags / oc_guests_users that then seq-scan forever.
|
||||
# Weekly: VACUUM ANALYZE (dead tuples) + backfill missing indices.
|
||||
# Scoped to nextclouddb only — matrix-synapse is untouched.
|
||||
systemd.services.nextcloud-db-maintenance = {
|
||||
description = "Nextcloud DB maintenance: VACUUM + missing indices";
|
||||
after = [ "postgresql.service" "redis-nextcloud.service" "phpfpm-nextcloud.service" ];
|
||||
wants = [ "postgresql.service" ];
|
||||
unitConfig = {
|
||||
ConditionPathExists = [
|
||||
"/var/lib/www/nextcloud/occ"
|
||||
"/var/lib/www/nextcloud/config/config.php"
|
||||
];
|
||||
};
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
};
|
||||
path = [ config.services.postgresql.package pkgs.coreutils pkgs.shadow ];
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
INSTALL_DIR="/var/lib/www/nextcloud"
|
||||
echo "Vacuuming nextclouddb..."
|
||||
psql -U postgres -d nextclouddb -c "VACUUM (ANALYZE);"
|
||||
echo "Backfilling Nextcloud indices..."
|
||||
/run/wrappers/bin/su -s /bin/sh caddy -c "
|
||||
php $INSTALL_DIR/occ db:add-missing-indices
|
||||
php $INSTALL_DIR/occ db:add-missing-columns
|
||||
php $INSTALL_DIR/occ db:add-missing-primary-keys
|
||||
"
|
||||
echo "Nextcloud DB maintenance complete."
|
||||
'';
|
||||
};
|
||||
|
||||
systemd.timers.nextcloud-db-maintenance = {
|
||||
description = "Weekly Nextcloud DB maintenance";
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig = {
|
||||
OnCalendar = "Sun 03:30";
|
||||
Persistent = true;
|
||||
RandomizedDelaySec = "30m";
|
||||
};
|
||||
};
|
||||
|
||||
services.cron.systemCronJobs = [
|
||||
"*/5 * * * * caddy /run/current-system/sw/bin/php -f /var/lib/www/nextcloud/cron.php"
|
||||
];
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
# Sovran_SystemsOS -- password model cleanup (GNOME 50 / gnome-keyring 50)
|
||||
#
|
||||
# * /etc/shadow is the only source of truth for authentication (Hub login == GDM login).
|
||||
# * /var/lib/secrets/{free,root}-password are root-only DISPLAY copies for the Hub, written
|
||||
# by exactly one module (system_password.py) and verified against shadow before shown.
|
||||
# * The GNOME login keyring is decoupled from the login password: it is created with a
|
||||
# blank password and pam_gnome_keyring never hands the login password to the daemon.
|
||||
# => no "unlock your login keyring" prompts, ever (also on the autologin live ISO);
|
||||
# Brave's cookie/password key survives every password change; no *.keyring wiping.
|
||||
# * Legacy machines (password unknown to the Hub) are handled by the Hub's existing
|
||||
# onboarding "migration password" step -- not by a boot service any more.
|
||||
#
|
||||
# Import from configuration.nix and DELETE these lines there:
|
||||
# security.pam.services.gdm-password.enableGnomeKeyring = true;
|
||||
# security.pam.services.gdm-autologin.enableGnomeKeyring = true;
|
||||
# the four systemd.tmpfiles.rules for /home/free/.local/share/keyrings/{,default}
|
||||
# and in modules/credentials.nix delete root-password-setup, free-password-setup,
|
||||
# free-password-migration and the `rm -f /home/free/.local/share/keyrings/*.keyring` lines.
|
||||
|
||||
let
|
||||
user = "free";
|
||||
home = "/home/${user}";
|
||||
|
||||
# A login keyring with a blank password. gnome-keyring stores those in this textual
|
||||
# format and auto-unlocks them on demand without any prompt (verified on 48; the rule in
|
||||
# gkm-secret-collection.c that picks the textual format for a blank master is unchanged
|
||||
# in 50 and the 50.0 release notes contain no behavioural changes).
|
||||
blankLoginKeyring = pkgs.writeText "login.keyring" ''
|
||||
[keyring]
|
||||
display-name=login
|
||||
ctime=0
|
||||
mtime=0
|
||||
lock-on-idle=false
|
||||
lock-after=false
|
||||
'';
|
||||
|
||||
# Apply the installer's plaintext password ONCE: only while the account still has no
|
||||
# usable hash (nixos-install --no-root-password leaves both accounts locked). After that
|
||||
# the file is display-only and the Hub (system_password.py) is the only thing that
|
||||
# writes it. This replaces the old services, which re-applied the file on EVERY boot
|
||||
# and silently reverted any password changed elsewhere.
|
||||
# On already-installed machines the case below matches `*)` (real hash) -> no-op:
|
||||
# an upgrade can never change or generate a password.
|
||||
applyOnce = { account, fallback ? false }: ''
|
||||
set -euo pipefail
|
||||
f=/var/lib/secrets/${account}-password
|
||||
hash="$(getent shadow ${account} | cut -d: -f2 || true)"
|
||||
case "$hash" in
|
||||
""|"!"*|"*"*)
|
||||
if [ -s "$f" ] && ! grep -qE '^[0-9a-f]{32}:[0-9a-f]{64,}$' "$f"; then
|
||||
echo "${account}:$(head -n1 "$f")" | chpasswd
|
||||
${lib.optionalString fallback ''
|
||||
else
|
||||
# Installer left no usable file: generate one so GDM is always reachable
|
||||
# (the user reads it in the Hub). Never runs once the account has a password.
|
||||
W="apple barn brook cabin cedar cloud coral crane delta eagle ember fern field flame
|
||||
flora flint frost grove haven hedge holly heron jade juniper kelp larch lemon lilac
|
||||
linden loch lotus maple marsh meadow mist mossy mount oak ocean olive petal pine
|
||||
pixel plum pond prism quartz raven ridge river robin rocky rose rowan sage sand
|
||||
sierra silver slate snow solar spark spruce stone storm summit swift thorn tide
|
||||
timber torch trout vale vault vine walnut wave willow wren"
|
||||
pw=$(echo $W | tr ' ' '\n' | shuf -n4 | paste -sd- -)-$(printf '%02d' $((RANDOM % 100)))
|
||||
umask 077; printf '%s\n' "$pw" > "$f"
|
||||
echo "${account}:$pw" | chpasswd
|
||||
''}
|
||||
fi ;;
|
||||
*) ;; # password already set: never touch it here again
|
||||
esac
|
||||
'';
|
||||
in
|
||||
{
|
||||
# ── 1. Keyring: never learns the login password ───────────────────────────
|
||||
# nixpkgs' gnome-keyring module turns this on for `login` (gdm-password includes it).
|
||||
# If the daemon ever receives the login password it re-encrypts the blank keyring with
|
||||
# it on the next save -- so it must be off everywhere.
|
||||
security.pam.services.login.enableGnomeKeyring = lib.mkForce false;
|
||||
security.pam.services.gdm-password.enableGnomeKeyring = lib.mkForce false;
|
||||
security.pam.services.gdm-autologin.enableGnomeKeyring = lib.mkForce false;
|
||||
security.pam.services.passwd.enableGnomeKeyring = lib.mkForce false;
|
||||
|
||||
# Seed the blank keyring. `C` copies only if the file does not exist, so an existing
|
||||
# keyring is never overwritten -- which is also what makes a reset's `rm *.keyring`
|
||||
# harmless: the next boot re-seeds a fresh blank keyring.
|
||||
systemd.tmpfiles.rules = [
|
||||
"d ${home}/.local 0700 ${user} users -"
|
||||
"d ${home}/.local/share 0700 ${user} users -"
|
||||
"d ${home}/.local/share/keyrings 0700 ${user} users -"
|
||||
"C ${home}/.local/share/keyrings/login.keyring 0600 ${user} users - ${blankLoginKeyring}"
|
||||
];
|
||||
|
||||
# One-time migration for machines that already have a keyring encrypted with the
|
||||
# (old) login password: move it aside and seed the blank one. Runs before GDM; the old
|
||||
# file is kept. Same cost users pay today on every password change (Brave logs you out
|
||||
# of sites once) -- but only once, ever.
|
||||
system.activationScripts.sovranBlankKeyring = lib.stringAfter [ "users" ] ''
|
||||
d=${home}/.local/share/keyrings
|
||||
if [ -f "$d/login.keyring" ] && ! head -c 9 "$d/login.keyring" | grep -q '^\[keyring\]'; then
|
||||
mv "$d/login.keyring" "$d/login.keyring.encrypted-$(date +%Y%m%d)"
|
||||
install -o ${user} -g users -m 0600 ${blankLoginKeyring} "$d/login.keyring"
|
||||
fi
|
||||
'';
|
||||
|
||||
# ── 2. First-boot password hand-off (unit names kept: the Hub's "System Passwords"
|
||||
# service card is bound to root-password-setup.service) ───────────────────────
|
||||
systemd.services.free-password-setup = {
|
||||
description = "Apply the installer's 'free' password (first boot only)";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
before = [ "display-manager.service" ];
|
||||
serviceConfig = { Type = "oneshot"; RemainAfterExit = true; };
|
||||
path = [ pkgs.shadow pkgs.coreutils pkgs.gnugrep pkgs.getent ];
|
||||
script = applyOnce { account = user; fallback = true; };
|
||||
};
|
||||
systemd.services.root-password-setup = {
|
||||
description = "Apply the installer's root password (first boot only)";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = { Type = "oneshot"; RemainAfterExit = true; };
|
||||
path = [ pkgs.shadow pkgs.coreutils pkgs.gnugrep pkgs.getent ];
|
||||
script = applyOnce { account = "root"; };
|
||||
};
|
||||
# Legacy detection now lives in the Hub (system_password.ensure_migration_pending).
|
||||
systemd.services.free-password-migration.enable = false;
|
||||
|
||||
# ── 3. The Hub verifies passwords against /etc/shadow via libxcrypt ───────
|
||||
systemd.services.sovran-hub-web.environment.LIBCRYPT = "${pkgs.libxcrypt}/lib/libcrypt.so.1";
|
||||
}
|
||||
+11
-53
@@ -1,70 +1,29 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
# ── Shared PHP for Nextcloud + WordPress ──────────────────────────────────────
|
||||
#
|
||||
# One interpreter (with one extension set and one php.ini) is shared by the
|
||||
# phpfpm-nextcloud and phpfpm-wordpress pools, the Nextcloud cron job and the
|
||||
# occ / wp-cli helper scripts. Every consumer must reference
|
||||
# config.sovran_systemsOS.phpPackage (or /run/current-system/sw/bin/php) so
|
||||
# that the CLI and the FPM pools always run the *same* PHP.
|
||||
#
|
||||
# Version policy (September 2026):
|
||||
# • Nextcloud 35 supports PHP 8.3 / 8.4 / 8.5 and recommends 8.5. Its setup
|
||||
# check flags 8.3 as "deprecated since Nextcloud 35" and warns that
|
||||
# Nextcloud 36 may require at least 8.4.
|
||||
# • WordPress 6.9 / 7.0 fully support PHP 8.4 and 8.5.
|
||||
# • PHP 8.3 has been security-only since 2025-12-31; PHP 8.4 leaves active
|
||||
# support on 2026-12-31; PHP 8.5 is actively supported until 2027-12-31.
|
||||
#
|
||||
# To fall back to PHP 8.4 (nixpkgs' current default `pkgs.php`) change only
|
||||
# the `phpBase` line below.
|
||||
|
||||
let
|
||||
phpBase = pkgs.php85;
|
||||
|
||||
custom-php = phpBase.buildEnv {
|
||||
# `enabled` is nixpkgs' default extension set. It already contains every
|
||||
# module Nextcloud lists as required or recommended (ctype, curl, dom,
|
||||
# fileinfo, gd, intl, mbstring, openssl, posix, session, simplexml,
|
||||
# xmlreader, xmlwriter, zip, zlib, pdo_pgsql, pdo_mysql, bcmath, gmp,
|
||||
# exif, sodium, sysvsem, pcntl, ...). OPcache is compiled into PHP >= 8.5
|
||||
# and no longer appears as a separate extension.
|
||||
extensions = { enabled, all }: enabled ++ (with all; [
|
||||
bz2 # Nextcloud: bz2 archive support
|
||||
apcu # Nextcloud: memcache.local (apc.enable_cli=1 below is mandatory for occ + cron)
|
||||
redis # Nextcloud: memcache.distributed / file locking once a Redis server is configured
|
||||
imagick # Nextcloud: previews + theming (nixpkgs ImageMagick is built with SVG support)
|
||||
memcached # WordPress object-cache plugins (legacy option for Nextcloud)
|
||||
]);
|
||||
|
||||
custom-php = pkgs.php83.buildEnv {
|
||||
extensions = { enabled, all }: enabled ++ (with all; [ bz2 apcu redis imagick memcached ]);
|
||||
extraConfig = ''
|
||||
; ── Error handling (production) ─────────────────────────────────
|
||||
display_errors = Off
|
||||
display_startup_errors = Off
|
||||
log_errors = On
|
||||
|
||||
; ── Limits ──────────────────────────────────────────────────────
|
||||
display_errors = On
|
||||
display_startup_errors = On
|
||||
max_execution_time = 10000
|
||||
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
|
||||
upload_max_filesize = 3G
|
||||
|
||||
; ── 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)
|
||||
opcache.interned_strings_buffer = 192
|
||||
redis.session.locking_enabled=1
|
||||
redis.session.lock_retries=-1
|
||||
redis.session.lock_wait_time=10000
|
||||
|
||||
'';
|
||||
};
|
||||
in
|
||||
@@ -96,4 +55,3 @@ in
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
Regular → Executable
+55
-120
@@ -2,104 +2,70 @@
|
||||
|
||||
lib.mkIf config.sovran_systemsOS.features.rdp {
|
||||
|
||||
users.users.gnome-remote-desktop = {
|
||||
isSystemUser = true;
|
||||
group = "gnome-remote-desktop";
|
||||
home = "/var/lib/gnome-remote-desktop";
|
||||
createHome = true;
|
||||
};
|
||||
users.groups.gnome-remote-desktop = {};
|
||||
|
||||
# Enable the GNOME Remote Desktop service at the system level
|
||||
services.gnome.gnome-remote-desktop.enable = true;
|
||||
|
||||
# Open RDP port in the firewall
|
||||
networking.firewall.allowedTCPPorts = [ 3389 ];
|
||||
|
||||
# Ensure the service only starts after setup succeeds
|
||||
# Ensure the service actually starts and waits for setup to complete
|
||||
systemd.services.gnome-remote-desktop = {
|
||||
wantedBy = [ "graphical.target" ];
|
||||
after = [ "gnome-remote-desktop-setup.service" ];
|
||||
requires = [ "gnome-remote-desktop-setup.service" ];
|
||||
wants = [ "gnome-remote-desktop-setup.service" ];
|
||||
};
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /var/lib/gnome-remote-desktop/.local 0700 gnome-remote-desktop gnome-remote-desktop -"
|
||||
"d /var/lib/gnome-remote-desktop/.local/share 0700 gnome-remote-desktop gnome-remote-desktop -"
|
||||
"d /var/lib/gnome-remote-desktop/.local/share/gnome-remote-desktop 0700 gnome-remote-desktop gnome-remote-desktop -"
|
||||
"d /var/lib/gnome-remote-desktop/tls 0700 gnome-remote-desktop gnome-remote-desktop -"
|
||||
"d /var/lib/gnome-remote-desktop 0750 gnome-remote-desktop gnome-remote-desktop -"
|
||||
"d /var/lib/gnome-remote-desktop/.local 0750 gnome-remote-desktop gnome-remote-desktop -"
|
||||
"d /var/lib/gnome-remote-desktop/.local/share 0750 gnome-remote-desktop gnome-remote-desktop -"
|
||||
"d /var/lib/gnome-remote-desktop/.local/share/gnome-remote-desktop 0750 gnome-remote-desktop gnome-remote-desktop -"
|
||||
];
|
||||
|
||||
systemd.services.gnome-remote-desktop-setup = {
|
||||
description = "Configure GNOME Remote Desktop RDP";
|
||||
wantedBy = [ "graphical.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
before = [ "gnome-remote-desktop.service" ];
|
||||
after = [
|
||||
"dbus.service"
|
||||
"systemd-tmpfiles-setup.service"
|
||||
"network-online.target"
|
||||
"gnome-remote-desktop-configuration.service"
|
||||
];
|
||||
wants = [
|
||||
"network-online.target"
|
||||
"gnome-remote-desktop-configuration.service"
|
||||
];
|
||||
after = [ "systemd-tmpfiles-setup.service" "network-online.target" ];
|
||||
wants = [ "network-online.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
TimeoutStartSec = "2min";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
path = [
|
||||
pkgs.coreutils
|
||||
pkgs.gawk
|
||||
pkgs.gnome-remote-desktop
|
||||
pkgs.hostname
|
||||
pkgs.polkit
|
||||
pkgs.openssl
|
||||
pkgs.systemd
|
||||
pkgs.hostname
|
||||
pkgs.gawk
|
||||
];
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
# Ensure directory structure exists
|
||||
mkdir -p /var/lib/gnome-remote-desktop/.local/share/gnome-remote-desktop
|
||||
chown -R gnome-remote-desktop:gnome-remote-desktop /var/lib/gnome-remote-desktop
|
||||
|
||||
# GRD 50.x invokes pkexec internally for every grdctl --system call, even
|
||||
# when the caller is root. NixOS exposes the required setuid wrapper at
|
||||
# /run/wrappers/bin/pkexec; the Nix-store polkit binary is not setuid and
|
||||
# must not shadow it. Prepend the wrapper directory so every subsequent
|
||||
# grdctl --system resolves the correct binary.
|
||||
export PATH="/run/wrappers/bin:$PATH"
|
||||
|
||||
STATE_DIR="/var/lib/gnome-remote-desktop"
|
||||
TLS_DIR="$STATE_DIR/tls"
|
||||
USERNAME_FILE="$STATE_DIR/rdp-username"
|
||||
PASSWORD_FILE="$STATE_DIR/rdp-password"
|
||||
CRED_FILE="$STATE_DIR/rdp-credentials"
|
||||
DEFAULT_USERNAME="sovran"
|
||||
|
||||
grdctl_system() {
|
||||
local rc=0
|
||||
|
||||
if timeout --kill-after=5s 10s \
|
||||
grdctl --system "$@"; then
|
||||
return 0
|
||||
else
|
||||
rc=$?
|
||||
fi
|
||||
|
||||
if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ]; then
|
||||
echo "grdctl command timed out: $*" >&2
|
||||
fi
|
||||
echo "grdctl command failed (exit $rc): $*" >&2
|
||||
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
mkdir -p "$STATE_DIR/.local/share/gnome-remote-desktop" "$TLS_DIR"
|
||||
chown -R gnome-remote-desktop:gnome-remote-desktop "$STATE_DIR"
|
||||
chmod 700 \
|
||||
"$STATE_DIR" \
|
||||
"$STATE_DIR/.local" \
|
||||
"$STATE_DIR/.local/share" \
|
||||
"$STATE_DIR/.local/share/gnome-remote-desktop" \
|
||||
"$TLS_DIR"
|
||||
TLS_DIR="/var/lib/gnome-remote-desktop/tls"
|
||||
CRED_FILE="/var/lib/gnome-remote-desktop/rdp-credentials"
|
||||
|
||||
# Regenerate TLS certificate if missing OR if ownership is wrong
|
||||
# (disable/re-enable cycle can break ownership or grdctl state)
|
||||
NEED_REGEN=0
|
||||
if [ ! -f "$TLS_DIR/rdp-tls.crt" ] || [ ! -f "$TLS_DIR/rdp-tls.key" ]; then
|
||||
NEED_REGEN=1
|
||||
elif [ "$(stat -c '%U:%G' "$TLS_DIR/rdp-tls.key" 2>/dev/null)" != "gnome-remote-desktop:gnome-remote-desktop" ]; then
|
||||
elif [ "$(stat -c '%U' "$TLS_DIR/rdp-tls.key" 2>/dev/null)" != "gnome-remote-desktop" ]; then
|
||||
NEED_REGEN=1
|
||||
fi
|
||||
|
||||
if [ "$NEED_REGEN" = "1" ]; then
|
||||
mkdir -p "$TLS_DIR"
|
||||
rm -f "$TLS_DIR/rdp-tls.key" "$TLS_DIR/rdp-tls.crt"
|
||||
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
|
||||
-sha256 -nodes -days 3650 \
|
||||
@@ -109,59 +75,39 @@ lib.mkIf config.sovran_systemsOS.features.rdp {
|
||||
echo "Generated new RDP TLS certificate"
|
||||
fi
|
||||
|
||||
chown gnome-remote-desktop:gnome-remote-desktop "$TLS_DIR/rdp-tls.key" "$TLS_DIR/rdp-tls.crt"
|
||||
# Always fix ownership and permissions (handles re-enable after disable)
|
||||
chown -R gnome-remote-desktop:gnome-remote-desktop "$TLS_DIR"
|
||||
chmod 600 "$TLS_DIR/rdp-tls.key"
|
||||
chmod 644 "$TLS_DIR/rdp-tls.crt"
|
||||
|
||||
if [ ! -f "$USERNAME_FILE" ]; then
|
||||
printf '%s\n' "$DEFAULT_USERNAME" > "$USERNAME_FILE"
|
||||
fi
|
||||
USERNAME="$(tr -d '\n' < "$USERNAME_FILE")"
|
||||
if [ -z "$USERNAME" ]; then
|
||||
USERNAME="$DEFAULT_USERNAME"
|
||||
printf '%s\n' "$USERNAME" > "$USERNAME_FILE"
|
||||
fi
|
||||
if [ "''${#USERNAME}" -gt 32 ]; then
|
||||
echo "RDP username is too long (''${#USERNAME} characters, maximum 32): $USERNAME from $USERNAME_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
case "$USERNAME" in
|
||||
[A-Za-z_][A-Za-z0-9_-]*)
|
||||
;;
|
||||
*)
|
||||
echo "RDP username must start with a letter or underscore and contain only letters, numbers, underscores, and hyphens: $USERNAME from $USERNAME_FILE" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
chown gnome-remote-desktop:gnome-remote-desktop "$USERNAME_FILE"
|
||||
chmod 600 "$USERNAME_FILE"
|
||||
# Configure TLS certificate
|
||||
grdctl --system rdp set-tls-cert "$TLS_DIR/rdp-tls.crt"
|
||||
grdctl --system rdp set-tls-key "$TLS_DIR/rdp-tls.key"
|
||||
|
||||
if [ ! -f "$PASSWORD_FILE" ]; then
|
||||
openssl rand -base64 16 > "$PASSWORD_FILE"
|
||||
fi
|
||||
PASSWORD="$(tr -d '\n' < "$PASSWORD_FILE")"
|
||||
if [ -z "$PASSWORD" ]; then
|
||||
echo "RDP password file is empty: $PASSWORD_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "''${#PASSWORD}" -lt 8 ]; then
|
||||
echo "RDP password is too short (''${#PASSWORD} characters, minimum 8): $PASSWORD_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
chown gnome-remote-desktop:gnome-remote-desktop "$PASSWORD_FILE"
|
||||
chmod 600 "$PASSWORD_FILE"
|
||||
|
||||
LOCAL_IP="$(hostname -I | awk '{print $1}')"
|
||||
if [ -z "$LOCAL_IP" ]; then
|
||||
LOCAL_IP="127.0.0.1"
|
||||
# Generate password on first boot only
|
||||
PASSWORD=""
|
||||
if [ ! -f /var/lib/gnome-remote-desktop/rdp-password ]; then
|
||||
PASSWORD=$(openssl rand -base64 16)
|
||||
echo "$PASSWORD" > /var/lib/gnome-remote-desktop/rdp-password
|
||||
chmod 600 /var/lib/gnome-remote-desktop/rdp-password
|
||||
else
|
||||
PASSWORD=$(cat /var/lib/gnome-remote-desktop/rdp-password)
|
||||
fi
|
||||
|
||||
# Write username to a separate file for the hub
|
||||
echo "sovran" > /var/lib/gnome-remote-desktop/rdp-username
|
||||
chmod 600 /var/lib/gnome-remote-desktop/rdp-username
|
||||
|
||||
# Get current IP address
|
||||
LOCAL_IP=$(hostname -I | awk '{print $1}')
|
||||
|
||||
# Always rewrite the credentials file with the current IP
|
||||
cat > "$CRED_FILE" <<EOF
|
||||
========================================
|
||||
GNOME Remote Desktop (RDP) Credentials
|
||||
========================================
|
||||
|
||||
Username: $USERNAME
|
||||
Username: sovran
|
||||
Password: $PASSWORD
|
||||
|
||||
Connect from any RDP client to:
|
||||
@@ -170,22 +116,11 @@ lib.mkIf config.sovran_systemsOS.features.rdp {
|
||||
========================================
|
||||
EOF
|
||||
|
||||
chown gnome-remote-desktop:gnome-remote-desktop "$CRED_FILE"
|
||||
chmod 600 "$CRED_FILE"
|
||||
|
||||
# Preflight: the NixOS setuid pkexec wrapper must be present and executable
|
||||
# before any grdctl --system call. Absence means the system was booted
|
||||
# without security.wrappers or the wrapper directory is not mounted yet.
|
||||
if ! test -x /run/wrappers/bin/pkexec; then
|
||||
echo "Preflight check failed: /run/wrappers/bin/pkexec is absent or not executable." >&2
|
||||
echo "GNOME Remote Desktop system configuration requires the NixOS setuid pkexec wrapper at /run/wrappers/bin/pkexec." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
grdctl_system rdp enable
|
||||
grdctl_system rdp set-tls-cert "$TLS_DIR/rdp-tls.crt"
|
||||
grdctl_system rdp set-tls-key "$TLS_DIR/rdp-tls.key"
|
||||
grdctl_system rdp set-credentials "$USERNAME" "$PASSWORD"
|
||||
# Enable RDP backend and set credentials
|
||||
grdctl --system rdp enable
|
||||
grdctl --system rdp set-credentials sovran "$PASSWORD"
|
||||
|
||||
echo "GNOME Remote Desktop RDP configured successfully"
|
||||
'';
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
# 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, options, ... }:
|
||||
|
||||
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 && config.networking.domain != null && config.networking.domain != ""
|
||||
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;
|
||||
# electrs 0.12.0 (bindex) requires bitcoind's REST interface (rest=1).
|
||||
extraConfig = lib.optionalString (!(options.services.bitcoind ? rest)) "rest=1";
|
||||
} // lib.optionalAttrs cfg.web.btcpayserver {
|
||||
rpc.users.btcpayserver.rpcwhitelist = lib.mkForce (lib.unique (config.services.bitcoind.rpc.users.public.rpcwhitelist ++ [
|
||||
"setban"
|
||||
"generatetoaddress"
|
||||
"getrpcinfo"
|
||||
"gettransaction"
|
||||
"getwalletinfo"
|
||||
"getnewaddress"
|
||||
"listunspent"
|
||||
"listtransactions"
|
||||
"listreceivedbyaddress"
|
||||
"importmulti"
|
||||
"importdescriptors"
|
||||
"importaddress"
|
||||
"importprivkey"
|
||||
"importpubkey"
|
||||
"rescanblockchain"
|
||||
"createwallet"
|
||||
"loadwallet"
|
||||
"signrawtransactionwithwallet"
|
||||
]));
|
||||
});
|
||||
|
||||
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 (
|
||||
let
|
||||
bitcoind = config.services.bitcoind;
|
||||
electrsCfg = config.services.electrs;
|
||||
nbLib = config.nix-bitcoin.lib;
|
||||
in {
|
||||
requires = lib.mkForce [ "run-media-Second_Drive.mount" ];
|
||||
after = [ "run-media-Second_Drive.mount" "bitcoind.service" ];
|
||||
wants = [ "bitcoind.service" ];
|
||||
} // lib.optionalAttrs (!(options.services.bitcoind ? rest)) {
|
||||
# Fallback when locked against Sovran_Bitcoin <= 1.1.1:
|
||||
# electrs 0.12.0 removed --daemon-p2p-addr and switched to bindex (format v1).
|
||||
preStart = lib.mkAfter ''
|
||||
dbNetDir='${electrsCfg.dataDir}/${bitcoind.makeNetworkName "bitcoin" "regtest"}'
|
||||
if [[ -d "$dbNetDir" ]] && grep -q 'prefix_extractor=rocksdb\.FixedPrefix\.8' "$dbNetDir"/OPTIONS-* 2>/dev/null; then
|
||||
echo "electrs: removing pre-0.12.0 database at $dbNetDir for bindex 0.12.0 reindex"
|
||||
rm -rf "$dbNetDir"
|
||||
fi
|
||||
'';
|
||||
serviceConfig.ExecStart = lib.mkForce ''
|
||||
${pkgs.electrs}/bin/electrs \
|
||||
--log-filters=INFO \
|
||||
--network=${bitcoind.makeNetworkName "bitcoin" "regtest"} \
|
||||
--db-dir='${electrsCfg.dataDir}' \
|
||||
--daemon-dir='${bitcoind.dataDir}' \
|
||||
--electrum-rpc-addr=${electrsCfg.address}:${toString electrsCfg.port} \
|
||||
--monitoring-addr=${electrsCfg.address}:${toString electrsCfg.monitoringPort} \
|
||||
--daemon-rpc-addr=${nbLib.addressWithPort bitcoind.rpc.address bitcoind.rpc.port} \
|
||||
${electrsCfg.extraArgs}
|
||||
'';
|
||||
}
|
||||
);
|
||||
|
||||
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; }
|
||||
]
|
||||
);
|
||||
}
|
||||
+3
-57
@@ -94,23 +94,16 @@ EOF
|
||||
# ── Synapse service ─────────────────────────────────────────
|
||||
services.matrix-synapse = {
|
||||
enable = true;
|
||||
# cache-memory provides cache-size statistics for the autotuning below
|
||||
# (in addition to the NixOS defaults).
|
||||
extras = [ "systemd" "postgres" "url-preview" "cache-memory" ];
|
||||
extraConfigFiles = [
|
||||
"/run/matrix-synapse/runtime-config.yaml"
|
||||
];
|
||||
settings = {
|
||||
database = {
|
||||
name = "psycopg2";
|
||||
# Recycle pooled connections less often (fewer reconnects).
|
||||
txn_limit = 10000;
|
||||
args = {
|
||||
host = "localhost";
|
||||
database = "matrix-synapse";
|
||||
user = "matrix-synapse";
|
||||
cp_min = 5;
|
||||
cp_max = 15;
|
||||
};
|
||||
};
|
||||
push.include_content = false;
|
||||
@@ -127,32 +120,6 @@ EOF
|
||||
];
|
||||
presence.enabled = true;
|
||||
enable_registration = false;
|
||||
# ── Performance (32 GB Server + Desktop) ─────────────────
|
||||
# Synapse trades RAM for fewer Postgres round-trips; most RAM goes
|
||||
# to caches. Stock is global_factor 0.5 + 10K event cache, which
|
||||
# leaves syncs hitting the database on every request.
|
||||
# Deliberately unchanged: presence and URL previews stay enabled —
|
||||
# disabling them is faster but changes user-visible behavior.
|
||||
event_cache_size = "100K";
|
||||
caches = {
|
||||
global_factor = 4.0;
|
||||
expire_caches = true;
|
||||
cache_entry_ttl = "30m";
|
||||
sync_response_cache_duration = "2m";
|
||||
cache_autotuning = {
|
||||
max_cache_memory_usage = "2G";
|
||||
target_cache_memory_usage = "1G";
|
||||
min_cache_ttl = "30s";
|
||||
};
|
||||
per_cache_factors = {
|
||||
# Hot paths for /sync and room joins.
|
||||
get_users_in_room = 3.0;
|
||||
get_current_state_ids = 3.0;
|
||||
get_unread_event_push_actions_by_room_for_user = 5.0;
|
||||
};
|
||||
};
|
||||
# Fewer GC pauses at the cost of a little more memory.
|
||||
gc_thresholds = [ 1500 20 10 ];
|
||||
listeners = [
|
||||
{
|
||||
port = 8008;
|
||||
@@ -262,30 +229,6 @@ CREDS
|
||||
chmod 600 "$CREDS_FILE"
|
||||
fi
|
||||
|
||||
# Provision a private, random Matrix administrator for the Hub's Admin
|
||||
# API calls. This is deliberately separate from the visible bootstrap
|
||||
# admin account: installations where @admin pre-dated Sovran do not have
|
||||
# its password, but Hub user management must still work. Keeping the
|
||||
# generated localpart in this root-only file makes the operation
|
||||
# idempotent without exposing this service credential in the Hub UI.
|
||||
HUB_ADMIN_CREDS="/var/lib/secrets/matrix-hub-admin"
|
||||
if [ ! -s "$HUB_ADMIN_CREDS" ]; then
|
||||
HUB_ADMIN_USER="sovran-hub-$(pwgen -sA0 20 1)"
|
||||
HUB_ADMIN_PASS=$(pwgen -s 32 1)
|
||||
if register_new_matrix_user -c /run/matrix-synapse/runtime-config.yaml \
|
||||
-u "$HUB_ADMIN_USER" -p "$HUB_ADMIN_PASS" -a http://localhost:8008; then
|
||||
(umask 077; cat > "$HUB_ADMIN_CREDS" <<CREDS
|
||||
username=$HUB_ADMIN_USER
|
||||
password=$HUB_ADMIN_PASS
|
||||
CREDS
|
||||
)
|
||||
echo "Created private Matrix Hub service administrator."
|
||||
else
|
||||
echo "Failed to create the private Matrix Hub service administrator." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Always write individual credential files for the hub UI, even if the bulk
|
||||
# credentials file already existed from a prior run (umask 077 ensures mode 600).
|
||||
# If passwords were not freshly generated above, parse them from the bulk file.
|
||||
@@ -307,6 +250,9 @@ CREDS
|
||||
'';
|
||||
};
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 8448 ];
|
||||
networking.firewall.allowedUDPPorts = [ 8448 ];
|
||||
|
||||
sovran_systemsOS.domainRequirements = [
|
||||
{ name = "matrix"; label = "Matrix Synapse"; example = "matrix.yourdomain.com"; }
|
||||
];
|
||||
|
||||
@@ -47,44 +47,29 @@ EOF
|
||||
systemd.services.zeus-connect-setup = {
|
||||
description = "Save Zeus lndconnect URL";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "lnd.service" "onion-addresses.service" ];
|
||||
wants = [ "lnd.service" "onion-addresses.service" ];
|
||||
after = [ "lnd.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
# 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" ];
|
||||
path = [ pkgs.coreutils "/run/current-system/sw" ];
|
||||
script = ''
|
||||
SECRET_FILE="/var/lib/secrets/zeus-connect-url"
|
||||
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=""
|
||||
ATTEMPTS=0
|
||||
while [ "$ATTEMPTS" -lt 60 ]; do
|
||||
if command -v lndconnect >/dev/null 2>&1; then
|
||||
URL=$(lndconnect --url 2>/dev/null | tr -d '\r' | tail -n 1 || true)
|
||||
URL=$(lndconnect --url 2>/dev/null || true)
|
||||
elif command -v lnconnect-clnrest >/dev/null 2>&1; then
|
||||
URL=$(lnconnect-clnrest --url 2>/dev/null || true)
|
||||
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
|
||||
printf '%s\n' "$URL" > "$SECRET_FILE"
|
||||
echo "$URL" > "$SECRET_FILE"
|
||||
chmod 600 "$SECRET_FILE"
|
||||
echo "Zeus connect URL saved."
|
||||
else
|
||||
echo "No valid lndconnect URL available yet."
|
||||
echo "No lndconnect URL available yet."
|
||||
fi
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -1,563 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# release-stable.sh
|
||||
# Automated stable release script for Sovran_SystemsOS
|
||||
#
|
||||
# Features:
|
||||
# - Detects latest tag and suggests next version
|
||||
# - Pushes main → stable on Gitea
|
||||
# - Creates annotated tag
|
||||
# - Auto-updates CHANGELOG.md
|
||||
# - Creates releases on both GitHub and Gitea via API
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/release-stable.sh [version] [--message "text"]
|
||||
#
|
||||
# Requirements:
|
||||
# - gh CLI (for GitHub releases)
|
||||
# - GITEA_TOKEN env var (for Gitea releases)
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Always operate from the repository root, regardless of the caller's cwd.
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Configuration
|
||||
GITEA_REMOTE_DEFAULT="gitea"
|
||||
GITHUB_REMOTE_DEFAULT="origin"
|
||||
GITEA_API_URL="https://git.sovransystems.com/api/v1"
|
||||
CHANGELOG_FILE="CHANGELOG.md"
|
||||
|
||||
# Auto-detect remotes
|
||||
detect_remote() {
|
||||
local preferred="$1"
|
||||
local keyword="$2"
|
||||
if git remote | grep -q -x "$preferred"; then
|
||||
echo "$preferred"
|
||||
return
|
||||
fi
|
||||
local found
|
||||
found=$(git remote -v | grep -i "$keyword" | head -n 1 | awk '{print $1}')
|
||||
if [[ -n "$found" ]]; then
|
||||
echo "$found"
|
||||
return
|
||||
fi
|
||||
echo "$preferred"
|
||||
}
|
||||
|
||||
GITEA_REMOTE=$(detect_remote "$GITEA_REMOTE_DEFAULT" "sovransystems\|gitea")
|
||||
GITHUB_REMOTE=$(detect_remote "$GITHUB_REMOTE_DEFAULT" "github")
|
||||
|
||||
echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BLUE}║ Sovran_SystemsOS Automated Stable Release Script ║${NC}"
|
||||
echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Helper: Get latest tag
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# GitHub release tags are fetched into a private namespace instead of
|
||||
# refs/tags/. GitHub and Gitea contain some same-named historical tags that
|
||||
# point to different objects; sharing refs/tags/ would make either fetch fail
|
||||
# with "would clobber existing tag".
|
||||
GITHUB_TAG_NAMESPACE="refs/release-tags/github"
|
||||
|
||||
get_latest_tag() {
|
||||
local exclude="${1:-}"
|
||||
local tag
|
||||
|
||||
while IFS= read -r tag; do
|
||||
if [[ -n "$exclude" && "$tag" == "v${exclude#v}" ]]; then
|
||||
continue
|
||||
fi
|
||||
printf '%s\n' "$tag"
|
||||
return 0
|
||||
done < <(git for-each-ref \
|
||||
--sort=-version:refname \
|
||||
--format='%(refname:strip=3)' \
|
||||
"${GITHUB_TAG_NAMESPACE}/v*")
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Helper: Suggest next version (patch bump)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
suggest_next_version() {
|
||||
local current="$1"
|
||||
if [[ -z "$current" ]]; then
|
||||
echo "1.0.0"
|
||||
return
|
||||
fi
|
||||
|
||||
# Remove 'v' prefix
|
||||
local ver="${current#v}"
|
||||
IFS='.' read -r major minor patch <<< "$ver"
|
||||
|
||||
# Default: bump patch
|
||||
echo "${major}.$((minor)).$((patch + 1))"
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 0: Preflight and fetch everything
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo -e "${BLUE}Step 0: Running release preflight...${NC}"
|
||||
|
||||
# A release must start from an exact, committed, reproducible tree. This also
|
||||
# prevents release metadata generated below from being mixed with local work.
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
echo -e "${RED}Error: the working tree is not clean. Commit, stash, or remove local changes first.${NC}" >&2
|
||||
git status --short >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for remote in "$GITHUB_REMOTE" "$GITEA_REMOTE"; do
|
||||
if ! git remote | grep -q -x "$remote"; then
|
||||
echo -e "${RED}Error: required remote '$remote' is not configured.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
for command_name in gh curl; do
|
||||
if ! command -v "$command_name" >/dev/null 2>&1; then
|
||||
echo -e "${RED}Error: required command '$command_name' is not installed.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if ! gh auth status >/dev/null 2>&1; then
|
||||
echo -e "${RED}Error: GitHub CLI is not authenticated. Run 'gh auth login' first.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
||||
echo
|
||||
read -rsp "Enter your Gitea API token (input hidden): " GITEA_TOKEN
|
||||
echo
|
||||
fi
|
||||
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
||||
echo -e "${RED}Error: a GITEA_TOKEN with write:repository scope is required.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e " Fetching GitHub (${GITHUB_REMOTE}) and Gitea (${GITEA_REMOTE})..."
|
||||
# Never fetch either host's tags into refs/tags/. Historical tags with the same
|
||||
# name differ between the hosts, and Git correctly refuses to clobber them.
|
||||
git fetch "$GITHUB_REMOTE" --prune --no-tags
|
||||
git fetch "$GITEA_REMOTE" --prune --no-tags
|
||||
git fetch "$GITHUB_REMOTE" --prune --no-tags \
|
||||
"+refs/tags/*:${GITHUB_TAG_NAMESPACE}/*"
|
||||
|
||||
GITHUB_MAIN_REF="refs/remotes/${GITHUB_REMOTE}/main"
|
||||
GITEA_STAGING_REF="refs/remotes/${GITEA_REMOTE}/staging-dev"
|
||||
|
||||
if ! git rev-parse --verify "$GITHUB_MAIN_REF" >/dev/null 2>&1; then
|
||||
echo -e "${RED}Error: cannot resolve GitHub main at ${GITHUB_MAIN_REF}.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! git rev-parse --verify "$GITEA_STAGING_REF" >/dev/null 2>&1; then
|
||||
echo -e "${RED}Error: cannot resolve Gitea staging-dev at ${GITEA_STAGING_REF}.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
HEAD_COMMIT=$(git rev-parse HEAD)
|
||||
GITHUB_MAIN_COMMIT=$(git rev-parse "$GITHUB_MAIN_REF")
|
||||
GITEA_STAGING_COMMIT=$(git rev-parse "$GITEA_STAGING_REF")
|
||||
|
||||
if [[ "$GITHUB_MAIN_COMMIT" != "$GITEA_STAGING_COMMIT" ]]; then
|
||||
echo -e "${RED}Error: GitHub main and Gitea staging-dev are not synchronized.${NC}" >&2
|
||||
echo " GitHub main : $GITHUB_MAIN_COMMIT" >&2
|
||||
echo " Gitea staging-dev : $GITEA_STAGING_COMMIT" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$HEAD_COMMIT" != "$GITHUB_MAIN_COMMIT" ]]; then
|
||||
echo -e "${RED}Error: local HEAD is not the synchronized release candidate.${NC}" >&2
|
||||
echo " Local HEAD : $HEAD_COMMIT" >&2
|
||||
echo " Remote HEAD : $GITHUB_MAIN_COMMIT" >&2
|
||||
echo "Update/check out the synchronized commit, then run the script again." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e " ${GREEN}✓${NC} Clean tree; GitHub main and Gitea staging-dev match local HEAD"
|
||||
|
||||
LATEST_TAG=$(get_latest_tag)
|
||||
NEXT_VERSION=$(suggest_next_version "$LATEST_TAG")
|
||||
|
||||
echo -e " Latest tag : ${CYAN}${LATEST_TAG:-none}${NC}"
|
||||
echo -e " Suggested : ${GREEN}v${NEXT_VERSION}${NC}"
|
||||
echo
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Get version from user or argument
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
if [ $# -ge 1 ] && [[ "$1" != --* ]]; then
|
||||
VERSION="$1"
|
||||
shift
|
||||
else
|
||||
read -rp "Enter version to release (default: ${NEXT_VERSION}): " input_version
|
||||
VERSION="${input_version:-$NEXT_VERSION}"
|
||||
fi
|
||||
|
||||
# Strip 'v' if user added it
|
||||
VERSION="${VERSION#v}"
|
||||
TAG="v${VERSION}"
|
||||
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo -e "${RED}Error: version must use MAJOR.MINOR.PATCH format (for example, 1.1.1).${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
remote_has_tag() {
|
||||
local remote="$1"
|
||||
[[ -n "$(git ls-remote --tags "$remote" "refs/tags/${TAG}" "refs/tags/${TAG}^{}")" ]]
|
||||
}
|
||||
|
||||
if git show-ref --verify --quiet "refs/tags/${TAG}" || \
|
||||
git show-ref --verify --quiet "${GITHUB_TAG_NAMESPACE}/${TAG}" || \
|
||||
remote_has_tag "$GITHUB_REMOTE" || remote_has_tag "$GITEA_REMOTE"; then
|
||||
echo -e "${RED}Error: tag ${TAG} already exists locally or on a remote.${NC}" >&2
|
||||
echo "Refusing to move or overwrite an existing release tag." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Get release message
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
RELEASE_MESSAGE=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--message|-m)
|
||||
RELEASE_MESSAGE="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$RELEASE_MESSAGE" ]]; then
|
||||
echo
|
||||
read -rp "Enter release headline (or press Enter for default): " input_msg
|
||||
if [[ -n "$input_msg" ]]; then
|
||||
RELEASE_MESSAGE="$input_msg"
|
||||
else
|
||||
RELEASE_MESSAGE="Sovran_SystemsOS ${TAG} — Stable Release"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Helper: Generate categorized release notes from commit history
|
||||
# Groups commits since the last tag into Keep-a-Changelog sections based on
|
||||
# conventional-commit prefixes (feat/fix/docs/security/etc.) and keywords.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
generate_release_notes() {
|
||||
local range="$1"
|
||||
|
||||
local added="" changed="" fixed="" security="" docs=""
|
||||
|
||||
while IFS= read -r subject; do
|
||||
# Skip noise commits
|
||||
case "$subject" in
|
||||
"Initial plan"|"initial plan") continue ;;
|
||||
"Merge pull request"*|"Merge branch"*) continue ;;
|
||||
"chore: bump VERSION"*|"docs: update CHANGELOG"*) continue ;;
|
||||
"Address code review"*|"Address review"*|"Address validation"*) continue ;;
|
||||
esac
|
||||
|
||||
# Strip conventional-commit prefix for display
|
||||
local clean
|
||||
clean="$(echo "$subject" | sed -E 's/^(feat|fix|docs|chore|refactor|test|security|perf|style|ci|build)(\([^)]*\))?!?:[[:space:]]*//')"
|
||||
# Capitalize first letter
|
||||
clean="$(echo "${clean:0:1}" | tr '[:lower:]' '[:upper:]')${clean:1}"
|
||||
|
||||
case "$subject" in
|
||||
security:*|security\(*) security+="- ${clean}"$'\n' ;;
|
||||
feat:*|feat\(*) added+="- ${clean}"$'\n' ;;
|
||||
fix:*|fix\(*|Fix\ *|fixed\ *) fixed+="- ${clean}"$'\n' ;;
|
||||
docs:*|docs\(*) docs+="- ${clean}"$'\n' ;;
|
||||
refactor:*|refactor\(*|chore:*|chore\(*|removed\ *|Updated\ *|updated\ *) changed+="- ${clean}"$'\n' ;;
|
||||
test:*|test\(*) continue ;;
|
||||
*) added+="- ${clean}"$'\n' ;;
|
||||
esac
|
||||
done < <(git log --no-merges --format='%s' "$range" 2>/dev/null | awk '!seen[$0]++')
|
||||
|
||||
local notes=""
|
||||
if [[ -n "$added" ]]; then notes+=$'### Added\n'"$added"$'\n'; fi
|
||||
if [[ -n "$changed" ]]; then notes+=$'### Changed\n'"$changed"$'\n'; fi
|
||||
if [[ -n "$fixed" ]]; then notes+=$'### Fixed\n'"$fixed"$'\n'; fi
|
||||
if [[ -n "$security" ]]; then notes+=$'### Security\n'"$security"$'\n'; fi
|
||||
if [[ -n "$docs" ]]; then notes+=$'### Documentation\n'"$docs"$'\n'; fi
|
||||
|
||||
if [[ -z "$notes" ]]; then
|
||||
notes=$'### Changed\n- Incremental stable updates\n'
|
||||
fi
|
||||
|
||||
printf '%s' "$notes"
|
||||
}
|
||||
|
||||
# Build notes from the previous canonical GitHub tag. Use its private ref so a
|
||||
# conflicting local or Gitea tag with the same name cannot select the wrong
|
||||
# commit.
|
||||
PREV_TAG=$(get_latest_tag "$TAG")
|
||||
PREV_TAG_REF="${GITHUB_TAG_NAMESPACE}/${PREV_TAG}"
|
||||
if [[ -n "$PREV_TAG" ]] && git rev-parse -q --verify "$PREV_TAG_REF" >/dev/null; then
|
||||
COMMIT_RANGE="${PREV_TAG_REF}..HEAD"
|
||||
COMMIT_RANGE_DISPLAY="${PREV_TAG}..HEAD"
|
||||
else
|
||||
COMMIT_RANGE="HEAD"
|
||||
COMMIT_RANGE_DISPLAY="HEAD"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo -e "${BLUE}Generating draft release notes from ${COMMIT_RANGE_DISPLAY}...${NC}"
|
||||
RELEASE_NOTES="$(generate_release_notes "$COMMIT_RANGE")"
|
||||
|
||||
# Let the user review/edit the generated notes before publishing
|
||||
NOTES_FILE="$(mktemp "/tmp/release-notes-${TAG}.XXXXXX.md")"
|
||||
trap 'rm -f "$NOTES_FILE"' EXIT
|
||||
{
|
||||
echo "## Sovran_SystemsOS ${TAG}"
|
||||
echo
|
||||
echo "${RELEASE_MESSAGE}"
|
||||
echo
|
||||
echo "$RELEASE_NOTES"
|
||||
echo "**Full changelog:** [CHANGELOG.md](https://github.com/naturallaw777/Sovran_SystemsOS/blob/main/CHANGELOG.md)"
|
||||
} > "$NOTES_FILE"
|
||||
|
||||
echo -e " ${GREEN}✓${NC} Draft notes written to: ${CYAN}${NOTES_FILE}${NC}"
|
||||
echo
|
||||
echo "──────────────── Draft Release Notes ────────────────"
|
||||
cat "$NOTES_FILE"
|
||||
echo "──────────────────────────────────────────────────────"
|
||||
echo
|
||||
read -rp "Edit the notes before publishing? (y/N): " edit_confirm
|
||||
if [[ "$edit_confirm" =~ ^[Yy]$ ]]; then
|
||||
"${EDITOR:-nano}" "$NOTES_FILE"
|
||||
RELEASE_NOTES="$(sed -n '/^###/,$p' "$NOTES_FILE" | sed '/^\*\*Full changelog/d')"
|
||||
fi
|
||||
RELEASE_BODY="$(cat "$NOTES_FILE")"
|
||||
|
||||
echo
|
||||
echo -e "${YELLOW}════════════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${YELLOW} Preparing Release${NC}"
|
||||
echo -e "${YELLOW}════════════════════════════════════════════════════════════${NC}"
|
||||
echo " Version : ${TAG}"
|
||||
echo " Message : ${RELEASE_MESSAGE}"
|
||||
echo " Gitea Remote : ${GITEA_REMOTE}"
|
||||
echo " GitHub Remote : ${GITHUB_REMOTE}"
|
||||
echo
|
||||
|
||||
read -rp "Proceed with this release? (y/N): " confirm
|
||||
if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
|
||||
echo "Aborted."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 1: Prepare and commit all release metadata
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo -e "${BLUE}Step 1: Preparing release metadata for ${TAG}...${NC}"
|
||||
|
||||
# VERSION drives ISO naming.
|
||||
echo "${VERSION}" > VERSION
|
||||
|
||||
# Update every versioned ISO filename in README.md.
|
||||
README_FILE="README.md"
|
||||
if [[ ! -f "$README_FILE" ]]; then
|
||||
echo -e "${RED}Error: ${README_FILE} not found.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
OLD_ISO_VER=$(grep -oE 'Sovran_SystemsOS-[0-9]+\.[0-9]+\.[0-9]+\.iso' "$README_FILE" \
|
||||
| head -1 | sed 's/Sovran_SystemsOS-//; s/\.iso//')
|
||||
if [[ -z "$OLD_ISO_VER" ]]; then
|
||||
echo -e "${RED}Error: no versioned ISO reference found in ${README_FILE}.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$OLD_ISO_VER" != "$VERSION" ]]; then
|
||||
sed "s/Sovran_SystemsOS-${OLD_ISO_VER}/Sovran_SystemsOS-${VERSION}/g" \
|
||||
"$README_FILE" > "$README_FILE.tmp"
|
||||
mv "$README_FILE.tmp" "$README_FILE"
|
||||
fi
|
||||
|
||||
# Add the changelog entry before tagging so the tag and stable branch contain it.
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
NEW_ENTRY="## [${VERSION}] - ${TODAY}
|
||||
|
||||
${RELEASE_NOTES}
|
||||
[${VERSION}]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/${TAG}
|
||||
"
|
||||
|
||||
if [[ ! -f "$CHANGELOG_FILE" ]]; then
|
||||
echo -e "${RED}Error: ${CHANGELOG_FILE} not found.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
awk -v new_entry="$NEW_ENTRY" '
|
||||
BEGIN { printed=0 }
|
||||
/^---$/ && !printed {
|
||||
print
|
||||
print ""
|
||||
print new_entry
|
||||
printed=1
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
END { if (!printed) exit 2 }
|
||||
' "$CHANGELOG_FILE" > "${CHANGELOG_FILE}.tmp" || {
|
||||
rm -f "${CHANGELOG_FILE}.tmp"
|
||||
echo -e "${RED}Error: could not find the changelog insertion marker.${NC}" >&2
|
||||
exit 1
|
||||
}
|
||||
mv "${CHANGELOG_FILE}.tmp" "$CHANGELOG_FILE"
|
||||
|
||||
git add VERSION "$README_FILE" "$CHANGELOG_FILE"
|
||||
if git diff --cached --quiet; then
|
||||
echo -e "${RED}Error: release preparation produced no changes.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
git commit -m "chore(release): prepare ${TAG}"
|
||||
RELEASE_COMMIT=$(git rev-parse HEAD)
|
||||
|
||||
echo -e " ${GREEN}✓${NC} VERSION, README, and CHANGELOG committed"
|
||||
echo -e " Release commit: ${CYAN}${RELEASE_COMMIT}${NC}"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 2: Publish the final release commit to every release branch
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo -e "${BLUE}Step 2: Publishing the final release commit...${NC}"
|
||||
|
||||
# GitHub main and Gitea staging-dev were verified equal during preflight, so
|
||||
# these are normal fast-forward pushes. Stable is an intentional promotion and
|
||||
# uses a lease to prevent overwriting a branch changed since the fetch.
|
||||
git push "$GITHUB_REMOTE" HEAD:main
|
||||
git push "$GITEA_REMOTE" HEAD:staging-dev
|
||||
git push "$GITEA_REMOTE" HEAD:stable --force-with-lease
|
||||
|
||||
# Verify all three branch tips before creating an immutable release tag.
|
||||
remote_branch_commit() {
|
||||
local remote="$1"
|
||||
local branch="$2"
|
||||
git ls-remote "$remote" "refs/heads/${branch}" | awk 'NR == 1 { print $1 }'
|
||||
}
|
||||
|
||||
PUBLISHED_GITHUB=$(remote_branch_commit "$GITHUB_REMOTE" main)
|
||||
PUBLISHED_STAGING=$(remote_branch_commit "$GITEA_REMOTE" staging-dev)
|
||||
PUBLISHED_STABLE=$(remote_branch_commit "$GITEA_REMOTE" stable)
|
||||
if [[ "$PUBLISHED_GITHUB" != "$RELEASE_COMMIT" || \
|
||||
"$PUBLISHED_STAGING" != "$RELEASE_COMMIT" || \
|
||||
"$PUBLISHED_STABLE" != "$RELEASE_COMMIT" ]]; then
|
||||
echo -e "${RED}Error: post-push verification failed; no release tag was created.${NC}" >&2
|
||||
echo " Expected : $RELEASE_COMMIT" >&2
|
||||
echo " GitHub main : ${PUBLISHED_GITHUB:-missing}" >&2
|
||||
echo " Gitea staging-dev : ${PUBLISHED_STAGING:-missing}" >&2
|
||||
echo " Gitea stable : ${PUBLISHED_STABLE:-missing}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo -e " ${GREEN}✓${NC} GitHub main, Gitea staging-dev, and Gitea stable all match"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 3: Tag the final release commit and publish the tag
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo -e "${BLUE}Step 3: Creating annotated tag ${TAG} on ${RELEASE_COMMIT}...${NC}"
|
||||
git tag -a "$TAG" -m "${RELEASE_MESSAGE}
|
||||
|
||||
- Stable release of Sovran_SystemsOS
|
||||
- See CHANGELOG.md for full details" "$RELEASE_COMMIT"
|
||||
|
||||
git push "$GITHUB_REMOTE" "$TAG"
|
||||
git push "$GITEA_REMOTE" "$TAG"
|
||||
|
||||
# For an annotated tag, ^{} resolves the commit referenced by the tag object.
|
||||
remote_tag_commit() {
|
||||
local remote="$1"
|
||||
git ls-remote "$remote" "refs/tags/${TAG}^{}" | awk 'NR == 1 { print $1 }'
|
||||
}
|
||||
GITHUB_TAG_COMMIT=$(remote_tag_commit "$GITHUB_REMOTE")
|
||||
GITEA_TAG_COMMIT=$(remote_tag_commit "$GITEA_REMOTE")
|
||||
if [[ "$GITHUB_TAG_COMMIT" != "$RELEASE_COMMIT" || "$GITEA_TAG_COMMIT" != "$RELEASE_COMMIT" ]]; then
|
||||
echo -e "${RED}Error: published tag verification failed.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo -e " ${GREEN}✓${NC} ${TAG} points to the final release commit on GitHub and Gitea"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 4: Create GitHub release via gh CLI
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo -e "${BLUE}Step 4: Creating GitHub release...${NC}"
|
||||
|
||||
title_suffix="${RELEASE_MESSAGE#Sovran_SystemsOS v* — }"
|
||||
title_suffix="${title_suffix#Sovran_SystemsOS * — }"
|
||||
gh release create "$TAG" \
|
||||
--repo naturallaw777/Sovran_SystemsOS \
|
||||
--title "${TAG} — ${title_suffix}" \
|
||||
--notes-file "$NOTES_FILE"
|
||||
echo -e " ${GREEN}✓${NC} GitHub release created successfully"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 5: Create Gitea release via API
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo -e "${BLUE}Step 5: Creating Gitea release via API...${NC}"
|
||||
|
||||
GITEA_REPO="Sovran_Systems/Sovran_SystemsOS"
|
||||
|
||||
# Build JSON safely because release notes may contain quotes and newlines.
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg tag "$TAG" \
|
||||
--arg name "$TAG — Stable Release" \
|
||||
--arg body "$RELEASE_BODY" \
|
||||
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')
|
||||
else
|
||||
PAYLOAD=$(python3 -c "import json,sys; print(json.dumps({'tag_name': sys.argv[1], 'name': sys.argv[1] + ' — Stable Release', 'body': open(sys.argv[2]).read(), 'draft': False, 'prerelease': False}))" "$TAG" "$NOTES_FILE")
|
||||
fi
|
||||
|
||||
RESPONSE_FILE=$(mktemp "/tmp/gitea-release-${TAG}.XXXXXX.json")
|
||||
HTTP_STATUS=$(curl -sS -o "$RESPONSE_FILE" -w '%{http_code}' -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
"${GITEA_API_URL}/repos/${GITEA_REPO}/releases")
|
||||
|
||||
if [[ "$HTTP_STATUS" != "201" ]]; then
|
||||
echo -e "${RED}Error: Gitea release creation failed (HTTP ${HTTP_STATUS}).${NC}" >&2
|
||||
cat "$RESPONSE_FILE" >&2
|
||||
rm -f "$RESPONSE_FILE"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$RESPONSE_FILE"
|
||||
echo -e " ${GREEN}✓${NC} Gitea release created successfully"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Final Summary
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo -e "${GREEN}╔════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ ✅ Release ${TAG} completed successfully! ║${NC}"
|
||||
echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo
|
||||
echo "Next manual steps (recommended):"
|
||||
echo " • Build the installer ISO:"
|
||||
echo " nix build .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage"
|
||||
echo " • Package, verify, and upload ISO to CDN:"
|
||||
echo " ./scripts/upload-cdn.sh --upload"
|
||||
echo " • Verify the ISO download and checksum from the public CDN"
|
||||
echo " • Verify releases on both GitHub and Gitea"
|
||||
echo
|
||||
echo -e "${CYAN}Tag created: ${TAG}${NC}"
|
||||
git show "${TAG}" --quiet
|
||||
|
||||
# Clean up temp notes file
|
||||
rm -f "${NOTES_FILE}"
|
||||
@@ -1,168 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# upload-cdn.sh
|
||||
# Copies built ISO out of the nix store, generates versioned SHA-256 checksum,
|
||||
# verifies it, and optionally uploads to CDN.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/upload-cdn.sh [--upload]
|
||||
#
|
||||
# EVERYTHING this script creates lives OUTSIDE the repository, even when you
|
||||
# run it from inside the repo:
|
||||
# - the nix build output symlink -> $ISO_OUT_DIR/result
|
||||
# - the ISO and .sha256 files -> $ISO_OUT_DIR
|
||||
# The repo working tree stays completely clean.
|
||||
#
|
||||
# Environment variables (optional):
|
||||
# ISO_OUT_DIR - dir for the build symlink + ISO + checksum
|
||||
# (default: ~/Sovran-builds)
|
||||
# CDN_RSYNC_TARGET - rsync destination (e.g. user@server:/var/www/downloads/)
|
||||
# CDN_RCLONE_REMOTE - rclone remote target (e.g. s3:my-bucket/downloads/)
|
||||
# CDN_UPLOAD_CMD - custom upload command
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Always operate from the repo root, no matter where the script is invoked from
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# Read version from VERSION file
|
||||
if [ ! -f VERSION ]; then
|
||||
echo -e "${RED}Error: VERSION file not found in $REPO_ROOT.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
VERSION=$(cat VERSION | tr -d '\n\r ')
|
||||
ISO_NAME="Sovran_SystemsOS-${VERSION}.iso"
|
||||
SHA_NAME="${ISO_NAME}.sha256"
|
||||
|
||||
# Output directory OUTSIDE the repo (override with ISO_OUT_DIR)
|
||||
OUT_DIR="${ISO_OUT_DIR:-$HOME/Sovran-builds}"
|
||||
mkdir -p "$OUT_DIR"
|
||||
OUT_DIR="$(cd "$OUT_DIR" && pwd)"
|
||||
ISO_PATH="$OUT_DIR/${ISO_NAME}"
|
||||
SHA_PATH="$OUT_DIR/${SHA_NAME}"
|
||||
RESULT_LINK="$OUT_DIR/result"
|
||||
|
||||
echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BLUE}║ Sovran_SystemsOS CDN ISO Packaging & Upload Tool ║${NC}"
|
||||
echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo -e " Version : ${CYAN}${VERSION}${NC}"
|
||||
echo -e " ISO : ${CYAN}${ISO_NAME}${NC}"
|
||||
echo -e " Repo : ${CYAN}${REPO_ROOT}${NC} (left untouched)"
|
||||
echo -e " Output : ${CYAN}${OUT_DIR}${NC} (outside the repo)"
|
||||
echo
|
||||
|
||||
# Locate or build ISO
|
||||
SRC_ISO=""
|
||||
if [ -f "$RESULT_LINK/iso/${ISO_NAME}" ]; then
|
||||
SRC_ISO="$RESULT_LINK/iso/${ISO_NAME}"
|
||||
elif [ -f "$RESULT_LINK/iso/Sovran_SystemsOS.iso" ]; then
|
||||
SRC_ISO="$RESULT_LINK/iso/Sovran_SystemsOS.iso"
|
||||
elif [ -f "result/iso/${ISO_NAME}" ]; then
|
||||
SRC_ISO="result/iso/${ISO_NAME}" # legacy: from an older in-repo build
|
||||
elif [ -f "result/iso/Sovran_SystemsOS.iso" ]; then
|
||||
SRC_ISO="result/iso/Sovran_SystemsOS.iso"
|
||||
else
|
||||
FOUND=$(find "$RESULT_LINK" result -name "*.iso" 2>/dev/null | head -n 1 || true)
|
||||
if [ -n "$FOUND" ]; then
|
||||
SRC_ISO="$FOUND"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$SRC_ISO" ] || [ ! -f "$SRC_ISO" ]; then
|
||||
echo -e "${YELLOW}Built ISO not found. Building via Nix (result link goes outside the repo)...${NC}"
|
||||
rm -f "$RESULT_LINK"
|
||||
nix build .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage \
|
||||
--out-link "$RESULT_LINK"
|
||||
|
||||
if [ -f "$RESULT_LINK/iso/${ISO_NAME}" ]; then
|
||||
SRC_ISO="$RESULT_LINK/iso/${ISO_NAME}"
|
||||
else
|
||||
FOUND=$(find "$RESULT_LINK" -name "*.iso" 2>/dev/null | head -n 1 || true)
|
||||
if [ -n "$FOUND" ]; then
|
||||
SRC_ISO="$FOUND"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$SRC_ISO" ] || [ ! -f "$SRC_ISO" ]; then
|
||||
echo -e "${RED}Error: Failed to locate built ISO.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}Step 1: Copying built ISO to output dir (outside repo)...${NC}"
|
||||
cp -v "$SRC_ISO" "$ISO_PATH"
|
||||
echo -e " ${GREEN}✓${NC} Copied to $ISO_PATH"
|
||||
|
||||
echo
|
||||
echo -e "${BLUE}Step 2: Generating versioned SHA-256 checksum...${NC}"
|
||||
sha256sum "$ISO_PATH" > "$SHA_PATH"
|
||||
echo -e " ${GREEN}✓${NC} Generated $SHA_PATH"
|
||||
cat "$SHA_PATH"
|
||||
|
||||
echo
|
||||
echo -e "${BLUE}Step 3: Verifying checksum...${NC}"
|
||||
sha256sum --check "$SHA_PATH"
|
||||
echo -e " ${GREEN}✓${NC} Checksum verified successfully"
|
||||
|
||||
# Parse arguments for upload
|
||||
DO_UPLOAD=0
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--upload)
|
||||
DO_UPLOAD=1
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$DO_UPLOAD" -eq 1 ]; then
|
||||
echo
|
||||
echo -e "${BLUE}Step 4: Uploading to CDN...${NC}"
|
||||
|
||||
UPLOADED=0
|
||||
if [ -n "${CDN_UPLOAD_CMD:-}" ]; then
|
||||
echo -e " Running custom CDN_UPLOAD_CMD..."
|
||||
eval "$CDN_UPLOAD_CMD"
|
||||
UPLOADED=1
|
||||
fi
|
||||
|
||||
if [ -n "${CDN_RSYNC_TARGET:-}" ]; then
|
||||
echo -e " Uploading via rsync to ${CDN_RSYNC_TARGET}..."
|
||||
rsync -avP "$ISO_PATH" "$SHA_PATH" "${CDN_RSYNC_TARGET}"
|
||||
UPLOADED=1
|
||||
fi
|
||||
|
||||
if [ -n "${CDN_RCLONE_REMOTE:-}" ]; then
|
||||
echo -e " Uploading via rclone to ${CDN_RCLONE_REMOTE}..."
|
||||
rclone copy "$ISO_PATH" "$SHA_PATH" "${CDN_RCLONE_REMOTE}"
|
||||
UPLOADED=1
|
||||
fi
|
||||
|
||||
if [ "$UPLOADED" -eq 0 ]; then
|
||||
echo -e " ${YELLOW}⚠ Warning: --upload requested, but no upload method specified.${NC}"
|
||||
echo -e " Set CDN_RSYNC_TARGET, CDN_RCLONE_REMOTE, or CDN_UPLOAD_CMD."
|
||||
else
|
||||
echo -e " ${GREEN}✓${NC} Upload complete."
|
||||
fi
|
||||
else
|
||||
echo
|
||||
echo -e "${GREEN}╔════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ ✅ ISO packaging & verification complete! ║${NC}"
|
||||
echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo -e "Ready files (in ${CYAN}${OUT_DIR}${NC}, outside the repo):"
|
||||
ls -lh "$ISO_PATH" "$SHA_PATH"
|
||||
echo
|
||||
echo "To upload to CDN, run:"
|
||||
echo " ./scripts/upload-cdn.sh --upload"
|
||||
echo "(configure CDN_RSYNC_TARGET, CDN_RCLONE_REMOTE, or CDN_UPLOAD_CMD)"
|
||||
fi
|
||||
@@ -1,73 +0,0 @@
|
||||
"""Regression tests for the Bitcoin Core Tor IBD gossip Hub option.
|
||||
|
||||
These tests intentionally avoid importing the FastAPI application so they can run
|
||||
in the repository's lightweight test environment without NixOS service access.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import os
|
||||
import unittest
|
||||
|
||||
|
||||
_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
|
||||
def _read(*parts: str) -> str:
|
||||
with open(os.path.join(_REPO_ROOT, *parts), encoding="utf-8") as source:
|
||||
return source.read()
|
||||
|
||||
|
||||
def _literal_assignment(source: str, name: str):
|
||||
tree = ast.parse(source)
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Assign):
|
||||
if any(isinstance(target, ast.Name) and target.id == name for target in node.targets):
|
||||
return ast.literal_eval(node.value)
|
||||
raise AssertionError(f"Assignment {name} was not found")
|
||||
|
||||
|
||||
class TestBitcoinTorGossipNixWiring(unittest.TestCase):
|
||||
def test_integration_layer_maps_tor_gossip_option(self):
|
||||
"""The integration layer must bridge sovran_systemsOS.features.bitcoin-tor-gossip
|
||||
to sovran-bitcoin.bitcoindTorGossip."""
|
||||
integration = _read("modules", "sovran-bitcoin-integration.nix")
|
||||
self.assertIn("bitcoindTorGossip", integration)
|
||||
self.assertIn("bitcoin-tor-gossip", integration)
|
||||
|
||||
def test_hub_option_and_evaluated_state_are_declared(self):
|
||||
roles = _read("modules", "core", "roles.nix")
|
||||
hub = _read("modules", "core", "sovran-hub.nix")
|
||||
self.assertIn("bitcoin-tor-gossip = lib.mkEnableOption", roles)
|
||||
self.assertIn("bitcoin-tor-gossip = cfg.features.bitcoin-tor-gossip;", hub)
|
||||
|
||||
|
||||
class TestBitcoinTorGossipHubWiring(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.server_source = _read("app", "sovran_systemsos_web", "server.py")
|
||||
cls.registry = _literal_assignment(cls.server_source, "FEATURE_REGISTRY")
|
||||
|
||||
def test_feature_is_modal_only_and_explains_risk(self):
|
||||
feature = next(item for item in self.registry if item["id"] == "bitcoin-tor-gossip")
|
||||
self.assertTrue(feature["modal_only"])
|
||||
self.assertIn("bitcoin-service", feature["requires"])
|
||||
self.assertTrue(any("clearnet" in detail for detail in feature["details"]))
|
||||
self.assertTrue(any("bandwidth" in detail for detail in feature["details"]))
|
||||
|
||||
def test_backend_rejects_gossip_without_bitcoin_service(self):
|
||||
self.assertIn(
|
||||
"Enable the Bitcoin service before advertising its Tor IBD service.",
|
||||
self.server_source,
|
||||
)
|
||||
|
||||
def test_core_modal_renders_and_controls_the_option(self):
|
||||
frontend = _read(
|
||||
"app", "sovran_systemsos_web", "static", "js", "service-detail.js"
|
||||
)
|
||||
self.assertIn("Tor IBD Service Advertising", frontend)
|
||||
self.assertIn("svc-detail-related-feature-btn", frontend)
|
||||
self.assertIn("handleFeatureToggle(relatedFeat", frontend)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,249 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,68 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,193 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,54 +0,0 @@
|
||||
"""Guards for the Hub checking its own clients.
|
||||
|
||||
The Hub runs as root. Whether a packet may reach its port is up to the firewall
|
||||
and the router; the application adds a second lock by answering only this
|
||||
computer and the local network. These read the modules as text, like the other
|
||||
nix-file checks: nothing is run and nothing touches the network.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import unittest
|
||||
|
||||
_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
|
||||
def _read(*parts):
|
||||
with open(os.path.join(_ROOT, *parts), encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def _option(src, name):
|
||||
m = re.search(name + r"\s*=\s*lib\.mkOption\s*\{(.*?)\n \};", src, re.S)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
class HubChecksItsOwnClients(unittest.TestCase):
|
||||
|
||||
def test_lan_only_option_exists_and_defaults_on(self):
|
||||
body = _option(_read("modules", "core", "roles.nix"), "lanOnly")
|
||||
self.assertIsNotNone(body, "hub.lanOnly option not found")
|
||||
self.assertRegex(body, r"default\s*=\s*true")
|
||||
|
||||
def test_extra_networks_option_exists_and_defaults_empty(self):
|
||||
body = _option(_read("modules", "core", "roles.nix"), "extraLanNetworks")
|
||||
self.assertIsNotNone(body, "hub.extraLanNetworks option not found")
|
||||
self.assertRegex(body, r"default\s*=\s*\[\s*\]")
|
||||
|
||||
def test_policy_is_baked_into_the_generated_config(self):
|
||||
hub = _read("modules", "core", "sovran-hub.nix")
|
||||
self.assertRegex(hub, r"lan_only\s*=\s*cfg\.hub\.lanOnly\s*;")
|
||||
self.assertRegex(hub, r"lan_extra_networks\s*=\s*cfg\.hub\.extraLanNetworks\s*;")
|
||||
|
||||
def test_a_typo_is_caught_at_build_time(self):
|
||||
hub = _read("modules", "core", "sovran-hub.nix")
|
||||
self.assertIn("builtins.all lanNetworkOk cfg.hub.extraLanNetworks", hub)
|
||||
|
||||
def test_the_hub_enforces_it_in_its_own_middleware(self):
|
||||
server = _read("app", "sovran_systemsos_web", "server.py")
|
||||
self.assertIn("class LanOnlyMiddleware(BaseHTTPMiddleware)", server)
|
||||
self.assertIn("app.add_middleware(LanOnlyMiddleware", server)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,230 +0,0 @@
|
||||
"""Tests for the Hub's local-network policy and the middleware that enforces it.
|
||||
|
||||
The Hub runs as root, so it answers this computer and the local network and
|
||||
nobody else: LanPolicy in security_helpers decides, LanOnlyMiddleware in
|
||||
server.py enforces it before authentication is considered.
|
||||
|
||||
LanPolicy is exercised directly. The middleware is exercised over real HTTP
|
||||
where the environment allows it; server.py cannot be imported from this repo
|
||||
(it needs sovran_nwc from the Sovran_Bitcoin flake), so those tests skip rather
|
||||
than fail, and the wiring is additionally asserted from source so it is always
|
||||
checked.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
_APP_PARENT = os.path.join(_REPO_ROOT, "app")
|
||||
if _APP_PARENT not in sys.path:
|
||||
sys.path.insert(0, _APP_PARENT)
|
||||
|
||||
from sovran_systemsos_web.security_helpers import ( # noqa: E402
|
||||
LanPolicy,
|
||||
LAN_ONLY_IPV4,
|
||||
LAN_ONLY_IPV6,
|
||||
)
|
||||
|
||||
_LOCAL = (
|
||||
"127.0.0.1", "10.0.0.1", "172.16.0.1", "172.31.255.254", "192.168.1.10",
|
||||
"100.64.0.1", "169.254.1.1",
|
||||
"::1", "fd12:3456::1", "fc00::1", "fe80::1",
|
||||
)
|
||||
|
||||
_REMOTE = (
|
||||
"8.8.8.8", "1.1.1.1", "203.0.113.9", "9.255.255.255",
|
||||
"172.15.255.255", "172.32.0.1", "192.169.0.1", "100.63.255.255",
|
||||
# public IPv6 — every address in 2000::/3 is on the internet
|
||||
"2001:4860:4860::8888", "2606:4700:4700::1111",
|
||||
"2a00:1450:4001::1", "2400:cb00::1",
|
||||
)
|
||||
|
||||
|
||||
class LanPolicyMatrix(unittest.TestCase):
|
||||
|
||||
def test_local_addresses_are_allowed(self):
|
||||
policy = LanPolicy()
|
||||
for address in _LOCAL:
|
||||
with self.subTest(local=address):
|
||||
self.assertTrue(policy.allows(address))
|
||||
|
||||
def test_remote_addresses_are_refused(self):
|
||||
policy = LanPolicy()
|
||||
for address in _REMOTE:
|
||||
with self.subTest(remote=address):
|
||||
self.assertFalse(policy.allows(address))
|
||||
|
||||
def test_ipv6_global_is_not_whitelisted(self):
|
||||
# 2000::/3 is the whole IPv6 global unicast space: allowing it would
|
||||
# let every public IPv6 address through.
|
||||
joined = " ".join(LAN_ONLY_IPV4 + LAN_ONLY_IPV6)
|
||||
self.assertNotIn("2000::/3", joined)
|
||||
for net in LanPolicy().networks:
|
||||
if net.version == 6:
|
||||
with self.subTest(range=str(net)):
|
||||
self.assertTrue(str(net).startswith(("::1", "fc00", "fe80")),
|
||||
f"{net} is not a local-only IPv6 range")
|
||||
|
||||
|
||||
class LanPolicyConfiguration(unittest.TestCase):
|
||||
|
||||
def test_declared_networks_are_allowed(self):
|
||||
policy = LanPolicy(extra_networks=["203.0.113.0/28", "2001:db8:abcd::/48"])
|
||||
self.assertTrue(policy.allows("203.0.113.9"))
|
||||
self.assertFalse(policy.allows("203.0.113.16"))
|
||||
self.assertTrue(policy.allows("2001:db8:abcd::5"))
|
||||
self.assertFalse(policy.allows("2001:db8:abce::5"))
|
||||
|
||||
def test_a_bare_address_is_a_single_host(self):
|
||||
policy = LanPolicy(extra_networks=["203.0.113.9"])
|
||||
self.assertTrue(policy.allows("203.0.113.9"))
|
||||
self.assertFalse(policy.allows("203.0.113.10"))
|
||||
|
||||
def test_disabled_allows_everything(self):
|
||||
policy = LanPolicy(enabled=False)
|
||||
for address in _REMOTE:
|
||||
with self.subTest(remote=address):
|
||||
self.assertTrue(policy.allows(address))
|
||||
|
||||
def test_malformed_network_does_not_widen_the_policy(self):
|
||||
# A typo must fail closed, not open the Hub to everything.
|
||||
policy = LanPolicy(extra_networks=["not-a-network", "203.0.113.0/28"])
|
||||
self.assertTrue(policy.allows("203.0.113.9"))
|
||||
self.assertFalse(policy.allows("8.8.8.8"))
|
||||
|
||||
def test_a_zero_length_prefix_is_not_a_network(self):
|
||||
# 0.0.0.0/0 and ::/0 mean "everyone". That is lan_only = false and it
|
||||
# has to be asked for by name rather than arrive as a "network".
|
||||
policy = LanPolicy(extra_networks=["0.0.0.0/0", "::/0"])
|
||||
for address in _REMOTE:
|
||||
with self.subTest(remote=address):
|
||||
self.assertFalse(policy.allows(address))
|
||||
|
||||
def test_missing_or_unparseable_client_is_refused(self):
|
||||
policy = LanPolicy()
|
||||
for address in (None, "", "testclient", "not-an-ip"):
|
||||
with self.subTest(client=address):
|
||||
self.assertFalse(policy.allows(address))
|
||||
|
||||
def test_a_dual_stack_socket_does_not_hide_the_ipv4_client(self):
|
||||
# With an IPv6 listener, IPv4 clients arrive as ::ffff:a.b.c.d. The
|
||||
# address that counts is the IPv4 one inside it, both ways round.
|
||||
policy = LanPolicy()
|
||||
for address in ("::ffff:192.168.1.5", "::ffff:127.0.0.1", "::ffff:10.1.2.3"):
|
||||
with self.subTest(local=address):
|
||||
self.assertTrue(policy.allows(address))
|
||||
for address in ("::ffff:8.8.8.8", "::ffff:203.0.113.9"):
|
||||
with self.subTest(remote=address):
|
||||
self.assertFalse(policy.allows(address))
|
||||
|
||||
|
||||
# ── Middleware ───────────────────────────────────────────────────────────────
|
||||
|
||||
try:
|
||||
from fastapi import FastAPI # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
from sovran_systemsos_web.server import LanOnlyMiddleware # noqa: E402
|
||||
HAVE_MIDDLEWARE = True
|
||||
except Exception: # fastapi / sovran_nwc unavailable from this repo
|
||||
HAVE_MIDDLEWARE = False
|
||||
|
||||
|
||||
def _app_with(policy):
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/ping")
|
||||
async def ping():
|
||||
return {"ok": True}
|
||||
|
||||
app.add_middleware(LanOnlyMiddleware, policy=policy)
|
||||
return app
|
||||
|
||||
|
||||
@unittest.skipUnless(HAVE_MIDDLEWARE, "server.py is not importable here")
|
||||
class LanOnlyMiddlewareOverHttp(unittest.TestCase):
|
||||
|
||||
def _status(self, policy, client_ip):
|
||||
client = TestClient(_app_with(policy), client=(client_ip, 51234))
|
||||
return client.get("/ping").status_code
|
||||
|
||||
def test_local_client_is_served(self):
|
||||
for address in ("127.0.0.1", "192.168.1.10", "10.0.0.1"):
|
||||
with self.subTest(local=address):
|
||||
self.assertEqual(self._status(LanPolicy(), address), 200)
|
||||
|
||||
def test_remote_client_is_refused(self):
|
||||
for address in ("203.0.113.9", "8.8.8.8", "2001:4860:4860::8888"):
|
||||
with self.subTest(remote=address):
|
||||
self.assertEqual(self._status(LanPolicy(), address), 403)
|
||||
|
||||
def test_refusal_says_nothing_about_the_configuration(self):
|
||||
# An outsider learns that the answer is no, not why or what to change.
|
||||
client = TestClient(_app_with(LanPolicy()), client=("203.0.113.9", 51234))
|
||||
response = client.get("/ping")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json(), {"detail": "Not available from this network"})
|
||||
|
||||
def test_disabled_policy_admits_remote_clients(self):
|
||||
self.assertEqual(self._status(LanPolicy(enabled=False), "203.0.113.9"), 200)
|
||||
|
||||
def test_a_refused_address_is_logged_once(self):
|
||||
# The operator whose own device is refused needs to find out why; a
|
||||
# scanner must not be able to fill the journal.
|
||||
client = TestClient(_app_with(LanPolicy()), client=("203.0.113.9", 51234))
|
||||
with self.assertLogs("sovran_systemsos_web.server", level="WARNING") as seen:
|
||||
for _ in range(5):
|
||||
client.get("/ping")
|
||||
self.assertEqual(len(seen.records), 1)
|
||||
message = seen.records[0].getMessage()
|
||||
self.assertIn("203.0.113.9", message)
|
||||
self.assertIn("sovran_systemsOS.hub.extraLanNetworks", message)
|
||||
|
||||
def test_a_served_client_is_not_logged(self):
|
||||
records = []
|
||||
|
||||
class _Collect(logging.Handler):
|
||||
def emit(self, record):
|
||||
records.append(record)
|
||||
|
||||
logger = logging.getLogger("sovran_systemsos_web.server")
|
||||
handler = _Collect(level=logging.WARNING)
|
||||
logger.addHandler(handler)
|
||||
try:
|
||||
client = TestClient(_app_with(LanPolicy()), client=("192.168.1.10", 51234))
|
||||
client.get("/ping")
|
||||
finally:
|
||||
logger.removeHandler(handler)
|
||||
self.assertEqual(records, [])
|
||||
|
||||
|
||||
# ── Wiring, checked from source so it always runs ─────────────────────────────
|
||||
|
||||
def _server_source():
|
||||
with open(os.path.join(_APP_PARENT, "sovran_systemsos_web", "server.py"),
|
||||
encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
class LanOnlyWiring(unittest.TestCase):
|
||||
|
||||
def test_middleware_is_registered_outermost(self):
|
||||
# Starlette makes the last-registered middleware the outermost one, so
|
||||
# an off-network client is turned away before auth is considered.
|
||||
src = _server_source()
|
||||
auth = src.index("app.add_middleware(AuthMiddleware)")
|
||||
nocache = src.index("app.add_middleware(NoCacheMiddleware)")
|
||||
lan = src.index("app.add_middleware(LanOnlyMiddleware")
|
||||
self.assertLess(auth, nocache)
|
||||
self.assertLess(nocache, lan)
|
||||
|
||||
def test_policy_comes_from_the_generated_config(self):
|
||||
src = _server_source()
|
||||
self.assertIn("LanPolicy(", src)
|
||||
self.assertIn('_hub_cfg.get("lan_only", True)', src)
|
||||
self.assertIn('_hub_cfg.get("lan_extra_networks")', src)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,196 +0,0 @@
|
||||
"""Tests for the Hub's login throttling.
|
||||
|
||||
These exercise the exact production implementation in
|
||||
sovran_systemsos_web.security_helpers.LoginThrottle. The clock and the sleep are
|
||||
injected, so the tests cover hours of lockout behaviour instantly.
|
||||
|
||||
No network access, no filesystem writes, no real delays.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
_APP_PARENT = os.path.join(_REPO_ROOT, "app")
|
||||
if _APP_PARENT not in sys.path:
|
||||
sys.path.insert(0, _APP_PARENT)
|
||||
|
||||
from sovran_systemsos_web.security_helpers import ( # noqa: E402
|
||||
LoginThrottle,
|
||||
LOGIN_FAIL_DELAY,
|
||||
LOGIN_FAIL_MAX_DELAY,
|
||||
LOGIN_FAIL_WINDOW,
|
||||
LOGIN_FAIL_MAX,
|
||||
LOGIN_LOCKOUT_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
class FakeClock:
|
||||
"""A clock that only moves when the test says so."""
|
||||
|
||||
def __init__(self):
|
||||
self.now = 1000.0
|
||||
|
||||
def __call__(self):
|
||||
return self.now
|
||||
|
||||
def advance(self, seconds):
|
||||
self.now += seconds
|
||||
|
||||
|
||||
class FakeSleeper:
|
||||
"""Records the delays it was asked to apply instead of sleeping."""
|
||||
|
||||
def __init__(self, clock):
|
||||
self.clock = clock
|
||||
self.calls = []
|
||||
|
||||
def __call__(self, seconds):
|
||||
self.calls.append(seconds)
|
||||
self.clock.advance(seconds)
|
||||
|
||||
|
||||
def _make(**kwargs):
|
||||
clock = kwargs.pop("clock", None) or FakeClock()
|
||||
sleep = kwargs.pop("sleep", None) or FakeSleeper(clock)
|
||||
return LoginThrottle(clock=clock, sleep=sleep, **kwargs), clock, sleep
|
||||
|
||||
|
||||
def _trip(throttle, ip="203.0.113.9"):
|
||||
"""Fail LOGIN_FAIL_MAX times. The fake sleeper advances the clock for us."""
|
||||
for _ in range(LOGIN_FAIL_MAX):
|
||||
throttle.record_failure(ip)
|
||||
|
||||
|
||||
class DelayRamp(unittest.TestCase):
|
||||
|
||||
def test_delay_ramps_with_the_failure_count(self):
|
||||
throttle, _, _ = _make()
|
||||
self.assertEqual(throttle.delay_for(0), 0.0)
|
||||
self.assertEqual(throttle.delay_for(1), LOGIN_FAIL_DELAY)
|
||||
self.assertEqual(throttle.delay_for(3), LOGIN_FAIL_DELAY * 3)
|
||||
|
||||
def test_delay_is_capped(self):
|
||||
# Unbounded ramping would let a single client park a thread-pool worker
|
||||
# for minutes at a time.
|
||||
throttle, _, _ = _make()
|
||||
self.assertLessEqual(throttle.delay_for(999), LOGIN_FAIL_MAX_DELAY)
|
||||
self.assertEqual(throttle.delay_for(999), LOGIN_FAIL_MAX_DELAY)
|
||||
|
||||
def test_first_failure_is_not_delayed_much(self):
|
||||
throttle, _, sleep = _make()
|
||||
delay = throttle.record_failure("203.0.113.9")
|
||||
self.assertEqual(delay, LOGIN_FAIL_DELAY)
|
||||
self.assertEqual(sleep.calls, [LOGIN_FAIL_DELAY])
|
||||
|
||||
|
||||
class Lockout(unittest.TestCase):
|
||||
|
||||
def test_not_locked_out_initially(self):
|
||||
throttle, _, _ = _make()
|
||||
self.assertFalse(throttle.is_locked_out("203.0.113.9"))
|
||||
self.assertEqual(throttle.remaining_lockout("203.0.113.9"), 0.0)
|
||||
|
||||
def test_reaching_the_limit_locks_the_address_out(self):
|
||||
throttle, _, _ = _make()
|
||||
_trip(throttle)
|
||||
self.assertTrue(throttle.is_locked_out("203.0.113.9"))
|
||||
|
||||
def test_the_limit_is_reachable_inside_the_window(self):
|
||||
# Regression guard for the old 60s window: with a ramping delay it
|
||||
# takes ~80s to reach LOGIN_FAIL_MAX, so a 60s window expired the
|
||||
# earliest failures first and the lockout could never fire.
|
||||
throttle, clock, _ = _make()
|
||||
start = clock.now
|
||||
_trip(throttle)
|
||||
self.assertLess(clock.now - start, LOGIN_FAIL_WINDOW)
|
||||
self.assertEqual(throttle.failure_count("203.0.113.9"), LOGIN_FAIL_MAX)
|
||||
self.assertTrue(throttle.is_locked_out("203.0.113.9"))
|
||||
|
||||
def test_one_failure_short_of_the_limit_is_not_a_lockout(self):
|
||||
throttle, _, _ = _make()
|
||||
for _ in range(LOGIN_FAIL_MAX - 1):
|
||||
throttle.record_failure("203.0.113.9")
|
||||
self.assertFalse(throttle.is_locked_out("203.0.113.9"))
|
||||
|
||||
def test_lockout_expires(self):
|
||||
throttle, clock, _ = _make()
|
||||
_trip(throttle)
|
||||
self.assertTrue(throttle.is_locked_out("203.0.113.9"))
|
||||
clock.advance(LOGIN_LOCKOUT_SECONDS + 1)
|
||||
self.assertFalse(throttle.is_locked_out("203.0.113.9"))
|
||||
|
||||
def test_remaining_lockout_counts_down(self):
|
||||
throttle, clock, _ = _make()
|
||||
_trip(throttle)
|
||||
full = throttle.remaining_lockout("203.0.113.9")
|
||||
# the final record_failure applied a delay, which the fake clock has
|
||||
# already advanced, so what is left is the lockout minus that delay
|
||||
self.assertAlmostEqual(full, LOGIN_LOCKOUT_SECONDS,
|
||||
delta=LOGIN_FAIL_MAX_DELAY + 1.0)
|
||||
clock.advance(full / 2)
|
||||
self.assertLess(throttle.remaining_lockout("203.0.113.9"), full)
|
||||
self.assertGreater(throttle.remaining_lockout("203.0.113.9"), 0.0)
|
||||
|
||||
def test_further_failures_while_locked_out_extend_it(self):
|
||||
throttle, clock, _ = _make()
|
||||
_trip(throttle)
|
||||
clock.advance(LOGIN_LOCKOUT_SECONDS - 1)
|
||||
throttle.record_failure("203.0.113.9")
|
||||
self.assertTrue(throttle.is_locked_out("203.0.113.9"))
|
||||
|
||||
|
||||
class Isolation(unittest.TestCase):
|
||||
|
||||
def test_one_address_does_not_lock_out_another(self):
|
||||
throttle, _, _ = _make()
|
||||
_trip(throttle, "203.0.113.9")
|
||||
self.assertTrue(throttle.is_locked_out("203.0.113.9"))
|
||||
self.assertFalse(throttle.is_locked_out("198.51.100.7"))
|
||||
|
||||
def test_successful_login_clears_the_address(self):
|
||||
throttle, _, _ = _make()
|
||||
for _ in range(LOGIN_FAIL_MAX - 1):
|
||||
throttle.record_failure("203.0.113.9")
|
||||
throttle.clear("203.0.113.9")
|
||||
self.assertEqual(throttle.failure_count("203.0.113.9"), 0)
|
||||
self.assertFalse(throttle.is_locked_out("203.0.113.9"))
|
||||
|
||||
def test_old_failures_age_out_of_the_window(self):
|
||||
throttle, clock, _ = _make()
|
||||
throttle.record_failure("203.0.113.9")
|
||||
clock.advance(LOGIN_FAIL_WINDOW + 1)
|
||||
self.assertEqual(throttle.failure_count("203.0.113.9"), 0)
|
||||
|
||||
|
||||
class BoundedMemory(unittest.TestCase):
|
||||
|
||||
def test_tracked_addresses_are_evicted(self):
|
||||
throttle, clock, _ = _make(max_tracked_ips=8)
|
||||
for i in range(64):
|
||||
throttle.record_failure(f"198.51.100.{i}")
|
||||
clock.advance(LOGIN_FAIL_WINDOW + LOGIN_LOCKOUT_SECONDS + 1)
|
||||
throttle.record_failure("203.0.113.9")
|
||||
self.assertLessEqual(throttle.tracked_addresses(), 8)
|
||||
|
||||
def test_sleep_is_never_called_under_the_lock(self):
|
||||
# If the lock were held across the sleep, one slow client would stall
|
||||
# every other login — a self-inflicted DoS.
|
||||
throttle, clock, _ = _make()
|
||||
order = []
|
||||
|
||||
def spy(seconds):
|
||||
order.append("sleep:start")
|
||||
clock.advance(seconds)
|
||||
order.append("sleep:end")
|
||||
|
||||
throttle._sleep = spy
|
||||
throttle.record_failure("203.0.113.9")
|
||||
self.assertEqual(order, ["sleep:start", "sleep:end"])
|
||||
# A second address can still be recorded while the first is "sleeping".
|
||||
self.assertEqual(throttle.failure_count("198.51.100.7"), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,112 +0,0 @@
|
||||
"""Guards for the Mullvad VPN client being a plain Desktop Role install.
|
||||
|
||||
The desktop app and its daemon ship with Desktop Only and with no other role:
|
||||
Server + Desktop and Bitcoin Node are machines that serve, and a VPN client with
|
||||
its own DNS and routing policy is not something their services asked for.
|
||||
|
||||
Beyond that the install is deliberately boring. It is the NixOS service exactly
|
||||
as NixOS ships it — no local-network boot service, no DNS or firewall edits of
|
||||
our own, no Hub tile, no Feature Manager card, no toggles. The account, the
|
||||
relay and the kill switch belong to the app.
|
||||
|
||||
Like the other nix-file checks these read the shipped sources as text: nothing is
|
||||
run and nothing touches the network.
|
||||
"""
|
||||
|
||||
import os
|
||||
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 source with whole-line `#` comments removed."""
|
||||
return "\n".join(l for l in src.splitlines() if not l.lstrip().startswith("#"))
|
||||
|
||||
|
||||
class TheClientIsBuiltOnDesktopAndNowhereElse(unittest.TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.module = _read("modules", "mullvad.nix")
|
||||
cls.code = _without_comments(cls.module)
|
||||
|
||||
def test_the_module_is_imported(self):
|
||||
self.assertIn("./mullvad.nix", _read("modules", "modules.nix"))
|
||||
|
||||
def test_the_gate_is_the_desktop_role(self):
|
||||
self.assertRegex(
|
||||
self.code,
|
||||
r"lib\.mkIf\s+config\.sovran_systemsOS\.roles\.desktop\s*\{",
|
||||
)
|
||||
|
||||
def test_no_other_role_is_mentioned(self):
|
||||
self.assertNotIn("server_plus_desktop", self.code)
|
||||
self.assertNotIn("roles.node", self.code)
|
||||
|
||||
def test_the_install_is_the_daemon_and_the_app(self):
|
||||
self.assertRegex(self.code, r"services\.mullvad-vpn\s*=\s*\{")
|
||||
self.assertRegex(self.code, r"(?m)^ enable = true;$")
|
||||
self.assertRegex(self.code, r"gui\.enable = true;")
|
||||
|
||||
|
||||
class TheInstallStaysVanilla(unittest.TestCase):
|
||||
"""Nothing is configured for the user, in the module or out of it."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.module = _without_comments(_read("modules", "mullvad.nix"))
|
||||
|
||||
def test_no_extra_services_are_added(self):
|
||||
self.assertNotIn("systemd.services", self.module)
|
||||
|
||||
def test_no_dns_or_network_edits_of_our_own(self):
|
||||
# The NixOS module already turns on systemd-resolved and points
|
||||
# NetworkManager at it; re-declaring either here would be our policy,
|
||||
# not the distribution's.
|
||||
for key in ("services.resolved", "networkmanager", "networking."):
|
||||
self.assertNotIn(key, self.module)
|
||||
|
||||
def test_no_firewall_or_kernel_edits(self):
|
||||
self.assertNotIn("firewall", self.module)
|
||||
self.assertNotIn("boot.", self.module)
|
||||
|
||||
def test_no_packages_are_forced_in(self):
|
||||
self.assertNotIn("environment.systemPackages", self.module)
|
||||
|
||||
def test_the_feature_flag_does_not_come_back(self):
|
||||
# A Hub-managed feature flag on an app that is simply installed would
|
||||
# put a toggle in front of something that has no decision to make.
|
||||
for path in (("modules", "core", "roles.nix"),
|
||||
("modules", "core", "role-logic.nix")):
|
||||
self.assertNotIn("mullvad", _read(*path).lower(), f"{path[-1]} mentions mullvad")
|
||||
|
||||
|
||||
class TheHubDoesNotMentionIt(unittest.TestCase):
|
||||
"""The app is not a service the Hub manages or announces."""
|
||||
|
||||
def test_the_hub_tiles_and_roles_are_untouched(self):
|
||||
self.assertNotIn("mullvad", _read("modules", "core", "sovran-hub.nix").lower())
|
||||
|
||||
def test_the_hub_backend_is_untouched(self):
|
||||
self.assertNotIn("mullvad", _read("app", "sovran_systemsos_web", "server.py").lower())
|
||||
|
||||
def test_the_desktop_shell_is_untouched(self):
|
||||
# The app appears in the GNOME app grid by itself; no folder edit, no
|
||||
# dock entry.
|
||||
self.assertNotIn(
|
||||
"mullvad", _read("modules", "core", "sovran_systemsos-desktop.nix").lower())
|
||||
|
||||
def test_there_is_no_icon_for_it(self):
|
||||
# app/icons holds Hub tile icons only.
|
||||
self.assertFalse(
|
||||
os.path.exists(os.path.join(_ROOT, "app", "icons", "mullvad.svg")))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,64 +0,0 @@
|
||||
"""Guards for when port 22 is open in the firewall.
|
||||
|
||||
sshd-localhost.nix gives every role "ssh root@localhost" by listening on
|
||||
127.0.0.1 only. NixOS opens sshd's ports in the firewall by default whether or
|
||||
not sshd listens on them, which left port 22 open on every role, Desktop Only
|
||||
included, with nothing behind it. The roles that really publish SSH open it
|
||||
explicitly, so the default has to stay off.
|
||||
|
||||
Like the other nix-file checks these read the modules as text: nothing is run
|
||||
and nothing touches the network.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import unittest
|
||||
|
||||
_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
|
||||
def _read(*parts):
|
||||
with open(os.path.join(_ROOT, *parts), encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def _without_comments(src):
|
||||
return "\n".join(l for l in src.splitlines() if not l.lstrip().startswith("#"))
|
||||
|
||||
|
||||
class LocalhostSshdDoesNotOpenThePort(unittest.TestCase):
|
||||
|
||||
def test_the_firewall_is_not_opened_by_default(self):
|
||||
code = _without_comments(_read("modules", "core", "sshd-localhost.nix"))
|
||||
self.assertRegex(code, r"openFirewall\s*=\s*lib\.mkDefault\s+false\s*;")
|
||||
|
||||
def test_it_still_listens_on_loopback_only(self):
|
||||
code = _without_comments(_read("modules", "core", "sshd-localhost.nix"))
|
||||
self.assertRegex(code, r'addr\s*=\s*"127\.0\.0\.1"')
|
||||
self.assertNotIn("0.0.0.0", code)
|
||||
|
||||
|
||||
class PublishedSshOpensItsOwnPort(unittest.TestCase):
|
||||
"""Turning the default off must not close the roles that want SSH open."""
|
||||
|
||||
def test_the_sshd_feature_opens_22_and_only_when_enabled(self):
|
||||
src = _without_comments(_read("modules", "sshd.nix"))
|
||||
self.assertRegex(src, r"lib\.mkIf\s+config\.sovran_systemsOS\.features\.sshd")
|
||||
self.assertRegex(src, r"networking\.firewall\.allowedTCPPorts\s*=\s*\[\s*22\s*\]")
|
||||
|
||||
def test_remote_deploy_opens_22_and_only_when_enabled(self):
|
||||
src = _without_comments(_read("modules", "core", "remote-deploy.nix"))
|
||||
self.assertRegex(src, r"lib\.mkIf\s+cfg\.enable")
|
||||
self.assertRegex(src, r"networking\.firewall\.allowedTCPPorts\s*=\s*\[\s*22\s*\]")
|
||||
|
||||
|
||||
class DesktopOnlyDocumentsWhatItOpens(unittest.TestCase):
|
||||
|
||||
def test_security_policy_says_desktop_opens_no_tcp_port(self):
|
||||
text = " ".join(_read("SECURITY.md").split()) # the file is line-wrapped
|
||||
self.assertIn("opens no TCP port", text)
|
||||
self.assertIn("UDP 5353", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,222 +0,0 @@
|
||||
"""Regression tests for Hub update completion and polling recovery."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
_APP_PARENT = _REPO_ROOT / "app"
|
||||
if str(_APP_PARENT) not in sys.path:
|
||||
sys.path.insert(0, str(_APP_PARENT))
|
||||
|
||||
from sovran_systemsos_web.update_state import ( # noqa: E402
|
||||
effective_update_status,
|
||||
reboot_is_pending,
|
||||
)
|
||||
|
||||
|
||||
# Real store paths observed on the incident machine that prompted this rework.
|
||||
RUNNING_GENERATION = (
|
||||
"84rsiqi66nc68jbikd26ms50ap831xf8-nixos-system-nixos-26.11.20260817.ec2d622"
|
||||
)
|
||||
PREVIOUS_GENERATION = (
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-nixos-system-nixos-26.11.20260816.old"
|
||||
)
|
||||
# What the stale Hub log still claimed was staged: a two-week-old update cycle.
|
||||
HUB_LOG_GENERATION = (
|
||||
"yis0saq6p8fhqcaii0h2yzqf0blhdwns-nixos-system-nixos-26.11.20260804.e72e4f2"
|
||||
)
|
||||
|
||||
|
||||
class TestRebootPendingState(unittest.TestCase):
|
||||
"""Reboot-pending state is derived from NixOS, not from Hub marker files.
|
||||
|
||||
The system profile (what boots next) is compared against
|
||||
/run/current-system (what is running). This stays correct no matter
|
||||
which tool performed the update: Hub "Update System", a terminal
|
||||
``nixos-rebuild``, or an SSH support session.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
root = Path(self.tmp.name)
|
||||
self.store = root / "store"
|
||||
self.store.mkdir()
|
||||
# /nix/var/nix/profiles/system is a two-hop chain: the diagnostics on
|
||||
# the incident box showed ``system`` -> ``system-148-link`` -> store.
|
||||
self.profile = root / "system"
|
||||
self.profile_entry = root / "system-148-link"
|
||||
# /run/current-system links straight to the running store path.
|
||||
self.current = root / "current-system"
|
||||
|
||||
def _store_dir(self, name: str) -> str:
|
||||
path = self.store / name
|
||||
path.mkdir(exist_ok=True)
|
||||
return str(path)
|
||||
|
||||
def _stage(self, booted: str, running: str) -> None:
|
||||
"""Stage ``booted`` as the boot default with ``running`` live."""
|
||||
os.symlink(self._store_dir(booted), self.profile_entry)
|
||||
os.symlink(self.profile_entry, self.profile)
|
||||
os.symlink(self._store_dir(running), self.current)
|
||||
|
||||
def test_hub_update_staged_and_not_yet_rebooted_is_pending(self):
|
||||
self._stage(booted=RUNNING_GENERATION, running=PREVIOUS_GENERATION)
|
||||
self.assertTrue(
|
||||
reboot_is_pending(str(self.profile), str(self.current))
|
||||
)
|
||||
|
||||
def test_staged_generation_booted_is_no_longer_pending(self):
|
||||
self._stage(booted=RUNNING_GENERATION, running=RUNNING_GENERATION)
|
||||
self.assertFalse(
|
||||
reboot_is_pending(str(self.profile), str(self.current))
|
||||
)
|
||||
|
||||
def test_terminal_switch_needs_no_reboot(self):
|
||||
# nixos-rebuild switch moves the profile AND current-system together.
|
||||
self._stage(booted=RUNNING_GENERATION, running=RUNNING_GENERATION)
|
||||
self.assertFalse(
|
||||
reboot_is_pending(str(self.profile), str(self.current))
|
||||
)
|
||||
|
||||
def test_rollback_leaves_nothing_pending(self):
|
||||
# nixos-rebuild switch --rollback points both at the rollback target.
|
||||
self._stage(booted=PREVIOUS_GENERATION, running=PREVIOUS_GENERATION)
|
||||
self.assertFalse(
|
||||
reboot_is_pending(str(self.profile), str(self.current))
|
||||
)
|
||||
|
||||
def test_unverifiable_state_is_never_a_reboot_demand(self):
|
||||
# No profile and no running system readable: the Hub must not nag
|
||||
# about a reboot it cannot substantiate.
|
||||
self.assertFalse(
|
||||
reboot_is_pending(str(self.profile), str(self.current))
|
||||
)
|
||||
|
||||
def test_stale_hub_marker_clears_after_terminal_updates(self):
|
||||
"""The exact incident: terminal-updated machine, frozen Hub marker.
|
||||
|
||||
The user's last Hub update (old updater, weeks prior) left
|
||||
REBOOT_REQUIRED behind. Every update since ran in a terminal and
|
||||
never touched the Hub's files, so the log still records a staged
|
||||
generation from 2026-08-04 while the machine runs a 2026-08-17
|
||||
build. With the profile and the running system in agreement, the
|
||||
stale claim must reconcile to IDLE regardless of anything the old
|
||||
marker/log files say.
|
||||
"""
|
||||
root = Path(self.tmp.name)
|
||||
log = root / "sovran-hub-update.log"
|
||||
log.write_text(
|
||||
"Done. The new configuration is "
|
||||
f"/nix/store/{HUB_LOG_GENERATION}\n"
|
||||
"✓ Update staged successfully\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
# Deliberately no sovran-hub-update.generation marker: the updater
|
||||
# that produced this state predates the marker feature.
|
||||
self.assertFalse(
|
||||
(root / "sovran-hub-update.generation").exists()
|
||||
)
|
||||
|
||||
self._stage(booted=RUNNING_GENERATION, running=RUNNING_GENERATION)
|
||||
self.assertEqual(
|
||||
effective_update_status(
|
||||
"REBOOT_REQUIRED", str(self.profile), str(self.current)
|
||||
),
|
||||
"IDLE",
|
||||
)
|
||||
|
||||
def test_genuine_pending_reboot_claim_survives(self):
|
||||
# A staged generation that has NOT been booted yet: the claim is
|
||||
# true and must keep surfacing until the reboot really happens.
|
||||
self._stage(booted=RUNNING_GENERATION, running=PREVIOUS_GENERATION)
|
||||
self.assertEqual(
|
||||
effective_update_status(
|
||||
"REBOOT_REQUIRED", str(self.profile), str(self.current)
|
||||
),
|
||||
"REBOOT_REQUIRED",
|
||||
)
|
||||
|
||||
def test_other_statuses_pass_through_unchanged(self):
|
||||
self._stage(booted=RUNNING_GENERATION, running=RUNNING_GENERATION)
|
||||
for status in ("RUNNING", "FAILED", "SUCCESS", "IDLE"):
|
||||
self.assertEqual(
|
||||
effective_update_status(
|
||||
status, str(self.profile), str(self.current)
|
||||
),
|
||||
status,
|
||||
)
|
||||
|
||||
|
||||
class TestUpdatePollingWiring(unittest.TestCase):
|
||||
"""Guard the browser failure modes that caused a permanent spinner."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
js_dir = _REPO_ROOT / "app" / "sovran_systemsos_web" / "static" / "js"
|
||||
cls.update_js = (js_dir / "update.js").read_text(encoding="utf-8")
|
||||
cls.rebuild_js = (js_dir / "rebuild.js").read_text(encoding="utf-8")
|
||||
cls.helpers_js = (js_dir / "helpers.js").read_text(encoding="utf-8")
|
||||
cls.events_js = (js_dir / "events.js").read_text(encoding="utf-8")
|
||||
cls.template = (
|
||||
_REPO_ROOT / "app" / "sovran_systemsos_web" / "templates" / "index.html"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
def test_status_fetches_have_an_abort_timeout(self):
|
||||
self.assertIn("function apiFetchWithTimeout", self.helpers_js)
|
||||
self.assertIn("new AbortController()", self.helpers_js)
|
||||
self.assertIn("controller.abort()", self.helpers_js)
|
||||
self.assertIn("apiFetchWithTimeout(", self.update_js)
|
||||
self.assertIn("STATUS_POLL_FETCH_TIMEOUT", self.update_js)
|
||||
|
||||
def test_async_update_polls_cannot_overlap(self):
|
||||
self.assertIn("_updatePollInFlight", self.update_js)
|
||||
self.assertIn(
|
||||
"if (_updateFinished || _updatePollInFlight) return;", self.update_js
|
||||
)
|
||||
self.assertIn("finally", self.update_js)
|
||||
|
||||
def test_connection_failure_has_explicit_non_running_ui(self):
|
||||
self.assertIn("showUpdateStatusUnavailable", self.update_js)
|
||||
self.assertIn("Update status unavailable", self.update_js)
|
||||
self.assertIn("Retry Status", self.template)
|
||||
self.assertIn("retryUpdateStatus", self.events_js)
|
||||
|
||||
def test_rdp_or_tab_resume_forces_reconciliation(self):
|
||||
self.assertIn("resumeUpdateStatusAfterInterruption", self.events_js)
|
||||
self.assertIn('window.addEventListener("focus"', self.events_js)
|
||||
self.assertIn('document.addEventListener("visibilitychange"', self.events_js)
|
||||
|
||||
def test_verbose_log_rendering_is_bounded(self):
|
||||
self.assertIn("UPDATE_VISIBLE_LOG_MAX_CHARS", self.update_js)
|
||||
self.assertIn("document.createTextNode(text)", self.update_js)
|
||||
self.assertNotIn("$modalLog.textContent += text", self.update_js)
|
||||
|
||||
def test_page_reload_restores_running_or_completed_update(self):
|
||||
self.assertIn("restoreUpdateModalIfNeeded", self.update_js)
|
||||
self.assertIn("await restoreUpdateModalIfNeeded();", self.events_js)
|
||||
self.assertIn('current.result === "reboot_required"', self.update_js)
|
||||
|
||||
def test_all_javascript_remains_syntax_valid(self):
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
self.skipTest("node is not available in this test environment")
|
||||
for script in (
|
||||
_REPO_ROOT / "app" / "sovran_systemsos_web" / "static" / "js"
|
||||
).glob("*.js"):
|
||||
result = subprocess.run(
|
||||
[node, "--check", str(script)], capture_output=True, text=True
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,56 +0,0 @@
|
||||
"""Regression tests for the Hub Zeus Connect QR.
|
||||
|
||||
The Zeus Connect setup service (modules/wallet-autoconnect.nix) and the Hub QR
|
||||
encoding are tested here. The lndconnect wrapper itself is now part of the
|
||||
Sovran_Bitcoin flake — its contract tests live in that repository.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import unittest
|
||||
|
||||
_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
|
||||
def _read(relpath: str) -> str:
|
||||
with open(os.path.join(_REPO_ROOT, relpath), encoding="utf-8") as fh:
|
||||
return fh.read()
|
||||
|
||||
|
||||
class TestZeusConnectSetup(unittest.TestCase):
|
||||
"""zeus-connect-setup must wait for the REST onion and validate the URI."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.src = _read("modules/wallet-autoconnect.nix")
|
||||
|
||||
def test_waits_for_onion_addresses(self):
|
||||
self.assertIn("onion-addresses.service", self.src)
|
||||
|
||||
def test_rejects_non_lndconnect_output(self):
|
||||
self.assertIn("^lndconnect://", self.src)
|
||||
self.assertIn("\\.onion", self.src)
|
||||
self.assertIn("macaroon=", self.src)
|
||||
|
||||
def test_no_clightning_fallback(self):
|
||||
self.assertNotIn("lnconnect-clnrest", self.src)
|
||||
|
||||
|
||||
class TestHubQrFallback(unittest.TestCase):
|
||||
"""Hub QR encoding must not die on a payload that is too large for ECC H."""
|
||||
|
||||
def test_qrencode_falls_back_to_lower_ecc(self):
|
||||
src = _read("app/sovran_systemsos_web/server.py")
|
||||
self.assertIn('for ecc in ("H", "Q", "L")', src)
|
||||
|
||||
def test_qronly_does_not_hide_uri_when_qr_fails(self):
|
||||
src = _read("app/sovran_systemsos_web/server.py")
|
||||
self.assertIn("Don't hide the URI if we could not render a scannable QR.", src)
|
||||
|
||||
def test_zeus_guide_mentions_use_tor(self):
|
||||
src = _read("app/sovran_systemsos_web/static/js/service-detail.js")
|
||||
self.assertIn("Use Tor", src)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user