Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8748ee7ba | ||
|
|
8469af5f4e | ||
|
|
92cffe9d46 | ||
|
|
d5ef4ef76e | ||
|
|
6110a7a9a7 | ||
|
|
c972419840 | ||
|
|
d0d9c032de | ||
|
|
ada59acdd5 | ||
|
|
4ace62fad9 | ||
|
|
e7592dc9c9 | ||
|
|
188411b79c | ||
|
|
e88c51d80b |
@@ -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: []
|
||||
|
||||
|
||||
@@ -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,15 @@ 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 +293,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 +1698,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 +1717,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 +1802,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 +1831,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 +4520,7 @@ mod tests {
|
||||
bind: String::new(),
|
||||
auth: None,
|
||||
auth_rationale: None,
|
||||
session_passthrough: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4447,6 +4528,62 @@ 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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
+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"
|
||||
}
|
||||
|
||||
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