merge(13): AIUI source migrated in-repo — supersedes the two-repo split (D-15/D-18)
Brings AIUI's full 230-commit history under aiui/ via git subtree, plus main's
current head. Operator decision 2026-08-03: AIUI moves into this repo rather
than staying at git.tx1138.com. This also lands e30ac1d (13-01 Task 3), which
was stranded local-only while that remote was unreachable.
Plans 13-06, 13-09 and 13-11 still target /home/archipelago/Projects/AIUI paths
and must be re-planned against aiui/ before wave 2 runs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -51,10 +51,48 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Error prefix the frontend keys on to know it should prompt for the node
|
||||
/// password and retry, rather than surface the message as a dead end.
|
||||
pub(in crate::api::rpc) const PASSWORD_REQUIRED_PREFIX: &str = "PASSWORD_REQUIRED";
|
||||
|
||||
impl RpcHandler {
|
||||
/// Re-authenticate the operator before granting `Trusted`.
|
||||
///
|
||||
/// A Trusted peer can read node state, be deployed to, and is exempt from
|
||||
/// the `!= Untrusted` gates federation/DWN/messaging use — so granting it
|
||||
/// is a privilege escalation and must cost a fresh proof that the person
|
||||
/// at the keyboard is the operator, not merely that a session cookie
|
||||
/// exists. This is the same reasoning as `node.rotate-identity` and 2FA
|
||||
/// setup, both of which already re-verify.
|
||||
///
|
||||
/// Only ever called on the way UP. Demotion stays ungated: making
|
||||
/// something less privileged must never be harder than leaving it alone,
|
||||
/// or the safe action becomes the inconvenient one.
|
||||
async fn verify_operator_password(&self, params: Option<&serde_json::Value>) -> Result<()> {
|
||||
let password = params
|
||||
.and_then(|p| p.get("password"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if password.is_empty() {
|
||||
anyhow::bail!("{PASSWORD_REQUIRED_PREFIX}: node password required to grant Trusted");
|
||||
}
|
||||
|
||||
if !self.auth_manager.verify_password(password).await? {
|
||||
anyhow::bail!("Password verification failed");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// federation.invite — Generate an invite code containing our DID + onion for a peer.
|
||||
/// Optional param `trust_level`: "trusted" (default, "Link Your Nodes") or
|
||||
/// "observer" ("Invite a Peer") — the level BOTH sides assign for this invite.
|
||||
///
|
||||
/// Minting a **Trusted** invite requires the node password (param
|
||||
/// `password`): the invite is a bearer grant of Trusted to whoever
|
||||
/// redeems it, so it is the escalation, not the later redemption.
|
||||
/// Observer invites are unchanged.
|
||||
pub(in crate::api::rpc) async fn handle_federation_invite(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
@@ -71,6 +109,13 @@ impl RpcHandler {
|
||||
.transpose()?
|
||||
.unwrap_or(TrustLevel::Trusted);
|
||||
|
||||
// Note this covers the DEFAULT too: "Link Your Nodes" sends no
|
||||
// `trust_level` and lands on Trusted above, so the gate must key off
|
||||
// the resolved level rather than an explicit request for Trusted.
|
||||
if trust_level == TrustLevel::Trusted {
|
||||
self.verify_operator_password(params.as_ref()).await?;
|
||||
}
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let onion = data.server_info.tor_address.clone().unwrap_or_default();
|
||||
@@ -272,6 +317,15 @@ impl RpcHandler {
|
||||
if let Some(at) = &n.last_sync_error_at {
|
||||
obj["last_sync_error_at"] = serde_json::json!(at);
|
||||
}
|
||||
// How this peer's trust level came to be. Emitted as an
|
||||
// explicit null when unknown rather than omitted: "recorded
|
||||
// before provenance was tracked" is the population the
|
||||
// operator most needs to review, so the UI must be able to
|
||||
// distinguish it from a field it simply didn't read.
|
||||
obj["trust_source"] = match &n.trust_source {
|
||||
Some(src) => serde_json::to_value(src).unwrap_or(serde_json::Value::Null),
|
||||
None => serde_json::Value::Null,
|
||||
};
|
||||
obj
|
||||
})
|
||||
.collect();
|
||||
@@ -323,6 +377,10 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
/// federation.set-trust — Change trust level for a federated node.
|
||||
///
|
||||
/// Promoting a node TO `Trusted` requires the node password (param
|
||||
/// `password`). Demotion and no-op re-sets do not: see
|
||||
/// `verify_operator_password` for why the gate is one-directional.
|
||||
pub(in crate::api::rpc) async fn handle_federation_set_trust(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
@@ -348,7 +406,32 @@ impl RpcHandler {
|
||||
),
|
||||
};
|
||||
|
||||
federation::set_trust_level(&self.config.data_dir, did, trust).await?;
|
||||
// Gate the ESCALATION only. Comparing against the node's current level
|
||||
// means a re-set of an already-Trusted peer (the dropdown re-emitting
|
||||
// its own value) doesn't pointlessly demand a password, while every
|
||||
// path that actually raises a peer to Trusted does.
|
||||
if trust == TrustLevel::Trusted {
|
||||
let already_trusted = federation::load_nodes(&self.config.data_dir)
|
||||
.await?
|
||||
.iter()
|
||||
.any(|n| n.did == did && n.trust_level == TrustLevel::Trusted);
|
||||
if !already_trusted {
|
||||
self.verify_operator_password(Some(¶ms)).await?;
|
||||
}
|
||||
}
|
||||
|
||||
// Stamp Manual: this is the one path where a human chose the level, so
|
||||
// an audit of `trust_source` can tell it apart from the automatic
|
||||
// grants that `UninvitedJoin` / `TransitiveMerge` mark.
|
||||
federation::set_trust_level(
|
||||
&self.config.data_dir,
|
||||
did,
|
||||
trust,
|
||||
Some(federation::TrustSource::Manual),
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!(did = %did, trust = %trust, "Operator set federation trust level");
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"updated": true,
|
||||
|
||||
@@ -350,10 +350,14 @@ impl RpcHandler {
|
||||
// lands on Observer; keep this explicit demotion as a
|
||||
// safety net for legacy Trusted-only invite codes — the
|
||||
// discovery flow should never auto-trust.
|
||||
// `None` source: this is an automatic safety-net
|
||||
// demotion, not an operator decision, so it must
|
||||
// not overwrite how the peer actually got here.
|
||||
let _ = crate::federation::set_trust_level(
|
||||
&self.config.data_dir,
|
||||
&node.did,
|
||||
crate::federation::TrustLevel::Observer,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
|
||||
use super::types::{FederatedNode, FederationInvite, NodeStateSnapshot, TrustLevel};
|
||||
use super::types::{FederatedNode, FederationInvite, NodeStateSnapshot, TrustLevel, TrustSource};
|
||||
|
||||
pub(crate) const FEDERATION_DIR: &str = "federation";
|
||||
pub(crate) const NODES_FILE: &str = "nodes.json";
|
||||
@@ -392,10 +392,19 @@ async fn untombstone_did_inner(data_dir: &Path, did: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Change a federated node's trust level, optionally recording HOW the change
|
||||
/// came about.
|
||||
///
|
||||
/// `source` is `Some(TrustSource::Manual)` on the operator RPC path so an
|
||||
/// audit of `trust_source` can tell a deliberate grant apart from the levels
|
||||
/// the automatic paths assign. Pass `None` for automatic adjustments that are
|
||||
/// not operator decisions (e.g. the discovery-handshake demotion safety net) —
|
||||
/// those must leave the recorded provenance alone rather than claim one.
|
||||
pub async fn set_trust_level(
|
||||
data_dir: &Path,
|
||||
did: &str,
|
||||
trust: TrustLevel,
|
||||
source: Option<TrustSource>,
|
||||
) -> Result<Vec<FederatedNode>> {
|
||||
let _guard = FEDERATION_STORE_LOCK.lock().await;
|
||||
let mut nodes = load_nodes_inner(data_dir).await?;
|
||||
@@ -404,6 +413,9 @@ pub async fn set_trust_level(
|
||||
.find(|n| n.did == did)
|
||||
.ok_or_else(|| anyhow::anyhow!("No federated node with DID {}", did))?;
|
||||
node.trust_level = trust;
|
||||
if let Some(source) = source {
|
||||
node.trust_source = Some(source);
|
||||
}
|
||||
save_nodes_inner(data_dir, &nodes).await?;
|
||||
Ok(nodes)
|
||||
}
|
||||
@@ -661,12 +673,55 @@ mod tests {
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
let nodes = set_trust_level(dir.path(), "did:key:z1", TrustLevel::Observer)
|
||||
let nodes = set_trust_level(dir.path(), "did:key:z1", TrustLevel::Observer, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(nodes[0].trust_level, TrustLevel::Observer);
|
||||
}
|
||||
|
||||
/// The operator RPC path stamps `Manual`, so an audit of `trust_source`
|
||||
/// can separate a deliberate grant from the levels the automatic paths
|
||||
/// (`UninvitedJoin`, `TransitiveMerge`) assign on their own authority.
|
||||
#[tokio::test]
|
||||
async fn test_set_trust_level_records_manual_source() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
let nodes = set_trust_level(
|
||||
dir.path(),
|
||||
"did:key:z1",
|
||||
TrustLevel::Trusted,
|
||||
Some(TrustSource::Manual),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(nodes[0].trust_level, TrustLevel::Trusted);
|
||||
assert_eq!(nodes[0].trust_source, Some(TrustSource::Manual));
|
||||
}
|
||||
|
||||
/// An automatic adjustment must not claim a provenance it doesn't have:
|
||||
/// passing `None` leaves whatever was recorded before intact, so the
|
||||
/// discovery-handshake demotion can't launder an `UninvitedJoin` peer
|
||||
/// into looking operator-approved.
|
||||
#[tokio::test]
|
||||
async fn test_set_trust_level_none_source_preserves_provenance() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut node = make_node("did:key:z1", "a.onion");
|
||||
node.trust_source = Some(TrustSource::UninvitedJoin);
|
||||
add_node(dir.path(), node).await.unwrap();
|
||||
|
||||
let nodes = set_trust_level(dir.path(), "did:key:z1", TrustLevel::Observer, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(nodes[0].trust_level, TrustLevel::Observer);
|
||||
assert_eq!(
|
||||
nodes[0].trust_source,
|
||||
Some(TrustSource::UninvitedJoin),
|
||||
"an automatic level change must not rewrite how the peer got here"
|
||||
);
|
||||
}
|
||||
|
||||
/// The .198 v1.7.103 update-bricking race (see `update.rs`'s
|
||||
/// `UPDATE_OP_LOCK`) had the same shape as this test: two concurrent
|
||||
/// mutators sharing one on-disk file with no coordination. Here,
|
||||
@@ -695,7 +750,7 @@ mod tests {
|
||||
async move { add_node(&dir_a, make_node("did:key:zB", "b.onion")).await },
|
||||
);
|
||||
let trust_task = tokio::spawn(async move {
|
||||
set_trust_level(&dir_b, "did:key:zA", TrustLevel::Observer).await
|
||||
set_trust_level(&dir_b, "did:key:zA", TrustLevel::Observer, None).await
|
||||
});
|
||||
add_task.await.unwrap().unwrap();
|
||||
trust_task.await.unwrap().unwrap();
|
||||
|
||||
@@ -503,6 +503,35 @@ fn default_network_policy() -> String {
|
||||
"isolated".to_string()
|
||||
}
|
||||
|
||||
/// Whether a published port must sit behind the node's app authentication
|
||||
/// gate.
|
||||
///
|
||||
/// The default is deliberately the protected one. Every app port on this
|
||||
/// node was reachable with no credential at all over LAN, Tailscale, Tor and
|
||||
/// the FIPS mesh alike (reproduced 2026-08-03) precisely because exposure
|
||||
/// was the thing you got by saying nothing. Making `Session` the default
|
||||
/// inverts that: a new app is protected unless its manifest argues for an
|
||||
/// exemption, and the exemptions are a `grep auth: none apps/` rather than a
|
||||
/// discovery.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PortAuth {
|
||||
/// Default. The daemon's app gate authenticates every connection: a
|
||||
/// valid session (2FA honoured, since a session still pending its TOTP
|
||||
/// step fails validation) or an app-scoped bearer token for machine
|
||||
/// clients. Anything else gets the login page.
|
||||
#[default]
|
||||
Session,
|
||||
/// Exempt — the gate does not touch this port.
|
||||
///
|
||||
/// Only legitimate when the port carries a protocol that authenticates
|
||||
/// itself (LND macaroons, Lightning's noise handshake, TLS client
|
||||
/// certs) or one where a login page would be meaningless and harmful
|
||||
/// (Bitcoin p2p gossip, mDNS). Requires `auth_rationale`: an exemption
|
||||
/// nobody can explain is an exemption nobody reviewed.
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PortMapping {
|
||||
pub host: u16,
|
||||
@@ -516,6 +545,15 @@ 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,
|
||||
/// 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.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub auth_rationale: Option<String>,
|
||||
}
|
||||
|
||||
impl From<(u16, u16)> for PortMapping {
|
||||
@@ -525,6 +563,8 @@ impl From<(u16, u16)> for PortMapping {
|
||||
container,
|
||||
protocol: "tcp".to_string(),
|
||||
bind: String::new(),
|
||||
auth: PortAuth::Session,
|
||||
auth_rationale: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1022,6 +1062,34 @@ fn validate_ports(ports: &[PortMapping]) -> Result<(), ManifestError> {
|
||||
port.bind
|
||||
)));
|
||||
}
|
||||
// An exemption from the app gate has to carry its own justification.
|
||||
// Enforcing it here rather than at review time means the reason
|
||||
// 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()) {
|
||||
(PortAuth::None, None) => {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"ports[{i}] sets auth: none but no auth_rationale — an unauthenticated \
|
||||
port must state why it is safe to expose"
|
||||
)));
|
||||
}
|
||||
(PortAuth::None, Some(rationale)) if rationale.trim().is_empty() => {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"ports[{i}].auth_rationale cannot be empty"
|
||||
)));
|
||||
}
|
||||
// A rationale on a gated port means the author wrote an
|
||||
// exemption and did not get one. Silently keeping the port
|
||||
// protected would be safe but misleading, so say so.
|
||||
(PortAuth::Session, Some(_)) => {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"ports[{i}] sets auth_rationale without auth: none — the port is gated \
|
||||
and the rationale has no effect"
|
||||
)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// The same host port may be listed more than once with different bind
|
||||
// addresses (e.g. loopback + the archy-net gateway); identical
|
||||
// (host, protocol, bind) triples are still rejected.
|
||||
@@ -1519,6 +1587,121 @@ app:
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a manifest with one port block, so each auth case differs only
|
||||
/// in the lines under test.
|
||||
fn manifest_with_port(port_yaml: &str) -> Result<AppManifest, ManifestError> {
|
||||
AppManifest::parse(&format!(
|
||||
"app:\n id: a\n name: a\n version: 1.0.0\n container:\n image: x:y\n ports:\n{port_yaml}"
|
||||
))
|
||||
}
|
||||
|
||||
/// Every manifest we ship must satisfy the schema — including the auth
|
||||
/// rules above. Without this the first exemption typo'd into a manifest
|
||||
/// would only surface when a node refused to load the app.
|
||||
#[test]
|
||||
fn all_shipped_manifests_parse() {
|
||||
let apps = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps");
|
||||
let Ok(entries) = std::fs::read_dir(&apps) else {
|
||||
return; // not a full checkout (vendored crate) — nothing to check
|
||||
};
|
||||
let mut checked = 0;
|
||||
for entry in entries.flatten() {
|
||||
let manifest = entry.path().join("manifest.yml");
|
||||
if !manifest.is_file() {
|
||||
continue;
|
||||
}
|
||||
let yaml = std::fs::read_to_string(&manifest).expect("manifest readable");
|
||||
AppManifest::parse(&yaml)
|
||||
.unwrap_or_else(|e| panic!("{} is invalid: {e}", manifest.display()));
|
||||
checked += 1;
|
||||
}
|
||||
assert!(checked > 40, "only found {checked} manifests — path wrong?");
|
||||
}
|
||||
|
||||
/// The exempt set is the node's entire unauthenticated attack surface, so
|
||||
/// it must stay small and deliberate. If this count moves, someone added
|
||||
/// or removed an exemption and it wants a second pair of eyes.
|
||||
#[test]
|
||||
fn unauthenticated_ports_are_all_accounted_for() {
|
||||
let apps = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps");
|
||||
let Ok(entries) = std::fs::read_dir(&apps) else {
|
||||
return;
|
||||
};
|
||||
let mut exempt: Vec<(String, u16)> = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
let manifest = entry.path().join("manifest.yml");
|
||||
if !manifest.is_file() {
|
||||
continue;
|
||||
}
|
||||
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 {
|
||||
exempt.push((parsed.app.id.clone(), port.host));
|
||||
}
|
||||
}
|
||||
}
|
||||
exempt.sort();
|
||||
assert_eq!(
|
||||
exempt.len(),
|
||||
17,
|
||||
"unauthenticated port set changed — review before updating this count: {exempt:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[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.
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_auth_none_requires_a_rationale() {
|
||||
let err = manifest_with_port(" - host: 8333\n container: 8333\n auth: none\n")
|
||||
.expect_err("auth: none without a rationale must be rejected");
|
||||
assert!(
|
||||
err.to_string().contains("auth_rationale"),
|
||||
"error should name the missing field, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_auth_none_rejects_a_blank_rationale() {
|
||||
assert!(manifest_with_port(
|
||||
" - host: 8333\n container: 8333\n auth: none\n auth_rationale: \" \"\n"
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_auth_none_with_a_rationale_parses() {
|
||||
let manifest = manifest_with_port(
|
||||
" - 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_rationale.as_deref(),
|
||||
Some("Bitcoin p2p gossip")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rationale_without_auth_none_is_rejected() {
|
||||
// Catches the author who wrote the justification but forgot the
|
||||
// `auth: none` line: the port stays gated, and shipping it silently
|
||||
// would leave them believing they had an exemption they never got.
|
||||
let err = manifest_with_port(
|
||||
" - host: 8080\n container: 80\n auth_rationale: I meant to exempt this\n",
|
||||
)
|
||||
.expect_err("a rationale on a gated port must be rejected");
|
||||
assert!(err.to_string().contains("no effect"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hooks_reject_empty_exec() {
|
||||
let yaml = "app:\n id: a\n name: a\n version: 1.0.0\n container:\n image: x:y\n hooks:\n post_install:\n - exec: []\n";
|
||||
|
||||
Reference in New Issue
Block a user