From 1267bcc41e11a114cfc44b0e73c17df1fdf4c704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 7 Oct 2026 20:23:13 +0500 Subject: [PATCH] Phase 10: central realm Registry, Host publishing lifecycle, deployment crates/coa-registry-proto (protocol 1: types, limits, signed-request canonicalisation, test vectors), services/coa-registry (axum + PostgreSQL, migrations, rate limits, Dockerfile, deploy files), services/coa-registry-e2e (Host lifecycle against the real service, live VPS gate), coa-core realm_registry (identity keys, client, publishing loop on its own thread), Settings -> Public listing card, docs REGISTRY_PROTOCOL.md and REGISTRY_DEPLOYMENT.md. Co-Authored-By: Claude Sonnet 5.5 --- .dockerignore | 5 + .gitignore | 3 + Cargo.lock | 15 + Cargo.toml | 3 +- crates/coa-core/Cargo.toml | 1 + crates/coa-core/src/lib.rs | 1 + crates/coa-core/src/realm_registry/advert.rs | 179 ++ crates/coa-core/src/realm_registry/client.rs | 116 + crates/coa-core/src/realm_registry/host.rs | 550 ++++ crates/coa-core/src/realm_registry/keys.rs | 160 + crates/coa-core/src/realm_registry/mod.rs | 19 + crates/coa-core/src/realm_registry/runtime.rs | 107 + .../coa-core/src/realm_registry/settings.rs | 111 + crates/coa-registry-proto/Cargo.toml | 19 + crates/coa-registry-proto/src/caps.rs | 312 ++ crates/coa-registry-proto/src/lib.rs | 66 + crates/coa-registry-proto/src/sign.rs | 251 ++ crates/coa-registry-proto/src/wire.rs | 363 +++ docs/INFRASTRUCTURE.md | 16 +- docs/REGISTRY_DEPLOYMENT.md | 112 + docs/REGISTRY_PROTOCOL.md | 189 ++ docs/VPS_BASELINE.md | 5 + services/coa-registry-e2e/Cargo.lock | 2749 +++++++++++++++++ services/coa-registry-e2e/Cargo.toml | 21 + services/coa-registry-e2e/tests/host_e2e.rs | 301 ++ services/coa-registry-e2e/tests/live_vps.rs | 239 ++ services/coa-registry/Cargo.lock | 1918 ++++++++++++ services/coa-registry/Cargo.toml | 35 + services/coa-registry/Dockerfile | 15 + services/coa-registry/deploy/Caddyfile | 25 + services/coa-registry/deploy/audit-db.sh | 4 + services/coa-registry/deploy/audit-db.sql | 14 + services/coa-registry/deploy/deploy.sh | 13 + services/coa-registry/deploy/gate-restarts.sh | 39 + .../coa-registry/deploy/postgres.compose.yaml | 37 + services/coa-registry/deploy/provision-db.sh | 18 + .../coa-registry/deploy/registry.compose.yaml | 45 + .../coa-registry/migrations/0001_realms.sql | 23 + services/coa-registry/src/api.rs | 380 +++ services/coa-registry/src/config.rs | 57 + services/coa-registry/src/lib.rs | 11 + services/coa-registry/src/limits.rs | 88 + services/coa-registry/src/listener.rs | 87 + services/coa-registry/src/main.rs | 100 + services/coa-registry/src/pg.rs | 227 ++ services/coa-registry/src/store.rs | 286 ++ services/coa-registry/tests/api.rs | 356 +++ services/coa-registry/tests/common/mod.rs | 189 ++ src-tauri/src/lib.rs | 64 +- src/i18n/locales/en.ts | 22 + src/i18n/locales/ru.ts | 22 + src/lib/registry.ts | 49 + src/screens/PublishCard.tsx | 81 + src/screens/SettingsHome.tsx | 4 +- src/screens/Shell.tsx | 2 +- tools/registry-vector-check.py | 87 + 56 files changed, 10198 insertions(+), 13 deletions(-) create mode 100644 .dockerignore create mode 100644 crates/coa-core/src/realm_registry/advert.rs create mode 100644 crates/coa-core/src/realm_registry/client.rs create mode 100644 crates/coa-core/src/realm_registry/host.rs create mode 100644 crates/coa-core/src/realm_registry/keys.rs create mode 100644 crates/coa-core/src/realm_registry/mod.rs create mode 100644 crates/coa-core/src/realm_registry/runtime.rs create mode 100644 crates/coa-core/src/realm_registry/settings.rs create mode 100644 crates/coa-registry-proto/Cargo.toml create mode 100644 crates/coa-registry-proto/src/caps.rs create mode 100644 crates/coa-registry-proto/src/lib.rs create mode 100644 crates/coa-registry-proto/src/sign.rs create mode 100644 crates/coa-registry-proto/src/wire.rs create mode 100644 docs/REGISTRY_DEPLOYMENT.md create mode 100644 docs/REGISTRY_PROTOCOL.md create mode 100644 services/coa-registry-e2e/Cargo.lock create mode 100644 services/coa-registry-e2e/Cargo.toml create mode 100644 services/coa-registry-e2e/tests/host_e2e.rs create mode 100644 services/coa-registry-e2e/tests/live_vps.rs create mode 100644 services/coa-registry/Cargo.lock create mode 100644 services/coa-registry/Cargo.toml create mode 100644 services/coa-registry/Dockerfile create mode 100644 services/coa-registry/deploy/Caddyfile create mode 100644 services/coa-registry/deploy/audit-db.sh create mode 100644 services/coa-registry/deploy/audit-db.sql create mode 100644 services/coa-registry/deploy/deploy.sh create mode 100644 services/coa-registry/deploy/gate-restarts.sh create mode 100644 services/coa-registry/deploy/postgres.compose.yaml create mode 100644 services/coa-registry/deploy/provision-db.sh create mode 100644 services/coa-registry/deploy/registry.compose.yaml create mode 100644 services/coa-registry/migrations/0001_realms.sql create mode 100644 services/coa-registry/src/api.rs create mode 100644 services/coa-registry/src/config.rs create mode 100644 services/coa-registry/src/lib.rs create mode 100644 services/coa-registry/src/limits.rs create mode 100644 services/coa-registry/src/listener.rs create mode 100644 services/coa-registry/src/main.rs create mode 100644 services/coa-registry/src/pg.rs create mode 100644 services/coa-registry/src/store.rs create mode 100644 services/coa-registry/tests/api.rs create mode 100644 services/coa-registry/tests/common/mod.rs create mode 100644 src/lib/registry.ts create mode 100644 src/screens/PublishCard.tsx create mode 100644 tools/registry-vector-check.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c442d5d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +target +**/target +node_modules +dist +.git diff --git a/.gitignore b/.gitignore index 42ca83f..92daadd 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ dist *.log .env src-tauri/gen + +# standalone service workspaces build into their own target folders +services/*/target/ diff --git a/Cargo.lock b/Cargo.lock index 6aca5b3..98982b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -495,6 +495,7 @@ version = "0.6.6" dependencies = [ "base64 0.22.1", "chrono", + "coa-registry-proto", "dunce", "ed25519-dalek", "fs4", @@ -519,6 +520,20 @@ dependencies = [ "zstd", ] +[[package]] +name = "coa-registry-proto" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "ed25519-dalek", + "hex", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.21", + "uuid", +] + [[package]] name = "coa-release" version = "0.6.6" diff --git a/Cargo.toml b/Cargo.toml index 2c16456..779d9b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] resolver = "2" -members = ["crates/coa-core", "crates/coa-release", "src-tauri"] +members = ["crates/coa-core", "crates/coa-release", "crates/coa-registry-proto", "src-tauri"] +exclude = ["services/coa-registry"] [workspace.package] version = "0.6.6" diff --git a/crates/coa-core/Cargo.toml b/crates/coa-core/Cargo.toml index b707bcb..f646f15 100644 --- a/crates/coa-core/Cargo.toml +++ b/crates/coa-core/Cargo.toml @@ -27,6 +27,7 @@ tracing-subscriber = { version = "0.3", default-features = false, features = ["f tempfile = "3" rusqlite = { version = "0.40.2", features = ["bundled"] } if-addrs = "0.15" +coa-registry-proto = { path = "../coa-registry-proto" } [dev-dependencies] rand_core = { version = "0.6", features = ["getrandom"] } diff --git a/crates/coa-core/src/lib.rs b/crates/coa-core/src/lib.rs index 6c88b12..2b70cb9 100644 --- a/crates/coa-core/src/lib.rs +++ b/crates/coa-core/src/lib.rs @@ -36,6 +36,7 @@ pub mod allsettings; pub mod modules; pub mod realmlist; pub mod realms; +pub mod realm_registry; pub mod registry; pub mod release; pub mod report; diff --git a/crates/coa-core/src/realm_registry/advert.rs b/crates/coa-core/src/realm_registry/advert.rs new file mode 100644 index 0000000..66304df --- /dev/null +++ b/crates/coa-core/src/realm_registry/advert.rs @@ -0,0 +1,179 @@ +//! What a local realm advertises, and how the Host learns it. + +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use coa_registry_proto::AdvertisedCapabilities; + +use crate::portable::capabilities::RealmCapabilities; +use crate::portable::extension::ExtensionRegistry; +use crate::portable::realm::profile::probe_capabilities; +use crate::portable::service::access::{load_descriptors, RealmAccess}; +use crate::{Error, Result}; + +/// The public form of a realm's capabilities: the profile as it is (same fields, same hash), bounded. +pub fn advertise(caps: &RealmCapabilities) -> Result { + let value = serde_json::to_value(caps)?; + let advert: AdvertisedCapabilities = serde_json::from_value(value).map_err(|e| Error::Invalid(format!("the capabilities cannot be advertised: {e}")))?; + advert.validate().map_err(|e| Error::Invalid(e.to_string()))?; + Ok(advert) +} + +#[derive(Clone, Debug, PartialEq)] +pub struct LocalAdvert { + /// The worldserver answers (database and console): only a running realm sends heartbeats. + pub running: bool, + /// What the realm advertises; the last known one while it is stopped, `None` before it was ever seen running. + pub capabilities: Option, + pub player_count: Option, + pub player_capacity: Option, + pub manager_version: String, +} + +/// Where the publishing loop gets a realm's current advertisement. Implementations must be quick or cache; they are called from the loop's thread. +pub trait AdvertSource: Send + Sync + 'static { + fn current(&self, local_id: &str) -> Option; +} + +struct Cached { + at: Instant, + advert: LocalAdvert, + caps_at: Option, +} + +struct Inner { + descriptors: PathBuf, + installs: Box Vec<(String, PathBuf)> + Send + Sync>, + registry: Arc, + cache: Mutex>, + inflight: Mutex>, + fresh: Duration, + caps_every: Duration, +} + +/// Probes the realms this Manager knows (installed servers and prepared descriptors) with the same code the portable engine uses. +/// +/// Probing talks to the realm's database and console and can take many seconds, so it never runs on the caller's thread: `current` answers from +/// the last probe at once (`None` before the first one has finished) and starts a refresh in the background when that probe is older than a few seconds. +pub struct LocalRealmsSource { + inner: Arc, +} + +impl LocalRealmsSource { + pub fn new(descriptors: impl Into, installs: impl Fn() -> Vec<(String, PathBuf)> + Send + Sync + 'static) -> Self { + Self { + inner: Arc::new(Inner { + descriptors: descriptors.into(), + installs: Box::new(installs), + registry: Arc::new(ExtensionRegistry::new()), + cache: Mutex::new(HashMap::new()), + inflight: Mutex::new(HashSet::new()), + fresh: Duration::from_secs(10), + caps_every: Duration::from_secs(60), + }), + } + } +} + +impl Inner { + fn access(&self, local_id: &str) -> Option { + if let Some((id, root)) = (self.installs)().into_iter().find(|(id, _)| format!("srv-{id}") == local_id || id == local_id) { + return Some(RealmAccess::from_install(&id, &root)); + } + let (descriptors, _) = load_descriptors(&self.descriptors); + descriptors.into_iter().find(|d| d.id == local_id).and_then(|d| RealmAccess::from_descriptor(d).ok()) + } + + fn probe(&self, local_id: &str) { + let previous = self.cache.lock().ok().and_then(|c| c.get(local_id).map(|c| (c.advert.clone(), c.caps_at))); + let Some(access) = self.access(local_id) else { return }; + let mut advert = LocalAdvert { running: false, capabilities: previous.as_ref().and_then(|(a, _)| a.capabilities.clone()), player_count: None, player_capacity: None, manager_version: crate::MANAGER_VERSION.to_string() }; + let mut caps_at = previous.as_ref().and_then(|(_, t)| *t); + if access.db_reachable() { + if let (Ok(db), Ok(mut ra)) = (access.db(), access.ra()) { + advert.running = true; + advert.player_count = ra.run("server info").ok().and_then(|text| connected_players(&text)); + if caps_at.is_none_or(|t| t.elapsed() >= self.caps_every) || advert.capabilities.is_none() { + let data = access.data_dir.is_dir().then_some(access.data_dir.as_path()); + if let Ok(caps) = probe_capabilities(&db, data, Some(&mut ra), &self.registry) { + if let Ok(a) = advertise(&caps) { + advert.capabilities = Some(a); + caps_at = Some(Instant::now()); + } + } + } + } + } + if let Ok(mut cache) = self.cache.lock() { + cache.insert(local_id.to_string(), Cached { at: Instant::now(), advert, caps_at }); + } + } +} + +/// `Connected players: 3. Characters in world: 3.` +pub fn connected_players(server_info: &str) -> Option { + let rest = server_info.split("Connected players:").nth(1)?; + rest.trim_start().split(|c: char| !c.is_ascii_digit()).next()?.parse().ok() +} + +impl AdvertSource for LocalRealmsSource { + fn current(&self, local_id: &str) -> Option { + let (answer, stale) = match self.inner.cache.lock().ok()?.get(local_id) { + Some(c) => (Some(c.advert.clone()), c.at.elapsed() >= self.inner.fresh), + None => (None, true), + }; + if stale && self.inner.inflight.lock().ok()?.insert(local_id.to_string()) { + let inner = self.inner.clone(); + let id = local_id.to_string(); + let spawned = std::thread::Builder::new().name("realm-probe".into()).spawn(move || { + inner.probe(&id); + if let Ok(mut f) = inner.inflight.lock() { + f.remove(&id); + } + }); + if spawned.is_err() { + self.inner.inflight.lock().ok()?.remove(local_id); + } + } + answer + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_player_count_is_read_from_the_server_info_report() { + assert_eq!(connected_players("Connected players: 3. Characters in world: 3."), Some(3)); + assert_eq!(connected_players("x\nConnected players: 0.\n"), Some(0)); + assert_eq!(connected_players("nothing"), None); + assert_eq!(connected_players("Connected players: many"), None); + } + + #[test] + fn a_real_profile_is_advertised_without_changing_its_hash() { + use crate::portable::capabilities::*; + use crate::portable::model::Ruleset; + use std::collections::{BTreeMap, BTreeSet}; + let content = ContentProfile { + ruleset: Ruleset::Coa, + character_formats: ContentProfile::manager_formats(), + online_import_job_formats: vec![2], + session_protocol: 2, + collection_protocol: 1, + features: [Feature::RuntimeSessions, Feature::Wardrobe, Feature::Collections, Feature::LevelProjection].into_iter().collect::>(), + collection_kinds: ["coa:appearance".to_string(), "coa:vanity".to_string()].into_iter().collect(), + extensions: vec![ExtensionSupport { namespace: "coa".into(), module_version: "1".into(), formats: FormatRange::single(1) }], + client_catalog: BTreeMap::from([("Appearances.dbc".to_string(), CatalogEntry { sha256: "c".repeat(64), records: 7 })]), + }; + let progression = Progression { max_player_level: 60, projection_protocol: 1, projection_policy_version: 1, progression_signature: "d".repeat(64), scaling_enabled: true }; + let core = Some(CoreIdentity { commit: "0f3737574afd".into(), branch: "feat/x".into(), date: "2026-10-01".into() }); + let caps = RealmCapabilities::build(core, content, Some(progression)).unwrap(); + let advert = advertise(&caps).unwrap(); + assert_eq!(advert.content_profile_hash, caps.content_profile_hash, "the Registry sees the hash the Manager computed"); + assert_eq!(serde_json::to_value(&advert).unwrap(), serde_json::to_value(&caps).unwrap(), "the same JSON, field for field"); + } +} diff --git a/crates/coa-core/src/realm_registry/client.rs b/crates/coa-core/src/realm_registry/client.rs new file mode 100644 index 0000000..9de2a87 --- /dev/null +++ b/crates/coa-core/src/realm_registry/client.rs @@ -0,0 +1,116 @@ +//! The Host's HTTP client of Registry protocol 1. Blocking, with hard timeouts; every mutating request is signed with the realm's own key. + +use std::time::Duration; + +use coa_registry_proto::sign::sign_request; +use coa_registry_proto::*; +use ed25519_dalek::SigningKey; +use serde::de::DeserializeOwned; +use serde::Serialize; +use std::result::Result; + +/// Why a request did not succeed, in the terms the retry policy needs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClientError { + /// The network, a timeout, a 5xx or a rate limit: the same request may work later. + Transient(String), + /// The Registry understood the request and refused it with this code. + Rejected { code: ErrorCode, status: u16, message: String }, + /// The answer is not Registry protocol 1. + Protocol(String), +} + +impl ClientError { + /// Repeating cannot help: the Host stops instead of retrying. + pub fn is_permanent(&self) -> bool { + match self { + ClientError::Transient(_) => false, + ClientError::Rejected { code, .. } => code.is_permanent(), + ClientError::Protocol(_) => true, + } + } + pub fn code(&self) -> Option { + match self { + ClientError::Rejected { code, .. } => Some(*code), + _ => None, + } + } +} + +impl std::fmt::Display for ClientError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ClientError::Transient(m) => write!(f, "the Registry could not be reached: {m}"), + ClientError::Rejected { code, status, message } => write!(f, "the Registry refused the request ({status} {code:?}): {message}"), + ClientError::Protocol(m) => write!(f, "the Registry answered something that is not protocol {REGISTRY_PROTOCOL_VERSION}: {m}"), + } + } +} + +impl std::error::Error for ClientError {} + +pub struct RegistryClient { + base: String, + http: reqwest::blocking::Client, +} + +impl RegistryClient { + pub fn new(base: &str) -> crate::Result { + let base = super::settings::validate_url(base)?; + let http = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(10)) + .connect_timeout(Duration::from_secs(5)) + .redirect(reqwest::redirect::Policy::none()) + .user_agent(concat!("coa-manager/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|e| crate::Error::Invalid(e.to_string()))?; + Ok(Self { base, http }) + } + + pub fn base(&self) -> &str { + &self.base + } + + fn send(&self, method: &str, path: &str, body: Vec, key: &SigningKey, realm: &RealmId, ts: i64) -> Result { + let headers = sign_request(key, method, path, realm, ts, &body); + let url = format!("{}{}", self.base, path); + let mut req = if method == "GET" { self.http.get(url) } else { self.http.post(url).header("content-type", "application/json").body(body) }; + for (name, value) in headers.pairs() { + req = req.header(name, value); + } + let resp = req.send().map_err(|e| ClientError::Transient(e.without_url().to_string()))?; + let status = resp.status(); + let bytes = resp.bytes().map_err(|e| ClientError::Transient(e.without_url().to_string()))?; + if status.is_success() { + return serde_json::from_slice(&bytes).map_err(|_| ClientError::Protocol("the answer cannot be read".into())); + } + match serde_json::from_slice::(&bytes) { + Ok(e) if status.as_u16() == 429 || status.as_u16() == 503 => Err(ClientError::Transient(format!("{:?}: {}", e.error.code, e.error.message))), + Ok(e) => Err(ClientError::Rejected { code: e.error.code, status: status.as_u16(), message: e.error.message }), + Err(_) if status.is_server_error() || status.as_u16() == 408 || status.as_u16() == 429 => Err(ClientError::Transient(format!("HTTP {status}"))), + Err(_) => Err(ClientError::Protocol(format!("HTTP {status} without a Registry error body"))), + } + } + + fn post(&self, path: &str, body: &B, key: &SigningKey, realm: &RealmId, ts: i64) -> Result { + let bytes = serde_json::to_vec(body).map_err(|e| ClientError::Protocol(e.to_string()))?; + self.send("POST", path, bytes, key, realm, ts) + } + + pub fn register(&self, key: &SigningKey, req: &RegisterRequest, ts: i64) -> Result { + self.post(PATH_REGISTER, req, key, &req.realm_id, ts) + } + + pub fn heartbeat(&self, key: &SigningKey, realm: &RealmId, req: &HeartbeatRequest, ts: i64) -> Result { + self.post(&path_heartbeat(realm), req, key, realm, ts) + } + + pub fn unpublish(&self, key: &SigningKey, realm: &RealmId, ts: i64) -> Result { + self.post(&path_unpublish(realm), &UnpublishRequest { protocol_version: REGISTRY_PROTOCOL_VERSION }, key, realm, ts) + } + + /// The Registry's own record of this realm (the authenticated read). + pub fn self_info(&self, key: &SigningKey, realm: &RealmId, ts: i64) -> Result { + self.send("GET", &path_self(realm), Vec::new(), key, realm, ts) + } +} diff --git a/crates/coa-core/src/realm_registry/host.rs b/crates/coa-core/src/realm_registry/host.rs new file mode 100644 index 0000000..977be0a --- /dev/null +++ b/crates/coa-core/src/realm_registry/host.rs @@ -0,0 +1,550 @@ +//! The publishing lifecycle of a Host Manager, independent of Player Mode and of the portable engine. +//! +//! ```text +//! publish enabled -> ensure the realm's identity (UUIDv7 + Ed25519 key) -> register -> heartbeat every ~30 s +//! the realm's capabilities changed -> the next heartbeat carries them +//! Manager restart -> the same RealmId and key -> register again as itself -> heartbeat +//! publish disabled -> unpublish (retried in the background until it is delivered or refused) +//! ``` +//! +//! Nothing here touches the realm's database writes, the worldserver or any portable store: the loop only *reads* what a realm advertises, and a +//! Registry that is down, slow or hostile changes nothing but this module's own status. Transient failures are retried with a capped exponential +//! backoff and jitter; a refusal that repeating cannot fix (bad signature, wrong key, unsupported protocol, invalid metadata) stops the realm's +//! publication until the owner acts. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use coa_registry_proto::sign::encode_public_key; +use coa_registry_proto::*; +use ed25519_dalek::SigningKey; +use serde::Serialize; + +use super::advert::{AdvertSource, LocalAdvert}; +use super::client::{ClientError, RegistryClient}; +use super::keys::KeyStore; +use super::settings::{self, PublishConfig, RegistrySettings}; +use crate::{Error, Result}; + +pub type Clock = Arc i64 + Send + Sync>; + +pub fn system_clock() -> Clock { + Arc::new(|| std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0)) +} + +#[derive(Clone, Debug)] +pub struct Timing { + pub heartbeat: Duration, + pub backoff_base: Duration, + pub backoff_cap: Duration, + /// How long to wait before looking again at a realm that is not running or not known yet (no network involved). + pub idle_poll: Duration, + /// Clock-skew refusals are retried this often at most; a clock that stays wrong is the owner's to fix. + pub max_clock_failures: u32, + pub max_unpublish_attempts: u32, +} + +impl Default for Timing { + fn default() -> Self { + Self { heartbeat: Duration::from_secs(HEARTBEAT_INTERVAL_SECS), backoff_base: Duration::from_secs(5), backoff_cap: Duration::from_secs(300), idle_poll: Duration::from_secs(5), max_clock_failures: 20, max_unpublish_attempts: 12 } + } +} + +/// `base * 2^(attempt-1)` capped, then spread by +-25% with `unit` in `[0, 1)`. +pub fn backoff(attempt: u32, base: Duration, cap: Duration, unit: f64) -> Duration { + let exp = attempt.saturating_sub(1).min(20); + let raw = base.as_secs_f64() * f64::from(1u32 << exp); + let capped = raw.min(cap.as_secs_f64()); + Duration::from_secs_f64(capped * (0.75 + 0.5 * unit.clamp(0.0, 0.999_999))) +} + +fn unit() -> f64 { + let b = uuid::Uuid::new_v4(); + let n = u64::from_le_bytes(b.as_bytes()[..8].try_into().expect("8 bytes")); + (n >> 11) as f64 / (1u64 << 53) as f64 +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PublishState { + /// Waiting for the first attempt. + Starting, + /// The realm is not known, not running or has no capabilities yet: nothing is sent. + WaitingForRealm, + RealmStopped, + Online, + /// The Registry could not be reached (or asked to slow down); trying again. + Retrying, + /// The Registry refused the request and repeating cannot change that. + Rejected, + Unpublishing, + Disabled, +} + +#[derive(Clone, Debug, Serialize)] +pub struct RealmPublishStatus { + pub local_id: String, + pub realm_id: Option, + pub enabled: bool, + pub state: PublishState, + pub display_name: String, + pub description: String, + pub language: String, + pub metadata_revision: Option, + pub last_ok_unix: Option, + pub last_error: Option, + pub retry_in_secs: Option, +} + +#[derive(Clone, Debug, Default, Serialize)] +pub struct RegistryStatus { + pub url: Option, + pub realms: Vec, +} + +enum Phase { + Register, + Beat, +} + +struct Publisher { + cfg: PublishConfig, + realm: Option, + key: Option, + phase: Phase, + state: PublishState, + /// `None`: nothing to do until the owner acts (a permanent refusal). + due: Option, + failures: u32, + clock_failures: u32, + unpublish_attempts: u32, + acked_hash: Option, + sent_meta: Option<(String, String, String)>, + revision: Option, + last_ok: Option, + last_error: Option, + last_ts: i64, +} + +impl Publisher { + fn new(cfg: PublishConfig, now: Instant) -> Self { + let enabled = cfg.enabled; + Self { + cfg, + realm: None, + key: None, + phase: Phase::Register, + state: if enabled { PublishState::Starting } else { PublishState::Disabled }, + due: enabled.then_some(now), + failures: 0, + clock_failures: 0, + unpublish_attempts: 0, + acked_hash: None, + sent_meta: None, + revision: None, + last_ok: None, + last_error: None, + last_ts: 0, + } + } + + fn reset_progress(&mut self, now: Instant) { + self.phase = Phase::Register; + self.failures = 0; + self.clock_failures = 0; + self.acked_hash = None; + self.sent_meta = None; + self.last_error = None; + self.state = PublishState::Starting; + self.due = Some(now); + } +} + +pub struct RegistryHost { + dir: PathBuf, + settings: RegistrySettings, + keys: Arc, + source: Arc, + clock: Clock, + timing: Timing, + client: Option, + publishers: BTreeMap, +} + +impl RegistryHost { + pub fn open(dir: &Path, keys: Arc, source: Arc, clock: Clock, timing: Timing, now: Instant) -> Result { + let settings = settings::load(dir)?; + let client = settings.url.as_deref().map(RegistryClient::new).transpose()?; + let mut host = Self { dir: dir.to_path_buf(), settings, keys, source, clock, timing, client, publishers: BTreeMap::new() }; + for (local, cfg) in host.settings.realms.clone() { + let mut p = Publisher::new(cfg.clone(), now); + if cfg.enabled { + host.attach_identity(&mut p); + } + host.publishers.insert(local, p); + } + Ok(host) + } + + pub fn url(&self) -> Option<&str> { + self.settings.url.as_deref() + } + + /// The identity of an enabled realm: its id and key must both exist; a missing key is not replaced behind the owner's back. + fn attach_identity(&self, p: &mut Publisher) { + let Some(id) = p.cfg.realm_id else { + p.state = PublishState::Rejected; + p.due = None; + p.last_error = Some("the realm has no identity yet".into()); + return; + }; + p.realm = Some(id); + match self.keys.load(&id) { + Ok(Some(key)) => p.key = Some(key), + Ok(None) => { + p.state = PublishState::Rejected; + p.due = None; + p.last_error = Some("the realm's key is missing on this machine; stop publishing and publish again to create a new identity".into()); + } + Err(e) => { + p.state = PublishState::Rejected; + p.due = None; + p.last_error = Some(format!("the realm's key cannot be read: {e}")); + } + } + } + + fn save(&self) -> Result<()> { + settings::save(&self.dir, &self.settings) + } + + pub fn set_url(&mut self, url: Option<&str>, now: Instant) -> Result<()> { + let url = match url.map(str::trim).filter(|u| !u.is_empty()) { + Some(u) => Some(settings::validate_url(u)?), + None => None, + }; + if url == self.settings.url { + return Ok(()); + } + self.client = url.as_deref().map(RegistryClient::new).transpose()?; + self.settings.url = url; + self.save()?; + for p in self.publishers.values_mut().filter(|p| p.cfg.enabled && p.key.is_some()) { + p.reset_progress(now); + } + Ok(()) + } + + /// Start (or restart) publishing a local realm. The first time creates its identity; later times keep it. + pub fn publish(&mut self, local_id: &str, display_name: &str, description: &str, language: &str, now: Instant) -> Result<()> { + if self.client.is_none() { + return Err(Error::Invalid("set the Registry address first".into())); + } + let mut cfg = self.settings.realms.get(local_id).cloned().unwrap_or_default(); + cfg.display_name = display_name.trim().to_string(); + cfg.description = description.trim().to_string(); + cfg.language = language.trim().to_string(); + cfg.enabled = true; + cfg.validate()?; + // an identity whose key is gone from this machine cannot sign for itself any more: publishing again makes a new identity, never a guess at the old key + if let Some(id) = cfg.realm_id { + if matches!(self.keys.load(&id), Ok(None)) { + cfg.realm_id = None; + } + } + let created = if cfg.realm_id.is_none() { + let id = RealmId::new(); + self.keys.create(&id)?; + cfg.realm_id = Some(id); + true + } else { + false + }; + self.settings.realms.insert(local_id.to_string(), cfg.clone()); + if let Err(e) = self.save() { + if created { + if let Some(id) = cfg.realm_id { + let _ = self.keys.remove(&id); + } + } + self.settings.realms.remove(local_id); + return Err(e); + } + let mut p = Publisher::new(cfg, now); + self.attach_identity(&mut p); + if let Some(old) = self.publishers.get(local_id) { + p.last_ok = old.last_ok; + p.last_ts = old.last_ts; + } + self.publishers.insert(local_id.to_string(), p); + Ok(()) + } + + /// Stop publishing: the realm is unpublished at the Registry (retried in the background); its identity is kept for a later republication. + pub fn unpublish(&mut self, local_id: &str, now: Instant) -> Result<()> { + let Some(cfg) = self.settings.realms.get_mut(local_id) else { return Ok(()) }; + cfg.enabled = false; + let cfg = cfg.clone(); + self.save()?; + let Some(p) = self.publishers.get_mut(local_id) else { return Ok(()) }; + let was_registered = p.realm.is_some() && p.key.is_some() && (p.revision.is_some() || matches!(p.phase, Phase::Beat)); + p.cfg = cfg; + p.unpublish_attempts = 0; + p.failures = 0; + if was_registered && self.client.is_some() { + p.state = PublishState::Unpublishing; + p.due = Some(now); + } else { + p.state = PublishState::Disabled; + p.due = None; + } + Ok(()) + } + + /// Try again now (after a refusal the owner has dealt with). + pub fn retry(&mut self, local_id: &str, now: Instant) { + if let Some(p) = self.publishers.get_mut(local_id) { + if p.cfg.enabled && p.key.is_some() { + p.reset_progress(now); + } + } + } + + pub fn status(&self, now: Instant) -> RegistryStatus { + let realms = self + .publishers + .iter() + .map(|(local, p)| RealmPublishStatus { + local_id: local.clone(), + realm_id: p.cfg.realm_id.map(|r| r.to_string()), + enabled: p.cfg.enabled, + state: p.state, + display_name: p.cfg.display_name.clone(), + description: p.cfg.description.clone(), + language: p.cfg.language.clone(), + metadata_revision: p.revision, + last_ok_unix: p.last_ok, + last_error: p.last_error.clone(), + retry_in_secs: p.due.map(|d| d.saturating_duration_since(now).as_secs()), + }) + .collect(); + RegistryStatus { url: self.settings.url.clone(), realms } + } + + /// The public key a published realm is known by (never the private one). + pub fn public_key(&self, local_id: &str) -> Option { + self.publishers.get(local_id).and_then(|p| p.key.as_ref()).map(|k| encode_public_key(&k.verifying_key())) + } + + /// What the Registry itself says about a published realm (the authenticated read; useful for support and for tests). + pub fn registry_record(&mut self, local_id: &str) -> std::result::Result { + let Some(mut p) = self.publishers.remove(local_id) else { return Err(ClientError::Protocol("the realm is not known here".into())) }; + let out = match (self.client.as_ref(), p.realm, p.key.clone()) { + (Some(client), Some(realm), Some(key)) => { + let ts = self.next_ts(&mut p); + client.self_info(&key, &realm, ts) + } + _ => Err(ClientError::Protocol("the realm has no identity or the Registry address is not set".into())), + }; + self.publishers.insert(local_id.to_string(), p); + out + } + + pub fn realm_id(&self, local_id: &str) -> Option { + self.publishers.get(local_id).and_then(|p| p.realm) + } + + fn next_ts(&self, p: &mut Publisher) -> i64 { + let ts = (self.clock)().max(p.last_ts + 1); + p.last_ts = ts; + ts + } + + /// One pass over the realms that are due. Network calls block (with their own timeouts); nothing else does. + pub fn tick(&mut self, now: Instant) { + let due: Vec = self.publishers.iter().filter(|(_, p)| p.due.is_some_and(|d| d <= now)).map(|(k, _)| k.clone()).collect(); + for local in due { + let Some(mut p) = self.publishers.remove(&local) else { continue }; + self.step(&local, &mut p, now); + self.publishers.insert(local, p); + } + } + + fn step(&self, local: &str, p: &mut Publisher, now: Instant) { + let (Some(client), Some(realm), Some(key)) = (self.client.as_ref(), p.realm, p.key.clone()) else { + p.due = None; + return; + }; + if p.state == PublishState::Unpublishing { + return self.step_unpublish(client, &realm, &key, p, now); + } + let Some(advert) = self.source.current(local) else { + p.state = PublishState::WaitingForRealm; + p.due = Some(now + self.timing.idle_poll); + return; + }; + let result = match p.phase { + Phase::Register => self.register(client, &realm, &key, p, &advert, now), + Phase::Beat => self.beat(client, &realm, &key, p, &advert, now), + }; + if let Err(e) = result { + self.on_error(p, e, now); + } + } + + fn register(&self, client: &RegistryClient, realm: &RealmId, key: &SigningKey, p: &mut Publisher, advert: &LocalAdvert, now: Instant) -> std::result::Result<(), ClientError> { + let Some(caps) = advert.capabilities.clone().filter(|_| advert.running) else { + p.state = if advert.running { PublishState::WaitingForRealm } else { PublishState::RealmStopped }; + p.due = Some(now + self.timing.idle_poll); + return Ok(()); + }; + let req = RegisterRequest { + protocol_version: REGISTRY_PROTOCOL_VERSION, + realm_id: *realm, + public_key: encode_public_key(&key.verifying_key()), + display_name: p.cfg.display_name.clone(), + description: p.cfg.description.clone(), + language: p.cfg.language.clone(), + ruleset: caps.content.ruleset, + manager_version: advert.manager_version.clone(), + capabilities_hash: caps.advert_hash(), + capabilities: caps, + player_count: advert.player_count, + player_capacity: advert.player_capacity, + }; + let ts = self.next_ts(p); + let resp = client.register(key, &req, ts)?; + p.phase = Phase::Beat; + p.acked_hash = Some(resp.capabilities_hash); + p.sent_meta = Some((req.display_name, req.description, req.language)); + self.ok(p, resp.metadata_revision, now); + Ok(()) + } + + fn beat(&self, client: &RegistryClient, realm: &RealmId, key: &SigningKey, p: &mut Publisher, advert: &LocalAdvert, now: Instant) -> std::result::Result<(), ClientError> { + let Some(caps) = advert.capabilities.clone().filter(|_| advert.running) else { + p.state = PublishState::RealmStopped; + p.due = Some(now + self.timing.idle_poll); + return Ok(()); + }; + let hash = caps.advert_hash(); + let meta = (p.cfg.display_name.clone(), p.cfg.description.clone(), p.cfg.language.clone()); + let meta_changed = p.sent_meta.as_ref() != Some(&meta); + let req = HeartbeatRequest { + protocol_version: REGISTRY_PROTOCOL_VERSION, + capabilities: (p.acked_hash.as_deref() != Some(hash.as_str())).then_some(caps), + capabilities_hash: hash, + manager_version: Some(advert.manager_version.clone()), + player_count: advert.player_count, + player_capacity: advert.player_capacity, + display_name: meta_changed.then(|| meta.0.clone()), + description: meta_changed.then(|| meta.1.clone()), + language: meta_changed.then(|| meta.2.clone()), + }; + let ts = self.next_ts(p); + let resp = client.heartbeat(key, realm, &req, ts)?; + if meta_changed { + p.sent_meta = Some(meta); + } + if resp.resend_capabilities { + p.acked_hash = None; + self.ok(p, resp.metadata_revision, now); + p.due = Some(now + Duration::from_secs(1)); + } else { + p.acked_hash = Some(resp.capabilities_hash); + self.ok(p, resp.metadata_revision, now); + } + Ok(()) + } + + fn ok(&self, p: &mut Publisher, revision: u64, now: Instant) { + p.state = PublishState::Online; + p.failures = 0; + p.clock_failures = 0; + p.last_error = None; + p.revision = Some(revision); + p.last_ok = Some((self.clock)()); + p.due = Some(now + self.timing.heartbeat.mul_f64(0.9 + 0.2 * unit())); + } + + fn on_error(&self, p: &mut Publisher, e: ClientError, now: Instant) { + p.last_error = Some(e.to_string()); + match &e { + ClientError::Rejected { code: ErrorCode::UnknownRealm | ErrorCode::NotPublished, .. } => { + p.phase = Phase::Register; + p.acked_hash = None; + p.failures += 1; + p.state = PublishState::Retrying; + p.due = Some(now + if p.failures <= 1 { Duration::from_secs(1) } else { backoff(p.failures, self.timing.backoff_base, self.timing.backoff_cap, unit()) }); + } + ClientError::Rejected { code: ErrorCode::BadTimestamp | ErrorCode::TimestampNotMonotonic, .. } => { + p.clock_failures += 1; + p.failures += 1; + if p.clock_failures > self.timing.max_clock_failures { + p.state = PublishState::Rejected; + p.due = None; + } else { + p.state = PublishState::Retrying; + p.due = Some(now + backoff(p.failures, self.timing.backoff_base, self.timing.backoff_cap, unit())); + } + } + e if e.is_permanent() => { + p.state = PublishState::Rejected; + p.due = None; + } + _ => { + p.failures += 1; + p.state = PublishState::Retrying; + p.due = Some(now + backoff(p.failures, self.timing.backoff_base, self.timing.backoff_cap, unit())); + } + } + } + + fn step_unpublish(&self, client: &RegistryClient, realm: &RealmId, key: &SigningKey, p: &mut Publisher, now: Instant) { + let ts = self.next_ts(p); + match client.unpublish(key, realm, ts) { + Ok(resp) => { + p.revision = Some(resp.metadata_revision); + p.last_error = None; + p.state = PublishState::Disabled; + p.due = None; + } + Err(ClientError::Rejected { code: ErrorCode::UnknownRealm, .. }) => { + p.state = PublishState::Disabled; + p.due = None; + } + Err(e) => { + p.last_error = Some(e.to_string()); + p.unpublish_attempts += 1; + if e.is_permanent() || p.unpublish_attempts >= self.timing.max_unpublish_attempts { + p.state = PublishState::Disabled; + p.due = None; + } else { + p.due = Some(now + backoff(p.unpublish_attempts, self.timing.backoff_base, self.timing.backoff_cap, unit())); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_backoff_grows_is_capped_and_is_spread() { + let (base, cap) = (Duration::from_secs(5), Duration::from_secs(300)); + let mid = |n| backoff(n, base, cap, 0.5).as_secs_f64(); + assert!((mid(1) - 5.0).abs() < 0.01 && (mid(2) - 10.0).abs() < 0.01 && (mid(3) - 20.0).abs() < 0.01); + assert!((mid(10) - 300.0).abs() < 0.01 && (mid(500) - 300.0).abs() < 0.01, "capped"); + assert!(backoff(4, base, cap, 0.0) < backoff(4, base, cap, 0.999), "jitter spreads"); + assert!(backoff(30, base, cap, 0.999) <= Duration::from_secs_f64(300.0 * 1.25)); + assert!(backoff(1, base, cap, 0.0).as_secs_f64() >= 3.7); + for _ in 0..1000 { + let u = unit(); + assert!((0.0..1.0).contains(&u)); + } + } +} diff --git a/crates/coa-core/src/realm_registry/keys.rs b/crates/coa-core/src/realm_registry/keys.rs new file mode 100644 index 0000000..8a1c100 --- /dev/null +++ b/crates/coa-core/src/realm_registry/keys.rs @@ -0,0 +1,160 @@ +//! A realm's Ed25519 identity key: one per realm, generated on first publication, never leaving this machine. +//! +//! Storage is behind [`KeyStore`]. The implementation here keeps one file per realm in the Manager's own data folder, readable by the current user +//! only (mode 0600 on Unix; on Windows the inherited permissions are removed and the current user is granted access with `icacls`). That is +//! weaker than an OS-protected secret store (DPAPI / Credential Manager), which this project does not use yet: anybody who can read the user's +//! files can read the key. The key is never written to the realm descriptor, a log, the diagnostics or the Registry. + +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64; +use base64::Engine; +use coa_registry_proto::RealmId; +use ed25519_dalek::SigningKey; + +use crate::{Error, Result}; + +pub trait KeyStore: Send + Sync { + /// The key of this realm, if one was created. + fn load(&self, realm: &RealmId) -> Result>; + /// A new random key for a realm that has none. Refuses to replace an existing one. + fn create(&self, realm: &RealmId) -> Result; + fn remove(&self, realm: &RealmId) -> Result<()>; +} + +pub struct FileKeyStore { + dir: PathBuf, +} + +impl FileKeyStore { + pub fn new(dir: impl Into) -> Self { + Self { dir: dir.into() } + } + + fn file(&self, realm: &RealmId) -> PathBuf { + self.dir.join(format!("{realm}.key")) + } +} + +fn random_seed() -> [u8; 32] { + let mut seed = [0u8; 32]; + seed[..16].copy_from_slice(uuid::Uuid::new_v4().as_bytes()); + seed[16..].copy_from_slice(uuid::Uuid::new_v4().as_bytes()); + seed +} + +#[cfg(unix)] +fn restrict(file: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(file, fs::Permissions::from_mode(0o600))?; + Ok(()) +} + +#[cfg(windows)] +fn restrict(file: &Path) -> Result<()> { + let user = std::env::var("USERNAME").map_err(|_| Error::Invalid("the current user is unknown".into()))?; + let domain = std::env::var("USERDOMAIN").unwrap_or_default(); + let who = if domain.is_empty() { user } else { format!("{domain}\\{user}") }; + let out = std::process::Command::new("icacls").arg(file).arg("/inheritance:r").arg("/grant:r").arg(format!("{who}:(F)")).output()?; + if !out.status.success() { + return Err(Error::Invalid("the key file's permissions could not be restricted".into())); + } + Ok(()) +} + +#[cfg(not(any(unix, windows)))] +fn restrict(_file: &Path) -> Result<()> { + Ok(()) +} + +impl KeyStore for FileKeyStore { + fn load(&self, realm: &RealmId) -> Result> { + let text = match fs::read_to_string(self.file(realm)) { + Ok(t) => t, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e.into()), + }; + let bytes = B64.decode(text.trim()).map_err(|_| Error::Invalid("a realm key file is not valid".into()))?; + let seed: [u8; 32] = bytes.try_into().map_err(|_| Error::Invalid("a realm key file is not valid".into()))?; + Ok(Some(SigningKey::from_bytes(&seed))) + } + + fn create(&self, realm: &RealmId) -> Result { + fs::create_dir_all(&self.dir)?; + let path = self.file(realm); + if path.exists() { + return Err(Error::Invalid("this realm already has a key".into())); + } + let seed = random_seed(); + let tmp = self.dir.join(format!(".{realm}.{}.tmp", uuid::Uuid::new_v4().simple())); + let write = || -> Result<()> { + let mut f = fs::OpenOptions::new().write(true).create_new(true).open(&tmp)?; + restrict(&tmp)?; + f.write_all(B64.encode(seed).as_bytes())?; + f.sync_all()?; + drop(f); + fs::rename(&tmp, &path)?; + Ok(()) + }; + if let Err(e) = write() { + let _ = fs::remove_file(&tmp); + return Err(e); + } + Ok(SigningKey::from_bytes(&seed)) + } + + fn remove(&self, realm: &RealmId) -> Result<()> { + match fs::remove_file(self.file(realm)) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e.into()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_key_is_created_once_and_read_back() { + let dir = tempfile::tempdir().unwrap(); + let store = FileKeyStore::new(dir.path().join("keys")); + let realm = RealmId::new(); + assert!(store.load(&realm).unwrap().is_none()); + let key = store.create(&realm).unwrap(); + assert_eq!(store.load(&realm).unwrap().unwrap().to_bytes(), key.to_bytes()); + assert!(store.create(&realm).is_err(), "a key is never replaced"); + let other = RealmId::new(); + assert_ne!(store.create(&other).unwrap().to_bytes(), key.to_bytes(), "one key per realm"); + store.remove(&realm).unwrap(); + assert!(store.load(&realm).unwrap().is_none()); + store.remove(&realm).unwrap(); + let leftovers: Vec<_> = fs::read_dir(dir.path().join("keys")).unwrap().flatten().filter(|e| e.file_name().to_string_lossy().ends_with(".tmp")).collect(); + assert!(leftovers.is_empty()); + } + + #[test] + fn a_damaged_key_file_is_an_error_not_a_new_identity() { + let dir = tempfile::tempdir().unwrap(); + let store = FileKeyStore::new(dir.path()); + let realm = RealmId::new(); + fs::write(dir.path().join(format!("{realm}.key")), "garbage").unwrap(); + assert!(store.load(&realm).is_err()); + assert!(store.create(&realm).is_err(), "the broken file is not silently replaced"); + } + + #[cfg(windows)] + #[test] + fn the_key_file_is_not_inherited_from_the_folder() { + let dir = tempfile::tempdir().unwrap(); + let store = FileKeyStore::new(dir.path()); + let realm = RealmId::new(); + store.create(&realm).unwrap(); + let out = std::process::Command::new("icacls").arg(dir.path().join(format!("{realm}.key"))).output().unwrap(); + let text = String::from_utf8_lossy(&out.stdout).to_string(); + assert!(!text.contains("BUILTIN\\Users") && !text.contains("Everyone") && !text.contains("Authenticated Users"), "{text}"); + } +} diff --git a/crates/coa-core/src/realm_registry/mod.rs b/crates/coa-core/src/realm_registry/mod.rs new file mode 100644 index 0000000..67938d3 --- /dev/null +++ b/crates/coa-core/src/realm_registry/mod.rs @@ -0,0 +1,19 @@ +//! Publishing a realm to the central Registry (Phase 10): the Host side of registration and presence. +//! +//! The Registry is discovery infrastructure only. What this module sends is the realm's public identity (a UUIDv7 and an Ed25519 public key), a +//! display name, a description, a language, the Manager version, the realm's advertised capabilities and optionally its player count. It never +//! sends or reads a database or console credential, a game account, a character, a canonical revision or a session payload, and it never changes +//! portable state. See `docs/REGISTRY_PROTOCOL.md`. + +pub mod advert; +pub mod client; +pub mod host; +pub mod keys; +pub mod runtime; +pub mod settings; + +pub use advert::{advertise, AdvertSource, LocalAdvert, LocalRealmsSource}; +pub use client::{ClientError, RegistryClient}; +pub use host::{PublishState, RealmPublishStatus, RegistryHost, RegistryStatus, Timing}; +pub use keys::{FileKeyStore, KeyStore}; +pub use runtime::RegistryRuntime; diff --git a/crates/coa-core/src/realm_registry/runtime.rs b/crates/coa-core/src/realm_registry/runtime.rs new file mode 100644 index 0000000..229af3b --- /dev/null +++ b/crates/coa-core/src/realm_registry/runtime.rs @@ -0,0 +1,107 @@ +//! The publishing loop on its own thread: it starts with the application, keeps going while the interface is elsewhere, and stops with the Manager. +//! Commands are answered between passes; a pass blocks only on the Registry's own timeouts. + +use std::path::Path; +use std::sync::mpsc::{self, RecvTimeoutError, Sender}; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +use super::advert::AdvertSource; +use super::host::{system_clock, Clock, RegistryHost, RegistryStatus, Timing}; +use super::keys::KeyStore; +use crate::{Error, Result}; + +enum Cmd { + SetUrl(Option, Sender>), + Publish { local_id: String, name: String, description: String, language: String, reply: Sender> }, + Unpublish(String, Sender>), + Retry(String), + Shutdown, +} + +pub struct RegistryRuntime { + tx: Sender, + status: Arc>, + join: Mutex>>, +} + +impl RegistryRuntime { + pub fn start(dir: &Path, keys: Arc, source: Arc) -> Result { + Self::start_with(dir, keys, source, system_clock(), Timing::default()) + } + + pub fn start_with(dir: &Path, keys: Arc, source: Arc, clock: Clock, timing: Timing) -> Result { + let mut host = RegistryHost::open(dir, keys, source, clock, timing, Instant::now())?; + let status = Arc::new(Mutex::new(host.status(Instant::now()))); + let (tx, rx) = mpsc::channel::(); + let shared = status.clone(); + let join = std::thread::Builder::new() + .name("realm-registry".into()) + .spawn(move || loop { + match rx.recv_timeout(Duration::from_millis(500)) { + Ok(Cmd::Shutdown) | Err(RecvTimeoutError::Disconnected) => return, + Ok(Cmd::SetUrl(url, reply)) => { + let _ = reply.send(host.set_url(url.as_deref(), Instant::now())); + } + Ok(Cmd::Publish { local_id, name, description, language, reply }) => { + let _ = reply.send(host.publish(&local_id, &name, &description, &language, Instant::now())); + } + Ok(Cmd::Unpublish(local_id, reply)) => { + let _ = reply.send(host.unpublish(&local_id, Instant::now())); + } + Ok(Cmd::Retry(local_id)) => host.retry(&local_id, Instant::now()), + Err(RecvTimeoutError::Timeout) => {} + } + let now = Instant::now(); + host.tick(now); + if let Ok(mut s) = shared.lock() { + *s = host.status(now); + } + }) + .map_err(Error::Io)?; + Ok(Self { tx, status, join: Mutex::new(Some(join)) }) + } + + pub fn status(&self) -> RegistryStatus { + self.status.lock().map(|s| s.clone()).unwrap_or_default() + } + + fn ask(&self, make: impl FnOnce(Sender>) -> Cmd) -> Result<()> { + let (reply, answer) = mpsc::channel(); + self.tx.send(make(reply)).map_err(|_| Error::Invalid("the registry loop has stopped".into()))?; + answer.recv_timeout(Duration::from_secs(30)).map_err(|_| Error::Invalid("the registry loop did not answer".into()))? + } + + pub fn set_url(&self, url: Option) -> Result<()> { + self.ask(|reply| Cmd::SetUrl(url, reply)) + } + + pub fn publish(&self, local_id: &str, name: &str, description: &str, language: &str) -> Result<()> { + self.ask(|reply| Cmd::Publish { local_id: local_id.into(), name: name.into(), description: description.into(), language: language.into(), reply }) + } + + pub fn unpublish(&self, local_id: &str) -> Result<()> { + self.ask(|reply| Cmd::Unpublish(local_id.into(), reply)) + } + + pub fn retry(&self, local_id: &str) { + let _ = self.tx.send(Cmd::Retry(local_id.into())); + } + + /// Stop the loop. A published realm is left published: it simply stops being heard of and goes offline after the TTL. + pub fn shutdown(&self) { + let _ = self.tx.send(Cmd::Shutdown); + if let Ok(mut guard) = self.join.lock() { + if let Some(j) = guard.take() { + let _ = j.join(); + } + } + } +} + +impl Drop for RegistryRuntime { + fn drop(&mut self) { + self.shutdown(); + } +} diff --git a/crates/coa-core/src/realm_registry/settings.rs b/crates/coa-core/src/realm_registry/settings.rs new file mode 100644 index 0000000..3cf289f --- /dev/null +++ b/crates/coa-core/src/realm_registry/settings.rs @@ -0,0 +1,111 @@ +//! What the Host remembers about publishing: the Registry's address and, per local realm, whether it is published and under which public identity. +//! No key and no credential is stored here (keys live behind [`KeyStore`](super::keys::KeyStore)). + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use coa_registry_proto::{validate_description, validate_display_name, validate_language, RealmId}; +use serde::{Deserialize, Serialize}; + +use crate::{fsx, Error, Result}; + +pub const SETTINGS_SCHEMA: u32 = 1; + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PublishConfig { + pub realm_id: Option, + pub enabled: bool, + pub display_name: String, + pub description: String, + pub language: String, +} + +impl PublishConfig { + pub fn validate(&self) -> Result<()> { + validate_display_name(&self.display_name).map_err(|e| Error::Invalid(e.to_string()))?; + validate_description(&self.description).map_err(|e| Error::Invalid(e.to_string()))?; + validate_language(&self.language).map_err(|e| Error::Invalid(e.to_string()))?; + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RegistrySettings { + pub schema: u32, + /// The Registry's base address, e.g. `http://host` or `https://registry.example` (no path). + pub url: Option, + /// Keyed by the local realm id (`srv-` or a prepared realm's id). + pub realms: BTreeMap, +} + +impl Default for RegistrySettings { + fn default() -> Self { + Self { schema: SETTINGS_SCHEMA, url: None, realms: BTreeMap::new() } + } +} + +/// A Registry address: http or https, a host, an optional port, nothing else. +pub fn validate_url(url: &str) -> Result { + let trimmed = url.trim().trim_end_matches('/'); + let rest = trimmed.strip_prefix("http://").or_else(|| trimmed.strip_prefix("https://")).ok_or_else(|| Error::Invalid("the Registry address must start with http:// or https://".into()))?; + if rest.is_empty() || rest.contains(['/', '?', '#', '@', ' ']) { + return Err(Error::Invalid("the Registry address is a host and an optional port, nothing else".into())); + } + let host = rest.rsplit_once(':').map(|(h, p)| if p.bytes().all(|b| b.is_ascii_digit()) && !p.is_empty() { h } else { rest }).unwrap_or(rest); + if !crate::client::host_ok(host) || host.contains(':') { + return Err(Error::Invalid("the Registry host is not a host name or an IPv4 address".into())); + } + Ok(trimmed.to_string()) +} + +pub fn file(dir: &Path) -> PathBuf { + dir.join("registry.json") +} + +pub fn load(dir: &Path) -> Result { + match std::fs::read(file(dir)) { + Ok(bytes) => { + let s: RegistrySettings = serde_json::from_slice(&bytes)?; + if s.schema != SETTINGS_SCHEMA { + return Err(Error::Invalid(format!("registry settings schema {} is not supported", s.schema))); + } + Ok(s) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(RegistrySettings::default()), + Err(e) => Err(e.into()), + } +} + +pub fn save(dir: &Path, settings: &RegistrySettings) -> Result<()> { + fsx::atomic_write(&file(dir), &serde_json::to_vec_pretty(settings)?) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn addresses_are_a_host_and_a_port_only() { + assert_eq!(validate_url("http://127.0.0.1:8080/").unwrap(), "http://127.0.0.1:8080"); + assert_eq!(validate_url(" https://registry.example ").unwrap(), "https://registry.example"); + for bad in ["", "ftp://x", "registry.example", "http://", "http://x/registry", "http://user:pw@x", "http://x?y=1", "http://x y", "http://[::1]:80", "http://x:abc"] { + assert!(validate_url(bad).is_err(), "{bad}"); + } + } + + #[test] + fn settings_round_trip_and_refuse_what_they_do_not_know() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(load(dir.path()).unwrap(), RegistrySettings::default()); + let mut s = RegistrySettings { url: Some("http://x".into()), ..Default::default() }; + s.realms.insert("srv-1".into(), PublishConfig { realm_id: Some(RealmId::new()), enabled: true, display_name: "A".into(), description: String::new(), language: "en".into() }); + save(dir.path(), &s).unwrap(); + assert_eq!(load(dir.path()).unwrap(), s); + let text = std::fs::read_to_string(file(dir.path())).unwrap(); + assert!(!text.contains("key") && !text.contains("password"), "{text}"); + std::fs::write(file(dir.path()), r#"{"schema":1,"url":null,"realms":{},"private_key":"x"}"#).unwrap(); + assert!(load(dir.path()).is_err()); + } +} diff --git a/crates/coa-registry-proto/Cargo.toml b/crates/coa-registry-proto/Cargo.toml new file mode 100644 index 0000000..3a81453 --- /dev/null +++ b/crates/coa-registry-proto/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "coa-registry-proto" +description = "Wire protocol of the CoA realm Registry (version 1): types, limits, validation and signed-request canonicalisation. No I/O." +version = "0.1.0" +edition = "2021" +license = "AGPL-3.0-only" +publish = false + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +hex = "0.4" +base64 = "0.22" +uuid = { version = "1", features = ["serde", "v7"] } +ed25519-dalek = "2" +thiserror = "2" + +[dev-dependencies] diff --git a/crates/coa-registry-proto/src/caps.rs b/crates/coa-registry-proto/src/caps.rs new file mode 100644 index 0000000..ce6ec91 --- /dev/null +++ b/crates/coa-registry-proto/src/caps.rs @@ -0,0 +1,312 @@ +//! The form of `RealmCapabilities` (Manager profile version 2) that a realm advertises to the Registry. +//! +//! The types mirror `coa_core::portable::capabilities` field for field (same names, same order: the content hash is taken over the JSON, +//! so the order is part of the contract), with the limits of a public, hostile-input boundary added. `coa-core` has a test that a real +//! profile parses into this type and keeps its hash, so the two cannot drift apart unnoticed. What the Registry stores is host-advertised +//! discovery metadata: a future JOIN negotiates the capabilities again with the live Host. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::{invalid, Result}; + +pub const PROFILE_VERSION: u32 = 2; +pub const MAX_EXTENSIONS: usize = 64; +pub const MAX_COLLECTION_KINDS: usize = 16; +pub const MAX_JOB_FORMATS: usize = 16; +pub const MAX_CATALOG_ENTRIES: usize = 8; +const MAX_TOKEN: usize = 64; +const MAX_IDENTITY: usize = 128; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Ruleset { + Coa, + Wildcard, +} + +impl Ruleset { + pub fn as_str(self) -> &'static str { + match self { + Ruleset::Coa => "coa", + Ruleset::Wildcard => "wildcard", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Feature { + RuntimeSessions, + Wardrobe, + Collections, + LevelProjection, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FormatRange { + pub min: u32, + pub max: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CharacterFormats { + pub readable: FormatRange, + pub writable: FormatRange, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CatalogEntry { + pub sha256: String, + pub records: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExtensionSupport { + pub namespace: String, + pub module_version: String, + pub formats: FormatRange, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CoreIdentity { + pub commit: String, + pub branch: String, + pub date: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ContentProfile { + pub ruleset: Ruleset, + pub character_formats: CharacterFormats, + pub online_import_job_formats: Vec, + pub session_protocol: u32, + pub collection_protocol: u32, + pub features: BTreeSet, + pub collection_kinds: BTreeSet, + pub extensions: Vec, + pub client_catalog: BTreeMap, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Progression { + pub max_player_level: u32, + pub projection_protocol: u32, + pub projection_policy_version: u32, + pub progression_signature: String, + pub scaling_enabled: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AdvertisedCapabilities { + pub profile_version: u32, + pub core: Option, + pub content: ContentProfile, + pub content_profile_hash: String, + #[serde(default)] + pub progression: Option, +} + +fn hex64(s: &str) -> bool { + s.len() == 64 && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +fn token(s: &str, what: &str) -> Result<()> { + if s.is_empty() || s.len() > MAX_TOKEN || !s.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_' | b':' | b'+')) { + return invalid(format!("capabilities: {what} is not a short token")); + } + Ok(()) +} + +fn identity_text(s: &str, what: &str) -> Result<()> { + if s.len() > MAX_IDENTITY || s.chars().any(char::is_control) { + return invalid(format!("capabilities: {what} is too long or has control characters")); + } + Ok(()) +} + +fn range(r: &FormatRange, what: &str) -> Result<()> { + if r.min < 1 || r.min > r.max || r.max > 1000 { + return invalid(format!("capabilities: {what} is not a sane version range")); + } + Ok(()) +} + +impl ContentProfile { + /// SHA-256 of the canonical JSON, as the Manager computes it (`coa-content-profile-v1\0` + the JSON with the extensions sorted). + pub fn hash(&self) -> String { + let mut canonical = self.clone(); + canonical.extensions.sort_by(|a, b| a.namespace.cmp(&b.namespace)); + let json = serde_json::to_vec(&canonical).expect("a profile serialises"); + let mut h = Sha256::new(); + h.update(b"coa-content-profile-v1\0"); + h.update(json); + hex::encode(h.finalize()) + } +} + +impl AdvertisedCapabilities { + /// Bounds on everything, and the declared hash must be the hash of the content. + pub fn validate(&self) -> Result<()> { + if self.profile_version != PROFILE_VERSION { + return invalid(format!("capabilities: profile version {} is not supported (expected {PROFILE_VERSION})", self.profile_version)); + } + let c = &self.content; + range(&c.character_formats.readable, "the readable character formats")?; + range(&c.character_formats.writable, "the writable character formats")?; + if c.online_import_job_formats.len() > MAX_JOB_FORMATS || c.online_import_job_formats.iter().any(|v| *v == 0 || *v > 1000) { + return invalid("capabilities: the import job formats are out of range"); + } + if c.session_protocol > 1000 || c.collection_protocol > 1000 { + return invalid("capabilities: a protocol version is out of range"); + } + if c.collection_kinds.len() > MAX_COLLECTION_KINDS { + return invalid("capabilities: too many collection kinds"); + } + for k in &c.collection_kinds { + token(k, "a collection kind")?; + } + if c.extensions.len() > MAX_EXTENSIONS { + return invalid("capabilities: too many extensions"); + } + let mut seen = BTreeSet::new(); + for e in &c.extensions { + token(&e.namespace, "an extension namespace")?; + token(&e.module_version, "an extension version")?; + range(&e.formats, "an extension's formats")?; + if !seen.insert(&e.namespace) { + return invalid("capabilities: an extension is listed twice"); + } + } + if c.client_catalog.len() > MAX_CATALOG_ENTRIES { + return invalid("capabilities: too many client tables"); + } + for (name, entry) in &c.client_catalog { + if name.is_empty() || name.len() > MAX_TOKEN || !name.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) { + return invalid("capabilities: a client table name is not a plain file name"); + } + if !hex64(&entry.sha256) || entry.records > 100_000_000 { + return invalid("capabilities: a client table entry is malformed"); + } + } + if let Some(core) = &self.core { + identity_text(&core.commit, "the core commit")?; + identity_text(&core.branch, "the core branch")?; + identity_text(&core.date, "the core date")?; + } + if let Some(p) = &self.progression { + if p.max_player_level == 0 || p.max_player_level > 255 || p.projection_protocol == 0 || p.projection_protocol > 1000 || p.projection_policy_version == 0 || p.projection_policy_version > 1000 { + return invalid("capabilities: the progression numbers are out of range"); + } + if !hex64(&p.progression_signature) { + return invalid("capabilities: the progression signature is not a SHA-256"); + } + } + if !hex64(&self.content_profile_hash) { + return invalid("capabilities: the content hash is not a SHA-256"); + } + if self.content.hash() != self.content_profile_hash { + return invalid("capabilities: the content hash is not the hash of the content"); + } + Ok(()) + } + + /// The hash that identifies this advertisement (the content hash plus the progression signature: both change what a Host can take). + pub fn advert_hash(&self) -> String { + let mut h = Sha256::new(); + h.update(b"coa-registry-advert-v1\0"); + h.update(self.content_profile_hash.as_bytes()); + match &self.progression { + Some(p) => { + h.update(b"\x01"); + h.update(p.max_player_level.to_be_bytes()); + h.update(p.projection_protocol.to_be_bytes()); + h.update(p.projection_policy_version.to_be_bytes()); + h.update(p.progression_signature.as_bytes()); + h.update([p.scaling_enabled as u8]); + } + None => h.update(b"\x00"), + } + hex::encode(h.finalize()) + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + pub(crate) fn sample() -> AdvertisedCapabilities { + let content = ContentProfile { + ruleset: Ruleset::Coa, + character_formats: CharacterFormats { readable: FormatRange { min: 1, max: 2 }, writable: FormatRange { min: 2, max: 2 } }, + online_import_job_formats: vec![2], + session_protocol: 2, + collection_protocol: 1, + features: [Feature::RuntimeSessions, Feature::Wardrobe, Feature::LevelProjection].into_iter().collect(), + collection_kinds: ["coa:appearance".to_string()].into_iter().collect(), + extensions: vec![ExtensionSupport { namespace: "coa".into(), module_version: "1".into(), formats: FormatRange { min: 1, max: 1 } }], + client_catalog: [("Appearances.dbc".to_string(), CatalogEntry { sha256: "a".repeat(64), records: 5 })].into_iter().collect(), + }; + let content_profile_hash = content.hash(); + AdvertisedCapabilities { + profile_version: 2, + core: Some(CoreIdentity { commit: "abc123".into(), branch: "main".into(), date: "2026-10-01".into() }), + content, + content_profile_hash, + progression: Some(Progression { max_player_level: 60, projection_protocol: 1, projection_policy_version: 1, progression_signature: "b".repeat(64), scaling_enabled: false }), + } + } + + #[test] + fn a_sound_advertisement_validates_and_a_lying_hash_does_not() { + sample().validate().unwrap(); + let mut c = sample(); + c.content_profile_hash = "0".repeat(64); + assert!(c.validate().is_err()); + let mut c = sample(); + c.content.session_protocol = 3; + assert!(c.validate().is_err(), "the content changed under the same hash"); + } + + #[test] + fn hostile_content_is_bounded() { + let mut c = sample(); + c.content.extensions = (0..65).map(|i| ExtensionSupport { namespace: format!("ns{i}"), module_version: "1".into(), formats: FormatRange { min: 1, max: 1 } }).collect(); + c.content_profile_hash = c.content.hash(); + assert!(c.validate().is_err()); + let mut c = sample(); + c.content.client_catalog.insert("../../x".into(), CatalogEntry { sha256: "a".repeat(64), records: 1 }); + c.content_profile_hash = c.content.hash(); + assert!(c.validate().is_err()); + let mut c = sample(); + c.core.as_mut().unwrap().branch = "x".repeat(10_000); + assert!(c.validate().is_err()); + let mut c = sample(); + c.content.collection_kinds.insert("")), ("display_name", json!("evil\u{202E}name"))] { + let mut v = base_body.clone(); + v[field] = value; + let r = hostile.send("POST", PATH_REGISTER, &serde_json::to_vec(&v).unwrap()); + assert_eq!(r.status, 400, "{field}"); + } + for extra in ["character", "snapshot", "password", "ra_password", "db_credentials", "canonical_revision"] { + let mut v = base_body.clone(); + v[extra] = json!("secret"); + let r = hostile.send("POST", PATH_REGISTER, &serde_json::to_vec(&v).unwrap()); + assert_eq!((r.status, r.code().as_str()), (400, "malformed_request"), "{extra}"); + } + let nested = format!("{}1{}", "[".repeat(30_000), "]".repeat(30_000)); + assert_eq!(hostile.send("POST", PATH_REGISTER, nested.as_bytes()).status, 400); + let mut clean = Host::remote(&base); + clean.realm = hostile.realm; + assert_eq!(clean.me().status, 404, "nothing was stored by the hostile requests"); + pass(11, "oversized body 413; hostile/oversized metadata, unknown fields, JSON nesting bomb: 400; nothing stored"); + + // the per-realm allowance (burst 4, one more every 10 s) was used up by the refusals above + std::thread::sleep(Duration::from_secs(22)); + h.sync_clock(); + assert_eq!(h.unpublish().status, 200); + std::thread::sleep(Duration::from_secs(12)); + h.sync_clock(); + let r = h.heartbeat(60, false); + assert_eq!((r.status, r.code().as_str()), (409, "not_published")); + pass(12, "raw unpublish: heartbeat afterwards 409 not_published"); + + println!("GATE_REALM {realm}"); +} + +fn sign_header_version() -> &'static str { + coa_registry_proto::sign::HEADER_VERSION +} + +/// 5. a realm that stops heartbeating goes offline after the TTL and is not deleted (takes just over two minutes). +#[test] +#[ignore] +fn gate_5_missing_heartbeats_mean_offline_not_deleted() { + let Some(base) = base() else { return }; + let mut h = Host::remote(&base); + assert_eq!(h.register("TTL realm", 60).status, 200); + h.sync_clock(); + assert_eq!(h.me().body["online"], true); + let started = Instant::now(); + std::thread::sleep(Duration::from_secs(125)); + h.sync_clock(); + let me = h.me(); + assert_eq!((me.status, me.body["online"].as_bool(), me.body["published"].as_bool()), (200, Some(false), Some(true)), "{:?}", me.body); + h.sync_clock(); + assert_eq!(h.heartbeat(60, false).status, 200); + h.sync_clock(); + assert_eq!(h.me().body["online"], true); + pass(5, &format!("no heartbeat for {}s: online=false, published=true, record kept; a heartbeat brings it back", started.elapsed().as_secs())); + println!("GATE_REALM {}", h.realm); +} + +/// The record a Registry restart (item 2) and a database restart (item 14) are checked against: written once, read back by the driver script. +#[test] +#[ignore] +fn gate_persist_probe_write() { + let Some(base) = base() else { return }; + let mut h = Host::remote(&base); + assert_eq!(h.register("Persist realm", 60).status, 200); + let seed = h.key.to_bytes(); + println!("PERSIST_REALM {} {}", h.realm, hex_of(&seed)); +} + +fn hex_of(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +#[test] +#[ignore] +fn gate_persist_probe_read() { + let Some(base) = base() else { return }; + let (Ok(realm), Ok(seed)) = (std::env::var("PERSIST_REALM"), std::env::var("PERSIST_SEED")) else { return }; + let mut h = Host::remote(&base); + h.realm = RealmId::parse(&realm).unwrap(); + let bytes: Vec = (0..seed.len() / 2).map(|i| u8::from_str_radix(&seed[2 * i..2 * i + 2], 16).unwrap()).collect(); + h.key = ed25519_dalek::SigningKey::from_bytes(&bytes.try_into().unwrap()); + h.sync_clock(); + let me = h.me(); + assert_eq!((me.status, me.body["display_name"].as_str(), me.body["published"].as_bool()), (200, Some("Persist realm"), Some(true)), "{:?}", me.body); + h.sync_clock(); + assert_eq!(h.heartbeat(60, false).status, 200, "and it can still heartbeat with its own key"); + println!("PERSIST_OK {realm}"); +} diff --git a/services/coa-registry/Cargo.lock b/services/coa-registry/Cargo.lock new file mode 100644 index 0000000..82975b2 --- /dev/null +++ b/services/coa-registry/Cargo.lock @@ -0,0 +1,1918 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "coa-registry" +version = "0.1.0" +dependencies = [ + "axum", + "coa-registry-proto", + "deadpool-postgres", + "ed25519-dalek", + "hex", + "pin-project-lite", + "reqwest", + "serde", + "serde_json", + "tempfile", + "thiserror", + "tokio", + "tokio-postgres", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "coa-registry-proto" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "ed25519-dalek", + "hex", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror", + "uuid", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +dependencies = [ + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "deadpool" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e98a7e119cd347f4201e1159b19831029e203e2d8b790547708e8157b4acf1e" +dependencies = [ + "deadpool-runtime", + "tokio", +] + +[[package]] +name = "deadpool-postgres" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65a536565624b97fc19f758cd01b15d12908d3344425066efc8162236fbd3749" +dependencies = [ + "async-trait", + "deadpool", + "getrandom 0.4.3", + "tokio", + "tokio-postgres", + "tracing", +] + +[[package]] +name = "deadpool-runtime" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2657f61fb1dd8bf37a8d51093cc7cee4e77125b22f7753f49b289f831bec2bae" +dependencies = [ + "tokio", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared", + "serde", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "postgres-protocol" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" +dependencies = [ + "base64 0.22.1", + "byteorder", + "bytes", + "fallible-iterator", + "hmac", + "md-5", + "memchr", + "rand", + "sha2 0.11.0", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" +dependencies = [ + "bytes", + "fallible-iterator", + "postgres-protocol", + "serde_core", + "serde_json", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tokio-postgres" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand", + "socket2", + "tokio", + "tokio-util", + "whoami", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tokio", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/services/coa-registry/Cargo.toml b/services/coa-registry/Cargo.toml new file mode 100644 index 0000000..e5b5f59 --- /dev/null +++ b/services/coa-registry/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "coa-registry" +description = "CoA realm Registry service (Phase 10): discovery and presence only. Standalone; not part of the Manager workspace." +version = "0.1.0" +edition = "2021" +license = "AGPL-3.0-only" +publish = false + +[workspace] + +[dependencies] +coa-registry-proto = { path = "../../crates/coa-registry-proto" } +axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio", "query"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "time", "net", "sync"] } +tower = { version = "0.5", features = ["limit", "util"] } +tower-http = { version = "0.6", features = ["timeout"] } +tokio-postgres = { version = "0.7", features = ["with-serde_json-1"] } +deadpool-postgres = "0.14" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +ed25519-dalek = "2" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] } +thiserror = "2" +hex = "0.4" +pin-project-lite = "0.2" + +[dev-dependencies] +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json"] } +tempfile = "3" + +[profile.release] +lto = "thin" +codegen-units = 4 +strip = true diff --git a/services/coa-registry/Dockerfile b/services/coa-registry/Dockerfile new file mode 100644 index 0000000..b37fe41 --- /dev/null +++ b/services/coa-registry/Dockerfile @@ -0,0 +1,15 @@ +# Build context: the repository root. docker build -f services/coa-registry/Dockerfile -t coa-registry: . +FROM rust:1-bookworm AS build +WORKDIR /src +COPY crates/coa-registry-proto crates/coa-registry-proto +COPY services/coa-registry services/coa-registry +WORKDIR /src/services/coa-registry +RUN cargo build --release --locked --bin coa-registry + +FROM debian:bookworm-slim +RUN useradd --system --uid 10001 --user-group --no-create-home --shell /usr/sbin/nologin coa +COPY --from=build /src/services/coa-registry/target/release/coa-registry /usr/local/bin/coa-registry +USER 10001:10001 +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD ["/usr/local/bin/coa-registry", "--healthcheck"] +ENTRYPOINT ["/usr/local/bin/coa-registry"] diff --git a/services/coa-registry/deploy/Caddyfile b/services/coa-registry/deploy/Caddyfile new file mode 100644 index 0000000..12400b5 --- /dev/null +++ b/services/coa-registry/deploy/Caddyfile @@ -0,0 +1,25 @@ +{ + # No domain yet: plain HTTP on :80 only, no ACME, no admin API. HTTPS is required before the public phases. + auto_https off + admin off +} + +:80 { + respond /healthz "ok" 200 + + handle /registry/* { + request_body { + max_size 70KB + } + reverse_proxy coa-registry:8080 { + # the Registry rate-limits by the address Caddy saw, taken from the last entry of this header + header_up X-Forwarded-For {remote_host} + transport http { + dial_timeout 3s + response_header_timeout 15s + } + } + } + + respond "not found" 404 +} diff --git a/services/coa-registry/deploy/audit-db.sh b/services/coa-registry/deploy/audit-db.sh new file mode 100644 index 0000000..f44ffa2 --- /dev/null +++ b/services/coa-registry/deploy/audit-db.sh @@ -0,0 +1,4 @@ +#!/bin/sh +# Run on the node as root: sudo sh audit-db.sh (the password is read inside the container from its own secret) +set -eu +docker exec -i coa-postgres-postgres-1 sh -c 'PGPASSWORD=$(cat /run/secrets/postgres_password) exec psql -U coa -d coa_registry -X -q' < "$(dirname "$0")/audit-db.sql" diff --git a/services/coa-registry/deploy/audit-db.sql b/services/coa-registry/deploy/audit-db.sql new file mode 100644 index 0000000..1d3f0c6 --- /dev/null +++ b/services/coa-registry/deploy/audit-db.sql @@ -0,0 +1,14 @@ +-- Phase-10 gate item 17: what the database holds. Run: sudo sh audit-db.sh +\echo == tables +SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY 1; +\echo == columns of realms +SELECT column_name || ' ' || data_type FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'realms' ORDER BY ordinal_position; +\echo == any table or column that looks like players, characters, accounts, credentials, snapshots (must be empty) +SELECT table_name || '.' || column_name FROM information_schema.columns + WHERE table_schema = 'public' AND (column_name ~* '(password|passwd|secret|token|credential|account|character|snapshot|inventory|payload|session|canonical|guid|ra_user|mysql)' OR table_name ~* '(player|character|account|session|snapshot|credential)'); +\echo == rows containing forbidden words anywhere in their text form (must be 0) +SELECT count(*) FROM realms r WHERE row_to_json(r)::text ~* '(password|credential|secret|snapshot|inventory|canonical|character_id|ra_password|mysql|private)'; +\echo == the keys stored are public keys of 32 bytes (private keys are 32 bytes too: they are never sent, there is no field for them) +SELECT count(*) AS realms, count(*) FILTER (WHERE octet_length(public_key) = 32) AS keys32 FROM realms; +\echo == largest stored capabilities (bytes) +SELECT max(octet_length(capabilities::text)) FROM realms; diff --git a/services/coa-registry/deploy/deploy.sh b/services/coa-registry/deploy/deploy.sh new file mode 100644 index 0000000..16f0710 --- /dev/null +++ b/services/coa-registry/deploy/deploy.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# Build the Registry image on the node from the working tree and (re)start the stack. Usage (from the repository root): +# VPS=ubuntu@ TAG=p10c sh services/coa-registry/deploy/deploy.sh +# Secrets are never touched here: /opt/coa/secrets/{postgres_password,registry_db_password} must already exist (see docs/REGISTRY_DEPLOYMENT.md). +set -eu +: "${VPS:?set VPS=user@host}" "${TAG:?set TAG=}" +tar --exclude=target -czf /tmp/registry-src.tgz crates/coa-registry-proto services/coa-registry .dockerignore +scp -q /tmp/registry-src.tgz "$VPS:/tmp/registry-src.tgz" +ssh "$VPS" "set -e +sudo rm -rf /opt/coa/registry/src && mkdir -p /opt/coa/registry/src && tar -xzf /tmp/registry-src.tgz -C /opt/coa/registry/src +cd /opt/coa/registry/src && sudo docker build -q -f services/coa-registry/Dockerfile -t coa-registry:$TAG . >/dev/null +cd /opt/coa/registry && sudo sed -i 's/^REGISTRY_TAG=.*/REGISTRY_TAG=$TAG/' .env && sudo docker compose up -d +sleep 8 && sudo docker ps --format '{{.Names}} {{.Status}}'" diff --git a/services/coa-registry/deploy/gate-restarts.sh b/services/coa-registry/deploy/gate-restarts.sh new file mode 100644 index 0000000..f1b92c4 --- /dev/null +++ b/services/coa-registry/deploy/gate-restarts.sh @@ -0,0 +1,39 @@ +#!/bin/sh +# Phase-10 gate items 2 and 14, driven from a workstation: VPS=ubuntu@ REGISTRY_URL=http:// sh gate-restarts.sh +# A realm is registered once (random key); after every restart below it must still be there and must still be able to heartbeat with its own key. +set -u +: "${VPS:?}" "${REGISTRY_URL:?}" +E2E="$(cd "$(dirname "$0")/../../coa-registry-e2e" && pwd)" +run() { (cd "$E2E" && REGISTRY_URL="$REGISTRY_URL" "$@" 2>&1); } +remote() { ssh "$VPS" "$1"; } +wait_healthy() { # $1 container, $2 seconds + i=0; while [ $i -lt "$2" ]; do + s=$(remote "sudo docker inspect -f '{{.State.Health.Status}}' $1 2>/dev/null"); [ "$s" = healthy ] && return 0; i=$((i+2)); sleep 2; done; return 1; } +wait_api() { i=0; while [ $i -lt 90 ]; do code=$(curl -s -o /dev/null -m 5 -w '%{http_code}' "$REGISTRY_URL/registry/v1/healthz"); [ "$code" = 200 ] && return 0; i=$((i+2)); sleep 2; done; return 1; } + +out=$(run cargo test --test live_vps gate_persist_probe_write -- --ignored --nocapture | grep PERSIST_REALM) +set -- $out; REALM=$2; SEED=$3 +[ -n "$REALM" ] || { echo "GATE FAIL: probe write"; exit 1; } +echo "probe realm $REALM written" +check() { # $1 label + r=$(PERSIST_REALM=$REALM PERSIST_SEED=$SEED run cargo test --test live_vps gate_persist_probe_read -- --ignored --nocapture | grep -E "PERSIST_OK|panicked") + case "$r" in *PERSIST_OK*) echo "GATE PASS $1: realm still registered, own key still accepted";; *) echo "GATE FAIL $1: $r"; return 1;; esac; } + +echo "--- 2. Registry container restart" +remote "sudo docker restart coa-registry-registry-1 >/dev/null"; wait_healthy coa-registry-registry-1 90 && wait_api && check "2: after restarting the Registry container" + +echo "--- 14a. PostgreSQL container restart (health is degraded meanwhile, then recovers by itself)" +remote "sudo docker restart coa-postgres-postgres-1 >/dev/null" +sleep 1; echo "health during the restart: $(curl -s -m 5 -o /dev/null -w '%{http_code}' "$REGISTRY_URL/registry/v1/healthz")" +wait_healthy coa-postgres-postgres-1 90 && wait_api && check "14a: after restarting PostgreSQL (the Registry container was not restarted)" + +echo "--- 14b. both stopped, PostgreSQL started later than the Registry (the Registry waits for it)" +remote "sudo docker stop coa-registry-registry-1 coa-postgres-postgres-1 >/dev/null; sudo docker start coa-registry-registry-1 >/dev/null" +sleep 6; echo "Registry up without a database: health $(curl -s -m 5 -o /dev/null -w '%{http_code}' "$REGISTRY_URL/registry/v1/healthz")" +remote "sudo docker start coa-postgres-postgres-1 >/dev/null" +wait_healthy coa-postgres-postgres-1 90 && wait_healthy coa-registry-registry-1 120 && wait_api && check "14b: after a cold start in the wrong order" + +echo "--- 14c. the stack taken down and brought up again (the data lives in the volume)" +remote "cd /opt/coa/registry && sudo docker compose down >/dev/null 2>&1; cd /opt/coa/postgres && sudo docker compose down >/dev/null 2>&1; cd /opt/coa/postgres && sudo docker compose up -d >/dev/null 2>&1; cd /opt/coa/registry && sudo docker compose up -d >/dev/null 2>&1" +wait_healthy coa-postgres-postgres-1 90 && wait_healthy coa-registry-registry-1 120 && wait_api && check "14c: after compose down/up of both stacks" +remote "sudo docker ps --format '{{.Names}} {{.Status}}'" diff --git a/services/coa-registry/deploy/postgres.compose.yaml b/services/coa-registry/deploy/postgres.compose.yaml new file mode 100644 index 0000000..f45ef6a --- /dev/null +++ b/services/coa-registry/deploy/postgres.compose.yaml @@ -0,0 +1,37 @@ +# /opt/coa/postgres/compose.yaml (Phase 10). No `ports:`: PostgreSQL is reachable only on the internal coa-db network. +# The superuser password is read from /opt/coa/secrets (never in git); the Registry has its own least-privilege role and database. +name: coa-postgres +services: + postgres: + image: postgres:17 + restart: unless-stopped + environment: + POSTGRES_USER: coa + POSTGRES_DB: coa + POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password + POSTGRES_INITDB_ARGS: "--auth-host=scram-sha-256 --auth-local=scram-sha-256" + command: ["postgres", "-c", "max_connections=40", "-c", "shared_buffers=128MB", "-c", "password_encryption=scram-sha-256", "-c", "log_min_duration_statement=1000"] + secrets: + - postgres_password + volumes: + - pgdata:/var/lib/postgresql/data + networks: + coa-db: + aliases: + - coa-postgres + healthcheck: + test: ["CMD-SHELL", "pg_isready -U coa -d coa"] + interval: 15s + timeout: 5s + retries: 5 + mem_limit: 768m + security_opt: + - no-new-privileges:true +secrets: + postgres_password: + file: /opt/coa/secrets/postgres_password +volumes: + pgdata: +networks: + coa-db: + external: true diff --git a/services/coa-registry/deploy/provision-db.sh b/services/coa-registry/deploy/provision-db.sh new file mode 100644 index 0000000..f344410 --- /dev/null +++ b/services/coa-registry/deploy/provision-db.sh @@ -0,0 +1,18 @@ +#!/bin/sh +# Create the Registry's database role and database (idempotent). Run on the node as root: sudo sh provision-db.sh +# The password is read from /opt/coa/secrets/registry_db_password and goes to psql on stdin, never on a command line. +set -eu +PW=$(cat /opt/coa/secrets/registry_db_password) +docker exec -i coa-postgres-postgres-1 sh -c 'PGPASSWORD=$(cat /run/secrets/postgres_password) exec psql -U coa -d coa -v ON_ERROR_STOP=1 -q' <= 1), + -- the timestamp of the last signed request that was applied; the next one must be strictly later (replay protection) + last_request_ts bigint NOT NULL, + player_count integer CHECK (player_count BETWEEN 0 AND 100000), + player_capacity integer CHECK (player_capacity BETWEEN 0 AND 100000) +); +CREATE INDEX realms_published_seen ON realms (published, last_seen_at); diff --git a/services/coa-registry/src/api.rs b/services/coa-registry/src/api.rs new file mode 100644 index 0000000..047a698 --- /dev/null +++ b/services/coa-registry/src/api.rs @@ -0,0 +1,380 @@ +//! The HTTP surface of Registry protocol 1. + +use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; +use std::time::Instant; + +use axum::body::Body; +use axum::extract::{ConnectInfo, Path, State}; +use axum::http::{HeaderMap, Method, Request, StatusCode, Uri}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use coa_registry_proto::sign::{self, SignedHeaders}; +use coa_registry_proto::*; +use std::result::Result; +use serde::Serialize; + +use crate::limits::{Limiter, Rate}; +use crate::listener::Peer; +use crate::store::{RealmRow, Store, StoreError}; + +pub type Clock = Arc i64 + Send + Sync>; + +#[derive(Clone, Debug)] +pub struct ApiConfig { + pub online_ttl_secs: u64, + /// Behind a reverse proxy the client address is the last entry of `X-Forwarded-For` (the one the proxy itself appended). + pub trust_proxy: bool, + pub general: Rate, + pub register: Rate, + pub heartbeat: Rate, +} + +impl Default for ApiConfig { + fn default() -> Self { + Self { + online_ttl_secs: ONLINE_TTL_SECS, + trust_proxy: false, + general: Rate::per_minute(120, 120.0), + register: Rate::per_hour(10, 10.0), + heartbeat: Rate { burst: 4.0, refill: 1.0 / 10.0 }, + } + } +} + +pub struct AppState { + pub store: S, + pub clock: Clock, + pub cfg: ApiConfig, + ip_general: Limiter, + ip_register: Limiter, + realm_heartbeat: Limiter, + started: Instant, +} + +impl AppState { + pub fn new(store: S, clock: Clock, cfg: ApiConfig) -> Self { + Self { + ip_general: Limiter::new(cfg.general, 100_000), + ip_register: Limiter::new(cfg.register, 100_000), + realm_heartbeat: Limiter::new(cfg.heartbeat, 100_000), + store, + clock, + cfg, + started: Instant::now(), + } + } + fn now(&self) -> i64 { + (self.clock)() + } + fn mono(&self) -> f64 { + self.started.elapsed().as_secs_f64() + } +} + +pub struct ApiError { + status: StatusCode, + code: ErrorCode, + message: String, +} + +impl ApiError { + fn new(status: StatusCode, code: ErrorCode, message: impl Into) -> Self { + Self { status, code, message: message.into() } + } + fn malformed(message: impl Into) -> Self { + Self::new(StatusCode::BAD_REQUEST, ErrorCode::MalformedRequest, message) + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + (self.status, Json(ErrorBody { error: ErrorDetail { code: self.code, message: self.message } })).into_response() + } +} + +impl From for ApiError { + fn from(e: StoreError) -> Self { + match e { + StoreError::Unknown => Self::new(StatusCode::NOT_FOUND, ErrorCode::UnknownRealm, "this realm is not registered"), + StoreError::KeyMismatch => Self::new(StatusCode::FORBIDDEN, ErrorCode::RealmKeyMismatch, "this realm id belongs to another key"), + StoreError::Stale => Self::new(StatusCode::UNAUTHORIZED, ErrorCode::TimestampNotMonotonic, "the timestamp is not later than the last request of this realm"), + StoreError::NotPublished => Self::new(StatusCode::CONFLICT, ErrorCode::NotPublished, "the realm is not published; register it again"), + StoreError::Backend(m) => { + tracing::error!(error = %m, "storage failure"); + Self::new(StatusCode::SERVICE_UNAVAILABLE, ErrorCode::Unavailable, "storage is unavailable") + } + } + } +} + +pub fn router(state: Arc>) -> Router { + Router::new() + .route("/registry/v1/healthz", get(healthz::)) + .route("/registry/v1/realms/register", post(register::)) + .route("/registry/v1/realms/{realm_id}/heartbeat", post(heartbeat::)) + .route("/registry/v1/realms/{realm_id}/unpublish", post(unpublish::)) + .route("/registry/v1/realms/{realm_id}", get(self_info::)) + .fallback(|| async { ApiError::new(StatusCode::NOT_FOUND, ErrorCode::MalformedRequest, "no such endpoint") }) + .with_state(state) +} + +fn client_ip(headers: &HeaderMap, peer: SocketAddr, trust_proxy: bool) -> IpAddr { + if trust_proxy { + if let Some(last) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()).and_then(|v| v.rsplit(',').next()) { + if let Ok(ip) = last.trim().parse::() { + return ip; + } + } + } + peer.ip() +} + +fn short(hash: &str) -> &str { + hash.get(..8).unwrap_or("") +} + +fn log(endpoint: &str, realm: Option<&RealmId>, started: Instant, result: &Result<(StatusCode, String), ApiError>) { + let (status, caps) = match result { + Ok((s, c)) => (s.as_u16(), c.as_str()), + Err(e) => (e.status.as_u16(), ""), + }; + let code = result.as_ref().err().map(|e| format!("{:?}", e.code)).unwrap_or_default(); + tracing::info!(endpoint, realm = realm.map(|r| r.to_string()).unwrap_or_default(), status, error = %code, latency_ms = started.elapsed().as_millis() as u64, caps = caps, "request"); +} + +/// The IP-level admission every request passes before anything is parsed. +fn admit(st: &AppState, ip: IpAddr, cost: f64) -> Result<(), ApiError> { + if st.ip_general.take(&ip, cost, st.mono()) { + Ok(()) + } else { + Err(ApiError::new(StatusCode::TOO_MANY_REQUESTS, ErrorCode::RateLimited, "too many requests")) + } +} + +async fn read_body(body: Body) -> Result, ApiError> { + match axum::body::to_bytes(body, MAX_REQUEST_BYTES).await { + Ok(b) => Ok(b.to_vec()), + Err(_) => Err(ApiError::new(StatusCode::PAYLOAD_TOO_LARGE, ErrorCode::RequestTooLarge, format!("a request is limited to {MAX_REQUEST_BYTES} bytes"))), + } +} + +/// Headers, id and timestamp: everything that can be checked before the key is known. +fn prelude(st: &AppState, headers: &HeaderMap, uri: &Uri, path_realm: Option<&str>) -> Result<(SignedHeaders, RealmId), ApiError> { + if uri.query().is_some() { + return Err(ApiError::malformed("a signed endpoint takes no query string")); + } + let h = SignedHeaders::parse(|n| headers.get(n).and_then(|v| v.to_str().ok())).map_err(|e| ApiError::malformed(e.to_string()))?; + if h.version != REGISTRY_PROTOCOL_VERSION { + return Err(ApiError::new(StatusCode::BAD_REQUEST, ErrorCode::UnsupportedProtocolVersion, format!("protocol version {} is not supported; this Registry speaks {REGISTRY_PROTOCOL_VERSION}", h.version))); + } + let realm = match path_realm { + Some(text) => RealmId::parse(text).map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, ErrorCode::InvalidRealmId, "the realm id in the path is not a canonical UUIDv7"))?, + None => h.realm, + }; + if h.realm != realm { + return Err(ApiError::new(StatusCode::BAD_REQUEST, ErrorCode::InvalidRealmId, "the realm header and the realm id disagree")); + } + if (h.timestamp - st.now()).abs() > MAX_CLOCK_SKEW_SECS { + return Err(ApiError::new(StatusCode::UNAUTHORIZED, ErrorCode::BadTimestamp, format!("the timestamp is more than {MAX_CLOCK_SKEW_SECS} seconds from the Registry's clock"))); + } + Ok((h, realm)) +} + +fn check_signature(st: &AppState, ip: IpAddr, key: &[u8; 32], h: &SignedHeaders, method: &Method, path: &str, body: &[u8]) -> Result<(), ApiError> { + let ok = ed25519_dalek::VerifyingKey::from_bytes(key).map(|k| sign::verify_request(&k, h, method.as_str(), path, body)).unwrap_or(false); + if ok { + return Ok(()); + } + // a forged request costs its sender more than an honest one + st.ip_general.take(&ip, 4.0, st.mono()); + Err(ApiError::new(StatusCode::UNAUTHORIZED, ErrorCode::InvalidSignature, "the signature does not match")) +} + +fn realm_admit(st: &AppState, realm: &RealmId) -> Result<(), ApiError> { + if st.realm_heartbeat.take(realm, 1.0, st.mono()) { + Ok(()) + } else { + Err(ApiError::new(StatusCode::TOO_MANY_REQUESTS, ErrorCode::RateLimited, "this realm sends requests too fast")) + } +} + +async fn healthz(State(st): State>>, ConnectInfo(Peer(peer)): ConnectInfo, req: Request) -> Response { + let ip = client_ip(req.headers(), peer, st.cfg.trust_proxy); + if let Err(e) = admit(&st, ip, 1.0) { + return e.into_response(); + } + if st.store.ping().await { + Json(Health { status: "ok".into(), protocol_version: REGISTRY_PROTOCOL_VERSION }).into_response() + } else { + (StatusCode::SERVICE_UNAVAILABLE, Json(Health { status: "degraded".into(), protocol_version: REGISTRY_PROTOCOL_VERSION })).into_response() + } +} + +fn json(value: &T) -> (StatusCode, Response) { + (StatusCode::OK, Json(value).into_response()) +} + +async fn register(State(st): State>>, ConnectInfo(Peer(peer)): ConnectInfo, req: Request) -> Response { + let started = Instant::now(); + let (parts, body) = req.into_parts(); + let ip = client_ip(&parts.headers, peer, st.cfg.trust_proxy); + let mut realm_for_log = None; + let result: Result<(StatusCode, Response, String), ApiError> = async { + admit(&st, ip, 1.0)?; + if !st.ip_register.take(&ip, 1.0, st.mono()) { + return Err(ApiError::new(StatusCode::TOO_MANY_REQUESTS, ErrorCode::RateLimited, "too many registrations from this address")); + } + let bytes = read_body(body).await?; + let (h, realm) = prelude(&st, &parts.headers, &parts.uri, None)?; + realm_for_log = Some(realm); + let req: RegisterRequest = serde_json::from_slice(&bytes).map_err(|_| ApiError::malformed("the body is not a registration"))?; + if req.protocol_version != REGISTRY_PROTOCOL_VERSION { + return Err(ApiError::new(StatusCode::BAD_REQUEST, ErrorCode::UnsupportedProtocolVersion, format!("protocol version {} is not supported", req.protocol_version))); + } + if req.realm_id != realm { + return Err(ApiError::new(StatusCode::BAD_REQUEST, ErrorCode::InvalidRealmId, "the realm header and the body disagree")); + } + req.validate().map_err(|e| ApiError::new(StatusCode::BAD_REQUEST, ErrorCode::InvalidMetadata, e.to_string()))?; + let public = sign::decode_public_key(&req.public_key).map_err(|e| ApiError::new(StatusCode::BAD_REQUEST, ErrorCode::InvalidMetadata, e.to_string()))?; + check_signature(&st, ip, public.as_bytes(), &h, &parts.method, parts.uri.path(), &bytes)?; + let now = st.now(); + let (row, created) = st.store.register(&req, *public.as_bytes(), now, h.timestamp).await?; + let resp = RegisterResponse { + protocol_version: REGISTRY_PROTOCOL_VERSION, + realm_id: realm, + created, + metadata_revision: row.metadata_revision, + published: row.published, + capabilities_hash: row.capabilities_hash.clone(), + server_time: now, + heartbeat_interval_secs: HEARTBEAT_INTERVAL_SECS, + online_ttl_secs: st.cfg.online_ttl_secs, + }; + let (status, response) = json(&resp); + Ok((status, response, row.capabilities_hash)) + } + .await; + finish("register", realm_for_log.as_ref(), started, result) +} + +fn finish(endpoint: &str, realm: Option<&RealmId>, started: Instant, result: Result<(StatusCode, Response, String), ApiError>) -> Response { + match result { + Ok((status, response, caps)) => { + log(endpoint, realm, started, &Ok((status, short(&caps).to_string()))); + response + } + Err(e) => { + log(endpoint, realm, started, &Err(ApiError { status: e.status, code: e.code, message: String::new() })); + e.into_response() + } + } +} + +/// Shared by heartbeat, unpublish and the self endpoint: an existing realm, a valid signature, and (for writes) the per-realm rate. +async fn authenticate(st: &AppState, ip: IpAddr, parts: &axum::http::request::Parts, path_realm: &str, bytes: &[u8], limited: bool) -> Result<(RealmId, i64), ApiError> { + let (h, realm) = prelude(st, &parts.headers, &parts.uri, Some(path_realm))?; + let key = st.store.key_of(&realm).await?.ok_or(StoreError::Unknown)?; + check_signature(st, ip, &key, &h, &parts.method, parts.uri.path(), bytes)?; + if limited { + realm_admit(st, &realm)?; + } + Ok((realm, h.timestamp)) +} + +async fn heartbeat(State(st): State>>, ConnectInfo(Peer(peer)): ConnectInfo, Path(realm_text): Path, req: Request) -> Response { + let started = Instant::now(); + let (parts, body) = req.into_parts(); + let ip = client_ip(&parts.headers, peer, st.cfg.trust_proxy); + let mut realm_for_log = None; + let result: Result<(StatusCode, Response, String), ApiError> = async { + admit(&st, ip, 1.0)?; + let bytes = read_body(body).await?; + let (realm, ts) = authenticate(&st, ip, &parts, &realm_text, &bytes, true).await?; + realm_for_log = Some(realm); + let hb: HeartbeatRequest = serde_json::from_slice(&bytes).map_err(|_| ApiError::malformed("the body is not a heartbeat"))?; + if hb.protocol_version != REGISTRY_PROTOCOL_VERSION { + return Err(ApiError::new(StatusCode::BAD_REQUEST, ErrorCode::UnsupportedProtocolVersion, format!("protocol version {} is not supported", hb.protocol_version))); + } + hb.validate().map_err(|e| ApiError::new(StatusCode::BAD_REQUEST, ErrorCode::InvalidMetadata, e.to_string()))?; + let now = st.now(); + let out = st.store.heartbeat(&realm, &hb, now, ts).await?; + let resp = HeartbeatResponse { protocol_version: REGISTRY_PROTOCOL_VERSION, metadata_revision: out.metadata_revision, published: true, capabilities_hash: out.capabilities_hash.clone(), resend_capabilities: out.resend_capabilities, server_time: now }; + let (status, response) = json(&resp); + Ok((status, response, out.capabilities_hash)) + } + .await; + finish("heartbeat", realm_for_log.as_ref(), started, result) +} + +async fn unpublish(State(st): State>>, ConnectInfo(Peer(peer)): ConnectInfo, Path(realm_text): Path, req: Request) -> Response { + let started = Instant::now(); + let (parts, body) = req.into_parts(); + let ip = client_ip(&parts.headers, peer, st.cfg.trust_proxy); + let mut realm_for_log = None; + let result: Result<(StatusCode, Response, String), ApiError> = async { + admit(&st, ip, 1.0)?; + let bytes = read_body(body).await?; + let (realm, ts) = authenticate(&st, ip, &parts, &realm_text, &bytes, true).await?; + realm_for_log = Some(realm); + let un: UnpublishRequest = serde_json::from_slice(&bytes).map_err(|_| ApiError::malformed("the body is not an unpublish request"))?; + if un.protocol_version != REGISTRY_PROTOCOL_VERSION { + return Err(ApiError::new(StatusCode::BAD_REQUEST, ErrorCode::UnsupportedProtocolVersion, format!("protocol version {} is not supported", un.protocol_version))); + } + let now = st.now(); + let revision = st.store.unpublish(&realm, now, ts).await?; + let (status, response) = json(&UnpublishResponse { protocol_version: REGISTRY_PROTOCOL_VERSION, published: false, metadata_revision: revision, server_time: now }); + Ok((status, response, String::new())) + } + .await; + finish("unpublish", realm_for_log.as_ref(), started, result) +} + +fn self_response(row: RealmRow, now: i64, ttl: u64) -> SelfResponse { + let online = row.published && now - row.last_seen_at <= ttl as i64; + SelfResponse { + protocol_version: REGISTRY_PROTOCOL_VERSION, + realm_id: row.realm_id, + public_key: sign::encode_public_key(&ed25519_dalek::VerifyingKey::from_bytes(&row.public_key).expect("a stored key was validated on the way in")), + display_name: row.display_name, + description: row.description, + language: row.language, + ruleset: row.ruleset, + manager_version: row.manager_version, + capabilities: row.capabilities, + capabilities_hash: row.capabilities_hash, + metadata_revision: row.metadata_revision, + published: row.published, + online, + created_at: row.created_at, + updated_at: row.updated_at, + last_seen_at: row.last_seen_at, + player_count: row.player_count, + player_capacity: row.player_capacity, + server_time: now, + } +} + +/// The authenticated read of a realm's own record. It does not advance the replay counter (a read changes nothing), so it is accepted with any timestamp inside the window. +async fn self_info(State(st): State>>, ConnectInfo(Peer(peer)): ConnectInfo, Path(realm_text): Path, req: Request) -> Response { + let started = Instant::now(); + let (parts, body) = req.into_parts(); + let ip = client_ip(&parts.headers, peer, st.cfg.trust_proxy); + let mut realm_for_log = None; + let result: Result<(StatusCode, Response, String), ApiError> = async { + admit(&st, ip, 1.0)?; + let bytes = read_body(body).await?; + if !bytes.is_empty() { + return Err(ApiError::malformed("a read has no body")); + } + let (realm, _) = authenticate(&st, ip, &parts, &realm_text, &bytes, false).await?; + realm_for_log = Some(realm); + let row = st.store.get(&realm).await?.ok_or(StoreError::Unknown)?; + let caps = row.capabilities_hash.clone(); + let (status, response) = json(&self_response(row, st.now(), st.cfg.online_ttl_secs)); + Ok((status, response, caps)) + } + .await; + finish("self", realm_for_log.as_ref(), started, result) +} diff --git a/services/coa-registry/src/config.rs b/services/coa-registry/src/config.rs new file mode 100644 index 0000000..c0a14cc --- /dev/null +++ b/services/coa-registry/src/config.rs @@ -0,0 +1,57 @@ +//! Configuration from the environment. The database password is read from a file (a Docker secret), never from the environment or the image. + +use std::env; + +use crate::api::ApiConfig; +use crate::limits::Rate; +use crate::pg::PgConfig; + +pub struct Config { + pub listen: String, + pub max_connections: usize, + pub request_timeout_secs: u64, + pub max_in_flight: usize, + pub api: ApiConfig, + pub pg: PgConfig, +} + +fn var(name: &str, default: &str) -> String { + env::var(name).ok().filter(|v| !v.is_empty()).unwrap_or_else(|| default.to_string()) +} + +fn num(name: &str, default: T) -> Result { + match env::var(name).ok().filter(|v| !v.is_empty()) { + Some(v) => v.parse().map_err(|_| format!("{name} is not a valid number")), + None => Ok(default), + } +} + +impl Config { + pub fn from_env() -> Result { + let password_file = env::var("REGISTRY_DB_PASSWORD_FILE").map_err(|_| "REGISTRY_DB_PASSWORD_FILE is required".to_string())?; + let password = std::fs::read_to_string(&password_file).map_err(|e| format!("cannot read the database password file: {e}"))?.trim().to_string(); + if password.is_empty() { + return Err("the database password file is empty".into()); + } + let mut api = ApiConfig { trust_proxy: var("REGISTRY_TRUST_PROXY", "0") == "1", ..ApiConfig::default() }; + api.online_ttl_secs = num("REGISTRY_ONLINE_TTL_SECS", api.online_ttl_secs)?; + api.register = Rate::per_hour(num("REGISTRY_REGISTER_BURST", 10)?, num("REGISTRY_REGISTER_PER_HOUR", 10.0)?); + api.general = Rate::per_minute(num("REGISTRY_GENERAL_BURST", 120)?, num("REGISTRY_GENERAL_PER_MINUTE", 120.0)?); + Ok(Self { + listen: var("REGISTRY_LISTEN", "0.0.0.0:8080"), + max_connections: num("REGISTRY_MAX_CONNECTIONS", 256)?, + request_timeout_secs: num("REGISTRY_REQUEST_TIMEOUT_SECS", 10)?, + max_in_flight: num("REGISTRY_MAX_IN_FLIGHT", 64)?, + api, + pg: PgConfig { + host: var("REGISTRY_DB_HOST", "postgres"), + port: num("REGISTRY_DB_PORT", 5432)?, + dbname: var("REGISTRY_DB_NAME", "coa"), + user: var("REGISTRY_DB_USER", "coa"), + password, + pool_size: num("REGISTRY_DB_POOL", 8)?, + statement_timeout_ms: num("REGISTRY_DB_STATEMENT_TIMEOUT_MS", 5000)?, + }, + }) + } +} diff --git a/services/coa-registry/src/lib.rs b/services/coa-registry/src/lib.rs new file mode 100644 index 0000000..14e14e5 --- /dev/null +++ b/services/coa-registry/src/lib.rs @@ -0,0 +1,11 @@ +//! The CoA realm Registry (Phase 10): who publishes a realm and whether it is alive. Nothing else. +//! +//! The Registry never receives or stores database or RA credentials, game account passwords, character snapshots, canonical revisions, +//! inventories, session payloads or player character ids. Every mutating request is signed by the realm's own Ed25519 key. + +pub mod api; +pub mod config; +pub mod limits; +pub mod listener; +pub mod pg; +pub mod store; diff --git a/services/coa-registry/src/limits.rs b/services/coa-registry/src/limits.rs new file mode 100644 index 0000000..cd303fb --- /dev/null +++ b/services/coa-registry/src/limits.rs @@ -0,0 +1,88 @@ +//! In-memory token buckets for abuse control. One process, one node: no shared store is needed. + +use std::collections::HashMap; +use std::hash::Hash; +use std::sync::Mutex; + +#[derive(Clone, Copy, Debug)] +pub struct Rate { + pub burst: f64, + /// Tokens per second. + pub refill: f64, +} + +impl Rate { + pub fn per_minute(burst: u32, per_minute: f64) -> Self { + Self { burst: burst as f64, refill: per_minute / 60.0 } + } + pub fn per_hour(burst: u32, per_hour: f64) -> Self { + Self { burst: burst as f64, refill: per_hour / 3600.0 } + } +} + +struct Bucket { + tokens: f64, + at: f64, +} + +pub struct Limiter { + rate: Rate, + max_keys: usize, + buckets: Mutex>, +} + +impl Limiter { + pub fn new(rate: Rate, max_keys: usize) -> Self { + Self { rate, max_keys, buckets: Mutex::new(HashMap::new()) } + } + + /// Take `cost` tokens at time `now` (seconds, any monotonic origin). `false`: refused, nothing taken. + pub fn take(&self, key: &K, cost: f64, now: f64) -> bool { + let mut map = self.buckets.lock().unwrap(); + if map.len() >= self.max_keys && !map.contains_key(key) { + // a full table is cleaned of buckets that have refilled completely (they carry no information) + let full = self.rate.burst; + let refill = self.rate.refill; + map.retain(|_, b| b.tokens + (now - b.at).max(0.0) * refill < full); + if map.len() >= self.max_keys { + return false; + } + } + let b = map.entry(key.clone()).or_insert(Bucket { tokens: self.rate.burst, at: now }); + b.tokens = (b.tokens + (now - b.at).max(0.0) * self.rate.refill).min(self.rate.burst); + b.at = now; + if b.tokens >= cost { + b.tokens -= cost; + true + } else { + false + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_bucket_allows_a_burst_then_refills() { + let l: Limiter<&str> = Limiter::new(Rate { burst: 3.0, refill: 0.5 }, 100); + assert!(l.take(&"a", 1.0, 0.0) && l.take(&"a", 1.0, 0.0) && l.take(&"a", 1.0, 0.0)); + assert!(!l.take(&"a", 1.0, 0.0), "the burst is used up"); + assert!(l.take(&"b", 1.0, 0.0), "another key is unaffected"); + assert!(!l.take(&"a", 1.0, 1.0), "half a token is not enough"); + assert!(l.take(&"a", 1.0, 2.0), "one token after two seconds"); + assert!(l.take(&"a", 3.0, 1000.0), "a long pause refills to the burst"); + assert!(!l.take(&"a", 1.0, 1000.0), "and never beyond it"); + } + + #[test] + fn the_table_is_bounded() { + let l: Limiter = Limiter::new(Rate { burst: 1.0, refill: 0.0 }, 4); + for k in 0..4 { + assert!(l.take(&k, 1.0, 0.0)); + } + assert!(!l.take(&99, 1.0, 0.0), "a new key is refused when the table is full of live buckets"); + assert!(!l.take(&0, 1.0, 0.0), "an existing one keeps its state"); + } +} diff --git a/services/coa-registry/src/listener.rs b/services/coa-registry/src/listener.rs new file mode 100644 index 0000000..67a05a4 --- /dev/null +++ b/services/coa-registry/src/listener.rs @@ -0,0 +1,87 @@ +//! A TCP listener that never has more than a fixed number of connections open: a new one waits in the kernel backlog until a slot is free. + +use std::io; +use std::net::SocketAddr; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; + +use pin_project_lite::pin_project; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; + +pub struct LimitedListener { + inner: TcpListener, + slots: Arc, +} + +impl LimitedListener { + pub fn new(inner: TcpListener, max_connections: usize) -> Self { + Self { inner, slots: Arc::new(Semaphore::new(max_connections)) } + } +} + +pin_project! { + pub struct LimitedIo { + #[pin] + stream: TcpStream, + _slot: OwnedSemaphorePermit, + } +} + +impl AsyncRead for LimitedIo { + fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + self.project().stream.poll_read(cx, buf) + } +} + +impl AsyncWrite for LimitedIo { + fn poll_write(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll> { + self.project().stream.poll_write(cx, buf) + } + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.project().stream.poll_flush(cx) + } + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.project().stream.poll_shutdown(cx) + } +} + +impl axum::serve::Listener for LimitedListener { + type Io = LimitedIo; + type Addr = SocketAddr; + + async fn accept(&mut self) -> (Self::Io, Self::Addr) { + loop { + let slot = self.slots.clone().acquire_owned().await.expect("the semaphore is never closed"); + match self.inner.accept().await { + Ok((stream, addr)) => return (LimitedIo { stream, _slot: slot }, addr), + Err(e) => { + tracing::warn!(error = %e, "accept failed"); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + } + } + } + + fn local_addr(&self) -> io::Result { + self.inner.local_addr() + } +} + +/// The address of the other end of a connection, whichever listener accepted it. +#[derive(Clone, Copy, Debug)] +pub struct Peer(pub SocketAddr); + +impl axum::extract::connect_info::Connected> for Peer { + fn connect_info(stream: axum::serve::IncomingStream<'_, LimitedListener>) -> Self { + Peer(*stream.remote_addr()) + } +} + +impl axum::extract::connect_info::Connected> for Peer { + fn connect_info(stream: axum::serve::IncomingStream<'_, TcpListener>) -> Self { + Peer(*stream.remote_addr()) + } +} diff --git a/services/coa-registry/src/main.rs b/services/coa-registry/src/main.rs new file mode 100644 index 0000000..41505d0 --- /dev/null +++ b/services/coa-registry/src/main.rs @@ -0,0 +1,100 @@ +use std::sync::Arc; +use std::time::Duration; + +use coa_registry::api::{router, AppState, Clock}; +use coa_registry::config::Config; +use coa_registry::listener::LimitedListener; +use coa_registry::pg::PgStore; +use tower::limit::ConcurrencyLimitLayer; +use tower_http::timeout::TimeoutLayer; + +fn clock() -> Clock { + Arc::new(|| std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0)) +} + +async fn shutdown() { + let ctrl_c = tokio::signal::ctrl_c(); + #[cfg(unix)] + { + let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).expect("a SIGTERM handler"); + tokio::select! { _ = ctrl_c => {}, _ = term.recv() => {} } + } + #[cfg(not(unix))] + { + let _ = ctrl_c.await; + } +} + +/// `coa-registry --healthcheck`: ask the local server for its health and exit 0 or 1 (the image has no curl). +fn healthcheck() -> i32 { + use std::io::{Read, Write}; + let addr = std::env::var("REGISTRY_LISTEN").unwrap_or_else(|_| "0.0.0.0:8080".into()); + let port = addr.rsplit(':').next().unwrap_or("8080"); + let Ok(mut s) = std::net::TcpStream::connect(format!("127.0.0.1:{port}")) else { return 1 }; + let _ = s.set_read_timeout(Some(Duration::from_secs(3))); + if s.write_all(b"GET /registry/v1/healthz HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n").is_err() { + return 1; + } + let mut out = String::new(); + let _ = s.read_to_string(&mut out); + i32::from(!out.starts_with("HTTP/1.1 200")) +} + +#[tokio::main] +async fn main() { + if std::env::args().nth(1).as_deref() == Some("--healthcheck") { + std::process::exit(healthcheck()); + } + tracing_subscriber::fmt().json().with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())).with_current_span(false).init(); + let cfg = match Config::from_env() { + Ok(c) => c, + Err(e) => { + tracing::error!(error = %e, "configuration"); + std::process::exit(2); + } + }; + let store = match PgStore::connect(&cfg.pg).await { + Ok(s) => s, + Err(e) => { + tracing::error!(error = %e, "database pool"); + std::process::exit(2); + } + }; + // the database may still be starting: wait for it instead of crashing in a loop + let mut waited = 0; + loop { + match store.migrate().await { + Ok(()) => break, + Err(e) if e.contains("refusing to start") || e.starts_with("migration ") => { + tracing::error!(error = %e, "migration"); + std::process::exit(3); + } + Err(e) => { + waited += 1; + if waited > 60 { + tracing::error!(error = %e, "the database never became ready"); + std::process::exit(3); + } + tracing::warn!(error = %e, "waiting for the database"); + tokio::time::sleep(Duration::from_secs(2)).await; + } + } + } + let state = Arc::new(AppState::new(store, clock(), cfg.api.clone())); + let app = router(state).layer(ConcurrencyLimitLayer::new(cfg.max_in_flight)).layer(TimeoutLayer::with_status_code(axum::http::StatusCode::REQUEST_TIMEOUT, Duration::from_secs(cfg.request_timeout_secs))); + let tcp = match tokio::net::TcpListener::bind(&cfg.listen).await { + Ok(l) => l, + Err(e) => { + tracing::error!(error = %e, listen = %cfg.listen, "bind"); + std::process::exit(2); + } + }; + tracing::info!(listen = %cfg.listen, "registry started"); + let listener = LimitedListener::new(tcp, cfg.max_connections); + let serve = axum::serve(listener, app.into_make_service_with_connect_info::()).with_graceful_shutdown(shutdown()); + if let Err(e) = serve.await { + tracing::error!(error = %e, "server"); + std::process::exit(1); + } + tracing::info!("registry stopped"); +} diff --git a/services/coa-registry/src/pg.rs b/services/coa-registry/src/pg.rs new file mode 100644 index 0000000..66b5cad --- /dev/null +++ b/services/coa-registry/src/pg.rs @@ -0,0 +1,227 @@ +//! PostgreSQL storage and forward-only migrations. + +use std::time::Duration; + +use coa_registry_proto::caps::{AdvertisedCapabilities, Ruleset}; +use coa_registry_proto::{HeartbeatRequest, RealmId, RegisterRequest}; +use deadpool_postgres::{Manager, ManagerConfig, Object, Pool, RecyclingMethod, Runtime}; +use tokio_postgres::{NoTls, Row}; + +use crate::store::*; + +const MIGRATIONS: &[(i32, &str, &str)] = &[(1, "realms", include_str!("../migrations/0001_realms.sql"))]; + +pub struct PgConfig { + pub host: String, + pub port: u16, + pub dbname: String, + pub user: String, + pub password: String, + pub pool_size: usize, + pub statement_timeout_ms: u64, +} + +pub struct PgStore { + pool: Pool, +} + +fn backend(e: impl std::fmt::Display) -> StoreError { + StoreError::Backend(e.to_string()) +} + +impl PgStore { + pub async fn connect(cfg: &PgConfig) -> Result { + let mut pg = tokio_postgres::Config::new(); + pg.host(&cfg.host).port(cfg.port).dbname(&cfg.dbname).user(&cfg.user).password(&cfg.password); + pg.connect_timeout(Duration::from_secs(5)); + pg.application_name("coa-registry"); + pg.options(&format!("-c statement_timeout={} -c idle_in_transaction_session_timeout=10000 -c lock_timeout=3000", cfg.statement_timeout_ms)); + let manager = Manager::from_config(pg, NoTls, ManagerConfig { recycling_method: RecyclingMethod::Fast }); + let pool = Pool::builder(manager) + .max_size(cfg.pool_size) + .wait_timeout(Some(Duration::from_secs(5))) + .create_timeout(Some(Duration::from_secs(5))) + .runtime(Runtime::Tokio1) + .build() + .map_err(|e| e.to_string())?; + Ok(Self { pool }) + } + + async fn conn(&self) -> StoreResult { + self.pool.get().await.map_err(backend) + } + + /// Apply every migration the database has not seen. A database that is ahead of this binary is refused: migrations only go forward. + pub async fn migrate(&self) -> Result<(), String> { + let mut c = self.pool.get().await.map_err(|e| e.to_string())?; + c.batch_execute("SELECT pg_advisory_lock(7300100)").await.map_err(|e| e.to_string())?; + let result = Self::migrate_locked(&mut c).await; + let _ = c.batch_execute("SELECT pg_advisory_unlock(7300100)").await; + result + } + + async fn migrate_locked(c: &mut Object) -> Result<(), String> { + c.batch_execute("CREATE TABLE IF NOT EXISTS schema_migrations (version integer PRIMARY KEY, name text NOT NULL, applied_at timestamptz NOT NULL DEFAULT now())").await.map_err(|e| e.to_string())?; + let applied: Vec = c.query("SELECT version FROM schema_migrations ORDER BY version", &[]).await.map_err(|e| e.to_string())?.iter().map(|r| r.get(0)).collect(); + let newest = MIGRATIONS.last().map(|m| m.0).unwrap_or(0); + if let Some(ahead) = applied.iter().find(|v| **v > newest) { + return Err(format!("the database is at migration {ahead}, this binary knows up to {newest}: refusing to start")); + } + for (version, name, sql) in MIGRATIONS { + if applied.contains(version) { + continue; + } + let tx = c.transaction().await.map_err(|e| e.to_string())?; + tx.batch_execute(sql).await.map_err(|e| format!("migration {version} {name}: {e}"))?; + tx.execute("INSERT INTO schema_migrations(version, name) VALUES ($1, $2)", &[version, name]).await.map_err(|e| e.to_string())?; + tx.commit().await.map_err(|e| e.to_string())?; + tracing::info!(version, name, "migration applied"); + } + Ok(()) + } +} + +const COLUMNS: &str = "realm_id::text, public_key, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM last_seen_at)::bigint, published, display_name, description, language, ruleset, manager_version, capabilities, capabilities_hash, metadata_revision, last_request_ts, player_count, player_capacity"; + +fn to_row(r: &Row) -> StoreResult { + let id: String = r.get(0); + let key: Vec = r.get(1); + let ruleset: String = r.get(9); + let caps: serde_json::Value = r.get(11); + let revision: i64 = r.get(13); + let count: Option = r.get(15); + let capacity: Option = r.get(16); + Ok(RealmRow { + realm_id: RealmId::parse(&id).map_err(backend)?, + public_key: key.try_into().map_err(|_| StoreError::Backend("a stored key is not 32 bytes".into()))?, + created_at: r.get(2), + updated_at: r.get(3), + last_seen_at: r.get(4), + published: r.get(5), + display_name: r.get(6), + description: r.get(7), + language: r.get(8), + ruleset: match ruleset.as_str() { + "coa" => Ruleset::Coa, + "wildcard" => Ruleset::Wildcard, + other => return Err(StoreError::Backend(format!("a stored ruleset is unknown: {other}"))), + }, + manager_version: r.get(10), + capabilities: serde_json::from_value::(caps).map_err(backend)?, + capabilities_hash: r.get(12), + metadata_revision: revision as u64, + last_request_ts: r.get(14), + player_count: count.map(|n| n as u32), + player_capacity: capacity.map(|n| n as u32), + }) +} + +fn caps_json(c: &AdvertisedCapabilities) -> StoreResult { + serde_json::to_value(c).map_err(backend) +} + +impl Store for PgStore { + async fn ping(&self) -> bool { + match self.pool.get().await { + Ok(c) => c.query_one("SELECT 1", &[]).await.is_ok(), + Err(_) => false, + } + } + + async fn key_of(&self, id: &RealmId) -> StoreResult> { + let c = self.conn().await?; + let row = c.query_opt("SELECT public_key FROM realms WHERE realm_id = $1::text::uuid", &[&id.to_string()]).await.map_err(backend)?; + row.map(|r| r.get::<_, Vec>(0).try_into().map_err(|_| StoreError::Backend("a stored key is not 32 bytes".into()))).transpose() + } + + async fn get(&self, id: &RealmId) -> StoreResult> { + let c = self.conn().await?; + let row = c.query_opt(&format!("SELECT {COLUMNS} FROM realms WHERE realm_id = $1::text::uuid"), &[&id.to_string()]).await.map_err(backend)?; + row.as_ref().map(to_row).transpose() + } + + async fn register(&self, req: &RegisterRequest, key: [u8; 32], now: i64, ts: i64) -> StoreResult<(RealmRow, bool)> { + let mut c = self.conn().await?; + let id = req.realm_id.to_string(); + for _ in 0..3 { + let tx = c.transaction().await.map_err(backend)?; + let existing = tx.query_opt(&format!("SELECT {COLUMNS} FROM realms WHERE realm_id = $1::text::uuid FOR UPDATE"), &[&id]).await.map_err(backend)?; + let existing = existing.as_ref().map(to_row).transpose()?; + let (row, created) = plan_register(existing.as_ref(), req, key, now, ts)?; + let caps = caps_json(&row.capabilities)?; + let ruleset = row.ruleset.as_str(); + let players = (row.player_count.map(|n| n as i32), row.player_capacity.map(|n| n as i32)); + if created { + let n = tx + .execute( + "INSERT INTO realms (realm_id, public_key, created_at, updated_at, last_seen_at, published, display_name, description, language, ruleset, manager_version, capabilities, capabilities_hash, metadata_revision, last_request_ts, player_count, player_capacity) \ + VALUES ($1::text::uuid, $2, to_timestamp($3::bigint), to_timestamp($3::bigint), to_timestamp($3::bigint), true, $4, $5, $6, $7, $8, $9, $10, 1, $11, $12, $13) ON CONFLICT (realm_id) DO NOTHING", + &[&id, &row.public_key.as_slice(), &now, &row.display_name, &row.description, &row.language, &ruleset, &row.manager_version, &caps, &row.capabilities_hash, &ts, &players.0, &players.1], + ) + .await + .map_err(backend)?; + if n == 0 { + tx.rollback().await.map_err(backend)?; + continue; + } + } else { + tx.execute( + "UPDATE realms SET updated_at = to_timestamp($2::bigint), last_seen_at = to_timestamp($3::bigint), published = true, display_name = $4, description = $5, language = $6, ruleset = $7, manager_version = $8, capabilities = $9, capabilities_hash = $10, metadata_revision = $11, last_request_ts = $12, player_count = $13, player_capacity = $14 WHERE realm_id = $1::text::uuid", + &[&id, &row.updated_at, &now, &row.display_name, &row.description, &row.language, &ruleset, &row.manager_version, &caps, &row.capabilities_hash, &(row.metadata_revision as i64), &ts, &players.0, &players.1], + ) + .await + .map_err(backend)?; + } + tx.commit().await.map_err(backend)?; + return Ok((row, created)); + } + Err(StoreError::Backend("the registration kept racing another one".into())) + } + + async fn heartbeat(&self, id: &RealmId, hb: &HeartbeatRequest, now: i64, ts: i64) -> StoreResult { + let mut c = self.conn().await?; + let text = id.to_string(); + let tx = c.transaction().await.map_err(backend)?; + let r = tx + .query_opt("SELECT published, last_request_ts, capabilities_hash, display_name, description, language, manager_version, metadata_revision FROM realms WHERE realm_id = $1::text::uuid FOR UPDATE", &[&text]) + .await + .map_err(backend)? + .ok_or(StoreError::Unknown)?; + let revision: i64 = r.get(7); + let view = HeartbeatView { published: r.get(0), last_request_ts: r.get(1), capabilities_hash: r.get(2), display_name: r.get(3), description: r.get(4), language: r.get(5), manager_version: r.get(6), metadata_revision: revision as u64 }; + let plan = plan_heartbeat(&view, hb, ts)?; + let players = (hb.player_count.map(|n| n as i32), hb.player_capacity.map(|n| n as i32)); + tx.execute( + "UPDATE realms SET last_seen_at = to_timestamp($2::bigint), last_request_ts = $3, player_count = $4, player_capacity = $5, display_name = $6, description = $7, language = $8, manager_version = $9, capabilities_hash = $10, metadata_revision = $11, updated_at = CASE WHEN $12 THEN to_timestamp($2::bigint) ELSE updated_at END WHERE realm_id = $1::text::uuid", + &[&text, &now, &ts, &players.0, &players.1, &plan.display_name, &plan.description, &plan.language, &plan.manager_version, &plan.capabilities_hash, &(plan.metadata_revision as i64), &plan.metadata_changed], + ) + .await + .map_err(backend)?; + if let Some(caps) = &plan.capabilities { + let json = caps_json(caps)?; + tx.execute("UPDATE realms SET capabilities = $2, ruleset = $3 WHERE realm_id = $1::text::uuid", &[&text, &json, &caps.content.ruleset.as_str()]).await.map_err(backend)?; + } + tx.commit().await.map_err(backend)?; + Ok(HeartbeatOutcome { metadata_revision: plan.metadata_revision, capabilities_hash: plan.capabilities_hash, resend_capabilities: plan.resend_capabilities }) + } + + async fn unpublish(&self, id: &RealmId, now: i64, ts: i64) -> StoreResult { + let mut c = self.conn().await?; + let text = id.to_string(); + let tx = c.transaction().await.map_err(backend)?; + let r = tx.query_opt("SELECT published, last_request_ts, metadata_revision FROM realms WHERE realm_id = $1::text::uuid FOR UPDATE", &[&text]).await.map_err(backend)?.ok_or(StoreError::Unknown)?; + let (published, last, revision): (bool, i64, i64) = (r.get(0), r.get(1), r.get(2)); + if ts <= last { + return Err(StoreError::Stale); + } + let revision = revision as u64 + u64::from(published); + tx.execute( + "UPDATE realms SET published = false, metadata_revision = $2, last_request_ts = $3, updated_at = CASE WHEN $4 THEN to_timestamp($5::bigint) ELSE updated_at END WHERE realm_id = $1::text::uuid", + &[&text, &(revision as i64), &ts, &published, &now], + ) + .await + .map_err(backend)?; + tx.commit().await.map_err(backend)?; + Ok(revision) + } +} diff --git a/services/coa-registry/src/store.rs b/services/coa-registry/src/store.rs new file mode 100644 index 0000000..9d51620 --- /dev/null +++ b/services/coa-registry/src/store.rs @@ -0,0 +1,286 @@ +//! What the Registry keeps, and the rules for changing it. The rules are pure functions shared by the in-memory store (tests) and PostgreSQL, +//! so both behave identically; the stores only load and save. + +use std::collections::HashMap; +use std::future::Future; +use std::sync::Mutex; + +use coa_registry_proto::caps::{AdvertisedCapabilities, Ruleset}; +use coa_registry_proto::{HeartbeatRequest, RealmId, RegisterRequest}; + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum StoreError { + #[error("the realm is not known")] + Unknown, + #[error("another key owns this realm id")] + KeyMismatch, + #[error("the request is not newer than the last one applied")] + Stale, + #[error("the realm is not published")] + NotPublished, + #[error("{0}")] + Backend(String), +} + +pub type StoreResult = Result; + +#[derive(Clone, Debug, PartialEq)] +pub struct RealmRow { + pub realm_id: RealmId, + pub public_key: [u8; 32], + pub created_at: i64, + pub updated_at: i64, + pub last_seen_at: i64, + pub published: bool, + pub display_name: String, + pub description: String, + pub language: String, + pub ruleset: Ruleset, + pub manager_version: String, + pub capabilities: AdvertisedCapabilities, + pub capabilities_hash: String, + pub metadata_revision: u64, + pub last_request_ts: i64, + pub player_count: Option, + pub player_capacity: Option, +} + +/// The light part of a row a heartbeat decides on (the capabilities JSON stays in the database unless it changes). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HeartbeatView { + pub published: bool, + pub last_request_ts: i64, + pub capabilities_hash: String, + pub display_name: String, + pub description: String, + pub language: String, + pub manager_version: String, + pub metadata_revision: u64, +} + +impl From<&RealmRow> for HeartbeatView { + fn from(r: &RealmRow) -> Self { + Self { + published: r.published, + last_request_ts: r.last_request_ts, + capabilities_hash: r.capabilities_hash.clone(), + display_name: r.display_name.clone(), + description: r.description.clone(), + language: r.language.clone(), + manager_version: r.manager_version.clone(), + metadata_revision: r.metadata_revision, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct HeartbeatPlan { + pub display_name: String, + pub description: String, + pub language: String, + pub manager_version: String, + /// Set only when the hash changed: the only case in which the large JSON is written. + pub capabilities: Option, + pub capabilities_hash: String, + pub metadata_revision: u64, + pub metadata_changed: bool, + pub resend_capabilities: bool, +} + +pub fn plan_heartbeat(view: &HeartbeatView, hb: &HeartbeatRequest, ts: i64) -> StoreResult { + if ts <= view.last_request_ts { + return Err(StoreError::Stale); + } + if !view.published { + return Err(StoreError::NotPublished); + } + let mut plan = HeartbeatPlan { + display_name: hb.display_name.clone().unwrap_or_else(|| view.display_name.clone()), + description: hb.description.clone().unwrap_or_else(|| view.description.clone()), + language: hb.language.clone().unwrap_or_else(|| view.language.clone()), + manager_version: hb.manager_version.clone().unwrap_or_else(|| view.manager_version.clone()), + capabilities: None, + capabilities_hash: view.capabilities_hash.clone(), + metadata_revision: view.metadata_revision, + metadata_changed: false, + resend_capabilities: false, + }; + plan.metadata_changed = plan.display_name != view.display_name || plan.description != view.description || plan.language != view.language || plan.manager_version != view.manager_version; + match &hb.capabilities { + Some(c) if hb.capabilities_hash != view.capabilities_hash => { + plan.capabilities = Some(c.clone()); + plan.capabilities_hash = hb.capabilities_hash.clone(); + plan.metadata_changed = true; + } + None if hb.capabilities_hash != view.capabilities_hash => plan.resend_capabilities = true, + _ => {} + } + if plan.metadata_changed { + plan.metadata_revision += 1; + } + Ok(plan) +} + +/// The new row for a registration: a first one, or the same key announcing itself again (a restart, a changed name, a republication). +pub fn plan_register(existing: Option<&RealmRow>, req: &RegisterRequest, key: [u8; 32], now: i64, ts: i64) -> StoreResult<(RealmRow, bool)> { + let mut row = RealmRow { + realm_id: req.realm_id, + public_key: key, + created_at: now, + updated_at: now, + last_seen_at: now, + published: true, + display_name: req.display_name.clone(), + description: req.description.clone(), + language: req.language.clone(), + ruleset: req.ruleset, + manager_version: req.manager_version.clone(), + capabilities: req.capabilities.clone(), + capabilities_hash: req.capabilities_hash.clone(), + metadata_revision: 1, + last_request_ts: ts, + player_count: req.player_count, + player_capacity: req.player_capacity, + }; + let Some(old) = existing else { return Ok((row, true)) }; + if old.public_key != key { + return Err(StoreError::KeyMismatch); + } + if ts <= old.last_request_ts { + return Err(StoreError::Stale); + } + row.created_at = old.created_at; + let changed = !old.published + || old.display_name != row.display_name + || old.description != row.description + || old.language != row.language + || old.ruleset != row.ruleset + || old.manager_version != row.manager_version + || old.capabilities_hash != row.capabilities_hash; + row.metadata_revision = old.metadata_revision + u64::from(changed); + row.updated_at = if changed { now } else { old.updated_at }; + Ok((row, false)) +} + +pub trait Store: Send + Sync + 'static { + fn ping(&self) -> impl Future + Send; + /// The key a realm id is bound to, if it is registered. + fn key_of(&self, id: &RealmId) -> impl Future>> + Send; + fn get(&self, id: &RealmId) -> impl Future>> + Send; + /// `Ok((row, created))` + fn register(&self, req: &RegisterRequest, key: [u8; 32], now: i64, ts: i64) -> impl Future> + Send; + fn heartbeat(&self, id: &RealmId, hb: &HeartbeatRequest, now: i64, ts: i64) -> impl Future> + Send; + /// The metadata revision after the change. + fn unpublish(&self, id: &RealmId, now: i64, ts: i64) -> impl Future> + Send; +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HeartbeatOutcome { + pub metadata_revision: u64, + pub capabilities_hash: String, + pub resend_capabilities: bool, +} + +#[derive(Default)] +pub struct MemoryStore { + rows: Mutex>, +} + +impl MemoryStore { + pub fn new() -> Self { + Self::default() + } + + #[doc(hidden)] + pub fn get_for_test(&self, id: &RealmId) -> Option { + self.rows.lock().unwrap().get(id).cloned() + } + + /// Forget everything (tests: a Registry that lost its data). + #[doc(hidden)] + pub fn forget_all(&self) { + self.rows.lock().unwrap().clear(); + } +} + +/// A store shared between a test and the server it runs. +impl Store for std::sync::Arc { + fn ping(&self) -> impl Future + Send { + (**self).ping() + } + fn key_of(&self, id: &RealmId) -> impl Future>> + Send { + (**self).key_of(id) + } + fn get(&self, id: &RealmId) -> impl Future>> + Send { + (**self).get(id) + } + fn register(&self, req: &RegisterRequest, key: [u8; 32], now: i64, ts: i64) -> impl Future> + Send { + (**self).register(req, key, now, ts) + } + fn heartbeat(&self, id: &RealmId, hb: &HeartbeatRequest, now: i64, ts: i64) -> impl Future> + Send { + (**self).heartbeat(id, hb, now, ts) + } + fn unpublish(&self, id: &RealmId, now: i64, ts: i64) -> impl Future> + Send { + (**self).unpublish(id, now, ts) + } +} + +impl Store for MemoryStore { + async fn ping(&self) -> bool { + true + } + + async fn key_of(&self, id: &RealmId) -> StoreResult> { + Ok(self.rows.lock().unwrap().get(id).map(|r| r.public_key)) + } + + async fn get(&self, id: &RealmId) -> StoreResult> { + Ok(self.rows.lock().unwrap().get(id).cloned()) + } + + async fn register(&self, req: &RegisterRequest, key: [u8; 32], now: i64, ts: i64) -> StoreResult<(RealmRow, bool)> { + let mut rows = self.rows.lock().unwrap(); + let (row, created) = plan_register(rows.get(&req.realm_id), req, key, now, ts)?; + rows.insert(req.realm_id, row.clone()); + Ok((row, created)) + } + + async fn heartbeat(&self, id: &RealmId, hb: &HeartbeatRequest, now: i64, ts: i64) -> StoreResult { + let mut rows = self.rows.lock().unwrap(); + let row = rows.get_mut(id).ok_or(StoreError::Unknown)?; + let plan = plan_heartbeat(&HeartbeatView::from(&*row), hb, ts)?; + row.display_name = plan.display_name; + row.description = plan.description; + row.language = plan.language; + row.manager_version = plan.manager_version; + if let Some(c) = plan.capabilities { + row.ruleset = c.content.ruleset; + row.capabilities = c; + } + row.capabilities_hash = plan.capabilities_hash.clone(); + if plan.metadata_changed { + row.updated_at = now; + } + row.metadata_revision = plan.metadata_revision; + row.last_seen_at = now; + row.last_request_ts = ts; + row.player_count = hb.player_count; + row.player_capacity = hb.player_capacity; + Ok(HeartbeatOutcome { metadata_revision: plan.metadata_revision, capabilities_hash: plan.capabilities_hash, resend_capabilities: plan.resend_capabilities }) + } + + async fn unpublish(&self, id: &RealmId, now: i64, ts: i64) -> StoreResult { + let mut rows = self.rows.lock().unwrap(); + let row = rows.get_mut(id).ok_or(StoreError::Unknown)?; + if ts <= row.last_request_ts { + return Err(StoreError::Stale); + } + if row.published { + row.published = false; + row.metadata_revision += 1; + row.updated_at = now; + } + row.last_request_ts = ts; + Ok(row.metadata_revision) + } +} diff --git a/services/coa-registry/tests/api.rs b/services/coa-registry/tests/api.rs new file mode 100644 index 0000000..b2f4f0d --- /dev/null +++ b/services/coa-registry/tests/api.rs @@ -0,0 +1,356 @@ +//! The protocol and its refusals, against a real HTTP server. The same checks run on the in-memory store and, when `REGISTRY_TEST_PG` is set, on PostgreSQL. + +mod common; + +use coa_registry::api::ApiConfig; +use coa_registry::limits::Rate; +use coa_registry::store::MemoryStore; +use coa_registry_proto::sign::{sign_request, HEADER_SIGNATURE, HEADER_TIMESTAMP, HEADER_VERSION}; +use coa_registry_proto::*; +use common::*; +use ed25519_dalek::SigningKey; +use serde_json::json; + +/// Limits high enough to stay out of the way of the functional checks (the limits have their own test). +fn lax() -> ApiConfig { + ApiConfig { general: Rate { burst: 100_000.0, refill: 1000.0 }, register: Rate { burst: 100_000.0, refill: 1000.0 }, heartbeat: Rate { burst: 100_000.0, refill: 1000.0 }, ..ApiConfig::default() } +} + +fn memory() -> TestServer { + spawn(MemoryStore::new(), lax()) +} + +/// 1, 2, 4: a fresh realm registers, registers again as itself, and a heartbeat updates last_seen. +fn lifecycle(s: &TestServer) { + let mut h = Host::new(s, 1); + let r = h.register("Friends' realm", 60); + assert_eq!(r.status, 200, "{:?}", r.body); + assert_eq!((r.body["created"].as_bool(), r.body["metadata_revision"].as_u64(), r.body["published"].as_bool()), (Some(true), Some(1), Some(true))); + assert_eq!((r.body["heartbeat_interval_secs"].as_u64(), r.body["online_ttl_secs"].as_u64()), (Some(30), Some(120))); + + let me = h.me(); + assert_eq!(me.status, 200); + assert_eq!((me.body["online"].as_bool(), me.body["display_name"].as_str(), me.body["last_seen_at"].as_i64()), (Some(true), Some("Friends' realm"), Some(s.time()))); + assert_eq!(me.body["capabilities"]["progression"]["max_player_level"], 60); + + s.advance(30); + h.ts = s.time(); + let hb = h.heartbeat(60, false); + assert_eq!(hb.status, 200, "{:?}", hb.body); + assert_eq!((hb.body["metadata_revision"].as_u64(), hb.body["resend_capabilities"].as_bool()), (Some(1), Some(false))); + let me = h.me(); + assert_eq!((me.body["last_seen_at"].as_i64(), me.body["player_count"].as_u64()), (Some(s.time()), Some(2)), "the heartbeat moved last_seen and the player count"); + assert_eq!(me.body["metadata_revision"], 1, "a heartbeat of unchanged metadata is not a metadata update"); + + // the same key announcing itself again (a Manager restart) keeps the identity; unchanged metadata keeps the revision + let again = h.register("Friends' realm", 60); + assert_eq!((again.status, again.body["created"].as_bool(), again.body["metadata_revision"].as_u64()), (200, Some(false), Some(1))); + let renamed = h.register("Friends' realm II", 60); + assert_eq!((renamed.status, renamed.body["metadata_revision"].as_u64()), (200, Some(2))); +} + +/// 5: a missing heartbeat makes the realm offline after the TTL; it is not deleted and the next heartbeat brings it back. +fn presence(s: &TestServer) { + let mut h = Host::new(s, 2); + assert_eq!(h.register("Presence", 60).status, 200); + s.advance(100); + h.ts = s.time(); + assert_eq!(h.me().body["online"], true, "inside the TTL"); + s.advance(21); + h.ts = s.time(); + let me = h.me(); + assert_eq!((me.status, me.body["online"].as_bool(), me.body["published"].as_bool()), (200, Some(false), Some(true)), "offline after the TTL, still there and still published"); + let hb = h.heartbeat(60, false); + assert_eq!(hb.status, 200); + assert_eq!(h.me().body["online"], true); +} + +/// 6: new capabilities arrive with a heartbeat and move the metadata revision; a changed hash without them asks for them. +fn metadata_changes(s: &TestServer) { + let mut h = Host::new(s, 3); + assert_eq!(h.register("Caps", 60).status, 200); + let hb = h.heartbeat(70, false); + assert_eq!(hb.status, 200); + assert_eq!((hb.body["resend_capabilities"].as_bool(), hb.body["metadata_revision"].as_u64()), (Some(true), Some(1)), "the hash changed but the capabilities were not sent"); + let me = h.me(); + assert_eq!(me.body["capabilities"]["progression"]["max_player_level"], 60, "nothing was applied without the capabilities"); + let hb = h.heartbeat(70, true); + assert_eq!((hb.status, hb.body["resend_capabilities"].as_bool(), hb.body["metadata_revision"].as_u64()), (200, Some(false), Some(2))); + let me = h.me(); + assert_eq!((me.body["capabilities"]["progression"]["max_player_level"].as_u64(), me.body["metadata_revision"].as_u64()), (Some(70), Some(2))); + let hb = h.heartbeat(70, true); + assert_eq!(hb.body["metadata_revision"], 2, "the same capabilities again change nothing"); + // a heartbeat may also rename + let mut body = h.heartbeat_body(70, false); + body.display_name = Some("Renamed".into()); + let path = path_heartbeat(&h.realm); + let r = h.send("POST", &path, &serde_json::to_vec(&body).unwrap()); + assert_eq!((r.status, r.body["metadata_revision"].as_u64()), (200, Some(3))); + assert_eq!(h.me().body["display_name"], "Renamed"); +} + +/// 7: an unrelated key can neither take a realm id over nor speak for it. +fn takeover(s: &TestServer) { + let mut owner = Host::new(s, 4); + assert_eq!(owner.register("Mine", 60).status, 200); + let mut thief = Host::new(s, 5); + thief.realm = owner.realm; + thief.ts = s.time() + 10; + let r = thief.register("Stolen", 60); + assert_eq!((r.status, r.code().as_str()), (403, "realm_key_mismatch"), "{:?}", r.body); + let r = thief.heartbeat(60, true); + assert_eq!((r.status, r.code().as_str()), (401, "invalid_signature")); + let r = thief.unpublish(); + assert_eq!((r.status, r.code().as_str()), (401, "invalid_signature")); + let r = thief.me(); + assert_eq!(r.status, 401); + let me = owner.me(); + assert_eq!((me.body["display_name"].as_str(), me.body["published"].as_bool()), (Some("Mine"), Some(true)), "the owner's record is untouched"); +} + +/// 8, 9, 10: tampering, time, and protocol version. +fn authentication(s: &TestServer) { + let mut h = Host::new(s, 6); + assert_eq!(h.register("Auth", 60).status, 200); + let path = path_heartbeat(&h.realm); + let body = serde_json::to_vec(&h.heartbeat_body(60, false)).unwrap(); + + // a body changed after it was signed + h.ts += 1; + let headers = sign_request(&h.key, "POST", &path, &h.realm, h.ts, &body); + let mut tampered = body.clone(); + tampered.extend_from_slice(b" "); + let r = h.send_with("POST", &path, &tampered, &headers.pairs()); + assert_eq!((r.status, r.code().as_str()), (401, "invalid_signature")); + // the method and the path are signed too + let r = h.send_with("POST", &path_unpublish(&h.realm), &serde_json::to_vec(&UnpublishRequest { protocol_version: 1 }).unwrap(), &headers.pairs()); + assert_eq!((r.status, r.code().as_str()), (401, "invalid_signature"), "a signature for one endpoint is not valid for another"); + let r = h.send_with("POST", &path, &body, &headers.pairs()); + assert_eq!(r.status, 200, "the untampered request is fine: {:?}", r.body); + let r = h.send_with("POST", &path, &body, &headers.pairs()); + assert_eq!((r.status, r.code().as_str()), (401, "timestamp_not_monotonic"), "a captured request cannot be replayed"); + + // timestamps outside the window, either way + for skew in [-400, 400] { + h.ts = s.time() + skew; + let r = h.send("POST", &path, &body); + assert_eq!((r.status, r.code().as_str()), (401, "bad_timestamp"), "skew {skew}"); + } + // a timestamp that is in the window but not later than the last applied one + h.ts = s.time() + 100; + assert_eq!(h.send("POST", &path, &body).status, 200); + h.ts = s.time() + 50; + let r = h.send("POST", &path, &body); + assert_eq!((r.status, r.code().as_str()), (401, "timestamp_not_monotonic")); + h.ts = s.time() + 101; + assert_eq!(h.send("POST", &path, &body).status, 200, "later ones are accepted again"); + + // protocol version in the header and in the body + h.ts += 1; + let mut headers = sign_request(&h.key, "POST", &path, &h.realm, h.ts, &body).pairs(); + headers[0] = (HEADER_VERSION, "2".into()); + let r = h.send_with("POST", &path, &body, &headers); + assert_eq!((r.status, r.code().as_str()), (400, "unsupported_protocol_version")); + let mut v2 = h.heartbeat_body(60, false); + v2.protocol_version = 2; + let r = h.send("POST", &path, &serde_json::to_vec(&v2).unwrap()); + assert_eq!((r.status, r.code().as_str()), (400, "unsupported_protocol_version")); + // missing or garbled headers + let r = h.send_with("POST", &path, &body, &[]); + assert_eq!(r.status, 400); + let mut bad = sign_request(&h.key, "POST", &path, &h.realm, h.ts + 1, &body).pairs(); + bad[3] = (HEADER_SIGNATURE, "not-a-signature".into()); + assert_eq!(h.send_with("POST", &path, &body, &bad).status, 400); + let mut bad = sign_request(&h.key, "POST", &path, &h.realm, h.ts + 1, &body).pairs(); + bad[2] = (HEADER_TIMESTAMP, "yesterday".into()); + assert_eq!(h.send_with("POST", &path, &body, &bad).status, 400); +} + +/// 11: hostile input is refused safely and stores nothing. +fn hostile_input(s: &TestServer) { + let mut h = Host::new(s, 7); + // oversized + let big = vec![b'x'; MAX_REQUEST_BYTES + 1]; + let r = h.send("POST", PATH_REGISTER, &big); + assert_eq!((r.status, r.code().as_str()), (413, "request_too_large")); + // not JSON, not an object, deeply nested + for body in [b"not json".as_slice(), b"[]", b"null", &b"{\"a\":".repeat(1)] { + let r = h.send("POST", PATH_REGISTER, body); + assert_eq!(r.status, 400, "{:?}", String::from_utf8_lossy(body)); + } + let nested = format!("{}1{}", "[".repeat(30_000), "]".repeat(30_000)); + assert_eq!(h.send("POST", PATH_REGISTER, nested.as_bytes()).status, 400); + // metadata outside its limits, one at a time + let base = serde_json::to_value(h.register_body("Hostile", 60)).unwrap(); + let cases: Vec<(&str, serde_json::Value)> = vec![ + ("display_name", json!("x".repeat(81))), + ("display_name", json!("")), + ("display_name", json!("evil\u{202E}name")), + ("display_name", json!("new\nline")), + ("description", json!("d".repeat(1025))), + ("language", json!("