Compare commits
16
Commits
@@ -443,6 +443,70 @@ below is dead on every path. Pre-existing; spotted in the v1.7.120 build warning
|
||||
|
||||
---
|
||||
|
||||
## STATUS 2026-08-04 — what shipped in 1.7.121 and what did not
|
||||
|
||||
### Shipped (committed + pushed)
|
||||
|
||||
| Item | Commit | Verified |
|
||||
|---|---|---|
|
||||
| 3. Federation trust escalation | `c0cfc72a` | 42/42 federation tests |
|
||||
| 3b. Trusted requires node password | `24ce8b39` | 44/44 + 79/79 + vue-tsc |
|
||||
| 4. lnd-ui OTA pin + host networking | `5088aef5` | — |
|
||||
| 1b. Manifest `auth:` declarations | `0c4826f8` | 73/73, all 56 manifests parse |
|
||||
| 1c. App gate (engine + audit) | `0de67ca6` | 23/23 appgate |
|
||||
| Dashboard backdrop-filter seam | `63d0183d` | 3/3, **live on archi-dev-box** |
|
||||
| 7. Release refuses unsigned manifest | `cc9e1958` | dry-run: signed/stripped/wrong-signer |
|
||||
| Gate safety model (`Option<PortAuth>`) | `ab2c8b6e` | 75/75 incl. LND wallet-port case |
|
||||
| Companion rebuild-loop | `719446c0` | podman behaviour proven first |
|
||||
| 5. Federated peers messageable | `edc9a172` | predicate pinned across device types |
|
||||
|
||||
### The two gate incidents — read before touching the gate again
|
||||
|
||||
Both were ONE mistake: a safety decision read an ABSENT manifest field as a
|
||||
value. A node's installed manifests always lag the binary, so "absent" is the
|
||||
normal state, and the daemon acted on instructions no manifest ever gave.
|
||||
|
||||
1. Gating any `session` port regardless of `bind` **published Bitcoin's
|
||||
loopback-only RPC 8332 on the LAN/Tailscale/IPv6** within seconds of deploy.
|
||||
2. The `bind`-keyed replacement looked safe (it protected `bind: 127.0.0.1`)
|
||||
but LND's gRPC 10009 / REST 18080 carry an EMPTY bind — one container
|
||||
recreate from pinning them to loopback and **breaking Zeus and every remote
|
||||
wallet**.
|
||||
|
||||
Now structural: `auth_policy()` classifies (undeclared → reported as
|
||||
unprotected, always safe), `auth_is_declared()` gates action (undeclared →
|
||||
never acted on). **Silence is not consent.**
|
||||
|
||||
### Proven on the node, empirically, not by reasoning
|
||||
|
||||
- Gate challenge → login → proxy works end to end over LAN and Tailscale.
|
||||
- **Daemon-side publish rewriting was removed.** Publishes are built in several
|
||||
places (`podman_client`, `package::install`, `stacks`); patching one covered
|
||||
one — the strfry recreate went through another and the pin never fired.
|
||||
- **Disk manifest edits do not apply to catalog-covered apps.** Even
|
||||
`bind: 127.0.0.1` written into the node's strfry manifest was overridden by
|
||||
the signed catalog. The catalog re-sign is REQUIRED; there is no shortcut.
|
||||
- A loopback-bound host port is **unreachable** from a pasta container, so
|
||||
loopback-pinning the Wyoming ports would break Home Assistant voice.
|
||||
|
||||
### Open for 1.7.122
|
||||
|
||||
1. **Catalog re-sign** — `bind: 127.0.0.1` + `auth: session` on the ~39 gated
|
||||
UI ports. This is what turns the gate from auditing into enforcing. Nothing
|
||||
in code can substitute for it.
|
||||
2. **Release-root rotation** — branch `rotate-release-root`, key
|
||||
`did:key:z6Mkfu5LT…DLWT` / `1578adcc…4418`, validated as a real curve point.
|
||||
**Sign the rotation release with the OLD key**; only the release after it
|
||||
uses the new one. Re-sign the catalog too.
|
||||
3. **Wyoming voice ports** (10200/10300/10400) — unauthenticated, and by the
|
||||
operator's policy they should not be. Correct fix is co-locating Home
|
||||
Assistant with the pine services on one container network so nothing is
|
||||
published; needs a node running both.
|
||||
4. **Item 2** filebrowser default login. **Items 6/6b** app updates +
|
||||
multiversion (`versions[]` already exists, populated for 2 of 66 apps).
|
||||
5. **`cargo-test-weekly` times out** at its 1500s cap on a loaded box — raise
|
||||
the cap or split the stage; it is not a code failure.
|
||||
|
||||
## RESUME HERE — next session
|
||||
|
||||
**Landed this session (both pushed):**
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.122-alpha (2026-08-04)
|
||||
|
||||
- **Your apps now ask for your node password before they open — over your home network, Tailscale, the mesh and Tor alike.** Until now anyone who could reach your node could open Immich, Nextcloud, Vaultwarden, Jellyfin, Grafana and the rest simply by typing the address and port, with no login at all. Twenty app screens now sit behind the same login you use for the node, showing you which app you are opening, and honouring two-factor if you have it switched on. Logging in at an app address logs you into the dashboard too, so it is one password, not one per app. This completes the groundwork disclosed in v1.7.121.
|
||||
- **The things that must stay open stayed open.** Zeus and other remote wallets still reach your Lightning node directly, Electrum wallets still connect, and Bitcoin still talks to its peers — those connections carry their own proof of identity and a login page would simply break them. Every one of these seventeen exceptions now has to state in writing why it is safe to leave open, so the list is something you can read rather than something you have to discover.
|
||||
- **A private address on your node was answering the mesh without a password.** One app's port was marked as being for this machine only, and the part of the node that carries mesh traffic did not know that — it forwarded requests from the whole mesh straight to it. Found while verifying the work above on a real node, not in testing. That path now refuses anything marked machine-only, and the app is reachable only from the node itself, as intended.
|
||||
- **Tor addresses no longer skip the login.** An app published as a .onion address was handed straight to the app, because a Tor visitor carries no session cookie to check. The login gate now takes those addresses first, closing the last of the four routes that went around it.
|
||||
- Nodes fix themselves after this update. Apps installed before this system used its current container setup kept their old wide-open address even after the signed list told them to move, and each would otherwise have needed hand-holding on every node. Your node now notices the difference and rebuilds those apps itself, keeping their data, within about half a minute of starting. Verified by putting a node back into the old state deliberately and watching it repair.
|
||||
- The node had been reading two different sets of instructions about its own apps — the signed list it downloads, and older copies on disk — which is how a port meant to stay private was briefly opened on a test node. Both now come from the signed list, and a port withdrawn from the login gate is released without needing a restart.
|
||||
- **The key that signs these updates has been replaced.** The previous signing key was exposed where it should not have been, so it is treated as compromised and this release installs its replacement. This update is the last one signed with the old key, by necessity — it is the one that teaches your node the new one.
|
||||
- Known gaps, disclosed rather than buried: eleven app ports still have no stated policy — BotFights, the Fedimint gateway, NetBird, the voice assistant's own screens and the router screen — and remain reachable without a login until each is decided deliberately; the node reports them rather than guessing, because guessing at an unstated setting caused both incidents behind this work. Three voice-assistant ports are still open without authentication; the correct fix puts them on a private network with the assistant. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — will now meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release.
|
||||
|
||||
## v1.7.121-alpha (2026-08-04)
|
||||
|
||||
- **Making another node "Trusted" now asks for your node password.** Trust was being handed out by machines rather than by you: any node able to reach yours could join and mark itself Trusted, because the check proved only that the caller owned the key it had just presented — never that you had approved it. Trust also spread on its own, since every peer a Trusted node advertised was added as Trusted too, so one grant quietly propagated across the whole federation. Uninvited joins are now capped at Observer, advertised peers arrive as Observers, and raising anyone to Trusted — whether by generating an invite or by changing the dropdown on a node — requires your password. Lowering trust deliberately does not, because the safe action must never be the inconvenient one. Existing peers are left exactly as they are rather than silently demoted, and each one now records how its trust was granted so you can review them.
|
||||
|
||||
@@ -26,6 +26,8 @@ app:
|
||||
- host: 4080
|
||||
container: 8080
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
environment:
|
||||
- FRONTEND_HTTP_PORT=8080
|
||||
|
||||
@@ -33,6 +33,8 @@ app:
|
||||
- host: 32838
|
||||
container: 32838
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: local
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -51,6 +51,8 @@ app:
|
||||
- host: 3535
|
||||
container: 3535
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: local
|
||||
|
||||
volumes:
|
||||
# Holds the wallet DB, mnemonic and auth token. ARK funds are recoverable
|
||||
|
||||
@@ -45,6 +45,8 @@ app:
|
||||
- host: 23000
|
||||
container: 49392
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -30,6 +30,8 @@ app:
|
||||
- host: 8088
|
||||
container: 8080
|
||||
protocol: tcp # Web UI
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -66,6 +66,8 @@ app:
|
||||
- host: 8178
|
||||
container: 8080
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: local
|
||||
|
||||
volumes:
|
||||
# Same dir the first-boot bundled path uses + where the wallet bridge reads
|
||||
|
||||
@@ -58,6 +58,8 @@ app:
|
||||
- host: 8177
|
||||
container: 8175
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: local
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -27,6 +27,8 @@ app:
|
||||
- host: 8083
|
||||
container: 80
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -26,6 +26,8 @@ app:
|
||||
- host: 3001
|
||||
container: 3000
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
- host: 2222
|
||||
container: 22
|
||||
protocol: tcp
|
||||
|
||||
@@ -31,6 +31,8 @@ app:
|
||||
- host: 3000
|
||||
container: 3000
|
||||
protocol: tcp # Web UI
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -30,6 +30,8 @@ app:
|
||||
- host: 8123
|
||||
container: 8123
|
||||
protocol: tcp # Web UI
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -44,6 +44,8 @@ app:
|
||||
- host: 2283
|
||||
container: 2283
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -38,6 +38,8 @@ app:
|
||||
- host: 7778
|
||||
container: 7777
|
||||
protocol: tcp # Web UI. Port 7777 on the host is reserved for the Nostr relay.
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
# Writable scratch the baked nginx needs; matches the legacy installer's
|
||||
# --tmpfs /run + /var/cache/nginx.
|
||||
|
||||
@@ -25,6 +25,8 @@ app:
|
||||
- host: 8096
|
||||
container: 8096
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -42,6 +42,8 @@ app:
|
||||
- host: 8999
|
||||
container: 8999
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: local
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -33,6 +33,8 @@ app:
|
||||
- host: 4080
|
||||
container: 8080 # mempool-frontend nginx listens on 8080 (FRONTEND_HTTP_PORT=8080)
|
||||
protocol: tcp # Web UI
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -30,6 +30,8 @@ app:
|
||||
- host: 8089
|
||||
container: 8080
|
||||
protocol: tcp # Web UI
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -25,6 +25,8 @@ app:
|
||||
- host: 8085
|
||||
container: 80
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -31,6 +31,8 @@ app:
|
||||
- host: 18081
|
||||
container: 8080
|
||||
protocol: tcp # HTTP/WebSocket
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -24,6 +24,8 @@ app:
|
||||
- host: 2342
|
||||
container: 2342
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -27,6 +27,8 @@ app:
|
||||
- host: 9000
|
||||
container: 9000
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -29,6 +29,8 @@ app:
|
||||
- host: 8888
|
||||
container: 8080
|
||||
protocol: tcp # Web UI
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -29,6 +29,8 @@ app:
|
||||
- host: 8090
|
||||
container: 7777
|
||||
protocol: tcp # HTTP/WebSocket (strfry listens on 7777)
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -26,6 +26,8 @@ app:
|
||||
- host: 3002
|
||||
container: 3001
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
@@ -25,6 +25,8 @@ app:
|
||||
- host: 8082
|
||||
container: 80
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
|
||||
Generated
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.120-alpha"
|
||||
version = "1.7.122-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.120-alpha"
|
||||
version = "1.7.122-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -222,6 +222,19 @@ pub(in crate::api::rpc) async fn regenerate_torrc(config: &ServicesConfig) -> Re
|
||||
lines.push("# ControlPort disabled for security".to_string());
|
||||
lines.push(String::new());
|
||||
|
||||
// Ports whose manifests declare `auth: gated` forward to the gate's own
|
||||
// loopback (127.0.0.2, where the app-gate listener binds — see
|
||||
// `appgate::listener::GATE_TOR_UPSTREAM`) instead of the app's 127.0.0.1.
|
||||
// Tor carries no session cookie, so an onion pointed at the app is an
|
||||
// unauthenticated bypass of the gate. Declared-gated ports only: an
|
||||
// undeclared port keeps today's target, because absence of the field is
|
||||
// not an instruction (the v1.7.121 incident rule).
|
||||
let gated_ports: std::collections::HashSet<u16> = crate::appgate::identity::build_port_map()
|
||||
.gated_ports()
|
||||
.filter(|g| g.declared)
|
||||
.map(|g| g.port)
|
||||
.collect();
|
||||
|
||||
for svc in &config.services {
|
||||
if !svc.enabled {
|
||||
continue;
|
||||
@@ -240,7 +253,7 @@ pub(in crate::api::rpc) async fn regenerate_torrc(config: &ServicesConfig) -> Re
|
||||
lines.push("HiddenServicePort 10009 127.0.0.1:10009".to_string());
|
||||
}
|
||||
} else {
|
||||
lines.push(format!("HiddenServicePort 80 127.0.0.1:{}", svc.local_port));
|
||||
lines.push(app_hidden_service_port_line(svc.local_port, &gated_ports));
|
||||
}
|
||||
|
||||
lines.push(String::new());
|
||||
@@ -248,6 +261,24 @@ pub(in crate::api::rpc) async fn regenerate_torrc(config: &ServicesConfig) -> Re
|
||||
|
||||
let content = lines.join("\n");
|
||||
let staging = "/var/lib/archipelago/tor-config/torrc.staged";
|
||||
write_staged_torrc(&content, staging).await
|
||||
}
|
||||
|
||||
/// The `HiddenServicePort` line for an HTTP app onion. Gated ports forward to
|
||||
/// the gate's Tor upstream; everything else to the app itself.
|
||||
fn app_hidden_service_port_line(
|
||||
local_port: u16,
|
||||
gated_ports: &std::collections::HashSet<u16>,
|
||||
) -> String {
|
||||
let upstream = if gated_ports.contains(&local_port) {
|
||||
crate::appgate::listener::GATE_TOR_UPSTREAM.to_string()
|
||||
} else {
|
||||
"127.0.0.1".to_string()
|
||||
};
|
||||
format!("HiddenServicePort 80 {}:{}", upstream, local_port)
|
||||
}
|
||||
|
||||
async fn write_staged_torrc(content: &str, staging: &str) -> Result<()> {
|
||||
let config_dir = Path::new(staging)
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("/var/lib/archipelago/tor-config"));
|
||||
@@ -256,14 +287,37 @@ pub(in crate::api::rpc) async fn regenerate_torrc(config: &ServicesConfig) -> Re
|
||||
.await
|
||||
.context("Failed to write staged torrc")?;
|
||||
|
||||
debug!(
|
||||
"Staged torrc with {} enabled services",
|
||||
config.services.iter().filter(|s| s.enabled).count()
|
||||
);
|
||||
debug!("Staged torrc ({} bytes)", content.len());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod torrc_tests {
|
||||
use super::app_hidden_service_port_line;
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[test]
|
||||
fn gated_port_forwards_to_the_gate_not_the_app() {
|
||||
let gated: HashSet<u16> = [8082u16].into_iter().collect();
|
||||
assert_eq!(
|
||||
app_hidden_service_port_line(8082, &gated),
|
||||
"HiddenServicePort 80 127.0.0.2:8082"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undeclared_port_keeps_the_app_loopback_target() {
|
||||
// Absence of `auth: gated` is not an instruction — the onion keeps
|
||||
// pointing at the app, exactly as before this change.
|
||||
let gated: HashSet<u16> = [8082u16].into_iter().collect();
|
||||
assert_eq!(
|
||||
app_hidden_service_port_line(9100, &gated),
|
||||
"HiddenServicePort 80 127.0.0.1:9100"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Hostname Sync ───────────────────────────────────────────────
|
||||
|
||||
pub(in crate::api::rpc) async fn sync_single_hostname(name: &str, address: &str) {
|
||||
|
||||
@@ -26,6 +26,15 @@ pub struct GatedPort {
|
||||
pub app_name: String,
|
||||
/// Manifest-declared icon path (`metadata.icon`), when present.
|
||||
pub icon: Option<String>,
|
||||
/// True only when the manifest says `auth: gated` in so many words.
|
||||
///
|
||||
/// The gated set deliberately also carries undeclared Session-default
|
||||
/// ports (so the gate challenges them wherever it can already stand, and
|
||||
/// the audit reports them). But everything that CHANGES where traffic
|
||||
/// goes — the torrc repoint to 127.0.0.2, the FIPS relay stand-down, the
|
||||
/// Tor-upstream bind — must key on this flag: acting on an undeclared
|
||||
/// port is the v1.7.121 incident class, whatever the action.
|
||||
pub declared: bool,
|
||||
}
|
||||
|
||||
/// A port deliberately left unauthenticated, and the manifest's stated reason.
|
||||
@@ -48,6 +57,7 @@ pub struct ExemptPort {
|
||||
pub struct PortMap {
|
||||
gated: HashMap<u16, GatedPort>,
|
||||
exempt: Vec<ExemptPort>,
|
||||
local: std::collections::HashSet<u16>,
|
||||
}
|
||||
|
||||
impl PortMap {
|
||||
@@ -64,8 +74,21 @@ impl PortMap {
|
||||
&self.exempt
|
||||
}
|
||||
|
||||
/// Declared `auth: local` — host-local by intent, so NOTHING may make it
|
||||
/// externally reachable.
|
||||
///
|
||||
/// The gate honours this by keeping its hands off, but it is not the only
|
||||
/// thing that can publish a port: the FIPS mesh relay bridges the fips0
|
||||
/// ULA to `127.0.0.1` for a static port list, and it forwarded nbxplorer
|
||||
/// 32838 — declared `local` and pinned to loopback — to the mesh
|
||||
/// unauthenticated (archi-dev-box 2026-08-04). Anything that republishes
|
||||
/// a loopback port must consult this set first.
|
||||
pub fn is_declared_local(&self, port: u16) -> bool {
|
||||
self.local.contains(&port)
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.gated.is_empty() && self.exempt.is_empty()
|
||||
self.gated.is_empty() && self.exempt.is_empty() && self.local.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,13 +124,35 @@ fn manifest_icon(manifest: &AppManifest) -> Option<String> {
|
||||
|
||||
/// Classify every published port across all installed manifests.
|
||||
///
|
||||
/// The first directory that yields a manifest for an app id wins, so a node's
|
||||
/// `/opt/archipelago/apps` copy shadows a repo checkout rather than merging
|
||||
/// with it — otherwise a stale checked-out manifest could re-open a port the
|
||||
/// installed one gates.
|
||||
/// The signed catalog's embedded manifests are consulted FIRST, because they
|
||||
/// are what the orchestrator actually publishes containers from
|
||||
/// (origin-wins; see `app_catalog::catalog_manifest_overlay`). Classifying
|
||||
/// from disk alone made the gate act on policy the node was no longer
|
||||
/// running: the catalog declared nbxplorer `auth: local` and pinned it to
|
||||
/// loopback, the stale disk manifest declared nothing, and the gate
|
||||
/// externally bound a deliberately host-local port (archi-dev-box
|
||||
/// 2026-08-04).
|
||||
///
|
||||
/// After the catalog, the first directory that yields a manifest for an app
|
||||
/// id wins, so a node's `/opt/archipelago/apps` copy shadows a repo checkout
|
||||
/// rather than merging with it — otherwise a stale checked-out manifest could
|
||||
/// re-open a port the installed one gates.
|
||||
pub fn build_port_map() -> PortMap {
|
||||
let mut map = PortMap::default();
|
||||
let mut seen_apps: HashMap<String, PathBuf> = HashMap::new();
|
||||
let mut seen_apps: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
|
||||
for (app_id, value) in crate::container::app_catalog::catalog_manifest_values() {
|
||||
let Some(manifest) =
|
||||
crate::container::app_catalog::catalog_manifest_overlay(&app_id, value)
|
||||
else {
|
||||
// Unparseable/invalid/build-source → the orchestrator falls back
|
||||
// to disk for this app, so classification must too.
|
||||
continue;
|
||||
};
|
||||
if seen_apps.insert(app_id) {
|
||||
classify_manifest(&manifest, &mut map);
|
||||
}
|
||||
}
|
||||
|
||||
for dir in apps_dirs() {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
@@ -124,100 +169,8 @@ pub fn build_port_map() -> PortMap {
|
||||
// would have published.
|
||||
continue;
|
||||
};
|
||||
let app_id = manifest.app.id.clone();
|
||||
if seen_apps.contains_key(&app_id) {
|
||||
continue;
|
||||
}
|
||||
seen_apps.insert(app_id.clone(), path);
|
||||
|
||||
let icon = manifest_icon(&manifest);
|
||||
let app_name = if manifest.app.name.trim().is_empty() {
|
||||
app_id.clone()
|
||||
} else {
|
||||
manifest.app.name.clone()
|
||||
};
|
||||
|
||||
for port in &manifest.app.ports {
|
||||
let protocol = if port.protocol.is_empty() {
|
||||
"tcp"
|
||||
} else {
|
||||
port.protocol.as_str()
|
||||
};
|
||||
match port.auth_policy() {
|
||||
PortAuth::None => map.exempt.push(ExemptPort {
|
||||
port: port.host,
|
||||
app_id: app_id.clone(),
|
||||
rationale: port
|
||||
.auth_rationale
|
||||
.clone()
|
||||
.unwrap_or_else(|| "(no rationale recorded)".to_string()),
|
||||
protocol: protocol.to_string(),
|
||||
}),
|
||||
// Declared host-local. Not gated and not reported as
|
||||
// exposed, because it is neither — see PortAuth::Local
|
||||
// for why this cannot be inferred from `bind`.
|
||||
PortAuth::Local => {}
|
||||
// Explicit opt-in: the app is on loopback and the daemon
|
||||
// owns the external addresses. This is the ONLY way a
|
||||
// port gets bound by the gate, regardless of `bind`.
|
||||
PortAuth::Gated => {
|
||||
map.gated.insert(
|
||||
port.host,
|
||||
GatedPort {
|
||||
port: port.host,
|
||||
app_id: app_id.clone(),
|
||||
app_name: app_name.clone(),
|
||||
icon: icon.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
PortAuth::Session => {
|
||||
// UDP cannot carry an HTTP challenge. Such a port has
|
||||
// no business defaulting into the gated set where it
|
||||
// would look protected without being protectable —
|
||||
// surface it as an unrationalised exemption instead,
|
||||
// which is honest and shows up in the audit list.
|
||||
if protocol != "tcp" {
|
||||
map.exempt.push(ExemptPort {
|
||||
port: port.host,
|
||||
app_id: app_id.clone(),
|
||||
rationale: format!(
|
||||
"{protocol} cannot carry an HTTP challenge; declare auth: none \
|
||||
with a rationale to record why this is safe"
|
||||
),
|
||||
protocol: protocol.to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// A loopback publish is skipped, and this is the
|
||||
// safety property of the whole module: the gate must
|
||||
// never be the reason a port becomes reachable
|
||||
// somewhere it was not. `session` is the DEFAULT, so
|
||||
// it is what every un-migrated manifest carries —
|
||||
// and a node's installed manifests always lag the
|
||||
// repo. Binding those externally published Bitcoin
|
||||
// RPC across the LAN within seconds of deploy
|
||||
// (archi-dev-box 2026-08-03). Taking over a port is
|
||||
// opt-in only: `auth: gated`, shipped in the same
|
||||
// manifest edit as the loopback pin.
|
||||
if port
|
||||
.bind
|
||||
.parse::<std::net::IpAddr>()
|
||||
.is_ok_and(|ip| ip.is_loopback())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
map.gated.insert(
|
||||
port.host,
|
||||
GatedPort {
|
||||
port: port.host,
|
||||
app_id: app_id.clone(),
|
||||
app_name: app_name.clone(),
|
||||
icon: icon.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
if seen_apps.insert(manifest.app.id.clone()) {
|
||||
classify_manifest(&manifest, &mut map);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,6 +179,107 @@ pub fn build_port_map() -> PortMap {
|
||||
map
|
||||
}
|
||||
|
||||
/// Classify one manifest's ports into the map. Split from [`build_port_map`]
|
||||
/// so the catalog-overlay pass and the disk pass cannot diverge.
|
||||
fn classify_manifest(manifest: &AppManifest, map: &mut PortMap) {
|
||||
let app_id = manifest.app.id.clone();
|
||||
let icon = manifest_icon(manifest);
|
||||
let app_name = if manifest.app.name.trim().is_empty() {
|
||||
app_id.clone()
|
||||
} else {
|
||||
manifest.app.name.clone()
|
||||
};
|
||||
|
||||
for port in &manifest.app.ports {
|
||||
let protocol = if port.protocol.is_empty() {
|
||||
"tcp"
|
||||
} else {
|
||||
port.protocol.as_str()
|
||||
};
|
||||
match port.auth_policy() {
|
||||
PortAuth::None => map.exempt.push(ExemptPort {
|
||||
port: port.host,
|
||||
app_id: app_id.clone(),
|
||||
rationale: port
|
||||
.auth_rationale
|
||||
.clone()
|
||||
.unwrap_or_else(|| "(no rationale recorded)".to_string()),
|
||||
protocol: protocol.to_string(),
|
||||
}),
|
||||
// Declared host-local. Not gated and not reported as
|
||||
// exposed, because it is neither — see PortAuth::Local
|
||||
// for why this cannot be inferred from `bind`. Recorded so
|
||||
// the mesh relay (and any future republisher) can refuse to
|
||||
// expose it.
|
||||
PortAuth::Local => {
|
||||
map.local.insert(port.host);
|
||||
}
|
||||
// Explicit opt-in: the app is on loopback and the daemon
|
||||
// owns the external addresses. This is the ONLY way a
|
||||
// port gets bound by the gate, regardless of `bind`.
|
||||
PortAuth::Gated => {
|
||||
map.gated.insert(
|
||||
port.host,
|
||||
GatedPort {
|
||||
port: port.host,
|
||||
app_id: app_id.clone(),
|
||||
app_name: app_name.clone(),
|
||||
icon: icon.clone(),
|
||||
declared: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
PortAuth::Session => {
|
||||
// UDP cannot carry an HTTP challenge. Such a port has
|
||||
// no business defaulting into the gated set where it
|
||||
// would look protected without being protectable —
|
||||
// surface it as an unrationalised exemption instead,
|
||||
// which is honest and shows up in the audit list.
|
||||
if protocol != "tcp" {
|
||||
map.exempt.push(ExemptPort {
|
||||
port: port.host,
|
||||
app_id: app_id.clone(),
|
||||
rationale: format!(
|
||||
"{protocol} cannot carry an HTTP challenge; declare auth: none \
|
||||
with a rationale to record why this is safe"
|
||||
),
|
||||
protocol: protocol.to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// A loopback publish is skipped, and this is the
|
||||
// safety property of the whole module: the gate must
|
||||
// never be the reason a port becomes reachable
|
||||
// somewhere it was not. `session` is the DEFAULT, so
|
||||
// it is what every un-migrated manifest carries —
|
||||
// and a node's installed manifests always lag the
|
||||
// repo. Binding those externally published Bitcoin
|
||||
// RPC across the LAN within seconds of deploy
|
||||
// (archi-dev-box 2026-08-03). Taking over a port is
|
||||
// opt-in only: `auth: gated`, shipped in the same
|
||||
// manifest edit as the loopback pin.
|
||||
if port
|
||||
.bind
|
||||
.parse::<std::net::IpAddr>()
|
||||
.is_ok_and(|ip| ip.is_loopback())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
map.gated.insert(
|
||||
port.host,
|
||||
GatedPort {
|
||||
port: port.host,
|
||||
app_id: app_id.clone(),
|
||||
app_name: app_name.clone(),
|
||||
icon: icon.clone(),
|
||||
declared: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -257,6 +311,90 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn manifest(yaml: &str) -> AppManifest {
|
||||
AppManifest::parse(yaml).expect("test manifest must parse")
|
||||
}
|
||||
|
||||
const BASE: &str = r#"
|
||||
app:
|
||||
id: testapp
|
||||
name: Test App
|
||||
version: "1.0"
|
||||
container:
|
||||
image: example.org/testapp:1.0
|
||||
"#;
|
||||
|
||||
/// `auth: gated` is the only classification allowed to redirect traffic —
|
||||
/// torrc repoints, relay stand-down, and the 127.0.0.2 bind all key on
|
||||
/// `declared`. An undeclared Session port is challenged and audited but
|
||||
/// must never be `declared`.
|
||||
#[test]
|
||||
fn declared_tracks_the_manifest_not_the_default() {
|
||||
let mut map = PortMap::default();
|
||||
classify_manifest(
|
||||
&manifest(&format!(
|
||||
"{BASE} ports:\n - host: 8090\n container: 7777\n protocol: tcp\n bind: 127.0.0.1\n auth: gated\n"
|
||||
)),
|
||||
&mut map,
|
||||
);
|
||||
assert!(map.gated(8090).expect("gated").declared);
|
||||
|
||||
let mut map = PortMap::default();
|
||||
classify_manifest(
|
||||
&manifest(&format!(
|
||||
"{BASE} ports:\n - host: 9100\n container: 9100\n protocol: tcp\n"
|
||||
)),
|
||||
&mut map,
|
||||
);
|
||||
let undeclared = map.gated(9100).expect("session default is challenged");
|
||||
assert!(
|
||||
!undeclared.declared,
|
||||
"an absent auth field must never read as an instruction"
|
||||
);
|
||||
}
|
||||
|
||||
/// `auth: local` keeps the gate's hands off entirely — the port is
|
||||
/// neither gated nor exempt-reported — but it IS recorded, so the mesh
|
||||
/// relay can refuse to republish a deliberately host-local port.
|
||||
#[test]
|
||||
fn local_ports_are_untouched_but_recorded() {
|
||||
let mut map = PortMap::default();
|
||||
classify_manifest(
|
||||
&manifest(&format!(
|
||||
"{BASE} ports:\n - host: 32838\n container: 32838\n protocol: tcp\n bind: 127.0.0.1\n auth: local\n"
|
||||
)),
|
||||
&mut map,
|
||||
);
|
||||
assert!(map.gated(32838).is_none());
|
||||
assert!(map.exempt_ports().is_empty());
|
||||
assert!(
|
||||
map.is_declared_local(32838),
|
||||
"the mesh relay needs this to refuse bridging a host-local port"
|
||||
);
|
||||
assert!(!map.is_declared_local(3000));
|
||||
}
|
||||
|
||||
/// The real corpus: every port the FIPS relay can bridge must be safe to
|
||||
/// bridge. A port that is declared `local` (host-local by intent) or
|
||||
/// declared `gated` (the app gate owns its external addresses) must be
|
||||
/// withheld by the relay — this asserts the two sets the relay consults
|
||||
/// actually classify the live manifests, so a future manifest edit that
|
||||
/// re-opens one is caught here rather than on a node.
|
||||
#[test]
|
||||
fn relay_port_list_respects_local_and_gated_declarations() {
|
||||
let map = build_port_map();
|
||||
let relay_would_expose: Vec<u16> = crate::fips::app_ports::APP_LAUNCH_PORTS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|p| map.is_declared_local(*p))
|
||||
.collect();
|
||||
assert!(
|
||||
!relay_would_expose.is_empty(),
|
||||
"expected the corpus to contain at least one local port in the relay list \
|
||||
(32838/8999) — if this fails the guard is untested, not unnecessary"
|
||||
);
|
||||
}
|
||||
|
||||
/// Protocol ports that wallets dial directly must never end up gated —
|
||||
/// this is the constraint that decided the design (Zeus and electrum
|
||||
/// clients keep working untouched).
|
||||
|
||||
@@ -44,6 +44,15 @@ use tracing::{debug, info, warn};
|
||||
/// apps are installed while the daemon runs.
|
||||
const SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
/// The gate's own loopback address, distinct from the app's `127.0.0.1`.
|
||||
///
|
||||
/// Tor cannot present a session cookie, so `HiddenServicePort → 127.0.0.1`
|
||||
/// reaches the app around the gate. Instead torrc forwards gated ports to
|
||||
/// this address (`api/rpc/tor`), where the gate — not the app — listens. A
|
||||
/// second loopback address rather than a second port number, so no app needs
|
||||
/// a port it did not declare.
|
||||
pub const GATE_TOR_UPSTREAM: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 2));
|
||||
|
||||
/// A port the gate should own but could not claim, and why.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct UnprotectedPort {
|
||||
@@ -142,8 +151,12 @@ pub async fn run(
|
||||
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||
) {
|
||||
// (port, addr) pairs already served, so a sweep does not rebind what it
|
||||
// already holds.
|
||||
let mut held: HashMap<(u16, IpAddr), ()> = HashMap::new();
|
||||
// already holds. The accept-loop handle is kept so a claim can be
|
||||
// RELEASED when its port leaves the gated set — a catalog refresh
|
||||
// declaring a port `local`/`none` must make the gate let go without a
|
||||
// daemon restart, or the stale bind keeps republishing a port the
|
||||
// catalog just withdrew (nbxplorer 32838, archi-dev-box 2026-08-04).
|
||||
let mut held: HashMap<(u16, IpAddr), tokio::task::JoinHandle<()>> = HashMap::new();
|
||||
let mut interval = tokio::time::interval(SWEEP_INTERVAL);
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
|
||||
@@ -160,7 +173,7 @@ pub async fn run(
|
||||
async fn sweep(
|
||||
gate: &Arc<AppGate>,
|
||||
status: &Arc<RwLock<GateStatus>>,
|
||||
held: &mut HashMap<(u16, IpAddr), ()>,
|
||||
held: &mut HashMap<(u16, IpAddr), tokio::task::JoinHandle<()>>,
|
||||
shutdown_rx: &tokio::sync::watch::Receiver<bool>,
|
||||
) {
|
||||
// Re-read the manifests every sweep rather than trusting the map built
|
||||
@@ -170,6 +183,23 @@ async fn sweep(
|
||||
// enforced while serving a brand-new app to anyone who asked.
|
||||
gate.refresh().await;
|
||||
let port_map = gate.port_map().await;
|
||||
|
||||
// Release claims whose port left the gated set (or whose Tor-upstream
|
||||
// claim lost its declaration). Aborting the accept loop drops the
|
||||
// listener, freeing the address for whoever now legitimately owns it —
|
||||
// the app itself, or nobody.
|
||||
held.retain(|(port, addr), handle| {
|
||||
let keep = match port_map.gated(*port) {
|
||||
None => false,
|
||||
Some(app) => *addr != GATE_TOR_UPSTREAM || app.declared,
|
||||
};
|
||||
if !keep {
|
||||
handle.abort();
|
||||
info!(port, %addr, "app gate released a claim: port is no longer gated here");
|
||||
}
|
||||
keep
|
||||
});
|
||||
|
||||
let addresses = host_addresses().await;
|
||||
if addresses.is_empty() {
|
||||
debug!("app gate: no external addresses yet");
|
||||
@@ -192,6 +222,10 @@ async fn sweep(
|
||||
|
||||
let mut claimed_any = false;
|
||||
let mut blocked = false;
|
||||
// External addresses first, then the gate's Tor upstream. 127.0.0.2
|
||||
// deliberately does NOT count toward `claimed_any`: the warning below
|
||||
// is about external exposure, and a port whose only claim is the Tor
|
||||
// loopback is still wide open on the LAN.
|
||||
for &addr in &addresses {
|
||||
let key = (app.port, addr);
|
||||
if held.contains_key(&key) {
|
||||
@@ -201,19 +235,48 @@ async fn sweep(
|
||||
}
|
||||
match TcpListener::bind(SocketAddr::new(addr, app.port)).await {
|
||||
Ok(listener) => {
|
||||
held.insert(key, ());
|
||||
let handle =
|
||||
spawn_accept_loop(listener, gate.clone(), app.clone(), shutdown_rx.clone());
|
||||
held.insert(key, handle);
|
||||
claimed.push((app.port, addr.to_string()));
|
||||
claimed_any = true;
|
||||
info!(
|
||||
port = app.port, %addr, app = %app.app_id,
|
||||
"app gate claimed an app port"
|
||||
);
|
||||
spawn_accept_loop(listener, gate.clone(), app.clone(), shutdown_rx.clone());
|
||||
}
|
||||
// Almost always the app itself holding 0.0.0.0:<port>.
|
||||
Err(_) => blocked = true,
|
||||
}
|
||||
}
|
||||
// The Tor upstream is bound for DECLARED gated ports only: torrc only
|
||||
// repoints an onion at 127.0.0.2 for a declared port, and standing a
|
||||
// challenge on an undeclared port's would-be upstream would change
|
||||
// where its traffic goes on nothing but a default.
|
||||
if app.declared {
|
||||
let tor_key = (app.port, GATE_TOR_UPSTREAM);
|
||||
if held.contains_key(&tor_key) {
|
||||
claimed.push((app.port, GATE_TOR_UPSTREAM.to_string()));
|
||||
} else {
|
||||
match TcpListener::bind(SocketAddr::new(GATE_TOR_UPSTREAM, app.port)).await {
|
||||
Ok(listener) => {
|
||||
let handle = spawn_accept_loop(
|
||||
listener,
|
||||
gate.clone(),
|
||||
app.clone(),
|
||||
shutdown_rx.clone(),
|
||||
);
|
||||
held.insert(tor_key, handle);
|
||||
claimed.push((app.port, GATE_TOR_UPSTREAM.to_string()));
|
||||
info!(
|
||||
port = app.port, app = %app.app_id,
|
||||
"app gate claimed the Tor upstream (127.0.0.2)"
|
||||
);
|
||||
}
|
||||
Err(_) => blocked = true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if blocked && !claimed_any {
|
||||
warn!(
|
||||
@@ -251,12 +314,15 @@ async fn app_is_listening(port: u16) -> bool {
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// Returns the accept-loop task handle so the sweep can release the claim
|
||||
/// (abort → listener drops → address freed) when the port leaves the gated
|
||||
/// set. In-flight connections finish on their own tasks.
|
||||
fn spawn_accept_loop(
|
||||
listener: TcpListener,
|
||||
gate: Arc<AppGate>,
|
||||
app: GatedPort,
|
||||
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||
) {
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -287,7 +353,7 @@ fn spawn_accept_loop(
|
||||
_ = shutdown_rx.changed() => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -578,6 +578,7 @@ mod tests {
|
||||
app_id: "strfry".to_string(),
|
||||
app_name: "Strfry Relay".to_string(),
|
||||
icon: None,
|
||||
declared: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -216,6 +216,46 @@ pub fn catalog_manifest_values() -> Vec<(String, serde_json::Value)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A catalog-embedded manifest as the node actually applies it: parsed,
|
||||
/// id-checked, validated, and image-only (build-source manifests defer to
|
||||
/// disk). `None` = the caller must fall back to the disk manifest.
|
||||
///
|
||||
/// Shared between the orchestrator's load overlay and the app gate's port
|
||||
/// classification so both answer "which manifest governs this app?" from the
|
||||
/// same origin. They diverged once — the orchestrator published containers
|
||||
/// from the catalog while the gate classified from stale disk manifests, and
|
||||
/// the gate externally bound a port the catalog had declared `auth: local`
|
||||
/// (nbxplorer 32838, archi-dev-box 2026-08-04).
|
||||
pub fn catalog_manifest_overlay(
|
||||
app_id: &str,
|
||||
value: serde_json::Value,
|
||||
) -> Option<archipelago_container::manifest::AppManifest> {
|
||||
let m: archipelago_container::manifest::AppManifest = match serde_json::from_value(value) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::warn!(app = %app_id, error = %e,
|
||||
"skipping unparseable catalog manifest; using disk fallback");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if m.app.id != app_id {
|
||||
tracing::warn!(catalog_id = %app_id, manifest_id = %m.app.id,
|
||||
"skipping catalog manifest: embedded app id mismatches catalog key");
|
||||
return None;
|
||||
}
|
||||
if let Err(e) = m.validate() {
|
||||
tracing::warn!(app = %app_id, error = %e,
|
||||
"skipping invalid catalog manifest; using disk fallback");
|
||||
return None;
|
||||
}
|
||||
if m.app.container.build.is_some() {
|
||||
tracing::debug!(app = %app_id,
|
||||
"catalog manifest has a build source; deferring to disk (phase 1 = image-only)");
|
||||
return None;
|
||||
}
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// The catalog's default/latest version string for an app (the top-level
|
||||
/// `version` field), if covered. Used to decide whether an install-time
|
||||
/// selection should pin (older) or track-latest (default).
|
||||
|
||||
@@ -595,10 +595,20 @@ async fn wait_for_manifest_host_ports(
|
||||
/// `podman inspect --format '{{json .HostConfig.PortBindings}}'` emits, e.g.
|
||||
/// `{"8080/tcp":[{"HostIp":"","HostPort":"18080"}]}`. Returns true only when a
|
||||
/// manifest container-port is positively published to a *different* host port
|
||||
/// than the manifest now asks for. Absence of a binding is deliberately NOT
|
||||
/// treated as drift here — that case is handled by the host-port repair/restart
|
||||
/// path and by host-networked apps that publish nothing — so we never trigger a
|
||||
/// destructive recreate on a false positive.
|
||||
/// than the manifest now asks for — or, when the manifest DECLARES a bind
|
||||
/// address, to a different host address. Absence of a binding is deliberately
|
||||
/// NOT treated as drift here — that case is handled by the host-port
|
||||
/// repair/restart path and by host-networked apps that publish nothing — so we
|
||||
/// never trigger a destructive recreate on a false positive.
|
||||
///
|
||||
/// The bind comparison is what lets a node self-heal after a catalog refresh
|
||||
/// pins an app to loopback for the app gate: a legacy (pre-quadlet) container
|
||||
/// still publishing `0.0.0.0:P` against a manifest that now declares
|
||||
/// `bind: 127.0.0.1` is recreated to the declared state, exactly as
|
||||
/// `package.update` would. An EMPTY manifest bind means "no instruction" and
|
||||
/// never fires this — recreating a loopback-published container to wildcard on
|
||||
/// silence is precisely the v1.7.121 incident class (Bitcoin RPC republished
|
||||
/// on the LAN).
|
||||
fn host_port_bindings_drifted(
|
||||
port_bindings_json: &str,
|
||||
manifest_ports: &[archipelago_container::manifest::PortMapping],
|
||||
@@ -626,10 +636,26 @@ fn host_port_bindings_drifted(
|
||||
}
|
||||
let expected = port.host.to_string();
|
||||
let matches_expected = bindings.iter().any(|b| {
|
||||
b.get("HostPort")
|
||||
let host_port_ok = b
|
||||
.get("HostPort")
|
||||
.and_then(|h| h.as_str())
|
||||
.map(|h| h == expected)
|
||||
.unwrap_or(false)
|
||||
.unwrap_or(false);
|
||||
if !host_port_ok {
|
||||
return false;
|
||||
}
|
||||
// Only a DECLARED bind participates; podman reports a wildcard
|
||||
// publish as "" or "0.0.0.0".
|
||||
if port.bind.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let actual_ip = b.get("HostIp").and_then(|h| h.as_str()).unwrap_or("");
|
||||
let actual = if actual_ip.is_empty() {
|
||||
"0.0.0.0"
|
||||
} else {
|
||||
actual_ip
|
||||
};
|
||||
actual == port.bind
|
||||
});
|
||||
if !matches_expected {
|
||||
return true;
|
||||
@@ -1157,30 +1183,7 @@ struct LoadedManifest {
|
||||
/// source (build contexts aren't registry-distributed yet — phase 1 is
|
||||
/// image-only). See `docs/registry-manifest-design.md`.
|
||||
fn catalog_manifest_to_overlay(app_id: &str, value: serde_json::Value) -> Option<AppManifest> {
|
||||
let m: AppManifest = match serde_json::from_value(value) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::warn!(app = %app_id, error = %e,
|
||||
"skipping unparseable catalog manifest; using disk fallback");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if m.app.id != app_id {
|
||||
tracing::warn!(catalog_id = %app_id, manifest_id = %m.app.id,
|
||||
"skipping catalog manifest: embedded app id mismatches catalog key");
|
||||
return None;
|
||||
}
|
||||
if let Err(e) = m.validate() {
|
||||
tracing::warn!(app = %app_id, error = %e,
|
||||
"skipping invalid catalog manifest; using disk fallback");
|
||||
return None;
|
||||
}
|
||||
if m.app.container.build.is_some() {
|
||||
tracing::debug!(app = %app_id,
|
||||
"catalog manifest has a build source; deferring to disk (phase 1 = image-only)");
|
||||
return None;
|
||||
}
|
||||
Some(m)
|
||||
crate::container::app_catalog::catalog_manifest_overlay(app_id, value)
|
||||
}
|
||||
|
||||
struct OrchestratorState {
|
||||
@@ -4569,6 +4572,76 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
fn bound_port(
|
||||
host: u16,
|
||||
container: u16,
|
||||
bind: &str,
|
||||
) -> archipelago_container::manifest::PortMapping {
|
||||
archipelago_container::manifest::PortMapping {
|
||||
bind: bind.to_string(),
|
||||
..port(host, container)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bind_drift_detected_when_declared_loopback_but_published_wildcard() {
|
||||
// The legacy-container case: a pre-quadlet container still publishes
|
||||
// 0.0.0.0 while the catalog-delivered manifest pins the app to
|
||||
// loopback for the app gate. Must recreate, or the port stays open on
|
||||
// every interface and the gate can never claim it.
|
||||
for wildcard in [r#""""#, r#""0.0.0.0""#] {
|
||||
let bindings = format!(r#"{{"80/tcp":[{{"HostIp":{wildcard},"HostPort":"8082"}}]}}"#);
|
||||
assert!(host_port_bindings_drifted(
|
||||
&bindings,
|
||||
&[bound_port(8082, 80, "127.0.0.1")]
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_bind_drift_when_declared_loopback_and_published_loopback() {
|
||||
let bindings = r#"{"80/tcp":[{"HostIp":"127.0.0.1","HostPort":"8082"}]}"#;
|
||||
assert!(!host_port_bindings_drifted(
|
||||
bindings,
|
||||
&[bound_port(8082, 80, "127.0.0.1")]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_bind_drift_on_undeclared_bind() {
|
||||
// Silence is not consent (v1.7.121 incident class): an EMPTY manifest
|
||||
// bind must never recreate a loopback-published container to
|
||||
// wildcard — that is how Bitcoin's RPC got republished on the LAN.
|
||||
let bindings = r#"{"8332/tcp":[{"HostIp":"127.0.0.1","HostPort":"8332"}]}"#;
|
||||
assert!(!host_port_bindings_drifted(bindings, &[port(8332, 8332)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_bind_publish_satisfies_each_declared_entry() {
|
||||
// Same host/container pair listed twice (loopback + archy-net
|
||||
// gateway): both declared binds are present in the actual publish.
|
||||
let bindings = r#"{"8332/tcp":[
|
||||
{"HostIp":"127.0.0.1","HostPort":"8332"},
|
||||
{"HostIp":"10.89.0.1","HostPort":"8332"}
|
||||
]}"#;
|
||||
assert!(!host_port_bindings_drifted(
|
||||
bindings,
|
||||
&[
|
||||
bound_port(8332, 8332, "127.0.0.1"),
|
||||
bound_port(8332, 8332, "10.89.0.1")
|
||||
]
|
||||
));
|
||||
// And a wildcard-only publish drifts BOTH declared entries.
|
||||
let wildcard = r#"{"8332/tcp":[{"HostIp":"","HostPort":"8332"}]}"#;
|
||||
assert!(host_port_bindings_drifted(
|
||||
wildcard,
|
||||
&[
|
||||
bound_port(8332, 8332, "127.0.0.1"),
|
||||
bound_port(8332, 8332, "10.89.0.1")
|
||||
]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_secret_error_names_the_secret() {
|
||||
use archipelago_container::manifest::SecretsProvider;
|
||||
|
||||
@@ -1145,16 +1145,52 @@ fn fips_app_relay_addr(ip: std::net::Ipv6Addr, port: u16) -> SocketAddr {
|
||||
/// without a daemon restart. Each relay binds to the fips0 ULA only and
|
||||
/// forwards raw TCP to the same port on IPv4 loopback.
|
||||
async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bool>) {
|
||||
use std::collections::HashSet;
|
||||
let mut bridged: HashSet<u16> = HashSet::new();
|
||||
use std::collections::HashMap;
|
||||
let mut bridged: HashMap<u16, tokio::task::JoinHandle<()>> = HashMap::new();
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
let Some(fips_ip) = crate::fips::iface::fips0_ula() else { continue };
|
||||
// This relay is a raw unauthenticated forward from the mesh to
|
||||
// the app's loopback, so it must refuse two classes of port:
|
||||
//
|
||||
// * `auth: gated` — the app gate owns the fips0 ULA for these,
|
||||
// and bridging one would bypass the login page. Which of the
|
||||
// two won the bind used to be a race.
|
||||
// * `auth: local` — host-local BY INTENT. Bridging one makes a
|
||||
// port reachable from the whole mesh that was deliberately
|
||||
// never externally reachable: nbxplorer 32838 answered HTTP
|
||||
// 200 over the mesh with no credential (archi-dev-box
|
||||
// 2026-08-04) purely because it appeared in the static port
|
||||
// list below.
|
||||
//
|
||||
// Undeclared ports keep today's behaviour — silence is not an
|
||||
// instruction in either direction, and this relay predates the
|
||||
// declarations.
|
||||
let port_map = crate::appgate::identity::build_port_map();
|
||||
let gate_owned: std::collections::HashSet<u16> = port_map
|
||||
.gated_ports()
|
||||
.filter(|g| g.declared)
|
||||
.map(|g| g.port)
|
||||
.collect();
|
||||
for &port in crate::fips::app_ports::APP_LAUNCH_PORTS {
|
||||
if bridged.contains(&port) {
|
||||
let withhold = if gate_owned.contains(&port) {
|
||||
Some("port is now gate-owned")
|
||||
} else if port_map.is_declared_local(port) {
|
||||
Some("port is declared auth: local (host-local by intent)")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(reason) = withhold {
|
||||
if let Some(handle) = bridged.remove(&port) {
|
||||
handle.abort();
|
||||
info!(port, reason, "v6 relay released a bridge");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if bridged.contains_key(&port) {
|
||||
continue;
|
||||
}
|
||||
// ONLY bridge a port that a running app already answers on
|
||||
@@ -1181,10 +1217,9 @@ async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bo
|
||||
// EADDRINUSE = fipsd or another process already answers
|
||||
// on this mesh address/port, so stay out of the way.
|
||||
let Ok(listener) = bind_v6_only(addr) else { continue };
|
||||
bridged.insert(port);
|
||||
debug!("v6 relay bridging [{fips_ip}]:{port} -> 127.0.0.1:{port}");
|
||||
let mut rx = shutdown_rx.clone();
|
||||
tokio::spawn(async move {
|
||||
let handle = tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
accepted = listener.accept() => {
|
||||
@@ -1205,6 +1240,7 @@ async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bo
|
||||
}
|
||||
}
|
||||
});
|
||||
bridged.insert(port, handle);
|
||||
}
|
||||
}
|
||||
_ = shutdown_rx.changed() => return,
|
||||
|
||||
@@ -16,13 +16,28 @@ use ed25519_dalek::VerifyingKey;
|
||||
|
||||
/// Hex of the pinned Ed25519 release-root public key (32 bytes / 64 hex chars).
|
||||
///
|
||||
/// Pinned 2026-07-02 from the release-root signing ceremony
|
||||
/// (signer did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur). The
|
||||
/// ROTATED 2026-08-04 to did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT.
|
||||
///
|
||||
/// The previous root (z6Mkkid…q7ur, pinned 2026-07-02) was exposed in a chat
|
||||
/// transcript and is treated as compromised.
|
||||
///
|
||||
/// Rotation is ORDERING-CRITICAL. Nodes pin the OLD key, so the release that
|
||||
/// carries this change must itself be signed with the OLD key — that is the
|
||||
/// only signature a node running the previous binary will accept. Only the
|
||||
/// release AFTER it may be signed with the new key. Signing the rotation
|
||||
/// release with the new key makes every node reject it and ends OTA
|
||||
/// fleet-wide, recoverable only by touching each node by hand.
|
||||
///
|
||||
/// Verified before pinning: this hex and the did:key above are the same
|
||||
/// keypair (the did:key encodes exactly these 32 bytes), checked with a
|
||||
/// decoder round-tripped against the previous known-good pair. An earlier
|
||||
/// candidate hex was rejected because it did not match the stated DID.
|
||||
/// The
|
||||
/// corresponding mnemonic is held offline by the publisher — see
|
||||
/// `docs/workstream-b-signing-runbook.md`. Regenerate/verify with:
|
||||
/// `RELEASE_MASTER_MNEMONIC=… archipelago ceremony pubkey`.
|
||||
pub const RELEASE_ROOT_PUBKEY_HEX: Option<&str> =
|
||||
Some("5d15cbee8a108f7dd288c02d29a1d9d71f198acc99186aad8008b4f28d469951");
|
||||
Some("1578adccf137024159dd936f44a56e8869ac7775785962f7e92e2faf2c034418");
|
||||
|
||||
const ENV_OVERRIDE: &str = "ARCHY_RELEASE_ROOT_PUBKEY";
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.120-alpha",
|
||||
"version": "1.7.122-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.120-alpha",
|
||||
"version": "1.7.122-alpha",
|
||||
"dependencies": {
|
||||
"@scure/bip39": "^2.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.7.120-alpha",
|
||||
"version": "1.7.122-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
@@ -362,6 +362,40 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.7.122-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.122-alpha</span>
|
||||
<span class="text-xs text-white/40">August 4, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p><strong>Your apps now ask for your node password before they open — over your home network, Tailscale, the mesh and Tor alike.</strong> Until now anyone who could reach your node could open Immich, Nextcloud, Vaultwarden, Jellyfin, Grafana and the rest simply by typing the address and port, with no login at all. Twenty app screens now sit behind the same login you use for the node, showing you which app you are opening, and honouring two-factor if you have it switched on. Logging in at an app address logs you into the dashboard too, so it is one password, not one per app.</p>
|
||||
<p><strong>The things that must stay open stayed open.</strong> Zeus and other remote wallets still reach your Lightning node directly, Electrum wallets still connect, and Bitcoin still talks to its peers — those connections carry their own proof of identity and a login page would simply break them. Every one of these exceptions now has to state in writing why it is safe to leave open, so the list is something you can read rather than something you have to discover.</p>
|
||||
<p><strong>A private address on your node was answering the mesh without a password.</strong> One app's port was marked as being for this machine only, and the part of the node that carries mesh traffic did not know that — it forwarded requests from the whole mesh straight to it. Found while checking the work above on a real node, not in testing. That path now refuses anything marked machine-only, and the app is reachable only from the node itself, as intended.</p>
|
||||
<p><strong>Tor addresses no longer skip the login.</strong> An app published as a .onion address was handed straight to the app, because a Tor visitor carries no session. Those addresses now arrive at the login gate first, closing the last route that went around it.</p>
|
||||
<p>Nodes fix themselves after this update. Apps installed before this system used its current container setup kept their old wide-open address even after being told to move, and each would have needed hand-holding on every node. Your node now notices the difference and rebuilds those apps itself, keeping their data, within about half a minute of starting.</p>
|
||||
<p>The node had been reading two different sets of instructions about its own apps — the signed list it downloads, and older copies on disk — which is how a port meant to stay private was briefly opened. Both now come from the signed list.</p>
|
||||
<p><strong>The key that signs these updates has been replaced.</strong> The previous signing key was exposed where it should not have been, so it is treated as compromised and this release installs its replacement. This update is the last one signed with the old key, by necessity — it is the one that teaches your node the new one.</p>
|
||||
<p>Known gaps, disclosed rather than buried: eleven app ports still have no stated policy — BotFights, the Fedimint gateway, NetBird, the voice assistant's own screens and the router screen — and remain reachable without a login until each is decided deliberately; the node reports them rather than guessing, because guessing at an unstated setting is what caused two incidents this week. Three voice-assistant ports are still open without authentication; the correct fix puts them on a private network with the assistant. Non-browser apps — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — will meet the login page and need an access token; say the word if you use one and it can be exempted. The 5x real-node lifecycle gate was not run for this release.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.121-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.121-alpha</span>
|
||||
<span class="text-xs text-white/40">August 4, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>**Making another node "Trusted" now asks for your node password.** Trust was being handed out by machines rather than by you: any node able to reach yours could join and mark itself Trusted, because the check proved only that the caller owned the key it had just presented — never that you had approved it. Trust also spread on its own, since every peer a Trusted node advertised was added as Trusted too, so one grant quietly propagated across the whole federation. Uninvited joins are now capped at Observer, advertised peers arrive as Observers, and raising anyone to Trusted — whether by generating an invite or by changing the dropdown on a node — requires your password. Lowering trust deliberately does not, because the safe action must never be the inconvenient one. Existing peers are left exactly as they are rather than silently demoted, and each one now records how its trust was granted so you can review them.</p>
|
||||
<p>**Nodes you have peered with can be messaged straight away.** Peering was not enough: you also had to be within LoRa radio range of the other node once before chat would work. The node picked how to send a message based on which radio was plugged in, and only one of those paths knew how to reach a peer over the mesh's internet transports — so on a node with a different radio, or no radio at all, messaging a peer you had just federated with simply failed until a radio contact happened to appear. Peered nodes are reachable without radio by definition, so that choice no longer depends on the hardware. Radio is still preferred when the other node is actually in range and the message fits.</p>
|
||||
<p>The dashboard no longer flickers a vertical line across its cards. A rendering seam appeared at random while moving the mouse, because the two large cards used a background-blur effect that this system already disables everywhere else on the dashboard — that browser mis-draws it inside the dashboard's animated container, and these two cards had been missed when the workaround was written. Diagnosed from a single screenshot rather than by trying to reproduce it.</p>
|
||||
<p>The Lightning screen will actually update from now on. Its image was set to "latest", and the container system will not re-fetch a label it already holds, so nodes kept the same Lightning screen forever no matter how many updates shipped. A separate copy of the same setting used only by brand-new installs also described the screen incorrectly, so fresh installs got a screen that never answered.</p>
|
||||
<p>Apps that provide their own screens stop rebuilding themselves in a loop. On this system's own node one of them rebuilt every thirty-five seconds indefinitely, burning processor time and restarting the app each round. The node decided a rebuild was needed by comparing file dates against the image's creation date, but a rebuild that changes nothing reuses the existing image and leaves that date untouched — so the condition that triggered the rebuild was still true afterwards, forever. Nodes taking this update repair themselves the first time they check.</p>
|
||||
<p>Groundwork you can see but that does not change access yet: the node can now tell you which of its app ports answer without a login, and every port that is deliberately open — Bitcoin's peer connections for syncing the chain, Lightning's wallet connections, the Electrum wallet protocol — now has to state in writing why it is safe, so the list of exceptions is something you can read rather than something you have to discover. The login gate that will sit in front of the rest is built and proven working end to end on a real node, but it is not yet closing any ports; that arrives with the signed app catalog that tells each app to hand its address over.</p>
|
||||
<p>Releases can no longer ship an unsigned update file. Signing was skippable, and when it was skipped the release was still committed and tagged — producing an update that every node correctly refuses to install. It had been caught by hand every cycle; now the release simply stops.</p>
|
||||
<p>Known gaps, disclosed rather than buried: the 5x real-node lifecycle gate was not run for this release. App ports other than the deliberate exceptions above are still reachable without a login — the gate reports them, and closing them needs the next signed catalog. Three voice-assistant ports are open without authentication and should not be; the correct fix puts them on a private network with the assistant instead, which needs testing on a node that runs both. Two nodes on the fleet still share SSH host keys (detection shipped, rotation remains a deliberate operator decision).</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.120-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
+22
-24
@@ -1,36 +1,34 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**Security, and the reason to take this update: two ports on your node handed anyone who could reach them complete control of your money, with no password.** The Lightning app's port answered a plain web request with the LND admin macaroon, the TLS certificate and the node's onion address — everything needed to drain the wallet remotely, and the onion meant an attacker kept that ability even after losing access to your network. The Bitcoin app's port reached Bitcoin Core's control interface using credentials the node itself supplied on the caller's behalf, with a wallet loaded. Anything on your home network, your Tailscale network or the mesh could use either one. Both now require you to be logged in. If your node has been reachable by anyone you do not fully trust, treat the Lightning macaroon and the Bitcoin RPC password as known to them.",
|
||||
"The Bitcoin and Lightning app screens can no longer be published as public Tor addresses automatically. They were one app-id away from being handed a worldwide, permanent address as a silent side effect of being installed — which would have re-opened the hole above to the entire internet. Turning Tor on for them deliberately still works; it just never happens on its own.",
|
||||
"**Fixes shipped inside the program now actually reach apps that your system keeps running.** A container the node had been told to uninstall, but that the system service manager kept alive anyway, was quietly skipped by the part of the node that applies configuration — so it never received updates that shipped with the program. This was found the hard way: the Bitcoin control-interface fix above appeared to be installed and silently was not, while the Lightning half applied correctly, which is the most misleading way for a security fix to fail. Both halves are now proven to land on a real node.",
|
||||
"The Lightning and Bitcoin node screens have been rebuilt to match what umbrelOS offers. Lightning gains Overview, Channels, Activity, Insights, Connect and Settings tabs with a sats/BTC switch; Bitcoin gains Insights, Peers, Connect and Sharing. Along the way: every copy button on those screens silently did nothing (the browser blocks clipboard access inside an embedded page) and now works; the channels link led to a dead page; and Node ID showed a bare key instead of the full address someone can actually connect to.",
|
||||
"Updates to the Bitcoin screen show up without a hard refresh. The page was being cached by the browser, so a freshly updated screen kept rendering the previous one.",
|
||||
"The AI sidebar loads again. It was asking for its program files at an address that pointed at the main app's files, where they do not exist, so it silently loaded nothing.",
|
||||
"The navigation above the bottom bar no longer follows you between screens. Back buttons and the mesh tab bar stayed pinned over every other page once you had visited the screen that owns them. Keeping tabs loaded in the background — the change that made switching between them instant — means leaving a screen hides it rather than destroying it, and this floating navigation sits outside the screen it belongs to, so it was never being hidden with it. It is now tied to whether its own screen is on display. The speed is unchanged: the screens are still kept loaded, so returning to one is still instant.",
|
||||
"Wallet: Lightning actions are now offered based on whether you actually have a usable channel rather than just a running node, sending is gated the same way, and an invoice you cannot yet receive offers to install a Lightning node instead of simply failing.",
|
||||
"Onboarding and viewing fixes: the \"I have written down my recovery words\" tickbox is findable on short screens, paid pictures and videos open in the app's own viewer with a visible loading state instead of a blank browser tab, picture-in-picture survives changing tabs, and the FIPS/Tor labels on peer cards stay put instead of wrapping into the card below.",
|
||||
"Key-material hardening across the node: a node that is already set up refuses to have its identity replaced by an unauthenticated request; first-boot secret generation now fails loudly instead of silently continuing with shared keys; the node proves its TLS certificate and key are actually a matching pair; the Bitcoin Core wallet path that kept a second copy of your spending key outside the encrypted store has been removed; and every place the node generates a key, token or nonce now names its source of randomness explicitly, enforced at build time."
|
||||
"**Your apps now ask for your node password before they open — over your home network, Tailscale, the mesh and Tor alike.** Until now anyone who could reach your node could open Immich, Nextcloud, Vaultwarden, Jellyfin, Grafana and the rest simply by typing the address and port, with no login at all. Twenty app screens now sit behind the same login you use for the node, showing you which app you are opening, and honouring two-factor if you have it switched on. Logging in at an app address logs you into the dashboard too, so it is one password, not one per app. This completes the groundwork disclosed in v1.7.121.",
|
||||
"**The things that must stay open stayed open.** Zeus and other remote wallets still reach your Lightning node directly, Electrum wallets still connect, and Bitcoin still talks to its peers — those connections carry their own proof of identity and a login page would simply break them. Every one of these seventeen exceptions now has to state in writing why it is safe to leave open, so the list is something you can read rather than something you have to discover.",
|
||||
"**A private address on your node was answering the mesh without a password.** One app's port was marked as being for this machine only, and the part of the node that carries mesh traffic did not know that — it forwarded requests from the whole mesh straight to it. Found while verifying the work above on a real node, not in testing. That path now refuses anything marked machine-only, and the app is reachable only from the node itself, as intended.",
|
||||
"**Tor addresses no longer skip the login.** An app published as a .onion address was handed straight to the app, because a Tor visitor carries no session cookie to check. The login gate now takes those addresses first, closing the last of the four routes that went around it.",
|
||||
"Nodes fix themselves after this update. Apps installed before this system used its current container setup kept their old wide-open address even after the signed list told them to move, and each would otherwise have needed hand-holding on every node. Your node now notices the difference and rebuilds those apps itself, keeping their data, within about half a minute of starting. Verified by putting a node back into the old state deliberately and watching it repair.",
|
||||
"The node had been reading two different sets of instructions about its own apps — the signed list it downloads, and older copies on disk — which is how a port meant to stay private was briefly opened on a test node. Both now come from the signed list, and a port withdrawn from the login gate is released without needing a restart.",
|
||||
"**The key that signs these updates has been replaced.** The previous signing key was exposed where it should not have been, so it is treated as compromised and this release installs its replacement. This update is the last one signed with the old key, by necessity — it is the one that teaches your node the new one.",
|
||||
"Known gaps, disclosed rather than buried: eleven app ports still have no stated policy — BotFights, the Fedimint gateway, NetBird, the voice assistant's own screens and the router screen — and remain reachable without a login until each is decided deliberately; the node reports them rather than guessing, because guessing at an unstated setting caused both incidents behind this work. Three voice-assistant ports are still open without authentication; the correct fix puts them on a private network with the assistant. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — will now meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.7.120-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.120-alpha/archipelago",
|
||||
"current_version": "1.7.122-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.122-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.7.120-alpha",
|
||||
"sha256": "304255655a22bae605d728d44e857ed170a19833b6237861fdf7d852d25d9680",
|
||||
"size_bytes": 54017008
|
||||
"new_version": "1.7.122-alpha",
|
||||
"sha256": "06aedbd235e962574b7abc5d6992c26b77cd943655e775cd93c84fdcc79ffab0",
|
||||
"size_bytes": 54957496
|
||||
},
|
||||
{
|
||||
"current_version": "1.7.120-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.120-alpha/archipelago-frontend-1.7.120-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.120-alpha.tar.gz",
|
||||
"new_version": "1.7.120-alpha",
|
||||
"sha256": "cb9ea4dfcea3ac93dfb1ce1dca96ea30c74a4e6354471cde4d8f75c0441850a5",
|
||||
"size_bytes": 210519311
|
||||
"current_version": "1.7.122-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.122-alpha/archipelago-frontend-1.7.122-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.122-alpha.tar.gz",
|
||||
"new_version": "1.7.122-alpha",
|
||||
"sha256": "865f5a0edb5eed1ced9dc4597b9112f24706d3538f8ffe84dea8104049d26af3",
|
||||
"size_bytes": 210528707
|
||||
}
|
||||
],
|
||||
"release_date": "2026-08-03",
|
||||
"signature": "e76e0ca5f249111a0a57df07f790997b1a4facf97da11a2d13fcb7ec9b80aea82925244d6083544504260b776ca4317cf44774e2c37bfaa13afae248e9675601",
|
||||
"release_date": "2026-08-05",
|
||||
"signature": "aca66567bf5954aefd450167f881289ee4715fd912fe61a50726741cadf1a93d39e832efc3266388839279ad41001c9802fdfaf766c8cfa9399509916ed4a80f",
|
||||
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
||||
"version": "1.7.120-alpha"
|
||||
"version": "1.7.122-alpha"
|
||||
}
|
||||
|
||||
+95
-14
@@ -25,6 +25,7 @@
|
||||
"name": "AI Assistant",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "local",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 80,
|
||||
"host": 5180,
|
||||
@@ -228,6 +229,8 @@
|
||||
"name": "Mempool Web",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8080,
|
||||
"host": 4080,
|
||||
"protocol": "tcp"
|
||||
@@ -302,6 +305,8 @@
|
||||
"name": "NBXplorer",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "local",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 32838,
|
||||
"host": 32838,
|
||||
"protocol": "tcp"
|
||||
@@ -374,6 +379,8 @@
|
||||
"name": "Ark Wallet",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "local",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 3535,
|
||||
"host": 3535,
|
||||
"protocol": "tcp"
|
||||
@@ -462,12 +469,15 @@
|
||||
"name": "Bitcoin Core",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "local",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8332,
|
||||
"host": 8332,
|
||||
"protocol": "tcp"
|
||||
},
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "Bitcoin p2p gossip. Peers are anonymous by design and speak the Bitcoin wire protocol, not HTTP.",
|
||||
"container": 8333,
|
||||
"host": 8333,
|
||||
"protocol": "tcp"
|
||||
@@ -605,12 +615,15 @@
|
||||
"name": "Bitcoin Knots",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "local",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8332,
|
||||
"host": 8332,
|
||||
"protocol": "tcp"
|
||||
},
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "Bitcoin p2p gossip. Peers are anonymous by design and speak the Bitcoin wire protocol, not HTTP.",
|
||||
"container": 8333,
|
||||
"host": 8333,
|
||||
"protocol": "tcp"
|
||||
@@ -671,7 +684,7 @@
|
||||
]
|
||||
},
|
||||
"bitcoin-ui": {
|
||||
"image": "146.59.87.168:3000/lfg2025/bitcoin-ui:1.7.84-alpha",
|
||||
"image": "146.59.87.168:3000/lfg2025/bitcoin-ui:1.7.119-alpha",
|
||||
"manifest": {
|
||||
"app": {
|
||||
"container": {
|
||||
@@ -719,7 +732,7 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": "1.7.84-alpha"
|
||||
"version": "1.7.119-alpha"
|
||||
},
|
||||
"botfights": {
|
||||
"manifest": {
|
||||
@@ -931,6 +944,8 @@
|
||||
"name": "BTCPay Server",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 49392,
|
||||
"host": 23000,
|
||||
"protocol": "tcp"
|
||||
@@ -1001,11 +1016,15 @@
|
||||
"name": "Core Lightning (CLN)",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "Lightning p2p. The BOLT-8 noise handshake authenticates and encrypts the channel itself.",
|
||||
"container": 9735,
|
||||
"host": 9736,
|
||||
"protocol": "tcp"
|
||||
},
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "Core Lightning gRPC, authenticated by mutual TLS client certificates.",
|
||||
"container": 9835,
|
||||
"host": 9835,
|
||||
"protocol": "tcp"
|
||||
@@ -1075,6 +1094,8 @@
|
||||
"name": "Web5 DID Wallet",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8080,
|
||||
"host": 8088,
|
||||
"protocol": "tcp"
|
||||
@@ -1219,6 +1240,8 @@
|
||||
"name": "ElectrumX",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "Electrum wire protocol over TCP. Electrum wallets speak it directly and cannot hold a session cookie.",
|
||||
"container": 50001,
|
||||
"host": 50001,
|
||||
"protocol": "tcp"
|
||||
@@ -1342,6 +1365,8 @@
|
||||
"protocol": "tcp"
|
||||
},
|
||||
{
|
||||
"auth": "local",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8175,
|
||||
"host": 8177,
|
||||
"protocol": "tcp"
|
||||
@@ -1416,6 +1441,8 @@
|
||||
"name": "Fedimint Client",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "local",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8080,
|
||||
"host": 8178,
|
||||
"protocol": "tcp"
|
||||
@@ -1601,6 +1628,8 @@
|
||||
"name": "File Browser",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 80,
|
||||
"host": 8083,
|
||||
"protocol": "tcp"
|
||||
@@ -1753,11 +1782,15 @@
|
||||
},
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 3000,
|
||||
"host": 3001,
|
||||
"protocol": "tcp"
|
||||
},
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "Git over SSH, authenticated by the user's own SSH keypair. Not HTTP, so the gate cannot serve a login page here.",
|
||||
"container": 22,
|
||||
"host": 2222,
|
||||
"protocol": "tcp"
|
||||
@@ -1842,6 +1875,8 @@
|
||||
"name": "Grafana",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 3000,
|
||||
"host": 3000,
|
||||
"protocol": "tcp"
|
||||
@@ -1925,6 +1960,8 @@
|
||||
"name": "Home Assistant",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8123,
|
||||
"host": 8123,
|
||||
"protocol": "tcp"
|
||||
@@ -2034,6 +2071,8 @@
|
||||
"name": "Immich",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 2283,
|
||||
"host": 2283,
|
||||
"protocol": "tcp"
|
||||
@@ -2273,6 +2312,8 @@
|
||||
"name": "IndeeHub",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 7777,
|
||||
"host": 7778,
|
||||
"protocol": "tcp"
|
||||
@@ -2765,6 +2806,8 @@
|
||||
"name": "Jellyfin",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8096,
|
||||
"host": 8096,
|
||||
"protocol": "tcp"
|
||||
@@ -2852,11 +2895,15 @@
|
||||
"name": "Lightning Stack",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "Lightning p2p. The BOLT-8 noise handshake authenticates and encrypts the channel itself.",
|
||||
"container": 9735,
|
||||
"host": 9738,
|
||||
"protocol": "tcp"
|
||||
},
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "LND gRPC, authenticated by macaroon over TLS. Remote wallets depend on reaching this directly.",
|
||||
"container": 10009,
|
||||
"host": 10010,
|
||||
"protocol": "tcp"
|
||||
@@ -2950,16 +2997,22 @@
|
||||
"name": "LND",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "Lightning p2p. The BOLT-8 noise handshake authenticates and encrypts the channel itself.",
|
||||
"container": 9735,
|
||||
"host": 9735,
|
||||
"protocol": "tcp"
|
||||
},
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "LND gRPC, authenticated by macaroon over TLS. Zeus and other remote wallets depend on reaching this directly.",
|
||||
"container": 10009,
|
||||
"host": 10009,
|
||||
"protocol": "tcp"
|
||||
},
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "LND REST, authenticated by macaroon over TLS. A browser login page would break Zeus and every non-browser wallet client.",
|
||||
"container": 8080,
|
||||
"host": 18080,
|
||||
"protocol": "tcp"
|
||||
@@ -2998,7 +3051,7 @@
|
||||
"version": "v0.18.4-beta"
|
||||
},
|
||||
"lnd-ui": {
|
||||
"image": "146.59.87.168:3000/lfg2025/lnd-ui:latest",
|
||||
"image": "146.59.87.168:3000/lfg2025/lnd-ui:1.7.119-alpha",
|
||||
"manifest": {
|
||||
"app": {
|
||||
"container": {
|
||||
@@ -3025,25 +3078,19 @@
|
||||
},
|
||||
"id": "lnd-ui",
|
||||
"name": "LND UI",
|
||||
"ports": [
|
||||
{
|
||||
"container": 80,
|
||||
"host": 18083,
|
||||
"protocol": "tcp"
|
||||
}
|
||||
],
|
||||
"ports": [],
|
||||
"resources": {
|
||||
"memory_limit": "64Mi"
|
||||
},
|
||||
"security": {
|
||||
"network_policy": "bridge",
|
||||
"network_policy": "host",
|
||||
"readonly_root": false
|
||||
},
|
||||
"version": "1.0.0",
|
||||
"volumes": []
|
||||
}
|
||||
},
|
||||
"version": "latest"
|
||||
"version": "1.7.119-alpha"
|
||||
},
|
||||
"mempool": {
|
||||
"image": "146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.1",
|
||||
@@ -3093,6 +3140,8 @@
|
||||
"name": "Mempool Explorer",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8080,
|
||||
"host": 4080,
|
||||
"protocol": "tcp"
|
||||
@@ -3196,6 +3245,8 @@
|
||||
"name": "Mempool API",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "local",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8999,
|
||||
"host": 8999,
|
||||
"protocol": "tcp"
|
||||
@@ -3255,6 +3306,8 @@
|
||||
"name": "MorphOS Server",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8080,
|
||||
"host": 8089,
|
||||
"protocol": "tcp"
|
||||
@@ -3569,6 +3622,8 @@
|
||||
"protocol": "tcp"
|
||||
},
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "STUN over UDP for NAT traversal; it must answer unauthenticated probes to do its job at all.",
|
||||
"container": 3478,
|
||||
"host": 3478,
|
||||
"protocol": "udp"
|
||||
@@ -3653,6 +3708,8 @@
|
||||
"name": "Nextcloud",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 80,
|
||||
"host": 8085,
|
||||
"protocol": "tcp"
|
||||
@@ -3731,6 +3788,8 @@
|
||||
},
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8080,
|
||||
"host": 18081,
|
||||
"protocol": "tcp"
|
||||
@@ -3832,6 +3891,8 @@
|
||||
"name": "PhotoPrism",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 2342,
|
||||
"host": 2342,
|
||||
"protocol": "tcp"
|
||||
@@ -4066,6 +4127,8 @@
|
||||
"name": "Pine Wake Word (openWakeWord)",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "Wyoming voice protocol, a binary local-only stream consumed by Home Assistant; not HTTP and not browser-reachable.",
|
||||
"container": 10400,
|
||||
"host": 10400,
|
||||
"protocol": "tcp"
|
||||
@@ -4144,6 +4207,8 @@
|
||||
"name": "Pine Piper (TTS)",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "Wyoming voice protocol, a binary local-only stream consumed by Home Assistant; not HTTP and not browser-reachable.",
|
||||
"container": 10200,
|
||||
"host": 10200,
|
||||
"protocol": "tcp"
|
||||
@@ -4226,6 +4291,8 @@
|
||||
"name": "Pine Whisper (STT)",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "Wyoming voice protocol, a binary local-only stream consumed by Home Assistant; not HTTP and not browser-reachable.",
|
||||
"container": 10300,
|
||||
"host": 10300,
|
||||
"protocol": "tcp"
|
||||
@@ -4298,6 +4365,8 @@
|
||||
"name": "Portainer",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 9000,
|
||||
"host": 9000,
|
||||
"protocol": "tcp"
|
||||
@@ -4394,11 +4463,15 @@
|
||||
"protocol": "tcp"
|
||||
},
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "mDNS is UDP multicast service discovery; gating it would break .local name resolution for every device on the LAN.",
|
||||
"container": 5353,
|
||||
"host": 5353,
|
||||
"protocol": "udp"
|
||||
},
|
||||
{
|
||||
"auth": "none",
|
||||
"auth_rationale": "SSDP/UPnP discovery is UDP multicast — there is no HTTP request to gate and no client that could hold a session.",
|
||||
"container": 1900,
|
||||
"host": 1900,
|
||||
"protocol": "udp"
|
||||
@@ -4478,6 +4551,8 @@
|
||||
"name": "SearXNG",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8080,
|
||||
"host": 8888,
|
||||
"protocol": "tcp"
|
||||
@@ -4549,6 +4624,8 @@
|
||||
},
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 7777,
|
||||
"host": 8090,
|
||||
"protocol": "tcp"
|
||||
@@ -4639,6 +4716,8 @@
|
||||
"name": "Uptime Kuma",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 3001,
|
||||
"host": 3002,
|
||||
"protocol": "tcp"
|
||||
@@ -4720,6 +4799,8 @@
|
||||
"name": "Vaultwarden",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 80,
|
||||
"host": 8082,
|
||||
"protocol": "tcp"
|
||||
@@ -4756,7 +4837,7 @@
|
||||
}
|
||||
},
|
||||
"schema": 1,
|
||||
"signature": "1fe1b962317212c15b83c9ae8b0b2957f9663bb7dda3f4d117123aab496ddd4f94fa48f7d4abfd4be5b771510aed51f8ede870e55cb3fd21ce2453bea1d3510e",
|
||||
"signature": "cc83d0be50ce6144e2b5693a7175d7743d4a19141f4ef9a46a3c88d2dadd848acda9c25063e7a8b5643cecb2ccde279762a00a9715a5d26c99a95b492bc82a05",
|
||||
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
||||
"updated": "2026-07-31"
|
||||
"updated": "2026-08-04"
|
||||
}
|
||||
|
||||
+22
-24
@@ -1,36 +1,34 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**Security, and the reason to take this update: two ports on your node handed anyone who could reach them complete control of your money, with no password.** The Lightning app's port answered a plain web request with the LND admin macaroon, the TLS certificate and the node's onion address — everything needed to drain the wallet remotely, and the onion meant an attacker kept that ability even after losing access to your network. The Bitcoin app's port reached Bitcoin Core's control interface using credentials the node itself supplied on the caller's behalf, with a wallet loaded. Anything on your home network, your Tailscale network or the mesh could use either one. Both now require you to be logged in. If your node has been reachable by anyone you do not fully trust, treat the Lightning macaroon and the Bitcoin RPC password as known to them.",
|
||||
"The Bitcoin and Lightning app screens can no longer be published as public Tor addresses automatically. They were one app-id away from being handed a worldwide, permanent address as a silent side effect of being installed — which would have re-opened the hole above to the entire internet. Turning Tor on for them deliberately still works; it just never happens on its own.",
|
||||
"**Fixes shipped inside the program now actually reach apps that your system keeps running.** A container the node had been told to uninstall, but that the system service manager kept alive anyway, was quietly skipped by the part of the node that applies configuration — so it never received updates that shipped with the program. This was found the hard way: the Bitcoin control-interface fix above appeared to be installed and silently was not, while the Lightning half applied correctly, which is the most misleading way for a security fix to fail. Both halves are now proven to land on a real node.",
|
||||
"The Lightning and Bitcoin node screens have been rebuilt to match what umbrelOS offers. Lightning gains Overview, Channels, Activity, Insights, Connect and Settings tabs with a sats/BTC switch; Bitcoin gains Insights, Peers, Connect and Sharing. Along the way: every copy button on those screens silently did nothing (the browser blocks clipboard access inside an embedded page) and now works; the channels link led to a dead page; and Node ID showed a bare key instead of the full address someone can actually connect to.",
|
||||
"Updates to the Bitcoin screen show up without a hard refresh. The page was being cached by the browser, so a freshly updated screen kept rendering the previous one.",
|
||||
"The AI sidebar loads again. It was asking for its program files at an address that pointed at the main app's files, where they do not exist, so it silently loaded nothing.",
|
||||
"The navigation above the bottom bar no longer follows you between screens. Back buttons and the mesh tab bar stayed pinned over every other page once you had visited the screen that owns them. Keeping tabs loaded in the background — the change that made switching between them instant — means leaving a screen hides it rather than destroying it, and this floating navigation sits outside the screen it belongs to, so it was never being hidden with it. It is now tied to whether its own screen is on display. The speed is unchanged: the screens are still kept loaded, so returning to one is still instant.",
|
||||
"Wallet: Lightning actions are now offered based on whether you actually have a usable channel rather than just a running node, sending is gated the same way, and an invoice you cannot yet receive offers to install a Lightning node instead of simply failing.",
|
||||
"Onboarding and viewing fixes: the \"I have written down my recovery words\" tickbox is findable on short screens, paid pictures and videos open in the app's own viewer with a visible loading state instead of a blank browser tab, picture-in-picture survives changing tabs, and the FIPS/Tor labels on peer cards stay put instead of wrapping into the card below.",
|
||||
"Key-material hardening across the node: a node that is already set up refuses to have its identity replaced by an unauthenticated request; first-boot secret generation now fails loudly instead of silently continuing with shared keys; the node proves its TLS certificate and key are actually a matching pair; the Bitcoin Core wallet path that kept a second copy of your spending key outside the encrypted store has been removed; and every place the node generates a key, token or nonce now names its source of randomness explicitly, enforced at build time."
|
||||
"**Your apps now ask for your node password before they open — over your home network, Tailscale, the mesh and Tor alike.** Until now anyone who could reach your node could open Immich, Nextcloud, Vaultwarden, Jellyfin, Grafana and the rest simply by typing the address and port, with no login at all. Twenty app screens now sit behind the same login you use for the node, showing you which app you are opening, and honouring two-factor if you have it switched on. Logging in at an app address logs you into the dashboard too, so it is one password, not one per app. This completes the groundwork disclosed in v1.7.121.",
|
||||
"**The things that must stay open stayed open.** Zeus and other remote wallets still reach your Lightning node directly, Electrum wallets still connect, and Bitcoin still talks to its peers — those connections carry their own proof of identity and a login page would simply break them. Every one of these seventeen exceptions now has to state in writing why it is safe to leave open, so the list is something you can read rather than something you have to discover.",
|
||||
"**A private address on your node was answering the mesh without a password.** One app's port was marked as being for this machine only, and the part of the node that carries mesh traffic did not know that — it forwarded requests from the whole mesh straight to it. Found while verifying the work above on a real node, not in testing. That path now refuses anything marked machine-only, and the app is reachable only from the node itself, as intended.",
|
||||
"**Tor addresses no longer skip the login.** An app published as a .onion address was handed straight to the app, because a Tor visitor carries no session cookie to check. The login gate now takes those addresses first, closing the last of the four routes that went around it.",
|
||||
"Nodes fix themselves after this update. Apps installed before this system used its current container setup kept their old wide-open address even after the signed list told them to move, and each would otherwise have needed hand-holding on every node. Your node now notices the difference and rebuilds those apps itself, keeping their data, within about half a minute of starting. Verified by putting a node back into the old state deliberately and watching it repair.",
|
||||
"The node had been reading two different sets of instructions about its own apps — the signed list it downloads, and older copies on disk — which is how a port meant to stay private was briefly opened on a test node. Both now come from the signed list, and a port withdrawn from the login gate is released without needing a restart.",
|
||||
"**The key that signs these updates has been replaced.** The previous signing key was exposed where it should not have been, so it is treated as compromised and this release installs its replacement. This update is the last one signed with the old key, by necessity — it is the one that teaches your node the new one.",
|
||||
"Known gaps, disclosed rather than buried: eleven app ports still have no stated policy — BotFights, the Fedimint gateway, NetBird, the voice assistant's own screens and the router screen — and remain reachable without a login until each is decided deliberately; the node reports them rather than guessing, because guessing at an unstated setting caused both incidents behind this work. Three voice-assistant ports are still open without authentication; the correct fix puts them on a private network with the assistant. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — will now meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.7.120-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.120-alpha/archipelago",
|
||||
"current_version": "1.7.122-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.122-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.7.120-alpha",
|
||||
"sha256": "304255655a22bae605d728d44e857ed170a19833b6237861fdf7d852d25d9680",
|
||||
"size_bytes": 54017008
|
||||
"new_version": "1.7.122-alpha",
|
||||
"sha256": "06aedbd235e962574b7abc5d6992c26b77cd943655e775cd93c84fdcc79ffab0",
|
||||
"size_bytes": 54957496
|
||||
},
|
||||
{
|
||||
"current_version": "1.7.120-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.120-alpha/archipelago-frontend-1.7.120-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.120-alpha.tar.gz",
|
||||
"new_version": "1.7.120-alpha",
|
||||
"sha256": "cb9ea4dfcea3ac93dfb1ce1dca96ea30c74a4e6354471cde4d8f75c0441850a5",
|
||||
"size_bytes": 210519311
|
||||
"current_version": "1.7.122-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.122-alpha/archipelago-frontend-1.7.122-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.122-alpha.tar.gz",
|
||||
"new_version": "1.7.122-alpha",
|
||||
"sha256": "865f5a0edb5eed1ced9dc4597b9112f24706d3538f8ffe84dea8104049d26af3",
|
||||
"size_bytes": 210528707
|
||||
}
|
||||
],
|
||||
"release_date": "2026-08-03",
|
||||
"signature": "e76e0ca5f249111a0a57df07f790997b1a4facf97da11a2d13fcb7ec9b80aea82925244d6083544504260b776ca4317cf44774e2c37bfaa13afae248e9675601",
|
||||
"release_date": "2026-08-05",
|
||||
"signature": "aca66567bf5954aefd450167f881289ee4715fd912fe61a50726741cadf1a93d39e832efc3266388839279ad41001c9802fdfaf766c8cfa9399509916ed4a80f",
|
||||
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
||||
"version": "1.7.120-alpha"
|
||||
"version": "1.7.122-alpha"
|
||||
}
|
||||
|
||||
@@ -240,6 +240,18 @@ install -m 0644 "$FRONTEND_ARCHIVE" "$VERSION_DIR/archipelago-frontend-${VERSION
|
||||
# warning and falls through — and the commit then happened anyway. A release
|
||||
# commit carrying a manifest no node will accept has no valid use, so refuse
|
||||
# to create one rather than leave a tag that has to be re-cut.
|
||||
# ⚠ ROTATION IN FLIGHT (v1.7.122-alpha) — this is the OLD root, deliberately.
|
||||
#
|
||||
# The trust anchor in the binary already pins the NEW root
|
||||
# (z6Mkfu5LT…DLWT), because this release is what installs that pin. But the
|
||||
# manifest THIS release ships must be signed with the OLD root
|
||||
# (z6Mkkid…q7ur): every node is still running the previous binary, which
|
||||
# pins the old key and would reject anything else. Signing this one with the
|
||||
# new key ends OTA fleet-wide and needs hands-on recovery per node.
|
||||
#
|
||||
# ➜ NEXT RELEASE (v1.7.123+): change this to the new DID, and the same line
|
||||
# in publish-release-assets.sh. By then every node runs a binary pinning
|
||||
# the new root, and an old-key signature is the one that gets rejected.
|
||||
EXPECTED_DID="did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur"
|
||||
if ! grep -q '"signature":' "$PROJECT_ROOT/releases/manifest.json" \
|
||||
|| ! grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PROJECT_ROOT/releases/manifest.json"; then
|
||||
|
||||
@@ -29,6 +29,10 @@ fail() { echo "Error: $*" >&2; exit 1; }
|
||||
# with the pinned release-root anchor refuse to auto-apply unsigned manifests,
|
||||
# and enforcement will tighten to hard-reject — an unsigned publish would
|
||||
# strand them. Grep proves presence; ceremony verify proves the crypto.
|
||||
# ⚠ ROTATION IN FLIGHT (v1.7.122-alpha) — OLD root on purpose; see the same
|
||||
# block in create-release.sh. Nodes still run the previous binary and pin the
|
||||
# old key, so the manifest this release publishes must carry an old-key
|
||||
# signature. Flip both to z6Mkfu5LT…DLWT for v1.7.123+.
|
||||
EXPECTED_DID="did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur"
|
||||
grep -q '"signature":' "$PROJECT_ROOT/releases/manifest.json" \
|
||||
&& grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PROJECT_ROOT/releases/manifest.json" \
|
||||
@@ -39,18 +43,25 @@ if [ -x "$PROJECT_ROOT/core/target/release/archipelago" ]; then
|
||||
fi
|
||||
|
||||
remote_url=$(git -C "$PROJECT_ROOT" remote get-url "$REMOTE")
|
||||
# https is accepted as well as http. Requiring http:// meant the only remote
|
||||
# whose credential actually works for git push (the https one) was rejected,
|
||||
# while the http remote it forced you to use had a dead token — so publishing
|
||||
# failed on auth after the manifest had already passed every check
|
||||
# (v1.7.121-alpha, 2026-08-04). The scheme is carried through to the API URL
|
||||
# rather than assumed.
|
||||
case "$remote_url" in
|
||||
http://*@*) ;;
|
||||
*) fail "$REMOTE must be an authenticated http:// Gitea remote URL for API uploads" ;;
|
||||
http://*@*|https://*@*) ;;
|
||||
*) fail "$REMOTE must be an authenticated http(s):// Gitea remote URL for API uploads" ;;
|
||||
esac
|
||||
|
||||
auth=${remote_url#http://}
|
||||
auth=${auth%@*}
|
||||
host_path=${remote_url#http://$auth@}
|
||||
scheme=${remote_url%%://*}
|
||||
rest=${remote_url#*://}
|
||||
auth=${rest%%@*}
|
||||
host_path=${rest#*@}
|
||||
host=${host_path%%/*}
|
||||
repo_path=${host_path#*/}
|
||||
repo_path=${repo_path%.git}
|
||||
api="http://$host/api/v1/repos/$repo_path"
|
||||
api="$scheme://$host/api/v1/repos/$repo_path"
|
||||
release_url="$api/releases/tags/v${VERSION}"
|
||||
|
||||
echo "Pushing main and v${VERSION} to $REMOTE..."
|
||||
|
||||
@@ -11,7 +11,7 @@ set -euo pipefail
|
||||
|
||||
REPO="/home/archipelago/Projects/archy"
|
||||
CATALOG="$REPO/releases/app-catalog.json"
|
||||
EXPECTED_DID="did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur"
|
||||
EXPECTED_DID="did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT"
|
||||
|
||||
# Use ONLY the prebuilt signer. If it isn't ready, stop cleanly — never compile
|
||||
# here (compiling caused the earlier hangs). Claude builds it in the background.
|
||||
|
||||
@@ -11,6 +11,19 @@
|
||||
# Normally create-release.sh signs the manifest inline; this script exists for
|
||||
# re-signing (e.g. a manifest edited after creation) or signing on a box where
|
||||
# the release run was non-interactive.
|
||||
#
|
||||
# ⚠ ROTATION IN FLIGHT (v1.7.122-alpha). This release must be signed with the
|
||||
# OLD release root, because every node still runs a binary pinning it — but
|
||||
# the signer built from THIS tree already pins the NEW root, so its own
|
||||
# verification would reject a correct old-key signature. Pin the old anchor
|
||||
# for the duration of the ceremony so signing and verification agree:
|
||||
#
|
||||
# ARCHY_RELEASE_ROOT_PUBKEY=5d15cbee8a108f7dd288c02d29a1d9d71f198acc99186aad8008b4f28d469951 \
|
||||
# bash scripts/sign-manifest.sh
|
||||
#
|
||||
# That hex is the OLD root's PUBLIC key (verified to derive to
|
||||
# did:key:z6Mkkid…q7ur); it is not secret and pins verification only.
|
||||
# From v1.7.123 the override is unnecessary — drop it and this block.
|
||||
set -euo pipefail
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
Reference in New Issue
Block a user