Compare commits
13
Commits
e98a0b7554
..
v1.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cf611d44c | ||
|
|
1e3dd2dd76 | ||
|
|
45322db41a | ||
|
|
2a1d73af25 | ||
|
|
e83c7fdb18 | ||
|
|
587c19c2a5 | ||
|
|
c862ed5806 | ||
|
|
1d79acb083 | ||
|
|
5edf594eac | ||
|
|
94dba571c1 | ||
|
|
de32699539 | ||
|
|
892397589f | ||
|
|
30b753ec40 |
No files matched your search
+49
-1
@@ -7,9 +7,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## [Unreleased]
|
## [1.1.1] - 2026-08-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Update release-stable.sh
|
||||||
|
- Enhance release script with preflight checks and metadata
|
||||||
|
- Migrate dock/folder/mime entries from brave to brave-origin on upgrade
|
||||||
|
- Skip Brave Origin startup dialog in Hub launcher
|
||||||
|
- Switch default browser to Brave Origin (brave-origin)
|
||||||
|
- Add Bitcoin Core Tor IBD gossip control
|
||||||
|
- Replace Bitcoin Knots with Bitcoin Core
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- Persistent browser profile so logout survives window reopen
|
||||||
|
- Recover from expired sessions and preserve logout
|
||||||
|
- Prevent Bitcoin Core switch from hanging the Hub UI
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- Update README ISO download links to v1.1.0
|
||||||
|
[1.1.1]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.1
|
||||||
|
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Replaced Brave Browser with Brave Origin (the new `brave-origin` nixpkgs
|
||||||
|
package) as the desktop's default browser: installed package, dock
|
||||||
|
favorite, Browsers app-folder entry, `xdg.mime.defaultApplications`, and
|
||||||
|
the `$BROWSER` session variable now all point at Brave Origin, and the
|
||||||
|
Sovran Hub launcher runs it.
|
||||||
|
- On installs upgrading from the old Brave package, the desktop theme init
|
||||||
|
now migrates `brave-browser.desktop` → `brave-origin.desktop` in the dock
|
||||||
|
favorites, the Browsers app folder, and the user's `mimeapps.list`, so
|
||||||
|
the dock icon and default-browser handler survive the switch without a
|
||||||
|
manual re-pin.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- The Hub launcher now uses a persistent per-user browser profile
|
||||||
|
(`$XDG_STATE_HOME/sovran-hub-browser`) instead of a throwaway `/tmp`
|
||||||
|
profile that was deleted on exit. The throwaway profile wiped the
|
||||||
|
`hub_manual_logout` marker cookie on every close, so `/auto-login`
|
||||||
|
silently logged the user straight back in after they signed out and
|
||||||
|
reopened the Hub window. The persistent profile makes explicit logout
|
||||||
|
stick (until the next password login) while still skipping Brave Origin's
|
||||||
|
one-time startup dialog.
|
||||||
|
- The Hub now redirects to the login page when an API request finds an
|
||||||
|
expired browser session, instead of leaving the dashboard tile grid in a
|
||||||
|
loading state while `/api/services` continues returning 401 every five
|
||||||
|
seconds.
|
||||||
|
- Explicitly logging out now suppresses the desktop launcher's local
|
||||||
|
auto-login after the Hub window is closed and reopened. A successful
|
||||||
|
password login clears that preference.
|
||||||
- Sessions are now persisted to `/var/lib/secrets/hub-sessions.json` so the
|
- Sessions are now persisted to `/var/lib/secrets/hub-sessions.json` so the
|
||||||
browser login survives the Hub service restart that `nixos-rebuild switch`
|
browser login survives the Hub service restart that `nixos-rebuild switch`
|
||||||
performs during activation. Previously the in-memory session store was
|
performs during activation. Previously the in-memory session store was
|
||||||
|
|||||||
@@ -21,9 +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.1.0.iso) ·
|
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.1.iso) ·
|
||||||
[Try it safely in a VM](#try-it-first-in-a-virtual-machine) ·
|
[Try it safely in a VM](#try-it-first-in-a-virtual-machine) ·
|
||||||
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.0.iso.sha256) ·
|
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.1.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" />
|
||||||
@@ -185,7 +185,7 @@ hardware you control.
|
|||||||
### Your desktop
|
### Your desktop
|
||||||
|
|
||||||
- [GNOME](https://www.gnome.org) desktop
|
- [GNOME](https://www.gnome.org) desktop
|
||||||
- [Brave](https://brave.com) and
|
- [Brave Origin](https://brave.com/origin/) and
|
||||||
[Firefox](https://www.mozilla.org/firefox) browsers
|
[Firefox](https://www.mozilla.org/firefox) browsers
|
||||||
- File management, email, calendar, and office applications
|
- File management, email, calendar, and office applications
|
||||||
- System monitoring and administration tools
|
- System monitoring and administration tools
|
||||||
@@ -341,8 +341,8 @@ with an imaging application such as [Balena Etcher](https://etcher.balena.io).
|
|||||||
|
|
||||||
### 1. Download the ISO and checksum
|
### 1. Download the ISO and checksum
|
||||||
|
|
||||||
- [Download Sovran_SystemsOS-1.1.0.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.0.iso)
|
- [Download Sovran_SystemsOS-1.1.1.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.1.iso)
|
||||||
- [Download Sovran_SystemsOS-1.1.0.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.0.iso.sha256)
|
- [Download Sovran_SystemsOS-1.1.1.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.1.iso.sha256)
|
||||||
|
|
||||||
The download may take some time. Do not rename or modify the ISO before
|
The download may take some time. Do not rename or modify the ISO before
|
||||||
verifying it, and keep both files in the same folder.
|
verifying it, and keep both files in the same folder.
|
||||||
@@ -360,16 +360,16 @@ checksum exactly.
|
|||||||
Open a terminal in the download folder and run:
|
Open a terminal in the download folder and run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sha256sum --check Sovran_SystemsOS-1.1.0.iso.sha256
|
sha256sum --check Sovran_SystemsOS-1.1.1.iso.sha256
|
||||||
```
|
```
|
||||||
|
|
||||||
A successful comparison reports:
|
A successful comparison reports:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Sovran_SystemsOS-1.1.0.iso: OK
|
Sovran_SystemsOS-1.1.1.iso: OK
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also run `sha256sum Sovran_SystemsOS-1.1.0.iso` and compare the output
|
You can also run `sha256sum Sovran_SystemsOS-1.1.1.iso` and compare the output
|
||||||
against the checksum file manually.
|
against the checksum file manually.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
@@ -380,11 +380,11 @@ against the checksum file manually.
|
|||||||
Open Terminal in the download folder and run:
|
Open Terminal in the download folder and run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
shasum -a 256 Sovran_SystemsOS-1.1.0.iso
|
shasum -a 256 Sovran_SystemsOS-1.1.1.iso
|
||||||
```
|
```
|
||||||
|
|
||||||
Compare the value shown in Terminal with the value inside
|
Compare the value shown in Terminal with the value inside
|
||||||
`Sovran_SystemsOS-1.1.0.iso.sha256`.
|
`Sovran_SystemsOS-1.1.1.iso.sha256`.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -394,7 +394,7 @@ Compare the value shown in Terminal with the value inside
|
|||||||
Open PowerShell in the download folder and run:
|
Open PowerShell in the download folder and run:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Get-FileHash .\Sovran_SystemsOS-1.1.0.iso -Algorithm SHA256
|
Get-FileHash .\Sovran_SystemsOS-1.1.1.iso -Algorithm SHA256
|
||||||
```
|
```
|
||||||
|
|
||||||
Compare the value under `Hash` with the published checksum.
|
Compare the value under `Hash` with the published checksum.
|
||||||
@@ -409,7 +409,7 @@ match exactly.
|
|||||||
|
|
||||||
1. Download and install [Balena Etcher](https://etcher.balena.io), then
|
1. Download and install [Balena Etcher](https://etcher.balena.io), then
|
||||||
connect the USB drive.
|
connect the USB drive.
|
||||||
2. Choose **Flash from file** and select `Sovran_SystemsOS-1.1.0.iso`.
|
2. Choose **Flash from file** and select `Sovran_SystemsOS-1.1.1.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
|
||||||
@@ -852,7 +852,7 @@ primary location for collaboration. Please read our
|
|||||||
## Privacy. Sovereignty. Bitcoin.
|
## Privacy. Sovereignty. Bitcoin.
|
||||||
|
|
||||||
[Visit Sovran Systems](https://sovransystems.com) ·
|
[Visit Sovran Systems](https://sovransystems.com) ·
|
||||||
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.0.iso) ·
|
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.1.iso) ·
|
||||||
[View the License](LICENSE)
|
[View the License](LICENSE)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -491,6 +491,8 @@ if [[ -d /home ]]; then
|
|||||||
--exclude='.config/chromium/*/Code Cache/' \
|
--exclude='.config/chromium/*/Code Cache/' \
|
||||||
--exclude='.config/BraveSoftware/Brave-Browser/*/Cache/' \
|
--exclude='.config/BraveSoftware/Brave-Browser/*/Cache/' \
|
||||||
--exclude='.config/BraveSoftware/Brave-Browser/*/Code Cache/' \
|
--exclude='.config/BraveSoftware/Brave-Browser/*/Code Cache/' \
|
||||||
|
--exclude='.config/BraveSoftware/Brave-Origin/*/Cache/' \
|
||||||
|
--exclude='.config/BraveSoftware/Brave-Origin/*/Code Cache/' \
|
||||||
--exclude='.local/share/baloo/' \
|
--exclude='.local/share/baloo/' \
|
||||||
--exclude='.thumbnails/' \
|
--exclude='.thumbnails/' \
|
||||||
--exclude='.xsession-errors' \
|
--exclude='.xsession-errors' \
|
||||||
|
|||||||
@@ -142,9 +142,13 @@ AUTOLAUNCH_DISABLE_FLAG = "/var/lib/sovran/hub-autolaunch-disabled"
|
|||||||
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"
|
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"
|
||||||
SESSION_MAX_AGE = 86400 # 24 hours
|
MANUAL_LOGOUT_COOKIE_NAME = "hub_manual_logout"
|
||||||
|
SESSION_MAX_AGE = 86400 # 24 hours
|
||||||
|
# Chromium limits persistent cookies to roughly 400 days. This marker only
|
||||||
|
# suppresses desktop auto-login until the next successful password login.
|
||||||
|
MANUAL_LOGOUT_MAX_AGE = 400 * 86400
|
||||||
|
|
||||||
# Sessions are persisted here so logins survive a restart of the Hub service.
|
# Sessions are persisted here so logins survive a restart of the Hub service.
|
||||||
# nixos-rebuild switch restarts sovran-hub-web.service during activation (its
|
# nixos-rebuild switch restarts sovran-hub-web.service during activation (its
|
||||||
@@ -317,6 +321,27 @@ FEATURE_REGISTRY = [
|
|||||||
"conflicts_with": [],
|
"conflicts_with": [],
|
||||||
"port_requirements": [],
|
"port_requirements": [],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "bitcoin-tor-gossip",
|
||||||
|
"name": "Advertise Tor IBD Node",
|
||||||
|
"description": "Advertise this Bitcoin Core node's onion address through Bitcoin peer gossip so more Tor-capable nodes can discover it and request blocks.",
|
||||||
|
"details": [
|
||||||
|
"Your Tor IBD listener remains available whether or not advertising is enabled.",
|
||||||
|
"Enabling this announces only the node's .onion P2P address; it does not publish your home IP address.",
|
||||||
|
"Other Tor nodes can discover your node and request historical blocks while performing Initial Block Download (IBD).",
|
||||||
|
"No clearnet port or router port forwarding is opened.",
|
||||||
|
"Serving additional IBD peers can use significant upload bandwidth.",
|
||||||
|
],
|
||||||
|
"category": "bitcoin",
|
||||||
|
"modal_only": True,
|
||||||
|
"needs_domain": False,
|
||||||
|
"domain_name": None,
|
||||||
|
"needs_ddns": False,
|
||||||
|
"extra_fields": [],
|
||||||
|
"conflicts_with": [],
|
||||||
|
"requires": ["bitcoin-service"],
|
||||||
|
"port_requirements": [],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "sshd",
|
"id": "sshd",
|
||||||
"name": "SSH Remote Access",
|
"name": "SSH Remote Access",
|
||||||
@@ -358,6 +383,7 @@ FEATURE_SERVICE_MAP = {
|
|||||||
"haven": "haven-relay.service",
|
"haven": "haven-relay.service",
|
||||||
"element-calling": "livekit.service",
|
"element-calling": "livekit.service",
|
||||||
"mempool": "mempool.service",
|
"mempool": "mempool.service",
|
||||||
|
"bitcoin-tor-gossip": None,
|
||||||
"btcpay-web": "btcpayserver.service",
|
"btcpay-web": "btcpayserver.service",
|
||||||
"nwc-wallets": "albyhub.service",
|
"nwc-wallets": "albyhub.service",
|
||||||
"sshd": "sshd.service",
|
"sshd": "sshd.service",
|
||||||
@@ -431,7 +457,7 @@ ROLE_CATEGORIES: dict[str, set[str] | None] = {
|
|||||||
ROLE_FEATURES: dict[str, set[str] | None] = {
|
ROLE_FEATURES: dict[str, set[str] | None] = {
|
||||||
"server_plus_desktop": None,
|
"server_plus_desktop": None,
|
||||||
"desktop": {"rdp", "sshd"},
|
"desktop": {"rdp", "sshd"},
|
||||||
"node": {"rdp", "mempool", "btcpay-web", "nwc-wallets", "sshd"},
|
"node": {"rdp", "bitcoin-tor-gossip", "mempool", "btcpay-web", "nwc-wallets", "sshd"},
|
||||||
}
|
}
|
||||||
|
|
||||||
SERVICE_DESCRIPTIONS: dict[str, str] = {
|
SERVICE_DESCRIPTIONS: dict[str, str] = {
|
||||||
@@ -2058,14 +2084,24 @@ def _migrate_strip_deprecated_features() -> None:
|
|||||||
# ── Feature status helpers ─────────────────────────────────────────
|
# ── Feature status helpers ─────────────────────────────────────────
|
||||||
|
|
||||||
def _is_feature_enabled_in_config(feature_id: str) -> bool | None:
|
def _is_feature_enabled_in_config(feature_id: str) -> bool | None:
|
||||||
"""Check if a feature's service appears as enabled in the running config.json.
|
"""Check whether a feature is enabled in the evaluated Hub configuration.
|
||||||
Returns True/False if found, None if the feature has no mapped service."""
|
|
||||||
|
Most features map directly to a systemd service. Modal-only settings are
|
||||||
|
represented separately in ``config.json``. Returns ``None`` only when no
|
||||||
|
evaluated state is available.
|
||||||
|
"""
|
||||||
if feature_id == "btcpay-web":
|
if feature_id == "btcpay-web":
|
||||||
return False # Default off in Node role; only on via explicit hub toggle
|
return False # Default off in Node role; only on via explicit hub toggle
|
||||||
|
|
||||||
|
cfg = load_config()
|
||||||
|
|
||||||
|
if feature_id == "bitcoin-tor-gossip":
|
||||||
|
state = cfg.get("feature_states", {}).get(feature_id)
|
||||||
|
return bool(state) if state is not None else None
|
||||||
|
|
||||||
unit = FEATURE_SERVICE_MAP.get(feature_id)
|
unit = FEATURE_SERVICE_MAP.get(feature_id)
|
||||||
if unit is None:
|
if unit is None:
|
||||||
return None
|
return None
|
||||||
cfg = load_config()
|
|
||||||
for svc in cfg.get("services", []):
|
for svc in cfg.get("services", []):
|
||||||
if svc.get("unit") == unit:
|
if svc.get("unit") == unit:
|
||||||
return svc.get("enabled", False)
|
return svc.get("enabled", False)
|
||||||
@@ -2485,6 +2521,10 @@ async def auto_login_redirect(request: Request):
|
|||||||
client_ip = request.client.host if request.client else "unknown"
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
if client_ip not in ("127.0.0.1", "::1"):
|
if client_ip not in ("127.0.0.1", "::1"):
|
||||||
raise HTTPException(status_code=403, detail="Forbidden")
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
# An explicit logout must take precedence over the desktop launcher's
|
||||||
|
# localhost auto-login, including after the Hub window is closed/reopened.
|
||||||
|
if request.cookies.get(MANUAL_LOGOUT_COOKIE_NAME) == "1":
|
||||||
|
return RedirectResponse(url="/login", status_code=303)
|
||||||
token = _create_session()
|
token = _create_session()
|
||||||
response = RedirectResponse(url="/", status_code=303)
|
response = RedirectResponse(url="/", status_code=303)
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
@@ -2521,17 +2561,27 @@ async def api_login(req: LoginRequest, request: Request):
|
|||||||
samesite="lax",
|
samesite="lax",
|
||||||
secure=False, # LAN-only appliance; no TLS on the Hub port
|
secure=False, # LAN-only appliance; no TLS on the Hub port
|
||||||
)
|
)
|
||||||
|
# A successful password login explicitly reverses a prior manual logout.
|
||||||
|
response.delete_cookie(key=MANUAL_LOGOUT_COOKIE_NAME)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/logout")
|
@app.post("/api/logout")
|
||||||
async def api_logout(request: Request):
|
async def api_logout(request: Request):
|
||||||
"""Clear the session cookie and destroy the server-side session."""
|
"""Destroy the session and prevent desktop auto-login until password login."""
|
||||||
token = request.cookies.get(SESSION_COOKIE_NAME)
|
token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||||
if token:
|
if token:
|
||||||
_destroy_session(token)
|
_destroy_session(token)
|
||||||
response = JSONResponse({"ok": True})
|
response = JSONResponse({"ok": True})
|
||||||
response.delete_cookie(key=SESSION_COOKIE_NAME)
|
response.delete_cookie(key=SESSION_COOKIE_NAME)
|
||||||
|
response.set_cookie(
|
||||||
|
key=MANUAL_LOGOUT_COOKIE_NAME,
|
||||||
|
value="1",
|
||||||
|
max_age=MANUAL_LOGOUT_MAX_AGE,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
secure=False, # LAN-only appliance; no TLS on the Hub port
|
||||||
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@@ -3546,6 +3596,37 @@ async def api_service_detail(unit: str, icon: str | None = None):
|
|||||||
"port_requirements": feat_meta.get("port_requirements", []),
|
"port_requirements": feat_meta.get("port_requirements", []),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Modal-only settings related to this service. These are intentionally not
|
||||||
|
# rendered as standalone feature cards: the user encounters them in the
|
||||||
|
# context where their consequences are easiest to understand.
|
||||||
|
related_features: list[dict] = []
|
||||||
|
if icon == "bitcoin-core":
|
||||||
|
related_id = "bitcoin-tor-gossip"
|
||||||
|
related_meta = next((f for f in FEATURE_REGISTRY if f["id"] == related_id), None)
|
||||||
|
if related_meta is not None:
|
||||||
|
if related_id in overrides:
|
||||||
|
related_enabled = bool(overrides[related_id])
|
||||||
|
else:
|
||||||
|
config_state = _is_feature_enabled_in_config(related_id)
|
||||||
|
related_enabled = bool(config_state) if config_state is not None else False
|
||||||
|
related_features.append({
|
||||||
|
"id": related_id,
|
||||||
|
"name": related_meta["name"],
|
||||||
|
"description": related_meta["description"],
|
||||||
|
"details": related_meta.get("details", []),
|
||||||
|
"category": related_meta["category"],
|
||||||
|
"enabled": related_enabled,
|
||||||
|
"available": bool(enabled),
|
||||||
|
"needs_domain": False,
|
||||||
|
"domain_configured": True,
|
||||||
|
"domain_name": None,
|
||||||
|
"needs_ddns": False,
|
||||||
|
"extra_fields": [],
|
||||||
|
"conflicts_with": related_meta.get("conflicts_with", []),
|
||||||
|
"requires": related_meta.get("requires", []),
|
||||||
|
"port_requirements": [],
|
||||||
|
})
|
||||||
|
|
||||||
service_detail: dict = {
|
service_detail: dict = {
|
||||||
"name": entry.get("name", ""),
|
"name": entry.get("name", ""),
|
||||||
"unit": unit,
|
"unit": unit,
|
||||||
@@ -3568,6 +3649,7 @@ async def api_service_detail(unit: str, icon: str | None = None):
|
|||||||
"external_ip": external_ip,
|
"external_ip": external_ip,
|
||||||
"internal_ip": internal_ip,
|
"internal_ip": internal_ip,
|
||||||
"feature": feature_entry,
|
"feature": feature_entry,
|
||||||
|
"related_features": related_features,
|
||||||
}
|
}
|
||||||
if sync_ibd is not None:
|
if sync_ibd is not None:
|
||||||
service_detail["sync_ibd"] = sync_ibd
|
service_detail["sync_ibd"] = sync_ibd
|
||||||
@@ -4190,8 +4272,10 @@ async def api_features():
|
|||||||
|
|
||||||
role = load_config().get("role", "server_plus_desktop")
|
role = load_config().get("role", "server_plus_desktop")
|
||||||
allowed_features = ROLE_FEATURES.get(role)
|
allowed_features = ROLE_FEATURES.get(role)
|
||||||
registry = FEATURE_REGISTRY if allowed_features is None else [
|
registry = [
|
||||||
f for f in FEATURE_REGISTRY if f["id"] in allowed_features
|
f for f in FEATURE_REGISTRY
|
||||||
|
if not f.get("modal_only")
|
||||||
|
and (allowed_features is None or f["id"] in allowed_features)
|
||||||
]
|
]
|
||||||
|
|
||||||
features = []
|
features = []
|
||||||
@@ -4265,6 +4349,22 @@ async def api_features_toggle(req: FeatureToggleRequest):
|
|||||||
features, nostr_npub, cur_tz, cur_locale = await loop.run_in_executor(None, _read_hub_overrides)
|
features, nostr_npub, cur_tz, cur_locale = await loop.run_in_executor(None, _read_hub_overrides)
|
||||||
|
|
||||||
if req.enabled:
|
if req.enabled:
|
||||||
|
# Onion-address advertising is only meaningful while the Bitcoin Core
|
||||||
|
# service is enabled. The control is shown in that service's modal, but
|
||||||
|
# enforce the dependency server-side as well.
|
||||||
|
if req.feature == "bitcoin-tor-gossip":
|
||||||
|
bitcoin_core_enabled = any(
|
||||||
|
svc.get("unit") == "bitcoind.service"
|
||||||
|
and svc.get("icon") == "bitcoin-core"
|
||||||
|
and bool(svc.get("enabled", False))
|
||||||
|
for svc in load_config().get("services", [])
|
||||||
|
)
|
||||||
|
if not bitcoin_core_enabled:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Enable the Bitcoin service before advertising its Tor IBD service.",
|
||||||
|
)
|
||||||
|
|
||||||
# Element-calling requires matrix domain
|
# Element-calling requires matrix domain
|
||||||
if req.feature == "element-calling":
|
if req.feature == "element-calling":
|
||||||
if not os.path.exists(os.path.join(DOMAINS_DIR, "matrix")):
|
if not os.path.exists(os.path.join(DOMAINS_DIR, "matrix")):
|
||||||
|
|||||||
@@ -365,6 +365,45 @@
|
|||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.svc-detail-option-card {
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(94, 173, 138, 0.06);
|
||||||
|
border: 1px solid rgba(94, 173, 138, 0.24);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-detail-option-list {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-detail-option-list li {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-detail-option-privacy {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-left: 3px solid var(--accent-color);
|
||||||
|
background: rgba(94, 173, 138, 0.08);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-detail-option-privacy strong {
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-detail-related-feature-btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
.feature-conflict-warning {
|
.feature-conflict-warning {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
|||||||
@@ -342,8 +342,11 @@ async function performFeatureToggle(featId, enabled, extra) {
|
|||||||
function handleFeatureToggle(feat, newEnabled) {
|
function handleFeatureToggle(feat, newEnabled) {
|
||||||
if (!newEnabled) {
|
if (!newEnabled) {
|
||||||
// Disable: ask confirmation
|
// Disable: ask confirmation
|
||||||
|
var disableMessage = (feat.id === "bitcoin-tor-gossip")
|
||||||
|
? "This will stop advertising your Bitcoin Core onion address to other peers. Nodes that already know the address can still connect through Tor, and no clearnet port will be opened. The system will rebuild. Continue?"
|
||||||
|
: "This will disable " + feat.name + ". The system will rebuild. Continue?";
|
||||||
openFeatureConfirm(
|
openFeatureConfirm(
|
||||||
"This will disable " + feat.name + ". The system will rebuild. Continue?",
|
disableMessage,
|
||||||
function() { performFeatureToggle(feat.id, false, {}); }
|
function() { performFeatureToggle(feat.id, false, {}); }
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -403,7 +406,10 @@ function handleFeatureToggle(feat, newEnabled) {
|
|||||||
openPortRequirementsModal(feat.name, ports, proceedAfterPortCheck);
|
openPortRequirementsModal(feat.name, ports, proceedAfterPortCheck);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (conflictNames.length > 0) {
|
if (feat.id === "bitcoin-tor-gossip") {
|
||||||
|
var torGossipConfirmMsg = "This will advertise your Bitcoin Core .onion P2P address through Bitcoin peer gossip. More Tor-capable nodes may discover your node and request historical blocks during IBD, which can use significant upload bandwidth. Your home IP remains hidden and no clearnet port or router forwarding is opened. Continue?";
|
||||||
|
openFeatureConfirm(torGossipConfirmMsg, proceedAfterConflictCheck);
|
||||||
|
} else if (conflictNames.length > 0) {
|
||||||
openFeatureConfirm("This will disable " + conflictNames.join(", ") + ". Continue?", proceedAfterConflictCheck);
|
openFeatureConfirm("This will disable " + conflictNames.join(", ") + ". Continue?", proceedAfterConflictCheck);
|
||||||
} else {
|
} else {
|
||||||
proceedAfterConflictCheck();
|
proceedAfterConflictCheck();
|
||||||
|
|||||||
@@ -111,8 +111,19 @@ function formatDuration(seconds) {
|
|||||||
|
|
||||||
// ── Fetch wrappers ────────────────────────────────────────────────
|
// ── Fetch wrappers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Avoid issuing multiple redirects when several startup requests discover an
|
||||||
|
// expired session at the same time.
|
||||||
|
let _authRedirectInProgress = false;
|
||||||
|
|
||||||
async function apiFetch(path, options) {
|
async function apiFetch(path, options) {
|
||||||
const res = await fetch(path, options || {});
|
const res = await fetch(path, options || {});
|
||||||
|
if (res.status === 401) {
|
||||||
|
if (!_authRedirectInProgress) {
|
||||||
|
_authRedirectInProgress = true;
|
||||||
|
window.location.replace("/login");
|
||||||
|
}
|
||||||
|
throw new Error("Unauthenticated");
|
||||||
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let detail = res.status + " " + res.statusText;
|
let detail = res.status + " " + res.statusText;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -918,6 +918,54 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
'</div>');
|
'</div>');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Section G: Contextual feature options. The Tor IBD advertising control is
|
||||||
|
// deliberately shown only inside the Bitcoin Core modal so its bandwidth
|
||||||
|
// and privacy implications are explained before the user enables it.
|
||||||
|
var relatedFeatures = Array.isArray(data.related_features) ? data.related_features : [];
|
||||||
|
relatedFeatures.forEach(function(optionFeat) {
|
||||||
|
// Keep the shared feature state current for the standard rebuild flow.
|
||||||
|
if (!_featuresData) {
|
||||||
|
_featuresData = { features: [optionFeat], ssl_email_configured: false };
|
||||||
|
} else {
|
||||||
|
var optionIndex = _featuresData.features.findIndex(function(f) { return f.id === optionFeat.id; });
|
||||||
|
if (optionIndex >= 0) _featuresData.features[optionIndex] = optionFeat;
|
||||||
|
else _featuresData.features.push(optionFeat);
|
||||||
|
}
|
||||||
|
|
||||||
|
var detailsHtml = "";
|
||||||
|
if (Array.isArray(optionFeat.details) && optionFeat.details.length > 0) {
|
||||||
|
detailsHtml = '<ul class="svc-detail-option-list">' +
|
||||||
|
optionFeat.details.map(function(detail) {
|
||||||
|
return '<li>' + escHtml(detail) + '</li>';
|
||||||
|
}).join("") +
|
||||||
|
'</ul>';
|
||||||
|
}
|
||||||
|
|
||||||
|
var optionAvailable = optionFeat.available !== false;
|
||||||
|
var optionStatusLabel = optionFeat.enabled ? "Advertising enabled \u2713" : "Not advertised";
|
||||||
|
var optionStatusCls = optionFeat.enabled ? "addon-status--on" : "addon-status--off";
|
||||||
|
var optionButtonLabel = optionFeat.enabled ? "Stop Advertising" : "Advertise Onion Address";
|
||||||
|
var optionButtonCls = optionFeat.enabled ? "btn btn-close-modal" : "btn btn-primary";
|
||||||
|
if (!optionAvailable) {
|
||||||
|
optionStatusLabel = "Bitcoin Core is not enabled";
|
||||||
|
optionButtonLabel = "Enable Bitcoin Core First";
|
||||||
|
optionButtonCls = "btn btn-close-modal";
|
||||||
|
}
|
||||||
|
|
||||||
|
addSetup('<div class="svc-detail-section svc-detail-option-card">' +
|
||||||
|
'<div class="svc-detail-section-title">Tor IBD Service Advertising</div>' +
|
||||||
|
'<p class="svc-detail-desc">' + escHtml(optionFeat.description || "") + '</p>' +
|
||||||
|
detailsHtml +
|
||||||
|
'<div class="svc-detail-option-privacy">' +
|
||||||
|
'<strong>Tor-only:</strong> This setting advertises the onion service. It does not open a clearnet port, expose your home IP address, or require router port forwarding.' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="svc-detail-addon-row">' +
|
||||||
|
'<span class="svc-detail-addon-status ' + optionStatusCls + '">' + escHtml(optionStatusLabel) + '</span>' +
|
||||||
|
'<button class="' + optionButtonCls + ' svc-detail-related-feature-btn" data-feature-id="' + escHtml(optionFeat.id) + '"' + (!optionAvailable ? ' disabled' : '') + '>' + escHtml(optionButtonLabel) + '</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>');
|
||||||
|
});
|
||||||
|
|
||||||
if ((effectiveEnabled || data.enabled) && unit !== "phpfpm-nextcloud.service" && unit !== "phpfpm-wordpress.service") {
|
if ((effectiveEnabled || data.enabled) && unit !== "phpfpm-nextcloud.service" && unit !== "phpfpm-wordpress.service") {
|
||||||
addSetup('<div class="svc-detail-section svc-detail-restart-section">' +
|
addSetup('<div class="svc-detail-section svc-detail-restart-section">' +
|
||||||
'<div class="svc-detail-section-title">Troubleshooting</div>' +
|
'<div class="svc-detail-section-title">Troubleshooting</div>' +
|
||||||
@@ -978,6 +1026,18 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var relatedFeatureButtons = $credsBody.querySelectorAll(".svc-detail-related-feature-btn");
|
||||||
|
relatedFeatureButtons.forEach(function(button) {
|
||||||
|
button.addEventListener("click", function() {
|
||||||
|
if (button.disabled) return;
|
||||||
|
var featureId = button.getAttribute("data-feature-id");
|
||||||
|
var relatedFeat = relatedFeatures.find(function(f) { return f.id === featureId; });
|
||||||
|
if (!relatedFeat) return;
|
||||||
|
closeCredsModal();
|
||||||
|
handleFeatureToggle(relatedFeat, !relatedFeat.enabled);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
var restartBtn = document.getElementById("svc-detail-restart-btn");
|
var restartBtn = document.getElementById("svc-detail-restart-btn");
|
||||||
var restartResult = document.getElementById("svc-detail-restart-result");
|
var restartResult = document.getElementById("svc-detail-restart-result");
|
||||||
if (restartBtn && restartResult) {
|
if (restartBtn && restartResult) {
|
||||||
|
|||||||
+1
-1
@@ -145,7 +145,7 @@
|
|||||||
ranger fastfetch gedit openssl pwgen
|
ranger fastfetch gedit openssl pwgen
|
||||||
aspell aspellDicts.en lm_sensors
|
aspell aspellDicts.en lm_sensors
|
||||||
hunspell hunspellDicts.en_US
|
hunspell hunspellDicts.en_US
|
||||||
synadm brave dua
|
synadm brave-origin dua
|
||||||
gparted pv unzip parted screen zenity
|
gparted pv unzip parted screen zenity
|
||||||
libargon2 gnome-terminal libreoffice-fresh
|
libargon2 gnome-terminal libreoffice-fresh
|
||||||
dig firefox wp-cli axel
|
dig firefox wp-cli axel
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
|
|||||||
|
|
||||||
services.bitcoind = {
|
services.bitcoind = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
# Keep the normal loopback P2P socket available for local clients such as
|
||||||
|
# Bisq. Because `address` defaults to 127.0.0.1 this does not expose a
|
||||||
|
# clearnet or LAN listener. When the existing bitcoind onion service is
|
||||||
|
# enabled, this also creates its Tor-tagged loopback target on port 8334.
|
||||||
|
listen = true;
|
||||||
package = pkgs.bitcoind;
|
package = pkgs.bitcoind;
|
||||||
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node";
|
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node";
|
||||||
txindex = true;
|
txindex = true;
|
||||||
@@ -16,7 +21,13 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
|
|||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
nix-bitcoin.onionServices.bitcoind.enable = true;
|
nix-bitcoin.onionServices.bitcoind = {
|
||||||
|
enable = true;
|
||||||
|
# This is a locally vendored option namespace, not an upstream dependency.
|
||||||
|
# The onion listener remains available to peers that already know it;
|
||||||
|
# advertising its address through Bitcoin peer gossip is opt-in in the Hub.
|
||||||
|
public = config.sovran_systemsOS.features.bitcoin-tor-gossip;
|
||||||
|
};
|
||||||
nix-bitcoin.onionServices.electrs.enable = true;
|
nix-bitcoin.onionServices.electrs.enable = true;
|
||||||
nix-bitcoin.onionServices.rtl.enable = true;
|
nix-bitcoin.onionServices.rtl.enable = true;
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
haven = lib.mkForce false;
|
haven = lib.mkForce false;
|
||||||
mempool = lib.mkForce false;
|
mempool = lib.mkForce false;
|
||||||
element-calling = lib.mkForce false;
|
element-calling = lib.mkForce false;
|
||||||
|
bitcoin-tor-gossip = lib.mkForce false;
|
||||||
"nwc-wallets" = lib.mkForce false;
|
"nwc-wallets" = lib.mkForce false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
haven = lib.mkEnableOption "Haven NOSTR relay";
|
haven = lib.mkEnableOption "Haven NOSTR relay";
|
||||||
mempool = lib.mkEnableOption "Bitcoin Mempool Explorer";
|
mempool = lib.mkEnableOption "Bitcoin Mempool Explorer";
|
||||||
element-calling = lib.mkEnableOption "Element Video and Audio Calling";
|
element-calling = lib.mkEnableOption "Element Video and Audio Calling";
|
||||||
|
bitcoin-tor-gossip = lib.mkEnableOption "Advertise the Bitcoin Core onion service through Bitcoin peer gossip";
|
||||||
# Compatibility shim for Hub-managed settings from releases where Core
|
# Compatibility shim for Hub-managed settings from releases where Core
|
||||||
# was an optional replacement for the default node. Core is now always
|
# was an optional replacement for the default node. Core is now always
|
||||||
# selected when the Bitcoin service is enabled.
|
# selected when the Bitcoin service is enabled.
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ let
|
|||||||
# ── Bitcoin Base ────────────────────────────────────────────
|
# ── Bitcoin Base ────────────────────────────────────────────
|
||||||
++ lib.optionals cfg.services.bitcoin [
|
++ lib.optionals cfg.services.bitcoin [
|
||||||
{ name = "Bitcoin Core"; unit = "bitcoind.service"; type = "system"; icon = "bitcoin-core"; enabled = cfg.services.bitcoin; category = "bitcoin-base"; credentials = [
|
{ name = "Bitcoin Core"; unit = "bitcoind.service"; type = "system"; icon = "bitcoin-core"; enabled = cfg.services.bitcoin; category = "bitcoin-base"; credentials = [
|
||||||
{ label = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; }
|
{ label = "Tor Bitcoin P2P Address — Reachable only through Tor"; file = "/var/lib/tor/onion/bitcoind/hostname"; suffix = ":8333"; }
|
||||||
]; }
|
]; }
|
||||||
]
|
]
|
||||||
# ── Bitcoin Apps (services on top of the node) ─────────────
|
# ── Bitcoin Apps (services on top of the node) ─────────────
|
||||||
@@ -122,6 +122,9 @@ let
|
|||||||
role = activeRole;
|
role = activeRole;
|
||||||
services = monitoredServices;
|
services = monitoredServices;
|
||||||
feature_manager = true;
|
feature_manager = true;
|
||||||
|
feature_states = {
|
||||||
|
bitcoin-tor-gossip = cfg.features.bitcoin-tor-gossip;
|
||||||
|
};
|
||||||
sovran_version = sovranVersion;
|
sovran_version = sovranVersion;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -279,17 +282,31 @@ let
|
|||||||
fi
|
fi
|
||||||
'';
|
'';
|
||||||
|
|
||||||
# ── Brave launcher wrapper: stable profile dir so Wayland app_id is
|
# ── Brave Origin launcher wrapper: a *persistent* per-user profile dir.
|
||||||
# deterministic and GNOME Shell can match the window to the .desktop
|
# It must NOT be wiped on exit: the Hub's logout marker cookie
|
||||||
# entry (fixes generic gear icon appearing in the dock).
|
# (hub_manual_logout) and the session cookie live in this profile.
|
||||||
|
# Launching from a fresh/ephemeral profile every time throws away the
|
||||||
|
# marker, so /auto-login would mint a new session and silently log the
|
||||||
|
# user straight back in after they signed out and reopened the window.
|
||||||
|
# A stable directory also keeps the Wayland app_id deterministic so
|
||||||
|
# GNOME Shell can match the window to the .desktop entry (dock icon).
|
||||||
hub-brave-wrapper = pkgs.writeShellScript "sovran-hub-brave.sh" ''
|
hub-brave-wrapper = pkgs.writeShellScript "sovran-hub-brave.sh" ''
|
||||||
export PATH="${lib.makeBinPath [ pkgs.brave pkgs.coreutils ]}:$PATH"
|
export PATH="${lib.makeBinPath [ pkgs.brave-origin pkgs.coreutils ]}:$PATH"
|
||||||
HUB_DATA="/tmp/sovran-hub-brave-$(id -u)"
|
# Per-user, persistent browser state. $XDG_STATE_HOME keeps it out of the
|
||||||
|
# way of backups and survives reboots and window close/reopen.
|
||||||
|
if [ -n "$XDG_STATE_HOME" ]; then
|
||||||
|
HUB_DATA="$XDG_STATE_HOME/sovran-hub-browser"
|
||||||
|
else
|
||||||
|
HUB_DATA="$HOME/.local/state/sovran-hub-browser"
|
||||||
|
fi
|
||||||
mkdir -p "$HUB_DATA"
|
mkdir -p "$HUB_DATA"
|
||||||
trap '[ -n "$HUB_DATA" ] && rm -rf "$HUB_DATA"' EXIT INT TERM
|
|
||||||
export BAMF_DESKTOP_FILE_HINT="/run/current-system/sw/share/applications/sovran-hub.desktop"
|
export BAMF_DESKTOP_FILE_HINT="/run/current-system/sw/share/applications/sovran-hub.desktop"
|
||||||
export GIO_LAUNCHED_DESKTOP_FILE="/run/current-system/sw/share/applications/sovran-hub.desktop"
|
export GIO_LAUNCHED_DESKTOP_FILE="/run/current-system/sw/share/applications/sovran-hub.desktop"
|
||||||
brave --app=http://localhost:8937/auto-login \
|
# With a persistent profile Brave Origin's one-time "Proceed with Origin
|
||||||
|
# for free on Linux" onboarding dialog only appears once; keep skipping it
|
||||||
|
# anyway so it can never block auto-login (Linux-only switch).
|
||||||
|
brave-origin --app=http://localhost:8937/auto-login \
|
||||||
|
--skip-origin-startup-dialog \
|
||||||
--class=sovran-hub \
|
--class=sovran-hub \
|
||||||
--user-data-dir="$HUB_DATA" \
|
--user-data-dir="$HUB_DATA" \
|
||||||
--password-store=basic \
|
--password-store=basic \
|
||||||
|
|||||||
@@ -44,6 +44,35 @@ let
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── Brave Origin migration (existing installs upgrading from `brave`) ──
|
||||||
|
# The old package's brave-browser.desktop no longer exists, so GNOME drops
|
||||||
|
# it from the dock and the Browsers folder. Swap the id in place, but only
|
||||||
|
# when the stale value is still present (never touch user customizations).
|
||||||
|
if [ -f "$USER_DB" ]; then
|
||||||
|
FAVS="$(${pkgs.dconf}/bin/dconf read /org/gnome/shell/favorite-apps 2>/dev/null || true)"
|
||||||
|
if [ -n "$FAVS" ]; then
|
||||||
|
NEW_FAVS="''${FAVS//brave-browser.desktop/brave-origin.desktop}"
|
||||||
|
if [ "$NEW_FAVS" != "$FAVS" ]; then
|
||||||
|
${pkgs.dconf}/bin/dconf write /org/gnome/shell/favorite-apps "$NEW_FAVS"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
BROWSER_APPS="$(${pkgs.dconf}/bin/dconf read /org/gnome/desktop/app-folders/folders/Browsers/apps 2>/dev/null || true)"
|
||||||
|
if [ -n "$BROWSER_APPS" ]; then
|
||||||
|
NEW_BROWSER_APPS="''${BROWSER_APPS//brave-browser.desktop/brave-origin.desktop}"
|
||||||
|
if [ "$NEW_BROWSER_APPS" != "$BROWSER_APPS" ]; then
|
||||||
|
${pkgs.dconf}/bin/dconf write /org/gnome/desktop/app-folders/folders/Browsers/apps "$NEW_BROWSER_APPS"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A previous fresh install wrote the user's mimeapps.list with the old id;
|
||||||
|
# XDG gives it precedence over the new system-wide default, so rewrite it.
|
||||||
|
MIME_LIST="$HOME/.config/mimeapps.list"
|
||||||
|
if [ -f "$MIME_LIST" ] && ${pkgs.gnugrep}/bin/grep -q 'brave-browser\.desktop' "$MIME_LIST"; then
|
||||||
|
${pkgs.gnused}/bin/sed -i 's/brave-browser\.desktop/brave-origin.desktop/g' "$MIME_LIST"
|
||||||
|
fi
|
||||||
|
|
||||||
# Already applied — skip
|
# Already applied — skip
|
||||||
if [ -f "$STAMP" ]; then
|
if [ -f "$STAMP" ]; then
|
||||||
exit 0
|
exit 0
|
||||||
@@ -61,11 +90,11 @@ let
|
|||||||
mkdir -p "$HOME/.config"
|
mkdir -p "$HOME/.config"
|
||||||
cat > "$HOME/.config/mimeapps.list" << EOF
|
cat > "$HOME/.config/mimeapps.list" << EOF
|
||||||
[Default Applications]
|
[Default Applications]
|
||||||
text/html=brave-browser.desktop
|
text/html=brave-origin.desktop
|
||||||
x-scheme-handler/http=brave-browser.desktop
|
x-scheme-handler/http=brave-origin.desktop
|
||||||
x-scheme-handler/https=brave-browser.desktop
|
x-scheme-handler/https=brave-origin.desktop
|
||||||
x-scheme-handler/about=brave-browser.desktop
|
x-scheme-handler/about=brave-origin.desktop
|
||||||
x-scheme-handler/unknown=brave-browser.desktop
|
x-scheme-handler/unknown=brave-origin.desktop
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
${pkgs.dconf}/bin/dconf load / << EOF
|
${pkgs.dconf}/bin/dconf load / << EOF
|
||||||
@@ -104,7 +133,7 @@ search-filter-time-type='last_modified'
|
|||||||
[org/gnome/shell]
|
[org/gnome/shell]
|
||||||
disabled-extensions=['just-perfection-desktop@just-perfection']
|
disabled-extensions=['just-perfection-desktop@just-perfection']
|
||||||
enabled-extensions=['appindicatorsupport@rgcjonas.gmail.com', 'dash-to-dock-cosmic-@halfmexicanhalfamazing@gmail.com', 'Vitals@CoreCoding.com', 'dash-to-dock@micxgx.gmail.com', 'pop-shell@system76.com', 'date-menu-formatter@marcinjakubowski.github.com', 'light-style@gnome-shell-extensions.gcampax.github.com']
|
enabled-extensions=['appindicatorsupport@rgcjonas.gmail.com', 'dash-to-dock-cosmic-@halfmexicanhalfamazing@gmail.com', 'Vitals@CoreCoding.com', 'dash-to-dock@micxgx.gmail.com', 'pop-shell@system76.com', 'date-menu-formatter@marcinjakubowski.github.com', 'light-style@gnome-shell-extensions.gcampax.github.com']
|
||||||
favorite-apps=['brave-browser.desktop', 'org.gnome.Settings.desktop', 'org.gnome.Nautilus.desktop', 'sovran-hub.desktop', 'org.gnome.Software.desktop', 'org.gnome.Geary.desktop', 'org.gnome.Contacts.desktop', 'org.gnome.Calendar.desktop', 'sparrow.desktop', 'Bisq.desktop', 'bisq2.desktop']
|
favorite-apps=['brave-origin.desktop', 'org.gnome.Settings.desktop', 'org.gnome.Nautilus.desktop', 'sovran-hub.desktop', 'org.gnome.Software.desktop', 'org.gnome.Geary.desktop', 'org.gnome.Contacts.desktop', 'org.gnome.Calendar.desktop', 'sparrow.desktop', 'Bisq.desktop', 'bisq2.desktop']
|
||||||
welcome-dialog-last-shown-version='48.4'
|
welcome-dialog-last-shown-version='48.4'
|
||||||
|
|
||||||
[org/gnome/desktop/app-folders]
|
[org/gnome/desktop/app-folders]
|
||||||
@@ -112,7 +141,7 @@ folder-children=['Browsers', 'Office', 'Terminal', 'Chat', 'Bitcoin', 'Media', '
|
|||||||
|
|
||||||
[org/gnome/desktop/app-folders/folders/Browsers]
|
[org/gnome/desktop/app-folders/folders/Browsers]
|
||||||
name='Browsers'
|
name='Browsers'
|
||||||
apps=['brave-browser.desktop', 'firefox.desktop', 'org.gnome.Epiphany.desktop']
|
apps=['brave-origin.desktop', 'firefox.desktop', 'org.gnome.Epiphany.desktop']
|
||||||
|
|
||||||
[org/gnome/desktop/app-folders/folders/Office]
|
[org/gnome/desktop/app-folders/folders/Office]
|
||||||
name='Office'
|
name='Office'
|
||||||
@@ -269,7 +298,7 @@ in
|
|||||||
];
|
];
|
||||||
|
|
||||||
favorite-apps = [
|
favorite-apps = [
|
||||||
"brave-browser.desktop"
|
"brave-origin.desktop"
|
||||||
"org.gnome.Settings.desktop"
|
"org.gnome.Settings.desktop"
|
||||||
"org.gnome.Nautilus.desktop"
|
"org.gnome.Nautilus.desktop"
|
||||||
"sovran-hub.desktop"
|
"sovran-hub.desktop"
|
||||||
@@ -292,7 +321,7 @@ in
|
|||||||
"org/gnome/desktop/app-folders/folders/Browsers" = {
|
"org/gnome/desktop/app-folders/folders/Browsers" = {
|
||||||
name = "Browsers";
|
name = "Browsers";
|
||||||
apps = [
|
apps = [
|
||||||
"brave-browser.desktop"
|
"brave-origin.desktop"
|
||||||
"firefox.desktop"
|
"firefox.desktop"
|
||||||
"org.gnome.Epiphany.desktop"
|
"org.gnome.Epiphany.desktop"
|
||||||
];
|
];
|
||||||
@@ -433,13 +462,13 @@ in
|
|||||||
];
|
];
|
||||||
|
|
||||||
xdg.mime.defaultApplications = {
|
xdg.mime.defaultApplications = {
|
||||||
"text/html" = "brave-browser.desktop";
|
"text/html" = "brave-origin.desktop";
|
||||||
"x-scheme-handler/http" = "brave-browser.desktop";
|
"x-scheme-handler/http" = "brave-origin.desktop";
|
||||||
"x-scheme-handler/https" = "brave-browser.desktop";
|
"x-scheme-handler/https" = "brave-origin.desktop";
|
||||||
"x-scheme-handler/about" = "brave-browser.desktop";
|
"x-scheme-handler/about" = "brave-origin.desktop";
|
||||||
"x-scheme-handler/unknown" = "brave-browser.desktop";
|
"x-scheme-handler/unknown" = "brave-origin.desktop";
|
||||||
};
|
};
|
||||||
|
|
||||||
environment.sessionVariables.BROWSER = "brave-browser";
|
environment.sessionVariables.BROWSER = "brave-origin";
|
||||||
|
|
||||||
}
|
}
|
||||||
+272
-188
@@ -20,6 +20,11 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Always operate from the repository root, regardless of the caller's cwd.
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
# Colors
|
# Colors
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
@@ -62,13 +67,28 @@ echo
|
|||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Helper: Get latest tag
|
# Helper: Get latest tag
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# GitHub release tags are fetched into a private namespace instead of
|
||||||
|
# refs/tags/. GitHub and Gitea contain some same-named historical tags that
|
||||||
|
# point to different objects; sharing refs/tags/ would make either fetch fail
|
||||||
|
# with "would clobber existing tag".
|
||||||
|
GITHUB_TAG_NAMESPACE="refs/release-tags/github"
|
||||||
|
|
||||||
get_latest_tag() {
|
get_latest_tag() {
|
||||||
local exclude="${1:-}"
|
local exclude="${1:-}"
|
||||||
if [[ -n "$exclude" ]]; then
|
local tag
|
||||||
git tag --list 'v*' --sort=-version:refname | grep -v -x -E "v?${exclude#v}|${exclude}" | head -1 || echo ""
|
|
||||||
else
|
while IFS= read -r tag; do
|
||||||
git tag --list 'v*' --sort=-version:refname | head -1 || echo ""
|
if [[ -n "$exclude" && "$tag" == "v${exclude#v}" ]]; then
|
||||||
fi
|
continue
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$tag"
|
||||||
|
return 0
|
||||||
|
done < <(git for-each-ref \
|
||||||
|
--sort=-version:refname \
|
||||||
|
--format='%(refname:strip=3)' \
|
||||||
|
"${GITHUB_TAG_NAMESPACE}/v*")
|
||||||
|
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -90,10 +110,84 @@ suggest_next_version() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Step 0: Fetch everything
|
# Step 0: Preflight and fetch everything
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
echo -e "${BLUE}Step 0: Fetching latest from all remotes...${NC}"
|
echo -e "${BLUE}Step 0: Running release preflight...${NC}"
|
||||||
git fetch --all --tags 2>/dev/null || true
|
|
||||||
|
# A release must start from an exact, committed, reproducible tree. This also
|
||||||
|
# prevents release metadata generated below from being mixed with local work.
|
||||||
|
if [[ -n "$(git status --porcelain)" ]]; then
|
||||||
|
echo -e "${RED}Error: the working tree is not clean. Commit, stash, or remove local changes first.${NC}" >&2
|
||||||
|
git status --short >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for remote in "$GITHUB_REMOTE" "$GITEA_REMOTE"; do
|
||||||
|
if ! git remote | grep -q -x "$remote"; then
|
||||||
|
echo -e "${RED}Error: required remote '$remote' is not configured.${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
for command_name in gh curl; do
|
||||||
|
if ! command -v "$command_name" >/dev/null 2>&1; then
|
||||||
|
echo -e "${RED}Error: required command '$command_name' is not installed.${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if ! gh auth status >/dev/null 2>&1; then
|
||||||
|
echo -e "${RED}Error: GitHub CLI is not authenticated. Run 'gh auth login' first.${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
||||||
|
echo
|
||||||
|
read -rsp "Enter your Gitea API token (input hidden): " GITEA_TOKEN
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
||||||
|
echo -e "${RED}Error: a GITEA_TOKEN with write:repository scope is required.${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e " Fetching GitHub (${GITHUB_REMOTE}) and Gitea (${GITEA_REMOTE})..."
|
||||||
|
# Never fetch either host's tags into refs/tags/. Historical tags with the same
|
||||||
|
# name differ between the hosts, and Git correctly refuses to clobber them.
|
||||||
|
git fetch "$GITHUB_REMOTE" --prune --no-tags
|
||||||
|
git fetch "$GITEA_REMOTE" --prune --no-tags
|
||||||
|
git fetch "$GITHUB_REMOTE" --prune --no-tags \
|
||||||
|
"+refs/tags/*:${GITHUB_TAG_NAMESPACE}/*"
|
||||||
|
|
||||||
|
GITHUB_MAIN_REF="refs/remotes/${GITHUB_REMOTE}/main"
|
||||||
|
GITEA_STAGING_REF="refs/remotes/${GITEA_REMOTE}/staging-dev"
|
||||||
|
|
||||||
|
if ! git rev-parse --verify "$GITHUB_MAIN_REF" >/dev/null 2>&1; then
|
||||||
|
echo -e "${RED}Error: cannot resolve GitHub main at ${GITHUB_MAIN_REF}.${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! git rev-parse --verify "$GITEA_STAGING_REF" >/dev/null 2>&1; then
|
||||||
|
echo -e "${RED}Error: cannot resolve Gitea staging-dev at ${GITEA_STAGING_REF}.${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
HEAD_COMMIT=$(git rev-parse HEAD)
|
||||||
|
GITHUB_MAIN_COMMIT=$(git rev-parse "$GITHUB_MAIN_REF")
|
||||||
|
GITEA_STAGING_COMMIT=$(git rev-parse "$GITEA_STAGING_REF")
|
||||||
|
|
||||||
|
if [[ "$GITHUB_MAIN_COMMIT" != "$GITEA_STAGING_COMMIT" ]]; then
|
||||||
|
echo -e "${RED}Error: GitHub main and Gitea staging-dev are not synchronized.${NC}" >&2
|
||||||
|
echo " GitHub main : $GITHUB_MAIN_COMMIT" >&2
|
||||||
|
echo " Gitea staging-dev : $GITEA_STAGING_COMMIT" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ "$HEAD_COMMIT" != "$GITHUB_MAIN_COMMIT" ]]; then
|
||||||
|
echo -e "${RED}Error: local HEAD is not the synchronized release candidate.${NC}" >&2
|
||||||
|
echo " Local HEAD : $HEAD_COMMIT" >&2
|
||||||
|
echo " Remote HEAD : $GITHUB_MAIN_COMMIT" >&2
|
||||||
|
echo "Update/check out the synchronized commit, then run the script again." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e " ${GREEN}✓${NC} Clean tree; GitHub main and Gitea staging-dev match local HEAD"
|
||||||
|
|
||||||
LATEST_TAG=$(get_latest_tag)
|
LATEST_TAG=$(get_latest_tag)
|
||||||
NEXT_VERSION=$(suggest_next_version "$LATEST_TAG")
|
NEXT_VERSION=$(suggest_next_version "$LATEST_TAG")
|
||||||
@@ -117,6 +211,23 @@ fi
|
|||||||
VERSION="${VERSION#v}"
|
VERSION="${VERSION#v}"
|
||||||
TAG="v${VERSION}"
|
TAG="v${VERSION}"
|
||||||
|
|
||||||
|
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
echo -e "${RED}Error: version must use MAJOR.MINOR.PATCH format (for example, 1.1.1).${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
remote_has_tag() {
|
||||||
|
local remote="$1"
|
||||||
|
[[ -n "$(git ls-remote --tags "$remote" "refs/tags/${TAG}" "refs/tags/${TAG}^{}")" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
if git show-ref --verify --quiet "refs/tags/${TAG}" || \
|
||||||
|
git show-ref --verify --quiet "${GITHUB_TAG_NAMESPACE}/${TAG}" || \
|
||||||
|
remote_has_tag "$GITHUB_REMOTE" || remote_has_tag "$GITEA_REMOTE"; then
|
||||||
|
echo -e "${RED}Error: tag ${TAG} already exists locally or on a remote.${NC}" >&2
|
||||||
|
echo "Refusing to move or overwrite an existing release tag." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Get release message
|
# Get release message
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -193,20 +304,26 @@ generate_release_notes() {
|
|||||||
printf '%s' "$notes"
|
printf '%s' "$notes"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Build the notes from commits since the previous tag (exclude the target tag if already present)
|
# Build notes from the previous canonical GitHub tag. Use its private ref so a
|
||||||
|
# conflicting local or Gitea tag with the same name cannot select the wrong
|
||||||
|
# commit.
|
||||||
PREV_TAG=$(get_latest_tag "$TAG")
|
PREV_TAG=$(get_latest_tag "$TAG")
|
||||||
if [[ -n "$PREV_TAG" ]] && git rev-parse -q --verify "$PREV_TAG" >/dev/null; then
|
PREV_TAG_REF="${GITHUB_TAG_NAMESPACE}/${PREV_TAG}"
|
||||||
COMMIT_RANGE="${PREV_TAG}..HEAD"
|
if [[ -n "$PREV_TAG" ]] && git rev-parse -q --verify "$PREV_TAG_REF" >/dev/null; then
|
||||||
|
COMMIT_RANGE="${PREV_TAG_REF}..HEAD"
|
||||||
|
COMMIT_RANGE_DISPLAY="${PREV_TAG}..HEAD"
|
||||||
else
|
else
|
||||||
COMMIT_RANGE="HEAD"
|
COMMIT_RANGE="HEAD"
|
||||||
|
COMMIT_RANGE_DISPLAY="HEAD"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo -e "${BLUE}Generating draft release notes from ${COMMIT_RANGE}...${NC}"
|
echo -e "${BLUE}Generating draft release notes from ${COMMIT_RANGE_DISPLAY}...${NC}"
|
||||||
RELEASE_NOTES="$(generate_release_notes "$COMMIT_RANGE")"
|
RELEASE_NOTES="$(generate_release_notes "$COMMIT_RANGE")"
|
||||||
|
|
||||||
# Let the user review/edit the generated notes before publishing
|
# Let the user review/edit the generated notes before publishing
|
||||||
NOTES_FILE="$(mktemp "/tmp/release-notes-${TAG}.XXXXXX.md")"
|
NOTES_FILE="$(mktemp "/tmp/release-notes-${TAG}.XXXXXX.md")"
|
||||||
|
trap 'rm -f "$NOTES_FILE"' EXIT
|
||||||
{
|
{
|
||||||
echo "## Sovran_SystemsOS ${TAG}"
|
echo "## Sovran_SystemsOS ${TAG}"
|
||||||
echo
|
echo
|
||||||
@@ -246,214 +363,182 @@ if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Step 1: Push tested code to Gitea (stable & staging-dev)
|
# Step 1: Prepare and commit all release metadata
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
echo
|
echo
|
||||||
echo -e "${BLUE}Step 1: Pushing tested code to Gitea (stable & staging-dev)...${NC}"
|
echo -e "${BLUE}Step 1: Preparing release metadata for ${TAG}...${NC}"
|
||||||
if git remote | grep -q -x "${GITEA_REMOTE}"; then
|
|
||||||
git push "${GITEA_REMOTE}" HEAD:stable --force-with-lease || echo -e " ${YELLOW}⚠ Push to ${GITEA_REMOTE} (stable) failed.${NC}"
|
|
||||||
git push "${GITEA_REMOTE}" HEAD:staging-dev --force-with-lease || echo -e " ${YELLOW}⚠ Push to ${GITEA_REMOTE} (staging-dev) failed.${NC}"
|
|
||||||
else
|
|
||||||
echo -e " ${YELLOW}⚠ Remote '${GITEA_REMOTE}' not found in git — skipping Gitea push.${NC}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# VERSION drives ISO naming.
|
||||||
# Step 2: Create annotated tag
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
echo
|
|
||||||
echo -e "${BLUE}Step 2: Creating annotated tag ${TAG}...${NC}"
|
|
||||||
git tag -f -a "${TAG}" -m "${RELEASE_MESSAGE}
|
|
||||||
|
|
||||||
- Stable release of Sovran_SystemsOS
|
|
||||||
- See CHANGELOG.md for full details" || true
|
|
||||||
|
|
||||||
if git remote | grep -q -x "${GITEA_REMOTE}"; then
|
|
||||||
git push "${GITEA_REMOTE}" "${TAG}" || echo -e " ${YELLOW}⚠ Tag push to ${GITEA_REMOTE} failed.${NC}"
|
|
||||||
else
|
|
||||||
echo -e " ${YELLOW}⚠ Remote '${GITEA_REMOTE}' not found in git — skipping Gitea tag push.${NC}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if git remote | grep -q -x "${GITHUB_REMOTE}"; then
|
|
||||||
git push "${GITHUB_REMOTE}" "${TAG}" || echo -e " ${YELLOW}⚠ Tag push to ${GITHUB_REMOTE} failed.${NC}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Update VERSION file for ISO builds
|
|
||||||
echo "${VERSION}" > VERSION
|
echo "${VERSION}" > VERSION
|
||||||
git add VERSION
|
|
||||||
git commit -m "chore: bump VERSION to ${TAG} for ISO naming" || true
|
|
||||||
echo -e " ${GREEN}✓${NC} VERSION file updated to ${VERSION}"
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Step 2b: Auto-update README.md ISO download references
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
echo
|
|
||||||
echo -e "${BLUE}Step 2b: Updating README.md ISO references to ${VERSION}...${NC}"
|
|
||||||
|
|
||||||
|
# Update every versioned ISO filename in README.md.
|
||||||
README_FILE="README.md"
|
README_FILE="README.md"
|
||||||
if [ -f "$README_FILE" ]; then
|
if [[ ! -f "$README_FILE" ]]; then
|
||||||
# Extract the version currently used in the README's ISO filenames,
|
echo -e "${RED}Error: ${README_FILE} not found.${NC}" >&2
|
||||||
# e.g. "Sovran_SystemsOS-1.0.6.iso" -> "1.0.6"
|
exit 1
|
||||||
OLD_ISO_VER=$(grep -oE 'Sovran_SystemsOS-[0-9]+\.[0-9]+\.[0-9]+\.iso' "$README_FILE" | head -1 | sed 's/Sovran_SystemsOS-//; s/\.iso//')
|
fi
|
||||||
|
OLD_ISO_VER=$(grep -oE 'Sovran_SystemsOS-[0-9]+\.[0-9]+\.[0-9]+\.iso' "$README_FILE" \
|
||||||
if [ -n "$OLD_ISO_VER" ] && [ "$OLD_ISO_VER" != "$VERSION" ]; then
|
| head -1 | sed 's/Sovran_SystemsOS-//; s/\.iso//')
|
||||||
# Portable in-place edit (works with GNU sed and BSD/macOS sed)
|
if [[ -z "$OLD_ISO_VER" ]]; then
|
||||||
sed "s/Sovran_SystemsOS-${OLD_ISO_VER}/Sovran_SystemsOS-${VERSION}/g" \
|
echo -e "${RED}Error: no versioned ISO reference found in ${README_FILE}.${NC}" >&2
|
||||||
"$README_FILE" > "$README_FILE.tmp" && mv "$README_FILE.tmp" "$README_FILE"
|
exit 1
|
||||||
|
fi
|
||||||
UPDATED=$(grep -c "Sovran_SystemsOS-${VERSION}" "$README_FILE")
|
if [[ "$OLD_ISO_VER" != "$VERSION" ]]; then
|
||||||
echo -e " ${GREEN}✓${NC} README.md: ${OLD_ISO_VER} -> ${VERSION} (${UPDATED} references updated)"
|
sed "s/Sovran_SystemsOS-${OLD_ISO_VER}/Sovran_SystemsOS-${VERSION}/g" \
|
||||||
|
"$README_FILE" > "$README_FILE.tmp"
|
||||||
git add "$README_FILE"
|
mv "$README_FILE.tmp" "$README_FILE"
|
||||||
git commit -m "docs: update README ISO download links to ${TAG}" || true
|
|
||||||
echo -e " ${GREEN}✓${NC} Committed README update"
|
|
||||||
else
|
|
||||||
echo -e " ${YELLOW}⚠${NC} README.md already references ${VERSION} or no versioned ISO links found — skipping"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo -e " ${YELLOW}⚠${NC} README.md not found — skipping"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# Add the changelog entry before tagging so the tag and stable branch contain it.
|
||||||
# Step 3: Auto-update CHANGELOG.md
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
echo
|
|
||||||
echo -e "${BLUE}Step 3: Updating ${CHANGELOG_FILE}...${NC}"
|
|
||||||
|
|
||||||
TODAY=$(date +%Y-%m-%d)
|
TODAY=$(date +%Y-%m-%d)
|
||||||
|
|
||||||
# Create new changelog entry from the generated notes (no placeholders)
|
|
||||||
NEW_ENTRY="## [${VERSION}] - ${TODAY}
|
NEW_ENTRY="## [${VERSION}] - ${TODAY}
|
||||||
|
|
||||||
${RELEASE_NOTES}
|
${RELEASE_NOTES}
|
||||||
[${VERSION}]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/${TAG}
|
[${VERSION}]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/${TAG}
|
||||||
"
|
"
|
||||||
|
|
||||||
# Prepend to changelog (after the header)
|
if [[ ! -f "$CHANGELOG_FILE" ]]; then
|
||||||
if [ -f "$CHANGELOG_FILE" ]; then
|
echo -e "${RED}Error: ${CHANGELOG_FILE} not found.${NC}" >&2
|
||||||
# Backup
|
exit 1
|
||||||
cp "$CHANGELOG_FILE" "${CHANGELOG_FILE}.bak"
|
|
||||||
|
|
||||||
# Insert new section after the first --- line
|
|
||||||
awk -v new_entry="$NEW_ENTRY" '
|
|
||||||
BEGIN { printed=0 }
|
|
||||||
/^---$/ && !printed {
|
|
||||||
print
|
|
||||||
print ""
|
|
||||||
print new_entry
|
|
||||||
printed=1
|
|
||||||
next
|
|
||||||
}
|
|
||||||
{ print }
|
|
||||||
' "$CHANGELOG_FILE" > "${CHANGELOG_FILE}.tmp" && mv "${CHANGELOG_FILE}.tmp" "$CHANGELOG_FILE"
|
|
||||||
rm -f "${CHANGELOG_FILE}.bak"
|
|
||||||
|
|
||||||
echo -e " ${GREEN}✓${NC} CHANGELOG.md updated with new section for ${TAG}"
|
|
||||||
else
|
|
||||||
echo -e " ${YELLOW}⚠${NC} CHANGELOG.md not found — skipping"
|
|
||||||
fi
|
fi
|
||||||
|
awk -v new_entry="$NEW_ENTRY" '
|
||||||
|
BEGIN { printed=0 }
|
||||||
|
/^---$/ && !printed {
|
||||||
|
print
|
||||||
|
print ""
|
||||||
|
print new_entry
|
||||||
|
printed=1
|
||||||
|
next
|
||||||
|
}
|
||||||
|
{ print }
|
||||||
|
END { if (!printed) exit 2 }
|
||||||
|
' "$CHANGELOG_FILE" > "${CHANGELOG_FILE}.tmp" || {
|
||||||
|
rm -f "${CHANGELOG_FILE}.tmp"
|
||||||
|
echo -e "${RED}Error: could not find the changelog insertion marker.${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
mv "${CHANGELOG_FILE}.tmp" "$CHANGELOG_FILE"
|
||||||
|
|
||||||
# Commit the changelog update
|
git add VERSION "$README_FILE" "$CHANGELOG_FILE"
|
||||||
git add "$CHANGELOG_FILE"
|
|
||||||
if git diff --cached --quiet; then
|
if git diff --cached --quiet; then
|
||||||
echo " (No changes to commit in changelog)"
|
echo -e "${RED}Error: release preparation produced no changes.${NC}" >&2
|
||||||
else
|
exit 1
|
||||||
git commit -m "docs: update CHANGELOG.md for ${TAG}"
|
|
||||||
echo -e " ${GREEN}✓${NC} Committed changelog update"
|
|
||||||
|
|
||||||
# Ask if user wants to push
|
|
||||||
echo
|
|
||||||
read -rp "Push the changelog commit to GitHub (main) and Gitea (staging-dev) now? (y/N): " push_confirm
|
|
||||||
if [[ "$push_confirm" =~ ^[Yy]$ ]]; then
|
|
||||||
if git remote | grep -q -x "${GITHUB_REMOTE}"; then
|
|
||||||
echo -e "${BLUE}Pushing changelog commit to GitHub main...${NC}"
|
|
||||||
if git push "${GITHUB_REMOTE}" HEAD:main; then
|
|
||||||
echo -e " ${GREEN}✓${NC} Changelog pushed to GitHub main (${GITHUB_REMOTE})"
|
|
||||||
else
|
|
||||||
echo -e " ${YELLOW}⚠ Push to GitHub main failed — verify credentials or remote name.${NC}"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
if git remote | grep -q -x "${GITEA_REMOTE}"; then
|
|
||||||
echo -e "${BLUE}Pushing changelog commit to Gitea staging-dev...${NC}"
|
|
||||||
if git push "${GITEA_REMOTE}" HEAD:staging-dev; then
|
|
||||||
echo -e " ${GREEN}✓${NC} Changelog pushed to Gitea staging-dev (${GITEA_REMOTE})"
|
|
||||||
else
|
|
||||||
echo -e " ${YELLOW}⚠ Push to Gitea staging-dev failed.${NC}"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo " (Changelog commit left local — remember to push later)"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
git commit -m "chore(release): prepare ${TAG}"
|
||||||
|
RELEASE_COMMIT=$(git rev-parse HEAD)
|
||||||
|
|
||||||
|
echo -e " ${GREEN}✓${NC} VERSION, README, and CHANGELOG committed"
|
||||||
|
echo -e " Release commit: ${CYAN}${RELEASE_COMMIT}${NC}"
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Step 4: Create GitHub Release via gh CLI
|
# Step 2: Publish the final release commit to every release branch
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
echo
|
echo
|
||||||
echo -e "${BLUE}Step 4: Creating GitHub Release...${NC}"
|
echo -e "${BLUE}Step 2: Publishing the final release commit...${NC}"
|
||||||
|
|
||||||
if command -v gh &>/dev/null; then
|
# GitHub main and Gitea staging-dev were verified equal during preflight, so
|
||||||
title_suffix="${RELEASE_MESSAGE#Sovran_SystemsOS v* — }"
|
# these are normal fast-forward pushes. Stable is an intentional promotion and
|
||||||
title_suffix="${title_suffix#Sovran_SystemsOS * — }"
|
# uses a lease to prevent overwriting a branch changed since the fetch.
|
||||||
if gh release create "${TAG}" \
|
git push "$GITHUB_REMOTE" HEAD:main
|
||||||
--repo naturallaw777/Sovran_SystemsOS \
|
git push "$GITEA_REMOTE" HEAD:staging-dev
|
||||||
--title "${TAG} — ${title_suffix}" \
|
git push "$GITEA_REMOTE" HEAD:stable --force-with-lease
|
||||||
--notes-file "${NOTES_FILE}"; then
|
|
||||||
echo -e " ${GREEN}✓${NC} GitHub release created successfully"
|
# Verify all three branch tips before creating an immutable release tag.
|
||||||
else
|
remote_branch_commit() {
|
||||||
echo -e " ${YELLOW}⚠${NC} GitHub release creation failed (check 'gh auth status' or create via web GUI)"
|
local remote="$1"
|
||||||
fi
|
local branch="$2"
|
||||||
else
|
git ls-remote "$remote" "refs/heads/${branch}" | awk 'NR == 1 { print $1 }'
|
||||||
echo -e " ${YELLOW}⚠${NC} gh CLI not found — skipping GitHub release (create via GitHub web GUI)"
|
}
|
||||||
|
|
||||||
|
PUBLISHED_GITHUB=$(remote_branch_commit "$GITHUB_REMOTE" main)
|
||||||
|
PUBLISHED_STAGING=$(remote_branch_commit "$GITEA_REMOTE" staging-dev)
|
||||||
|
PUBLISHED_STABLE=$(remote_branch_commit "$GITEA_REMOTE" stable)
|
||||||
|
if [[ "$PUBLISHED_GITHUB" != "$RELEASE_COMMIT" || \
|
||||||
|
"$PUBLISHED_STAGING" != "$RELEASE_COMMIT" || \
|
||||||
|
"$PUBLISHED_STABLE" != "$RELEASE_COMMIT" ]]; then
|
||||||
|
echo -e "${RED}Error: post-push verification failed; no release tag was created.${NC}" >&2
|
||||||
|
echo " Expected : $RELEASE_COMMIT" >&2
|
||||||
|
echo " GitHub main : ${PUBLISHED_GITHUB:-missing}" >&2
|
||||||
|
echo " Gitea staging-dev : ${PUBLISHED_STAGING:-missing}" >&2
|
||||||
|
echo " Gitea stable : ${PUBLISHED_STABLE:-missing}" >&2
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
echo -e " ${GREEN}✓${NC} GitHub main, Gitea staging-dev, and Gitea stable all match"
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Step 5: Create Gitea Release via API
|
# Step 3: Tag the final release commit and publish the tag
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
echo
|
echo
|
||||||
echo -e "${BLUE}Step 5: Creating Gitea Release via API...${NC}"
|
echo -e "${BLUE}Step 3: Creating annotated tag ${TAG} on ${RELEASE_COMMIT}...${NC}"
|
||||||
|
git tag -a "$TAG" -m "${RELEASE_MESSAGE}
|
||||||
|
|
||||||
# ── Gitea Token Handling ─────────────────────────────────────────────────────
|
- Stable release of Sovran_SystemsOS
|
||||||
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
- See CHANGELOG.md for full details" "$RELEASE_COMMIT"
|
||||||
echo
|
|
||||||
echo -e "${YELLOW}GITEA_TOKEN is not set.${NC}"
|
git push "$GITHUB_REMOTE" "$TAG"
|
||||||
read -rsp "Enter your Gitea API token (input will be hidden): " GITEA_TOKEN
|
git push "$GITEA_REMOTE" "$TAG"
|
||||||
echo
|
|
||||||
if [[ -z "$GITEA_TOKEN" ]]; then
|
# For an annotated tag, ^{} resolves the commit referenced by the tag object.
|
||||||
echo -e " ${YELLOW}⚠${NC} No token provided — skipping Gitea release"
|
remote_tag_commit() {
|
||||||
GITEA_TOKEN=""
|
local remote="$1"
|
||||||
fi
|
git ls-remote "$remote" "refs/tags/${TAG}^{}" | awk 'NR == 1 { print $1 }'
|
||||||
|
}
|
||||||
|
GITHUB_TAG_COMMIT=$(remote_tag_commit "$GITHUB_REMOTE")
|
||||||
|
GITEA_TAG_COMMIT=$(remote_tag_commit "$GITEA_REMOTE")
|
||||||
|
if [[ "$GITHUB_TAG_COMMIT" != "$RELEASE_COMMIT" || "$GITEA_TAG_COMMIT" != "$RELEASE_COMMIT" ]]; then
|
||||||
|
echo -e "${RED}Error: published tag verification failed.${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo -e " ${GREEN}✓${NC} ${TAG} points to the final release commit on GitHub and Gitea"
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Step 4: Create GitHub release via gh CLI
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
echo
|
||||||
|
echo -e "${BLUE}Step 4: Creating GitHub release...${NC}"
|
||||||
|
|
||||||
|
title_suffix="${RELEASE_MESSAGE#Sovran_SystemsOS v* — }"
|
||||||
|
title_suffix="${title_suffix#Sovran_SystemsOS * — }"
|
||||||
|
gh release create "$TAG" \
|
||||||
|
--repo naturallaw777/Sovran_SystemsOS \
|
||||||
|
--title "${TAG} — ${title_suffix}" \
|
||||||
|
--notes-file "$NOTES_FILE"
|
||||||
|
echo -e " ${GREEN}✓${NC} GitHub release created successfully"
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Step 5: Create Gitea release via API
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
echo
|
||||||
|
echo -e "${BLUE}Step 5: Creating Gitea release via API...${NC}"
|
||||||
|
|
||||||
|
GITEA_REPO="Sovran_Systems/Sovran_SystemsOS"
|
||||||
|
|
||||||
|
# Build JSON safely because release notes may contain quotes and newlines.
|
||||||
|
if command -v jq >/dev/null 2>&1; then
|
||||||
|
PAYLOAD=$(jq -n \
|
||||||
|
--arg tag "$TAG" \
|
||||||
|
--arg name "$TAG — Stable Release" \
|
||||||
|
--arg body "$RELEASE_BODY" \
|
||||||
|
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')
|
||||||
|
else
|
||||||
|
PAYLOAD=$(python3 -c "import json,sys; print(json.dumps({'tag_name': sys.argv[1], 'name': sys.argv[1] + ' — Stable Release', 'body': open(sys.argv[2]).read(), 'draft': False, 'prerelease': False}))" "$TAG" "$NOTES_FILE")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -n "${GITEA_TOKEN:-}" ]]; then
|
RESPONSE_FILE=$(mktemp "/tmp/gitea-release-${TAG}.XXXXXX.json")
|
||||||
GITEA_REPO="Sovran_Systems/Sovran_SystemsOS"
|
HTTP_STATUS=$(curl -sS -o "$RESPONSE_FILE" -w '%{http_code}' -X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$PAYLOAD" \
|
||||||
|
"${GITEA_API_URL}/repos/${GITEA_REPO}/releases")
|
||||||
|
|
||||||
# Build JSON payload safely (release body may contain quotes/newlines)
|
if [[ "$HTTP_STATUS" != "201" ]]; then
|
||||||
if command -v jq &>/dev/null; then
|
echo -e "${RED}Error: Gitea release creation failed (HTTP ${HTTP_STATUS}).${NC}" >&2
|
||||||
PAYLOAD=$(jq -n \
|
cat "$RESPONSE_FILE" >&2
|
||||||
--arg tag "${TAG}" \
|
rm -f "$RESPONSE_FILE"
|
||||||
--arg name "${TAG} — Stable Release" \
|
exit 1
|
||||||
--arg body "${RELEASE_BODY}" \
|
|
||||||
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')
|
|
||||||
else
|
|
||||||
PAYLOAD=$(python3 -c "import json,sys; print(json.dumps({'tag_name': sys.argv[1], 'name': sys.argv[1] + ' — Stable Release', 'body': open(sys.argv[2]).read(), 'draft': False, 'prerelease': False}))" "${TAG}" "${NOTES_FILE}")
|
|
||||||
fi
|
|
||||||
|
|
||||||
RESPONSE=$(curl -s -X POST \
|
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "${PAYLOAD}" \
|
|
||||||
"${GITEA_API_URL}/repos/${GITEA_REPO}/releases" 2>/dev/null || echo "")
|
|
||||||
|
|
||||||
if echo "$RESPONSE" | grep -q '"id"'; then
|
|
||||||
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
|
|
||||||
echo -e " ${YELLOW}⚠${NC} Gitea release creation failed or already exists"
|
|
||||||
echo " Response: $RESPONSE"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
rm -f "$RESPONSE_FILE"
|
||||||
|
echo -e " ${GREEN}✓${NC} Gitea release created successfully"
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Final Summary
|
# Final Summary
|
||||||
@@ -468,8 +553,7 @@ echo " • Build the installer ISO:"
|
|||||||
echo " nix build .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage"
|
echo " nix build .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage"
|
||||||
echo " • Package, verify, and upload ISO to CDN:"
|
echo " • Package, verify, and upload ISO to CDN:"
|
||||||
echo " ./scripts/upload-cdn.sh --upload"
|
echo " ./scripts/upload-cdn.sh --upload"
|
||||||
echo " • Review and enhance the new section in CHANGELOG.md"
|
echo " • Verify the ISO download and checksum from the public CDN"
|
||||||
echo " • Push changes: git push ${GITHUB_REMOTE} HEAD:main && git push ${GITEA_REMOTE} HEAD:staging-dev"
|
|
||||||
echo " • Verify releases on both GitHub and Gitea"
|
echo " • Verify releases on both GitHub and Gitea"
|
||||||
echo
|
echo
|
||||||
echo -e "${CYAN}Tag created: ${TAG}${NC}"
|
echo -e "${CYAN}Tag created: ${TAG}${NC}"
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Regression tests for the Bitcoin Core Tor IBD gossip Hub option.
|
||||||
|
|
||||||
|
These tests intentionally avoid importing the FastAPI application so they can run
|
||||||
|
in the repository's lightweight test environment without NixOS service access.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
|
||||||
|
def _read(*parts: str) -> str:
|
||||||
|
with open(os.path.join(_REPO_ROOT, *parts), encoding="utf-8") as source:
|
||||||
|
return source.read()
|
||||||
|
|
||||||
|
|
||||||
|
def _literal_assignment(source: str, name: str):
|
||||||
|
tree = ast.parse(source)
|
||||||
|
for node in tree.body:
|
||||||
|
if isinstance(node, ast.Assign):
|
||||||
|
if any(isinstance(target, ast.Name) and target.id == name for target in node.targets):
|
||||||
|
return ast.literal_eval(node.value)
|
||||||
|
raise AssertionError(f"Assignment {name} was not found")
|
||||||
|
|
||||||
|
|
||||||
|
class TestBitcoinTorGossipNixWiring(unittest.TestCase):
|
||||||
|
def test_bitcoind_loopback_listener_is_always_enabled(self):
|
||||||
|
ecosystem = _read("modules", "bitcoinecosystem.nix")
|
||||||
|
self.assertIn("listen = true;", ecosystem)
|
||||||
|
self.assertIn("peerbloomfilters=1", ecosystem)
|
||||||
|
|
||||||
|
def test_gossip_is_opt_in(self):
|
||||||
|
ecosystem = _read("modules", "bitcoinecosystem.nix")
|
||||||
|
self.assertIn(
|
||||||
|
"public = config.sovran_systemsOS.features.bitcoin-tor-gossip;",
|
||||||
|
ecosystem,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_hub_option_and_evaluated_state_are_declared(self):
|
||||||
|
roles = _read("modules", "core", "roles.nix")
|
||||||
|
hub = _read("modules", "core", "sovran-hub.nix")
|
||||||
|
self.assertIn("bitcoin-tor-gossip = lib.mkEnableOption", roles)
|
||||||
|
self.assertIn("bitcoin-tor-gossip = cfg.features.bitcoin-tor-gossip;", hub)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBitcoinTorGossipHubWiring(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.server_source = _read("app", "sovran_systemsos_web", "server.py")
|
||||||
|
cls.registry = _literal_assignment(cls.server_source, "FEATURE_REGISTRY")
|
||||||
|
|
||||||
|
def test_feature_is_modal_only_and_explains_risk(self):
|
||||||
|
feature = next(item for item in self.registry if item["id"] == "bitcoin-tor-gossip")
|
||||||
|
self.assertTrue(feature["modal_only"])
|
||||||
|
self.assertIn("bitcoin-service", feature["requires"])
|
||||||
|
self.assertTrue(any("clearnet" in detail for detail in feature["details"]))
|
||||||
|
self.assertTrue(any("bandwidth" in detail for detail in feature["details"]))
|
||||||
|
|
||||||
|
def test_backend_rejects_gossip_without_bitcoin_service(self):
|
||||||
|
self.assertIn(
|
||||||
|
"Enable the Bitcoin service before advertising its Tor IBD service.",
|
||||||
|
self.server_source,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_core_modal_renders_and_controls_the_option(self):
|
||||||
|
frontend = _read(
|
||||||
|
"app", "sovran_systemsos_web", "static", "js", "service-detail.js"
|
||||||
|
)
|
||||||
|
self.assertIn("Tor IBD Service Advertising", frontend)
|
||||||
|
self.assertIn("svc-detail-related-feature-btn", frontend)
|
||||||
|
self.assertIn("handleFeatureToggle(relatedFeat", frontend)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -365,6 +365,100 @@ class TestAuthExemptPaths(unittest.TestCase):
|
|||||||
self.assertIn("/api/ping", self._get_exempt_paths())
|
self.assertIn("/api/ping", self._get_exempt_paths())
|
||||||
|
|
||||||
|
|
||||||
|
class TestFrontendAuthRecovery(unittest.TestCase):
|
||||||
|
"""Expired browser sessions must not leave the Hub polling forever."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
path = os.path.join(
|
||||||
|
_REPO_ROOT, "app", "sovran_systemsos_web", "static", "js", "helpers.js"
|
||||||
|
)
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
cls.helpers = f.read()
|
||||||
|
|
||||||
|
def test_api_fetch_redirects_unauthorized_response_to_login(self):
|
||||||
|
self.assertRegex(self.helpers, r"res\.status\s*===\s*401")
|
||||||
|
self.assertIn('window.location.replace("/login")', self.helpers)
|
||||||
|
|
||||||
|
def test_unauthorized_response_does_not_use_local_auto_login(self):
|
||||||
|
# Remote clients must still authenticate with the Hub password.
|
||||||
|
self.assertNotIn('window.location.replace("/auto-login")', self.helpers)
|
||||||
|
|
||||||
|
|
||||||
|
class TestManualLogoutPersistence(unittest.TestCase):
|
||||||
|
"""Explicit logout must take precedence over desktop auto-login."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
path = os.path.join(_REPO_ROOT, "app", "sovran_systemsos_web", "server.py")
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
cls.server = f.read()
|
||||||
|
|
||||||
|
def _between(self, start, end):
|
||||||
|
return self.server.split(start, 1)[1].split(end, 1)[0]
|
||||||
|
|
||||||
|
def test_auto_login_honors_manual_logout_cookie(self):
|
||||||
|
route = self._between(
|
||||||
|
'@app.get("/auto-login")',
|
||||||
|
"class LoginRequest",
|
||||||
|
)
|
||||||
|
self.assertIn("request.cookies.get(MANUAL_LOGOUT_COOKIE_NAME)", route)
|
||||||
|
self.assertIn('RedirectResponse(url="/login"', route)
|
||||||
|
|
||||||
|
def test_logout_sets_persistent_manual_logout_cookie(self):
|
||||||
|
route = self._between(
|
||||||
|
'@app.post("/api/logout")',
|
||||||
|
"def _get_sovran_version",
|
||||||
|
)
|
||||||
|
self.assertIn("key=MANUAL_LOGOUT_COOKIE_NAME", route)
|
||||||
|
self.assertIn("max_age=MANUAL_LOGOUT_MAX_AGE", route)
|
||||||
|
self.assertIn("httponly=True", route)
|
||||||
|
|
||||||
|
def test_password_login_clears_manual_logout_cookie(self):
|
||||||
|
route = self._between(
|
||||||
|
'@app.post("/api/login")',
|
||||||
|
'@app.post("/api/logout")',
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"response.delete_cookie(key=MANUAL_LOGOUT_COOKIE_NAME)", route
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHubBrowserProfilePersistence(unittest.TestCase):
|
||||||
|
"""The desktop launcher must keep a persistent browser profile.
|
||||||
|
|
||||||
|
The hub_manual_logout marker (and the session cookie) are stored in this
|
||||||
|
profile. If the launcher used an ephemeral /tmp profile that it deleted on
|
||||||
|
exit, closing and reopening the Hub window would wipe the marker and
|
||||||
|
/auto-login would silently log the user back in without a password.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
path = os.path.join(
|
||||||
|
_REPO_ROOT, "modules", "core", "sovran-hub.nix"
|
||||||
|
)
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
cls.wrapper = f.read()
|
||||||
|
|
||||||
|
def test_profile_is_not_under_tmp(self):
|
||||||
|
# The profile must live in a persistent per-user location, not /tmp.
|
||||||
|
self.assertNotIn("/tmp/sovran-hub-brave", self.wrapper)
|
||||||
|
|
||||||
|
def test_profile_is_not_deleted_on_exit(self):
|
||||||
|
# There must be no trap that removes the user-data-dir on exit.
|
||||||
|
self.assertNotRegex(self.wrapper, r"rm\s+-rf\s+.*HUB_DATA")
|
||||||
|
self.assertNotIn("trap '", self.wrapper)
|
||||||
|
|
||||||
|
def test_profile_is_persistent_per_user_location(self):
|
||||||
|
self.assertIn("sovran-hub-browser", self.wrapper)
|
||||||
|
# It should honour XDG_STATE_HOME (standard, persistent per-user dir).
|
||||||
|
self.assertIn("XDG_STATE_HOME", self.wrapper)
|
||||||
|
|
||||||
|
def test_launcher_still_uses_user_data_dir(self):
|
||||||
|
self.assertIn("--user-data-dir=", self.wrapper)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Persistent session store
|
# Persistent session store
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in new issue
Block a user