feat(security): app gate — authenticate every app port
Demo images / Build & push demo images (push) Successful in 4m18s
Demo images / Build & push demo images (push) Successful in 4m18s
Reproduced again on this node today: with no session cookie, six app ports answered HTTP 200 with their real UIs (18083 LND, 8334, 8175 Fedimint Guardian, 8336 FIPS Mesh, 8090, 7777), all bound 0.0.0.0 and so served on every host address. Same bug class as the /lnd-connect-info and /bitcoin-rpc/ leaks closed in v1.7.120, but across every app. LAN, Tailscale, Tor and the FIPS mesh all converge on 127.0.0.1:<port>, so this is one gate rather than four. It lives in the daemon rather than a per-app sidecar (umbrel's app_proxy model): rootless, no extra container per app, and it can reuse machinery that already exists. It invents no authentication policy. verify_password, TOTP secret decryption, verify_code with used-step replay protection, the session store, and — importantly — the SAME LoginRateLimiter instance as the JSON-RPC path, so an attacker cannot get a fresh budget of password guesses by moving to an app port. Only the transport differs, an HTML form instead of JSON-RPC, because a browser being sent to an app cannot speak JSON-RPC. 2FA comes for free: a session still pending its TOTP step fails validate(), so the gate rejects it without knowing what a second factor is. Details worth keeping: - 401, not 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 their API response. - Cookie and Authorization are stripped before proxying. The app has no use for the node session and must never be able to log or forward it. - The challenge page names and pictures the app being opened, so the visitor can confirm what they are authenticating to. - device_tokens grew `apps: Option<Vec<String>>` and verify_for_app for machine clients. None = node-wide, which every existing companion token is; migrating them by guessing a scope would silently revoke access nobody asked to revoke. An empty list is rejected rather than minted, since it reads as unrestricted while authorising nothing. The rollout is necessarily per-app and the gate is built to say so. A container publishing 0.0.0.0:<port> claims every host address, so the gate cannot bind that port until the app is pinned to bind: 127.0.0.1 and recreated — gate-first is impossible, and all-at-once would recreate every container on a node simultaneously. Every port it cannot claim is logged at warn each sweep and recorded in GateStatus::unprotected, surfaced by security.app-gate-status. The failure mode being designed against is a gate that binds nothing, logs at debug, and reports success while every app stays exactly as open as before — worse than no gate, because it stops anyone looking. Same reasoning that ruled out an nft drop-in, whose absence is a silent no-op. Not yet done: pinning the 39 gated ports to loopback, repointing HiddenServicePort at the gate, and on-node verification. Tests: 21/21 appgate, workspace builds clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
63d0183dd2
commit
0de67ca6ae
@@ -156,13 +156,86 @@ The gate is mostly assembly, not invention:
|
||||
So the new code is: the listener/redirect, the app-identification step (which app is this port?),
|
||||
the login page render (app name + icon), and per-app scoping on `device_tokens`.
|
||||
|
||||
#### Research — StartOS: **NOT YET VERIFIED**
|
||||
#### Research — StartOS: **DROPPED** (operator, 2026-08-03)
|
||||
|
||||
Their public docs cover the *addressing* model (per-service `.onion` and `.local`
|
||||
addresses, an explicit "make public" opt-in for clearnet) but do not state whether a
|
||||
universal auth layer sits in front of service interfaces, and the source could not be
|
||||
read from this box (`gh` is not installed, and raw GitHub paths 404'd). **Do not assume
|
||||
they delegate auth to each service — read `Start9Labs/start-os` before designing.**
|
||||
"don't need the startOS research we decided on a approach already." The umbrelOS read
|
||||
plus the design decision above settled it; no further prior-art work.
|
||||
|
||||
### 1b. Manifest declaration of unauthenticated ports — **DONE** (`0c4826f8`, pushed)
|
||||
|
||||
`PortMapping` grew `auth` (`session` | `none`, defaulting to **`session`**) and
|
||||
`auth_rationale`. The default is the protected one, so exposure is now something a
|
||||
manifest has to ask for rather than something it gets by saying nothing.
|
||||
|
||||
Validation is two-sided: `auth: none` without a rationale is rejected, **and** a
|
||||
rationale without `auth: none` is rejected — that combination means the author wrote an
|
||||
exemption and did not get one, and shipping it silently would leave them believing
|
||||
otherwise.
|
||||
|
||||
**17 ports across 12 apps are exempt**, each with its reason: Lightning p2p (BOLT-8
|
||||
noise), LND gRPC 10009 / REST 18080 and CLN gRPC 9835 (macaroon / mutual TLS — this is
|
||||
what keeps Zeus and remote wallets working), Bitcoin p2p 8333, electrum 50001, the three
|
||||
Wyoming voice ports, git-over-SSH 2222, and the UDP discovery protocols (mDNS 5353,
|
||||
SSDP 1900, STUN 3478). **The other 39 published ports now default to gated.**
|
||||
|
||||
Bitcoin RPC 8332 is deliberately *not* exempted: it is already `bind: 127.0.0.1`, so the
|
||||
gate never sees it, and claiming an exemption it does not need would put a meaningless
|
||||
line in the audit list. If that bind is ever dropped it fails closed.
|
||||
|
||||
Two corpus tests pin this: every shipped manifest must parse, and the exempt set is
|
||||
frozen at 17 so the node's unauthenticated surface cannot grow by accident.
|
||||
|
||||
### 1c. The gate itself — **IN PROGRESS**
|
||||
|
||||
`core/archipelago/src/appgate/` — `identity.rs` (port → app id/name/icon, gated vs
|
||||
exempt, re-read from manifests so a catalog refresh applies without a restart),
|
||||
`mod.rs` (authorize + login page + TOTP step + reverse proxy), `listener.rs` (binds the
|
||||
external addresses, sweeps every 60s).
|
||||
|
||||
Design points worth not re-deriving:
|
||||
|
||||
- **It invents no auth policy.** `verify_password`, `totp::decrypt_secret`,
|
||||
`verify_code` + used-step replay protection, `SessionStore::create/create_pending/
|
||||
upgrade_to_full`, and the *same* `LoginRateLimiter` instance as the JSON-RPC path.
|
||||
Only the transport differs (HTML form vs JSON-RPC), because a browser being redirected
|
||||
to an app cannot speak JSON-RPC. Sharing the limiter matters: otherwise an attacker
|
||||
gets a fresh budget of password guesses by moving to an app port.
|
||||
- **2FA is free.** A session still pending its TOTP step fails `validate()`, so the gate
|
||||
rejects it without knowing anything about second factors.
|
||||
- **Cookies ignore port.** The session cookie is host-only with no `Domain`, so one
|
||||
sign-in covers the dashboard and every app port on the same host. The corollary is
|
||||
that an app reached on a *different* host — its own onion — is a separate sign-in.
|
||||
- **401, not 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 their API
|
||||
response.
|
||||
- **The gate strips `Cookie` and `Authorization` before proxying.** The app has no use
|
||||
for the node session and must never be in a position to log or forward it.
|
||||
- **Machine clients**: `device_tokens` grew `apps: Option<Vec<String>>` and
|
||||
`verify_for_app`. `None` = node-wide (what every existing companion token is —
|
||||
migrating them by guessing a scope would silently revoke access nobody asked to
|
||||
revoke); `Some(list)` restricts to those apps. An empty list is rejected rather than
|
||||
minted, since it would read as "unrestricted" while authorising nothing.
|
||||
|
||||
#### ⚠️ The ordering constraint that shapes the rollout
|
||||
|
||||
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.
|
||||
So the gate can only stand in front of an app whose publish has been pinned to loopback
|
||||
(`bind: 127.0.0.1`) and whose container has been recreated. Gate-first is not possible;
|
||||
all-apps-at-once would recreate every container on the node simultaneously.
|
||||
|
||||
Therefore the rollout is **per app**, and the gate is built to be honest about being
|
||||
partially deployed: a port it cannot claim is logged at **warn** every sweep and recorded
|
||||
in `GateStatus::unprotected`. The failure mode this exists to prevent is a gate that
|
||||
binds nothing, logs at debug, and reports success while every app stays exactly as open
|
||||
as before — worse than no gate, because it stops anyone looking. (Same reasoning that
|
||||
killed the nft drop-in: `/etc/fips/fips.nft` is provisioned out-of-band and its absence
|
||||
is a silent no-op.)
|
||||
|
||||
**Still open on this item:** pin the 39 gated ports to loopback app-by-app, repoint
|
||||
`HiddenServicePort` at the gate (Tor connects *from* loopback, so a loopback-exempt
|
||||
redirect will not catch it, and the mapping loses the original destination port), gate
|
||||
the FIPS relay path, surface `GateStatus` in the UI, and verify on a real node.
|
||||
|
||||
### 2. Filebrowser ships an insecure default login — **OPEN**
|
||||
- Change the default credential **without breaking the dashboard's Cloud view**, which
|
||||
@@ -289,6 +362,51 @@ The whole update *pipeline* is built and is already independent of OTA:
|
||||
3. **The detail-page affordance** — same treatment as the card.
|
||||
4. **Button copy**: "See update" rather than "Update".
|
||||
|
||||
### 6b. Multiversion for ALL apps + upstream release discovery — **OPEN** (operator, 2026-08-03)
|
||||
> "we also need a way to provide multiversion support for all apps and it automatically
|
||||
> pulls the latest versions from the source app repository, safely, and the user can
|
||||
> choose to update so we aren't always updating manually"
|
||||
|
||||
#### Verified 2026-08-03: the schema and runtime already exist
|
||||
|
||||
This is much less work than it sounds, because the multiversion machinery built for
|
||||
Bitcoin generalises as data rather than code:
|
||||
|
||||
- `releases/app-catalog.json` entries already support a `versions[]` array of
|
||||
`{version, image, default?, deprecated?}`.
|
||||
- Runtime is complete: `catalog_versions(app_id)`, `catalog_default_version`,
|
||||
`catalog_image_for_version`, `package.versions`, version pinning through
|
||||
`package.set-config`, and `available_update_for_app` falling back to the
|
||||
`image-versions.sh` baseline pin.
|
||||
|
||||
**It is populated for 2 of 66 apps** — `bitcoin-core` (9 versions) and `bitcoin-knots`
|
||||
(5). Every other app carries a single `version`. So "multiversion for all apps" is
|
||||
primarily a **catalog-generation and image-mirroring job**, not new runtime plumbing.
|
||||
|
||||
#### What has to be built
|
||||
|
||||
1. **Populate `versions[]` fleet-wide.** Extend `scripts/generate-app-catalog.py` to emit
|
||||
a version list per app instead of a single pin. Needs a per-app policy for how many
|
||||
historical versions to carry and which is `default` (Bitcoin's list shows the shape,
|
||||
including `deprecated: true` for old-but-installable).
|
||||
2. **Mirror the images.** A version in the catalog that is not in our registry is a
|
||||
broken promise — `package.update` would pull and fail. Use the existing skopeo path
|
||||
(`feedback_skopeo_source_selection`: prefer `.160`, concurrency ≤ 6).
|
||||
3. **An upstream release-watcher.** 48 manifests already carry a `repo:` URL under
|
||||
`metadata`, so there is something to poll (GitHub releases / registry tags). It runs
|
||||
**off-node**, as part of catalog generation.
|
||||
4. **Keep the signed catalog as the trust boundary.** This is the whole of "safely":
|
||||
the watcher **proposes** versions, the offline signing ceremony **admits** them, and
|
||||
nodes only ever install what the signed catalog carries. A node must never pull
|
||||
straight from an upstream repo — that would put an unsigned third party inside the
|
||||
supply chain, which is exactly what the signed-registry model exists to prevent.
|
||||
5. **The user chooses.** Discovery must never auto-apply. `package.check-updates` already
|
||||
refreshes and hot-reloads without touching the running containers, so "a new version
|
||||
exists" and "install it" stay separate — which is also what item 6's modal is for.
|
||||
|
||||
**Sequencing note:** 6b's step 1 and item 6's UI-vs-app classification want the same
|
||||
thing — `*-ui` images represented in the catalog. Doing that once unblocks both.
|
||||
|
||||
---
|
||||
|
||||
## P2 — Carried over from v1.7.120
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -462,6 +462,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 auth;
|
||||
mod backup_rpc;
|
||||
@@ -87,6 +88,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>>>,
|
||||
@@ -151,6 +157,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,
|
||||
@@ -161,6 +174,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,261 @@
|
||||
//! 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 {
|
||||
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(),
|
||||
}),
|
||||
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 publish pinned to loopback is not externally
|
||||
// reachable, so the gate has nothing to stand in
|
||||
// front of. Gating it would mean binding a port the
|
||||
// app already holds and breaking in-node clients.
|
||||
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 loopback-pinned, so the gate must leave it alone
|
||||
/// even though its manifest does not declare an exemption.
|
||||
#[test]
|
||||
fn loopback_pinned_ports_are_not_gated() {
|
||||
assert!(build_port_map().gated(8332).is_none());
|
||||
}
|
||||
|
||||
/// 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,332 @@
|
||||
//! 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>,
|
||||
) {
|
||||
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,711 @@
|
||||
//! 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,
|
||||
}
|
||||
|
||||
@@ -4435,6 +4435,8 @@ mod tests {
|
||||
container,
|
||||
protocol: "tcp".to_string(),
|
||||
bind: String::new(),
|
||||
auth: archipelago_container::manifest::PortAuth::Session,
|
||||
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
|
||||
|
||||
@@ -27,6 +27,7 @@ use tracing::info;
|
||||
|
||||
mod api;
|
||||
mod app_ops;
|
||||
mod appgate;
|
||||
mod auth;
|
||||
mod avatar;
|
||||
mod backup;
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user