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:
archipelago
2026-08-03 15:11:26 -04:00
co-authored by Claude Opus 5
407 changed files with 69788 additions and 32 deletions
@@ -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(&params)).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;
+58 -3
View File
@@ -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();
+183
View File
@@ -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";