Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:49 +00:00
commit e3bcd9fca9
2056 changed files with 468069 additions and 0 deletions
@@ -0,0 +1,186 @@
//! What the assistant is allowed to read about this node.
//!
//! # Why this lives on the node and not in the browser
//!
//! These grants were stored in `localStorage`, which is scoped to an
//! **origin**. A node answers on several: its LAN address, its Tailscale
//! address, `<host>.local`, and its hostname. Granting "Media" over the LAN
//! and returning over Tailscale showed every switch off again — not reset,
//! simply never set *there*. Operator-reported as "the AI Data Access settings
//! are not persistent through sessions, often turns them all off", and it made
//! a working content path look broken: every scope silently returns nothing
//! without a grant, so an ungranted permission is indistinguishable from an
//! empty library.
//!
//! The grant answers "what may the assistant read **about this node**". That is
//! a property of the node, not of one browser at one address, so the node is
//! where it belongs. Stored here it survives a cache clear, a new device, a
//! different browser, and any change of address.
//!
//! # Why the category list is not validated against a hardcoded set
//!
//! The authoritative list of categories lives in the UI
//! (`AI_PERMISSION_CATEGORIES`) and in the context broker's `fetchAndSanitize`.
//! Duplicating it here would create a third copy that silently drops a new
//! category on upgrade — the grant would round-trip through an older node and
//! come back missing. Unknown strings are stored verbatim and simply never
//! match a fetch, which fails closed. **Storing a category grants nothing on
//! its own**: the broker checks each category before it fetches, and the node
//! re-checks before it answers (T-13-33). This file records intent; it is not
//! the enforcement point.
use serde::{Deserialize, Serialize};
use std::path::Path;
const FILE_PATH: &str = "settings/ai_permissions.json";
/// Defence against a hand-edited or hostile file turning into unbounded
/// memory. Far above any real category count.
const MAX_CATEGORIES: usize = 64;
const MAX_CATEGORY_LEN: usize = 64;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiPermissions {
/// Categories the operator has granted. Empty means the assistant reads
/// nothing about this node, which is the default: a fresh node grants
/// nothing until asked.
#[serde(default)]
pub granted: Vec<String>,
}
impl AiPermissions {
/// Drop anything malformed and de-duplicate. Applied on both read and
/// write so a hand-edited file cannot produce a state the UI can never
/// display or undo.
pub fn sanitized(mut self) -> Self {
self.granted.retain(|c| {
!c.is_empty()
&& c.len() <= MAX_CATEGORY_LEN
// Category ids are lowercase kebab (`ai-local`). Anything else
// is not something this node will ever match against.
&& c.chars()
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
});
self.granted.sort();
self.granted.dedup();
self.granted.truncate(MAX_CATEGORIES);
self
}
pub fn is_granted(&self, category: &str) -> bool {
self.granted.iter().any(|c| c == category)
}
}
pub async fn load(data_dir: &Path) -> AiPermissions {
let path = data_dir.join(FILE_PATH);
match tokio::fs::read(&path).await {
Ok(bytes) => serde_json::from_slice::<AiPermissions>(&bytes)
.map(AiPermissions::sanitized)
.unwrap_or_else(|e| {
// Fail closed. An unreadable grant file must not be treated as
// "everything allowed" — the assistant simply reads nothing
// until the operator sets it again.
tracing::warn!(error = %e, "AI permissions unreadable; granting nothing");
AiPermissions::default()
}),
// Absent file is the ordinary first-run case, not an error.
Err(_) => AiPermissions::default(),
}
}
pub async fn save(data_dir: &Path, perms: AiPermissions) -> anyhow::Result<AiPermissions> {
let perms = perms.sanitized();
let path = data_dir.join(FILE_PATH);
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
// Temp + rename so a crash mid-write cannot leave a truncated file that
// reads as "no grants" on the next boot.
let tmp = path.with_extension("json.tmp");
tokio::fs::write(&tmp, serde_json::to_vec_pretty(&perms)?).await?;
tokio::fs::rename(&tmp, &path).await?;
Ok(perms)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_fresh_node_grants_nothing() {
assert!(AiPermissions::default().granted.is_empty());
assert!(!AiPermissions::default().is_granted("media"));
}
#[test]
fn unknown_categories_survive_a_round_trip() {
// A newer UI may grant a category this binary has never heard of.
// Dropping it would silently revoke the grant on downgrade/upgrade.
let p = AiPermissions {
granted: vec!["media".into(), "some-future-category".into()],
}
.sanitized();
assert!(p.is_granted("some-future-category"));
}
#[test]
fn malformed_entries_are_dropped_not_stored() {
let p = AiPermissions {
granted: vec![
"media".into(),
"".into(),
"UPPER".into(),
"has space".into(),
"../../etc/passwd".into(),
"x".repeat(500),
],
}
.sanitized();
assert_eq!(p.granted, vec!["media".to_string()]);
}
#[test]
fn duplicates_collapse() {
let p = AiPermissions {
granted: vec!["media".into(), "media".into(), "files".into()],
}
.sanitized();
assert_eq!(p.granted, vec!["files".to_string(), "media".to_string()]);
}
#[tokio::test]
async fn absent_file_reads_as_no_grants_rather_than_an_error() {
let dir = tempfile::tempdir().unwrap();
assert!(load(dir.path()).await.granted.is_empty());
}
#[tokio::test]
async fn a_corrupt_file_fails_closed() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(FILE_PATH);
tokio::fs::create_dir_all(path.parent().unwrap())
.await
.unwrap();
tokio::fs::write(&path, b"{ not json").await.unwrap();
// The dangerous failure would be defaulting to "all granted".
assert!(load(dir.path()).await.granted.is_empty());
}
#[tokio::test]
async fn saved_grants_survive_a_reload() {
let dir = tempfile::tempdir().unwrap();
save(
dir.path(),
AiPermissions {
granted: vec!["media".into(), "files".into()],
},
)
.await
.unwrap();
let back = load(dir.path()).await;
assert!(back.is_granted("media"));
assert!(back.is_granted("files"));
assert!(!back.is_granted("wallet"));
}
}
+9
View File
@@ -0,0 +1,9 @@
//! User-editable settings that are not part of the initial onboarding
//! flow. Each submodule persists a focused slice of preferences to
//! `<data_dir>/settings/*.json` and exposes a process-wide handle so
//! call sites (deep in the transport / RPC / ingest stacks) don't need
//! to thread a data_dir or Arc through the entire call graph.
pub mod ai_permissions;
pub mod session_policy;
pub mod transport;
@@ -0,0 +1,200 @@
//! How long a login lasts, and who gets to say so.
//!
//! # Why this is configurable rather than a constant
//!
//! There is no single correct session lifetime. The same node can be a
//! wall-mounted TV in a living room that must never ask for a password
//! mid-film, and a wallet holding real funds where PCI DSS-style guidance
//! says fifteen minutes. Both are legitimate; the operator knows which one
//! this node is and we do not.
//!
//! # The two tokens
//!
//! * **Session token** — short-lived, refreshed silently on every
//! authenticated request. This is what the browser sends; if it leaks, it
//! is useful only until [`SessionPolicy::idle_timeout_secs`] of silence.
//! * **Login (remember) token** — long-lived, and its *only* power is to
//! mint a fresh session token. Kept separate so raising the convenience
//! knob does not put a 30-day bearer credential on every request.
//!
//! Raising the idle timeout therefore does not weaken the credential that
//! actually travels; it only changes how long a quiet tab stays usable.
//!
//! # Why an absolute cap exists at all
//!
//! Idle timeout alone can be defeated by any page that polls — the
//! dashboard polls constantly, so an idle timeout would never fire while a
//! tab is open. The absolute cap is what guarantees a login eventually
//! ends, which is the property an auditor actually asks about.
use serde::{Deserialize, Serialize};
use std::path::Path;
const FILE_PATH: &str = "settings/session_policy.json";
/// Bounds. A setting that can be made meaningless is not a setting, and one
/// that can lock the operator out of their own node is a footgun.
const MIN_IDLE_SECS: u64 = 60;
const MAX_IDLE_SECS: u64 = 90 * 24 * 3600;
const MIN_ABSOLUTE_SECS: u64 = 300;
const MAX_ABSOLUTE_SECS: u64 = 365 * 24 * 3600;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum DeviceClass {
/// Ordinary browser on a phone or laptop. Policy applies as configured.
Browser,
/// A screen nobody logs into — a wall-mounted dashboard or TV. Being
/// signed out mid-view is the failure mode here, not a stale session:
/// the device is physically in the home, and there is no keyboard to
/// re-authenticate with. Exempt from the idle timeout, still subject to
/// the absolute cap so a stolen box does not stay authenticated forever.
Kiosk,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionPolicy {
/// Silence after which a session token stops validating.
pub idle_timeout_secs: u64,
/// Hard ceiling from login, regardless of activity. `None` = no cap.
pub absolute_timeout_secs: Option<u64>,
/// Re-prompt for the password before actions that move money, however
/// fresh the session is. Independent of the timeouts on purpose: it is
/// the control that matters when funds are involved, and it costs the
/// operator nothing the rest of the time.
pub reauth_for_funds: bool,
}
impl Default for SessionPolicy {
fn default() -> Self {
Self {
// A day of silence, matching the previous hard-coded constant so
// existing nodes see no behaviour change until someone chooses.
idle_timeout_secs: 86_400,
// 30 days, aligned with the login token's own lifetime: a
// session that outlived the token which could refresh it would
// be an oddity.
absolute_timeout_secs: Some(30 * 24 * 3600),
reauth_for_funds: true,
}
}
}
impl SessionPolicy {
/// Clamp to the supported range. Applied on load as well as on save, so
/// a hand-edited file cannot disable expiry by writing `0`.
pub fn sanitized(mut self) -> Self {
self.idle_timeout_secs = self.idle_timeout_secs.clamp(MIN_IDLE_SECS, MAX_IDLE_SECS);
self.absolute_timeout_secs = self
.absolute_timeout_secs
.map(|v| v.clamp(MIN_ABSOLUTE_SECS, MAX_ABSOLUTE_SECS))
// An absolute cap below the idle timeout would expire sessions
// while they are still active, which reads as random logouts.
.map(|v| v.max(self.idle_timeout_secs));
self
}
/// Idle timeout for a given device, or `None` when idleness is not a
/// reason to expire (kiosk screens).
pub fn idle_timeout_for(&self, class: DeviceClass) -> Option<u64> {
match class {
DeviceClass::Browser => Some(self.idle_timeout_secs),
DeviceClass::Kiosk => None,
}
}
/// Has a session expired? `age` is time since login, `idle` since last
/// use. Both are checked because either alone is insufficient: idle
/// never fires on a polling dashboard, and absolute alone leaves a
/// forgotten tab usable for a month.
pub fn is_expired(&self, class: DeviceClass, age_secs: u64, idle_secs: u64) -> bool {
if let Some(limit) = self.absolute_timeout_secs {
if age_secs >= limit {
return true;
}
}
match self.idle_timeout_for(class) {
Some(limit) => idle_secs >= limit,
None => false,
}
}
}
pub async fn load(data_dir: &Path) -> SessionPolicy {
let path = data_dir.join(FILE_PATH);
match tokio::fs::read(&path).await {
Ok(bytes) => serde_json::from_slice::<SessionPolicy>(&bytes)
.map(SessionPolicy::sanitized)
.unwrap_or_else(|e| {
tracing::warn!(error = %e, "session policy unreadable; using defaults");
SessionPolicy::default()
}),
Err(_) => SessionPolicy::default(),
}
}
pub async fn save(data_dir: &Path, policy: SessionPolicy) -> anyhow::Result<SessionPolicy> {
let policy = policy.sanitized();
let path = data_dir.join(FILE_PATH);
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let tmp = path.with_extension("json.tmp");
tokio::fs::write(&tmp, serde_json::to_vec_pretty(&policy)?).await?;
tokio::fs::rename(&tmp, &path).await?;
Ok(policy)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_match_the_previous_hardcoded_behaviour() {
let p = SessionPolicy::default();
assert_eq!(p.idle_timeout_secs, 86_400);
assert!(p.reauth_for_funds);
}
#[test]
fn expiry_cannot_be_disabled_by_hand_editing_the_file() {
let p = SessionPolicy {
idle_timeout_secs: 0,
absolute_timeout_secs: Some(0),
reauth_for_funds: false,
}
.sanitized();
assert!(p.idle_timeout_secs >= MIN_IDLE_SECS);
assert!(p.absolute_timeout_secs.unwrap() >= MIN_ABSOLUTE_SECS);
}
#[test]
fn absolute_cap_is_never_shorter_than_idle() {
// Otherwise a session dies while actively in use, which the operator
// experiences as being logged out at random.
let p = SessionPolicy {
idle_timeout_secs: 7 * 24 * 3600,
absolute_timeout_secs: Some(3600),
reauth_for_funds: true,
}
.sanitized();
assert_eq!(p.absolute_timeout_secs.unwrap(), p.idle_timeout_secs);
}
#[test]
fn a_kiosk_never_expires_from_idleness_but_still_has_a_ceiling() {
let p = SessionPolicy::default();
let a_week = 7 * 24 * 3600;
assert!(!p.is_expired(DeviceClass::Kiosk, 60, a_week));
assert!(p.is_expired(DeviceClass::Browser, 60, a_week));
// The absolute cap still applies to the TV.
assert!(p.is_expired(DeviceClass::Kiosk, 31 * 24 * 3600, 0));
}
#[test]
fn a_polling_dashboard_still_eventually_expires() {
// idle never grows because the page polls; only the cap saves us.
let p = SessionPolicy::default();
assert!(p.is_expired(DeviceClass::Browser, 30 * 24 * 3600, 0));
}
}
+227
View File
@@ -0,0 +1,227 @@
//! Per-service transport preferences.
//!
//! The user picks, for each peer-to-peer service, whether to prefer
//! FIPS (mesh overlay), Tor (hidden service fallback), or leave it on
//! Auto (FIPS preferred, Tor fallback — the default). Preferences are
//! persisted to `<data_dir>/settings/transport_preferences.json` and
//! cached in a process-wide handle so that calls from the transport,
//! RPC, and ingest stacks can consult them without threading data_dir
//! through every signature.
//!
//! Services covered (matching the Settings UI):
//! - `federation` — state sync, invites, peer notifications
//! - `peers` — address/DID rotation broadcast
//! - `peer_files` — content download / browse / preview
//! - `messaging` — archipelago channel + mesh-typed relay
//! - `mesh_file_sharing`— content_ref blob fetches over onion
//!
//! Unknown files parse as `default()` so a missing or corrupt
//! preferences file is equivalent to "Auto everywhere."
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::OnceLock;
use tokio::sync::RwLock;
const FILE_PATH: &str = "settings/transport_preferences.json";
/// Which transport to use for a given service.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum TransportPref {
/// FIPS preferred, Tor fallback. The default.
#[default]
Auto,
/// FIPS only. If FIPS is unavailable or the peer has no npub,
/// requests fail rather than leaking over Tor.
Fips,
/// Tor only. Useful when the user explicitly wants onion anonymity
/// for a given surface (e.g., first-contact messaging).
Tor,
}
/// Enum of peer-facing services that have a preference knob.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PeerService {
Federation,
Peers,
PeerFiles,
Messaging,
MeshFileSharing,
}
impl PeerService {
#[allow(dead_code)]
pub fn as_str(&self) -> &'static str {
match self {
Self::Federation => "federation",
Self::Peers => "peers",
Self::PeerFiles => "peer_files",
Self::Messaging => "messaging",
Self::MeshFileSharing => "mesh_file_sharing",
}
}
}
/// Persisted shape. One field per service so the on-disk file is
/// self-describing and trivially diff-able.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct TransportPreferences {
pub federation: TransportPref,
pub peers: TransportPref,
pub peer_files: TransportPref,
pub messaging: TransportPref,
pub mesh_file_sharing: TransportPref,
}
impl TransportPreferences {
pub fn for_service(&self, s: PeerService) -> TransportPref {
match s {
PeerService::Federation => self.federation,
PeerService::Peers => self.peers,
PeerService::PeerFiles => self.peer_files,
PeerService::Messaging => self.messaging,
PeerService::MeshFileSharing => self.mesh_file_sharing,
}
}
pub fn set_for_service(&mut self, s: PeerService, pref: TransportPref) {
match s {
PeerService::Federation => self.federation = pref,
PeerService::Peers => self.peers = pref,
PeerService::PeerFiles => self.peer_files = pref,
PeerService::Messaging => self.messaging = pref,
PeerService::MeshFileSharing => self.mesh_file_sharing = pref,
}
}
}
// ── Process-wide handle ─────────────────────────────────────────────────
static HANDLE: OnceLock<RwLock<TransportPreferences>> = OnceLock::new();
/// Initialise the handle from `<data_dir>/settings/transport_preferences.json`.
/// Must be called early in startup — before any call that reads `get()`.
/// Idempotent: second call reloads.
pub async fn init(data_dir: &Path) -> Result<()> {
let prefs = load_from_disk(data_dir).await;
match HANDLE.get() {
Some(lock) => {
*lock.write().await = prefs;
}
None => {
HANDLE
.set(RwLock::new(prefs))
.map_err(|_| anyhow::anyhow!("transport prefs already initialised"))?;
}
}
Ok(())
}
/// Read the current preference for a service. Returns `Auto` if the
/// handle wasn't initialised (tests, fallbacks).
pub async fn get(service: PeerService) -> TransportPref {
match HANDLE.get() {
Some(lock) => lock.read().await.for_service(service),
None => TransportPref::Auto,
}
}
/// Read the whole preferences block for the Settings UI.
pub async fn snapshot() -> TransportPreferences {
match HANDLE.get() {
Some(lock) => lock.read().await.clone(),
None => TransportPreferences::default(),
}
}
/// Update a single service preference, persist to disk, and update the
/// handle. Callers must pass `data_dir` because the on-disk file lives
/// under it — the handle alone doesn't know where to write.
pub async fn set(data_dir: &Path, service: PeerService, pref: TransportPref) -> Result<()> {
let new_prefs = {
let lock = HANDLE.get_or_init(|| RwLock::new(TransportPreferences::default()));
let mut w = lock.write().await;
w.set_for_service(service, pref);
w.clone()
};
save_to_disk(data_dir, &new_prefs).await
}
// ── On-disk I/O ─────────────────────────────────────────────────────────
async fn load_from_disk(data_dir: &Path) -> TransportPreferences {
let path = data_dir.join(FILE_PATH);
let s = match tokio::fs::read_to_string(&path).await {
Ok(s) => s,
Err(_) => return TransportPreferences::default(),
};
serde_json::from_str(&s).unwrap_or_default()
}
async fn save_to_disk(data_dir: &Path, prefs: &TransportPreferences) -> Result<()> {
let path = data_dir.join(FILE_PATH);
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent)
.await
.with_context(|| format!("create {}", parent.display()))?;
}
let body = serde_json::to_string_pretty(prefs).context("serialize TransportPreferences")?;
tokio::fs::write(&path, body)
.await
.with_context(|| format!("write {}", path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_all_auto() {
let p = TransportPreferences::default();
for s in [
PeerService::Federation,
PeerService::Peers,
PeerService::PeerFiles,
PeerService::Messaging,
PeerService::MeshFileSharing,
] {
assert_eq!(p.for_service(s), TransportPref::Auto);
}
}
#[test]
fn set_then_read_for_service() {
let mut p = TransportPreferences::default();
p.set_for_service(PeerService::Federation, TransportPref::Fips);
assert_eq!(p.for_service(PeerService::Federation), TransportPref::Fips);
assert_eq!(p.for_service(PeerService::Peers), TransportPref::Auto);
}
#[test]
fn json_round_trips() {
let mut p = TransportPreferences::default();
p.set_for_service(PeerService::Messaging, TransportPref::Tor);
let s = serde_json::to_string(&p).unwrap();
let back: TransportPreferences = serde_json::from_str(&s).unwrap();
assert_eq!(back.for_service(PeerService::Messaging), TransportPref::Tor);
}
#[test]
fn service_names_round_trip() {
for s in [
PeerService::Federation,
PeerService::Peers,
PeerService::PeerFiles,
PeerService::Messaging,
PeerService::MeshFileSharing,
] {
let json = serde_json::to_value(s).unwrap();
assert_eq!(json.as_str().unwrap(), s.as_str());
}
}
}