Compare commits
No files matched your search
@@ -0,0 +1,11 @@
|
||||
custom.nix
|
||||
role-state.nix
|
||||
*.iso
|
||||
*.zip
|
||||
*.pma
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
iso/secrets/enroll-token
|
||||
iso/secrets/provisioner-url
|
||||
result
|
||||
@@ -1,5 +0,0 @@
|
||||
custom.nix
|
||||
role-state.nix
|
||||
*.iso
|
||||
*.zip
|
||||
*.pma
|
||||
@@ -0,0 +1,539 @@
|
||||
# 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
|
||||
@@ -0,0 +1,112 @@
|
||||
# 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.
|
||||
|
||||
@@ -0,0 +1,889 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="iso/assets/sovran-hub-icon.svg" alt="Sovran Systems logo" width="160" />
|
||||
|
||||
# Sovran_SystemsOS
|
||||
|
||||
### Bitcoin sovereignty. Sovereign computing. One system.
|
||||
|
||||
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.*
|
||||
|
||||
</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.
|
||||
|
||||
---
|
||||
|
||||
## Contents
|
||||
|
||||
- [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)
|
||||
|
||||
---
|
||||
|
||||
## Why Sovran_SystemsOS?
|
||||
|
||||
Bitcoin lets you hold and transfer value without asking a bank, exchange, or
|
||||
custodian for permission, but that freedom depends on the software and
|
||||
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 │
|
||||
└────────────┬────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ 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 │
|
||||
└────────────┘ └────────────┘ └────────────┘
|
||||
```
|
||||
|
||||
The Hub writes supported user choices into the local configuration. NixOS then
|
||||
rebuilds the machine into the selected declarative state.
|
||||
|
||||
### Module overview
|
||||
|
||||
| Area | Modules |
|
||||
|---|---|
|
||||
| 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` |
|
||||
|
||||
Feature availability and defaults may change as Sovran_SystemsOS develops:
|
||||
review the relevant Nix module before relying on a default in production.
|
||||
|
||||
---
|
||||
|
||||
## About Bitcoin wallet entropy
|
||||
|
||||
Wallet recovery words control the funds. Never share them with a website,
|
||||
support technician, cloud service, or chat application.
|
||||
|
||||
For meaningful balances, prefer a well-reviewed hardware signer and follow its
|
||||
verified backup process. A BIP39 passphrase is optional, advanced protection,
|
||||
not a replacement for recovery words: back it up separately, because losing
|
||||
either item can make the wallet unrecoverable.
|
||||
|
||||
Keep durable offline backups in separate secure locations, test recovery before
|
||||
relying on a wallet, and begin with a small amount.
|
||||
|
||||
---
|
||||
|
||||
## Security approach
|
||||
|
||||
Sovran_SystemsOS uses layered controls:
|
||||
|
||||
- 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)
|
||||
|
||||
See [`SECURITY.md`](SECURITY.md) for the threat model, limitations, reporting,
|
||||
and operator guidance.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
## 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>
|
||||
@@ -0,0 +1,149 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,36 @@
|
||||
# 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.
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" fill="#1E3A5F"/><text x="32" y="28" font-family="sans-serif" font-size="12" font-weight="bold" fill="#F7931A" text-anchor="middle">BIP</text><text x="32" y="46" font-family="sans-serif" font-size="18" font-weight="bold" fill="white" text-anchor="middle">110</text></svg>
|
||||
|
Before Width: | Height: | Size: 362 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="83 78 333 357">
|
||||
<g>
|
||||
<path
|
||||
d="m274.23,434.09 l4.37,-0.26 4.1,-0.42 4.1,-0.42 4.23,-0.64 4.23,-0.64 2.88,-0.53 2.88,-0.53 4.1,-0.95 4.1,-0.95 4.26,-1.18 4.26,-1.18 4.32,-1.45 4.32,-1.45 3.36,-1.29 3.36,-1.29 4.1,-1.77 4.1,-1.77 6.09,-3.11 6.09,-3.11 3.7,-2.2 3.7,-2.2 4.23,-2.82 4.23,-2.82 2.65,-1.95 2.65,-1.95 3.12,-2.48 3.12,-2.48 3.23,-2.82 3.23,-2.82 3.54,-3.45 3.54,-3.45 3.17,-3.44 3.17,-3.44 2.55,-3.04 2.55,-3.04 2.54,-3.39 2.54,-3.39 1.46,-2.83 1.46,-2.83 0.66,-1.85 0.66,-1.85 0.56,-2.63 0.56,-2.63 -0,-2.26 -0,-2.26 -0.63,-2.51 -0.63,-2.51 -0.67,-1.5 -0.67,-1.5 -1.13,-1.67 -1.13,-1.67 -1.37,-1.37 -1.37,-1.37 -2.01,-1.31 -2.01,-1.31 -2.22,-0.8 -2.22,-0.8 -1.69,-0.31 -1.69,-0.31 -2.65,0 -2.65,0 -2.08,0.44 -2.08,0.44 -1.89,0.68 -1.89,0.68 -1.48,0.74 -1.48,0.74 -1.83,1.25 -1.83,1.25 -1.85,1.59 -1.85,1.59 -5.56,4.14 -5.56,4.14 -4.1,2.92 -4.1,2.92 -5.42,3.71 -5.42,3.71 -6.35,4.09 -6.35,4.09 -5.69,3.45 -5.69,3.45 -5.56,3.31 -5.56,3.31 -2.91,1.46 -2.91,1.46 -2.91,1.19 -2.91,1.19 -3.04,0.98 -3.04,0.98 -3.18,0.75 -3.18,0.75 -2.51,0.41 -2.51,0.41 -2.65,0.31 -2.65,0.31 -4.89,-0 -4.89,-0 -3.57,-0.44 -3.57,-0.44 -3.42,-0.69 -3.42,-0.69 -3.54,-1.03 -3.54,-1.03 -3.1,-1.22 -3.1,-1.22 -2.66,-1.25 -2.66,-1.25 -2.9,-1.66 -2.9,-1.66 -7.28,-4.84 -7.28,-4.84 -5.03,-3.67 -5.03,-3.67 -4.01,-3.12 -4.01,-3.12 -4.11,-3.41 -4.11,-3.41 -3.41,-2.99 -3.41,-2.99 -2.23,-1.9 -2.23,-1.9 -3.46,-3.53 -3.46,-3.53 -2.19,-2.76 -2.19,-2.76 -2.05,-3.06 -2.05,-3.06 -1.07,-1.85 -1.07,-1.85 -1.61,-3.22 -1.61,-3.22 -1.3,-3.26 -1.3,-3.26 -0.84,-2.65 -0.84,-2.65 -0.76,-3.17 -0.76,-3.17 -0.42,-2.38 -0.42,-2.38 -0.39,-3.44 -0.39,-3.44 -0,-5.95 -0,-5.95 0.4,-5.29 0.4,-5.29 0.41,-3.44 0.41,-3.44 0.63,-3.97 0.63,-3.97 0.69,-3.57 0.69,-3.57 1.06,-4.23 1.06,-4.23 1.72,-6.61 1.72,-6.61 1.33,-5.03 1.33,-5.03 1.98,-7.14 1.98,-7.14 2.25,-7.54 2.25,-7.54 1.46,-4.63 1.46,-4.63 1.21,-3.7 1.21,-3.7 1.22,-2.38 1.22,-2.38 1.86,-1.88 1.86,-1.88 1.6,-0.81 1.6,-0.81 1.49,-0.43 1.49,-0.43 2.73,0 2.73,0 2.03,0.53 2.03,0.53 1.94,0.81 1.94,0.81 1.98,1.13 1.98,1.13 3.97,2.93 3.97,2.93 7.54,5.28 7.54,5.28 7.94,5.45 7.94,5.45 2.14,1.32 2.14,1.32 2.62,1.31 2.62,1.31 2.41,0.95 2.41,0.95 3.01,0.8 3.01,0.8 2.65,0.4 2.65,0.4 4,0.01 4,0.01 2.61,-0.42 2.61,-0.42 2.49,-0.67 2.49,-0.67 2.01,-0.79 2.01,-0.79 2.76,-1.45 2.76,-1.45 2.27,-1.56 2.27,-1.56 2.12,-1.7 2.12,-1.7 3.1,-3.14 3.1,-3.14 1.87,-2.25 1.87,-2.25 1.6,-2.37 1.6,-2.37 0.87,-1.74 0.87,-1.74 0.82,-2.33 0.82,-2.33 0.55,-2.7 0.55,-2.7 0.15,-3.17 0.15,-3.17 -0.32,-2.78 -0.32,-2.78 -0.4,-1.85 -0.4,-1.85 -0.67,-2.25 -0.67,-2.25 -1.18,-2.76 -1.18,-2.76 -1.21,-2.14 -1.21,-2.14 -1.26,-1.87 -1.26,-1.87 -1.66,-2.1 -1.66,-2.1 -2.8,-2.79 -2.8,-2.79 -2.51,-2.01 -2.51,-2.01 -2.91,-1.89 -2.91,-1.89 -4.1,-2.07 -4.1,-2.07 -3.17,-1.2 -3.17,-1.2 -2.8,-0.81 -2.8,-0.81 -3.02,-0.65 -3.02,-0.65 -3.7,-0.54 -3.7,-0.54 -4.76,-0.3 -4.76,-0.3 -3.44,0.17 -3.44,0.17 -3.17,0.27 -3.17,0.27 -4.37,0.66 -4.37,0.66 -4.17,0.92 -4.17,0.92 -3.65,1.03 -3.65,1.03 -2.89,0.97 -2.89,0.97 -3.7,1.39 -3.7,1.39 -4.1,1.8 -4.1,1.8 -4.57,2.33 -4.57,2.33 -3.1,1.78 -3.1,1.78 -3.57,2.23 -3.57,2.23 -2.91,2 -2.91,2 -4.07,3.03 -4.07,3.03 -3.21,2.66 -3.21,2.66 -3.44,3.06 -3.44,3.06 -4.37,4.37 -4.37,4.37 -3.9,4.67 -3.9,4.67 -3.25,4.32 -3.25,4.32 -2.93,4.37 -2.93,4.37 -2.61,4.37 -2.61,4.37 -3.08,6.09 -3.08,6.09 -1.76,4.16 -1.76,4.16 -1.73,4.76 -1.73,4.76 -1.43,5 -1.43,5 -1.08,4.76 -1.08,4.76 -0.78,4.87 -0.78,4.87 -0.4,3.57 -0.4,3.57 -0.3,3.7 -0.3,3.7 -0.08,8.6 -0.08,8.6 0.23,3.7 0.23,3.7 0.41,4.1 0.41,4.1 0.53,3.7 0.53,3.7 0.81,4.23 0.81,4.23 0.9,3.76 0.9,3.76 1.09,3.78 1.09,3.78 1.47,4.37 1.47,4.37 1.92,4.76 1.92,4.76 1.9,4.1 1.9,4.1 1.75,3.31 1.75,3.31 2.24,3.84 2.24,3.84 2.43,3.7 2.43,3.7 2.5,3.44 2.5,3.44 2.59,3.25 2.59,3.25 2.81,3.19 2.81,3.19 3.47,3.59 3.47,3.59 4.1,3.71 4.1,3.71 2.91,2.38 2.91,2.38 3.7,2.77 3.7,2.77 3.84,2.56 3.84,2.56 3.14,1.92 3.14,1.92 4.63,2.51 4.63,2.51 4.14,1.94 4.14,1.94 3.57,1.5 3.57,1.5 4.63,1.68 4.63,1.68 3.84,1.19 3.84,1.19 3.57,0.94 3.57,0.94 4.28,0.94 4.28,0.94 4.85,0.78 4.85,0.78 3.44,0.4 3.44,0.4 3.84,0.28 3.84,0.28 1.32,0.08 1.32,0.08 6.61,0.05 6.61,0.05zM266.31,350.59 L267.75,350.16 273.3,344.7 278.86,339.24 282.47,335.8 286.08,332.36 286.4,330.9 286.72,329.44 286.43,328.36 286.14,327.28 285.37,326.22 284.6,325.15 283.36,324.55 282.12,323.95 280.02,323.74 277.91,323.53 269.66,323.35 261.4,323.17 253.48,323.47 245.56,323.78 244.35,324.31 243.14,324.84 242.25,325.69 241.36,326.54 240.87,327.6 240.37,328.65 240.37,329.58 240.37,330.51 240.65,331.53 240.94,332.56 242.57,334.19 244.2,335.82 251.54,342.68 258.88,349.54 259.61,349.93 260.34,350.32 261.27,350.65 262.19,350.99 263.53,351 264.87,351.01zM215.35,299.54 L216.9,299.14 218.4,298.34 219.9,297.54 221.17,296.3 222.44,295.05 223.32,293.73 224.21,292.41 224.85,290.82 225.5,289.23 225.92,287.19 226.34,285.15v-2.76,-2.76l-0.4,-2.11 -0.4,-2.11 -0.65,-1.93 -0.65,-1.93 -1.08,-1.99 -1.08,-1.99 -1.32,-1.66 -1.32,-1.66 -1.64,-1.29 -1.64,-1.29 -0.95,-0.57 -0.95,-0.57 -1.83,-0.69 -1.83,-0.69 -1.98,-0.36 -1.98,-0.36 -1.99,-0Line truncated
|
||||
fill="#25b135"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.3 KiB |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" fill="#F7931A"/><circle cx="32" cy="32" r="22" fill="none" stroke="white" stroke-width="2"/><text x="32" y="44" font-family="sans-serif" font-size="30" font-weight="bold" fill="white" text-anchor="middle">₿</text></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="1024" height="1024" viewBox="-34 -34 580 580"><filter id="b"><feColorMatrix in="SourceAlpha" result="matrixOut" type="saturate" values=".1"/><feGaussianBlur in="matrixOut" result="blur-out" stdDeviation="6"/><feColorMatrix in="blur-out" result="color-out" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.35 0"/><feBlend in="SourceGraphic" in2="color-out"/></filter><circle cx="255" cy="255" r="200" fill="#fff"/><radialGradient id="a" cx="277.49" cy="196.441" r="34.397" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#f9aa4b"/><stop offset="1" style="stop-color:#f7931a"/></radialGradient><path fill="url(#a)" 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" filter="url(#b)"/><radialGradient id="c" cx="261.915" cy="284.567" r="39.838" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#f9aa4b"/><stop offset="1" style="stop-color:#f7931a"/></radialGradient><path fill="url(#c)" 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" filter="url(#b)"/><radialGradient id="d" cx="256.028" cy="256.003" r="255.988" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#f9aa4b"/><stop offset="1" style="stop-color:#f7931a"/></radialGradient><path fill="url(#d)" 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" filter="url(#b)"/></svg>
|
||||
|
Before Width: | Height: | Size: 313 B After Width: | Height: | Size: 2.3 KiB |
@@ -1 +1,177 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" fill="#F7931A"/><text x="32" y="44" font-family="sans-serif" font-size="36" font-weight="bold" fill="white" text-anchor="middle">₿</text></svg>
|
||||
<svg width="512" height="512" version="1.1" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<image width="512" height="512" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAMAAADDpiTIAAAAOVBMVEVHcEzSeg/Seg/Seg/Seg/S
|
||||
eg/Seg8XFxf3kxrSeg/6lBrvjhnkhxdmQxiXXhjagBNBLhcrIhezbRlPwFBIAAAAB3RSTlMA3Kt2
|
||||
JQxMlGKhdwAAIABJREFUeNrsXduW66gO3ImTtI0Nvvz/x46T9HQSBwlxMQgM8zDrrN070ydVlEpC
|
||||
iH//DrGu18vlcrs1TXNe1+m+ulP3t9b/sa77H53XH7nd1h++Xv/VVQTud9ifoHfk9WTDkwqVCXlC
|
||||
vyJ/B94Gdj0VHkRYeVC/05yg90dew4NVDyoNmGN/3/Vhof+mQWUBV+zXbd9FWJUFLMGPgv27FlQS
|
||||
HBX8NymoJDgs+JUEydFPD/47CSoHoq516zMB/+UJqhBEE342W/9LCCoHDot+5UBFv3Jg77jPH/0X
|
||||
BypeR0X/fw7cKgdCZnxZof/LgZobBtr8oQL/uK7hsfrf1f7987sef3r/uRoKytn8T9gfeD+XEM9/
|
||||
tfD6JYM/E6oM+MHvtfl/gW+91i8Rqgwk0f6TB/S+yH/zwJkGp0oBF+132/zhsQ/BgjUpqJHAEn5H
|
||||
7NvdlxsLKgXsQr8D+H0E8N+0wJ4E1QzsFPojg+9BgmoGwsOfBvx3ElQKJIP/vvXb5MtSCCoFAsGf
|
||||
duu/L2EpBJUC/vCz2PpfQlApEAd+huhbc6BSYJv4nXJH35YDp6bWBezLPrzRt+TAqZaGnosK/5gD
|
||||
/FYUON8q+v8utJp/NuhbceB0ProVIAb/zOB/LCIFDu0GacH/jr5oM1w0GTjwKdHlXOjmt6XApao/
|
||||
CH/G6P9yYKwpobP3zx/+pwzUOODk/Yci4CdGgmPlAwT1H8uBn0qB44iA2fwVBj+RAgcRAfP2LxB+
|
||||
GgUOYQaN279Q+GkUKF4ErrfTYayfU0ZQuAgYt3/Z8FMoUHQ6YNr+Y/HwU0pDp1LPCC+NKfi3B1km
|
||||
CpQZBgylv+PATzgqLLBRwJT8DX17qGWwAsV5QYP7Gw8GPyEOlOUFcfk/lvpT40BBYcAg/0PfHnTh
|
||||
caCYMIC7//Gw8JvjQBntYmj4P6r6E+NACUYALf4ce/sTRCD7ohAa/uv2N4tA5kbgeq7b31cEcg4D
|
||||
aPiv2/9dBIpkABb+6/b/WAITgVyNAIZ/3f42IpAlA65NBttfCEZXjjARyM8KYvaPTelPDMvIiQFD
|
||||
OQzA7B8X+RdtJ38m1TOiAJIQ5mUFEfz5yP+g5p91yTGPMJATAy4n9vIv2mX6ea5pYSQCSBjIxwoi
|
||||
9n9gtv2fSw6cwkD2DIDx55P8r9H/bc1Tl0VdMA8G8Mdf9O/bn2EYyJoB8OkPH/mXP99LZREGTk2+
|
||||
5R82+Hc6/JllA0OmBQEY/5Gf+9+uqcsiH+TMgAzw/w7/70agrQzYB382xd9e/SBrZsQAAVcEuDIA
|
||||
wZ+1/XtngMrifJAnA0D8+XR+mfB/MEBUBgTGn03xfzTif2cAo4IA3CbCjwGF4M8tCmTDgGLwv5eE
|
||||
KgMc6n/sq/8DFf9sNKDiH9L/f2aDIgMGMKoKg5OfRj5uygL/e0WI1XRyKBlgczIE4c+o9Vf9WK1p
|
||||
ZKUBA++zQf74C6T+q1+sWkRADeDBgAzwX2zxXxnA6+IKxAAGfYIX/viPkzX+zFIBxgyA+n8Z4W9n
|
||||
AHmmAiADUvcKX9njjxtACYvDNOTBgGvFHxeAbsYiPRIemNkAlgxo+OOPVYBkL0Q35RIEIAY07BIA
|
||||
Vld/lSHXE2CLGLNqAMiAdCXBHPBHAsBvFyiSJEpe+EMVoVTlACABHFl9ZXAAePWAqmyCQDsySgaB
|
||||
BJAV/i28u1/gInkis4IgyIAUySCQALAa/SKGiVLoQX5McSMAcDaYIBVochj9o2g5HmwD2PlAqEus
|
||||
4WEAeeGP1IA3VR6hsmgPQjQgthG8ZIA/cglg3lwCgm3A3IlMGHBhYAB54Y8IgKLXCyU7CQDKATGN
|
||||
INACymz2GywAOnOvcrgx+L9pTd0mmgX+sADoZB3OBPi5AEgDmrQGkN3sRztjB2YC7BIBkAGxjKDe
|
||||
AHCb/QpvaQBR0AcqhgTQG8FINiAL/LHMTtgdG0xDPgy4JjMA7PAHNzSIJ/Q3+J0IPH7bVJ3imRgA
|
||||
MKTPoKKDqaBkOdm8T2MD9EcADId/S/tOL1ACONpAwAjuHQT0FYCR3ZcD54BIVgdKgGJJAP3J4M65
|
||||
4C2Txx/EqICF7WZIAnjGAMAI7hoE9EcAHL8eAS4H48DwQACxAZdqAILXDpjGgOg24Fb66y9Q8UBy
|
||||
feFoiNokqg0ARb3+BNlAlrUg0AbsFQS0AaCw178AG8izFgQyYKcg0Bzg+S8oBnA1AYANaKIFgNKe
|
||||
fxPjnJcJAGzApQaAwDFgYCsBsYLAbd8KgEi8/n4RlZkJAKoBt9wCwJB29aY84M0E9H3fmotKpQWB
|
||||
865HAGKY0q5XjAdqQfL1qyq5LqWWbhyeTGB5KBC4Q3DvADDYT/EJul7FXsAEvBjy9wPzPK1EWMY+
|
||||
+Qu0WhsQNAhou8BCZgCpCfCSeCARfCsFff7AnQZq/S6ScmDY2wc2e58Bu83xCbik4UDo1RMgFs2f
|
||||
TnJVgoQcGPctBugcYNgMEH3KJcb62+FAJeDtMjFwZLgKQToO6IJAOB94Pe9fAkIec4prAqATwRcB
|
||||
RpCqqw4MiR6c2DUI3OI0AXVJGbAYXODLJAzY7zmpLo0M7OgDdQ5wlxLgKDm4wNZAAJNfmeWSggL9
|
||||
fhLQRDsDSMkAaTgPkhaGdY0E8Skw7OUDtQ5wp6OYIR0D/hJ9ncv/PA4i/JLTEv/wYC8feI7YBZiQ
|
||||
Aa80QO/yXwQQpNnzMvpz1NogsM9FkP0OgdMx4EUA/WnA1AsrAqwU6CJnBMMeF0Wu58iHwGOiXOBV
|
||||
6QEIMNgSYLWDcW+U6IoB3j7wFrsLRCTKBl+FgNFEgMXi7ZmocWAInwpqU8Cd/28sM28CtIvFp8YV
|
||||
gfAS0KS4B6KKUYDYItCHTgWvpxRtgElOhvZRgEcJKWJGOAROBZskbYBJbMCfCRRdUAV4pAPRGKDz
|
||||
gU3YGlCMPmCRIAi8AA6WBbyFgUwloEl0E1wkCAKmQpAPAWK+SR5SAnQCEKcPXMTPBF6VPr3C2xeC
|
||||
Po1ALAb0ASUglQAk8YHGw6DeL0BFmzwfTgLSCUAr4kuAEngWancYlJAB4SSgSXkTLLoEmBpCpPev
|
||||
FosBQyAJSCgACRKBVxYINPwowgMjPBgQSgKSCoDbe68+SUCP14GoLWEGoynykQCNAES9CiqM+2wO
|
||||
eDPo9YIM5D4oTaFMcgFNNchBAprEd8HN9Ta1z+VAhVeK/VJUlY0LSC0AhBgQcmiDMFm894shcwiv
|
||||
yV0CUgsAwWzvMrUDivBvZ0H9ouTkSoI470/4S4CmDyD6MAiVhACLeUSIEOs3rKSbF4wyb0ojAZZ9
|
||||
Abf002CMUrsPAWhDgoQQ/bA4cSDKtJnBszXoykAAjG57DwKASb7mP3YXgkVax4Iow0Z8JYCBAJjz
|
||||
7V0I0NkNixV9p2xlIMobNJ4SwEAAzDX3XUKAso/coy0FYgQBnQR45YAJ5sGZqsE7EEC4zAsXKwVm
|
||||
dkFg8MkEGxYTwRMQoHV9MaCz8gIxMoHeIxPkIQDGWiAKSg8stwhg3LOitxpvoHhLwI3HkwCmPBD9
|
||||
Fh+jvDQLNWCg76Rs2dFCBGL4wN7ZBupywDYFATp3AoDhA2UN+LcoDwhbTbmJcSbgnAlqroMmmQhr
|
||||
KgSgBFhcXgADq88k1yYEPQxEkADNC8PEq6INk0ehTMMDUQJAYo69/gI/NUeEy+JCQxoJaDKygOZK
|
||||
EG4Cpb2aw3+nJ4uWZJQIONrAhstMcC8CgOEcFl/YdNJNO50BEWoBvZMEaCxgokcB/AgAOkhlLQBW
|
||||
D0dSo0CMcuDgYgO5WEBz9yW+L+G3gwE4YQGwworaLxLjKbrexQZysYCtsSUEJwDyergt3azUWlD7
|
||||
mWMUgxxiwIWNAPgSAIwBegmA646Wfo3aNp7IBl6tq4DJnoUxDYwybCEYB2VnOGwzNmJHe4QYIDQ2
|
||||
8GZ9EJzuXSgvBUC2tParV+GAIgaBNAcC53wigCcBWnhPa1wdXHaWDtI1cckDettSACML6E0AZFNv
|
||||
bR0St10O72kSMI9JbOAtjyJACALAu/rLgKmg+5QmAYn6QtBSwHcESPkyoC8BkL8vqTV8N5RoEqCS
|
||||
dIeiMaBh9TSoJwGwIQMbXGXoQN3NTEyARgIaqwjQ50sA9AM+iwEgU1xlmlQLiHNHxCYGaCJAmzMB
|
||||
sIl+H1e13doHnAxobBfY2sQAXhHAnwBYUW7+rO8Ab4W5FmtIJwJzonuCTSYRIIACYHOHp0911/Zz
|
||||
SddfnTZEIM5VYXoMYBYBAhAArctvzgQ0DHBv3KIlgirKwBB6DGAWAUIoACrFnzN7xDcDfPq2JBsC
|
||||
kGMAtwgQggD4Z2wmtmzJ4jXUiUIAGedrpJ4HcIsAQQiA95ZvtvhnV69XpY5UCpJxNhg1BtyYRYAg
|
||||
BMATsnlBKoJejbucCDDQzgOuDbMIEIgAqB3b7vK3nk6/Mg0nAvQ0E3DlFgECEQBPyTfJYPvXhOJ5
|
||||
VMOJAJoYcM0hAgQigOFjNlOb/tqQPG9usCLAQOoNZRcBQhHA0KK1ZcDzip/vWFdWBCDFgO8kcCyE
|
||||
ACYwtgy4JwPe09yE5ESAkVAMZJcEhiOA6YO2aItOehdpe04EICWC/CxAOAKY+nS3TrD1BobWGy4j
|
||||
fZGCkgjyswDhCGA8nAv+qhOnswCaCdBYgLYcArS9MvXnhX3oWZBagqIRoDWbgG8LMJREAOOr5IHf
|
||||
9aK9LbhEI8BgNAE3fhEgJAEIsxvCPvBJ6giKR4DeaAIadklgWAIQenRCvuhC8oDzmPBF0Ya/BQhL
|
||||
AMKmDDjKndQQNEV8VNhkAjhagMAEIGRmUygrSLsXICOKrMkEcLQAoRXAaATDWUHaDXEpIn6XBhPA
|
||||
0QKEJgDp3nYQI0BLAiMmAUYTwNICBCcAaYBPgLfeRU+7HNpFJIDBBLC0ADsQoCW060+LbxggToiY
|
||||
+pgEwE3AhaMF2IMAFAbM3mGAOCAi7peJ9gQ0hyEAaaCv7CIIQFQLYDoOYGkBdiEALT5PPtkAzQFE
|
||||
rQLoTQDmAYdSCUBkwCzdLwURBwXKyN/mgLhAnh5wHwJQk3TXohCh2hD7IMDsAlmWgQIfBlmVBJ9F
|
||||
ITeNJs6JlH1kAmCloIalBQhJgOU956ZuUukgAuSXhZWI/W0ipaDSCSC6aXJhgL0IkF8MiG0BdS6w
|
||||
Ye4Bw3UF3/F+v+otqAywFgHq58YXAMwFMvWAwQjw/Bz5wQDiQF87ESDzKoUAIC7wmwB9UQT4Dcsf
|
||||
BT4yA2xEgPyZcwIBEN8u8AIlAWNJBHiF5Q0D1E9YEaBzKn4KoHeBN94eMAwB3mH5LPLTn3mj1QTo
|
||||
+EevARhcIFMPuMN8gM/ZcHQGEESAHv9jHwMhLpB3EhCGAB8nM9uDPvpTjyYRoL8WlcYBYmkAVw+4
|
||||
y4ygDQMWMgNwEbB4MTByIwhWC/xNA0omwHdhbjMazOLNZ1gERGvxMbNK9n0CBLgx9YAh5gRqAvOG
|
||||
ATZbFxABq1eDf1S67QWkAU3BBNClepuTXovgrb9EamP/EmWAeBrQMPWAAWYF6+/ofg4AtcFv1m3f
|
||||
zgr/IR3+3y6wYZ0EBCCAIl0BIpeEpqX3k/8Yr4Zb54HlEgC+pP/FABKImi4hm+wvOf5AHsg2CfAn
|
||||
gCJeAxSkZuHvPkEr9x/28mGgNKBoAmBTOra5uDkdlN1XEigGK/n/kWn3P5QHss0CvRVgoRdjhcCt
|
||||
nCYDFHbujwH+QB5YLAF6q6eg0GRAUwOydH+J/T9GgKZUAlg8G4gjqrkqZL39A4+gCVkIYFsG8CWA
|
||||
7WOgAjgb0lwWtN7+oYdQhSwEnAslgMNz4For+H1d2Hr7J7f/WCHgWigB4DldyGnstxXUJX+9muy2
|
||||
P4fwDxYCvgnQF0EA8C/j7TgbK/jt/hy2P4vwr88DVwLwLQN4EeA/9q5FOW4Vhm6SZmODwY///9g6
|
||||
j816bQkQIJDZ0E7nztxu0okOR0cHIfAruu7JPI/Z/Vi5kbc/ZCCIMgIaBQBqAvjbMe6u4GHr+uyC
|
||||
fBfMKgJgbAMAJr4f70cKHpV7xPZfOkHxPzYGrwCQ6wOlAAC1gQOGM6rvE57DCPHu5NsfMgJerq0C
|
||||
AHOBgvqx1GDmQ/Wn9HLi7I9bgW9tAsAmNWSuwT7s3RUURPEvbfvDVqBcIzBJA5jEsRxqd1pIPPhd
|
||||
a3952x+2ApsEACYBYq/kUA9+M8yaKwaA1yYBgAxrju3IJJ/8DCLDD3rBbQIA6fCJHSw2N8D+zwUA
|
||||
m+9OjiKqP6ns/1wAMBEuMEb/UxvsD1uBrxe5TnACAJBP0jMAkf4lsz/iBTcJAKwIIGcAIv2LZv/n
|
||||
AsCc5Vo2zfsVzv7PBADkyR6iBKCZP+LZ/6kYAD4JID4zRkr/EPsrpf4AUAcASzoASHd+IfZXalzE
|
||||
5QQAAH2TAEj2gUmX/iD2/+odmBZhaeE4Ku7Si+0HSdAAS7IN1E9p7N8t5lsZjJIQoAIA0AQDgEbg
|
||||
TCDkcPk3W+DOcDf+lo+ySsPnYAAFAyC4CqTIP5D9h23nmCwSeGYGCAUAIf5Qx/exc0gQCbTEADMX
|
||||
A4RP/F33NnRraIb+4h8DVK8CwgBAmPgNib8Bu18ooxx46iogCADB8Yd2tatvVIhP/CwMMMd1hIeX
|
||||
/9DEAIj9H+TiHwOUYgDQCg7wAULjD25/X9+giBEBT8IA4GGQ3wkMjT84McBvHQi4JQwwwEt7VQDW
|
||||
D+CdKhUWf2D7f14mmoNcg7oIUM9yFgADwGSJ/5QyMaB2NRDAAE0AAP6k52J4WPwTB4ZUFgLP0hCC
|
||||
HAaMKrX+g8bFEbvGqwqBJwfAohLjDzE49cpw1XmBTwMAuCPEdMSk4Q8deWJA3ZFRAABemwTASOsK
|
||||
DYo/lL5pbwXUR4B+losh8EfRHBAyMB4W8ORroz9fqxICWroZNEdcD8dyQEAQsV1Lnhl5Q1MVH+BZ
|
||||
roZhUwLhef0hT747tLta6DLgcwDRHwMwAgC5GQB/xt//56ze6aPD6mUBAABtTgjBVB0gA/GJgtvt
|
||||
6p5KOpwFAcPTjIjpbKBwCAieP1RRQqBGLRACgCaGRKF5/UAB/gIw5ASHPEMIVySsCxgSdW0UANig
|
||||
wN2nlHfrhlq3PR0B5V1hYEzc9aVJAOBPxj0+GrjM2YI00oVA8VckgUGRpx0VO3uO97FRkQ9egFcA
|
||||
Ug7vIqRg4XeEoVGxrQ6LRt3drR2o+pxdXEobuh1QlAKeaVo4/mrggw50SoDZdow3yisIQQgArT4Y
|
||||
4ajvbaALTHfrIhBgdE0AgC+GNPJmkMJPeLZJAP9bMW6t6qgImG1BCgDfjm0VAI4Ev7VgUATEuvVU
|
||||
BJRMAuDr0c0+G+d4OG5b2yEIiD6tUVQEFEwCQ9C7gc08HevQ+FZ7EnfKaR35TcFiFAA+HHnWp2ND
|
||||
kqdx6HsnAlLir0LaS6IGF3AYgS0/Hu2igIdtd0DAnGTQkP2AYjqwPxqBDT8f3zlbvR7O4nYImBMN
|
||||
OurxcCkKgJ+PbxkArrP+3WnsFgHJlzcCegxqUMARAGsVKNgISNcAStngJm/Hc4ERCOhngRQA2gCn
|
||||
BUDQtnGm491B3+1EP88R3TLLowAYAHKNgAwAwA8Fjwj4ae7MdEhPE4JlOgNAG0CwEZAFAJ0N/rmr
|
||||
z56OXMYcUQYU8QJGGADXcwIg0ER3JoF5R/f9lK9RT5GSgCnx8wRtAMFlQB4GcG/FHQLGvtg/v/yJ
|
||||
AFwFNg8A91bcj23JGAZaJWDrAUBsGZAJAG4ZwKm/KJZwgSMhpAiQeyCcRQN4vxDf6CaSDiyQA8DD
|
||||
YMllQDYG8FizfBxgRdUBSBEAlQG6LQD4un/ZEEChAPY6ACsCLserAX1zAFDuaxtsU1soz45w3xHA
|
||||
NCBUBgwnAACtmc5TlfMgQFG8gJlZBBxnA/wCQGoZkJEBvAjguamJXlCr0RiEFgFAGTC2BwDf8QzT
|
||||
XV0rxwkYsSIAKgN0gwDw9OyzIICSA5hVoEaLAKAM6E8AAHJDvfL063JMbKBYAcxWkAb7wUSrwMwM
|
||||
0Pn6dTkQQDgQYO4KwTWgWBWYHwDlEaCMlDLAoQGlqkAGAHgQkF+Jq3AVOPOOjHFoQKkqMLMG+AlI
|
||||
WQ4gqEDeOvA4KP5tAwCZKpCDAXzVYO4oUM6EywJgowFXESBSBfIAQC9FOWCcZRgBLg0IqcCxJQAo
|
||||
kiOUFQFqEAKA0aUBhYqAbBpg2J2zlPQEhQDAZQOJFQG5WsIGY6shQAoDHDXg9QEAIq2gXACwh7PW
|
||||
cmeDSogGcEsAoSIgZ1fwvvdzKdQhIgUAHglweZcoAvJogB8vjoiAbN0ZvQgA+CSATBGQhQF+C/Ed
|
||||
r3sQkGuCI+U4kBMAHgkgUwRkAcD9izxqO+VBQKZiEHm5ovS8MJ8EkCkCcgBguwNJCMjkzBPOAjid
|
||||
QJ8EgERA/RyQQwM8fI1Hj8fTsZfndJZyGsgHAPdBACYChgYYYBfj3VtNbgRkadAg9AMwngYOXgkA
|
||||
iYCxhRRg3NO/nNcGM+xIUlcoWz+AGr0SABQB+vQAOJ7F7RHgOh3OcFuLchjI1xGk/RIAOg7oZQMg
|
||||
SAMY7wRIFwLSkwBFA05sN0NCJIBEEZA+JxCy4R4R4J7qmJ4ESFeDyhWBVwAA7+JEQHoKCJkC7Pou
|
||||
qUmAIgEY28JHcDyc/ByQPCsY+fHvOMB1dTh1YiRlTIytmwEE5oBUDYC6cDt97+jdTy3NCBKAzwYI
|
||||
KQJFFoKpDIB+fv+zduzTpMRMygB8VWBIEQg3h1fOAakPRqAnsfuoOrR6EgUQDgIYq8BjBniF4w+I
|
||||
gOHUALChHT+uUXIpFECaE8ZWBIRmAHk5IFEDoB+3AFlM+SlA0SYF2uoZADAD6+aAxEejMBMOqu0c
|
||||
sTJlCIBNAwIZ4P1yjhzA9G4gWNrhflC0F0AjADYJMAQWgQJzQBoAsE/DAXUkgUhuJr4bwiYBwjOA
|
||||
uByQpAFU0NvB21owd2hoLwdZARlAWg5geT0cE3X4ho2TgcQnI9iaAUZCBgBzgD5rCjDE/Yyn7JjN
|
||||
SX02iCsDaEoGAHPAcE4AoCacQ2ybfMER83DYQMoA0HlAPRno2URuDYDtZ4fYRikgxqQlPh1YMANc
|
||||
XfEHckA9GaiSGMCSj/fw70cv0Rchz8ceJaAzA4DnAfVygJ7iAYAE07nTUOvecMefrSN8CD4HcOQA
|
||||
XS0FxAMA+6yz7Qr9hrSLYvQn5NlcIE3NAFBfUDUK8ADArQGQIsyjtWwGEaA0Of7ZLqIdfoJ9WC+Q
|
||||
2woYz8gANkZrYTKQ4tNT6z/WVgCaCYDNjq8lA303q50AMDFUu0Ju3q7pZ5lgAKhumcjxZ+sGBCTg
|
||||
1QsAOVaAz0tzAAAT9N6fdL/8sN44Dt9LD3pdwdufTv+MNSDVBEBzQB0K8JymOTUAkj28O1mp7z8f
|
||||
VuhTYkrHbH9GAojJAKAVMFQCQLR5hpAH6zBWpXszx8S/IAH4TADsnnAlGWjjAWAL371R6+6PCz9b
|
||||
CRAnAREZWIcCTG4AGK7oqyE6/HzvxR0JoL8GAQCQgVUowOMEOzWAKTaCY42+7u30Eb2WYr2AQRIQ
|
||||
loE1KkFvUz0eTgWbyNmT7adAHHobvflZO4F0LAGIcQN9HRWO/QxjZ85ouKrv4C9mSok+21NF8RJQ
|
||||
DgV4b1U4GADGTobZb7fCUA/jYlODz3ofDCCAt9D4y6gEtfUO1drX6x4HwRwCSVmf/ySth3XXr6Ff
|
||||
Y58c/MJXwgkEAMrA0hTg76mxw25pTxGwoYw1ivtlv39hy6xRX+P+6RV/ZFslh4J4D4Ldh8LlKcA7
|
||||
Y/XXqr+tXzpFAHCn2xVcMxbJ+et3kVV0LNTLlQAAARRAuli5D7Bx51vSrV3G+DPOhYuvAcVQAPlU
|
||||
dWP0Gvf/J7Zscy3L98Mb4mtAIRRAeXHxkFBh+XD//2qRAACr5RIAWAmWpQB6Brg7/bAP9AsApayA
|
||||
+HM+Fz+k1IBoJViUArSJ+Im6U/z9Jy4BAGYoORyeUgPKoICILH0v84fZU3PbpuOfgwCqU0BEiH7L
|
||||
fKSXzCQIzFPFPwsB1KWACAm49Xl6DwC0aVf/5SIAkALKnQjEcLT1iHyrhABg5o0/cAoQQwBVKSCG
|
||||
AO4+kHAAzEtX9pnwOAKoSgFRAdoAwMMQuqYROPXM9VMuAgApoExrUJxRdzdWPQDoKgJgNqNiZs9c
|
||||
BFCNAlQcQ8++syAJDMCc/uEEEEsA1SjAfiQBQHlPg6tpANN3qjsNAcAUwK4Do09qfBrA1AbAZAfu
|
||||
8OclAJgCmJNAzOXKQA1Q1weYC2x/yANKIIAaFKDig+NlgKpO4LRoxZ89MxMASAHMOjDepvcZQTXP
|
||||
AkqwP1wCJhEA2BfAqwMTTurDAVD6NHCyoyoRf0gBUvsAAlqDGJNAUqeG9TT8bBtCCoe/KxJ+KAEQ
|
||||
G4ECKUCzxT+lQDed+zRwA4ByLWHzSv6Fwg8qwFQCAK+KclFAWvy9DSGbnsFSTaGzWQZVKPywAkwl
|
||||
AHBwHJcOTIv/veMHuxq4lDUC1s3f63LhBxXga3r8wVKQQQeqLrVT09cUujEKlCkQ/ZKbH1GAaSWg
|
||||
qxTMngRihqvhFG88XjCzCvyKflc2/KACfMsR/8u/EklgSN+T93s22HyI+z965FOBk7F98egjCeA9
|
||||
CwDAUnDMvP9zcLLPCtzcxOMRAfMa/GXQqnj04QTQX/PEHywFMyeBIceO9BkB26t4ma2g+TP2686v
|
||||
E3wkAeQiAEQH5kwCKgsA7kYAUuZtREAuJ+BzmuQa+mWNfVcr+IgFkEcB4jpwzAoAk2FZ7WH4zXGQ
|
||||
NpN7rZG9/T6sr+mhn9/QLss4aP04naDGGtkUoEMHZk0CGlrd4Zd7dR6zf3sdXw/fv2jrNkD067vt
|
||||
51LUW7wJAPMDa78u7/SUPnwiQHXdbiooalBs/vvrbxGGh9arAF6uOeMP68BRbPyxOdNWdS2ukZ0A
|
||||
YB1Y+W1p554wPiegoQUlgJwKEDcDBCeBiBdjzrqgBNBfc8cfSQJCEYA+3N1gDtAlEsDpkgDmBDSY
|
||||
A8okADQJSEWAfpYcAMU/qwXgSQJSZQB23mcaiz8oADgSAJoEpMqAkfxyaOcYIHoqAcCTAM6WBLB1
|
||||
VHaZAAAGHElEQVTzPpcM1Nig0P5UCeDKFX8kCQznygEOCujn26DQ2++fPxZ1ovhzJQA0CciUARHP
|
||||
h6NNAtMgEwCgAOBLAGgSkCkDsGnj6Hw+/FKCUOUIC4DrhXW99Wc5FECP/DEKQAlgFpoBxnIVoOdg
|
||||
WKYMwAKKqAAHAWiRACguAFwyQCIC0IiChYBjgrg9T/x5BYAjCYgUgtgkGFjT2Y9TSUBQALInAPSq
|
||||
kEghiF4CBua0O0ZTWon7HxSABRIALgMEIgBldeBEAO8Tn0Z1mvj/uxRZ17PIAJQCDrLOcS9dJAHA
|
||||
AuB6KbRgGTCchwL2us6RAEQSwFBPAJwMAQs6rlOFJQCRBIDE/70cAGAZILAUQCP7aAbgCUBiCQAX
|
||||
AKUEgMsNkCcE8dxut57hFDB7TrgAfCkaf0wICkSA3991TSYU2EKGxP96KbxgGSAOAbi8u8kA181k
|
||||
gR1kcPxLCkCnHyQQAajDd5MBjskU9iT7v5ADFNIdIq4UcGzwLzfANZnOyFOAowABeAMALATFnQ07
|
||||
JN6KANdkMoEJAI7/S434o0JQnh1gHdP7HQWAwAQAGwDlBeDZEGDwOX5TwNg54fHvq8X/8g6XAtIQ
|
||||
EPUInUAPGIn/2+XyhwA3AGJGEE/LWeL/fqmJgNdzIIA8E2oWJwCQ+L9WjT96KiANAeSxcKeJ/79L
|
||||
5YUUg9IQQBxEKU4AIvF/qR7/syCA9hipOAdIcPzRYlAaB/RTe/G/Xi6SESDLEwwvBYywAlALj39r
|
||||
CBBnAIzS44+dDYs7GwxCgLj9j8S/qgHUMAL+4t8yArQXAdL0Hx7/d1kAeG+DA/7inx8BsnqF/7d3
|
||||
rruNwkAYxdxU1zQ07/+yaxwiJd1gj42BudGfu1JXOme++WyiTfybyW647n++Jzr8YwaguhCI3Afc
|
||||
b7j+D+it4z9O/mQM+Jp/tt7/0bj+x8o/ZgCuIvD5vQCV6x+8/MkY8Okr6u7I1v92/UPMn44B/31J
|
||||
qY9/5X/ofQC2KvhWBO7Y4n97/Rvk/L0BhkYVfFkDvzdHh3+D/hkNjSr4ZdcQ+LHfVOIf0/sfBgZM
|
||||
SwjgG3/q/GMGICsCPgT89qcS/2T4b39KbDEAVwg4dOPPgf/2Z4URf78A9vhH8PnfSgYg/rI5xPFP
|
||||
jH/sSgjdGqAQ/xb98f+DAUbXQK34N/T4ewMihwFdA1nxT6n+AY+DGgLw8SfLP14FNQSA40+u/oEN
|
||||
0BCAjD9p/tF3Q94ADYHH+Mfin2L9AxcBDYHU+NNd/8A1oE0gtv2px//TgM5qCJSMv+1Y8I/fCYm+
|
||||
GHTR8ae//l+KQHQNSC2DU3T827Fh9MSLgMg9EE9/JusfeB4UuAfi6c8q/mFrQNgemISNf/INsaw9
|
||||
kMBvGY5/ECB+KSRGgcTyZ3H5U9gFJVSBxPLnGv+gDwkIUCCJ34xDw/tJhsDMVwGXWv7Mxx92IGSr
|
||||
QBq/6YZGwpMMAY4KpPHLGH9gE+CmAAA//+3//oYwqQCjOgjBL2f8nyGQ3ANc7gWSzZ/dm596IcDg
|
||||
gjg9/HLKX34ZpF4GHAS/lZb+mXuArgJA/OPQiH2GAbIHaDoAou/Tv29kPz1LBRwQfysdP3gPUHIA
|
||||
SF9m9+evQAb+QdnnVQH8DoDpiz36bToAVgCvA3D62v3K2yBWBzLoK/4KCiwOoJHA5dBX/LUUsDOK
|
||||
IPDwM+gr/qoKXB0EeaOv+A9RIASBuwZ+Hn3FDz4RtNailqAAvj/3K354CozZCoR1cEIn8OynueAf
|
||||
p9c+2beDxpZIsFjgDmRfAt8axX9GGfhjgauJvpi9rv59m6DUgVWD3WmwkC9HH4Zf8e/aBN0OBVYP
|
||||
HiLkqODcCn7e+ctNp9m/fxO0ex14ihBUWG349Dz+dN7PXYe/bgz0XRUF/gjx9lP/0eGvaEDxoeCq
|
||||
R2s/tkZ4Ln2N/qNWAX4HFvo6/FIdUPqUjgVKn3If6AwqCYzv/Er/7CsiLEHgR187/1VBcLUEC3wd
|
||||
/Utb4XUSKHxUEhiFL16Cc6LAtAofsQVHZoFR9iQseISBqY9e2RPToIIHgbyfekVP1AO/FYII3gS4
|
||||
C8vfbQN4Jc9HhD6osLiw2vDigwnMF+ieesDeD0LA/wO925C+jjTE3wAAAABJRU5ErkJggg==
|
||||
"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 237 B After Width: | Height: | Size: 13 KiB |
@@ -1 +1,63 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" fill="#51B13E"/><text x="32" y="44" font-family="sans-serif" font-size="28" font-weight="bold" fill="white" text-anchor="middle">PAY</text></svg>
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xml:space="preserve"
|
||||
width="1024"
|
||||
height="1024"
|
||||
version="1.1"
|
||||
id="svg7"
|
||||
sodipodi:docname="BTCPay Server Icon_Version2 (1) (1).svg"
|
||||
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||
id="namedview7"
|
||||
pagecolor="#505050"
|
||||
bordercolor="#ffffff"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="1"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="0.360203"
|
||||
inkscape:cx="512.21117"
|
||||
inkscape:cy="512.21117"
|
||||
inkscape:window-width="2346"
|
||||
inkscape:window-height="1355"
|
||||
inkscape:window-x="26"
|
||||
inkscape:window-y="23"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="svg7" />
|
||||
<defs
|
||||
id="defs1">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</defs>
|
||||
<path
|
||||
d="M 395.38615,1007.3428 A 57.385688,57.385688 0 0 1 337.99353,949.95705 V 86.24698 a 57.39955,57.39955 0 0 1 114.78524,0 v 863.71007 a 57.385688,57.385688 0 0 1 -57.39262,57.38575"
|
||||
style="fill:#cedc21;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:6.93064"
|
||||
id="path2" /><path
|
||||
d="M 395.42245,1007.3558 A 57.39955,57.39955 0 0 1 370.7771,898.10115 L 716.85161,733.85888 361.3445,471.95004 a 57.385688,57.385688 0 0 1 -12.15634,-80.2568 57.385688,57.385688 0 0 1 80.236,-12.15634 l 432.75601,318.83017 a 57.371827,57.371827 0 0 1 -9.43953,98.04775 L 419.99156,1001.7974 a 57.177769,57.177769 0 0 1 -24.56911,5.5584"
|
||||
style="fill:#51b13e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:6.93064"
|
||||
id="path3" /><path
|
||||
d="M 395.43261,667.85437 A 57.385688,57.385688 0 0 1 361.34773,564.25518 L 716.84791,302.33942 370.78033,138.09021 A 57.385688,57.385688 0 0 1 343.53599,61.638322 c 13.59791,-28.6374 47.83527,-40.842254 76.45187,-27.237411 L 852.74387,239.77654 a 57.385688,57.385688 0 0 1 9.4326,98.05468 l -432.74908,318.8371 a 57.177769,57.177769 0 0 1 -33.99478,11.18605"
|
||||
style="fill:#cedc21;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:6.93064"
|
||||
id="path4" /><path
|
||||
d="M 452.77605,396.7388 V 639.46363 L 617.42723,518.15666 Z"
|
||||
style="fill:#1e7a44;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:6.93064"
|
||||
id="path5" /><path
|
||||
d="M 452.77896,574.75277 H 337.99334 V 294.96882 h 114.78562 z"
|
||||
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:6.93065"
|
||||
id="path6" /><path
|
||||
d="M 395.38615,28.855342 A 57.385688,57.385688 0 0 0 337.99353,86.24796 V 808.52449 H 452.77877 V 86.24796 A 57.385688,57.385688 0 0 0 395.38615,28.855342"
|
||||
style="fill:#cedc21;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:6.93064"
|
||||
id="path7" />
|
||||
|
||||
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 237 B After Width: | Height: | Size: 2.9 KiB |
@@ -1 +1,93 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" fill="#1F2937"/><path d="M20 18h24v6H20z M20 28h24v6H20z M20 38h24v6H20z" fill="#22D3EE"/><circle cx="42" cy="21" r="2" fill="#4ADE80"/><circle cx="42" cy="31" r="2" fill="#4ADE80"/><circle cx="42" cy="41" r="2" fill="#FBBF24"/></svg>
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="96.767509"
|
||||
height="92.983162"
|
||||
viewBox="0 0 96.767509 92.983162"
|
||||
version="1.1"
|
||||
xml:space="preserve"
|
||||
style="clip-rule:evenodd;fill-rule:evenodd;stroke-linecap:round;stroke-linejoin:round"
|
||||
id="svg19"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs19" />
|
||||
<g
|
||||
transform="translate(-51.987986,-84.712078)"
|
||||
id="g19">
|
||||
<g
|
||||
id="Icon"
|
||||
transform="matrix(0.99999699,0,0,1.0000031,-3876.6653,-1644.4859)">
|
||||
<g
|
||||
id="g3">
|
||||
<g
|
||||
transform="matrix(-0.191794,-0.715786,0.715786,-0.191794,4329.14,4673.64)"
|
||||
id="g1">
|
||||
<path
|
||||
d="m 3901.56,610.734 c -8.03,-0.473 -15.5,-2.634 -22.36,-5.857 -6.96,-3.269 -13.16,-7.784 -18.4,-13.244 -2.09,-2.176 -4.04,-4.484 -5.83,-6.924 -1.77,-2.428 -3.4,-4.976 -4.84,-7.643 -4.24,-7.842 -6.92,-16.685 -7.24,-26.198 -0.32,-7.547 0.75,-14.813 3.05,-21.561 2.43,-7.104 6.14,-13.611 10.89,-19.258 l -1.04,-0.954 c -5.4,5.445 -9.77,11.886 -12.89,19.03 -3.06,7 -4.87,14.656 -5.22,22.743 -0.34,10.523 1.83,20.557 6.11,29.438 1.48,3.074 3.24,5.998 5.22,8.743 2,2.757 4.23,5.341 6.68,7.693 6.06,5.826 13.31,10.448 21.34,13.358 7.63,2.767 15.93,3.959 24.53,3.452 z"
|
||||
style="fill:#1f88c0"
|
||||
id="path1" />
|
||||
</g>
|
||||
<g
|
||||
transform="matrix(-0.191794,-0.715786,0.715786,-0.191794,4329.14,4673.64)"
|
||||
id="g2">
|
||||
<path
|
||||
d="m 3875.69,496.573 c 3.93,-2.035 8.11,-3.676 12.51,-4.787 4.29,-1.082 8.76,-1.662 13.36,-1.754 2.26,0.098 4.47,0.3 6.65,0.656 8.92,1.459 16.98,5.126 24.1,9.995 3.82,2.611 7.28,5.652 10.5,8.936 4.28,4.361 8.08,9.197 11.04,14.613 4.35,7.965 7.11,16.954 7.47,26.636 0.29,7.88 -0.86,15.477 -3.44,22.454 -1.79,4.847 -4.18,9.431 -7.22,13.516 -3.44,4.623 -7.7,8.589 -12.39,11.931 -4.61,3.286 -9.74,5.85 -15.18,7.709 -0.72,0.243 -1.49,0.327 -2.16,0.689 -0.51,0.281 -0.79,0.687 -1.24,1.057 l 0.68,2.165 c 0.61,0.043 1.1,0.184 1.7,0.085 0.79,-0.13 1.48,-0.591 2.21,-0.908 7.71,-3.35 14.54,-8.211 20.29,-14.138 2.45,-2.525 4.68,-5.254 6.74,-8.109 2.28,-3.151 4.35,-6.466 6.12,-9.971 4.04,-8.008 6.58,-16.926 6.93,-26.48 0.38,-10.357 -1.7,-20.24 -5.88,-29 -2.91,-6.093 -6.76,-11.705 -11.53,-16.39 -3.58,-3.516 -7.69,-6.488 -12.11,-8.916 -7.96,-4.37 -16.97,-6.802 -26.47,-7.333 -2.25,-0.125 -4.51,-0.175 -6.81,-0.075 -4.69,-0.094 -9.26,0.365 -13.67,1.243 -4.59,0.912 -9,2.286 -13.18,4.128 z"
|
||||
style="fill:#1f88c0"
|
||||
id="path2" />
|
||||
</g>
|
||||
</g>
|
||||
<g
|
||||
id="g6">
|
||||
<g
|
||||
transform="matrix(-3.37109,-0.514565,0.514565,-3.37109,4078.07,1806.88)"
|
||||
id="g4">
|
||||
<path
|
||||
d="m 22,12 c 0,-1.097 -0.903,-2 -2,-2 -0.579,0 -1.103,0.251 -1.47,0.649 C 18.202,11.006 18,11.481 18,12 c 0,1.097 0.903,2 2,2 1.097,0 2,-0.903 2,-2 z"
|
||||
style="fill:none;fill-rule:nonzero;stroke:#1f88c0;stroke-width:1.05px"
|
||||
id="path3" />
|
||||
</g>
|
||||
<g
|
||||
transform="matrix(-5.33921,-5.26159,-3.12106,-6.96393,4073.87,1861.55)"
|
||||
id="g5">
|
||||
<path
|
||||
d="m 10.315,5.333 c 0,0 -0.567,0.588 -1.285,1.34 C 7.768,7.995 6.054,9.805 6.054,9.805 L 6.237,9.86 c 0,0 1.808,-1.783 3.123,-3.089 0.747,-0.743 1.329,-1.327 1.329,-1.327 z"
|
||||
style="fill:#1f88c0"
|
||||
id="path4" />
|
||||
</g>
|
||||
</g>
|
||||
<g
|
||||
id="Padlock"
|
||||
transform="matrix(3.11426,0,0,3.11426,3938.31,1737.25)">
|
||||
<g
|
||||
id="g7">
|
||||
<path
|
||||
d="m 9.876,21 h 8.286 C 18.625,21 19,20.625 19,20.162 V 11.838 C 19,11.375 18.625,11 18.162,11 H 5.838 C 5.375,11 5,11.375 5,11.838 v 4.92"
|
||||
style="fill:none;stroke:#22b638;stroke-width:1.89px;stroke-linecap:butt;stroke-linejoin:miter"
|
||||
id="path6" />
|
||||
<path
|
||||
d="M 8,11 V 7 c 0,-2.194 1.806,-4 4,-4 2.194,0 4,1.806 4,4 v 4"
|
||||
style="fill:none;fill-rule:nonzero;stroke:#22b638;stroke-width:1.89px"
|
||||
id="path7" />
|
||||
</g>
|
||||
</g>
|
||||
<g
|
||||
id="g10">
|
||||
<g
|
||||
transform="matrix(5.30977,0.697415,-0.697415,5.30977,3852.72,1727.97)"
|
||||
id="g8">
|
||||
<path
|
||||
d="M 22,12 C 22,11.659 21.913,11.337 21.76,11.055 21.421,10.429 20.756,10 20,10 c -1.097,0 -2,0.903 -2,2 0,1.097 0.903,2 2,2 1.097,0 2,-0.903 2,-2 z"
|
||||
style="fill:none;fill-rule:nonzero;stroke:#1f88c0;stroke-width:0.98px"
|
||||
id="path8" />
|
||||
</g>
|
||||
<g
|
||||
transform="matrix(4.93114,2.49604,1.11018,5.44847,3921.41,1726.72)"
|
||||
id="g9">
|
||||
<path
|
||||
d="m 8.902,6.77 c 0,0 -1.667,1.483 -2.875,2.596 -0.684,0.63 -1.208,1.136 -1.208,1.136 l 0.701,0.662 c 0,0 0.501,-0.527 1.126,-1.213 C 7.749,8.739 9.219,7.068 9.219,7.068 Z"
|
||||
style="fill:#1f88c0"
|
||||
id="path9" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 326 B After Width: | Height: | Size: 5.1 KiB |
@@ -1 +1,72 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" fill="#3B82F6"/><text x="32" y="44" font-family="sans-serif" font-size="36" fill="white" text-anchor="middle">⚡</text></svg>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 24.1.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 85.3 85.3" style="enable-background:new 0 0 85.3 85.3;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#083766;}
|
||||
.st1{fill:#FFFFFF;}
|
||||
.st2{fill:none;stroke:#1250A9;stroke-width:1.9869;stroke-miterlimit:10;}
|
||||
.st3{fill:none;stroke:#1859D1;stroke-width:1.9869;stroke-miterlimit:10;}
|
||||
.st4{fill:none;stroke:#1853FF;stroke-width:1.9869;stroke-miterlimit:10;}
|
||||
</style>
|
||||
<g>
|
||||
<path class="st0" d="M84.7,41.6L84.7,41.6l-3.6-2.2c0-0.4-0.1-0.7-0.1-1l3.1-2.9c0.3-0.3,0.4-0.7,0.4-1.1c-0.1-0.4-0.4-0.8-0.8-0.9
|
||||
L79.8,32c-0.1-0.3-0.2-0.7-0.3-1l2.5-3.4c0.3-0.3,0.3-0.8,0.1-1.2c-0.2-0.4-0.5-0.7-0.9-0.8L77,24.9c-0.2-0.3-0.3-0.6-0.5-0.9
|
||||
l1.8-3.8c0.2-0.4,0.1-0.8-0.1-1.2c-0.2-0.4-0.6-0.6-1.1-0.5l-4.2,0.1c-0.2-0.3-0.4-0.5-0.7-0.8l1-4.1c0.1-0.4,0-0.9-0.3-1.2
|
||||
c-0.3-0.3-0.7-0.4-1.2-0.3l-4.1,1c-0.3-0.2-0.5-0.4-0.8-0.7l0.2-4.2c0-0.4-0.2-0.8-0.6-1.1C66,7,65.5,6.9,65.2,7.1l-3.8,1.7
|
||||
c-0.3-0.2-0.6-0.3-0.9-0.5l-0.7-4.2c-0.1-0.4-0.4-0.8-0.8-0.9c-0.4-0.2-0.9-0.1-1.2,0.1l-3.4,2.5c-0.3-0.1-0.7-0.2-1-0.3l-1.5-3.9
|
||||
c-0.1-0.4-0.5-0.7-0.9-0.8c-0.4-0.1-0.9,0.1-1.1,0.4L47,4.3c-0.3,0-0.7-0.1-1-0.1l-2.2-3.6C43.5,0.2,43.1,0,42.7,0
|
||||
c-0.4,0-0.8,0.2-1.1,0.6l-2.2,3.6c-0.3,0-0.7,0.1-1,0.1l-2.9-3.1c-0.3-0.3-0.7-0.4-1.1-0.4c-0.4,0.1-0.8,0.4-0.9,0.8L32,5.5
|
||||
c-0.3,0.1-0.7,0.2-1,0.3l-3.4-2.5c-0.3-0.3-0.8-0.3-1.2-0.1c-0.4,0.2-0.7,0.5-0.7,0.9l-0.7,4.2c-0.3,0.2-0.6,0.3-0.9,0.5l-3.8-1.7
|
||||
C19.8,6.9,19.3,7,19,7.2c-0.4,0.2-0.6,0.6-0.6,1.1l0.1,4.2c-0.3,0.2-0.5,0.4-0.8,0.7l-4.1-1c-0.4-0.1-0.9,0-1.2,0.3
|
||||
c-0.3,0.3-0.4,0.7-0.3,1.2l1,4.1c-0.2,0.3-0.4,0.5-0.7,0.8l-4.2-0.1c-0.4,0-0.8,0.2-1.1,0.5c-0.2,0.4-0.3,0.8-0.1,1.2L8.9,24
|
||||
c-0.2,0.3-0.3,0.6-0.5,0.9l-4.1,0.7c-0.4,0.1-0.8,0.4-0.9,0.8c-0.2,0.4-0.1,0.8,0.1,1.2l2.5,3.4c-0.1,0.3-0.2,0.7-0.3,1l-3.9,1.5
|
||||
c-0.4,0.1-0.7,0.5-0.8,0.9c-0.1,0.4,0.1,0.9,0.4,1.1l3.1,2.9c0,0.3-0.1,0.7-0.1,1l-3.6,2.2h0C0.2,41.8,0,42.2,0,42.7
|
||||
c0,0.4,0.2,0.8,0.6,1l3.6,2.2c0,0.3,0.1,0.7,0.1,1l-3.1,2.9c-0.3,0.3-0.5,0.7-0.4,1.1c0.1,0.4,0.4,0.8,0.8,0.9l3.9,1.5
|
||||
c0.1,0.3,0.2,0.7,0.3,1l-2.5,3.4c-0.2,0.3-0.3,0.8-0.1,1.2c0.1,0.4,0.5,0.7,0.9,0.7l4.1,0.7c0.2,0.3,0.3,0.6,0.5,0.9l-1.7,3.8
|
||||
c-0.2,0.4-0.1,0.8,0.1,1.2c0.2,0.4,0.6,0.6,1.1,0.5l4.2-0.1c0.2,0.3,0.4,0.5,0.7,0.8l-1,4.1c-0.1,0.4,0,0.8,0.3,1.1
|
||||
c0.3,0.3,0.7,0.4,1.2,0.3l4.1-1c0.3,0.2,0.5,0.4,0.8,0.7L18.4,77c0,0.4,0.2,0.8,0.6,1.1c0.3,0.2,0.8,0.3,1.2,0.1l3.8-1.7
|
||||
c0.3,0.2,0.6,0.3,0.9,0.5l0.7,4.1c0.1,0.4,0.4,0.8,0.7,0.9c0.4,0.2,0.8,0.1,1.2-0.1l3.4-2.5c0.3,0.1,0.7,0.2,1,0.3l1.5,3.9
|
||||
c0.1,0.4,0.5,0.7,0.9,0.8c0.4,0.1,0.9-0.1,1.1-0.4l2.9-3.1c0.4,0,0.7,0.1,1,0.1l2.2,3.6c0.2,0.4,0.6,0.6,1.1,0.6
|
||||
c0.4,0,0.8-0.2,1.1-0.6l2.2-3.6c0.3,0,0.7-0.1,1-0.1l2.9,3.1c0.3,0.3,0.7,0.5,1.1,0.4c0.4-0.1,0.8-0.4,0.9-0.8l1.5-3.9
|
||||
c0.3-0.1,0.7-0.2,1-0.3l3.4,2.5c0.4,0.2,0.8,0.3,1.2,0.1c0.4-0.2,0.7-0.5,0.7-0.9l0.7-4.1c0.3-0.2,0.6-0.3,0.9-0.5l3.8,1.7
|
||||
c0.4,0.2,0.8,0.1,1.2-0.1c0.4-0.2,0.6-0.6,0.5-1.1l-0.2-4.2c0.3-0.2,0.5-0.4,0.8-0.7l4.1,1c0.4,0.1,0.9,0,1.2-0.3
|
||||
c0.3-0.3,0.4-0.7,0.3-1.1l-1-4.1c0.2-0.3,0.4-0.5,0.7-0.8l4.2,0.1c0.4,0,0.8-0.2,1.1-0.5c0.2-0.4,0.3-0.8,0.1-1.2l-1.7-3.8
|
||||
c0.2-0.3,0.3-0.6,0.5-0.9l4.1-0.7c0.4-0.1,0.8-0.3,0.9-0.7c0.2-0.4,0.1-0.9-0.1-1.2l-2.4-3.4c0.1-0.3,0.2-0.7,0.3-1l3.9-1.5
|
||||
c0.4-0.2,0.7-0.5,0.8-0.9c0.1-0.4-0.1-0.8-0.4-1.1L81.1,47c0-0.3,0.1-0.7,0.1-1l3.6-2.2c0.4-0.2,0.6-0.6,0.6-1
|
||||
C85.3,42.2,85.1,41.8,84.7,41.6z M62.4,66.6c-1.1,0.9-2.4,1.8-3.6,2.6c-4.7,2.9-10.3,4.5-16.3,4.5c-3.4,0-6.7-0.5-9.8-1.6
|
||||
c-0.1,0-0.2-0.1-0.4-0.1c-1.1-0.4-2.1-0.8-3.2-1.2v0C28.1,70.3,27,69.7,26,69c-1.2-0.8-2.4-1.6-3.5-2.5c-0.9-0.7-1.7-1.5-2.5-2.3
|
||||
l-0.1,0l-0.1,0c-1-1.1-2-2.2-2.9-3.4c0,0,0.1,0,0.1,0c-3.4-4.6-5.5-10.1-6-16.1h0c0-0.2,0-0.3,0-0.5c0-0.2,0-0.3,0-0.4
|
||||
c0-0.1,0-0.3,0-0.4c0-0.3,0-0.5,0-0.8v-0.1l0,0c0-0.8,0-1.6,0.1-2.4c0-0.2,0-0.4,0.1-0.7h0l0.2-0.1c0.1-1.2,0.3-2.4,0.6-3.5
|
||||
c0.4-1.6,0.8-3.2,1.4-4.8c0.5-1.3,1.1-2.6,1.8-3.9L15,27c2.3-4,5.5-7.5,9.2-10.1c0.1-0.1,0.3-0.2,0.4-0.3c4.4-3.1,9.6-5,15.3-5.4
|
||||
c0.8-0.1,1.7-0.1,2.5-0.1c0.8,0,1.5,0,2.2,0.1c12.3,0.9,22.6,8.8,26.9,19.8c0.6,1.6,1.1,3.2,1.5,4.8c0.5,2.2,0.7,4.4,0.7,6.7
|
||||
C73.9,52.1,69.4,60.9,62.4,66.6z"/>
|
||||
<circle class="st1" cx="42.7" cy="42.7" r="33.5"/>
|
||||
<g>
|
||||
<ellipse class="st2" cx="42.7" cy="42.8" rx="27.2" ry="13.6"/>
|
||||
|
||||
<ellipse transform="matrix(0.8957 -0.4447 0.4447 0.8957 -14.4551 23.4064)" class="st3" cx="42.7" cy="42.5" rx="13.6" ry="27.2"/>
|
||||
<path class="st4" d="M30.6,67.1c6.7,3.3,17.6-4.9,24.2-18.3s6.6-27-0.1-30.4"/>
|
||||
|
||||
<ellipse transform="matrix(0.4447 -0.8957 0.8957 0.4447 -14.6426 61.9687)" class="st4" cx="42.7" cy="42.8" rx="27.2" ry="13.6"/>
|
||||
<path class="st3" d="M30.6,18.2c6.7-3.3,17.6,4.9,24.2,18.3s6.6,27-0.1,30.4S37.2,62,30.5,48.6"/>
|
||||
<path class="st2" d="M69.8,42.8c0,7.5-12.2,13.6-27.2,13.6s-27.2-6.1-27.2-13.6"/>
|
||||
</g>
|
||||
<g id="Layer_x0020_1_3_">
|
||||
<g id="_1421344023328_3_">
|
||||
<path class="st0" d="M50.1,40.8L50.1,40.8c0.3-2.2-1.3-3.3-3.6-4.1l0.7-2.9l-1.8-0.4l-0.7,2.8c-0.5-0.1-0.9-0.2-1.4-0.3l0.7-2.9
|
||||
l-1.8-0.4l-0.7,2.9c-0.4-0.1-0.8-0.2-1.1-0.3v0L38,34.6l-0.5,1.9c0,0,1.3,0.3,1.3,0.3c0.7,0.2,0.8,0.6,0.8,1l-0.8,3.3
|
||||
c0,0,0.1,0,0.2,0.1c-0.1,0-0.1,0-0.2,0l-1.2,4.7c-0.1,0.2-0.3,0.5-0.8,0.4c-0.1,0-1.3-0.3-1.3-0.3l-0.9,2l2.3,0.6
|
||||
c0.4,0.1,0.8,0.2,1.3,0.3l-0.7,3l1.8,0.4l0.7-2.9c0.5,0.1,0.9,0.3,1.4,0.4l-0.7,2.9l1.8,0.4l0.7-2.9c3,0.6,5.3,0.3,6.3-2.4
|
||||
c0.8-2.2,0-3.5-1.6-4.3C49,43.1,49.9,42.4,50.1,40.8z M46.1,46.5c-0.5,2.2-4.3,1-5.5,0.7l1-3.9C42.8,43.6,46.6,44.2,46.1,46.5z
|
||||
M46.6,40.8c-0.5,2-3.6,1-4.6,0.7l0.9-3.5C43.9,38.2,47.2,38.7,46.6,40.8z"/>
|
||||
<path class="st0" d="M50.1,40.8L50.1,40.8c0.3-2.2-1.3-3.3-3.6-4.1l0.7-2.9l-1.8-0.4l-0.7,2.8c-0.5-0.1-0.9-0.2-1.4-0.3l0.7-2.9
|
||||
l-1.8-0.4l-0.7,2.9c-0.4-0.1-0.8-0.2-1.1-0.3v0L38,34.6l-0.5,1.9c0,0,1.3,0.3,1.3,0.3c0.7,0.2,0.8,0.6,0.8,1l-0.8,3.3
|
||||
c0,0,0.1,0,0.2,0.1c-0.1,0-0.1,0-0.2,0l-1.2,4.7c-0.1,0.2-0.3,0.5-0.8,0.4c-0.1,0-1.3-0.3-1.3-0.3l-0.9,2l2.3,0.6
|
||||
c0.4,0.1,0.8,0.2,1.3,0.3l-0.7,3l1.8,0.4l0.7-2.9c0.5,0.1,0.9,0.3,1.4,0.4l-0.7,2.9l1.8,0.4l0.7-2.9c3,0.6,5.3,0.3,6.3-2.4
|
||||
c0.8-2.2,0-3.5-1.6-4.3C49,43.1,49.9,42.4,50.1,40.8z M46.1,46.5c-0.5,2.2-4.3,1-5.5,0.7l1-3.9C42.8,43.6,46.6,44.2,46.1,46.5z
|
||||
M46.6,40.8c-0.5,2-3.6,1-4.6,0.7l0.9-3.5C43.9,38.2,47.2,38.7,46.6,40.8z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 218 B After Width: | Height: | Size: 6.3 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Logo Mark">
|
||||
<rect width="47.86" height="48" rx="23.93" fill="#0DBD8B"/>
|
||||
<g id="Union">
|
||||
<path d="M21.3075 9.42871C20.3396 9.42871 19.5549 10.214 19.5549 11.1828C19.5549 12.1516 20.3396 12.9369 21.3075 12.9369C25.9321 12.9369 29.6811 16.689 29.6811 21.3175C29.6811 22.2863 30.4657 23.0716 31.4337 23.0716C32.4016 23.0716 33.1863 22.2863 33.1863 21.3175C33.1863 14.7515 27.868 9.42871 21.3075 9.42871Z" fill="white"/>
|
||||
<path d="M38.4591 21.3174C38.4591 20.3486 37.6745 19.5633 36.7065 19.5633C35.7386 19.5633 34.9539 20.3486 34.9539 21.3174C34.9539 25.9459 31.2049 29.698 26.5804 29.698C25.6124 29.698 24.8277 30.4833 24.8277 31.4521C24.8277 32.4209 25.6124 33.2062 26.5804 33.2062C33.1408 33.2062 38.4591 27.8834 38.4591 21.3174Z" fill="white"/>
|
||||
<path d="M28.3329 36.8173C28.3329 37.786 27.5482 38.5714 26.5803 38.5714C20.0198 38.5714 14.7015 33.2486 14.7015 26.6826C14.7015 25.7138 15.4862 24.9285 16.4541 24.9285C17.4221 24.9285 18.2067 25.7138 18.2067 26.6826C18.2067 31.3111 21.9557 35.0632 26.5803 35.0632C27.5482 35.0632 28.3329 35.8485 28.3329 36.8173Z" fill="white"/>
|
||||
<path d="M9.40112 26.6827C9.40112 27.6514 10.1858 28.4368 11.1537 28.4368C12.1217 28.4368 12.9064 27.6514 12.9064 26.6827C12.9064 22.0542 16.6553 18.3021 21.2799 18.3021C22.2478 18.3021 23.0325 17.5167 23.0325 16.548C23.0325 15.5792 22.2478 14.7939 21.2799 14.7939C14.7194 14.7939 9.40112 20.1167 9.40112 26.6827Z" fill="white"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -1 +1,23 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" fill="#8B5CF6"/><text x="32" y="44" font-family="sans-serif" font-size="28" font-weight="bold" fill="white" text-anchor="middle">N</text></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
|
||||
<!-- Background circle -->
|
||||
<circle cx="128" cy="128" r="120" fill="#5B21B6"/>
|
||||
<!-- Outer signal arc (top-left) -->
|
||||
<path d="M60 128 A68 68 0 0 1 128 60" stroke="#A78BFA" stroke-width="14" stroke-linecap="round" fill="none"/>
|
||||
<!-- Outer signal arc (top-right) -->
|
||||
<path d="M128 60 A68 68 0 0 1 196 128" stroke="#A78BFA" stroke-width="14" stroke-linecap="round" fill="none"/>
|
||||
<!-- Middle signal arc (top-left) -->
|
||||
<path d="M82 128 A46 46 0 0 1 128 82" stroke="#C4B5FD" stroke-width="12" stroke-linecap="round" fill="none"/>
|
||||
<!-- Middle signal arc (top-right) -->
|
||||
<path d="M128 82 A46 46 0 0 1 174 128" stroke="#C4B5FD" stroke-width="12" stroke-linecap="round" fill="none"/>
|
||||
<!-- Inner signal arc (top-left) -->
|
||||
<path d="M104 128 A24 24 0 0 1 128 104" stroke="#EDE9FE" stroke-width="10" stroke-linecap="round" fill="none"/>
|
||||
<!-- Inner signal arc (top-right) -->
|
||||
<path d="M128 104 A24 24 0 0 1 152 128" stroke="#EDE9FE" stroke-width="10" stroke-linecap="round" fill="none"/>
|
||||
<!-- Center antenna dot -->
|
||||
<circle cx="128" cy="128" r="14" fill="#F5F3FF"/>
|
||||
<!-- Antenna mast -->
|
||||
<rect x="122" y="128" width="12" height="52" rx="4" fill="#DDD6FE"/>
|
||||
<!-- Base platform -->
|
||||
<rect x="96" y="176" width="64" height="12" rx="6" fill="#7C3AED"/>
|
||||
<rect x="108" y="188" width="40" height="10" rx="5" fill="#6D28D9"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 235 B After Width: | Height: | Size: 1.4 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" fill="#0DBD8B"/><text x="32" y="44" font-family="sans-serif" font-size="28" fill="white" text-anchor="middle">EC</text></svg>
|
||||
|
Before Width: | Height: | Size: 217 B |
@@ -1 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" fill="#7B3FE4"/><polygon points="36,8 20,36 30,36 28,56 44,28 34,28" fill="white"/></svg>
|
||||
<svg width="280px" height="280px" style="background-color:#ffffff00" version="1.1" viewBox="0 0 280 280" xmlns="http://www.w3.org/2000/svg">
|
||||
<path id="Ellipse" d="m7 140c0-74 60-134 134-134s134 60 134 134-60 134-134 134-134-60-134-134z" fill="#7e1af7"/>
|
||||
<path d="m161 52c-8 21-16 43-25 65 0 0 0 3 3 3h65s0 2 2 3l-96 106c-2-2-2-3-2-5l33-72v-6h-67v-6l81-89h5z" fill="#fff"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 181 B After Width: | Height: | Size: 382 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" fill="#2D3348"/><rect x="14" y="38" width="8" height="12" rx="1" fill="#5E35B1"/><rect x="24" y="30" width="8" height="20" rx="1" fill="#7C4DFF"/><rect x="34" y="22" width="8" height="28" rx="1" fill="#B388FF"/><rect x="44" y="14" width="8" height="36" rx="1" fill="#E1BEE7"/></svg>
|
||||
<?xml version="1.0" encoding="utf-8" standalone="yes"?><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.2" baseProfile="tiny-ps" viewBox="0 0 241 241" xml:space="preserve"><title>Mempool Holdings S.A. de C.V.</title><g><g><path fill="#2E3349" d="M241.37,211.23c0,16.56-13.43,29.99-29.99,29.99H30.36c-16.56,0-29.99-13.43-29.99-29.99V30.21 c0-16.56,13.43-29.99,29.99-29.99h181.02c16.56,0,29.99,13.43,29.99,29.99V211.23z"></path><linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="120.8689" y1="68.6556" x2="120.8689" y2="301.1491"><stop offset="0" stop-color="#AE61FF"></stop><stop offset="1" stop-color="#13EFD8"></stop></linearGradient><path fill="url(#SVGID_1_)" d="M0.32,120.99v90.24c0,16.56,13.49,29.99,30.14,29.99h180.82c16.64,0,30.13-13.43,30.13-29.99 v-90.24H0.32z"></path></g><g><path fill="#FFFFFF" fill-opacity="0.3" d="M212.72,209c0,3.7-2.53,6.7-5.65,6.7h-31.24c-3.12,0-5.65-3-5.65-6.7V32.44c0-3.7,2.53-6.7,5.65-6.7h31.24 c3.12,0,5.65,3,5.65,6.7V209z"></path></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 374 B After Width: | Height: | Size: 1.0 KiB |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" fill="#0082C9"/><ellipse cx="32" cy="34" rx="18" ry="12" fill="white" opacity="0.9"/><ellipse cx="22" cy="36" rx="10" ry="8" fill="white"/><ellipse cx="42" cy="36" rx="10" ry="8" fill="white"/></svg>
|
||||
<svg width="32" height="32" version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg"><rect width="32" height="32" rx="5" ry="5" fill="#0082c9"/><g transform="matrix(.12 0 0 .12 .64 8.32)" fill="none" stroke="#fff" stroke-width="22"><circle cx="40" cy="64" r="26"/><circle cx="216" cy="64" r="26"/><circle cx="128" cy="64" r="46"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 291 B After Width: | Height: | Size: 353 B |
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
|
||||
<!-- Shield -->
|
||||
<path d="M128 20L32 60v68c0 55.2 40.8 106.8 96 120 55.2-13.2 96-64.8 96-120V60L128 20z" fill="#1565C0"/>
|
||||
<path d="M128 32L44 68v60c0 49.6 36 95.6 84 108V32z" fill="#1E88E5"/>
|
||||
<!-- Key head (circle) -->
|
||||
<circle cx="128" cy="108" r="32" fill="#FFC107"/>
|
||||
<circle cx="128" cy="108" r="16" fill="#1565C0"/>
|
||||
<!-- Key shaft -->
|
||||
<rect x="122" y="136" width="12" height="56" rx="2" fill="#FFC107"/>
|
||||
<!-- Key teeth -->
|
||||
<rect x="134" y="160" width="16" height="8" rx="2" fill="#FFC107"/>
|
||||
<rect x="134" y="176" width="12" height="8" rx="2" fill="#FFC107"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 677 B |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
|
||||
<!-- Monitor body -->
|
||||
<rect x="28" y="40" width="200" height="140" rx="12" fill="#1A73E8"/>
|
||||
<!-- Screen -->
|
||||
<rect x="40" y="52" width="176" height="108" rx="4" fill="#E8F0FE"/>
|
||||
<!-- Screen content - window bars -->
|
||||
<rect x="52" y="64" width="100" height="12" rx="2" fill="#4285F4"/>
|
||||
<rect x="52" y="84" width="152" height="8" rx="2" fill="#DADCE0"/>
|
||||
<rect x="52" y="100" width="152" height="8" rx="2" fill="#DADCE0"/>
|
||||
<rect x="52" y="116" width="120" height="8" rx="2" fill="#DADCE0"/>
|
||||
<!-- Cursor arrow -->
|
||||
<path d="M168 88l24 56-12 4-8-18-14 16-6-6 14-16-18-8z" fill="#34A853"/>
|
||||
<!-- Stand -->
|
||||
<rect x="108" y="180" width="40" height="20" fill="#1A73E8"/>
|
||||
<!-- Base -->
|
||||
<rect x="80" y="200" width="96" height="12" rx="6" fill="#1A73E8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 864 B |
@@ -1 +1,22 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" fill="#16A34A"/><text x="32" y="43" font-family="sans-serif" font-size="32" font-weight="bold" fill="white" text-anchor="middle">RTL</text></svg>
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="610px" height="524px" viewBox="0 0 610 524" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 63.1 (92452) - https://sketch.com -->
|
||||
<title>RTL-Logo-Single</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
<polygon id="path-1" points="0.451852397 0.2573 52.6803 0.2573 52.6803 52 0.451852397 52"></polygon>
|
||||
</defs>
|
||||
<g id="RTL-Logo-Single" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="BY_-_RTL_logo_wht" transform="translate(58.000000, 54.000000)">
|
||||
<g id="Group-3" transform="translate(0.000000, 0.737000)" fill="#FFFFFE">
|
||||
<path d="M360.6201,52.8608 C362.5721,57.4638 363.3541,61.1008 363.2471,63.1018 C355.6871,58.5468 344.3161,54.6748 338.1131,53.5648 C334.0751,52.8408 347.2931,49.6338 360.6201,52.8608 M371.6051,222.1978 C373.9431,221.7028 376.2931,221.2298 378.6451,220.7688 C381.1041,220.2858 383.5691,219.8228 386.0431,219.3858 C384.0591,218.1358 382.0681,216.8978 380.0641,215.6788 C354.6091,200.1908 327.4751,187.0398 300.6651,174.3118 C283.7451,166.2788 266.5131,158.2938 248.9661,151.0968 C264.2721,130.9078 283.8701,113.7738 304.8171,99.0968 C304.8351,99.0848 304.9201,99.0258 305.0521,98.9328 C309.6371,105.1898 315.4331,109.4478 318.3391,111.0478 C324.0201,114.1778 331.1751,117.1028 337.7531,117.9968 C350.6891,119.7558 361.3701,119.4848 373.1871,117.4478 C374.2521,117.2648 375.3251,117.1348 376.3931,116.9568 C383.9711,115.6898 388.8201,115.4978 393.2591,115.4148 C399.1381,115.3048 402.0501,116.5498 402.7421,116.7298 C404.8421,117.2728 406.6271,118.1718 407.6191,119.3778 C408.7711,120.8458 410.0021,122.2308 411.3211,123.4988 C415.2771,127.4358 419.2051,129.6158 424.9041,130.2408 C432.2391,131.0448 437.4311,128.1758 441.1021,123.4128 C442.8361,121.1638 442.0011,117.6318 441.8111,116.9388 C440.8481,113.3958 438.6251,108.2418 437.0161,103.4438 C436.5991,102.2038 434.5781,98.3918 432.1461,95.8048 C431.3431,94.9488 430.5351,94.0948 429.7281,93.2418 C425.9511,89.2468 422.1441,85.2818 418.3701,81.2898 C403.3111,65.3718 386.9861,50.7158 370.1561,36.7028 C368.4121,35.2508 366.6651,33.7948 364.9151,32.3418 C363.6461,31.2868 362.3721,30.2378 361.1001,29.1868 C360.4531,28.6528 359.8071,28.1198 359.1581,27.5868 C357.7891,26.4608 356.4161,25.3428 355.0401,24.2258 C354.4971,23.7858 353.9521,23.3488 353.4081,22.9098 C352.5511,22.2198 350.5951,20.5728 348.9481,19.2538 C354.4071,13.8118 358.8121,10.4238 366.6311,5.3008 C367.7521,4.5668 373.7921,1.3448 373.5791,0.5268 C373.4511,0.0328 363.5341,0.1448 352.1711,1.6088 C348.1791,2.1228 325.8041,5.4268 310.4411,8.7748 C294.0871,12.3378 276.8181,17.3188 260.9121,22.3258 C214.9671,36.7898 171.0961,56.3918 130.9221,83.0318 C107.8341,98.3418 86.2221,115.2368 65.3621,133.4398 C55.6411,141.9208 45.9791,150.5378 36.5451,159.3928 C35.0671,160.7798 33.6011,162.1788 32.1351,163.5788 C34.1441,163.9548 36.1521,164.3478 38.1561,164.7598 C54.7691,168.1718 71.2001,172.7318 87.1751,177.2978 C106.7131,182.8818 126.4791,188.9018 145.5921,196.2918 C134.7011,206.6528 124.0761,217.2688 113.9611,228.5028 C94.9141,249.6538 77.1581,271.9988 60.5891,295.1368 C38.9991,325.2848 20.5681,357.7988 4.5021,391.3058 C2.9801,394.4758 1.4761,397.6528 0.0001,400.8388 C2.9721,398.4598 5.9621,396.1018 8.9741,393.7648 C34.9841,373.5938 62.4081,354.9428 89.7891,336.9238 C128.3171,311.5688 169.6651,290.6218 212.5711,273.7668 C264.2991,253.4468 317.1251,233.7338 371.6051,222.1978" id="Fill-1"></path>
|
||||
</g>
|
||||
<g id="Group-24" transform="translate(172.000000, 352.737000)">
|
||||
<mask id="mask-2" fill="white">
|
||||
<use xlink:href="#path-1"></use>
|
||||
</mask>
|
||||
<g id="Clip-23"></g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 237 B After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,111 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Created with Vectornator (http://vectornator.io/) -->
|
||||
<svg stroke-miterlimit="10" style="fill-rule:nonzero;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;" version="1.1" viewBox="8.789 8.791 32.422 32.418" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="Untitled">
|
||||
<path d="M38.8519 23.6353L38.3818 24.2217L41.2105 25.0446L41.2105 24.8132L39.3844 23.4968L38.8519 23.6353Z" fill="#d3d3d3" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M37.9843 25.2648L38.7188 25.2376L40.119 25.1551L41.2106 25.0446L38.3819 24.2217L37.5381 23.9764L37.9843 25.2648Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M38.8519 23.6353L38.3818 24.2217L37.5381 23.9764L38.0403 23.846L38.8519 23.6353Z" fill="#ababab" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M37.9843 25.2648L33.5662 25.2648L36.0929 24.3388L37.9843 25.2648Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M38.0403 23.846L37.145 23.3355L36.8724 23.18L38.7803 21.9829L39.3844 23.4968L38.8519 23.6353L38.0403 23.846Z" fill="#9a9a9a" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M37.0092 23.5521L36.7613 23.8298L36.3913 23.3554L36.3959 23.3517L37.0092 23.5521Z" fill="#ffffff" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M36.8724 23.1801L37.1451 23.3355L37.0092 23.5521L36.3959 23.3517L36.6823 23.1455L36.8724 23.1801Z" fill="#ffffff" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M38.7803 21.9829L36.8724 23.18L36.2058 20.8919L38.7665 21.949L38.7803 21.9829Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M36.7613 23.8298L37.0092 23.5521L37.1451 23.3355L38.0403 23.846L37.5381 23.9764L36.7172 23.8792L36.7613 23.8298Z" fill="#454545" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M36.3913 23.3554L36.7613 23.8298L36.7172 23.8792L35.9653 23.6626L36.3913 23.3554Z" fill="#808080" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M36.7172 23.8792L37.5381 23.9764L36.103 24.3351L36.069 24.3278L36.0681 24.3271L33.4046 23.7318L35.9653 23.6626L36.7172 23.8792Z" fill="#d3d3d3" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M36.3958 23.3517L36.3913 23.3554L36.1535 23.0497L36.6823 23.1455L36.3958 23.3517Z" fill="#efefef" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M36.1535 23.0497L36.3913 23.3554L35.9653 23.6626L35.9139 23.2419L36.1535 23.0497Z" fill="#d4d4d4" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M36.2058 20.8919L36.8724 23.1801L36.6823 23.1454L36.1535 23.0497L35.6779 22.9627L36.1342 20.8956L36.2058 20.8919Z" fill="#d8d8d8" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M36.1342 20.8956L35.6779 22.9627L34.4972 20.9745L36.1342 20.8956Z" fill="#bcbcbc" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M36.103 24.3351L37.5381 23.9764L37.9843 25.2648L36.0929 24.3388L36.103 24.3351Z" fill="#d8d8d8" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M36.0928 24.3388L33.5661 25.2648L33.5092 25.2648L33.3183 23.734L33.4046 23.7318L36.0681 24.3271L36.069 24.3278L36.0928 24.3388Z" fill="#484848" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M33.5092 25.2648L35.7366 27.2015L35.3005 27.5949L35.2785 27.6664L35.2638 27.6745L31.5591 26.1025L33.5092 25.2648Z" fill="#d3d3d3" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M34.4972 20.9745L35.6779 22.9627L33.3202 23.7046L34.4972 20.9745Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M35.2748 27.6789L34.8397 29.0874L34.0712 31.1582L31.3848 29.8823L35.2639 27.6745L35.2748 27.6789Z" fill="#595959" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M35.2638 27.6745L31.3847 29.8823L31.5591 26.1025L35.2638 27.6745Z" fill="#9a9a9a" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M33.2366 23.6942L31.0303 23.4143L32.3322 22.3697L32.705 22.0706L34.4972 20.9744L33.3202 23.7045L33.2366 23.6942Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M34.0712 31.1582L33.9959 31.2459L33.995 31.2459L29.0407 31.307L29.0398 31.307L29.0306 31.2916L29.0003 31.2399L31.3848 29.8823L34.0712 31.1582Z" fill="#d3d3d3" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M33.9949 31.2459L32.3276 33.1745L30.6869 33.9126L29.0407 31.307L33.9949 31.2459Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M33.5661 25.2648L37.9842 25.2648L37.112 25.8822L36.5152 26.4988L35.7366 27.2015L33.5092 25.2648L33.5661 25.2648Z" fill="#484848" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M33.3183 23.734L33.5092 25.2648L31.5591 26.1024L31.0303 23.4143L33.2366 23.6942L33.3155 23.7119L33.3183 23.734Z" fill="#bcbcbc" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M33.3183 23.734L33.4046 23.7318L33.3156 23.712L33.3183 23.734Z" fill="#3a3a3a" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M35.6779 22.9627L36.1535 23.0497L35.9139 23.2419L35.9653 23.6626L33.4046 23.7318L33.3156 23.7119L33.2366 23.6942L33.3202 23.7046L35.6779 22.9627Z" fill="#484848" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<g opacity="1">
|
||||
<path d="M34.0372 36.0387C34.0712 36.1212 34.0023 36.4077 34.0023 36.4077L33.5773 36.8313L33.6938 36.4622L33.8655 36.1594L33.6598 35.7359L32.671 35.3941L32.9253 35.7904L33.1998 36.4077L32.8904 36.6L32.4305 37.0795L32.5682 36.628L32.8904 36.2972C32.8904 36.2972 32.705 35.9834 32.671 35.8729C32.6361 35.7631 32.0394 35.3661 32.0394 35.3661L31.8677 35.1186L32.8106 35.0124L32.8115 35.0124L34.14 35.3116L34.14 35.8729L33.9683 35.5038L33.3706 35.3388L33.8655 35.6526C33.8655 35.6526 34.0023 35.9554 34.0372 36.0387" fill="#484848" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M32.8106 35.0124L31.8677 35.1186L30.2958 34.3251L32.4994 34.9425L32.8106 35.0124Z" fill="#7f7f7f" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M31.9016 15.918L32.4305 17.3559L30.3894 21.9646L29.6981 23.5248L29.9561 20.9605L30.0994 19.5358L30.2352 18.1869L30.2958 18.2348L30.2728 18.1354L31.9016 15.918Z" fill="#585858" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M32.4305 17.3559L31.9016 20.8647L31.0303 23.4143L30.3894 21.9645L32.4305 17.3559Z" fill="#545454" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M31.9016 20.8647L32.3322 22.3697L31.0303 23.4143L31.9016 20.8647Z" fill="#454545" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M31.9016 15.918L30.2728 18.1354L29.89 16.4808L29.89 16.4801L29.935 14.4225C30.3986 14.7769 30.6943 15.0111 30.7219 15.0539C30.8724 15.2851 31.9016 15.918 31.9016 15.918" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M31.8677 35.1186L30.8935 34.9145L29.5613 34.4467L30.2958 34.3251L31.8677 35.1186Z" fill="#d3d3d3" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M31.5592 26.1024L31.3848 29.8823L27.8912 29.3739L27.1521 28.1289L31.5142 26.123L31.5592 26.1024Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M31.4894 26.0891L31.5141 26.123L27.1521 28.1289L29.3363 26.07L29.3896 26.0199L31.4894 26.0891Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M31.3848 29.8823L29.0003 31.2399L27.8912 29.374L31.3848 29.8823Z" fill="#9a9a9a" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M30.7898 36.0659L30.9275 35.5428L31.8677 35.1186L32.0394 35.3661L31.1331 35.6806L31.0303 36.0107L31.202 36.4077L30.7898 36.0659Z" fill="#7c7c7c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M30.3894 21.9645L31.0303 23.4143L29.6981 23.5248L30.3894 21.9645Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M29.5282 24.895L29.6981 23.5248L31.0303 23.4143L29.3987 25.9462L29.5282 24.895Z" fill="#454545" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M30.6869 33.9126L30.2958 34.3251L29.5613 34.4467L28.6496 34.0754L30.6869 33.9126Z" fill="#7c7c7c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M30.159 37.7234L29.7669 37.162L29.2179 36.804L30.7181 37.0648L30.9615 37.2445C30.9615 37.2445 31.236 37.5584 31.2709 37.6961C31.3048 37.8339 31.3737 38.1477 31.3737 38.2582C31.3737 38.368 30.8247 38.7098 30.8247 38.7098L31.0992 38.396L30.9275 37.7234L30.2269 37.327C30.2269 37.327 30.5703 38.038 30.6043 38.1477C30.6392 38.2582 30.4675 38.5168 30.3298 38.5448C30.1976 38.5713 29.7936 38.8564 29.7679 38.874L30.2958 38.2582L30.159 37.7234Z" fill="#484848" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M30.7181 37.0648L29.2179 36.804L29.127 36.1167L29.1353 36.1211L29.5952 36.3252L30.3298 36.7768L30.7181 37.0648Z" fill="#7c7c7c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M30.2352 18.1869L30.0994 19.5358L22.4568 15.5216L20.4039 14.2052L26.5673 16.175L30.181 18.1449L30.1828 18.1456L30.2352 18.1869Z" fill="#9c9c9c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M30.181 18.1449L26.5673 16.175L22.5045 13.9599L20.9189 12.4372L20.2533 11.7653L20.2533 11.3137L20.6444 11.0935L22.353 11.9966L30.181 18.1449Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M30.0993 19.5358L29.9561 20.9605L23.1912 17.2491L23.1224 17.2344L20.9189 15.918L20.816 15.2741L21.1383 15.0811L22.4567 15.5216L30.0993 19.5358Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M29.935 14.4225L29.89 16.4801L29.89 16.4808L29.8881 16.5773L22.4788 10.1284L29.935 14.4225Z" fill="#9c9c9c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M29.935 14.4225L22.4788 10.1284L21.6874 9.07799L21.9417 8.79141L22.5596 8.95646C22.5596 8.95646 27.8866 12.8571 29.935 14.4225" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M22.5247 10.1904L22.4789 10.1284L29.8882 16.5773L29.89 16.4808L30.2728 18.1353L30.2352 18.1869L30.1829 18.1457L30.181 18.1449L22.3531 11.9966L21.0218 10.5042L20.884 9.97009L21.3789 9.63932L22.5247 10.1904Z" fill="#585858" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M29.6871 23.5197L23.9258 19.2617L23.1564 18.6171L22.7598 18.069L22.7607 18.069L29.9561 20.9605L29.6981 23.5248L29.6871 23.5197Z" fill="#545454" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M29.6981 23.5248L29.5282 24.895L26.0605 21.9771L29.6871 23.5197L29.6981 23.5248Z" fill="#454545" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M29.6871 23.5197L26.0605 21.9771L24.1452 20.3851L23.5485 19.658L23.5135 19.1792L23.9258 19.2617L29.6871 23.5197Z" fill="#3a3a3a" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M31.4894 26.0891L29.3896 26.0199L29.3987 25.9462L31.0303 23.4143L31.5592 26.1024L31.5141 26.123L31.4894 26.0891Z" fill="#595959" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M29.3896 26.0199L29.3364 26.07L29.2399 26.067L29.3896 26.0199Z" fill="#a3a3a3" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M29.3896 26.0199L29.2399 26.067L23.1637 25.8792L24.8265 22.8566L29.3896 26.0199Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M29.3363 26.07L27.152 28.129L24.237 29.1773L24.0626 29.0491L22.7451 28.9386L22.1823 28.6955L25.8198 27.1434L29.2399 26.0671L29.3363 26.07Z" fill="#595959" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M29.2399 26.0671L25.8199 27.1434L23.1637 25.88L23.1637 25.8792L29.2399 26.0671Z" fill="#c8c8c8" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M28.9985 36.4902L28.118 35.5996L29.127 36.1167L29.2179 36.804L28.8268 37.2998L28.7579 37.7786L29.1353 38.3407L28.5862 38.01L28.4357 37.3661L28.6551 36.997L28.9985 36.4902Z" fill="#454545" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M29.1261 36.1108L29.127 36.1167L28.118 35.5996L28.0234 34.832L29.1261 36.1108Z" fill="#484848" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M28.6147 34.0769L28.5743 34.071L29.0398 31.307L29.0407 31.307L30.6869 33.9126L28.6496 34.0754L28.6211 34.0776L28.6156 34.0769L28.6147 34.0769Z" fill="#484848" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M29.0398 31.307L28.5743 34.071L22.8819 33.268L29.0398 31.307Z" fill="#a2a2a2" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M29.0398 31.307L22.8819 33.268L26.2331 30.0856L29.0398 31.307Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M29.0398 31.307L26.2331 30.0856L27.8912 29.374L29.0003 31.2399L28.9984 31.2407L29.0306 31.2916L29.0398 31.307Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M28.6211 34.0776L28.6496 34.0754L29.5613 34.4467L28.0234 34.832L28.6211 34.0776Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M28.6211 34.0776L28.0234 34.832L28.118 35.5996L28.0234 35.5038L27.2889 34.9425L28.6211 34.0776Z" fill="#d3d3d3" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M28.6211 34.0776L27.2889 34.9425L26.8639 35.0361L25.9411 34.0864L28.5735 34.0769L28.6147 34.0769L28.6156 34.0769L28.6211 34.0776Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M28.5743 34.071L28.5734 34.0769L25.9411 34.0864L25.9383 34.0864L23.2858 34.0953L23.2849 34.0953L22.8819 33.268L28.5743 34.071Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M27.1521 28.129L27.8912 29.374L26.2331 30.0856L27.1521 28.129Z" fill="#484848" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M26.0862 35.1288L26.8639 35.036C26.8639 35.036 24.7972 38.9285 24.3619 39.7816L26.0862 35.1288Z" fill="#595959" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M22.5045 13.9599L26.5673 16.175L20.4039 14.2052L20.1156 13.6711L20.2533 13.2858L20.7472 13.2858L22.5596 14.0129L22.5045 13.9599Z" fill="#7c7c7c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M26.2331 30.0856L22.8819 33.268L23.5135 31.1309L24.2232 29.2148L24.2407 29.2192L26.2331 30.0856Z" fill="#484848" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M26.0862 35.1288L24.3619 39.7816C24.3142 39.8759 24.2867 39.9326 24.283 39.9437C24.2481 40.0535 23.5485 40.5603 23.5485 40.5603L22.848 40.5876L26.0862 35.1288Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M25.9383 34.0864L25.9411 34.0864L26.8638 35.0361L26.0861 35.1288L24.5565 35.3116L25.9383 34.0864Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M25.9383 34.0864L24.5565 35.3116L24.1076 35.0559L23.6164 34.7767L23.2849 34.0961L23.2858 34.0953L25.9383 34.0864Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M25.8199 27.1434L22.1823 28.6955L22.1814 28.6955L22.0445 28.6358L21.5368 28.1784L23.1637 25.88L25.8199 27.1434Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M24.4198 22.5745L24.8265 22.8566L21.0998 22.6128L19.7033 22.5214L19.6308 22.506L16.4063 19.8805L22.6845 21.415L24.4198 22.5745Z" fill="#bcbcbc" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M24.5565 35.3116L22.69 40.7334C22.5403 40.8661 22.3227 41.0399 22.2162 41.0399C22.0445 41.0399 21.1732 41.1769 21.1732 41.1769L20.8014 41.1865L24.5565 35.3116Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M24.5565 35.3116L20.8014 41.1865L19.986 41.2086L19.3121 41.1496L19.1809 40.9906L24.1076 35.0559L24.5565 35.3116Z" fill="#595959" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M24.0552 20.3608L24.4197 22.5745L22.6844 21.415L19.0744 19.0024L24.0552 20.3608Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M24.2407 29.2192L24.2233 29.2148L24.237 29.1772L27.1521 28.1289L26.2331 30.0856L24.2407 29.2192Z" fill="#8d8d8d" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M24.1452 20.3852L26.0605 21.9771L29.5283 24.895L29.3988 25.9463L29.3896 26.02L24.8265 22.8567L24.4198 22.5745L24.0552 20.3608L24.1452 20.3852Z" fill="#595959" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M24.1076 35.0559L19.1809 40.9905L18.5777 41.0399L17.8092 40.8078L17.5347 40.2848L24.1076 35.0559Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M24.1076 35.0559L17.5347 40.2848L23.2849 34.0961L23.6164 34.7767L24.1076 35.0559Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M23.2858 34.0953L23.2849 34.0961L23.2849 34.0953L23.2858 34.0953Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M23.2849 34.0953L23.2849 34.0961L16.3603 39.3853L19.0036 36.8313L22.8819 33.268L23.2849 34.0953Z" fill="#9a9a9a" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M23.2849 34.0961L17.5346 40.2848L16.869 40.3673L16.2373 40.0535L16.3062 39.4369L16.3585 39.3861L16.3603 39.3853L23.2849 34.0961Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M24.8265 22.8567L23.1637 25.8792L23.1628 25.8792L23.138 25.8674L21.0998 22.6128L24.8265 22.8567Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M23.1628 25.8792L23.1637 25.8792L23.1637 25.88L21.5368 28.1784L21.4817 28.129L20.4039 28.157L19.9788 27.9368L19.5187 27.5677L19.1754 27.1434L19.2699 27.1132L23.1628 25.8792Z" fill="#9a9a9a" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M23.138 25.8674L23.1628 25.8792L19.2698 27.1132L20.3441 24.5385L23.138 25.8674Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M22.8479 40.5876C22.8479 40.5876 22.7818 40.6524 22.69 40.7334L24.5565 35.3116L26.0861 35.1288L22.8479 40.5876Z" fill="#9a9a9a" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M22.7102 18.0005L22.6762 17.2071L23.1224 17.2344L23.1913 17.2491L29.9561 20.9605L22.7606 18.0691L22.7598 18.0691L22.7102 18.0005Z" fill="#454545" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M19.0744 19.0024L22.6844 21.415L16.4063 19.8805L11.2674 17.3287L19.0734 19.0024L19.0744 19.0024Z" fill="#d3d3d3" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M20.3442 24.5385L20.256 24.5385L14.6995 24.5385C14.6995 24.5385 12.7365 23.6626 12.7016 23.5794C12.6676 23.4969 12.3591 22.9628 12.3591 22.9628L12.4271 22.6048L17.9763 23.6118L18.0745 23.6295L20.3442 24.5385Z" fill="#d3d3d3" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M20.3442 24.5385L19.2699 27.1132L19.1754 27.1434L18.3381 27.0881L17.6724 26.8958L17.2464 26.526L17.0885 26.0104L20.256 24.5385L20.3442 24.5385Z" fill="#9a9a9a" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M20.256 24.5385L17.0884 26.0104L17.0747 25.9647L15.2971 25.7717L14.8712 25.2376L14.6995 24.5385L20.256 24.5385Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M16.4063 19.8805L19.6307 22.506L19.5114 22.4802L11.24 19.1166L10.0381 18.3305L9.06393 17.3007L8.82343 16.673L8.99512 16.3703L9.86642 16.7666L11.2675 17.3287L16.4063 19.8805Z" fill="#484848" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M17.9763 23.6117L12.4271 22.6047L10.8552 22.2356L19.2993 22.4353L19.5114 22.4802L19.6307 22.506L19.7033 22.5214L17.9763 23.6117Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M17.9763 23.6117L19.7033 22.5214L21.0998 22.6128L23.1381 25.8674L20.3442 24.5385L18.0745 23.6294L17.9763 23.6117Z" fill="#9a9a9a" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M19.0735 19.0024L11.2675 17.3287L9.45504 15.5216L9.38617 15.0538L9.62673 14.8226L12.4959 16.5625C12.4959 16.5625 16.2373 18.0278 16.443 18.1383C16.6487 18.248 18.1664 18.7549 18.1664 18.7549L19.0735 19.0024Z" fill="#9a9a9a" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M19.2993 22.4353L11.463 20.7601L11.542 20.7704L9.59278 19.9608L8.99511 19.2617L8.78939 18.6724L9.06398 18.4411L11.3354 19.1792L11.24 19.1166L19.5114 22.4802L19.2993 22.4353Z" fill="#9a9a9a" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M11.4621 20.7601L11.4631 20.7601L19.2993 22.4353L10.8552 22.2357L10.073 21.5918L9.79847 20.8367L9.90129 20.5502L11.4621 20.7601Z" fill="#484848" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 19 KiB |
@@ -1 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" fill="#0DBD8B"/><text x="32" y="44" font-family="sans-serif" font-size="30" font-weight="bold" fill="white" text-anchor="middle">[M]</text></svg>
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#ffffff" d="M 30,2.0000001 V 30 h -1 -2 v 2 h 5 V -3.3333334e-8 L 27,0 v 2 z"/>
|
||||
<path fill="#ffffff" d="M 9.9515939,10.594002 V 12.138 h 0.043994 c 0.3845141,-0.563728 0.8932271,-1.031728 1.4869981,-1.368 0.580003,-0.322998 1.244999,-0.485 1.993002,-0.485 0.72,0 1.376999,0.139993 1.971998,0.42 0.595,0.279004 1.047001,0.771001 1.355002,1.477001 0.338003,-0.500001 0.795999,-0.941 1.376999,-1.323001 0.579999,-0.382998 1.265998,-0.574 2.059998,-0.574 0.602003,0 1.160002,0.074 1.674002,0.220006 0.514,0.148006 0.953998,0.382998 1.321999,0.706998 0.36601,0.322999 0.653001,0.746 0.859,1.268002 0.205001,0.521998 0.307994,1.15 0.307994,1.887001 v 7.632997 h -3.127 v -6.463997 c 0,-0.383002 -0.01512,-0.743002 -0.04399,-1.082003 -0.02079,-0.3072 -0.103219,-0.607113 -0.242003,-0.881998 -0.133153,-0.25081 -0.335962,-0.457777 -0.584001,-0.596002 -0.257008,-0.146003 -0.605998,-0.220006 -1.046997,-0.220006 -0.440002,0 -0.796003,0.085 -1.068,0.253002 -0.272013,0.170003 -0.485001,0.390002 -0.639001,0.662003 -0.159119,0.287282 -0.263585,0.601602 -0.307994,0.926997 -0.05197,0.346923 -0.07801,0.697217 -0.07801,1.048002 v 6.353999 h -3.128005 v -6.398 c 0,-0.338003 -0.0072,-0.673001 -0.02116,-1.004001 -0.01134,-0.313663 -0.07487,-0.623229 -0.187994,-0.915999 -0.107943,-0.276623 -0.300435,-0.512126 -0.550001,-0.673001 -0.25799,-0.168 -0.636,-0.253002 -1.134999,-0.253002 -0.198123,0.0083 -0.394383,0.04195 -0.584002,0.100006 -0.258368,0.07446 -0.498455,0.201827 -0.704999,0.373985 -0.227981,0.183987 -0.421999,0.449 -0.583997,0.794003 -0.161008,0.345978 -0.242003,0.797998 -0.242003,1.356998 v 6.618999 H 6.99942 V 10.590001 Z"/>
|
||||
<path fill="#ffffff" d="M 2,2.0000001 V 30 h 3 v 2 H 0 V 9.2650922e-8 L 5,0 v 2 z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 237 B After Width: | Height: | Size: 1.8 KiB |
@@ -1 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" fill="#7D4698"/><text x="32" y="44" font-family="sans-serif" font-size="30" font-weight="bold" fill="white" text-anchor="middle">TOR</text></svg>
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="512px" height="512px" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<defs>
|
||||
<linearGradient x1="50%" y1="100%" x2="50%" y2="0%" id="linearGradient-1">
|
||||
<stop stop-color="#420C5D" offset="0%"></stop>
|
||||
<stop stop-color="#951AD1" offset="100%"></stop>
|
||||
</linearGradient>
|
||||
<path d="M25,29 C152.577777,29 256,131.974508 256,259 C256,386.025492 152.577777,489 25,489 L25,29 Z" id="path-2"></path>
|
||||
<filter x="-18.2%" y="-7.4%" width="129.4%" height="114.8%" filterUnits="objectBoundingBox" id="filter-3">
|
||||
<feOffset dx="-8" dy="0" in="SourceAlpha" result="shadowOffsetOuter1"></feOffset>
|
||||
<feGaussianBlur stdDeviation="10" in="shadowOffsetOuter1" result="shadowBlurOuter1"></feGaussianBlur>
|
||||
<feColorMatrix values="0 0 0 0 0.250980392 0 0 0 0 0.250980392 0 0 0 0 0.250980392 0 0 0 0.2 0" type="matrix" in="shadowBlurOuter1"></feColorMatrix>
|
||||
</filter>
|
||||
</defs>
|
||||
<g id="tor-browser-icon" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="icon_512x512">
|
||||
<g id="Group">
|
||||
<g id="tb_icon/Stable">
|
||||
<g id="Stable">
|
||||
<circle id="background" fill="#F2E4FF" fill-rule="nonzero" cx="256" cy="256" r="246"></circle>
|
||||
<path d="M256.525143,465.439707 L256.525143,434.406609 C354.826191,434.122748 434.420802,354.364917 434.420802,255.992903 C434.420802,157.627987 354.826191,77.8701558 256.525143,77.5862948 L256.525143,46.5531962 C371.964296,46.8441537 465.446804,140.489882 465.446804,255.992903 C465.446804,371.503022 371.964296,465.155846 256.525143,465.439707 Z M256.525143,356.820314 C311.970283,356.529356 356.8487,311.516106 356.8487,255.992903 C356.8487,200.476798 311.970283,155.463547 256.525143,155.17259 L256.525143,124.146588 C329.115485,124.430449 387.881799,183.338693 387.881799,255.992903 C387.881799,328.654211 329.115485,387.562455 256.525143,387.846316 L256.525143,356.820314 Z M256.525143,201.718689 C286.266674,202.00255 310.3026,226.180407 310.3026,255.992903 C310.3026,285.812497 286.266674,309.990353 256.525143,310.274214 L256.525143,201.718689 Z M0,255.992903 C0,397.384044 114.60886,512 256,512 C397.384044,512 512,397.384044 512,255.992903 C512,114.60886 397.384044,0 256,0 C114.60886,0 0,114.60886 0,255.992903 Z" id="center" fill="url(#linearGradient-1)"></path>
|
||||
<g id="half" transform="translate(140.500000, 259.000000) scale(-1, 1) translate(-140.500000, -259.000000) ">
|
||||
<use fill="black" fill-opacity="1" filter="url(#filter-3)" xlink:href="#path-2"></use>
|
||||
<use fill="url(#linearGradient-1)" fill-rule="evenodd" xlink:href="#path-2"></use>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 237 B After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,236 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="128px"
|
||||
height="128px"
|
||||
viewBox="0 0 128 128"
|
||||
version="1.1"
|
||||
id="svg96"
|
||||
sodipodi:docname="Sovran_SystemsOS_Updater_Iconv3.svg"
|
||||
xml:space="preserve"
|
||||
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||
id="namedview98"
|
||||
pagecolor="#505050"
|
||||
bordercolor="#ffffff"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="1"
|
||||
inkscape:deskcolor="#505050"
|
||||
showgrid="false"
|
||||
inkscape:zoom="5.2149125"
|
||||
inkscape:cx="9.0126153"
|
||||
inkscape:cy="64.430611"
|
||||
inkscape:window-width="3440"
|
||||
inkscape:window-height="1352"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer2" /><defs
|
||||
id="defs67"><linearGradient
|
||||
inkscape:collect="always"
|
||||
id="linearGradient936"><stop
|
||||
style="stop-color:#1e8e11;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop932" /><stop
|
||||
style="stop-color:#1bff00;stop-opacity:0;"
|
||||
offset="1"
|
||||
id="stop934" /></linearGradient><linearGradient
|
||||
id="linearGradient1028"
|
||||
inkscape:swatch="solid"><stop
|
||||
style="stop-color:#000000;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop1026" /></linearGradient><linearGradient
|
||||
id="linearGradient998"
|
||||
inkscape:swatch="solid"><stop
|
||||
style="stop-color:#000000;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop996" /></linearGradient><radialGradient
|
||||
id="radial0"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
cx="131.914749"
|
||||
cy="55.927143"
|
||||
fx="131.914749"
|
||||
fy="55.927143"
|
||||
r="160"
|
||||
gradientTransform="matrix(0.232034,-0.541475,-0.368794,-0.0298398,4.277749,118.95849)"><stop
|
||||
offset="0"
|
||||
style="stop-color:#00ff39;stop-opacity:1;"
|
||||
id="stop2" /><stop
|
||||
offset="1"
|
||||
style="stop-color:#004a19;stop-opacity:1;"
|
||||
id="stop4" /></radialGradient><radialGradient
|
||||
id="radial1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
cx="525.587769"
|
||||
cy="638.591797"
|
||||
fx="525.587769"
|
||||
fy="638.591797"
|
||||
r="192"
|
||||
gradientTransform="matrix(-0.107656,-0.225172,-0.327748,0.258343,373.87973,30.205086)"><stop
|
||||
offset="0"
|
||||
style="stop-color:#43b60b;stop-opacity:1;"
|
||||
id="stop7" /><stop
|
||||
offset="1"
|
||||
style="stop-color:#0b88ff;stop-opacity:0.00829875;"
|
||||
id="stop9" /></radialGradient><clipPath
|
||||
id="clip1"><path
|
||||
d="M 7 46 L 57 46 L 57 93 L 7 93 Z M 7 46 "
|
||||
id="path12" /></clipPath><clipPath
|
||||
id="clip2"><path
|
||||
d="M 32.25 46.957031 C 19.6875 46.96875 9.085938 56.636719 7.503906 69.53125 C 9.0625 82.445312 19.667969 92.144531 32.25 92.160156 C 44.816406 92.148438 55.414062 82.480469 57 69.585938 C 55.441406 56.671875 44.835938 46.972656 32.25 46.957031 Z M 32.25 46.957031 "
|
||||
id="path15" /></clipPath><radialGradient
|
||||
id="radial2"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
cx="131.914749"
|
||||
cy="55.927143"
|
||||
fx="131.914749"
|
||||
fy="55.927143"
|
||||
r="160"
|
||||
gradientTransform="matrix(0.485163,-1.148584,-0.771115,-0.0632965,-47.124961,203.98857)"><stop
|
||||
offset="0"
|
||||
style="stop-color:rgb(92.941177%,20%,23.137255%);stop-opacity:1;"
|
||||
id="stop18" /><stop
|
||||
offset="1"
|
||||
style="stop-color:rgb(63.921571%,27.843139%,72.941178%);stop-opacity:1;"
|
||||
id="stop20" /></radialGradient><radialGradient
|
||||
id="radial3"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
cx="525.587769"
|
||||
cy="638.591797"
|
||||
fx="525.587769"
|
||||
fy="638.591797"
|
||||
r="192"
|
||||
gradientTransform="matrix(-0.225099,-0.477638,-0.685291,0.548001,725.67923,15.723794)"><stop
|
||||
offset="0"
|
||||
style="stop-color:rgb(10.980392%,44.313726%,84.705883%);stop-opacity:1;"
|
||||
id="stop23" /><stop
|
||||
offset="1"
|
||||
style="stop-color:rgb(20.784314%,51.764709%,89.411765%);stop-opacity:0.00829876;"
|
||||
id="stop25" /></radialGradient><linearGradient
|
||||
id="linear0"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="22"
|
||||
y1="37"
|
||||
x2="62"
|
||||
y2="37"
|
||||
gradientTransform="matrix(1.4,0,0,1.4,-26.799973,2.491745)"><stop
|
||||
offset="0"
|
||||
style="stop-color:rgb(58.039218%,57.647061%,56.470591%);stop-opacity:1;"
|
||||
id="stop28" /><stop
|
||||
offset="0.0908155"
|
||||
style="stop-color:rgb(87.058824%,86.666667%,85.490197%);stop-opacity:1;"
|
||||
id="stop30" /><stop
|
||||
offset="0.336093"
|
||||
style="stop-color:rgb(60.392159%,60.000002%,58.823532%);stop-opacity:1;"
|
||||
id="stop32" /><stop
|
||||
offset="0.844326"
|
||||
style="stop-color:rgb(76.47059%,75.294119%,72.941178%);stop-opacity:1;"
|
||||
id="stop34" /><stop
|
||||
offset="0.930505"
|
||||
style="stop-color:rgb(87.058824%,86.666667%,85.490197%);stop-opacity:1;"
|
||||
id="stop36" /><stop
|
||||
offset="1"
|
||||
style="stop-color:rgb(75.294119%,74.901962%,73.725492%);stop-opacity:1;"
|
||||
id="stop38" /></linearGradient><radialGradient
|
||||
id="radial4"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
cx="-172.560638"
|
||||
cy="28.569126"
|
||||
fx="-172.560638"
|
||||
fy="28.569126"
|
||||
r="15.85742"
|
||||
gradientTransform="matrix(1.560712,0,0,1.4252,300.69366,13.349996)"><stop
|
||||
offset="0"
|
||||
style="stop-color:rgb(100%,100%,100%);stop-opacity:0.358268;"
|
||||
id="stop41" /><stop
|
||||
offset="1"
|
||||
style="stop-color:rgb(100%,100%,100%);stop-opacity:0.0944882;"
|
||||
id="stop43" /></radialGradient><filter
|
||||
id="alpha"
|
||||
filterUnits="objectBoundingBox"
|
||||
x="0"
|
||||
y="0"
|
||||
width="1"
|
||||
height="1"><feColorMatrix
|
||||
type="matrix"
|
||||
in="SourceGraphic"
|
||||
values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"
|
||||
id="feColorMatrix46" /></filter><mask
|
||||
id="mask0"><g
|
||||
filter="url(#alpha)"
|
||||
id="g51"><rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="128"
|
||||
height="128"
|
||||
style="fill:rgb(0%,0%,0%);fill-opacity:0.1;stroke:none;"
|
||||
id="rect49" /></g></mask><clipPath
|
||||
id="clip3"><rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="192"
|
||||
height="152"
|
||||
id="rect54" /></clipPath><g
|
||||
id="surface382"
|
||||
clip-path="url(#clip3)"><path
|
||||
style=" stroke:none;fill-rule:nonzero;fill:rgb(27.058825%,21.176471%,21.568628%);fill-opacity:1;"
|
||||
d="M 40 59.957031 C 26.191406 59.957031 15 71.152344 15 84.957031 C 15.011719 85.996094 15.085938 86.777344 15.222656 87.804688 C 15.222656 75.957031 27.421875 65.96875 40 65.957031 C 52.597656 65.972656 64.777344 75.957031 64.777344 87.859375 C 64.917969 86.816406 64.992188 86.011719 65 84.957031 C 65 71.152344 53.808594 59.957031 40 59.957031 Z M 40 59.957031 "
|
||||
id="path57" /></g><radialGradient
|
||||
id="radial5"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
cx="40"
|
||||
cy="227"
|
||||
fx="40"
|
||||
fy="227"
|
||||
r="28"
|
||||
gradientTransform="matrix(0.575553,0,1.60551e-8,1.540703,8.977913,-280.78108)"><stop
|
||||
offset="0"
|
||||
style="stop-color:rgb(100%,100%,100%);stop-opacity:1;"
|
||||
id="stop60" /><stop
|
||||
offset="0.744626"
|
||||
style="stop-color:rgb(98.039216%,98.039216%,98.039216%);stop-opacity:1;"
|
||||
id="stop62" /><stop
|
||||
offset="1"
|
||||
style="stop-color:rgb(87.450981%,87.450981%,87.450981%);stop-opacity:1;"
|
||||
id="stop64" /></radialGradient>
|
||||
|
||||
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient936"
|
||||
id="linearGradient938"
|
||||
x1="-48.519272"
|
||||
y1="18.511358"
|
||||
x2="287.07454"
|
||||
y2="18.511358"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(1.1020247,0,0,1.1097375,37.198581,-10.424856)" /></defs><path
|
||||
style="fill:#f5f5f3;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 20,11.957031 h 88 c 4.41797,0 8,3.582031 8,8 V 108 c 0,4.41797 -3.58203,8 -8,8 H 20 c -4.417969,0 -8,-3.58203 -8,-8 V 19.957031 c 0,-4.417969 3.582031,-8 8,-8 z m 0,0"
|
||||
id="path69" /><path
|
||||
style="fill:url(#radial0);fill-rule:nonzero;stroke:none"
|
||||
d="m 20,85.957031 h 88 v -66 H 20 Z m 0,0"
|
||||
id="path71" /><path
|
||||
style="fill:none;fill-rule:nonzero;stroke:none;fill-opacity:1"
|
||||
d="m 20,85.957031 h 88 v -66 H 20 Z m 0,0"
|
||||
id="path73" /><g
|
||||
inkscape:groupmode="layer"
|
||||
id="layer2"
|
||||
inkscape:label="Layer 1"
|
||||
transform="matrix(0.1816,0,0,0.1816,35.224187,79.037164)"><ellipse
|
||||
fill="#54c147"
|
||||
cx="168.64549"
|
||||
cy="10.117889"
|
||||
id="circle8314"
|
||||
rx="184.91634"
|
||||
ry="179.91556"
|
||||
style="fill:url(#linearGradient938);fill-opacity:1;stroke-width:1.71591" /><polygon
|
||||
fill="#ffffff"
|
||||
points="46.678,120.299 63.562,96.402 96.977,121.718 145.084,50.79 168.752,69.02 103.583,164.647 "
|
||||
id="polygon8316"
|
||||
transform="matrix(1.7395866,0,0,1.6925423,-18.737581,-172.19767)" /></g></svg>
|
||||
|
After Width: | Height: | Size: 8.4 KiB |
@@ -1 +1,267 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" fill="#175DDC"/><path d="M32 10 L48 20 L48 36 C48 48 32 54 32 54 C32 54 16 48 16 36 L16 20 Z" fill="white" opacity="0.9"/><text x="32" y="40" font-family="sans-serif" font-size="18" font-weight="bold" fill="#175DDC" text-anchor="middle">VW</text></svg>
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
version="1.1"
|
||||
viewBox="0 0 256 256"
|
||||
id="svg383"
|
||||
sodipodi:docname="vaultwarden.svg"
|
||||
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
|
||||
width="256"
|
||||
height="256"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<defs
|
||||
id="defs387">
|
||||
<mask
|
||||
id="holes">
|
||||
<rect
|
||||
x="-60"
|
||||
y="-60"
|
||||
width="120"
|
||||
height="120"
|
||||
fill="#fff"
|
||||
id="rect368" />
|
||||
<circle
|
||||
id="hole"
|
||||
cy="-40"
|
||||
r="3" />
|
||||
<use
|
||||
transform="rotate(72)"
|
||||
xlink:href="#hole"
|
||||
id="use371" />
|
||||
<use
|
||||
transform="rotate(144)"
|
||||
xlink:href="#hole"
|
||||
id="use373" />
|
||||
<use
|
||||
transform="rotate(216)"
|
||||
xlink:href="#hole"
|
||||
id="use375" />
|
||||
<use
|
||||
transform="rotate(-72)"
|
||||
xlink:href="#hole"
|
||||
id="use377" />
|
||||
</mask>
|
||||
</defs>
|
||||
<sodipodi:namedview
|
||||
id="namedview385"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
showgrid="false"
|
||||
inkscape:zoom="3.3359375"
|
||||
inkscape:cx="128.14988"
|
||||
inkscape:cy="127.85012"
|
||||
inkscape:window-width="3440"
|
||||
inkscape:window-height="1363"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="gear" />
|
||||
<title
|
||||
id="title287">Vaultwarden Icon</title>
|
||||
<g
|
||||
id="gear"
|
||||
mask="url(#holes)"
|
||||
transform="matrix(2.4381018,0,0,2.4381018,128,128)">
|
||||
<path
|
||||
d="M -31.1718,-33.813208 -4.675771,40.375675 H 4.6757689 L 31.171799,-33.813208 H 21.404635 L 4.6757689,13.77574 q -1.662496,4.571864 -2.805462,8.624198 -1.142966,3.948427 -1.870308,7.585137 -0.72734199,-3.63671 -1.8703079,-7.689043 -1.142966,-4.052334 -2.805462,-8.728104 L -21.30073,-33.813208 Z"
|
||||
stroke="#ffffff"
|
||||
stroke-width="4.51171"
|
||||
id="path289"
|
||||
style="fill:#ffffff;fill-opacity:1" />
|
||||
<circle
|
||||
transform="scale(-1,1)"
|
||||
r="43"
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
stroke-width="9"
|
||||
id="circle291"
|
||||
cx="0"
|
||||
cy="0" />
|
||||
<g
|
||||
id="cogs"
|
||||
transform="scale(-1,1)">
|
||||
<polygon
|
||||
id="cog"
|
||||
points="46,3 51,0 46,-3 "
|
||||
stroke="#ffffff"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="3" />
|
||||
<use
|
||||
transform="rotate(11.25)"
|
||||
xlink:href="#cog"
|
||||
id="use294" />
|
||||
<use
|
||||
transform="rotate(22.5)"
|
||||
xlink:href="#cog"
|
||||
id="use296" />
|
||||
<use
|
||||
transform="rotate(33.75)"
|
||||
xlink:href="#cog"
|
||||
id="use298" />
|
||||
<use
|
||||
transform="rotate(45)"
|
||||
xlink:href="#cog"
|
||||
id="use300" />
|
||||
<use
|
||||
transform="rotate(56.25)"
|
||||
xlink:href="#cog"
|
||||
id="use302" />
|
||||
<use
|
||||
transform="rotate(67.5)"
|
||||
xlink:href="#cog"
|
||||
id="use304" />
|
||||
<use
|
||||
transform="rotate(78.75)"
|
||||
xlink:href="#cog"
|
||||
id="use306" />
|
||||
<use
|
||||
transform="rotate(90)"
|
||||
xlink:href="#cog"
|
||||
id="use308" />
|
||||
<use
|
||||
transform="rotate(101.25)"
|
||||
xlink:href="#cog"
|
||||
id="use310" />
|
||||
<use
|
||||
transform="rotate(112.5)"
|
||||
xlink:href="#cog"
|
||||
id="use312" />
|
||||
<use
|
||||
transform="rotate(123.75)"
|
||||
xlink:href="#cog"
|
||||
id="use314" />
|
||||
<use
|
||||
transform="rotate(135)"
|
||||
xlink:href="#cog"
|
||||
id="use316" />
|
||||
<use
|
||||
transform="rotate(146.25)"
|
||||
xlink:href="#cog"
|
||||
id="use318" />
|
||||
<use
|
||||
transform="rotate(157.5)"
|
||||
xlink:href="#cog"
|
||||
id="use320" />
|
||||
<use
|
||||
transform="rotate(168.75)"
|
||||
xlink:href="#cog"
|
||||
id="use322" />
|
||||
<use
|
||||
transform="scale(-1)"
|
||||
xlink:href="#cog"
|
||||
id="use324" />
|
||||
<use
|
||||
transform="rotate(-168.75)"
|
||||
xlink:href="#cog"
|
||||
id="use326" />
|
||||
<use
|
||||
transform="rotate(-157.5)"
|
||||
xlink:href="#cog"
|
||||
id="use328" />
|
||||
<use
|
||||
transform="rotate(-146.25)"
|
||||
xlink:href="#cog"
|
||||
id="use330" />
|
||||
<use
|
||||
transform="rotate(-135)"
|
||||
xlink:href="#cog"
|
||||
id="use332" />
|
||||
<use
|
||||
transform="rotate(-123.75)"
|
||||
xlink:href="#cog"
|
||||
id="use334" />
|
||||
<use
|
||||
transform="rotate(-112.5)"
|
||||
xlink:href="#cog"
|
||||
id="use336" />
|
||||
<use
|
||||
transform="rotate(-101.25)"
|
||||
xlink:href="#cog"
|
||||
id="use338" />
|
||||
<use
|
||||
transform="rotate(-90)"
|
||||
xlink:href="#cog"
|
||||
id="use340" />
|
||||
<use
|
||||
transform="rotate(-78.75)"
|
||||
xlink:href="#cog"
|
||||
id="use342" />
|
||||
<use
|
||||
transform="rotate(-67.5)"
|
||||
xlink:href="#cog"
|
||||
id="use344" />
|
||||
<use
|
||||
transform="rotate(-56.25)"
|
||||
xlink:href="#cog"
|
||||
id="use346" />
|
||||
<use
|
||||
transform="rotate(-45)"
|
||||
xlink:href="#cog"
|
||||
id="use348" />
|
||||
<use
|
||||
transform="rotate(-33.75)"
|
||||
xlink:href="#cog"
|
||||
id="use350" />
|
||||
<use
|
||||
transform="rotate(-22.5)"
|
||||
xlink:href="#cog"
|
||||
id="use352" />
|
||||
<use
|
||||
transform="rotate(-11.25)"
|
||||
xlink:href="#cog"
|
||||
id="use354" />
|
||||
</g>
|
||||
<g
|
||||
id="mounts"
|
||||
transform="scale(-1,1)">
|
||||
<polygon
|
||||
id="mount"
|
||||
points="-7,-42 0,-35 7,-42 "
|
||||
stroke="#ffffff"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="6" />
|
||||
<use
|
||||
transform="rotate(72)"
|
||||
xlink:href="#mount"
|
||||
id="use358" />
|
||||
<use
|
||||
transform="rotate(144)"
|
||||
xlink:href="#mount"
|
||||
id="use360" />
|
||||
<use
|
||||
transform="rotate(-144)"
|
||||
xlink:href="#mount"
|
||||
id="use362" />
|
||||
<use
|
||||
transform="rotate(-72)"
|
||||
xlink:href="#mount"
|
||||
id="use364" />
|
||||
</g>
|
||||
</g>
|
||||
<metadata
|
||||
id="metadata381">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:title>Vaultwarden Icon</dc:title>
|
||||
<dc:creator>
|
||||
<cc:Agent>
|
||||
<dc:title>Mathijs van Veluw</dc:title>
|
||||
</cc:Agent>
|
||||
</dc:creator>
|
||||
<dc:relation>Rust Logo</dc:relation>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 344 B After Width: | Height: | Size: 6.7 KiB |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" fill="#21759B"/><text x="32" y="44" font-family="serif" font-size="38" font-weight="bold" fill="white" text-anchor="middle">W</text></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Layer_1" x="0px" y="0px" width="64px" height="64px" viewBox="0 0 64 64" enable-background="new 0 0 64 64" xml:space="preserve"><style>.style0{fill: #0073aa;}</style><g><g><path d="M4.548 31.999c0 10.9 6.3 20.3 15.5 24.706L6.925 20.827C5.402 24.2 4.5 28 4.5 31.999z M50.531 30.614c0-3.394-1.219-5.742-2.264-7.57c-1.391-2.263-2.695-4.177-2.695-6.439c0-2.523 1.912-4.872 4.609-4.872 c0.121 0 0.2 0 0.4 0.022C45.653 7.3 39.1 4.5 32 4.548c-9.591 0-18.027 4.921-22.936 12.4 c0.645 0 1.3 0 1.8 0.033c2.871 0 7.316-0.349 7.316-0.349c1.479-0.086 1.7 2.1 0.2 2.3 c0 0-1.487 0.174-3.142 0.261l9.997 29.735l6.008-18.017l-4.276-11.718c-1.479-0.087-2.879-0.261-2.879-0.261 c-1.48-0.087-1.306-2.349 0.174-2.262c0 0 4.5 0.3 7.2 0.349c2.87 0 7.317-0.349 7.317-0.349 c1.479-0.086 1.7 2.1 0.2 2.262c0 0-1.489 0.174-3.142 0.261l9.92 29.508l2.739-9.148 C49.628 35.7 50.5 33 50.5 30.614z M32.481 34.4l-8.237 23.934c2.46 0.7 5.1 1.1 7.8 1.1 c3.197 0 6.262-0.552 9.116-1.556c-0.072-0.118-0.141-0.243-0.196-0.379L32.481 34.4z M56.088 18.8 c0.119 0.9 0.2 1.8 0.2 2.823c0 2.785-0.521 5.916-2.088 9.832l-8.385 24.242c8.161-4.758 13.65-13.6 13.65-23.728 C59.451 27.2 58.2 22.7 56.1 18.83z M32 0c-17.645 0-32 14.355-32 32C0 49.6 14.4 64 32 64s32-14.355 32-32.001 C64 14.4 49.6 0 32 0z M32 62.533c-16.835 0-30.533-13.698-30.533-30.534C1.467 15.2 15.2 1.5 32 1.5 s30.534 13.7 30.5 30.532C62.533 48.8 48.8 62.5 32 62.533z" class="style0"/></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 230 B After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,144 @@
|
||||
<svg width="160" height="160" version="1.1" viewBox="0 0 160 160" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<image width="160" height="160" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/jpeg;base64,/9j/4QDKRXhpZgAATU0AKgAAAAgABgESAAMAAAABAAEAAAEaAAUAAAABAAAAVgEbAAUAAAABAAAA
|
||||
XgEoAAMAAAABAAIAAAITAAMAAAABAAEAAIdpAAQAAAABAAAAZgAAAAAAAABIAAAAAQAAAEgAAAAB
|
||||
AAeQAAAHAAAABDAyMjGRAQAHAAAABAECAwCgAAAHAAAABDAxMDCgAQADAAAAAQABAACgAgAEAAAA
|
||||
AQAAAUCgAwAEAAAAAQAAAUCkBgADAAAAAQAAAAAAAAAAAAD/2wCEAAEBAQEBAQIBAQIDAgICAwQD
|
||||
AwMDBAUEBAQEBAUGBQUFBQUFBgYGBgYGBgYHBwcHBwcICAgICAkJCQkJCQkJCQkBAQEBAgICBAIC
|
||||
BAkGBQYJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCf/d
|
||||
AAQACv/AABEIAKAAoAMBIgACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQ
|
||||
AAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYX
|
||||
GBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqS
|
||||
k5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz
|
||||
9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQID
|
||||
EQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RF
|
||||
RkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqy
|
||||
s7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/
|
||||
AP8AP/ooooA734ceM734f+MrLxVZ5b7LIC6D+OI8SJ26r098elft/wCHLyz1fT7fVtMcS21zGssT
|
||||
r0ZHGVI/CvwEBx0r9R/2J/iMuveH5/hxqT5udL/fWuTy1s5+ZR/1zc/grAdq/LvFHKPaYT63Baw3
|
||||
9P8AgH+gf0DfFz+zs3qcL4uVqeI1h5VEtv8At6Kt6xij9CtGjOQPavWtFi6Zrz7RrbnOK9X0W3Py
|
||||
iv4z4jxKsz/WqrM9P0GPG2vbNBQgrXlWg2/3QRXtGgW/Sv514qxC1PJxM9LHrGgLt24Hpiux8TfE
|
||||
XwD8KPC03jb4maxZ6FpFtjzLq9lWKME8BRn7zE8BVBJPAFfmd+1x/wAFBvh1+yhpsnhvRkj8QeMm
|
||||
VdmnCTbFah+kl265K8crEo3v/sr8w/mX+PH7S3xh/aO8WHxd8U9Zm1GZC32eL/V29sjHOy3hX5Il
|
||||
AAHHzkD5navrvCz6J+a8WcuOzCXsMK9nb35L+6ui/vP5Jn8YeOX0rcq4bnLLsuSr4lbr7EH/AHmt
|
||||
3/dXzaP6Ff2hf+C8PhTwo8/h39mPw6dXmUFV1fWg8NvnjDRWSkTOOf8Alq0PTpivxT+OH/BRn9sX
|
||||
9oOSW18d+N9SNjMXH9n2EhsLMK/AUQ2vl7gBxiRpK+Fq+of2Pf2dPE/7VH7Qfhr4K+F9ySazdrHN
|
||||
Oo/49rZPnubg+nkwqzKenmbF/ir++eFPAvgfgjBTzGhhYL2UXKVWfvSSirt8z20/lsvI/wA3eKfG
|
||||
DirizFLD4mvJ87SUI+7HXZcq3+d2f1H/APBvV+x6ngP4Y6p+1n4vtsal4oDaZom9eU06F/8ASJ19
|
||||
PtM6hVI4aKFCOtf06ab2FeOfDTwR4Z+GvgvSPh74LtFsdI0O0hsbKBBhY4IEEcagf7o/OvadNQcC
|
||||
v+cX6RviviOM+KcXn9f4Zu0F/LCOkF92/m2f6OcF8GUuH8lo5ZT3ive85Pf8dF5WO1sB09K7Ky6i
|
||||
uSsF7fSuwsV6Gv5dzFnmZpIb4r8aeFfhv4M1X4h+Ob2LTdE0Gzn1C/u5mCRwW1tGZJXYngBUUmv8
|
||||
sH9v39rTxV+2v+1L4s/aA8TGSJdbvCbK0kY/6Hp8I8uytQu91UxQBfMC8ec0rD71f12/8HKX7cSf
|
||||
Cb4EaV+xx4KvAmt+OwNR1wRvh4tGtpP3UDBXVgL25UDGCrwQzKetfwgySPLIZZSWZjkk9STX++H7
|
||||
KrwB/sfh+txvj4WrYv3aflRi9/8At+S+6MWfwn498W/WMbHLKT92nv8A4v8AgL9RlFFFf62H8+H/
|
||||
0P8AP/ooooAK9M+E/j+/+GPj7TvGVjlvscoMkY/5aQt8sidh8yZx23Y9K8zpQcHI7VjiMPCrTdKo
|
||||
tGrHo5RmtfA4qnjMLLlnBqUWujWqP6gfCNxp2vaVaa5pEiz2l5Ek8Mi9GjcBlI+or2jRLLleK/MX
|
||||
/gnF8WU8VeFrr4SavLm90X/SLPceWtHb5lGTn9zIfoFdR2r9btH04gjAr/NfxRwM8pzCrgqnTbzX
|
||||
T8D/AKBfCnxHocU8P4bOaO817y/lktJL79vKx1uh2p44r4Q/bp/bzs/2f9Om+FPwonSbxlcR4uLg
|
||||
YddMRxxwcg3LLyiHiMYdh91W9B/bJ/aksv2Xvhqv9ilZvFOsq0WmxHBEIAw91Iv9yPICj+JyB0yR
|
||||
/L1rGr6j4g1S51rVpnnuruV55pZDueSSQlndm7sxOSfWvq/APwThnNVZ7m8L0Iv3IvabXV/3V26v
|
||||
yWv8jfS2+kjPJ4vhvI52ryX7yS+wntFdpNdfsrbV6R6nquoa3fzalqsz3FxcSPLLLKxd5JHOWd2b
|
||||
JZmPJY8msuiiv9A0kkopWR/lLObk7sVVLEKoyTwAK/sU/wCDfz9kIeBPhlqf7Vniy226j4o3ado2
|
||||
8cpp0L/6RMvHH2mddoI4aKFCOtfzEfshfs8eJv2pv2gfDfwX8MbkfWLtY5p1H/HtbIN9zcHjjyYg
|
||||
xU9N+xf4q/0e/hp4G8N/DrwdpPgDwbarZaRotpDY2dugwscECBI1AHoB+df5pftH/Gv+yMghwpgp
|
||||
2q4nWdulJdP+3np6KSP6/wDoleHixmYzz3Ex9yjpH/G1/wC2r80eu6ZFjFeg6cnSuN0yLaRXf6dH
|
||||
gV/gJmlQ/u3Nq51VhH0qz4q8Y+Ffhv4K1X4heObyLTtF0Gznv7+6lISOC2tozJK7E4ACopp9knAF
|
||||
fza/8HJP7b6/Cj4E6V+x34KvAmteOQNQ1sRvh4tHt5P3UDBXVh9tuVwRgq8MMy96+t8BvCHFcd8Y
|
||||
YPhrDbVJLna+zTjrOXyjt52R+F+IvE9PKsvq42fRaLu+i/rofyPft8ftYeK/20v2ofFfx98TmSNd
|
||||
buy1naOT/odhEPLsrULuZVMUAXeF485pWH3q+MKe7vK5kkO5mOST3NMr/rk4fyLC5XgKOW4GChSp
|
||||
RUIxWyjFWS+SR/mbjMXOvVlWqO7eoUUUV65zH//R/wA/+iiigAooooA9k+A/xV1P4MfFPSPiFpoZ
|
||||
vsEwM0S/8trdvlmixkD5oydueNwU9q/q+l8f+BPD/wAMJfjFfXqf8I9BYf2n9pT5g1uUDqUx94sC
|
||||
AoHUkAV/G8CVIZeCK+pNY/ar+IGsfs26b+zVccaZp1+9154c75Ifvw2zL/chlJdef7owAvP4H4y+
|
||||
Dn+s1fCVqT5eWSU/+ve7t5rp6n9U/R8+kRU4OwOOwdRc0Zx5qa6KqtF8mt/8KOO/aI+NniP4/fFT
|
||||
UviL4jyjXT7LeAElbe2jyIYV9kU846uWbvXhdFFfuGX4CjhKEMLh48sIJJJdEtEfzPm2a18diZ4z
|
||||
FS5pzbbfdsKVVLEKoyTwAKSvp79kH9nfxL+1N+0B4b+C/hvdG+sXSpPcKP8Aj2tU+e5uDwQPKiDF
|
||||
SePM2L3rHOc2w+X4SrjsXJRp04uTfRJK7/Azy3L6uKxEMNQjeUmkku70R/TR/wAECf2RR4D+GGpf
|
||||
tTeK7bGpeKd2n6PvXlNOhf8AfzLkcfaZ1wCOGiiQjrX9KOlQDivMPhz4G8NfDzwfpXgLwdarZaTo
|
||||
tpDZWcEYwscECBEUAeigV7RpkHHSv+Xj6Q/ivX4w4mxWe1tpu0F/LBaRX3b+dz/Y3gLg+lw7klDK
|
||||
6W8V7z7yfxP79vJJHVadDxiu4sIugrmtPiwVFdrYJ2r+XcyrGWaVuxD4r8Y+Ffhr4L1b4ieObyPT
|
||||
tF0Gzn1C/upSFSG3tozJK7E4ACqpr/Lt/by/as8Vftm/tP8Air4+eJzJGNavC1naOT/oljEPLs7U
|
||||
LuYL5UAXeF485pGH3q/rW/4OQv22U+FfwM0r9j3wZdhNZ8bhdR1wRthotIt5P3MDbWVh9suV6EFX
|
||||
hhlXvX8MLu8jmSQ7mY5JPc1/u5+y28BP7IyCrxtj4WrYv3aflSi9/wDt+Sv6Ri+p/n59IjjP61jo
|
||||
5XRfu09/8X/AX6jaKKK/1fP5tCiiigD/0v8AP/ooooAKKKKACiiigAooooAVVZmCIMk8ACv7Bf8A
|
||||
ggb+yIPAfwu1L9qXxZbbdS8VbtP0cuvMemwv++mXIBH2mdcAjhookI61/M1+x9+zr4l/ao/aB8N/
|
||||
Bfw3vRtXu1S4uEH/AB7WqfPc3GcEDyoQxUnjzNi/xV/o0/DzwN4a+H3hHS/Ang61Wy0nRrSGxs7e
|
||||
MYWOCBAkagDphRX+a/7Rnxr/ALIyKnwpgp2q4nWdulJdP+3np6Jn9ifRJ8OvruYzz7ER9yjpHzm1
|
||||
/wC2r8Wj0DTLY5HrXe6fCBj2rB02AKvArsLGMYz/AJ4r/BfMsQf33mFU6Wwi4qx4q8ZeFfhp4L1b
|
||||
4i+OLyPTtF0Kznv766lIVIbe3QySOxPAAVTUlivAr+cP/g44/bXX4XfBHSv2P/Bd2E1fxqF1HWxG
|
||||
2Gj0m3k/cwNtZSPtlwvIwVeGGVe9fYeBPhJiuOeL8Hw3h17tSXvtfZprWb+S287I/F/Eniqnk+WV
|
||||
cdU6LRd30X9dD+Tf9u39qjxT+2R+054q+PXigyRf21eFrS1cn/RLKIeXZ2oG5gvkwKocLx5pkYfe
|
||||
r45pzu8jmSQ5ZuSTTa/6ycgyPC5ZgaOXYKChSpRUYpbKMVZL5JH+WWNxlTEVpV6rvKTuwooor1zl
|
||||
CiiigD//0/8AP/ooooAKKKKACiiigApyqzMEQZJ4AFNr6j/Y7/Zz8SftU/tB+G/gv4dDo2r3SpcX
|
||||
CD/j2tI/nubjOCB5UIYqSMeYUX+KvNznN8Pl+Dq47FyUadOLlJ9Ekrv8Dty3L6uLxEMNQjeUmkku
|
||||
70SP6a/+CBf7Ia+AfhVqX7U3i22C6n4sBsNH3r8yabA/76ZcgEfap1wCOGiijI61/R5p8IGK878B
|
||||
eDfDfw/8JaV4G8HWqWOk6NaQ2NnbxgKsUECCONAB6KBXp9oAuB7V/wAvv0gPFPEcY8T4rPa203aC
|
||||
/lgtIr7t/O5/sv4ecF0uHskoZXS3ivefeT3f+XlY6WzQADFdTZgACuXtSAMV0Vs4xzX86Yu56uMj
|
||||
oT+LPGnhX4a+DNW+Ifje8j0/RtCs57++upCFSK3t0MkjknphVNf5jn7c37Uvir9sT9pjxT8ePFBk
|
||||
j/tq7LWts5P+iWUQ8u0tQNzBfJgVQ4HHmmRh96v6q/8Ag4m/bUT4a/BbSv2Q/Bl2F1XxkF1HWhG3
|
||||
zR6VA/7iBsMCPtdwvIIIaGGRe9fxKuzSMXc5J5JNf7ofsxvAf+x8iq8ZY+Fq2K92n5Uovf8A7fkr
|
||||
+kYn+dP0muN/rWYxyii/cpfF/i/4C/UbRRRX+p5/LgUUUUAFFFFAH//U/wA/+iiigAooooAKKKKA
|
||||
HKrOwRBkngAV/YX/AMEEP2R0+Hvwm1L9qXxZahdU8W5sNH3r80emQP8AvpVyoI+1TrgEcNFFGR1r
|
||||
+Zj9jn9nLxJ+1T+0J4b+C/hzfH/a90q3Nwg/49rSP57m4ztIHlQhipPHmGNf4q/0UPCHhPw38P8A
|
||||
wlpngbwfapY6To1rDY2VvGNqxQQII40AHTCgV/mx+0V8Z/7LyOnwngp2q4nWdulNdP8At56eiaP7
|
||||
H+iJ4dfXcynnuJj7lHSP+Nr/ANtX5o7WFgP8K27e5GMdK5EXBU8VMt72r/EKpheY/wBHpwuei212
|
||||
FxzVXxb4/wDDHw68Har4/wDGd2lho+iWk1/e3EhCrFBboXkYnpworlIdRAPBr+fT/gv1+2UPAXwg
|
||||
0z9lLwfd7dT8W7b/AFnY2Gj0yB/3MLYII+1TrkjGGihkHev0TwR8GsTxlxThMgoq0Zy99/ywWsn9
|
||||
23nZH5h4ocU0chyatmVX7K91d5PSK+/8D+ZD9tn9pvxT+1z+0f4n+OXicvG2s3Za2tnJ/wBFs4x5
|
||||
drbAZIHlQqoYDjzTIw+9XyXTmZnYu5yT1Jptf9RmR5LhstwVLL8HDlp04qMUtkoqyXyR/jrmGOqY
|
||||
mvLEVneUnd/MKKKK9Q4wooooAKKKKAP/1f8AP/ooooAKKKKACnIrOwRBkngAU2vq79jD9mvxL+1j
|
||||
+0R4a+CXh0PH/a92FurlB/x62cfz3VxnawXyoQ2wkY80xr/FXl53nGGy7BVcfi5ctOnFyk+iUVd/
|
||||
gjty3L6uLrww1BXlJpJeb0R/TP8A8EE/2Q/+Fc/B7Uv2qPFtrt1XxeDYaPvXDR6XA/72ZcqCPtU6
|
||||
8EZDRRRkda/fmeYIMtUPhzwn4b+HvhHTPAng20Sx0jRbSGxsreMBUigt0EcaADgAKBWPqF1sJJNf
|
||||
8zHjH4l4jjXijFZ9X2m/cX8sFpGP3b+dz/aPwv4IpZBk1DK6X2Vq+8nq3/l5WHzXuDVJtS29DXK3
|
||||
2o7ec1zNzrO08HFfL4TJnLofpccOdX4u+Ifh34feE9S8c+LrpLPStHtZby8ncgLHBAhd2J9lFf57
|
||||
v7X37RfiX9qf9oDxH8Z/ExdG1e6LW9ux/wCPa1jGy2twMkDyoVUMBx5hdh96v6Ef+C2/7Wj+Efhh
|
||||
p/7NPhe523/ibbe6tsPKafC/7mI4II+0TLkjo0cTDvX8qDMzHcxyTX+yf0AfBKOTZRU4nxcLVcRp
|
||||
Dypr/wCSevoon+aP0wPENYvNIZDhpe5Q1l5za/8AbVp6tiUUUV/ogfxkFFFFABRRRQAUUUUAf//W
|
||||
/wA/+iiigAooooAciM7hIxljwAK/s2/4N/8A9jlfhr8GNT/au8YWmzVvGQNhoxdcNHpUD/vZlyoY
|
||||
fa7heCCQ0UMRHWv5f/2Iv2YvE/7Xv7SPhj4G+HBJGNZuwt3coD/otlEPMu7knawXyoQ2wkY80xr/
|
||||
ABV/pI6J4P8ADPw78HaZ4C8F2cdho+iWkNhY20ShUht7dBHGigcABVFf5i/tJPHBZVktLg7Az/e4
|
||||
r3qlulKPT/t+St6Rkj+vfoneH6xeZyzrER9yjpH/ABP/AORX4tHJ6tJszXles323I9K77XrjYDmv
|
||||
DfEGoeWGPev8iOGcDz2R/p3gaVzD1bVdmea8h8bfELQ/BHhrUPGPia4W007SraS6upmOFSGFSzn8
|
||||
hVzW9Z2bhmvwg/4K+/tNnw74Esv2fvDtxi81/beansPKWUTfuozjp50q5I6FI2Hev628F/CWrxHn
|
||||
OHyyC92T959or4n923nY8DxP41ocM5BiM3q7xXurvJ6RX37+SPw6/ad+OviD9o341698WPEO5X1S
|
||||
4LQwsf8AUW6DZBAByB5cYAIHG/ce9fPlKSScmkr/AHtyvLaGCw1PCYaPLCCUUl0SVkj/AA0zPMau
|
||||
LxE8VXd5Sbbfmwoor6L/AGXPgF4o/aS+MukfC/wzmM3km+5uSCUtrWMgzzt2/dqflB+85Re9LNMz
|
||||
oYLDVMXiZKMIJtvskPKsrr43EQwmGjzTm0kl1b0SPWfDf7CXxY8S/sc61+2RY4Gj6NqcdkbUxMZJ
|
||||
bbiO4vFYf8sreZljf5cffbcAnPw6ysjFHGCOCD2r/Q2+H/wy+GGg/Be3/Z8tNNjbwlFpX9jGyk+d
|
||||
ZLRo/KdXznczgksT1Y5r+GL9rr9nvX/2YPj34i+D+u75BpVyRa3Dj/j5s5BvtZ87VB8yIruxx5gd
|
||||
f4a/lb6OH0klxnmOY5fiI8kqcuaktm6Oyv5p7/4l2P6G8dfAWpwnhcHiYPmU48s30VRau3k18P8A
|
||||
hPmSiiiv64P5qCiiigD/1/8AP/ooooAKciPI4jjGWbgAU2vr/wDYa/Zb8UfthftLeF/gV4bEkS6z
|
||||
dgXd0gP+iWMQ8y7uc7WCmKFTsJGPNaJf4hXk59nWGyzA1cxxklGnSi5SfRKKu38kjsy/A1MTWhh6
|
||||
KvKTSS9dD+qb/g3m/YzT4W/BLVP2tvGNoF1jxqDYaMZEw8Wk27/vZlyisBeXC8EEq8MMTDrX79a9
|
||||
dKikCtXQ/C3hj4c+DdM8A+CrOPT9H0SzhsLG1iUKkNvboI4o1A4AVVArz3xFeFQRmv8Alq8XvE3F
|
||||
cc8XYviHEbVJe4v5YLSEfkrX87s/188KuEKWSZTRy+nvFa+b6v79vI8v8T3wAY9hXzn4o1LG7mvW
|
||||
fFN8NrZNfM/izUfvYr7zgfJ72uj9/wAsp6Hj3xJ8daN4O8O6h4r8R3AttP02CS5uJG4CRRLuY/kO
|
||||
K/i4/aE+MOufHf4t618TNcyr6lOWiiP/ACxgUbYYgO3lxgA443bj3r9sP+Ctn7RB8P8AhKy+BGgz
|
||||
4u9bxeajtPK2kbfuoz6edIucd0jI71/PCSScmv8Aan6IHhjHLMplnVeP7ytpHygv/kn+CR/mz9NP
|
||||
xOWOzWHDuFl+7w+svOo1/wC2rT1bQlFFXrS0udQuI7OzRpZZWVERAWZmbhVUDkkngAda/sbY/iCE
|
||||
HJ8sSxpWl32t6lBpOmRPPcXMiQxRxjc7u5CqiqOrMTgCv6qP+Cf/AOzfpf7Mfw+Muq7JvFGthJNR
|
||||
mGD5Kj7lrGQPuR9WP8UhJ6bQPz+/Yz/Zl0/4UGH4i+OYkl8SSL+4iOGWxVhg47GZhwzD7g+Ve5P6
|
||||
zeGde+7g1/DH0mPEGeZ4d5LgH+6+2/5rbJf3V+Poj/Vz6KP0ZquTUlxJntO1eS/dwf2Ivq+0mun2
|
||||
Vvq7L748MeId23nivya/4LW/szx/E34Rad+0r4Xt92q+EwLLVSi/NJpkz/u5G2qSfsszewWKWQ9q
|
||||
++/C2uHanNe4mz0Dxt4Yv/Bniy3S90vVraWzu7eQZSWCZCkiEHjBU4r/AD94P4jxHCPEmHzzDLSm
|
||||
/eXeD0kvu280j+iPFzw6o8RZJXyqotZL3X2kvhf37+R/nvsrIxRxgjgj0ptfSn7WHwB179mn46+I
|
||||
PhHrW+RdMuCLW4Yf8fFpIN9tPnaoO+IjdjjzA6j7tfNdf7s5RmlDHYWnjMLLmhNKSa6pq6/A/wAK
|
||||
M0yytg8TPCYiPLODaa7NaNBRRRXoHAf/0P8AP/ooooAfGjyOscQLMxAAHUnsBX9vH/Bu1+xbH8K/
|
||||
gbqn7XXjK0C6z43BsNFMiYeLSLeT95Mu5FYfbLlcgglXhhhI61/KX+wh+yt4o/bJ/ad8K/Abw2JI
|
||||
k1q7AvbqMH/RLCIeZeXOdjqpigDeWWGPOaJf4q/029C8H+F/h34P0vwD4Hs49O0bQ7OGwsbWFQsc
|
||||
NtboI4o1A4AVVA4r/K79p347f2TkdHgvATtWxXvVLdKUXt/2/JW9IyR/T30auCvrWYSzasvdpaR/
|
||||
xf8AAX5o5rW5sKa8N8S3RwwBr2DX2+UivBfE0h2k1/jXwrhk2mf6O5P0PC/Fl5jd7V8jfFTxno3g
|
||||
7w5qPi3xFOttYaZbyXNxK3ASKJSzH8hxX014smIVjX88P/BYX9ohfDXhKx+APh+bbe65tvdS2nlL
|
||||
ONv3MR/67SrkjukZHev7y+j14fVM/wA5w+WU1pJ+95RW7+7bzsd/iBx7R4ayCvm9XeKtFd5PSK+/
|
||||
fyR+FX7QHxd1z45/FnWfiXrmVfUpy0cRP+phX5YYR6eXGFB7btx714pX0T4C/Za+O/xK2T+HfDl3
|
||||
9mfaftVyn2aDB775tuR/uBq+0/Af/BP3TNC2aj8VdT+2yDk2VjlIs88POQHb/gCp9a/24xXF2SZN
|
||||
Rjg/aRXIklGOrSWiVlt87H+VfCHgjxjxli3isLhpP2ju6k/djru7vf8A7du/I/OTwJ8N/F/xH1Zd
|
||||
H8J2b3Mgx5jdI4lP8Ujn5UH6nsDX6n/Az9nzwv8ACNY9Xviuo66Rzclfkhz1WFT09C5+Y+w4r3mx
|
||||
8P8Ah/wjpiaF4Xs4bCzj+7FAgRfqcdT7nmoVkPmfLX5dxHx5iMxg6NFclP8AF+v+R/p94C/Q+yfh
|
||||
WcMyzJrEYpbO3uQ/wrq1/M/kkes6NfEMMGvcfDOpsNuTXzJo1ydwHpXtfh25I2mv534qy5ODP65r
|
||||
wR9geFdUb5eeK+kfC2qH5Qa+OPClwflzX0p4WuDhea/kbjjKo3Z8rjqSPzf/AOCzn7M6fEb4S6f+
|
||||
0f4Ytt2qeEwLTVNi/NJpkzfJI2FLH7LM3qAsckh7V/LMysjFHGCOCPSv9CKXQ9C8aeGr7wd4ptkv
|
||||
dM1W2ls7u3kAKSQTIUkQg8YKk1/Df+1j+z/r37M3x28QfCHW97rpdwRa3Dj/AI+LSQb7aYHaoO+I
|
||||
jdgYEgdf4a/t36EHih9dy2rwvi5fvMPrDzpt7f8Abr09HFdD/Jr6Z/hl9QzWHEGGj7lbSVuk0v8A
|
||||
25fimfNdFFFf3gfxCf/Z
|
||||
"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
@@ -1,40 +0,0 @@
|
||||
"""Thin wrapper around the systemctl CLI for Sovran_SystemsOS_Hub."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from typing import Literal
|
||||
|
||||
|
||||
def _run(cmd: list[str]) -> str:
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
||||
return result.stdout.strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def is_active(unit: str, scope: Literal["system", "user"] = "system") -> str:
|
||||
return _run(["systemctl", f"--{scope}", "is-active", unit]) or "unknown"
|
||||
|
||||
|
||||
def is_enabled(unit: str, scope: Literal["system", "user"] = "system") -> str:
|
||||
return _run(["systemctl", f"--{scope}", "is-enabled", unit]) or "unknown"
|
||||
|
||||
|
||||
def run_action(
|
||||
action: str,
|
||||
unit: str,
|
||||
scope: Literal["system", "user"] = "system",
|
||||
method: str = "systemctl",
|
||||
) -> bool:
|
||||
base_cmd = ["systemctl", f"--{scope}", action, unit]
|
||||
if scope == "system" and method == "pkexec":
|
||||
cmd = ["pkexec", "--user", "root"] + base_cmd
|
||||
else:
|
||||
cmd = base_cmd
|
||||
try:
|
||||
subprocess.Popen(cmd)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
@@ -1,727 +0,0 @@
|
||||
"""Sovran_SystemsOS_Hub — Main GTK4 Application."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Adw", "1")
|
||||
|
||||
from gi.repository import Adw, Gdk, Gio, GLib, Gtk
|
||||
|
||||
from .config import load_config
|
||||
from .service_tile import ServiceTile
|
||||
|
||||
APP_ID = "com.sovransystems.hub"
|
||||
|
||||
Adw.init()
|
||||
|
||||
CATEGORY_ORDER = [
|
||||
("infrastructure", "Infrastructure"),
|
||||
("bitcoin-base", "Bitcoin Base"),
|
||||
("bitcoin-apps", "Bitcoin Apps"),
|
||||
("communication", "Communication"),
|
||||
("apps", "Self-Hosted Apps"),
|
||||
("nostr", "Nostr"),
|
||||
]
|
||||
|
||||
ROLE_LABELS = {
|
||||
"server_plus_desktop": "Server + Desktop",
|
||||
"desktop": "Desktop Only",
|
||||
"node": "Bitcoin Node",
|
||||
}
|
||||
|
||||
AUTOSTART_DIR = os.path.join(
|
||||
os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")),
|
||||
"autostart",
|
||||
)
|
||||
USER_AUTOSTART_FILE = os.path.join(AUTOSTART_DIR, "sovran-hub.desktop")
|
||||
SYSTEM_AUTOSTART_FILE = "/etc/xdg/autostart/sovran-hub.desktop"
|
||||
|
||||
DOWNLOADS_DIR = os.path.join(os.path.expanduser("~"), "Downloads")
|
||||
|
||||
FLAKE_LOCK_PATH = "/etc/nixos/flake.lock"
|
||||
FLAKE_INPUT_NAME = "Sovran_Systems"
|
||||
|
||||
GITEA_API_BASE = "https://git.sovransystems.com/api/v1/repos/Sovran_Systems/Sovran_SystemsOS/commits"
|
||||
|
||||
UPDATE_COMMAND = [
|
||||
"ssh", "-o", "StrictHostKeyChecking=no", "-o", "BatchMode=yes",
|
||||
"root@localhost",
|
||||
"cd /etc/nixos && nix flake update && nixos-rebuild switch && flatpak update -y",
|
||||
]
|
||||
|
||||
REBOOT_COMMAND = [
|
||||
"ssh", "-o", "StrictHostKeyChecking=no", "-o", "BatchMode=yes",
|
||||
"root@localhost",
|
||||
"reboot",
|
||||
]
|
||||
|
||||
UPDATE_CHECK_INTERVAL = 1800
|
||||
TILE_GRID_WIDTH = 820
|
||||
|
||||
|
||||
# ── Autostart helpers ────────────────────────────────────────────
|
||||
|
||||
def get_autostart_enabled() -> bool:
|
||||
if os.path.isfile(USER_AUTOSTART_FILE):
|
||||
try:
|
||||
with open(USER_AUTOSTART_FILE, "r") as f:
|
||||
for line in f:
|
||||
if line.strip().lower() == "x-gnome-autostart-enabled=false":
|
||||
return False
|
||||
if line.strip().lower() == "hidden=true":
|
||||
return False
|
||||
return True
|
||||
except Exception:
|
||||
return True
|
||||
return os.path.isfile(SYSTEM_AUTOSTART_FILE)
|
||||
|
||||
|
||||
def set_autostart_enabled(enabled: bool):
|
||||
os.makedirs(AUTOSTART_DIR, exist_ok=True)
|
||||
if enabled:
|
||||
if os.path.isfile(USER_AUTOSTART_FILE):
|
||||
os.remove(USER_AUTOSTART_FILE)
|
||||
else:
|
||||
with open(USER_AUTOSTART_FILE, "w") as f:
|
||||
f.write("[Desktop Entry]\n")
|
||||
f.write("Type=Application\n")
|
||||
f.write("Name=Sovran_SystemsOS Hub\n")
|
||||
f.write("Exec=sovran-hub\n")
|
||||
f.write("X-GNOME-Autostart-enabled=false\n")
|
||||
f.write("Hidden=true\n")
|
||||
|
||||
|
||||
# ── Update check helpers ────────────────────────────────────────
|
||||
|
||||
def _get_locked_info():
|
||||
try:
|
||||
with open(FLAKE_LOCK_PATH, "r") as f:
|
||||
lock = json.load(f)
|
||||
nodes = lock.get("nodes", {})
|
||||
node = nodes.get(FLAKE_INPUT_NAME, {})
|
||||
locked = node.get("locked", {})
|
||||
rev = locked.get("rev")
|
||||
branch = locked.get("ref")
|
||||
if not branch:
|
||||
branch = node.get("original", {}).get("ref")
|
||||
return rev, branch
|
||||
except Exception:
|
||||
pass
|
||||
return None, None
|
||||
|
||||
|
||||
def _get_remote_rev(branch=None):
|
||||
try:
|
||||
url = GITEA_API_BASE + "?limit=1"
|
||||
if branch:
|
||||
url += f"&sha={branch}"
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
req.add_header("Accept", "application/json")
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
if isinstance(data, list) and len(data) > 0:
|
||||
return data[0].get("sha")
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def check_for_updates() -> bool:
|
||||
locked_rev, branch = _get_locked_info()
|
||||
remote_rev = _get_remote_rev(branch)
|
||||
if locked_rev and remote_rev:
|
||||
return locked_rev != remote_rev
|
||||
return False
|
||||
|
||||
|
||||
# ── IP address helpers ───────────────────────────────────────────
|
||||
|
||||
def _get_internal_ip():
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.settimeout(2)
|
||||
s.connect(("1.1.1.1", 80))
|
||||
ip = s.getsockname()[0]
|
||||
s.close()
|
||||
return ip
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["hostname", "-I"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
parts = result.stdout.strip().split()
|
||||
if parts:
|
||||
return parts[0]
|
||||
except Exception:
|
||||
pass
|
||||
return "unavailable"
|
||||
|
||||
|
||||
def _get_external_ip():
|
||||
for url in [
|
||||
"https://api.ipify.org",
|
||||
"https://ifconfig.me/ip",
|
||||
"https://icanhazip.com",
|
||||
]:
|
||||
try:
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||
ip = resp.read().decode().strip()
|
||||
if ip and len(ip) < 46:
|
||||
return ip
|
||||
except Exception:
|
||||
continue
|
||||
return "unavailable"
|
||||
|
||||
|
||||
# ── UpdateDialog ─────────────────────────────────────────────────
|
||||
|
||||
class UpdateDialog(Adw.Window):
|
||||
|
||||
def __init__(self, parent):
|
||||
super().__init__(
|
||||
title="Sovran_SystemsOS Update",
|
||||
default_width=900,
|
||||
default_height=700,
|
||||
modal=True,
|
||||
transient_for=parent,
|
||||
)
|
||||
|
||||
self._process = None
|
||||
self._full_log = ""
|
||||
|
||||
header = Adw.HeaderBar()
|
||||
|
||||
self._close_btn = Gtk.Button(label="Close", sensitive=False)
|
||||
self._close_btn.connect("clicked", lambda _b: self.close())
|
||||
header.pack_end(self._close_btn)
|
||||
|
||||
self._reboot_btn = Gtk.Button(
|
||||
label="Reboot",
|
||||
css_classes=["destructive-action"],
|
||||
tooltip_text="Reboot the system now",
|
||||
visible=False,
|
||||
)
|
||||
self._reboot_btn.connect("clicked", self._on_reboot_clicked)
|
||||
header.pack_end(self._reboot_btn)
|
||||
|
||||
self._save_btn = Gtk.Button(
|
||||
label="Save Error Report",
|
||||
css_classes=["warning"],
|
||||
tooltip_text="Save full log to ~/Downloads",
|
||||
visible=False,
|
||||
)
|
||||
self._save_btn.connect("clicked", self._on_save_report)
|
||||
header.pack_start(self._save_btn)
|
||||
|
||||
self._spinner = Gtk.Spinner(spinning=True)
|
||||
header.pack_start(self._spinner)
|
||||
|
||||
self._status_label = Gtk.Label(
|
||||
label="Updating…",
|
||||
css_classes=["title-4"],
|
||||
halign=Gtk.Align.CENTER,
|
||||
margin_top=12,
|
||||
margin_bottom=8,
|
||||
)
|
||||
|
||||
self._textview = Gtk.TextView(
|
||||
editable=False,
|
||||
cursor_visible=False,
|
||||
monospace=True,
|
||||
wrap_mode=Gtk.WrapMode.WORD_CHAR,
|
||||
top_margin=8,
|
||||
bottom_margin=8,
|
||||
left_margin=12,
|
||||
right_margin=12,
|
||||
)
|
||||
self._buffer = self._textview.get_buffer()
|
||||
|
||||
scrolled = Gtk.ScrolledWindow(
|
||||
hscrollbar_policy=Gtk.PolicyType.AUTOMATIC,
|
||||
vscrollbar_policy=Gtk.PolicyType.AUTOMATIC,
|
||||
vexpand=True,
|
||||
child=self._textview,
|
||||
)
|
||||
|
||||
content = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
spacing=0,
|
||||
)
|
||||
content.append(self._status_label)
|
||||
content.append(scrolled)
|
||||
|
||||
toolbar_view = Adw.ToolbarView()
|
||||
toolbar_view.add_top_bar(header)
|
||||
toolbar_view.set_content(content)
|
||||
self.set_content(toolbar_view)
|
||||
|
||||
self._start_update()
|
||||
|
||||
def _append_text(self, text):
|
||||
self._full_log += text
|
||||
end_iter = self._buffer.get_end_iter()
|
||||
self._buffer.insert(end_iter, text)
|
||||
mark = self._buffer.create_mark(None, self._buffer.get_end_iter(), False)
|
||||
self._textview.scroll_mark_onscreen(mark)
|
||||
self._buffer.delete_mark(mark)
|
||||
|
||||
def _start_update(self):
|
||||
self._append_text(
|
||||
"$ ssh root@localhost 'cd /etc/nixos && nix flake update "
|
||||
"&& nixos-rebuild switch && flatpak update -y'\n\n"
|
||||
)
|
||||
thread = threading.Thread(target=self._run_update, daemon=True)
|
||||
thread.start()
|
||||
|
||||
def _run_update(self):
|
||||
try:
|
||||
self._process = subprocess.Popen(
|
||||
UPDATE_COMMAND,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
|
||||
for line in self._process.stdout:
|
||||
GLib.idle_add(self._append_text, line)
|
||||
|
||||
self._process.wait()
|
||||
rc = self._process.returncode
|
||||
|
||||
if rc == 0:
|
||||
GLib.idle_add(self._on_finished, True, "Update complete!")
|
||||
else:
|
||||
GLib.idle_add(self._on_finished, False, f"Update failed (exit code {rc})")
|
||||
|
||||
except Exception as e:
|
||||
GLib.idle_add(self._on_finished, False, f"Error: {e}")
|
||||
|
||||
def _on_finished(self, success, message):
|
||||
self._spinner.set_spinning(False)
|
||||
self._close_btn.set_sensitive(True)
|
||||
|
||||
if success:
|
||||
self._status_label.set_label("✓ " + message)
|
||||
self._status_label.set_css_classes(["title-4", "success"])
|
||||
self._reboot_btn.set_visible(True)
|
||||
else:
|
||||
self._status_label.set_label("✗ " + message)
|
||||
self._status_label.set_css_classes(["title-4", "error"])
|
||||
self._save_btn.set_visible(True)
|
||||
|
||||
self._append_text(f"\n{'─' * 60}\n{message}\n")
|
||||
|
||||
def _on_save_report(self, _btn):
|
||||
os.makedirs(DOWNLOADS_DIR, exist_ok=True)
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
filename = f"sovran-update-error-{timestamp}.log"
|
||||
filepath = os.path.join(DOWNLOADS_DIR, filename)
|
||||
try:
|
||||
with open(filepath, "w") as f:
|
||||
f.write(f"Sovran_SystemsOS Update Error Report\n")
|
||||
f.write(f"Date: {datetime.now().isoformat()}\n")
|
||||
f.write(f"{'═' * 60}\n\n")
|
||||
f.write(self._full_log)
|
||||
self._save_btn.set_label(f"Saved: {filename}")
|
||||
self._save_btn.set_sensitive(False)
|
||||
self._append_text(f"\n✓ Error report saved to ~/Downloads/{filename}\n")
|
||||
except Exception as e:
|
||||
self._append_text(f"\n✗ Failed to save report: {e}\n")
|
||||
|
||||
def _on_reboot_clicked(self, _btn):
|
||||
dialog = Adw.MessageDialog(
|
||||
transient_for=self,
|
||||
heading="Reboot Now?",
|
||||
body="The system will restart immediately. Save any open work first.",
|
||||
)
|
||||
dialog.add_response("cancel", "Cancel")
|
||||
dialog.add_response("reboot", "Reboot")
|
||||
dialog.set_response_appearance("reboot", Adw.ResponseAppearance.DESTRUCTIVE)
|
||||
dialog.set_default_response("cancel")
|
||||
dialog.set_close_response("cancel")
|
||||
dialog.connect("response", self._on_reboot_confirmed)
|
||||
dialog.present()
|
||||
|
||||
def _on_reboot_confirmed(self, dialog, response):
|
||||
if response == "reboot":
|
||||
try:
|
||||
subprocess.Popen(REBOOT_COMMAND)
|
||||
except Exception as e:
|
||||
self._append_text(f"\n✗ Reboot failed: {e}\n")
|
||||
|
||||
|
||||
# ── Main Window ──────────────────────────────────────────────────
|
||||
|
||||
class SovranHubWindow(Adw.ApplicationWindow):
|
||||
|
||||
def __init__(self, app, config):
|
||||
super().__init__(
|
||||
application=app,
|
||||
title="Sovran_SystemsOS Hub",
|
||||
default_width=860,
|
||||
default_height=800,
|
||||
)
|
||||
self._config = config
|
||||
self._tiles = []
|
||||
self._update_available = False
|
||||
|
||||
css_path = os.environ.get("SOVRAN_HUB_CSS", "")
|
||||
if css_path and os.path.isfile(css_path):
|
||||
provider = Gtk.CssProvider()
|
||||
provider.load_from_path(css_path)
|
||||
Gtk.StyleContext.add_provider_for_display(
|
||||
Gdk.Display.get_default(), provider,
|
||||
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
)
|
||||
|
||||
header = Adw.HeaderBar()
|
||||
header.set_title_widget(self._build_title_box())
|
||||
|
||||
self._update_btn = Gtk.Button(
|
||||
label="Update System",
|
||||
css_classes=["suggested-action"],
|
||||
tooltip_text="System is up to date",
|
||||
)
|
||||
self._update_btn.connect("clicked", self._on_update_clicked)
|
||||
header.pack_start(self._update_btn)
|
||||
|
||||
self._badge = Gtk.Label(
|
||||
label=" ●",
|
||||
css_classes=["update-badge"],
|
||||
visible=False,
|
||||
)
|
||||
header.pack_start(self._badge)
|
||||
|
||||
refresh_btn = Gtk.Button(
|
||||
icon_name="view-refresh-symbolic",
|
||||
tooltip_text="Refresh now",
|
||||
)
|
||||
refresh_btn.connect("clicked", lambda _b: self._refresh_all())
|
||||
header.pack_end(refresh_btn)
|
||||
|
||||
menu_btn = Gtk.MenuButton(
|
||||
icon_name="open-menu-symbolic",
|
||||
tooltip_text="Settings",
|
||||
)
|
||||
popover = Gtk.Popover()
|
||||
menu_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
spacing=8,
|
||||
margin_top=12,
|
||||
margin_bottom=12,
|
||||
margin_start=12,
|
||||
margin_end=12,
|
||||
)
|
||||
|
||||
autostart_row = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL,
|
||||
spacing=12,
|
||||
)
|
||||
autostart_label = Gtk.Label(
|
||||
label="Start at login",
|
||||
hexpand=True,
|
||||
halign=Gtk.Align.START,
|
||||
)
|
||||
self._autostart_switch = Gtk.Switch(
|
||||
valign=Gtk.Align.CENTER,
|
||||
active=get_autostart_enabled(),
|
||||
)
|
||||
self._autostart_switch.connect("state-set", self._on_autostart_toggled)
|
||||
autostart_row.append(autostart_label)
|
||||
autostart_row.append(self._autostart_switch)
|
||||
menu_box.append(autostart_row)
|
||||
|
||||
popover.set_child(menu_box)
|
||||
menu_btn.set_popover(popover)
|
||||
header.pack_end(menu_btn)
|
||||
|
||||
# ── Main content area ────────────────────────────────────
|
||||
self._main_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
spacing=0,
|
||||
)
|
||||
|
||||
# ── IP Address Banner ────────────────────────────────────
|
||||
self._ip_bar = self._build_ip_bar()
|
||||
self._main_box.append(self._ip_bar)
|
||||
|
||||
scrolled = Gtk.ScrolledWindow(
|
||||
hscrollbar_policy=Gtk.PolicyType.NEVER,
|
||||
vscrollbar_policy=Gtk.PolicyType.AUTOMATIC,
|
||||
vexpand=True,
|
||||
)
|
||||
|
||||
self._tiles_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
spacing=0,
|
||||
)
|
||||
scrolled.set_child(self._tiles_box)
|
||||
self._main_box.append(scrolled)
|
||||
|
||||
toolbar_view = Adw.ToolbarView()
|
||||
toolbar_view.add_top_bar(header)
|
||||
toolbar_view.set_content(self._main_box)
|
||||
self.set_content(toolbar_view)
|
||||
|
||||
self._build_tiles()
|
||||
|
||||
interval = config.get("refresh_interval", 5)
|
||||
if interval and interval > 0:
|
||||
GLib.timeout_add_seconds(interval, self._auto_refresh)
|
||||
|
||||
GLib.timeout_add_seconds(5, self._check_for_updates_once)
|
||||
GLib.timeout_add_seconds(UPDATE_CHECK_INTERVAL, self._periodic_update_check)
|
||||
GLib.timeout_add_seconds(1, self._fetch_ips_once)
|
||||
|
||||
# ── IP Address Bar ───────────────────────────────────────────
|
||||
|
||||
def _build_ip_bar(self):
|
||||
bar = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL,
|
||||
spacing=28,
|
||||
halign=Gtk.Align.CENTER,
|
||||
margin_top=14,
|
||||
margin_bottom=6,
|
||||
margin_start=24,
|
||||
margin_end=24,
|
||||
css_classes=["ip-bar"],
|
||||
)
|
||||
|
||||
internal_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL,
|
||||
spacing=8,
|
||||
)
|
||||
internal_icon = Gtk.Image(
|
||||
icon_name="network-wired-symbolic",
|
||||
pixel_size=18,
|
||||
css_classes=["dim-label"],
|
||||
)
|
||||
internal_label = Gtk.Label(
|
||||
label="Internal:",
|
||||
css_classes=["dim-label"],
|
||||
)
|
||||
self._internal_ip_label = Gtk.Label(
|
||||
label="…",
|
||||
css_classes=["ip-value"],
|
||||
selectable=True,
|
||||
)
|
||||
internal_box.append(internal_icon)
|
||||
internal_box.append(internal_label)
|
||||
internal_box.append(self._internal_ip_label)
|
||||
|
||||
sep = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
external_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL,
|
||||
spacing=8,
|
||||
)
|
||||
external_icon = Gtk.Image(
|
||||
icon_name="network-server-symbolic",
|
||||
pixel_size=18,
|
||||
css_classes=["dim-label"],
|
||||
)
|
||||
external_label = Gtk.Label(
|
||||
label="External:",
|
||||
css_classes=["dim-label"],
|
||||
)
|
||||
self._external_ip_label = Gtk.Label(
|
||||
label="…",
|
||||
css_classes=["ip-value"],
|
||||
selectable=True,
|
||||
)
|
||||
external_box.append(external_icon)
|
||||
external_box.append(external_label)
|
||||
external_box.append(self._external_ip_label)
|
||||
|
||||
bar.append(internal_box)
|
||||
bar.append(sep)
|
||||
bar.append(external_box)
|
||||
|
||||
return bar
|
||||
|
||||
def _fetch_ips_once(self):
|
||||
thread = threading.Thread(target=self._do_fetch_ips, daemon=True)
|
||||
thread.start()
|
||||
return False
|
||||
|
||||
def _do_fetch_ips(self):
|
||||
internal = _get_internal_ip()
|
||||
GLib.idle_add(self._internal_ip_label.set_label, internal)
|
||||
external = _get_external_ip()
|
||||
GLib.idle_add(self._external_ip_label.set_label, external)
|
||||
|
||||
# ── Title box ────────────────────────────────────────────────
|
||||
|
||||
def _build_title_box(self):
|
||||
role = self._config.get("role", "server_plus_desktop")
|
||||
role_label = ROLE_LABELS.get(role, role)
|
||||
box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
halign=Gtk.Align.CENTER,
|
||||
)
|
||||
box.append(Gtk.Label(
|
||||
label="Sovran_SystemsOS Hub",
|
||||
css_classes=["hub-title"],
|
||||
))
|
||||
box.append(Gtk.Label(
|
||||
label=role_label,
|
||||
css_classes=["role-badge", "dim-label"],
|
||||
))
|
||||
return box
|
||||
|
||||
# ── Service tiles ────────────────────────────────────────────
|
||||
|
||||
def _build_tiles(self):
|
||||
method = self._config.get("command_method", "systemctl")
|
||||
services = self._config.get("services", [])
|
||||
|
||||
grouped = {}
|
||||
for entry in services:
|
||||
cat = entry.get("category", "other")
|
||||
grouped.setdefault(cat, []).append(entry)
|
||||
|
||||
for cat_key, cat_label in CATEGORY_ORDER:
|
||||
entries = grouped.get(cat_key, [])
|
||||
if not entries:
|
||||
continue
|
||||
|
||||
container = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
halign=Gtk.Align.CENTER,
|
||||
css_classes=["tiles-container"],
|
||||
)
|
||||
container.set_size_request(TILE_GRID_WIDTH, -1)
|
||||
|
||||
section_label = Gtk.Label(
|
||||
label=cat_label,
|
||||
css_classes=["section-header"],
|
||||
halign=Gtk.Align.START,
|
||||
margin_top=24,
|
||||
margin_bottom=6,
|
||||
margin_start=16,
|
||||
)
|
||||
container.append(section_label)
|
||||
|
||||
sep = Gtk.Separator(
|
||||
orientation=Gtk.Orientation.HORIZONTAL,
|
||||
margin_start=16,
|
||||
margin_end=16,
|
||||
margin_bottom=10,
|
||||
)
|
||||
container.append(sep)
|
||||
|
||||
flowbox = Gtk.FlowBox(
|
||||
max_children_per_line=4,
|
||||
min_children_per_line=2,
|
||||
selection_mode=Gtk.SelectionMode.NONE,
|
||||
homogeneous=False,
|
||||
row_spacing=14,
|
||||
column_spacing=14,
|
||||
margin_top=4,
|
||||
margin_bottom=10,
|
||||
margin_start=16,
|
||||
margin_end=16,
|
||||
halign=Gtk.Align.START,
|
||||
valign=Gtk.Align.START,
|
||||
)
|
||||
|
||||
for entry in entries:
|
||||
tile = ServiceTile(
|
||||
name=entry.get("name", entry["unit"]),
|
||||
unit=entry["unit"],
|
||||
scope=entry.get("type", "system"),
|
||||
method=method,
|
||||
icon_name=entry.get("icon", ""),
|
||||
enabled=entry.get("enabled", True),
|
||||
)
|
||||
flowbox.append(tile)
|
||||
self._tiles.append(tile)
|
||||
|
||||
container.append(flowbox)
|
||||
self._tiles_box.append(container)
|
||||
|
||||
GLib.idle_add(self._refresh_all)
|
||||
|
||||
# ── Update check ─────────────────────────────────────────────
|
||||
|
||||
def _check_for_updates_once(self):
|
||||
thread = threading.Thread(target=self._do_update_check, daemon=True)
|
||||
thread.start()
|
||||
return False
|
||||
|
||||
def _periodic_update_check(self):
|
||||
thread = threading.Thread(target=self._do_update_check, daemon=True)
|
||||
thread.start()
|
||||
return True
|
||||
|
||||
def _do_update_check(self):
|
||||
available = check_for_updates()
|
||||
GLib.idle_add(self._set_update_indicator, available)
|
||||
|
||||
def _set_update_indicator(self, available):
|
||||
self._update_available = available
|
||||
if available:
|
||||
self._update_btn.set_label("Update Available")
|
||||
self._update_btn.set_css_classes(["update-available"])
|
||||
self._update_btn.set_tooltip_text("A new version of Sovran_SystemsOS is available!")
|
||||
self._badge.set_visible(True)
|
||||
else:
|
||||
self._update_btn.set_label("Update System")
|
||||
self._update_btn.set_css_classes(["suggested-action"])
|
||||
self._update_btn.set_tooltip_text("System is up to date")
|
||||
self._badge.set_visible(False)
|
||||
|
||||
# ── Callbacks ────────────────────────────────────────────────
|
||||
|
||||
def _on_update_clicked(self, _btn):
|
||||
dialog = UpdateDialog(self)
|
||||
dialog.connect("close-request", lambda _w: self._after_update())
|
||||
dialog.present()
|
||||
|
||||
def _after_update(self):
|
||||
GLib.timeout_add_seconds(3, self._check_for_updates_once)
|
||||
return False
|
||||
|
||||
def _on_autostart_toggled(self, switch, state):
|
||||
set_autostart_enabled(state)
|
||||
return False
|
||||
|
||||
def _refresh_all(self):
|
||||
for t in self._tiles:
|
||||
t.refresh()
|
||||
return False
|
||||
|
||||
def _auto_refresh(self):
|
||||
self._refresh_all()
|
||||
return True
|
||||
|
||||
|
||||
class SovranHubApp(Adw.Application):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
application_id=APP_ID,
|
||||
flags=Gio.ApplicationFlags.DEFAULT_FLAGS,
|
||||
)
|
||||
self._config = load_config()
|
||||
|
||||
def do_activate(self):
|
||||
win = self.get_active_window()
|
||||
if not win:
|
||||
win = SovranHubWindow(self, self._config)
|
||||
win.present()
|
||||
@@ -1,20 +0,0 @@
|
||||
"""Load the Nix-generated config for Sovran_SystemsOS_Hub."""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
"""Read config from the path injected by the Nix derivation."""
|
||||
path = os.environ.get(
|
||||
"SOVRAN_HUB_CONFIG",
|
||||
os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"config.json",
|
||||
),
|
||||
)
|
||||
try:
|
||||
with open(path, "r") as fh:
|
||||
return json.load(fh)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return {"refresh_interval": 5, "command_method": "systemctl", "services": []}
|
||||
@@ -1,104 +0,0 @@
|
||||
"""Adw.ActionRow subclass representing a single systemd unit."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Adw", "1")
|
||||
|
||||
from gi.repository import Adw, GLib, Gtk # noqa: E402
|
||||
|
||||
from . import systemctl # noqa: E402
|
||||
|
||||
LOADING_STATES = {"reloading", "activating", "deactivating", "maintenance"}
|
||||
|
||||
|
||||
class ServiceRow(Adw.ActionRow):
|
||||
"""A row showing one systemd unit with a toggle switch and action buttons."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
unit: str,
|
||||
scope: str = "system",
|
||||
method: str = "systemctl",
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(title=name, subtitle=unit, **kwargs)
|
||||
self._unit = unit
|
||||
self._scope = scope
|
||||
self._method = method
|
||||
|
||||
# ── Active / Inactive switch ──
|
||||
self._switch = Gtk.Switch(valign=Gtk.Align.CENTER)
|
||||
self._switch.connect("state-set", self._on_toggled)
|
||||
self.add_suffix(self._switch)
|
||||
self.set_activatable_widget(self._switch)
|
||||
|
||||
# ── Restart button ──
|
||||
restart_btn = Gtk.Button(
|
||||
icon_name="view-refresh-symbolic",
|
||||
valign=Gtk.Align.CENTER,
|
||||
tooltip_text="Restart",
|
||||
css_classes=["flat"],
|
||||
)
|
||||
restart_btn.connect("clicked", self._on_restart)
|
||||
self.add_suffix(restart_btn)
|
||||
|
||||
# ── Status pill ──
|
||||
self._status_label = Gtk.Label(
|
||||
css_classes=["caption", "dim-label"],
|
||||
valign=Gtk.Align.CENTER,
|
||||
margin_end=4,
|
||||
)
|
||||
self.add_suffix(self._status_label)
|
||||
|
||||
# Initial state
|
||||
self.refresh()
|
||||
|
||||
# ── public API ──
|
||||
|
||||
def refresh(self):
|
||||
"""Poll systemctl and update the row."""
|
||||
active_state = systemctl.is_active(self._unit, self._scope)
|
||||
enabled_state = systemctl.is_enabled(self._unit, self._scope)
|
||||
|
||||
is_active = active_state == "active"
|
||||
is_loading = active_state in LOADING_STATES
|
||||
is_failed = active_state == "failed"
|
||||
|
||||
# Block the handler so we don't trigger a start/stop when we
|
||||
# programmatically flip the switch.
|
||||
self._switch.handler_block_by_func(self._on_toggled)
|
||||
self._switch.set_active(is_active)
|
||||
self._switch.handler_unblock_by_func(self._on_toggled)
|
||||
|
||||
self._switch.set_sensitive(not is_loading)
|
||||
|
||||
# Status text
|
||||
label = enabled_state
|
||||
if is_failed:
|
||||
label = "failed"
|
||||
elif is_loading:
|
||||
label = active_state
|
||||
self._status_label.set_label(label)
|
||||
|
||||
# Visual cue for failures
|
||||
if is_failed:
|
||||
self.add_css_class("error")
|
||||
else:
|
||||
self.remove_css_class("error")
|
||||
|
||||
# ── signal handlers ──
|
||||
|
||||
def _on_toggled(self, switch: Gtk.Switch, state: bool) -> bool:
|
||||
action = "start" if state else "stop"
|
||||
systemctl.run_action(action, self._unit, self._scope, self._method)
|
||||
# Delay refresh so systemd has a moment to change state
|
||||
GLib.timeout_add(1500, self.refresh)
|
||||
return False # let GTK update the switch position
|
||||
|
||||
def _on_restart(self, _btn: Gtk.Button):
|
||||
systemctl.run_action("restart", self._unit, self._scope, self._method)
|
||||
GLib.timeout_add(1500, self.refresh)
|
||||
@@ -1,172 +0,0 @@
|
||||
"""Square tile widget representing a single systemd unit."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Adw", "1")
|
||||
gi.require_version("Gdk", "4.0")
|
||||
|
||||
from gi.repository import Gdk, GdkPixbuf, GLib, Gtk, Pango
|
||||
|
||||
from . import systemctl
|
||||
|
||||
LOADING_STATES = {"reloading", "activating", "deactivating", "maintenance"}
|
||||
|
||||
ICON_DIR = os.environ.get("SOVRAN_HUB_ICONS", "")
|
||||
ICON_EXTENSIONS = [".svg", ".png"]
|
||||
|
||||
# ── Locked tile dimensions ───────────────────────────────────────
|
||||
TILE_W = 180
|
||||
TILE_H = 210
|
||||
ICON_PX = 48
|
||||
LABEL_W = TILE_W - 24 # 12px padding each side
|
||||
|
||||
|
||||
class ServiceTile(Gtk.Box):
|
||||
|
||||
def __init__(self, name, unit, scope="system", method="systemctl",
|
||||
icon_name="", enabled=True, **kw):
|
||||
super().__init__(
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
spacing=2,
|
||||
halign=Gtk.Align.CENTER,
|
||||
valign=Gtk.Align.START,
|
||||
css_classes=["card", "sovran-tile"],
|
||||
**kw,
|
||||
)
|
||||
self.set_size_request(TILE_W, TILE_H)
|
||||
self.set_hexpand(False)
|
||||
self.set_vexpand(False)
|
||||
|
||||
self._unit = unit
|
||||
self._scope = scope
|
||||
self._method = method
|
||||
self._enabled = enabled
|
||||
|
||||
# ── Icon ─────────────────────────────────────────────────
|
||||
self._logo = Gtk.Image(
|
||||
pixel_size=ICON_PX,
|
||||
margin_top=18,
|
||||
halign=Gtk.Align.CENTER,
|
||||
)
|
||||
self._set_logo(icon_name)
|
||||
self.append(self._logo)
|
||||
|
||||
# ── Name label ───────────────────────────────────────────
|
||||
self._name_label = Gtk.Label(
|
||||
label=name,
|
||||
css_classes=["tile-name"],
|
||||
halign=Gtk.Align.CENTER,
|
||||
justify=Gtk.Justification.CENTER,
|
||||
wrap=True,
|
||||
wrap_mode=Pango.WrapMode.WORD_CHAR,
|
||||
lines=2,
|
||||
ellipsize=Pango.EllipsizeMode.END,
|
||||
margin_start=12,
|
||||
margin_end=12,
|
||||
margin_top=6,
|
||||
)
|
||||
self._name_label.set_size_request(LABEL_W, -1)
|
||||
self._name_label.set_max_width_chars(1)
|
||||
self.append(self._name_label)
|
||||
|
||||
# ── Status label ─────────────────────────────────────────
|
||||
self._status_label = Gtk.Label(
|
||||
label="● …",
|
||||
css_classes=["caption", "tile-status", "dim-label"],
|
||||
halign=Gtk.Align.CENTER,
|
||||
margin_top=2,
|
||||
)
|
||||
self.append(self._status_label)
|
||||
|
||||
# ── Spacer ───────────────────────────────────────────────
|
||||
spacer = Gtk.Box(vexpand=True)
|
||||
self.append(spacer)
|
||||
|
||||
# ── Controls ───────────────���─────────────────────────────
|
||||
controls = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL,
|
||||
spacing=10,
|
||||
halign=Gtk.Align.CENTER,
|
||||
margin_bottom=14,
|
||||
)
|
||||
self._switch = Gtk.Switch(valign=Gtk.Align.CENTER)
|
||||
self._switch.connect("state-set", self._on_toggled)
|
||||
controls.append(self._switch)
|
||||
|
||||
restart_btn = Gtk.Button(
|
||||
icon_name="view-refresh-symbolic",
|
||||
valign=Gtk.Align.CENTER,
|
||||
tooltip_text="Restart",
|
||||
css_classes=["flat", "circular"],
|
||||
)
|
||||
restart_btn.connect("clicked", self._on_restart)
|
||||
controls.append(restart_btn)
|
||||
self.append(controls)
|
||||
|
||||
if not self._enabled:
|
||||
self._switch.set_active(False)
|
||||
self._switch.set_sensitive(False)
|
||||
self._status_label.set_label("○ disabled")
|
||||
self._status_label.set_css_classes(["caption", "tile-status", "disabled-label"])
|
||||
self._logo.set_opacity(0.35)
|
||||
self.set_tooltip_text(f"{name} is not enabled in custom.nix")
|
||||
|
||||
def _set_logo(self, icon_name):
|
||||
if icon_name and ICON_DIR:
|
||||
for ext in ICON_EXTENSIONS:
|
||||
path = os.path.join(ICON_DIR, f"{icon_name}{ext}")
|
||||
if os.path.isfile(path):
|
||||
try:
|
||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
|
||||
path, ICON_PX, ICON_PX, True)
|
||||
texture = Gdk.Texture.new_for_pixbuf(pixbuf)
|
||||
self._logo.set_from_paintable(texture)
|
||||
return
|
||||
except Exception:
|
||||
break
|
||||
self._logo.set_from_icon_name("system-run-symbolic")
|
||||
|
||||
def refresh(self):
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
active = systemctl.is_active(self._unit, self._scope)
|
||||
is_on = active == "active"
|
||||
is_loading = active in LOADING_STATES
|
||||
is_failed = active == "failed"
|
||||
|
||||
self._switch.handler_block_by_func(self._on_toggled)
|
||||
self._switch.set_active(is_on)
|
||||
self._switch.handler_unblock_by_func(self._on_toggled)
|
||||
self._switch.set_sensitive(not is_loading)
|
||||
|
||||
if is_failed:
|
||||
self._status_label.set_label("● failed")
|
||||
self._status_label.set_css_classes(["caption", "tile-status", "error"])
|
||||
elif is_on:
|
||||
self._status_label.set_label("● running")
|
||||
self._status_label.set_css_classes(["caption", "tile-status", "success"])
|
||||
elif is_loading:
|
||||
self._status_label.set_label(f"● {active}")
|
||||
self._status_label.set_css_classes(["caption", "tile-status", "warning"])
|
||||
else:
|
||||
self._status_label.set_label(f"● {active}")
|
||||
self._status_label.set_css_classes(["caption", "tile-status", "dim-label"])
|
||||
|
||||
def _on_toggled(self, switch, state):
|
||||
if not self._enabled:
|
||||
return True
|
||||
systemctl.run_action("start" if state else "stop", self._unit, self._scope, self._method)
|
||||
GLib.timeout_add(1500, self.refresh)
|
||||
return False
|
||||
|
||||
def _on_restart(self, _btn):
|
||||
if not self._enabled:
|
||||
return
|
||||
systemctl.run_action("restart", self._unit, self._scope, self._method)
|
||||
GLib.timeout_add(1500, self.refresh)
|
||||
@@ -1,40 +0,0 @@
|
||||
"""Thin wrapper around the systemctl CLI for Sovran_SystemsOS_Hub."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from typing import Literal
|
||||
|
||||
|
||||
def _run(cmd: list[str]) -> str:
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
||||
return result.stdout.strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def is_active(unit: str, scope: Literal["system", "user"] = "system") -> str:
|
||||
return _run(["systemctl", f"--{scope}", "is-active", unit]) or "unknown"
|
||||
|
||||
|
||||
def is_enabled(unit: str, scope: Literal["system", "user"] = "system") -> str:
|
||||
return _run(["systemctl", f"--{scope}", "is-enabled", unit]) or "unknown"
|
||||
|
||||
|
||||
def run_action(
|
||||
action: str,
|
||||
unit: str,
|
||||
scope: Literal["system", "user"] = "system",
|
||||
method: str = "systemctl",
|
||||
) -> bool:
|
||||
base_cmd = ["systemctl", f"--{scope}", action, unit]
|
||||
if scope == "system" and method == "pkexec":
|
||||
cmd = ["pkexec", "--user", "root"] + base_cmd
|
||||
else:
|
||||
cmd = base_cmd
|
||||
try:
|
||||
subprocess.Popen(cmd)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Load the Nix-generated config and build-time versions for Sovran_SystemsOS_Hub."""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
"""Read config from the path injected by the Nix derivation."""
|
||||
path = os.environ.get(
|
||||
"SOVRAN_HUB_CONFIG",
|
||||
os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"config.json",
|
||||
),
|
||||
)
|
||||
try:
|
||||
with open(path, "r") as fh:
|
||||
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 {}
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sovran DDNS update runner (sovran-ddns-update.service).
|
||||
|
||||
For every stored Njal.la update URL this asks Njal.la to point the record at
|
||||
the address the request came from ("&auto"), then reads back the address
|
||||
Njal.la says it recorded. That address is saved to /var/lib/secrets/external-ip,
|
||||
where livekit-turn-setup and the Hub read it.
|
||||
|
||||
Njal.la is the only party involved. It has to learn the address to publish
|
||||
it, so nothing else -- no STUN server, no public resolver, no "what is my IP"
|
||||
service -- is ever asked for it.
|
||||
|
||||
Kept from the previous runner:
|
||||
* every URL goes through _validate_ddns_url() (https, njal.la only, /update/)
|
||||
* curl is run directly: no shell, no redirects
|
||||
* the update key is never printed or logged
|
||||
|
||||
The module is installed next to security_helpers.py (/etc/sovran/) and run as a
|
||||
script by the service; it is also importable as
|
||||
sovran_systemsos_web.ddns_update so the tests can exercise it.
|
||||
"""
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
try:
|
||||
from .security_helpers import _validate_ddns_url # imported as part of the Hub package
|
||||
except ImportError: # run as a script from /etc/sovran, next to security_helpers.py
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from security_helpers import _validate_ddns_url
|
||||
|
||||
URLS_FILE = "/var/lib/njalla/ddns_urls.json"
|
||||
IP_FILE = "/var/lib/secrets/external-ip"
|
||||
|
||||
_CGNAT = ipaddress.ip_network("100.64.0.0/10")
|
||||
|
||||
|
||||
def is_public_ipv4(value) -> bool:
|
||||
"""True for a globally routable IPv4 literal (not private, loopback, CGNAT ...)."""
|
||||
try:
|
||||
ip = ipaddress.ip_address(str(value).strip())
|
||||
except ValueError:
|
||||
return False
|
||||
if ip.version != 4 or ip in _CGNAT:
|
||||
return False
|
||||
# is_global alone is not enough: CPython reports multicast as global.
|
||||
return ip.is_global and not (
|
||||
ip.is_multicast or ip.is_reserved or ip.is_loopback
|
||||
or ip.is_link_local or ip.is_unspecified or ip.is_private
|
||||
)
|
||||
|
||||
|
||||
def normalise_url(raw: str) -> str:
|
||||
"""Return the URL to call for a stored (or freshly pasted) update URL.
|
||||
|
||||
* Older Hubs stored "...&a=${IP}": the address was looked up locally and
|
||||
substituted. Njal.la can use the address the request came from, so that
|
||||
placeholder becomes "&auto".
|
||||
* "&quiet" is dropped: the reply is how we learn the address Njal.la recorded.
|
||||
"""
|
||||
return raw.replace("&a=${IP}", "&auto").replace("&quiet", "")
|
||||
|
||||
|
||||
def parse_reply(body: str):
|
||||
"""Return the public IPv4 address Njal.la says it recorded, or None.
|
||||
|
||||
A successful update replies with JSON of the form
|
||||
{"status": 200, "message": "record updated", "value": {"A": "203.0.113.7", ...}}
|
||||
"""
|
||||
try:
|
||||
data = json.loads(body)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not isinstance(data, dict) or str(data.get("status")) != "200":
|
||||
return None
|
||||
value = data.get("value")
|
||||
ip = value.get("A") if isinstance(value, dict) else None
|
||||
return str(ip).strip() if is_public_ipv4(ip) else None
|
||||
|
||||
|
||||
def read_ip_file(path: str = None):
|
||||
"""The address recorded by the last successful update, or None."""
|
||||
try:
|
||||
with open(path or IP_FILE) as f:
|
||||
return f.read().strip() or None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def write_ip_file(ip: str, path: str = None) -> None:
|
||||
"""Replace the file atomically so a path watcher never sees a partial write."""
|
||||
path = path or IP_FILE
|
||||
directory = os.path.dirname(path)
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=directory, prefix=".external-ip-")
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(ip)
|
||||
os.chmod(tmp, 0o644)
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def update_all(urls, *, run=None, validate=_validate_ddns_url):
|
||||
"""Call every update URL once; return the address Njal.la reported, or None.
|
||||
|
||||
Nothing secret is printed: URLs are only ever referred to by position.
|
||||
"""
|
||||
run = run or subprocess.run # resolved per call so tests can substitute it
|
||||
reported = None
|
||||
seen = set()
|
||||
todo = []
|
||||
for raw in urls:
|
||||
url = normalise_url(raw)
|
||||
if url not in seen: # an old "&a=${IP}" entry and its "&auto" twin are one record
|
||||
seen.add(url)
|
||||
todo.append(url)
|
||||
for number, url in enumerate(todo, 1):
|
||||
try:
|
||||
validate(url)
|
||||
proc = run(
|
||||
["curl", "--silent", "--ipv4", "--max-time", "15", "--fail", "--no-location", url],
|
||||
capture_output=True, text=True, timeout=20, check=False,
|
||||
)
|
||||
except Exception:
|
||||
print(f"DDNS update {number}/{len(todo)}: skipped (invalid URL or curl unavailable)")
|
||||
continue
|
||||
if proc.returncode != 0:
|
||||
print(f"DDNS update {number}/{len(todo)}: failed (curl exit {proc.returncode})")
|
||||
continue
|
||||
ip = parse_reply(proc.stdout)
|
||||
if ip is None:
|
||||
print(f"DDNS update {number}/{len(todo)}: Njal.la did not report a public IPv4 address")
|
||||
continue
|
||||
print(f"DDNS update {number}/{len(todo)}: ok")
|
||||
if reported is None:
|
||||
reported = ip
|
||||
elif ip != reported:
|
||||
print("DDNS: Njal.la reported different addresses for different records; using the first")
|
||||
return reported
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
with open(URLS_FILE) as f:
|
||||
urls = json.load(f)
|
||||
if not isinstance(urls, list):
|
||||
raise ValueError("not a list")
|
||||
except Exception:
|
||||
return 0 # no URLs configured -- nothing to do
|
||||
urls = [u for u in urls if isinstance(u, str)]
|
||||
if not urls:
|
||||
return 0
|
||||
|
||||
ip = update_all(urls)
|
||||
if ip is None:
|
||||
print("DDNS: no address reported by Njal.la; keeping the last known one")
|
||||
return 0
|
||||
previous = read_ip_file()
|
||||
if ip == previous:
|
||||
print(f"DDNS: public IP unchanged ({ip})")
|
||||
return 0
|
||||
write_ip_file(ip)
|
||||
print(f"DDNS: public IP is now {ip} (was {previous or 'unknown'})")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,637 @@
|
||||
#!/usr/bin/env bash
|
||||
# ── Sovran Hub External Backup Script ────────────────────────────
|
||||
# Backs up Sovran_SystemsOS data to an external USB hard drive using rsync.
|
||||
# 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).
|
||||
#
|
||||
# Usage:
|
||||
# BACKUP_TARGET=/run/media/<user>/<drive> bash sovran-hub-backup.sh
|
||||
# (or run with no env var to auto-detect the first external USB drive)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BACKUP_LOG="/var/log/sovran-hub-backup.log"
|
||||
BACKUP_STATUS="/var/log/sovran-hub-backup.status"
|
||||
MEDIA_ROOT="/run/media"
|
||||
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"
|
||||
|
||||
# ── Logging helpers ──────────────────────────────────────────────
|
||||
|
||||
log() {
|
||||
local msg
|
||||
msg="[$(date '+%Y-%m-%d %H:%M:%S')] $*"
|
||||
echo "$msg" | tee -a "$BACKUP_LOG"
|
||||
}
|
||||
|
||||
set_status() {
|
||||
echo "$1" > "$BACKUP_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"
|
||||
for internal in "${INTERNAL_MOUNTS[@]}"; do
|
||||
if [[ "$mnt" == "$internal" || "$mnt" == "${internal}/"* ]]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Use lsblk to find the first genuine external USB drive ───────
|
||||
|
||||
find_external_drive() {
|
||||
local target=""
|
||||
|
||||
while IFS=$'\t' read -r dev_type hotplug removable label mountpoint; do
|
||||
[[ "$dev_type" == "part" || "$dev_type" == "disk" ]] || continue
|
||||
[[ "$hotplug" == "1" || "$removable" == "1" ]] || continue
|
||||
[[ -n "$mountpoint" ]] || continue
|
||||
|
||||
local skip=0
|
||||
for lbl in "${INTERNAL_LABELS[@]}"; do
|
||||
[[ "$label" == "$lbl" ]] && skip=1 && break
|
||||
done
|
||||
[[ "$skip" -eq 1 ]] && continue
|
||||
|
||||
is_internal "$mountpoint" && continue
|
||||
|
||||
if mountpoint -q "$mountpoint" 2>/dev/null; then
|
||||
target="$mountpoint"
|
||||
break
|
||||
fi
|
||||
done < <(lsblk -J -o NAME,LABEL,MOUNTPOINT,HOTPLUG,RM,TYPE 2>/dev/null | \
|
||||
python3 -c "
|
||||
import sys, json
|
||||
|
||||
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([
|
||||
d.get('type') or '',
|
||||
str(d.get('hotplug') or '0'),
|
||||
str(d.get('rm') or '0'),
|
||||
d.get('label') or '',
|
||||
d.get('mountpoint') or '',
|
||||
]))
|
||||
" 2>/dev/null || true)
|
||||
|
||||
if [[ -z "$target" && -d "$MEDIA_ROOT" ]]; then
|
||||
while IFS= read -r -d '' mnt; do
|
||||
is_internal "$mnt" && continue
|
||||
if mountpoint -q "$mnt" 2>/dev/null; then
|
||||
target="$mnt"
|
||||
break
|
||||
fi
|
||||
done < <(find "$MEDIA_ROOT" -mindepth 2 -maxdepth 2 -type d -print0 2>/dev/null)
|
||||
fi
|
||||
|
||||
echo "$target"
|
||||
}
|
||||
|
||||
# ── Detect the configured system role ───────────────────────────
|
||||
|
||||
detect_role() {
|
||||
local role="server_plus_desktop"
|
||||
|
||||
if [[ -f "$HUB_CONFIG_JSON" ]] && command -v python3 &>/dev/null; then
|
||||
local r
|
||||
r=$(python3 -c \
|
||||
"import json,sys; d=json.load(open(sys.argv[1])); print(d.get('role',''))" \
|
||||
"$HUB_CONFIG_JSON" 2>/dev/null || true)
|
||||
if [[ -n "$r" ]]; then
|
||||
echo "$r"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -f "$ROLE_STATE_NIX" ]]; then
|
||||
if grep -q 'roles\.desktop = lib\.mkDefault true' "$ROLE_STATE_NIX" 2>/dev/null; then
|
||||
role="desktop"
|
||||
elif grep -q 'roles\.node = lib\.mkDefault true' "$ROLE_STATE_NIX" 2>/dev/null; then
|
||||
role="node"
|
||||
fi
|
||||
fi
|
||||
|
||||
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"
|
||||
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)"
|
||||
case "$ROLE" in
|
||||
desktop) ROLE_LABEL="Desktop Only" ;;
|
||||
node) ROLE_LABEL="Node (Bitcoin-only)" ;;
|
||||
server_plus_desktop) ROLE_LABEL="Server + Desktop" ;;
|
||||
*) ROLE_LABEL="$ROLE" ;;
|
||||
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"
|
||||
if is_internal "$TARGET"; then
|
||||
fail "Target '$TARGET' is an internal system drive and cannot be used for external backup."
|
||||
fi
|
||||
log "Using specified backup target: $TARGET"
|
||||
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."
|
||||
fi
|
||||
log "Detected external drive: $TARGET"
|
||||
fi
|
||||
|
||||
validate_target_mount "$TARGET"
|
||||
|
||||
# ── Set up stable backup destination ────────────────────────────
|
||||
# Subsequent runs update the same mirror, transferring only new or changed files.
|
||||
|
||||
BACKUP_DIR="${TARGET}/${BACKUP_SUBPATH}"
|
||||
mkdir -p -- "$BACKUP_DIR"
|
||||
|
||||
# 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"
|
||||
|
||||
# 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"
|
||||
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 ────────────────────────────────
|
||||
|
||||
log ""
|
||||
log "── Stage 1/${TOTAL_STAGES}: NixOS configuration (/etc/nixos) ──────────────"
|
||||
if [[ -d /etc/nixos ]]; then
|
||||
sync_tree "/etc/nixos" no /etc/nixos/ "$BACKUP_DIR/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.
|
||||
|
||||
if [[ "$ROLE" != "desktop" ]]; then
|
||||
log ""
|
||||
log "── Stage 2/${TOTAL_STAGES}: Secrets (/etc/nix-bitcoin-secrets) ───────────"
|
||||
if [[ -e /etc/nix-bitcoin-secrets ]]; then
|
||||
sync_tree "/etc/nix-bitcoin-secrets" no /etc/nix-bitcoin-secrets/ "$BACKUP_DIR/etc/nix-bitcoin-secrets/"
|
||||
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 ""
|
||||
log "── Stage ${HOME_STAGE_NUM}/${TOTAL_STAGES}: Home directory (/home) ───────────────────────"
|
||||
if [[ -d /home ]]; then
|
||||
sync_tree "/home" yes /home/ "$BACKUP_DIR/home/" \
|
||||
--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."
|
||||
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.
|
||||
|
||||
if [[ "$ROLE" != "desktop" ]]; then
|
||||
log ""
|
||||
log "── Stage 4/${TOTAL_STAGES}: System data (/var/lib) ───────────────────────"
|
||||
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/' \
|
||||
--exclude='*/logs/' \
|
||||
--exclude='*/cache/' \
|
||||
--exclude='*/tmp/'
|
||||
log "Stage 4 complete."
|
||||
else
|
||||
log "WARNING: /var/lib not found — skipping."
|
||||
fi
|
||||
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 "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"
|
||||
|
||||
# ── 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"
|
||||
@@ -0,0 +1,526 @@
|
||||
"""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
|
||||
@@ -0,0 +1,197 @@
|
||||
/* 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. */
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
: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;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
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;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
min-height: 100vh;
|
||||
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-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
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);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
width: 78px;
|
||||
height: 78px;
|
||||
margin-bottom: 18px;
|
||||
filter: drop-shadow(0 8px 24px rgba(28, 196, 120, 0.3));
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 650;
|
||||
color: var(--text-2);
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border-radius: 99px;
|
||||
background: linear-gradient(180deg, #3fd68e, #1ea263);
|
||||
color: #04220f;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
margin-top: 8px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.28), 0 6px 18px rgba(35, 199, 124, 0.22);
|
||||
}
|
||||
|
||||
.btn-login:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.login-error {
|
||||
background: rgba(246, 97, 81, 0.10);
|
||||
border: 1px solid rgba(246, 97, 81, 0.35);
|
||||
color: #f8a39b;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.login-error.visible {
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/* ── Buttons ────────────────────────────────────────────────────── */
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
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;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.55;
|
||||
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;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-primary:active:not(:disabled) { transform: scale(0.97); }
|
||||
|
||||
/* Ghost — the default secondary action (legacy: btn-close-modal / btn-save) */
|
||||
.btn-ghost,
|
||||
.btn-close-modal,
|
||||
.btn-save {
|
||||
background: transparent;
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled),
|
||||
.btn-close-modal:hover:not(:disabled),
|
||||
.btn-save:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.26);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
/* Blue — restart / retry actions (amber reads as an error) */
|
||||
.btn-amber,
|
||||
.btn-reboot,
|
||||
.btn-restart-amber,
|
||||
.btn-warning {
|
||||
background: rgba(120, 174, 237, 0.10);
|
||||
border-color: rgba(120, 174, 237, 0.35);
|
||||
color: #a5cbf2;
|
||||
}
|
||||
|
||||
.btn-amber:hover:not(:disabled),
|
||||
.btn-reboot:hover:not(:disabled),
|
||||
.btn-restart-amber:hover:not(:disabled),
|
||||
.btn-warning:hover:not(:disabled) {
|
||||
background: rgba(120, 174, 237, 0.18);
|
||||
border-color: rgba(120, 174, 237, 0.55);
|
||||
color: #c2dcf8;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: rgba(246, 97, 81, 0.10);
|
||||
border-color: rgba(246, 97, 81, 0.35);
|
||||
color: #f8a39b;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: rgba(246, 97, 81, 0.18);
|
||||
border-color: rgba(246, 97, 81, 0.5);
|
||||
}
|
||||
|
||||
.btn-sm { padding: 6px 14px; font-size: 0.77rem; }
|
||||
|
||||
/* ── Header / topbar buttons ────────────────────────────────────── */
|
||||
|
||||
.btn-header-reboot {
|
||||
background: rgba(120, 174, 237, 0.10);
|
||||
border: 1px solid rgba(120, 174, 237, 0.35);
|
||||
color: #a5cbf2;
|
||||
font-size: 0.77rem;
|
||||
font-weight: 700;
|
||||
padding: 6px 14px;
|
||||
border-radius: 99px;
|
||||
}
|
||||
|
||||
.btn-header-reboot:hover:not(:disabled) {
|
||||
background: rgba(120, 174, 237, 0.18);
|
||||
border-color: rgba(120, 174, 237, 0.55);
|
||||
}
|
||||
|
||||
.btn-logout {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--text-2);
|
||||
font-size: 0.77rem;
|
||||
font-weight: 700;
|
||||
padding: 6px 14px;
|
||||
border-radius: 99px;
|
||||
}
|
||||
|
||||
.btn-logout:hover {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.26);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
/* ── Update System button (sidebar) ─────────────────────────────── */
|
||||
|
||||
.btn-update {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--text-2);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-update:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.26);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.btn-update.has-updates {
|
||||
background: rgba(66, 243, 154, 0.12);
|
||||
border-color: rgba(66, 243, 154, 0.4);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.update-badge {
|
||||
display: none;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background-color: var(--amber);
|
||||
border-radius: 50%;
|
||||
animation: pulse-badge 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.update-badge.visible {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@keyframes pulse-badge {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(1.35); }
|
||||
}
|
||||
|
||||
/* ── Icon buttons ───────────────────────────────────────────────── */
|
||||
|
||||
.btn-icon {
|
||||
background: none;
|
||||
color: var(--text-2);
|
||||
padding: 6px;
|
||||
border-radius: 50%;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.btn-icon:hover:not(:disabled) {
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
/* ── Domain setup modal ──────────────────────────────────────────── */
|
||||
|
||||
domain-narrow-dialog {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
domain-field-group {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
domain-field-label {
|
||||
display: block;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
domain-field-input {
|
||||
width: 100%;
|
||||
background-color: var(--inset);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
font-size: 0.9rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
domain-field-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
domain-field-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ── Port Requirements modal ─────────────────────────────────────── */
|
||||
|
||||
.domain-narrow-dialog {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.domain-setup-intro {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.domain-setup-intro ol {
|
||||
padding-left: 20px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.domain-setup-intro li {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.domain-field-group {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.domain-field-label {
|
||||
display: block;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.domain-field-input {
|
||||
width: 100%;
|
||||
background-color: var(--inset);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
font-size: 0.9rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.domain-field-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.domain-field-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 4px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.domain-field-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.port-req-intro {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.port-req-hint {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.6;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.port-req-internal-ip {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||
font-weight: 700;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.port-req-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82rem;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.port-req-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);
|
||||
}
|
||||
|
||||
.port-req-table td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid rgba(30, 45, 39, 0.5);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.port-req-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.port-req-port {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||
font-weight: 600;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.port-req-proto {
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.port-req-desc {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/* ── Sidebar: Feature Manager + Preferences ─────────────────────── */
|
||||
|
||||
.category-section.autolaunch-section,
|
||||
.category-section.feature-manager-section {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* The old "Preferences" header + hr becomes a small sidebar label.
|
||||
features.js renders: .section-header + hr.section-divider + cards */
|
||||
.autolaunch-section .section-header,
|
||||
.feature-manager-section .section-header {
|
||||
font-size: 0.66rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
margin: 0;
|
||||
padding: 14px 10px 7px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.autolaunch-section .section-divider,
|
||||
.feature-manager-section .section-divider {
|
||||
background: none;
|
||||
height: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.feature-cards-wrap { display: flex; flex-direction: column; }
|
||||
|
||||
.feature-subcategory { display: flex; flex-direction: column; }
|
||||
|
||||
.feature-subcategory-header {
|
||||
font-size: 0.66rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
padding: 12px 10px 6px;
|
||||
}
|
||||
|
||||
/* Feature card — sidebar preference row with a switch */
|
||||
.feature-card {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
padding: 9px 10px;
|
||||
border-radius: 10px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.feature-card:hover { background: rgba(255, 255, 255, 0.05); }
|
||||
|
||||
.feature-card-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.feature-card-info { min-width: 0; flex: 1; }
|
||||
|
||||
.feature-card-name {
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.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);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Domain badges inside feature cards */
|
||||
.feature-domain-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.64rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.feature-domain-badge.configured {
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.feature-domain-badge.not-configured {
|
||||
color: var(--amber);
|
||||
background: rgba(233, 182, 74, 0.10);
|
||||
}
|
||||
|
||||
.feature-domain-icon { font-size: 0.8rem; }
|
||||
|
||||
.feature-domain-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 650;
|
||||
margin-top: 5px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.feature-domain-label--ok { color: var(--accent); }
|
||||
.feature-domain-label--warn { color: var(--amber); }
|
||||
.feature-domain-label--error { color: var(--red); }
|
||||
.feature-domain-label--checking { color: var(--blue); }
|
||||
|
||||
/* ── Toggle switch (libadwaita style) ───────────────────────────── */
|
||||
|
||||
.feature-toggle {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 46px;
|
||||
height: 26px;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
margin-top: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.feature-toggle-input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.feature-toggle-slider::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
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);
|
||||
}
|
||||
|
||||
.feature-toggle-input:checked + .feature-toggle-slider {
|
||||
background: var(--accent);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.feature-toggle-input:checked + .feature-toggle-slider::after {
|
||||
left: 23px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.feature-toggle-input:focus-visible + .feature-toggle-slider {
|
||||
box-shadow: 0 0 0 3px rgba(66, 243, 154, 0.3);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/* ── Topbar (replaces the old header-bar + ip-bar) ──────────────── */
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 13px 26px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.02rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Search ─────────────────────────────────────────────────────── */
|
||||
|
||||
.search-box {
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
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;
|
||||
}
|
||||
|
||||
.os-version-badge {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-2);
|
||||
font-size: 0.66rem;
|
||||
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;
|
||||
}
|
||||
.btn-logout {
|
||||
padding: 5px 10px;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,668 @@
|
||||
/* ── App shell: sidebar + main column ───────────────────────────── */
|
||||
|
||||
.app {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 26px 30px 70px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Sidebar ────────────────────────────────────────────────────── */
|
||||
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
flex-shrink: 0;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px 14px 14px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 2px 8px 20px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
flex-shrink: 0;
|
||||
filter: drop-shadow(0 4px 14px rgba(28, 196, 120, 0.25));
|
||||
}
|
||||
|
||||
.brand-text { display: flex; flex-direction: column; min-width: 0; }
|
||||
|
||||
.brand-title {
|
||||
font-size: 1.02rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.brand-title em { font-style: normal; color: var(--accent); }
|
||||
|
||||
.brand-sub {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-3);
|
||||
font-family: var(--mono);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 0.66rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
padding: 14px 10px 7px;
|
||||
}
|
||||
|
||||
/* Category nav (dashboard.js) */
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text-2);
|
||||
font-size: 0.87rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.nav-item svg { width: 17px; height: 17px; flex-shrink: 0; }
|
||||
|
||||
.nav-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text);
|
||||
box-shadow: inset 2.5px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
.nav-text {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.nav-count {
|
||||
margin-left: auto;
|
||||
font-size: 0.67rem;
|
||||
font-weight: 750;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-3);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
}
|
||||
|
||||
.nav-item.active .nav-count {
|
||||
background: rgba(255, 255, 255, 0.10);
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
/* System items (tiles.js renders these as .sidebar-support-btn) */
|
||||
.sidebar-support-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 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, border-color 0.15s;
|
||||
}
|
||||
|
||||
.sidebar-support-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sidebar-support-btn + .sidebar-support-btn {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.sidebar-support-icon {
|
||||
font-size: 1rem;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-support-icon svg { width: 17px; height: 17px; }
|
||||
|
||||
.sidebar-support-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.sidebar-divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 14px 0 0;
|
||||
}
|
||||
|
||||
.sidebar-spacer { flex: 1; min-height: 18px; }
|
||||
|
||||
/* ── 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);
|
||||
}
|
||||
|
||||
/* Soft drifting glow — calm, GPU-cheap (transforms only) */
|
||||
.welcome-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.orb-a {
|
||||
width: 560px; height: 560px;
|
||||
left: -140px; top: -180px;
|
||||
background: radial-gradient(circle, rgba(62, 207, 142, 0.10), transparent 70%);
|
||||
animation: orb-drift-a 26s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.orb-b {
|
||||
width: 680px; height: 680px;
|
||||
right: -200px; top: 20%;
|
||||
background: radial-gradient(circle, rgba(120, 174, 237, 0.07), transparent 70%);
|
||||
animation: orb-drift-b 34s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.orb-c {
|
||||
width: 460px; height: 460px;
|
||||
left: 34%; bottom: -200px;
|
||||
background: radial-gradient(circle, rgba(66, 243, 154, 0.06), transparent 70%);
|
||||
animation: orb-drift-c 42s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes orb-drift-a { to { transform: translate(70px, 50px) scale(1.14); } }
|
||||
@keyframes orb-drift-b { to { transform: translate(-60px, -70px) scale(1.10); } }
|
||||
@keyframes orb-drift-c { to { transform: translate(50px, -40px) scale(1.18); } }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.orb { animation: none; }
|
||||
.welcome-inner { animation: none; }
|
||||
}
|
||||
|
||||
/* ── Boot splash ────────────────────────────────────────────────── */
|
||||
/* Covers the shell while the first services/config data loads; the
|
||||
inline script in index.html lifts it (window.__liftAppSplash). */
|
||||
#app-splash {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity 0.45s ease;
|
||||
}
|
||||
|
||||
#app-splash.done { opacity: 0; pointer-events: none; }
|
||||
|
||||
.splash-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.splash-ring {
|
||||
position: relative;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@keyframes splash-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.splash-title {
|
||||
font-size: 1.02rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.splash-sub {
|
||||
font-size: 0.84rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.splash-ring-arc { animation-duration: 2.5s; }
|
||||
}
|
||||
|
||||
.welcome-inner {
|
||||
position: relative;
|
||||
max-width: 1040px;
|
||||
margin: 0 auto;
|
||||
animation: welcome-in 0.5s ease both;
|
||||
}
|
||||
|
||||
@keyframes welcome-in {
|
||||
from { opacity: 0; transform: translateY(14px); }
|
||||
}
|
||||
|
||||
.welcome-greeting {
|
||||
font-size: 0.98rem;
|
||||
font-weight: 650;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
margin-top: 6px;
|
||||
font-size: clamp(1.7rem, 2.8vw, 2.25rem);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.18;
|
||||
}
|
||||
|
||||
.welcome-title em { font-style: normal; color: var(--accent); }
|
||||
|
||||
.welcome-meta {
|
||||
margin-top: 12px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-3);
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
.welcome-meta-sep { margin: 0 8px; opacity: 0.6; }
|
||||
|
||||
.welcome-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-auto-rows: 1fr; /* both rows share the taller height → uniform cards */
|
||||
gap: 16px;
|
||||
margin-top: 34px;
|
||||
}
|
||||
|
||||
/* Three cards (Desktop-only role, no Bitcoin card) → one symmetric row */
|
||||
@media (min-width: 681px) {
|
||||
.welcome-cards:not(:has(#w-btc)) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* wrappers whose children become grid items */
|
||||
.welcome-dyn { display: contents; }
|
||||
|
||||
.w-network .net-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
.w-network .net-k {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-3);
|
||||
width: 30px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.w-network .ip-value {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.w-network .sub { margin-top: 8px; }
|
||||
|
||||
.welcome-browse {
|
||||
margin-top: 26px;
|
||||
}
|
||||
|
||||
.widget {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 17px 19px;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03), var(--shadow-card);
|
||||
transition: border-color 0.18s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
min-height: 88px; /* uniform card height everywhere */
|
||||
}
|
||||
|
||||
.widget:hover { border-color: var(--border-strong); }
|
||||
.widget.clickable:hover { transform: translateY(-1px); }
|
||||
|
||||
.widget.clickable { cursor: pointer; }
|
||||
.widget.clickable:focus-visible {
|
||||
outline: 2px solid rgba(66, 243, 154, 0.45);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.widget-chip {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 14px;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 6px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.widget-chip svg { width: 22px; height: 22px; }
|
||||
.widget-chip img { width: 46px; height: 46px; display: block; object-fit: contain; }
|
||||
|
||||
.widget-chip.chip-green {
|
||||
background: linear-gradient(160deg, #2f9f6b, #17683f);
|
||||
}
|
||||
|
||||
.widget-chip.chip-btc {
|
||||
background: linear-gradient(160deg, #f7931a, #b96a0a);
|
||||
}
|
||||
|
||||
.widget-chip.chip-amber {
|
||||
background: linear-gradient(160deg, #b98426, #8a5f16);
|
||||
}
|
||||
|
||||
.widget-chip.chip-sovran {
|
||||
background: linear-gradient(160deg, #42f39a, #1aa45d);
|
||||
}
|
||||
|
||||
.widget-chip.chip-neutral {
|
||||
background: linear-gradient(160deg, #2e333a, #23272c);
|
||||
}
|
||||
|
||||
.w-body { min-width: 0; }
|
||||
|
||||
.widget h3 {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.005em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.widget .sub {
|
||||
margin-top: 4px;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
color: var(--text-2);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.widget .sub b { color: var(--text); font-weight: 650; }
|
||||
.widget .sub .good { color: var(--accent); font-weight: 650; }
|
||||
.widget .sub .warn { color: var(--amber); font-weight: 650; }
|
||||
|
||||
.w-bar {
|
||||
height: 7px;
|
||||
border-radius: 99px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
overflow: hidden;
|
||||
margin: 8px 0 7px;
|
||||
}
|
||||
|
||||
.w-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 99px;
|
||||
background: linear-gradient(90deg, #42f39a, #1aa45d);
|
||||
transition: width 0.6s cubic-bezier(0.3, 0.7, 0.3, 1);
|
||||
}
|
||||
|
||||
|
||||
/* ── Tiles area ─────────────────────────────────────────────────── */
|
||||
|
||||
#tiles-area {
|
||||
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 {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
border: none;
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, var(--border), transparent);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tiles-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(158px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
/* ── Empty state ────────────────────────────────────────────────── */
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 64px 24px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ── Upgrade modal (kept from previous layout) ──────────────────── */
|
||||
|
||||
.upgrade-dialog {
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.upgrade-info-box {
|
||||
background: rgba(0, 0, 0, 0.16);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 16px;
|
||||
padding: 14px 16px;
|
||||
margin: 14px 0;
|
||||
}
|
||||
|
||||
.upgrade-info-title {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.upgrade-info-list {
|
||||
padding-left: 20px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-2);
|
||||
line-height: 1.7;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.upgrade-info-list a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.upgrade-rebuild-note {
|
||||
font-style: italic;
|
||||
color: var(--text-3);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
/* ── First-login security banner (inside .content) ──────────────── */
|
||||
|
||||
.security-first-login-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: rgba(233, 182, 74, 0.08);
|
||||
border: 1px solid rgba(233, 182, 74, 0.30);
|
||||
border-radius: 16px;
|
||||
padding: 12px 16px;
|
||||
color: var(--text-2);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* ── Narrow viewports (phone / half-screen RDP) ──────────────────── */
|
||||
/* The sidebar becomes an icon rail so the content keeps room, and the
|
||||
topbar wraps its search onto a second row instead of overflowing. */
|
||||
@media (max-width: 920px) {
|
||||
.sidebar {
|
||||
width: 76px;
|
||||
padding: 16px 8px 12px;
|
||||
}
|
||||
|
||||
.brand { justify-content: center; padding: 2px 0 18px; gap: 0; }
|
||||
.brand-text,
|
||||
.nav-label,
|
||||
.nav-text,
|
||||
.nav-count,
|
||||
.sidebar-support-text,
|
||||
.sidebar-divider,
|
||||
#sidebar-features { display: none; }
|
||||
|
||||
.nav-item { justify-content: center; gap: 0; padding: 10px 0; }
|
||||
.sidebar-support-btn { justify-content: center; gap: 0; padding: 10px 0; }
|
||||
|
||||
.content { padding: 18px 18px 40px; }
|
||||
|
||||
/* keep the welcome background full-bleed against the new padding */
|
||||
.welcome {
|
||||
margin: -18px -18px -40px;
|
||||
padding: 48px 20px 40px;
|
||||
min-height: calc(100% + 58px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.topbar { flex-wrap: wrap; row-gap: 10px; padding: 12px 16px; }
|
||||
.search-box { order: 5; flex-basis: 100%; }
|
||||
|
||||
.welcome { padding: 40px 16px 36px; }
|
||||
.welcome-cards { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -0,0 +1,830 @@
|
||||
/* ── Onboarding wizard ──────────────────────────────────────────── */
|
||||
|
||||
.onboarding-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(ellipse at top, rgba(62, 207, 142, 0.04) 0%, transparent 50%),
|
||||
var(--bg-color);
|
||||
padding: 24px 16px 48px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.onboarding-shell {
|
||||
width: 100%;
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
|
||||
.onboarding-progress-bar {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background-color: var(--card-color);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.onboarding-progress-fill {
|
||||
height: 100%;
|
||||
background-color: var(--accent-color);
|
||||
border-radius: 2px;
|
||||
transition: width 0.4s ease;
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
/* Step indicator dots */
|
||||
|
||||
.onboarding-steps-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.onboarding-step-dot {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--card-color);
|
||||
border: 2px solid var(--border-color);
|
||||
color: var(--text-dim);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.2s, border-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.onboarding-step-dot.active {
|
||||
background-color: var(--accent-color);
|
||||
border-color: var(--accent-color);
|
||||
color: #04220f;
|
||||
}
|
||||
|
||||
.onboarding-step-dot.completed {
|
||||
background-color: var(--green);
|
||||
border-color: var(--green);
|
||||
color: #04220f;
|
||||
}
|
||||
|
||||
.onboarding-step-connector {
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
background-color: var(--border-color);
|
||||
min-width: 24px;
|
||||
max-width: 80px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
/* Panel wrapper and panels */
|
||||
|
||||
.onboarding-panel-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.onboarding-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
animation: panel-fade-in 0.25s ease-out;
|
||||
}
|
||||
|
||||
@keyframes panel-fade-in {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Hero / welcome section */
|
||||
|
||||
.onboarding-hero {
|
||||
text-align: center;
|
||||
padding: 16px 0 4px;
|
||||
}
|
||||
|
||||
.onboarding-logo {
|
||||
margin-bottom: 16px;
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.onboarding-logo-img {
|
||||
height: 90px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.onboarding-title {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.onboarding-subtitle {
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
|
||||
.onboarding-card {
|
||||
background-color: rgba(18, 24, 20, 0.65);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: var(--radius-card);
|
||||
padding: 24px 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Body text */
|
||||
|
||||
.onboarding-body-text {
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.onboarding-body-text--dim {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Role row */
|
||||
|
||||
.onboarding-role-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background-color: var(--card-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.onboarding-role-label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.onboarding-role-badge {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-color);
|
||||
background-color: rgba(62, 207, 142, 0.10);
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(62, 207, 142, 0.25);
|
||||
}
|
||||
|
||||
/* Step header */
|
||||
|
||||
.onboarding-step-header {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.onboarding-step-icon {
|
||||
font-size: 2rem;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.onboarding-step-title {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.onboarding-step-desc {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Footer / navigation */
|
||||
|
||||
.onboarding-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 24px;
|
||||
padding-bottom: 24px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.onboarding-btn-next {
|
||||
/* inherits from .btn.btn-primary */
|
||||
}
|
||||
|
||||
.onboarding-btn-back {
|
||||
/* inherits from .btn.btn-close-modal */
|
||||
}
|
||||
|
||||
/* Domain configuration (Step 2) */
|
||||
|
||||
.onboarding-domain-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.onboarding-domain-group:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.onboarding-domain-group--email {
|
||||
/* email-specific variant */
|
||||
}
|
||||
|
||||
.onboarding-domain-label {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.onboarding-domain-label--sub {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.onboarding-domain-input,
|
||||
.domain-field-input {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--card-color);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.88rem;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.onboarding-domain-input:focus,
|
||||
.domain-field-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.onboarding-hint {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.onboarding-hint--inline {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Port forwarding (Step 3) */
|
||||
|
||||
.onboarding-port-note {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.onboarding-port-ip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background-color: var(--card-color);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.onboarding-port-ip-label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.onboarding-port-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.onboarding-port-section-title {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.onboarding-port-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.onboarding-port-table thead th {
|
||||
text-align: left;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.onboarding-port-table td {
|
||||
padding: 8px 8px;
|
||||
border-bottom: 1px solid rgba(30, 45, 39, 0.5);
|
||||
color: var(--text-secondary);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.onboarding-port-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.onboarding-port-totals {
|
||||
padding: 10px 14px;
|
||||
background-color: var(--card-color);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.onboarding-port-warn {
|
||||
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.85rem;
|
||||
color: var(--yellow);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.onboarding-port-details {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.onboarding-port-details-summary {
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-color);
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.onboarding-port-details-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.onboarding-port-details-summary::before {
|
||||
content: '▶ ';
|
||||
font-size: 0.65em;
|
||||
}
|
||||
|
||||
.onboarding-port-details[open] .onboarding-port-details-summary::before {
|
||||
content: '▼ ';
|
||||
}
|
||||
|
||||
/* Credentials (Step 4) */
|
||||
|
||||
.onboarding-creds-notice {
|
||||
padding: 12px 16px;
|
||||
background-color: rgba(62, 207, 142, 0.08);
|
||||
border: 1px solid rgba(62, 207, 142, 0.20);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.onboarding-creds-category {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.onboarding-creds-category:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.onboarding-creds-category-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.onboarding-creds-service {
|
||||
background-color: var(--card-color);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.onboarding-creds-service-name {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.onboarding-cred-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.onboarding-cred-label {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.78rem;
|
||||
min-width: 80px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.onboarding-cred-value {
|
||||
color: var(--text-primary);
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 0.82rem;
|
||||
word-break: break-all;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.onboarding-cred-secret {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.onboarding-cred-hidden {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.onboarding-cred-real {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.onboarding-cred-reveal-btn {
|
||||
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);
|
||||
color: var(--accent-color);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.onboarding-cred-reveal-btn:hover {
|
||||
background-color: rgba(62, 207, 142, 0.15);
|
||||
}
|
||||
|
||||
/* Completion checklist (Step 5) */
|
||||
|
||||
.onboarding-checklist {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.onboarding-checklist li {
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Status & feedback */
|
||||
|
||||
.onboarding-loading {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
padding: 16px 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.onboarding-error {
|
||||
font-size: 0.85rem;
|
||||
color: var(--red);
|
||||
padding: 10px 14px;
|
||||
background-color: rgba(224, 27, 36, 0.1);
|
||||
border: 1px solid rgba(224, 27, 36, 0.3);
|
||||
border-radius: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.onboarding-save-status {
|
||||
font-size: 0.85rem;
|
||||
min-height: 1.2em;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.onboarding-save-status--ok {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.onboarding-save-status--error {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.onboarding-save-status--info {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ── Timezone / Locale step (Step 2) ────────────────────────────── */
|
||||
|
||||
.onboarding-tz-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.onboarding-tz-search {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--card-color);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.88rem;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.onboarding-tz-search:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.onboarding-tz-select {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--card-color);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.88rem;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
transition: border-color 0.15s;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.onboarding-tz-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.onboarding-tz-select option {
|
||||
padding: 4px 8px;
|
||||
background-color: var(--card-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.onboarding-locale-select {
|
||||
height: auto;
|
||||
size: auto;
|
||||
}
|
||||
|
||||
/* ── Password step (Step 3) ─────────────────────────────────────── */
|
||||
|
||||
.onboarding-password-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.onboarding-password-input-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.onboarding-password-input {
|
||||
flex: 1;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--card-color);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.88rem;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.onboarding-password-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.onboarding-password-toggle {
|
||||
padding: 6px 10px;
|
||||
background-color: var(--card-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-btn);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
transition: background-color 0.15s, border-color 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.onboarding-password-toggle:hover {
|
||||
background-color: rgba(62, 207, 142, 0.10);
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.onboarding-password-hint {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.onboarding-password-warning {
|
||||
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.85rem;
|
||||
color: var(--yellow);
|
||||
line-height: 1.5;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.onboarding-password-success {
|
||||
padding: 12px 16px;
|
||||
background-color: rgba(62, 207, 142, 0.10);
|
||||
border: 1px solid rgba(62, 207, 142, 0.30);
|
||||
border-radius: 8px;
|
||||
font-size: 0.92rem;
|
||||
color: var(--green);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.onboarding-password-optional {
|
||||
margin-top: 12px;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.onboarding-password-optional > summary {
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-color);
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.onboarding-password-optional > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.onboarding-password-optional > summary::before {
|
||||
content: '▶ ';
|
||||
font-size: 0.65em;
|
||||
}
|
||||
|
||||
.onboarding-password-optional[open] > summary::before {
|
||||
content: '▼ ';
|
||||
}
|
||||
|
||||
/* ── Reboot overlay ─────────────────────────────────────────────── */
|
||||
|
||||
.reboot-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(6, 8, 7, 0.7);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
z-index: 999;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.reboot-overlay.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.reboot-card {
|
||||
background-color: rgba(18, 24, 20, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 20px;
|
||||
padding: 48px 56px;
|
||||
text-align: center;
|
||||
max-width: 480px;
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.8);
|
||||
animation: reboot-fade-in 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes reboot-fade-in {
|
||||
from { opacity: 0; transform: scale(0.92) translateY(12px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
.reboot-icon {
|
||||
font-size: 3rem;
|
||||
color: var(--accent-color);
|
||||
margin-bottom: 16px;
|
||||
animation: reboot-spin 2s linear infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@keyframes reboot-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.reboot-title {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.reboot-message {
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.reboot-dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.reboot-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--accent-color);
|
||||
animation: reboot-bounce 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.reboot-dot:nth-child(2) { animation-delay: 0.2s; }
|
||||
.reboot-dot:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes reboot-bounce {
|
||||
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
40% { opacity: 1; transform: scale(1.2); }
|
||||
}
|
||||
|
||||
.reboot-submessage {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── 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. */
|
||||
@@ -0,0 +1,460 @@
|
||||
/* ── Security Modal sections ──────────────────────────────────────── */
|
||||
|
||||
.security-section {
|
||||
padding: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.security-section-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
.security-section-desc {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.security-divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin: 18px 0;
|
||||
}
|
||||
|
||||
/* ── Security Reset warning box ──────────────────────────────────── */
|
||||
|
||||
.security-warning-box {
|
||||
background-color: rgba(180, 40, 40, 0.10);
|
||||
border-left: 3px solid var(--red);
|
||||
border-radius: 6px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.security-warning-text {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 8px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.security-warning-list {
|
||||
margin: 6px 0 6px 20px;
|
||||
padding: 0;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.security-erase-group {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.security-erase-label {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.security-erase-input {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--input-bg, var(--card-color));
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.security-reset-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--red);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 18px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background-color: var(--red);
|
||||
}
|
||||
|
||||
.btn-danger:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.security-status-msg {
|
||||
font-size: 0.83rem;
|
||||
margin-top: 10px;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
.security-status-info { color: var(--text-secondary); }
|
||||
.security-status-ok { color: var(--accent); }
|
||||
.security-status-error { color: var(--red); }
|
||||
|
||||
/* ── Verify System Integrity ─────────────────────────────────────── */
|
||||
|
||||
.security-verify-list {
|
||||
margin: 8px 0 10px 20px;
|
||||
padding: 0;
|
||||
font-size: 0.84rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.security-verify-loading {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.security-verify-result-card {
|
||||
background: var(--card-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.security-verify-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.security-verify-label {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
min-width: 130px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.security-verify-value {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.security-verify-mono {
|
||||
font-family: monospace;
|
||||
font-size: 0.78rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.security-verify-link {
|
||||
font-size: 0.78rem;
|
||||
color: var(--accent-color, var(--accent));
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.security-verify-link:hover { text-decoration: underline; }
|
||||
|
||||
.security-verify-badge {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.security-verify-pass {
|
||||
background-color: rgba(66, 243, 154, 0.15);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.security-verify-fail {
|
||||
background-color: rgba(224, 82, 82, 0.15);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.security-verify-errors {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.security-verify-pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-size: 0.75rem;
|
||||
background: var(--card-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
margin-top: 6px;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.security-verify-path-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 0.78rem;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.security-verify-path-label {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
/* ── Security Reset full-screen overlay ──────────────────────────── */
|
||||
|
||||
.security-reset-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: radial-gradient(1000px 640px at 50% 18%, #202429, #0f1113 72%);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 26px;
|
||||
animation: security-reset-fade-in 0.35s ease-out;
|
||||
}
|
||||
|
||||
.security-reset-overlay.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@keyframes security-reset-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.security-reset-overlay-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ── Phase 2: password display box ──────────────────────────────── */
|
||||
|
||||
.security-reset-password-label {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 16px 0 8px 0;
|
||||
}
|
||||
|
||||
.security-reset-password-box {
|
||||
font-family: var(--mono);
|
||||
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;
|
||||
padding: 14px 24px;
|
||||
letter-spacing: 0.04em;
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
margin-bottom: 16px;
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.security-reset-password-warning {
|
||||
font-size: 0.84rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.security-reset-reboot-btn {
|
||||
background: linear-gradient(180deg, #3fd68e, #1ea263);
|
||||
color: #04220f;
|
||||
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);
|
||||
padding: 11px 22px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s, opacity 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.security-reset-reboot-btn:hover:not(:disabled) {
|
||||
background-color: var(--accent);
|
||||
}
|
||||
|
||||
.security-reset-reboot-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── First-login security banner ─────────────────────────────────── */
|
||||
|
||||
.security-first-login-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.security-banner-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.security-banner-icon {
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.security-banner-text {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.security-banner-dismiss {
|
||||
background: none;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 99px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 10px;
|
||||
flex-shrink: 0;
|
||||
line-height: 1.4;
|
||||
transition: 0.15s;
|
||||
}
|
||||
|
||||
.security-banner-dismiss:hover {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.26);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
/* ── Legacy security inline warning banner ───────────────────────── */
|
||||
|
||||
.security-inline-banner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 12px;
|
||||
background-color: rgba(180, 100, 0, 0.12);
|
||||
border-left: 3px solid #c98d08;
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.security-inline-icon {
|
||||
font-size: 1rem;
|
||||
color: #e0a010;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.security-inline-text {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.security-inline-link {
|
||||
display: inline-block;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: #e0a010;
|
||||
text-decoration: none;
|
||||
border: 1px solid #c98d08;
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
align-self: flex-start;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.security-inline-link:hover {
|
||||
background-color: rgba(180, 100, 0, 0.22);
|
||||
}
|
||||
|
||||
/* ── System change-password form extras ──────────────────────────── */
|
||||
|
||||
.sys-chpw-header {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.sys-chpw-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sys-chpw-desc {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.pw-input-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pw-input-wrap .matrix-form-input {
|
||||
padding-right: 2.4rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pw-toggle-btn {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
padding: 2px 4px;
|
||||
line-height: 1;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.75;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.pw-toggle-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.pw-hint {
|
||||
font-size: 0.76rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.pw-credentials-note {
|
||||
font-size: 0.78rem;
|
||||
color: #c98d08;
|
||||
background-color: rgba(180, 100, 0, 0.10);
|
||||
border-left: 2px solid #c98d08;
|
||||
border-radius: 4px;
|
||||
padding: 7px 10px;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
/* ── Tech Support modal ──────────────────────────────────────────── */
|
||||
|
||||
.support-section {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.support-icon-big {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.support-active-icon {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.support-heading {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.support-active-heading {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.support-desc {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 16px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.support-active-note {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.support-info-box {
|
||||
background-color: var(--card-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 16px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.support-active-box {
|
||||
border-color: var(--green);
|
||||
}
|
||||
|
||||
.support-info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.support-info-label {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.support-info-value {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||
font-size: 0.88rem;
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.support-info-hint {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 6px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.support-steps {
|
||||
text-align: left;
|
||||
margin-bottom: 16px;
|
||||
padding: 14px 18px;
|
||||
background-color: var(--card-color);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.support-steps-title {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.support-steps ol {
|
||||
padding-left: 20px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.support-steps code {
|
||||
background-color: rgba(62, 207, 142, 0.10);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.support-btn-enable {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--accent-color);
|
||||
color: #04220f;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.support-btn-enable:hover:not(:disabled) {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.support-btn-disable {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--red);
|
||||
color: #fff;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.support-btn-disable:hover:not(:disabled) {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.support-btn-done {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--accent-color);
|
||||
color: #04220f;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.support-btn-done:hover:not(:disabled) {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.support-btn-auditlog {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.support-btn-auditlog:hover:not(:disabled) {
|
||||
background-color: var(--card-hover);
|
||||
}
|
||||
|
||||
.support-fine-print {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.support-verify-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin: 16px 0;
|
||||
padding: 12px;
|
||||
background-color: var(--card-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.support-verify-label {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.support-verify-value {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.support-verify-value.verified-gone {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.support-verify-value.verify-warning {
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
/* ── Wallet protection ───────────────────────────────────────────── */
|
||||
|
||||
.support-wallet-box {
|
||||
text-align: left;
|
||||
padding: 14px 18px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.support-wallet-protected {
|
||||
background-color: rgba(66, 243, 154, 0.06);
|
||||
border-color: rgba(66, 243, 154, 0.3);
|
||||
}
|
||||
|
||||
.support-wallet-unlocked {
|
||||
background-color: rgba(233, 182, 74, 0.06);
|
||||
border-color: rgba(233, 182, 74, 0.3);
|
||||
}
|
||||
|
||||
.support-wallet-warning {
|
||||
background-color: rgba(224, 27, 36, 0.06);
|
||||
border-color: rgba(224, 27, 36, 0.3);
|
||||
}
|
||||
|
||||
.support-wallet-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.support-wallet-icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.support-wallet-title {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.support-wallet-desc {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.support-wallet-paths {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.support-wallet-paths li {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-dim);
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.support-wallet-unlock-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.support-unlock-select {
|
||||
background-color: var(--card-color);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.support-btn-wallet-unlock {
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--yellow);
|
||||
color: #04220f;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.support-btn-wallet-unlock:hover:not(:disabled) {
|
||||
background-color: #c98d08;
|
||||
}
|
||||
|
||||
.support-btn-wallet-lock {
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--green);
|
||||
color: #fff;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.support-btn-wallet-lock:hover:not(:disabled) {
|
||||
background-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Audit log ───────────────────────────────────────────────────── */
|
||||
|
||||
.support-audit-container {
|
||||
margin-top: 12px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.support-audit-log {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background-color: var(--inset);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.support-audit-entry {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-secondary);
|
||||
padding: 3px 0;
|
||||
border-bottom: 1px solid rgba(30, 45, 39, 0.4);
|
||||
}
|
||||
|
||||
.support-audit-entry:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.support-audit-empty {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* ── Tech Support tile ───────────────────────────────────────────── */
|
||||
|
||||
.support-tile {
|
||||
border-color: var(--border-color);
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.support-tile:hover {
|
||||
border-color: var(--accent-color);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
/* ── Manual Backup ───────────────────────────────────────────────── */
|
||||
|
||||
.support-backup-steps {
|
||||
padding-left: 20px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.8;
|
||||
margin: 8px 0 0 0;
|
||||
}
|
||||
|
||||
.support-backup-steps li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/* ── Service tiles ──────────────────────────────────────────────── */
|
||||
|
||||
.service-tile {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
padding: 21px 12px 17px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: transform 0.18s, border-color 0.18s, background 0.18s, box-shadow 0.18s, opacity 0.18s;
|
||||
animation: tileIn 0.45s cubic-bezier(0.2, 0.8, 0.3, 1) backwards;
|
||||
}
|
||||
|
||||
@keyframes tileIn {
|
||||
from { opacity: 0; transform: translateY(10px) scale(0.96); }
|
||||
}
|
||||
|
||||
.service-tile:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: var(--border-strong);
|
||||
background: var(--card-hover);
|
||||
box-shadow: var(--shadow-hover);
|
||||
}
|
||||
|
||||
.service-tile:active { transform: translateY(-1px) scale(0.985); }
|
||||
|
||||
.service-tile:focus-visible {
|
||||
outline: 2px solid rgba(66, 243, 154, 0.45);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.service-tile.disabled { opacity: 0.55; }
|
||||
|
||||
.tile-icon {
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.tile-icon-fallback {
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-3);
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tile-name {
|
||||
margin-top: 13px;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 650;
|
||||
text-align: center;
|
||||
line-height: 1.25;
|
||||
min-height: 2.5em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Status pill — dot + label inside a rounded pill */
|
||||
.tile-status {
|
||||
margin-top: 9px;
|
||||
display: inline-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);
|
||||
}
|
||||
|
||||
.status-text { line-height: 1; }
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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); }
|
||||
|
||||
@keyframes tile-dot-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
/* Progressive enhancement: tint the whole pill on modern browsers */
|
||||
.tile-status:has(.status-dot.active) { background: var(--accent-dim); color: var(--accent); }
|
||||
.tile-status:has(.status-dot.needs-attention) { background: rgba(233, 182, 74, 0.10); color: var(--amber); }
|
||||
.tile-status:has(.status-dot.failed) { background: rgba(246, 97, 81, 0.10); color: var(--red); }
|
||||
.tile-status:has(.status-dot.syncing),
|
||||
.tile-status:has(.status-dot.loading),
|
||||
.tile-status:has(.status-dot.checking-reachability) { background: rgba(120, 174, 237, 0.10); color: var(--blue); }
|
||||
|
||||
.support-status-label {
|
||||
font-size: 0.69rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
/* ── Bitcoin IBD sync tile ──────────────────────────────────────── */
|
||||
|
||||
.tile-sync-container {
|
||||
margin-top: 12px;
|
||||
width: 100%;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.tile-sync-label {
|
||||
font-size: 0.66rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--amber);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tile-sync-bar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
.tile-sync-bar-track {
|
||||
flex: 1;
|
||||
height: 7px;
|
||||
border-radius: 99px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
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);
|
||||
}
|
||||
|
||||
.tile-sync-percent {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
color: var(--amber);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tile-sync-eta {
|
||||
margin-top: 6px;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.66rem;
|
||||
color: var(--text-3);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="128px"
|
||||
height="128px"
|
||||
viewBox="0 0 128 128"
|
||||
version="1.1"
|
||||
id="svg96"
|
||||
sodipodi:docname="Sovran_SystemsOS_Updater_Iconv3.svg"
|
||||
xml:space="preserve"
|
||||
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||
id="namedview98"
|
||||
pagecolor="#505050"
|
||||
bordercolor="#ffffff"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="1"
|
||||
inkscape:deskcolor="#505050"
|
||||
showgrid="false"
|
||||
inkscape:zoom="5.2149125"
|
||||
inkscape:cx="9.0126153"
|
||||
inkscape:cy="64.430611"
|
||||
inkscape:window-width="3440"
|
||||
inkscape:window-height="1352"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer2" /><defs
|
||||
id="defs67"><linearGradient
|
||||
inkscape:collect="always"
|
||||
id="linearGradient936"><stop
|
||||
style="stop-color:#1e8e11;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop932" /><stop
|
||||
style="stop-color:#1bff00;stop-opacity:0;"
|
||||
offset="1"
|
||||
id="stop934" /></linearGradient><linearGradient
|
||||
id="linearGradient1028"
|
||||
inkscape:swatch="solid"><stop
|
||||
style="stop-color:#000000;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop1026" /></linearGradient><linearGradient
|
||||
id="linearGradient998"
|
||||
inkscape:swatch="solid"><stop
|
||||
style="stop-color:#000000;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop996" /></linearGradient><radialGradient
|
||||
id="radial0"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
cx="131.914749"
|
||||
cy="55.927143"
|
||||
fx="131.914749"
|
||||
fy="55.927143"
|
||||
r="160"
|
||||
gradientTransform="matrix(0.232034,-0.541475,-0.368794,-0.0298398,4.277749,118.95849)"><stop
|
||||
offset="0"
|
||||
style="stop-color:#00ff39;stop-opacity:1;"
|
||||
id="stop2" /><stop
|
||||
offset="1"
|
||||
style="stop-color:#004a19;stop-opacity:1;"
|
||||
id="stop4" /></radialGradient><radialGradient
|
||||
id="radial1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
cx="525.587769"
|
||||
cy="638.591797"
|
||||
fx="525.587769"
|
||||
fy="638.591797"
|
||||
r="192"
|
||||
gradientTransform="matrix(-0.107656,-0.225172,-0.327748,0.258343,373.87973,30.205086)"><stop
|
||||
offset="0"
|
||||
style="stop-color:#43b60b;stop-opacity:1;"
|
||||
id="stop7" /><stop
|
||||
offset="1"
|
||||
style="stop-color:#0b88ff;stop-opacity:0.00829875;"
|
||||
id="stop9" /></radialGradient><clipPath
|
||||
id="clip1"><path
|
||||
d="M 7 46 L 57 46 L 57 93 L 7 93 Z M 7 46 "
|
||||
id="path12" /></clipPath><clipPath
|
||||
id="clip2"><path
|
||||
d="M 32.25 46.957031 C 19.6875 46.96875 9.085938 56.636719 7.503906 69.53125 C 9.0625 82.445312 19.667969 92.144531 32.25 92.160156 C 44.816406 92.148438 55.414062 82.480469 57 69.585938 C 55.441406 56.671875 44.835938 46.972656 32.25 46.957031 Z M 32.25 46.957031 "
|
||||
id="path15" /></clipPath><radialGradient
|
||||
id="radial2"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
cx="131.914749"
|
||||
cy="55.927143"
|
||||
fx="131.914749"
|
||||
fy="55.927143"
|
||||
r="160"
|
||||
gradientTransform="matrix(0.485163,-1.148584,-0.771115,-0.0632965,-47.124961,203.98857)"><stop
|
||||
offset="0"
|
||||
style="stop-color:rgb(92.941177%,20%,23.137255%);stop-opacity:1;"
|
||||
id="stop18" /><stop
|
||||
offset="1"
|
||||
style="stop-color:rgb(63.921571%,27.843139%,72.941178%);stop-opacity:1;"
|
||||
id="stop20" /></radialGradient><radialGradient
|
||||
id="radial3"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
cx="525.587769"
|
||||
cy="638.591797"
|
||||
fx="525.587769"
|
||||
fy="638.591797"
|
||||
r="192"
|
||||
gradientTransform="matrix(-0.225099,-0.477638,-0.685291,0.548001,725.67923,15.723794)"><stop
|
||||
offset="0"
|
||||
style="stop-color:rgb(10.980392%,44.313726%,84.705883%);stop-opacity:1;"
|
||||
id="stop23" /><stop
|
||||
offset="1"
|
||||
style="stop-color:rgb(20.784314%,51.764709%,89.411765%);stop-opacity:0.00829876;"
|
||||
id="stop25" /></radialGradient><linearGradient
|
||||
id="linear0"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="22"
|
||||
y1="37"
|
||||
x2="62"
|
||||
y2="37"
|
||||
gradientTransform="matrix(1.4,0,0,1.4,-26.799973,2.491745)"><stop
|
||||
offset="0"
|
||||
style="stop-color:rgb(58.039218%,57.647061%,56.470591%);stop-opacity:1;"
|
||||
id="stop28" /><stop
|
||||
offset="0.0908155"
|
||||
style="stop-color:rgb(87.058824%,86.666667%,85.490197%);stop-opacity:1;"
|
||||
id="stop30" /><stop
|
||||
offset="0.336093"
|
||||
style="stop-color:rgb(60.392159%,60.000002%,58.823532%);stop-opacity:1;"
|
||||
id="stop32" /><stop
|
||||
offset="0.844326"
|
||||
style="stop-color:rgb(76.47059%,75.294119%,72.941178%);stop-opacity:1;"
|
||||
id="stop34" /><stop
|
||||
offset="0.930505"
|
||||
style="stop-color:rgb(87.058824%,86.666667%,85.490197%);stop-opacity:1;"
|
||||
id="stop36" /><stop
|
||||
offset="1"
|
||||
style="stop-color:rgb(75.294119%,74.901962%,73.725492%);stop-opacity:1;"
|
||||
id="stop38" /></linearGradient><radialGradient
|
||||
id="radial4"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
cx="-172.560638"
|
||||
cy="28.569126"
|
||||
fx="-172.560638"
|
||||
fy="28.569126"
|
||||
r="15.85742"
|
||||
gradientTransform="matrix(1.560712,0,0,1.4252,300.69366,13.349996)"><stop
|
||||
offset="0"
|
||||
style="stop-color:rgb(100%,100%,100%);stop-opacity:0.358268;"
|
||||
id="stop41" /><stop
|
||||
offset="1"
|
||||
style="stop-color:rgb(100%,100%,100%);stop-opacity:0.0944882;"
|
||||
id="stop43" /></radialGradient><filter
|
||||
id="alpha"
|
||||
filterUnits="objectBoundingBox"
|
||||
x="0"
|
||||
y="0"
|
||||
width="1"
|
||||
height="1"><feColorMatrix
|
||||
type="matrix"
|
||||
in="SourceGraphic"
|
||||
values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"
|
||||
id="feColorMatrix46" /></filter><mask
|
||||
id="mask0"><g
|
||||
filter="url(#alpha)"
|
||||
id="g51"><rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="128"
|
||||
height="128"
|
||||
style="fill:rgb(0%,0%,0%);fill-opacity:0.1;stroke:none;"
|
||||
id="rect49" /></g></mask><clipPath
|
||||
id="clip3"><rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="192"
|
||||
height="152"
|
||||
id="rect54" /></clipPath><g
|
||||
id="surface382"
|
||||
clip-path="url(#clip3)"><path
|
||||
style=" stroke:none;fill-rule:nonzero;fill:rgb(27.058825%,21.176471%,21.568628%);fill-opacity:1;"
|
||||
d="M 40 59.957031 C 26.191406 59.957031 15 71.152344 15 84.957031 C 15.011719 85.996094 15.085938 86.777344 15.222656 87.804688 C 15.222656 75.957031 27.421875 65.96875 40 65.957031 C 52.597656 65.972656 64.777344 75.957031 64.777344 87.859375 C 64.917969 86.816406 64.992188 86.011719 65 84.957031 C 65 71.152344 53.808594 59.957031 40 59.957031 Z M 40 59.957031 "
|
||||
id="path57" /></g><radialGradient
|
||||
id="radial5"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
cx="40"
|
||||
cy="227"
|
||||
fx="40"
|
||||
fy="227"
|
||||
r="28"
|
||||
gradientTransform="matrix(0.575553,0,1.60551e-8,1.540703,8.977913,-280.78108)"><stop
|
||||
offset="0"
|
||||
style="stop-color:rgb(100%,100%,100%);stop-opacity:1;"
|
||||
id="stop60" /><stop
|
||||
offset="0.744626"
|
||||
style="stop-color:rgb(98.039216%,98.039216%,98.039216%);stop-opacity:1;"
|
||||
id="stop62" /><stop
|
||||
offset="1"
|
||||
style="stop-color:rgb(87.450981%,87.450981%,87.450981%);stop-opacity:1;"
|
||||
id="stop64" /></radialGradient>
|
||||
|
||||
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient936"
|
||||
id="linearGradient938"
|
||||
x1="-48.519272"
|
||||
y1="18.511358"
|
||||
x2="287.07454"
|
||||
y2="18.511358"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(1.1020247,0,0,1.1097375,37.198581,-10.424856)" /></defs><path
|
||||
style="fill:#f5f5f3;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 20,11.957031 h 88 c 4.41797,0 8,3.582031 8,8 V 108 c 0,4.41797 -3.58203,8 -8,8 H 20 c -4.417969,0 -8,-3.58203 -8,-8 V 19.957031 c 0,-4.417969 3.582031,-8 8,-8 z m 0,0"
|
||||
id="path69" /><path
|
||||
style="fill:url(#radial0);fill-rule:nonzero;stroke:none"
|
||||
d="m 20,85.957031 h 88 v -66 H 20 Z m 0,0"
|
||||
id="path71" /><path
|
||||
style="fill:none;fill-rule:nonzero;stroke:none;fill-opacity:1"
|
||||
d="m 20,85.957031 h 88 v -66 H 20 Z m 0,0"
|
||||
id="path73" /><g
|
||||
inkscape:groupmode="layer"
|
||||
id="layer2"
|
||||
inkscape:label="Layer 1"
|
||||
transform="matrix(0.1816,0,0,0.1816,35.224187,79.037164)"><ellipse
|
||||
fill="#54c147"
|
||||
cx="168.64549"
|
||||
cy="10.117889"
|
||||
id="circle8314"
|
||||
rx="184.91634"
|
||||
ry="179.91556"
|
||||
style="fill:url(#linearGradient938);fill-opacity:1;stroke-width:1.71591" /><polygon
|
||||
fill="#ffffff"
|
||||
points="46.678,120.299 63.562,96.402 96.977,121.718 145.084,50.79 168.752,69.02 103.583,164.647 "
|
||||
id="polygon8316"
|
||||
transform="matrix(1.7395866,0,0,1.6925423,-18.737581,-172.19767)" /></g></svg>
|
||||
|
After Width: | Height: | Size: 8.4 KiB |
@@ -0,0 +1,50 @@
|
||||
/* Sovran_SystemsOS Hub — Vanilla JS Frontend
|
||||
v7 — Status-only dashboard + Tech Support + Feature Manager */
|
||||
"use strict";
|
||||
|
||||
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;
|
||||
const REBOOT_INITIAL_DELAY = 25000;
|
||||
const SUPPORT_TIMER_INTERVAL = 1000;
|
||||
|
||||
const CATEGORY_ORDER = [
|
||||
"infrastructure",
|
||||
"bitcoin",
|
||||
"communication",
|
||||
"apps",
|
||||
"nostr",
|
||||
];
|
||||
|
||||
/* The service catalog still distinguishes the Bitcoin foundation services
|
||||
from the Bitcoin apps; the Hub shows them as one "Bitcoin" section. */
|
||||
const CATEGORY_ALIASES = {
|
||||
"bitcoin-base": "bitcoin",
|
||||
"bitcoin-apps": "bitcoin",
|
||||
};
|
||||
|
||||
const FEATURE_SUBCATEGORY_LABELS = {
|
||||
"infrastructure": "🔧 Infrastructure",
|
||||
"bitcoin": "₿ Bitcoin",
|
||||
"communication": "💬 Communication",
|
||||
"nostr": "📡 Nostr",
|
||||
};
|
||||
|
||||
const FEATURE_SUBCATEGORY_ORDER = ["infrastructure", "bitcoin", "communication", "nostr"];
|
||||
|
||||
const STATUS_LOADING_STATES = new Set([
|
||||
"reloading", "activating", "deactivating", "maintenance",
|
||||
]);
|
||||
@@ -0,0 +1,451 @@
|
||||
"use strict";
|
||||
|
||||
/* ── The Hub dashboard chrome ──────────────────────────────────────
|
||||
Welcome dashboard (default view), category navigation, service
|
||||
search, status cards, and the Systems Operational modal.
|
||||
|
||||
Everything lives in an IIFE so no globals leak into the other Hub
|
||||
scripts; tiles.js calls window.dashboardServicesUpdated() whenever
|
||||
the service list refreshes. */
|
||||
|
||||
(function () {
|
||||
|
||||
var $nav = document.getElementById("sidebar-nav");
|
||||
var $pageTitle = document.getElementById("page-title");
|
||||
var $search = document.getElementById("search-input");
|
||||
var $sysModal = document.getElementById("systems-modal");
|
||||
var $sysBody = document.getElementById("systems-body");
|
||||
var $welcome = document.getElementById("welcome-view");
|
||||
var $tilesArea = document.getElementById("tiles-area");
|
||||
var $wcSystems = document.getElementById("wc-systems");
|
||||
var $wcMore = document.getElementById("wc-more");
|
||||
var $greeting = document.getElementById("welcome-greeting");
|
||||
var $welcomeRole = document.getElementById("welcome-role");
|
||||
var $browseBtn = document.getElementById("welcome-browse-btn");
|
||||
|
||||
var _view = "dashboard"; // "dashboard" | "services"
|
||||
var _cat = "all";
|
||||
var _query = "";
|
||||
|
||||
var CAT_ICONS = {
|
||||
"all": "g-grid",
|
||||
"infrastructure": "g-server",
|
||||
"bitcoin": "g-btc-sym",
|
||||
"communication": "g-chat",
|
||||
"apps": "g-dots",
|
||||
"nostr": "g-antenna",
|
||||
"other": "g-dots"
|
||||
};
|
||||
|
||||
var CAT_FALLBACK_LABELS = {
|
||||
"infrastructure": "Infrastructure",
|
||||
"bitcoin": "Bitcoin",
|
||||
"communication": "Communication",
|
||||
"apps": "Personal Apps",
|
||||
"nostr": "Nostr",
|
||||
"other": "Other"
|
||||
};
|
||||
|
||||
/* Units that serve a domain — used to find a live diagnostics
|
||||
checklist for the Systems Operational modal (order matters only
|
||||
for which service is polled first). */
|
||||
var DOMAIN_UNITS = [
|
||||
"matrix-synapse.service",
|
||||
"btcpayserver.service",
|
||||
"vaultwarden.service",
|
||||
"phpfpm-nextcloud.service",
|
||||
"phpfpm-wordpress.service",
|
||||
"haven-relay.service",
|
||||
"livekit.service",
|
||||
"albyhub.service"
|
||||
];
|
||||
|
||||
function icon(id) {
|
||||
return '<svg><use href="#' + id + '"/></svg>';
|
||||
}
|
||||
|
||||
function visibleServices() {
|
||||
var services = (typeof _servicesCache !== "undefined" && _servicesCache) ? _servicesCache : [];
|
||||
return services.filter(function (s) {
|
||||
return s.category !== "support" && s.type !== "support";
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Views ────────────────────────────────────────────────────── */
|
||||
|
||||
function setTitle(t) {
|
||||
if ($pageTitle) $pageTitle.textContent = t;
|
||||
}
|
||||
|
||||
function syncNav() {
|
||||
if (!$nav) return;
|
||||
$nav.querySelectorAll(".nav-item").forEach(function (b) {
|
||||
var isActive;
|
||||
if (b.dataset.cat === "__dash") isActive = (_view === "dashboard");
|
||||
else isActive = (_view === "services" && b.dataset.cat === _cat);
|
||||
b.classList.toggle("active", isActive);
|
||||
});
|
||||
}
|
||||
|
||||
function showDashboard() {
|
||||
_view = "dashboard";
|
||||
_cat = "all";
|
||||
if ($welcome) $welcome.style.display = "";
|
||||
if ($tilesArea) $tilesArea.style.display = "none";
|
||||
setTitle("Dashboard");
|
||||
syncNav();
|
||||
}
|
||||
|
||||
function showServices(cat) {
|
||||
_view = "services";
|
||||
if (cat) _cat = cat;
|
||||
if ($welcome) $welcome.style.display = "none";
|
||||
if ($tilesArea) $tilesArea.style.display = "";
|
||||
setTitle(_cat === "all" ? "All services" : catLabel(_cat));
|
||||
syncNav();
|
||||
applyFilter();
|
||||
}
|
||||
|
||||
/* ── Category navigation ──────────────────────────────────────── */
|
||||
|
||||
function renderNav() {
|
||||
if (!$nav) return;
|
||||
var counts = {};
|
||||
var order = [];
|
||||
visibleServices().forEach(function (s) {
|
||||
var cat = s.category || "other";
|
||||
if (CATEGORY_ALIASES[cat]) cat = CATEGORY_ALIASES[cat];
|
||||
if (!counts[cat]) { counts[cat] = 0; order.push(cat); }
|
||||
counts[cat]++;
|
||||
});
|
||||
var total = visibleServices().length;
|
||||
|
||||
var html = '<div class="nav-label">Menu</div>';
|
||||
html += '<button class="nav-item' + (_view === "dashboard" ? " active" : "") + '" data-cat="__dash" type="button">' +
|
||||
icon("g-home") +
|
||||
'<span class="nav-text">Dashboard</span>' +
|
||||
'</button>';
|
||||
html += '<div class="nav-label">Services</div>';
|
||||
html += navItem("all", "All services", total);
|
||||
order.forEach(function (cat) {
|
||||
html += navItem(cat, catLabel(cat), counts[cat]);
|
||||
});
|
||||
$nav.innerHTML = html;
|
||||
|
||||
$nav.querySelectorAll(".nav-item").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
if (btn.dataset.cat === "__dash") showDashboard();
|
||||
else showServices(btn.dataset.cat);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function navItem(cat, label, count) {
|
||||
return '<button class="nav-item' + (_view === "services" && cat === _cat ? " active" : "") + '" data-cat="' + escHtml(cat) + '" type="button">' +
|
||||
icon(CAT_ICONS[cat] || "g-dots") +
|
||||
'<span class="nav-text">' + escHtml(label) + '</span>' +
|
||||
'<span class="nav-count">' + count + '</span>' +
|
||||
'</button>';
|
||||
}
|
||||
|
||||
function catLabel(cat) {
|
||||
if (typeof _categoryLabels !== "undefined" && _categoryLabels[cat]) return _categoryLabels[cat];
|
||||
return CAT_FALLBACK_LABELS[cat] || cat;
|
||||
}
|
||||
|
||||
/* ── Filtering (applies in the services view) ─────────────────── */
|
||||
|
||||
function applyFilter() {
|
||||
if (_view !== "services") return;
|
||||
var area = $tilesArea;
|
||||
if (!area) return;
|
||||
var q = _query.trim().toLowerCase();
|
||||
area.querySelectorAll(".category-section").forEach(function (section) {
|
||||
var catMatch = (_cat === "all") || (section.dataset.category === _cat);
|
||||
var anyVisible = false;
|
||||
section.querySelectorAll(".service-tile").forEach(function (tile) {
|
||||
var nameEl = tile.querySelector(".tile-name");
|
||||
var name = nameEl ? nameEl.textContent.toLowerCase() : "";
|
||||
var match = catMatch && (!q || name.indexOf(q) !== -1);
|
||||
tile.style.display = match ? "" : "none";
|
||||
if (match) anyVisible = true;
|
||||
});
|
||||
section.style.display = (catMatch && (anyVisible || !q)) ? "" : "none";
|
||||
});
|
||||
}
|
||||
|
||||
if ($search) {
|
||||
$search.addEventListener("input", function () {
|
||||
// Searching implies browsing services — leave the welcome view.
|
||||
if (_view === "dashboard" && $search.value) showServices("all");
|
||||
_query = $search.value;
|
||||
applyFilter();
|
||||
});
|
||||
}
|
||||
|
||||
if ($browseBtn) {
|
||||
$browseBtn.addEventListener("click", function () { showServices("all"); });
|
||||
}
|
||||
|
||||
/* ── Welcome header ───────────────────────────────────────────── */
|
||||
|
||||
function updateWelcomeMeta() {
|
||||
if ($greeting) {
|
||||
var h = new Date().getHours();
|
||||
var g;
|
||||
if (h >= 5 && h < 12) g = "Good morning";
|
||||
else if (h >= 12 && h < 17) g = "Good afternoon";
|
||||
else g = "Good evening";
|
||||
$greeting.textContent = g;
|
||||
}
|
||||
if ($welcomeRole) {
|
||||
$welcomeRole.textContent = (typeof window._roleLabel !== "undefined" && window._roleLabel) ? window._roleLabel : "";
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Status cards ─────────────────────────────────────────────── */
|
||||
|
||||
function serviceCounts() {
|
||||
var services = visibleServices();
|
||||
var running = 0, attention = 0, off = 0;
|
||||
var attentionNames = [], offNames = [];
|
||||
services.forEach(function (s) {
|
||||
if (!s.enabled) { off++; offNames.push(s.name); return; }
|
||||
var h = s.health || s.status;
|
||||
if (h === "needs_attention" || h === "failed") { attention++; attentionNames.push(s.name); }
|
||||
else running++;
|
||||
});
|
||||
return { services: services, running: running, attention: attention, off: off, attentionNames: attentionNames, offNames: offNames };
|
||||
}
|
||||
|
||||
function renderWidgets() {
|
||||
if (!$wcSystems) return;
|
||||
var c = serviceCounts();
|
||||
if (!c.services.length) { $wcSystems.innerHTML = ""; if ($wcMore) $wcMore.innerHTML = ""; return; }
|
||||
|
||||
/* Systems operational */
|
||||
var sub = '<b>' + c.running + '</b> running';
|
||||
if (c.attention) sub += ' · <span class="warn">' + c.attention + ' needs attention</span>';
|
||||
if (c.off) sub += ' · ' + c.off + ' off';
|
||||
var attentionTitle = c.attention ? "Systems need attention" : "Systems operational";
|
||||
$wcSystems.innerHTML =
|
||||
'<div class="widget clickable" id="w-systems" role="button" tabindex="0" title="System status and router setup">' +
|
||||
'<div class="widget-chip chip-green">' + icon("g-pulse") + '</div>' +
|
||||
'<div class="w-body"><h3>' + attentionTitle + '</h3><div class="sub">' + sub + '</div></div>' +
|
||||
'</div>';
|
||||
|
||||
var wSys = document.getElementById("w-systems");
|
||||
if (wSys) {
|
||||
wSys.addEventListener("click", openSystemsModal);
|
||||
wSys.addEventListener("keydown", function (e) { if (e.key === "Enter") openSystemsModal(); });
|
||||
}
|
||||
|
||||
if (!$wcMore) return;
|
||||
var more = "";
|
||||
|
||||
/* Bitcoin Core sync */
|
||||
var btc = null;
|
||||
c.services.forEach(function (s) {
|
||||
if (s.sync_ibd && s.enabled) btc = s;
|
||||
});
|
||||
if (btc) {
|
||||
var pct = Math.round((btc.sync_progress || 0) * 100);
|
||||
var blocks = btc.sync_blocks ? btc.sync_blocks.toLocaleString() : "—";
|
||||
var eta = (typeof _calcBtcEta === "function") ? _calcBtcEta(btc.unit + "::" + btc.name, btc.sync_progress || 0) : "";
|
||||
more +=
|
||||
'<div class="widget clickable" id="w-btc" role="button" tabindex="0" title="' + escHtml(btc.name) + ' details">' +
|
||||
'<div class="widget-chip chip-btc"><img src="/static/icons/' + escHtml(btc.icon) + '.svg" alt="" style="width:46px;height:46px;display:block;object-fit:contain"/></div>' +
|
||||
'<div class="w-body"><h3>' + escHtml(btc.name) + ' — syncing timechain</h3>' +
|
||||
'<div class="w-bar"><div class="w-bar-fill" style="width:' + pct + '%"></div></div>' +
|
||||
'<div class="sub">Block <b>' + blocks + '</b> · ' + pct + '% · <span class="warn">' + escHtml(eta) + '</span></div></div>' +
|
||||
'</div>';
|
||||
} else {
|
||||
var btcDone = null;
|
||||
c.services.forEach(function (s) { if (s.unit === "bitcoind.service" && s.enabled) btcDone = s; });
|
||||
if (btcDone) {
|
||||
var blk = btcDone.sync_blocks ? btcDone.sync_blocks.toLocaleString() : "";
|
||||
more +=
|
||||
'<div class="widget clickable" id="w-btc" role="button" tabindex="0" title="' + escHtml(btcDone.name) + ' details">' +
|
||||
'<div class="widget-chip chip-btc"><img src="/static/icons/' + escHtml(btcDone.icon) + '.svg" alt="" style="width:46px;height:46px;display:block;object-fit:contain"/></div>' +
|
||||
'<div class="w-body"><h3>' + escHtml(btcDone.name) + '</h3><div class="sub"><span class="good">Fully synced</span>' + (blk ? ' · Block <b>' + blk + '</b>' : '') + '</div></div>' +
|
||||
'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/* Updates — mirror the sidebar's read of the update state, so the card
|
||||
never claims "up to date" while an update failed or needs a restart */
|
||||
var upd = (typeof window._lastUpdateCheck === "object" && window._lastUpdateCheck) ? window._lastUpdateCheck : null;
|
||||
var updStatus = (upd && upd.status) || "idle";
|
||||
var updTitle, updSub, updChip;
|
||||
if (updStatus === "failed") {
|
||||
updTitle = "Update failed"; updSub = "Click to retry the update"; updChip = "chip-amber";
|
||||
} else if (updStatus === "reboot_required") {
|
||||
updTitle = "Restart required"; updSub = "Click to restart and finish the update"; updChip = "chip-amber";
|
||||
} else if (updStatus === "running") {
|
||||
updTitle = "Update in progress"; updSub = "Installing the new system generation"; updChip = "chip-amber";
|
||||
} else if (upd && upd.available) {
|
||||
updTitle = "Updates available"; updSub = "Click to review and update"; updChip = "chip-amber";
|
||||
} else if (!upd) {
|
||||
/* First check hasn't returned yet — never claim "up to date" before
|
||||
a check has actually completed */
|
||||
updTitle = "Checking for updates"; updSub = "Comparing with the latest Sovran_SystemsOS release"; updChip = "chip-green";
|
||||
} else {
|
||||
/* Updates are applied by the user, not automatically — the card must
|
||||
not imply the OS updates itself */
|
||||
updTitle = "System is up to date"; updSub = "Last check found no updates · Click to check again"; updChip = "chip-green";
|
||||
}
|
||||
more +=
|
||||
'<div class="widget clickable" id="w-updates" role="button" tabindex="0" title="Check for system updates">' +
|
||||
'<div class="widget-chip ' + updChip + '">' + icon("g-update") + '</div>' +
|
||||
'<div class="w-body"><h3>' + updTitle + '</h3>' +
|
||||
'<div class="sub">' + updSub + '</div></div>' +
|
||||
'</div>';
|
||||
|
||||
$wcMore.innerHTML = more;
|
||||
|
||||
var wBtc = document.getElementById("w-btc");
|
||||
if (wBtc) {
|
||||
var svc = btc || btcDone;
|
||||
if (svc) {
|
||||
wBtc.addEventListener("click", function () { openServiceDetailModal(svc.unit, svc.name, svc.icon); });
|
||||
wBtc.addEventListener("keydown", function (e) { if (e.key === "Enter") openServiceDetailModal(svc.unit, svc.name, svc.icon); });
|
||||
}
|
||||
}
|
||||
var wUpd = document.getElementById("w-updates");
|
||||
if (wUpd) {
|
||||
wUpd.addEventListener("click", function () { openUpdateModal(); });
|
||||
wUpd.addEventListener("keydown", function (e) { if (e.key === "Enter") openUpdateModal(); });
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Systems Operational modal ────────────────────────────────── */
|
||||
|
||||
function isNodeRole() {
|
||||
return (typeof _currentRole !== "undefined" && _currentRole === "node");
|
||||
}
|
||||
|
||||
function hasEnabledDomainService(services) {
|
||||
return DOMAIN_UNITS.some(function (u) {
|
||||
return services.some(function (s) { return s.unit === u && s.enabled; });
|
||||
});
|
||||
}
|
||||
|
||||
function whoUsesPorts() {
|
||||
if (isNodeRole()) {
|
||||
return 'On this <strong>Bitcoin Node</strong> install, <strong>BTCPay Server</strong> and <strong>Lightning Wallet Connections (LNURL)</strong> are the domain services that use these ports.';
|
||||
}
|
||||
return 'All your domain services share ports 80 and 443 — Matrix, BTCPay Server, VaultWarden, Nextcloud, WordPress, Haven Relay, Lightning Wallet Connections, and Element Calling.';
|
||||
}
|
||||
|
||||
function step(n, title, sub, value) {
|
||||
return '<div class="sysstep"><div class="sysnum">' + n + '</div><div class="sysstep-x">' +
|
||||
'<div class="sysstep-t">' + title + (sub ? ' <span class="sysstep-sub">· ' + sub + '</span>' : '') + '</div>' +
|
||||
(value ? '<div class="sysval"><span class="sysval-text">' + value + '</span></div>' : '') +
|
||||
'</div></div>';
|
||||
}
|
||||
|
||||
function openSystemsModal() {
|
||||
if (!$sysModal || !$sysBody) return;
|
||||
var c = serviceCounts();
|
||||
|
||||
var html = "";
|
||||
|
||||
/* System status */
|
||||
html += '<div class="sysmodal-card">' +
|
||||
'<div class="sysmodal-card-title">' + icon("g-pulse") + 'System Status</div>' +
|
||||
step(1, "Services running", "", String(c.running)) +
|
||||
step(2, "Needs attention", "", c.attention ? escHtml(c.attentionNames.join(", ")) : "None") +
|
||||
step(3, "Turned off", "", c.off ? escHtml(c.offNames.join(", ")) : "None") +
|
||||
'</div>';
|
||||
|
||||
/* Router — a simple open / not-open verdict. How to open the ports is
|
||||
covered during onboarding, so the modal does not repeat instructions.
|
||||
When no domain service is enabled (a fresh Desktop-only install, a
|
||||
Node with BTCPay/LNURL off, or everything turned off), there is
|
||||
nothing to check on the router — say so instead of listing services
|
||||
this machine does not have. */
|
||||
if (!hasEnabledDomainService(c.services)) {
|
||||
var noRouterDesc = isNodeRole()
|
||||
? 'Ports 80 and 443 only need to be forwarded on your router if you turn on <strong>BTCPay Server</strong> or <strong>Lightning Wallet Connections (LNURL)</strong>. If you enable one of them, come back here to check your ports.'
|
||||
: 'None of your services need ports forwarded from your router right now. If you turn on a service that uses a domain, come back here to check your ports.';
|
||||
html += '<div class="sysmodal-card">' +
|
||||
'<div class="sysmodal-card-title">' + icon("g-wifi") + 'Router</div>' +
|
||||
'<div class="sysnote"><div class="sysnote-title">' + icon("g-check") + 'No router setup needed yet</div>' +
|
||||
'<div class="sysnote-desc">' + noRouterDesc + '</div></div>' +
|
||||
'</div>';
|
||||
} else {
|
||||
html += '<div class="sysmodal-card" id="sys-ports-card" style="display:none">' +
|
||||
'<div class="sysmodal-card-title">' + icon("g-wifi") + 'Router</div>' +
|
||||
'<div id="sys-ports-status"><div class="sysfineprint">Checking…</div></div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
/* Who uses these ports (only meaningful when a domain service is
|
||||
actually enabled — otherwise the router note above covers it) */
|
||||
if (hasEnabledDomainService(c.services)) {
|
||||
html += '<div class="sysnote" style="margin-top:14px">' +
|
||||
'<div class="sysnote-title">' + icon("g-antenna") + 'Who uses these ports</div>' +
|
||||
'<div class="sysnote-desc">' + whoUsesPorts() + '</div></div>';
|
||||
}
|
||||
|
||||
$sysBody.innerHTML = html;
|
||||
$sysModal.classList.add("open");
|
||||
|
||||
/* Poll the first configured domain service and reduce its diagnostics
|
||||
to one verdict: the ports are open or they are not. */
|
||||
var portsCard = document.getElementById("sys-ports-card");
|
||||
var portsEl = document.getElementById("sys-ports-status");
|
||||
var units = DOMAIN_UNITS.filter(function (u) {
|
||||
return c.services.some(function (s) { return s.unit === u && s.enabled; });
|
||||
});
|
||||
if (!units.length || !portsCard || !portsEl) return;
|
||||
portsCard.style.display = "";
|
||||
|
||||
apiFetch("/api/service-detail/" + encodeURIComponent(units[0]))
|
||||
.then(function (data) {
|
||||
var steps = (data && data.domain_check_steps) || [];
|
||||
var portsStep = null;
|
||||
steps.forEach(function (s) {
|
||||
if (Number(s.step) === 3 || /ports?\s*80/i.test(s.label || "")) portsStep = s;
|
||||
});
|
||||
if (!portsStep) { portsCard.style.display = "none"; return; }
|
||||
if (portsStep.status === "ok") {
|
||||
portsEl.innerHTML = '<div class="svc-detail-status" style="font-size:0.92rem"><span class="status-dot active"></span>Ports 80 and 443 are open</div>';
|
||||
} else {
|
||||
portsEl.innerHTML = '<div class="svc-detail-status" style="font-size:0.92rem"><span class="status-dot failed"></span>Ports 80 and 443 are not open</div>';
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
portsCard.style.display = "none";
|
||||
});
|
||||
}
|
||||
|
||||
function closeSystemsModal() {
|
||||
if ($sysModal) $sysModal.classList.remove("open");
|
||||
}
|
||||
|
||||
var sysClose = document.getElementById("systems-close-btn");
|
||||
if (sysClose) sysClose.addEventListener("click", closeSystemsModal);
|
||||
if ($sysModal) {
|
||||
$sysModal.addEventListener("click", function (e) {
|
||||
if (e.target === $sysModal) closeSystemsModal();
|
||||
});
|
||||
$sysModal.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Escape") closeSystemsModal();
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Public hook for tiles.js ────────────────────────────────── */
|
||||
|
||||
window.dashboardServicesUpdated = function () {
|
||||
renderNav();
|
||||
updateWelcomeMeta();
|
||||
renderWidgets();
|
||||
applyFilter();
|
||||
};
|
||||
|
||||
// Initial view
|
||||
showDashboard();
|
||||
updateWelcomeMeta();
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,109 @@
|
||||
/* 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. */ });
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
"use strict";
|
||||
|
||||
// ── Event listeners ───────────────────────────────────────────────
|
||||
|
||||
// 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);
|
||||
|
||||
// Logout button
|
||||
if ($logoutBtn) $logoutBtn.addEventListener("click", function () {
|
||||
fetch("/api/logout", { method: "POST", credentials: "same-origin" })
|
||||
.finally(function () { window.location.replace("/login"); });
|
||||
});
|
||||
|
||||
// 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(); });
|
||||
|
||||
// Domain setup modal
|
||||
if ($domainSetupClose) $domainSetupClose.addEventListener("click", closeDomainSetupModal);
|
||||
if ($domainSetupModal) $domainSetupModal.addEventListener("click", function(e) { if (e.target === $domainSetupModal) closeDomainSetupModal(); });
|
||||
|
||||
// SSL Email modal
|
||||
if ($sslEmailClose) $sslEmailClose.addEventListener("click", closeSslEmailModal);
|
||||
if ($sslEmailCancel) $sslEmailCancel.addEventListener("click", closeSslEmailModal);
|
||||
if ($sslEmailModal) $sslEmailModal.addEventListener("click", function(e) { if (e.target === $sslEmailModal) closeSslEmailModal(); });
|
||||
|
||||
// Feature confirm modal
|
||||
if ($featureConfirmClose) $featureConfirmClose.addEventListener("click", closeFeatureConfirm);
|
||||
if ($featureConfirmCancel) $featureConfirmCancel.addEventListener("click", closeFeatureConfirm);
|
||||
if ($featureConfirmModal) $featureConfirmModal.addEventListener("click", function(e) { if (e.target === $featureConfirmModal) closeFeatureConfirm(); });
|
||||
|
||||
if ($modal) $modal.addEventListener("click", function(e) { if (e.target === $modal) closeUpdateModal(); });
|
||||
if ($credsModal) $credsModal.addEventListener("click", function(e) { if (e.target === $credsModal) closeCredsModal(); });
|
||||
if ($supportModal) $supportModal.addEventListener("click", function(e) { if (e.target === $supportModal) closeSupportModal(); });
|
||||
|
||||
// Upgrade modal
|
||||
if ($upgradeCloseBtn) $upgradeCloseBtn.addEventListener("click", closeUpgradeModal);
|
||||
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() {
|
||||
if ($upgradeModal) $upgradeModal.classList.add("open");
|
||||
}
|
||||
|
||||
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…"; }
|
||||
closeUpgradeModal();
|
||||
|
||||
// Reuse the rebuild modal to show reboot progress
|
||||
_rebuildFeatureName = "Server + Desktop";
|
||||
_rebuildIsEnabling = true;
|
||||
openRebuildModal();
|
||||
|
||||
try {
|
||||
await apiFetch("/api/role/upgrade-to-server", { method: "POST" });
|
||||
// Server is rebooting — show message and wait for it to come back
|
||||
if ($rebuildStatus) $rebuildStatus.textContent = "Rebooting — the setup wizard will guide you through domain and port configuration…";
|
||||
if ($rebuildSpinner) $rebuildSpinner.classList.add("spinning");
|
||||
|
||||
// Poll until server comes back, then redirect to onboarding
|
||||
var pollInterval = setInterval(async function() {
|
||||
try {
|
||||
await apiFetch("/api/ping");
|
||||
clearInterval(pollInterval);
|
||||
window.location.href = "/onboarding";
|
||||
} catch (_) {
|
||||
// Server still down — keep polling
|
||||
}
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
if ($rebuildStatus) $rebuildStatus.textContent = "✗ Upgrade failed: " + err.message;
|
||||
if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning");
|
||||
if ($rebuildClose) $rebuildClose.disabled = false;
|
||||
if (confirmBtn) { confirmBtn.disabled = false; confirmBtn.textContent = "Yes, Upgrade"; }
|
||||
}
|
||||
}
|
||||
|
||||
if ($upgradeConfirmBtn) $upgradeConfirmBtn.addEventListener("click", doUpgradeToServer);
|
||||
|
||||
// ── First-login security banner ───────────────────────────────────
|
||||
|
||||
function showSecurityBanner() {
|
||||
var existing = document.getElementById("security-first-login-banner");
|
||||
if (existing) return;
|
||||
|
||||
var banner = document.createElement("div");
|
||||
banner.id = "security-first-login-banner";
|
||||
banner.className = "security-first-login-banner";
|
||||
banner.innerHTML =
|
||||
'<div class="security-banner-content">' +
|
||||
'<span class="security-banner-icon">\uD83D\uDEE1</span>' +
|
||||
'<span class="security-banner-text">' +
|
||||
'<strong>Did someone else set up this machine?</strong> ' +
|
||||
'If this computer was pre-configured by another person, go to ' +
|
||||
'<strong>Menu \u2192 Security</strong> to reset all passwords and keys. ' +
|
||||
'This ensures only you have access.' +
|
||||
'</span>' +
|
||||
'</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);
|
||||
} else {
|
||||
document.body.insertAdjacentElement("afterbegin", banner);
|
||||
}
|
||||
|
||||
var dismissBtn = document.getElementById("security-banner-dismiss-btn");
|
||||
if (dismissBtn) {
|
||||
dismissBtn.addEventListener("click", async function() {
|
||||
banner.remove();
|
||||
try {
|
||||
await apiFetch("/api/security/banner-dismiss", { method: "POST" });
|
||||
} catch (_) {
|
||||
// Non-fatal
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
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) {
|
||||
window.location.href = "/onboarding";
|
||||
return;
|
||||
}
|
||||
|
||||
// Show first-login security banner only for machines that went through onboarding
|
||||
// (legacy machines without the onboarding flag will never see this)
|
||||
if (bannerData && bannerData.show) {
|
||||
showSecurityBanner();
|
||||
}
|
||||
|
||||
try {
|
||||
if (!cfg) throw new Error("Hub config unavailable");
|
||||
_currentRole = cfg.role || "server_plus_desktop";
|
||||
if (cfg.category_order) {
|
||||
for (var i = 0; i < cfg.category_order.length; i++) {
|
||||
_categoryLabels[cfg.category_order[i][0]] = cfg.category_order[i][1];
|
||||
}
|
||||
}
|
||||
var badge = document.getElementById("role-badge");
|
||||
if (badge && cfg.role_label) badge.textContent = cfg.role_label;
|
||||
window._roleLabel = cfg.role_label || "";
|
||||
|
||||
loadNetwork();
|
||||
checkUpdates();
|
||||
await refreshServices();
|
||||
|
||||
setInterval(refreshServices, POLL_INTERVAL_SERVICES);
|
||||
setInterval(checkUpdates, POLL_INTERVAL_UPDATES);
|
||||
|
||||
if (cfg.feature_manager) {
|
||||
loadFeatureManager();
|
||||
}
|
||||
loadAutolaunchToggle();
|
||||
} catch (_) {
|
||||
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);
|
||||
@@ -0,0 +1,680 @@
|
||||
"use strict";
|
||||
|
||||
// ── Feature confirm modal ─────────────────────────────────────────
|
||||
|
||||
function openFeatureConfirm(message, onConfirm) {
|
||||
if (!$featureConfirmModal) return;
|
||||
if ($featureConfirmMsg) $featureConfirmMsg.textContent = message;
|
||||
$featureConfirmModal.classList.add("open");
|
||||
// Replace ok handler
|
||||
var newOk = $featureConfirmOk.cloneNode(true);
|
||||
$featureConfirmOk.parentNode.replaceChild(newOk, $featureConfirmOk);
|
||||
newOk.addEventListener("click", function() {
|
||||
closeFeatureConfirm();
|
||||
onConfirm();
|
||||
});
|
||||
}
|
||||
|
||||
function closeFeatureConfirm() {
|
||||
if ($featureConfirmModal) $featureConfirmModal.classList.remove("open");
|
||||
}
|
||||
|
||||
// ── SSL Email modal ───────────────────────────────────────────────
|
||||
|
||||
function openSslEmailModal(onSaved) {
|
||||
if (!$sslEmailModal) return;
|
||||
if ($sslEmailInput) $sslEmailInput.value = "";
|
||||
$sslEmailModal.classList.add("open");
|
||||
// Replace save handler
|
||||
var newSave = $sslEmailSave.cloneNode(true);
|
||||
$sslEmailSave.parentNode.replaceChild(newSave, $sslEmailSave);
|
||||
newSave.addEventListener("click", async function() {
|
||||
var email = $sslEmailInput ? $sslEmailInput.value.trim() : "";
|
||||
if (!email) { alert("Please enter an email address."); return; }
|
||||
newSave.disabled = true;
|
||||
newSave.textContent = "Saving…";
|
||||
try {
|
||||
await apiFetch("/api/domains/set-email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: email }),
|
||||
});
|
||||
closeSslEmailModal();
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
newSave.disabled = false;
|
||||
newSave.textContent = "Save";
|
||||
alert("Failed to save email. Please try again.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function closeSslEmailModal() {
|
||||
if ($sslEmailModal) $sslEmailModal.classList.remove("open");
|
||||
}
|
||||
|
||||
// ── Domain Setup modal ────────────────────────────────────────────
|
||||
|
||||
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 = "";
|
||||
if (feat.extra_fields && feat.extra_fields.length > 0) {
|
||||
for (var i = 0; i < feat.extra_fields.length; i++) {
|
||||
if (feat.extra_fields[i].id === "nostr_npub") {
|
||||
currentNpub = feat.extra_fields[i].current_value || "";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
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" : "";
|
||||
|
||||
$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>' +
|
||||
'</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>' +
|
||||
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>';
|
||||
|
||||
document.getElementById("domain-setup-cancel-btn").addEventListener("click", closeDomainSetupModal);
|
||||
|
||||
document.getElementById("domain-setup-save-btn").addEventListener("click", async function() {
|
||||
var subdomain = (document.getElementById("domain-subdomain-input") || {}).value || "";
|
||||
var ddnsUrl = (document.getElementById("domain-ddns-input") || {}).value || "";
|
||||
var npub = document.getElementById("domain-npub-input") ? (document.getElementById("domain-npub-input").value || "") : "";
|
||||
subdomain = subdomain.trim();
|
||||
ddnsUrl = ddnsUrl.trim();
|
||||
npub = npub.trim();
|
||||
|
||||
if (!subdomain) { alert("Please enter a domain."); return; }
|
||||
if (feat.id === "haven" && !npub) { alert("Please enter your Nostr public key."); return; }
|
||||
|
||||
var saveBtn = document.getElementById("domain-setup-save-btn");
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = "Saving…";
|
||||
|
||||
try {
|
||||
await apiFetch("/api/domains/set", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
domain_name: feat.domain_name,
|
||||
domain: subdomain,
|
||||
ddns_url: ddnsUrl,
|
||||
}),
|
||||
});
|
||||
closeDomainSetupModal();
|
||||
onSaved(npub);
|
||||
} 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);
|
||||
}
|
||||
});
|
||||
|
||||
$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 = "";
|
||||
if (feat.extra_fields && feat.extra_fields.length > 0) {
|
||||
for (var i = 0; i < feat.extra_fields.length; i++) {
|
||||
if (feat.extra_fields[i].id === "nostr_npub") {
|
||||
currentNpub = feat.extra_fields[i].current_value || "";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
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>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>' +
|
||||
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>';
|
||||
|
||||
document.getElementById("domain-setup-cancel-btn").addEventListener("click", closeDomainSetupModal);
|
||||
|
||||
document.getElementById("domain-setup-save-btn").addEventListener("click", async function() {
|
||||
var subdomain = (document.getElementById("domain-subdomain-input") || {}).value || "";
|
||||
var ddnsUrl = (document.getElementById("domain-ddns-input") || {}).value || "";
|
||||
var npub = document.getElementById("domain-npub-input") ? (document.getElementById("domain-npub-input").value || "") : "";
|
||||
subdomain = subdomain.trim();
|
||||
ddnsUrl = ddnsUrl.trim();
|
||||
npub = npub.trim();
|
||||
|
||||
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");
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = "Saving…";
|
||||
|
||||
try {
|
||||
await apiFetch("/api/domains/set", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
domain_name: feat.domain_name,
|
||||
domain: subdomain,
|
||||
ddns_url: ddnsUrl,
|
||||
}),
|
||||
});
|
||||
closeDomainSetupModal();
|
||||
onSaved(npub);
|
||||
} 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);
|
||||
}
|
||||
});
|
||||
|
||||
$domainSetupModal.classList.add("open");
|
||||
|
||||
}
|
||||
|
||||
function closeDomainSetupModal() {
|
||||
if ($domainSetupModal) $domainSetupModal.classList.remove("open");
|
||||
}
|
||||
|
||||
// ── Port Requirements modal ───────────────────────────────────────
|
||||
|
||||
function openPortRequirementsModal(featureName, ports, onContinue) {
|
||||
if (!$portReqModal || !$portReqBody) return;
|
||||
|
||||
var continueBtn = onContinue
|
||||
? '<button class="btn btn-primary" id="port-req-continue-btn">I Understand — Continue</button>'
|
||||
: '';
|
||||
|
||||
function renderPortRequirements(internalIp) {
|
||||
$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>' +
|
||||
'<div class="domain-field-actions">' +
|
||||
'<button class="btn btn-close-modal" id="port-req-dismiss-btn">Dismiss</button>' +
|
||||
continueBtn +
|
||||
'</div>';
|
||||
|
||||
document.getElementById("port-req-dismiss-btn").onclick = function() {
|
||||
closePortRequirementsModal();
|
||||
};
|
||||
|
||||
if (onContinue) {
|
||||
document.getElementById("port-req-continue-btn").onclick = function() {
|
||||
closePortRequirementsModal();
|
||||
onContinue();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
$portReqModal.classList.add("open");
|
||||
renderPortRequirements(null);
|
||||
|
||||
fetch("/api/network")
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!$portReqModal.classList.contains("open")) return;
|
||||
var internalIp = (data.internal_ip && data.internal_ip !== "unavailable")
|
||||
? data.internal_ip : null;
|
||||
renderPortRequirements(internalIp);
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.warn("Failed to fetch network info for port requirements modal:", err);
|
||||
});
|
||||
}
|
||||
|
||||
function closePortRequirementsModal() {
|
||||
if ($portReqModal) $portReqModal.classList.remove("open");
|
||||
}
|
||||
|
||||
if ($portReqClose) {
|
||||
$portReqClose.addEventListener("click", closePortRequirementsModal);
|
||||
}
|
||||
|
||||
// ── Feature toggle logic ──────────────────────────────────────────
|
||||
|
||||
async function performFeatureToggle(featId, enabled, extra) {
|
||||
// Look up feature name for the rebuild modal
|
||||
_rebuildIsEnabling = enabled;
|
||||
_rebuildFeatureName = featId;
|
||||
if (_featuresData) {
|
||||
var found = _featuresData.features.find(function(f) { return f.id === featId; });
|
||||
if (found) _rebuildFeatureName = found.name;
|
||||
}
|
||||
try {
|
||||
var res = await fetch("/api/features/toggle", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ feature: featId, enabled: enabled, extra: extra || {} }),
|
||||
});
|
||||
var body = await res.json();
|
||||
if (!res.ok) {
|
||||
if (body && body.error === "domain_required") {
|
||||
alert("Domain not configured for this feature. Please configure it first.");
|
||||
} else {
|
||||
alert("Error: " + (body.detail || body.error || "Unknown error"));
|
||||
}
|
||||
loadFeatureManager();
|
||||
return;
|
||||
}
|
||||
openRebuildModal();
|
||||
} catch (err) {
|
||||
alert("Failed to toggle feature: " + err);
|
||||
loadFeatureManager();
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
function() { performFeatureToggle(feat.id, false, {}); }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Enabling
|
||||
var conflictNames = [];
|
||||
if (feat.conflicts_with && feat.conflicts_with.length > 0 && _featuresData) {
|
||||
feat.conflicts_with.forEach(function(cid) {
|
||||
var cf = _featuresData.features.find(function(f) { return f.id === cid; });
|
||||
if (cf && cf.enabled) conflictNames.push(cf.name);
|
||||
});
|
||||
}
|
||||
|
||||
function proceedAfterPortCheck() {
|
||||
// Check SSL email first
|
||||
if (!_featuresData || !_featuresData.ssl_email_configured) {
|
||||
if (feat.needs_domain) {
|
||||
openSslEmailModal(function() {
|
||||
// After ssl email saved, check domain
|
||||
checkDomainAndEnable(feat, {});
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (feat.needs_domain && !feat.domain_configured) {
|
||||
checkDomainAndEnable(feat, {});
|
||||
return;
|
||||
}
|
||||
if (feat.id === "haven") {
|
||||
var npub = "";
|
||||
if (feat.extra_fields) {
|
||||
var ef = feat.extra_fields.find(function(e) { return e.id === "nostr_npub"; });
|
||||
if (ef) npub = ef.current_value || "";
|
||||
}
|
||||
if (!npub) {
|
||||
// Need to collect npub via domain modal
|
||||
openDomainSetupModal(feat, function(collectedNpub) {
|
||||
performFeatureToggle(feat.id, true, { nostr_npub: collectedNpub });
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
performFeatureToggle(feat.id, true, {});
|
||||
}
|
||||
|
||||
function proceedAfterConflictCheck() {
|
||||
var ports = feat.port_requirements || [];
|
||||
if (ports.length === 0) {
|
||||
proceedAfterPortCheck();
|
||||
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.
|
||||
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);
|
||||
} else if (conflictNames.length > 0) {
|
||||
openFeatureConfirm("This will disable " + conflictNames.join(", ") + ". Continue?", proceedAfterConflictCheck);
|
||||
} else {
|
||||
proceedAfterConflictCheck();
|
||||
}
|
||||
}
|
||||
|
||||
function checkDomainAndEnable(feat, extra) {
|
||||
openDomainSetupModal(feat, function(collectedNpub) {
|
||||
var extraData = {};
|
||||
if (collectedNpub) extraData.nostr_npub = collectedNpub;
|
||||
performFeatureToggle(feat.id, true, extraData);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Feature Manager rendering ─────────────────────────────────────
|
||||
|
||||
async function loadFeatureManager() {
|
||||
try {
|
||||
var data = await apiFetch("/api/features");
|
||||
_featuresData = data;
|
||||
// Feature Manager is now integrated into tile modals; sidebar rendering removed.
|
||||
} catch (err) {
|
||||
console.warn("Failed to load features:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function _checkFeatureManagerDomains(data) {
|
||||
// Collect all features with a configured domain
|
||||
var featsWithDomain = (data.features || []).filter(function(f) {
|
||||
return f.needs_domain && f.domain_configured;
|
||||
});
|
||||
if (!featsWithDomain.length) return;
|
||||
|
||||
// Get the actual domain values from /api/domains/status, then check them
|
||||
fetch("/api/domains/status")
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(statusData) {
|
||||
var domainFileMap = statusData.domains || {};
|
||||
// Build list of domains to check and a map from domain value → feature id
|
||||
var domainsToCheck = [];
|
||||
var domainToFeatIds = {};
|
||||
featsWithDomain.forEach(function(feat) {
|
||||
var domainName = feat.domain_name;
|
||||
var domainVal = domainName ? domainFileMap[domainName] : null;
|
||||
if (domainVal) {
|
||||
domainsToCheck.push(domainVal);
|
||||
if (!domainToFeatIds[domainVal]) domainToFeatIds[domainVal] = [];
|
||||
domainToFeatIds[domainVal].push(feat.id);
|
||||
} else {
|
||||
// Domain file missing — update badge to warn
|
||||
_updateFeatureDomainBadge(feat.id, null, "unresolvable");
|
||||
}
|
||||
});
|
||||
|
||||
if (!domainsToCheck.length) return;
|
||||
|
||||
return fetch("/api/domains/check", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ domains: domainsToCheck }),
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(checkData) {
|
||||
(checkData.domains || []).forEach(function(d) {
|
||||
var featIds = domainToFeatIds[d.domain] || [];
|
||||
featIds.forEach(function(featId) {
|
||||
_updateFeatureDomainBadge(featId, d.domain, d.status);
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(function() {});
|
||||
}
|
||||
|
||||
function _updateFeatureDomainBadge(featId, domainVal, status) {
|
||||
var section = $sidebarFeatures.querySelector(".feature-manager-section");
|
||||
if (!section) return;
|
||||
// Find the card — cards don't have a data-feat-id, so find via name match
|
||||
var badges = section.querySelectorAll(".feature-domain-badge.configured");
|
||||
badges.forEach(function(badge) {
|
||||
var domainNameAttr = badge.getAttribute("data-domain-name");
|
||||
// Match by domain_name attribute — we need to look up the feat's domain_name
|
||||
var feat = _featuresData && _featuresData.features
|
||||
? _featuresData.features.find(function(f) { return f.id === featId; })
|
||||
: null;
|
||||
if (!feat) return;
|
||||
if (domainNameAttr !== (feat.domain_name || "")) return;
|
||||
|
||||
var lbl = badge.querySelector(".feature-domain-label");
|
||||
if (!lbl) return;
|
||||
lbl.classList.remove("feature-domain-label--checking");
|
||||
if (status === "connected") {
|
||||
lbl.className = "feature-domain-label feature-domain-label--ok";
|
||||
lbl.textContent = (domainVal || "Domain") + " ✓";
|
||||
} else if (status === "dns_mismatch") {
|
||||
lbl.className = "feature-domain-label feature-domain-label--warn";
|
||||
lbl.textContent = (domainVal || "Domain") + " (IP mismatch)";
|
||||
} else if (status === "unresolvable") {
|
||||
lbl.className = "feature-domain-label feature-domain-label--error";
|
||||
lbl.textContent = (domainVal || "Domain") + " (DNS error)";
|
||||
} else {
|
||||
lbl.className = "feature-domain-label feature-domain-label--warn";
|
||||
lbl.textContent = (domainVal || "Domain") + " (unknown)";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderFeatureManager(data) {
|
||||
// Remove old feature manager section if it exists
|
||||
var old = $sidebarFeatures.querySelector(".feature-manager-section");
|
||||
if (old) old.parentNode.removeChild(old);
|
||||
|
||||
var section = document.createElement("div");
|
||||
section.className = "category-section feature-manager-section";
|
||||
section.dataset.category = "feature-manager";
|
||||
section.innerHTML = '<div class="section-header">Feature Manager</div><hr class="section-divider" />';
|
||||
|
||||
// Group by sub-category
|
||||
var grouped = {};
|
||||
for (var i = 0; i < data.features.length; i++) {
|
||||
var f = data.features[i];
|
||||
var cat = f.category || "other";
|
||||
if (!grouped[cat]) grouped[cat] = [];
|
||||
grouped[cat].push(f);
|
||||
}
|
||||
|
||||
var orderedCats = FEATURE_SUBCATEGORY_ORDER.filter(function(k) { return grouped[k]; });
|
||||
Object.keys(grouped).forEach(function(k) {
|
||||
if (orderedCats.indexOf(k) === -1) orderedCats.push(k);
|
||||
});
|
||||
|
||||
for (var j = 0; j < orderedCats.length; j++) {
|
||||
var catKey = orderedCats[j];
|
||||
var feats = grouped[catKey];
|
||||
if (!feats || feats.length === 0) continue;
|
||||
|
||||
var subcat = document.createElement("div");
|
||||
subcat.className = "feature-subcategory";
|
||||
var subcatLabel = FEATURE_SUBCATEGORY_LABELS[catKey] || catKey;
|
||||
subcat.innerHTML = '<div class="feature-subcategory-header">' + escHtml(subcatLabel) + '</div>';
|
||||
|
||||
var cardsWrap = document.createElement("div");
|
||||
cardsWrap.className = "feature-cards-wrap";
|
||||
|
||||
for (var k = 0; k < feats.length; k++) {
|
||||
cardsWrap.appendChild(buildFeatureCard(feats[k]));
|
||||
}
|
||||
subcat.appendChild(cardsWrap);
|
||||
section.appendChild(subcat);
|
||||
}
|
||||
|
||||
$sidebarFeatures.appendChild(section);
|
||||
}
|
||||
|
||||
function buildFeatureCard(feat) {
|
||||
var card = document.createElement("div");
|
||||
card.className = "feature-card";
|
||||
|
||||
var conflictHtml = "";
|
||||
if (feat.conflicts_with && feat.conflicts_with.length > 0) {
|
||||
var conflictNames = feat.conflicts_with.map(function(cid) {
|
||||
if (!_featuresData) return cid;
|
||||
var cf = _featuresData.features.find(function(f) { return f.id === cid; });
|
||||
return cf ? cf.name : cid;
|
||||
});
|
||||
conflictHtml = '<div class="feature-conflict-warning">⚠ Conflicts with: ' + escHtml(conflictNames.join(", ")) + '</div>';
|
||||
}
|
||||
|
||||
var domainHtml = "";
|
||||
if (feat.needs_domain) {
|
||||
if (feat.domain_configured) {
|
||||
domainHtml = '<div class="feature-domain-badge configured" data-domain-name="' + escHtml(feat.domain_name || '') + '">'
|
||||
+ '<span class="feature-domain-icon">🌐</span>'
|
||||
+ '<span class="feature-domain-label feature-domain-label--checking">Domain: Checking\u2026</span>'
|
||||
+ '</div>';
|
||||
} else {
|
||||
domainHtml = '<div class="feature-domain-badge not-configured">'
|
||||
+ '<span class="feature-domain-icon">🌐</span>'
|
||||
+ '<span class="feature-domain-label feature-domain-label--warn">Domain: Not configured</span>'
|
||||
+ '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
var statusText = feat.enabled ? "Enabled" : "Disabled";
|
||||
|
||||
card.innerHTML =
|
||||
'<div class="feature-card-top">' +
|
||||
'<div class="feature-card-info">' +
|
||||
'<div class="feature-card-name">' + escHtml(feat.name) + '</div>' +
|
||||
'<div class="feature-card-desc">' + escHtml(feat.description) + '</div>' +
|
||||
'</div>' +
|
||||
'<label class="feature-toggle' + (feat.enabled ? " active" : "") + '" title="Toggle ' + escHtml(feat.name) + '">' +
|
||||
'<input type="checkbox" class="feature-toggle-input"' + (feat.enabled ? " checked" : "") + ' />' +
|
||||
'<span class="feature-toggle-slider"></span>' +
|
||||
'</label>' +
|
||||
'</div>' +
|
||||
domainHtml +
|
||||
conflictHtml +
|
||||
'<div class="feature-card-status">Status: ' + escHtml(statusText) + '</div>';
|
||||
|
||||
var toggle = card.querySelector(".feature-toggle-input");
|
||||
var toggleLabel = card.querySelector(".feature-toggle");
|
||||
toggle.addEventListener("change", function() {
|
||||
var newEnabled = toggle.checked;
|
||||
// Revert visually until confirmed
|
||||
toggle.checked = feat.enabled;
|
||||
if (newEnabled) { toggleLabel.classList.remove("active"); } else { toggleLabel.classList.add("active"); }
|
||||
handleFeatureToggle(feat, newEnabled);
|
||||
});
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
// ── Auto-launch toggle ────────────────────────────────────────────
|
||||
|
||||
async function loadAutolaunchToggle() {
|
||||
try {
|
||||
var data = await apiFetch("/api/autolaunch/status");
|
||||
renderAutolaunchToggle(data.enabled);
|
||||
} catch (err) {
|
||||
console.warn("Failed to load autolaunch status:", err);
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
if (old) old.parentNode.removeChild(old);
|
||||
|
||||
var section = document.createElement("div");
|
||||
section.className = "category-section autolaunch-section";
|
||||
|
||||
section.innerHTML =
|
||||
'<div class="feature-card">' +
|
||||
'<div class="feature-card-top">' +
|
||||
'<div class="feature-card-info">' +
|
||||
'<div class="feature-card-name">Auto-launch Hub on Login</div>' +
|
||||
'<div class="feature-card-desc">Automatically open the Sovran Hub dashboard in your browser when you log in to the desktop.</div>' +
|
||||
'</div>' +
|
||||
'<label class="feature-toggle' + (enabled ? " active" : "") + '" id="autolaunch-toggle-label" title="Toggle auto-launch">' +
|
||||
'<input type="checkbox" class="feature-toggle-input" id="autolaunch-toggle-input"' + (enabled ? " checked" : "") + ' />' +
|
||||
'<span class="feature-toggle-slider"></span>' +
|
||||
'</label>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
slot.appendChild(section);
|
||||
|
||||
var input = document.getElementById("autolaunch-toggle-input");
|
||||
var label = document.getElementById("autolaunch-toggle-label");
|
||||
if (!input || !label) return;
|
||||
|
||||
input.addEventListener("change", async function() {
|
||||
var newEnabled = input.checked;
|
||||
// Revert visually until confirmed
|
||||
input.checked = !newEnabled;
|
||||
if (newEnabled) { label.classList.remove("active"); } else { label.classList.add("active"); }
|
||||
input.disabled = true;
|
||||
try {
|
||||
await apiFetch("/api/autolaunch/toggle", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: newEnabled }),
|
||||
});
|
||||
input.checked = newEnabled;
|
||||
if (newEnabled) { label.classList.add("active"); } else { label.classList.remove("active"); }
|
||||
} catch (err) {
|
||||
alert("Failed to update auto-launch setting. Please try again.");
|
||||
} finally {
|
||||
input.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
"use strict";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function tileId(svc) { return svc.unit + "::" + svc.name; }
|
||||
|
||||
function statusClass(health) {
|
||||
if (!health) return "unknown";
|
||||
if (health === "healthy") return "active";
|
||||
if (health === "needs_attention") return "needs-attention";
|
||||
if (health === "active") return "active"; // backwards compat
|
||||
if (health === "inactive") return "inactive";
|
||||
if (health === "failed") return "failed";
|
||||
if (health === "disabled") return "disabled";
|
||||
if (health === "syncing") return "syncing";
|
||||
if (STATUS_LOADING_STATES.has(health)) return "loading";
|
||||
if (health === "checking_reachability") return "checking-reachability";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function statusText(health, enabled) {
|
||||
if (!enabled) return "Disabled";
|
||||
if (health === "healthy") return "Active";
|
||||
if (health === "needs_attention") return "Needs Attention";
|
||||
if (health === "active") return "Active";
|
||||
if (health === "inactive") return "Inactive";
|
||||
if (health === "failed") return "Failed";
|
||||
if (health === "syncing") return "Syncing\u2026";
|
||||
if (!health || health === "unknown") return "Unknown";
|
||||
if (STATUS_LOADING_STATES.has(health)) return health;
|
||||
if (health === "checking_reachability") return "Checking\u2026";
|
||||
return health;
|
||||
}
|
||||
|
||||
function escHtml(str) {
|
||||
return String(str).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'");
|
||||
}
|
||||
|
||||
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);
|
||||
const s = Math.floor(seconds % 60);
|
||||
if (h > 0) return h + "h " + m + "m " + s + "s";
|
||||
if (m > 0) return m + "m " + s + "s";
|
||||
return s + "s";
|
||||
}
|
||||
|
||||
// ── 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) {}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
"use strict";
|
||||
|
||||
// ── 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 ($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);
|
||||
}
|
||||
|
||||
function closeRebuildModal() {
|
||||
if ($rebuildModal) $rebuildModal.classList.remove("open");
|
||||
stopRebuildPoll();
|
||||
}
|
||||
|
||||
function appendRebuildLog(text) {
|
||||
if (!text) return;
|
||||
_rebuildLog += text;
|
||||
// Log is collected silently for error reports — not displayed to user
|
||||
}
|
||||
|
||||
function startRebuildPoll() {
|
||||
if (_rebuildPollTimer) clearInterval(_rebuildPollTimer);
|
||||
pollRebuildStatus();
|
||||
_rebuildPollTimer = setInterval(pollRebuildStatus, UPDATE_POLL_INTERVAL);
|
||||
}
|
||||
|
||||
function stopRebuildPoll() {
|
||||
if (_rebuildPollTimer) { clearInterval(_rebuildPollTimer); _rebuildPollTimer = null; }
|
||||
}
|
||||
|
||||
async function pollRebuildStatus() {
|
||||
if (_rebuildFinished || _rebuildPollInFlight) return;
|
||||
_rebuildPollInFlight = true;
|
||||
try {
|
||||
var data = await apiFetchWithTimeout(
|
||||
"/api/rebuild/status?offset=" + _rebuildLogOffset,
|
||||
{ cache: "no-store" },
|
||||
STATUS_POLL_FETCH_TIMEOUT
|
||||
);
|
||||
_rebuildPollFailures = 0;
|
||||
if (_rebuildServerDown) { _rebuildServerDown = false; }
|
||||
if (data.log) appendRebuildLog(data.log);
|
||||
_rebuildLogOffset = data.offset;
|
||||
if (data.running) return;
|
||||
_rebuildFinished = true;
|
||||
stopRebuildPoll();
|
||||
if (data.result === "reboot_required") {
|
||||
onRebuildDone("reboot_required");
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function onRebuildDone(result) {
|
||||
if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning");
|
||||
if ($rebuildClose) $rebuildClose.disabled = false;
|
||||
if ($rebuildCloseHdr) $rebuildCloseHdr.disabled = false;
|
||||
if (result === true) {
|
||||
_setRebuildStatus("✓ Done");
|
||||
// Auto-reload the page after a short delay so tiles and toggles reflect the new state
|
||||
setTimeout(function() { window.location.reload(); }, 1200);
|
||||
} else if (result === "reboot_required") {
|
||||
_setRebuildStatus("✓ Done — restart required");
|
||||
if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex";
|
||||
} else {
|
||||
_setRebuildStatus("✗ Something went wrong");
|
||||
if ($rebuildSave) $rebuildSave.style.display = "inline-flex";
|
||||
if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex";
|
||||
}
|
||||
}
|
||||
|
||||
function saveRebuildErrorReport() {
|
||||
var blob = new Blob([_rebuildLog], { type: "text/plain" });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "sovran-rebuild-error-" + new Date().toISOString().split(".")[0].replace(/:/g, "-") + ".txt";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
"use strict";
|
||||
|
||||
// ── Security Modal ────────────────────────────────────────────────
|
||||
|
||||
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 ($supportBody) {
|
||||
$supportBody.innerHTML =
|
||||
// ── Section A: Security Reset ──────────────────────────────
|
||||
'<div class="security-section">' +
|
||||
'<h3 class="security-section-title">Security Reset</h3>' +
|
||||
'<p class="security-section-desc">' +
|
||||
'Run this if you are using this physical computer for the first time <strong>AND</strong> ' +
|
||||
'it was not set up by you. This will complete the security setup by resetting all passwords ' +
|
||||
'and your Bitcoin Lightning Node\u2019s private keys.' +
|
||||
'</p>' +
|
||||
'<p class="security-section-desc">' +
|
||||
'You can also run this if you wish to reset all your passwords and your Bitcoin Lightning ' +
|
||||
'Node\u2019s private keys. If you have not transferred the Bitcoin out of this node and did ' +
|
||||
'not back up the private keys, <strong>you will lose your Bitcoin.</strong>' +
|
||||
'</p>' +
|
||||
'<button class="btn btn-primary" id="security-reset-open-btn">Proceed with Security Reset</button>' +
|
||||
'<div id="security-reset-confirm" style="display:none;margin-top:16px;">' +
|
||||
'<div class="security-warning-box">' +
|
||||
'<p class="security-warning-text">' +
|
||||
'<strong>\u26A0\uFE0F This will permanently delete:</strong>' +
|
||||
'</p>' +
|
||||
'<ul class="security-warning-list">' +
|
||||
'<li>All generated passwords and SSH keys</li>' +
|
||||
'<li>LND wallet data (seed words, channels, macaroons)</li>' +
|
||||
'<li>Application databases</li>' +
|
||||
'<li>Vaultwarden data</li>' +
|
||||
'</ul>' +
|
||||
'<p class="security-warning-text">You will go through onboarding again. <strong>This cannot be undone.</strong></p>' +
|
||||
'</div>' +
|
||||
'<div class="security-erase-group">' +
|
||||
'<label class="security-erase-label" for="security-erase-input">Type <strong>ERASE</strong> to confirm:</label>' +
|
||||
'<input class="security-erase-input" type="text" id="security-erase-input" autocomplete="off" placeholder="ERASE" />' +
|
||||
'</div>' +
|
||||
'<div class="security-reset-actions">' +
|
||||
'<button class="btn btn-close-modal" id="security-reset-cancel-btn">Cancel</button>' +
|
||||
'<button class="btn btn-danger" id="security-reset-confirm-btn" disabled>Erase & Reset</button>' +
|
||||
'</div>' +
|
||||
'<div id="security-reset-status" class="security-status-msg"></div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
|
||||
'<hr class="security-divider" />' +
|
||||
|
||||
// ── Section B: Verify System Integrity ────────────────────
|
||||
'<div class="security-section">' +
|
||||
'<h3 class="security-section-title">Verify System Integrity</h3>' +
|
||||
'<p class="security-section-desc">' +
|
||||
'Your Sovran_SystemsOS is built with NixOS \u2014 a system designed for complete transparency ' +
|
||||
'and reproducibility. Every piece of software on this machine is built from publicly auditable ' +
|
||||
'source code and verified using cryptographic hashes.' +
|
||||
'</p>' +
|
||||
'<p class="security-section-desc">This verification confirms three things:</p>' +
|
||||
'<ol class="security-verify-list">' +
|
||||
'<li>' +
|
||||
'<strong>Source Code Match</strong> \u2014 The system configuration on this machine matches ' +
|
||||
'the exact commit published in the public repository. No hidden changes were added.' +
|
||||
'</li>' +
|
||||
'<li>' +
|
||||
'<strong>Binary Integrity</strong> \u2014 Every installed package in the system store is ' +
|
||||
'verified against its expected cryptographic hash. If any binary, library, or config file ' +
|
||||
'was tampered with, it will be detected.' +
|
||||
'</li>' +
|
||||
'<li>' +
|
||||
'<strong>Running System Match</strong> \u2014 The currently running system matches what the ' +
|
||||
'configuration says it should be. No unauthorized modifications are active.' +
|
||||
'</li>' +
|
||||
'</ol>' +
|
||||
'<p class="security-section-desc">' +
|
||||
'In short: if this verification passes, you can be confident that the software running on ' +
|
||||
'your machine is exactly what is published \u2014 nothing more, nothing less.' +
|
||||
'</p>' +
|
||||
'<button class="btn btn-primary" id="security-verify-btn">Verify Now</button>' +
|
||||
'<div id="security-verify-results" style="display:none;margin-top:16px;"></div>' +
|
||||
'</div>';
|
||||
|
||||
// ── Wire Security Reset flow
|
||||
var resetOpenBtn = document.getElementById("security-reset-open-btn");
|
||||
var resetConfirmDiv = document.getElementById("security-reset-confirm");
|
||||
var eraseInput = document.getElementById("security-erase-input");
|
||||
var resetConfirmBtn = document.getElementById("security-reset-confirm-btn");
|
||||
var resetCancelBtn = document.getElementById("security-reset-cancel-btn");
|
||||
var resetStatus = document.getElementById("security-reset-status");
|
||||
|
||||
if (resetOpenBtn) {
|
||||
resetOpenBtn.addEventListener("click", function() {
|
||||
resetOpenBtn.style.display = "none";
|
||||
if (resetConfirmDiv) resetConfirmDiv.style.display = "";
|
||||
if (eraseInput) eraseInput.focus();
|
||||
});
|
||||
}
|
||||
|
||||
if (eraseInput && resetConfirmBtn) {
|
||||
eraseInput.addEventListener("input", function() {
|
||||
resetConfirmBtn.disabled = eraseInput.value.trim() !== "ERASE";
|
||||
});
|
||||
}
|
||||
|
||||
if (resetCancelBtn) {
|
||||
resetCancelBtn.addEventListener("click", function() {
|
||||
if (resetConfirmDiv) resetConfirmDiv.style.display = "none";
|
||||
if (resetOpenBtn) resetOpenBtn.style.display = "";
|
||||
if (eraseInput) eraseInput.value = "";
|
||||
if (resetConfirmBtn) resetConfirmBtn.disabled = true;
|
||||
if (resetStatus) { resetStatus.textContent = ""; resetStatus.className = "security-status-msg"; }
|
||||
});
|
||||
}
|
||||
|
||||
if (resetConfirmBtn) {
|
||||
resetConfirmBtn.addEventListener("click", async function() {
|
||||
if (!eraseInput || eraseInput.value.trim() !== "ERASE") return;
|
||||
resetConfirmBtn.disabled = true;
|
||||
resetConfirmBtn.textContent = "Erasing\u2026";
|
||||
|
||||
// Show the full-screen blocking overlay immediately so the user knows
|
||||
// the wipe is in progress even while the API call runs synchronously.
|
||||
var $secResetOverlay = document.getElementById("security-reset-overlay");
|
||||
var $secResetStep = document.getElementById("security-reset-overlay-step");
|
||||
if ($secResetOverlay) $secResetOverlay.classList.add("visible");
|
||||
// Close the support modal so its content doesn't bleed through the overlay
|
||||
if ($supportModal) $supportModal.classList.remove("open");
|
||||
|
||||
if (resetStatus) { resetStatus.textContent = "Running security reset\u2026"; resetStatus.className = "security-status-msg security-status-info"; }
|
||||
try {
|
||||
var data = await apiFetch("/api/security/reset", { method: "POST" });
|
||||
|
||||
// Switch to Phase 2: show the new password and wait for user confirmation
|
||||
var phase1 = document.getElementById("security-reset-phase1");
|
||||
var phase2 = document.getElementById("security-reset-phase2");
|
||||
var passwordBox = document.getElementById("security-reset-new-password");
|
||||
var rebootBtn = document.getElementById("security-reset-reboot-btn");
|
||||
|
||||
if (phase1) phase1.style.display = "none";
|
||||
if (phase2) phase2.style.display = "";
|
||||
if (passwordBox && data.new_password) passwordBox.textContent = data.new_password;
|
||||
|
||||
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 + ")";
|
||||
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";
|
||||
} else {
|
||||
rebootBtn.textContent = "I have written down my new password \u2014 Restart Entire System (" + 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();
|
||||
}, { once: true });
|
||||
}
|
||||
} catch (err) {
|
||||
if ($secResetOverlay) $secResetOverlay.classList.remove("visible");
|
||||
if (resetStatus) { resetStatus.textContent = "\u2717 Error: " + (err.message || "Reset failed."); resetStatus.className = "security-status-msg security-status-error"; }
|
||||
resetConfirmBtn.disabled = false;
|
||||
resetConfirmBtn.textContent = "Erase & Reset";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Wire Verify System Integrity
|
||||
var verifyBtn = document.getElementById("security-verify-btn");
|
||||
var verifyResults = document.getElementById("security-verify-results");
|
||||
|
||||
if (verifyBtn && verifyResults) {
|
||||
verifyBtn.addEventListener("click", async function() {
|
||||
verifyBtn.disabled = true;
|
||||
verifyBtn.textContent = "Verifying\u2026";
|
||||
verifyResults.style.display = "";
|
||||
verifyResults.innerHTML = '<p class="security-verify-loading">\u231B Running verification checks\u2026 This may take a few minutes.</p>';
|
||||
|
||||
try {
|
||||
var data = await apiFetch("/api/security/verify-integrity", { method: "POST" });
|
||||
var html = '<div class="security-verify-result-card">';
|
||||
|
||||
// Flake commit
|
||||
html += '<div class="security-verify-row">';
|
||||
html += '<span class="security-verify-label">Source Commit:</span>';
|
||||
html += '<span class="security-verify-value security-verify-mono">' + escHtml(data.flake_commit || "unknown") + '</span>';
|
||||
if (data.repo_url) {
|
||||
html += '<a class="security-verify-link" href="' + escHtml(data.repo_url) + '" target="_blank" rel="noopener noreferrer">View on Gitea \u2197</a>';
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
// Store verification
|
||||
var storeOk = data.store_verified === true;
|
||||
html += '<div class="security-verify-row">';
|
||||
html += '<span class="security-verify-label">Binary Integrity:</span>';
|
||||
html += '<span class="security-verify-badge ' + (storeOk ? "security-verify-pass" : "security-verify-fail") + '">';
|
||||
html += storeOk ? "\u2705 PASS" : "\u274C FAIL";
|
||||
html += '</span>';
|
||||
html += '</div>';
|
||||
if (!storeOk && data.store_errors && data.store_errors.length > 0) {
|
||||
html += '<details class="security-verify-errors"><summary>Show errors (' + data.store_errors.length + ')</summary>';
|
||||
html += '<pre class="security-verify-pre">' + escHtml(data.store_errors.join("\n")) + '</pre>';
|
||||
html += '</details>';
|
||||
}
|
||||
|
||||
// System match
|
||||
var sysOk = data.system_matches === true;
|
||||
html += '<div class="security-verify-row">';
|
||||
html += '<span class="security-verify-label">Running System Match:</span>';
|
||||
html += '<span class="security-verify-badge ' + (sysOk ? "security-verify-pass" : "security-verify-fail") + '">';
|
||||
html += sysOk ? "\u2705 PASS" : "\u274C FAIL";
|
||||
html += '</span>';
|
||||
html += '</div>';
|
||||
if (!sysOk) {
|
||||
html += '<div class="security-verify-path-row">';
|
||||
html += '<span class="security-verify-path-label">Current:</span><code class="security-verify-mono">' + escHtml(data.current_system_path || "") + '</code>';
|
||||
html += '</div>';
|
||||
html += '<div class="security-verify-path-row">';
|
||||
html += '<span class="security-verify-path-label">Expected:</span><code class="security-verify-mono">' + escHtml(data.expected_system_path || "") + '</code>';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
verifyResults.innerHTML = html;
|
||||
} catch (err) {
|
||||
verifyResults.innerHTML = '<p class="security-status-msg security-status-error">\u274C Verification failed: ' + escHtml(err.message || "Unknown error") + '</p>';
|
||||
}
|
||||
|
||||
verifyBtn.disabled = false;
|
||||
verifyBtn.textContent = "Verify Now";
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"use strict";
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
let _walletUnlockTimerInt = null;
|
||||
let _cachedExternalIp = null;
|
||||
|
||||
// Current role (set during init from /api/config)
|
||||
let _currentRole = "server_plus_desktop";
|
||||
|
||||
// Feature Manager state
|
||||
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;
|
||||
|
||||
// ── DOM refs ──────────────────────────────────────────────────────
|
||||
|
||||
const $tilesArea = document.getElementById("tiles-area");
|
||||
const $sidebarSupport = document.getElementById("sidebar-support");
|
||||
const $sidebarFeatures = document.getElementById("sidebar-features");
|
||||
// No longer needed — Update System moved to sidebar
|
||||
// const $updateBtn = document.getElementById("btn-update");
|
||||
// const $updateBadge = document.getElementById("update-badge");
|
||||
const $internalIp = document.getElementById("ip-internal");
|
||||
const $externalIp = document.getElementById("ip-external");
|
||||
|
||||
const $modal = document.getElementById("update-modal");
|
||||
const $modalSpinner = document.getElementById("modal-spinner");
|
||||
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");
|
||||
const $credsBody = document.getElementById("creds-body");
|
||||
const $credsCloseBtn = document.getElementById("creds-close-btn");
|
||||
|
||||
const $supportModal = document.getElementById("support-modal");
|
||||
const $supportBody = document.getElementById("support-body");
|
||||
const $supportCloseBtn = document.getElementById("support-close-btn");
|
||||
|
||||
const $logoutBtn = document.getElementById("btn-logout");
|
||||
|
||||
// Feature Manager — rebuild modal
|
||||
const $rebuildModal = document.getElementById("rebuild-modal");
|
||||
const $rebuildSpinner = document.getElementById("rebuild-spinner");
|
||||
const $rebuildStatus = document.getElementById("rebuild-status");
|
||||
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");
|
||||
const $domainSetupTitle = document.getElementById("domain-setup-title");
|
||||
const $domainSetupBody = document.getElementById("domain-setup-body");
|
||||
const $domainSetupClose = document.getElementById("domain-setup-close-btn");
|
||||
|
||||
// Feature Manager — SSL email modal
|
||||
const $sslEmailModal = document.getElementById("ssl-email-modal");
|
||||
const $sslEmailInput = document.getElementById("ssl-email-input");
|
||||
const $sslEmailSave = document.getElementById("ssl-email-save-btn");
|
||||
const $sslEmailCancel = document.getElementById("ssl-email-cancel-btn");
|
||||
const $sslEmailClose = document.getElementById("ssl-email-close-btn");
|
||||
|
||||
// Feature Manager — confirm modal
|
||||
const $featureConfirmModal = document.getElementById("feature-confirm-modal");
|
||||
const $featureConfirmMsg = document.getElementById("feature-confirm-message");
|
||||
const $featureConfirmOk = document.getElementById("feature-confirm-ok-btn");
|
||||
const $featureConfirmCancel = document.getElementById("feature-confirm-cancel-btn");
|
||||
const $featureConfirmClose = document.getElementById("feature-confirm-close-btn");
|
||||
|
||||
// Port Requirements modal
|
||||
const $portReqModal = document.getElementById("port-requirements-modal");
|
||||
const $portReqBody = document.getElementById("port-req-body");
|
||||
const $portReqClose = document.getElementById("port-req-close-btn");
|
||||
|
||||
// Upgrade modal (Node → Server+Desktop)
|
||||
const $upgradeModal = document.getElementById("upgrade-modal");
|
||||
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)
|
||||
@@ -0,0 +1,734 @@
|
||||
"use strict";
|
||||
|
||||
// ── Tech Support modal ────────────────────────────────────────────
|
||||
|
||||
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");
|
||||
_supportStatus = status;
|
||||
if (status.active) { _supportEnabledAt = status.enabled_at; renderSupportActive(status); }
|
||||
else if (!status.sshd_enabled) { renderSupportSshdOff(); }
|
||||
else { renderSupportInactive(); }
|
||||
} catch (err) {
|
||||
$supportBody.innerHTML = '<p class="creds-empty">Could not check support status.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderSupportSshdOff() {
|
||||
stopSupportTimer();
|
||||
$supportBody.innerHTML = [
|
||||
'<div class="support-section">',
|
||||
'<div class="support-icon-big">🛟</div>',
|
||||
'<h3 class="support-heading">Need help from Sovran Systems?</h3>',
|
||||
'<p class="support-desc">To get Tech Support, SSH must be enabled first. SSH is <strong>off by default</strong> for maximum security — it only needs to be on during a support session.</p>',
|
||||
'<div class="support-wallet-box support-wallet-protected">',
|
||||
'<div class="support-wallet-header"><span class="support-wallet-icon">🔐</span><span class="support-wallet-title">SSH is Off</span></div>',
|
||||
'<p class="support-wallet-desc">SSH (remote login) is <strong>disabled by default</strong> on your Sovran Pro. Clicking the button below will enable SSH and trigger a system rebuild. Once complete, you can then grant support access.</p>',
|
||||
'<p class="support-wallet-desc">When you end the support session, you\'ll be able to disable SSH to return to the default secure state.</p>',
|
||||
'</div>',
|
||||
'<div class="support-steps"><div class="support-steps-title">Steps:</div><ol>',
|
||||
'<li>Enable SSH (triggers a system rebuild — takes a few minutes)</li>',
|
||||
'<li>Grant Sovran Systems temporary support access</li>',
|
||||
'<li>End the session when done — you\'ll be prompted to disable SSH</li>',
|
||||
'</ol></div>',
|
||||
'<button class="btn support-btn-enable" id="btn-sshd-enable">Enable SSH</button>',
|
||||
'<p class="support-fine-print">This will trigger a NixOS rebuild. Your machine will remain operational during the rebuild.</p>',
|
||||
'</div>',
|
||||
].join("");
|
||||
document.getElementById("btn-sshd-enable").addEventListener("click", enableSshd);
|
||||
}
|
||||
|
||||
async function enableSshd() {
|
||||
var btn = document.getElementById("btn-sshd-enable");
|
||||
if (btn) { btn.disabled = true; btn.textContent = "Enabling SSH…"; }
|
||||
try {
|
||||
await apiFetch("/api/features/toggle", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ feature: "sshd", enabled: true }),
|
||||
});
|
||||
// Poll until rebuild completes and sshd_enabled is true
|
||||
$supportBody.innerHTML = [
|
||||
'<div class="support-section">',
|
||||
'<div class="support-icon-big">⚙️</div>',
|
||||
'<h3 class="support-heading">Enabling SSH…</h3>',
|
||||
'<p class="support-desc">A system rebuild is in progress. This may take a few minutes. The page will update automatically when SSH is ready.</p>',
|
||||
'<p class="creds-loading" id="sshd-rebuild-status">Rebuilding system…</p>',
|
||||
'</div>',
|
||||
].join("");
|
||||
pollForSshdReady();
|
||||
} catch (err) {
|
||||
if (btn) { btn.disabled = false; btn.textContent = "Enable SSH"; }
|
||||
alert("Failed to enable SSH. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
function pollForSshdReady() {
|
||||
var attempts = 0;
|
||||
var maxAttempts = 60; // 5 minutes (5s interval)
|
||||
var interval = setInterval(async function() {
|
||||
attempts++;
|
||||
try {
|
||||
var status = await apiFetch("/api/support/status");
|
||||
var el = document.getElementById("sshd-rebuild-status");
|
||||
if (status.sshd_enabled) {
|
||||
clearInterval(interval);
|
||||
_supportStatus = status;
|
||||
renderSupportInactive();
|
||||
} else if (attempts >= maxAttempts) {
|
||||
clearInterval(interval);
|
||||
if (el) el.textContent = "Rebuild is taking longer than expected. Please close this dialog and try again.";
|
||||
} else {
|
||||
if (el) el.textContent = "Rebuilding system… (" + attempts * 5 + "s)";
|
||||
}
|
||||
} catch (_) {}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function renderSupportInactive() {
|
||||
stopSupportTimer();
|
||||
var ip = _cachedExternalIp || "loading…";
|
||||
$supportBody.innerHTML = [
|
||||
'<div class="support-section">',
|
||||
'<div class="support-icon-big">🛟</div>',
|
||||
'<h3 class="support-heading">Need help from Sovran Systems?</h3>',
|
||||
'<p class="support-desc">This will temporarily grant our support team SSH access to your machine so we can help diagnose and fix issues.</p>',
|
||||
'<div class="support-wallet-box support-wallet-protected">',
|
||||
'<div class="support-wallet-header"><span class="support-wallet-icon">✅</span><span class="support-wallet-title">SSH is Active</span></div>',
|
||||
'<p class="support-wallet-desc">SSH is enabled on your machine. You can now grant Sovran Systems temporary access below.</p>',
|
||||
'</div>',
|
||||
'<div class="support-info-box">',
|
||||
'<div class="support-info-row"><span class="support-info-label">Your IP</span><span class="support-info-value">' + escHtml(ip) + '</span></div>',
|
||||
'<div class="support-info-hint">This IP will be shared with Sovran Systems support</div>',
|
||||
'</div>',
|
||||
'<div class="support-wallet-box support-wallet-protected">',
|
||||
'<div class="support-wallet-header"><span class="support-wallet-icon">🔒</span><span class="support-wallet-title">Wallet Protection</span></div>',
|
||||
'<p class="support-wallet-desc">Wallet files (LND, Sparrow, Bisq) are <strong>protected by default</strong>. Support staff cannot access your private keys unless you explicitly grant access.</p>',
|
||||
'</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>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("");
|
||||
document.getElementById("btn-support-enable").addEventListener("click", enableSupport);
|
||||
}
|
||||
|
||||
function renderSupportActive(status) {
|
||||
var ip = _cachedExternalIp || "loading…";
|
||||
var walletProtected = status && status.wallet_protected;
|
||||
var walletUnlocked = status && status.wallet_unlocked;
|
||||
var unlockUntil = status && status.wallet_unlocked_until_human ? status.wallet_unlocked_until_human : "";
|
||||
var protectedPaths = (status && status.protected_paths && status.protected_paths.length)
|
||||
? status.protected_paths : [];
|
||||
|
||||
var walletSection;
|
||||
if (walletProtected) {
|
||||
if (walletUnlocked) {
|
||||
walletSection = [
|
||||
'<div class="support-wallet-box support-wallet-unlocked">',
|
||||
'<div class="support-wallet-header"><span class="support-wallet-icon">🔓</span><span class="support-wallet-title">Wallet Access: UNLOCKED</span></div>',
|
||||
'<p class="support-wallet-desc">You have granted support temporary access to wallet files' + (unlockUntil ? ' until <strong>' + escHtml(unlockUntil) + '</strong>' : '') + '.</p>',
|
||||
'<button class="btn support-btn-wallet-lock" id="btn-wallet-lock">Re-lock Wallet Now</button>',
|
||||
'</div>',
|
||||
].join("");
|
||||
} else {
|
||||
var pathList = protectedPaths.length
|
||||
? '<ul class="support-wallet-paths">' + protectedPaths.map(function(p){ return '<li>' + escHtml(p) + '</li>'; }).join("") + '</ul>'
|
||||
: '';
|
||||
walletSection = [
|
||||
'<div class="support-wallet-box support-wallet-protected">',
|
||||
'<div class="support-wallet-header"><span class="support-wallet-icon">🔒</span><span class="support-wallet-title">Wallet Files: Protected</span></div>',
|
||||
'<p class="support-wallet-desc">Support cannot access your wallet files. Grant temporary access only if needed for wallet troubleshooting.</p>',
|
||||
pathList,
|
||||
'<div class="support-wallet-unlock-row">',
|
||||
'<select id="wallet-unlock-duration" class="support-unlock-select">',
|
||||
'<option value="3600">1 hour</option>',
|
||||
'<option value="1800">30 minutes</option>',
|
||||
'<option value="7200">2 hours</option>',
|
||||
'</select>',
|
||||
'<button class="btn support-btn-wallet-unlock" id="btn-wallet-unlock">Grant Wallet Access</button>',
|
||||
'</div>',
|
||||
'</div>',
|
||||
].join("");
|
||||
}
|
||||
} else {
|
||||
walletSection = [
|
||||
'<div class="support-wallet-box support-wallet-warning">',
|
||||
'<div class="support-wallet-header"><span class="support-wallet-icon">⚠️</span><span class="support-wallet-title">Wallet Protection Unavailable</span></div>',
|
||||
'<p class="support-wallet-desc">The restricted support user could not be created. Support is running with root access — wallet files may be accessible. End the session if you are concerned.</p>',
|
||||
'</div>',
|
||||
].join("");
|
||||
}
|
||||
|
||||
$supportBody.innerHTML = [
|
||||
'<div class="support-section">',
|
||||
'<div class="support-icon-big support-active-icon">🔓</div>',
|
||||
'<h3 class="support-heading support-active-heading">Support Access is Active</h3>',
|
||||
'<p class="support-active-note">Sovran Systems can currently connect to your machine via SSH.</p>',
|
||||
'<div class="support-info-box support-active-box">',
|
||||
'<div class="support-info-row"><span class="support-info-label">Your IP</span><span class="support-info-value">' + escHtml(ip) + '</span></div>',
|
||||
'<div class="support-info-row"><span class="support-info-label">Duration</span><span class="support-info-value" id="support-timer">…</span></div>',
|
||||
'</div>',
|
||||
walletSection,
|
||||
'<button class="btn support-btn-disable" id="btn-support-disable">End Support Session</button>',
|
||||
'<p class="support-fine-print">This will remove the SSH key and revoke all wallet access immediately.</p>',
|
||||
'<button class="btn support-btn-auditlog" id="btn-support-audit">View Audit Log</button>',
|
||||
'</div>',
|
||||
'<div id="support-audit-container" class="support-audit-container" style="display:none;"></div>',
|
||||
].join("");
|
||||
|
||||
document.getElementById("btn-support-disable").addEventListener("click", disableSupport);
|
||||
document.getElementById("btn-support-audit").addEventListener("click", toggleAuditLog);
|
||||
if (walletProtected && !walletUnlocked) {
|
||||
document.getElementById("btn-wallet-unlock").addEventListener("click", walletUnlock);
|
||||
}
|
||||
if (walletProtected && walletUnlocked) {
|
||||
document.getElementById("btn-wallet-lock").addEventListener("click", walletLock);
|
||||
}
|
||||
startSupportTimer();
|
||||
if (walletUnlocked && status.wallet_unlocked_until) {
|
||||
startWalletUnlockTimer(status.wallet_unlocked_until);
|
||||
}
|
||||
}
|
||||
|
||||
function renderSupportRemoved(verified) {
|
||||
stopSupportTimer();
|
||||
stopWalletUnlockTimer();
|
||||
var icon = verified ? "✅" : "⚠️";
|
||||
var msg = verified ? "The Sovran Systems SSH key has been completely removed from your machine. We no longer have any access." : "The key removal was requested but could not be fully verified. Please reboot to ensure it is gone.";
|
||||
var vclass = verified ? "verified-gone" : "verify-warning";
|
||||
var vlabel = verified ? "✓ Removed — No access" : "⚠ Verify by rebooting";
|
||||
$supportBody.innerHTML = [
|
||||
'<div class="support-section">',
|
||||
'<div class="support-icon-big">' + icon + '</div>',
|
||||
'<h3 class="support-heading">Support Session Ended</h3>',
|
||||
'<p class="support-desc">' + escHtml(msg) + '</p>',
|
||||
'<div class="support-verify-box"><span class="support-verify-label">SSH Key Status:</span><span class="support-verify-value ' + vclass + '">' + vlabel + '</span></div>',
|
||||
'<div class="support-wallet-box support-wallet-protected" style="margin-top:12px;">',
|
||||
'<div class="support-wallet-header"><span class="support-wallet-icon">🔐</span><span class="support-wallet-title">Disable SSH When Done</span></div>',
|
||||
'<p class="support-wallet-desc">SSH is still enabled on your machine. Click below to turn it off and return to the default secure state.</p>',
|
||||
'<button class="btn support-btn-enable" id="btn-sshd-disable">Disable SSH</button>',
|
||||
'</div>',
|
||||
'<button class="btn support-btn-done" id="btn-support-done">Done</button>',
|
||||
'</div>',
|
||||
].join("");
|
||||
document.getElementById("btn-support-done").addEventListener("click", closeSupportModal);
|
||||
document.getElementById("btn-sshd-disable").addEventListener("click", disableSshd);
|
||||
}
|
||||
|
||||
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 }),
|
||||
});
|
||||
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); }
|
||||
}
|
||||
}
|
||||
|
||||
async function disableSupport() {
|
||||
var btn = document.getElementById("btn-support-disable");
|
||||
if (btn) { btn.disabled = true; btn.textContent = "Removing key…"; }
|
||||
try {
|
||||
var result = await apiFetch("/api/support/disable", { method: "POST" });
|
||||
renderSupportRemoved(result.verified);
|
||||
} catch (err) {
|
||||
if (btn) { btn.disabled = false; btn.textContent = "End Support Session"; }
|
||||
alert("Failed to disable support access. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
async function disableSshd() {
|
||||
var btn = document.getElementById("btn-sshd-disable");
|
||||
if (btn) { btn.disabled = true; btn.textContent = "Disabling SSH…"; }
|
||||
try {
|
||||
await apiFetch("/api/features/toggle", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ feature: "sshd", enabled: false }),
|
||||
});
|
||||
$supportBody.innerHTML = [
|
||||
'<div class="support-section">',
|
||||
'<div class="support-icon-big">⚙️</div>',
|
||||
'<h3 class="support-heading">Disabling SSH…</h3>',
|
||||
'<p class="support-desc">A system rebuild is in progress to turn off SSH. This may take a few minutes.</p>',
|
||||
'<p class="creds-loading" id="sshd-disable-status">Rebuilding system…</p>',
|
||||
'</div>',
|
||||
].join("");
|
||||
pollForSshdDisabled();
|
||||
} catch (err) {
|
||||
if (btn) { btn.disabled = false; btn.textContent = "Disable SSH"; }
|
||||
alert("Failed to disable SSH. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
function pollForSshdDisabled() {
|
||||
var attempts = 0;
|
||||
var maxAttempts = 60; // 5 minutes (5s interval)
|
||||
var interval = setInterval(async function() {
|
||||
attempts++;
|
||||
try {
|
||||
var status = await apiFetch("/api/support/status");
|
||||
var el = document.getElementById("sshd-disable-status");
|
||||
if (!status.sshd_enabled) {
|
||||
clearInterval(interval);
|
||||
$supportBody.innerHTML = [
|
||||
'<div class="support-section">',
|
||||
'<div class="support-icon-big">🔐</div>',
|
||||
'<h3 class="support-heading">SSH is Off</h3>',
|
||||
'<p class="support-desc">SSH has been disabled. Your machine is back to its default secure state.</p>',
|
||||
'<button class="btn support-btn-done" id="btn-support-done">Done</button>',
|
||||
'</div>',
|
||||
].join("");
|
||||
document.getElementById("btn-support-done").addEventListener("click", closeSupportModal);
|
||||
} else if (attempts >= maxAttempts) {
|
||||
clearInterval(interval);
|
||||
if (el) el.textContent = "Rebuild is taking longer than expected. Please close this dialog and try again.";
|
||||
} else {
|
||||
if (el) el.textContent = "Rebuilding system… (" + attempts * 5 + "s)";
|
||||
}
|
||||
} catch (_) {}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async function walletUnlock() {
|
||||
var btn = document.getElementById("btn-wallet-unlock");
|
||||
var sel = document.getElementById("wallet-unlock-duration");
|
||||
var duration = sel ? parseInt(sel.value, 10) : 3600;
|
||||
if (btn) { btn.disabled = true; btn.textContent = "Unlocking…"; }
|
||||
try {
|
||||
var result = await apiFetch("/api/support/wallet-unlock", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ duration: duration }),
|
||||
});
|
||||
var status = await apiFetch("/api/support/status");
|
||||
_supportStatus = status;
|
||||
renderSupportActive(status);
|
||||
} catch (err) {
|
||||
if (btn) { btn.disabled = false; btn.textContent = "Grant Wallet Access"; }
|
||||
alert("Failed to unlock wallet access: " + (err.message || "Unknown error"));
|
||||
}
|
||||
}
|
||||
|
||||
async function walletLock() {
|
||||
var btn = document.getElementById("btn-wallet-lock");
|
||||
if (btn) { btn.disabled = true; btn.textContent = "Locking…"; }
|
||||
try {
|
||||
await apiFetch("/api/support/wallet-lock", { method: "POST" });
|
||||
var status = await apiFetch("/api/support/status");
|
||||
_supportStatus = status;
|
||||
renderSupportActive(status);
|
||||
} catch (err) {
|
||||
if (btn) { btn.disabled = false; btn.textContent = "Re-lock Wallet Now"; }
|
||||
alert("Failed to re-lock wallet: " + (err.message || "Unknown error"));
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAuditLog() {
|
||||
var container = document.getElementById("support-audit-container");
|
||||
if (!container) return;
|
||||
if (container.style.display !== "none") {
|
||||
container.style.display = "none";
|
||||
return;
|
||||
}
|
||||
container.style.display = "block";
|
||||
container.innerHTML = '<p class="creds-loading">Loading audit log…</p>';
|
||||
try {
|
||||
var data = await apiFetch("/api/support/audit-log");
|
||||
if (!data.entries || data.entries.length === 0) {
|
||||
container.innerHTML = '<p class="support-audit-empty">No audit events recorded yet.</p>';
|
||||
} else {
|
||||
container.innerHTML = '<div class="support-audit-log">' +
|
||||
data.entries.map(function(e) { return '<div class="support-audit-entry">' + escHtml(e) + '</div>'; }).join("") +
|
||||
'</div>';
|
||||
}
|
||||
} catch (err) {
|
||||
container.innerHTML = '<p class="creds-empty">Could not load audit log.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function startSupportTimer() {
|
||||
stopSupportTimer();
|
||||
updateSupportTimer();
|
||||
_supportTimerInt = setInterval(updateSupportTimer, SUPPORT_TIMER_INTERVAL);
|
||||
}
|
||||
|
||||
function stopSupportTimer() {
|
||||
if (_supportTimerInt) { clearInterval(_supportTimerInt); _supportTimerInt = null; }
|
||||
}
|
||||
|
||||
function updateSupportTimer() {
|
||||
var el = document.getElementById("support-timer");
|
||||
if (!el || !_supportEnabledAt) return;
|
||||
var elapsed = (Date.now() / 1000) - _supportEnabledAt;
|
||||
el.textContent = formatDuration(Math.max(0, elapsed));
|
||||
}
|
||||
|
||||
function startWalletUnlockTimer(expiresAt) {
|
||||
stopWalletUnlockTimer();
|
||||
_walletUnlockTimerInt = setInterval(function() {
|
||||
if (Date.now() / 1000 >= expiresAt) {
|
||||
stopWalletUnlockTimer();
|
||||
// Refresh the support modal to show re-locked state
|
||||
apiFetch("/api/support/status").then(function(status) {
|
||||
_supportStatus = status;
|
||||
renderSupportActive(status);
|
||||
}).catch(function() {});
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
function stopWalletUnlockTimer() {
|
||||
if (_walletUnlockTimerInt) { clearInterval(_walletUnlockTimerInt); _walletUnlockTimerInt = null; }
|
||||
}
|
||||
|
||||
function closeSupportModal() {
|
||||
if ($supportModal) $supportModal.classList.remove("open");
|
||||
stopSupportTimer();
|
||||
stopWalletUnlockTimer();
|
||||
}
|
||||
|
||||
// ── Manual Backup modal ───────────────────────────────────────────
|
||||
|
||||
var _backupPollTimer = null;
|
||||
var _backupLogOffset = 0;
|
||||
|
||||
function openBackupModal() {
|
||||
if (!$supportModal) return;
|
||||
$supportModal.classList.add("open");
|
||||
$supportBody.innerHTML = '<p class="creds-loading">Detecting external drives\u2026</p>';
|
||||
detectDrivesAndRender();
|
||||
}
|
||||
|
||||
async function detectDrivesAndRender() {
|
||||
try {
|
||||
// Check whether a backup is already in progress
|
||||
var status = await apiFetch("/api/backup/status?offset=0");
|
||||
if (status.running) {
|
||||
renderBackupRunning();
|
||||
_backupLogOffset = status.offset || 0;
|
||||
if (status.log) {
|
||||
var logDiv = document.getElementById("backup-log");
|
||||
if (logDiv) { logDiv.insertAdjacentText("beforeend", status.log); logDiv.scrollTop = logDiv.scrollHeight; }
|
||||
}
|
||||
startBackupPoll();
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
var data = await apiFetch("/api/backup/drives");
|
||||
renderBackupReady(data.drives || []);
|
||||
} catch (err) {
|
||||
$supportBody.innerHTML = '<p class="creds-empty">Could not detect drives. Please try again.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderBackupReady(drives) {
|
||||
var driveSelector = "";
|
||||
if (drives.length > 0) {
|
||||
driveSelector = [
|
||||
'<label class="support-info-label" style="display:block;margin-bottom:6px;">Select drive:</label>',
|
||||
'<div style="display:flex;gap:8px;align-items:center;margin-bottom:14px;">',
|
||||
'<select id="backup-drive-select" class="support-unlock-select" style="flex:1;">',
|
||||
].join("");
|
||||
for (var i = 0; i < drives.length; i++) {
|
||||
var d = drives[i];
|
||||
driveSelector += '<option value="' + escHtml(d.path) + '">' +
|
||||
escHtml(d.name) + ' \u2014 ' + d.free_gb + ' GB free / ' + d.total_gb + ' GB total' +
|
||||
'</option>';
|
||||
}
|
||||
driveSelector += '</select>';
|
||||
driveSelector += '<button class="btn support-btn-auditlog" id="btn-backup-refresh" style="white-space:nowrap;">↻ Refresh</button>';
|
||||
driveSelector += '</div>';
|
||||
driveSelector += '<button class="btn support-btn-enable" id="btn-start-backup">Start Backup</button>';
|
||||
} else {
|
||||
driveSelector = [
|
||||
'<div class="support-wallet-box support-wallet-warning">',
|
||||
'<div class="support-wallet-header">',
|
||||
'<span class="support-wallet-icon">\u26a0\ufe0f</span>',
|
||||
'<span class="support-wallet-title">No External Drive Detected</span>',
|
||||
'</div>',
|
||||
'<p class="support-wallet-desc">',
|
||||
'No USB drive was found under /run/media/. ',
|
||||
'Make sure the drive is plugged in and mounted, then click Refresh.',
|
||||
'</p>',
|
||||
'</div>',
|
||||
'<button class="btn support-btn-auditlog" id="btn-backup-refresh">↻ Refresh</button>',
|
||||
].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");
|
||||
|
||||
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. ',
|
||||
'This manual backup lets you create an additional copy on an external USB drive \u2014 ',
|
||||
'storing your data in a third location, outside the computer, for maximum protection ',
|
||||
'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>',
|
||||
'</ol>',
|
||||
'</div>',
|
||||
|
||||
'<div class="support-steps">',
|
||||
'<div class="support-steps-title">What gets backed up</div>',
|
||||
'<ol class="support-backup-steps">',
|
||||
backupItemsHtml,
|
||||
'</ol>',
|
||||
'</div>',
|
||||
|
||||
dbNoteHtml,
|
||||
|
||||
'<div class="support-wallet-box support-wallet-protected">',
|
||||
'<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>',
|
||||
'</div>',
|
||||
|
||||
driveSelector,
|
||||
'</div>',
|
||||
].join("");
|
||||
|
||||
if (drives.length > 0) {
|
||||
document.getElementById("btn-start-backup").addEventListener("click", startBackup);
|
||||
document.getElementById("btn-backup-refresh").addEventListener("click", function() {
|
||||
$supportBody.innerHTML = '<p class="creds-loading">Scanning for external drives\u2026</p>';
|
||||
detectDrivesAndRender();
|
||||
});
|
||||
} else {
|
||||
document.getElementById("btn-backup-refresh").addEventListener("click", function() {
|
||||
$supportBody.innerHTML = '<p class="creds-loading">Scanning for external drives\u2026</p>';
|
||||
detectDrivesAndRender();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function startBackup() {
|
||||
var btn = document.getElementById("btn-start-backup");
|
||||
if (btn) { btn.disabled = true; btn.textContent = "Starting\u2026"; }
|
||||
var sel = document.getElementById("backup-drive-select");
|
||||
var target = sel ? sel.value : "";
|
||||
try {
|
||||
_backupLogOffset = 0;
|
||||
await apiFetch("/api/backup/run" + (target ? "?target=" + encodeURIComponent(target) : ""), { method: "POST" });
|
||||
renderBackupRunning();
|
||||
startBackupPoll();
|
||||
} catch (err) {
|
||||
if (btn) { btn.disabled = false; btn.textContent = "Start Backup"; }
|
||||
alert("Failed to start backup: " + (err.message || "Unknown error"));
|
||||
}
|
||||
}
|
||||
|
||||
function renderBackupRunning() {
|
||||
$supportBody.innerHTML = [
|
||||
'<div class="support-section">',
|
||||
'<div class="support-icon-big support-active-icon">\ud83d\udcbe</div>',
|
||||
'<h3 class="support-heading support-active-heading">Backup In Progress</h3>',
|
||||
'<div class="support-wallet-box support-wallet-warning">',
|
||||
'<div class="support-wallet-header">',
|
||||
'<span class="support-wallet-icon">\u26a0\ufe0f</span>',
|
||||
'<span class="support-wallet-title">Do Not Unplug</span>',
|
||||
'</div>',
|
||||
'<p class="support-wallet-desc">Do not remove the USB drive while the backup is running. This could corrupt the backup and your drive.</p>',
|
||||
'</div>',
|
||||
'<div class="modal-log" id="backup-log" style="text-align:left;"></div>',
|
||||
'</div>',
|
||||
].join("");
|
||||
}
|
||||
|
||||
function startBackupPoll() {
|
||||
stopBackupPoll();
|
||||
_backupPollTimer = setInterval(pollBackupStatus, 2000);
|
||||
pollBackupStatus();
|
||||
}
|
||||
|
||||
function stopBackupPoll() {
|
||||
if (_backupPollTimer) { clearInterval(_backupPollTimer); _backupPollTimer = null; }
|
||||
}
|
||||
|
||||
async function pollBackupStatus() {
|
||||
try {
|
||||
var data = await apiFetch("/api/backup/status?offset=" + _backupLogOffset);
|
||||
var logDiv = document.getElementById("backup-log");
|
||||
if (logDiv && data.log) {
|
||||
logDiv.insertAdjacentText("beforeend", data.log);
|
||||
logDiv.scrollTop = logDiv.scrollHeight;
|
||||
}
|
||||
_backupLogOffset = data.offset;
|
||||
const result = (data.result || "").toLowerCase();
|
||||
if (result === "success" || result === "failed") {
|
||||
stopBackupPoll();
|
||||
renderBackupDone(result === "success");
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function renderBackupDone(success) {
|
||||
var logDiv = document.getElementById("backup-log");
|
||||
var logContent = logDiv ? logDiv.textContent : "";
|
||||
|
||||
if (success) {
|
||||
$supportBody.innerHTML = [
|
||||
'<div class="support-section">',
|
||||
'<div class="support-icon-big">\u2705</div>',
|
||||
'<h3 class="support-heading">All Finished!</h3>',
|
||||
'<div class="support-wallet-box support-wallet-protected">',
|
||||
'<div class="support-wallet-header">',
|
||||
'<span class="support-wallet-icon">\u23cf\ufe0f</span>',
|
||||
'<span class="support-wallet-title">Eject Your Drive</span>',
|
||||
'</div>',
|
||||
'<p class="support-wallet-desc">Please eject the drive before removing it from your Sovran Pro.</p>',
|
||||
'</div>',
|
||||
'<div class="modal-log" id="backup-log-done" style="text-align:left;"></div>',
|
||||
'<button class="btn support-btn-done" id="btn-backup-close">Close</button>',
|
||||
'</div>',
|
||||
].join("");
|
||||
var doneLog = document.getElementById("backup-log-done");
|
||||
if (doneLog) { doneLog.textContent = logContent; doneLog.scrollTop = doneLog.scrollHeight; }
|
||||
} else {
|
||||
$supportBody.innerHTML = [
|
||||
'<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>',
|
||||
'<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>',
|
||||
].join("");
|
||||
var failLog = document.getElementById("backup-log-fail");
|
||||
if (failLog) { failLog.textContent = logContent; failLog.scrollTop = failLog.scrollHeight; }
|
||||
}
|
||||
document.getElementById("btn-backup-close").addEventListener("click", closeSupportModal);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
"use strict";
|
||||
|
||||
// ── Bitcoin IBD sync state (for ETA calculation) ──────────────────
|
||||
// Keyed by tileId: { progress: float, timestamp: ms }
|
||||
var _btcSyncPrev = {};
|
||||
|
||||
|
||||
function _firstElementFromHtml(html) {
|
||||
var tmp = document.createElement("div");
|
||||
tmp.innerHTML = html;
|
||||
return tmp.firstElementChild || null;
|
||||
}
|
||||
|
||||
// ── Render: initial build ─────────────────────────────────────────
|
||||
|
||||
function buildTiles(services, categoryLabels) {
|
||||
_servicesCache = services;
|
||||
var grouped = {};
|
||||
var supportServices = [];
|
||||
for (var i = 0; i < services.length; i++) {
|
||||
var svc = services[i];
|
||||
// Support tiles go to the sidebar, not the main grid
|
||||
if (svc.category === "support" || svc.type === "support") {
|
||||
supportServices.push(svc);
|
||||
continue;
|
||||
}
|
||||
var cat = svc.category || "other";
|
||||
if (CATEGORY_ALIASES[cat]) cat = CATEGORY_ALIASES[cat];
|
||||
if (!grouped[cat]) grouped[cat] = [];
|
||||
grouped[cat].push(svc);
|
||||
}
|
||||
renderSidebarSupport(supportServices);
|
||||
$tilesArea.innerHTML = "";
|
||||
var orderedKeys = CATEGORY_ORDER.filter(function(k) { return grouped[k]; });
|
||||
Object.keys(grouped).forEach(function(k) {
|
||||
if (orderedKeys.indexOf(k) === -1) orderedKeys.push(k);
|
||||
});
|
||||
for (var j = 0; j < orderedKeys.length; j++) {
|
||||
var catKey = orderedKeys[j];
|
||||
var entries = grouped[catKey];
|
||||
if (!entries || entries.length === 0) continue;
|
||||
var label = categoryLabels[catKey] || catKey;
|
||||
var section = document.createElement("div");
|
||||
section.className = "category-section";
|
||||
section.dataset.category = catKey;
|
||||
section.innerHTML = '<div class="section-header">' + escHtml(label) + '</div><hr class="section-divider" /><div class="tiles-grid" data-cat="' + escHtml(catKey) + '"></div>';
|
||||
var grid = section.querySelector(".tiles-grid");
|
||||
for (var k = 0; k < entries.length; k++) {
|
||||
grid.appendChild(buildTile(entries[k]));
|
||||
}
|
||||
$tilesArea.appendChild(section);
|
||||
}
|
||||
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) {
|
||||
$sidebarSupport.innerHTML = "";
|
||||
|
||||
// ── Update System button (above Tech Help)
|
||||
var sidebarUpdateBtn = document.createElement("button");
|
||||
sidebarUpdateBtn.className = "sidebar-support-btn";
|
||||
sidebarUpdateBtn.id = "sidebar-btn-update";
|
||||
sidebarUpdateBtn.innerHTML =
|
||||
'<span class="sidebar-support-icon"><svg><use href="#g-update"/></svg></span>' +
|
||||
'<span class="sidebar-support-text">' +
|
||||
'<span class="sidebar-support-title">Update System</span>' +
|
||||
'<span class="sidebar-support-hint" id="sidebar-update-hint">Check for updates</span>' +
|
||||
'</span>';
|
||||
sidebarUpdateBtn.addEventListener("click", function() { openUpdateModal(); });
|
||||
$sidebarSupport.appendChild(sidebarUpdateBtn);
|
||||
// checkUpdates may already have run before the sidebar was built
|
||||
applyUpdateSidebarState(window._lastUpdateCheck);
|
||||
|
||||
for (var i = 0; i < supportServices.length; i++) {
|
||||
var svc = supportServices[i];
|
||||
var btn = document.createElement("button");
|
||||
btn.className = "sidebar-support-btn";
|
||||
btn.innerHTML =
|
||||
'<span class="sidebar-support-icon"><svg><use href="#g-lifebuoy"/></svg></span>' +
|
||||
'<span class="sidebar-support-text">' +
|
||||
'<span class="sidebar-support-title">' + escHtml(svc.name || "Tech Support") + '</span>' +
|
||||
'<span class="sidebar-support-hint">Click for help</span>' +
|
||||
'</span>';
|
||||
btn.addEventListener("click", function() { openSupportModal(); });
|
||||
$sidebarSupport.appendChild(btn);
|
||||
}
|
||||
|
||||
// ── Manual Backup button
|
||||
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-text">' +
|
||||
'<span class="sidebar-support-title">Manual Backup</span>' +
|
||||
'<span class="sidebar-support-hint">Back up to external drive</span>' +
|
||||
'</span>';
|
||||
backupBtn.addEventListener("click", function() { openBackupModal(); });
|
||||
$sidebarSupport.appendChild(backupBtn);
|
||||
|
||||
// ── Security button
|
||||
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-text">' +
|
||||
'<span class="sidebar-support-title">Security</span>' +
|
||||
'<span class="sidebar-support-hint">Reset & verify system</span>' +
|
||||
'</span>';
|
||||
securityBtn.addEventListener("click", function() { openSecurityModal(); });
|
||||
$sidebarSupport.appendChild(securityBtn);
|
||||
|
||||
// ── Upgrade button (Node role only)
|
||||
if (_currentRole === "node") {
|
||||
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-text">' +
|
||||
'<span class="sidebar-support-title">Upgrade to Full Server</span>' +
|
||||
'<span class="sidebar-support-hint">Unlock all services</span>' +
|
||||
'</span>';
|
||||
upgradeBtn.addEventListener("click", function() { openUpgradeModal(); });
|
||||
$sidebarSupport.appendChild(upgradeBtn);
|
||||
}
|
||||
|
||||
var hr = document.createElement("hr");
|
||||
hr.className = "sidebar-divider";
|
||||
$sidebarSupport.appendChild(hr);
|
||||
}
|
||||
|
||||
function buildTile(svc) {
|
||||
var isSupport = svc.type === "support";
|
||||
var sc = statusClass(svc.health || svc.status);
|
||||
var st = statusText(svc.health || svc.status, svc.enabled);
|
||||
var dis = !svc.enabled;
|
||||
|
||||
var tile = document.createElement("div");
|
||||
tile.className = "service-tile" + (dis ? " disabled" : "") + (isSupport ? " support-tile" : "");
|
||||
tile.dataset.unit = svc.unit;
|
||||
tile.dataset.tileId = tileId(svc);
|
||||
if (isSupport) {
|
||||
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="support-status-label">Click for help</span></div>';
|
||||
tile.style.cursor = "pointer";
|
||||
tile.addEventListener("click", function() { openSupportModal(); });
|
||||
return tile;
|
||||
}
|
||||
|
||||
if (svc.sync_ibd && svc.enabled) {
|
||||
var pct = Math.round((svc.sync_progress || 0) * 100);
|
||||
var id = tileId(svc);
|
||||
var eta = _calcBtcEta(id, svc.sync_progress || 0);
|
||||
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-sync-container">' +
|
||||
'<div class="tile-sync-label">\u23F3 Syncing Timechain</div>' +
|
||||
'<div class="tile-sync-bar-row">' +
|
||||
'<div class="tile-sync-bar-track"><div class="tile-sync-bar-fill" style="width:' + pct + '%"></div></div>' +
|
||||
'<span class="tile-sync-percent">' + pct + '%</span>' +
|
||||
'</div>' +
|
||||
'<div class="tile-sync-eta">' + escHtml(eta) + '</div>' +
|
||||
'</div>';
|
||||
tile.style.cursor = "pointer";
|
||||
tile.addEventListener("click", function() {
|
||||
openServiceDetailModal(svc.unit, svc.name, svc.icon);
|
||||
});
|
||||
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>';
|
||||
|
||||
tile.style.cursor = "pointer";
|
||||
tile.addEventListener("click", function() {
|
||||
openServiceDetailModal(svc.unit, svc.name, svc.icon);
|
||||
});
|
||||
|
||||
return tile;
|
||||
}
|
||||
|
||||
// ── Render: live update ───────────────────────────────────────────
|
||||
|
||||
// Calculate ETA text for Bitcoin IBD and track progress history.
|
||||
function _calcBtcEta(id, progress) {
|
||||
var now = Date.now();
|
||||
var prev = _btcSyncPrev[id];
|
||||
// Only update the cache when progress has actually advanced
|
||||
if (!prev || prev.progress < progress) {
|
||||
_btcSyncPrev[id] = { progress: progress, timestamp: now };
|
||||
}
|
||||
if (!prev || prev.progress >= progress) return "Estimating\u2026";
|
||||
var elapsed = (now - prev.timestamp) / 1000; // seconds
|
||||
if (elapsed <= 0) return "Estimating\u2026";
|
||||
var rate = (progress - prev.progress) / elapsed; // progress per second
|
||||
if (rate <= 0) return "Estimating\u2026";
|
||||
var remaining = (1.0 - progress) / rate;
|
||||
return "\u007E" + formatDuration(remaining) + " remaining";
|
||||
}
|
||||
|
||||
function updateTiles(services) {
|
||||
_servicesCache = services;
|
||||
for (var i = 0; i < services.length; i++) {
|
||||
var svc = services[i];
|
||||
if (svc.type === "support") continue;
|
||||
var id = CSS.escape(tileId(svc));
|
||||
var tile = $tilesArea.querySelector('.service-tile[data-tile-id="' + id + '"]');
|
||||
if (!tile) continue;
|
||||
|
||||
if (svc.sync_ibd && svc.enabled) {
|
||||
// If tile was previously normal, rebuild it with the sync layout
|
||||
if (!tile.querySelector(".tile-sync-container")) {
|
||||
var newTile = buildTile(svc);
|
||||
tile.parentNode.replaceChild(newTile, tile);
|
||||
continue;
|
||||
}
|
||||
// Update progress bar values in-place
|
||||
var pct = Math.round((svc.sync_progress || 0) * 100);
|
||||
var etaText = _calcBtcEta(tileId(svc), svc.sync_progress || 0);
|
||||
var fill = tile.querySelector(".tile-sync-bar-fill");
|
||||
var pctEl = tile.querySelector(".tile-sync-percent");
|
||||
var etaEl = tile.querySelector(".tile-sync-eta");
|
||||
if (fill) fill.style.width = pct + "%";
|
||||
if (pctEl) pctEl.textContent = pct + "%";
|
||||
if (etaEl) etaEl.textContent = etaText;
|
||||
} else {
|
||||
// IBD finished or not syncing — if tile had sync layout rebuild it normally
|
||||
if (tile.querySelector(".tile-sync-container")) {
|
||||
delete _btcSyncPrev[tileId(svc)];
|
||||
var normalTile = buildTile(svc);
|
||||
tile.parentNode.replaceChild(normalTile, tile);
|
||||
continue;
|
||||
}
|
||||
var sc = statusClass(svc.health || svc.status);
|
||||
var st = statusText(svc.health || svc.status, svc.enabled);
|
||||
var dot = tile.querySelector(".status-dot");
|
||||
var text = tile.querySelector(".status-text");
|
||||
if (dot) dot.className = "status-dot " + sc;
|
||||
if (text) text.textContent = st;
|
||||
}
|
||||
}
|
||||
if (typeof window.dashboardServicesUpdated === "function") window.dashboardServicesUpdated();
|
||||
}
|
||||
|
||||
// ── Service polling ───────────────────────────────────────────────
|
||||
|
||||
var _firstLoad = true;
|
||||
|
||||
async function refreshServices() {
|
||||
try {
|
||||
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); }
|
||||
}
|
||||
|
||||
// ── Network IPs ───────────────────────────────────────────────────
|
||||
|
||||
async function loadNetwork() {
|
||||
try {
|
||||
var data = await apiFetch("/api/network");
|
||||
if ($internalIp) $internalIp.textContent = data.internal_ip || "—";
|
||||
if ($externalIp) $externalIp.textContent = data.external_ip || "—";
|
||||
_cachedExternalIp = data.external_ip || "unavailable";
|
||||
} catch (_) {
|
||||
if ($internalIp) $internalIp.textContent = "—";
|
||||
if ($externalIp) $externalIp.textContent = "—";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Update check ──────────────────────────────────────────────────
|
||||
|
||||
// Paint the sidebar Update button from the last known update state.
|
||||
// Called after each check AND when the button is (re)built, so the hint
|
||||
// is correct regardless of which finishes first at startup.
|
||||
function applyUpdateSidebarState(data) {
|
||||
if (!data) return;
|
||||
var sidebarUpdateBtn = document.getElementById("sidebar-btn-update");
|
||||
var sidebarUpdateHint = document.getElementById("sidebar-update-hint");
|
||||
if (!sidebarUpdateBtn) return;
|
||||
var hasUpdates = !!data.available;
|
||||
var updateStatus = data.status || "idle";
|
||||
if (updateStatus === "failed") {
|
||||
// Last update errored and did not apply — surface it as a persistent
|
||||
// red banner that re-opens the failed run with a "Retry Update" action.
|
||||
sidebarUpdateBtn.style.borderColor = "#f66151";
|
||||
sidebarUpdateBtn.style.backgroundColor = "rgba(246, 97, 81, 0.10)";
|
||||
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Update failed — click to retry";
|
||||
} else if (updateStatus === "reboot_required") {
|
||||
sidebarUpdateBtn.style.borderColor = "#e9b64a";
|
||||
sidebarUpdateBtn.style.backgroundColor = "rgba(233, 182, 74, 0.10)";
|
||||
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Restart required";
|
||||
} else if (updateStatus === "running") {
|
||||
sidebarUpdateBtn.style.borderColor = "#78aeed";
|
||||
sidebarUpdateBtn.style.backgroundColor = "rgba(120, 174, 237, 0.10)";
|
||||
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Update in progress…";
|
||||
} else if (hasUpdates) {
|
||||
sidebarUpdateBtn.style.borderColor = "#3ecf8e";
|
||||
sidebarUpdateBtn.style.backgroundColor = "rgba(62, 207, 142, 0.10)";
|
||||
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Updates available!";
|
||||
} else {
|
||||
sidebarUpdateBtn.style.borderColor = "";
|
||||
sidebarUpdateBtn.style.backgroundColor = "";
|
||||
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "System is up to date";
|
||||
}
|
||||
}
|
||||
|
||||
async function checkUpdates() {
|
||||
try {
|
||||
var data = await apiFetch("/api/updates/check");
|
||||
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 (_) {}
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
"use strict";
|
||||
|
||||
// ── Update modal ──────────────────────────────────────────────────
|
||||
|
||||
// ── Update dialog presentation (pill, status line, last-checked) ──
|
||||
|
||||
var _updateLastCheckTs = 0;
|
||||
|
||||
function markUpdateChecked() {
|
||||
_updateLastCheckTs = Date.now();
|
||||
}
|
||||
|
||||
function _updateLastCheckedText() {
|
||||
if (!$updLastChecked) return;
|
||||
if (!_updateLastCheckTs) { $updLastChecked.textContent = "—"; return; }
|
||||
var s = Math.floor((Date.now() - _updateLastCheckTs) / 1000);
|
||||
if (s < 30) { $updLastChecked.textContent = "just now"; return; }
|
||||
var m = Math.floor(s / 60);
|
||||
if (m < 60) { $updLastChecked.textContent = m + (m === 1 ? " minute ago" : " minutes ago"); return; }
|
||||
var h = Math.floor(m / 60);
|
||||
$updLastChecked.textContent = h + (h === 1 ? " hour ago" : " hours ago");
|
||||
}
|
||||
|
||||
function setUpdatePill(state) {
|
||||
if (!$updPill) return;
|
||||
var cls = "upd-pill";
|
||||
var html;
|
||||
if (state === "checking") {
|
||||
cls += " st-loading"; html = '<span class="status-dot loading pulse"></span>Checking…';
|
||||
} else if (state === "uptodate") {
|
||||
cls += " st-active"; html = '<span class="status-dot active"></span>Up to date';
|
||||
} else if (state === "complete") {
|
||||
cls += " st-active"; html = '<span class="status-dot active"></span>Update complete';
|
||||
} else if (state === "reboot") {
|
||||
cls += " st-needs-attention"; html = '<span class="status-dot needs-attention pulse"></span>Restart required';
|
||||
} else if (state === "failed") {
|
||||
cls += " st-failed"; html = '<span class="status-dot failed"></span>Update failed';
|
||||
} else if (state === "unavailable") {
|
||||
cls += " st-needs-attention"; html = '<span class="status-dot needs-attention pulse"></span>Status unknown';
|
||||
} else {
|
||||
cls += " st-loading"; html = '<span class="status-dot loading pulse"></span>Updating…';
|
||||
}
|
||||
$updPill.className = cls;
|
||||
$updPill.innerHTML = html;
|
||||
}
|
||||
|
||||
// Single place that reflects update state in the dialog: status message
|
||||
// text + color, header pill, and the Close / Check again availability.
|
||||
function _setUpdateStatus(text) {
|
||||
if ($modalStatus) $modalStatus.textContent = text;
|
||||
var state;
|
||||
if (text.charAt(0) === "✗") state = "failed";
|
||||
else if (text.indexOf("restart required") !== -1) state = "reboot";
|
||||
else if (text.charAt(0) === "✓") state = (text.indexOf("already up to date") !== -1) ? "uptodate" : "complete";
|
||||
else if (text.indexOf("unavailable") !== -1) state = "unavailable";
|
||||
else if (text.indexOf("Checking") === 0) state = "checking";
|
||||
else state = "updating";
|
||||
if ($modalStatus) {
|
||||
var msg = "update-status-msg";
|
||||
if (state === "failed") $modalStatus.className = msg + " st-err";
|
||||
else if (state === "reboot" || state === "unavailable") $modalStatus.className = msg + " st-warn";
|
||||
else if (state === "uptodate" || state === "complete") $modalStatus.className = msg + " st-ok";
|
||||
else $modalStatus.className = msg;
|
||||
// In the up-to-date state the green console line already says it —
|
||||
// the mockup shows it exactly once.
|
||||
$modalStatus.style.display = (state === "uptodate") ? "none" : "";
|
||||
}
|
||||
setUpdatePill(state);
|
||||
var interactive = (state !== "updating");
|
||||
if ($updateCloseBtn) $updateCloseBtn.disabled = !interactive;
|
||||
if ($btnCheckAgain) {
|
||||
$btnCheckAgain.disabled = (state === "checking" || state === "updating");
|
||||
$btnCheckAgain.style.display = interactive ? "inline-flex" : "none";
|
||||
}
|
||||
_updateLastCheckedText();
|
||||
}
|
||||
|
||||
async function openUpdateModal() {
|
||||
if (!$modal) return;
|
||||
|
||||
// Open immediately in a checking state; the status/check requests below
|
||||
// fill in the real result (mockup: auto-check on open).
|
||||
$modal.classList.add("open");
|
||||
if ($modalLog) $modalLog.innerHTML = '<span class="dim">Checking for updates…</span>';
|
||||
_setUpdateStatus("Checking for updates…");
|
||||
|
||||
// Reattach before checking for new updates. This makes a browser reload,
|
||||
// RDP reconnect, or suspended tab recover the authoritative systemd-backed
|
||||
// state instead of starting over or claiming the system is merely up to date.
|
||||
try {
|
||||
var current = await apiFetchWithTimeout(
|
||||
"/api/updates/status?offset=0",
|
||||
{ cache: "no-store" },
|
||||
STATUS_POLL_FETCH_TIMEOUT
|
||||
);
|
||||
if (current.running || current.result === "reboot_required" || current.result === "failed") {
|
||||
// An in-progress update, a staged update awaiting reboot, or a prior
|
||||
// failed update — reattach to the persisted systemd/log state instead of
|
||||
// starting over or wrongly reporting "up to date".
|
||||
showExistingUpdate(current);
|
||||
return;
|
||||
}
|
||||
} catch (_) {
|
||||
// The normal start path below has its own visible error handling.
|
||||
}
|
||||
|
||||
apiFetchWithTimeout(
|
||||
"/api/updates/check",
|
||||
{ cache: "no-store" },
|
||||
STATUS_POLL_FETCH_TIMEOUT
|
||||
)
|
||||
.then(function(data) {
|
||||
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 ($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;
|
||||
}
|
||||
_doOpenUpdateModal();
|
||||
})
|
||||
.catch(function() {
|
||||
_doOpenUpdateModal();
|
||||
});
|
||||
}
|
||||
|
||||
function prepareUpdateModal() {
|
||||
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 ($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");
|
||||
stopUpdatePoll();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function startUpdate() {
|
||||
apiFetchWithTimeout(
|
||||
"/api/updates/run",
|
||||
{ method: "POST" },
|
||||
STATUS_POLL_FETCH_TIMEOUT * 2
|
||||
)
|
||||
.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 ($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…");
|
||||
startUpdatePoll();
|
||||
})
|
||||
.catch(function(err) {
|
||||
appendLog("[Error: failed to start update — " + err + "]\n");
|
||||
onUpdateDone(false);
|
||||
});
|
||||
}
|
||||
|
||||
function startUpdatePoll() {
|
||||
if (_updatePollTimer) clearInterval(_updatePollTimer);
|
||||
pollUpdateStatus();
|
||||
_updatePollTimer = setInterval(pollUpdateStatus, UPDATE_POLL_INTERVAL);
|
||||
}
|
||||
|
||||
function stopUpdatePoll() {
|
||||
if (_updatePollTimer) { clearInterval(_updatePollTimer); _updatePollTimer = null; }
|
||||
}
|
||||
|
||||
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;
|
||||
try {
|
||||
var data = await apiFetchWithTimeout(
|
||||
"/api/updates/status?offset=" + _updateLogOffset,
|
||||
{ cache: "no-store" },
|
||||
STATUS_POLL_FETCH_TIMEOUT
|
||||
);
|
||||
_updatePollFailures = 0;
|
||||
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.
|
||||
_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
|
||||
);
|
||||
if (fullData.log) appendLog(fullData.log);
|
||||
_updateLogOffset = fullData.offset;
|
||||
data = fullData;
|
||||
} catch (_) {
|
||||
if (data.log) appendLog(data.log);
|
||||
_updateLogOffset = data.offset;
|
||||
}
|
||||
if (data.result === "reboot_required") {
|
||||
appendLog("[Reconnected — update completed, reboot required.]\n");
|
||||
} else if (data.result === "success") {
|
||||
appendLog("[Reconnected — update completed successfully.]\n");
|
||||
} else {
|
||||
appendLog("[Reconnected — update encountered an error.]\n");
|
||||
}
|
||||
_updateFinished = true;
|
||||
stopUpdatePoll();
|
||||
if (data.result === "reboot_required") {
|
||||
onUpdateDone("reboot_required");
|
||||
} else {
|
||||
onUpdateDone(data.result === "success");
|
||||
}
|
||||
return;
|
||||
}
|
||||
appendLog("[Update status reconnected]\n");
|
||||
_setUpdateStatus("Updating…");
|
||||
}
|
||||
if (data.log) appendLog(data.log);
|
||||
_updateLogOffset = data.offset;
|
||||
if (data.running) return;
|
||||
_updateFinished = true;
|
||||
stopUpdatePoll();
|
||||
if (data.result === "reboot_required") {
|
||||
onUpdateDone("reboot_required");
|
||||
} else if (data.result === "success") {
|
||||
onUpdateDone(true);
|
||||
} else {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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 ($btnReboot) $btnReboot.style.display = "inline-flex";
|
||||
} else if (result === "reboot_required") {
|
||||
_setUpdateStatus("✓ Update complete — restart 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 ($btnSave) $btnSave.style.display = "inline-flex";
|
||||
if ($btnReboot) $btnReboot.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
function saveErrorReport() {
|
||||
var blob = new Blob([_updateLog], { type: "text/plain" });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "sovran-update-error-" + new Date().toISOString().split(".")[0].replace(/:/g, "-") + ".txt";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
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.
|
||||
});
|
||||
// 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);
|
||||
|
||||
fetch("/api/ping", { cache: "no-store", signal: controller.signal, headers: { "Connection": "close" } })
|
||||
.then(function(res) {
|
||||
clearTimeout(timeoutId);
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Generator: Adobe Illustrator 23.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
|
||||
<svg
|
||||
version="1.1"
|
||||
id="Layer_1"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 218.44057 109.75845"
|
||||
xml:space="preserve"
|
||||
sodipodi:docname="sovran_systems_2.svg"
|
||||
width="218.44057"
|
||||
height="109.75845"
|
||||
inkscape:export-filename="sovran_systems_2.svg"
|
||||
inkscape:export-xdpi="169.6163"
|
||||
inkscape:export-ydpi="169.6163"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs47" /><sodipodi:namedview
|
||||
id="namedview45"
|
||||
pagecolor="#505050"
|
||||
bordercolor="#ffffff"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="1"
|
||||
inkscape:deskcolor="#505050"
|
||||
showgrid="false" />
|
||||
<style
|
||||
type="text/css"
|
||||
id="style2">
|
||||
.st0{fill:#1C9954;}
|
||||
.st1{fill:#077233;}
|
||||
</style>
|
||||
<g
|
||||
id="g30"
|
||||
transform="matrix(0.32162395,0,0,0.33123626,-75.234275,-114.64087)"
|
||||
style="fill:#dedede;fill-opacity:1">
|
||||
<path
|
||||
d="m 354.93,540.02 h -18.69 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.34,-0.33 -0.5,-0.88 -0.5,-1.63 v -6.92 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 15.91 c 0.51,0 0.9,-0.17 1.15,-0.5 0.26,-0.33 0.38,-0.74 0.38,-1.21 0,-0.67 -0.13,-1.16 -0.38,-1.48 -0.26,-0.31 -0.64,-0.49 -1.15,-0.53 l -8.87,-1.24 c -2.76,-0.39 -4.98,-1.3 -6.65,-2.72 -1.68,-1.42 -2.51,-3.78 -2.51,-7.1 v -6.21 c 0,-3.35 1.08,-5.92 3.25,-7.72 2.17,-1.79 5.16,-2.69 8.99,-2.69 h 16.56 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 7.04 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 h -13.78 c -0.51,0 -0.91,0.17 -1.18,0.5 -0.28,0.34 -0.41,0.76 -0.41,1.27 0,0.51 0.14,0.95 0.41,1.3 0.28,0.35 0.67,0.55 1.18,0.59 l 8.81,1.18 c 2.76,0.39 4.99,1.3 6.68,2.72 1.69,1.42 2.54,3.78 2.54,7.1 v 6.21 c 0,3.35 -1.09,5.92 -3.28,7.72 -2.19,1.8 -5.18,2.69 -8.96,2.69 z"
|
||||
id="path4"
|
||||
style="fill:#dedede;fill-opacity:1" />
|
||||
<path
|
||||
d="m 412.05,528.85 c 0,1.81 -0.27,3.46 -0.8,4.94 -0.53,1.48 -1.48,2.74 -2.84,3.78 -1.36,1.04 -3.23,1.86 -5.62,2.45 -2.39,0.59 -5.41,0.89 -9.08,0.89 -3.67,0 -6.7,-0.29 -9.11,-0.89 -2.4,-0.59 -4.29,-1.41 -5.65,-2.45 -1.36,-1.04 -2.31,-2.31 -2.84,-3.78 -0.53,-1.48 -0.8,-3.12 -0.8,-4.94 v -20.17 c 0,-1.81 0.27,-3.46 0.8,-4.94 0.53,-1.48 1.48,-2.75 2.84,-3.81 1.36,-1.06 3.24,-1.89 5.65,-2.48 2.4,-0.59 5.44,-0.89 9.11,-0.89 3.67,0 6.69,0.3 9.08,0.89 2.39,0.59 4.26,1.42 5.62,2.48 1.36,1.06 2.31,2.34 2.84,3.81 0.53,1.48 0.8,3.13 0.8,4.94 z m -23.3,-2.13 c 0,0.79 0.3,1.45 0.89,1.98 0.59,0.53 1.95,0.8 4.08,0.8 2.13,0 3.49,-0.27 4.08,-0.8 0.59,-0.53 0.89,-1.19 0.89,-1.98 v -15.91 c 0,-0.75 -0.3,-1.39 -0.89,-1.92 -0.59,-0.53 -1.95,-0.8 -4.08,-0.8 -2.13,0 -3.49,0.27 -4.08,0.8 -0.59,0.53 -0.89,1.17 -0.89,1.92 z"
|
||||
id="path6"
|
||||
style="fill:#dedede;fill-opacity:1" />
|
||||
<path
|
||||
d="m 447.12,540.02 h -15.38 c -0.75,0 -1.37,-0.14 -1.86,-0.41 -0.49,-0.28 -0.88,-0.79 -1.15,-1.54 l -5.8,-14.49 c -0.35,-0.87 -0.65,-1.63 -0.89,-2.28 -0.24,-0.65 -0.43,-1.27 -0.59,-1.86 -0.16,-0.59 -0.27,-1.21 -0.33,-1.86 -0.06,-0.65 -0.09,-1.45 -0.09,-2.4 v -15.61 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 9.4 c 0.75,0 1.32,0.17 1.71,0.5 0.39,0.34 0.59,0.88 0.59,1.63 v 16.32 c 0,0.39 0.04,0.79 0.12,1.18 0.08,0.39 0.2,0.81 0.35,1.24 l 2.78,8.28 c 0.12,0.39 0.26,0.66 0.41,0.8 0.16,0.14 0.39,0.21 0.71,0.21 h 0.65 c 0.31,0 0.55,-0.07 0.71,-0.21 0.16,-0.14 0.3,-0.4 0.41,-0.8 l 2.78,-8.34 c 0.16,-0.43 0.28,-0.85 0.35,-1.24 0.08,-0.39 0.12,-0.79 0.12,-1.18 v -16.26 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 9.28 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 15.61 c 0,0.95 -0.03,1.74 -0.09,2.4 -0.06,0.65 -0.17,1.27 -0.33,1.86 -0.16,0.59 -0.35,1.21 -0.59,1.86 -0.24,0.65 -0.53,1.41 -0.89,2.28 l -5.8,14.49 c -0.28,0.75 -0.66,1.26 -1.15,1.54 -0.45,0.28 -1.07,0.41 -1.82,0.41 z"
|
||||
id="path8"
|
||||
style="fill:#dedede;fill-opacity:1" />
|
||||
<path
|
||||
d="m 478.69,540.02 h -9.11 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.34,-0.33 -0.5,-0.88 -0.5,-1.63 v -38.32 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 19.69 c 4.41,0 7.46,0.92 9.14,2.75 1.68,1.83 2.51,4.21 2.51,7.13 v 2.72 c 0,1.66 -0.25,3.07 -0.74,4.23 -0.49,1.16 -1.35,2 -2.57,2.51 2.13,0.24 3.85,1.1 5.17,2.6 1.32,1.5 1.98,3.49 1.98,5.97 v 12.54 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 h -9.17 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.34,-0.33 -0.5,-0.88 -0.5,-1.63 v -9.05 c 0,-0.87 -0.17,-1.51 -0.5,-1.92 -0.34,-0.41 -0.92,-0.62 -1.74,-0.62 h -8.28 v 11.59 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.32,0.34 -0.87,0.5 -1.62,0.5 z m 2.13,-31.93 v 7.57 h 4.44 c 1.02,0 1.71,-0.27 2.07,-0.8 0.36,-0.53 0.53,-1.19 0.53,-1.98 v -2.01 c 0,-0.79 -0.18,-1.45 -0.53,-1.98 -0.35,-0.53 -1.04,-0.8 -2.07,-0.8 z"
|
||||
id="path10"
|
||||
style="fill:#dedede;fill-opacity:1" />
|
||||
<path
|
||||
d="m 525.83,537.89 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 H 515 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.34,-0.33 -0.5,-0.88 -0.5,-1.63 v -15.61 c 0,-1.18 0.19,-2.54 0.56,-4.08 0.38,-1.54 0.96,-3.33 1.75,-5.38 l 5.15,-13.42 c 0.24,-0.67 0.6,-1.16 1.09,-1.48 0.49,-0.31 1.13,-0.47 1.92,-0.47 h 15.91 c 0.75,0 1.37,0.16 1.86,0.47 0.49,0.32 0.86,0.81 1.09,1.48 l 5.14,13.42 c 0.79,2.05 1.37,3.84 1.75,5.38 0.37,1.54 0.56,2.9 0.56,4.08 v 15.61 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 h -8.93 c -0.79,0 -1.37,-0.17 -1.74,-0.5 -0.38,-0.33 -0.56,-0.88 -0.56,-1.63 v -8.28 h -10.47 v 8.28 z m 3.37,-28.03 -2.78,8.99 h 9.29 l -2.78,-8.99 c -0.16,-0.35 -0.33,-0.61 -0.5,-0.77 -0.18,-0.16 -0.38,-0.24 -0.62,-0.24 h -1.48 c -0.24,0 -0.44,0.08 -0.62,0.24 -0.19,0.16 -0.36,0.42 -0.51,0.77 z"
|
||||
id="path12"
|
||||
style="fill:#dedede;fill-opacity:1" />
|
||||
<path
|
||||
d="m 570.29,540.02 h -8.87 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.33,-0.33 -0.5,-0.88 -0.5,-1.63 v -38.32 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 6.15 c 0.75,0 1.39,0.12 1.92,0.35 0.53,0.24 1.05,0.65 1.57,1.24 l 11.47,13.13 v -12.6 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 8.87 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 38.32 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 h -8.87 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.34,-0.33 -0.5,-0.88 -0.5,-1.63 v -7.27 l -10.11,-12.24 v 19.51 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.35 -0.88,0.51 -1.63,0.51 z"
|
||||
id="path14"
|
||||
style="fill:#dedede;fill-opacity:1" />
|
||||
<path
|
||||
d="m 641.61,540.02 h -18.69 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.33,-0.33 -0.5,-0.88 -0.5,-1.63 v -6.92 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.34 0.88,-0.5 1.63,-0.5 h 15.91 c 0.51,0 0.9,-0.17 1.15,-0.5 0.26,-0.33 0.38,-0.74 0.38,-1.21 0,-0.67 -0.13,-1.16 -0.38,-1.48 -0.26,-0.31 -0.64,-0.49 -1.15,-0.53 l -8.87,-1.24 c -2.76,-0.39 -4.98,-1.3 -6.65,-2.72 -1.68,-1.42 -2.51,-3.78 -2.51,-7.1 v -6.21 c 0,-3.35 1.08,-5.92 3.25,-7.72 2.17,-1.79 5.16,-2.69 8.99,-2.69 h 16.56 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 7.04 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 h -13.78 c -0.51,0 -0.91,0.17 -1.18,0.5 -0.28,0.34 -0.41,0.76 -0.41,1.27 0,0.51 0.14,0.95 0.41,1.3 0.28,0.35 0.67,0.55 1.18,0.59 l 8.81,1.18 c 2.76,0.39 4.99,1.3 6.68,2.72 1.7,1.42 2.54,3.78 2.54,7.1 v 6.21 c 0,3.35 -1.09,5.92 -3.28,7.72 -2.19,1.8 -5.17,2.69 -8.96,2.69 z"
|
||||
id="path16"
|
||||
style="fill:#dedede;fill-opacity:1" />
|
||||
<path
|
||||
d="m 683.66,540.02 h -9.58 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.34,-0.33 -0.5,-0.88 -0.5,-1.63 v -7.57 L 662.9,518.2 c -0.91,-1.22 -1.51,-2.29 -1.8,-3.19 -0.3,-0.91 -0.44,-2.27 -0.44,-4.08 v -11.35 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 9.11 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 9.7 c 0,0.39 0.02,0.81 0.06,1.24 0.04,0.43 0.2,0.85 0.47,1.24 l 2.72,4.26 c 0.2,0.35 0.4,0.61 0.62,0.77 0.22,0.16 0.48,0.24 0.8,0.24 h 0.59 c 0.31,0 0.58,-0.08 0.8,-0.24 0.22,-0.16 0.42,-0.41 0.62,-0.77 l 2.72,-4.26 c 0.28,-0.39 0.43,-0.81 0.47,-1.24 0.04,-0.43 0.06,-0.85 0.06,-1.24 v -9.7 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 8.81 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 11.35 c 0,1.81 -0.16,3.17 -0.47,4.08 -0.32,0.91 -0.91,1.97 -1.77,3.19 l -8.99,12.18 v 7.51 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.35,0.34 -0.9,0.5 -1.64,0.5 z"
|
||||
id="path18"
|
||||
style="fill:#dedede;fill-opacity:1" />
|
||||
<path
|
||||
d="m 725.88,540.02 h -18.69 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.33,-0.33 -0.5,-0.88 -0.5,-1.63 v -6.92 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.34 0.88,-0.5 1.63,-0.5 h 15.91 c 0.51,0 0.9,-0.17 1.15,-0.5 0.26,-0.33 0.38,-0.74 0.38,-1.21 0,-0.67 -0.13,-1.16 -0.38,-1.48 -0.26,-0.31 -0.64,-0.49 -1.15,-0.53 l -8.87,-1.24 c -2.76,-0.39 -4.98,-1.3 -6.65,-2.72 -1.68,-1.42 -2.51,-3.78 -2.51,-7.1 v -6.21 c 0,-3.35 1.08,-5.92 3.25,-7.72 2.17,-1.79 5.16,-2.69 8.99,-2.69 h 16.56 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 7.04 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 h -13.78 c -0.51,0 -0.91,0.17 -1.18,0.5 -0.28,0.34 -0.41,0.76 -0.41,1.27 0,0.51 0.14,0.95 0.41,1.3 0.28,0.35 0.67,0.55 1.18,0.59 l 8.81,1.18 c 2.76,0.39 4.99,1.3 6.68,2.72 1.7,1.42 2.54,3.78 2.54,7.1 v 6.21 c 0,3.35 -1.09,5.92 -3.28,7.72 -2.19,1.8 -5.18,2.69 -8.96,2.69 z"
|
||||
id="path20"
|
||||
style="fill:#dedede;fill-opacity:1" />
|
||||
<path
|
||||
d="m 766.44,540.02 h -9.58 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.34,-0.33 -0.5,-0.88 -0.5,-1.63 v -29.04 h -8.69 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.33,-0.33 -0.5,-0.88 -0.5,-1.63 v -7.16 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 31.22 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 7.16 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.33,0.34 -0.88,0.5 -1.63,0.5 h -8.69 v 29.04 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.33,0.34 -0.88,0.5 -1.63,0.5 z"
|
||||
id="path22"
|
||||
style="fill:#dedede;fill-opacity:1" />
|
||||
<path
|
||||
d="m 817.06,540.02 h -27.44 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.33,-0.33 -0.5,-0.88 -0.5,-1.63 v -38.32 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 27.44 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 6.92 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 h -16.32 v 4.55 h 11.53 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.33 0.5,0.88 0.5,1.63 v 6.33 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.33 -0.88,0.5 -1.63,0.5 h -11.53 v 5.08 h 16.32 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.33 0.5,0.88 0.5,1.63 v 6.92 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 z"
|
||||
id="path24"
|
||||
style="fill:#dedede;fill-opacity:1" />
|
||||
<path
|
||||
d="m 839.48,540.02 h -8.81 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.33,-0.33 -0.5,-0.88 -0.5,-1.63 v -38.32 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 9.52 c 0.63,0 1.15,0.14 1.57,0.41 0.41,0.28 0.8,0.73 1.15,1.36 l 5.32,9.64 c 0.2,0.35 0.36,0.61 0.5,0.77 0.14,0.16 0.33,0.24 0.56,0.24 h 0.53 c 0.24,0 0.42,-0.08 0.56,-0.24 0.14,-0.16 0.3,-0.41 0.5,-0.77 l 5.26,-9.64 c 0.36,-0.63 0.74,-1.08 1.15,-1.36 0.41,-0.28 0.94,-0.41 1.57,-0.41 h 9.58 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 38.32 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 h -9.11 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.34,-0.33 -0.5,-0.88 -0.5,-1.63 v -20.82 l -3.49,6.45 c -0.35,0.67 -0.78,1.15 -1.27,1.45 -0.49,0.3 -1.12,0.44 -1.86,0.44 h -2.37 c -0.75,0 -1.37,-0.15 -1.86,-0.44 -0.49,-0.29 -0.92,-0.78 -1.27,-1.45 l -3.49,-6.45 v 20.82 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.32,0.34 -0.87,0.5 -1.61,0.5 z"
|
||||
id="path26"
|
||||
style="fill:#dedede;fill-opacity:1" />
|
||||
<path
|
||||
d="m 900.86,540.02 h -18.69 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.33,-0.33 -0.5,-0.88 -0.5,-1.63 v -6.92 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.34 0.88,-0.5 1.63,-0.5 h 15.91 c 0.51,0 0.9,-0.17 1.15,-0.5 0.26,-0.33 0.38,-0.74 0.38,-1.21 0,-0.67 -0.13,-1.16 -0.38,-1.48 -0.26,-0.31 -0.64,-0.49 -1.15,-0.53 l -8.87,-1.24 c -2.76,-0.39 -4.98,-1.3 -6.65,-2.72 -1.68,-1.42 -2.51,-3.78 -2.51,-7.1 v -6.21 c 0,-3.35 1.08,-5.92 3.25,-7.72 2.17,-1.79 5.16,-2.69 8.99,-2.69 h 16.56 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 7.04 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 h -13.78 c -0.51,0 -0.91,0.17 -1.18,0.5 -0.28,0.34 -0.41,0.76 -0.41,1.27 0,0.51 0.14,0.95 0.41,1.3 0.28,0.35 0.67,0.55 1.18,0.59 l 8.81,1.18 c 2.76,0.39 4.99,1.3 6.68,2.72 1.7,1.42 2.54,3.78 2.54,7.1 v 6.21 c 0,3.35 -1.09,5.92 -3.28,7.72 -2.19,1.8 -5.18,2.69 -8.96,2.69 z"
|
||||
id="path28"
|
||||
style="fill:#dedede;fill-opacity:1" />
|
||||
</g>
|
||||
<g
|
||||
id="g34"
|
||||
transform="matrix(0.32162395,0,0,0.33123626,-75.234275,-114.64087)">
|
||||
<path
|
||||
class="st0"
|
||||
d="m 399.59,375.57 c -75.1,0 -136.2,61.1 -136.2,136.2 0,75.1 61.1,136.2 136.2,136.2 27.74,0 53.37,-8.55 74.84,-22.9 -1.35,-1.17 -2.61,-2.4 -3.8,-3.72 -20.44,13.4 -44.79,21.29 -71.04,21.29 -72.16,0 -130.87,-58.71 -130.87,-130.87 0,-72.16 58.71,-130.87 130.87,-130.87 47.14,0 88.35,25.15 111.36,62.66 h 6.25 C 493.61,402.98 449.81,375.57 399.59,375.57 Z"
|
||||
id="path32" />
|
||||
</g>
|
||||
<g
|
||||
id="g38"
|
||||
transform="matrix(0.32162395,0,0,0.33123626,-75.234275,-114.64087)">
|
||||
<path
|
||||
class="st1"
|
||||
d="m 535.7,566.96 c -21.87,53.75 -74.6,91.78 -136.11,91.78 -81.04,0 -146.98,-65.94 -146.98,-146.98 0,-81.04 65.94,-146.98 146.98,-146.98 29.26,0 56.5,8.66 79.43,23.45 l 3.7,-3.7 c -23.92,-15.69 -52.44,-24.91 -83.13,-24.91 -83.89,0 -152.14,68.24 -152.14,152.14 0,83.9 68.24,152.14 152.14,152.14 64.42,0 119.58,-40.26 141.72,-96.94 z"
|
||||
id="path36" />
|
||||
</g>
|
||||
<g
|
||||
id="g42"
|
||||
transform="matrix(0.32162395,0,0,0.33123626,-75.234275,-114.64087)"
|
||||
inkscape:export-filename="./g42.svg"
|
||||
inkscape:export-xdpi="169.6163"
|
||||
inkscape:export-ydpi="169.6163">
|
||||
<path
|
||||
class="st0"
|
||||
d="m 399.59,672.5 c -88.63,0 -160.73,-72.11 -160.73,-160.73 0,-88.62 72.11,-160.73 160.73,-160.73 75.66,0 139.05,52.63 156.04,123.16 h 5.06 C 543.61,400.93 478,346.1 399.6,346.1 c -91.36,0 -165.68,74.32 -165.68,165.68 0,91.36 74.32,165.68 165.68,165.68 56.18,0 105.83,-28.17 135.77,-71.07 -1.21,-1.21 -2.42,-2.42 -3.64,-3.63 -29,42.03 -77.32,69.74 -132.14,69.74 z"
|
||||
id="path40" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,623 @@
|
||||
/* Sovran_SystemsOS Hub — First-Boot Onboarding Wizard
|
||||
Drives the 4-step post-install setup flow. */
|
||||
"use strict";
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────
|
||||
|
||||
const TOTAL_STEPS = 4;
|
||||
|
||||
// Steps to skip per role (step 3 involves domain setup)
|
||||
// Step 2 (timezone/locale) is NEVER skipped — all roles need it.
|
||||
const ROLE_SKIP_STEPS = {
|
||||
"desktop": [3],
|
||||
"node": [3],
|
||||
};
|
||||
|
||||
// ── Role state (loaded at init) ───────────────────────────────────
|
||||
|
||||
var _onboardingRole = "server_plus_desktop";
|
||||
|
||||
// Domains that may need configuration, with service unit mapping for enabled check
|
||||
const DOMAIN_DEFS = [
|
||||
{ name: "matrix", label: "Matrix (Synapse)", unit: "matrix-synapse.service", needsDdns: true },
|
||||
{ name: "haven", label: "Haven Nostr Relay", unit: "haven-relay.service", needsDdns: true },
|
||||
{ name: "element-calling", label: "Element Video/Audio Calling", unit: "livekit.service", needsDdns: true },
|
||||
{ name: "vaultwarden", label: "Vaultwarden (Password Vault)", unit: "vaultwarden.service", needsDdns: true },
|
||||
{ name: "btcpayserver", label: "BTCPay Server", unit: "btcpayserver.service", needsDdns: true },
|
||||
{ name: "nextcloud", label: "Nextcloud", unit: "phpfpm-nextcloud.service", needsDdns: true },
|
||||
{ name: "wordpress", label: "WordPress", unit: "phpfpm-wordpress.service", needsDdns: true },
|
||||
];
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────
|
||||
|
||||
var _currentStep = 1;
|
||||
var _servicesData = null;
|
||||
var _domainsData = null;
|
||||
var _migrationOccurred = false;
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function escHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
async function apiFetch(path, options) {
|
||||
var res = await fetch(path, options || {});
|
||||
if (!res.ok) {
|
||||
var detail = res.status + " " + res.statusText;
|
||||
try {
|
||||
var body = await res.json();
|
||||
if (body && body.detail) detail = body.detail;
|
||||
} catch (e) {}
|
||||
throw new Error(detail);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function setStatus(elId, msg, type) {
|
||||
var el = document.getElementById(elId);
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.className = "onboarding-save-status" + (type ? " onboarding-save-status--" + type : "");
|
||||
}
|
||||
|
||||
function updateCompleteChecklist() {
|
||||
var checklist = document.getElementById("onboarding-checklist");
|
||||
if (!checklist) return;
|
||||
var existing = document.getElementById("onboarding-migration-check");
|
||||
if (_migrationOccurred) {
|
||||
if (!existing) {
|
||||
var li = document.createElement("li");
|
||||
li.id = "onboarding-migration-check";
|
||||
li.textContent = "✅ Migration password noted";
|
||||
checklist.appendChild(li);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (existing) existing.remove();
|
||||
}
|
||||
|
||||
function showMigrationStep(password) {
|
||||
for (var i = 1; i <= TOTAL_STEPS; i++) {
|
||||
var panel = document.getElementById("step-" + i);
|
||||
if (panel) panel.style.display = "none";
|
||||
}
|
||||
var migrationPanel = document.getElementById("step-migration");
|
||||
if (migrationPanel) migrationPanel.style.display = "";
|
||||
var pw = document.getElementById("migration-password-value");
|
||||
if (pw) pw.textContent = password || "";
|
||||
var progressBar = document.getElementById("onboarding-progress-bar");
|
||||
if (progressBar) progressBar.style.display = "none";
|
||||
var nav = document.getElementById("onboarding-steps-nav");
|
||||
if (nav) nav.style.display = "none";
|
||||
}
|
||||
|
||||
function showStep1FromMigration() {
|
||||
var migrationPanel = document.getElementById("step-migration");
|
||||
if (migrationPanel) migrationPanel.style.display = "none";
|
||||
var progressBar = document.getElementById("onboarding-progress-bar");
|
||||
if (progressBar) progressBar.style.display = "";
|
||||
var nav = document.getElementById("onboarding-steps-nav");
|
||||
if (nav) nav.style.display = "";
|
||||
showStep(1);
|
||||
loadStep1();
|
||||
}
|
||||
|
||||
// ── Progress / step navigation ────────────────────────────────────
|
||||
|
||||
function updateProgress(step) {
|
||||
var fill = document.getElementById("onboarding-progress-fill");
|
||||
if (fill) {
|
||||
fill.style.width = Math.round(((step - 1) / (TOTAL_STEPS - 1)) * 100) + "%";
|
||||
}
|
||||
var dots = document.querySelectorAll(".onboarding-step-dot");
|
||||
dots.forEach(function(dot) {
|
||||
var ds = parseInt(dot.dataset.step, 10);
|
||||
dot.classList.remove("active", "completed");
|
||||
if (ds < step) dot.classList.add("completed");
|
||||
if (ds === step) dot.classList.add("active");
|
||||
});
|
||||
}
|
||||
|
||||
function showStep(step) {
|
||||
for (var i = 1; i <= TOTAL_STEPS; i++) {
|
||||
var panel = document.getElementById("step-" + i);
|
||||
if (panel) panel.style.display = (i === step) ? "" : "none";
|
||||
}
|
||||
_currentStep = step;
|
||||
updateProgress(step);
|
||||
|
||||
// Lazy-load step content
|
||||
if (step === 2) loadStep2();
|
||||
if (step === 3) loadStep3();
|
||||
// Step 4 (Complete) is static — no lazy-load needed
|
||||
}
|
||||
|
||||
// Return the next step number, skipping over role-excluded steps
|
||||
function nextStep(current) {
|
||||
var skip = ROLE_SKIP_STEPS[_onboardingRole] || [];
|
||||
var next = current + 1;
|
||||
while (next < TOTAL_STEPS && skip.indexOf(next) !== -1) next++;
|
||||
return next;
|
||||
}
|
||||
|
||||
// Return the previous step number, skipping over role-excluded steps
|
||||
function prevStep(current) {
|
||||
var skip = ROLE_SKIP_STEPS[_onboardingRole] || [];
|
||||
var prev = current - 1;
|
||||
while (prev > 1 && skip.indexOf(prev) !== -1) prev--;
|
||||
return prev;
|
||||
}
|
||||
|
||||
// ── Step 1: Welcome ───────────────────────────────────────────────
|
||||
|
||||
async function loadStep1() {
|
||||
try {
|
||||
var cfg = await apiFetch("/api/config");
|
||||
var badge = document.getElementById("onboarding-role-badge");
|
||||
if (badge && cfg.role_label) badge.textContent = cfg.role_label;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── Step 2: Timezone & Locale ─────────────────────────────────────
|
||||
|
||||
async function loadStep2() {
|
||||
var body = document.getElementById("step-2-body");
|
||||
if (!body || body._tzLoaded) return;
|
||||
body._tzLoaded = true;
|
||||
|
||||
body.innerHTML = '<p class="onboarding-loading">Loading timezone data…</p>';
|
||||
|
||||
var timezones = [];
|
||||
var currentTz = null;
|
||||
var locales = [];
|
||||
var currentLocale = null;
|
||||
|
||||
try {
|
||||
var results = await Promise.all([
|
||||
apiFetch("/api/system/timezones"),
|
||||
apiFetch("/api/system/locales"),
|
||||
]);
|
||||
timezones = results[0].timezones || [];
|
||||
currentTz = results[0].current || null;
|
||||
locales = results[1].locales || [];
|
||||
currentLocale = results[1].current || null;
|
||||
} catch (err) {
|
||||
body.innerHTML = '<p class="onboarding-error">⚠ Could not load timezone data: ' + escHtml(err.message) + '</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to auto-detect timezone from browser
|
||||
var browserTz = null;
|
||||
try { browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone; } catch (_) {}
|
||||
var selectedTz = currentTz || browserTz || "";
|
||||
|
||||
var html = '';
|
||||
|
||||
// Timezone section
|
||||
html += '<div class="onboarding-tz-group">';
|
||||
html += '<label class="onboarding-domain-label" for="tz-search">🕐 Timezone</label>';
|
||||
html += '<input class="onboarding-tz-search" type="text" id="tz-search" placeholder="Search timezones…" autocomplete="off" value="' + escHtml(selectedTz) + '" />';
|
||||
html += '<select class="onboarding-tz-select" id="tz-select" size="5">';
|
||||
timezones.forEach(function(tz) {
|
||||
var sel = tz === selectedTz ? ' selected' : '';
|
||||
html += '<option value="' + escHtml(tz) + '"' + sel + '>' + escHtml(tz) + '</option>';
|
||||
});
|
||||
html += '</select>';
|
||||
html += '<p class="onboarding-hint">Current: <span id="tz-current-display">' + escHtml(selectedTz || 'Not set') + '</span></p>';
|
||||
html += '</div>';
|
||||
|
||||
// Locale section
|
||||
html += '<div class="onboarding-tz-group">';
|
||||
html += '<label class="onboarding-domain-label" for="locale-select">🌐 Language / Locale</label>';
|
||||
html += '<select class="onboarding-tz-select onboarding-locale-select" id="locale-select">';
|
||||
locales.forEach(function(loc) {
|
||||
var sel = loc === (currentLocale || 'en_US.UTF-8') ? ' selected' : '';
|
||||
html += '<option value="' + escHtml(loc) + '"' + sel + '>' + escHtml(loc) + '</option>';
|
||||
});
|
||||
html += '</select>';
|
||||
html += '</div>';
|
||||
|
||||
body.innerHTML = html;
|
||||
|
||||
// Wire timezone search filter
|
||||
var tzSearch = document.getElementById('tz-search');
|
||||
var tzSelect = document.getElementById('tz-select');
|
||||
var tzCurrentDisplay = document.getElementById('tz-current-display');
|
||||
|
||||
if (tzSearch && tzSelect) {
|
||||
// When typing in search box, filter the dropdown options
|
||||
tzSearch.addEventListener('input', function() {
|
||||
var q = tzSearch.value.toLowerCase();
|
||||
var opts = tzSelect.options;
|
||||
var firstVisible = null;
|
||||
for (var i = 0; i < opts.length; i++) {
|
||||
var match = opts[i].value.toLowerCase().indexOf(q) !== -1;
|
||||
opts[i].style.display = match ? '' : 'none';
|
||||
if (match && firstVisible === null) firstVisible = i;
|
||||
}
|
||||
if (firstVisible !== null) {
|
||||
tzSelect.selectedIndex = firstVisible;
|
||||
if (tzCurrentDisplay) tzCurrentDisplay.textContent = tzSelect.options[firstVisible].value;
|
||||
}
|
||||
});
|
||||
|
||||
// When selecting from dropdown, update search box
|
||||
tzSelect.addEventListener('change', function() {
|
||||
if (tzSelect.value) {
|
||||
tzSearch.value = tzSelect.value;
|
||||
if (tzCurrentDisplay) tzCurrentDisplay.textContent = tzSelect.value;
|
||||
}
|
||||
});
|
||||
|
||||
// Scroll selected option into view
|
||||
if (tzSelect.selectedIndex >= 0) {
|
||||
tzSelect.options[tzSelect.selectedIndex].scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveStep2() {
|
||||
var tzSelect = document.getElementById('tz-select');
|
||||
var tzSearch = document.getElementById('tz-search');
|
||||
var localeSelect = document.getElementById('locale-select');
|
||||
|
||||
// Determine selected timezone: prefer dropdown selection, fall back to search text
|
||||
var tz = (tzSelect && tzSelect.value) ? tzSelect.value : (tzSearch ? tzSearch.value.trim() : '');
|
||||
var locale = localeSelect ? localeSelect.value : '';
|
||||
|
||||
if (!tz) {
|
||||
setStatus('step-2-status', '⚠ Please select a timezone.', 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
setStatus('step-2-status', 'Saving…', 'info');
|
||||
|
||||
var errors = [];
|
||||
|
||||
try {
|
||||
await apiFetch('/api/system/timezone', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ timezone: tz }),
|
||||
});
|
||||
} catch (err) {
|
||||
errors.push('Timezone: ' + err.message);
|
||||
}
|
||||
|
||||
if (locale) {
|
||||
try {
|
||||
await apiFetch('/api/system/locale', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ locale: locale }),
|
||||
});
|
||||
} catch (err) {
|
||||
errors.push('Locale: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
setStatus('step-2-status', '⚠ ' + errors.join('; '), 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
setStatus('step-2-status', '✓ Timezone & locale saved', 'ok');
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Step 3: Domain Configuration ─────────────────────────────────
|
||||
|
||||
async function loadStep3() {
|
||||
var body = document.getElementById("step-3-body");
|
||||
if (!body) return;
|
||||
|
||||
try {
|
||||
// Fetch services and domains in parallel (network info is best-effort below)
|
||||
var results = await Promise.all([
|
||||
apiFetch("/api/services"),
|
||||
apiFetch("/api/domains/status"),
|
||||
]);
|
||||
_servicesData = results[0];
|
||||
_domainsData = results[1];
|
||||
} 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 (_) {}
|
||||
|
||||
// Build set of enabled service units
|
||||
var enabledUnits = new Set();
|
||||
(_servicesData || []).forEach(function(svc) {
|
||||
if (svc.enabled) enabledUnits.add(svc.unit);
|
||||
});
|
||||
|
||||
// Filter domain defs to only those whose service is enabled
|
||||
var relevantDomains = DOMAIN_DEFS.filter(function(d) {
|
||||
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" })
|
||||
+ '</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>';
|
||||
|
||||
relevantDomains.forEach(function(d) {
|
||||
var currentVal = domainValues[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 += '<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"] || "";
|
||||
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 += '<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>';
|
||||
html += '</div>';
|
||||
|
||||
body.innerHTML = html;
|
||||
|
||||
// Wire per-field save buttons for domains
|
||||
body.querySelectorAll('[data-save-domain]').forEach(function(btn) {
|
||||
btn.addEventListener('click', async function() {
|
||||
var domainName = btn.dataset.saveDomain;
|
||||
var domainInput = document.getElementById('domain-input-' + domainName);
|
||||
var ddnsInput = document.getElementById('ddns-input-' + domainName);
|
||||
var statusEl = document.getElementById('domain-save-status-' + domainName);
|
||||
var domainVal = domainInput ? domainInput.value.trim() : '';
|
||||
var ddnsVal = ddnsInput ? ddnsInput.value.trim() : '';
|
||||
|
||||
if (!domainVal) {
|
||||
if (statusEl) { statusEl.textContent = '⚠ Enter a domain first'; statusEl.style.color = 'var(--red)'; }
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Saving…';
|
||||
if (statusEl) { statusEl.textContent = ''; }
|
||||
|
||||
try {
|
||||
await apiFetch('/api/domains/set', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain_name: domainName, domain: domainVal, ddns_url: ddnsVal }),
|
||||
});
|
||||
if (statusEl) { statusEl.textContent = '✓ Saved'; statusEl.style.color = 'var(--green)'; }
|
||||
} catch (err) {
|
||||
if (statusEl) { statusEl.textContent = '⚠ ' + err.message; statusEl.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Save';
|
||||
});
|
||||
});
|
||||
|
||||
// Wire save button for SSL email
|
||||
body.querySelectorAll('[data-save-email]').forEach(function(btn) {
|
||||
btn.addEventListener('click', async function() {
|
||||
var emailInput = document.getElementById('ssl-email-input');
|
||||
var statusEl = document.getElementById('domain-save-status-email');
|
||||
var emailVal = emailInput ? emailInput.value.trim() : '';
|
||||
|
||||
if (!emailVal) {
|
||||
if (statusEl) { statusEl.textContent = '⚠ Enter an email first'; statusEl.style.color = 'var(--red)'; }
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Saving…';
|
||||
if (statusEl) { statusEl.textContent = ''; }
|
||||
|
||||
try {
|
||||
await apiFetch('/api/domains/set-email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: emailVal }),
|
||||
});
|
||||
if (statusEl) { statusEl.textContent = '✓ Saved'; statusEl.style.color = 'var(--green)'; }
|
||||
} catch (err) {
|
||||
if (statusEl) { statusEl.textContent = '⚠ ' + err.message; statusEl.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Save';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function saveStep3() {
|
||||
setStatus("step-3-status", "Saving domains…", "info");
|
||||
var errors = [];
|
||||
|
||||
// Save each domain input
|
||||
var domainInputs = document.querySelectorAll("[data-domain]");
|
||||
for (var i = 0; i < domainInputs.length; i++) {
|
||||
var inp = domainInputs[i];
|
||||
var domainName = inp.dataset.domain;
|
||||
var domainVal = inp.value.trim();
|
||||
if (!domainVal) continue; // skip empty — not required
|
||||
|
||||
var ddnsInput = document.getElementById("ddns-input-" + domainName);
|
||||
var ddnsVal = ddnsInput ? ddnsInput.value.trim() : "";
|
||||
|
||||
try {
|
||||
await apiFetch("/api/domains/set", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ domain_name: domainName, domain: domainVal, ddns_url: ddnsVal }),
|
||||
});
|
||||
} catch (err) {
|
||||
errors.push(domainName + ": " + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Save SSL email
|
||||
var emailInput = document.getElementById("ssl-email-input");
|
||||
if (emailInput && emailInput.value.trim()) {
|
||||
try {
|
||||
await apiFetch("/api/domains/set-email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: emailInput.value.trim() }),
|
||||
});
|
||||
} catch (err) {
|
||||
errors.push("SSL email: " + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
setStatus("step-3-status", "⚠ Some errors: " + errors.join("; "), "error");
|
||||
return false;
|
||||
}
|
||||
|
||||
setStatus("step-3-status", "✓ Saved", "ok");
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Step 4: Complete ──────────────────────────────────────────────
|
||||
|
||||
async function completeOnboarding() {
|
||||
var btn = document.getElementById("step-4-finish");
|
||||
if (btn) { btn.disabled = true; btn.textContent = "Finishing…"; }
|
||||
|
||||
try {
|
||||
await apiFetch("/api/onboarding/complete", { method: "POST" });
|
||||
} catch (_) {
|
||||
// Even if this fails, navigate to dashboard
|
||||
}
|
||||
|
||||
window.location.href = "/";
|
||||
}
|
||||
|
||||
// ── Event wiring ──────────────────────────────────────────────────
|
||||
|
||||
function wireNavButtons() {
|
||||
var migrationContinue = document.getElementById("migration-password-continue");
|
||||
if (migrationContinue) migrationContinue.addEventListener("click", async function() {
|
||||
migrationContinue.disabled = true;
|
||||
migrationContinue.textContent = "Continuing…";
|
||||
setStatus("migration-password-status", "Saving acknowledgement…", "info");
|
||||
try {
|
||||
await apiFetch("/api/migration/password-acknowledge", { method: "POST" });
|
||||
_migrationOccurred = true;
|
||||
updateCompleteChecklist();
|
||||
showStep1FromMigration();
|
||||
} catch (err) {
|
||||
setStatus("migration-password-status", "⚠ " + err.message, "error");
|
||||
migrationContinue.disabled = false;
|
||||
migrationContinue.textContent = "I've written it down — Continue →";
|
||||
}
|
||||
});
|
||||
|
||||
// Step 1 → next
|
||||
var s1next = document.getElementById("step-1-next");
|
||||
if (s1next) s1next.addEventListener("click", function() { showStep(nextStep(1)); });
|
||||
|
||||
// Step 2 → 3 (save timezone/locale first)
|
||||
var s2next = document.getElementById("step-2-next");
|
||||
if (s2next) s2next.addEventListener("click", async function() {
|
||||
s2next.disabled = true;
|
||||
var origText = s2next.textContent;
|
||||
s2next.textContent = "Saving…";
|
||||
var ok = await saveStep2();
|
||||
s2next.disabled = false;
|
||||
s2next.textContent = origText;
|
||||
if (ok) showStep(nextStep(2));
|
||||
});
|
||||
|
||||
// Step 3 → 4 (save domains first)
|
||||
var s3next = document.getElementById("step-3-next");
|
||||
if (s3next) s3next.addEventListener("click", async function() {
|
||||
s3next.disabled = true;
|
||||
s3next.textContent = "Saving…";
|
||||
await saveStep3();
|
||||
s3next.disabled = false;
|
||||
s3next.textContent = "Save & Continue →";
|
||||
showStep(nextStep(3));
|
||||
});
|
||||
|
||||
// Step 4: finish
|
||||
var s4finish = document.getElementById("step-4-finish");
|
||||
if (s4finish) s4finish.addEventListener("click", completeOnboarding);
|
||||
|
||||
// Back buttons
|
||||
document.querySelectorAll(".onboarding-btn-back").forEach(function(btn) {
|
||||
var prev = parseInt(btn.dataset.prev, 10);
|
||||
btn.addEventListener("click", function() { showStep(prevStep(prev + 1)); });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async function() {
|
||||
// If onboarding is already complete, go to dashboard
|
||||
try {
|
||||
var status = await apiFetch("/api/onboarding/status");
|
||||
if (status.complete) {
|
||||
window.location.href = "/";
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Load role so step-skipping is applied before wiring nav buttons
|
||||
try {
|
||||
var cfg = await apiFetch("/api/config");
|
||||
if (cfg.role) _onboardingRole = cfg.role;
|
||||
} catch (_) {}
|
||||
|
||||
wireNavButtons();
|
||||
|
||||
try {
|
||||
var migration = await apiFetch("/api/migration/password-status");
|
||||
if (migration && migration.pending) {
|
||||
updateCompleteChecklist();
|
||||
showMigrationStep(migration.password || "");
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
updateCompleteChecklist();
|
||||
showStep(1);
|
||||
loadStep1();
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#153126"/>
|
||||
<stop offset="55%" stop-color="#0F241B"/>
|
||||
<stop offset="100%" stop-color="#091C14"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="outerArc" x1="70" y1="40" x2="190" y2="210" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#42F39A"/>
|
||||
<stop offset="45%" stop-color="#28D978"/>
|
||||
<stop offset="100%" stop-color="#1AA45D"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="innerArc" x1="90" y1="60" x2="180" y2="190" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#27C86F"/>
|
||||
<stop offset="100%" stop-color="#157E49"/>
|
||||
</linearGradient>
|
||||
|
||||
<filter id="innerShade" x="-10%" y="-10%" width="120%" height="120%">
|
||||
<feOffset dx="0" dy="2"/>
|
||||
<feGaussianBlur stdDeviation="5" result="blur"/>
|
||||
<feComposite in="blur" in2="SourceAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="
|
||||
0 0 0 0 0
|
||||
0 0 0 0 0
|
||||
0 0 0 0 0
|
||||
0 0 0 .18 0"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<rect width="256" height="256" rx="48" ry="48" fill="url(#bg)"/>
|
||||
<rect x="1.5" y="1.5" width="253" height="253" rx="46.5" ry="46.5"
|
||||
fill="none" stroke="rgba(255,255,255,0.08)"/>
|
||||
<rect x="6" y="6" width="244" height="244" rx="42" ry="42"
|
||||
fill="none" filter="url(#innerShade)"/>
|
||||
|
||||
<path d="M128 32 A96 96 0 1 1 58 196"
|
||||
fill="none"
|
||||
stroke="url(#outerArc)"
|
||||
stroke-width="12"
|
||||
stroke-linecap="round"/>
|
||||
|
||||
<path d="M128 56 A72 72 0 1 1 76 178"
|
||||
fill="none"
|
||||
stroke="url(#innerArc)"
|
||||
stroke-width="10"
|
||||
stroke-linecap="round"/>
|
||||
|
||||
<circle cx="128" cy="128" r="8" fill="#F2FFF7"/>
|
||||
<circle cx="128" cy="128" r="18" fill="none" stroke="#7BFFC0" stroke-opacity="0.14" stroke-width="4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,283 @@
|
||||
"""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
|
||||
@@ -0,0 +1,231 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Thin wrapper around the systemctl CLI for Sovran_SystemsOS_Hub."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from typing import Literal
|
||||
|
||||
|
||||
def _run(cmd: list[str]) -> str:
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
||||
return result.stdout.strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
def run_action(
|
||||
action: str,
|
||||
unit: str,
|
||||
scope: Literal["system", "user"] = "system",
|
||||
method: str = "systemctl",
|
||||
) -> bool:
|
||||
base_cmd = ["systemctl", f"--{scope}", action, unit]
|
||||
if scope == "system" and method == "pkexec":
|
||||
cmd = ["pkexec", "--user", "root"] + base_cmd
|
||||
else:
|
||||
cmd = base_cmd
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
return result.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
@@ -0,0 +1,598 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sovran_SystemsOS — The Hub</title>
|
||||
<link rel="stylesheet" href="/static/css/base.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/buttons.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/header.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/layout.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/tiles.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/modals.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/features.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/onboarding.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/support.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/domain-setup.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/security.css?v={{ asset_version }}" />
|
||||
</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>
|
||||
<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>
|
||||
</span>
|
||||
<button class="creds-close-btn" id="systems-close-btn" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="creds-body" id="systems-body">
|
||||
<p class="creds-loading">Loading…</p>
|
||||
</div>
|
||||
<!-- Auto-launch preference (features.js) — moved here from the sidebar -->
|
||||
<div class="sysprefs" id="autolaunch-slot"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Update modal -->
|
||||
<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>
|
||||
<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-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>
|
||||
|
||||
<!-- Credentials info modal -->
|
||||
<div class="modal-overlay" id="creds-modal" role="dialog" aria-modal="true" aria-labelledby="creds-modal-title">
|
||||
<div class="creds-dialog">
|
||||
<div class="creds-header">
|
||||
<span class="creds-title" id="creds-modal-title">Service Info</span>
|
||||
<button class="creds-close-btn" id="creds-close-btn" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="creds-body" id="creds-body">
|
||||
<p class="creds-loading">Loading…</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tech Support modal -->
|
||||
<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>
|
||||
<button class="creds-close-btn" id="support-close-btn" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="creds-body" id="support-body">
|
||||
<p class="creds-loading">Loading…</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Domain Setup Modal -->
|
||||
<div class="modal-overlay" id="domain-setup-modal" role="dialog" aria-modal="true" aria-labelledby="domain-setup-title">
|
||||
<div class="creds-dialog">
|
||||
<div class="creds-header">
|
||||
<span class="creds-title" id="domain-setup-title">🌐 Domain Setup</span>
|
||||
<button class="creds-close-btn" id="domain-setup-close-btn" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="creds-body" id="domain-setup-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SSL Email Modal -->
|
||||
<div class="modal-overlay" id="ssl-email-modal" role="dialog" aria-modal="true" aria-labelledby="ssl-email-title">
|
||||
<div class="creds-dialog domain-narrow-dialog">
|
||||
<div class="creds-header">
|
||||
<span class="creds-title" id="ssl-email-title">📧 SSL Certificate Email</span>
|
||||
<button class="creds-close-btn" id="ssl-email-close-btn" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="creds-body">
|
||||
<p class="support-desc">Let's Encrypt needs an email address for SSL certificate notifications.</p>
|
||||
<div class="domain-field-group">
|
||||
<label class="domain-field-label" for="ssl-email-input">Email:</label>
|
||||
<input class="domain-field-input" type="email" id="ssl-email-input" placeholder="you@example.com" />
|
||||
</div>
|
||||
<div class="domain-field-actions">
|
||||
<button class="btn btn-close-modal" id="ssl-email-cancel-btn">Cancel</button>
|
||||
<button class="btn btn-primary" id="ssl-email-save-btn">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Feature Confirm Modal -->
|
||||
<div class="modal-overlay" id="feature-confirm-modal" role="dialog" aria-modal="true" aria-labelledby="feature-confirm-title">
|
||||
<div class="creds-dialog domain-narrow-dialog">
|
||||
<div class="creds-header">
|
||||
<span class="creds-title" id="feature-confirm-title">Confirm Action</span>
|
||||
<button class="creds-close-btn" id="feature-confirm-close-btn" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="creds-body">
|
||||
<p class="support-desc" id="feature-confirm-message"></p>
|
||||
<div class="domain-field-actions">
|
||||
<button class="btn btn-close-modal" id="feature-confirm-cancel-btn">Cancel</button>
|
||||
<button class="btn btn-primary" id="feature-confirm-ok-btn">Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Port Requirements Modal -->
|
||||
<div class="modal-overlay" id="port-requirements-modal" role="dialog" aria-modal="true" aria-labelledby="port-req-title">
|
||||
<div class="creds-dialog">
|
||||
<div class="creds-header">
|
||||
<span class="creds-title" id="port-req-title">🔌 Router / Firewall Port Requirements</span>
|
||||
<button class="creds-close-btn" id="port-req-close-btn" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="creds-body" id="port-req-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-save" id="rebuild-save-report" style="display:none">Save Error Report</button>
|
||||
<button class="btn btn-reboot" id="rebuild-reboot-btn" style="display:none">Restart Entire System</button>
|
||||
<span class="modal-footer-spacer"></span>
|
||||
<button class="btn btn-close-modal" id="rebuild-close-btn" disabled>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upgrade Modal (Node → Server+Desktop) -->
|
||||
<div class="modal-overlay" id="upgrade-modal" role="dialog" aria-modal="true" aria-labelledby="upgrade-modal-title">
|
||||
<div class="creds-dialog upgrade-dialog">
|
||||
<div class="creds-header">
|
||||
<span class="creds-title" id="upgrade-modal-title">🚀 Upgrade to Server + Desktop</span>
|
||||
<button class="creds-close-btn" id="upgrade-close-btn" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="creds-body">
|
||||
<p class="support-desc">
|
||||
Upgrading to the full <strong>Server + Desktop</strong> experience will unlock all services —
|
||||
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>
|
||||
<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>
|
||||
</ul>
|
||||
</div>
|
||||
<p class="support-desc">
|
||||
⚠️ <strong>Heads-up:</strong> your domain points at your home internet connection,
|
||||
so anyone can look up your home IP address. Domain privacy does not hide it.
|
||||
</p>
|
||||
<p class="support-desc">
|
||||
The Hub guides you through every step.
|
||||
</p>
|
||||
<p class="support-desc upgrade-rebuild-note">
|
||||
The system will rebuild after upgrading. This may take several minutes.
|
||||
</p>
|
||||
<div class="domain-field-actions">
|
||||
<button class="btn btn-close-modal" id="upgrade-cancel-btn">Cancel</button>
|
||||
<button class="btn btn-primary" id="upgrade-confirm-btn">Yes, Upgrade</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Security Reset overlay -->
|
||||
<div class="security-reset-overlay" id="security-reset-overlay">
|
||||
<!-- Phase 1: wiping in progress -->
|
||||
<div class="reboot-card" id="security-reset-phase1">
|
||||
<div class="security-reset-overlay-icon">🛡</div>
|
||||
<h2 class="reboot-title">Security Reset In Progress</h2>
|
||||
<p class="reboot-message">
|
||||
⚠️ Wiping all data and credentials.<br />
|
||||
<strong>Do not power off your computer.</strong><br />
|
||||
This may take several minutes.
|
||||
</p>
|
||||
<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="security-reset-overlay-step">Erasing data and resetting credentials…</p>
|
||||
</div>
|
||||
<!-- Phase 2: password display -->
|
||||
<div class="reboot-card" id="security-reset-phase2" style="display:none;">
|
||||
<div class="security-reset-overlay-icon">🔑</div>
|
||||
<h2 class="reboot-title">Security Reset Complete</h2>
|
||||
<p class="security-reset-password-label">Your new login password is:</p>
|
||||
<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>.
|
||||
</p>
|
||||
<button class="security-reset-reboot-btn" id="security-reset-reboot-btn" disabled>
|
||||
I have written down my new password — Restart Entire System
|
||||
</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>
|
||||
<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.
|
||||
</p>
|
||||
<div class="reboot-dots" aria-hidden="true">
|
||||
<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>
|
||||
</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>
|
||||
<script src="/static/js/update.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/rebuild.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/features.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/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>
|
||||
@@ -0,0 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>The Hub — Login</title>
|
||||
<link rel="stylesheet" href="/static/css/base.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/buttons.css?v={{ asset_version }}" />
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<form class="login-form" id="login-form" onsubmit="return false;">
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
autocomplete="current-password"
|
||||
autofocus
|
||||
placeholder="Enter your Hub password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="login-error" id="login-error">Incorrect password. Please try again.</div>
|
||||
|
||||
<button type="submit" class="btn btn-login" id="btn-login">Sign In</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var form = document.getElementById('login-form');
|
||||
var input = document.getElementById('password');
|
||||
var errEl = document.getElementById('login-error');
|
||||
var btnEl = document.getElementById('btn-login');
|
||||
|
||||
form.addEventListener('submit', function () {
|
||||
var password = input.value;
|
||||
if (!password) return;
|
||||
|
||||
btnEl.disabled = true;
|
||||
btnEl.textContent = 'Signing in…';
|
||||
errEl.classList.remove('visible');
|
||||
|
||||
fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: password }),
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
.then(function (res) {
|
||||
if (res.ok) {
|
||||
window.location.replace('/');
|
||||
} else {
|
||||
return res.json().then(function (data) {
|
||||
errEl.textContent = (data && data.detail) ? data.detail : 'Incorrect password. Please try again.';
|
||||
errEl.classList.add('visible');
|
||||
input.value = '';
|
||||
input.focus();
|
||||
btnEl.disabled = false;
|
||||
btnEl.textContent = 'Sign In';
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
errEl.textContent = 'Network error. Please try again.';
|
||||
errEl.classList.add('visible');
|
||||
btnEl.disabled = false;
|
||||
btnEl.textContent = 'Sign In';
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,175 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sovran_SystemsOS — First-Boot Setup</title>
|
||||
<link rel="stylesheet" href="/static/css/base.css" />
|
||||
<link rel="stylesheet" href="/static/css/buttons.css" />
|
||||
<link rel="stylesheet" href="/static/css/header.css" />
|
||||
<link rel="stylesheet" href="/static/css/layout.css" />
|
||||
<link rel="stylesheet" href="/static/css/tiles.css" />
|
||||
<link rel="stylesheet" href="/static/css/modals.css" />
|
||||
<link rel="stylesheet" href="/static/css/features.css" />
|
||||
<link rel="stylesheet" href="/static/css/onboarding.css" />
|
||||
<link rel="stylesheet" href="/static/css/support.css" />
|
||||
<link rel="stylesheet" href="/static/css/domain-setup.css" />
|
||||
</head>
|
||||
<body class="onboarding-body">
|
||||
|
||||
<!-- Onboarding wizard container -->
|
||||
<div class="onboarding-shell">
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="onboarding-progress-bar" id="onboarding-progress-bar">
|
||||
<div class="onboarding-progress-fill" id="onboarding-progress-fill"></div>
|
||||
</div>
|
||||
|
||||
<!-- Step indicators -->
|
||||
<div class="onboarding-steps-nav" id="onboarding-steps-nav">
|
||||
<span class="onboarding-step-dot" data-step="1">1</span>
|
||||
<span class="onboarding-step-connector"></span>
|
||||
<span class="onboarding-step-dot" data-step="2">2</span>
|
||||
<span class="onboarding-step-connector"></span>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Step panels -->
|
||||
<div class="onboarding-panel-wrap">
|
||||
|
||||
<!-- ── Migration Password Gate (pre-step) ── -->
|
||||
<div class="onboarding-panel" id="step-migration" style="display:none">
|
||||
<div class="onboarding-hero">
|
||||
<div class="onboarding-logo">🔐</div>
|
||||
<h1 class="onboarding-title">Your system has been migrated to Sovran_SystemsOS</h1>
|
||||
<p class="onboarding-subtitle">Important password update required</p>
|
||||
</div>
|
||||
<div class="onboarding-card">
|
||||
<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>
|
||||
<div style="padding:10px 14px; background-color:rgba(233, 182, 74, 0.1); border:1px solid rgba(233, 182, 74, 0.35); border-radius:8px; font-size:0.92rem; color:var(--yellow); line-height:1.55;">
|
||||
⚠ Write this password down! You will need it to log in next time. This is also your Sovran Hub login password.
|
||||
</div>
|
||||
<div id="migration-password-status" class="onboarding-save-status" style="margin-top:8px;"></div>
|
||||
</div>
|
||||
<div class="onboarding-footer">
|
||||
<div></div>
|
||||
<button class="btn btn-primary" id="migration-password-continue">
|
||||
I've written it down — Continue →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Step 1: Welcome ── -->
|
||||
<div class="onboarding-panel" id="step-1">
|
||||
<div class="onboarding-hero">
|
||||
<div class="onboarding-logo">
|
||||
<img src="/static/logo-light.svg" alt="Sovran Systems" class="onboarding-logo-img" />
|
||||
</div>
|
||||
<h1 class="onboarding-title">Welcome to Sovran_SystemsOS!</h1>
|
||||
<p class="onboarding-subtitle">Be Digitally Sovereign</p>
|
||||
</div>
|
||||
<div class="onboarding-card">
|
||||
<p class="onboarding-body-text">
|
||||
Your system is installed and ready to configure. This wizard will guide
|
||||
you through the final setup steps so everything works perfectly.
|
||||
</p>
|
||||
<div class="onboarding-role-row" id="onboarding-role-row">
|
||||
<span class="onboarding-role-label">Your Role:</span>
|
||||
<span class="onboarding-role-badge" id="onboarding-role-badge">Loading…</span>
|
||||
</div>
|
||||
<p class="onboarding-body-text onboarding-body-text--dim">
|
||||
This setup only takes a few minutes. You can always revisit these
|
||||
settings from the main Hub dashboard.
|
||||
</p>
|
||||
</div>
|
||||
<div class="onboarding-footer">
|
||||
<div></div>
|
||||
<button class="btn btn-primary onboarding-btn-next" id="step-1-next">
|
||||
Let's Go →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Step 2: Timezone & Locale ── -->
|
||||
<div class="onboarding-panel" id="step-2" style="display:none">
|
||||
<div class="onboarding-step-header">
|
||||
<span class="onboarding-step-icon">🌍</span>
|
||||
<h2 class="onboarding-step-title">Timezone & Locale</h2>
|
||||
<p class="onboarding-step-desc">
|
||||
Select your timezone and preferred language so your system clock, logs,
|
||||
and services display the correct time and format.
|
||||
</p>
|
||||
</div>
|
||||
<div class="onboarding-card" id="step-2-body">
|
||||
<p class="onboarding-loading">Loading timezone data…</p>
|
||||
</div>
|
||||
<div id="step-2-status" class="onboarding-save-status"></div>
|
||||
<div class="onboarding-footer">
|
||||
<button class="btn btn-close-modal onboarding-btn-back" data-prev="1">← Back</button>
|
||||
<button class="btn btn-primary onboarding-btn-next" id="step-2-next">
|
||||
Save & Continue →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Step 3: Domain Configuration ── -->
|
||||
<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>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
<div class="onboarding-card" id="step-3-body">
|
||||
<p class="onboarding-loading">Loading service information…</p>
|
||||
</div>
|
||||
<div id="step-3-status" class="onboarding-save-status"></div>
|
||||
<div class="onboarding-footer">
|
||||
<button class="btn btn-close-modal onboarding-btn-back" data-prev="2">← Back</button>
|
||||
<button class="btn btn-primary onboarding-btn-next" id="step-3-next">
|
||||
Save & Continue →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Step 4: Complete ── -->
|
||||
<div class="onboarding-panel" id="step-4" style="display:none">
|
||||
<div class="onboarding-hero">
|
||||
<div class="onboarding-logo">✅</div>
|
||||
<h1 class="onboarding-title">Your Sovran_SystemsOS is Ready!</h1>
|
||||
<p class="onboarding-subtitle">Setup complete</p>
|
||||
</div>
|
||||
<div class="onboarding-card">
|
||||
<p class="onboarding-body-text">
|
||||
All configuration steps are done. Head to the main Hub dashboard to
|
||||
monitor your services, manage credentials, and make changes at any time.
|
||||
</p>
|
||||
<ul class="onboarding-checklist" id="onboarding-checklist">
|
||||
<li>✅ Timezone & locale configured</li>
|
||||
<li>✅ Domain configuration saved</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">
|
||||
Go to Dashboard →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</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>
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Update-state helpers for the Sovran Hub.
|
||||
|
||||
The full-system updater stages a NixOS generation with ``nixos-rebuild boot``.
|
||||
That generation is not active until the machine reboots — and the same is true
|
||||
for updates started from a terminal or an SSH support session, which never go
|
||||
near the Hub's status files.
|
||||
|
||||
The ONLY reliable indicator that a reboot is pending is NixOS itself: the
|
||||
system profile (``/nix/var/nix/profiles/system``), which ``nixos-rebuild``
|
||||
points at the newest generation on every ``boot`` AND every ``switch``, versus
|
||||
``/run/current-system``, the generation actually running since the last boot.
|
||||
When the two differ, a staged generation has not been booted yet.
|
||||
|
||||
Earlier revisions reconstructed this from a marker file and log tails written
|
||||
by the Hub's own updater. Any system updated by other means — or whose
|
||||
``REBOOT_REQUIRED`` status was written by an updater older than the marker
|
||||
feature — left the Hub showing "Restart required" forever: the recorded
|
||||
generation could never equal the (since advanced) running one, so the marker
|
||||
could never be cleared.
|
||||
|
||||
This module has no FastAPI or systemd dependencies so the policy can be tested
|
||||
without importing the Hub server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
# The NixOS system profile. ``nixos-rebuild boot`` and ``nixos-rebuild
|
||||
# switch`` both add a generation here; ``boot`` additionally makes it the
|
||||
# bootloader default. The path is a symlink chain (``system`` ->
|
||||
# ``system-N-link`` -> ``/nix/store/...-nixos-system-...``).
|
||||
BOOT_PROFILE_PATH = "/nix/var/nix/profiles/system"
|
||||
CURRENT_SYSTEM_PATH = "/run/current-system"
|
||||
|
||||
|
||||
def reboot_is_pending(
|
||||
boot_profile_path: str = BOOT_PROFILE_PATH,
|
||||
current_system_path: str = CURRENT_SYSTEM_PATH,
|
||||
) -> bool:
|
||||
"""Return whether a staged NixOS generation has not been booted yet.
|
||||
|
||||
This is deliberately independent of how the update was started — Hub
|
||||
"Update System", terminal ``nixos-rebuild boot``, or a support session all
|
||||
move the system profile the same way:
|
||||
|
||||
* after ``nixos-rebuild boot``: profile -> new, current -> old → pending
|
||||
* after rebooting: both -> new → cleared
|
||||
* after ``nixos-rebuild switch``: both move together → no reboot
|
||||
ever needed (switch activates immediately)
|
||||
* after a rollback: both point at the rollback target → cleared
|
||||
|
||||
Unreadable or missing paths are treated as "not pending": the Hub must
|
||||
never demand a reboot it cannot substantiate.
|
||||
"""
|
||||
try:
|
||||
boot_default = os.path.realpath(boot_profile_path)
|
||||
current = os.path.realpath(current_system_path)
|
||||
except OSError:
|
||||
return False
|
||||
if not os.path.exists(boot_default) or not os.path.exists(current):
|
||||
return False
|
||||
return boot_default != current
|
||||
|
||||
|
||||
def effective_update_status(
|
||||
status: str,
|
||||
boot_profile_path: str = BOOT_PROFILE_PATH,
|
||||
current_system_path: str = CURRENT_SYSTEM_PATH,
|
||||
) -> str:
|
||||
"""Map a persisted Hub status to the one that reflects live NixOS state.
|
||||
|
||||
Only ``REBOOT_REQUIRED`` is re-validated: it means "the update staged a
|
||||
generation the machine has not booted into", a claim that must stay true
|
||||
no matter which tool performed the last update. When the boot default IS
|
||||
the running system the claim is stale — the staged generation booted, was
|
||||
superseded by a newer update, or the marker was written by an updater that
|
||||
could never clear it — so the effective status is ``IDLE``.
|
||||
|
||||
All other statuses (``RUNNING``, ``FAILED``, ``SUCCESS``, ``IDLE``) pass
|
||||
through unchanged; RUNNING staleness is handled separately against the
|
||||
systemd unit itself.
|
||||
"""
|
||||
if status == "REBOOT_REQUIRED" and not reboot_is_pending(
|
||||
boot_profile_path, current_system_path
|
||||
):
|
||||
return "IDLE"
|
||||
return status
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
/* ── Tile (locked dimensions via GTK min-width/height only) ── */
|
||||
.sovran-tile {
|
||||
border-radius: 18px;
|
||||
padding: 0px;
|
||||
min-width: 180px;
|
||||
min-height: 210px;
|
||||
transition: box-shadow 200ms ease-in-out;
|
||||
}
|
||||
.sovran-tile:hover {
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* ── Tile text ─────────────────────────────────────────────── */
|
||||
.tile-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
.tile-status {
|
||||
}
|
||||
|
||||
/* ── Section headers ───────────────────────────────────────── */
|
||||
.section-header {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* ── Status colors ─────────────────────────────────────────── */
|
||||
.success { color: #2ec27e; }
|
||||
.warning { color: #e5a50a; }
|
||||
.error { color: #e01b24; }
|
||||
.disabled-label { color: #888888; font-style: italic; }
|
||||
|
||||
/* ── Header / role ─────────────────────────────────────────── */
|
||||
.hub-title {
|
||||
font-weight: bold;
|
||||
}
|
||||
.role-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ── Update indicator ──────────────────────────────────────── */
|
||||
.update-badge {
|
||||
color: #2ec27e;
|
||||
font-weight: bold;
|
||||
}
|
||||
.update-available {
|
||||
background: #2ec27e;
|
||||
color: white;
|
||||
}
|
||||
.update-available:hover {
|
||||
background: #26a269;
|
||||
}
|
||||
|
||||
/* ── IP bar ────────────────────────────────────────────────── */
|
||||
.ip-bar {
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
background: alpha(@card_bg_color, 0.5);
|
||||
}
|
||||
.ip-value {
|
||||
font-family: monospace;
|
||||
font-weight: bold;
|
||||
color: @accent_color;
|
||||
}
|
||||
|
||||
/* ── Grid container ────────────────────────────────────────── */
|
||||
.tiles-container {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
After Width: | Height: | Size: 217 KiB |
@@ -0,0 +1,52 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#153126"/>
|
||||
<stop offset="55%" stop-color="#0F241B"/>
|
||||
<stop offset="100%" stop-color="#091C14"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="outerArc" x1="70" y1="40" x2="190" y2="210" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#42F39A"/>
|
||||
<stop offset="45%" stop-color="#28D978"/>
|
||||
<stop offset="100%" stop-color="#1AA45D"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="innerArc" x1="90" y1="60" x2="180" y2="190" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#27C86F"/>
|
||||
<stop offset="100%" stop-color="#157E49"/>
|
||||
</linearGradient>
|
||||
|
||||
<filter id="innerShade" x="-10%" y="-10%" width="120%" height="120%">
|
||||
<feOffset dx="0" dy="2"/>
|
||||
<feGaussianBlur stdDeviation="5" result="blur"/>
|
||||
<feComposite in="blur" in2="SourceAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="
|
||||
0 0 0 0 0
|
||||
0 0 0 0 0
|
||||
0 0 0 0 0
|
||||
0 0 0 .18 0"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<rect width="256" height="256" rx="48" ry="48" fill="url(#bg)"/>
|
||||
<rect x="1.5" y="1.5" width="253" height="253" rx="46.5" ry="46.5"
|
||||
fill="none" stroke="rgba(255,255,255,0.08)"/>
|
||||
<rect x="6" y="6" width="244" height="244" rx="42" ry="42"
|
||||
fill="none" filter="url(#innerShade)"/>
|
||||
|
||||
<path d="M128 32 A96 96 0 1 1 58 196"
|
||||
fill="none"
|
||||
stroke="url(#outerArc)"
|
||||
stroke-width="12"
|
||||
stroke-linecap="round"/>
|
||||
|
||||
<path d="M128 56 A72 72 0 1 1 76 178"
|
||||
fill="none"
|
||||
stroke="url(#innerArc)"
|
||||
stroke-width="10"
|
||||
stroke-linecap="round"/>
|
||||
|
||||
<circle cx="128" cy="128" r="8" fill="#F2FFF7"/>
|
||||
<circle cx="128" cy="128" r="18" fill="none" stroke="#7BFFC0" stroke-opacity="0.14" stroke-width="4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 59 KiB |
@@ -0,0 +1,300 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
viewBox="0 0 3440 1440"
|
||||
width="3440"
|
||||
height="1440"
|
||||
version="1.1"
|
||||
id="svg21"
|
||||
sodipodi:docname="sovran-wallpaper-12-ultrawide-3440x1440.svg"
|
||||
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
|
||||
xml:space="preserve"
|
||||
inkscape:export-filename="sovran-wallpaper-12-ultrawide-3440x1440.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||
id="namedview21"
|
||||
pagecolor="#505050"
|
||||
bordercolor="#ffffff"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="1"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="0.657762"
|
||||
inkscape:cx="1721.7474"
|
||||
inkscape:cy="718.34493"
|
||||
inkscape:window-width="3440"
|
||||
inkscape:window-height="1363"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg21" /><defs
|
||||
id="defs14"><linearGradient
|
||||
id="bg"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="1"
|
||||
y2="1"><stop
|
||||
offset="0%"
|
||||
stop-color="#040706"
|
||||
id="stop1" /><stop
|
||||
offset="50%"
|
||||
stop-color="#06100c"
|
||||
id="stop2" /><stop
|
||||
offset="100%"
|
||||
stop-color="#050706"
|
||||
id="stop3" /></linearGradient><radialGradient
|
||||
id="softGlow"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="210"
|
||||
fx="0"
|
||||
fy="0"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(210)"><stop
|
||||
offset="0%"
|
||||
stop-color="#28d978"
|
||||
stop-opacity="0.04"
|
||||
id="stop4" /><stop
|
||||
offset="100%"
|
||||
stop-color="#28d978"
|
||||
stop-opacity="0"
|
||||
id="stop5" /></radialGradient><linearGradient
|
||||
id="tileBg"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="340"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(210)"><stop
|
||||
offset="0%"
|
||||
stop-color="#153126"
|
||||
id="stop6" /><stop
|
||||
offset="55%"
|
||||
stop-color="#0F241B"
|
||||
id="stop7" /><stop
|
||||
offset="100%"
|
||||
stop-color="#091C14"
|
||||
id="stop8" /></linearGradient><linearGradient
|
||||
id="outerArc"
|
||||
x1="75.634857"
|
||||
y1="47.268153"
|
||||
x2="326.94922"
|
||||
y2="298.58251"
|
||||
gradientTransform="matrix(0.95194204,0,0,1.0504841,210,0)"
|
||||
gradientUnits="userSpaceOnUse"><stop
|
||||
offset="0%"
|
||||
stop-color="#42F39A"
|
||||
id="stop9" /><stop
|
||||
offset="45%"
|
||||
stop-color="#28D978"
|
||||
id="stop10" /><stop
|
||||
offset="100%"
|
||||
stop-color="#1AA45D"
|
||||
id="stop11" /></linearGradient><linearGradient
|
||||
id="innerArc"
|
||||
x1="130.64136"
|
||||
y1="106.15404"
|
||||
x2="258.09194"
|
||||
y2="251.81184"
|
||||
gradientTransform="matrix(0.95325178,0,0,1.0490408,210,0)"
|
||||
gradientUnits="userSpaceOnUse"><stop
|
||||
offset="0%"
|
||||
stop-color="#27C86F"
|
||||
id="stop12" /><stop
|
||||
offset="100%"
|
||||
stop-color="#157E49"
|
||||
id="stop13" /></linearGradient><filter
|
||||
id="tileShadow"
|
||||
x="-0.12705882"
|
||||
y="-0.12705882"
|
||||
width="1.2541176"
|
||||
height="1.2952941"><feOffset
|
||||
dy="14"
|
||||
id="feOffset13" /><feGaussianBlur
|
||||
stdDeviation="18"
|
||||
result="blur"
|
||||
id="feGaussianBlur13" /><feColorMatrix
|
||||
type="matrix"
|
||||
values=" 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .24 0"
|
||||
id="feColorMatrix13" /><feMerge
|
||||
id="feMerge14"><feMergeNode
|
||||
in="blur"
|
||||
id="feMergeNode13" /><feMergeNode
|
||||
in="SourceGraphic"
|
||||
id="feMergeNode14" /></feMerge></filter><linearGradient
|
||||
id="bg-7"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="256"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.79478947,0,0,0.82005964,452.62858,4.2254746)"><stop
|
||||
offset="0%"
|
||||
stop-color="#153126"
|
||||
id="stop1-0" /><stop
|
||||
offset="55%"
|
||||
stop-color="#0F241B"
|
||||
id="stop2-9" /><stop
|
||||
offset="100%"
|
||||
stop-color="#091C14"
|
||||
id="stop3-3" /></linearGradient><linearGradient
|
||||
id="outerArc-6"
|
||||
x1="70"
|
||||
y1="40"
|
||||
x2="190"
|
||||
y2="210"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.79478947,0,0,0.82005964,452.62858,4.2254746)"><stop
|
||||
offset="0%"
|
||||
stop-color="#42F39A"
|
||||
id="stop4-0" /><stop
|
||||
offset="45%"
|
||||
stop-color="#28D978"
|
||||
id="stop5-6" /><stop
|
||||
offset="100%"
|
||||
stop-color="#1AA45D"
|
||||
id="stop6-2" /></linearGradient><linearGradient
|
||||
id="innerArc-6"
|
||||
x1="90"
|
||||
y1="60"
|
||||
x2="180"
|
||||
y2="190"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.79478947,0,0,0.82005964,452.62858,4.2254746)"><stop
|
||||
offset="0%"
|
||||
stop-color="#27C86F"
|
||||
id="stop7-1" /><stop
|
||||
offset="100%"
|
||||
stop-color="#157E49"
|
||||
id="stop8-8" /></linearGradient><filter
|
||||
id="innerShade"
|
||||
x="-0.049180328"
|
||||
y="-0.049180328"
|
||||
width="1.0983607"
|
||||
height="1.1065574"><feOffset
|
||||
dx="0"
|
||||
dy="2"
|
||||
id="feOffset8" /><feGaussianBlur
|
||||
stdDeviation="5"
|
||||
result="blur"
|
||||
id="feGaussianBlur8" /><feComposite
|
||||
in="blur"
|
||||
in2="SourceAlpha"
|
||||
operator="arithmetic"
|
||||
k2="-1"
|
||||
k3="1"
|
||||
id="feComposite8" /><feColorMatrix
|
||||
type="matrix"
|
||||
values=" 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .18 0"
|
||||
id="feColorMatrix8" /></filter></defs><rect
|
||||
width="3440"
|
||||
height="1440"
|
||||
fill="url(#bg)"
|
||||
id="rect14" /><!-- centered for ultrawide balance --><g
|
||||
id="g3"
|
||||
transform="translate(18.243681,41.048282)"><g
|
||||
id="g2"
|
||||
transform="translate(-161.15251,-21.284294)"><g
|
||||
transform="translate(1330,720)"
|
||||
id="g20"><circle
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="310"
|
||||
fill="none"
|
||||
stroke="rgba(242,255,247,0.045)"
|
||||
stroke-width="1"
|
||||
id="circle15" /><circle
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="390"
|
||||
fill="none"
|
||||
stroke="rgba(66,243,154,0.055)"
|
||||
stroke-width="2"
|
||||
stroke-dasharray="3, 22"
|
||||
id="circle16" /></g><g
|
||||
transform="translate(1565,702)"
|
||||
id="g21"><text
|
||||
x="0"
|
||||
y="0"
|
||||
fill="#c3cbc6"
|
||||
font-family="Inter, ui-sans-serif, system-ui, '-apple-system', BlinkMacSystemFont, 'Segoe UI', sans-serif"
|
||||
font-size="42px"
|
||||
font-weight="500"
|
||||
letter-spacing="8"
|
||||
id="text20">PRIVACY. SOVEREIGNTY. BITCOIN.</text><rect
|
||||
x="0"
|
||||
y="56"
|
||||
width="240"
|
||||
height="2"
|
||||
rx="1"
|
||||
fill="#42f39a"
|
||||
id="rect21" /></g><g
|
||||
id="g1"
|
||||
transform="matrix(1.2581949,0,0,1.2194235,1167.4138,564.08337)"><rect
|
||||
width="256"
|
||||
height="256"
|
||||
rx="48"
|
||||
ry="48"
|
||||
fill="url(#bg)"
|
||||
id="rect8"
|
||||
style="fill:url(#bg-7)"
|
||||
x="0"
|
||||
y="0" /><rect
|
||||
x="1.5"
|
||||
y="1.5"
|
||||
width="253"
|
||||
height="253"
|
||||
rx="46.5"
|
||||
ry="46.5"
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.08)"
|
||||
id="rect9" /><rect
|
||||
x="6"
|
||||
y="6"
|
||||
width="244"
|
||||
height="244"
|
||||
rx="42"
|
||||
ry="42"
|
||||
fill="none"
|
||||
filter="url(#innerShade)"
|
||||
id="rect10" /><path
|
||||
d="M 128,32 A 96,96 0 1 1 58,196"
|
||||
fill="none"
|
||||
stroke="url(#outerArc)"
|
||||
stroke-width="12"
|
||||
stroke-linecap="round"
|
||||
id="path10"
|
||||
style="stroke:url(#outerArc-6)" /><path
|
||||
d="M 128,56 A 72,72 0 1 1 76,178"
|
||||
fill="none"
|
||||
stroke="url(#innerArc)"
|
||||
stroke-width="10"
|
||||
stroke-linecap="round"
|
||||
id="path11"
|
||||
style="stroke:url(#innerArc-6)" /><circle
|
||||
cx="128"
|
||||
cy="128"
|
||||
r="8"
|
||||
fill="#f2fff7"
|
||||
id="circle11" /><circle
|
||||
cx="128"
|
||||
cy="128"
|
||||
r="18"
|
||||
fill="none"
|
||||
stroke="#7bffc0"
|
||||
stroke-opacity="0.14"
|
||||
stroke-width="4"
|
||||
id="circle12" /></g><rect
|
||||
x="0"
|
||||
y="56"
|
||||
width="560"
|
||||
height="2"
|
||||
rx="1"
|
||||
fill="rgba(242,255,247,0.08)"
|
||||
id="rect20"
|
||||
style="fill:#b3b3b3"
|
||||
transform="translate(1565,702)" /></g></g></svg>
|
||||
|
After Width: | Height: | Size: 8.8 KiB |
@@ -3,7 +3,6 @@
|
||||
{
|
||||
imports = [
|
||||
./modules/modules.nix
|
||||
./iso/branding.nix
|
||||
];
|
||||
|
||||
# ── Boot ────────────────────────────────────────────────────
|
||||
@@ -11,6 +10,8 @@
|
||||
boot.loader.efi.canTouchEfiVariables = true;
|
||||
boot.loader.efi.efiSysMountPoint = "/boot/efi";
|
||||
boot.kernelPackages = pkgs.linuxPackages_latest;
|
||||
boot.kernelParams = [ "quiet" "loglevel=3" "rd.systemd.show_status=false" "udev.log_level=3" ];
|
||||
boot.blacklistedKernelModules = [ "rxrpc" ];
|
||||
|
||||
# ── Filesystems ─────────────────────────────────────────────
|
||||
fileSystems."/run/media/Second_Drive" = {
|
||||
@@ -25,26 +26,50 @@
|
||||
nix.settings = {
|
||||
experimental-features = [ "nix-command" "flakes" ];
|
||||
download-buffer-size = 524288000;
|
||||
|
||||
# Network resilience for cache.nixos.org (Fastly) flakiness.
|
||||
connect-timeout = 10; # fail-fast on dead TCP connects (default: 0 = unlimited)
|
||||
stalled-download-timeout = 90; # default 300s; retry sooner on stalled transfers
|
||||
download-attempts = 7; # default 5
|
||||
http-connections = 25; # cap concurrency (helps MTU/middlebox paths)
|
||||
fallback = true; # build locally if a substitute can't be fetched
|
||||
};
|
||||
|
||||
# ── Networking ──────────────────────────────────────────────
|
||||
networking.hostName = "nixos";
|
||||
networking.networkmanager.enable = true;
|
||||
networking.firewall.enable = true;
|
||||
networking.firewall.allowedTCPPorts = [ 80 443 8448 3051 ];
|
||||
networking.firewall.allowedUDPPorts = [ 80 443 8448 3051 ];
|
||||
networking.firewall.allowedUDPPortRanges = [
|
||||
{ from = 49152; to = 65535; }
|
||||
];
|
||||
networking.firewall.allowedUDPPorts = [ 5353 ];
|
||||
|
||||
# ── Avahi (mDNS) ───────────────────────────────────────────
|
||||
services.avahi = {
|
||||
enable = true;
|
||||
hostName = "sovransystemsos";
|
||||
nssmdns4 = true;
|
||||
publish = { enable = true; addresses = true; };
|
||||
};
|
||||
|
||||
# ── Locale / Time ──────────────────────────────────────────
|
||||
time.timeZone = "America/Los_Angeles";
|
||||
i18n.defaultLocale = "en_US.UTF-8";
|
||||
time.timeZone = null;
|
||||
i18n.defaultLocale = lib.mkDefault "en_US.UTF-8";
|
||||
i18n.supportedLocales = [
|
||||
"en_US.UTF-8/UTF-8"
|
||||
"en_GB.UTF-8/UTF-8"
|
||||
"es_ES.UTF-8/UTF-8"
|
||||
"fr_FR.UTF-8/UTF-8"
|
||||
"de_DE.UTF-8/UTF-8"
|
||||
"pt_BR.UTF-8/UTF-8"
|
||||
"ja_JP.UTF-8/UTF-8"
|
||||
"zh_CN.UTF-8/UTF-8"
|
||||
"ko_KR.UTF-8/UTF-8"
|
||||
"ru_RU.UTF-8/UTF-8"
|
||||
"ar_SA.UTF-8/UTF-8"
|
||||
"hi_IN/UTF-8"
|
||||
];
|
||||
|
||||
# ── Desktop ────────────────────────────────────────────────
|
||||
services.displayManager.gdm.enable = true;
|
||||
services.displayManager.gdm.autoSuspend = false;
|
||||
services.displayManager.gdm.wayland = true;
|
||||
services.desktopManager.gnome.enable = true;
|
||||
services.printing.enable = true;
|
||||
systemd.enableEmergencyMode = false;
|
||||
@@ -67,16 +92,25 @@
|
||||
extraGroups = [ "networkmanager" ];
|
||||
};
|
||||
|
||||
services.displayManager.autoLogin.enable = true;
|
||||
services.displayManager.autoLogin.user = "free";
|
||||
services.displayManager.autoLogin.enable = false;
|
||||
|
||||
# ── Flatpak ────────────────────────────────────────────────
|
||||
services.flatpak.enable = true;
|
||||
systemd.services.flatpak-repo = {
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network-online.target" ];
|
||||
wants = [ "network-online.target" ];
|
||||
after = [ "network-online.target" "nss-lookup.target" ];
|
||||
wants = [ "network-online.target" "nss-lookup.target" ];
|
||||
path = [ pkgs.flatpak ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
Restart = "on-failure";
|
||||
RestartSec = "15s";
|
||||
};
|
||||
unitConfig = {
|
||||
StartLimitIntervalSec = 120;
|
||||
StartLimitBurst = 5;
|
||||
};
|
||||
script = ''
|
||||
flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
'';
|
||||
@@ -84,9 +118,9 @@
|
||||
|
||||
# ── Packages ───────────────────────────────────────────────
|
||||
nixpkgs.config.allowUnfree = true;
|
||||
nixpkgs.config.permittedInsecurePackages = [ "jitsi-meet-1.0.8043" ];
|
||||
|
||||
environment.systemPackages = with pkgs; [
|
||||
nftables
|
||||
git wget fish htop btop
|
||||
gnomeExtensions.transparent-top-bar-adjustable-transparency
|
||||
gnomeExtensions.dash-to-dock
|
||||
@@ -99,12 +133,13 @@
|
||||
ranger fastfetch gedit openssl pwgen
|
||||
aspell aspellDicts.en lm_sensors
|
||||
hunspell hunspellDicts.en_US
|
||||
synadm brave dua bitwarden-desktop
|
||||
synadm brave-origin dua
|
||||
gparted pv unzip parted screen zenity
|
||||
libargon2 gnome-terminal libreoffice-fresh
|
||||
dig firefox element-desktop wp-cli axel
|
||||
lk-jwt-service livekit-libwebrtc livekit-cli livekit
|
||||
matrix-synapse age
|
||||
libargon2 gnome-terminal libreoffice-stable
|
||||
dig firefox wp-cli axel
|
||||
lk-jwt-service livekit-libwebrtc livekit
|
||||
matrix-synapse age onlyoffice-desktopeditors
|
||||
tor-browser
|
||||
];
|
||||
|
||||
# ── Shell ──────────────────────────────────────────────────
|
||||
@@ -118,6 +153,14 @@
|
||||
programs.fish = { enable = true; promptInit = "fastfetch"; };
|
||||
|
||||
# ── PostgreSQL base ────────────────────────────────────────
|
||||
# Shared cluster for Nextcloud (nextclouddb) + Matrix Synapse.
|
||||
# Sized for the README's Server + Desktop recommendation (32 GB RAM,
|
||||
# 500 GB NVMe OS + 2 TB NVMe timechain). Postgres shares the box with
|
||||
# Bitcoin Core, Electrs, LND, MariaDB, PHP-FPM and GNOME, so
|
||||
# shared_buffers stays below the 25%-of-RAM dedicated-server rule.
|
||||
# Fixes Nextcloud 35 Database checks (pg.cache_hit_ratio,
|
||||
# pg.dead_tuples). Override in custom.nix for other hosts, e.g.:
|
||||
# services.postgresql.settings.shared_buffers = lib.mkForce "512MB";
|
||||
services.postgresql = {
|
||||
enable = true;
|
||||
authentication = lib.mkForce ''
|
||||
@@ -125,6 +168,34 @@
|
||||
host all all 127.0.0.1/32 trust
|
||||
host all all ::1/128 trust
|
||||
'';
|
||||
settings = {
|
||||
# Memory — fixes low buffer cache hit ratio (stock default is
|
||||
# 128MB shared_buffers). effective_cache_size is only a planner
|
||||
# hint, not an allocation, so it can be generous.
|
||||
# NOTE: changing shared_buffers requires a Postgres restart.
|
||||
shared_buffers = "2GB";
|
||||
effective_cache_size = "12GB";
|
||||
maintenance_work_mem = "512MB";
|
||||
work_mem = "32MB";
|
||||
wal_buffers = "64MB";
|
||||
|
||||
# Checkpoints — spread write bursts out on NVMe. Reload-only.
|
||||
min_wal_size = "1GB";
|
||||
max_wal_size = "4GB";
|
||||
checkpoint_completion_target = 0.9;
|
||||
|
||||
# Autovacuum — the stock 60s naptime can't keep up with
|
||||
# Nextcloud's and Synapse's write-heavy tables (filecache,
|
||||
# activity, jobs, state). Reload-only.
|
||||
autovacuum_naptime = "30s";
|
||||
autovacuum_vacuum_scale_factor = 0.05;
|
||||
autovacuum_analyze_scale_factor = 0.025;
|
||||
autovacuum_max_workers = 4;
|
||||
|
||||
# NVMe planner assumptions (README: NVMe OS + data disks).
|
||||
random_page_cost = "1.1";
|
||||
effective_io_concurrency = 200;
|
||||
};
|
||||
};
|
||||
|
||||
# ── Backups ────────────────────────────────────────────────
|
||||
@@ -146,33 +217,13 @@ backup /etc/nix-bitcoin-secrets/ localhost/
|
||||
};
|
||||
|
||||
# ── Cron ───────────────────────────────────────────────────
|
||||
services.cron = {
|
||||
enable = true;
|
||||
systemCronJobs = [
|
||||
"*/15 * * * * root /run/current-system/sw/bin/bash /var/lib/njalla/njalla.sh"
|
||||
"*/15 * * * * root /run/current-system/sw/bin/bash /var/lib/external_ip/external_ip.sh"
|
||||
];
|
||||
};
|
||||
# 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.
|
||||
|
||||
# ── Tor ────────────────────────────────────────────────────
|
||||
services.tor = { enable = true; client.enable = true; torsocks.enable = true; };
|
||||
|
||||
# ── SSH ────────────────────────────────────────────────────
|
||||
services.openssh = {
|
||||
enable = true;
|
||||
settings = {
|
||||
PasswordAuthentication = false;
|
||||
KbdInteractiveAuthentication = false;
|
||||
PermitRootLogin = "yes";
|
||||
};
|
||||
};
|
||||
|
||||
# ── Fail2Ban ───────────────────────────────────────────────
|
||||
services.fail2ban = {
|
||||
enable = true;
|
||||
ignoreIP = [ "127.0.0.0/8" "10.0.0.0/8" "172.16.0.0/12" "192.168.0.0/16" "8.8.8.8" ];
|
||||
};
|
||||
|
||||
# ── Garbage Collection ─────────────────────────────────────
|
||||
nix.gc = { automatic = true; dates = "weekly"; options = "--delete-older-than 7d"; };
|
||||
|
||||
|
||||
@@ -5,8 +5,14 @@
|
||||
# #
|
||||
# Sovran_SystemsOS — custom.nix #
|
||||
# #
|
||||
# This is YOUR configuration file. Edit it to customize #
|
||||
# which services and features run on your machine. #
|
||||
# Services, features, and roles are managed by the #
|
||||
# Sovran Hub. Any changes you make through the Hub #
|
||||
# will appear in a Hub Managed section added #
|
||||
# automatically below. #
|
||||
# #
|
||||
# If you want to add your own NixOS modules or #
|
||||
# configuration, place them here — outside of the #
|
||||
# Hub Managed section. #
|
||||
# #
|
||||
# After making changes, rebuild with: #
|
||||
# #
|
||||
@@ -14,114 +20,27 @@
|
||||
# #
|
||||
###########################################################
|
||||
|
||||
# ─── Add your custom NixOS configuration below ───────────
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# STEP 1: CHOOSE YOUR ROLE
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
#
|
||||
# Your initial role was selected during installation.
|
||||
# To CHANGE your role, uncomment exactly ONE of the lines below.
|
||||
#
|
||||
# Server+Desktop: Full server + desktop environment
|
||||
# Desktop Only: Desktop environment, no server services
|
||||
# Node (Bitcoin Only): Bitcoin ecosystem
|
||||
#
|
||||
# ───────────────────────────────────────────────────────────
|
||||
# ─── 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;
|
||||
|
||||
# sovran_systemsOS.roles.server_plus_desktop = true;
|
||||
# sovran_systemsOS.roles.desktop = true;
|
||||
# sovran_systemsOS.roles.node = true;
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# STEP 2: SERVICES (default: ON)
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# ─── Custom Caddy virtual hosts ──────────────────────────
|
||||
# Uncomment and edit below to add your own Caddy sites:
|
||||
#
|
||||
# These are all ON by default in the Server+Desktop role.
|
||||
# Set any to "false" to disable it.
|
||||
# sovran_systemsOS.caddy.extraVirtualHosts = ''
|
||||
# mysite.example.com {
|
||||
# encode gzip zstd
|
||||
# root * /var/lib/www/mysite
|
||||
# php_fastcgi unix//run/phpfpm/mypool.sock
|
||||
# file_server browse
|
||||
# }
|
||||
#
|
||||
# ┌─────────────────────┬────────────────────────────────┐
|
||||
# │ Service │ What it does │
|
||||
# ├─────────────────────┼────────────────────────────────┤
|
||||
# │ synapse │ Matrix Synapse homeserver │
|
||||
# │ bitcoin │ Bitcoin ecosystem (bitcoind, │
|
||||
# │ │ electrs, lnd, rtl, btcpay) │
|
||||
# │ vaultwarden │ Vaultwarden password manager │
|
||||
# │ wordpress │ WordPress website │
|
||||
# │ nextcloud │ Nextcloud file hosting │
|
||||
# └─────────────────────┴────────────────────────────────┘
|
||||
#
|
||||
# Example — disable WordPress and Nextcloud:
|
||||
#
|
||||
# sovran_systemsOS.services.wordpress = false;
|
||||
# sovran_systemsOS.services.nextcloud = false;
|
||||
#
|
||||
# ───────────────────────────────────────────────────────────
|
||||
|
||||
# sovran_systemsOS.services.wordpress = false;
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# STEP 3: FEATURES (default: OFF)
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
#
|
||||
# These are OFF by default. Set to "true" to enable.
|
||||
#
|
||||
# ┌─────────────────────┬────────────────────────────────┐
|
||||
# │ Feature │ What it does │
|
||||
# ├─────────────────────┼────────────────────────────────┤
|
||||
# │ haven │ Haven NOSTR relay & Blossom │
|
||||
# │ bip110 │ BIP-110 Bitcoin Better Money │
|
||||
# │ mempool │ Mempool.space block explorer │
|
||||
# │ element-calling │ LiveKit server for Matrix │
|
||||
# │ rdp │ GNOME Remote Desktop (RDP) │
|
||||
# │ bitcoin-core │ Bitcoin Core GUI desktop app │
|
||||
# └─────────────────────┴─────���──────────────────────────┘
|
||||
#
|
||||
# Example — enable element video calling:
|
||||
#
|
||||
# sovran_systemsOS.features.element-calling = true;
|
||||
#
|
||||
# ───────────────────────────────────────────────────────────
|
||||
|
||||
# sovran_systemsOS.features.element-calling = true;
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# STEP 4: WEB EXPOSURE (default: ON)
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
#
|
||||
# Controls whether Caddy serves this application to the web.
|
||||
# (Does not stop the application itself from running).
|
||||
#
|
||||
# ┌─────────────────────┬────────────────────────────────┐
|
||||
# │ Option │ Default │
|
||||
# ├─────────────────────┼────────────────────────────────┤
|
||||
# │ btcpayserver │ true (false in Node role) │
|
||||
# └─────────────────────┴────────────────────────────────┘
|
||||
#
|
||||
# Example — hide BTCPay from the web:
|
||||
#
|
||||
# sovran_systemsOS.web.btcpayserver = false;
|
||||
#
|
||||
# ───────────────────────────────────────────────────────────
|
||||
|
||||
# sovran_systemsOS.web.btcpayserver = false;
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# STEP 5: NOSTR PUBLIC KEY (required for Haven)
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
#
|
||||
# If you enabled Haven above, paste your npub here.
|
||||
# Haven will NOT start without a valid npub.
|
||||
#
|
||||
# Example:
|
||||
#
|
||||
# sovran_systemsOS.nostr_npub = "npub1abc123...";
|
||||
#
|
||||
# ───────────────────────────────────────────────────────────
|
||||
|
||||
# sovran_systemsOS.nostr_npub = "";
|
||||
# anotherdomain.com {
|
||||
# reverse_proxy localhost:9090
|
||||
# }
|
||||
# '';
|
||||
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
cd /home/free/Downloads
|
||||
|
||||
|
||||
#### SCRIPT 1 ####
|
||||
|
||||
/run/current-system/sw/bin/wget "https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/file_fixes_and_new_services/sovran-pro-flake-update.sh"
|
||||
|
||||
/run/current-system/sw/bin/bash /home/free/Downloads/sovran-pro-flake-update.sh
|
||||
|
||||
rm -rf /home/free/Downloads/sovran-pro-flake-update.sh
|
||||
|
||||
|
||||
#### SCRIPT 2 ####
|
||||
|
||||
/run/current-system/sw/bin/wget "https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/file_fixes_and_new_services/add-custom-nix.sh"
|
||||
|
||||
/run/current-system/sw/bin/bash /home/free/Downloads/add-custom-nix.sh
|
||||
|
||||
rm -rf /home/free/Downloads/add-custom-nix.sh
|
||||
|
||||
|
||||
#### SCRIPT 3 ####
|
||||
|
||||
/run/current-system/sw/bin/wget "https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/file_fixes_and_new_services/sovran-pro-flake-update2.sh"
|
||||
|
||||
/run/current-system/sw/bin/bash /home/free/Downloads/sovran-pro-flake-update2.sh
|
||||
|
||||
rm -rf /home/free/Downloads/sovran-pro-flake-update2.sh
|
||||
|
||||
|
||||
#### SCRIPT 4 ####
|
||||
|
||||
/run/current-system/sw/bin/wget "https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/file_fixes_and_new_services/nextcloud_maintenance_window_fix.sh"
|
||||
|
||||
/run/current-system/sw/bin/bash /home/free/Downloads/nextcloud_maintenance_window_fix.sh
|
||||
|
||||
rm -rf /home/free/Downloads/nextcloud_maintenance_window_fix.sh
|
||||
|
||||
|
||||
#### SCRIPT 5 ####
|
||||
|
||||
/run/current-system/sw/bin/wget "https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/file_fixes_and_new_services/add_external_backup_app.sh"
|
||||
|
||||
/run/current-system/sw/bin/bash /home/free/Downloads/add_external_backup_app.sh
|
||||
|
||||
rm -rf /home/free/Downloads/add_external_backup_app.sh
|
||||
|
||||
|
||||
#### SCRIPT 6 ####
|
||||
|
||||
/run/current-system/sw/bin/wget "https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/file_fixes_and_new_services/update-agenix.sh"
|
||||
|
||||
/run/current-system/sw/bin/bash /home/free/Downloads/update-agenix.sh
|
||||
|
||||
rm -rf /home/free/Downloads/update-agenix.sh
|
||||
|
||||
#### SCRIPT 7 ####
|
||||
|
||||
/run/current-system/sw/bin/wget "https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/file_fixes_and_new_services/element-calling_haven"
|
||||
|
||||
/run/current-system/sw/bin/bash /home/free/Downloads/element-calling_haven.sh
|
||||
|
||||
rm -rf /home/free/Downloads/element-calling_haven.sh
|
||||
|
||||
|
||||
#### REMOVAL OF MAIN SCRIPT ####
|
||||
|
||||
rm -rf /home/free/Downloads/Sovran_SystemsOS_File_Fixes_And_New_Services.sh
|
||||
@@ -1,81 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
function log_console () {
|
||||
echo "`date` :: $1" >> /var/lib/beacons/awesome.log
|
||||
echo $1
|
||||
}
|
||||
|
||||
|
||||
#### CHECK TO SEE IF IT HAS BEEN RUN BEFORE ####
|
||||
|
||||
FILE=/var/lib/beacons/file_fixes_and_new_services/add-custom-nix/completed
|
||||
|
||||
if [ -e $FILE ]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "File Found :), No Need to Run ... Exiting"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
#### CREATE INITIAL TAG ####
|
||||
|
||||
/run/current-system/sw/bin/mkdir -p /var/lib/beacons/file_fixes_and_new_services/add-custom-nix ; touch /var/lib/beacons/file_fixes_and_new_services/add-custom-nix/started
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Create Initial Tag"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
#### MAIN SCRIPT ####
|
||||
|
||||
touch /etc/nixos/custom.nix
|
||||
|
||||
/run/current-system/sw/bin/cat > /etc/nixos/custom.nix <<- "EOF"
|
||||
|
||||
{config, pkgs, lib, ...}:
|
||||
|
||||
# Add custom NixOS modules here.
|
||||
|
||||
let
|
||||
personalization = import ./personalization.nix;
|
||||
|
||||
in
|
||||
{
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
EOF
|
||||
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Run add-custom-nix"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
|
||||
#### CREATE COMPELETE TAG ####
|
||||
|
||||
/run/current-system/sw/bin/touch /var/lib/beacons/file_fixes_and_new_services/add-custom-nix/completed
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Create Completed Tag"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
exit 0
|
||||
@@ -1,66 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
function log_console () {
|
||||
echo "`date` :: $1" >> /var/lib/beacons/awesome.log
|
||||
echo $1
|
||||
}
|
||||
|
||||
|
||||
#### CHECK TO SEE IF IT HAS BEEN RUN BEFORE ####
|
||||
|
||||
FILE=/var/lib/beacons/file_fixes_and_new_services/add_external_backup_app/completed
|
||||
|
||||
if [ -e $FILE ]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "File Found :), No Need to Run ... Exiting"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
#### CREATE INITIAL TAG ####
|
||||
|
||||
/run/current-system/sw/bin/mkdir -p /var/lib/beacons/file_fixes_and_new_services/add_external_backup_app ; touch /var/lib/beacons/file_fixes_and_new_services/add_external_backup_app/started
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Create Initial Tag"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
#### MAIN SCRIPT ####
|
||||
|
||||
cd /home/free/Downloads
|
||||
|
||||
/run/current-system/sw/bin/wget "https://git.sovransystems.com/Sovran_Systems/Software/raw/branch/main/Sovran_SystemsOS_External_Backup/sovran_systemsOS_external_backup_local_installer/sovran_systemsOS_external_backup_install.sh"
|
||||
|
||||
/run/current-system/sw/bin/bash "sovran_systemsOS_external_backup_install.sh"
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Run add_external_backup_app"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
|
||||
#### CREATE COMPELETE TAG ####
|
||||
|
||||
/run/current-system/sw/bin/touch /var/lib/beacons/file_fixes_and_new_services/add_external_backup_app/completed
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Create Completed Tag"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
exit 0
|
||||
@@ -1,63 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
function log_console () {
|
||||
echo "`date` :: $1" >> /var/lib/beacons/awesome.log
|
||||
echo $1
|
||||
}
|
||||
|
||||
|
||||
#### CHECK TO SEE IF IT HAS BEEN RUN BEFORE ####
|
||||
|
||||
FILE=/var/lib/beacons/file_fixes_and_new_services/element-calling_haven/completed
|
||||
|
||||
if [ -e $FILE ]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "File Found :), No Need to Run ... Exiting"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
#### CREATE INITIAL TAG ####
|
||||
|
||||
/run/current-system/sw/bin/mkdir -p /var/lib/beacons/file_fixes_and_new_services/element-calling_haven ; touch /var/lib/beacons/file_fixes_and_new_services/element-calling_haven/started
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Create Initial Tag"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
#### MAIN SCRIPT ####
|
||||
|
||||
touch /var/lib/domains/haven
|
||||
touch /var/lib/domains/element-calling
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Run element-calling_haven"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
|
||||
#### CREATE COMPELETE TAG ####
|
||||
|
||||
/run/current-system/sw/bin/touch /var/lib/beacons/file_fixes_and_new_services/element-calling_haven/completed
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Create Completed Tag"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
exit 0
|
||||
@@ -1,62 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
function log_console () {
|
||||
echo "`date` :: $1" >> /var/lib/beacons/awesome.log
|
||||
echo $1
|
||||
}
|
||||
|
||||
|
||||
#### CHECK TO SEE IF IT HAS BEEN RUN BEFORE ####
|
||||
|
||||
FILE=/var/lib/beacons/file_fixes_and_new_services/nextcloud_maintenance_window_fix/completed
|
||||
|
||||
if [ -e $FILE ]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "File Found :), No Need to Run ... Exiting"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
#### CREATE INITIAL TAG ####
|
||||
|
||||
/run/current-system/sw/bin/mkdir -p /var/lib/beacons/file_fixes_and_new_services/nextcloud_maintenance_window_fix ; touch /var/lib/beacons/file_fixes_and_new_services/nextcloud_maintenance_window_fix/started
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Create Initial Tag"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
#### MAIN SCRIPT ####
|
||||
|
||||
/run/wrappers/bin/sudo -u caddy /run/current-system/sw/bin/php /var/lib/www/nextcloud/occ config:system:set maintenance_window_start --type=integer --value=1
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Run add-custom-nix"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
|
||||
#### CREATE COMPELETE TAG ####
|
||||
|
||||
/run/current-system/sw/bin/touch /var/lib/beacons/file_fixes_and_new_services/nextcloud_maintenance_window_fix/completed
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Create Completed Tag"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
exit 0
|
||||
@@ -1,96 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
function log_console () {
|
||||
echo "`date` :: $1" >> /var/lib/beacons/awesome.log
|
||||
echo $1
|
||||
}
|
||||
|
||||
|
||||
#### CHECK TO SEE IF IT HAS BEEN RUN BEFORE ####
|
||||
|
||||
FILE=/var/lib/beacons/file_fixes_and_new_services/sovran-pro-flake-update/completed
|
||||
|
||||
if [ -e $FILE ]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "File Found :), No Need to Run ... Exiting"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
#### CREATE INITIAL TAG ####
|
||||
|
||||
/run/current-system/sw/bin/mkdir -p /var/lib/beacons/file_fixes_and_new_services/sovran-pro-flake-update ; touch /var/lib/beacons/file_fixes_and_new_services/sovran-pro-flake-update/started
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Create Initial Tag"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
#### MAIN SCRIPT ####
|
||||
|
||||
/run/current-system/sw/bin/rm /etc/nixos/flake.nix
|
||||
|
||||
/run/current-system/sw/bin/cat > /etc/nixos/flake.nix <<- "EOF"
|
||||
|
||||
{
|
||||
description = "Sovran_SystemsOS for the Sovran Pro from Sovran Systems";
|
||||
|
||||
inputs = {
|
||||
|
||||
Sovran_Systems.url = "git+https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS";
|
||||
|
||||
};
|
||||
|
||||
outputs = { self, Sovran_Systems, ... }@inputs: {
|
||||
|
||||
nixosConfigurations."nixos" = Sovran_Systems.inputs.nixpkgs.lib.nixosSystem {
|
||||
|
||||
system = "x86_64-linux";
|
||||
|
||||
modules = [
|
||||
|
||||
./hardware-configuration.nix
|
||||
|
||||
Sovran_Systems.nixosModules.Sovran_SystemsOS
|
||||
|
||||
];
|
||||
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
EOF
|
||||
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Run sovran-pro-flake-update"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
|
||||
#### CREATE COMPELETE TAG ####
|
||||
|
||||
/run/current-system/sw/bin/touch /var/lib/beacons/file_fixes_and_new_services/sovran-pro-flake-update/completed
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Create Completed Tag"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
exit 0
|
||||
@@ -1,98 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
function log_console () {
|
||||
echo "`date` :: $1" >> /var/lib/beacons/awesome.log
|
||||
echo $1
|
||||
}
|
||||
|
||||
|
||||
#### CHECK TO SEE IF IT HAS BEEN RUN BEFORE ####
|
||||
|
||||
FILE=/var/lib/beacons/file_fixes_and_new_services/sovran-pro-flake-update2/completed
|
||||
|
||||
if [ -e $FILE ]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "File Found :), No Need to Run ... Exiting"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
#### CREATE INITIAL TAG ####
|
||||
|
||||
/run/current-system/sw/bin/mkdir -p /var/lib/beacons/file_fixes_and_new_services/sovran-pro-flake-update2 ; touch /var/lib/beacons/file_fixes_and_new_services/sovran-pro-flake-update2/started
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Create Initial Tag"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
#### MAIN SCRIPT ####
|
||||
|
||||
/run/current-system/sw/bin/rm /etc/nixos/flake.nix
|
||||
|
||||
/run/current-system/sw/bin/cat > /etc/nixos/flake.nix <<- "EOF"
|
||||
|
||||
{
|
||||
description = "Sovran_SystemsOS for the Sovran Pro from Sovran Systems";
|
||||
|
||||
inputs = {
|
||||
|
||||
Sovran_Systems.url = "git+https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS";
|
||||
|
||||
};
|
||||
|
||||
outputs = { self, Sovran_Systems, ... }@inputs: {
|
||||
|
||||
nixosConfigurations."nixos" = Sovran_Systems.inputs.nixpkgs.lib.nixosSystem {
|
||||
|
||||
system = "x86_64-linux";
|
||||
|
||||
modules = [
|
||||
|
||||
./custom.nix
|
||||
|
||||
./hardware-configuration.nix
|
||||
|
||||
Sovran_Systems.nixosModules.Sovran_SystemsOS
|
||||
|
||||
];
|
||||
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
EOF
|
||||
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Run sovran-pro-flake-update2"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
|
||||
#### CREATE COMPELETE TAG ####
|
||||
|
||||
/run/current-system/sw/bin/touch /var/lib/beacons/file_fixes_and_new_services/sovran-pro-flake-update2/completed
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Create Completed Tag"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
exit 0
|
||||
@@ -1,83 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
#### CHECK TO SEE IF IT HAS BEEN RUN BEFORE ####
|
||||
|
||||
FILE=/var/lib/beacons/file_fixes_and_new_services/update-agenix/completed
|
||||
|
||||
if [ -e $FILE ]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "File Found :), No Need to Run ... Exiting"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
#### CREATE INITIAL TAG ####
|
||||
|
||||
/run/current-system/sw/bin/mkdir -p /var/lib/beacons/file_fixes_and_new_services/update-agenix ; touch /var/lib/beacons/file_fixes_and_new_services/update-agenix/started
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Create Initial Tag"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
#### MAIN SCRIPT ####
|
||||
|
||||
/run/current-system/sw/bin/rm -rf /var/lib/agenix-secrets/nextclouddb.age
|
||||
|
||||
/run/current-system/sw/bin/rm -rf /var/lib/agenix-secrets/wordpressdb.age
|
||||
|
||||
/run/current-system/sw/bin/rm -rf /var/lib/agenix-secrets/turn.age
|
||||
|
||||
/run/current-system/sw/bin/rm -rf /var/lib/agenix-secrets/matrixdb.age
|
||||
|
||||
/run/current-system/sw/bin/rm -rf /var/lib/agenix-secrets/matrix_reg_secret.age
|
||||
|
||||
|
||||
pushd /var/lib/agenix-secrets/
|
||||
|
||||
|
||||
/run/current-system/sw/bin/echo -n $(/run/current-system/sw/bin/cat /var/lib/secrets/wordpressdb) | EDITOR='/run/current-system/sw/bin/cp /dev/stdin' /run/current-system/sw/bin/nix run github:ryantm/agenix -- -e wordpressdb.age -i /root/.ssh/agenix/agenix-secret-keys
|
||||
|
||||
/run/current-system/sw/bin/echo -n $(/run/current-system/sw/bin/cat /var/lib/secrets/nextclouddb) | EDITOR='/run/current-system/sw/bin/cp /dev/stdin' /run/current-system/sw/bin/nix run github:ryantm/agenix -- -e nextclouddb.age -i /root/.ssh/agenix/agenix-secret-keys
|
||||
|
||||
/run/current-system/sw/bin/echo -n $(/run/current-system/sw/bin/cat /var/lib/secrets/matrixdb) | EDITOR='/run/current-system/sw/bin/cp /dev/stdin' /run/current-system/sw/bin/nix run github:ryantm/agenix -- -e matrixdb.age -i /root/.ssh/agenix/agenix-secret-keys
|
||||
|
||||
/run/current-system/sw/bin/echo -n $(/run/current-system/sw/bin/cat /var/lib/secrets/turn) | EDITOR='/run/current-system/sw/bin/cp /dev/stdin' /run/current-system/sw/bin/nix run github:ryantm/agenix -- -e turn.age -i /root/.ssh/agenix/agenix-secret-keys
|
||||
|
||||
/run/current-system/sw/bin/echo -n $(/run/current-system/sw/bin/cat /var/lib/secrets/matrix_reg_secret) | EDITOR='/run/current-system/sw/bin/cp /dev/stdin' /run/current-system/sw/bin/nix run github:ryantm/agenix -- -e matrix_reg_secret.age -i /root/.ssh/agenix/agenix-secret-keys
|
||||
|
||||
|
||||
popd
|
||||
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Run update-agenix"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
|
||||
#### CREATE COMPELETE TAG ####
|
||||
|
||||
/run/current-system/sw/bin/touch /var/lib/beacons/file_fixes_and_new_services/update-agenix/completed
|
||||
|
||||
if [[ $? != 0 ]]; then
|
||||
|
||||
/run/current-system/sw/bin/echo "Could Not Create Completed Tag"
|
||||
|
||||
exit 1
|
||||
|
||||
fi
|
||||
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -1,34 +1,15 @@
|
||||
{
|
||||
"nodes": {
|
||||
"bip110": {
|
||||
"btc-clients": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1773169138,
|
||||
"narHash": "sha256-6X41z8o2z8KjF4gMzLTPD41WjvCDGXTc0muPGmwcOMk=",
|
||||
"owner": "emmanuelrosa",
|
||||
"repo": "bitcoin-knots-bip-110-nix",
|
||||
"rev": "b9d018b71e20ce8c1567cbc2401b6edc2c1c7793",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "emmanuelrosa",
|
||||
"repo": "bitcoin-knots-bip-110-nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"btc-clients": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs_2",
|
||||
"oldNixpkgs": "oldNixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1774797058,
|
||||
"narHash": "sha256-URUOiKNjG3s7vDkTj554+3yzQ0qqNQoQwHdc7vs63X0=",
|
||||
"lastModified": 1792157233,
|
||||
"narHash": "sha256-XQnyEc+Pj0Tg21ckbPMri0fiK/exCzhBw6wNgd1INqw=",
|
||||
"owner": "emmanuelrosa",
|
||||
"repo": "btc-clients-nix",
|
||||
"rev": "a10dae067da04b7b170eed73efc665d27fc0e0c5",
|
||||
"rev": "990d29e23437f30b8573f640d3e323167de1dc7b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -37,32 +18,6 @@
|
||||
"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": [
|
||||
@@ -71,11 +26,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1769996383,
|
||||
"narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=",
|
||||
"lastModified": 1790898470,
|
||||
"narHash": "sha256-zTwuwezD0w3hpga6a6P8emidzAZFyWqlNeRBlpWOOl8=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "57928607ea566b5db3ad13af0e57e921e6b12381",
|
||||
"rev": "024633cd702b10285db5cb19b40ad48d2399ba60",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -84,149 +39,44 @@
|
||||
"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_3",
|
||||
"nixpkgs-25_05": "nixpkgs-25_05",
|
||||
"nixpkgs-unstable": "nixpkgs-unstable"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1767721199,
|
||||
"narHash": "sha256-UzRxDiJlopBGPTjyhCdMP+QdTwXK+l+y45urXCyH69A=",
|
||||
"owner": "fort-nix",
|
||||
"repo": "nix-bitcoin",
|
||||
"rev": "5b532698ce9e8bd79b07d77ab4fc60e1a8408f73",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "fort-nix",
|
||||
"ref": "release",
|
||||
"repo": "nix-bitcoin",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1772380631,
|
||||
"narHash": "sha256-FhW0uxeXjefINP0vUD4yRBB52Us7fXZPk9RiPAopfiY=",
|
||||
"owner": "NixOS",
|
||||
"lastModified": 1792155732,
|
||||
"narHash": "sha256-3Dk56anLQ1V1VklQFKFD6ul3cKQk40sThurHlPHIhOo=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "6d3b61b190a899042ce82a5355111976ba76d698",
|
||||
"rev": "7694c4f6b8537d50062ff36c21ef36de3c9322d7",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "master",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-25_05": {
|
||||
"locked": {
|
||||
"lastModified": 1767051569,
|
||||
"narHash": "sha256-0MnuWoN+n1UYaGBIpqpPs9I9ZHW4kynits4mrnh1Pk4=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "40ee5e1944bebdd128f9fbada44faefddfde29bd",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-25.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-stable": {
|
||||
"locked": {
|
||||
"lastModified": 1751274312,
|
||||
"narHash": "sha256-/bVBlRpECLVzjV19t5KMdMFWSwKLtb5RyXdjz3LJT+g=",
|
||||
"lastModified": 1792030654,
|
||||
"narHash": "sha256-OaQwAtPMTjQiY2sT/2/62UbSPhzP/0aDnWzMm/NCiUw=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "50ab793786d9de88ee30ec4e4c24fb4236fc2674",
|
||||
"rev": "26f4107e47e3ad0ffd0db220469ba83b6f6d96e8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-24.11",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-unstable": {
|
||||
"locked": {
|
||||
"lastModified": 1767364772,
|
||||
"narHash": "sha256-fFUnEYMla8b7UKjijLnMe+oVFOz6HjijGGNS1l7dYaQ=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "16c7794d0a28b5a37904d55bcca36003b9109aaa",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"ref": "nixos-26.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1772380631,
|
||||
"narHash": "sha256-FhW0uxeXjefINP0vUD4yRBB52Us7fXZPk9RiPAopfiY=",
|
||||
"lastModified": 1792094642,
|
||||
"narHash": "sha256-zp8ToDMnqZcu4o4IH9pdwXFXGp7yLm/M0dEnNWnyd7I=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "6d3b61b190a899042ce82a5355111976ba76d698",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_3": {
|
||||
"locked": {
|
||||
"lastModified": 1767480499,
|
||||
"narHash": "sha256-8IQQUorUGiSmFaPnLSo2+T+rjHtiNWc+OAzeHck7N48=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "30a3c519afcf3f99e2c6df3b359aec5692054d92",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-25.11",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_4": {
|
||||
"locked": {
|
||||
"lastModified": 1775036866,
|
||||
"narHash": "sha256-ZojAnPuCdy657PbTq5V0Y+AHKhZAIwSIT2cb8UgAz/U=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "6201e203d09599479a3b3450ed24fa81537ebc4e",
|
||||
"rev": "fe13242281a67641c0102cdb1087f0ebe586c7df",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -236,13 +86,13 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_5": {
|
||||
"nixpkgs_3": {
|
||||
"locked": {
|
||||
"lastModified": 1770380644,
|
||||
"narHash": "sha256-P7dWMHRUWG5m4G+06jDyThXO7kwSk46C1kgjEWcybkE=",
|
||||
"lastModified": 1791487292,
|
||||
"narHash": "sha256-tqM7xDEsaeK1p6VvRygeZ9LkQRpUUkbRT02BHv6hnTM=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "ae67888ff7ef9dff69b3cf0cc0fbfbcd3a722abe",
|
||||
"rev": "8edc0c72e3a38faf5434e40d1f19431125fe4b30",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -252,78 +102,82 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixvim": {
|
||||
"inputs": {
|
||||
"flake-parts": "flake-parts",
|
||||
"nixpkgs": "nixpkgs_5",
|
||||
"systems": "systems_2"
|
||||
},
|
||||
"nixpkgs_4": {
|
||||
"locked": {
|
||||
"lastModified": 1774802402,
|
||||
"narHash": "sha256-L1UJ/zxKTyyaGGmytH6OYlgQ0HGSMhvPkvU+iz4Mkb8=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixvim",
|
||||
"rev": "cbd8536a05d1aae2593cb5c9ace1010c8c5845cb",
|
||||
"lastModified": 1791956154,
|
||||
"narHash": "sha256-jBYGPi4udQ9uo4KQp9m2J0546nJVQitG15gvZSaVVAs=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "55bfb4d7437bd4e09ab7cba58a43bd68eb310044",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "nixvim",
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"oldNixpkgs": {
|
||||
"nixvim": {
|
||||
"inputs": {
|
||||
"flake-parts": "flake-parts",
|
||||
"nixpkgs": "nixpkgs_3",
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1727619874,
|
||||
"narHash": "sha256-a4Jcd+vjQAzF675/7B1LN3U2ay22jfDAVA8pOml5J/0=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "6710d0dd013f55809648dfb1265b8f85447d30a6",
|
||||
"lastModified": 1792203519,
|
||||
"narHash": "sha256-yJ072hxngo/srNdLTp4BlFeo0hedwnKicI3TGlSXLFE=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixvim",
|
||||
"rev": "453dc9c5037e6d1bec81d2a51ddacd58a2f44e9c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "6710d0dd013f55809648dfb1265b8f85447d30a6",
|
||||
"repo": "nixpkgs",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixvim",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"bip110": "bip110",
|
||||
"btc-clients": "btc-clients",
|
||||
"nix-bitcoin": "nix-bitcoin",
|
||||
"nixpkgs": "nixpkgs_4",
|
||||
"nixpkgs": "nixpkgs_2",
|
||||
"nixpkgs-stable": "nixpkgs-stable",
|
||||
"nixvim": "nixvim"
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"lastModified": 1791244623,
|
||||
"narHash": "sha256-uuanbkFk8a313Zfrxdcz8Us/yQtaxNnKJtX8v0ZPpd8=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems_2": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"rev": "d36eeed142f88b5a636f2e3856a53c61654cf29b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"ref": "future-26.11",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
|
||||
@@ -3,14 +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-24.11";
|
||||
bip110.url = "github:emmanuelrosa/bitcoin-knots-bip-110-nix";
|
||||
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05";
|
||||
sovran-bitcoin.url = "github:naturallaw777/Sovran_Bitcoin";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, nix-bitcoin, nixvim, btc-clients, nixpkgs-stable, bip110, ... }:
|
||||
outputs = { self, nixpkgs, nixvim, btc-clients, nixpkgs-stable, sovran-bitcoin, ... }:
|
||||
|
||||
let
|
||||
overlay-stable = final: prev: {
|
||||
@@ -23,29 +22,19 @@
|
||||
{
|
||||
nixosConfigurations.nixos = nixpkgs.lib.nixosSystem {
|
||||
modules = [
|
||||
{ nixpkgs.hostPlatform = "x86_64-linux"; }
|
||||
{ nixpkgs.hostPlatform = "x86_64-linux"; nixpkgs.overlays = [ overlay-stable ]; }
|
||||
self.nixosModules.Sovran_SystemsOS
|
||||
/etc/nixos/role-state.nix
|
||||
/etc/nixos/custom.nix
|
||||
./hardware-configuration.nix
|
||||
./role-state.nix
|
||||
./custom.nix
|
||||
];
|
||||
};
|
||||
|
||||
nixosConfigurations.sovran-iso-desktop = nixpkgs.lib.nixosSystem {
|
||||
nixosConfigurations.sovran_systemsos-iso = nixpkgs.lib.nixosSystem {
|
||||
modules = [
|
||||
{ nixpkgs.hostPlatform = "x86_64-linux"; }
|
||||
({ config, pkgs, ... }: { nixpkgs.overlays = [ overlay-stable ]; })
|
||||
./iso/desktop.nix
|
||||
nix-bitcoin.nixosModules.default
|
||||
nixvim.nixosModules.nixvim
|
||||
];
|
||||
};
|
||||
|
||||
nixosConfigurations.sovran-iso-server = nixpkgs.lib.nixosSystem {
|
||||
modules = [
|
||||
{ nixpkgs.hostPlatform = "x86_64-linux"; }
|
||||
({ config, pkgs, ... }: { nixpkgs.overlays = [ overlay-stable ]; })
|
||||
./iso/server.nix
|
||||
nix-bitcoin.nixosModules.default
|
||||
{ nixpkgs.hostPlatform = "x86_64-linux"; nixpkgs.overlays = [ overlay-stable ]; }
|
||||
./iso/common.nix
|
||||
sovran-bitcoin.nixosModules.default
|
||||
nixvim.nixosModules.nixvim
|
||||
];
|
||||
};
|
||||
@@ -56,16 +45,16 @@
|
||||
nixpkgs.overlays = [ overlay-stable ];
|
||||
})
|
||||
./configuration.nix
|
||||
nix-bitcoin.nixosModules.default
|
||||
sovran-bitcoin.nixosModules.default
|
||||
./modules/sovran-bitcoin-integration.nix
|
||||
nixvim.nixosModules.nixvim
|
||||
];
|
||||
config = {
|
||||
environment.systemPackages = with pkgs; [
|
||||
btc-clients.packages.${pkgs.system}.bisq
|
||||
btc-clients.packages.${pkgs.system}.bisq2
|
||||
btc-clients.packages.${pkgs.system}.sparrow
|
||||
btc-clients.packages.${pkgs.stdenv.hostPlatform.system}.bisq
|
||||
btc-clients.packages.${pkgs.stdenv.hostPlatform.system}.bisq2
|
||||
btc-clients.packages.${pkgs.stdenv.hostPlatform.system}.sparrow
|
||||
];
|
||||
sovran_systemsOS.packages.bip110 = bip110.packages.${pkgs.system}.bitcoind-knots-bip-110;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#153126"/>
|
||||
<stop offset="55%" stop-color="#0F241B"/>
|
||||
<stop offset="100%" stop-color="#091C14"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="outerArc" x1="70" y1="40" x2="190" y2="210" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#42F39A"/>
|
||||
<stop offset="45%" stop-color="#28D978"/>
|
||||
<stop offset="100%" stop-color="#1AA45D"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="innerArc" x1="90" y1="60" x2="180" y2="190" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#27C86F"/>
|
||||
<stop offset="100%" stop-color="#157E49"/>
|
||||
</linearGradient>
|
||||
|
||||
<filter id="innerShade" x="-10%" y="-10%" width="120%" height="120%">
|
||||
<feOffset dx="0" dy="2"/>
|
||||
<feGaussianBlur stdDeviation="5" result="blur"/>
|
||||
<feComposite in="blur" in2="SourceAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="
|
||||
0 0 0 0 0
|
||||
0 0 0 0 0
|
||||
0 0 0 0 0
|
||||
0 0 0 .18 0"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<rect width="256" height="256" rx="48" ry="48" fill="url(#bg)"/>
|
||||
<rect x="1.5" y="1.5" width="253" height="253" rx="46.5" ry="46.5"
|
||||
fill="none" stroke="rgba(255,255,255,0.08)"/>
|
||||
<rect x="6" y="6" width="244" height="244" rx="42" ry="42"
|
||||
fill="none" filter="url(#innerShade)"/>
|
||||
|
||||
<path d="M128 32 A96 96 0 1 1 58 196"
|
||||
fill="none"
|
||||
stroke="url(#outerArc)"
|
||||
stroke-width="12"
|
||||
stroke-linecap="round"/>
|
||||
|
||||
<path d="M128 56 A72 72 0 1 1 76 178"
|
||||
fill="none"
|
||||
stroke="url(#innerArc)"
|
||||
stroke-width="10"
|
||||
stroke-linecap="round"/>
|
||||
|
||||
<circle cx="128" cy="128" r="8" fill="#F2FFF7"/>
|
||||
<circle cx="128" cy="128" r="18" fill="none" stroke="#7BFFC0" stroke-opacity="0.14" stroke-width="4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 31 KiB |
@@ -1,12 +0,0 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
let
|
||||
theme = pkgs.callPackage ./plymouth-theme.nix {};
|
||||
in
|
||||
{
|
||||
boot.plymouth.enable = true;
|
||||
boot.plymouth.theme = "sovran";
|
||||
boot.plymouth.themePackages = [ theme ];
|
||||
boot.kernelParams = [ "quiet" "splash" ];
|
||||
boot.initrd.systemd.enable = true;
|
||||
}
|
||||