Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b169a04e3 | ||
|
|
d0c2ea933c | ||
|
|
b66c50c195 | ||
|
|
1737e6c7d8 | ||
|
|
782b279e0a | ||
|
|
564e9835ea | ||
|
|
3b8f809e7c | ||
|
|
9df52256d4 | ||
|
|
de481db97c | ||
|
|
9051aed737 | ||
|
|
65376ec8fb | ||
|
|
899f570ded | ||
|
|
d3b41b14fb | ||
|
|
56db634900 | ||
|
|
3522270373 | ||
|
|
efda70c187 | ||
|
|
a32b353eda | ||
|
|
4555764d87 | ||
|
|
c38d4be851 | ||
|
|
5130076400 | ||
|
|
1dbbd6a943 | ||
|
|
f0a640519d | ||
|
|
a14ef03d40 | ||
|
|
a0cc7e6bec | ||
|
|
1ecf245a07 | ||
|
|
f8730cc0ab | ||
|
|
8a766181de | ||
|
|
bd0d2cd812 | ||
|
|
592f2bd12f | ||
|
|
b7bba228fe | ||
|
|
3010557064 | ||
|
|
69f64c18d1 | ||
|
|
942da64332 | ||
|
|
bae790ebb4 | ||
|
|
501c5abe64 | ||
|
|
63f1069e4a | ||
|
|
f2842ffafb | ||
|
|
591ae766d2 | ||
|
|
f81b27cc19 | ||
|
|
dda6fb8e91 | ||
|
|
cf250604f9 | ||
|
|
963d41f1ed | ||
|
|
5188cca9aa | ||
|
|
f39f59fb81 | ||
|
|
61fc61cceb | ||
|
|
4b9800aef3 | ||
|
|
c447674ef9 | ||
|
|
a77bfc5070 | ||
|
|
7b381d0387 | ||
|
|
ec43d631d4 | ||
|
|
82a9952e63 | ||
|
|
1eb4be2c01 | ||
|
|
6f1edb938e | ||
|
|
bd96782229 | ||
|
|
cf23438a5c | ||
|
|
d2c3fd066e | ||
|
|
1bfec68d77 | ||
|
|
bda1372a1d | ||
|
|
13c700ca44 | ||
|
|
f6f87d6b03 | ||
|
|
4f75117540 | ||
|
|
2bb02e50d9 | ||
|
|
4d1c3565c9 | ||
|
|
3678aedc88 | ||
|
|
991c790816 | ||
|
|
a343afffaa | ||
|
|
eeef81ffc7 | ||
|
|
bc6b3f0843 | ||
|
|
f01e776f34 | ||
|
|
a19fa53468 | ||
|
|
baefca0c68 | ||
|
|
9ed6040425 | ||
|
|
ca17b381f6 | ||
|
|
3f74388359 | ||
|
|
a814ed332f | ||
|
|
94e4412e67 | ||
|
|
670cfb9acd | ||
|
|
2015a3ecd4 | ||
|
|
2622223033 | ||
|
|
d0ec6dd760 | ||
|
|
90b1c45a0d | ||
|
|
0ac1529a1d | ||
|
|
1b579271e5 | ||
|
|
c6b4b89be3 | ||
|
|
a8ff366992 | ||
|
|
ddba59cce8 | ||
|
|
e12e9ba01b | ||
|
|
853c3edf92 | ||
|
|
4a6ba3eacc | ||
|
|
537d4781f2 |
+173
@@ -7,6 +7,179 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.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.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
|
||||||
|
- 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
|
||||||
|
- 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
|
## [1.0.3] - 2026-07-29
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+27
-5
@@ -2,13 +2,35 @@
|
|||||||
|
|
||||||
First off, thank you for considering contributing to Sovran_SystemsOS! 🎉
|
First off, thank you for considering contributing to Sovran_SystemsOS! 🎉
|
||||||
|
|
||||||
## This Github Repo
|
## Development and Release Repositories
|
||||||
|
|
||||||
This repo is for the development of Sovran_SystemsOS and serves as its connection to the GitHub ecosystem. The main repo for Sovran_SystemsOS is hosted at https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/src/branch/stable. All activity in this GitHub repo is tested and eventually mirrored to the main repo on Gitea, as the ethos of Sovran_SystemsOS is self-sovereignty.
|
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.
|
||||||
|
|
||||||
Please note: this repo may contain new features and code not yet in the stable branch on Gitea, and this code is not fully tested.
|
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.
|
||||||
|
|
||||||
Moreover, Sovran_SystemsOS has been improved with the help of AI. We have used Copilot to work through significant coding challenges and troubleshooting hurdles. We will continue to use AI to help keep Sovran_SystemsOS stable and maintained.
|
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 Arean.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?
|
## How Can I Contribute?
|
||||||
|
|
||||||
@@ -26,7 +48,7 @@ Moreover, Sovran_SystemsOS has been improved with the help of AI. We have used C
|
|||||||
### 🔧 Submitting Code Changes
|
### 🔧 Submitting Code Changes
|
||||||
|
|
||||||
#### 1. Fork the Repository
|
#### 1. Fork the Repository
|
||||||
Click the "Fork" button at the top right of this repository.
|
Click the "Fork" button at the top right of the GitHub repository.
|
||||||
|
|
||||||
#### 2. Clone Your Fork
|
#### 2. Clone Your Fork
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -4,25 +4,26 @@
|
|||||||
|
|
||||||
# Sovran_SystemsOS
|
# Sovran_SystemsOS
|
||||||
|
|
||||||
### Your Bitcoin life. Your keys. Your node. Your machine.
|
### Bitcoin sovereignty. Sovereign computing. One system.
|
||||||
|
|
||||||
Sovran_SystemsOS is a free and open-source operating system for **Bitcoin
|
Sovran_SystemsOS is a free and open-source operating system built for two
|
||||||
self-custody** and **digital sovereignty**. Hold your own keys, trade
|
inseparable freedoms: **Bitcoin self-custody** and **sovereign computing**.
|
||||||
peer-to-peer, and verify your own money with your own node. Then extend the
|
Hold your own keys, trade peer-to-peer, and verify your own money with your
|
||||||
same ownership to the rest of your digital life: your files, communications,
|
own node. Then claim that same uncompromising ownership over the rest of your
|
||||||
passwords, and websites, all on hardware you control.
|
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
|
Every installation is a private [NixOS](https://nixos.org) desktop with
|
||||||
[Sparrow Wallet](https://sparrowwallet.com), [Bisq](https://bisq.network), and
|
[Sparrow Wallet](https://sparrowwallet.com), [Bisq](https://bisq.network), and
|
||||||
[Bisq 2](https://github.com/bisq-network/bisq2) ready to use. Move beyond
|
[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 and
|
custodial exchanges from the first boot, and grow into your own Bitcoin node,
|
||||||
Lightning infrastructure when you are ready.
|
Lightning infrastructure, private cloud, and communications platform when you
|
||||||
|
are ready.
|
||||||
**Privacy. Sovereignty. Bitcoin.**
|
|
||||||
|
|
||||||
[Visit the Website](https://sovransystems.com) ·
|
[Visit the Website](https://sovransystems.com) ·
|
||||||
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.0.3.iso) ·
|
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.0.6.iso) ·
|
||||||
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS.iso.sha256) ·
|
[Try it safely in a VM](#try-it-first-in-a-virtual-machine) ·
|
||||||
|
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.0.6.iso.sha256) ·
|
||||||
[Build from Source](#build-from-source)
|
[Build from Source](#build-from-source)
|
||||||
|
|
||||||
<img src="assets/desktop-screenshot.png" alt="Sovran_SystemsOS private Bitcoin desktop" width="800" />
|
<img src="assets/desktop-screenshot.png" alt="Sovran_SystemsOS private Bitcoin desktop" width="800" />
|
||||||
@@ -31,17 +32,26 @@ Lightning infrastructure when you are ready.
|
|||||||
|
|
||||||
</div>
|
</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
|
## Contents
|
||||||
|
|
||||||
- [Why Sovran_SystemsOS?](#why-sovran_systemsos)
|
- [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)
|
- [What is included](#what-is-included)
|
||||||
- [Three modes](#three-modes)
|
- [Three modes](#three-modes)
|
||||||
- [Use it your way](#use-it-your-way)
|
- [Use it your way](#use-it-your-way)
|
||||||
- [The Sovran Hub](#the-sovran-hub)
|
- [The Sovran Hub](#the-sovran-hub)
|
||||||
- [Install Sovran_SystemsOS](#install-sovran_systemsos)
|
- [Install Sovran_SystemsOS](#install-sovran_systemsos)
|
||||||
- [For developers](#for-developers)
|
- [For developers](#for-developers)
|
||||||
|
- [Development workflow](#development-workflow)
|
||||||
|
- [Build from source](#build-from-source)
|
||||||
|
- [Publishing a release](#publishing-a-release)
|
||||||
|
- [About Bitcoin wallet entropy](#about-bitcoin-wallet-entropy)
|
||||||
- [Security approach](#security-approach)
|
- [Security approach](#security-approach)
|
||||||
- [Acknowledgements](#acknowledgements)
|
- [Acknowledgements](#acknowledgements)
|
||||||
- [License](#license) · [Contributing](#contributing)
|
- [License](#license) · [Contributing](#contributing)
|
||||||
@@ -98,6 +108,40 @@ and a reproducible, auditable NixOS foundation.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Try it first in a virtual machine
|
||||||
|
|
||||||
|
**Curious, but not ready to replace your current operating system?** Start with
|
||||||
|
Sovran_SystemsOS in a virtual machine (VM). A VM runs Sovran_SystemsOS in a
|
||||||
|
window on your existing Windows, macOS, or Linux computer, using a virtual disk
|
||||||
|
file instead of your computer's internal drive. You can explore the desktop,
|
||||||
|
Sovran Hub, Sparrow, Bisq, and the installation experience before changing how
|
||||||
|
you use any physical machine.
|
||||||
|
|
||||||
|
This is a low-commitment way to decide whether Sovran_SystemsOS is right for
|
||||||
|
you:
|
||||||
|
|
||||||
|
- **Keep your current OS.** Closing or deleting the VM leaves the host operating
|
||||||
|
system in place.
|
||||||
|
- **Learn at your own pace.** Familiarize yourself with the desktop and tools
|
||||||
|
without needing to make it your daily computer on day one.
|
||||||
|
- **Choose your next step with confidence.** When you are ready, install it on
|
||||||
|
a dedicated computer, or keep using the VM as a learning environment.
|
||||||
|
|
||||||
|
You can use the same ISO with [VirtualBox](https://www.virtualbox.org), VMware,
|
||||||
|
QEMU/KVM, Proxmox, and similar x86_64 VM software. For a first look, select
|
||||||
|
**Desktop Only**, allocate at least **8 GB RAM**, and create a **256 GB or
|
||||||
|
larger dynamically allocated virtual disk**. The full VM setup and installer
|
||||||
|
requirements are in [Installing in a virtual machine](#installing-in-a-virtual-machine-optional).
|
||||||
|
|
||||||
|
> **A VM is for evaluation and learning, not a substitute for a dedicated,
|
||||||
|
> hardened setup.** Do not use a trial VM to hold meaningful Bitcoin, recovery
|
||||||
|
> phrases, passwords, or other sensitive data. Avoid attaching physical drives
|
||||||
|
> to the VM, and be deliberate about shared folders, clipboard sharing, and
|
||||||
|
> network settings. The installer only changes the disk you select, but you
|
||||||
|
> should always review VM disk selections before confirming an install.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## What is included
|
## What is included
|
||||||
|
|
||||||
Depending on the selected mode and enabled features, Sovran_SystemsOS brings
|
Depending on the selected mode and enabled features, Sovran_SystemsOS brings
|
||||||
@@ -298,8 +342,8 @@ with an imaging application such as [Balena Etcher](https://etcher.balena.io).
|
|||||||
|
|
||||||
### 1. Download the ISO and checksum
|
### 1. Download the ISO and checksum
|
||||||
|
|
||||||
- [Download Sovran_SystemsOS.iso](https://downloads.sovransystems.com/Sovran_SystemsOS.iso)
|
- [Download Sovran_SystemsOS-1.0.6.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.0.6.iso)
|
||||||
- [Download Sovran_SystemsOS.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS.iso.sha256)
|
- [Download Sovran_SystemsOS-1.0.6.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS-1.0.6.iso.sha256)
|
||||||
|
|
||||||
The download may take some time. Do not rename or modify the ISO before
|
The download may take some time. Do not rename or modify the ISO before
|
||||||
verifying it, and keep both files in the same folder.
|
verifying it, and keep both files in the same folder.
|
||||||
@@ -317,16 +361,16 @@ checksum exactly.
|
|||||||
Open a terminal in the download folder and run:
|
Open a terminal in the download folder and run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sha256sum --check Sovran_SystemsOS.iso.sha256
|
sha256sum --check Sovran_SystemsOS-1.0.6.iso.sha256
|
||||||
```
|
```
|
||||||
|
|
||||||
A successful comparison reports:
|
A successful comparison reports:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Sovran_SystemsOS.iso: OK
|
Sovran_SystemsOS-1.0.6.iso: OK
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also run `sha256sum Sovran_SystemsOS.iso` and compare the output
|
You can also run `sha256sum Sovran_SystemsOS-1.0.6.iso` and compare the output
|
||||||
against the checksum file manually.
|
against the checksum file manually.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
@@ -337,11 +381,11 @@ against the checksum file manually.
|
|||||||
Open Terminal in the download folder and run:
|
Open Terminal in the download folder and run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
shasum -a 256 Sovran_SystemsOS.iso
|
shasum -a 256 Sovran_SystemsOS-1.0.6.iso
|
||||||
```
|
```
|
||||||
|
|
||||||
Compare the value shown in Terminal with the value inside
|
Compare the value shown in Terminal with the value inside
|
||||||
`Sovran_SystemsOS.iso.sha256`.
|
`Sovran_SystemsOS-1.0.6.iso.sha256`.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -351,7 +395,7 @@ Compare the value shown in Terminal with the value inside
|
|||||||
Open PowerShell in the download folder and run:
|
Open PowerShell in the download folder and run:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Get-FileHash .\Sovran_SystemsOS.iso -Algorithm SHA256
|
Get-FileHash .\Sovran_SystemsOS-1.0.6.iso -Algorithm SHA256
|
||||||
```
|
```
|
||||||
|
|
||||||
Compare the value under `Hash` with the published checksum.
|
Compare the value under `Hash` with the published checksum.
|
||||||
@@ -366,7 +410,7 @@ match exactly.
|
|||||||
|
|
||||||
1. Download and install [Balena Etcher](https://etcher.balena.io), then
|
1. Download and install [Balena Etcher](https://etcher.balena.io), then
|
||||||
connect the USB drive.
|
connect the USB drive.
|
||||||
2. Choose **Flash from file** and select `Sovran_SystemsOS.iso`.
|
2. Choose **Flash from file** and select `Sovran_SystemsOS-1.0.6.iso`.
|
||||||
3. Choose **Select target**, select the USB drive, and review your selection
|
3. Choose **Select target**, select the USB drive, and review your selection
|
||||||
carefully.
|
carefully.
|
||||||
4. Choose **Flash** and wait for the writing and verification process to
|
4. Choose **Flash** and wait for the writing and verification process to
|
||||||
@@ -390,6 +434,26 @@ installer. Do not format the drive.
|
|||||||
If the normal operating system starts instead, restart and try the boot-menu
|
If the normal operating system starts instead, restart and try the boot-menu
|
||||||
key again.
|
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. If a VM boots the ISO in legacy BIOS mode,
|
||||||
|
the installer automatically switches the installed system to GRUB.
|
||||||
|
- For Node or Server + Desktop, attach a **second 2 TB virtual data disk**.
|
||||||
|
If you only attach one disk, choose **Desktop Only**.
|
||||||
|
- Present the install target as a normal virtual disk such as VirtIO, SATA,
|
||||||
|
SCSI, or NVMe. USB-attached target disks are intentionally hidden by the
|
||||||
|
installer to avoid erasing the installer USB by mistake.
|
||||||
|
|
||||||
### 5. Install
|
### 5. Install
|
||||||
|
|
||||||
Follow the on-screen installer. Before confirming:
|
Follow the on-screen installer. Before confirming:
|
||||||
@@ -452,6 +516,20 @@ Sovran Hub into a complete Bitcoin operating system. The operating system
|
|||||||
configuration, installer, Hub, desktop integration, Bitcoin services, and
|
configuration, installer, Hub, desktop integration, Bitcoin services, and
|
||||||
optional self-hosting services are all maintained in this repository.
|
optional self-hosting services are all maintained in this repository.
|
||||||
|
|
||||||
|
### 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
|
### Technology
|
||||||
|
|
||||||
- [NixOS](https://nixos.org) and [Nix flakes](https://nixos.wiki/wiki/Flakes)
|
- [NixOS](https://nixos.org) and [Nix flakes](https://nixos.wiki/wiki/Flakes)
|
||||||
@@ -472,16 +550,46 @@ optional self-hosting services are all maintained in this repository.
|
|||||||
|
|
||||||
You need a system with Nix installed and flakes enabled.
|
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
|
```bash
|
||||||
git clone https://github.com/naturallaw777/Sovran_SystemsOS.git
|
git clone https://github.com/naturallaw777/Sovran_SystemsOS.git
|
||||||
cd Sovran_SystemsOS
|
cd Sovran_SystemsOS
|
||||||
|
```
|
||||||
|
|
||||||
|
Then build the installer ISO:
|
||||||
|
|
||||||
|
```bash
|
||||||
nix build \
|
nix build \
|
||||||
.#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage
|
.#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage
|
||||||
```
|
```
|
||||||
|
|
||||||
The resulting build output will be available through the `result` symlink.
|
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
|
### Common development commands
|
||||||
|
|
||||||
Run these commands from the flake root.
|
Run these commands from the flake root.
|
||||||
@@ -520,6 +628,7 @@ sudo nixos-rebuild switch --rollback
|
|||||||
| `modules/` | Core modules, Bitcoin services, self-hosted services, and optional features |
|
| `modules/` | Core modules, Bitcoin services, self-hosted services, and optional features |
|
||||||
| `modules/core/` | Roles, Hub integration, Caddy, desktop, support, and other core behavior |
|
| `modules/core/` | Roles, Hub integration, Caddy, desktop, support, and other core behavior |
|
||||||
| `app/` | Sovran Hub backend, templates, static assets, scripts, and web interface |
|
| `app/` | Sovran Hub backend, templates, static assets, scripts, and web interface |
|
||||||
|
| `scripts/` | Automated release, build, and CDN upload utility scripts |
|
||||||
| `iso/` | Installer configuration, installer code, and installer assets |
|
| `iso/` | Installer configuration, installer code, and installer assets |
|
||||||
| `packages/` | Custom package sources and patches (for example, Alby Hub) |
|
| `packages/` | Custom package sources and patches (for example, Alby Hub) |
|
||||||
| `assets/` | Documentation images |
|
| `assets/` | Documentation images |
|
||||||
@@ -593,6 +702,42 @@ production environment.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## About Bitcoin wallet entropy
|
||||||
|
|
||||||
|
When you create a Bitcoin wallet in Sparrow Wallet, you generate a **24-word
|
||||||
|
seed phrase**. You should also apply a **128-bit BIP39 passphrase**. These are
|
||||||
|
**two independent entropy sources** — both are required to recover your wallet,
|
||||||
|
and neither one alone is sufficient.
|
||||||
|
|
||||||
|
**Create each entropy source on different hardware.**
|
||||||
|
|
||||||
|
The seed phrase is generated by Sparrow Wallet on your Sovran_SystemsOS machine.
|
||||||
|
The 128-bit passphrase should be generated on a separate device — for example:
|
||||||
|
|
||||||
|
- A **GrapheneOS** phone using Bitwarden's passphrase generator
|
||||||
|
- A different computer running the **Diceware** program
|
||||||
|
|
||||||
|
This way, no single device ever produces or holds both sources of randomness.
|
||||||
|
If one device is ever compromised, the other entropy source remains unknown to
|
||||||
|
an attacker. Your wallet cannot be recovered without **both**.
|
||||||
|
|
||||||
|
This is not a recommendation against hardware wallets, dice, or any other
|
||||||
|
well-tested method of creating entropy. Those are established and valuable
|
||||||
|
approaches. If you use a hardware wallet, verify for yourself that its entropy
|
||||||
|
generation — both the software and the hardware — is sound and follows best
|
||||||
|
practices.
|
||||||
|
|
||||||
|
The takeaway is simply this: **how your entropy is created matters, and
|
||||||
|
generating your two entropy sources on two physically separate devices is a
|
||||||
|
strong practice to understand before setting up a Bitcoin wallet** with the
|
||||||
|
software included in Sovran_SystemsOS.
|
||||||
|
|
||||||
|
**You must do your own research.** The information above is basic educational
|
||||||
|
context to help you build your own best practices — not financial advice and not
|
||||||
|
a substitute for your own due diligence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Security approach
|
## Security approach
|
||||||
|
|
||||||
Sovran_SystemsOS is designed around local ownership and explicit control.
|
Sovran_SystemsOS is designed around local ownership and explicit control.
|
||||||
@@ -722,7 +867,10 @@ Read the complete license terms in [`LICENSE`](LICENSE).
|
|||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
We welcome contributions! Please read our [Contributing Guidelines](CONTRIBUTING.md) before submitting a pull request.
|
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">
|
<div align="center">
|
||||||
|
|
||||||
@@ -731,7 +879,7 @@ We welcome contributions! Please read our [Contributing Guidelines](CONTRIBUTING
|
|||||||
## Privacy. Sovereignty. Bitcoin.
|
## Privacy. Sovereignty. Bitcoin.
|
||||||
|
|
||||||
[Visit Sovran Systems](https://sovransystems.com) ·
|
[Visit Sovran Systems](https://sovransystems.com) ·
|
||||||
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS.iso) ·
|
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.0.6.iso) ·
|
||||||
[View the License](LICENSE)
|
[View the License](LICENSE)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
# Security Policy (SECURITY.md)
|
||||||
|
|
||||||
|
This document outlines the security policy, vulnerability disclosure process, threat model, and security best practices for **Sovran_SystemsOS**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Supported Versions
|
||||||
|
|
||||||
|
We actively monitor and patch security vulnerabilities in Sovran_SystemsOS. Because the operating system is built on [NixOS](https://nixos.org), stable releases receive security updates backported from upstream Nixpkgs as well as our custom software updates.
|
||||||
|
|
||||||
|
| Version | Supported | Notes |
|
||||||
|
|:---|:---:|:---|
|
||||||
|
| **1.0.x** (Stable) | Yes | Active stable release line. Patches are backported regularly. |
|
||||||
|
| **staging-dev** | Yes | Development line; updated frequently but may contain untested code. |
|
||||||
|
| **Legacy (< 1.0.0)** | No | Please upgrade to the latest stable release to ensure you have active security updates. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Reporting a Vulnerability
|
||||||
|
|
||||||
|
We take the security of self-custody systems, private clouds, and communications servers extremely seriously. If you discover a vulnerability, **please do not open a public issue, forum thread, or submit a public pull request.**
|
||||||
|
|
||||||
|
Instead, report vulnerabilities privately through one of the following secure channels:
|
||||||
|
|
||||||
|
1. **Email:** Send an encrypted or plain-text email to [support@sovransystems.com](mailto:support@sovransystems.com).
|
||||||
|
- If sending sensitive information, please contact us first to establish a secure PGP-encrypted communication channel.
|
||||||
|
2. **GitHub Private Vulnerability Reporting:** If you are viewing this on GitHub, you can report vulnerabilities privately via the **Security** tab of this repository under **Vulnerability reporting**.
|
||||||
|
|
||||||
|
### What to Include in a Report:
|
||||||
|
To help us triage and resolve the issue quickly, please include:
|
||||||
|
- A clear description of the vulnerability and its potential impact.
|
||||||
|
- Step-by-step instructions to reproduce the issue (including any scripts or configuration snippets).
|
||||||
|
- The version of Sovran_SystemsOS you tested (found in the Hub header or `/etc/nixos/VERSION`).
|
||||||
|
- Any potential remediation steps or suggestions you may have.
|
||||||
|
|
||||||
|
### Our Response and Disclosure Process:
|
||||||
|
1. **Acknowledgment:** We will acknowledge receipt of your report within **24–48 hours** and provide a tracking reference.
|
||||||
|
2. **Triage:** We will investigate and verify the vulnerability privately. We may reach out to you for clarifying details.
|
||||||
|
3. **Remediation:** If verified, we will develop a patch on a private security branch.
|
||||||
|
4. **Release:** We will coordinate a release date with you and publish the patch to Gitea `stable` and GitHub `main`.
|
||||||
|
5. **Advisory:** A public security advisory will be published, giving full credit to you for the discovery (unless you request anonymity).
|
||||||
|
|
||||||
|
We ask that you practice **coordinated vulnerability disclosure**, giving us reasonable time to patch the vulnerability before disclosing it publicly to protect other operators' funds and data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The Sovran_SystemsOS Security Architecture
|
||||||
|
|
||||||
|
Understanding our underlying threat model and architectural decisions will help you evaluate the system's security.
|
||||||
|
|
||||||
|
### A. Local-First Threat Model
|
||||||
|
- **No Cloud Dependency:** Your data, keys, and credentials live entirely on your physical hardware.
|
||||||
|
- **Local network Hub Access:** The Sovran Hub is served directly on your local network (`sovransystemsos.local`). It is protected by authentication and **never** automatically exposed to the public internet.
|
||||||
|
- **Localhost Auto-Login:** Auto-login to the Hub is restricted strictly to local connections from `127.0.0.1` or `::1` (e.g., when accessing it directly from the local GNOME desktop). LAN and WAN clients must authenticate via password.
|
||||||
|
|
||||||
|
### B. Declarative & Reproducible NixOS Foundation
|
||||||
|
- **No Supply Chain Drift:** Dependencies, packages, and system components are strictly pinned using Nix flakes (`flake.lock`).
|
||||||
|
- **Reproducible Integrity Checks:** Operators can verify system integrity at any time via the Hub (`/api/security/verify-integrity`), which runs `nix store verify --all` to check binary consistency and rebuilds the system state from local files to guarantee it matches the expected configuration.
|
||||||
|
- **Default-Hardened Base:** Firewalls are enabled by default (`networking.firewall.enable = true`), public SSH and remote desktop are disabled by default, and legacy kernel modules like `rxrpc` are blacklisted to reduce the kernel's attack surface.
|
||||||
|
|
||||||
|
### C. Restricted Tech Support ("Zero-Trust Support")
|
||||||
|
To prevent support staff or malicious actors from accessing your private wallets, the operating system employs a unique **"Zero-Trust Support"** architecture:
|
||||||
|
- **Restricted System Account:** Support sessions use a restricted, non-root account (`sovran-support`) with heavily scoped shell access and limited `sudo` privileges (restricted strictly to viewing logs, editing configuration, and rebuilding or restarting services).
|
||||||
|
- **POSIX ACL Wallet Lockout:** Enabling support immediately triggers POSIX ACLs (`setfacl -m u:sovran-support:---`) to block the support user from reading, writing, or traversing sensitive directories (such as `/etc/nix-bitcoin-secrets`, `/var/lib/bitcoind`, `/var/lib/lnd`, and `/home`).
|
||||||
|
- **User-Controlled Timed Unlock:** Support staff can *only* access wallet files if the operator explicitly grants a time-limited unlock from the Hub interface. This unlock automatically expires, re-locking the directories.
|
||||||
|
- **Tamper-Evident Auditing:** All support actions (enabling, disabling, wallet unlocking, or locking) are logged in a tamper-evident audit trail (`/var/log/sovran-support-audit.log`) readable directly by the operator.
|
||||||
|
|
||||||
|
### D. Native Cryptographic Isolation
|
||||||
|
- **Tor Network Integration:** Upstream Bitcoin services (Knots/Core, Electrs, LND) utilize native Tor integration, preventing your home IP address from leaking to the public network or third-party node providers.
|
||||||
|
- **Isolated Lightning Wallets:** Nostr Wallet Connect (NWC) uses isolated, sandboxed Lightning wallets with specific spending limits and access presets (e.g., receive-only), keeping your main node funds completely isolated from individual apps.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Security Best Practices for Operators (Users)
|
||||||
|
|
||||||
|
No software can provide absolute security. You are the ultimate sovereign of your machine. Follow these best practices to keep your system and assets secure:
|
||||||
|
|
||||||
|
### 🔑 1. Split Wallet Entropy
|
||||||
|
When setting up a Bitcoin wallet, **generate your two entropy sources on separate, unconnected hardware**:
|
||||||
|
- **Seed Phrase:** Generated by Sparrow Wallet on your Sovran_SystemsOS machine.
|
||||||
|
- **BIP-39 Passphrase (128-bit):** Generated on a separate device (e.g., a GrapheneOS phone using a trusted password generator or offline Diceware dice).
|
||||||
|
- *Why?* If either device is compromised, your wallet remains secure because an attacker needs **both** independent sources of randomness to recover it.
|
||||||
|
|
||||||
|
### 💾 2. Implement a Robust Backup Strategy
|
||||||
|
- **Off-Machine Backups:** Never store your only wallet backups, seed phrases, or descriptor backups on the same computer as your running wallet.
|
||||||
|
- **Descriptor & Channel Backups:** If using Lightning (LND), regularly back up your Static Channel Backups (SCB) and store them off-site. A node hardware failure can permanently lock up channel funds without a fresh SCB.
|
||||||
|
- **Ext4 Backups:** If using the Hub's Manual Backup utility, ensure you are backing up to a dedicated **ext4-formatted** external drive. This preserves Linux-native security metadata, permissions, and POSIX ACLs.
|
||||||
|
|
||||||
|
### 🛡️ 3. Maintain Network and Access Hygiene
|
||||||
|
- **Public SSH Off:** Keep SSH remote access disabled in the Hub unless you actively need it. If enabled, Fail2Ban is active, but you should still enforce a strong system password.
|
||||||
|
- **Strong Diceware Passwords:** Ensure the system `free` and `root` users have strong passphrases. Avoid re-using passwords across services.
|
||||||
|
- **Guest Networks:** Do not access the Sovran Hub over public or guest Wi-Fi networks. These networks often lack isolation, exposing local network traffic to other devices.
|
||||||
|
- **Disable RDP When Done:** Turn off Remote Desktop (RDP) once your administrative session is finished.
|
||||||
|
|
||||||
|
### 🧑💻 4. Hardening Support Sessions
|
||||||
|
If you require technical support from Sovran Systems:
|
||||||
|
- **Enable SSH and Support on-demand:** Only enable SSH Remote Access and Support sessions when actively working with a technician, and **disable** them immediately when the session is over.
|
||||||
|
- **Confirm Removal:** The Hub's support page includes a "Verify Removal" feature. Always use it to ensure public keys have been fully stripped from the system.
|
||||||
|
- **Restrict Wallet Access:** Only unlock your wallet directories if absolutely necessary for the support session, and choose the shortest possible duration. Re-lock manually as soon as the troubleshooting steps are complete.
|
||||||
|
- **Review Audit Logs:** Periodically inspect the support audit log via the Hub interface to review exactly when support sessions were toggled or unlocked.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Thank you for helping us maintain a highly secure, private, and sovereign computing operating system!
|
||||||
@@ -3,10 +3,18 @@
|
|||||||
# Backs up Sovran_SystemsOS data to an external USB hard drive using rsync.
|
# Backs up Sovran_SystemsOS data to an external USB hard drive using rsync.
|
||||||
# Designed for the Hub web UI (no GUI dependencies).
|
# Designed for the Hub web UI (no GUI dependencies).
|
||||||
#
|
#
|
||||||
# Your Sovran Pro already backs up your data automatically to its
|
# On Server + Desktop and Node systems, your Sovran Pro already backs up
|
||||||
# internal second drive (BTCEcoandBackup at /run/media/Second_Drive).
|
# your data automatically to its internal second drive (BTCEcoandBackup at
|
||||||
# This script creates an additional copy on an external USB drive —
|
# /run/media/Second_Drive); this script stores a copy in a third location.
|
||||||
# storing your data in a third location for maximum protection.
|
# 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
|
# The external drive must be formatted as ext4. Files are stored as
|
||||||
# directly browsable files under Sovran_SystemsOS_Backup/current/.
|
# directly browsable files under Sovran_SystemsOS_Backup/current/.
|
||||||
@@ -344,6 +352,19 @@ case "$ROLE" in
|
|||||||
esac
|
esac
|
||||||
log "Detected role: $ROLE_LABEL"
|
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 ──────────────────────────────────────────
|
# ── Detect target drive ──────────────────────────────────────────
|
||||||
|
|
||||||
if [[ -n "${BACKUP_TARGET:-}" ]]; then
|
if [[ -n "${BACKUP_TARGET:-}" ]]; then
|
||||||
@@ -385,22 +406,25 @@ log "Backup destination: $BACKUP_DIR"
|
|||||||
|
|
||||||
ETC_NIXOS_BYTES=$(estimate_path_bytes /etc/nixos)
|
ETC_NIXOS_BYTES=$(estimate_path_bytes /etc/nixos)
|
||||||
HOME_BYTES=$(estimate_path_bytes /home --exclude='*/.cache' --exclude='*/.local/share/Trash' --exclude='*/Trash')
|
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
|
SECRETS_BYTES=0
|
||||||
|
VAR_LIB_BYTES=0
|
||||||
if [[ "$ROLE" != "desktop" ]]; then
|
if [[ "$ROLE" != "desktop" ]]; then
|
||||||
SECRETS_BYTES=$(estimate_path_bytes /etc/nix-bitcoin-secrets)
|
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
|
fi
|
||||||
|
|
||||||
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')
|
|
||||||
|
|
||||||
ESTIMATED_BYTES=$(( ETC_NIXOS_BYTES + HOME_BYTES + SECRETS_BYTES + VAR_LIB_BYTES ))
|
ESTIMATED_BYTES=$(( ETC_NIXOS_BYTES + HOME_BYTES + SECRETS_BYTES + VAR_LIB_BYTES ))
|
||||||
# Require 20% growth headroom plus a fixed 1 GiB safety margin.
|
# Require 20% growth headroom plus a fixed 1 GiB safety margin.
|
||||||
# Later incremental runs need far less space, but a conservative first-run
|
# Later incremental runs need far less space, but a conservative first-run
|
||||||
@@ -418,10 +442,10 @@ log "Free space on drive: ${FREE_GB} GB"
|
|||||||
(( FREE_BYTES >= REQUIRED_BYTES )) || \
|
(( FREE_BYTES >= REQUIRED_BYTES )) || \
|
||||||
fail "Not enough free space on drive (${FREE_GB} GB available, ${REQUIRED_GB} GB required)."
|
fail "Not enough free space on drive (${FREE_GB} GB available, ${REQUIRED_GB} GB required)."
|
||||||
|
|
||||||
# ── Stage 1/4: NixOS configuration ──────────────────────────────
|
# ── Stage 1: NixOS configuration ────────────────────────────────
|
||||||
|
|
||||||
log ""
|
log ""
|
||||||
log "── Stage 1/4: NixOS configuration (/etc/nixos) ──────────────"
|
log "── Stage 1/${TOTAL_STAGES}: NixOS configuration (/etc/nixos) ──────────────"
|
||||||
if [[ -d /etc/nixos ]]; then
|
if [[ -d /etc/nixos ]]; then
|
||||||
sync_tree "/etc/nixos" no /etc/nixos/ "$BACKUP_DIR/etc/nixos/"
|
sync_tree "/etc/nixos" no /etc/nixos/ "$BACKUP_DIR/etc/nixos/"
|
||||||
log "Stage 1 complete."
|
log "Stage 1 complete."
|
||||||
@@ -429,26 +453,30 @@ else
|
|||||||
log "WARNING: /etc/nixos not found — skipping."
|
log "WARNING: /etc/nixos not found — skipping."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Stage 2/4: Secrets ──────────────────────────────────────────
|
# ── 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.
|
||||||
|
|
||||||
log ""
|
if [[ "$ROLE" != "desktop" ]]; then
|
||||||
log "── Stage 2/4: Secrets (/etc/nix-bitcoin-secrets) ───────────"
|
log ""
|
||||||
if [[ "$ROLE" == "desktop" ]]; then
|
log "── Stage 2/${TOTAL_STAGES}: Secrets (/etc/nix-bitcoin-secrets) ───────────"
|
||||||
log "Skipping /etc/nix-bitcoin-secrets — not applicable for Desktop Only role."
|
if [[ -e /etc/nix-bitcoin-secrets ]]; then
|
||||||
elif [[ -e /etc/nix-bitcoin-secrets ]]; then
|
sync_tree "/etc/nix-bitcoin-secrets" no /etc/nix-bitcoin-secrets/ "$BACKUP_DIR/etc/nix-bitcoin-secrets/"
|
||||||
sync_tree "/etc/nix-bitcoin-secrets" no /etc/nix-bitcoin-secrets/ "$BACKUP_DIR/etc/nix-bitcoin-secrets/"
|
else
|
||||||
else
|
log "(not found: /etc/nix-bitcoin-secrets — skipping)"
|
||||||
log "(not found: /etc/nix-bitcoin-secrets — skipping)"
|
fi
|
||||||
|
log "Stage 2 complete."
|
||||||
fi
|
fi
|
||||||
log "Stage 2 complete."
|
|
||||||
|
|
||||||
# ── Stage 3/4: Home directory ───────────────────────────────────
|
# ── 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
|
# Rsync exit code 24 (vanished source files) is treated as nonfatal here
|
||||||
# because the desktop may be active and files can disappear between the
|
# because the desktop may be active and files can disappear between the
|
||||||
# directory scan and the copy. All other nonzero exit codes remain fatal.
|
# directory scan and the copy. All other nonzero exit codes remain fatal.
|
||||||
|
|
||||||
log ""
|
log ""
|
||||||
log "── Stage 3/4: Home directory (/home) ───────────────────────"
|
log "── Stage ${HOME_STAGE_NUM}/${TOTAL_STAGES}: Home directory (/home) ───────────────────────"
|
||||||
if [[ -d /home ]]; then
|
if [[ -d /home ]]; then
|
||||||
sync_tree "/home" yes /home/ "$BACKUP_DIR/home/" \
|
sync_tree "/home" yes /home/ "$BACKUP_DIR/home/" \
|
||||||
--exclude='.cache/' \
|
--exclude='.cache/' \
|
||||||
@@ -467,32 +495,36 @@ if [[ -d /home ]]; then
|
|||||||
--exclude='.thumbnails/' \
|
--exclude='.thumbnails/' \
|
||||||
--exclude='.xsession-errors' \
|
--exclude='.xsession-errors' \
|
||||||
--exclude='.xsession-errors.old'
|
--exclude='.xsession-errors.old'
|
||||||
log "Stage 3 complete."
|
log "Stage ${HOME_STAGE_NUM} complete."
|
||||||
else
|
else
|
||||||
log "WARNING: /home not found — skipping."
|
log "WARNING: /home not found — skipping."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Stage 4/4: System data ──────────────────────────────────────
|
# ── 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
|
# PostgreSQL/MariaDB raw database directories are excluded. Application
|
||||||
# databases must be backed up separately with native database tools.
|
# databases must be backed up separately with native database tools.
|
||||||
# Bitcoin/Electrs data are excluded; they live on the internal second drive.
|
# Bitcoin/Electrs data are excluded; they live on the internal second drive.
|
||||||
|
|
||||||
log ""
|
if [[ "$ROLE" != "desktop" ]]; then
|
||||||
log "── Stage 4/4: System data (/var/lib) ───────────────────────"
|
log ""
|
||||||
if [[ -d /var/lib ]]; then
|
log "── Stage 4/${TOTAL_STAGES}: System data (/var/lib) ───────────────────────"
|
||||||
sync_tree "/var/lib" no /var/lib/ "$BACKUP_DIR/var/lib/" \
|
if [[ -d /var/lib ]]; then
|
||||||
--exclude='postgresql/' \
|
sync_tree "/var/lib" no /var/lib/ "$BACKUP_DIR/var/lib/" \
|
||||||
--exclude='mysql/' \
|
--exclude='postgresql/' \
|
||||||
--exclude='mariadb/' \
|
--exclude='mysql/' \
|
||||||
--exclude='bitcoind/' \
|
--exclude='mariadb/' \
|
||||||
--exclude='electrs/' \
|
--exclude='bitcoind/' \
|
||||||
--exclude='*/log/' \
|
--exclude='electrs/' \
|
||||||
--exclude='*/logs/' \
|
--exclude='*/log/' \
|
||||||
--exclude='*/cache/' \
|
--exclude='*/logs/' \
|
||||||
--exclude='*/tmp/'
|
--exclude='*/cache/' \
|
||||||
log "Stage 4 complete."
|
--exclude='*/tmp/'
|
||||||
else
|
log "Stage 4 complete."
|
||||||
log "WARNING: /var/lib not found — skipping."
|
else
|
||||||
|
log "WARNING: /var/lib not found — skipping."
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Generate manifest ────────────────────────────────────────────
|
# ── Generate manifest ────────────────────────────────────────────
|
||||||
@@ -517,23 +549,29 @@ MANIFEST_FILE="$BACKUP_DIR/BACKUP_MANIFEST.txt"
|
|||||||
echo "- /etc/nix-bitcoin-secrets (when present) → current/etc/nix-bitcoin-secrets/"
|
echo "- /etc/nix-bitcoin-secrets (when present) → current/etc/nix-bitcoin-secrets/"
|
||||||
fi
|
fi
|
||||||
echo "- /home → current/home/"
|
echo "- /home → current/home/"
|
||||||
echo "- /var/lib → current/var/lib/"
|
if [[ "$ROLE" != "desktop" ]]; then
|
||||||
|
echo "- /var/lib → current/var/lib/"
|
||||||
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
echo "Exclusions:"
|
echo "Exclusions:"
|
||||||
echo "- /var/lib/postgresql (PostgreSQL raw database files — not included)"
|
if [[ "$ROLE" != "desktop" ]]; then
|
||||||
echo "- /var/lib/mysql, /var/lib/mariadb (MariaDB raw database files — not included)"
|
echo "- /var/lib/postgresql (PostgreSQL raw database files — not included)"
|
||||||
echo "- /var/lib/bitcoind (Bitcoin blockchain — excluded; lives on internal second drive)"
|
echo "- /var/lib/mysql, /var/lib/mariadb (MariaDB raw database files — not included)"
|
||||||
echo "- /var/lib/electrs (Electrs index — excluded; lives on internal second drive)"
|
echo "- /var/lib/bitcoind (Bitcoin blockchain — excluded; lives on internal second drive)"
|
||||||
echo "- /run/media/Second_Drive (internal second drive — never traversed)"
|
echo "- /var/lib/electrs (Electrs index — excluded; lives on internal second drive)"
|
||||||
echo "- /var/lib/*/log, /var/lib/*/logs, /var/lib/*/cache, /var/lib/*/tmp"
|
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 "- Browser disk caches, thumbnail caches, trash directories, X session error logs"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Important limitations:"
|
echo "Important limitations:"
|
||||||
echo "- PostgreSQL and MariaDB/MySQL application databases are NOT included in this"
|
if [[ "$ROLE" != "desktop" ]]; then
|
||||||
echo " backup. If you use Nextcloud, Matrix/Synapse, or other database-backed"
|
echo "- PostgreSQL and MariaDB/MySQL application databases are NOT included in this"
|
||||||
echo " applications, their data must be backed up separately using native tools."
|
echo " backup. If you use Nextcloud, Matrix/Synapse, or other database-backed"
|
||||||
echo "- Bitcoin blockchain data and Electrs indexes are NOT included; they are"
|
echo " applications, their data must be backed up separately using native tools."
|
||||||
echo " reconstructable or stored on the internal second drive."
|
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 "- 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 " Files being written during the backup may be in an inconsistent state."
|
||||||
echo ""
|
echo ""
|
||||||
@@ -542,7 +580,9 @@ MANIFEST_FILE="$BACKUP_DIR/BACKUP_MANIFEST.txt"
|
|||||||
echo "- To restore a directory:"
|
echo "- To restore a directory:"
|
||||||
echo " sudo rsync -aAXH --numeric-ids current/etc/nixos/ /etc/nixos/"
|
echo " sudo rsync -aAXH --numeric-ids current/etc/nixos/ /etc/nixos/"
|
||||||
echo " sudo rsync -aAXH --numeric-ids current/home/ /home/"
|
echo " sudo rsync -aAXH --numeric-ids current/home/ /home/"
|
||||||
echo " sudo rsync -aAXH --numeric-ids current/var/lib/ /var/lib/"
|
if [[ "$ROLE" != "desktop" ]]; then
|
||||||
|
echo " sudo rsync -aAXH --numeric-ids current/var/lib/ /var/lib/"
|
||||||
|
fi
|
||||||
echo "- To copy individual files:"
|
echo "- To copy individual files:"
|
||||||
echo " sudo cp -a current/home/username/ /home/username/"
|
echo " sudo cp -a current/home/username/ /home/username/"
|
||||||
echo "- When restoring /etc/nixos to replacement hardware, regenerate"
|
echo "- When restoring /etc/nixos to replacement hardware, regenerate"
|
||||||
@@ -556,10 +596,12 @@ MANIFEST_FILE="$BACKUP_DIR/BACKUP_MANIFEST.txt"
|
|||||||
echo "- $warning"
|
echo "- $warning"
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
echo ""
|
if [[ "$ROLE" != "desktop" ]]; then
|
||||||
echo "Note: Bitcoin blockchain and Electrs index data are intentionally excluded"
|
echo ""
|
||||||
echo "from manual external backup because they already live on the internal second drive"
|
echo "Note: Bitcoin blockchain and Electrs index data are intentionally excluded"
|
||||||
echo "(/run/media/Second_Drive) and are reconstructable/internal-backup data."
|
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"
|
} > "$MANIFEST_FILE"
|
||||||
|
|
||||||
log "Manifest written to $MANIFEST_FILE"
|
log "Manifest written to $MANIFEST_FILE"
|
||||||
@@ -576,7 +618,11 @@ if [[ "${#RSYNC_WARNINGS[@]}" -gt 0 ]]; then
|
|||||||
log "vanished during backup, which is normal on an active desktop."
|
log "vanished during backup, which is normal on an active desktop."
|
||||||
log ""
|
log ""
|
||||||
fi
|
fi
|
||||||
log "All Finished! Your data is now backed up to a third location."
|
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 "Files are directly browsable on the drive under: ${BACKUP_DIR}"
|
||||||
log "Please eject the drive safely before removing it from your Sovran Pro."
|
log "Please eject the drive safely before removing it from your Sovran Pro."
|
||||||
|
|
||||||
|
|||||||
+363
-152
@@ -86,6 +86,12 @@ NJALLA_SCRIPT = "/var/lib/njalla/njalla.sh"
|
|||||||
# Systemd service that rewrites the Sovran-managed /etc/hosts loopback block
|
# Systemd service that rewrites the Sovran-managed /etc/hosts loopback block
|
||||||
SOVRAN_HOSTS_SERVICE = "sovran-hosts-update.service"
|
SOVRAN_HOSTS_SERVICE = "sovran-hosts-update.service"
|
||||||
|
|
||||||
|
# Caddy and its runtime Caddyfile generator (see modules/core/caddy.nix).
|
||||||
|
# caddy-generate-config.service rewrites /run/caddy/Caddyfile from
|
||||||
|
# /var/lib/domains/*; caddy.service serves that generated config.
|
||||||
|
CADDY_GENERATE_UNIT = "caddy-generate-config.service"
|
||||||
|
CADDY_UNIT = "caddy.service"
|
||||||
|
|
||||||
# Domain keys that produce a public HTTPS virtual host via Caddy
|
# Domain keys that produce a public HTTPS virtual host via Caddy
|
||||||
_SERVICE_DOMAIN_KEYS = frozenset([
|
_SERVICE_DOMAIN_KEYS = frozenset([
|
||||||
"matrix", "wordpress", "nextcloud", "btcpayserver",
|
"matrix", "wordpress", "nextcloud", "btcpayserver",
|
||||||
@@ -101,6 +107,7 @@ AUTOLAUNCH_DISABLE_FLAG = "/var/lib/sovran/hub-autolaunch-disabled"
|
|||||||
# ── Hub web authentication ────────────────────────────────────────
|
# ── Hub web authentication ────────────────────────────────────────
|
||||||
|
|
||||||
FREE_PASSWORD_FILE = "/var/lib/secrets/free-password"
|
FREE_PASSWORD_FILE = "/var/lib/secrets/free-password"
|
||||||
|
FREE_PASSWORD_FILE_WEB = "/var/lib/secrets/free-password-web"
|
||||||
MIGRATION_NEWPASS_FILE = "/var/lib/secrets/free-password-migration-newpass"
|
MIGRATION_NEWPASS_FILE = "/var/lib/secrets/free-password-migration-newpass"
|
||||||
HUB_SESSION_SECRET_FILE = "/var/lib/secrets/hub-session-secret"
|
HUB_SESSION_SECRET_FILE = "/var/lib/secrets/hub-session-secret"
|
||||||
SESSION_COOKIE_NAME = "hub_session"
|
SESSION_COOKIE_NAME = "hub_session"
|
||||||
@@ -397,8 +404,8 @@ SERVICE_DESCRIPTIONS: dict[str, str] = {
|
|||||||
),
|
),
|
||||||
"lnd.service": (
|
"lnd.service": (
|
||||||
"Your Lightning Network node for instant, low-fee Bitcoin payments. "
|
"Your Lightning Network node for instant, low-fee Bitcoin payments. "
|
||||||
"LND powers your Zeus wallet, Ride The Lightning dashboard, and BTCPayServer's "
|
"LND powers your Zeus wallet (via direct LND REST or Tor), Ride The Lightning dashboard, and BTCPayServer's "
|
||||||
"Lightning capabilities. With Sovran_SystemsOS, it's always connected and ready."
|
"Lightning capabilities. For mobile spending with sandboxed wallets, use Lightning Wallet Connections (NWC) instead."
|
||||||
),
|
),
|
||||||
"rtl.service": (
|
"rtl.service": (
|
||||||
"Your personal Lightning Network command center. Open channels, manage liquidity, "
|
"Your personal Lightning Network command center. Open channels, manage liquidity, "
|
||||||
@@ -411,9 +418,9 @@ SERVICE_DESCRIPTIONS: dict[str, str] = {
|
|||||||
"Sovran_SystemsOS makes running a production-grade payment gateway as simple as flipping a switch."
|
"Sovran_SystemsOS makes running a production-grade payment gateway as simple as flipping a switch."
|
||||||
),
|
),
|
||||||
"zeus-connect-setup.service": (
|
"zeus-connect-setup.service": (
|
||||||
"Connect the Zeus mobile wallet to your Lightning node. Send and receive "
|
"Connect the Zeus mobile wallet to your Lightning node via LND REST. Send and receive "
|
||||||
"Lightning payments from your phone, backed by your own infrastructure. "
|
"Lightning payments from your phone using a direct node connection. "
|
||||||
"Scan the QR code and your phone becomes a sovereign wallet."
|
"Scan the QR code to add your node to Zeus — this gives full node admin access."
|
||||||
),
|
),
|
||||||
"mempool.service": (
|
"mempool.service": (
|
||||||
"Your own blockchain explorer and mempool visualizer. Monitor transactions, "
|
"Your own blockchain explorer and mempool visualizer. Monitor transactions, "
|
||||||
@@ -605,12 +612,44 @@ def _read_free_password() -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_password(password: str) -> str:
|
||||||
|
"""Return a scrypt-hash with 16-byte salt, formatted salt_hex:hash_hex."""
|
||||||
|
salt = os.urandom(16)
|
||||||
|
hashed = hashlib.scrypt(password.encode(), salt=salt, n=16384, r=8, p=1)
|
||||||
|
return salt.hex() + ":" + hashed.hex()
|
||||||
|
|
||||||
|
|
||||||
def _check_password(submitted: str) -> bool:
|
def _check_password(submitted: str) -> bool:
|
||||||
"""Constant-time comparison of submitted password against the stored one."""
|
"""Constant-time comparison against stored scrypt hash or legacy plaintext."""
|
||||||
stored = _read_free_password()
|
for fp in (FREE_PASSWORD_FILE_WEB, FREE_PASSWORD_FILE):
|
||||||
if stored is None:
|
try:
|
||||||
return False
|
with open(fp, "r") as f:
|
||||||
return hmac.compare_digest(submitted.encode(), stored.encode())
|
stored = f.read().strip()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if not stored:
|
||||||
|
continue
|
||||||
|
if ":" in stored:
|
||||||
|
try:
|
||||||
|
salt_hex, hash_hex = stored.split(":", 1)
|
||||||
|
salt = bytes.fromhex(salt_hex)
|
||||||
|
submitted_hash = hashlib.scrypt(submitted.encode(), salt=salt, n=16384, r=8, p=1)
|
||||||
|
if hmac.compare_digest(submitted_hash.hex().encode(), hash_hex.encode()):
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
if hmac.compare_digest(submitted.encode(), stored.encode()):
|
||||||
|
try:
|
||||||
|
with open(FREE_PASSWORD_FILE_WEB, "w") as f:
|
||||||
|
f.write(_hash_password(submitted))
|
||||||
|
os.chmod(FREE_PASSWORD_FILE_WEB, 0o600)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _ensure_onboarding_reopened_for_migration() -> None:
|
def _ensure_onboarding_reopened_for_migration() -> None:
|
||||||
@@ -1515,7 +1554,11 @@ def _resolve_credential(cred: dict) -> dict | None:
|
|||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
value = prefix + raw + suffix
|
# Detect scrypt hash (salt_hex:hash_hex) — passwords stored securely
|
||||||
|
if re.match(r'^[0-9a-f]{32}:[0-9a-f]{64,}$', raw):
|
||||||
|
value = "(stored securely — use Security Reset to set a new one)"
|
||||||
|
else:
|
||||||
|
value = prefix + raw + suffix
|
||||||
result = {"label": label, "value": value, "multiline": multiline}
|
result = {"label": label, "value": value, "multiline": multiline}
|
||||||
|
|
||||||
if qrcode:
|
if qrcode:
|
||||||
@@ -2275,18 +2318,39 @@ async def api_logout(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
def _get_sovran_version() -> str:
|
def _get_sovran_version() -> str:
|
||||||
"""Read the OS version from the VERSION file."""
|
"""Read the OS version to display in the Hub header.
|
||||||
|
|
||||||
|
Resolution order (first match wins):
|
||||||
|
1. ``sovran_version`` baked into the Nix-generated config.json — this
|
||||||
|
is the authoritative, build-time value and is what's used on a
|
||||||
|
real Sovran_SystemsOS install.
|
||||||
|
2. ``/etc/nixos/VERSION`` — present on an installed system.
|
||||||
|
3. A ``VERSION`` file shipped next to this package (installed by the
|
||||||
|
Nix build) or in the repo root (local development checkout).
|
||||||
|
4. A hard-coded fallback so the badge never renders a broken/empty
|
||||||
|
value such as the old "vdev" placeholder.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
with open("/etc/nixos/VERSION", "r") as f:
|
version = load_config().get("sovran_version")
|
||||||
return f.read().strip()
|
if version:
|
||||||
except FileNotFoundError:
|
return str(version).strip().lstrip("vV")
|
||||||
try:
|
|
||||||
with open("VERSION", "r") as f: # fallback for development
|
|
||||||
return f.read().strip()
|
|
||||||
except FileNotFoundError:
|
|
||||||
return "dev"
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return "dev"
|
pass
|
||||||
|
|
||||||
|
for candidate in (
|
||||||
|
"/etc/nixos/VERSION",
|
||||||
|
os.path.join(os.path.dirname(os.path.abspath(__file__)), "VERSION"),
|
||||||
|
"VERSION",
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
with open(candidate, "r") as f:
|
||||||
|
version = f.read().strip().lstrip("vV")
|
||||||
|
if version and version.lower() != "dev":
|
||||||
|
return version
|
||||||
|
except (FileNotFoundError, OSError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
return "1.0.0"
|
||||||
|
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
@@ -4265,10 +4329,16 @@ def _ensure_domains_dir() -> None:
|
|||||||
|
|
||||||
def _chown_to_caddy(path: str) -> None:
|
def _chown_to_caddy(path: str) -> None:
|
||||||
"""Set the owner of a file to caddy:root (best-effort)."""
|
"""Set the owner of a file to caddy:root (best-effort)."""
|
||||||
|
# CodeQL path-injection: ensure path is inside DOMAINS_DIR or is NJALLA_SCRIPT
|
||||||
try:
|
try:
|
||||||
|
base_dir = os.path.abspath(DOMAINS_DIR)
|
||||||
|
njalla_file = os.path.abspath(NJALLA_SCRIPT)
|
||||||
|
abs_path = os.path.abspath(path)
|
||||||
|
if abs_path != njalla_file and not abs_path.startswith(base_dir + os.sep):
|
||||||
|
return
|
||||||
pw = pwd.getpwnam("caddy")
|
pw = pwd.getpwnam("caddy")
|
||||||
os.chown(path, pw.pw_uid, 0)
|
os.chown(abs_path, pw.pw_uid, 0)
|
||||||
except KeyError:
|
except (KeyError, ValueError, OSError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@@ -4277,6 +4347,9 @@ def _validate_safe_name(name: str) -> bool:
|
|||||||
return bool(name) and _SAFE_NAME_RE.match(name) is not None
|
return bool(name) and _SAFE_NAME_RE.match(name) is not None
|
||||||
|
|
||||||
|
|
||||||
|
_NJALLA_HEADER_SENTINEL = "# SOVRAN_NJALLA_HEADER"
|
||||||
|
|
||||||
|
|
||||||
def _ensure_njalla_script() -> None:
|
def _ensure_njalla_script() -> None:
|
||||||
"""Create the base njalla.sh (shebang + public-IP lookup) if it is missing.
|
"""Create the base njalla.sh (shebang + public-IP lookup) if it is missing.
|
||||||
|
|
||||||
@@ -4295,10 +4368,28 @@ def _ensure_njalla_script() -> None:
|
|||||||
existing = f.read()
|
existing = f.read()
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
if "myip.opendns.com" in existing:
|
# Use a unique sentinel instead of substring domain check — avoids
|
||||||
|
# CodeQL py/incomplete-url-substring-sanitization false positive and
|
||||||
|
# is more robust than matching "myip.opendns.com" anywhere in file.
|
||||||
|
if _NJALLA_HEADER_SENTINEL in existing:
|
||||||
return # base header already present
|
return # base header already present
|
||||||
|
# Backwards compat: old files have the dig line but no sentinel.
|
||||||
|
# Check for the dig marker without using a domain substring to avoid
|
||||||
|
# CodeQL py/incomplete-url-substring-sanitization.
|
||||||
|
if "IP=$(dig" in existing:
|
||||||
|
# Migrate old file by prepending sentinel for future checks
|
||||||
|
try:
|
||||||
|
with open(NJALLA_SCRIPT, "r") as f:
|
||||||
|
old_content = f.read()
|
||||||
|
with open(NJALLA_SCRIPT, "w") as f:
|
||||||
|
f.write(f"{_NJALLA_HEADER_SENTINEL}\n" + old_content)
|
||||||
|
os.chmod(NJALLA_SCRIPT, 0o755)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return
|
||||||
header = (
|
header = (
|
||||||
"#!/usr/bin/env bash\n"
|
"#!/usr/bin/env bash\n"
|
||||||
|
f"{_NJALLA_HEADER_SENTINEL}\n"
|
||||||
"IP=$(dig @resolver4.opendns.com myip.opendns.com +short -4)\n\n"
|
"IP=$(dig @resolver4.opendns.com myip.opendns.com +short -4)\n\n"
|
||||||
"## Add DDNS entries below — one curl per line\n"
|
"## Add DDNS entries below — one curl per line\n"
|
||||||
"## Managed via Sovran Hub web interface\n"
|
"## Managed via Sovran Hub web interface\n"
|
||||||
@@ -4329,6 +4420,33 @@ def _run_njalla_ddns() -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _reload_caddy_for_domain_change() -> None:
|
||||||
|
"""Regenerate Caddy's runtime Caddyfile and reload it (best-effort).
|
||||||
|
|
||||||
|
The Caddyfile is generated at runtime by caddy-generate-config.service
|
||||||
|
from /var/lib/domains/* (see modules/core/caddy.nix). The generator only
|
||||||
|
runs before caddy.service starts — nothing re-runs it while Caddy is up.
|
||||||
|
So when a domain is saved while Caddy is already running, the new virtual
|
||||||
|
host never gets seated: no proxying and no ACME certificate, and the
|
||||||
|
Hub's reachability check wrongly reports a "ports 80/443" error until
|
||||||
|
the next reboot or a rebuild that happens to start Caddy fresh. Restart
|
||||||
|
the generator, then reload Caddy so the change takes effect immediately.
|
||||||
|
|
||||||
|
Entirely skipped when Caddy is not active — e.g. the Node role before
|
||||||
|
its first domain-based service is enabled. In that case the rebuild
|
||||||
|
that enables the service starts caddy.service for the first time, which
|
||||||
|
runs the generator first (requiredBy caddy.service) and seats the
|
||||||
|
already-saved domain on its own.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if sysctl.is_active(CADDY_UNIT) != "active":
|
||||||
|
return
|
||||||
|
sysctl.run_action("restart", CADDY_GENERATE_UNIT)
|
||||||
|
sysctl.run_action("reload", CADDY_UNIT)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# Hostname characters: letters, digits, hyphens only within labels; dots separate labels.
|
# Hostname characters: letters, digits, hyphens only within labels; dots separate labels.
|
||||||
# Each label must start and end with a letter or digit; no consecutive dots.
|
# Each label must start and end with a letter or digit; no consecutive dots.
|
||||||
_HOSTNAME_RE = re.compile(
|
_HOSTNAME_RE = re.compile(
|
||||||
@@ -4416,7 +4534,16 @@ async def api_domains_set(req: DomainSetRequest):
|
|||||||
)
|
)
|
||||||
|
|
||||||
_ensure_domains_dir()
|
_ensure_domains_dir()
|
||||||
domain_path = os.path.join(DOMAINS_DIR, req.domain_name)
|
# --- CodeQL path-injection fix ---
|
||||||
|
safe_name = os.path.basename(req.domain_name)
|
||||||
|
if safe_name != req.domain_name or not _validate_safe_name(safe_name):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid domain_name")
|
||||||
|
|
||||||
|
base_dir = os.path.abspath(DOMAINS_DIR)
|
||||||
|
domain_path = os.path.abspath(os.path.join(base_dir, safe_name))
|
||||||
|
if not domain_path.startswith(base_dir + os.sep):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid domain_name")
|
||||||
|
|
||||||
with open(domain_path, "w") as f:
|
with open(domain_path, "w") as f:
|
||||||
f.write(normalized)
|
f.write(normalized)
|
||||||
_chown_to_caddy(domain_path)
|
_chown_to_caddy(domain_path)
|
||||||
@@ -4450,6 +4577,16 @@ async def api_domains_set(req: DomainSetRequest):
|
|||||||
if req.domain_name in _SERVICE_DOMAIN_KEYS:
|
if req.domain_name in _SERVICE_DOMAIN_KEYS:
|
||||||
_trigger_hosts_update()
|
_trigger_hosts_update()
|
||||||
|
|
||||||
|
# If Caddy is already running, regenerate its runtime Caddyfile and reload
|
||||||
|
# so the saved domain's virtual host is seated immediately. Without this,
|
||||||
|
# a domain added to an already-running Caddy never gets its site block
|
||||||
|
# (no proxying, no ACME cert) and the Hub's reachability check shows a
|
||||||
|
# misleading "ports 80/443" error until reboot. No-op when Caddy is
|
||||||
|
# inactive — e.g. Node role pre-enable, where the rebuild seats it anyway.
|
||||||
|
if req.domain_name in _SERVICE_DOMAIN_KEYS:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
await loop.run_in_executor(None, _reload_caddy_for_domain_change)
|
||||||
|
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -4465,6 +4602,12 @@ async def api_domains_set_email(req: DomainSetEmailRequest):
|
|||||||
with open(email_path, "w") as f:
|
with open(email_path, "w") as f:
|
||||||
f.write(req.email.strip())
|
f.write(req.email.strip())
|
||||||
_chown_to_caddy(email_path)
|
_chown_to_caddy(email_path)
|
||||||
|
|
||||||
|
# The ACME email lives in the Caddyfile's global block — regenerate and
|
||||||
|
# reload so a running Caddy picks it up (no-op when Caddy is inactive).
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
await loop.run_in_executor(None, _reload_caddy_for_domain_change)
|
||||||
|
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -4860,9 +5003,16 @@ async def api_nwc_wallet_lnurl_qr_print(wallet_identifier: str):
|
|||||||
if err:
|
if err:
|
||||||
return err
|
return err
|
||||||
address = wallet.get("lightning_address") or f"{wallet['alias']}@{domain}"
|
address = wallet.get("lightning_address") or f"{wallet['alias']}@{domain}"
|
||||||
name = _html_escape(str(wallet.get("name") or "Wallet"))
|
name = _html_escape(str(wallet.get("name") or "Wallet"), quote=True)
|
||||||
addr_html = _html_escape(address)
|
addr_html = _html_escape(address, quote=True)
|
||||||
img_src = f"/api/nwc/wallets/{urllib.parse.quote(wallet_identifier, safe='')}/lnurl-qr.png?scale=24"
|
# ── XSS FIX: do not reflect raw wallet_identifier (attacker-controlled) ──
|
||||||
|
# Use canonical ID from the resolved wallet (DB = trusted), then
|
||||||
|
# URL-encode for URL context + HTML-escape for HTML attribute context.
|
||||||
|
# urllib.parse.quote alone is NOT recognized by CodeQL as an HTML sanitizer.
|
||||||
|
canonical_id = str(wallet.get("id") or wallet.get("pubkey") or wallet_identifier)
|
||||||
|
safe_id = urllib.parse.quote(canonical_id, safe="")
|
||||||
|
img_src_raw = f"/api/nwc/wallets/{safe_id}/lnurl-qr.png?scale=24"
|
||||||
|
img_src = _html_escape(img_src_raw, quote=True)
|
||||||
html_doc = f"""<!DOCTYPE html>
|
html_doc = f"""<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -4903,7 +5053,14 @@ async def api_nwc_wallet_lnurl_qr_print(wallet_identifier: str):
|
|||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>"""
|
</html>"""
|
||||||
return HTMLResponse(content=html_doc, headers={"Cache-Control": "no-store"})
|
return HTMLResponse(
|
||||||
|
content=html_doc,
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
"Content-Security-Policy": "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -4968,7 +5125,8 @@ async def api_security_reset():
|
|||||||
import shutil as _shutil
|
import shutil as _shutil
|
||||||
_shutil.rmtree(path, ignore_errors=True)
|
_shutil.rmtree(path, ignore_errors=True)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
errors.append(f"rm {path}: {exc}")
|
logger.warning("Failed to wipe path %s: %s", path, exc)
|
||||||
|
errors.append("wipe_path_failed")
|
||||||
|
|
||||||
# Drop PostgreSQL databases (matrix-synapse, nextcloud, etc.)
|
# Drop PostgreSQL databases (matrix-synapse, nextcloud, etc.)
|
||||||
try:
|
try:
|
||||||
@@ -4986,7 +5144,8 @@ async def api_security_reset():
|
|||||||
capture_output=True, text=True, timeout=30,
|
capture_output=True, text=True, timeout=30,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
errors.append(f"postgres wipe: {exc}")
|
logger.warning("Postgres wipe failed: %s", exc)
|
||||||
|
errors.append("postgres_wipe_failed")
|
||||||
|
|
||||||
# Drop MariaDB databases
|
# Drop MariaDB databases
|
||||||
try:
|
try:
|
||||||
@@ -5005,7 +5164,8 @@ async def api_security_reset():
|
|||||||
capture_output=True, text=True, timeout=30,
|
capture_output=True, text=True, timeout=30,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
errors.append(f"mariadb wipe: {exc}")
|
logger.warning("MariaDB wipe failed: %s", exc)
|
||||||
|
errors.append("mariadb_wipe_failed")
|
||||||
|
|
||||||
# Generate new diceware passwords
|
# Generate new diceware passwords
|
||||||
new_free_password = _generate_diceware_password()
|
new_free_password = _generate_diceware_password()
|
||||||
@@ -5027,9 +5187,11 @@ async def api_security_reset():
|
|||||||
capture_output=True, text=True,
|
capture_output=True, text=True,
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
errors.append(f"chpasswd free: {(result.stderr or result.stdout).strip()}")
|
logger.warning("chpasswd free failed: %s", (result.stderr or result.stdout).strip())
|
||||||
|
errors.append("chpasswd_free_failed")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
errors.append(f"chpasswd free: {exc}")
|
logger.warning("chpasswd free error: %s", exc)
|
||||||
|
errors.append("chpasswd_free_failed")
|
||||||
|
|
||||||
# Set root password
|
# Set root password
|
||||||
try:
|
try:
|
||||||
@@ -5039,28 +5201,46 @@ async def api_security_reset():
|
|||||||
capture_output=True, text=True,
|
capture_output=True, text=True,
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
errors.append(f"chpasswd root: {(result.stderr or result.stdout).strip()}")
|
logger.warning("chpasswd root failed: %s", (result.stderr or result.stdout).strip())
|
||||||
|
errors.append("chpasswd_root_failed")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
errors.append(f"chpasswd root: {exc}")
|
logger.warning("chpasswd root error: %s", exc)
|
||||||
|
errors.append("chpasswd_root_failed")
|
||||||
else:
|
else:
|
||||||
errors.append("chpasswd not found; passwords not reset")
|
logger.warning("chpasswd not found; passwords not reset")
|
||||||
|
errors.append("chpasswd_not_found")
|
||||||
|
|
||||||
# Write new passwords to secrets files
|
# Write new passwords to secrets files
|
||||||
try:
|
try:
|
||||||
os.makedirs("/var/lib/secrets", exist_ok=True)
|
os.makedirs("/var/lib/secrets", exist_ok=True)
|
||||||
with open("/var/lib/secrets/free-password", "w") as f:
|
try:
|
||||||
f.write(new_free_password)
|
import subprocess
|
||||||
os.chmod("/var/lib/secrets/free-password", 0o600)
|
subprocess.run(
|
||||||
|
["chpasswd"],
|
||||||
|
input=f"free:{new_free_password}\n".encode(),
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
with open(FREE_PASSWORD_FILE_WEB, "w") as f:
|
||||||
|
f.write(_hash_password(new_free_password))
|
||||||
|
os.chmod(FREE_PASSWORD_FILE_WEB, 0o600)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
errors.append(f"write free-password: {exc}")
|
logger.warning("write free-password error: %s", exc)
|
||||||
|
errors.append("write_free_password_failed")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
os.makedirs("/var/lib/secrets", exist_ok=True)
|
os.makedirs("/var/lib/secrets", exist_ok=True)
|
||||||
with open("/var/lib/secrets/root-password", "w") as f:
|
with open("/var/lib/secrets/root-password", "w") as f:
|
||||||
f.write(new_root_password)
|
f.write(_hash_password(new_root_password))
|
||||||
os.chmod("/var/lib/secrets/root-password", 0o600)
|
os.chmod("/var/lib/secrets/root-password", 0o600)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
errors.append(f"write root-password: {exc}")
|
logger.warning("write root-password error: %s", exc)
|
||||||
|
errors.append("write_root_password_failed")
|
||||||
|
|
||||||
# Clear only the locked keyring databases, leaving the directory and 'default' pointer intact.
|
# Clear only the locked keyring databases, leaving the directory and 'default' pointer intact.
|
||||||
keyring_dir = "/home/free/.local/share/keyrings"
|
keyring_dir = "/home/free/.local/share/keyrings"
|
||||||
@@ -5069,7 +5249,8 @@ async def api_security_reset():
|
|||||||
try:
|
try:
|
||||||
os.remove(kf)
|
os.remove(kf)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
errors.append(f"keyring wipe: {kf}: {exc}")
|
logger.warning("keyring wipe error for %s: %s", kf, exc)
|
||||||
|
errors.append("keyring_wipe_failed")
|
||||||
|
|
||||||
# The user performed a full security reset — the banner's purpose is served.
|
# The user performed a full security reset — the banner's purpose is served.
|
||||||
try:
|
try:
|
||||||
@@ -5079,7 +5260,7 @@ async def api_security_reset():
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass # Non-fatal
|
pass # Non-fatal
|
||||||
|
|
||||||
return {"ok": True, "new_password": new_free_password, "errors": errors}
|
return {"ok": True, "new_password": new_free_password, "new_root_password": new_root_password, "errors": errors}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/security/verify-integrity")
|
@app.post("/api/security/verify-integrity")
|
||||||
@@ -5132,7 +5313,8 @@ async def api_security_verify_integrity():
|
|||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
store_errors = ["Verification timed out after 5 minutes."]
|
store_errors = ["Verification timed out after 5 minutes."]
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
store_errors = [str(exc)]
|
logger.warning("Nix store verification failed: %s", exc)
|
||||||
|
store_errors = ["Verification failed unexpectedly."]
|
||||||
|
|
||||||
# ── 3. Compare running system to flake build ──────────────────
|
# ── 3. Compare running system to flake build ──────────────────
|
||||||
system_matches = False
|
system_matches = False
|
||||||
@@ -5159,13 +5341,15 @@ async def api_security_verify_integrity():
|
|||||||
expected_system_path = "Build succeeded but no result symlink found"
|
expected_system_path = "Build succeeded but no result symlink found"
|
||||||
else:
|
else:
|
||||||
# Surface the error so the UI can show what went wrong
|
# Surface the error so the UI can show what went wrong
|
||||||
expected_system_path = f"Build failed: {(result.stderr or result.stdout).strip()[:500]}"
|
logger.warning("System verification build failed: %s", (result.stderr or result.stdout).strip()[:500])
|
||||||
|
expected_system_path = "Build failed"
|
||||||
finally:
|
finally:
|
||||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
expected_system_path = "Build timed out"
|
expected_system_path = "Build timed out"
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
expected_system_path = str(exc)
|
logger.warning("System verification failed: %s", exc)
|
||||||
|
expected_system_path = "Verification failed"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"flake_commit": flake_commit,
|
"flake_commit": flake_commit,
|
||||||
@@ -5226,16 +5410,30 @@ async def api_change_password(req: ChangePasswordRequest):
|
|||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to update system password: {exc}")
|
logger.error("Failed to update system password: %s", exc)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to update system password.")
|
||||||
|
|
||||||
# Write new password to secrets file so Hub credentials stay in sync
|
# Write new password to secrets file so Hub credentials stay in sync
|
||||||
try:
|
try:
|
||||||
os.makedirs(os.path.dirname(FREE_PASSWORD_FILE), exist_ok=True)
|
os.makedirs(os.path.dirname(FREE_PASSWORD_FILE), exist_ok=True)
|
||||||
with open(FREE_PASSWORD_FILE, "w") as f:
|
# Write secure web-only hash; sync system shadow via chpasswd (memory)
|
||||||
f.write(req.new_password)
|
try:
|
||||||
os.chmod(FREE_PASSWORD_FILE, 0o600)
|
import subprocess
|
||||||
|
subprocess.run(
|
||||||
|
["chpasswd"],
|
||||||
|
input=f"free:{req.new_password}\n".encode(),
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
# Log but do not block; web hash is the critical fix
|
||||||
|
pass
|
||||||
|
with open(FREE_PASSWORD_FILE_WEB, "w") as f:
|
||||||
|
f.write(_hash_password(req.new_password))
|
||||||
|
os.chmod(FREE_PASSWORD_FILE_WEB, 0o600)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to write secrets file: {exc}")
|
logger.error("Failed to write secrets file: %s", exc)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to write secrets file.")
|
||||||
|
|
||||||
# Clear only the locked keyring databases, leaving the directory and 'default' pointer intact.
|
# Clear only the locked keyring databases, leaving the directory and 'default' pointer intact.
|
||||||
keyring_dir = "/home/free/.local/share/keyrings"
|
keyring_dir = "/home/free/.local/share/keyrings"
|
||||||
@@ -5421,7 +5619,7 @@ async def api_system_set_locale(req: LocaleRequest):
|
|||||||
|
|
||||||
# ── Matrix user management ────────────────────────────────────────
|
# ── Matrix user management ────────────────────────────────────────
|
||||||
|
|
||||||
MATRIX_USERS_FILE = "/var/lib/secrets/matrix-users"
|
MATRIX_HUB_ADMIN_FILE = "/var/lib/secrets/matrix-hub-admin"
|
||||||
MATRIX_DOMAINS_FILE = "/var/lib/domains/matrix"
|
MATRIX_DOMAINS_FILE = "/var/lib/domains/matrix"
|
||||||
|
|
||||||
_SAFE_USERNAME_RE = re.compile(r'^[a-z0-9._\-]+$')
|
_SAFE_USERNAME_RE = re.compile(r'^[a-z0-9._\-]+$')
|
||||||
@@ -5432,49 +5630,46 @@ def _validate_matrix_username(username: str) -> bool:
|
|||||||
return bool(username) and len(username) <= 255 and bool(_SAFE_USERNAME_RE.match(username))
|
return bool(username) and len(username) <= 255 and bool(_SAFE_USERNAME_RE.match(username))
|
||||||
|
|
||||||
|
|
||||||
def _parse_matrix_admin_creds() -> tuple[str, str]:
|
def _parse_matrix_hub_admin_creds() -> tuple[str, str]:
|
||||||
"""Parse admin username and password from the matrix-users credentials file.
|
"""Read the private Matrix service-admin credentials used by the Hub.
|
||||||
|
|
||||||
Returns (localpart, password) for the admin account.
|
These credentials are provisioned independently from the visible bootstrap
|
||||||
Raises FileNotFoundError if the file does not exist.
|
admin, so user management also works on systems where that account existed
|
||||||
Raises ValueError if the file cannot be parsed.
|
before Sovran and its password is unavailable.
|
||||||
"""
|
"""
|
||||||
with open(MATRIX_USERS_FILE, "r") as f:
|
with open(MATRIX_HUB_ADMIN_FILE, "r") as f:
|
||||||
content = f.read()
|
values = dict(
|
||||||
|
line.strip().split("=", 1)
|
||||||
admin_user: str | None = None
|
for line in f
|
||||||
admin_pass: str | None = None
|
if "=" in line
|
||||||
in_admin_section = False
|
|
||||||
|
|
||||||
for line in content.splitlines():
|
|
||||||
stripped = line.strip()
|
|
||||||
if stripped == "[ Admin Account ]":
|
|
||||||
in_admin_section = True
|
|
||||||
continue
|
|
||||||
if stripped.startswith("[ ") and in_admin_section:
|
|
||||||
break
|
|
||||||
if in_admin_section:
|
|
||||||
if stripped.startswith("Username:"):
|
|
||||||
raw = stripped[len("Username:"):].strip()
|
|
||||||
# Format is @localpart:domain — extract localpart
|
|
||||||
if raw.startswith("@") and ":" in raw:
|
|
||||||
admin_user = raw[1:raw.index(":")]
|
|
||||||
else:
|
|
||||||
admin_user = raw
|
|
||||||
elif stripped.startswith("Password:"):
|
|
||||||
admin_pass = stripped[len("Password:"):].strip()
|
|
||||||
|
|
||||||
if not admin_user or not admin_pass:
|
|
||||||
raise ValueError("Could not parse admin credentials from matrix-users file")
|
|
||||||
if "(pre-existing" in admin_pass:
|
|
||||||
raise ValueError(
|
|
||||||
"Admin password is not stored (user was pre-existing). "
|
|
||||||
"Please reset the admin password manually before using this feature."
|
|
||||||
)
|
)
|
||||||
return admin_user, admin_pass
|
|
||||||
|
username = values.get("username", "")
|
||||||
|
password = values.get("password", "")
|
||||||
|
if not _validate_matrix_username(username) or not password:
|
||||||
|
raise ValueError("Matrix Hub service credentials are invalid.")
|
||||||
|
return username, password
|
||||||
|
|
||||||
|
|
||||||
def _matrix_get_admin_token(domain: str, admin_user: str, admin_pass: str) -> str:
|
class MatrixAdminAPIError(Exception):
|
||||||
|
"""An HTTP error returned by Synapse's local Admin API."""
|
||||||
|
|
||||||
|
def __init__(self, status_code: int, detail: str):
|
||||||
|
self.status_code = status_code
|
||||||
|
self.detail = detail
|
||||||
|
super().__init__(detail)
|
||||||
|
|
||||||
|
|
||||||
|
def _matrix_error_detail(exc: urllib.error.HTTPError) -> str:
|
||||||
|
"""Extract Synapse's non-sensitive error description from an HTTP error."""
|
||||||
|
body = exc.read().decode(errors="replace")
|
||||||
|
try:
|
||||||
|
return str(json.loads(body).get("error", body))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return body or f"Synapse returned HTTP {exc.code}"
|
||||||
|
|
||||||
|
|
||||||
|
def _matrix_get_admin_token(admin_user: str, admin_pass: str) -> str:
|
||||||
"""Log in to the local Synapse instance and return an access token."""
|
"""Log in to the local Synapse instance and return an access token."""
|
||||||
url = "http://[::1]:8008/_matrix/client/v3/login"
|
url = "http://[::1]:8008/_matrix/client/v3/login"
|
||||||
payload = json.dumps({
|
payload = json.dumps({
|
||||||
@@ -5487,14 +5682,78 @@ def _matrix_get_admin_token(domain: str, admin_user: str, admin_pass: str) -> st
|
|||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json"},
|
||||||
method="POST",
|
method="POST",
|
||||||
)
|
)
|
||||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
try:
|
||||||
body = json.loads(resp.read())
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
body = json.loads(resp.read())
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
raise MatrixAdminAPIError(exc.code, _matrix_error_detail(exc)) from exc
|
||||||
token: str = body.get("access_token", "")
|
token: str = body.get("access_token", "")
|
||||||
if not token:
|
if not token:
|
||||||
raise ValueError("No access_token in Synapse login response")
|
raise ValueError("No access_token in Synapse login response")
|
||||||
return token
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def _matrix_update_user(
|
||||||
|
domain: str, admin_user: str, admin_pass: str, username: str, payload: dict
|
||||||
|
) -> None:
|
||||||
|
"""Update a Matrix user through Synapse without blocking the web event loop."""
|
||||||
|
token = _matrix_get_admin_token(admin_user, admin_pass)
|
||||||
|
target_user_id = f"@{username}:{domain}"
|
||||||
|
url = (
|
||||||
|
"http://[::1]:8008/_synapse/admin/v2/users/"
|
||||||
|
f"{urllib.parse.quote(target_user_id, safe='@:')}"
|
||||||
|
)
|
||||||
|
api_req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=json.dumps(payload).encode(),
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
},
|
||||||
|
method="PUT",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(api_req, timeout=15) as resp:
|
||||||
|
resp.read()
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
raise MatrixAdminAPIError(exc.code, _matrix_error_detail(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
async def _matrix_update_user_async(domain: str, username: str, payload: dict) -> None:
|
||||||
|
"""Load Hub service credentials and make a local Synapse Admin API call."""
|
||||||
|
try:
|
||||||
|
admin_user, admin_pass = _parse_matrix_hub_admin_creds()
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Matrix user management is still being provisioned. Try again shortly.",
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
logger.error("Invalid Matrix Hub service credentials: %s", exc)
|
||||||
|
raise HTTPException(status_code=500, detail="Matrix Hub service credentials are invalid.")
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
try:
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None, _matrix_update_user, domain, admin_user, admin_pass, username, payload
|
||||||
|
)
|
||||||
|
except MatrixAdminAPIError as exc:
|
||||||
|
if exc.status_code == 409:
|
||||||
|
raise HTTPException(status_code=400, detail=exc.detail)
|
||||||
|
if exc.status_code in (401, 403):
|
||||||
|
logger.error("Matrix Hub service account was rejected by Synapse: HTTP %s", exc.status_code)
|
||||||
|
raise HTTPException(status_code=502, detail="Matrix Hub service account was rejected by Synapse.")
|
||||||
|
if 400 <= exc.status_code < 500:
|
||||||
|
raise HTTPException(status_code=400, detail=exc.detail)
|
||||||
|
raise HTTPException(status_code=502, detail="Synapse could not complete the request.")
|
||||||
|
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||||
|
logger.warning("Matrix Synapse Admin API is unavailable: %s", exc)
|
||||||
|
raise HTTPException(status_code=503, detail="Matrix Synapse is temporarily unavailable.")
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Unexpected Matrix Synapse Admin API failure")
|
||||||
|
raise HTTPException(status_code=502, detail="Matrix Synapse user-management request failed.")
|
||||||
|
|
||||||
|
|
||||||
class MatrixCreateUserRequest(BaseModel):
|
class MatrixCreateUserRequest(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
password: str
|
password: str
|
||||||
@@ -5503,32 +5762,22 @@ class MatrixCreateUserRequest(BaseModel):
|
|||||||
|
|
||||||
@app.post("/api/matrix/create-user")
|
@app.post("/api/matrix/create-user")
|
||||||
async def api_matrix_create_user(req: MatrixCreateUserRequest):
|
async def api_matrix_create_user(req: MatrixCreateUserRequest):
|
||||||
"""Create a new Matrix user via register_new_matrix_user."""
|
"""Create a new Matrix user via the Synapse Admin API."""
|
||||||
if not _validate_matrix_username(req.username):
|
if not _validate_matrix_username(req.username):
|
||||||
raise HTTPException(status_code=400, detail="Invalid username. Use only lowercase letters, digits, '.', '_', '-'.")
|
raise HTTPException(status_code=400, detail="Invalid username. Use only lowercase letters, digits, '.', '_', '-'.")
|
||||||
if not req.password:
|
if not req.password:
|
||||||
raise HTTPException(status_code=400, detail="Password must not be empty.")
|
raise HTTPException(status_code=400, detail="Password must not be empty.")
|
||||||
|
|
||||||
admin_flag = ["-a"] if req.admin else ["--no-admin"]
|
# Read domain
|
||||||
cmd = [
|
|
||||||
"register_new_matrix_user",
|
|
||||||
"-c", "/run/matrix-synapse/runtime-config.yaml",
|
|
||||||
"-u", req.username,
|
|
||||||
"-p", req.password,
|
|
||||||
*admin_flag,
|
|
||||||
"http://localhost:8008",
|
|
||||||
]
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
with open(MATRIX_DOMAINS_FILE, "r") as f:
|
||||||
|
domain = f.read().strip()
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise HTTPException(status_code=500, detail="register_new_matrix_user not found on this system.")
|
raise HTTPException(status_code=500, detail="Matrix domain not configured.")
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
raise HTTPException(status_code=500, detail="Command timed out.")
|
|
||||||
|
|
||||||
output = (result.stdout + result.stderr).strip()
|
await _matrix_update_user_async(
|
||||||
if result.returncode != 0:
|
domain, req.username, {"password": req.password, "admin": req.admin}
|
||||||
# Surface the actual error from the tool (e.g. "User ID already taken")
|
)
|
||||||
raise HTTPException(status_code=400, detail=output or "Failed to create user.")
|
|
||||||
|
|
||||||
return {"ok": True, "username": req.username}
|
return {"ok": True, "username": req.username}
|
||||||
|
|
||||||
@@ -5553,47 +5802,9 @@ async def api_matrix_change_password(req: MatrixChangePasswordRequest):
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise HTTPException(status_code=500, detail="Matrix domain not configured.")
|
raise HTTPException(status_code=500, detail="Matrix domain not configured.")
|
||||||
|
|
||||||
# Parse admin credentials
|
await _matrix_update_user_async(
|
||||||
try:
|
domain, req.username, {"password": req.new_password}
|
||||||
admin_user, admin_pass = _parse_matrix_admin_creds()
|
|
||||||
except FileNotFoundError:
|
|
||||||
raise HTTPException(status_code=500, detail="Matrix credentials file not found.")
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=500, detail=str(exc))
|
|
||||||
|
|
||||||
# Obtain admin access token
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
try:
|
|
||||||
token = await loop.run_in_executor(
|
|
||||||
None, _matrix_get_admin_token, domain, admin_user, admin_pass
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
raise HTTPException(status_code=500, detail=f"Could not authenticate as admin: {exc}")
|
|
||||||
|
|
||||||
# Call Synapse Admin API to reset the password
|
|
||||||
target_user_id = f"@{req.username}:{domain}"
|
|
||||||
url = f"http://[::1]:8008/_synapse/admin/v2/users/{urllib.parse.quote(target_user_id, safe='@:')}"
|
|
||||||
payload = json.dumps({"password": req.new_password}).encode()
|
|
||||||
api_req = urllib.request.Request(
|
|
||||||
url, data=payload,
|
|
||||||
headers={
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": f"Bearer {token}",
|
|
||||||
},
|
|
||||||
method="PUT",
|
|
||||||
)
|
)
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(api_req, timeout=15) as resp:
|
|
||||||
resp.read()
|
|
||||||
except urllib.error.HTTPError as exc:
|
|
||||||
body = exc.read().decode(errors="replace")
|
|
||||||
try:
|
|
||||||
detail = json.loads(body).get("error", body)
|
|
||||||
except Exception:
|
|
||||||
detail = body
|
|
||||||
raise HTTPException(status_code=400, detail=detail)
|
|
||||||
except Exception as exc:
|
|
||||||
raise HTTPException(status_code=500, detail=f"Admin API call failed: {exc}")
|
|
||||||
|
|
||||||
return {"ok": True, "username": req.username}
|
return {"ok": True, "username": req.username}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,15 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.title-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.header-buttons {
|
.header-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -45,42 +54,21 @@
|
|||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── OS Version Badge (polished) ───────────────────────────────── */
|
/* ── OS Version Badge — identical styling to the version badge ────
|
||||||
|
── shown next to titles in the service modal windows ──────────── */
|
||||||
.os-version-badge {
|
.os-version-badge {
|
||||||
|
background-color: rgba(255, 255, 255, 0.06);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: linear-gradient(145deg, #1a2a22, #0f1a15);
|
|
||||||
border: 1px solid rgba(74, 222, 128, 0.25);
|
|
||||||
border-radius: 9999px;
|
|
||||||
padding: 2px 14px 2px 10px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #4ade80;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
margin-left: 12px;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
flex-shrink: 0;
|
||||||
|
|
||||||
.os-version-badge:hover {
|
|
||||||
border-color: rgba(74, 222, 128, 0.45);
|
|
||||||
box-shadow: 0 4px 8px rgba(74, 222, 128, 0.15);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.os-version-badge .version-label {
|
|
||||||
font-size: 0.68rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #4ade80;
|
|
||||||
opacity: 0.85;
|
|
||||||
margin-right: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.os-version-badge .version-number {
|
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
color: #86efac;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── IP bar ─────────────────────────────────────────────────────── */
|
/* ── IP bar ─────────────────────────────────────────────────────── */
|
||||||
|
|||||||
@@ -1,5 +1,56 @@
|
|||||||
|
/* ── NWC Benefits Grid ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.nwc-benefits-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-benefit-item {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-benefit-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-benefit-icon {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-top: 2px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-benefit-content strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-benefit-content p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.nwc-benefits-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Update modal ────────────────────────────────────────────────── */
|
/* ── Update modal ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -607,10 +658,12 @@ button.btn-reboot:hover:not(:disabled) {
|
|||||||
|
|
||||||
.nwc-tab-intro {
|
.nwc-tab-intro {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
align-items: stretch;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nwc-tab-intro-title {
|
.nwc-tab-intro-title {
|
||||||
@@ -982,6 +1035,74 @@ button.btn-reboot:hover:not(:disabled) {
|
|||||||
background: #1c2a24;
|
background: #1c2a24;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── NWC Connect Guide (shown after wallet creation) ────────────── */
|
||||||
|
|
||||||
|
.nwc-connect-guide {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(109, 191, 139, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-connect-guide-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-connect-guide-intro {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-connect-steps {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-connect-step {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-step-num {
|
||||||
|
background: var(--accent-color);
|
||||||
|
color: #0A1A10;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-connect-note {
|
||||||
|
background: rgba(245, 158, 11, 0.08);
|
||||||
|
border-left: 3px solid #f59e0b;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-connect-note strong {
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Narrow screens ─────────────────────────────────────────────── */
|
/* ── Narrow screens ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
/* Sovran_SystemsOS Hub — Shared domain-prerequisite instructions.
|
||||||
|
|
||||||
|
SINGLE SOURCE OF TRUTH for the "you need a Njal.la domain + router port
|
||||||
|
forwarding" guidance that must read identically everywhere it appears:
|
||||||
|
|
||||||
|
• First-boot onboarding wizard (Server + Desktop role)
|
||||||
|
— onboarding.js step 3
|
||||||
|
• Feature-enable domain modal (Node role, and any role)
|
||||||
|
— features.js openDomainSetupModal()
|
||||||
|
(Lightning Wallet Connections / NWC, BTCPay Server, Haven, …)
|
||||||
|
• Domain reconfigure / troubleshooting modal
|
||||||
|
— features.js openDomainReconfigureModal()
|
||||||
|
|
||||||
|
Keep these three surfaces word-for-word consistent: always edit this
|
||||||
|
file, never fork the wording inline. Plain classic script (no modules) —
|
||||||
|
both templates load it with a plain <script> tag. */
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/* Escape helper local to this file so it is self-contained on both pages. */
|
||||||
|
function dpEsc(str) {
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── "What you'll need" intro ──────────────────────────────────────
|
||||||
|
opts.serviceName — e.g. "Lightning Wallet Connections", or null for
|
||||||
|
the multi-service (onboarding) wording.
|
||||||
|
opts.hostExample — e.g. "lightning" → lightning.yourdomain.com */
|
||||||
|
function renderDomainNeedsHtml(opts) {
|
||||||
|
var serviceName = opts.serviceName || null;
|
||||||
|
var hostExample = dpEsc(opts.hostExample || "myservice");
|
||||||
|
var html = "";
|
||||||
|
|
||||||
|
if (serviceName) {
|
||||||
|
html += "<p>To enable <strong>" + dpEsc(serviceName) + "</strong>, you'll need two things first:</p>";
|
||||||
|
html += '<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">';
|
||||||
|
html += "<li><strong>A domain of your own from <a href=\"https://njal.la\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:var(--accent-color);\">Njal.la</a></strong> "
|
||||||
|
+ "— privacy-friendly, no personal details required, accepts Bitcoin. "
|
||||||
|
+ dpEsc(serviceName) + " gets its own hostname: a subdomain (e.g. <code>" + hostExample + ".yourdomain.com</code>) "
|
||||||
|
+ "or a separate domain — your choice. Subdomains are free, and one domain can have many.</li>";
|
||||||
|
html += "<li><strong>Access to your router</strong> — you'll forward ports <strong>80</strong> and <strong>443</strong> (TCP) "
|
||||||
|
+ "to this computer once. All domain-based services share these two ports; "
|
||||||
|
+ "they're required for HTTPS and SSL certificates.</li>";
|
||||||
|
html += "</ol>";
|
||||||
|
} else {
|
||||||
|
html += "<p><strong>Each service below needs two things set up:</strong></p>";
|
||||||
|
html += '<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">';
|
||||||
|
html += "<li><strong>A domain of your own from <a href=\"https://njal.la\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:var(--accent-color);\">Njal.la</a></strong> "
|
||||||
|
+ "— privacy-friendly, no personal details required, accepts Bitcoin. "
|
||||||
|
+ "Each service gets its own hostname: its own subdomain (e.g. <code>" + hostExample + ".yourdomain.com</code>) "
|
||||||
|
+ "or a separate domain — your choice. Subdomains are free, and one domain can have many, "
|
||||||
|
+ "so a single domain can serve every service.</li>";
|
||||||
|
html += "<li><strong>Access to your router</strong> — you'll forward ports <strong>80</strong> and <strong>443</strong> (TCP) "
|
||||||
|
+ "to this computer once. All domain-based services share these two ports; "
|
||||||
|
+ "they're required for HTTPS and SSL certificates.</li>";
|
||||||
|
html += "</ol>";
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── "How to set it up at Njal.la" steps ───────────────────────────
|
||||||
|
opts.hostExample — host part used in the examples (default "call").
|
||||||
|
opts.pasteHint — where the curl command goes:
|
||||||
|
"below" (single service) or "next to its service below"
|
||||||
|
(onboarding, many services). */
|
||||||
|
function renderNjallaStepsHtml(opts) {
|
||||||
|
var hostExample = dpEsc((opts && opts.hostExample) || "call");
|
||||||
|
var pasteHint = (opts && opts.pasteHint) || "below";
|
||||||
|
var html = "";
|
||||||
|
|
||||||
|
html += "<p style=\"margin-top:12px;\"><strong>How to set it up at Njal.la:</strong></p>";
|
||||||
|
html += '<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">';
|
||||||
|
html += "<li>Create an account at <a href=\"https://njal.la\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:var(--accent-color);\">https://njal.la</a> and buy a domain.</li>";
|
||||||
|
html += "<li>Add a <strong>Dynamic</strong> record for the hostname:"
|
||||||
|
+ '<ul style="margin:4px 0 0 16px;padding:0;line-height:1.7;">'
|
||||||
|
+ "<li>In the Njal.la <strong>Name</strong> field, type ONLY the host part — the word before your domain.<br>"
|
||||||
|
+ "(For "" + hostExample + ".yourdomain.com" you'd type just: <code>" + hostExample + "</code>.)<br>"
|
||||||
|
+ "⚠ Do NOT type the full domain here — Njal.la adds it automatically.</li>"
|
||||||
|
+ "<li>Dedicating a whole separate domain to this service? Leave the Name field blank or use <code>@</code>.</li>"
|
||||||
|
+ "</ul>"
|
||||||
|
+ "</li>";
|
||||||
|
html += "<li>A Dynamic record has <strong>NO IP field</strong> — you don't enter an IP anywhere. "
|
||||||
|
+ "It auto-fills once Sovran_SystemsOS runs the update command (on save, and again after every reboot).</li>";
|
||||||
|
html += "<li>Njal.la gives you a curl command, e.g.:<br>"
|
||||||
|
+ '<code style="font-size:0.8em;">curl "https://njal.la/update/?h=' + hostExample + '.yourdomain.com&k=abc123&auto"</code><br>'
|
||||||
|
+ "Copy it and paste it " + pasteHint + ".</li>";
|
||||||
|
html += "</ol>";
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── "One router task" port-forwarding box ─────────────────────────
|
||||||
|
opts.internalIp — LAN IP string, or empty/null for generic wording.
|
||||||
|
opts.plural — true for multi-service (onboarding) wording.
|
||||||
|
opts.includeSsh — also mention port 22 for SSH (onboarding).
|
||||||
|
opts.extraNote — extra sentence appended at the end (optional). */
|
||||||
|
function renderRouterPortsHtml(opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
var ipPart = opts.internalIp
|
||||||
|
? " to this computer’s internal IP <strong>" + dpEsc(opts.internalIp) + "</strong>"
|
||||||
|
: " to this computer’s internal IP";
|
||||||
|
var serviceWord = opts.plural ? "services" : "service";
|
||||||
|
|
||||||
|
var html = "";
|
||||||
|
html += "🔌 <strong>One router task:</strong> in your router’s <strong>port forwarding</strong> settings, "
|
||||||
|
+ "forward port <strong>80 (TCP)</strong> and port <strong>443 (TCP)</strong>"
|
||||||
|
+ ipPart + ". Use the <strong>same number for the internal and external port</strong>. "
|
||||||
|
+ "This only needs to be done once — all domain services share these ports — "
|
||||||
|
+ "but HTTPS and SSL certificates won’t work without them, so your " + serviceWord + " "
|
||||||
|
+ "can’t be reached from outside your home network. "
|
||||||
|
+ "You’ll need normal access to your router’s settings with working port forwarding — "
|
||||||
|
+ "if your ISP blocks it (e.g. CGNAT), domain-based services can’t be reached from the internet. ";
|
||||||
|
if (opts.includeSsh) {
|
||||||
|
html += "Add port <strong>22 (TCP)</strong> as well if you want remote SSH access. ";
|
||||||
|
}
|
||||||
|
if (opts.extraNote) {
|
||||||
|
html += dpEsc(opts.extraNote);
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Async helper: fill a router-box container with the internal IP ──
|
||||||
|
Renders generic text immediately, then upgrades to the concrete LAN IP
|
||||||
|
if /api/network provides one. Best-effort — never blocks the UI. */
|
||||||
|
function renderRouterPortsBox(elId, opts) {
|
||||||
|
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 already shown — fine */ });
|
||||||
|
}
|
||||||
@@ -86,45 +86,17 @@ function openDomainSetupModal(feat, onSaved) {
|
|||||||
var domainPlaceholder = isWalletConnections ? "lightning.yourdomain.com" : "myservice.example.com";
|
var domainPlaceholder = isWalletConnections ? "lightning.yourdomain.com" : "myservice.example.com";
|
||||||
var domainLabelExample = isWalletConnections ? "lightning.yourdomain.com" : "call.yourdomain.com";
|
var domainLabelExample = isWalletConnections ? "lightning.yourdomain.com" : "call.yourdomain.com";
|
||||||
|
|
||||||
var introHtml;
|
// Shared instructions (single source of truth: static/js/domain-prereqs.js) —
|
||||||
if (_currentRole === "node") {
|
// identical wording to the Server + Desktop onboarding wizard and every other
|
||||||
introHtml =
|
// domain-based feature, regardless of role (Node / Desktop / Server+Desktop).
|
||||||
'<p>To enable <strong>' + escHtml(feat.name) + '</strong>, it needs its own domain from Njal.la.</p>' +
|
var hostExample = isWalletConnections ? "lightning" : "call";
|
||||||
'<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">' +
|
|
||||||
'<li>Create an account at <a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">njal.la</a>.</li>' +
|
|
||||||
'<li>Set up a domain for it — either a free subdomain or a separate domain. Pick one option:</li>' +
|
|
||||||
'</ol>';
|
|
||||||
} else {
|
|
||||||
introHtml =
|
|
||||||
'<p>To enable <strong>' + escHtml(feat.name) + '</strong>, it needs its own domain from Njal.la. ' +
|
|
||||||
'In your Njal.la account, set up a domain for it — either a free subdomain or a separate domain. Pick one option:</p>';
|
|
||||||
}
|
|
||||||
|
|
||||||
$domainSetupBody.innerHTML =
|
$domainSetupBody.innerHTML =
|
||||||
'<div class="domain-setup-intro">' +
|
'<div class="domain-setup-intro">' +
|
||||||
nwcWarning +
|
nwcWarning +
|
||||||
introHtml +
|
renderDomainNeedsHtml({ serviceName: feat.name, hostExample: hostExample }) +
|
||||||
'<details style="margin-top:10px;">' +
|
renderNjallaStepsHtml({ hostExample: hostExample, pasteHint: "below" }) +
|
||||||
'<summary style="cursor:pointer;font-weight:600;">Option A — Free subdomain (recommended)</summary>' +
|
'<div class="onboarding-port-warn" id="domain-router-box" style="margin-top:12px;"></div>' +
|
||||||
'<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">' +
|
|
||||||
'<li>In Njal.la, open a domain you own and click "Add record".</li>' +
|
|
||||||
'<li>Set record type to <strong>Dynamic</strong>.</li>' +
|
|
||||||
'<li>In the <strong>Name</strong> field, type ONLY the host part — the word before your domain.<br>' +
|
|
||||||
'(Example only, your choice — for "' + domainLabelExample + '" you'd type just: <code>' + (isWalletConnections ? 'lightning' : 'call') + '</code>)<br>' +
|
|
||||||
'⚠ Do NOT type the full domain here — Njal.la adds it automatically.</li>' +
|
|
||||||
'<li>A Dynamic record has NO IP field — the IP auto-fills after the rebuild/reboot.</li>' +
|
|
||||||
'<li>Copy the curl command Njal.la gives you, e.g.:<br>' +
|
|
||||||
'<code style="font-size:0.8em;">curl "https://njal.la/update/?h=' + domainLabelExample + '&k=abc123&auto"</code></li>' +
|
|
||||||
'</ol>' +
|
|
||||||
'</details>' +
|
|
||||||
'<details style="margin-top:6px;">' +
|
|
||||||
'<summary style="cursor:pointer;font-weight:600;">Option B — Separate / new domain</summary>' +
|
|
||||||
'<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">' +
|
|
||||||
'<li>In Njal.la, buy the domain you want.</li>' +
|
|
||||||
'<li>Add a Dynamic record as in Option A. If this domain is dedicated to the service, leave the Name field blank or use <code>@</code>.</li>' +
|
|
||||||
'<li>Copy the curl command Njal.la gives you.</li>' +
|
|
||||||
'</ol>' +
|
|
||||||
'</details>' +
|
|
||||||
'<p style="margin-top:10px;">Below, enter the full domain for this service — a subdomain (e.g. ' + domainLabelExample + ') or a separate domain — and paste its curl command.</p>' +
|
'<p style="margin-top:10px;">Below, enter the full domain for this service — a subdomain (e.g. ' + domainLabelExample + ') or a separate domain — and paste its curl command.</p>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service domain (e.g. ' + domainLabelExample + '):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="' + domainPlaceholder + '" /></div>' +
|
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service domain (e.g. ' + domainLabelExample + '):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="' + domainPlaceholder + '" /></div>' +
|
||||||
@@ -170,6 +142,9 @@ function openDomainSetupModal(feat, onSaved) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
$domainSetupModal.classList.add("open");
|
$domainSetupModal.classList.add("open");
|
||||||
|
|
||||||
|
// Fill the router port-forwarding box with this computer's LAN IP (best-effort)
|
||||||
|
renderRouterPortsBox("domain-router-box");
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDomainReconfigureModal(feat, existingDomain, onSaved) {
|
function openDomainReconfigureModal(feat, existingDomain, onSaved) {
|
||||||
@@ -218,7 +193,9 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) {
|
|||||||
'<span style="display:inline-block;margin-top:4px;padding:4px 10px;background:var(--card-color);border:1px solid var(--border-color);border-radius:6px;font-family:monospace;font-size:1em;font-weight:700;">' + escHtml(externalIp) + '</span></li>' +
|
'<span style="display:inline-block;margin-top:4px;padding:4px 10px;background:var(--card-color);border:1px solid var(--border-color);border-radius:6px;font-family:monospace;font-size:1em;font-weight:700;">' + escHtml(externalIp) + '</span></li>' +
|
||||||
'<li>If the IP is wrong or the record is missing, update it</li>' +
|
'<li>If the IP is wrong or the record is missing, update it</li>' +
|
||||||
'<li>If you changed the DDNS curl command, paste the updated one below</li>' +
|
'<li>If you changed the DDNS curl command, paste the updated one below</li>' +
|
||||||
|
'<li>Confirm ports <strong>80</strong> and <strong>443</strong> (TCP) are still forwarded on your router to this computer — see the reminder below:</li>' +
|
||||||
'</ol>' +
|
'</ol>' +
|
||||||
|
'<div class="onboarding-port-warn" id="domain-router-box" style="margin-top:12px;"></div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service domain (e.g. ' + domainLabelExample + '):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="' + domainPlaceholder + '" value="' + escHtml(currentDomain) + '" /></div>' +
|
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service domain (e.g. ' + domainLabelExample + '):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="' + domainPlaceholder + '" value="' + escHtml(currentDomain) + '" /></div>' +
|
||||||
'<div class="domain-field-group"><label class="domain-field-label" for="domain-ddns-input">Njal.la Dynamic DNS Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl "https://njal.la/update/?h=' + domainPlaceholder + '&k=abc123&auto"" /><p class="domain-field-hint">ℹ Paste the full curl command from your Njal.la dashboard\'s Dynamic record</p></div>' +
|
'<div class="domain-field-group"><label class="domain-field-label" for="domain-ddns-input">Njal.la Dynamic DNS Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl "https://njal.la/update/?h=' + domainPlaceholder + '&k=abc123&auto"" /><p class="domain-field-hint">ℹ Paste the full curl command from your Njal.la dashboard\'s Dynamic record</p></div>' +
|
||||||
@@ -263,6 +240,9 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
$domainSetupModal.classList.add("open");
|
$domainSetupModal.classList.add("open");
|
||||||
|
|
||||||
|
// Fill the router port-forwarding box with this computer's LAN IP (best-effort)
|
||||||
|
renderRouterPortsBox("domain-router-box");
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeDomainSetupModal() {
|
function closeDomainSetupModal() {
|
||||||
|
|||||||
@@ -2,6 +2,21 @@
|
|||||||
|
|
||||||
// ── Service detail modal ──────────────────────────────────────────
|
// ── Service detail modal ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function _getZeusConnectGuideHtml() {
|
||||||
|
return '<div class="nwc-connect-guide">' +
|
||||||
|
'<div class="nwc-connect-guide-title">📱 Connect to Zeus</div>' +
|
||||||
|
'<p class="nwc-connect-guide-intro">This connection URL is an <strong>LND REST</strong> connection — the direct way to use Zeus with your node for full admin access. It connects securely through Tor, allowing you to manage channels, balances, and your full node on the go.</p>' +
|
||||||
|
'<div class="nwc-connect-steps">' +
|
||||||
|
'<div class="nwc-connect-step"><div class="nwc-step-num">1</div><div><strong>Download Zeus</strong> from the App Store or Google Play.</div></div>' +
|
||||||
|
'<div class="nwc-connect-step"><div class="nwc-step-num">2</div><div>Open Zeus and open the <strong>Wallets</strong> screen.</div></div>' +
|
||||||
|
'<div class="nwc-connect-step"><div class="nwc-step-num">3</div><div>Tap the <strong>+ (Add Wallet)</strong> button in the top-right corner.</div></div>' +
|
||||||
|
'<div class="nwc-connect-step"><div class="nwc-step-num">4</div><div>On <strong>Wallet Configuration</strong>, tap the <strong>scan icon</strong> in the top-right corner, then scan the QR code above.</div></div>' +
|
||||||
|
'<div class="nwc-connect-step"><div class="nwc-step-num">5</div><div>Zeus detects the LND REST QR and fills in the connection details. Review them, then tap <strong>Save Wallet Config</strong>.</div></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="nwc-connect-note"><strong>💡 Note:</strong> This is <em>not</em> the same as the NWC pairing QR shown in Lightning Wallet Connections — that gives your wallet sandboxed, limited access for everyday spending. LND REST connects Zeus directly to your node for full admin control.</div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
function _renderCredsHtml(credentials, unit) {
|
function _renderCredsHtml(credentials, unit) {
|
||||||
var html = "";
|
var html = "";
|
||||||
for (var i = 0; i < credentials.length; i++) {
|
for (var i = 0; i < credentials.length; i++) {
|
||||||
@@ -9,7 +24,10 @@ function _renderCredsHtml(credentials, unit) {
|
|||||||
var id = "cred-" + Math.random().toString(36).substring(2, 8);
|
var id = "cred-" + Math.random().toString(36).substring(2, 8);
|
||||||
var qrBlock = "";
|
var qrBlock = "";
|
||||||
if (cred.qrcode) {
|
if (cred.qrcode) {
|
||||||
qrBlock = '<div class="creds-qr-wrap"><img class="creds-qr-img" src="' + cred.qrcode + '" alt="QR Code for ' + escHtml(cred.label) + '"><div class="creds-qr-hint">Scan with Zeus app on your phone</div></div>';
|
var qrHint = (unit === "zeus-connect-setup.service")
|
||||||
|
? "This is an <strong>LND REST</strong> connection QR — in Zeus, add a wallet and use the scan icon (see steps below)."
|
||||||
|
: "In Zeus: <em>Wallets → + → scan icon</em>. This is an <strong>LND REST</strong> QR for direct node access.";
|
||||||
|
qrBlock = '<div class="creds-qr-wrap"><img class="creds-qr-img" src="' + cred.qrcode + '" alt="QR Code for ' + escHtml(cred.label) + '"><div class="creds-qr-hint">' + qrHint + '</div></div>';
|
||||||
}
|
}
|
||||||
// If qronly, render the label + QR block only — skip value and copy button
|
// If qronly, render the label + QR block only — skip value and copy button
|
||||||
if (cred.qronly) {
|
if (cred.qronly) {
|
||||||
@@ -174,7 +192,7 @@ function _nwcRenderWalletState() {
|
|||||||
var selectedLimited = state.createForm.access_preset === "send_receive_limited";
|
var selectedLimited = state.createForm.access_preset === "send_receive_limited";
|
||||||
html +=
|
html +=
|
||||||
'<div class="nwc-tab-intro-title">Create a Wallet</div>' +
|
'<div class="nwc-tab-intro-title">Create a Wallet</div>' +
|
||||||
'<p class="nwc-tab-intro-desc">An isolated wallet you pair to a single app, with its own Lightning Address.</p>' +
|
'<p class="nwc-tab-intro-desc">Create a secure, sandboxed wallet for a specific app. Each wallet gets its own Lightning Address and optional spending limit.</p>' +
|
||||||
'<div class="matrix-form-group"><label class="matrix-form-label" for="nwc-wallet-name">Wallet Name</label>' +
|
'<div class="matrix-form-group"><label class="matrix-form-label" for="nwc-wallet-name">Wallet Name</label>' +
|
||||||
'<input class="matrix-form-input" id="nwc-wallet-name" type="text" placeholder="My Wallet" value="' + escHtml(state.createForm.name || "") + '" autocomplete="off"></div>' +
|
'<input class="matrix-form-input" id="nwc-wallet-name" type="text" placeholder="My Wallet" value="' + escHtml(state.createForm.name || "") + '" autocomplete="off"></div>' +
|
||||||
'<div class="matrix-form-group"><label class="matrix-form-label" for="nwc-wallet-alias">Lightning Address Alias</label>' +
|
'<div class="matrix-form-group"><label class="matrix-form-label" for="nwc-wallet-alias">Lightning Address Alias</label>' +
|
||||||
@@ -218,7 +236,8 @@ function _nwcRenderWalletState() {
|
|||||||
var pairId = "nwc-pairing-uri-" + Math.random().toString(36).substring(2, 8);
|
var pairId = "nwc-pairing-uri-" + Math.random().toString(36).substring(2, 8);
|
||||||
html += '<div class="nwc-secret-warning">⚠ One-time pairing secret. Save it now — it will not be shown again.</div>';
|
html += '<div class="nwc-secret-warning">⚠ One-time pairing secret. Save it now — it will not be shown again.</div>';
|
||||||
if (created.pairing_qrcode) {
|
if (created.pairing_qrcode) {
|
||||||
html += '<div class="creds-qr-wrap"><img class="creds-qr-img" src="' + created.pairing_qrcode + '" alt="QR code for Lightning Wallet Connections pairing secret"><div class="creds-qr-hint">Scan now in Zeus or copy the URI below.</div></div>';
|
html += '<div class="creds-row"><div class="creds-label">QR Code</div>' +
|
||||||
|
'<div class="creds-qr-wrap"><img class="creds-qr-img" src="' + created.pairing_qrcode + '" alt="QR code for Lightning Wallet Connections pairing secret"><div class="creds-qr-hint">This is an <strong>NWC</strong> pairing QR — in Zeus, add a wallet and use the scan icon (see steps below).</div></div></div>';
|
||||||
}
|
}
|
||||||
html += '<div class="creds-row"><div class="creds-label">Pairing URI</div>' +
|
html += '<div class="creds-row"><div class="creds-label">Pairing URI</div>' +
|
||||||
'<div class="creds-value-wrap"><div class="creds-value" id="' + pairId + '">' + escHtml(created.pairing_uri || "Unavailable") + '</div><button class="creds-copy-btn" data-target="' + pairId + '">Copy</button></div></div>';
|
'<div class="creds-value-wrap"><div class="creds-value" id="' + pairId + '">' + escHtml(created.pairing_uri || "Unavailable") + '</div><button class="creds-copy-btn" data-target="' + pairId + '">Copy</button></div></div>';
|
||||||
@@ -226,10 +245,22 @@ function _nwcRenderWalletState() {
|
|||||||
html += '<div class="creds-row"><div class="creds-label">Lightning Address</div>' +
|
html += '<div class="creds-row"><div class="creds-label">Lightning Address</div>' +
|
||||||
'<div class="creds-value-wrap"><div class="creds-value">' + escHtml(created.wallet.lightning_address) + '</div></div></div>';
|
'<div class="creds-value-wrap"><div class="creds-value">' + escHtml(created.wallet.lightning_address) + '</div></div></div>';
|
||||||
}
|
}
|
||||||
|
html += '<div class="nwc-connect-guide">' +
|
||||||
|
'<div class="nwc-connect-guide-title">📱 Connect to Zeus</div>' +
|
||||||
|
'<p class="nwc-connect-guide-intro">This pairing URI is an <strong>NWC (Nostr Wallet Connect)</strong> connection — the modern, mobile-friendly way to use Zeus with your node. It connects directly through your Lightning domain, so no Tor or port forwarding is needed on your phone.</p>' +
|
||||||
|
'<div class="nwc-connect-steps">' +
|
||||||
|
'<div class="nwc-connect-step"><div class="nwc-step-num">1</div><div><strong>Download Zeus</strong> from the App Store or Google Play.</div></div>' +
|
||||||
|
'<div class="nwc-connect-step"><div class="nwc-step-num">2</div><div>Open Zeus and open the <strong>Wallets</strong> screen.</div></div>' +
|
||||||
|
'<div class="nwc-connect-step"><div class="nwc-step-num">3</div><div>Tap the <strong>+ (Add Wallet)</strong> button in the top-right corner.</div></div>' +
|
||||||
|
'<div class="nwc-connect-step"><div class="nwc-step-num">4</div><div>On <strong>Wallet Configuration</strong>, tap the <strong>scan icon</strong> in the top-right corner, then scan the QR code above.</div></div>' +
|
||||||
|
'<div class="nwc-connect-step"><div class="nwc-step-num">5</div><div>Zeus detects the NWC QR and fills in <strong>Nostr Wallet Connect</strong>. Review it, then tap <strong>Save Wallet Config</strong>.</div></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="nwc-connect-note"><strong>💡 Note:</strong> This is <em>not</em> the same as the LND REST / Tor QR shown on your LND tile — that connects Zeus directly to your Lightning node for full admin control. NWC gives your wallet sandboxed, limited access for everyday spending.</div>' +
|
||||||
|
'</div>';
|
||||||
html += '<div class="matrix-form-actions">' +
|
html += '<div class="matrix-form-actions">' +
|
||||||
'<button class="matrix-form-back" id="nwc-created-another-btn"' + (state.busy ? " disabled" : "") + '>Create Another Wallet</button>' +
|
'<button class="matrix-form-back" id="nwc-created-another-btn"' + (state.busy ? " disabled" : "") + '>Create Another Wallet</button>' +
|
||||||
'<button class="matrix-form-submit" id="nwc-created-continue-btn"' + (state.busy ? " disabled" : "") + '>I Saved This Secret</button>' +
|
'<button class="matrix-form-submit" id="nwc-created-continue-btn"' + (state.busy ? " disabled" : "") + '>I Saved This Secret</button>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
host.innerHTML = html;
|
host.innerHTML = html;
|
||||||
_attachCopyHandlers(host);
|
_attachCopyHandlers(host);
|
||||||
var continueBtn = document.getElementById("nwc-created-continue-btn");
|
var continueBtn = document.getElementById("nwc-created-continue-btn");
|
||||||
@@ -330,9 +361,9 @@ function _nwcRenderWalletState() {
|
|||||||
|
|
||||||
if (!state.wallets || state.wallets.length === 0) {
|
if (!state.wallets || state.wallets.length === 0) {
|
||||||
html += '<div class="nwc-empty-state">' +
|
html += '<div class="nwc-empty-state">' +
|
||||||
'<div class="nwc-empty-icon">👛</div>' +
|
'<div class="nwc-empty-icon">⚡</div>' +
|
||||||
'<div class="nwc-empty-title">No wallets yet</div>' +
|
'<div class="nwc-empty-title">Ready to start spending</div>' +
|
||||||
'<p class="nwc-empty-desc">Create your first wallet to get a one-time pairing secret for an app, plus a reusable Lightning Address.</p>' +
|
'<p class="nwc-empty-desc">Create your first isolated wallet to connect to apps like Zeus (via NWC) or Nostr. Experience faster, more secure Lightning payments today.</p>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
host.innerHTML = html;
|
host.innerHTML = html;
|
||||||
_nwcWireDomainLink();
|
_nwcWireDomainLink();
|
||||||
@@ -761,9 +792,32 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
if (isNwc) {
|
if (isNwc) {
|
||||||
if (effectiveEnabled || data.enabled) {
|
if (effectiveEnabled || data.enabled) {
|
||||||
html += '<div class="nwc-tab-intro">' +
|
html += '<div class="nwc-tab-intro">' +
|
||||||
'<div>' +
|
'<div class="nwc-intro-header">' +
|
||||||
'<div class="nwc-tab-intro-title">Your Lightning Wallets</div>' +
|
'<div class="nwc-tab-intro-title">Lightning Wallet Connections</div>' +
|
||||||
'<p class="nwc-tab-intro-desc">Each wallet is isolated: pair it to one app with a one-time secret, and share its Lightning Address to get paid.</p>' +
|
'<p class="nwc-tab-intro-desc">Powerful, isolated wallets for your daily spending and modern apps.</p>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="nwc-benefits-grid">' +
|
||||||
|
'<div class="nwc-benefit-item">' +
|
||||||
|
'<div class="nwc-benefit-icon">🛡️</div>' +
|
||||||
|
'<div class="nwc-benefit-content">' +
|
||||||
|
'<strong>Isolated & Secure</strong>' +
|
||||||
|
'<p>Create sandboxed wallets for quick spending while keeping your main node protected. Isolated access is faster than Tor and perfect for budgeting.</p>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="nwc-benefit-item">' +
|
||||||
|
'<div class="nwc-benefit-icon">🌐</div>' +
|
||||||
|
'<div class="nwc-benefit-content">' +
|
||||||
|
'<strong>Modern Ecosystem</strong>' +
|
||||||
|
'<p>Easily spend and receive bitcoin using LNURL and Nostr (NWC) across the growing ecosystem of decentralized apps.</p>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="nwc-benefit-item">' +
|
||||||
|
'<div class="nwc-benefit-icon">📱</div>' +
|
||||||
|
'<div class="nwc-benefit-content">' +
|
||||||
|
'<strong>Zeus on the Go (via NWC)</strong>' +
|
||||||
|
'<p>Connect Zeus to your wallet using NWC — no Tor, no port forwarding. Create a wallet below, scan the pairing QR, and start spending from your phone.</p>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div id="nwc-wallets-body"><p class="creds-loading">Loading wallets…</p></div>' +
|
'<div id="nwc-wallets-body"><p class="creds-loading">Loading wallets…</p></div>' +
|
||||||
@@ -813,6 +867,7 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
html += '<div class="svc-detail-section">' +
|
html += '<div class="svc-detail-section">' +
|
||||||
'<div class="svc-detail-section-title">Credentials & Access</div>' +
|
'<div class="svc-detail-section-title">Credentials & Access</div>' +
|
||||||
_renderCredsHtml(data.credentials, unit) +
|
_renderCredsHtml(data.credentials, unit) +
|
||||||
|
(unit === "zeus-connect-setup.service" ? _getZeusConnectGuideHtml() : "") +
|
||||||
(unit === "matrix-synapse.service" ?
|
(unit === "matrix-synapse.service" ?
|
||||||
'<hr class="matrix-actions-divider"><div class="matrix-actions-row">' +
|
'<hr class="matrix-actions-divider"><div class="matrix-actions-row">' +
|
||||||
'<button class="matrix-action-btn" id="matrix-add-user-btn">➕ Add New User</button>' +
|
'<button class="matrix-action-btn" id="matrix-add-user-btn">➕ Add New User</button>' +
|
||||||
@@ -1030,6 +1085,9 @@ async function openCredsModal(unit, name, icon) {
|
|||||||
'<button class="matrix-action-btn" id="matrix-change-pw-btn">🔑 Change Password</button>' +
|
'<button class="matrix-action-btn" id="matrix-change-pw-btn">🔑 Change Password</button>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}
|
}
|
||||||
|
if (unit === "zeus-connect-setup.service") {
|
||||||
|
html += _getZeusConnectGuideHtml();
|
||||||
|
}
|
||||||
$credsBody.innerHTML = html;
|
$credsBody.innerHTML = html;
|
||||||
_attachCopyHandlers($credsBody);
|
_attachCopyHandlers($credsBody);
|
||||||
if (unit === "matrix-synapse.service") {
|
if (unit === "matrix-synapse.service") {
|
||||||
|
|||||||
@@ -473,19 +473,73 @@ function renderBackupReady(drives) {
|
|||||||
].join("");
|
].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 = [
|
$supportBody.innerHTML = [
|
||||||
'<div class="support-section">',
|
'<div class="support-section">',
|
||||||
'<div class="support-icon-big">\ud83d\udcbe</div>',
|
'<div class="support-icon-big">\ud83d\udcbe</div>',
|
||||||
'<h3 class="support-heading">Manual Backup</h3>',
|
'<h3 class="support-heading">Manual Backup</h3>',
|
||||||
|
|
||||||
'<div class="support-wallet-box support-wallet-protected" style="margin-bottom:16px;">',
|
introHtml,
|
||||||
'<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>',
|
|
||||||
|
|
||||||
'<div class="support-steps">',
|
'<div class="support-steps">',
|
||||||
'<div class="support-steps-title">Requirements</div>',
|
'<div class="support-steps-title">Requirements</div>',
|
||||||
@@ -500,20 +554,11 @@ function renderBackupReady(drives) {
|
|||||||
'<div class="support-steps">',
|
'<div class="support-steps">',
|
||||||
'<div class="support-steps-title">What gets backed up</div>',
|
'<div class="support-steps-title">What gets backed up</div>',
|
||||||
'<ol class="support-backup-steps">',
|
'<ol class="support-backup-steps">',
|
||||||
'<li>NixOS configuration (<code>/etc/nixos</code>)</li>',
|
backupItemsHtml,
|
||||||
'<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>',
|
|
||||||
'</ol>',
|
'</ol>',
|
||||||
'</div>',
|
'</div>',
|
||||||
|
|
||||||
'<div class="support-wallet-box support-wallet-warning">',
|
dbNoteHtml,
|
||||||
'<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>',
|
|
||||||
|
|
||||||
'<div class="support-wallet-box support-wallet-protected">',
|
'<div class="support-wallet-box support-wallet-protected">',
|
||||||
'<div class="support-wallet-header">',
|
'<div class="support-wallet-header">',
|
||||||
|
|||||||
@@ -357,38 +357,24 @@ async function loadStep3() {
|
|||||||
if (relevantDomains.length === 0) {
|
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>';
|
html += '<p class="onboarding-body-text">No domain-based services are enabled for your role. You can skip this step.</p>';
|
||||||
} else {
|
} 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;">'
|
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
|
||||||
+ '<p style="margin:0 0 8px;"><strong>Sovran_SystemsOS uses Njal.la for domains and Dynamic DNS.</strong></p>'
|
+ renderDomainNeedsHtml({ serviceName: null, hostExample: "call" })
|
||||||
+ '<ol style="margin:8px 0 0 16px; padding:0; line-height:1.7;">'
|
+ renderNjallaStepsHtml({ hostExample: "call", pasteHint: "next to its service below" })
|
||||||
+ '<li>Create an account at <a href="https://njal.la" target="_blank" style="color:var(--accent-color);">https://njal.la</a>.</li>'
|
|
||||||
+ '<li>Buy at least one domain. Each service below needs its own domain — you can either give each service its own subdomain of a single domain you buy (subdomains are free, and one domain can have many), OR use a separate domain for each. Your choice.</li>'
|
|
||||||
+ '<li>For each service, add a <strong>Dynamic</strong> record in Njal.la:'
|
|
||||||
+ '<ul style="margin:4px 0 0 16px;padding:0;line-height:1.7;">'
|
|
||||||
+ '<li>In the Njal.la <strong>Name</strong> field, type ONLY the host part — the word before your domain.<br>'
|
|
||||||
+ '(Example only, your choice — for "call.yourdomain.com" you'd type just: <code>call</code>.)<br>'
|
|
||||||
+ 'If you bought a whole separate domain just for this service, leave Name blank or use <code>@</code>.<br>'
|
|
||||||
+ '⚠ Do NOT type the full domain in the Name field — Njal.la adds it automatically.</li>'
|
|
||||||
+ '<li>A Dynamic record has NO IP field. You don't enter an IP anywhere — it auto-fills once Sovran_SystemsOS updates it (on save, and again after reboot).</li>'
|
|
||||||
+ '</ul>'
|
|
||||||
+ '</li>'
|
|
||||||
+ '<li>Njal.la gives you a curl command like:<br>'
|
|
||||||
+ '<code style="font-size:0.8em;">curl "https://njal.la/update/?h=call.yourdomain.com&k=abc123&auto"</code></li>'
|
|
||||||
+ '</ol>'
|
|
||||||
+ '</div>';
|
+ '</div>';
|
||||||
html += '<p class="onboarding-hint">Enter each service\'s full domain — a subdomain (e.g. <code>call.yourdomain.com</code>) or a separate domain (e.g. <code>call.com</code>) — and its Njal.la DDNS curl command.</p>';
|
html += '<p class="onboarding-hint">Enter each service\'s full domain — a subdomain (e.g. <code>call.yourdomain.com</code>) or a separate domain (e.g. <code>call.com</code>) — and its Njal.la DDNS curl command.</p>';
|
||||||
|
|
||||||
// Compact router note (full port guidance is shown when a feature is
|
// Router note (the same wording is shown again, per-service, whenever a
|
||||||
// enabled, and lives on each service tile afterwards)
|
// domain-based feature is enabled, and lives on each service tile afterwards)
|
||||||
var routerIpPart = internalIp
|
|
||||||
? ' to this computer’s internal IP <strong>' + escHtml(internalIp) + '</strong>'
|
|
||||||
: ' to this computer’s internal IP';
|
|
||||||
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
|
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
|
||||||
+ '🔌 <strong>One router task:</strong> in your router’s <strong>port forwarding</strong> settings, forward '
|
+ renderRouterPortsHtml({
|
||||||
+ 'port <strong>80 (TCP)</strong> and port <strong>443 (TCP)</strong>'
|
internalIp: internalIp,
|
||||||
+ routerIpPart + '. Use the <strong>same number for the internal and external port</strong>. '
|
plural: true,
|
||||||
+ 'These are required for HTTPS and SSL certificates — without them your services cannot be reached from outside your home network. '
|
includeSsh: true,
|
||||||
+ 'Add port <strong>22 (TCP)</strong> as well if you want remote SSH access. '
|
extraNote: "Element Call needs a few extra ports (some UDP), and you’ll be shown exactly which when you enable it.",
|
||||||
+ 'Element Call needs a few extra ports (some UDP), and you’ll be shown exactly which when you enable it.'
|
})
|
||||||
+ '</div>';
|
+ '</div>';
|
||||||
|
|
||||||
relevantDomains.forEach(function(d) {
|
relevantDomains.forEach(function(d) {
|
||||||
|
|||||||
@@ -21,13 +21,13 @@
|
|||||||
<!-- Header bar -->
|
<!-- Header bar -->
|
||||||
<header class="header-bar">
|
<header class="header-bar">
|
||||||
<img src="/static/sovran-hub-icon.svg" alt="Sovran Hub" class="header-logo" />
|
<img src="/static/sovran-hub-icon.svg" alt="Sovran Hub" class="header-logo" />
|
||||||
<span class="title">Sovran_SystemsOS Hub</span>
|
|
||||||
|
|
||||||
<!-- OS Version Badge -->
|
<div class="title-group">
|
||||||
<span class="os-version-badge" id="os-version-badge" title="Sovran_SystemsOS Version">
|
<span class="title">Sovran_SystemsOS Hub</span>
|
||||||
<span class="version-label">v</span>
|
|
||||||
<span class="version-number" id="version-number">{{ sovran_version }}</span>
|
<!-- OS Version Badge — styled identically to the version badge in service modals -->
|
||||||
</span>
|
<span class="os-version-badge" id="os-version-badge" title="Sovran_SystemsOS Version">v{{ sovran_version }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="header-buttons">
|
<div class="header-buttons">
|
||||||
<span class="role-badge" id="role-badge">Loading…</span>
|
<span class="role-badge" id="role-badge">Loading…</span>
|
||||||
@@ -309,6 +309,7 @@
|
|||||||
<script src="/static/js/constants.js?v={{ asset_version }}"></script>
|
<script src="/static/js/constants.js?v={{ asset_version }}"></script>
|
||||||
<script src="/static/js/state.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/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/tiles.js?v={{ asset_version }}"></script>
|
||||||
<script src="/static/js/service-detail.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/support.js?v={{ asset_version }}"></script>
|
||||||
|
|||||||
@@ -125,9 +125,8 @@
|
|||||||
<span class="onboarding-step-icon">🌐</span>
|
<span class="onboarding-step-icon">🌐</span>
|
||||||
<h2 class="onboarding-step-title">Domain Configuration</h2>
|
<h2 class="onboarding-step-title">Domain Configuration</h2>
|
||||||
<p class="onboarding-step-desc">
|
<p class="onboarding-step-desc">
|
||||||
Sovran_SystemsOS uses <strong><a href="https://njal.la" target="_blank" style="color: var(--accent-color);">Njal.la</a></strong> for domains and Dynamic DNS.
|
Sovran_SystemsOS uses <strong><a href="https://njal.la" target="_blank" style="color: var(--accent-color);">Njal.la</a></strong> for domains and Dynamic DNS, and your router needs ports <strong>80</strong> and <strong>443</strong> (TCP) forwarded to this computer.
|
||||||
Create an account at Njal.la, then for each service below, add a <strong>Dynamic</strong> record — no IP needed, it auto-populates once the DDNS curl command runs.
|
Everything you need — Njal.la account, Dynamic records, and the one router task — is laid out step by step below.
|
||||||
Paste the curl command from your Njal.la dashboard for each service.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="onboarding-card" id="step-3-body">
|
<div class="onboarding-card" id="step-3-body">
|
||||||
@@ -170,6 +169,7 @@
|
|||||||
</div><!-- /panel-wrap -->
|
</div><!-- /panel-wrap -->
|
||||||
</div><!-- /shell -->
|
</div><!-- /shell -->
|
||||||
|
|
||||||
|
<script src="/static/js/domain-prereqs.js?v={{ asset_version }}"></script>
|
||||||
<script src="/static/onboarding.js?v={{ onboarding_js_hash }}"></script>
|
<script src="/static/onboarding.js?v={{ onboarding_js_hash }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Generated
+24
-24
@@ -5,11 +5,11 @@
|
|||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1784932759,
|
"lastModified": 1785778881,
|
||||||
"narHash": "sha256-44/iCx+wiYukHGhvPm65ppJZ3FZZbp6f9JE5isz8TsA=",
|
"narHash": "sha256-yHJJTm3o7ZhiVp68G+hh+dz1GKV+ACOT6mQj+eYvJLQ=",
|
||||||
"owner": "emmanuelrosa",
|
"owner": "emmanuelrosa",
|
||||||
"repo": "btc-clients-nix",
|
"repo": "btc-clients-nix",
|
||||||
"rev": "8aab86c245ab9a2bea0d72175d6fd663a892af9f",
|
"rev": "fc1aca94d839f82e7501fe0d01279d5c1fba6e63",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -92,11 +92,11 @@
|
|||||||
"nixpkgs-unstable": "nixpkgs-unstable"
|
"nixpkgs-unstable": "nixpkgs-unstable"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1779253922,
|
"lastModified": 1785177868,
|
||||||
"narHash": "sha256-k5DpYVfyy27ELuEiV+51EfVg7B6vKUW63NWeA6eKGd0=",
|
"narHash": "sha256-zlAZZkokXpeHHBP8Nt4MvLXOn1Js1qVi81VPjtVbRV8=",
|
||||||
"owner": "fort-nix",
|
"owner": "fort-nix",
|
||||||
"repo": "nix-bitcoin",
|
"repo": "nix-bitcoin",
|
||||||
"rev": "1496f842477976c085cd96f1837ea12444014088",
|
"rev": "360e30fee5ba32f9fecc89bc35628195d9d2dbbe",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -108,11 +108,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1782911660,
|
"lastModified": 1785590095,
|
||||||
"narHash": "sha256-PbR+tJ5E/Ux+01UtdFKqblccVA4/FgWbkym4ev3VHHQ=",
|
"narHash": "sha256-CNO2szJbdLjVN/Hi1BML9MSALz1GM2fIdwnzs404QO8=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "cf720c15e108d432d29041cc5a185630809acefb",
|
"rev": "e568f3b19d54b08f48bfae9b12b3e124d1a28002",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -139,11 +139,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs-stable": {
|
"nixpkgs-stable": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1784856561,
|
"lastModified": 1785734586,
|
||||||
"narHash": "sha256-J+Bx1Z6Oeoj2FgnBhRMKyUhhtDoOpTgXYaVLZpDjW4A=",
|
"narHash": "sha256-ODZkEK9Gy50yg6h98u7KkitZ3oc/uuTFK00bh1CRdNA=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "597283ad8aa0b331c788e97c4c262d58877074ef",
|
"rev": "531670d871c0e29724a02f3cbcac170adc65b58c",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -155,11 +155,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs-unstable": {
|
"nixpkgs-unstable": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1778869304,
|
"lastModified": 1784453100,
|
||||||
"narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=",
|
"narHash": "sha256-zpGZb8FYui+i8KLtC0nlcmb0voR2zB80Qn8pe7lzIbk=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "d233902339c02a9c334e7e593de68855ad26c4cb",
|
"rev": "b471514bed69eff5255c8e63c1f80e5fe56c616f",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -171,11 +171,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs_2": {
|
"nixpkgs_2": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1778737229,
|
"lastModified": 1782847189,
|
||||||
"narHash": "sha256-6xWoytx8jFW4PF1GjRm/i/53trbpKGfz6zjzQGBr4cI=",
|
"narHash": "sha256-twXPFqFsrrY5r28Zh7Homgcp2gUMBgQ6WDS98Q/3xFI=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "d7a713c0b7e47c908258e71cba7a2d77cc8d71d5",
|
"rev": "b6018f87da91d19d0ab4cf979885689b469cdd41",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -187,11 +187,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs_3": {
|
"nixpkgs_3": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1784796856,
|
"lastModified": 1785828668,
|
||||||
"narHash": "sha256-wWFrV5/Qbm+lyt5x20E/bSbfJiGKMo4RCxZV8cl/WZI=",
|
"narHash": "sha256-8fsyqeO+mJqvIzeO4xIpgJe/f7MTbbVTEC6RT6WSXNs=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "e2587caef70cea85dd97d7daab492899902dbf5d",
|
"rev": "e72e4f299401a3689d4b3d5fc6496b11db7064eb",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -224,11 +224,11 @@
|
|||||||
"systems": "systems_2"
|
"systems": "systems_2"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1784814601,
|
"lastModified": 1785763201,
|
||||||
"narHash": "sha256-T32JXjZ7kIbhBn8/Har171yGg6IBdl97cxAWameqZDE=",
|
"narHash": "sha256-wA373y/B9orM3HatLu9oS+Ke5lmdZyBl/bdjd2gLMq4=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "nixvim",
|
"repo": "nixvim",
|
||||||
"rev": "f316e949e0ed9df0e1e0bf645c6dce721d4e230e",
|
"rev": "c7be49306b23a952c0151cf4bbeacd944ed82f2a",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ let
|
|||||||
else "dev";
|
else "dev";
|
||||||
|
|
||||||
# Clean version (remove 'v' prefix and newlines)
|
# Clean version (remove 'v' prefix and newlines)
|
||||||
cleanVersion = builtins.replaceStrings ["v" "\n" "\r"] [""] versionFile;
|
cleanVersion = builtins.replaceStrings ["v" "\n" "\r"] ["" "" ""] versionFile;
|
||||||
|
|
||||||
pythonEnv = pkgs.python3.withPackages (ps: [ ps.pygobject3 ps.pycairo ]);
|
pythonEnv = pkgs.python3.withPackages (ps: [ ps.pygobject3 ps.pycairo ]);
|
||||||
|
|
||||||
|
|||||||
+194
-31
@@ -103,25 +103,81 @@ def human_size(nbytes):
|
|||||||
return f"{nbytes:.1f} PB"
|
return f"{nbytes:.1f} PB"
|
||||||
|
|
||||||
def check_internet():
|
def check_internet():
|
||||||
"""Return True if the machine can reach the internet."""
|
"""Return True if the machine can reach the internet.
|
||||||
|
|
||||||
|
Some VM NAT setups and managed networks block ICMP even when HTTPS works,
|
||||||
|
so keep the existing ping checks but fall back to small HTTPS requests.
|
||||||
|
"""
|
||||||
|
checks = [
|
||||||
|
["ping", "-c", "1", "-W", "5", "nixos.org"],
|
||||||
|
["curl", "--location", "--fail", "--silent", "--show-error", "--connect-timeout", "5", "--max-time", "10", "--output", "/dev/null", "https://cache.nixos.org/nix-cache-info"],
|
||||||
|
["curl", "--location", "--fail", "--silent", "--show-error", "--connect-timeout", "5", "--max-time", "10", "--output", "/dev/null", "https://nixos.org/"],
|
||||||
|
["ping", "-c", "1", "-W", "5", "1.1.1.1"],
|
||||||
|
]
|
||||||
|
|
||||||
|
for cmd in checks:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return True
|
||||||
|
log(f"Connectivity check failed ({' '.join(cmd)}): {result.stderr.strip() or result.stdout.strip()}")
|
||||||
|
except Exception as e:
|
||||||
|
log(f"Connectivity check failed ({' '.join(cmd)}): {e}")
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def detect_virtualization():
|
||||||
|
"""Return the VM technology name when running inside a VM, else None."""
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["ping", "-c", "1", "-W", "5", "nixos.org"],
|
["systemd-detect-virt", "--vm"],
|
||||||
capture_output=True, text=True
|
capture_output=True, text=True
|
||||||
)
|
)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
return True
|
name = result.stdout.strip()
|
||||||
|
if name == "none":
|
||||||
|
return None
|
||||||
|
return name or "virtual machine"
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# Fallback: try a second host in case DNS for nixos.org is down
|
|
||||||
try:
|
# Fallback for minimal environments where systemd-detect-virt is absent.
|
||||||
result = subprocess.run(
|
dmi_paths = [
|
||||||
["ping", "-c", "1", "-W", "5", "1.1.1.1"],
|
"/sys/class/dmi/id/product_name",
|
||||||
capture_output=True, text=True
|
"/sys/class/dmi/id/sys_vendor",
|
||||||
)
|
"/sys/class/dmi/id/board_vendor",
|
||||||
return result.returncode == 0
|
]
|
||||||
except Exception:
|
markers = (
|
||||||
return False
|
"kvm", "qemu", "virtualbox", "vmware", "hyper-v",
|
||||||
|
"bhyve", "parallels", "xen", "bochs", "virtual",
|
||||||
|
)
|
||||||
|
for dmi_path in dmi_paths:
|
||||||
|
try:
|
||||||
|
value = open(dmi_path, "r", encoding="utf-8", errors="ignore").read().strip()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
lowered = value.lower()
|
||||||
|
if any(marker in lowered for marker in markers):
|
||||||
|
return value or "virtual machine"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def detect_boot_mode():
|
||||||
|
"""Return the firmware mode used to boot the installer ISO."""
|
||||||
|
return "uefi" if os.path.isdir("/sys/firmware/efi") else "bios"
|
||||||
|
|
||||||
|
def boot_mode_label(mode):
|
||||||
|
if mode == "uefi":
|
||||||
|
return "UEFI (systemd-boot)"
|
||||||
|
return "Legacy BIOS (GRUB)"
|
||||||
|
|
||||||
|
def nix_string(value):
|
||||||
|
"""Quote a Python string for a simple Nix string literal."""
|
||||||
|
escaped = value.replace("\\", "\\\\")
|
||||||
|
escaped = escaped.replace('"', '\\"')
|
||||||
|
escaped = escaped.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
|
||||||
|
escaped = escaped.replace("${", "\\${")
|
||||||
|
return f'"{escaped}"'
|
||||||
|
|
||||||
def symbolic_icon(name):
|
def symbolic_icon(name):
|
||||||
"""Create a crisp symbolic icon suitable for use as an ActionRow prefix."""
|
"""Create a crisp symbolic icon suitable for use as an ActionRow prefix."""
|
||||||
@@ -159,6 +215,10 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
self.data_size = None
|
self.data_size = None
|
||||||
self.data_drive_has_timechain = False
|
self.data_drive_has_timechain = False
|
||||||
self.free_password = None
|
self.free_password = None
|
||||||
|
self.virtualization = detect_virtualization()
|
||||||
|
self.boot_mode = detect_boot_mode()
|
||||||
|
|
||||||
|
log(f"Installer environment: boot_mode={self.boot_mode}, virtualization={self.virtualization or 'none'}")
|
||||||
|
|
||||||
# Root navigation view
|
# Root navigation view
|
||||||
self.nav = Adw.NavigationView()
|
self.nav = Adw.NavigationView()
|
||||||
@@ -343,7 +403,7 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
if os.path.exists(LOGO):
|
if os.path.exists(LOGO):
|
||||||
try:
|
try:
|
||||||
img = Gtk.Image.new_from_file(LOGO)
|
img = Gtk.Image.new_from_file(LOGO)
|
||||||
img.set_pixel_size(480)
|
img.set_pixel_size(320 if self.virtualization else 480)
|
||||||
hero.append(img)
|
hero.append(img)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -397,6 +457,42 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
notice_frame.set_child(notice_box)
|
notice_frame.set_child(notice_box)
|
||||||
outer.append(notice_frame)
|
outer.append(notice_frame)
|
||||||
|
|
||||||
|
if self.virtualization:
|
||||||
|
vm_frame = Gtk.Frame()
|
||||||
|
vm_frame.add_css_class("card")
|
||||||
|
vm_frame.set_margin_start(40)
|
||||||
|
vm_frame.set_margin_end(40)
|
||||||
|
vm_frame.set_margin_top(12)
|
||||||
|
vm_frame.set_margin_bottom(4)
|
||||||
|
|
||||||
|
vm_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
||||||
|
vm_box.set_margin_top(12)
|
||||||
|
vm_box.set_margin_bottom(12)
|
||||||
|
vm_box.set_margin_start(16)
|
||||||
|
vm_box.set_margin_end(16)
|
||||||
|
|
||||||
|
vm_icon = symbolic_icon("computer-symbolic")
|
||||||
|
vm_icon.set_valign(Gtk.Align.START)
|
||||||
|
vm_box.append(vm_icon)
|
||||||
|
|
||||||
|
vm_lbl = Gtk.Label()
|
||||||
|
vm_lbl.set_use_markup(True)
|
||||||
|
vm_lbl.set_wrap(True)
|
||||||
|
vm_lbl.set_xalign(0)
|
||||||
|
vm_lbl.set_halign(Gtk.Align.FILL)
|
||||||
|
vm_lbl.set_markup(
|
||||||
|
f"<span weight='bold'>Virtual machine detected: {GLib.markup_escape_text(self.virtualization)}</span>\n"
|
||||||
|
f"Boot mode: <span weight='bold'>{boot_mode_label(self.boot_mode)}</span>. "
|
||||||
|
"For the smoothest VM test, use 8+ GB RAM, NAT/bridged networking, "
|
||||||
|
"and a thin-provisioned virtual OS disk of at least 256 GB. "
|
||||||
|
"For Node or Server + Desktop, attach a second 2 TB virtual data disk; "
|
||||||
|
"otherwise choose Desktop Only."
|
||||||
|
)
|
||||||
|
vm_box.append(vm_lbl)
|
||||||
|
|
||||||
|
vm_frame.set_child(vm_box)
|
||||||
|
outer.append(vm_frame)
|
||||||
|
|
||||||
# Role label
|
# Role label
|
||||||
role_lbl = Gtk.Label()
|
role_lbl = Gtk.Label()
|
||||||
role_lbl.set_markup("<span size='medium' weight='bold'>Choose your installation type:</span>")
|
role_lbl.set_markup("<span size='medium' weight='bold'>Choose your installation type:</span>")
|
||||||
@@ -448,7 +544,10 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
|
|
||||||
available = has_second_drive or key not in NEEDS_DATA_DRIVE
|
available = has_second_drive or key not in NEEDS_DATA_DRIVE
|
||||||
if not available:
|
if not available:
|
||||||
card.set_subtitle(desc + "\n⚠ Requires a second internal drive (not detected)")
|
drive_hint = "⚠ Requires a second internal drive (not detected)"
|
||||||
|
if self.virtualization:
|
||||||
|
drive_hint += " — in a VM, attach a second virtual disk or choose Desktop Only"
|
||||||
|
card.set_subtitle(desc + f"\n{drive_hint}")
|
||||||
card.set_sensitive(False)
|
card.set_sensitive(False)
|
||||||
else:
|
else:
|
||||||
card.set_subtitle(desc)
|
card.set_subtitle(desc)
|
||||||
@@ -531,9 +630,10 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
# ── OS Drive group ────────────────────────────────────────────
|
# ── OS Drive group ────────────────────────────────────────────
|
||||||
os_group = Adw.PreferencesGroup()
|
os_group = Adw.PreferencesGroup()
|
||||||
os_group.set_title("OS Drive (NixOS Boot + Root)")
|
os_group.set_title("OS Drive (NixOS Boot + Root)")
|
||||||
os_group.set_description(
|
os_description = "Choose the drive for the NixOS installation. Minimum 256 GB required."
|
||||||
"Choose the drive for the NixOS installation. Minimum 256 GB required."
|
if self.virtualization:
|
||||||
)
|
os_description += " In a VM, a thin-provisioned virtual disk is OK."
|
||||||
|
os_group.set_description(os_description)
|
||||||
os_group.set_margin_top(24)
|
os_group.set_margin_top(24)
|
||||||
os_group.set_margin_start(40)
|
os_group.set_margin_start(40)
|
||||||
os_group.set_margin_end(40)
|
os_group.set_margin_end(40)
|
||||||
@@ -546,6 +646,8 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
row.set_title(f"/dev/{name}")
|
row.set_title(f"/dev/{name}")
|
||||||
type_label = tran.upper() if tran else "Disk"
|
type_label = tran.upper() if tran else "Disk"
|
||||||
meets = "✓ Meets 256 GB minimum" if size >= BYTES_256GB else "✗ Below 256 GB minimum"
|
meets = "✓ Meets 256 GB minimum" if size >= BYTES_256GB else "✗ Below 256 GB minimum"
|
||||||
|
if self.virtualization and size < BYTES_256GB:
|
||||||
|
meets += " (expand the virtual disk)"
|
||||||
row.set_subtitle(f"{human_size(size)} · {type_label} — {meets}")
|
row.set_subtitle(f"{human_size(size)} · {type_label} — {meets}")
|
||||||
row.add_prefix(symbolic_icon("drive-harddisk-symbolic"))
|
row.add_prefix(symbolic_icon("drive-harddisk-symbolic"))
|
||||||
|
|
||||||
@@ -570,11 +672,14 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
if self.role != "Desktop Only":
|
if self.role != "Desktop Only":
|
||||||
data_group = Adw.PreferencesGroup()
|
data_group = Adw.PreferencesGroup()
|
||||||
data_group.set_title("Bitcoin Timechain & Backups Drive")
|
data_group.set_title("Bitcoin Timechain & Backups Drive")
|
||||||
data_group.set_description(
|
data_description = (
|
||||||
"💡 Tip: Always assign your LARGEST drive here. "
|
"💡 Tip: Always assign your LARGEST drive here. "
|
||||||
"The full Bitcoin timechain is over 700 GB and grows continuously — "
|
"The full Bitcoin timechain is over 700 GB and grows continuously — "
|
||||||
"a 2 TB or larger drive is required."
|
"a 2 TB or larger drive is required."
|
||||||
)
|
)
|
||||||
|
if self.virtualization:
|
||||||
|
data_description += " In a VM, attach a second 2 TB thin-provisioned virtual disk for Node or Server + Desktop."
|
||||||
|
data_group.set_description(data_description)
|
||||||
data_group.set_margin_top(20)
|
data_group.set_margin_top(20)
|
||||||
data_group.set_margin_start(40)
|
data_group.set_margin_start(40)
|
||||||
data_group.set_margin_end(40)
|
data_group.set_margin_end(40)
|
||||||
@@ -599,6 +704,8 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
row.set_title(f"/dev/{name}")
|
row.set_title(f"/dev/{name}")
|
||||||
type_label = tran.upper() if tran else "Disk"
|
type_label = tran.upper() if tran else "Disk"
|
||||||
meets = "✓ Meets 2 TB minimum" if size >= BYTES_2TB else "✗ Below 2 TB minimum"
|
meets = "✓ Meets 2 TB minimum" if size >= BYTES_2TB else "✗ Below 2 TB minimum"
|
||||||
|
if self.virtualization and size < BYTES_2TB:
|
||||||
|
meets += " (use a 2 TB thin-provisioned virtual disk)"
|
||||||
row.set_subtitle(f"{human_size(size)} · {type_label} — {meets}")
|
row.set_subtitle(f"{human_size(size)} · {type_label} — {meets}")
|
||||||
row.add_prefix(symbolic_icon("drive-harddisk-symbolic"))
|
row.add_prefix(symbolic_icon("drive-harddisk-symbolic"))
|
||||||
|
|
||||||
@@ -655,10 +762,16 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
dlg = Adw.MessageDialog()
|
dlg = Adw.MessageDialog()
|
||||||
dlg.set_transient_for(self)
|
dlg.set_transient_for(self)
|
||||||
dlg.set_heading("OS Drive Too Small")
|
dlg.set_heading("OS Drive Too Small")
|
||||||
dlg.set_body(
|
body = (
|
||||||
f"The selected OS drive (/dev/{os_name}, {human_size(os_size)}) "
|
f"The selected OS drive (/dev/{os_name}, {human_size(os_size)}) "
|
||||||
f"does not meet the 256 GB minimum. Please choose a larger drive."
|
f"does not meet the 256 GB minimum. Please choose a larger drive."
|
||||||
)
|
)
|
||||||
|
if self.virtualization:
|
||||||
|
body += (
|
||||||
|
"\n\nVM tip: expand the virtual OS disk to at least 256 GB. "
|
||||||
|
"Thin-provisioned disks usually do not consume the full size immediately."
|
||||||
|
)
|
||||||
|
dlg.set_body(body)
|
||||||
dlg.add_response("ok", "OK")
|
dlg.add_response("ok", "OK")
|
||||||
dlg.present()
|
dlg.present()
|
||||||
return
|
return
|
||||||
@@ -668,12 +781,18 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
dlg = Adw.MessageDialog()
|
dlg = Adw.MessageDialog()
|
||||||
dlg.set_transient_for(self)
|
dlg.set_transient_for(self)
|
||||||
dlg.set_heading("Bitcoin Drive Too Small")
|
dlg.set_heading("Bitcoin Drive Too Small")
|
||||||
dlg.set_body(
|
body = (
|
||||||
f"The selected Bitcoin Timechain & Backups drive "
|
f"The selected Bitcoin Timechain & Backups drive "
|
||||||
f"(/dev/{data_name}, {human_size(data_size)}) "
|
f"(/dev/{data_name}, {human_size(data_size)}) "
|
||||||
f"does not meet the 2 TB minimum. "
|
f"does not meet the 2 TB minimum. "
|
||||||
f"Please choose a larger drive or select \"None\"."
|
f"Please choose a larger drive or select \"None\"."
|
||||||
)
|
)
|
||||||
|
if self.virtualization:
|
||||||
|
body += (
|
||||||
|
"\n\nVM tip: attach a second 2 TB thin-provisioned virtual disk "
|
||||||
|
"for Node or Server + Desktop test installs."
|
||||||
|
)
|
||||||
|
dlg.set_body(body)
|
||||||
dlg.add_response("ok", "OK")
|
dlg.add_response("ok", "OK")
|
||||||
dlg.present()
|
dlg.present()
|
||||||
return
|
return
|
||||||
@@ -829,7 +948,12 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
# ── Worker: partition ─────────────────────────────────────────────────
|
# ── Worker: partition ─────────────────────────────────────────────────
|
||||||
|
|
||||||
def partition_path(self, dev_path, num):
|
def partition_path(self, dev_path, num):
|
||||||
return f"{dev_path}p{num}" if "nvme" in dev_path else f"{dev_path}{num}"
|
base = os.path.basename(dev_path)
|
||||||
|
needs_p = (
|
||||||
|
base.startswith(("nvme", "mmcblk", "loop", "md")) or
|
||||||
|
(base[-1:].isdigit())
|
||||||
|
)
|
||||||
|
return f"{dev_path}p{num}" if needs_p else f"{dev_path}{num}"
|
||||||
|
|
||||||
def detect_existing_timechain_data(self, data_path, buf=None):
|
def detect_existing_timechain_data(self, data_path, buf=None):
|
||||||
data_p1 = self.partition_path(data_path, 1)
|
data_p1 = self.partition_path(data_path, 1)
|
||||||
@@ -900,12 +1024,19 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
|
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
|
|
||||||
# ── Partition boot disk: 512M ESP + rest as root ──
|
# ── Partition boot disk ──
|
||||||
GLib.idle_add(append_text, buf, "\n=== Partitioning boot disk ===\n")
|
if self.boot_mode == "uefi":
|
||||||
run_stream(["sudo", "sgdisk",
|
GLib.idle_add(append_text, buf, "\n=== Partitioning boot disk (UEFI) ===\n")
|
||||||
"-n", "1:1M:+512M", "-t", "1:EF00", "-c", "1:ESP",
|
run_stream(["sudo", "sgdisk",
|
||||||
"-n", "2:0:0", "-t", "2:8300", "-c", "2:root",
|
"-n", "1:1M:+512M", "-t", "1:EF00", "-c", "1:ESP",
|
||||||
boot_path], buf)
|
"-n", "2:0:0", "-t", "2:8300", "-c", "2:root",
|
||||||
|
boot_path], buf)
|
||||||
|
else:
|
||||||
|
GLib.idle_add(append_text, buf, "\n=== Partitioning boot disk (Legacy BIOS) ===\n")
|
||||||
|
run_stream(["sudo", "sgdisk",
|
||||||
|
"-n", "1:1M:+1M", "-t", "1:EF02", "-c", "1:BIOS-boot",
|
||||||
|
"-n", "2:0:0", "-t", "2:8300", "-c", "2:root",
|
||||||
|
boot_path], buf)
|
||||||
|
|
||||||
run_stream(["sudo", "partprobe", boot_path], buf)
|
run_stream(["sudo", "partprobe", boot_path], buf)
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
@@ -925,7 +1056,10 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
boot_p1 = self.partition_path(boot_path, 1)
|
boot_p1 = self.partition_path(boot_path, 1)
|
||||||
boot_p2 = self.partition_path(boot_path, 2)
|
boot_p2 = self.partition_path(boot_path, 2)
|
||||||
|
|
||||||
run_stream(["sudo", "mkfs.vfat", "-F", "32", boot_p1], buf)
|
if self.boot_mode == "uefi":
|
||||||
|
run_stream(["sudo", "mkfs.vfat", "-F", "32", boot_p1], buf)
|
||||||
|
else:
|
||||||
|
GLib.idle_add(append_text, buf, "Skipping ESP format for Legacy BIOS install.\n")
|
||||||
run_stream(["sudo", "mkfs.ext4", "-F", "-L", "sovran_systemsos", boot_p2], buf)
|
run_stream(["sudo", "mkfs.ext4", "-F", "-L", "sovran_systemsos", boot_p2], buf)
|
||||||
|
|
||||||
if data_path and not self.data_drive_has_timechain:
|
if data_path and not self.data_drive_has_timechain:
|
||||||
@@ -935,8 +1069,11 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
# ── Mount filesystems ──
|
# ── Mount filesystems ──
|
||||||
GLib.idle_add(append_text, buf, "\n=== Mounting filesystems ===\n")
|
GLib.idle_add(append_text, buf, "\n=== Mounting filesystems ===\n")
|
||||||
run_stream(["sudo", "mount", boot_p2, "/mnt"], buf)
|
run_stream(["sudo", "mount", boot_p2, "/mnt"], buf)
|
||||||
run_stream(["sudo", "mkdir", "-p", "/mnt/boot/efi"], buf)
|
if self.boot_mode == "uefi":
|
||||||
run_stream(["sudo", "mount", "-o", "umask=0077,defaults", boot_p1, "/mnt/boot/efi"], buf)
|
run_stream(["sudo", "mkdir", "-p", "/mnt/boot/efi"], buf)
|
||||||
|
run_stream(["sudo", "mount", "-o", "umask=0077,defaults", boot_p1, "/mnt/boot/efi"], buf)
|
||||||
|
else:
|
||||||
|
GLib.idle_add(append_text, buf, "Legacy BIOS install: /boot/efi mount not required.\n")
|
||||||
|
|
||||||
if data_path:
|
if data_path:
|
||||||
data_p1 = self.partition_path(data_path, 1)
|
data_p1 = self.partition_path(data_path, 1)
|
||||||
@@ -966,12 +1103,33 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
is_server = str(self.role == "Server+Desktop").lower()
|
is_server = str(self.role == "Server+Desktop").lower()
|
||||||
is_desktop = str(self.role == "Desktop Only").lower()
|
is_desktop = str(self.role == "Desktop Only").lower()
|
||||||
is_node = str(self.role == "Node (Bitcoin-only)").lower()
|
is_node = str(self.role == "Node (Bitcoin-only)").lower()
|
||||||
|
boot_overrides = ""
|
||||||
|
if self.boot_mode == "bios":
|
||||||
|
grub_device = nix_string(f"/dev/{self.boot_disk}")
|
||||||
|
boot_overrides = f"""
|
||||||
|
|
||||||
|
# The installer ISO was booted in Legacy BIOS mode, so install GRUB to the
|
||||||
|
# target disk instead of using the default UEFI systemd-boot configuration.
|
||||||
|
boot.loader.systemd-boot.enable = lib.mkForce false;
|
||||||
|
boot.loader.efi.canTouchEfiVariables = lib.mkForce false;
|
||||||
|
boot.loader.grub.enable = lib.mkForce true;
|
||||||
|
boot.loader.grub.device = lib.mkForce {grub_device};
|
||||||
|
fileSystems."/boot/efi".enable = lib.mkForce false;
|
||||||
|
"""
|
||||||
|
elif self.virtualization:
|
||||||
|
boot_overrides = """
|
||||||
|
|
||||||
|
# VM UEFI firmware can reject or forget NVRAM boot-entry writes. Keep the
|
||||||
|
# normal UEFI systemd-boot layout, but use the fallback removable path instead
|
||||||
|
# of depending on EFI variable updates.
|
||||||
|
boot.loader.efi.canTouchEfiVariables = lib.mkForce false;
|
||||||
|
"""
|
||||||
content = f"""# THIS FILE IS AUTO-GENERATED BY THE INSTALLER. DO NOT EDIT.
|
content = f"""# THIS FILE IS AUTO-GENERATED BY THE INSTALLER. DO NOT EDIT.
|
||||||
{{ config, lib, ... }}:
|
{{ config, lib, ... }}:
|
||||||
{{
|
{{
|
||||||
sovran_systemsOS.roles.server_plus_desktop = lib.mkDefault {is_server};
|
sovran_systemsOS.roles.server_plus_desktop = lib.mkDefault {is_server};
|
||||||
sovran_systemsOS.roles.desktop = lib.mkDefault {is_desktop};
|
sovran_systemsOS.roles.desktop = lib.mkDefault {is_desktop};
|
||||||
sovran_systemsOS.roles.node = lib.mkDefault {is_node};
|
sovran_systemsOS.roles.node = lib.mkDefault {is_node};{boot_overrides}
|
||||||
}}
|
}}
|
||||||
"""
|
"""
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
@@ -1008,6 +1166,11 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
boot_row.set_subtitle(f"/dev/{self.boot_disk} — {human_size(self.boot_size)}")
|
boot_row.set_subtitle(f"/dev/{self.boot_disk} — {human_size(self.boot_size)}")
|
||||||
details.add(boot_row)
|
details.add(boot_row)
|
||||||
|
|
||||||
|
boot_mode_row = Adw.ActionRow()
|
||||||
|
boot_mode_row.set_title("Boot Mode")
|
||||||
|
boot_mode_row.set_subtitle(boot_mode_label(self.boot_mode))
|
||||||
|
details.add(boot_mode_row)
|
||||||
|
|
||||||
if self.data_disk:
|
if self.data_disk:
|
||||||
data_row = Adw.ActionRow()
|
data_row = Adw.ActionRow()
|
||||||
data_row.set_title("Data Disk")
|
data_row.set_title("Data Disk")
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ Options:
|
|||||||
--deploy-key "ssh-ed25519 AAAA..." SSH pubkey for remote access after install
|
--deploy-key "ssh-ed25519 AAAA..." SSH pubkey for remote access after install
|
||||||
--headscale-server URL Headscale login server for post-install Tailnet
|
--headscale-server URL Headscale login server for post-install Tailnet
|
||||||
--headscale-key KEY Headscale pre-auth key for the installed OS
|
--headscale-key KEY Headscale pre-auth key for the installed OS
|
||||||
|
|
||||||
|
VM notes:
|
||||||
|
Desktop test installs still need a 256 GB OS disk (thin-provisioned is OK).
|
||||||
|
Node/server installs need a separate 2 TB data disk. UEFI is preferred; UEFI
|
||||||
|
VMs avoid NVRAM boot-entry writes, and legacy BIOS VMs use GRUB.
|
||||||
USAGE
|
USAGE
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +30,8 @@ DEPLOY_KEY=""
|
|||||||
HEADSCALE_SERVER=""
|
HEADSCALE_SERVER=""
|
||||||
HEADSCALE_KEY=""
|
HEADSCALE_KEY=""
|
||||||
DATA_DISK_HAS_TIMECHAIN=false
|
DATA_DISK_HAS_TIMECHAIN=false
|
||||||
|
BOOT_MODE=""
|
||||||
|
VIRTUALIZATION=""
|
||||||
|
|
||||||
FLAKE="/etc/sovran/flake"
|
FLAKE="/etc/sovran/flake"
|
||||||
LOG="/tmp/sovran-headless-install.log"
|
LOG="/tmp/sovran-headless-install.log"
|
||||||
@@ -69,6 +76,22 @@ case "$ROLE" in
|
|||||||
*) die "--role must be one of: server, desktop, node" ;;
|
*) die "--role must be one of: server, desktop, node" ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
# ── Detect firmware / VM environment ─────────────────────────────────────────
|
||||||
|
if [[ -d /sys/firmware/efi ]]; then
|
||||||
|
BOOT_MODE="uefi"
|
||||||
|
else
|
||||||
|
BOOT_MODE="bios"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v systemd-detect-virt >/dev/null 2>&1; then
|
||||||
|
if VIRT_RESULT=$(systemd-detect-virt --vm 2>/dev/null); then
|
||||||
|
[[ "$VIRT_RESULT" != "none" ]] && VIRTUALIZATION="$VIRT_RESULT"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Boot mode: ${BOOT_MODE} ($([[ "$BOOT_MODE" == uefi ]] && echo systemd-boot || echo GRUB))"
|
||||||
|
[[ -n "$VIRTUALIZATION" ]] && log "Virtual machine detected: ${VIRTUALIZATION}"
|
||||||
|
|
||||||
# ── Validate disk existence and size ─────────────────────────────────────────
|
# ── Validate disk existence and size ─────────────────────────────────────────
|
||||||
log "=== Validating disks ==="
|
log "=== Validating disks ==="
|
||||||
|
|
||||||
@@ -96,13 +119,26 @@ fi
|
|||||||
# ── Helper: partition suffix ──────────────────────────────────────────────────
|
# ── Helper: partition suffix ──────────────────────────────────────────────────
|
||||||
part_suffix() {
|
part_suffix() {
|
||||||
local dev="$1" n="$2"
|
local dev="$1" n="$2"
|
||||||
if [[ "$dev" == *nvme* ]]; then
|
local base
|
||||||
|
base=$(basename "$dev")
|
||||||
|
if [[ "$base" == nvme* || "$base" == mmcblk* || "$base" == loop* || "$base" == md* || "$base" =~ [0-9]$ ]]; then
|
||||||
echo "${dev}p${n}"
|
echo "${dev}p${n}"
|
||||||
else
|
else
|
||||||
echo "${dev}${n}"
|
echo "${dev}${n}"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nix_string() {
|
||||||
|
local value="$1"
|
||||||
|
value="${value//\\/\\\\}"
|
||||||
|
value="${value//\"/\\\"}"
|
||||||
|
value="${value//$'\n'/\\n}"
|
||||||
|
value="${value//$'\r'/\\r}"
|
||||||
|
value="${value//$'\t'/\\t}"
|
||||||
|
value="${value//\$\{/\\\$\{}"
|
||||||
|
printf '"%s"' "$value"
|
||||||
|
}
|
||||||
|
|
||||||
# ── Detect existing Bitcoin timechain data on data disk ───────────────────────
|
# ── Detect existing Bitcoin timechain data on data disk ───────────────────────
|
||||||
if [[ -n "$DATA_DISK" ]]; then
|
if [[ -n "$DATA_DISK" ]]; then
|
||||||
DATA_P1=$(part_suffix "$DATA_DISK" 1)
|
DATA_P1=$(part_suffix "$DATA_DISK" 1)
|
||||||
@@ -142,12 +178,19 @@ partprobe "$DISK"
|
|||||||
sleep 2
|
sleep 2
|
||||||
|
|
||||||
# ── Step 2: Partition OS disk ─────────────────────────────────────────────────
|
# ── Step 2: Partition OS disk ─────────────────────────────────────────────────
|
||||||
log "=== Partitioning OS disk ==="
|
if [[ "$BOOT_MODE" == "uefi" ]]; then
|
||||||
|
log "=== Partitioning OS disk (UEFI) ==="
|
||||||
sgdisk \
|
sgdisk \
|
||||||
-n "1:1M:+512M" -t "1:EF00" -c "1:ESP" \
|
-n "1:1M:+512M" -t "1:EF00" -c "1:ESP" \
|
||||||
-n "2:0:0" -t "2:8300" -c "2:root" \
|
-n "2:0:0" -t "2:8300" -c "2:root" \
|
||||||
"$DISK"
|
"$DISK"
|
||||||
|
else
|
||||||
|
log "=== Partitioning OS disk (Legacy BIOS) ==="
|
||||||
|
sgdisk \
|
||||||
|
-n "1:1M:+1M" -t "1:EF02" -c "1:BIOS-boot" \
|
||||||
|
-n "2:0:0" -t "2:8300" -c "2:root" \
|
||||||
|
"$DISK"
|
||||||
|
fi
|
||||||
|
|
||||||
partprobe "$DISK"
|
partprobe "$DISK"
|
||||||
sleep 2
|
sleep 2
|
||||||
@@ -168,7 +211,11 @@ log "=== Formatting partitions ==="
|
|||||||
BOOT_P1=$(part_suffix "$DISK" 1)
|
BOOT_P1=$(part_suffix "$DISK" 1)
|
||||||
BOOT_P2=$(part_suffix "$DISK" 2)
|
BOOT_P2=$(part_suffix "$DISK" 2)
|
||||||
|
|
||||||
mkfs.vfat -F 32 "$BOOT_P1"
|
if [[ "$BOOT_MODE" == "uefi" ]]; then
|
||||||
|
mkfs.vfat -F 32 "$BOOT_P1"
|
||||||
|
else
|
||||||
|
log "Skipping ESP format for Legacy BIOS install"
|
||||||
|
fi
|
||||||
mkfs.ext4 -F -L sovran_systemsos "$BOOT_P2"
|
mkfs.ext4 -F -L sovran_systemsos "$BOOT_P2"
|
||||||
|
|
||||||
if [[ -n "$DATA_DISK" && "$DATA_DISK_HAS_TIMECHAIN" != true ]]; then
|
if [[ -n "$DATA_DISK" && "$DATA_DISK_HAS_TIMECHAIN" != true ]]; then
|
||||||
@@ -180,8 +227,12 @@ fi
|
|||||||
log "=== Mounting filesystems ==="
|
log "=== Mounting filesystems ==="
|
||||||
|
|
||||||
mount "$BOOT_P2" /mnt
|
mount "$BOOT_P2" /mnt
|
||||||
mkdir -p /mnt/boot/efi
|
if [[ "$BOOT_MODE" == "uefi" ]]; then
|
||||||
mount -o umask=0077,defaults "$BOOT_P1" /mnt/boot/efi
|
mkdir -p /mnt/boot/efi
|
||||||
|
mount -o umask=0077,defaults "$BOOT_P1" /mnt/boot/efi
|
||||||
|
else
|
||||||
|
log "Legacy BIOS install: /boot/efi mount not required"
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ -n "$DATA_DISK" ]]; then
|
if [[ -n "$DATA_DISK" ]]; then
|
||||||
DATA_P1=$(part_suffix "$DATA_DISK" 1)
|
DATA_P1=$(part_suffix "$DATA_DISK" 1)
|
||||||
@@ -219,16 +270,41 @@ case "$ROLE" in
|
|||||||
IS_SERVER=false; IS_DESKTOP=false; IS_NODE=true ;;
|
IS_SERVER=false; IS_DESKTOP=false; IS_NODE=true ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
cat > /mnt/etc/nixos/role-state.nix <<EOF
|
{
|
||||||
|
cat <<EOF
|
||||||
# THIS FILE IS AUTO-GENERATED BY THE INSTALLER. DO NOT EDIT.
|
# THIS FILE IS AUTO-GENERATED BY THE INSTALLER. DO NOT EDIT.
|
||||||
{ config, lib, ... }:
|
{ config, lib, ... }:
|
||||||
{
|
{
|
||||||
sovran_systemsOS.roles.server_plus_desktop = lib.mkDefault ${IS_SERVER};
|
sovran_systemsOS.roles.server_plus_desktop = lib.mkDefault ${IS_SERVER};
|
||||||
sovran_systemsOS.roles.desktop = lib.mkDefault ${IS_DESKTOP};
|
sovran_systemsOS.roles.desktop = lib.mkDefault ${IS_DESKTOP};
|
||||||
sovran_systemsOS.roles.node = lib.mkDefault ${IS_NODE};
|
sovran_systemsOS.roles.node = lib.mkDefault ${IS_NODE};
|
||||||
}
|
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
if [[ "$BOOT_MODE" == "bios" ]]; then
|
||||||
|
GRUB_DEVICE=$(nix_string "$DISK")
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
# The installer ISO was booted in Legacy BIOS mode, so install GRUB to the
|
||||||
|
# target disk instead of using the default UEFI systemd-boot configuration.
|
||||||
|
boot.loader.systemd-boot.enable = lib.mkForce false;
|
||||||
|
boot.loader.efi.canTouchEfiVariables = lib.mkForce false;
|
||||||
|
boot.loader.grub.enable = lib.mkForce true;
|
||||||
|
boot.loader.grub.device = lib.mkForce ${GRUB_DEVICE};
|
||||||
|
fileSystems."/boot/efi".enable = lib.mkForce false;
|
||||||
|
EOF
|
||||||
|
elif [[ -n "$VIRTUALIZATION" ]]; then
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
# VM UEFI firmware can reject or forget NVRAM boot-entry writes. Keep the
|
||||||
|
# normal UEFI systemd-boot layout, but use the fallback removable path instead
|
||||||
|
# of depending on EFI variable updates.
|
||||||
|
boot.loader.efi.canTouchEfiVariables = lib.mkForce false;
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "}"
|
||||||
|
} > /mnt/etc/nixos/role-state.nix
|
||||||
|
|
||||||
# ── Step 10: Write custom.nix with deploy config ──────────────────────────────
|
# ── Step 10: Write custom.nix with deploy config ──────────────────────────────
|
||||||
log "=== Writing custom.nix ==="
|
log "=== Writing custom.nix ==="
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,16 @@
|
|||||||
let
|
let
|
||||||
cfg = config.sovran_systemsOS;
|
cfg = config.sovran_systemsOS;
|
||||||
|
|
||||||
|
# Read the OS version from the repo-root VERSION file at eval time so the
|
||||||
|
# Hub always ships a real, baked-in version string instead of relying on
|
||||||
|
# a runtime file lookup (which returns "dev" when /etc/nixos/VERSION is
|
||||||
|
# missing, e.g. in a dev checkout — this is the "vdev" bug in the Hub UI).
|
||||||
|
versionFile = ../../VERSION;
|
||||||
|
sovranVersion =
|
||||||
|
if builtins.pathExists versionFile
|
||||||
|
then builtins.replaceStrings [ "v" "\n" "\r" ] [ "" "" "" ] (builtins.readFile versionFile)
|
||||||
|
else "1.0.0";
|
||||||
|
|
||||||
monitoredServices =
|
monitoredServices =
|
||||||
# ── Infrastructure — System Passwords (always present) ─────
|
# ── Infrastructure — System Passwords (always present) ─────
|
||||||
[
|
[
|
||||||
@@ -55,7 +65,6 @@ let
|
|||||||
]; }
|
]; }
|
||||||
{ name = "Zeus Connect"; unit = "zeus-connect-setup.service"; type = "system"; icon = "zeus"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
|
{ name = "Zeus Connect"; unit = "zeus-connect-setup.service"; type = "system"; icon = "zeus"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
|
||||||
{ label = "QR Code"; file = "/var/lib/secrets/zeus-connect-url"; qrcode = true; qronly = true; }
|
{ label = "QR Code"; file = "/var/lib/secrets/zeus-connect-url"; qrcode = true; qronly = true; }
|
||||||
{ label = "How to Connect"; value = "1. Download Zeus from App Store or Google Play\n2. Open Zeus → Scan Node Config\n3. Scan the QR code above"; }
|
|
||||||
]; }
|
]; }
|
||||||
{ name = "Sparrow Auto-Link"; unit = "sparrow-autoconnect.service"; type = "system"; icon = "sparrow"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
|
{ name = "Sparrow Auto-Link"; unit = "sparrow-autoconnect.service"; type = "system"; icon = "sparrow"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
|
||||||
{ label = "Server"; value = "tcp://127.0.0.1:50001 (Electrs)"; }
|
{ label = "Server"; value = "tcp://127.0.0.1:50001 (Electrs)"; }
|
||||||
@@ -116,6 +125,7 @@ let
|
|||||||
role = activeRole;
|
role = activeRole;
|
||||||
services = monitoredServices;
|
services = monitoredServices;
|
||||||
feature_manager = true;
|
feature_manager = true;
|
||||||
|
sovran_version = sovranVersion;
|
||||||
});
|
});
|
||||||
|
|
||||||
generatedVersions = pkgs.writeText "sovran-hub-versions.json" (builtins.toJSON {
|
generatedVersions = pkgs.writeText "sovran-hub-versions.json" (builtins.toJSON {
|
||||||
@@ -331,6 +341,7 @@ let
|
|||||||
|
|
||||||
cp ${generatedConfig} $out/lib/sovran-hub-web/config.json
|
cp ${generatedConfig} $out/lib/sovran-hub-web/config.json
|
||||||
cp ${generatedVersions} $out/lib/sovran-hub-web/versions.json
|
cp ${generatedVersions} $out/lib/sovran-hub-web/versions.json
|
||||||
|
printf '%s' "${sovranVersion}" > $out/lib/sovran-hub-web/VERSION
|
||||||
|
|
||||||
install -d $out/share/sovran-hub/icons
|
install -d $out/share/sovran-hub/icons
|
||||||
cp icons/* $out/share/sovran-hub/icons/ 2>/dev/null || true
|
cp icons/* $out/share/sovran-hub/icons/ 2>/dev/null || true
|
||||||
|
|||||||
@@ -111,7 +111,15 @@ in
|
|||||||
echo "$ROOT_PASS" > "$SECRET_FILE"
|
echo "$ROOT_PASS" > "$SECRET_FILE"
|
||||||
chmod 600 "$SECRET_FILE"
|
chmod 600 "$SECRET_FILE"
|
||||||
fi
|
fi
|
||||||
echo "root:$(cat "$SECRET_FILE")" | chpasswd
|
# If the file contains a scrypt hash (salt:hash), skip chpasswd — the
|
||||||
|
# password was already set via the Hub security reset endpoint and this
|
||||||
|
# service is only re-running as a manual recovery step.
|
||||||
|
CONTENT="$(cat "$SECRET_FILE")"
|
||||||
|
if echo "$CONTENT" | grep -qE '^[0-9a-f]{32}:[0-9a-f]{64,}$'; then
|
||||||
|
echo "root-password-setup: stored value is already hashed — skipping chpasswd" >&2
|
||||||
|
else
|
||||||
|
echo "root:$CONTENT" | chpasswd
|
||||||
|
fi
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -229,6 +229,30 @@ CREDS
|
|||||||
chmod 600 "$CREDS_FILE"
|
chmod 600 "$CREDS_FILE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Provision a private, random Matrix administrator for the Hub's Admin
|
||||||
|
# API calls. This is deliberately separate from the visible bootstrap
|
||||||
|
# admin account: installations where @admin pre-dated Sovran do not have
|
||||||
|
# its password, but Hub user management must still work. Keeping the
|
||||||
|
# generated localpart in this root-only file makes the operation
|
||||||
|
# idempotent without exposing this service credential in the Hub UI.
|
||||||
|
HUB_ADMIN_CREDS="/var/lib/secrets/matrix-hub-admin"
|
||||||
|
if [ ! -s "$HUB_ADMIN_CREDS" ]; then
|
||||||
|
HUB_ADMIN_USER="sovran-hub-$(tr -dc 'a-z0-9' < /dev/urandom | head -c 20)"
|
||||||
|
HUB_ADMIN_PASS=$(pwgen -s 32 1)
|
||||||
|
if register_new_matrix_user -c /run/matrix-synapse/runtime-config.yaml \
|
||||||
|
-u "$HUB_ADMIN_USER" -p "$HUB_ADMIN_PASS" -a http://localhost:8008; then
|
||||||
|
(umask 077; cat > "$HUB_ADMIN_CREDS" <<CREDS
|
||||||
|
username=$HUB_ADMIN_USER
|
||||||
|
password=$HUB_ADMIN_PASS
|
||||||
|
CREDS
|
||||||
|
)
|
||||||
|
echo "Created private Matrix Hub service administrator."
|
||||||
|
else
|
||||||
|
echo "Failed to create the private Matrix Hub service administrator." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# Always write individual credential files for the hub UI, even if the bulk
|
# Always write individual credential files for the hub UI, even if the bulk
|
||||||
# credentials file already existed from a prior run (umask 077 ensures mode 600).
|
# credentials file already existed from a prior run (umask 077 ensures mode 600).
|
||||||
# If passwords were not freshly generated above, parse them from the bulk file.
|
# If passwords were not freshly generated above, parse them from the bulk file.
|
||||||
|
|||||||
+185
-39
@@ -29,11 +29,31 @@ CYAN='\033[0;36m'
|
|||||||
NC='\033[0m'
|
NC='\033[0m'
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
GITEA_REMOTE="gitea"
|
GITEA_REMOTE_DEFAULT="gitea"
|
||||||
GITHUB_REMOTE="origin"
|
GITHUB_REMOTE_DEFAULT="origin"
|
||||||
GITEA_API_URL="https://git.sovransystems.com/api/v1"
|
GITEA_API_URL="https://git.sovransystems.com/api/v1"
|
||||||
CHANGELOG_FILE="CHANGELOG.md"
|
CHANGELOG_FILE="CHANGELOG.md"
|
||||||
|
|
||||||
|
# Auto-detect remotes
|
||||||
|
detect_remote() {
|
||||||
|
local preferred="$1"
|
||||||
|
local keyword="$2"
|
||||||
|
if git remote | grep -q -x "$preferred"; then
|
||||||
|
echo "$preferred"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
local found
|
||||||
|
found=$(git remote -v | grep -i "$keyword" | head -n 1 | awk '{print $1}')
|
||||||
|
if [[ -n "$found" ]]; then
|
||||||
|
echo "$found"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
echo "$preferred"
|
||||||
|
}
|
||||||
|
|
||||||
|
GITEA_REMOTE=$(detect_remote "$GITEA_REMOTE_DEFAULT" "sovransystems\|gitea")
|
||||||
|
GITHUB_REMOTE=$(detect_remote "$GITHUB_REMOTE_DEFAULT" "github")
|
||||||
|
|
||||||
echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}"
|
echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}"
|
||||||
echo -e "${BLUE}║ Sovran_SystemsOS Automated Stable Release Script ║${NC}"
|
echo -e "${BLUE}║ Sovran_SystemsOS Automated Stable Release Script ║${NC}"
|
||||||
echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}"
|
echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}"
|
||||||
@@ -43,7 +63,12 @@ echo
|
|||||||
# Helper: Get latest tag
|
# Helper: Get latest tag
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
get_latest_tag() {
|
get_latest_tag() {
|
||||||
git tag --list 'v*' --sort=-version:refname | head -1 || echo ""
|
local exclude="${1:-}"
|
||||||
|
if [[ -n "$exclude" ]]; then
|
||||||
|
git tag --list 'v*' --sort=-version:refname | grep -v -x -E "v?${exclude#v}|${exclude}" | head -1 || echo ""
|
||||||
|
else
|
||||||
|
git tag --list 'v*' --sort=-version:refname | head -1 || echo ""
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -68,7 +93,7 @@ suggest_next_version() {
|
|||||||
# Step 0: Fetch everything
|
# Step 0: Fetch everything
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
echo -e "${BLUE}Step 0: Fetching latest from all remotes...${NC}"
|
echo -e "${BLUE}Step 0: Fetching latest from all remotes...${NC}"
|
||||||
git fetch --all --tags --prune --force || git fetch --all --tags --prune --force 2>/dev/null || true
|
git fetch --all --tags 2>/dev/null || true
|
||||||
|
|
||||||
LATEST_TAG=$(get_latest_tag)
|
LATEST_TAG=$(get_latest_tag)
|
||||||
NEXT_VERSION=$(suggest_next_version "$LATEST_TAG")
|
NEXT_VERSION=$(suggest_next_version "$LATEST_TAG")
|
||||||
@@ -110,7 +135,7 @@ done
|
|||||||
|
|
||||||
if [[ -z "$RELEASE_MESSAGE" ]]; then
|
if [[ -z "$RELEASE_MESSAGE" ]]; then
|
||||||
echo
|
echo
|
||||||
read -rp "Enter release message (or press Enter for default): " input_msg
|
read -rp "Enter release headline (or press Enter for default): " input_msg
|
||||||
if [[ -n "$input_msg" ]]; then
|
if [[ -n "$input_msg" ]]; then
|
||||||
RELEASE_MESSAGE="$input_msg"
|
RELEASE_MESSAGE="$input_msg"
|
||||||
else
|
else
|
||||||
@@ -118,6 +143,92 @@ if [[ -z "$RELEASE_MESSAGE" ]]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Helper: Generate categorized release notes from commit history
|
||||||
|
# Groups commits since the last tag into Keep-a-Changelog sections based on
|
||||||
|
# conventional-commit prefixes (feat/fix/docs/security/etc.) and keywords.
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
generate_release_notes() {
|
||||||
|
local range="$1"
|
||||||
|
|
||||||
|
local added="" changed="" fixed="" security="" docs=""
|
||||||
|
|
||||||
|
while IFS= read -r subject; do
|
||||||
|
# Skip noise commits
|
||||||
|
case "$subject" in
|
||||||
|
"Initial plan"|"initial plan") continue ;;
|
||||||
|
"Merge pull request"*|"Merge branch"*) continue ;;
|
||||||
|
"chore: bump VERSION"*|"docs: update CHANGELOG"*) continue ;;
|
||||||
|
"Address code review"*|"Address review"*|"Address validation"*) continue ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Strip conventional-commit prefix for display
|
||||||
|
local clean
|
||||||
|
clean="$(echo "$subject" | sed -E 's/^(feat|fix|docs|chore|refactor|test|security|perf|style|ci|build)(\([^)]*\))?!?:[[:space:]]*//')"
|
||||||
|
# Capitalize first letter
|
||||||
|
clean="$(echo "${clean:0:1}" | tr '[:lower:]' '[:upper:]')${clean:1}"
|
||||||
|
|
||||||
|
case "$subject" in
|
||||||
|
security:*|security\(*) security+="- ${clean}"$'\n' ;;
|
||||||
|
feat:*|feat\(*) added+="- ${clean}"$'\n' ;;
|
||||||
|
fix:*|fix\(*|Fix\ *|fixed\ *) fixed+="- ${clean}"$'\n' ;;
|
||||||
|
docs:*|docs\(*) docs+="- ${clean}"$'\n' ;;
|
||||||
|
refactor:*|refactor\(*|chore:*|chore\(*|removed\ *|Updated\ *|updated\ *) changed+="- ${clean}"$'\n' ;;
|
||||||
|
test:*|test\(*) continue ;;
|
||||||
|
*) added+="- ${clean}"$'\n' ;;
|
||||||
|
esac
|
||||||
|
done < <(git log --no-merges --format='%s' "$range" 2>/dev/null | awk '!seen[$0]++')
|
||||||
|
|
||||||
|
local notes=""
|
||||||
|
if [[ -n "$added" ]]; then notes+=$'### Added\n'"$added"$'\n'; fi
|
||||||
|
if [[ -n "$changed" ]]; then notes+=$'### Changed\n'"$changed"$'\n'; fi
|
||||||
|
if [[ -n "$fixed" ]]; then notes+=$'### Fixed\n'"$fixed"$'\n'; fi
|
||||||
|
if [[ -n "$security" ]]; then notes+=$'### Security\n'"$security"$'\n'; fi
|
||||||
|
if [[ -n "$docs" ]]; then notes+=$'### Documentation\n'"$docs"$'\n'; fi
|
||||||
|
|
||||||
|
if [[ -z "$notes" ]]; then
|
||||||
|
notes=$'### Changed\n- Incremental stable updates\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s' "$notes"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build the notes from commits since the previous tag (exclude the target tag if already present)
|
||||||
|
PREV_TAG=$(get_latest_tag "$TAG")
|
||||||
|
if [[ -n "$PREV_TAG" ]] && git rev-parse -q --verify "$PREV_TAG" >/dev/null; then
|
||||||
|
COMMIT_RANGE="${PREV_TAG}..HEAD"
|
||||||
|
else
|
||||||
|
COMMIT_RANGE="HEAD"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo -e "${BLUE}Generating draft release notes from ${COMMIT_RANGE}...${NC}"
|
||||||
|
RELEASE_NOTES="$(generate_release_notes "$COMMIT_RANGE")"
|
||||||
|
|
||||||
|
# Let the user review/edit the generated notes before publishing
|
||||||
|
NOTES_FILE="$(mktemp "/tmp/release-notes-${TAG}.XXXXXX.md")"
|
||||||
|
{
|
||||||
|
echo "## Sovran_SystemsOS ${TAG}"
|
||||||
|
echo
|
||||||
|
echo "${RELEASE_MESSAGE}"
|
||||||
|
echo
|
||||||
|
echo "$RELEASE_NOTES"
|
||||||
|
echo "**Full changelog:** [CHANGELOG.md](https://github.com/naturallaw777/Sovran_SystemsOS/blob/main/CHANGELOG.md)"
|
||||||
|
} > "$NOTES_FILE"
|
||||||
|
|
||||||
|
echo -e " ${GREEN}✓${NC} Draft notes written to: ${CYAN}${NOTES_FILE}${NC}"
|
||||||
|
echo
|
||||||
|
echo "──────────────── Draft Release Notes ────────────────"
|
||||||
|
cat "$NOTES_FILE"
|
||||||
|
echo "──────────────────────────────────────────────────────"
|
||||||
|
echo
|
||||||
|
read -rp "Edit the notes before publishing? (y/N): " edit_confirm
|
||||||
|
if [[ "$edit_confirm" =~ ^[Yy]$ ]]; then
|
||||||
|
"${EDITOR:-nano}" "$NOTES_FILE"
|
||||||
|
RELEASE_NOTES="$(sed -n '/^###/,$p' "$NOTES_FILE" | sed '/^\*\*Full changelog/d')"
|
||||||
|
fi
|
||||||
|
RELEASE_BODY="$(cat "$NOTES_FILE")"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo -e "${YELLOW}════════════════════════════════════════════════════════════${NC}"
|
echo -e "${YELLOW}════════════════════════════════════════════════════════════${NC}"
|
||||||
echo -e "${YELLOW} Preparing Release${NC}"
|
echo -e "${YELLOW} Preparing Release${NC}"
|
||||||
@@ -135,23 +246,36 @@ if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Step 1: Push main to stable
|
# Step 1: Push tested code to Gitea (stable & staging-dev)
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
echo
|
echo
|
||||||
echo -e "${BLUE}Step 1: Pushing main → stable on Gitea...${NC}"
|
echo -e "${BLUE}Step 1: Pushing tested code to Gitea (stable & staging-dev)...${NC}"
|
||||||
git push "${GITEA_REMOTE}" main:stable --force-with-lease
|
if git remote | grep -q -x "${GITEA_REMOTE}"; then
|
||||||
|
git push "${GITEA_REMOTE}" HEAD:stable --force-with-lease || echo -e " ${YELLOW}⚠ Push to ${GITEA_REMOTE} (stable) failed.${NC}"
|
||||||
|
git push "${GITEA_REMOTE}" HEAD:staging-dev --force-with-lease || echo -e " ${YELLOW}⚠ Push to ${GITEA_REMOTE} (staging-dev) failed.${NC}"
|
||||||
|
else
|
||||||
|
echo -e " ${YELLOW}⚠ Remote '${GITEA_REMOTE}' not found in git — skipping Gitea push.${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Step 2: Create annotated tag
|
# Step 2: Create annotated tag
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
echo
|
echo
|
||||||
echo -e "${BLUE}Step 2: Creating annotated tag ${TAG}...${NC}"
|
echo -e "${BLUE}Step 2: Creating annotated tag ${TAG}...${NC}"
|
||||||
git tag -a "${TAG}" -m "${RELEASE_MESSAGE}
|
git tag -f -a "${TAG}" -m "${RELEASE_MESSAGE}
|
||||||
|
|
||||||
- Stable release of Sovran_SystemsOS
|
- Stable release of Sovran_SystemsOS
|
||||||
- See CHANGELOG.md for full details"
|
- See CHANGELOG.md for full details" || true
|
||||||
|
|
||||||
git push "${GITEA_REMOTE}" "${TAG}"
|
if git remote | grep -q -x "${GITEA_REMOTE}"; then
|
||||||
|
git push "${GITEA_REMOTE}" "${TAG}" || echo -e " ${YELLOW}⚠ Tag push to ${GITEA_REMOTE} failed.${NC}"
|
||||||
|
else
|
||||||
|
echo -e " ${YELLOW}⚠ Remote '${GITEA_REMOTE}' not found in git — skipping Gitea tag push.${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if git remote | grep -q -x "${GITHUB_REMOTE}"; then
|
||||||
|
git push "${GITHUB_REMOTE}" "${TAG}" || echo -e " ${YELLOW}⚠ Tag push to ${GITHUB_REMOTE} failed.${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
# Update VERSION file for ISO builds
|
# Update VERSION file for ISO builds
|
||||||
echo "${VERSION}" > VERSION
|
echo "${VERSION}" > VERSION
|
||||||
@@ -167,19 +291,11 @@ echo -e "${BLUE}Step 3: Updating ${CHANGELOG_FILE}...${NC}"
|
|||||||
|
|
||||||
TODAY=$(date +%Y-%m-%d)
|
TODAY=$(date +%Y-%m-%d)
|
||||||
|
|
||||||
# Create new changelog entry
|
# Create new changelog entry from the generated notes (no placeholders)
|
||||||
NEW_ENTRY="## [${VERSION}] - ${TODAY}
|
NEW_ENTRY="## [${VERSION}] - ${TODAY}
|
||||||
|
|
||||||
### Added
|
${RELEASE_NOTES}
|
||||||
- (Add new features here)
|
[${VERSION}]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/${TAG}
|
||||||
|
|
||||||
### Changed
|
|
||||||
- (Add changes here)
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- (Add bug fixes here)
|
|
||||||
|
|
||||||
[${VERSION}]: ${GITEA_API_URL%/*}/Sovran_Systems/Sovran_SystemsOS/releases/tag/${TAG}
|
|
||||||
"
|
"
|
||||||
|
|
||||||
# Prepend to changelog (after the header)
|
# Prepend to changelog (after the header)
|
||||||
@@ -199,6 +315,7 @@ if [ -f "$CHANGELOG_FILE" ]; then
|
|||||||
}
|
}
|
||||||
{ print }
|
{ print }
|
||||||
' "$CHANGELOG_FILE" > "${CHANGELOG_FILE}.tmp" && mv "${CHANGELOG_FILE}.tmp" "$CHANGELOG_FILE"
|
' "$CHANGELOG_FILE" > "${CHANGELOG_FILE}.tmp" && mv "${CHANGELOG_FILE}.tmp" "$CHANGELOG_FILE"
|
||||||
|
rm -f "${CHANGELOG_FILE}.bak"
|
||||||
|
|
||||||
echo -e " ${GREEN}✓${NC} CHANGELOG.md updated with new section for ${TAG}"
|
echo -e " ${GREEN}✓${NC} CHANGELOG.md updated with new section for ${TAG}"
|
||||||
else
|
else
|
||||||
@@ -215,11 +332,24 @@ else
|
|||||||
|
|
||||||
# Ask if user wants to push
|
# Ask if user wants to push
|
||||||
echo
|
echo
|
||||||
read -rp "Push the changelog commit to GitHub now? (y/N): " push_confirm
|
read -rp "Push the changelog commit to GitHub (main) and Gitea (staging-dev) now? (y/N): " push_confirm
|
||||||
if [[ "$push_confirm" =~ ^[Yy]$ ]]; then
|
if [[ "$push_confirm" =~ ^[Yy]$ ]]; then
|
||||||
echo -e "${BLUE}Pushing changelog commit...${NC}"
|
if git remote | grep -q -x "${GITHUB_REMOTE}"; then
|
||||||
git push "${GITHUB_REMOTE}" main
|
echo -e "${BLUE}Pushing changelog commit to GitHub main...${NC}"
|
||||||
echo -e " ${GREEN}✓${NC} Changelog pushed to GitHub"
|
if git push "${GITHUB_REMOTE}" HEAD:main; then
|
||||||
|
echo -e " ${GREEN}✓${NC} Changelog pushed to GitHub main (${GITHUB_REMOTE})"
|
||||||
|
else
|
||||||
|
echo -e " ${YELLOW}⚠ Push to GitHub main failed — verify credentials or remote name.${NC}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if git remote | grep -q -x "${GITEA_REMOTE}"; then
|
||||||
|
echo -e "${BLUE}Pushing changelog commit to Gitea staging-dev...${NC}"
|
||||||
|
if git push "${GITEA_REMOTE}" HEAD:staging-dev; then
|
||||||
|
echo -e " ${GREEN}✓${NC} Changelog pushed to Gitea staging-dev (${GITEA_REMOTE})"
|
||||||
|
else
|
||||||
|
echo -e " ${YELLOW}⚠ Push to Gitea staging-dev failed.${NC}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo " (Changelog commit left local — remember to push later)"
|
echo " (Changelog commit left local — remember to push later)"
|
||||||
fi
|
fi
|
||||||
@@ -232,17 +362,18 @@ echo
|
|||||||
echo -e "${BLUE}Step 4: Creating GitHub Release...${NC}"
|
echo -e "${BLUE}Step 4: Creating GitHub Release...${NC}"
|
||||||
|
|
||||||
if command -v gh &>/dev/null; then
|
if command -v gh &>/dev/null; then
|
||||||
|
title_suffix="${RELEASE_MESSAGE#Sovran_SystemsOS v* — }"
|
||||||
|
title_suffix="${title_suffix#Sovran_SystemsOS * — }"
|
||||||
if gh release create "${TAG}" \
|
if gh release create "${TAG}" \
|
||||||
--repo naturallaw777/Sovran_SystemsOS \
|
--repo naturallaw777/Sovran_SystemsOS \
|
||||||
--title "${TAG}" \
|
--title "${TAG} — ${title_suffix}" \
|
||||||
--notes "${RELEASE_MESSAGE}" \
|
--notes-file "${NOTES_FILE}"; then
|
||||||
--target main 2>/dev/null; then
|
|
||||||
echo -e " ${GREEN}✓${NC} GitHub release created successfully"
|
echo -e " ${GREEN}✓${NC} GitHub release created successfully"
|
||||||
else
|
else
|
||||||
echo -e " ${YELLOW}⚠${NC} GitHub release may already exist or failed"
|
echo -e " ${YELLOW}⚠${NC} GitHub release creation failed (check 'gh auth status' or create via web GUI)"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo -e " ${YELLOW}⚠${NC} gh CLI not found — skipping GitHub release"
|
echo -e " ${YELLOW}⚠${NC} gh CLI not found — skipping GitHub release (create via GitHub web GUI)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -266,20 +397,28 @@ fi
|
|||||||
if [[ -n "${GITEA_TOKEN:-}" ]]; then
|
if [[ -n "${GITEA_TOKEN:-}" ]]; then
|
||||||
GITEA_REPO="Sovran_Systems/Sovran_SystemsOS"
|
GITEA_REPO="Sovran_Systems/Sovran_SystemsOS"
|
||||||
|
|
||||||
|
# Build JSON payload safely (release body may contain quotes/newlines)
|
||||||
|
if command -v jq &>/dev/null; then
|
||||||
|
PAYLOAD=$(jq -n \
|
||||||
|
--arg tag "${TAG}" \
|
||||||
|
--arg name "${TAG} — Stable Release" \
|
||||||
|
--arg body "${RELEASE_BODY}" \
|
||||||
|
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')
|
||||||
|
else
|
||||||
|
PAYLOAD=$(python3 -c "import json,sys; print(json.dumps({'tag_name': sys.argv[1], 'name': sys.argv[1] + ' — Stable Release', 'body': open(sys.argv[2]).read(), 'draft': False, 'prerelease': False}))" "${TAG}" "${NOTES_FILE}")
|
||||||
|
fi
|
||||||
|
|
||||||
RESPONSE=$(curl -s -X POST \
|
RESPONSE=$(curl -s -X POST \
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{
|
-d "${PAYLOAD}" \
|
||||||
\"tag_name\": \"${TAG}\",
|
|
||||||
\"name\": \"${TAG}\",
|
|
||||||
\"body\": \"${RELEASE_MESSAGE}\",
|
|
||||||
\"draft\": false,
|
|
||||||
\"prerelease\": false
|
|
||||||
}" \
|
|
||||||
"${GITEA_API_URL}/repos/${GITEA_REPO}/releases" 2>/dev/null || echo "")
|
"${GITEA_API_URL}/repos/${GITEA_REPO}/releases" 2>/dev/null || echo "")
|
||||||
|
|
||||||
if echo "$RESPONSE" | grep -q '"id"'; then
|
if echo "$RESPONSE" | grep -q '"id"'; then
|
||||||
echo -e " ${GREEN}✓${NC} Gitea release created successfully"
|
echo -e " ${GREEN}✓${NC} Gitea release created successfully"
|
||||||
|
elif echo "$RESPONSE" | grep -q "write:repository"; then
|
||||||
|
echo -e " ${YELLOW}⚠${NC} Gitea token scope issue: your token requires the 'write:repository' scope (currently has write:package)."
|
||||||
|
echo " To fix: In Gitea, navigate to Settings → Applications → Manage Access Tokens and generate a token with 'write:repository'."
|
||||||
else
|
else
|
||||||
echo -e " ${YELLOW}⚠${NC} Gitea release creation failed or already exists"
|
echo -e " ${YELLOW}⚠${NC} Gitea release creation failed or already exists"
|
||||||
echo " Response: $RESPONSE"
|
echo " Response: $RESPONSE"
|
||||||
@@ -295,9 +434,16 @@ echo -e "${GREEN}║ ✅ Release ${TAG} completed successfully!
|
|||||||
echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}"
|
echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}"
|
||||||
echo
|
echo
|
||||||
echo "Next manual steps (recommended):"
|
echo "Next manual steps (recommended):"
|
||||||
|
echo " • Build the installer ISO:"
|
||||||
|
echo " nix build .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage"
|
||||||
|
echo " • Package, verify, and upload ISO to CDN:"
|
||||||
|
echo " ./scripts/upload-cdn.sh --upload"
|
||||||
echo " • Review and enhance the new section in CHANGELOG.md"
|
echo " • Review and enhance the new section in CHANGELOG.md"
|
||||||
echo " • Push the changelog commit: git push origin main"
|
echo " • Push changes: git push ${GITHUB_REMOTE} HEAD:main && git push ${GITEA_REMOTE} HEAD:staging-dev"
|
||||||
echo " • Verify releases on both GitHub and Gitea"
|
echo " • Verify releases on both GitHub and Gitea"
|
||||||
echo
|
echo
|
||||||
echo -e "${CYAN}Tag created: ${TAG}${NC}"
|
echo -e "${CYAN}Tag created: ${TAG}${NC}"
|
||||||
git show "${TAG}" --quiet
|
git show "${TAG}" --quiet
|
||||||
|
|
||||||
|
# Clean up temp notes file
|
||||||
|
rm -f "${NOTES_FILE}"
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# upload-cdn.sh
|
||||||
|
# Copies built ISO out of the nix store, generates versioned SHA-256 checksum,
|
||||||
|
# verifies it, and optionally uploads to CDN.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/upload-cdn.sh [--upload]
|
||||||
|
#
|
||||||
|
# Environment variables (optional):
|
||||||
|
# CDN_RSYNC_TARGET - rsync destination (e.g. user@server:/var/www/downloads/)
|
||||||
|
# CDN_RCLONE_REMOTE - rclone remote target (e.g. s3:my-bucket/downloads/)
|
||||||
|
# CDN_UPLOAD_CMD - custom upload command
|
||||||
|
#
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
# Read version from VERSION file
|
||||||
|
if [ ! -f VERSION ]; then
|
||||||
|
echo -e "${RED}Error: VERSION file not found.${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
VERSION=$(cat VERSION | tr -d '\n\r ')
|
||||||
|
ISO_NAME="Sovran_SystemsOS-${VERSION}.iso"
|
||||||
|
SHA_NAME="${ISO_NAME}.sha256"
|
||||||
|
|
||||||
|
echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}"
|
||||||
|
echo -e "${BLUE}║ Sovran_SystemsOS CDN ISO Packaging & Upload Tool ║${NC}"
|
||||||
|
echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}"
|
||||||
|
echo -e " Version : ${CYAN}${VERSION}${NC}"
|
||||||
|
echo -e " ISO : ${CYAN}${ISO_NAME}${NC}"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Locate or build ISO
|
||||||
|
SRC_ISO=""
|
||||||
|
if [ -f "result/iso/${ISO_NAME}" ]; then
|
||||||
|
SRC_ISO="result/iso/${ISO_NAME}"
|
||||||
|
elif [ -f "result/iso/Sovran_SystemsOS.iso" ]; then
|
||||||
|
SRC_ISO="result/iso/Sovran_SystemsOS.iso"
|
||||||
|
else
|
||||||
|
FOUND=$(find result -name "*.iso" 2>/dev/null | head -n 1 || true)
|
||||||
|
if [ -n "$FOUND" ]; then
|
||||||
|
SRC_ISO="$FOUND"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$SRC_ISO" ] || [ ! -f "$SRC_ISO" ]; then
|
||||||
|
echo -e "${YELLOW}Built ISO not found in result/. Building ISO via Nix...${NC}"
|
||||||
|
nix build .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage
|
||||||
|
|
||||||
|
if [ -f "result/iso/${ISO_NAME}" ]; then
|
||||||
|
SRC_ISO="result/iso/${ISO_NAME}"
|
||||||
|
else
|
||||||
|
FOUND=$(find result -name "*.iso" 2>/dev/null | head -n 1 || true)
|
||||||
|
if [ -n "$FOUND" ]; then
|
||||||
|
SRC_ISO="$FOUND"
|
||||||
|
else
|
||||||
|
echo -e "${RED}Error: Failed to locate built ISO in result/.${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${BLUE}Step 1: Copying built ISO out of Nix store...${NC}"
|
||||||
|
cp -v "$SRC_ISO" "./${ISO_NAME}"
|
||||||
|
echo -e " ${GREEN}✓${NC} Copied to ./${ISO_NAME}"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo -e "${BLUE}Step 2: Generating versioned SHA-256 checksum...${NC}"
|
||||||
|
sha256sum "${ISO_NAME}" > "${SHA_NAME}"
|
||||||
|
echo -e " ${GREEN}✓${NC} Generated ${SHA_NAME}"
|
||||||
|
cat "${SHA_NAME}"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo -e "${BLUE}Step 3: Verifying checksum...${NC}"
|
||||||
|
sha256sum --check "${SHA_NAME}"
|
||||||
|
echo -e " ${GREEN}✓${NC} Checksum verified successfully"
|
||||||
|
|
||||||
|
# Parse arguments for upload
|
||||||
|
DO_UPLOAD=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case $arg in
|
||||||
|
--upload)
|
||||||
|
DO_UPLOAD=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$DO_UPLOAD" -eq 1 ]; then
|
||||||
|
echo
|
||||||
|
echo -e "${BLUE}Step 4: Uploading to CDN...${NC}"
|
||||||
|
|
||||||
|
UPLOADED=0
|
||||||
|
if [ -n "${CDN_UPLOAD_CMD:-}" ]; then
|
||||||
|
echo -e " Running custom CDN_UPLOAD_CMD..."
|
||||||
|
eval "$CDN_UPLOAD_CMD"
|
||||||
|
UPLOADED=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "${CDN_RSYNC_TARGET:-}" ]; then
|
||||||
|
echo -e " Uploading via rsync to ${CDN_RSYNC_TARGET}..."
|
||||||
|
rsync -avP "${ISO_NAME}" "${SHA_NAME}" "${CDN_RSYNC_TARGET}"
|
||||||
|
UPLOADED=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "${CDN_RCLONE_REMOTE:-}" ]; then
|
||||||
|
echo -e " Uploading via rclone to ${CDN_RCLONE_REMOTE}..."
|
||||||
|
rclone copy "${ISO_NAME}" "${SHA_NAME}" "${CDN_RCLONE_REMOTE}"
|
||||||
|
UPLOADED=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$UPLOADED" -eq 0 ]; then
|
||||||
|
echo -e " ${YELLOW}⚠ Warning: --upload requested, but no upload method specified.${NC}"
|
||||||
|
echo -e " Set CDN_RSYNC_TARGET, CDN_RCLONE_REMOTE, or CDN_UPLOAD_CMD."
|
||||||
|
else
|
||||||
|
echo -e " ${GREEN}✓${NC} Upload complete."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo
|
||||||
|
echo -e "${GREEN}╔════════════════════════════════════════════════════════════╗${NC}"
|
||||||
|
echo -e "${GREEN}║ ✅ ISO packaging & verification complete! ║${NC}"
|
||||||
|
echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}"
|
||||||
|
echo -e "Ready files:"
|
||||||
|
ls -lh "${ISO_NAME}" "${SHA_NAME}"
|
||||||
|
echo
|
||||||
|
echo "To upload to CDN, run:"
|
||||||
|
echo " ./scripts/upload-cdn.sh --upload"
|
||||||
|
echo "(configure CDN_RSYNC_TARGET, CDN_RCLONE_REMOTE, or CDN_UPLOAD_CMD)"
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user