Compare commits
43
Commits
f39f59fb81
..
v1.0.6
| 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 |
No files matched your search
@@ -7,6 +7,94 @@ 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
|
## [1.0.5] - 2026-08-04
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -35,6 +123,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Fixed
|
### 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
|
- **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
|
"What gets backed up" list no longer shows Node / Server + Desktop items
|
||||||
(nix-bitcoin secrets, `/var/lib` system service data, and the database/blockchain
|
(nix-bitcoin secrets, `/var/lib` system service data, and the database/blockchain
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ Lightning infrastructure, private cloud, and communications platform when you
|
|||||||
are ready.
|
are ready.
|
||||||
|
|
||||||
[Visit the Website](https://sovransystems.com) ·
|
[Visit the Website](https://sovransystems.com) ·
|
||||||
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.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" />
|
||||||
@@ -40,6 +41,7 @@ are ready.
|
|||||||
## 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)
|
||||||
@@ -48,6 +50,7 @@ are ready.
|
|||||||
- [For developers](#for-developers)
|
- [For developers](#for-developers)
|
||||||
- [Development workflow](#development-workflow)
|
- [Development workflow](#development-workflow)
|
||||||
- [Build from source](#build-from-source)
|
- [Build from source](#build-from-source)
|
||||||
|
- [Publishing a release](#publishing-a-release)
|
||||||
- [About Bitcoin wallet entropy](#about-bitcoin-wallet-entropy)
|
- [About Bitcoin wallet entropy](#about-bitcoin-wallet-entropy)
|
||||||
- [Security approach](#security-approach)
|
- [Security approach](#security-approach)
|
||||||
- [Acknowledgements](#acknowledgements)
|
- [Acknowledgements](#acknowledgements)
|
||||||
@@ -105,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
|
||||||
@@ -305,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.
|
||||||
@@ -324,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>
|
||||||
@@ -344,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>
|
||||||
|
|
||||||
@@ -358,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.
|
||||||
@@ -373,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
|
||||||
@@ -397,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:
|
||||||
@@ -516,6 +573,23 @@ nix build \
|
|||||||
|
|
||||||
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.
|
||||||
@@ -554,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 |
|
||||||
@@ -804,7 +879,7 @@ primary location for collaboration. Please read our
|
|||||||
## Privacy. Sovereignty. Bitcoin.
|
## Privacy. Sovereignty. Bitcoin.
|
||||||
|
|
||||||
[Visit Sovran Systems](https://sovransystems.com) ·
|
[Visit Sovran Systems](https://sovransystems.com) ·
|
||||||
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS.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!
|
||||||
+278
-137
@@ -107,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"
|
||||||
@@ -611,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:
|
||||||
@@ -1521,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:
|
||||||
@@ -4292,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
|
||||||
|
|
||||||
|
|
||||||
@@ -4304,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.
|
||||||
|
|
||||||
@@ -4322,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"
|
||||||
@@ -4470,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)
|
||||||
@@ -4930,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>
|
||||||
@@ -4973,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",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -5038,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:
|
||||||
@@ -5056,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:
|
||||||
@@ -5075,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()
|
||||||
@@ -5097,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:
|
||||||
@@ -5109,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"
|
||||||
@@ -5139,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:
|
||||||
@@ -5149,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")
|
||||||
@@ -5202,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
|
||||||
@@ -5229,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,
|
||||||
@@ -5296,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"
|
||||||
@@ -5491,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._\-]+$')
|
||||||
@@ -5502,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({
|
||||||
@@ -5557,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
|
||||||
@@ -5573,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}
|
||||||
|
|
||||||
@@ -5623,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}
|
||||||
|
|
||||||
|
|||||||
+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 ==="
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+25
-11
@@ -63,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
|
||||||
}
|
}
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -88,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")
|
||||||
@@ -188,9 +193,10 @@ generate_release_notes() {
|
|||||||
printf '%s' "$notes"
|
printf '%s' "$notes"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Build the notes from commits since the previous tag
|
# Build the notes from commits since the previous tag (exclude the target tag if already present)
|
||||||
if [[ -n "$LATEST_TAG" ]] && git rev-parse -q --verify "$LATEST_TAG" >/dev/null; then
|
PREV_TAG=$(get_latest_tag "$TAG")
|
||||||
COMMIT_RANGE="${LATEST_TAG}..HEAD"
|
if [[ -n "$PREV_TAG" ]] && git rev-parse -q --verify "$PREV_TAG" >/dev/null; then
|
||||||
|
COMMIT_RANGE="${PREV_TAG}..HEAD"
|
||||||
else
|
else
|
||||||
COMMIT_RANGE="HEAD"
|
COMMIT_RANGE="HEAD"
|
||||||
fi
|
fi
|
||||||
@@ -256,7 +262,7 @@ fi
|
|||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
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" || true
|
- See CHANGELOG.md for full details" || true
|
||||||
@@ -356,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} — ${RELEASE_MESSAGE#Sovran_SystemsOS v* — }" \
|
--title "${TAG} — ${title_suffix}" \
|
||||||
--notes-file "${NOTES_FILE}" \
|
--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
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -409,6 +416,9 @@ if [[ -n "${GITEA_TOKEN:-}" ]]; then
|
|||||||
|
|
||||||
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"
|
||||||
@@ -424,6 +434,10 @@ 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 changes: git push ${GITHUB_REMOTE} HEAD:main && git push ${GITEA_REMOTE} HEAD:staging-dev"
|
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"
|
||||||
|
|||||||
@@ -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