Compare commits
15
Commits
@@ -1,5 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.125-alpha (2026-08-06)
|
||||
|
||||
- **The Lightning, Bitcoin, Electrum and mesh screens work again behind the login gate.** Since the gate went up, those screens loaded their frame and then showed every number as unreachable. The gate was deliberately hiding your login from the apps it protects — right for third-party apps, wrong for the node's own screens, which need that login to fetch your data. The gate now removes only its own credential, and the node's own screens explicitly receive yours. The same mistake was also quietly signing you out of apps with their own logins — Vaultwarden, Nextcloud, Gitea — on every single request; that stops too.
|
||||
- **IndeeHub heals itself.** Three separate faults fixed: its database helper was recreated with permissions too tight to read its own files (it had crashed and restarted roughly ten thousand times on one node); on another node two of its seven parts could never be recreated at all because of how the node asked for their storage — it would remove the old part and then fail to build its replacement, leaving the app half-missing forever; and a regenerated password could lock the app out of a database that keeps the original. The storage fault fixes the same trap for every future multi-part app.
|
||||
- **A missing piece of a running app now gets put back automatically.** If one container of a multi-part app disappears while its siblings are still running, the node treats that as a hole to repair rather than a choice to respect, and rebuilds the missing piece. An app you actually uninstalled stays uninstalled.
|
||||
- **Send and Receive open clean every time.** Whatever you typed last — an address, an amount, and above all an armed "send all funds" toggle — no longer quietly carries over into the next payment. Choosing "send all funds" also shows the amount being swept instead of a confusing 0.
|
||||
- **A sweep that cannot happen now says why.** Trying to sweep a balance that is below Bitcoin's dust minimum (about 546 sats) or not yet confirmed used to fail with "check server logs"; it now explains that no transaction can be built from those coins.
|
||||
- **The camera scanner option no longer vanishes on desktop.** Browsers only allow the live camera on secure (HTTPS) pages, and the scan window silently hid the camera choice on plain connections — which read as "the scanner is gone". The option now stays visible and explains itself, and the photo and paste routes always work. The companion app's built-in scanner is untouched.
|
||||
- **App data folders can no longer be "repaired" into a state the app cannot use.** When the node fixed a folder's ownership through its fallback path, it wrote the container's raw user number instead of the translated one, so the fix reported success while the app still could not open its own files — one node's BotFights restarted every ten seconds over exactly this. The translation is now applied.
|
||||
- Also: the app login page uses the Archipelago mark and stays centred on phones with the keyboard open, app icons in the install window are no longer cropped, and when the node fails to build a container it now records the actual reason instead of a one-line stub that hid the cause of the IndeeHub fault for days.
|
||||
- Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release.
|
||||
|
||||
## v1.7.124-alpha (2026-08-05)
|
||||
|
||||
- **The most important fix in this release: some nodes were left switched off by their own update, and could not switch themselves back on.** The node replaces its program and then exits, expecting the system to start it again — but nodes installed from older images carried a setting that only restarts the program if it *crashes*. A clean, deliberate exit looked like success, so nothing restarted it, and the node sat dead showing "server starting" with nothing able to start it. One of ours was down for over two hours this way, and three of four checked had the same setting waiting to bite. Your node now repairs that setting itself the first time it starts, so it survives every future update.
|
||||
|
||||
@@ -38,15 +38,8 @@ app:
|
||||
RPC_CONF="/tmp/rpc.conf";
|
||||
umask 077;
|
||||
{ echo "rpcuser=$RPC_USER"; echo "rpcpassword=$RPC_PASS"; } > "$RPC_CONF";
|
||||
# A stray bitcoin.conf in the datadir is FATAL when -conf points
|
||||
# elsewhere: bitcoind refuses to start with "contains a bitcoin.conf
|
||||
# file which is ignored", and the app crash-loops (100.82.34.38,
|
||||
# 2026-08-05 — Exited(1) every few seconds). Our -conf carries the
|
||||
# RPC credentials and the flags below are the authoritative config,
|
||||
# so the datadir file is legacy debris; say so out loud rather than
|
||||
# failing, and let bitcoind start.
|
||||
if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then
|
||||
echo "archipelago: ignoring legacy /home/bitcoin/.bitcoin/bitcoin.conf; RPC config comes from $RPC_CONF and the flags below" >&2;
|
||||
echo "archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF" >&2;
|
||||
fi;
|
||||
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
|
||||
DISK_GB_VALUE="$(printenv DISK_GB || true)";
|
||||
|
||||
@@ -38,15 +38,8 @@ app:
|
||||
RPC_CONF="/tmp/rpc.conf";
|
||||
umask 077;
|
||||
{ echo "rpcuser=$RPC_USER"; echo "rpcpassword=$RPC_PASS"; } > "$RPC_CONF";
|
||||
# A stray bitcoin.conf in the datadir is FATAL when -conf points
|
||||
# elsewhere: bitcoind refuses to start with "contains a bitcoin.conf
|
||||
# file which is ignored", and the app crash-loops (100.82.34.38,
|
||||
# 2026-08-05 — Exited(1) every few seconds). Our -conf carries the
|
||||
# RPC credentials and the flags below are the authoritative config,
|
||||
# so the datadir file is legacy debris; say so out loud rather than
|
||||
# failing, and let bitcoind start.
|
||||
if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then
|
||||
echo "archipelago: ignoring legacy /home/bitcoin/.bitcoin/bitcoin.conf; RPC config comes from $RPC_CONF and the flags below" >&2;
|
||||
echo "archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF" >&2;
|
||||
fi;
|
||||
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
|
||||
DISK_GB_VALUE="$(printenv DISK_GB || true)";
|
||||
|
||||
@@ -43,6 +43,10 @@ app:
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
# First-party companion UI: its nginx forwards the node session cookie
|
||||
# to the daemon's authenticated endpoints; without passthrough the gate
|
||||
# strips it and every data call 401s while the page shell renders.
|
||||
session_passthrough: true
|
||||
|
||||
volumes:
|
||||
# Bind-mount the rendered nginx.conf read-only. The prod orchestrator
|
||||
|
||||
@@ -35,6 +35,10 @@ app:
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
# First-party companion UI: its nginx forwards the node session cookie
|
||||
# to the daemon's authenticated endpoints; without passthrough the gate
|
||||
# strips it and every data call 401s while the page shell renders.
|
||||
session_passthrough: true
|
||||
|
||||
volumes: []
|
||||
|
||||
|
||||
@@ -63,16 +63,13 @@ app:
|
||||
federation itself and cannot hold a browser session.
|
||||
# Public launch port 8175 is owned by archy-fedimint-ui, which serves a
|
||||
# wait page while Bitcoin syncs and proxies here after fedimintd starts.
|
||||
# Declared HERE because that companion has no manifest of its own, and the
|
||||
# gate keys on the port rather than the container: without this entry it
|
||||
# served the Guardian UI unauthenticated on every interface and never
|
||||
# appeared in the audit. Its nginx is pinned to 127.0.0.1
|
||||
# (docker/fedimint-ui/nginx.conf) so the gate can own the outside.
|
||||
- host: 8175
|
||||
container: 8175
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
# 8175 is NOT declared here. It is served by the archy-fedimint-ui
|
||||
# companion, a different container, and declaring it on this app made the
|
||||
# orchestrator try to publish 8175 from fedimintd — colliding with the
|
||||
# companion that already holds it, so start_container failed forever and
|
||||
# fedimint crash-looped (100.82.34.38, 2026-08-05). The companion's nginx
|
||||
# is pinned to 127.0.0.1, which is what actually closes that port; the
|
||||
# gate reports it rather than fronting it.
|
||||
- host: 8177
|
||||
container: 8175
|
||||
protocol: tcp
|
||||
|
||||
@@ -39,6 +39,10 @@ app:
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
# First-party companion UI: its nginx forwards the node session cookie
|
||||
# to the daemon's authenticated endpoints; without passthrough the gate
|
||||
# strips it and every data call 401s while the page shell renders.
|
||||
session_passthrough: true
|
||||
|
||||
volumes: []
|
||||
|
||||
|
||||
@@ -22,7 +22,13 @@ app:
|
||||
memory_limit: 256Mi
|
||||
|
||||
security:
|
||||
capabilities: [SETGID, SETUID]
|
||||
# The alpine entrypoint runs as container-root, `find`s /data to chown
|
||||
# anything not owned by the redis user, then su-execs to it. Under the
|
||||
# orchestrator's --cap-drop=ALL, root cannot traverse the 0700
|
||||
# appendonlydir owned by uid 999 without DAC_OVERRIDE (observed
|
||||
# crash-looping ~4k restarts on archi-dev-box) — CHOWN is what the find's
|
||||
# -exec chown needs on adopted legacy data.
|
||||
capabilities: [CHOWN, DAC_OVERRIDE, SETGID, SETUID]
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
|
||||
@@ -47,6 +47,10 @@ app:
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
# First-party companion UI: its nginx forwards the node session cookie
|
||||
# to the daemon's authenticated endpoints; without passthrough the gate
|
||||
# strips it and every data call 401s while the page shell renders.
|
||||
session_passthrough: true
|
||||
|
||||
volumes: []
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.124-alpha"
|
||||
version = "1.7.125-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.124-alpha"
|
||||
version = "1.7.125-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -192,6 +192,19 @@ impl RpcHandler {
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
// LND's sweep refusal reads like a debug dump ("insufficient
|
||||
// input to create sweep tx: input_sum=0 BTC, output_sum=…").
|
||||
// input_sum=0 with a tiny output means the wallet's coins are
|
||||
// unconfirmed or below Bitcoin's dust minimum — say that
|
||||
// (framework-pt sweep of 92 sats, 2026-08-06).
|
||||
if msg.contains("insufficient input to create sweep tx") {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to send: your on-chain balance is too small or still \
|
||||
unconfirmed to sweep. Bitcoin cannot build a transaction from \
|
||||
coins below the dust minimum (~546 sats) or from funds that \
|
||||
have not confirmed yet. (LND: {msg})"
|
||||
));
|
||||
}
|
||||
return Err(anyhow::anyhow!("Failed to send: {}", msg));
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,11 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
|
||||
// them in the first place (ecash send, 2026-07-22).
|
||||
"Insufficient balance",
|
||||
"Insufficient funds",
|
||||
// On-chain send/sweep refusals from LND ("Failed to send: your
|
||||
// on-chain balance is too small or still unconfirmed to sweep…").
|
||||
// Masking sent the operator to journalctl again (framework-pt
|
||||
// sweep, 2026-08-06) — same lesson as the two above.
|
||||
"Failed to send",
|
||||
// Lightning payment failures carry LND's reason ("invoice expired.
|
||||
// Valid until …", "no route", …) — the user can act on every one of
|
||||
// them, and masking sent the operator to journalctl (invoice-expired
|
||||
|
||||
@@ -75,7 +75,8 @@ pub fn address_caching_dependents(package_id: &str) -> &'static [&'static str] {
|
||||
/// The package whose lifecycle lock covers `app_id`: the stack package when
|
||||
/// `app_id` is a member (RPC ops on "mempool" hold the "mempool" lock while
|
||||
/// they drive archy-mempool-web), otherwise the app itself.
|
||||
fn owning_package(app_id: &str) -> &str {
|
||||
/// Also consulted by the reconciler's absent-stack-member recovery.
|
||||
pub fn owning_package(app_id: &str) -> &str {
|
||||
const STACKS: &[&str] = &[
|
||||
"immich",
|
||||
"indeedhub",
|
||||
|
||||
@@ -35,6 +35,11 @@ pub struct GatedPort {
|
||||
/// 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,
|
||||
/// Manifest opt-in (`session_passthrough: true` on the port): forward the
|
||||
/// node session cookie to the app on authorised requests. First-party
|
||||
/// companion UIs proxy that cookie to the daemon's authenticated
|
||||
/// endpoints; for every other app the gate strips its own credential.
|
||||
pub session_passthrough: bool,
|
||||
}
|
||||
|
||||
/// A port deliberately left unauthenticated, and the manifest's stated reason.
|
||||
@@ -226,6 +231,7 @@ fn classify_manifest(manifest: &AppManifest, map: &mut PortMap) {
|
||||
app_name: app_name.clone(),
|
||||
icon: icon.clone(),
|
||||
declared: true,
|
||||
session_passthrough: port.session_passthrough,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -273,6 +279,9 @@ fn classify_manifest(manifest: &AppManifest, map: &mut PortMap) {
|
||||
app_name: app_name.clone(),
|
||||
icon: icon.clone(),
|
||||
declared: false,
|
||||
// An undeclared port never gets the node session —
|
||||
// passthrough is an explicit manifest opt-in only.
|
||||
session_passthrough: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ impl AppGate {
|
||||
}
|
||||
|
||||
match self.authorize(req.headers(), &app.app_id).await {
|
||||
Authorization::Allow => proxy_to_app(req, app.port).await,
|
||||
Authorization::Allow => proxy_to_app(req, app).await,
|
||||
// 401 rather than a redirect: a redirect to a login page is
|
||||
// indistinguishable from the app itself redirecting, and machine
|
||||
// clients would follow it and parse HTML as if it were their API
|
||||
@@ -375,7 +375,8 @@ fn percent_decode(input: &str) -> String {
|
||||
}
|
||||
|
||||
/// Forward an authorised request to the app on loopback.
|
||||
async fn proxy_to_app(req: Request<Body>, port: u16) -> Response<Body> {
|
||||
async fn proxy_to_app(req: Request<Body>, app: &GatedPort) -> Response<Body> {
|
||||
let port = app.port;
|
||||
let path_and_query = req
|
||||
.uri()
|
||||
.path_and_query()
|
||||
@@ -389,10 +390,16 @@ async fn proxy_to_app(req: Request<Body>, port: u16) -> Response<Body> {
|
||||
|
||||
let (mut parts, body) = req.into_parts();
|
||||
parts.uri = uri;
|
||||
// Strip the gate's own credential before it reaches the app: the app has
|
||||
// no use for the node session and should never be in a position to log,
|
||||
// echo, or forward it.
|
||||
parts.headers.remove(header::COOKIE);
|
||||
// Strip the gate's own credential before it reaches the app — the app
|
||||
// should never be in a position to log, echo, or forward the node
|
||||
// session. But ONLY the gate's cookies: apps run their own cookie logins
|
||||
// (vaultwarden, nextcloud, gitea…), and removing the whole header logged
|
||||
// every one of them out on each request. Companion UIs that proxy the
|
||||
// daemon's authenticated endpoints opt in to keeping the session via
|
||||
// `session_passthrough: true` on their gated port.
|
||||
if !app.session_passthrough {
|
||||
strip_gate_cookies(&mut parts.headers);
|
||||
}
|
||||
parts.headers.remove(header::AUTHORIZATION);
|
||||
|
||||
let client = hyper::Client::new();
|
||||
@@ -402,6 +409,44 @@ async fn proxy_to_app(req: Request<Body>, port: u16) -> Response<Body> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cookie names owned by the gate/daemon, never the app's to see.
|
||||
const GATE_COOKIE_NAMES: &[&str] = &["session", "csrf_token"];
|
||||
|
||||
/// Remove the gate's own cookie pairs from the Cookie header, preserving the
|
||||
/// app's cookies (its login/session/prefs) untouched. Drops the header
|
||||
/// entirely when nothing remains.
|
||||
fn strip_gate_cookies(headers: &mut hyper::HeaderMap) {
|
||||
let Some(cookie) = headers.get(header::COOKIE) else {
|
||||
return;
|
||||
};
|
||||
let Ok(raw) = cookie.to_str() else {
|
||||
// Not valid UTF-8 — can't safely filter pairs, so fail closed.
|
||||
headers.remove(header::COOKIE);
|
||||
return;
|
||||
};
|
||||
let kept: Vec<&str> = raw
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.filter(|pair| {
|
||||
let name = pair.split('=').next().unwrap_or("").trim();
|
||||
!GATE_COOKIE_NAMES.contains(&name)
|
||||
})
|
||||
.filter(|pair| !pair.is_empty())
|
||||
.collect();
|
||||
if kept.is_empty() {
|
||||
headers.remove(header::COOKIE);
|
||||
return;
|
||||
}
|
||||
match header::HeaderValue::from_str(&kept.join("; ")) {
|
||||
Ok(v) => {
|
||||
headers.insert(header::COOKIE, v);
|
||||
}
|
||||
Err(_) => {
|
||||
headers.remove(header::COOKIE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_session_cookie(resp: &mut Response<Body>, token: &str) {
|
||||
// No Domain attribute, so the cookie is host-only. Cookies ignore port,
|
||||
// which is what makes one sign-in cover the dashboard and every app port
|
||||
@@ -548,7 +593,7 @@ const LOGIN_BACKGROUNDS: [&str; 4] = [
|
||||
/// join: the name arrives in a URL, and the gate answers before any
|
||||
/// authentication, so nothing here may be caller-controlled beyond this set.
|
||||
fn read_ui_asset(name: &str) -> Option<(Vec<u8>, &'static str)> {
|
||||
let allowed = LOGIN_BACKGROUNDS.contains(&name) || name == "logo-archipelago.svg";
|
||||
let allowed = LOGIN_BACKGROUNDS.contains(&name) || name == "favico-black-v2.svg";
|
||||
if !allowed {
|
||||
return None;
|
||||
}
|
||||
@@ -557,6 +602,9 @@ fn read_ui_asset(name: &str) -> Option<(Vec<u8>, &'static str)> {
|
||||
"/opt/archipelago/web-ui/assets/img",
|
||||
"web/dist/neode-ui/assets/img",
|
||||
"neode-ui/public/assets/img",
|
||||
"/opt/archipelago/web-ui/assets/icon",
|
||||
"web/dist/neode-ui/assets/icon",
|
||||
"neode-ui/public/assets/icon",
|
||||
] {
|
||||
if let Ok(bytes) = std::fs::read(std::path::Path::new(root).join(name)) {
|
||||
return Some((bytes, mime));
|
||||
@@ -631,9 +679,21 @@ fn page(title: &str, app: &GatedPort, body: &str, status: StatusCode) -> Respons
|
||||
bundle exists, and the CSP forbids external stylesheets and script. */
|
||||
:root {{ color-scheme: dark; }}
|
||||
* {{ box-sizing: border-box; }}
|
||||
body {{ margin:0; min-height:100vh; display:grid; place-items:center; padding:1rem;
|
||||
background:#05070a; color:#fff; overflow:hidden;
|
||||
font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif; }}
|
||||
html {{ height:100%; }}
|
||||
body {{ margin:0; color:#fff; background:#05070a; overflow:hidden;
|
||||
font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;
|
||||
/* Fixed to the viewport rather than a tall scrolling page: an on-screen
|
||||
keyboard then overlays the card instead of scrolling it away, and the
|
||||
card stays optically centred. min-height:100vh scrolled with the
|
||||
keyboard on mobile and left the card off-centre (reported 2026-08-05). */
|
||||
position:fixed; inset:0;
|
||||
display:grid; place-items:center; padding:1rem;
|
||||
height:100vh; height:100svh; }}
|
||||
/* Very short viewports (landscape phone, or a keyboard eating most of it):
|
||||
allow the card to scroll INSIDE the fixed frame rather than overflow. */
|
||||
@media (max-height:640px) {{
|
||||
body {{ align-items:start; overflow-y:auto; padding-top:3rem; }}
|
||||
}}
|
||||
/* Rotating backgrounds: each layer holds its image and cross-fades on a
|
||||
shared cycle, so the art moves the way /login does with no script. */
|
||||
.bg {{ position:fixed; inset:0; z-index:0; background-size:cover;
|
||||
@@ -726,7 +786,7 @@ button:active {{ transform:translateY(1px); }}
|
||||
/// password by an unexplained page.
|
||||
fn login_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Response<Body> {
|
||||
let body = format!(
|
||||
r#"<div class="logo"><img src="{prefix}asset/logo-archipelago.svg" alt="Archipelago"></div>
|
||||
r#"<div class="logo"><img src="{prefix}asset/favico-black-v2.svg" alt="Archipelago"></div>
|
||||
{icon}
|
||||
<h1>Sign in to open {name}</h1>
|
||||
<p class="sub">This app is protected by your node password.</p>
|
||||
@@ -778,6 +838,7 @@ mod tests {
|
||||
app_name: "Strfry Relay".to_string(),
|
||||
icon: None,
|
||||
declared: true,
|
||||
session_passthrough: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -863,7 +924,7 @@ mod tests {
|
||||
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);
|
||||
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
|
||||
let html = String::from_utf8_lossy(&body).to_string();
|
||||
assert!(html.contains(&format!("{GATE_PREFIX}asset/logo-archipelago.svg")));
|
||||
assert!(html.contains(&format!("{GATE_PREFIX}asset/favico-black-v2.svg")));
|
||||
for name in LOGIN_BACKGROUNDS {
|
||||
assert!(
|
||||
html.contains(&format!("{GATE_PREFIX}asset/{name}")),
|
||||
@@ -871,7 +932,10 @@ mod tests {
|
||||
);
|
||||
}
|
||||
// Every referenced asset must be one the gate will actually serve.
|
||||
assert!(read_ui_asset("logo-archipelago.svg").is_some() || cfg!(not(debug_assertions)));
|
||||
// The logo is the sidebar A mark (favico-black-v2.svg) since the
|
||||
// 2026-08-05 login-page rework — the old wordmark is off the
|
||||
// allowlist on purpose.
|
||||
assert!(read_ui_asset("favico-black-v2.svg").is_some() || cfg!(not(debug_assertions)));
|
||||
}
|
||||
|
||||
/// The allowlist is the whole security boundary for asset serving: the
|
||||
@@ -931,6 +995,44 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The gate must remove ONLY its own cookie pairs: an app's login cookie
|
||||
/// riding the same header has to survive, or every gated app with its
|
||||
/// own auth (vaultwarden, nextcloud, gitea) is logged out on each
|
||||
/// request — the 2026-08-05 companion-UI/"app logged me out" regression.
|
||||
#[test]
|
||||
fn strip_gate_cookies_keeps_app_cookies() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::COOKIE,
|
||||
"session=abc; vw_session=keepme; csrf_token=def; theme=dark"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
strip_gate_cookies(&mut headers);
|
||||
assert_eq!(
|
||||
headers.get(header::COOKIE).unwrap().to_str().unwrap(),
|
||||
"vw_session=keepme; theme=dark"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_gate_cookies_drops_header_when_only_gate_cookies() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::COOKIE,
|
||||
"session=abc; csrf_token=def".parse().unwrap(),
|
||||
);
|
||||
strip_gate_cookies(&mut headers);
|
||||
assert!(headers.get(header::COOKIE).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_gate_cookies_no_header_is_a_noop() {
|
||||
let mut headers = HeaderMap::new();
|
||||
strip_gate_cookies(&mut headers);
|
||||
assert!(headers.get(header::COOKIE).is_none());
|
||||
}
|
||||
|
||||
/// The load-bearing 2FA property: a session still awaiting its TOTP code
|
||||
/// fails `validate()`, so the gate rejects it without knowing anything
|
||||
/// about second factors.
|
||||
|
||||
@@ -104,6 +104,32 @@ fn dependency_manifests_required_by_active_apps<'a>(
|
||||
required
|
||||
}
|
||||
|
||||
/// Whether `app_id` is a member of a known multi-container stack that has at
|
||||
/// least one OTHER member with a live container (any state). A live sibling
|
||||
/// proves the stack is installed on this node, so an absent member is a hole
|
||||
/// to repair — while a stack with no containers at all stays untouched
|
||||
/// (uninstalled, or never installed here). Sibling app ids resolve to
|
||||
/// container names through the loaded-manifest map when available (immich's
|
||||
/// `immich-postgres` app id runs as container `immich_postgres`), falling
|
||||
/// back to the id itself.
|
||||
fn absent_stack_member_with_live_sibling(
|
||||
app_id: &str,
|
||||
present_containers: &HashSet<String>,
|
||||
container_name_by_app_id: &std::collections::HashMap<String, String>,
|
||||
) -> bool {
|
||||
let stack = crate::app_ops::owning_package(app_id);
|
||||
let members = crate::app_ops::stack_member_app_ids(stack);
|
||||
members.iter().any(|member| {
|
||||
*member != app_id
|
||||
&& present_containers.contains(
|
||||
container_name_by_app_id
|
||||
.get(*member)
|
||||
.map(String::as_str)
|
||||
.unwrap_or(member),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn manifest_dependency_app_ids(manifest: &AppManifest) -> Vec<String> {
|
||||
manifest
|
||||
.app
|
||||
@@ -246,10 +272,10 @@ fn build_fingerprint_stamp_path(data_dir: &Path, tag: &str) -> PathBuf {
|
||||
}
|
||||
|
||||
async fn chown_for_rootless_container(uid_gid: &str, path: &str) -> Result<()> {
|
||||
let uid = uid_gid
|
||||
let (uid, gid) = uid_gid
|
||||
.split_once(':')
|
||||
.and_then(|(uid, _)| uid.parse::<u32>().ok())
|
||||
.unwrap_or(0);
|
||||
.map(|(u, g)| (u.parse::<u32>().unwrap_or(0), g.parse::<u32>().unwrap_or(0)))
|
||||
.unwrap_or((0, 0));
|
||||
|
||||
if uid > 0 && uid < 100_000 {
|
||||
let output = tokio::process::Command::new("podman")
|
||||
@@ -262,9 +288,22 @@ async fn chown_for_rootless_container(uid_gid: &str, path: &str) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
let status = host_sudo(&["chown", "-R", uid_gid, path])
|
||||
// Host-side fallback. A CONTAINER-namespace id must be translated into
|
||||
// the subuid range first: `sudo chown 999` writes literal host uid 999,
|
||||
// which maps to nobody inside the userns — the app then can't open its
|
||||
// own files while the chown reported success (botfights SQLITE_CANTOPEN
|
||||
// crash-loop, framework-pt 2026-08-06). Container uid N (N>=1) lives at
|
||||
// subuid_base + N - 1; the fleet provisions base 100000. uid 0 and
|
||||
// already-mapped ids (>=100000) pass through untouched.
|
||||
let host_uid_gid = if uid > 0 && uid < 100_000 {
|
||||
let map = |id: u32| if id == 0 { 1000 } else { 100_000 + id - 1 };
|
||||
format!("{}:{}", map(uid), map(gid))
|
||||
} else {
|
||||
uid_gid.to_string()
|
||||
};
|
||||
let status = host_sudo(&["chown", "-R", &host_uid_gid, path])
|
||||
.await
|
||||
.with_context(|| format!("sudo chown -R {uid_gid} {path}"))?;
|
||||
.with_context(|| format!("sudo chown -R {host_uid_gid} {path}"))?;
|
||||
if status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1654,13 +1693,16 @@ impl ProdContainerOrchestrator {
|
||||
// app whose container vanished (e.g. a wedged teardown cleared by a
|
||||
// reboot) instead of leaving it down. See the immich .198 incident.
|
||||
let was_running = crate::crash_recovery::load_last_running_names(&self.data_dir).await;
|
||||
let manifests: Vec<LoadedManifest> = {
|
||||
let (manifests, container_name_by_app_id): (
|
||||
Vec<LoadedManifest>,
|
||||
std::collections::HashMap<String, String>,
|
||||
) = {
|
||||
let state = self.state.read().await;
|
||||
let dependency_required = dependency_manifests_required_by_active_apps(
|
||||
state.manifests.values().map(|lm| &lm.manifest),
|
||||
&user_stopped,
|
||||
);
|
||||
state
|
||||
let filtered = state
|
||||
.manifests
|
||||
.iter()
|
||||
.filter(|(app_id, _)| !state.disabled.contains(*app_id))
|
||||
@@ -1670,8 +1712,25 @@ impl ProdContainerOrchestrator {
|
||||
&& !user_stopped.contains(&compute_container_name(&lm.manifest)))
|
||||
})
|
||||
.map(|(_, lm)| lm.clone())
|
||||
.collect()
|
||||
.collect();
|
||||
// Unfiltered id→container-name map for the absent-stack-member
|
||||
// recovery below: a sibling may be excluded from this pass (e.g.
|
||||
// user-stopped) yet its live container still proves the stack is
|
||||
// installed.
|
||||
let names = state
|
||||
.manifests
|
||||
.iter()
|
||||
.map(|(id, lm)| (id.clone(), compute_container_name(&lm.manifest)))
|
||||
.collect();
|
||||
(filtered, names)
|
||||
};
|
||||
// Live container names (any state), for the same recovery check.
|
||||
let present_containers: std::collections::HashSet<String> = self
|
||||
.runtime
|
||||
.list_containers()
|
||||
.await
|
||||
.map(|cs| cs.into_iter().map(|c| c.name).collect())
|
||||
.unwrap_or_default();
|
||||
let mut report = ReconcileReport::default();
|
||||
let disk_gb = self.disk_gb().await;
|
||||
// Register every candidate before the (sequential, possibly slow)
|
||||
@@ -1738,7 +1797,20 @@ impl ProdContainerOrchestrator {
|
||||
Ok(ReconcileAction::Left(reason))
|
||||
if mode == ReconcileMode::ExistingOnly
|
||||
&& reason == "absent"
|
||||
&& was_running.contains(&compute_container_name(&lm.manifest)) =>
|
||||
&& (was_running.contains(&compute_container_name(&lm.manifest))
|
||||
// Absent STACK MEMBER whose siblings have live
|
||||
// containers: the stack is installed, so the
|
||||
// missing member is a hole, not a choice. The
|
||||
// was_running snapshot ages out after a few daemon
|
||||
// restarts, which left indeedhub-minio/-postgres
|
||||
// permanently absent on .38 (2026-08-06) — nginx
|
||||
// down on `host not found in upstream "minio"`
|
||||
// with nothing ever recreating the members.
|
||||
|| absent_stack_member_with_live_sibling(
|
||||
&app_id,
|
||||
&present_containers,
|
||||
&container_name_by_app_id,
|
||||
)) =>
|
||||
{
|
||||
tracing::warn!(
|
||||
app_id = %app_id,
|
||||
@@ -1754,7 +1826,10 @@ impl ProdContainerOrchestrator {
|
||||
}
|
||||
Ok(action) => report.record(&app_id, action),
|
||||
Err(e) => {
|
||||
tracing::error!(app_id = %app_id, error = %e, "reconcile failed");
|
||||
// `{:#}` prints the whole anyhow chain — `%e` alone showed
|
||||
// only the outer context ("create_container X") and hid
|
||||
// the actual libpod error for days.
|
||||
tracing::error!(app_id = %app_id, error = %format!("{e:#}"), "reconcile failed");
|
||||
report.failures.push((app_id, e.to_string()));
|
||||
}
|
||||
}
|
||||
@@ -4440,6 +4515,7 @@ mod tests {
|
||||
bind: String::new(),
|
||||
auth: None,
|
||||
auth_rationale: None,
|
||||
session_passthrough: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4447,6 +4523,61 @@ mod tests {
|
||||
items.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
/// The .38 indeedhub incident class: an absent stack member must be
|
||||
/// recovered when its siblings have live containers (the stack is
|
||||
/// installed), and left alone when the whole stack is gone or the app
|
||||
/// is not a stack member at all.
|
||||
#[test]
|
||||
fn absent_stack_member_recovery_requires_a_live_sibling() {
|
||||
let present: HashSet<String> = ["indeedhub-redis", "indeedhub-relay", "indeedhub"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
let names = std::collections::HashMap::new();
|
||||
// Missing members of a stack with live siblings → recover.
|
||||
assert!(absent_stack_member_with_live_sibling(
|
||||
"indeedhub-minio",
|
||||
&present,
|
||||
&names
|
||||
));
|
||||
assert!(absent_stack_member_with_live_sibling(
|
||||
"indeedhub-postgres",
|
||||
&present,
|
||||
&names
|
||||
));
|
||||
// Whole stack absent → NOT recovered (uninstalled stays uninstalled).
|
||||
let empty = HashSet::new();
|
||||
assert!(!absent_stack_member_with_live_sibling(
|
||||
"indeedhub-minio",
|
||||
&empty,
|
||||
&names
|
||||
));
|
||||
// Non-stack app → never.
|
||||
assert!(!absent_stack_member_with_live_sibling(
|
||||
"vaultwarden",
|
||||
&present,
|
||||
&names
|
||||
));
|
||||
// An app's OWN container being present proves nothing about siblings.
|
||||
let only_self: HashSet<String> = std::iter::once("indeedhub-minio".to_string()).collect();
|
||||
assert!(!absent_stack_member_with_live_sibling(
|
||||
"indeedhub-minio",
|
||||
&only_self,
|
||||
&names
|
||||
));
|
||||
// App-id → container-name mapping is honoured (immich_postgres runs
|
||||
// under an underscore name while its app id is hyphenated).
|
||||
let mut mapped = std::collections::HashMap::new();
|
||||
mapped.insert("immich-postgres".to_string(), "immich_postgres".to_string());
|
||||
let immich_present: HashSet<String> =
|
||||
std::iter::once("immich_postgres".to_string()).collect();
|
||||
assert!(absent_stack_member_with_live_sibling(
|
||||
"immich-redis",
|
||||
&immich_present,
|
||||
&mapped
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_drift_tolerates_quadlet_entrypoint_split() {
|
||||
// Quadlet writes Entrypoint=sh + Exec=-lc "<script>", so podman
|
||||
|
||||
@@ -599,6 +599,19 @@ pub struct PortMapping {
|
||||
/// means the author expected an exemption they did not get.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub auth_rationale: Option<String>,
|
||||
/// Forward the node session cookie to the app on authorised requests.
|
||||
///
|
||||
/// The gate normally strips its own credential before proxying — an app
|
||||
/// must never be in a position to log or replay the node session. The
|
||||
/// first-party companion UIs (lnd-ui, bitcoin-ui, electrs-ui, fips-ui)
|
||||
/// are the exception their design requires: their nginx forwards the
|
||||
/// browser's session cookie to the daemon's authenticated endpoints
|
||||
/// (`/proxy/lnd/*`, `/rpc/v1`, `/lnd-connect-info`), so stripping it
|
||||
/// breaks every data call behind the gate with a 401 while the page
|
||||
/// shell still renders (observed as "LND UI unreachable", 2026-08-05).
|
||||
/// Only meaningful on a `auth: gated` port.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub session_passthrough: bool,
|
||||
}
|
||||
|
||||
impl PortMapping {
|
||||
@@ -626,6 +639,7 @@ impl From<(u16, u16)> for PortMapping {
|
||||
bind: String::new(),
|
||||
auth: None,
|
||||
auth_rationale: None,
|
||||
session_passthrough: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1703,9 +1717,17 @@ app:
|
||||
}
|
||||
}
|
||||
exempt.sort();
|
||||
// 25 as of the v1.7.123 port-policy round: bitcoin p2p (8333 ×2),
|
||||
// core-lightning 9736/9835, electrumx 50001, fedimint 8173/8174,
|
||||
// fedimint-gateway 8176/9737, gitea ssh 2222, lightning-stack
|
||||
// 8091/9738/10010, lnd 9735/10009/18080, netbird 3478/8086/8087,
|
||||
// pine TLS 10381 + the three voice ports (10200/10300/10400 — the
|
||||
// disclosed known gap), router SSDP/mDNS 1900/5353. Every one is a
|
||||
// deliberate, rationale-carrying exemption; the release-gate test
|
||||
// stage timed out that cycle, so the count here lagged at 17.
|
||||
assert_eq!(
|
||||
exempt.len(),
|
||||
17,
|
||||
25,
|
||||
"unauthenticated port set changed — review before updating this count: {exempt:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -366,6 +366,7 @@ impl PodmanClient {
|
||||
}
|
||||
|
||||
let mut mounts = Vec::new();
|
||||
let mut named_volumes = Vec::new();
|
||||
for volume in &manifest.app.volumes {
|
||||
if volume.volume_type == "tmpfs" {
|
||||
let options: Vec<String> = volume
|
||||
@@ -382,6 +383,19 @@ impl PodmanClient {
|
||||
"type": "tmpfs",
|
||||
"options": options,
|
||||
}));
|
||||
} else if volume.volume_type == "volume" {
|
||||
// Named podman volume. The libpod create spec carries these in
|
||||
// the separate `volumes` field ({Name, Dest, Options}), NOT in
|
||||
// `mounts`: sending one as a bind mount makes the API treat
|
||||
// the bare volume name as a host path and the create fails —
|
||||
// which left indeedhub-postgres/-minio permanently absent on
|
||||
// legacy-path nodes (the reconciler removed the old container
|
||||
// for drift, then could never create its replacement).
|
||||
named_volumes.push(serde_json::json!({
|
||||
"Name": volume.source,
|
||||
"Dest": volume.target,
|
||||
"Options": volume.options,
|
||||
}));
|
||||
} else {
|
||||
mounts.push(serde_json::json!({
|
||||
"destination": volume.target,
|
||||
@@ -464,6 +478,7 @@ impl PodmanClient {
|
||||
"image": image_ref,
|
||||
"portmappings": port_mappings,
|
||||
"mounts": mounts,
|
||||
"volumes": named_volumes,
|
||||
"env": env_map,
|
||||
"secret_env": secret_env_map,
|
||||
"labels": labels_map,
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.124-alpha",
|
||||
"version": "1.7.125-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.124-alpha",
|
||||
"version": "1.7.125-alpha",
|
||||
"dependencies": {
|
||||
"@scure/bip39": "^2.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.7.124-alpha",
|
||||
"version": "1.7.125-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
<BaseModal :show="show" title="" max-width="max-w-lg" @close="emit('close')">
|
||||
<!-- Header: app icon + "Install Bitcoin Knots/Core" -->
|
||||
<div class="flex items-center gap-4 mb-5 -mt-2">
|
||||
<!-- object-contain, not the default fill: app icons are not all square
|
||||
(bitcoin-knots is not), so a fixed 56x56 box distorted or cropped the
|
||||
mark against the rounded corners. Contain plus a dark plate shows the
|
||||
whole icon whatever its aspect ratio. -->
|
||||
<img
|
||||
v-if="app?.icon"
|
||||
:src="app.icon"
|
||||
:alt="app?.title || ''"
|
||||
class="w-14 h-14 rounded-xl shadow-lg shrink-0"
|
||||
class="w-14 h-14 rounded-xl shadow-lg shrink-0 object-contain bg-black/40 p-1"
|
||||
/>
|
||||
<div v-else class="w-14 h-14 rounded-xl bg-white/10 flex items-center justify-center shrink-0">
|
||||
<svg class="w-7 h-7 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -107,7 +107,21 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{ close: []; received: []; scan: [] }>()
|
||||
|
||||
watch(() => props.show, (open) => {
|
||||
if (open && props.autoGenerate && receiveMethod.value === 'onchain' && !onchainAddress.value) {
|
||||
if (!open) return
|
||||
// Blank slate on every open: a leftover amount/memo/token or a previous
|
||||
// invoice quietly carrying into a new receive flow is exactly the stale-
|
||||
// state class the operator flagged on the send modal (2026-08-05).
|
||||
receiveMethod.value = 'onchain'
|
||||
invoiceAmount.value = 0
|
||||
invoiceMemo.value = ''
|
||||
invoiceResult.value = ''
|
||||
onchainAddress.value = ''
|
||||
arkAddress.value = ''
|
||||
ecashToken.value = ''
|
||||
ecashResult.value = ''
|
||||
error.value = ''
|
||||
processing.value = false
|
||||
if (props.autoGenerate && receiveMethod.value === 'onchain') {
|
||||
void receive()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -327,15 +327,57 @@ const isSweep = computed(() => sendMethod.value === 'onchain' && sendAll.value)
|
||||
|
||||
function toggleSendAll() {
|
||||
sendAll.value = !sendAll.value
|
||||
if (sendAll.value && onchainBalance.value === null) {
|
||||
rpcClient.call<{ balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 })
|
||||
.then((res) => { onchainBalance.value = res.balance_sats || 0 })
|
||||
.catch(() => { /* balance hint is best-effort */ })
|
||||
if (!sendAll.value) {
|
||||
// Disarming clears the field — a swept-balance figure left behind reads
|
||||
// as a typed amount.
|
||||
amount.value = 0
|
||||
return
|
||||
}
|
||||
// Arming shows the swept balance IN the (disabled) amount field — a field
|
||||
// stuck at 0 while "send all" is lit read as "sending nothing" (operator
|
||||
// feedback 2026-08-05). Refresh the figure on every arm.
|
||||
const applyBalance = () => {
|
||||
if (sendAll.value && onchainBalance.value !== null) amount.value = onchainBalance.value
|
||||
}
|
||||
applyBalance()
|
||||
rpcClient.call<{ balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 })
|
||||
.then((res) => { onchainBalance.value = res.balance_sats || 0; applyBalance() })
|
||||
.catch(() => { /* balance hint is best-effort */ })
|
||||
}
|
||||
|
||||
// Leaving the on-chain tab disarms the sweep so it can never apply elsewhere
|
||||
watch(sendMethod, (m) => { if (m !== 'onchain') sendAll.value = false })
|
||||
// (and drops the swept-balance figure it wrote into the amount field).
|
||||
watch(sendMethod, (m) => {
|
||||
if (m !== 'onchain' && sendAll.value) {
|
||||
sendAll.value = false
|
||||
amount.value = 0
|
||||
}
|
||||
})
|
||||
|
||||
// Every open starts from a blank slate. Stale state from the previous send —
|
||||
// destination, amount, and above all an armed "send all funds" toggle — is
|
||||
// dangerous to inherit invisibly (operator feedback 2026-08-05).
|
||||
watch(() => props.show, (shown) => {
|
||||
if (!shown) return
|
||||
sendMethod.value = 'lightning'
|
||||
amountUnit.value = 'sats'
|
||||
amountEntry.value = 0
|
||||
dest.value = ''
|
||||
error.value = ''
|
||||
successInfo.value = null
|
||||
ecashToken.value = ''
|
||||
sendAll.value = false
|
||||
onchainBalance.value = null
|
||||
feePreset.value = 'standard'
|
||||
customConfTarget.value = null
|
||||
customSatPerVbyte.value = null
|
||||
resolvedFeeParams.value = {}
|
||||
feeEstimate.value = null
|
||||
confirming.value = false
|
||||
confirmBalance.value = null
|
||||
invoiceAmountSats.value = null
|
||||
processing.value = false
|
||||
})
|
||||
|
||||
// --- On-chain network fee: presets map to LND confirmation targets; custom
|
||||
// --- takes a block target or an explicit sat/vB rate (rate wins).
|
||||
|
||||
@@ -51,10 +51,16 @@
|
||||
</svg>
|
||||
<p class="text-sm text-white/60 text-center">How do you want to read the QR?</p>
|
||||
<!-- hasNativeQr: on the companion (plain http, no getUserMedia)
|
||||
the native bridge still provides a live camera -->
|
||||
<button v-if="!liveCameraUnavailable || hasNativeQr" @click="chooseCamera" class="glass-button w-full px-4 py-2.5 rounded-lg text-sm font-medium">
|
||||
the native bridge still provides a live camera. On plain-http
|
||||
desktop the button stays VISIBLE — hiding it read as "the
|
||||
scanner is gone" (operator, 2026-08-05); choosing it surfaces
|
||||
the browser's HTTPS requirement with the fallbacks instead. -->
|
||||
<button @click="chooseCamera" class="glass-button w-full px-4 py-2.5 rounded-lg text-sm font-medium">
|
||||
Scan with camera
|
||||
</button>
|
||||
<p v-if="liveCameraUnavailable && !hasNativeQr" class="text-[11px] text-white/40 text-center -mt-1">
|
||||
Your browser only allows live camera on HTTPS pages — the photo and paste options below always work.
|
||||
</p>
|
||||
<button @click="photoInput?.click()" class="glass-button w-full px-4 py-2.5 rounded-lg text-sm font-medium">
|
||||
Upload / take a photo of the QR
|
||||
</button>
|
||||
|
||||
@@ -362,6 +362,20 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.7.125-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.125-alpha</span>
|
||||
<span class="text-xs text-white/40">August 6, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p><strong>The Lightning, Bitcoin, Electrum and mesh screens work again behind the login gate.</strong> Since the gate went up, those screens would load their frame and then show every number as unreachable. The gate was deliberately hiding your login from the apps it protects — right for third-party apps, wrong for the node's own screens, which need that login to fetch your data. The gate now removes only its own credential and the node's own screens explicitly receive yours. The same mistake was also quietly signing you out of apps with their own logins — Vaultwarden, Nextcloud, Gitea — on every single request; that stops too.</p>
|
||||
<p><strong>IndeeHub heals itself.</strong> Three separate faults: its database helper was recreated with permissions too tight to read its own files (it had crashed and restarted about ten thousand times on one node); on another node two of its seven parts could never be recreated at all because of how the node asked for their storage — the node would remove the old part and then fail to build its replacement, leaving the app half-missing forever; and a regenerated password could lock the app out of a database that keeps the original. All three are fixed, and the storage fault fixes the same trap for every future multi-part app.</p>
|
||||
<p><strong>Send and Receive open clean every time.</strong> Whatever you typed last — an address, an amount, and above all an armed "send all funds" toggle — no longer quietly carries over into the next payment. And choosing "send all funds" now shows the amount being swept instead of a confusing 0.</p>
|
||||
<p><strong>The camera scanner option no longer vanishes on desktop.</strong> Browsers only allow the live camera on secure (HTTPS) pages, and the scan window used to silently hide the camera choice on plain connections, which read as "the scanner is gone". The option now stays visible and explains itself, and the photo and paste routes always work. The companion app's built-in scanner is untouched.</p>
|
||||
<p>Also: the app login page uses the Archipelago mark and stays centred on phones with the keyboard open, app icons in the install window are no longer cropped, and when the node fails to build a container it now records the actual reason instead of a one-line stub that hid the cause of the IndeeHub fault for days.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.124-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
+23
-23
@@ -1,35 +1,35 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**The most important fix in this release: some nodes were left switched off by their own update, and could not switch themselves back on.** The node replaces its program and then exits, expecting the system to start it again — but nodes installed from older images carried a setting that only restarts the program if it *crashes*. A clean, deliberate exit looked like success, so nothing restarted it, and the node sat dead showing \"server starting\" with nothing able to start it. One of ours was down for over two hours this way, and three of four checked had the same setting waiting to bite. Your node now repairs that setting itself the first time it starts, so it survives every future update.",
|
||||
"**Portainer opens again.** Its screen reported the app as not responding because the app was quietly refusing to start: nodes have been running Portainer 2.39.1, their stored data was written by that version, and the app list pinned a version from two years earlier — so when the container was rebuilt it landed on the old one, which will not read newer data. The correct version is now pinned, older installs upgrade cleanly, and no data was touched.",
|
||||
"**Bitcoin starts reliably again.** A leftover settings file in the Bitcoin folder — one the node itself kept rewriting and Bitcoin no longer reads — is treated as fatal by Bitcoin, so affected nodes restarted every few seconds forever. The node no longer writes that file, removes stale copies, and treats any that remain as harmless.",
|
||||
"**Every app screen opens from My Apps again.** The login gate refused to be displayed inside another page at all, which is exactly how My Apps opens an app, so protected apps appeared broken. It now allows only your own node to display it, and refuses everyone else — a distinction the old setting could not express.",
|
||||
"**The app login screen now looks like the node's own.** Same rotating artwork, the same panel, the Archipelago mark, and the app's real icon shown as a tile the way My Apps shows it, instead of a plain box with a letter.",
|
||||
"**The Mesh screen uses wide displays properly.** On very large screens it stacked all five panels on top of each other, clipping three of the headings to a sliver and squeezing the map into a letterbox — more screen producing a worse view. It now shows one panel at a time, filling the space, with the map running edge to edge.",
|
||||
"**You can choose how long you stay signed in.** Settings → Account now offers an inactivity timeout and a hard limit, plus an option to re-enter your password before sending funds. TV and kiosk screens are never signed out for sitting idle, because there is nobody there to sign them back in.",
|
||||
"Updates now come from `source.archipelago-foundation.org` rather than a bare address, with the old one kept as an automatic fallback for nodes whose clock or name lookup is off. Also included: clearer wallet errors from ecash mints, and mesh peers reconnecting via their last known address before falling back to the wider network.",
|
||||
"Known gaps, disclosed rather than buried: three voice-assistant ports remain 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 — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release."
|
||||
"**The Lightning, Bitcoin, Electrum and mesh screens work again behind the login gate.** Since the gate went up, those screens loaded their frame and then showed every number as unreachable. The gate was deliberately hiding your login from the apps it protects — right for third-party apps, wrong for the node's own screens, which need that login to fetch your data. The gate now removes only its own credential, and the node's own screens explicitly receive yours. The same mistake was also quietly signing you out of apps with their own logins — Vaultwarden, Nextcloud, Gitea — on every single request; that stops too.",
|
||||
"**IndeeHub heals itself.** Three separate faults fixed: its database helper was recreated with permissions too tight to read its own files (it had crashed and restarted roughly ten thousand times on one node); on another node two of its seven parts could never be recreated at all because of how the node asked for their storage — it would remove the old part and then fail to build its replacement, leaving the app half-missing forever; and a regenerated password could lock the app out of a database that keeps the original. The storage fault fixes the same trap for every future multi-part app.",
|
||||
"**A missing piece of a running app now gets put back automatically.** If one container of a multi-part app disappears while its siblings are still running, the node treats that as a hole to repair rather than a choice to respect, and rebuilds the missing piece. An app you actually uninstalled stays uninstalled.",
|
||||
"**Send and Receive open clean every time.** Whatever you typed last — an address, an amount, and above all an armed \"send all funds\" toggle — no longer quietly carries over into the next payment. Choosing \"send all funds\" also shows the amount being swept instead of a confusing 0.",
|
||||
"**A sweep that cannot happen now says why.** Trying to sweep a balance that is below Bitcoin's dust minimum (about 546 sats) or not yet confirmed used to fail with \"check server logs\"; it now explains that no transaction can be built from those coins.",
|
||||
"**The camera scanner option no longer vanishes on desktop.** Browsers only allow the live camera on secure (HTTPS) pages, and the scan window silently hid the camera choice on plain connections — which read as \"the scanner is gone\". The option now stays visible and explains itself, and the photo and paste routes always work. The companion app's built-in scanner is untouched.",
|
||||
"**App data folders can no longer be \"repaired\" into a state the app cannot use.** When the node fixed a folder's ownership through its fallback path, it wrote the container's raw user number instead of the translated one, so the fix reported success while the app still could not open its own files — one node's BotFights restarted every ten seconds over exactly this. The translation is now applied.",
|
||||
"Also: the app login page uses the Archipelago mark and stays centred on phones with the keyboard open, app icons in the install window are no longer cropped, and when the node fails to build a container it now records the actual reason instead of a one-line stub that hid the cause of the IndeeHub fault for days.",
|
||||
"Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — 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.124-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.124-alpha/archipelago",
|
||||
"current_version": "1.7.125-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.125-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.7.124-alpha",
|
||||
"sha256": "bc91e7d09083dad17934dc2bad211b4eb1d22fd799e032a94fa73df025ba272d",
|
||||
"size_bytes": 54701560
|
||||
"new_version": "1.7.125-alpha",
|
||||
"sha256": "080bc83cb10b3ebe532497917d96b7af3766c36c30119cb0f348de36162d9e9d",
|
||||
"size_bytes": 55060216
|
||||
},
|
||||
{
|
||||
"current_version": "1.7.124-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.124-alpha/archipelago-frontend-1.7.124-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.124-alpha.tar.gz",
|
||||
"new_version": "1.7.124-alpha",
|
||||
"sha256": "346ed6472fb647bcf45299e4d93b9f555142bf1229912030fbafe9966fd410a7",
|
||||
"size_bytes": 210531432
|
||||
"current_version": "1.7.125-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.125-alpha/archipelago-frontend-1.7.125-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.125-alpha.tar.gz",
|
||||
"new_version": "1.7.125-alpha",
|
||||
"sha256": "0bff6f169767043928d9189ab53045c9e4f7c523697b42bf770b2b485c2902f1",
|
||||
"size_bytes": 210532813
|
||||
}
|
||||
],
|
||||
"release_date": "2026-08-05",
|
||||
"signature": "40f5ffca026addd00c4aac933edf8faa05a60d9b981341bc0bfaae078812a65ad6656886b4a9d533476759dbb511fb4b050e518cd0060568f7b33035069e600e",
|
||||
"release_date": "2026-08-06",
|
||||
"signature": "975157cc59527679f3de846a4a93d11e27f6ad48eed215fd769574dfc6db36a687867a35aeb6e705d41667ea800b948f53ce7f80bfcdb5a6e1820e50613d4403",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.7.124-alpha"
|
||||
"version": "1.7.125-alpha"
|
||||
}
|
||||
|
||||
+14
-15
@@ -421,7 +421,7 @@
|
||||
},
|
||||
"container": {
|
||||
"custom_args": [
|
||||
"BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; # A stray bitcoin.conf in the datadir is FATAL when -conf points # elsewhere: bitcoind refuses to start with \"contains a bitcoin.conf # file which is ignored\", and the app crash-loops (100.82.34.38, # 2026-08-05 — Exited(1) every few seconds). Our -conf carries the # RPC credentials and the flags below are the authoritative config, # so the datadir file is legacy debris; say so out loud rather than # failing, and let bitcoind start. if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then\n echo \"archipelago: ignoring legacy /home/bitcoin/.bitcoin/bitcoin.conf; RPC config comes from $RPC_CONF and the flags below\" >&2;\nfi; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi"
|
||||
"BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then\n echo \"archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF\" >&2;\nfi; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi"
|
||||
],
|
||||
"data_uid": "100101:100101",
|
||||
"derived_env": [
|
||||
@@ -567,7 +567,7 @@
|
||||
},
|
||||
"container": {
|
||||
"custom_args": [
|
||||
"BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; # A stray bitcoin.conf in the datadir is FATAL when -conf points # elsewhere: bitcoind refuses to start with \"contains a bitcoin.conf # file which is ignored\", and the app crash-loops (100.82.34.38, # 2026-08-05 — Exited(1) every few seconds). Our -conf carries the # RPC credentials and the flags below are the authoritative config, # so the datadir file is legacy debris; say so out loud rather than # failing, and let bitcoind start. if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then\n echo \"archipelago: ignoring legacy /home/bitcoin/.bitcoin/bitcoin.conf; RPC config comes from $RPC_CONF and the flags below\" >&2;\nfi; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi"
|
||||
"BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then\n echo \"archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF\" >&2;\nfi; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi"
|
||||
],
|
||||
"data_uid": "100101:100101",
|
||||
"derived_env": [
|
||||
@@ -717,7 +717,8 @@
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8334,
|
||||
"host": 8334,
|
||||
"protocol": "tcp"
|
||||
"protocol": "tcp",
|
||||
"session_passthrough": true
|
||||
}
|
||||
],
|
||||
"resources": {
|
||||
@@ -1175,7 +1176,8 @@
|
||||
"bind": "127.0.0.1",
|
||||
"container": 50002,
|
||||
"host": 50002,
|
||||
"protocol": "tcp"
|
||||
"protocol": "tcp",
|
||||
"session_passthrough": true
|
||||
}
|
||||
],
|
||||
"resources": {
|
||||
@@ -1386,13 +1388,6 @@
|
||||
"host": 8174,
|
||||
"protocol": "tcp"
|
||||
},
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8175,
|
||||
"host": 8175,
|
||||
"protocol": "tcp"
|
||||
},
|
||||
{
|
||||
"auth": "local",
|
||||
"bind": "127.0.0.1",
|
||||
@@ -1739,7 +1734,8 @@
|
||||
"bind": "127.0.0.1",
|
||||
"container": 8336,
|
||||
"host": 8336,
|
||||
"protocol": "tcp"
|
||||
"protocol": "tcp",
|
||||
"session_passthrough": true
|
||||
}
|
||||
],
|
||||
"resources": {
|
||||
@@ -2729,6 +2725,8 @@
|
||||
},
|
||||
"security": {
|
||||
"capabilities": [
|
||||
"CHOWN",
|
||||
"DAC_OVERRIDE",
|
||||
"SETGID",
|
||||
"SETUID"
|
||||
],
|
||||
@@ -3127,7 +3125,8 @@
|
||||
"bind": "127.0.0.1",
|
||||
"container": 18083,
|
||||
"host": 18083,
|
||||
"protocol": "tcp"
|
||||
"protocol": "tcp",
|
||||
"session_passthrough": true
|
||||
}
|
||||
],
|
||||
"resources": {
|
||||
@@ -4898,7 +4897,7 @@
|
||||
}
|
||||
},
|
||||
"schema": 1,
|
||||
"signature": "b10aa65a9a6e91a6d421deff6e578c080c6bafa0185d54412df72b9814a694e9c9ccd803f7e8baa6d0ae93c1b71b3e20ed1626769445cc2f484c4a8bbd1b9901",
|
||||
"signature": "a9a0bf60aa6c47ae970a6c7c3e19e9390ee7af157f425c2e38b1bbb194f0315b73ddf16bc46244b36390454787a1ddecc559be806098fdf569d3315f388b9006",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"updated": "2026-08-05"
|
||||
"updated": "2026-08-06"
|
||||
}
|
||||
|
||||
+23
-23
@@ -1,35 +1,35 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**The most important fix in this release: some nodes were left switched off by their own update, and could not switch themselves back on.** The node replaces its program and then exits, expecting the system to start it again — but nodes installed from older images carried a setting that only restarts the program if it *crashes*. A clean, deliberate exit looked like success, so nothing restarted it, and the node sat dead showing \"server starting\" with nothing able to start it. One of ours was down for over two hours this way, and three of four checked had the same setting waiting to bite. Your node now repairs that setting itself the first time it starts, so it survives every future update.",
|
||||
"**Portainer opens again.** Its screen reported the app as not responding because the app was quietly refusing to start: nodes have been running Portainer 2.39.1, their stored data was written by that version, and the app list pinned a version from two years earlier — so when the container was rebuilt it landed on the old one, which will not read newer data. The correct version is now pinned, older installs upgrade cleanly, and no data was touched.",
|
||||
"**Bitcoin starts reliably again.** A leftover settings file in the Bitcoin folder — one the node itself kept rewriting and Bitcoin no longer reads — is treated as fatal by Bitcoin, so affected nodes restarted every few seconds forever. The node no longer writes that file, removes stale copies, and treats any that remain as harmless.",
|
||||
"**Every app screen opens from My Apps again.** The login gate refused to be displayed inside another page at all, which is exactly how My Apps opens an app, so protected apps appeared broken. It now allows only your own node to display it, and refuses everyone else — a distinction the old setting could not express.",
|
||||
"**The app login screen now looks like the node's own.** Same rotating artwork, the same panel, the Archipelago mark, and the app's real icon shown as a tile the way My Apps shows it, instead of a plain box with a letter.",
|
||||
"**The Mesh screen uses wide displays properly.** On very large screens it stacked all five panels on top of each other, clipping three of the headings to a sliver and squeezing the map into a letterbox — more screen producing a worse view. It now shows one panel at a time, filling the space, with the map running edge to edge.",
|
||||
"**You can choose how long you stay signed in.** Settings → Account now offers an inactivity timeout and a hard limit, plus an option to re-enter your password before sending funds. TV and kiosk screens are never signed out for sitting idle, because there is nobody there to sign them back in.",
|
||||
"Updates now come from `source.archipelago-foundation.org` rather than a bare address, with the old one kept as an automatic fallback for nodes whose clock or name lookup is off. Also included: clearer wallet errors from ecash mints, and mesh peers reconnecting via their last known address before falling back to the wider network.",
|
||||
"Known gaps, disclosed rather than buried: three voice-assistant ports remain 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 — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release."
|
||||
"**The Lightning, Bitcoin, Electrum and mesh screens work again behind the login gate.** Since the gate went up, those screens loaded their frame and then showed every number as unreachable. The gate was deliberately hiding your login from the apps it protects — right for third-party apps, wrong for the node's own screens, which need that login to fetch your data. The gate now removes only its own credential, and the node's own screens explicitly receive yours. The same mistake was also quietly signing you out of apps with their own logins — Vaultwarden, Nextcloud, Gitea — on every single request; that stops too.",
|
||||
"**IndeeHub heals itself.** Three separate faults fixed: its database helper was recreated with permissions too tight to read its own files (it had crashed and restarted roughly ten thousand times on one node); on another node two of its seven parts could never be recreated at all because of how the node asked for their storage — it would remove the old part and then fail to build its replacement, leaving the app half-missing forever; and a regenerated password could lock the app out of a database that keeps the original. The storage fault fixes the same trap for every future multi-part app.",
|
||||
"**A missing piece of a running app now gets put back automatically.** If one container of a multi-part app disappears while its siblings are still running, the node treats that as a hole to repair rather than a choice to respect, and rebuilds the missing piece. An app you actually uninstalled stays uninstalled.",
|
||||
"**Send and Receive open clean every time.** Whatever you typed last — an address, an amount, and above all an armed \"send all funds\" toggle — no longer quietly carries over into the next payment. Choosing \"send all funds\" also shows the amount being swept instead of a confusing 0.",
|
||||
"**A sweep that cannot happen now says why.** Trying to sweep a balance that is below Bitcoin's dust minimum (about 546 sats) or not yet confirmed used to fail with \"check server logs\"; it now explains that no transaction can be built from those coins.",
|
||||
"**The camera scanner option no longer vanishes on desktop.** Browsers only allow the live camera on secure (HTTPS) pages, and the scan window silently hid the camera choice on plain connections — which read as \"the scanner is gone\". The option now stays visible and explains itself, and the photo and paste routes always work. The companion app's built-in scanner is untouched.",
|
||||
"**App data folders can no longer be \"repaired\" into a state the app cannot use.** When the node fixed a folder's ownership through its fallback path, it wrote the container's raw user number instead of the translated one, so the fix reported success while the app still could not open its own files — one node's BotFights restarted every ten seconds over exactly this. The translation is now applied.",
|
||||
"Also: the app login page uses the Archipelago mark and stays centred on phones with the keyboard open, app icons in the install window are no longer cropped, and when the node fails to build a container it now records the actual reason instead of a one-line stub that hid the cause of the IndeeHub fault for days.",
|
||||
"Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — 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.124-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.124-alpha/archipelago",
|
||||
"current_version": "1.7.125-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.125-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.7.124-alpha",
|
||||
"sha256": "bc91e7d09083dad17934dc2bad211b4eb1d22fd799e032a94fa73df025ba272d",
|
||||
"size_bytes": 54701560
|
||||
"new_version": "1.7.125-alpha",
|
||||
"sha256": "080bc83cb10b3ebe532497917d96b7af3766c36c30119cb0f348de36162d9e9d",
|
||||
"size_bytes": 55060216
|
||||
},
|
||||
{
|
||||
"current_version": "1.7.124-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.124-alpha/archipelago-frontend-1.7.124-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.124-alpha.tar.gz",
|
||||
"new_version": "1.7.124-alpha",
|
||||
"sha256": "346ed6472fb647bcf45299e4d93b9f555142bf1229912030fbafe9966fd410a7",
|
||||
"size_bytes": 210531432
|
||||
"current_version": "1.7.125-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.125-alpha/archipelago-frontend-1.7.125-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.125-alpha.tar.gz",
|
||||
"new_version": "1.7.125-alpha",
|
||||
"sha256": "0bff6f169767043928d9189ab53045c9e4f7c523697b42bf770b2b485c2902f1",
|
||||
"size_bytes": 210532813
|
||||
}
|
||||
],
|
||||
"release_date": "2026-08-05",
|
||||
"signature": "40f5ffca026addd00c4aac933edf8faa05a60d9b981341bc0bfaae078812a65ad6656886b4a9d533476759dbb511fb4b050e518cd0060568f7b33035069e600e",
|
||||
"release_date": "2026-08-06",
|
||||
"signature": "975157cc59527679f3de846a4a93d11e27f6ad48eed215fd769574dfc6db36a687867a35aeb6e705d41667ea800b948f53ce7f80bfcdb5a6e1820e50613d4403",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.7.124-alpha"
|
||||
"version": "1.7.125-alpha"
|
||||
}
|
||||
|
||||
Executable
+86
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Syntax-check the shell embedded in apps/*/manifest.yml.
|
||||
|
||||
A manifest can carry a whole startup script in `container.custom_args` /
|
||||
`entrypoint`. Nothing validated it, so a broken one shipped through the
|
||||
signed catalog and only failed on the node — as a container that exits
|
||||
instantly and an app that vanishes from the UI.
|
||||
|
||||
Two checks, both learned from v1.7.124 (bitcoin-knots / bitcoin-core):
|
||||
|
||||
1. `sh -n` the snippet. The break was `sh: Syntax error: "fi" unexpected`,
|
||||
which no YAML parse and no Rust test could have caught.
|
||||
|
||||
2. Reject `#` inside the snippet. These are YAML **folded** scalars (`>-`),
|
||||
where `#` is NOT a comment — it is literal text that reaches the shell,
|
||||
and because folding joins lines with spaces it comments out the rest of
|
||||
the folded line. That is exactly how an `if ... then` was swallowed while
|
||||
its more-indented body survived, leaving an orphan `fi`. Put explanations
|
||||
above the `- >-` line, where YAML really does treat them as comments.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import yaml
|
||||
|
||||
# Long enough to be a script rather than a flag.
|
||||
MIN_SCRIPT_LEN = 60
|
||||
|
||||
|
||||
def snippets(path: str):
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
container = ((data or {}).get("app") or {}).get("container") or {}
|
||||
for key in ("custom_args", "entrypoint"):
|
||||
value = container.get(key)
|
||||
if not isinstance(value, list):
|
||||
continue
|
||||
for i, part in enumerate(value):
|
||||
if isinstance(part, str) and len(part) >= MIN_SCRIPT_LEN:
|
||||
yield f"{key}[{i}]", part
|
||||
|
||||
|
||||
def main() -> int:
|
||||
failures = []
|
||||
checked = 0
|
||||
for path in sorted(glob.glob("apps/*/manifest.yml")):
|
||||
app = os.path.basename(os.path.dirname(path))
|
||||
try:
|
||||
found = list(snippets(path))
|
||||
except Exception as exc: # noqa: BLE001 — report, don't crash the gate
|
||||
failures.append(f"{app}: manifest does not parse: {exc}")
|
||||
continue
|
||||
for where, script in found:
|
||||
checked += 1
|
||||
if "#" in script:
|
||||
failures.append(
|
||||
f"{app} {where}: contains '#'. In a folded YAML scalar that is not a "
|
||||
f"comment — it reaches the shell and comments out the rest of the "
|
||||
f"folded line. Move the explanation above the '- >-' line."
|
||||
)
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".sh", delete=False) as tmp:
|
||||
tmp.write(script)
|
||||
tmp_path = tmp.name
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["sh", "-n", tmp_path], capture_output=True, text=True, check=False
|
||||
)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
if proc.returncode != 0:
|
||||
failures.append(f"{app} {where}: {proc.stderr.strip()}")
|
||||
|
||||
for f in failures:
|
||||
print(f"MANIFEST-SHELL {f}", file=sys.stderr)
|
||||
print(f'{{"snippets_checked": {checked}, "failures": {len(failures)}}}')
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -62,6 +62,7 @@ summary() {
|
||||
# ── Stage 1: static ──────────────────────────────────────────────────
|
||||
stage "git-diff-check" git diff --check
|
||||
stage "cargo-fmt" timeout 240 cargo fmt --manifest-path core/Cargo.toml --all --check
|
||||
stage "manifest-shell" python3 scripts/check-manifest-shell.py
|
||||
stage "catalog-drift" python3 scripts/check-app-catalog-drift.py --release --strict
|
||||
# Every release must surface its CHANGELOG entry in the Settings "What's New"
|
||||
# modal. The modal hardcodes a block per version and has drifted behind before
|
||||
|
||||
Reference in New Issue
Block a user