merge(13): bring main forward — unblocks the crate's test build
The lane merged main at0c4826f8, one commit before0de67ca6added auth/auth_rationale to PortMapping's test constructors in prod_orchestrator.rs. That left the lane unable to compile ANY test in the archipelago crate, which is why 13-05 could not observe its 13 tests pass (window 19). Not a defect in this phase's work — just staleness. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> # Conflicts: # core/archipelago/src/main.rs
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
//! `security.app-gate-status` — what the app gate is actually enforcing.
|
||||
//!
|
||||
//! The gate rolls out per app (an app must be pinned to loopback before the
|
||||
//! gate can claim its port — see `appgate::listener`), so for a while every
|
||||
//! node is partially protected. "Partially" is only safe if it is *visible*:
|
||||
//! this is the RPC that lets the UI say which app ports are still reachable
|
||||
//! without a credential, instead of the operator having to port-scan their
|
||||
//! own node to find out.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use super::RpcHandler;
|
||||
|
||||
impl RpcHandler {
|
||||
pub(in crate::api::rpc) async fn handle_app_gate_status(&self) -> Result<serde_json::Value> {
|
||||
let status = crate::appgate::listener::shared_status();
|
||||
let status = status.read().await.clone();
|
||||
let port_map = self.app_gate.port_map().await;
|
||||
|
||||
// Exemptions are reported alongside, and with their manifest
|
||||
// rationale, because "which ports are open and why" is the actual
|
||||
// question — a list of unprotected ports without the deliberate ones
|
||||
// next to it invites someone to "fix" LND's gRPC port and break every
|
||||
// remote wallet.
|
||||
let exempt: Vec<serde_json::Value> = port_map
|
||||
.exempt_ports()
|
||||
.iter()
|
||||
.map(|e| {
|
||||
serde_json::json!({
|
||||
"port": e.port,
|
||||
"app_id": e.app_id,
|
||||
"protocol": e.protocol,
|
||||
"rationale": e.rationale,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let gated: Vec<serde_json::Value> = port_map
|
||||
.gated_ports()
|
||||
.map(|g| {
|
||||
serde_json::json!({
|
||||
"port": g.port,
|
||||
"app_id": g.app_id,
|
||||
"app_name": g.app_name,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
// The headline. False means this node still has app ports that
|
||||
// answer without authentication.
|
||||
"fully_enforced": status.is_fully_enforced(),
|
||||
"claimed": status.claimed,
|
||||
"unprotected": status.unprotected,
|
||||
"gated": gated,
|
||||
"exempt": exempt,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -470,6 +470,7 @@ impl RpcHandler {
|
||||
"server.set-location" => self.handle_server_set_location(params).await,
|
||||
|
||||
// System monitoring
|
||||
"security.app-gate-status" => self.handle_app_gate_status().await,
|
||||
"system.get-hostname" => self.handle_system_get_hostname().await,
|
||||
"system.stats" => self.handle_system_stats().await,
|
||||
"system.processes" => self.handle_system_processes().await,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod analytics;
|
||||
mod appgate;
|
||||
mod ark;
|
||||
mod assistant_chat;
|
||||
mod auth;
|
||||
@@ -94,6 +95,11 @@ pub struct RpcHandler {
|
||||
port_allocator: Arc<tokio::sync::Mutex<PortAllocator>>,
|
||||
pub session_store: SessionStore,
|
||||
login_rate_limiter: LoginRateLimiter,
|
||||
/// Authentication in front of every app port. Built here rather than in
|
||||
/// `server.rs` so it shares this handler's session store and login rate
|
||||
/// limiter — an attacker must not get a fresh budget of password guesses
|
||||
/// by moving from the dashboard to an app port.
|
||||
pub(crate) app_gate: Arc<crate::appgate::AppGate>,
|
||||
endpoint_rate_limiter: EndpointRateLimiter,
|
||||
response_cache: ResponseCache,
|
||||
mesh_service: Arc<tokio::sync::RwLock<Option<crate::mesh::MeshService>>>,
|
||||
@@ -158,6 +164,13 @@ impl RpcHandler {
|
||||
});
|
||||
}
|
||||
|
||||
let app_gate = Arc::new(crate::appgate::AppGate::new(
|
||||
session_store.clone(),
|
||||
auth_manager.clone(),
|
||||
login_rate_limiter.clone(),
|
||||
config.data_dir.clone(),
|
||||
));
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
auth_manager,
|
||||
@@ -168,6 +181,7 @@ impl RpcHandler {
|
||||
port_allocator,
|
||||
session_store,
|
||||
login_rate_limiter,
|
||||
app_gate,
|
||||
endpoint_rate_limiter,
|
||||
response_cache: ResponseCache::new(5),
|
||||
mesh_service: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
//! Which app is behind a given host port, and may it be reached without
|
||||
//! authenticating?
|
||||
//!
|
||||
//! The gate has to answer both questions for every inbound connection: the
|
||||
//! first to decide whether to challenge at all, the second so the login page
|
||||
//! can name and picture what the visitor is trying to open ("you are logging
|
||||
//! in to reach Immich"), which is what makes the challenge legible instead of
|
||||
//! alarming.
|
||||
//!
|
||||
//! Both answers come from the installed manifests rather than a generated
|
||||
//! table, so a catalog refresh that adds or repoints an app is reflected
|
||||
//! without a daemon restart — the same reason `app_port_v6_relay_loop`
|
||||
//! rescans instead of snapshotting once.
|
||||
|
||||
use archipelago_container::manifest::{AppManifest, PortAuth};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// An app port the gate is responsible for.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GatedPort {
|
||||
pub port: u16,
|
||||
pub app_id: String,
|
||||
/// Display name for the login page. Falls back to the id when a manifest
|
||||
/// omits `name`.
|
||||
pub app_name: String,
|
||||
/// Manifest-declared icon path (`metadata.icon`), when present.
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
/// A port deliberately left unauthenticated, and the manifest's stated reason.
|
||||
///
|
||||
/// Carried around rather than discarded because "which ports are open and
|
||||
/// why" is the question an operator actually asks, and the answer should be
|
||||
/// one RPC call rather than an audit of 56 YAML files.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ExemptPort {
|
||||
pub port: u16,
|
||||
pub app_id: String,
|
||||
pub rationale: String,
|
||||
/// UDP ports are listed for completeness. The gate is TCP-only, so it
|
||||
/// could not touch them even if they were marked `session`.
|
||||
pub protocol: String,
|
||||
}
|
||||
|
||||
/// Everything the gate knows about the node's published surface.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PortMap {
|
||||
gated: HashMap<u16, GatedPort>,
|
||||
exempt: Vec<ExemptPort>,
|
||||
}
|
||||
|
||||
impl PortMap {
|
||||
/// The app behind `port`, if the gate is responsible for it.
|
||||
pub fn gated(&self, port: u16) -> Option<&GatedPort> {
|
||||
self.gated.get(&port)
|
||||
}
|
||||
|
||||
pub fn gated_ports(&self) -> impl Iterator<Item = &GatedPort> {
|
||||
self.gated.values()
|
||||
}
|
||||
|
||||
pub fn exempt_ports(&self) -> &[ExemptPort] {
|
||||
&self.exempt
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.gated.is_empty() && self.exempt.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Directories searched for installed manifests, most specific first.
|
||||
///
|
||||
/// Mirrors `api::rpc::package::runtime::manifest_apps_dirs` deliberately: the
|
||||
/// gate must classify exactly the manifests the orchestrator installs from,
|
||||
/// or a port could be gated here and published from a different declaration
|
||||
/// there.
|
||||
fn apps_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
|
||||
dirs.push(PathBuf::from(manifest_dir).join("../../apps"));
|
||||
}
|
||||
dirs.extend([
|
||||
PathBuf::from("apps"),
|
||||
PathBuf::from("/opt/archipelago/apps"),
|
||||
PathBuf::from("/opt/archipelago/web-ui/archipelago-runtime/apps"),
|
||||
]);
|
||||
dirs
|
||||
}
|
||||
|
||||
/// Read `metadata.icon` out of the manifest's untyped extension bag.
|
||||
fn manifest_icon(manifest: &AppManifest) -> Option<String> {
|
||||
manifest
|
||||
.app
|
||||
.extensions
|
||||
.get("metadata")?
|
||||
.get("icon")?
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// Classify every published port across all installed manifests.
|
||||
///
|
||||
/// The first directory that yields a manifest for an app id wins, so a node's
|
||||
/// `/opt/archipelago/apps` copy shadows a repo checkout rather than merging
|
||||
/// with it — otherwise a stale checked-out manifest could re-open a port the
|
||||
/// installed one gates.
|
||||
pub fn build_port_map() -> PortMap {
|
||||
let mut map = PortMap::default();
|
||||
let mut seen_apps: HashMap<String, PathBuf> = HashMap::new();
|
||||
|
||||
for dir in apps_dirs() {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path().join("manifest.yml");
|
||||
let Ok(contents) = std::fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(manifest) = AppManifest::parse(&contents) else {
|
||||
// A manifest that does not parse is not installable either,
|
||||
// so skipping it cannot open a port that the orchestrator
|
||||
// would have published.
|
||||
continue;
|
||||
};
|
||||
let app_id = manifest.app.id.clone();
|
||||
if seen_apps.contains_key(&app_id) {
|
||||
continue;
|
||||
}
|
||||
seen_apps.insert(app_id.clone(), path);
|
||||
|
||||
let icon = manifest_icon(&manifest);
|
||||
let app_name = if manifest.app.name.trim().is_empty() {
|
||||
app_id.clone()
|
||||
} else {
|
||||
manifest.app.name.clone()
|
||||
};
|
||||
|
||||
for port in &manifest.app.ports {
|
||||
let protocol = if port.protocol.is_empty() {
|
||||
"tcp"
|
||||
} else {
|
||||
port.protocol.as_str()
|
||||
};
|
||||
match port.auth_policy() {
|
||||
PortAuth::None => map.exempt.push(ExemptPort {
|
||||
port: port.host,
|
||||
app_id: app_id.clone(),
|
||||
rationale: port
|
||||
.auth_rationale
|
||||
.clone()
|
||||
.unwrap_or_else(|| "(no rationale recorded)".to_string()),
|
||||
protocol: protocol.to_string(),
|
||||
}),
|
||||
// Declared host-local. Not gated and not reported as
|
||||
// exposed, because it is neither — see PortAuth::Local
|
||||
// for why this cannot be inferred from `bind`.
|
||||
PortAuth::Local => {}
|
||||
// Explicit opt-in: the app is on loopback and the daemon
|
||||
// owns the external addresses. This is the ONLY way a
|
||||
// port gets bound by the gate, regardless of `bind`.
|
||||
PortAuth::Gated => {
|
||||
map.gated.insert(
|
||||
port.host,
|
||||
GatedPort {
|
||||
port: port.host,
|
||||
app_id: app_id.clone(),
|
||||
app_name: app_name.clone(),
|
||||
icon: icon.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
PortAuth::Session => {
|
||||
// UDP cannot carry an HTTP challenge. Such a port has
|
||||
// no business defaulting into the gated set where it
|
||||
// would look protected without being protectable —
|
||||
// surface it as an unrationalised exemption instead,
|
||||
// which is honest and shows up in the audit list.
|
||||
if protocol != "tcp" {
|
||||
map.exempt.push(ExemptPort {
|
||||
port: port.host,
|
||||
app_id: app_id.clone(),
|
||||
rationale: format!(
|
||||
"{protocol} cannot carry an HTTP challenge; declare auth: none \
|
||||
with a rationale to record why this is safe"
|
||||
),
|
||||
protocol: protocol.to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// A loopback publish is skipped, and this is the
|
||||
// safety property of the whole module: the gate must
|
||||
// never be the reason a port becomes reachable
|
||||
// somewhere it was not. `session` is the DEFAULT, so
|
||||
// it is what every un-migrated manifest carries —
|
||||
// and a node's installed manifests always lag the
|
||||
// repo. Binding those externally published Bitcoin
|
||||
// RPC across the LAN within seconds of deploy
|
||||
// (archi-dev-box 2026-08-03). Taking over a port is
|
||||
// opt-in only: `auth: gated`, shipped in the same
|
||||
// manifest edit as the loopback pin.
|
||||
if port
|
||||
.bind
|
||||
.parse::<std::net::IpAddr>()
|
||||
.is_ok_and(|ip| ip.is_loopback())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
map.gated.insert(
|
||||
port.host,
|
||||
GatedPort {
|
||||
port: port.host,
|
||||
app_id: app_id.clone(),
|
||||
app_name: app_name.clone(),
|
||||
icon: icon.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
map.exempt.sort_by_key(|e| e.port);
|
||||
map
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The corpus this runs against is the real `apps/` tree, so these assert
|
||||
/// on properties rather than exact contents — the set of apps changes,
|
||||
/// the invariants must not.
|
||||
#[test]
|
||||
fn real_manifests_classify_into_both_sets() {
|
||||
let map = build_port_map();
|
||||
assert!(!map.is_empty(), "no manifests found — apps dir missing?");
|
||||
assert!(
|
||||
map.gated_ports().count() > 20,
|
||||
"expected most published ports to be gated, got {}",
|
||||
map.gated_ports().count()
|
||||
);
|
||||
assert!(!map.exempt_ports().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_exemption_carries_a_reason() {
|
||||
for exempt in build_port_map().exempt_ports() {
|
||||
assert!(
|
||||
!exempt.rationale.trim().is_empty(),
|
||||
"port {} ({}) is exempt with no rationale",
|
||||
exempt.port,
|
||||
exempt.app_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Protocol ports that wallets dial directly must never end up gated —
|
||||
/// this is the constraint that decided the design (Zeus and electrum
|
||||
/// clients keep working untouched).
|
||||
#[test]
|
||||
fn wallet_protocol_ports_are_not_gated() {
|
||||
let map = build_port_map();
|
||||
for port in [10009, 18080, 9735, 50001] {
|
||||
assert!(
|
||||
map.gated(port).is_none(),
|
||||
"port {port} must stay ungated — remote wallets cannot hold a session"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bitcoin's RPC is host-local by intent (`auth: local`), so the gate
|
||||
/// must neither gate it nor report it as exposed — fronting it would
|
||||
/// newly publish it on every host address, behind a login but reachable
|
||||
/// where it deliberately was not.
|
||||
#[test]
|
||||
fn host_local_ports_are_neither_gated_nor_reported() {
|
||||
let map = build_port_map();
|
||||
assert!(map.gated(8332).is_none(), "bitcoin RPC must not be gated");
|
||||
assert!(
|
||||
!map.exempt_ports().iter().any(|e| e.port == 8332),
|
||||
"a host-local port is not an unauthenticated exposure"
|
||||
);
|
||||
}
|
||||
|
||||
/// THE safety property. A `session` port pinned to loopback must NOT be
|
||||
/// gated, because gating means binding external addresses — the one
|
||||
/// action that can make a port reachable where it was not.
|
||||
///
|
||||
/// This is not hypothetical. `session` is the default, so it is what
|
||||
/// every un-migrated manifest carries, and a node's installed manifests
|
||||
/// always lag the repo. An earlier revision gated these regardless of
|
||||
/// `bind`, and within seconds of deploying to archi-dev-box the daemon
|
||||
/// had published Bitcoin's loopback-only RPC 8332 on the LAN, Tailscale
|
||||
/// and IPv6 addresses. Taking over a port must be opt-in.
|
||||
#[test]
|
||||
fn a_loopback_pinned_session_port_is_never_gated() {
|
||||
let map = build_port_map();
|
||||
// aiui and bitcoin RPC are both loopback-pinned in the shipped tree.
|
||||
for port in [5180, 8332] {
|
||||
assert!(
|
||||
map.gated(port).is_none(),
|
||||
"port {port} is loopback-pinned; gating it would newly expose it"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The migration end state: `auth: gated` opts a loopback-pinned port
|
||||
/// into daemon ownership. Without this the rollout could never complete.
|
||||
#[test]
|
||||
fn an_explicitly_gated_loopback_port_is_gated() {
|
||||
use archipelago_container::manifest::{AppManifest, PortAuth as PA};
|
||||
let yaml = "app:\n id: pinned\n name: Pinned\n version: 1.0.0\n container:\n image: x:y\n ports:\n - host: 9911\n container: 80\n bind: 127.0.0.1\n auth: gated\n";
|
||||
let m = AppManifest::parse(yaml).expect("parses");
|
||||
assert_eq!(m.app.ports[0].auth, Some(PA::Gated));
|
||||
assert_eq!(m.app.ports[0].bind, "127.0.0.1");
|
||||
}
|
||||
|
||||
/// An app UI that was reachable with no credential in the 2026-08-03
|
||||
/// reproduction must now resolve to a gated port with a display name.
|
||||
#[test]
|
||||
fn reproduced_open_ports_are_now_gated() {
|
||||
let map = build_port_map();
|
||||
let strfry = map.gated(8090).expect("strfry :8090 must be gated");
|
||||
assert_eq!(strfry.app_id, "strfry");
|
||||
assert!(!strfry.app_name.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
//! Binding the gate in front of apps, and telling the truth when it cannot.
|
||||
//!
|
||||
//! # The ordering problem
|
||||
//!
|
||||
//! A published container port is bound `0.0.0.0:<port>`, which claims *every*
|
||||
//! host address. While the app holds that, the gate cannot bind
|
||||
//! `<lan-ip>:<port>` at all — the kernel refuses the overlap. So the gate can
|
||||
//! only stand in front of an app whose own publish has been pinned to
|
||||
//! loopback (`bind: 127.0.0.1` in its manifest, which
|
||||
//! `PortMapping::bind` has supported all along).
|
||||
//!
|
||||
//! That makes the rollout necessarily two-step, per app: pin the publish,
|
||||
//! recreate the container, and the gate claims the external addresses. Doing
|
||||
//! it the other way round — gate first — is not possible, and doing it in one
|
||||
//! step for every app at once would recreate every container on the node
|
||||
//! simultaneously.
|
||||
//!
|
||||
//! # Why the failure has to be loud
|
||||
//!
|
||||
//! The dangerous version of this module is the one that tries to bind, fails
|
||||
//! because the app still holds the port, logs at debug, and moves on. The
|
||||
//! node would then be running "the app gate" while every app remained exactly
|
||||
//! as open as before — a security control that reports success and does
|
||||
//! nothing, which is worse than no control at all because it stops anyone
|
||||
//! looking.
|
||||
//!
|
||||
//! So an unclaimable port is recorded in [`GateStatus::unprotected`] and
|
||||
//! logged at warn on every sweep. The same reasoning killed the nft-drop-in
|
||||
//! design: `/etc/fips/fips.nft` is provisioned out-of-band and its absence is
|
||||
//! a silent no-op, so a gate shipped that way would be absent on every node
|
||||
//! without the hardening baseline and nobody would know.
|
||||
|
||||
use super::identity::GatedPort;
|
||||
use super::AppGate;
|
||||
use std::collections::HashMap;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// How often the sweep re-runs. Matches `app_port_v6_relay_loop`: addresses
|
||||
/// come and go (DHCP, Tailscale up/down, the fips0 ULA appearing late) and
|
||||
/// apps are installed while the daemon runs.
|
||||
const SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
/// A port the gate should own but could not claim, and why.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct UnprotectedPort {
|
||||
pub port: u16,
|
||||
pub app_id: String,
|
||||
pub app_name: String,
|
||||
/// Human-readable cause, e.g. that the app still publishes on all
|
||||
/// interfaces.
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// What the gate is actually enforcing right now.
|
||||
#[derive(Debug, Clone, Default, serde::Serialize)]
|
||||
pub struct GateStatus {
|
||||
/// (port, address) pairs the gate holds.
|
||||
pub claimed: Vec<(u16, String)>,
|
||||
/// Ports that should be gated but are not. **Non-empty means the node
|
||||
/// has unauthenticated app surface.**
|
||||
pub unprotected: Vec<UnprotectedPort>,
|
||||
}
|
||||
|
||||
impl GateStatus {
|
||||
pub fn is_fully_enforced(&self) -> bool {
|
||||
self.unprotected.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Every non-loopback address currently on this host.
|
||||
///
|
||||
/// Shells out to `ip` rather than pulling in a `getifaddrs` binding: the
|
||||
/// codebase already resolves addresses this way (`host_ip`), the result is
|
||||
/// re-derived every sweep so a stale parse self-corrects, and a failure here
|
||||
/// degrades to "claim nothing this round" rather than to a wrong claim.
|
||||
async fn host_addresses() -> Vec<IpAddr> {
|
||||
let Ok(out) = tokio::process::Command::new("ip")
|
||||
.args(["-o", "addr", "show"])
|
||||
.output()
|
||||
.await
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
let mut addrs = Vec::new();
|
||||
for line in text.lines() {
|
||||
let mut fields = line.split_whitespace();
|
||||
// `1: lo inet 127.0.0.1/8 scope host lo`
|
||||
let Some(family) = fields.clone().nth(2) else {
|
||||
continue;
|
||||
};
|
||||
if family != "inet" && family != "inet6" {
|
||||
continue;
|
||||
}
|
||||
let Some(cidr) = fields.nth(3) else { continue };
|
||||
let Some(addr) = cidr.split('/').next() else {
|
||||
continue;
|
||||
};
|
||||
// Strip a zone index (`fe80::1%eth0`) — link-local addresses need a
|
||||
// scope to bind and are not how anyone reaches an app anyway.
|
||||
let addr = addr.split('%').next().unwrap_or(addr);
|
||||
let Ok(ip) = addr.parse::<IpAddr>() else {
|
||||
continue;
|
||||
};
|
||||
if ip.is_loopback() || ip.is_unspecified() {
|
||||
continue;
|
||||
}
|
||||
if let IpAddr::V6(v6) = ip {
|
||||
// Link-local v6 requires a scope id we do not carry.
|
||||
if (v6.segments()[0] & 0xffc0) == 0xfe80 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
addrs.push(ip);
|
||||
}
|
||||
addrs.sort();
|
||||
addrs.dedup();
|
||||
addrs
|
||||
}
|
||||
|
||||
/// Process-wide gate status, so any RPC handler can report what the gate is
|
||||
/// actually enforcing without threading a handle through every caller.
|
||||
///
|
||||
/// A single shared cell rather than a value returned from `run`: "is my node
|
||||
/// actually protected?" has to be answerable from the RPC layer, and the
|
||||
/// listener that knows the answer runs in a detached task.
|
||||
pub fn shared_status() -> Arc<RwLock<GateStatus>> {
|
||||
static STATUS: std::sync::OnceLock<Arc<RwLock<GateStatus>>> = std::sync::OnceLock::new();
|
||||
STATUS
|
||||
.get_or_init(|| Arc::new(RwLock::new(GateStatus::default())))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Run the gate. Returns only on shutdown.
|
||||
pub async fn run(
|
||||
gate: Arc<AppGate>,
|
||||
status: Arc<RwLock<GateStatus>>,
|
||||
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||
) {
|
||||
// (port, addr) pairs already served, so a sweep does not rebind what it
|
||||
// already holds.
|
||||
let mut held: HashMap<(u16, IpAddr), ()> = HashMap::new();
|
||||
let mut interval = tokio::time::interval(SWEEP_INTERVAL);
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
sweep(&gate, &status, &mut held, &shutdown_rx).await;
|
||||
}
|
||||
_ = shutdown_rx.changed() => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn sweep(
|
||||
gate: &Arc<AppGate>,
|
||||
status: &Arc<RwLock<GateStatus>>,
|
||||
held: &mut HashMap<(u16, IpAddr), ()>,
|
||||
shutdown_rx: &tokio::sync::watch::Receiver<bool>,
|
||||
) {
|
||||
// Re-read the manifests every sweep rather than trusting the map built
|
||||
// at construction. An app installed while the daemon is running would
|
||||
// otherwise never be gated until the next restart — and it would not
|
||||
// appear in `unprotected` either, so the node would report itself fully
|
||||
// enforced while serving a brand-new app to anyone who asked.
|
||||
gate.refresh().await;
|
||||
let port_map = gate.port_map().await;
|
||||
let addresses = host_addresses().await;
|
||||
if addresses.is_empty() {
|
||||
debug!("app gate: no external addresses yet");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut claimed = Vec::new();
|
||||
let mut unprotected = Vec::new();
|
||||
|
||||
for app in port_map.gated_ports() {
|
||||
// Nothing is listening on this port, so there is no app to protect
|
||||
// and binding would steal the port from an install that has not
|
||||
// happened yet. The relay loop learned this the hard way: binding a
|
||||
// port for an app that is not installed makes its later install hit
|
||||
// "address already in use", and the install's port-free step then
|
||||
// kills the daemon holding it.
|
||||
if !app_is_listening(app.port).await {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut claimed_any = false;
|
||||
let mut blocked = false;
|
||||
for &addr in &addresses {
|
||||
let key = (app.port, addr);
|
||||
if held.contains_key(&key) {
|
||||
claimed.push((app.port, addr.to_string()));
|
||||
claimed_any = true;
|
||||
continue;
|
||||
}
|
||||
match TcpListener::bind(SocketAddr::new(addr, app.port)).await {
|
||||
Ok(listener) => {
|
||||
held.insert(key, ());
|
||||
claimed.push((app.port, addr.to_string()));
|
||||
claimed_any = true;
|
||||
info!(
|
||||
port = app.port, %addr, app = %app.app_id,
|
||||
"app gate claimed an app port"
|
||||
);
|
||||
spawn_accept_loop(listener, gate.clone(), app.clone(), shutdown_rx.clone());
|
||||
}
|
||||
// Almost always the app itself holding 0.0.0.0:<port>.
|
||||
Err(_) => blocked = true,
|
||||
}
|
||||
}
|
||||
|
||||
if blocked && !claimed_any {
|
||||
warn!(
|
||||
port = app.port, app = %app.app_id,
|
||||
"APP GATE CANNOT PROTECT THIS PORT — the app still publishes on all \
|
||||
interfaces. Pin its manifest port to bind: 127.0.0.1 and recreate the \
|
||||
container, or it stays reachable without authentication."
|
||||
);
|
||||
unprotected.push(UnprotectedPort {
|
||||
port: app.port,
|
||||
app_id: app.app_id.clone(),
|
||||
app_name: app.app_name.clone(),
|
||||
reason: "app publishes on all interfaces; manifest port needs bind: 127.0.0.1"
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
claimed.sort();
|
||||
unprotected.sort_by_key(|u| u.port);
|
||||
let mut guard = status.write().await;
|
||||
guard.claimed = claimed;
|
||||
guard.unprotected = unprotected;
|
||||
}
|
||||
|
||||
/// Is anything answering on loopback for this port?
|
||||
async fn app_is_listening(port: u16) -> bool {
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_millis(300),
|
||||
tokio::net::TcpStream::connect(("127.0.0.1", port)),
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|r| r.ok())
|
||||
.is_some()
|
||||
}
|
||||
|
||||
fn spawn_accept_loop(
|
||||
listener: TcpListener,
|
||||
gate: Arc<AppGate>,
|
||||
app: GatedPort,
|
||||
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
accepted = listener.accept() => {
|
||||
let Ok((stream, peer)) = accepted else { break };
|
||||
let gate = gate.clone();
|
||||
let app = app.clone();
|
||||
tokio::spawn(async move {
|
||||
let service = hyper::service::service_fn(move |req| {
|
||||
let gate = gate.clone();
|
||||
let app = app.clone();
|
||||
async move {
|
||||
Ok::<_, std::convert::Infallible>(
|
||||
gate.handle(req, &app, peer.ip()).await,
|
||||
)
|
||||
}
|
||||
});
|
||||
let _ = hyper::server::conn::Http::new()
|
||||
// Same slowloris guard as the main listener: an
|
||||
// unauthenticated caller must not be able to hold
|
||||
// a connection open by never sending headers.
|
||||
.http1_header_read_timeout(std::time::Duration::from_secs(30))
|
||||
.serve_connection(stream, service)
|
||||
.with_upgrades()
|
||||
.await;
|
||||
});
|
||||
}
|
||||
_ = shutdown_rx.changed() => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_addresses_excludes_loopback() {
|
||||
for addr in host_addresses().await {
|
||||
assert!(!addr.is_loopback(), "{addr} is loopback");
|
||||
assert!(!addr.is_unspecified());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_is_listening_is_false_for_a_dead_port() {
|
||||
// Bind and immediately drop, so the port is known-free.
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
drop(listener);
|
||||
assert!(!app_is_listening(port).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_is_listening_is_true_for_a_live_port() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
assert!(app_is_listening(port).await);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_status_with_unprotected_ports_is_not_fully_enforced() {
|
||||
let mut status = GateStatus::default();
|
||||
assert!(status.is_fully_enforced());
|
||||
status.unprotected.push(UnprotectedPort {
|
||||
port: 8090,
|
||||
app_id: "strfry".into(),
|
||||
app_name: "Strfry".into(),
|
||||
reason: "test".into(),
|
||||
});
|
||||
assert!(!status.is_fully_enforced());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
//! The app gate — authentication in front of every app port.
|
||||
//!
|
||||
//! # Why this exists
|
||||
//!
|
||||
//! Reproduced on a live node 2026-08-03: with no session cookie at all, over
|
||||
//! the Tailscale address, six app ports answered `HTTP 200` with their real
|
||||
//! UIs. `ss -tlnp` showed them bound `0.0.0.0`, so the same pages were served
|
||||
//! on the LAN address, the FIPS mesh address, and through each app's onion.
|
||||
//! This is the same bug class as the `/lnd-connect-info` and `/bitcoin-rpc/`
|
||||
//! leaks closed in v1.7.120, but across every app rather than two endpoints.
|
||||
//!
|
||||
//! # Why one gate covers four transports
|
||||
//!
|
||||
//! LAN, Tailscale, Tor and the FIPS mesh all converge on
|
||||
//! `127.0.0.1:<app_port>` — the container publishes there, the mesh relay
|
||||
//! forwards there, and `HiddenServicePort` points there. Authorising at that
|
||||
//! convergence point is one gate rather than four, which is the only reason
|
||||
//! this is tractable at all.
|
||||
//!
|
||||
//! # Why not umbrel's sidecar proxy
|
||||
//!
|
||||
//! umbrelOS gives every app an `app_proxy` container that owns the published
|
||||
//! port. That works, but it costs a container per app and a second service to
|
||||
//! hold the shared secret. Here the daemon already terminates HTTP, already
|
||||
//! owns the session store, and already runs a relay loop for the mesh, so the
|
||||
//! gate is assembly rather than new infrastructure.
|
||||
//!
|
||||
//! # What it does NOT do
|
||||
//!
|
||||
//! It does not invent authentication policy. Password verification, TOTP
|
||||
//! decryption and step replay protection, session lifetime, and rate limiting
|
||||
//! are the same primitives the JSON-RPC login path uses. Only the transport
|
||||
//! differs — an HTML form instead of JSON-RPC — because a browser being
|
||||
//! redirected to an app cannot speak JSON-RPC.
|
||||
|
||||
pub mod identity;
|
||||
pub mod listener;
|
||||
|
||||
use crate::auth::AuthManager;
|
||||
use crate::rate_limit::LoginRateLimiter;
|
||||
use crate::session::SessionStore;
|
||||
use hyper::{header, Body, HeaderMap, Method, Request, Response, StatusCode};
|
||||
use identity::{GatedPort, PortMap};
|
||||
use std::net::IpAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Paths the gate serves itself rather than proxying. Namespaced so an app
|
||||
/// that happens to have its own `/login` is unaffected.
|
||||
const GATE_PREFIX: &str = "/__archipelago-gate/";
|
||||
|
||||
/// Result of examining a request's credentials.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Authorization {
|
||||
/// Proxy it through.
|
||||
Allow,
|
||||
/// Serve the login page.
|
||||
Challenge,
|
||||
}
|
||||
|
||||
pub struct AppGate {
|
||||
sessions: SessionStore,
|
||||
auth: AuthManager,
|
||||
limiter: LoginRateLimiter,
|
||||
data_dir: PathBuf,
|
||||
port_map: Arc<RwLock<PortMap>>,
|
||||
}
|
||||
|
||||
impl AppGate {
|
||||
pub fn new(
|
||||
sessions: SessionStore,
|
||||
auth: AuthManager,
|
||||
limiter: LoginRateLimiter,
|
||||
data_dir: PathBuf,
|
||||
) -> Self {
|
||||
Self {
|
||||
sessions,
|
||||
auth,
|
||||
limiter,
|
||||
data_dir,
|
||||
port_map: Arc::new(RwLock::new(identity::build_port_map())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-read the manifests. Called on catalog refresh so a newly installed
|
||||
/// app is gated without a daemon restart.
|
||||
pub async fn refresh(&self) {
|
||||
*self.port_map.write().await = identity::build_port_map();
|
||||
}
|
||||
|
||||
pub async fn port_map(&self) -> PortMap {
|
||||
self.port_map.read().await.clone()
|
||||
}
|
||||
|
||||
/// Does this request carry a credential good for `app_id`?
|
||||
///
|
||||
/// Two accepted forms, deliberately no others:
|
||||
///
|
||||
/// * the node session cookie — and because a session still pending its
|
||||
/// TOTP step fails `validate()`, **2FA is honoured here for free**. The
|
||||
/// gate never sees a TOTP code on a proxied request and never needs to.
|
||||
/// * an app-scoped bearer token, for machine clients that speak HTTP but
|
||||
/// cannot hold a cookie or complete an interactive login (Home
|
||||
/// Assistant reaching an app's API is the motivating case).
|
||||
pub async fn authorize(&self, headers: &HeaderMap, app_id: &str) -> Authorization {
|
||||
if let Some(token) = crate::session::extract_session_cookie(headers) {
|
||||
if self.sessions.validate(&token).await {
|
||||
return Authorization::Allow;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(token) = bearer_token(headers) {
|
||||
if crate::device_tokens::verify_for_app(&self.data_dir, &token, app_id)
|
||||
.await
|
||||
.is_some()
|
||||
{
|
||||
return Authorization::Allow;
|
||||
}
|
||||
}
|
||||
|
||||
Authorization::Challenge
|
||||
}
|
||||
|
||||
/// Handle one inbound request on a gated port.
|
||||
pub async fn handle(
|
||||
&self,
|
||||
req: Request<Body>,
|
||||
app: &GatedPort,
|
||||
client_ip: IpAddr,
|
||||
) -> Response<Body> {
|
||||
let path = req.uri().path().to_string();
|
||||
|
||||
if let Some(action) = path.strip_prefix(GATE_PREFIX) {
|
||||
return self.handle_gate_action(req, app, action, client_ip).await;
|
||||
}
|
||||
|
||||
match self.authorize(req.headers(), &app.app_id).await {
|
||||
Authorization::Allow => proxy_to_app(req, app.port).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
|
||||
// response. The status says "you are not authenticated" in a way
|
||||
// every client understands, and browsers still render the body.
|
||||
Authorization::Challenge => login_page(app, None, StatusCode::UNAUTHORIZED),
|
||||
}
|
||||
}
|
||||
|
||||
/// The gate's own endpoints: the login form target and the TOTP step.
|
||||
async fn handle_gate_action(
|
||||
&self,
|
||||
req: Request<Body>,
|
||||
app: &GatedPort,
|
||||
action: &str,
|
||||
client_ip: IpAddr,
|
||||
) -> Response<Body> {
|
||||
if req.method() != Method::POST {
|
||||
return login_page(app, None, StatusCode::OK);
|
||||
}
|
||||
|
||||
// Captured before the body is consumed. The pending-2FA session
|
||||
// rides the cookie rather than a hidden form field so the token
|
||||
// never appears in the HTML, in a `view-source`, or in a screenshot
|
||||
// of the second-factor page.
|
||||
let pending = crate::session::extract_session_cookie(req.headers());
|
||||
|
||||
// Same limiter instance as the JSON-RPC login path, so an attacker
|
||||
// cannot get a fresh budget of guesses simply by moving to an app
|
||||
// port.
|
||||
if !self.limiter.check(client_ip).await {
|
||||
return login_page(
|
||||
app,
|
||||
Some("Too many attempts. Wait a minute and try again."),
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
);
|
||||
}
|
||||
|
||||
let form = match read_form(req).await {
|
||||
Some(form) => form,
|
||||
None => return login_page(app, Some("Malformed request."), StatusCode::BAD_REQUEST),
|
||||
};
|
||||
|
||||
match action {
|
||||
"login" => self.do_login(app, &form, client_ip).await,
|
||||
"totp" => self.do_totp(app, &form, pending, client_ip).await,
|
||||
_ => not_found(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn do_login(&self, app: &GatedPort, form: &Form, client_ip: IpAddr) -> Response<Body> {
|
||||
let password = field(form, "password").unwrap_or_default();
|
||||
|
||||
match self.auth.verify_password(&password).await {
|
||||
Ok(true) => {}
|
||||
_ => {
|
||||
self.limiter.record_failure(client_ip).await;
|
||||
return login_page(app, Some("Incorrect password."), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
|
||||
// 2FA, if configured. The secret is encrypted with the password, so
|
||||
// this is the only moment it can be decrypted — exactly as in the
|
||||
// JSON-RPC path. A pending session cannot pass `authorize`, so a
|
||||
// half-finished login grants nothing.
|
||||
if self.auth.is_totp_enabled().await.unwrap_or(false) {
|
||||
if let Ok(Some(totp_data)) = self.auth.get_totp_data().await {
|
||||
if let Ok(secret) = crate::totp::decrypt_secret(&totp_data, &password) {
|
||||
let pending = self.sessions.create_pending(secret).await;
|
||||
let mut resp = totp_page(app, None, StatusCode::OK);
|
||||
set_session_cookie(&mut resp, &pending);
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
// TOTP is on but its data is unreadable. Refuse: falling through
|
||||
// to a full session would silently downgrade the node's second
|
||||
// factor to nothing.
|
||||
return login_page(
|
||||
app,
|
||||
Some("Two-factor data could not be read. Sign in from the dashboard."),
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
let token = self.sessions.create().await;
|
||||
let mut resp = redirect_to_app();
|
||||
set_session_cookie(&mut resp, &token);
|
||||
resp
|
||||
}
|
||||
|
||||
async fn do_totp(
|
||||
&self,
|
||||
app: &GatedPort,
|
||||
form: &Form,
|
||||
pending: Option<String>,
|
||||
client_ip: IpAddr,
|
||||
) -> Response<Body> {
|
||||
let code = field(form, "code").unwrap_or_default();
|
||||
let Some(pending) = pending.filter(|s| !s.is_empty()) else {
|
||||
return login_page(app, Some("Session expired."), StatusCode::UNAUTHORIZED);
|
||||
};
|
||||
|
||||
let Some(secret) = self.sessions.get_pending_secret(&pending).await else {
|
||||
return login_page(
|
||||
app,
|
||||
Some("Session expired. Start again."),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
);
|
||||
};
|
||||
|
||||
let totp_data = self.auth.get_totp_data().await.ok().flatten();
|
||||
let used_steps = totp_data
|
||||
.as_ref()
|
||||
.map(|d| d.used_steps.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
match crate::totp::verify_code(&secret, &code, &used_steps) {
|
||||
Ok(Some(step)) => {
|
||||
// Record the step so the same code cannot be replayed — the
|
||||
// JSON-RPC path does this and skipping it here would make the
|
||||
// gate the weaker of the two doors.
|
||||
if let Some(mut data) = totp_data {
|
||||
data.used_steps.push(step);
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
let cutoff = (now / 30) - 10;
|
||||
data.used_steps.retain(|s| *s > cutoff);
|
||||
let _ = self.auth.update_totp(data).await;
|
||||
}
|
||||
match self.sessions.upgrade_to_full(&pending).await {
|
||||
Some(full) => {
|
||||
let mut resp = redirect_to_app();
|
||||
set_session_cookie(&mut resp, &full);
|
||||
resp
|
||||
}
|
||||
None => login_page(app, Some("Session expired."), StatusCode::UNAUTHORIZED),
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.limiter.record_failure(client_ip).await;
|
||||
let mut resp = totp_page(app, Some("Incorrect code."), StatusCode::UNAUTHORIZED);
|
||||
set_session_cookie(&mut resp, &pending);
|
||||
resp
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn bearer_token(headers: &HeaderMap) -> Option<String> {
|
||||
let value = headers.get(header::AUTHORIZATION)?.to_str().ok()?;
|
||||
let token = value
|
||||
.strip_prefix("Bearer ")
|
||||
.or_else(|| value.strip_prefix("bearer "))?;
|
||||
let token = token.trim();
|
||||
(!token.is_empty()).then(|| token.to_string())
|
||||
}
|
||||
|
||||
type Form = std::collections::HashMap<String, String>;
|
||||
|
||||
/// Free function rather than a trait method: `HashMap` has an inherent `get`
|
||||
/// that would win method resolution and silently return `Option<&String>`.
|
||||
fn field(form: &Form, key: &str) -> Option<String> {
|
||||
form.get(key).cloned()
|
||||
}
|
||||
|
||||
/// Read an `application/x-www-form-urlencoded` body.
|
||||
///
|
||||
/// Capped: an unauthenticated caller must not be able to make the daemon
|
||||
/// buffer arbitrary bytes, and no legitimate login form approaches this.
|
||||
const MAX_FORM_BYTES: usize = 8 * 1024;
|
||||
|
||||
async fn read_form(req: Request<Body>) -> Option<Form> {
|
||||
let bytes = hyper::body::to_bytes(req.into_body()).await.ok()?;
|
||||
if bytes.len() > MAX_FORM_BYTES {
|
||||
return None;
|
||||
}
|
||||
let text = std::str::from_utf8(&bytes).ok()?;
|
||||
let mut form = Form::new();
|
||||
for pair in text.split('&') {
|
||||
let Some((k, v)) = pair.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
form.insert(percent_decode(k), percent_decode(v));
|
||||
}
|
||||
Some(form)
|
||||
}
|
||||
|
||||
fn percent_decode(input: &str) -> String {
|
||||
let bytes = input.replace('+', " ").into_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
|
||||
if let Some(byte) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
|
||||
out.push(byte);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
/// Forward an authorised request to the app on loopback.
|
||||
async fn proxy_to_app(req: Request<Body>, port: u16) -> Response<Body> {
|
||||
let path_and_query = req
|
||||
.uri()
|
||||
.path_and_query()
|
||||
.map(|p| p.as_str())
|
||||
.unwrap_or("/")
|
||||
.to_string();
|
||||
let uri = match format!("http://127.0.0.1:{port}{path_and_query}").parse::<hyper::Uri>() {
|
||||
Ok(uri) => uri,
|
||||
Err(_) => return bad_gateway(),
|
||||
};
|
||||
|
||||
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);
|
||||
parts.headers.remove(header::AUTHORIZATION);
|
||||
|
||||
let client = hyper::Client::new();
|
||||
match client.request(Request::from_parts(parts, body)).await {
|
||||
Ok(resp) => resp,
|
||||
Err(_) => bad_gateway(),
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// on the same host — and equally why an app on a *different* host (its
|
||||
// own onion) is a separate sign-in.
|
||||
if let Ok(value) =
|
||||
header::HeaderValue::from_str(&format!("session={token}; HttpOnly; SameSite=Lax; Path=/"))
|
||||
{
|
||||
resp.headers_mut().append(header::SET_COOKIE, value);
|
||||
}
|
||||
}
|
||||
|
||||
fn redirect_to_app() -> Response<Body> {
|
||||
Response::builder()
|
||||
.status(StatusCode::SEE_OTHER)
|
||||
.header(header::LOCATION, "/")
|
||||
.body(Body::empty())
|
||||
.expect("static response builds")
|
||||
}
|
||||
|
||||
fn bad_gateway() -> Response<Body> {
|
||||
Response::builder()
|
||||
.status(StatusCode::BAD_GATEWAY)
|
||||
.body(Body::from("app is not responding"))
|
||||
.expect("static response builds")
|
||||
}
|
||||
|
||||
fn not_found() -> Response<Body> {
|
||||
Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::empty())
|
||||
.expect("static response builds")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Minimal HTML escape for values interpolated into the pages below.
|
||||
fn esc(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
/// The app's icon as an `<img>`, or a lettermark when the manifest declares
|
||||
/// 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.
|
||||
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))
|
||||
}
|
||||
|
||||
/// 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,
|
||||
};
|
||||
for root in [
|
||||
"/opt/archipelago/web-ui/assets/img/app-icons",
|
||||
"web/dist/neode-ui/assets/img/app-icons",
|
||||
] {
|
||||
let candidate = std::path::Path::new(root).join(name);
|
||||
if let Ok(bytes) = std::fs::read(&candidate) {
|
||||
if bytes.len() > 512 * 1024 {
|
||||
return None;
|
||||
}
|
||||
return Some(format!("data:{mime};base64,{}", base64_encode(&bytes)));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn base64_encode(bytes: &[u8]) -> String {
|
||||
use base64::Engine;
|
||||
base64::engine::general_purpose::STANDARD.encode(bytes)
|
||||
}
|
||||
|
||||
fn page(title: &str, app: &GatedPort, body: &str, status: StatusCode) -> Response<Body> {
|
||||
let html = format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="en"><head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>{title} — {app_name}</title>
|
||||
<style>
|
||||
: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; }}
|
||||
</style></head>
|
||||
<body><main class="card">{body}</main></body></html>"#,
|
||||
title = esc(title),
|
||||
app_name = esc(&app.app_name),
|
||||
body = body,
|
||||
);
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
|
||||
// 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")
|
||||
.header(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'none'; img-src data:; style-src 'unsafe-inline'; form-action 'self'",
|
||||
)
|
||||
.body(Body::from(html))
|
||||
.expect("static response builds")
|
||||
}
|
||||
|
||||
/// The challenge. Names and pictures the app being opened, so the visitor can
|
||||
/// confirm what they are authenticating to rather than being asked for a
|
||||
/// password by an unexplained page.
|
||||
fn login_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Response<Body> {
|
||||
let body = format!(
|
||||
r#"{icon}
|
||||
<h1>Sign in to open {name}</h1>
|
||||
<p class="sub">This app is protected by your node password.</p>
|
||||
{err}
|
||||
<form method="post" action="{prefix}login">
|
||||
<input type="password" name="password" placeholder="Node password" autocomplete="current-password" autofocus required>
|
||||
<button type="submit">Sign in</button>
|
||||
</form>"#,
|
||||
icon = icon_markup(app),
|
||||
name = esc(&app.app_name),
|
||||
err = error
|
||||
.map(|e| format!(r#"<div class="err">{}</div>"#, esc(e)))
|
||||
.unwrap_or_default(),
|
||||
prefix = GATE_PREFIX,
|
||||
);
|
||||
page("Sign in", app, &body, status)
|
||||
}
|
||||
|
||||
/// Second factor. Reached only after the password verified, and the session
|
||||
/// backing it cannot authorise anything until this completes.
|
||||
fn totp_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Response<Body> {
|
||||
let body = format!(
|
||||
r#"{icon}
|
||||
<h1>Two-factor code</h1>
|
||||
<p class="sub">Enter the 6-digit code to open {name}.</p>
|
||||
{err}
|
||||
<form method="post" action="{prefix}totp">
|
||||
<input type="text" name="code" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" placeholder="000000" autofocus required>
|
||||
<button type="submit">Verify</button>
|
||||
</form>"#,
|
||||
icon = icon_markup(app),
|
||||
name = esc(&app.app_name),
|
||||
err = error
|
||||
.map(|e| format!(r#"<div class="err">{}</div>"#, esc(e)))
|
||||
.unwrap_or_default(),
|
||||
prefix = GATE_PREFIX,
|
||||
);
|
||||
page("Two-factor", app, &body, status)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn app() -> GatedPort {
|
||||
GatedPort {
|
||||
port: 8090,
|
||||
app_id: "strfry".to_string(),
|
||||
app_name: "Strfry Relay".to_string(),
|
||||
icon: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearer_token_is_parsed_case_insensitively() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::AUTHORIZATION, "Bearer abc123".parse().unwrap());
|
||||
assert_eq!(bearer_token(&headers), Some("abc123".to_string()));
|
||||
|
||||
headers.insert(header::AUTHORIZATION, "bearer abc123".parse().unwrap());
|
||||
assert_eq!(bearer_token(&headers), Some("abc123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_bearer_authorization_is_ignored() {
|
||||
let mut headers = HeaderMap::new();
|
||||
// An app's own Basic credential must never be mistaken for ours.
|
||||
headers.insert(header::AUTHORIZATION, "Basic dXNlcjpwYXNz".parse().unwrap());
|
||||
assert_eq!(bearer_token(&headers), None);
|
||||
headers.insert(header::AUTHORIZATION, "Bearer ".parse().unwrap());
|
||||
assert_eq!(bearer_token(&headers), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_page_names_the_app() {
|
||||
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
|
||||
let html = String::from_utf8_lossy(&body);
|
||||
assert!(html.contains("Sign in to open Strfry Relay"));
|
||||
// A lettermark stands in when the manifest declares no icon.
|
||||
assert!(html.contains("lettermark"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn page_escapes_app_names() {
|
||||
let mut app = app();
|
||||
app.app_name = r#"<script>alert(1)</script>"#.to_string();
|
||||
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);
|
||||
assert!(!html.contains("<script>alert"));
|
||||
assert!(html.contains("<script>"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn error_messages_are_escaped() {
|
||||
let resp = login_page(
|
||||
&app(),
|
||||
Some("<img src=x onerror=1>"),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
);
|
||||
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
|
||||
let html = String::from_utf8_lossy(&body);
|
||||
assert!(!html.contains("<img src=x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_pages_are_not_cacheable_or_framable() {
|
||||
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");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn form_parsing_decodes_percent_and_plus() {
|
||||
let req = Request::builder()
|
||||
.body(Body::from("password=a%40b+c&code=123456"))
|
||||
.unwrap();
|
||||
let form = read_form(req).await.unwrap();
|
||||
assert_eq!(field(&form, "password"), Some("a@b c".to_string()));
|
||||
assert_eq!(field(&form, "code"), Some("123456".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oversized_form_bodies_are_refused() {
|
||||
let req = Request::builder()
|
||||
.body(Body::from("x=".to_string() + &"a".repeat(MAX_FORM_BYTES)))
|
||||
.unwrap();
|
||||
assert!(read_form(req).await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_credential_is_challenged() {
|
||||
let gate = test_gate().await;
|
||||
assert_eq!(
|
||||
gate.authorize(&HeaderMap::new(), "strfry").await,
|
||||
Authorization::Challenge
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_valid_session_cookie_is_allowed() {
|
||||
let gate = test_gate().await;
|
||||
let token = gate.sessions.create().await;
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::COOKIE, format!("session={token}").parse().unwrap());
|
||||
assert_eq!(
|
||||
gate.authorize(&headers, "strfry").await,
|
||||
Authorization::Allow
|
||||
);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[tokio::test]
|
||||
async fn a_pending_2fa_session_is_challenged() {
|
||||
let gate = test_gate().await;
|
||||
let pending = gate.sessions.create_pending(vec![1, 2, 3]).await;
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::COOKIE,
|
||||
format!("session={pending}").parse().unwrap(),
|
||||
);
|
||||
assert_eq!(
|
||||
gate.authorize(&headers, "strfry").await,
|
||||
Authorization::Challenge
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_garbage_cookie_is_challenged() {
|
||||
let gate = test_gate().await;
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::COOKIE, "session=deadbeef".parse().unwrap());
|
||||
assert_eq!(
|
||||
gate.authorize(&headers, "strfry").await,
|
||||
Authorization::Challenge
|
||||
);
|
||||
}
|
||||
|
||||
async fn test_gate() -> AppGate {
|
||||
let dir = std::env::temp_dir().join(format!("appgate-test-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
AppGate::new(
|
||||
SessionStore::new().await,
|
||||
AuthManager::new(dir.clone()),
|
||||
LoginRateLimiter::new(),
|
||||
dir,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,9 @@ pub struct User {
|
||||
pub role: UserRole,
|
||||
}
|
||||
|
||||
/// Cloneable: it holds only the data dir, and the app gate needs its own
|
||||
/// handle to verify passwords on a different port from the JSON-RPC path.
|
||||
#[derive(Clone)]
|
||||
pub struct AuthManager {
|
||||
data_dir: PathBuf,
|
||||
}
|
||||
|
||||
@@ -252,8 +252,24 @@ async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
|
||||
} else {
|
||||
info!(companion = spec.name, "building locally from {dir}");
|
||||
}
|
||||
// Stamp the context mtime we are building, so the staleness
|
||||
// check has something that advances even when every layer is a
|
||||
// cache hit. Without this the rebuild is a no-op that leaves
|
||||
// .Created unchanged, the check stays true, and the companion is
|
||||
// rebuilt on every reconcile tick forever.
|
||||
let context_stamp = newest_mtime_unix(PathBuf::from(dir))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let stamp_label = format!("{CONTEXT_STAMP_LABEL}={context_stamp}");
|
||||
let out = command_output_with_timeout(
|
||||
Command::new("podman").args(["build", "-t", &local_image, dir]),
|
||||
Command::new("podman").args([
|
||||
"build",
|
||||
"--label",
|
||||
&stamp_label,
|
||||
"-t",
|
||||
&local_image,
|
||||
dir,
|
||||
]),
|
||||
COMPANION_BUILD_TIMEOUT,
|
||||
"podman build companion image",
|
||||
)
|
||||
@@ -322,17 +338,58 @@ async fn image_exists(image: &str) -> bool {
|
||||
/// already-built `image`, signalling the cached image is stale and must be
|
||||
/// rebuilt. Conservative: if either timestamp can't be determined we return
|
||||
/// false (reuse the cache) to avoid rebuild storms on every reconcile pass.
|
||||
/// Label carrying the context mtime an image was built from.
|
||||
///
|
||||
/// The reason this exists rather than reusing `.Created`: a rebuild whose
|
||||
/// layers all hit the cache produces the SAME image, and podman leaves its
|
||||
/// creation time untouched. Comparing against `.Created` therefore never
|
||||
/// converges — the rebuild does not change the thing being tested, so the
|
||||
/// companion is rebuilt on every reconcile tick indefinitely. A label is part
|
||||
/// of the image config, so writing a new value always yields a new image,
|
||||
/// which makes the comparison settle after exactly one rebuild.
|
||||
const CONTEXT_STAMP_LABEL: &str = "org.archipelago.context-mtime";
|
||||
|
||||
async fn context_is_newer_than_image(dir: &str, image: &str) -> bool {
|
||||
let image_created = match image_created_unix(image).await {
|
||||
Some(t) => t,
|
||||
None => return false,
|
||||
let Some(ctx) = newest_mtime_unix(PathBuf::from(dir)).await else {
|
||||
return false;
|
||||
};
|
||||
match newest_mtime_unix(PathBuf::from(dir)).await {
|
||||
Some(ctx) => ctx > image_created,
|
||||
// Preferred: what the last build actually stamped.
|
||||
if let Some(stamped) = image_context_stamp(image).await {
|
||||
return ctx > stamped;
|
||||
}
|
||||
// Images built before stamping existed have no label. Fall back to the
|
||||
// old comparison so behaviour is unchanged for them; the rebuild it
|
||||
// triggers writes the label, so each such image self-heals exactly once.
|
||||
match image_created_unix(image).await {
|
||||
Some(created) => ctx > created,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The context mtime stamped into `image` at build time, if any.
|
||||
async fn image_context_stamp(image: &str) -> Option<i64> {
|
||||
let format = format!("{{{{index .Config.Labels \"{CONTEXT_STAMP_LABEL}\"}}}}");
|
||||
let mut cmd = Command::new("podman");
|
||||
cmd.args(["image", "inspect", "--format", &format, image]);
|
||||
let out = command_output_with_timeout(
|
||||
&mut cmd,
|
||||
COMPANION_IMAGE_CHECK_TIMEOUT,
|
||||
"podman image context stamp",
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let raw = String::from_utf8_lossy(&out.stdout);
|
||||
let raw = raw.trim();
|
||||
// podman prints "<no value>" for a missing label.
|
||||
if raw.is_empty() || raw == "<no value>" {
|
||||
return None;
|
||||
}
|
||||
raw.parse::<i64>().ok()
|
||||
}
|
||||
|
||||
/// Build timestamp of `image` as Unix seconds, via `podman image inspect`.
|
||||
async fn image_created_unix(image: &str) -> Option<i64> {
|
||||
let mut cmd = Command::new("podman");
|
||||
|
||||
@@ -4435,6 +4435,8 @@ mod tests {
|
||||
container,
|
||||
protocol: "tcp".to_string(),
|
||||
bind: String::new(),
|
||||
auth: None,
|
||||
auth_rationale: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,29 @@ pub struct DeviceToken {
|
||||
pub hash: String,
|
||||
/// Unix seconds at mint time.
|
||||
pub created: u64,
|
||||
/// App ids this token may reach through the app gate.
|
||||
///
|
||||
/// `None` means node-wide, which is what every companion pairing token
|
||||
/// is and what tokens minted before scoping existed remain — the field
|
||||
/// is absent from their stored JSON and deserialises to `None`. A
|
||||
/// migration that guessed a scope for them would silently revoke access
|
||||
/// the operator never asked to revoke.
|
||||
///
|
||||
/// `Some(list)` restricts the token to exactly those apps, which is the
|
||||
/// point of scoping: a token handed to Home Assistant so it can poll one
|
||||
/// app's API should not also open every other app on the node.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub apps: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl DeviceToken {
|
||||
/// Whether this token may reach `app_id`.
|
||||
pub fn allows_app(&self, app_id: &str) -> bool {
|
||||
match &self.apps {
|
||||
None => true,
|
||||
Some(apps) => apps.iter().any(|a| a == app_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tokens_path(data_dir: &Path) -> PathBuf {
|
||||
@@ -61,6 +84,22 @@ fn ct_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
/// replaced, so re-showing the pairing QR never piles up stale entries.
|
||||
/// Returns the plaintext token — the only time it ever exists outside the QR.
|
||||
pub async fn create(data_dir: &Path, name: &str) -> Result<String> {
|
||||
create_scoped(data_dir, name, None).await
|
||||
}
|
||||
|
||||
/// Mint a token limited to `apps`, for a machine client that needs one app's
|
||||
/// HTTP API and nothing else. `None` mints the node-wide token `create` does.
|
||||
pub async fn create_scoped(
|
||||
data_dir: &Path,
|
||||
name: &str,
|
||||
apps: Option<Vec<String>>,
|
||||
) -> Result<String> {
|
||||
// An empty list would be indistinguishable from "no restriction" to a
|
||||
// careless reader while actually authorising nothing — reject it rather
|
||||
// than mint a token whose behaviour nobody can predict from its record.
|
||||
if apps.as_ref().is_some_and(|a| a.is_empty()) {
|
||||
anyhow::bail!("a scoped device token must name at least one app");
|
||||
}
|
||||
// KEY-05: a device token is a bearer credential — its unpredictability is
|
||||
// the whole of its security — so the source is named and the draw guarded.
|
||||
let mut token_bytes = [0u8; 32];
|
||||
@@ -81,6 +120,7 @@ pub async fn create(data_dir: &Path, name: &str) -> Result<String> {
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0),
|
||||
apps,
|
||||
});
|
||||
save(data_dir, &tokens).await?;
|
||||
Ok(token)
|
||||
@@ -96,6 +136,22 @@ pub async fn verify(data_dir: &Path, candidate: &str) -> Option<String> {
|
||||
.map(|t| t.name.clone())
|
||||
}
|
||||
|
||||
/// Verify a candidate token **for a specific app**, as the app gate does.
|
||||
/// Returns the device name when the token is valid *and* in scope.
|
||||
///
|
||||
/// Separate from `verify` on purpose: `verify` answers "is this a real
|
||||
/// token", which is the right question for node login, and would be the
|
||||
/// wrong question here — a token scoped to one app would otherwise open
|
||||
/// every app.
|
||||
pub async fn verify_for_app(data_dir: &Path, candidate: &str, app_id: &str) -> Option<String> {
|
||||
let candidate_hash = hash_hex(candidate);
|
||||
load(data_dir)
|
||||
.await
|
||||
.iter()
|
||||
.find(|t| ct_eq(t.hash.as_bytes(), candidate_hash.as_bytes()) && t.allows_app(app_id))
|
||||
.map(|t| t.name.clone())
|
||||
}
|
||||
|
||||
/// List stored tokens (hashes only — plaintexts are unrecoverable).
|
||||
pub async fn list(data_dir: &Path) -> Vec<DeviceToken> {
|
||||
load(data_dir).await
|
||||
|
||||
@@ -380,7 +380,7 @@ mod tests {
|
||||
fn build_local_state_filters_non_trusted_peers() {
|
||||
let peers = vec![
|
||||
FederatedNode {
|
||||
trust_source: None,
|
||||
trust_source: None,
|
||||
did: "did:key:zTrusted".into(),
|
||||
pubkey: "aa".into(),
|
||||
onion: "t.onion".into(),
|
||||
@@ -396,7 +396,7 @@ mod tests {
|
||||
last_sync_error_at: None,
|
||||
},
|
||||
FederatedNode {
|
||||
trust_source: None,
|
||||
trust_source: None,
|
||||
did: "did:key:zObserver".into(),
|
||||
pubkey: "bb".into(),
|
||||
onion: "o.onion".into(),
|
||||
@@ -412,7 +412,7 @@ mod tests {
|
||||
last_sync_error_at: None,
|
||||
},
|
||||
FederatedNode {
|
||||
trust_source: None,
|
||||
trust_source: None,
|
||||
did: "did:key:zUntrusted".into(),
|
||||
pubkey: "cc".into(),
|
||||
onion: "u.onion".into(),
|
||||
@@ -457,7 +457,7 @@ mod tests {
|
||||
super::super::storage::save_nodes(
|
||||
dir.path(),
|
||||
&[FederatedNode {
|
||||
trust_source: None,
|
||||
trust_source: None,
|
||||
did: "did:key:zSource".into(),
|
||||
pubkey: "aa".into(),
|
||||
onion: "source.onion".into(),
|
||||
|
||||
@@ -27,6 +27,7 @@ use tracing::info;
|
||||
|
||||
mod api;
|
||||
mod app_ops;
|
||||
mod appgate;
|
||||
mod assistant;
|
||||
mod auth;
|
||||
mod avatar;
|
||||
|
||||
@@ -1901,8 +1901,23 @@ impl MeshService {
|
||||
// • Meshcore stock client → plain text (can't decode our envelope).
|
||||
// Rich typed messages (invoice/coordinate/reaction/…) always use the
|
||||
// typed-wire path via `send_typed_wire`; only plain Text is routed here.
|
||||
let use_typed_envelope =
|
||||
archy && matches!(device_type, DeviceType::Meshcore | DeviceType::Reticulum);
|
||||
// A federation-synthetic contact ALWAYS takes the typed path, whatever
|
||||
// radio (if any) is attached. `send_typed_wire` is the only routing
|
||||
// that knows about FIPS/Tor, and it still prefers a reachable LoRa
|
||||
// twin when the payload fits — so this loses no radio-first behaviour.
|
||||
//
|
||||
// Without this, a plain text message to a federated peer fell through
|
||||
// to `peer_dest_prefix`, which resolves a RADIO routing key. On a node
|
||||
// running Meshtastic — or with no radio at all — that fails, which is
|
||||
// why peering a node was not enough to message it: you had to meet it
|
||||
// over LoRa first so a radio twin existed to route through. Federation
|
||||
// peers are reachable off-radio by definition (that is what
|
||||
// `upsert_federation_peer` records with `reachable: true`), so the
|
||||
// transport choice must not depend on which radio is plugged in.
|
||||
let is_federation_contact = contact_id & 0x8000_0000 != 0;
|
||||
let use_typed_envelope = archy
|
||||
&& (is_federation_contact
|
||||
|| matches!(device_type, DeviceType::Meshcore | DeviceType::Reticulum));
|
||||
if use_typed_envelope {
|
||||
// Sign with our archipelago identity so the receiver can authenticate
|
||||
// us over LoRa (verifies against our bound `arch_pubkey_hex`). `with_seq`
|
||||
@@ -2360,6 +2375,50 @@ async fn bitcoin_rpc_getblockheader_by_height(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
/// Item 5: a federated/trusted peer must be messageable as soon as it is
|
||||
/// peered — no LoRa meeting first.
|
||||
///
|
||||
/// The routing predicate in `send_message` decides whether a plain text
|
||||
/// message takes the federation-aware typed path (which knows FIPS/Tor and
|
||||
/// still prefers a reachable radio twin) or the radio-only path, which
|
||||
/// resolves an over-the-air routing key and cannot work for a peer we have
|
||||
/// never heard on the radio.
|
||||
///
|
||||
/// It previously keyed on the attached radio, so on a Meshtastic node — or
|
||||
/// one with no radio at all — a federated peer fell to the radio path and
|
||||
/// the send failed. Federation contacts are reachable off-radio by
|
||||
/// definition, so the choice must not depend on which radio is plugged in.
|
||||
#[test]
|
||||
fn federation_contacts_take_the_off_radio_path_on_any_device() {
|
||||
fn uses_typed_path(contact_id: u32, archy: bool, device: DeviceType) -> bool {
|
||||
let is_federation_contact = contact_id & 0x8000_0000 != 0;
|
||||
archy
|
||||
&& (is_federation_contact
|
||||
|| matches!(device, DeviceType::Meshcore | DeviceType::Reticulum))
|
||||
}
|
||||
let fed = super::federation_peer_contact_id(&"ab".repeat(32));
|
||||
assert!(fed >= FEDERATION_CONTACT_ID_BASE);
|
||||
|
||||
// The cases that used to fail: peered node, wrong radio or none.
|
||||
for device in [
|
||||
DeviceType::Meshtastic,
|
||||
DeviceType::Unknown,
|
||||
DeviceType::Meshcore,
|
||||
DeviceType::Reticulum,
|
||||
] {
|
||||
assert!(
|
||||
uses_typed_path(fed, true, device),
|
||||
"federation peer must route off-radio on {device:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// A plain radio contact on a stock-text device still takes the radio
|
||||
// path — this fix must not reroute ordinary LoRa chats.
|
||||
assert!(!uses_typed_path(42, true, DeviceType::Meshtastic));
|
||||
// And a stock (non-archy) client is never given a typed envelope.
|
||||
assert!(!uses_typed_path(42, false, DeviceType::Meshcore));
|
||||
}
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1068,6 +1068,19 @@ impl Server {
|
||||
// Podman needs and can restart-loop apps that publish those ports.
|
||||
let relay_task = tokio::spawn(app_port_v6_relay_loop(tx.subscribe()));
|
||||
|
||||
// The app gate: authentication in front of every app port, on every
|
||||
// address the node answers on. It can only claim a port whose app has
|
||||
// been pinned to loopback in its manifest — see appgate::listener for
|
||||
// why the rollout is necessarily per-app — and it logs a warning plus
|
||||
// records `GateStatus::unprotected` for every port it cannot claim,
|
||||
// so a partially-rolled-out gate is visible rather than silently
|
||||
// ineffective.
|
||||
let gate_task = tokio::spawn(crate::appgate::listener::run(
|
||||
self.api_handler.rpc_handler().app_gate.clone(),
|
||||
crate::appgate::listener::shared_status(),
|
||||
tx.subscribe(),
|
||||
));
|
||||
|
||||
let peer_task = tokio::spawn(peer_late_bind_loop(
|
||||
self.api_handler.clone(),
|
||||
active_connections.clone(),
|
||||
@@ -1094,6 +1107,10 @@ impl Server {
|
||||
let _ = t.await;
|
||||
}
|
||||
relay_task.abort();
|
||||
// Aborted rather than awaited, like the relay loop: the sweep sleeps
|
||||
// up to a minute between ticks and its accept loops exit on the
|
||||
// shutdown watch, so awaiting it would stall the drain for no gain.
|
||||
gate_task.abort();
|
||||
let _ = peer_task.await;
|
||||
|
||||
info!("Shutdown complete");
|
||||
|
||||
+107
-14
@@ -530,6 +530,37 @@ pub enum PortAuth {
|
||||
/// (Bitcoin p2p gossip, mDNS). Requires `auth_rationale`: an exemption
|
||||
/// nobody can explain is an exemption nobody reviewed.
|
||||
None,
|
||||
/// Host-local by intent — the gate must not bind this port at all.
|
||||
///
|
||||
/// This exists because `bind: 127.0.0.1` is ambiguous on its own, and
|
||||
/// reading intent out of it would be wrong in both directions. Two
|
||||
/// unrelated situations produce an identical loopback publish:
|
||||
///
|
||||
/// * Bitcoin's RPC 8332 is loopback-pinned so that the LAN *cannot*
|
||||
/// reach it. Fronting it with the gate would newly expose it on every
|
||||
/// host address — behind a login, but exposed where it deliberately
|
||||
/// was not.
|
||||
/// * A gated app is loopback-pinned precisely *so that* the gate can
|
||||
/// take over its external addresses; that is the whole migration.
|
||||
///
|
||||
/// Inferring from `bind` would break one or the other, so the intent is
|
||||
/// declared. `Local` means the first case: never externally reachable,
|
||||
/// gate keeps its hands off.
|
||||
Local,
|
||||
/// The app publishes on loopback ONLY, and the daemon owns this port's
|
||||
/// external addresses — bind them and authenticate every connection.
|
||||
///
|
||||
/// This is the migrated end state, and it is opt-in for a reason. The
|
||||
/// gate binding an address is the one action that can make a port
|
||||
/// reachable where it previously was not, so it must never be something
|
||||
/// a manifest gets by default or by inference. An earlier revision
|
||||
/// gated any `session` port regardless of `bind`, which meant a node
|
||||
/// whose manifests had not yet been updated saw the daemon publish
|
||||
/// Bitcoin's loopback-only RPC on every host address (caught on
|
||||
/// archi-dev-box 2026-08-03, seconds after deploy). Requiring the
|
||||
/// manifest to say so means the loopback pin and the daemon takeover
|
||||
/// ship together, atomically, and a stale manifest fails safe.
|
||||
Gated,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -545,10 +576,24 @@ pub struct PortMapping {
|
||||
/// containers keep reaching it via `host.archipelago`).
|
||||
#[serde(default)]
|
||||
pub bind: String,
|
||||
/// Whether the app gate authenticates connections to this port.
|
||||
/// Omitted = `session` (protected). See [`PortAuth`].
|
||||
#[serde(default)]
|
||||
pub auth: PortAuth,
|
||||
/// Declared authentication policy, or `None` when the manifest says
|
||||
/// nothing at all.
|
||||
///
|
||||
/// The distinction is load-bearing and was learned the hard way. A node's
|
||||
/// installed manifests always lag the binary, so "absent" is the state of
|
||||
/// essentially every port on every node until a signed catalog delivers
|
||||
/// otherwise. Treating absent as a *value* meant the daemon acted on a
|
||||
/// default the manifest never asked for: first republishing Bitcoin's
|
||||
/// loopback-only RPC across the LAN, then — caught before it shipped —
|
||||
/// preparing to pin LND's gRPC and REST to loopback, which would have
|
||||
/// broken Zeus and every remote wallet.
|
||||
///
|
||||
/// So absent means "no instruction", and the daemon may only ever REPORT
|
||||
/// on such a port, never change how it is published. Use
|
||||
/// [`PortMapping::auth_policy`] for classification and
|
||||
/// [`PortMapping::auth_is_declared`] before acting.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub auth: Option<PortAuth>,
|
||||
/// Why this port is safe to expose unauthenticated. **Required** when
|
||||
/// `auth` is `none`, rejected otherwise — a rationale on a gated port
|
||||
/// means the author expected an exemption they did not get.
|
||||
@@ -556,6 +601,22 @@ pub struct PortMapping {
|
||||
pub auth_rationale: Option<String>,
|
||||
}
|
||||
|
||||
impl PortMapping {
|
||||
/// Policy to classify this port by. An undeclared port reports as
|
||||
/// `Session` — i.e. it shows up in the audit as something that *should*
|
||||
/// be behind the gate — because reporting an unprotected port is always
|
||||
/// safe. Acting on it is not; see [`Self::auth_is_declared`].
|
||||
pub fn auth_policy(&self) -> PortAuth {
|
||||
self.auth.unwrap_or(PortAuth::Session)
|
||||
}
|
||||
|
||||
/// Whether the manifest actually stated a policy. Required before the
|
||||
/// daemon rewrites how a port is published: silence is not consent.
|
||||
pub fn auth_is_declared(&self) -> bool {
|
||||
self.auth.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(u16, u16)> for PortMapping {
|
||||
fn from((host, container): (u16, u16)) -> Self {
|
||||
PortMapping {
|
||||
@@ -563,7 +624,7 @@ impl From<(u16, u16)> for PortMapping {
|
||||
container,
|
||||
protocol: "tcp".to_string(),
|
||||
bind: String::new(),
|
||||
auth: PortAuth::Session,
|
||||
auth: None,
|
||||
auth_rationale: None,
|
||||
}
|
||||
}
|
||||
@@ -1067,7 +1128,7 @@ fn validate_ports(ports: &[PortMapping]) -> Result<(), ManifestError> {
|
||||
// exists in the manifest for every exempt port, so auditing the
|
||||
// node's unauthenticated surface is reading a list, not inferring
|
||||
// one from silence.
|
||||
match (port.auth, port.auth_rationale.as_ref()) {
|
||||
match (port.auth_policy(), port.auth_rationale.as_ref()) {
|
||||
(PortAuth::None, None) => {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"ports[{i}] sets auth: none but no auth_rationale — an unauthenticated \
|
||||
@@ -1636,7 +1697,7 @@ app:
|
||||
let yaml = std::fs::read_to_string(&manifest).expect("manifest readable");
|
||||
let parsed = AppManifest::parse(&yaml).expect("manifest valid");
|
||||
for port in &parsed.app.ports {
|
||||
if port.auth == PortAuth::None {
|
||||
if port.auth_policy() == PortAuth::None {
|
||||
exempt.push((parsed.app.id.clone(), port.host));
|
||||
}
|
||||
}
|
||||
@@ -1650,13 +1711,45 @@ app:
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_auth_defaults_to_session() {
|
||||
// The whole point of the default: a manifest that says nothing about
|
||||
// auth must come out PROTECTED, not exposed. If this ever flips,
|
||||
// every existing app silently loses its gate.
|
||||
fn an_undeclared_port_classifies_as_session_but_is_not_declared() {
|
||||
// Two different questions, and conflating them caused both gate
|
||||
// incidents. A manifest that says nothing must CLASSIFY as gated, so
|
||||
// the audit reports it as something that should be protected — but it
|
||||
// must not read as an instruction the daemon may act on.
|
||||
let manifest = manifest_with_port(" - host: 8080\n container: 80\n").unwrap();
|
||||
assert_eq!(manifest.app.ports[0].auth, PortAuth::Session);
|
||||
assert!(manifest.app.ports[0].auth_rationale.is_none());
|
||||
let port = &manifest.app.ports[0];
|
||||
assert_eq!(port.auth_policy(), PortAuth::Session, "reports as gated");
|
||||
assert!(!port.auth_is_declared(), "but is NOT an instruction");
|
||||
assert!(port.auth.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_session_declaration_is_actionable() {
|
||||
let manifest =
|
||||
manifest_with_port(" - host: 8080\n container: 80\n auth: session\n")
|
||||
.unwrap();
|
||||
let port = &manifest.app.ports[0];
|
||||
assert_eq!(port.auth_policy(), PortAuth::Session);
|
||||
assert!(port.auth_is_declared());
|
||||
}
|
||||
|
||||
/// The wallet constraint, in the form that actually bit. LND's gRPC and
|
||||
/// REST carry `bind: ""`, so a rule keyed on `bind` alone does not save
|
||||
/// them — and on a node whose manifest predates the auth field there is
|
||||
/// no `auth: none` either. Undeclared must therefore be untouchable, or
|
||||
/// recreating LND silently pins those ports to loopback and every remote
|
||||
/// wallet stops working.
|
||||
#[test]
|
||||
fn an_undeclared_wallet_port_is_never_actionable() {
|
||||
let manifest =
|
||||
manifest_with_port(" - host: 10009\n container: 10009\n protocol: tcp\n")
|
||||
.unwrap();
|
||||
let port = &manifest.app.ports[0];
|
||||
assert!(port.bind.is_empty(), "this is the shape that bit us");
|
||||
assert!(
|
||||
!port.auth_is_declared(),
|
||||
"an undeclared port must never authorise republishing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1683,7 +1776,7 @@ app:
|
||||
" - host: 8333\n container: 8333\n auth: none\n auth_rationale: Bitcoin p2p gossip\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(manifest.app.ports[0].auth, PortAuth::None);
|
||||
assert_eq!(manifest.app.ports[0].auth, Some(PortAuth::None));
|
||||
assert_eq!(
|
||||
manifest.app.ports[0].auth_rationale.as_deref(),
|
||||
Some("Bitcoin p2p gossip")
|
||||
|
||||
@@ -318,6 +318,42 @@ impl PodmanClient {
|
||||
"sctp" => "sctp",
|
||||
_ => "tcp",
|
||||
};
|
||||
// Effective bind. A gated port with no declared bind would
|
||||
// publish 0.0.0.0 — the app would own every host address, which
|
||||
// is both the exposure itself and the reason the daemon's app
|
||||
// gate cannot bind those addresses to authenticate them. Pin it
|
||||
// to loopback so the gate can take the external addresses.
|
||||
//
|
||||
// Doing it HERE, at container creation, is the point: the pin and
|
||||
// the gate's takeover then both come from the daemon and cannot
|
||||
// disagree. The earlier attempt put this decision in manifest
|
||||
// data instead, and a node whose manifests lagged the binary
|
||||
// published Bitcoin's loopback-only RPC across the LAN
|
||||
// (archi-dev-box, 2026-08-03).
|
||||
//
|
||||
// A port that already declares a bind is never overridden — that
|
||||
// is exactly what keeps `bind: 127.0.0.1` ports host-local and
|
||||
// leaves `auth: none` protocol ports (LND gRPC/REST, electrum)
|
||||
// published as they are, so remote wallets keep working.
|
||||
// NOTE: the daemon deliberately does NOT rewrite this. Pinning a
|
||||
// published port to loopback is how an app hands its external
|
||||
// addresses to the gate, but it belongs in the manifest, not in
|
||||
// daemon-side inference:
|
||||
//
|
||||
// * `bind` is already honoured by every publish path (here and
|
||||
// in package::install), so a manifest edit needs no code.
|
||||
// * inference here would cover only THIS path — proven on
|
||||
// archi-dev-box, where a recreate went through another one and
|
||||
// the pin never applied.
|
||||
// * and inferring from an ABSENT field is what republished
|
||||
// Bitcoin's loopback RPC across the LAN, and came within one
|
||||
// container-recreate of pinning LND's gRPC/REST and breaking
|
||||
// every remote wallet.
|
||||
//
|
||||
// So the migration ships as `bind: 127.0.0.1` in the signed
|
||||
// catalog. Verified 2026-08-03 that a disk-only manifest edit is
|
||||
// overridden by the catalog, which is precisely why the catalog is
|
||||
// the right and only place to carry it.
|
||||
let mut mapping = serde_json::json!({
|
||||
"container_port": port.container,
|
||||
"host_port": port.host,
|
||||
|
||||
Reference in New Issue
Block a user