fix: portainer pin, bitcoin conf tolerance, gate login UI, named OTA origin

Portainer: nodes have been running :latest — which is 2.39.1 — while the
manifest pinned 2.19.4 from two years ago. The port migration recreated the
container onto that old pin and Portainer refused to start: it migrates a
database forward, never backward, so an existing install died with 'schema
version does not align' and My Apps showed 'app is not responding'
(100.82.34.38). 2.39.1 published as an immutable tag and pinned forward, so
existing databases keep working and older ones migrate up.

Bitcoin: complements PR #131. That removes the code which kept writing a
datadir bitcoin.conf; -allowignoredconf=1 additionally makes an existing
one non-fatal, so a node already carrying the file recovers on restart
instead of crash-looping until something reinstalls it.

App gate login: rebuilt against the dashboard's own design — rotating
intro backgrounds served from the gate, the glass panel, the Archipelago
mark in its gradient ring, the app's icon as a My Apps tile, and the glass
button. Crucially it no longer sends X-Frame-Options: DENY, which made
every gated app render as unreachable inside My Apps' embedded frame;
frame-ancestors expresses 'only this node may frame me', which
X-Frame-Options cannot.

OTA origin: primary mirror is now source.archipelago-foundation.org over
TLS instead of a bare IP on plaintext. The IP stays as an automatic
fallback for nodes whose DNS or clock is broken — both break TLS, and the
signature, not the transport, is what establishes trust.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-05 12:44:57 -04:00
co-authored by Claude Fable 5
parent 08a725ba12
commit 91bbe4faa1
6 changed files with 376 additions and 68 deletions
+12 -2
View File
@@ -38,6 +38,16 @@ 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;
fi;
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
DISK_GB_VALUE="$(printenv DISK_GB || true)";
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
@@ -46,9 +56,9 @@ app:
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";
fi;
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -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;
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;
else
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -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;
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;
fi
derived_env:
- key: DISK_GB
+12 -2
View File
@@ -38,6 +38,16 @@ 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;
fi;
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
DISK_GB_VALUE="$(printenv DISK_GB || true)";
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
@@ -46,9 +56,9 @@ app:
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";
fi;
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -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;
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;
else
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -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;
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;
fi
derived_env:
- key: DISK_GB
+1 -1
View File
@@ -6,7 +6,7 @@ app:
category: development
container:
image: 146.59.87.168:3000/lfg2025/portainer:2.19.4
image: 146.59.87.168:3000/lfg2025/portainer:2.39.1
pull_policy: if-not-present
data_uid: "1000:1000"
+298 -49
View File
@@ -154,6 +154,11 @@ impl AppGate {
action: &str,
client_ip: IpAddr,
) -> Response<Body> {
// Assets are GET and pre-auth by nature: the login page cannot
// render its own background or logo without them.
if let Some(name) = action.strip_prefix("asset/") {
return self.serve_asset(name);
}
if req.method() != Method::POST {
return login_page(app, None, StatusCode::OK);
}
@@ -187,6 +192,26 @@ impl AppGate {
}
}
/// Static assets the login page needs, served from the gate's own origin.
///
/// The backgrounds are ~1 MB each, so inlining them as data URIs would
/// bloat every challenge response. Serving them here keeps the page
/// byte-identical to the dashboard's login while the CSP stays tight:
/// `img-src 'self' data:` and nothing else.
fn serve_asset(&self, name: &str) -> Response<Body> {
let Some((bytes, mime)) = read_ui_asset(name) else {
return not_found();
};
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime)
// Immutable art; caching it costs nothing and keeps the login
// instant on a repeat challenge.
.header(header::CACHE_CONTROL, "public, max-age=86400")
.body(Body::from(bytes))
.expect("asset response builds")
}
async fn do_login(&self, app: &GatedPort, form: &Form, client_ip: IpAddr) -> Response<Body> {
let password = field(form, "password").unwrap_or_default();
@@ -428,43 +453,158 @@ fn esc(s: &str) -> String {
/// none. Inlined as a data URI rather than linked: the gate is answering on
/// the app's own port, so any asset URL would either hit the unauthenticated
/// app behind it or a different origin the browser may not reach.
/// One stacked layer per background, each delayed so they cross-fade in turn.
fn background_layers() -> String {
let step = LOGIN_BACKGROUNDS.len() as u32 * 9 / LOGIN_BACKGROUNDS.len() as u32;
LOGIN_BACKGROUNDS
.iter()
.enumerate()
.map(|(i, name)| {
format!(
r#"<div class="bg" style="background-image:url('{prefix}asset/{name}');animation-delay:{delay}s"></div>"#,
prefix = GATE_PREFIX,
delay = i as u32 * step,
)
})
.collect()
}
fn icon_markup(app: &GatedPort) -> String {
if let Some(path) = &app.icon {
if let Some(data_uri) = read_icon_data_uri(path) {
return format!(r#"<img class="icon" src="{}" alt="">"#, esc(&data_uri));
}
}
let letter = app
.app_name
.chars()
.next()
.map(|c| c.to_uppercase().to_string())
.unwrap_or_else(|| "?".to_string());
format!(r#"<div class="icon lettermark">{}</div>"#, esc(&letter))
let inner = app
.icon
.as_deref()
.and_then(read_icon_data_uri)
// A manifest that names no icon still gets one: the dashboard already
// ships icons named after the app, so fall back to those before
// giving up. Without this EVERY gated app showed a lettermark,
// because no manifest declares metadata.icon (archi-dev-box,
// 2026-08-05).
.or_else(|| {
icon_candidates(&app.app_id)
.iter()
.find_map(|c| read_icon_data_uri(c))
})
.map(|data_uri| format!(r#"<img class="icon" src="{}" alt="">"#, esc(&data_uri)))
.unwrap_or_else(|| {
let letter = app
.app_name
.chars()
.find(|c| c.is_alphanumeric())
.map(|c| c.to_uppercase().to_string())
.unwrap_or_else(|| "?".to_string());
format!(r#"<div class="icon lettermark">{}</div>"#, esc(&letter))
});
format!(r#"<div class="tile">{inner}</div>"#)
}
/// Icons live with the web UI. Only files under the icon directory are read,
/// and only known image extensions — the path comes from a manifest, which is
/// signed, but treating it as untrusted costs nothing.
fn read_icon_data_uri(icon_path: &str) -> Option<String> {
let name = std::path::Path::new(icon_path).file_name()?.to_str()?;
let mime = match name.rsplit_once('.')?.1.to_ascii_lowercase().as_str() {
"svg" => "image/svg+xml",
"png" => "image/png",
"webp" => "image/webp",
"jpg" | "jpeg" => "image/jpeg",
_ => return None,
/// Icon basenames to try for an app id, best first.
///
/// The shipped icon set is named for the *product*, while app ids carry
/// packaging detail — `filebrowser` vs `file-browser`, `morphos-server` vs
/// `morphos` — and the per-app screens (`lnd-ui`, `bitcoin-ui`, `electrs-ui`)
/// have no icon of their own but obviously belong to the app they front.
/// Resolving those here keeps the mapping in one readable place instead of
/// adding a `metadata.icon` line to every manifest, which would have to be
/// re-signed into the catalog to take effect.
fn icon_candidates(app_id: &str) -> Vec<String> {
let mut out = vec![app_id.to_string()];
let alias = match app_id {
"filebrowser" => Some("file-browser"),
"home-assistant" => Some("homeassistant"),
"morphos-server" => Some("morphos"),
"barkd" => Some("bark"),
"archy-mempool-web" | "mempool-api" => Some("mempool"),
"lnd-ui" | "lightning-stack" => Some("lnd"),
"bitcoin-ui" => Some("bitcoin-core"),
"electrs-ui" => Some("electrumx"),
"fips-ui" | "aiui" | "did-wallet" => Some("archipelago-a"),
"fedimint-gateway" | "fedimint-clientd" => Some("fedimint"),
_ => None,
};
out.extend(alias.map(str::to_string));
// `<app>-ui` / `-server` / `-web` front an app whose icon is the bare name.
for suffix in ["-ui", "-server", "-web"] {
if let Some(base) = app_id.strip_suffix(suffix) {
out.push(base.to_string());
}
}
out
}
/// Backgrounds the login cycles through, matching the dashboard's own
/// `/login` art. Cross-faded by CSS alone — the CSP forbids script, and a
/// rotation that needs JavaScript would not survive it.
const LOGIN_BACKGROUNDS: [&str; 4] = [
"bg-intro.jpg",
"bg-intro-4.webp",
"bg-intro-6.webp",
"bg-intro-3.jpg",
];
/// Assets the gate will serve, by exact name. An allowlist rather than a path
/// 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";
if !allowed {
return None;
}
let mime = icon_mime(name.rsplit_once('.')?.1)?;
for root in [
"/opt/archipelago/web-ui/assets/img/app-icons",
"web/dist/neode-ui/assets/img/app-icons",
"/opt/archipelago/web-ui/assets/img",
"web/dist/neode-ui/assets/img",
"neode-ui/public/assets/img",
] {
let candidate = std::path::Path::new(root).join(name);
if let Ok(bytes) = std::fs::read(&candidate) {
if bytes.len() > 512 * 1024 {
return None;
if let Ok(bytes) = std::fs::read(std::path::Path::new(root).join(name)) {
return Some((bytes, mime));
}
}
None
}
const ICON_ROOTS: [&str; 2] = [
"/opt/archipelago/web-ui/assets/img/app-icons",
"web/dist/neode-ui/assets/img/app-icons",
];
fn icon_mime(ext: &str) -> Option<&'static str> {
match ext.to_ascii_lowercase().as_str() {
"svg" => Some("image/svg+xml"),
"png" => Some("image/png"),
"webp" => Some("image/webp"),
"jpg" | "jpeg" => Some("image/jpeg"),
_ => None,
}
}
/// Read an app icon as a `data:` URI.
///
/// `icon_ref` may be a filename or path with an extension (a manifest's
/// `metadata.icon`), or a bare name such as an app id — in which case the
/// known extensions are tried in turn. Only the file name is used; the
/// directories searched are fixed, so a manifest cannot point the gate at an
/// arbitrary path.
fn read_icon_data_uri(icon_ref: &str) -> Option<String> {
let name = std::path::Path::new(icon_ref).file_name()?.to_str()?;
let candidates: Vec<(String, &str)> = match name.rsplit_once('.') {
Some((_, ext)) => vec![(name.to_string(), icon_mime(ext)?)],
None => ["svg", "png", "webp", "jpg"]
.iter()
.filter_map(|ext| Some((format!("{name}.{ext}"), icon_mime(ext)?)))
.collect(),
};
for (file, mime) in candidates {
for root in ICON_ROOTS {
let candidate = std::path::Path::new(root).join(&file);
if let Ok(bytes) = std::fs::read(&candidate) {
if bytes.len() > 512 * 1024 {
continue;
}
return Some(format!("data:{mime};base64,{}", base64_encode(&bytes)));
}
return Some(format!("data:{mime};base64,{}", base64_encode(&bytes)));
}
}
None
@@ -484,29 +624,79 @@ fn page(title: &str, app: &GatedPort, body: &str, status: StatusCode) -> Respons
<meta name="robots" content="noindex">
<title>{title} — {app_name}</title>
<style>
/* The dashboard's own /login, rebuilt in static CSS: the same rotating
intro art, .glass-card panel, .glass-button action and transparent
white-bordered inputs from neode-ui/src/style.css. Written longhand
rather than shared with the SPA because the gate answers before any
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;
background:#0b0f14; color:#e6edf3; font:16px/1.5 system-ui,-apple-system,Segoe UI,sans-serif; }}
.card {{ width:min(92vw,380px); padding:2rem; background:#121820;
border:1px solid #223; border-radius:14px; text-align:center; }}
.icon {{ width:64px; height:64px; border-radius:14px; margin:0 auto 1rem; display:block; object-fit:cover; }}
.lettermark {{ display:grid; place-items:center; background:#1d2733; font-size:28px; font-weight:600; }}
h1 {{ font-size:1.15rem; margin:0 0 .25rem; }}
p.sub {{ margin:0 0 1.5rem; color:#8b98a5; font-size:.9rem; }}
input {{ width:100%; padding:.7rem .8rem; margin-bottom:.75rem; border-radius:9px;
border:1px solid #2b3947; background:#0d131a; color:#e6edf3; font-size:1rem; }}
input:focus {{ outline:2px solid #3b82f6; outline-offset:1px; }}
button {{ width:100%; padding:.7rem; border:0; border-radius:9px; background:#3b82f6;
color:#fff; font-size:1rem; font-weight:600; cursor:pointer; }}
button:hover {{ background:#2f6fd6; }}
.err {{ background:#3b1519; border:1px solid #7f1d1d; color:#fca5a5;
padding:.6rem .8rem; border-radius:9px; margin-bottom:1rem; font-size:.9rem; }}
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; }}
/* 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;
background-position:center; opacity:0; animation:bg-cycle {cycle}s infinite; }}
.bg::after {{ content:''; position:absolute; inset:0;
background:linear-gradient(180deg, rgba(0,0,0,.35), rgba(0,0,0,.72)); }}
@keyframes bg-cycle {{
0% {{ opacity:0; }} 4% {{ opacity:1; }}
{hold}% {{ opacity:1; }} {fade}% {{ opacity:0; }} 100% {{ opacity:0; }}
}}
main {{ position:relative; z-index:1; width:min(92vw,28rem); }}
.card {{ padding:2rem; padding-top:3.5rem; position:relative;
background:rgba(0,0,0,.65); backdrop-filter:blur(18px);
-webkit-backdrop-filter:blur(18px); border:1px solid rgba(255,255,255,.18);
border-radius:1rem; box-shadow:0 8px 24px rgba(0,0,0,.45); text-align:center; }}
/* The Archipelago mark, half in and half out of the panel — same placement
and gradient ring as Login.vue. */
.logo {{ position:absolute; top:-2.5rem; left:50%; transform:translateX(-50%);
width:5rem; height:5rem; border-radius:9999px; padding:3px;
background:linear-gradient(135deg, rgba(255,255,255,.6) 0%, rgba(0,0,0,.8) 100%);
box-shadow:0 8px 24px rgba(0,0,0,.5); }}
.logo img {{ width:100%; height:100%; border-radius:9999px; display:block;
background:#000; padding:.5rem; }}
/* The app's own tile, in the My Apps shape: 18px-rounded square on dark
glass with the same inner highlight and drop shadow. */
.tile {{ width:60px; height:60px; border-radius:18px; margin:0 auto .75rem;
background:rgba(0,0,0,.72); box-shadow:0 8px 18px rgba(0,0,0,.38); }}
.tile .icon {{ width:100%; height:100%; border-radius:18px; display:block;
object-fit:cover; border:1px solid rgba(255,255,255,.18);
background:radial-gradient(circle at 35% 28%, rgba(255,255,255,.1), rgba(255,255,255,0) 42%),
linear-gradient(145deg, rgba(22,22,24,.96), rgba(0,0,0,.96));
box-shadow:inset 0 1px 0 rgba(255,255,255,.12), inset 0 -10px 24px rgba(0,0,0,.34); }}
.lettermark {{ display:grid; place-items:center; font-size:1.6rem; font-weight:600;
color:rgba(255,255,255,.9); }}
h1 {{ font-size:1.5rem; font-weight:600; margin:0 0 .4rem;
color:rgba(255,255,255,.96); text-shadow:0 2px 6px rgba(0,0,0,.4); }}
p.sub {{ margin:0 0 1.75rem; color:rgba(255,255,255,.6); font-size:.875rem; }}
input {{ width:100%; padding:.75rem 1rem; margin-bottom:1rem; border-radius:.5rem;
border:1px solid rgba(255,255,255,.2); background:transparent; color:#fff;
font-size:1rem; transition:border-color .2s ease; }}
input::placeholder {{ color:rgba(255,255,255,.4); }}
input:focus {{ outline:none; border-color:rgba(255,255,255,.4);
box-shadow:0 0 0 1px rgba(255,255,255,.2); }}
button {{ width:100%; min-height:44px; padding:.75rem 1.25rem; border:none;
border-radius:.75rem; background:rgba(0,0,0,.6);
backdrop-filter:blur(24px); -webkit-backdrop-filter:blur(24px);
box-shadow:0 8px 24px rgba(0,0,0,.45), inset 0 1px 0 rgba(255,255,255,.22);
color:rgba(255,255,255,.9); font-size:1rem; font-weight:500; cursor:pointer;
transition:background-color .2s ease, transform .3s cubic-bezier(.4,0,.2,1); }}
button:hover {{ background:rgba(0,0,0,.7); }}
button:active {{ transform:translateY(1px); }}
.err {{ background:rgba(239,68,68,.2); border:1px solid rgba(239,68,68,.4);
color:#fecaca; padding:.75rem; border-radius:.5rem; margin-bottom:1rem;
font-size:.875rem; text-align:left; }}
</style></head>
<body><main class="card">{body}</main></body></html>"#,
<body>{backgrounds}<main><div class="card">{body}</div></main></body></html>"#,
title = esc(title),
app_name = esc(&app.app_name),
body = body,
backgrounds = background_layers(),
cycle = LOGIN_BACKGROUNDS.len() as u32 * 9,
hold = 100 / LOGIN_BACKGROUNDS.len() as u32,
fade = 100 / LOGIN_BACKGROUNDS.len() as u32 + 4,
);
Response::builder()
.status(status)
@@ -514,10 +704,18 @@ button:hover {{ background:#2f6fd6; }}
// The gate answers on the app's own port for an unauthenticated
// caller; nothing here should be cached or framed.
.header(header::CACHE_CONTROL, "no-store")
.header("X-Frame-Options", "DENY")
// NOT X-Frame-Options: DENY. My Apps opens an app in an embedded
// frame, so a blanket DENY made every gated app render as "app is
// not responding" the moment the gate challenged it (reported on
// 100.82.34.38, 2026-08-05). frame-ancestors is the modern control
// and can be precise: only pages from this same node may frame the
// login, on any port or scheme, which is exactly the dashboard.
// Anything else — another site embedding it to harvest the node
// password — is still refused.
.header(
"Content-Security-Policy",
"default-src 'none'; img-src data:; style-src 'unsafe-inline'; form-action 'self'",
"default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
form-action 'self'; frame-ancestors 'self' http://*:* https://*:*",
)
.body(Body::from(html))
.expect("static response builds")
@@ -528,7 +726,8 @@ button:hover {{ background:#2f6fd6; }}
/// password by an unexplained page.
fn login_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Response<Body> {
let body = format!(
r#"{icon}
r#"<div class="logo"><img src="{prefix}asset/logo-archipelago.svg" alt="Archipelago"></div>
{icon}
<h1>Sign in to open {name}</h1>
<p class="sub">This app is protected by your node password.</p>
{err}
@@ -636,11 +835,61 @@ mod tests {
assert!(!html.contains("<img src=x"));
}
/// The challenge must be framable by this node's own dashboard — My Apps
/// opens apps in an embedded frame, and a blanket `X-Frame-Options: DENY`
/// turned every gated app into "app is not responding" (100.82.34.38,
/// 2026-08-05). It must still be uncacheable, and still refuse to be
/// framed by a foreign origin, which `frame-ancestors` expresses and
/// `X-Frame-Options` cannot.
#[test]
fn challenge_pages_are_not_cacheable_or_framable() {
fn challenge_pages_are_uncacheable_and_framable_only_by_this_node() {
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);
assert_eq!(resp.headers()[header::CACHE_CONTROL], "no-store");
assert_eq!(resp.headers()["X-Frame-Options"], "DENY");
assert!(
!resp.headers().contains_key("X-Frame-Options"),
"X-Frame-Options cannot express 'my own node on another port' — it \
blocked the dashboard's own frame"
);
let csp = resp.headers()["Content-Security-Policy"].to_str().unwrap();
assert!(csp.contains("frame-ancestors 'self'"));
assert!(csp.contains("form-action 'self'"));
}
/// The login page must render entirely from the gate's own origin: the
/// CSP allows no external host, so a background or logo that 404s leaves
/// a black page rather than the dashboard's art.
#[tokio::test]
async fn login_page_sources_its_art_from_the_gate() {
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")));
for name in LOGIN_BACKGROUNDS {
assert!(
html.contains(&format!("{GATE_PREFIX}asset/{name}")),
"background {name} is not referenced"
);
}
// 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 allowlist is the whole security boundary for asset serving: the
/// name arrives in a URL and is read before any authentication.
#[test]
fn asset_serving_refuses_anything_off_the_allowlist() {
for name in [
"../../../etc/passwd",
"/etc/passwd",
"db.sqlite3",
"manifest.yml",
"",
] {
assert!(
read_ui_asset(name).is_none(),
"{name} must not be servable by the gate"
);
}
}
#[tokio::test]
+46 -13
View File
@@ -74,7 +74,20 @@ fn is_newer(candidate: &str, current: &str) -> bool {
}
}
/// Primary OTA origin. Named host over TLS rather than the bare IP it used
/// to be: the IP pinned the fleet to one machine and one plaintext port, so
/// moving or fronting the origin meant an OTA to change where OTAs come
/// from — the one update you cannot ship if the origin is unreachable. The
/// signature is what establishes trust (see `trust::anchor`), not the
/// transport, but HTTPS also stops a network observer seeing which version
/// a node runs.
const DEFAULT_UPDATE_MANIFEST_URL: &str =
"https://source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/releases/manifest.json";
/// The previous IP-based origin, kept as an automatic fallback so a node
/// whose DNS or TLS is broken still updates. Dropped from the mirror list
/// once the fleet has moved.
const LEGACY_UPDATE_MANIFEST_URL: &str =
"http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json";
const UPDATE_STATE_FILE: &str = "update_state.json";
const UPDATE_MIRRORS_FILE: &str = "update-mirrors.json";
@@ -113,10 +126,19 @@ fn mirrors_path(data_dir: &Path) -> std::path::PathBuf {
}
fn default_mirrors() -> Vec<UpdateMirror> {
vec![UpdateMirror {
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
label: "Server 1 (OVH)".to_string(),
}]
vec![
UpdateMirror {
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
label: "Archipelago Foundation".to_string(),
},
// Fallback, tried only if the named origin fails: a node whose DNS
// or clock is wrong (both break TLS) must still be able to update
// itself, and the signature check is what makes either source safe.
UpdateMirror {
url: LEGACY_UPDATE_MANIFEST_URL.to_string(),
label: "Direct (fallback)".to_string(),
},
]
}
/// Load the operator-configured mirror list. Returns defaults if the
@@ -186,15 +208,18 @@ fn force_ovh_update_primary(list: &mut Vec<UpdateMirror>) {
}
for mirror in list.iter_mut() {
if mirror.url == DEFAULT_UPDATE_MANIFEST_URL {
mirror.label = "Server 1 (OVH)".to_string();
mirror.label = "Archipelago Foundation".to_string();
} else if mirror.url == LEGACY_UPDATE_MANIFEST_URL {
mirror.label = "Direct (fallback)".to_string();
}
}
list.sort_by_key(|m| {
if m.url == DEFAULT_UPDATE_MANIFEST_URL {
0
} else {
1
}
// Named origin first, its IP fallback second, anything the operator
// added after that. Ordering matters: the list is tried in order, so a
// stale entry sitting first costs a timeout on every check.
list.sort_by_key(|m| match m.url.as_str() {
u if u == DEFAULT_UPDATE_MANIFEST_URL => 0,
u if u == LEGACY_UPDATE_MANIFEST_URL => 1,
_ => 2,
});
}
@@ -2373,8 +2398,16 @@ mod tests {
async fn test_load_mirrors_returns_defaults_when_absent() {
let dir = tempfile::tempdir().unwrap();
let list = load_mirrors(dir.path()).await.unwrap();
assert_eq!(list.len(), 1);
assert!(list[0].url.contains("146.59.87.168"));
// The named origin leads, its IP fallback follows. A node with broken
// DNS or a wrong clock (both break TLS) must still have a way to
// update; the signature is what makes either source trustworthy.
assert_eq!(list.len(), 2);
assert!(
list[0].url.starts_with("https://source.archipelago-foundation.org/"),
"the named origin must be primary, got {}",
list[0].url
);
assert!(list[1].url.contains("146.59.87.168"));
assert!(
!list.iter().any(|m| m.url.contains("git.tx1138.com")),
"tx1138 was retired as a release server and must not be a default mirror"
+7 -1
View File
@@ -45,7 +45,13 @@ SEARXNG_IMAGE="$ARCHY_REGISTRY/searxng:latest"
CRYPTPAD_IMAGE="$ARCHY_REGISTRY/cryptpad:2024.12.0"
FILEBROWSER_IMAGE="$ARCHY_REGISTRY/filebrowser:v2.27.0"
NPM_IMAGE="$ARCHY_REGISTRY/nginx-proxy-manager:latest"
PORTAINER_IMAGE="$ARCHY_REGISTRY/portainer:2.19.4"
# 2.39.1 is what the fleet has actually been running via the moving :latest
# tag, and it is the version that wrote their databases. Pinning back to
# 2.19.4 (2 years older) made Portainer refuse to start the moment a
# container was recreated: "database schema version does not align with the
# server version" — it migrates a DB forward, never backward. Pinned
# forward and published as a concrete tag so this is reproducible.
PORTAINER_IMAGE="$ARCHY_REGISTRY/portainer:2.39.1"
# Networking
TAILSCALE_IMAGE="$ARCHY_REGISTRY/tailscale:stable"