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 <noreply@anthropic.com>
This commit is contained in:
ДмитрийandClaude Sonnet 5.5 committed 2026-10-07 20:23:13 +05:00
1 parent ec40f146aa
commit 1267bcc41e
56 files changed
+10198 -13

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
target
**/target
node_modules
dist
.git
+3
View File
@@ -4,3 +4,6 @@ dist
*.log
.env
src-tauri/gen
# standalone service workspaces build into their own target folders
services/*/target/
Generated
+15
View File
@@ -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"
+2 -1
View File
@@ -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"
+1
View File
@@ -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"] }
+1
View File
@@ -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;
@@ -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<AdvertisedCapabilities> {
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<AdvertisedCapabilities>,
pub player_count: Option<u32>,
pub player_capacity: Option<u32>,
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<LocalAdvert>;
}
struct Cached {
at: Instant,
advert: LocalAdvert,
caps_at: Option<Instant>,
}
struct Inner {
descriptors: PathBuf,
installs: Box<dyn Fn() -> Vec<(String, PathBuf)> + Send + Sync>,
registry: Arc<ExtensionRegistry>,
cache: Mutex<HashMap<String, Cached>>,
inflight: Mutex<HashSet<String>>,
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<Inner>,
}
impl LocalRealmsSource {
pub fn new(descriptors: impl Into<PathBuf>, 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<RealmAccess> {
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<u32> {
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<LocalAdvert> {
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::<BTreeSet<_>>(),
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");
}
}
@@ -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<ErrorCode> {
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<Self> {
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<R: DeserializeOwned>(&self, method: &str, path: &str, body: Vec<u8>, key: &SigningKey, realm: &RealmId, ts: i64) -> Result<R, ClientError> {
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::<ErrorBody>(&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<B: Serialize, R: DeserializeOwned>(&self, path: &str, body: &B, key: &SigningKey, realm: &RealmId, ts: i64) -> Result<R, ClientError> {
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<RegisterResponse, ClientError> {
self.post(PATH_REGISTER, req, key, &req.realm_id, ts)
}
pub fn heartbeat(&self, key: &SigningKey, realm: &RealmId, req: &HeartbeatRequest, ts: i64) -> Result<HeartbeatResponse, ClientError> {
self.post(&path_heartbeat(realm), req, key, realm, ts)
}
pub fn unpublish(&self, key: &SigningKey, realm: &RealmId, ts: i64) -> Result<UnpublishResponse, ClientError> {
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<SelfResponse, ClientError> {
self.send("GET", &path_self(realm), Vec::new(), key, realm, ts)
}
}
+550
View File
@@ -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<dyn Fn() -> 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<String>,
pub enabled: bool,
pub state: PublishState,
pub display_name: String,
pub description: String,
pub language: String,
pub metadata_revision: Option<u64>,
pub last_ok_unix: Option<i64>,
pub last_error: Option<String>,
pub retry_in_secs: Option<u64>,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct RegistryStatus {
pub url: Option<String>,
pub realms: Vec<RealmPublishStatus>,
}
enum Phase {
Register,
Beat,
}
struct Publisher {
cfg: PublishConfig,
realm: Option<RealmId>,
key: Option<SigningKey>,
phase: Phase,
state: PublishState,
/// `None`: nothing to do until the owner acts (a permanent refusal).
due: Option<Instant>,
failures: u32,
clock_failures: u32,
unpublish_attempts: u32,
acked_hash: Option<String>,
sent_meta: Option<(String, String, String)>,
revision: Option<u64>,
last_ok: Option<i64>,
last_error: Option<String>,
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<dyn KeyStore>,
source: Arc<dyn AdvertSource>,
clock: Clock,
timing: Timing,
client: Option<RegistryClient>,
publishers: BTreeMap<String, Publisher>,
}
impl RegistryHost {
pub fn open(dir: &Path, keys: Arc<dyn KeyStore>, source: Arc<dyn AdvertSource>, clock: Clock, timing: Timing, now: Instant) -> Result<Self> {
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<String> {
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<SelfResponse, ClientError> {
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<RealmId> {
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<String> = 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));
}
}
}
+160
View File
@@ -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<Option<SigningKey>>;
/// A new random key for a realm that has none. Refuses to replace an existing one.
fn create(&self, realm: &RealmId) -> Result<SigningKey>;
fn remove(&self, realm: &RealmId) -> Result<()>;
}
pub struct FileKeyStore {
dir: PathBuf,
}
impl FileKeyStore {
pub fn new(dir: impl Into<PathBuf>) -> 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<Option<SigningKey>> {
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<SigningKey> {
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}");
}
}
+19
View File
@@ -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;
@@ -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<String>, Sender<Result<()>>),
Publish { local_id: String, name: String, description: String, language: String, reply: Sender<Result<()>> },
Unpublish(String, Sender<Result<()>>),
Retry(String),
Shutdown,
}
pub struct RegistryRuntime {
tx: Sender<Cmd>,
status: Arc<Mutex<RegistryStatus>>,
join: Mutex<Option<JoinHandle<()>>>,
}
impl RegistryRuntime {
pub fn start(dir: &Path, keys: Arc<dyn KeyStore>, source: Arc<dyn AdvertSource>) -> Result<Self> {
Self::start_with(dir, keys, source, system_clock(), Timing::default())
}
pub fn start_with(dir: &Path, keys: Arc<dyn KeyStore>, source: Arc<dyn AdvertSource>, clock: Clock, timing: Timing) -> Result<Self> {
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::<Cmd>();
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<Result<()>>) -> 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<String>) -> 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();
}
}
@@ -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<RealmId>,
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<String>,
/// Keyed by the local realm id (`srv-<install id>` or a prepared realm's id).
pub realms: BTreeMap<String, PublishConfig>,
}
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<String> {
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<RegistrySettings> {
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());
}
}
+19
View File
@@ -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]
+312
View File
@@ -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<u32>,
pub session_protocol: u32,
pub collection_protocol: u32,
pub features: BTreeSet<Feature>,
pub collection_kinds: BTreeSet<String>,
pub extensions: Vec<ExtensionSupport>,
pub client_catalog: BTreeMap<String, CatalogEntry>,
}
#[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<CoreIdentity>,
pub content: ContentProfile,
pub content_profile_hash: String,
#[serde(default)]
pub progression: Option<Progression>,
}
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("<script>".into());
c.content_profile_hash = c.content.hash();
assert!(c.validate().is_err());
assert!(serde_json::from_str::<AdvertisedCapabilities>(r#"{"profile_version":2,"extra":1}"#).is_err(), "unknown fields");
}
#[test]
fn the_advert_hash_moves_with_the_progression() {
let a = sample();
let mut b = sample();
b.progression.as_mut().unwrap().max_player_level = 70;
assert_ne!(a.advert_hash(), b.advert_hash());
assert_eq!(a.advert_hash(), sample().advert_hash());
let mut c = sample();
c.progression = None;
assert_ne!(a.advert_hash(), c.advert_hash());
}
}
+66
View File
@@ -0,0 +1,66 @@
//! The wire protocol of the CoA realm Registry, version 1 (Phase 10).
//!
//! The Registry is discovery and presence infrastructure only. Everything in this crate is public, host-advertised metadata:
//! it never carries a credential, a character, a snapshot or a session payload. The protocol version is the Registry's own and
//! is unrelated to the portable character, session or capability-profile versions.
//!
//! * [`sign`]: the canonical signed-request input and Ed25519 helpers (see `docs/REGISTRY_PROTOCOL.md`).
//! * [`wire`]: request and response bodies with their limits and validation.
//! * [`caps`]: the strict, bounded form of `RealmCapabilities` a realm advertises.
pub mod caps;
pub mod sign;
pub mod wire;
pub use caps::AdvertisedCapabilities;
pub use sign::{RealmId, SignedHeaders};
pub use wire::*;
/// The only protocol version this crate speaks.
pub const REGISTRY_PROTOCOL_VERSION: u32 = 1;
/// The largest request body any endpoint accepts.
pub const MAX_REQUEST_BYTES: usize = 64 * 1024;
/// A realm's display name, in characters.
pub const MAX_DISPLAY_NAME_CHARS: usize = 80;
/// A realm's description, in UTF-8 bytes.
pub const MAX_DESCRIPTION_BYTES: usize = 1024;
/// The longest language tag.
pub const MAX_LANGUAGE_BYTES: usize = 16;
/// The longest Manager version string.
pub const MAX_VERSION_BYTES: usize = 32;
/// Players or capacity above this are refused as nonsense.
pub const MAX_PLAYER_NUMBER: u32 = 100_000;
/// How often a Host sends a heartbeat.
pub const HEARTBEAT_INTERVAL_SECS: u64 = 30;
/// A realm that has not been heard of for this long is offline (it is never deleted for it).
pub const ONLINE_TTL_SECS: u64 = 120;
/// How far a request's timestamp may be from the Registry's clock.
pub const MAX_CLOCK_SKEW_SECS: i64 = 300;
pub const PATH_PREFIX: &str = "/registry/v1";
pub const PATH_REGISTER: &str = "/registry/v1/realms/register";
pub const PATH_HEALTHZ: &str = "/registry/v1/healthz";
pub fn path_heartbeat(realm: &RealmId) -> String {
format!("{PATH_PREFIX}/realms/{realm}/heartbeat")
}
pub fn path_unpublish(realm: &RealmId) -> String {
format!("{PATH_PREFIX}/realms/{realm}/unpublish")
}
pub fn path_self(realm: &RealmId) -> String {
format!("{PATH_PREFIX}/realms/{realm}")
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum ProtoError {
#[error("{0}")]
Invalid(String),
}
pub type Result<T> = std::result::Result<T, ProtoError>;
pub(crate) fn invalid<T>(what: impl Into<String>) -> Result<T> {
Err(ProtoError::Invalid(what.into()))
}
+251
View File
@@ -0,0 +1,251 @@
//! Signed requests. One canonical input, one algorithm (Ed25519); see `docs/REGISTRY_PROTOCOL.md` for the format and test vectors.
//!
//! ```text
//! coa-registry-sig-v1
//! <protocol version> decimal
//! <HTTP method> upper case
//! <request path> exactly as sent, no query
//! <realm id> lower-case hyphenated UUID
//! <unix timestamp> decimal seconds
//! <hex SHA-256 of the body> lower case; the empty body for a GET
//! ```
//!
//! The lines are joined with `\n` (no trailing newline) and the UTF-8 bytes are signed. The signature travels base64url (no padding).
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
use base64::Engine;
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::{invalid, Result, REGISTRY_PROTOCOL_VERSION};
pub const SIGNATURE_DOMAIN: &str = "coa-registry-sig-v1";
pub const HEADER_VERSION: &str = "x-coa-registry-version";
pub const HEADER_REALM: &str = "x-coa-realm";
pub const HEADER_TIMESTAMP: &str = "x-coa-timestamp";
pub const HEADER_SIGNATURE: &str = "x-coa-signature";
/// A realm's persistent identity: a UUIDv7, in its one canonical spelling.
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct RealmId(Uuid);
impl RealmId {
pub fn new() -> Self {
Self(Uuid::now_v7())
}
/// Only the canonical form (lower case, hyphenated) of a version-7 UUID is accepted, so one id has one spelling in the signed input.
pub fn parse(text: &str) -> Result<Self> {
let Ok(uuid) = Uuid::parse_str(text) else { return invalid("the realm id is not a UUID") };
if uuid.get_version_num() != 7 || uuid.get_variant() != uuid::Variant::RFC4122 {
return invalid("the realm id is not a version-7 UUID");
}
if uuid.hyphenated().to_string() != text {
return invalid("the realm id is not in canonical form");
}
Ok(Self(uuid))
}
pub fn as_uuid(&self) -> Uuid {
self.0
}
pub fn from_uuid(uuid: Uuid) -> Result<Self> {
Self::parse(&uuid.hyphenated().to_string())
}
}
impl Default for RealmId {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Display for RealmId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0.hyphenated())
}
}
impl std::fmt::Debug for RealmId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "RealmId({self})")
}
}
impl serde::Serialize for RealmId {
fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
s.collect_str(self)
}
}
impl<'de> serde::Deserialize<'de> for RealmId {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
let text = String::deserialize(d)?;
Self::parse(&text).map_err(serde::de::Error::custom)
}
}
/// The exact bytes that are signed.
pub fn signature_input(method: &str, path: &str, realm: &RealmId, timestamp: i64, body: &[u8]) -> Vec<u8> {
format!("{SIGNATURE_DOMAIN}\n{REGISTRY_PROTOCOL_VERSION}\n{}\n{path}\n{realm}\n{timestamp}\n{}", method.to_ascii_uppercase(), hex::encode(Sha256::digest(body))).into_bytes()
}
/// The four headers of a signed request.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SignedHeaders {
pub version: u32,
pub realm: RealmId,
pub timestamp: i64,
pub signature: [u8; 64],
}
impl SignedHeaders {
pub fn pairs(&self) -> [(&'static str, String); 4] {
[
(HEADER_VERSION, self.version.to_string()),
(HEADER_REALM, self.realm.to_string()),
(HEADER_TIMESTAMP, self.timestamp.to_string()),
(HEADER_SIGNATURE, B64.encode(self.signature)),
]
}
/// Read the headers from whatever the transport offers. A missing or malformed header is an error; the protocol version is checked by the caller.
pub fn parse<'a>(get: impl Fn(&str) -> Option<&'a str>) -> Result<Self> {
let need = |name: &str| get(name).ok_or_else(|| crate::ProtoError::Invalid(format!("the {name} header is missing")));
let version: u32 = need(HEADER_VERSION)?.parse().map_err(|_| crate::ProtoError::Invalid("the protocol version header is not a number".into()))?;
let realm = RealmId::parse(need(HEADER_REALM)?)?;
let timestamp: i64 = need(HEADER_TIMESTAMP)?.parse().map_err(|_| crate::ProtoError::Invalid("the timestamp header is not a number".into()))?;
let sig = need(HEADER_SIGNATURE)?;
if sig.len() != 86 {
return invalid("the signature header has the wrong length");
}
let bytes = B64.decode(sig).map_err(|_| crate::ProtoError::Invalid("the signature header is not base64url".into()))?;
let signature: [u8; 64] = bytes.try_into().map_err(|_| crate::ProtoError::Invalid("the signature is not 64 bytes".into()))?;
Ok(Self { version, realm, timestamp, signature })
}
}
/// Sign a request. The caller chooses the timestamp (it must grow with every request of one realm).
pub fn sign_request(key: &SigningKey, method: &str, path: &str, realm: &RealmId, timestamp: i64, body: &[u8]) -> SignedHeaders {
let signature = key.sign(&signature_input(method, path, realm, timestamp, body)).to_bytes();
SignedHeaders { version: REGISTRY_PROTOCOL_VERSION, realm: *realm, timestamp, signature }
}
/// Verify a request against a public key. Strict: a signature that merely validates under a weak key or a non-canonical encoding is refused.
pub fn verify_request(public: &VerifyingKey, headers: &SignedHeaders, method: &str, path: &str, body: &[u8]) -> bool {
let input = signature_input(method, path, &headers.realm, headers.timestamp, body);
public.verify_strict(&input, &Signature::from_bytes(&headers.signature)).is_ok()
}
pub fn encode_public_key(key: &VerifyingKey) -> String {
B64.encode(key.as_bytes())
}
pub fn decode_public_key(text: &str) -> Result<VerifyingKey> {
if text.len() != 43 {
return invalid("the public key has the wrong length");
}
let bytes = B64.decode(text).map_err(|_| crate::ProtoError::Invalid("the public key is not base64url".into()))?;
let array: [u8; 32] = bytes.try_into().map_err(|_| crate::ProtoError::Invalid("the public key is not 32 bytes".into()))?;
let key = VerifyingKey::from_bytes(&array).map_err(|_| crate::ProtoError::Invalid("the public key is not a valid Ed25519 key".into()))?;
if key.is_weak() {
return invalid("the public key is a weak key");
}
Ok(key)
}
#[cfg(test)]
mod tests {
use super::*;
const REALM: &str = "018f2d9e-5c3a-7b21-8c4d-0e5f6a7b8c9d";
fn key() -> SigningKey {
SigningKey::from_bytes(&[7u8; 32])
}
#[test]
fn realm_ids_have_one_spelling() {
assert!(RealmId::parse(REALM).is_ok());
assert!(RealmId::parse(&REALM.to_uppercase()).is_err(), "upper case is another spelling");
assert!(RealmId::parse(&REALM.replace('-', "")).is_err(), "so is the simple form");
assert!(RealmId::parse("018f2d9e-5c3a-4b21-8c4d-0e5f6a7b8c9d").is_err(), "version 4 is not an identity");
assert!(RealmId::parse("018f2d9e-5c3a-7b21-0c4d-0e5f6a7b8c9d").is_err(), "nor is a foreign variant");
assert!(RealmId::parse("../../etc").is_err());
assert!(RealmId::parse("").is_err());
assert_eq!(RealmId::new().to_string().len(), 36);
}
/// The deterministic vector documented in `docs/REGISTRY_PROTOCOL.md`: Ed25519 signatures are deterministic, so this never changes.
#[test]
fn the_documented_test_vector() {
let realm = RealmId::parse(REALM).unwrap();
let key = key();
assert_eq!(encode_public_key(&key.verifying_key()), VECTOR_PUBLIC_KEY);
let body = br#"{"protocol_version":1}"#;
let input = signature_input("post", "/registry/v1/realms/018f2d9e-5c3a-7b21-8c4d-0e5f6a7b8c9d/heartbeat", &realm, 1_790_000_000, body);
assert_eq!(String::from_utf8(input).unwrap(), VECTOR_INPUT);
let headers = sign_request(&key, "POST", "/registry/v1/realms/018f2d9e-5c3a-7b21-8c4d-0e5f6a7b8c9d/heartbeat", &realm, 1_790_000_000, body);
assert_eq!(B64.encode(headers.signature), VECTOR_SIGNATURE);
assert!(verify_request(&key.verifying_key(), &headers, "POST", "/registry/v1/realms/018f2d9e-5c3a-7b21-8c4d-0e5f6a7b8c9d/heartbeat", body));
}
/// The second documented vector: a GET has the empty body, whose SHA-256 is `e3b0c442...`.
#[test]
fn the_documented_vector_of_a_read() {
let realm = RealmId::parse(REALM).unwrap();
let headers = sign_request(&key(), "GET", "/registry/v1/realms/018f2d9e-5c3a-7b21-8c4d-0e5f6a7b8c9d", &realm, 1_790_000_030, b"");
assert_eq!(B64.encode(headers.signature), "38nHMS6yvl8yrTbLmG1w5LiLVkvTaiRdQPrgT2GSvtR8jW_W0z50IRxD_kInBYP5Yl1Qwwm-r6BVDdTR5ts-BQ");
}
const VECTOR_PUBLIC_KEY: &str = "6kpsY-KcUgq-9VB7Ey7F-ZVHdq6-vnuSQh7qaRRG0iw";
const VECTOR_INPUT: &str = "coa-registry-sig-v1
1
POST
/registry/v1/realms/018f2d9e-5c3a-7b21-8c4d-0e5f6a7b8c9d/heartbeat
018f2d9e-5c3a-7b21-8c4d-0e5f6a7b8c9d
1790000000
00aa4c2c857995eb8e19cb0fade07e4b49aac774e37a99c37a0c9f549204d9de";
const VECTOR_SIGNATURE: &str = "1ys2C7RVjhEtTl0bVrdowpuPELwOnhuiYZKp88dAWdcA3cezP9QuI903OJi2N2mFA9kifblD_fvdT2B_8F5_Dw";
#[test]
fn every_part_of_the_input_is_covered() {
let realm = RealmId::parse(REALM).unwrap();
let other = RealmId::parse("018f2d9e-5c3a-7b21-8c4d-0e5f6a7b8c9e").unwrap();
let key = key();
let path = "/registry/v1/realms/x";
let h = sign_request(&key, "POST", path, &realm, 100, b"body");
let public = key.verifying_key();
assert!(verify_request(&public, &h, "POST", path, b"body"));
assert!(!verify_request(&public, &h, "POST", path, b"bodY"), "the body");
assert!(!verify_request(&public, &h, "POST", "/registry/v1/realms/y", b"body"), "the path");
assert!(!verify_request(&public, &h, "GET", path, b"body"), "the method");
assert!(!verify_request(&public, &SignedHeaders { realm: other, ..h.clone() }, "POST", path, b"body"), "the realm");
assert!(!verify_request(&public, &SignedHeaders { timestamp: 101, ..h.clone() }, "POST", path, b"body"), "the timestamp");
assert!(!verify_request(&SigningKey::from_bytes(&[8u8; 32]).verifying_key(), &h, "POST", path, b"body"), "the key");
}
#[test]
fn headers_round_trip_and_hostile_ones_are_refused() {
let realm = RealmId::parse(REALM).unwrap();
let h = sign_request(&key(), "POST", "/p", &realm, 5, b"");
let pairs = h.pairs();
let get = |name: &str| pairs.iter().find(|(n, _)| *n == name).map(|(_, v)| v.as_str());
assert_eq!(SignedHeaders::parse(get).unwrap(), h);
assert!(SignedHeaders::parse(|n| if n == HEADER_SIGNATURE { None } else { get(n) }).is_err());
assert!(SignedHeaders::parse(|n| if n == HEADER_SIGNATURE { Some("AAAA") } else { get(n) }).is_err());
assert!(SignedHeaders::parse(|n| if n == HEADER_TIMESTAMP { Some("12x") } else { get(n) }).is_err());
assert!(SignedHeaders::parse(|n| if n == HEADER_REALM { Some("nope") } else { get(n) }).is_err());
}
#[test]
fn public_keys_are_checked() {
assert!(decode_public_key(&encode_public_key(&key().verifying_key())).is_ok());
assert!(decode_public_key("short").is_err());
assert!(decode_public_key(&B64.encode([0u8; 32])).is_err(), "the identity point is a weak key");
assert!(decode_public_key(&"A".repeat(43)).is_err());
}
}
+363
View File
@@ -0,0 +1,363 @@
//! Request and response bodies of Registry protocol 1. Every request type refuses unknown fields and validates its own bounds.
use serde::{Deserialize, Serialize};
use crate::caps::{AdvertisedCapabilities, Ruleset};
use crate::sign::RealmId;
use crate::{invalid, Result, MAX_DESCRIPTION_BYTES, MAX_DISPLAY_NAME_CHARS, MAX_LANGUAGE_BYTES, MAX_PLAYER_NUMBER, MAX_VERSION_BYTES, REGISTRY_PROTOCOL_VERSION};
fn bidi_or_control(c: char, allow_newline: bool) -> bool {
(c.is_control() && !(allow_newline && c == '\n')) || matches!(c, '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' | '\u{FEFF}' | '\u{2028}' | '\u{2029}')
}
/// Plain text only: the Registry stores text and never HTML, and a consumer must render it as text.
pub fn validate_display_name(s: &str) -> Result<()> {
let n = s.chars().count();
if n == 0 || n > MAX_DISPLAY_NAME_CHARS {
return invalid(format!("the display name must be 1 to {MAX_DISPLAY_NAME_CHARS} characters"));
}
if s != s.trim() || s.chars().any(|c| bidi_or_control(c, false)) {
return invalid("the display name has control characters, direction overrides or surrounding spaces");
}
Ok(())
}
pub fn validate_description(s: &str) -> Result<()> {
if s.len() > MAX_DESCRIPTION_BYTES {
return invalid(format!("the description is limited to {MAX_DESCRIPTION_BYTES} bytes"));
}
if s.chars().any(|c| bidi_or_control(c, true)) {
return invalid("the description has control characters or direction overrides");
}
Ok(())
}
/// A short language tag such as `en`, `ru`, `pt-BR`.
pub fn validate_language(s: &str) -> Result<()> {
let ok = !s.is_empty() && s.len() <= MAX_LANGUAGE_BYTES && s.split('-').enumerate().all(|(i, part)| !part.is_empty() && part.len() <= 8 && part.bytes().all(|b| b.is_ascii_alphanumeric()) && (i > 0 || part.bytes().all(|b| b.is_ascii_lowercase()) && part.len() >= 2));
if !ok {
return invalid("the language is not a short language tag");
}
Ok(())
}
pub fn validate_version(s: &str) -> Result<()> {
if s.is_empty() || s.len() > MAX_VERSION_BYTES || !s.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'+' | b'_')) {
return invalid("the Manager version is not a short version string");
}
Ok(())
}
pub fn validate_players(count: Option<u32>, capacity: Option<u32>) -> Result<()> {
if count.is_some_and(|n| n > MAX_PLAYER_NUMBER) || capacity.is_some_and(|n| n > MAX_PLAYER_NUMBER) {
return invalid("a player number is out of range");
}
if let (Some(c), Some(cap)) = (count, capacity) {
if c > cap {
return invalid("more players than capacity");
}
}
Ok(())
}
fn check_version(v: u32) -> Result<()> {
if v != REGISTRY_PROTOCOL_VERSION {
return invalid(format!("protocol version {v} is not supported"));
}
Ok(())
}
fn hex64(s: &str) -> bool {
s.len() == 64 && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RegisterRequest {
pub protocol_version: u32,
pub realm_id: RealmId,
/// Base64url (no padding) of the 32-byte Ed25519 public key.
pub public_key: String,
pub display_name: String,
pub description: String,
pub language: String,
pub ruleset: Ruleset,
pub manager_version: String,
pub capabilities: AdvertisedCapabilities,
/// [`AdvertisedCapabilities::advert_hash`]
pub capabilities_hash: String,
#[serde(default)]
pub player_count: Option<u32>,
#[serde(default)]
pub player_capacity: Option<u32>,
}
impl RegisterRequest {
pub fn validate(&self) -> Result<()> {
check_version(self.protocol_version)?;
crate::sign::decode_public_key(&self.public_key)?;
validate_display_name(&self.display_name)?;
validate_description(&self.description)?;
validate_language(&self.language)?;
validate_version(&self.manager_version)?;
validate_players(self.player_count, self.player_capacity)?;
self.capabilities.validate()?;
if self.capabilities.content.ruleset != self.ruleset {
return invalid("the ruleset is not the ruleset of the capabilities");
}
if !hex64(&self.capabilities_hash) || self.capabilities_hash != self.capabilities.advert_hash() {
return invalid("the capabilities hash is not the hash of the capabilities");
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HeartbeatRequest {
pub protocol_version: u32,
/// The hash of the capabilities the Host advertises now.
pub capabilities_hash: String,
/// Present when the Host's hash differs from the one the Registry last acknowledged.
#[serde(default)]
pub capabilities: Option<AdvertisedCapabilities>,
#[serde(default)]
pub manager_version: Option<String>,
#[serde(default)]
pub player_count: Option<u32>,
#[serde(default)]
pub player_capacity: Option<u32>,
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub language: Option<String>,
}
impl HeartbeatRequest {
pub fn validate(&self) -> Result<()> {
check_version(self.protocol_version)?;
if !hex64(&self.capabilities_hash) {
return invalid("the capabilities hash is not a SHA-256");
}
if let Some(c) = &self.capabilities {
c.validate()?;
if c.advert_hash() != self.capabilities_hash {
return invalid("the capabilities hash is not the hash of the capabilities");
}
}
if let Some(v) = &self.manager_version {
validate_version(v)?;
}
if let Some(n) = &self.display_name {
validate_display_name(n)?;
}
if let Some(d) = &self.description {
validate_description(d)?;
}
if let Some(l) = &self.language {
validate_language(l)?;
}
validate_players(self.player_count, self.player_capacity)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UnpublishRequest {
pub protocol_version: u32,
}
impl UnpublishRequest {
pub fn validate(&self) -> Result<()> {
check_version(self.protocol_version)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RegisterResponse {
pub protocol_version: u32,
pub realm_id: RealmId,
pub created: bool,
pub metadata_revision: u64,
pub published: bool,
pub capabilities_hash: String,
pub server_time: i64,
pub heartbeat_interval_secs: u64,
pub online_ttl_secs: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct HeartbeatResponse {
pub protocol_version: u32,
pub metadata_revision: u64,
pub published: bool,
/// The hash the Registry holds now.
pub capabilities_hash: String,
/// The heartbeat carried another hash without the capabilities: send them with the next one.
pub resend_capabilities: bool,
pub server_time: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct UnpublishResponse {
pub protocol_version: u32,
pub published: bool,
pub metadata_revision: u64,
pub server_time: i64,
}
/// What the Registry holds about one realm (the authenticated self endpoint; there is no public list yet).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SelfResponse {
pub protocol_version: u32,
pub realm_id: RealmId,
pub public_key: String,
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 published: bool,
/// Published and heard of within the online TTL.
pub online: bool,
pub created_at: i64,
pub updated_at: i64,
pub last_seen_at: i64,
pub player_count: Option<u32>,
pub player_capacity: Option<u32>,
pub server_time: i64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorCode {
MalformedRequest,
UnsupportedProtocolVersion,
RequestTooLarge,
InvalidMetadata,
InvalidRealmId,
InvalidSignature,
BadTimestamp,
TimestampNotMonotonic,
RealmKeyMismatch,
UnknownRealm,
NotPublished,
RateLimited,
Unavailable,
Internal,
}
impl ErrorCode {
/// Errors that repeating the same request cannot fix (the Host stops instead of retrying).
pub fn is_permanent(self) -> bool {
matches!(self, ErrorCode::MalformedRequest | ErrorCode::UnsupportedProtocolVersion | ErrorCode::RequestTooLarge | ErrorCode::InvalidMetadata | ErrorCode::InvalidRealmId | ErrorCode::InvalidSignature | ErrorCode::RealmKeyMismatch)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ErrorBody {
pub error: ErrorDetail,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ErrorDetail {
pub code: ErrorCode,
pub message: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Health {
pub status: String,
pub protocol_version: u32,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::caps::tests::sample;
pub(crate) fn register() -> RegisterRequest {
let key = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
let capabilities = sample();
RegisterRequest {
protocol_version: 1,
realm_id: RealmId::parse("018f2d9e-5c3a-7b21-8c4d-0e5f6a7b8c9d").unwrap(),
public_key: crate::sign::encode_public_key(&key.verifying_key()),
display_name: "Friends' realm".into(),
description: "A small realm.\nPlain text.".into(),
language: "en".into(),
ruleset: Ruleset::Coa,
manager_version: "0.6.6".into(),
capabilities_hash: capabilities.advert_hash(),
capabilities,
player_count: Some(2),
player_capacity: Some(100),
}
}
#[test]
fn a_sound_registration_validates() {
register().validate().unwrap();
}
#[test]
fn text_is_plain_and_bounded() {
for bad in ["", " lead", "trail ", "tab\there", "ctl\u{0007}", "rtl\u{202E}name", &"x".repeat(81)] {
assert!(validate_display_name(bad).is_err(), "{bad:?}");
}
assert!(validate_display_name(&"я".repeat(80)).is_ok(), "80 characters, not bytes");
assert!(validate_description(&"x".repeat(1025)).is_err());
assert!(validate_description(&"я".repeat(600)).is_err(), "bytes, not characters");
assert!(validate_description("a\nb").is_ok());
assert!(validate_description("a\r\nb").is_err());
assert!(validate_description("\u{2066}x").is_err());
assert!(validate_language("pt-BR").is_ok() && validate_language("en").is_ok());
for bad in ["", "E", "EN", "en_US", "en-", "-en", "<b>", "x".repeat(17).as_str()] {
assert!(validate_language(bad).is_err(), "{bad:?}");
}
assert!(validate_version("0.6.6").is_ok() && validate_version("0.6.6-rc.1+b2").is_ok());
assert!(validate_version("1 2").is_err() && validate_version("").is_err());
assert!(validate_players(Some(5), Some(4)).is_err() && validate_players(Some(100_001), None).is_err() && validate_players(None, None).is_ok());
}
#[test]
fn the_request_refuses_what_it_does_not_know_and_what_does_not_add_up() {
let mut r = register();
r.protocol_version = 2;
assert!(r.validate().is_err());
let mut r = register();
r.ruleset = Ruleset::Wildcard;
assert!(r.validate().is_err(), "ruleset and capabilities disagree");
let mut r = register();
r.capabilities_hash = "0".repeat(64);
assert!(r.validate().is_err());
let mut value = serde_json::to_value(register()).unwrap();
value.as_object_mut().unwrap().insert("character".into(), serde_json::json!({"name": "x"}));
assert!(serde_json::from_value::<RegisterRequest>(value).is_err(), "no field for a character, and none is accepted");
let mut value = serde_json::to_value(register()).unwrap();
value["realm_id"] = serde_json::json!("018F2D9E-5C3A-7B21-8C4D-0E5F6A7B8C9D");
assert!(serde_json::from_value::<RegisterRequest>(value).is_err());
}
#[test]
fn a_heartbeat_with_capabilities_must_hash_to_its_claim() {
let caps = sample();
let mut h = HeartbeatRequest { protocol_version: 1, capabilities_hash: caps.advert_hash(), capabilities: Some(caps.clone()), manager_version: None, player_count: None, player_capacity: None, display_name: None, description: None, language: None };
h.validate().unwrap();
h.capabilities_hash = "1".repeat(64);
assert!(h.validate().is_err());
h.capabilities = None;
h.validate().unwrap();
}
#[test]
fn permanent_and_transient_errors_are_told_apart() {
assert!(ErrorCode::InvalidSignature.is_permanent() && ErrorCode::RealmKeyMismatch.is_permanent() && ErrorCode::UnsupportedProtocolVersion.is_permanent());
assert!(!ErrorCode::RateLimited.is_permanent() && !ErrorCode::Unavailable.is_permanent() && !ErrorCode::BadTimestamp.is_permanent() && !ErrorCode::UnknownRealm.is_permanent());
}
}
+8 -8
View File
@@ -12,8 +12,8 @@ This file never contains private keys, passwords, DB credentials, transfer PINs,
Registry API Relay Coordinator PostgreSQL Caddy (ingress) monitoring
```
Phase -1 only prepared the host. No Registry, Relay, Coordinator or schema is deployed. The VPS is not the source of
truth for any player data.
Phase -1 prepared the host. **Phase 10 deployed the Registry and PostgreSQL** (see [REGISTRY_DEPLOYMENT.md](REGISTRY_DEPLOYMENT.md)); no Relay or Coordinator exists yet.
The VPS is not the source of truth for any player data.
No domain is registered. Until one exists, services are addressed by IP, and Caddy serves plain HTTP on :80.
The design keeps client protocols independent of hostnames so the relay can later be moved to separate nodes and
@@ -29,9 +29,9 @@ Internet
v
Caddy (container, network coa-ingress)
|
+--> Registry API (future, coa-ingress)
+--> Registry API (coa-registry:8080, networks coa-ingress + coa-db, no published port)
|
+--> PostgreSQL (future, network coa-db, --internal, no published port)
+--> PostgreSQL (coa-postgres:5432, network coa-db only, --internal, no published port)
```
Rules:
@@ -55,8 +55,8 @@ Rules:
/opt/coa/
├── bin/ helper scripts (health.sh)
├── caddy/ compose.yaml, Caddyfile
├── postgres/ compose.yaml (prepared, not started)
├── registry/ (empty)
├── postgres/ compose.yaml (running since Phase 10)
├── registry/ compose.yaml, .env, src/ (the build context of the running image)
├── relay/ (empty)
├── coordinator/ (empty)
├── monitoring/ (empty)
@@ -126,9 +126,9 @@ containers. No monitoring stack is installed on purpose (no Grafana/Prometheus u
cd /opt/coa/caddy && sudo docker compose up -d
sudo docker compose logs --tail 50
# PostgreSQL (prepared; do NOT start until a password file exists and the schema phase begins)
sudo install -m 600 /dev/null /opt/coa/secrets/postgres_password # then write the password into it (as root)
# PostgreSQL and the Registry (Phase 10): see REGISTRY_DEPLOYMENT.md
cd /opt/coa/postgres && sudo docker compose up -d
cd /opt/coa/registry && sudo docker compose up -d
```
## Backups
+112
View File
@@ -0,0 +1,112 @@
# Registry deployment (Phase 10)
How the Registry runs on `coa-infra-01`, how to rebuild or restart it, and what the Phase-10 gate checked. Protocol: [REGISTRY_PROTOCOL.md](REGISTRY_PROTOCOL.md).
Node facts: [INFRASTRUCTURE.md](INFRASTRUCTURE.md), [VPS_BASELINE.md](VPS_BASELINE.md). `<VPS_IP>` is the node's public IPv4 (kept out of git); no secret is in this file.
## Topology
```
Internet
| 22/tcp (sshd) 80/tcp, 443/tcp (Caddy, the only container with published ports)
v
Caddy :80 /registry/* -> coa-registry:8080 network coa-ingress
|
Registry container (coa-registry) +--- network coa-db (internal) ---+
v
PostgreSQL container (alias coa-postgres:5432)
```
* Registry and PostgreSQL publish **no host port**. `docker ps` shows `8080/tcp` and `5432/tcp` (exposed inside Docker networks only), and `ss -tulnH` lists only 22, 80, 443.
Never add `5432:5432` or `8080:8080`.
* The Registry joins `coa-ingress` (to be reached by Caddy) and `coa-db` (to reach PostgreSQL). PostgreSQL joins only `coa-db`, which is `--internal`.
* Caddy serves plain HTTP on :80 (no domain yet: `auto_https off`, admin API off). Port 443 is allowed by UFW and published by Docker, but nothing listens on it until TLS exists.
**HTTPS is mandatory before Phase 11 is treated as release-ready**; for this staging gate HTTP is acceptable because every Host request is signed and carries no secret.
## Files (in git: `services/coa-registry/deploy/`)
| In git | On the node |
|---|---|
| `postgres.compose.yaml` | `/opt/coa/postgres/compose.yaml` |
| `registry.compose.yaml` | `/opt/coa/registry/compose.yaml` (+ `.env`: `REGISTRY_TAG`, rate limits) |
| `Caddyfile` | `/opt/coa/caddy/Caddyfile` (the Phase -1 file is kept as `Caddyfile.phase-1.bak`) |
| `provision-db.sh` | run once (idempotent) as root: creates the least-privilege role `coa_registry` and database `coa_registry` |
| `deploy.sh` | builds the image on the node from the working tree and restarts the stack |
| `audit-db.sql` / `audit-db.sh` | what the database holds (gate item 17) |
| `gate-restarts.sh` | gate items 2 and 14 |
| `../Dockerfile` | multi-stage build (`rust:1-bookworm` → `debian:bookworm-slim`, non-root uid 10001, read-only root, all capabilities dropped, no-new-privileges, 256 MB, 128 pids) |
## Secrets (never in git)
```
/opt/coa/secrets/postgres_password root:root 0600 superuser of PostgreSQL; read only by the PostgreSQL entrypoint
/opt/coa/secrets/registry_db_password root:10001 0440 password of the role coa_registry; mounted at /run/secrets in the Registry container
```
Both were generated on the node (`/dev/urandom`, 40 characters) and never displayed. The Registry connects as `coa_registry` (not a superuser, no CREATEDB/CREATEROLE,
connection limit 20, owner of its own database only); PUBLIC has no access to that database. Passwords reach `psql` through the container's own secret or stdin, never a command line.
Rotating: write a new value to the file, `sudo sh provision-db.sh` (it `ALTER`s the role), `docker compose up -d --force-recreate` in `/opt/coa/registry`.
## First deployment (what was done)
```bash
# 1. secrets (on the node)
sudo sh -c 'umask 077; head -c 48 /dev/urandom | base64 | tr -dc A-Za-z0-9 | head -c 40 > /opt/coa/secrets/postgres_password'
sudo sh -c 'umask 077; head -c 48 /dev/urandom | base64 | tr -dc A-Za-z0-9 | head -c 40 > /opt/coa/secrets/registry_db_password'
sudo chown root:10001 /opt/coa/secrets/registry_db_password && sudo chmod 440 /opt/coa/secrets/registry_db_password
# 2. PostgreSQL (started now, for the Registry only) and the Registry's role/database
cd /opt/coa/postgres && sudo docker compose up -d && sudo sh provision-db.sh
# 3. from a workstation, at the repository root: build on the node and start the Registry
VPS=ubuntu@<VPS_IP> TAG=p10 sh services/coa-registry/deploy/deploy.sh
# 4. Caddy: copy the Caddyfile, validate, restart
sudo docker compose -f /opt/coa/caddy/compose.yaml exec caddy caddy validate --config /etc/caddy/Caddyfile
sudo docker compose -f /opt/coa/caddy/compose.yaml restart caddy
```
Migrations run at start-up under an advisory lock; the Registry waits for a database that is still starting and refuses one that is ahead of its binary.
Staging note: the gate ran with `REGISTRY_REGISTER_BURST=100`; the production defaults (burst 10, 10 per hour) are what `registry.compose.yaml` uses when `.env` does not override them.
## Operating
```bash
cd /opt/coa/registry && sudo docker compose ps && sudo docker compose logs --tail 50 # JSON lines
curl -s http://<VPS_IP>/registry/v1/healthz # {"status":"ok","protocol_version":1}
sudo sh /opt/coa/registry/src/services/coa-registry/deploy/audit-db.sh # contents audit
```
Logs are structured JSON: `realm`, `endpoint`, `status`, `error` (code), `latency_ms`, and the first 8 characters of the capabilities hash. They never contain keys,
signatures, headers, request bodies or the database credentials (checked: 0 occurrences of either password in any container's log). Backups: not yet automated
(`pg_dump coa_registry` to `/opt/coa/backups`, see INFRASTRUCTURE.md); the data is discovery metadata that every Host re-announces by itself within a heartbeat or two.
## The Phase-10 gate
Run on 2026-10-07 against `http://<VPS_IP>` (Windows workstation → Internet → Caddy → Registry → PostgreSQL). Tools: `services/coa-registry-e2e/tests/live_vps.rs`
(`REGISTRY_URL=http://<VPS_IP> cargo test --test live_vps -- --ignored --nocapture`), `deploy/gate-restarts.sh`, `deploy/audit-db.sh`, a TCP connect scan of 1–65535, and the real Manager window.
| # | Item | Result |
|---|---|---|
| 1 | Fresh realm generates identity and registers | pass (`RegistryHost` made the UUIDv7 and the key, registered; the record shows `published`, `online`, revision 1) |
| 2 | Registry restart keeps the realm | pass (`docker restart`; the realm is still registered and heartbeats with its own key) |
| 3 | Host Manager restart keeps RealmId and key | pass (same id and public key, registered again as itself, revision unchanged) |
| 4 | Valid heartbeat updates last_seen | pass |
| 5 | Missing heartbeats → offline after the TTL, not deleted | pass (125 s without a heartbeat: `online=false`, `published=true`; a heartbeat brings it back) |
| 6 | Metadata/capability change updates the record | pass (cap 60 → 70: revision 1 → 2, new capabilities stored) |
| 7 | Same RealmId, another key | pass (`register` 403 `realm_key_mismatch`; heartbeat/unpublish/read 401 `invalid_signature`; the record is untouched) |
| 8 | Tampered body | pass (401 `invalid_signature`) |
| 9 | Old/future timestamp | pass (±1 h: 401 `bad_timestamp`; a non-increasing one: 401 `timestamp_not_monotonic`) |
| 10 | Unsupported protocol version | pass (header and body: 400 `unsupported_protocol_version`) |
| 11 | Oversized/hostile metadata | pass (413; 500-character name, 5000-byte description, `<script>` language, direction override, 6 unknown fields (`character`, `snapshot`, `password`, `ra_password`, `db_credentials`, `canonical_revision`), 30000-deep JSON: 400; nothing stored) |
| 12 | Unpublish stops publication | pass (`published=false`, `online=false`; heartbeat 409 `not_published`; registering again republishes the same id) |
| 13 | A Registry/network outage does not disturb the worldserver or portable sessions | pass (below) |
| 14 | Registry and PostgreSQL container restarts recover | pass (`gate-restarts.sh`: Registry restart, PostgreSQL restart alone, both stopped and started in the wrong order, `compose down/up` of both; after each the realm and its key are intact) |
| 15 | External scan shows only 22, 80, 443 | pass (all 65535 TCP ports: 22 and 80 answer; 443 is allowed and published but refuses until TLS exists; nothing else) |
| 16 | PostgreSQL not reachable externally | pass (5432 closed from outside; the container has no published port; `ss` on the node shows no 5432 or 8080 listener) |
| 17 | No credentials, snapshots or character records in the database | pass (two tables, `realms` and `schema_migrations`; no column or table named for players, characters, accounts, sessions, snapshots, credentials; 0 rows mention any forbidden word; the largest capabilities blob is under 1 KB) |
**Item 13 in detail.** The real Manager (debug build, merged branch) published the realm *PT Guest* of the owner's smoke setup (cap 60), a character was in a portable session on it (a bot standing in for the client),
and the Registry was stopped for two minutes, then the Manager was pointed at an unroutable address (timeouts), then the Registry was restored without touching the Manager. Throughout: the worldserver answered RA
(`server info`) every poll, the portable session stayed `playing` with one open session, canonical revisions kept advancing from the game (5 → 8, including a deliberate change), the portable runtime's tick
kept running with no error, and the Manager showed *retrying* with a growing delay (5 s doubling, capped), then went back to *online* by itself when the Registry returned (at its next scheduled attempt, within half a minute).
After the logout the final sync was made and the next session armed as usual.
**Found by running it for real** (and fixed): on Windows the key file's access list had to include delete rights or the final rename was refused (`icacls … (F)`); the realm probe, which takes many seconds, was blocking the publishing loop (now a cached, background probe);
a republish within the same second as the last request was refused as a replay (the counter is carried over).
+189
View File
@@ -0,0 +1,189 @@
# Registry protocol 1 (Phase 10)
The central realm Registry is **discovery and presence infrastructure only**. It answers two questions: *which realms exist and who publishes them*, and
*are they alive*. Code: `crates/coa-registry-proto` (the protocol, shared by both sides), `services/coa-registry` (the service),
`crates/coa-core/src/realm_registry` (the Host Manager's client and publishing loop).
`REGISTRY_PROTOCOL_VERSION = 1`. It is the Registry's own number; it has nothing to do with the portable character, session or capability-profile versions.
## 1. Trust model
The Registry never receives or stores: MySQL credentials, RA credentials, game account passwords, `PortableCharacter` snapshots, canonical revisions or
payloads, inventories, portable session payloads, player character ids. There is no field for any of them; every request type refuses unknown fields,
so nothing of the sort can be smuggled in, and the database has no table or column that could hold it (`deploy/audit-db.sql`).
Registering is not trust. The capabilities a Registry publishes are **host-advertised discovery metadata**: a later JOIN negotiates capabilities again with the
live Host. The Registry checks that an advertisement is well-formed, bounded and self-consistent (its hash is the hash of its content), not that it is true.
There is no shared admin secret, no bearer token, no unauthenticated admin API, and no public realm list yet.
## 2. Identity
Every publishable realm has an identity independent of its address: `RealmId` (a UUIDv7, lower-case hyphenated; any other spelling is refused so that one id
has one spelling in the signed input) and an Ed25519 key pair, **one per realm** (not per Manager installation). The first registration carries the public key
and proves possession by signing the request. Afterwards the Registry holds the key for that id: a request for the id signed by any other key is refused
(`401 invalid_signature`), and a registration for the id with another key is refused (`403 realm_key_mismatch`). Nobody can take an id over.
The private key stays in the Host Manager's storage (section 8) and never enters a log, a diagnostic, a descriptor or the Registry.
## 3. Signed requests
Every mutating request (and the authenticated read) is signed.
Headers:
| Header | Value |
|---|---|
| `X-Coa-Registry-Version` | `1` |
| `X-Coa-Realm` | the realm id; for every endpoint except `register` it must equal the id in the path |
| `X-Coa-Timestamp` | unix seconds (decimal) |
| `X-Coa-Signature` | base64url (no padding, 86 characters) of the 64-byte Ed25519 signature |
The signed input is these seven lines, joined by a single `\n` (no trailing newline), as UTF-8 bytes:
```
coa-registry-sig-v1
<protocol version, decimal>
<HTTP method, upper case>
<request path exactly as sent; no query string is allowed>
<realm id>
<unix timestamp, decimal>
<SHA-256 of the body bytes as sent, lower-case hex> (the empty body for a GET)
```
The signature is made over those bytes with the realm's Ed25519 key (RFC 8032, deterministic). Verification is strict (`verify_strict`).
### Rules checked, in this order
1. `429 rate_limited`: the source address is over its allowance (section 6).
2. `413 request_too_large`: more than 65536 body bytes.
3. `400 malformed_request`: a header is missing or malformed; the path has a query string; the body is not the expected JSON, or has an unknown field.
4. `400 unsupported_protocol_version`: the header, or `protocol_version` in the body, is not `1`.
5. `400 invalid_realm_id`: not a canonical UUIDv7, or the header and the path (or body) name different realms.
6. `401 bad_timestamp`: the timestamp is more than 300 s from the Registry's clock (either way).
7. For `register`: `400 invalid_metadata` (a limit or consistency rule below), then the signature is verified with the key *in the body*.
For the others: `404 unknown_realm`, then the signature is verified with the *stored* key.
8. `401 invalid_signature`: the signature does not match (a forged request also costs its sender more of its address allowance).
9. `403 realm_key_mismatch`: `register` for a known id with another key (cannot happen with a valid signature from the stored key).
10. `401 timestamp_not_monotonic`: the timestamp is not **strictly later than the last request applied for this realm**. This is the replay protection: a captured
request cannot be sent again, and one delayed past a later request is refused. The Host never reuses a second: it signs `max(clock, last + 1)`.
11. `429 rate_limited` per realm (writes only): 4 immediately, then one every 10 s.
### Test vectors
Key: the Ed25519 seed is 32 bytes of `0x07`; public key (base64url) `6kpsY-KcUgq-9VB7Ey7F-ZVHdq6-vnuSQh7qaRRG0iw`.
Realm `018f2d9e-5c3a-7b21-8c4d-0e5f6a7b8c9d`.
Heartbeat, body `{"protocol_version":1}` (SHA-256 `00aa4c2c857995eb8e19cb0fade07e4b49aac774e37a99c37a0c9f549204d9de`), timestamp `1790000000`:
```
coa-registry-sig-v1
1
POST
/registry/v1/realms/018f2d9e-5c3a-7b21-8c4d-0e5f6a7b8c9d/heartbeat
018f2d9e-5c3a-7b21-8c4d-0e5f6a7b8c9d
1790000000
00aa4c2c857995eb8e19cb0fade07e4b49aac774e37a99c37a0c9f549204d9de
```
signature `1ys2C7RVjhEtTl0bVrdowpuPELwOnhuiYZKp88dAWdcA3cezP9QuI903OJi2N2mFA9kifblD_fvdT2B_8F5_Dw`.
Authenticated read, `GET /registry/v1/realms/018f2d9e-5c3a-7b21-8c4d-0e5f6a7b8c9d`, empty body (SHA-256 `e3b0c442…b855`), timestamp `1790000030`:
signature `38nHMS6yvl8yrTbLmG1w5LiLVkvTaiRdQPrgT2GSvtR8jW_W0z50IRxD_kInBYP5Yl1Qwwm-r6BVDdTR5ts-BQ`.
Both are checked by `sign::tests` and independently by `tools/registry-vector-check.py` (RFC 8032 reference algorithm in plain Python; it prints the public key and the heartbeat signature).
## 4. Endpoints
| | |
|---|---|
| `GET /registry/v1/healthz` | unauthenticated: `{"status":"ok","protocol_version":1}`, `503 degraded` when the database is unreachable |
| `POST /registry/v1/realms/register` | create the realm's record, or announce the same key again; publishes |
| `POST /registry/v1/realms/{realm_id}/heartbeat` | presence, player count, and metadata that changed |
| `POST /registry/v1/realms/{realm_id}/unpublish` | stop publishing (the record stays) |
| `GET /registry/v1/realms/{realm_id}` | the authenticated self read (integration tests, support); **not** a public list |
Anything else is `404`. There is no realm list, no search and no admin endpoint.
Errors are `{"error":{"code":"<code>","message":"…"}}` with the codes above plus `unknown_realm` (404), `not_published` (409), `unavailable` (503, storage), `internal` (500).
Codes a repeat cannot fix are *permanent* (`malformed_request`, `unsupported_protocol_version`, `request_too_large`, `invalid_metadata`, `invalid_realm_id`, `invalid_signature`,
`realm_key_mismatch`): the Host stops instead of retrying.
### Register
```json
{ "protocol_version": 1, "realm_id": "…", "public_key": "<base64url, 43 chars>",
"display_name": "…", "description": "…", "language": "en", "ruleset": "coa",
"manager_version": "0.6.6",
"capabilities": { …RealmCapabilities profile version 2… },
"capabilities_hash": "<64 hex>", "player_count": 3, "player_capacity": 100 }
```
Response: `created`, `metadata_revision`, `published`, `capabilities_hash`, `server_time`, `heartbeat_interval_secs` (30), `online_ttl_secs` (120).
Registering a known id with the same key is idempotent and is how a restarted Manager (or a republication after `unpublish`) announces itself.
### Heartbeat
`capabilities_hash` is always sent; `capabilities` only when it differs from the hash the Registry last acknowledged. Optional: `manager_version`, `player_count`,
`player_capacity` (absent = unknown), `display_name`/`description`/`language` (absent = unchanged). If the hash differs and the capabilities were not sent, the
answer carries `resend_capabilities: true` and nothing is applied; the Host sends them with the next heartbeat. An unchanged heartbeat writes only
`last_seen_at`, the player numbers and the replay counter; the capabilities JSON is rewritten only when its hash changed. A heartbeat of an unpublished realm is
`409 not_published`; the Host registers again to publish.
### Metadata revision
`metadata_revision` starts at 1 and grows by one whenever the display name, description, language, ruleset, Manager version, capabilities, or the published state
changes; presence and player counts do not move it. The Registry returns it in every answer so that an update is explicit.
## 5. Limits
| What | Limit |
|---|---|
| request body | 65536 bytes |
| display name | 1..80 characters, no control characters, no direction overrides (U+202A–202E, U+2066–2069, U+200E/F), no surrounding space |
| description | 1024 bytes (UTF-8); control characters other than `\n` refused; plain text, never HTML |
| language | a short tag (`en`, `pt-BR`), ≤ 16 bytes |
| Manager version | ≤ 32 bytes of `[A-Za-z0-9.+_-]` |
| player count / capacity | ≤ 100000, count ≤ capacity |
| capabilities | profile version 2 only; field by field (≤ 64 extensions, ≤ 16 collection kinds, ≤ 8 client tables, tokens ≤ 64 characters, hashes 64 hex), unknown fields refused, `content_profile_hash` must equal the hash of the content, `ruleset` must equal the capabilities' ruleset |
| `capabilities_hash` | must equal `advert_hash`: SHA-256 of `coa-registry-advert-v1\0` ‖ content hash ‖ the progression numbers and signature (so a level-cap change moves it) |
The advertised type mirrors `RealmCapabilities` field for field (same names, same order: the content hash is taken over the JSON); `coa-core` tests that a real
profile parses into it and keeps its hash, so the two cannot drift apart unnoticed. Consumers must render names and descriptions as plain text.
## 6. Presence and abuse controls
* Heartbeat every ≈30 s (±10 % jitter); online = published and heard of within **120 s**. Missing heartbeats make a realm offline; they never delete it.
* A Host sends heartbeats only while its realm is running; a stopped realm goes offline after the TTL.
* Per source address: 120 requests/minute (burst 120); registration: burst 10, 10 per hour (production defaults; the staging gate ran with 100). A forged request costs 5.
The address is the last entry of `X-Forwarded-For`, which Caddy sets to the client address it saw; the Registry publishes no port, so only Caddy can reach it.
* Per realm: heartbeat and unpublish burst 4, one more every 10 s (applied only after the signature was verified, so forged traffic cannot throttle a real realm).
* 64 requests in flight, 256 open connections, 10 s request timeout, database pool of 8, `statement_timeout` 5 s, `lock_timeout` 3 s, a 70 KB body cap at Caddy as well.
## 7. Storage
PostgreSQL, forward-only migrations (`migrations/NNNN_name.sql`, recorded in `schema_migrations`; a database that is ahead of the binary is refused). One table:
`realms` (realm id, public key, created/updated/last-seen, published, display name, description, language, ruleset, Manager version, capabilities JSONB and hash,
metadata revision, last request timestamp, player count and capacity). There is no heartbeat history and no table about players, characters, accounts or relays.
## 8. The Host side
`crates/coa-core/src/realm_registry`:
* **Identity**: created the first time publishing is enabled for a local realm. The key is behind `KeyStore`; the implementation (`FileKeyStore`) keeps
`<data>/registry/keys/<realm id>.key` (base64url of the 32-byte seed), created with its permissions restricted *before* the secret is written: mode 0600 on Unix, and
on Windows the inherited permissions removed and the current user granted full control (`icacls`). **Limitation:** this is a file; it is weaker than DPAPI or the Credential Manager, which
the project does not use yet, and anybody who can read the user's files can read the key. It is never written to the realm descriptor, `registry.json`, a log, the diagnostics or the status shown to the interface. If the key
is missing, publication stops with a message; publishing again makes a *new* identity (it never guesses the old key).
* **Settings**: `<data>/registry/registry.json` (the Registry address, and per local realm: enabled, realm id, name, description, language).
* **Lifecycle** (`RegistryHost`, on its own thread, `RegistryRuntime`): publish → ensure identity → register → heartbeat every ~30 s; changed capabilities or metadata ride the next heartbeat;
a Manager restart resumes with the same id and key (registering again as itself); unpublish is retried in the background until delivered or refused. The loop only *reads* what a realm
advertises (probing is done on a separate thread and cached), so a Registry that is down, slow or hostile cannot touch the worldserver or any portable session; nothing here writes portable state.
* **Retry**: transient failures (network, timeout, 5xx, rate limit) back off 5 s → 300 s with ±25 % jitter. `unknown_realm`/`not_published` register again. Clock refusals retry with backoff up to 20 times.
Permanent refusals stop the realm's publication (`rejected`) until the owner presses *Try again* or republishes.
* **Interface**: *Settings → Public listing* of a server (address of the Registry, name, description, language, *Publish*/*Stop publishing*, status).
## 9. Not in this phase
Public realm browser, JOIN, remote account provisioning, portable-character network transport, Relay, direct-connect optimisation, Coordinator, player accounts on the node.
HTTPS is mandatory before Phase 11 is treated as release-ready (the staging node serves plain HTTP: requests are signed and carry no secret).
+5
View File
@@ -98,6 +98,11 @@ published as `127.0.0.1:<port>:<port>`.
- RAM: ~0.5 GiB used of 3.7 GiB (3.2 GiB available).
- Disk: 2.9 GB of 38 GB used.
## Phase 10 addendum
PostgreSQL 17 and the Registry now run (containers `coa-postgres-postgres-1`, `coa-registry-registry-1`); Caddy proxies `/registry/*` to the Registry. Public ports are unchanged (22, 80; 443 is
allowed and published but has no listener until TLS). Details and the gate: [REGISTRY_DEPLOYMENT.md](REGISTRY_DEPLOYMENT.md).
## Open items
- Fill in region, plan/service ID, network limit from the OVH Control Panel.
File diff suppressed because it is too large. Load diff
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "coa-registry-e2e"
description = "End-to-end tests: the Host Manager's publishing lifecycle (coa-core) against the real Registry service code. Test-only; separate from the service so that its image build never needs coa-core."
version = "0.1.0"
edition = "2021"
publish = false
[workspace]
[dependencies]
[dev-dependencies]
coa-core = { path = "../../crates/coa-core" }
coa-registry = { path = "../coa-registry" }
coa-registry-proto = { path = "../../crates/coa-registry-proto" }
axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "net", "sync"] }
ed25519-dalek = "2"
serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json"] }
tempfile = "3"
+301
View File
@@ -0,0 +1,301 @@
//! The Host Manager's publishing lifecycle (coa-core) against the real Registry code over HTTP, on virtual time.
#[path = "../../coa-registry/tests/common/mod.rs"]
mod common;
use std::sync::atomic::Ordering;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use coa_core::realm_registry::host::{Clock, PublishState, RegistryHost, Timing};
use coa_core::realm_registry::{AdvertSource, FileKeyStore, KeyStore, LocalAdvert};
use coa_registry::api::ApiConfig;
use coa_registry::limits::Rate;
use coa_registry::store::MemoryStore;
use common::*;
struct Source(Mutex<LocalAdvert>);
impl Source {
fn new(level: u32) -> Arc<Self> {
Arc::new(Self(Mutex::new(LocalAdvert { running: true, capabilities: Some(caps(level)), player_count: Some(3), player_capacity: None, manager_version: "0.6.6".into() })))
}
fn set(&self, f: impl FnOnce(&mut LocalAdvert)) {
f(&mut self.0.lock().unwrap());
}
}
impl AdvertSource for Source {
fn current(&self, _local_id: &str) -> Option<LocalAdvert> {
Some(self.0.lock().unwrap().clone())
}
}
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() }
}
struct Rig {
server: TestServer,
store: Arc<MemoryStore>,
dir: tempfile::TempDir,
source: Arc<Source>,
t0: Instant,
elapsed: Duration,
}
impl Rig {
fn new() -> Self {
let store = Arc::new(MemoryStore::new());
let server = spawn(store.clone(), lax());
Self { server, store, dir: tempfile::tempdir().unwrap(), source: Source::new(60), t0: Instant::now(), elapsed: Duration::ZERO }
}
fn clock(&self) -> Clock {
let now = self.server.now.clone();
Arc::new(move || now.load(Ordering::SeqCst))
}
fn keys(&self) -> Arc<dyn KeyStore> {
Arc::new(FileKeyStore::new(self.dir.path().join("keys")))
}
/// A Manager start: everything is read back from the folder.
fn open(&self) -> RegistryHost {
let mut host = RegistryHost::open(self.dir.path(), self.keys(), self.source.clone(), self.clock(), Timing::default(), self.t0 + self.elapsed).unwrap();
host.set_url(Some(&self.server.base), self.t0 + self.elapsed).unwrap();
host
}
/// Let `secs` pass for the Registry's clock and for the Host's timers, then run one pass.
fn pass(&mut self, host: &mut RegistryHost, secs: u64) {
self.server.advance(secs as i64);
self.elapsed += Duration::from_secs(secs);
host.tick(self.t0 + self.elapsed);
}
fn now(&self) -> Instant {
self.t0 + self.elapsed
}
}
fn state(host: &RegistryHost, rig: &Rig) -> PublishState {
host.status(rig.now()).realms[0].state
}
#[test]
fn a_fresh_realm_makes_an_identity_registers_and_heartbeats() {
let mut rig = Rig::new();
let mut host = rig.open();
host.publish("srv-1", "Friends' realm", "Plain text.", "en", rig.now()).unwrap();
let realm = host.realm_id("srv-1").expect("an identity was made");
assert!(rig.dir.path().join("keys").join(format!("{realm}.key")).is_file());
rig.pass(&mut host, 0);
assert_eq!(state(&host, &rig), PublishState::Online);
let rec = host.registry_record("srv-1").unwrap();
assert_eq!((rec.realm_id, rec.display_name.as_str(), rec.published, rec.online, rec.metadata_revision), (realm, "Friends' realm", true, true, 1));
assert_eq!(Some(rec.public_key.clone()), host.public_key("srv-1"));
assert_eq!(rec.player_count, Some(3));
let before = rec.last_seen_at;
rig.pass(&mut host, 35);
let rec = host.registry_record("srv-1").unwrap();
assert!(rec.last_seen_at > before, "the heartbeat moved last_seen");
assert_eq!(rec.metadata_revision, 1, "an unchanged realm is not a metadata update");
// nothing secret in anything the Host writes or shows
let key_text = std::fs::read_to_string(rig.dir.path().join("keys").join(format!("{realm}.key"))).unwrap();
let status = serde_json::to_string(&host.status(rig.now())).unwrap();
let settings = std::fs::read_to_string(rig.dir.path().join("registry.json")).unwrap();
for text in [&status, &settings] {
assert!(!text.contains(key_text.trim()), "the private key is only in its own file");
}
}
#[test]
fn a_manager_restart_keeps_the_realm_id_and_the_key() {
let mut rig = Rig::new();
let (realm, key) = {
let mut host = rig.open();
host.publish("srv-1", "Restarting", "", "en", rig.now()).unwrap();
rig.pass(&mut host, 0);
assert_eq!(state(&host, &rig), PublishState::Online);
(host.realm_id("srv-1").unwrap(), host.public_key("srv-1").unwrap())
};
rig.server.advance(5);
let mut host = rig.open();
assert_eq!((host.realm_id("srv-1"), host.public_key("srv-1")), (Some(realm), Some(key.clone())), "the same identity after the restart");
rig.pass(&mut host, 0);
assert_eq!(state(&host, &rig), PublishState::Online, "it resumes by itself");
let rec = host.registry_record("srv-1").unwrap();
assert_eq!((rec.realm_id, rec.public_key), (realm, key));
assert_eq!(rec.metadata_revision, 1, "registering again as itself changed nothing");
assert_eq!(rig.dir.path().join("keys").read_dir().unwrap().count(), 1, "no second key was made");
}
#[test]
fn changed_capabilities_reach_the_registry_with_the_next_heartbeat() {
let mut rig = Rig::new();
let mut host = rig.open();
host.publish("srv-1", "Caps", "", "en", rig.now()).unwrap();
rig.pass(&mut host, 0);
rig.source.set(|a| a.capabilities = Some(caps(70)));
rig.pass(&mut host, 35);
let rec = host.registry_record("srv-1").unwrap();
assert_eq!((rec.capabilities.progression.unwrap().max_player_level, rec.metadata_revision), (70, 2));
rig.pass(&mut host, 35);
assert_eq!(host.registry_record("srv-1").unwrap().metadata_revision, 2, "and then nothing is rewritten");
// a rename
host.publish("srv-1", "Renamed", "", "en", rig.now()).unwrap();
rig.pass(&mut host, 0);
rig.pass(&mut host, 0);
let rec = host.registry_record("srv-1").unwrap();
assert_eq!(rec.display_name, "Renamed");
}
#[test]
fn missing_heartbeats_make_the_realm_offline_and_it_comes_back() {
let mut rig = Rig::new();
let mut host = rig.open();
host.publish("srv-1", "Gone and back", "", "en", rig.now()).unwrap();
rig.pass(&mut host, 0);
// the Manager is closed: nothing is sent
rig.server.advance(125);
let rec = host.registry_record("srv-1").unwrap();
assert_eq!((rec.online, rec.published), (false, true), "offline, not deleted");
drop(host);
let mut host = rig.open();
rig.pass(&mut host, 0);
assert!(host.registry_record("srv-1").unwrap().online, "the restarted Manager brings it back");
}
#[test]
fn a_stopped_realm_sends_no_heartbeats_and_goes_offline() {
let mut rig = Rig::new();
let mut host = rig.open();
host.publish("srv-1", "Stoppable", "", "en", rig.now()).unwrap();
rig.pass(&mut host, 0);
rig.source.set(|a| a.running = false);
for _ in 0..5 {
rig.pass(&mut host, 35);
}
assert_eq!(state(&host, &rig), PublishState::RealmStopped);
assert!(!host.registry_record("srv-1").unwrap().online, "no worldserver, no presence");
rig.source.set(|a| a.running = true);
rig.pass(&mut host, 35);
assert!(host.registry_record("srv-1").unwrap().online);
}
#[test]
fn unpublishing_stops_the_publication_and_the_identity_is_kept() {
let mut rig = Rig::new();
let mut host = rig.open();
host.publish("srv-1", "Brief", "", "en", rig.now()).unwrap();
rig.pass(&mut host, 0);
let realm = host.realm_id("srv-1").unwrap();
host.unpublish("srv-1", rig.now()).unwrap();
rig.pass(&mut host, 0);
assert_eq!(state(&host, &rig), PublishState::Disabled);
assert!(!host.registry_record("srv-1").unwrap().published);
rig.pass(&mut host, 60);
assert!(!host.registry_record("srv-1").unwrap().published, "no heartbeat republishes it");
host.publish("srv-1", "Brief", "", "en", rig.now()).unwrap();
rig.pass(&mut host, 0);
let rec = host.registry_record("srv-1").unwrap();
assert_eq!((rec.realm_id, rec.published, rec.online), (realm, true, true), "the same realm id, published again");
}
#[test]
fn a_registry_that_is_down_is_retried_with_a_growing_delay_and_nothing_else_happens() {
let mut rig = Rig::new();
let mut host = RegistryHost::open(rig.dir.path(), rig.keys(), rig.source.clone(), rig.clock(), Timing::default(), rig.now()).unwrap();
host.set_url(Some("http://127.0.0.1:9"), rig.now()).unwrap();
host.publish("srv-1", "Offline registry", "", "en", rig.now()).unwrap();
let mut waits = Vec::new();
for _ in 0..6 {
host.tick(rig.now());
let s = host.status(rig.now());
assert_eq!(s.realms[0].state, PublishState::Retrying);
assert!(s.realms[0].last_error.as_deref().unwrap_or("").contains("could not be reached"));
let wait = s.realms[0].retry_in_secs.unwrap();
waits.push(wait);
rig.elapsed += Duration::from_secs(wait + 1);
}
assert!(waits.windows(2).filter(|w| w[1] >= w[0]).count() >= 4, "the delay grows: {waits:?}");
assert!(waits.iter().all(|w| *w <= 300 * 5 / 4 + 1), "and is capped: {waits:?}");
// the Registry comes back: the same Host recovers by itself
host.set_url(Some(&rig.server.base), rig.now()).unwrap();
rig.pass(&mut host, 0);
assert_eq!(state(&host, &rig), PublishState::Online);
}
#[test]
fn a_refusal_that_repeating_cannot_fix_stops_the_publication() {
let mut rig = Rig::new();
let mut host = rig.open();
host.publish("srv-1", "Contested", "", "en", rig.now()).unwrap();
// somebody else got to this realm id first with another key
let mut squatter = Host::new(&rig.server, 99);
squatter.realm = host.realm_id("srv-1").unwrap();
assert_eq!(squatter.register("Squatter", 60).status, 200);
rig.pass(&mut host, 0);
let s = host.status(rig.now());
assert_eq!(s.realms[0].state, PublishState::Rejected);
assert!(s.realms[0].retry_in_secs.is_none(), "no further attempt is scheduled");
assert!(s.realms[0].last_error.as_deref().unwrap_or("").contains("RealmKeyMismatch"));
let seen = rig.store.get_for_test(&squatter.realm);
rig.pass(&mut host, 600);
rig.pass(&mut host, 600);
assert_eq!(state(&host, &rig), PublishState::Rejected, "still stopped");
assert_eq!(rig.store.get_for_test(&squatter.realm), seen, "and nothing was sent in the meantime");
}
#[test]
fn a_registry_that_lost_its_data_gets_the_realm_again() {
let mut rig = Rig::new();
let mut host = rig.open();
host.publish("srv-1", "Amnesia", "", "en", rig.now()).unwrap();
rig.pass(&mut host, 0);
rig.store.forget_all();
rig.pass(&mut host, 35);
assert_eq!(state(&host, &rig), PublishState::Retrying, "the heartbeat of an unknown realm is refused");
rig.pass(&mut host, 2);
assert_eq!(state(&host, &rig), PublishState::Online, "and it registers again as itself");
assert!(host.registry_record("srv-1").unwrap().online);
}
#[test]
fn the_host_never_publishes_what_is_not_discovery_metadata() {
let mut rig = Rig::new();
let mut host = rig.open();
host.publish("srv-1", "Plain", "", "en", rig.now()).unwrap();
rig.pass(&mut host, 0);
let rec = host.registry_record("srv-1").unwrap();
let json = serde_json::to_string(&rec).unwrap().to_lowercase();
for word in ["password", "credential", "secret", "snapshot", "inventory", "canonical", "character_id", "ra_user", "mysql", "token"] {
assert!(!json.contains(word), "the Registry's record mentions {word}: {json}");
}
assert!(host.publish("srv-1", &"x".repeat(81), "", "en", rig.now()).is_err());
assert!(host.publish("srv-1", "ok", "", "not a language", rig.now()).is_err());
}
#[test]
fn a_realm_whose_key_is_gone_is_stopped_and_publishing_again_makes_a_new_identity() {
let mut rig = Rig::new();
let mut host = rig.open();
host.publish("srv-1", "Lost key", "", "en", rig.now()).unwrap();
rig.pass(&mut host, 0);
let old = host.realm_id("srv-1").unwrap();
drop(host);
std::fs::remove_file(rig.dir.path().join("keys").join(format!("{old}.key"))).unwrap();
let mut host = rig.open();
rig.pass(&mut host, 0);
let s = host.status(rig.now());
assert_eq!(s.realms[0].state, PublishState::Rejected);
assert!(s.realms[0].last_error.as_deref().unwrap_or("").contains("key is missing"), "{:?}", s.realms[0].last_error);
host.publish("srv-1", "Lost key", "", "en", rig.now()).unwrap();
rig.pass(&mut host, 0);
assert_eq!(state(&host, &rig), PublishState::Online);
assert_ne!(host.realm_id("srv-1").unwrap(), old, "a new identity, not the old id with a guessed key");
}
+239
View File
@@ -0,0 +1,239 @@
//! The Phase-10 gate against a real node (`REGISTRY_URL=http://<VPS_IP>`), through the public path: Caddy, the Registry container, PostgreSQL.
//! `#[ignore]`d. The infrastructure items (container restarts, port scan, database contents) are driven by `tools/registry_gate.ps1` / `.sh`.
#[path = "../../coa-registry/tests/common/mod.rs"]
mod common;
use std::sync::Arc;
use std::time::{Duration, Instant};
use coa_core::realm_registry::host::{system_clock, PublishState, RegistryHost, Timing};
use coa_core::realm_registry::{AdvertSource, FileKeyStore, KeyStore, LocalAdvert};
use coa_registry_proto::sign::sign_request;
use coa_registry_proto::*;
use common::*;
use serde_json::json;
fn base() -> Option<String> {
std::env::var("REGISTRY_URL").ok().filter(|u| !u.is_empty())
}
struct Fixed(LocalAdvert);
impl AdvertSource for Fixed {
fn current(&self, _: &str) -> Option<LocalAdvert> {
Some(self.0.clone())
}
}
fn pass(n: u32, what: &str) {
println!("GATE {n:>2} PASS {what}");
}
#[test]
#[ignore]
fn gate_1_to_12_against_the_node() {
let Some(base) = base() else { return };
// 1. a fresh realm generates its identity and registers (through the Host Manager code)
let dir = tempfile::tempdir().unwrap();
let keys: Arc<dyn KeyStore> = Arc::new(FileKeyStore::new(dir.path().join("keys")));
let source = Arc::new(Fixed(LocalAdvert { running: true, capabilities: Some(caps(60)), player_count: Some(2), player_capacity: None, manager_version: "0.6.6".into() }));
let t0 = Instant::now();
let mut elapsed = Duration::ZERO;
let mut host = RegistryHost::open(dir.path(), keys.clone(), source.clone(), system_clock(), Timing::default(), t0).unwrap();
host.set_url(Some(&base), t0).unwrap();
host.publish("srv-gate", "Gate realm", "Phase 10 gate", "en", t0).unwrap();
host.tick(t0);
assert_eq!(host.status(t0).realms[0].state, PublishState::Online, "{:?}", host.status(t0));
let realm = host.realm_id("srv-gate").unwrap();
let key = host.public_key("srv-gate").unwrap();
let rec = host.registry_record("srv-gate").unwrap();
assert_eq!((rec.realm_id, rec.published, rec.online, rec.metadata_revision), (realm, true, true, 1));
pass(1, &format!("fresh realm {realm} generated an identity and registered"));
// 3. a Host Manager restart keeps the same RealmId and key
drop(host);
let mut host = RegistryHost::open(dir.path(), keys.clone(), source.clone(), system_clock(), Timing::default(), t0).unwrap();
host.set_url(Some(&base), t0).unwrap();
assert_eq!((host.realm_id("srv-gate"), host.public_key("srv-gate")), (Some(realm), Some(key.clone())));
std::thread::sleep(Duration::from_millis(1200));
host.tick(t0);
assert_eq!(host.status(t0).realms[0].state, PublishState::Online);
let rec2 = host.registry_record("srv-gate").unwrap();
assert_eq!((rec2.realm_id, rec2.public_key.clone(), rec2.metadata_revision), (realm, key, 1));
pass(3, "after a Manager restart: same RealmId, same key, registered again as itself, revision unchanged");
// 4. a valid heartbeat updates last_seen
std::thread::sleep(Duration::from_millis(2100));
elapsed += Duration::from_secs(31);
host.tick(t0 + elapsed + Duration::from_secs(10));
let rec3 = host.registry_record("srv-gate").unwrap();
assert!(rec3.last_seen_at > rec2.last_seen_at, "{} -> {}", rec2.last_seen_at, rec3.last_seen_at);
pass(4, &format!("heartbeat moved last_seen_at {} -> {}", rec2.last_seen_at, rec3.last_seen_at));
// 6. a capability change updates the record with the next heartbeat
let source2 = Arc::new(Fixed(LocalAdvert { running: true, capabilities: Some(caps(70)), player_count: Some(3), player_capacity: None, manager_version: "0.6.6".into() }));
drop(host);
let mut host = RegistryHost::open(dir.path(), keys.clone(), source2, system_clock(), Timing::default(), t0).unwrap();
host.set_url(Some(&base), t0).unwrap();
std::thread::sleep(Duration::from_millis(1200));
host.tick(t0);
std::thread::sleep(Duration::from_millis(1200));
elapsed += Duration::from_secs(31);
host.tick(t0 + elapsed + Duration::from_secs(40));
let rec4 = host.registry_record("srv-gate").unwrap();
assert_eq!(rec4.capabilities.progression.as_ref().unwrap().max_player_level, 70, "{rec4:?}");
assert!(rec4.metadata_revision > rec3.metadata_revision);
pass(6, &format!("capability change (cap 60 -> 70) applied; metadata revision {} -> {}", rec3.metadata_revision, rec4.metadata_revision));
// 12. unpublish stops publication (the Manager's path)
host.unpublish("srv-gate", t0 + elapsed).unwrap();
host.tick(t0 + elapsed);
let rec5 = host.registry_record("srv-gate").unwrap();
assert!(!rec5.published && !rec5.online);
pass(12, "unpublish: published=false, online=false");
// 7-11 on a separate realm with the raw protocol
let mut h = Host::remote(&base);
assert_eq!(h.register("Raw realm", 60).status, 200);
let mut thief = Host::remote(&base);
thief.realm = h.realm;
let r = thief.register("Stolen", 60);
assert_eq!((r.status, r.code().as_str()), (403, "realm_key_mismatch"));
let r = thief.heartbeat(60, true);
assert_eq!((r.status, r.code().as_str()), (401, "invalid_signature"));
assert_eq!(h.me().body["display_name"], "Raw realm");
pass(7, "same RealmId signed by another key: register 403 realm_key_mismatch, heartbeat 401 invalid_signature, record untouched");
h.sync_clock();
let path = path_heartbeat(&h.realm);
let body = serde_json::to_vec(&h.heartbeat_body(60, false)).unwrap();
h.ts += 1;
let headers = sign_request(&h.key, "POST", &path, &h.realm, h.ts, &body);
let mut tampered = body.clone();
tampered.push(b' ');
let r = h.send_with("POST", &path, &tampered, &headers.pairs());
assert_eq!((r.status, r.code().as_str()), (401, "invalid_signature"));
pass(8, "body tampered after signing: 401 invalid_signature");
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
for skew in [-3600, 3600] {
h.ts = now + skew;
let r = h.send("POST", &path, &body);
assert_eq!((r.status, r.code().as_str()), (401, "bad_timestamp"), "skew {skew}");
}
h.ts = now;
h.sync_clock();
let ok = h.send("POST", &path, &body);
assert_eq!(ok.status, 200, "{:?}", ok.body);
h.ts -= 5;
let r = h.send("POST", &path, &body);
assert_eq!((r.status, r.code().as_str()), (401, "timestamp_not_monotonic"));
pass(9, "old (-1h) and future (+1h) timestamps: 401 bad_timestamp; a non-increasing one: 401 timestamp_not_monotonic");
h.sync_clock();
let mut hv = sign_request(&h.key, "POST", &path, &h.realm, h.ts + 1, &body).pairs();
hv[0] = (sign_header_version(), "2".into());
let r = h.send_with("POST", &path, &body, &hv);
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"));
pass(10, "protocol version 2 (header and body): 400 unsupported_protocol_version");
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"));
let mut hostile = Host::remote(&base);
let base_body = serde_json::to_value(hostile.register_body("Hostile", 60)).unwrap();
for (field, value) in [("display_name", json!("x".repeat(500))), ("description", json!("d".repeat(5000))), ("language", json!("<script>alert(1)</script>")), ("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<u8> = (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}");
}
+1918
View File
File diff suppressed because it is too large. Load diff
+35
View File
@@ -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
+15
View File
@@ -0,0 +1,15 @@
# Build context: the repository root. docker build -f services/coa-registry/Dockerfile -t coa-registry:<tag> .
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"]
+25
View File
@@ -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
}
+4
View File
@@ -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"
+14
View File
@@ -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;
+13
View File
@@ -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@<VPS_IP> 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=<image 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}}'"
@@ -0,0 +1,39 @@
#!/bin/sh
# Phase-10 gate items 2 and 14, driven from a workstation: VPS=ubuntu@<VPS_IP> REGISTRY_URL=http://<VPS_IP> 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}}'"
@@ -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
@@ -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' <<SQL
DO \$\$ BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'coa_registry') THEN
CREATE ROLE coa_registry LOGIN PASSWORD '$PW' NOSUPERUSER NOCREATEDB NOCREATEROLE CONNECTION LIMIT 20;
ELSE
ALTER ROLE coa_registry PASSWORD '$PW';
END IF;
END \$\$;
SELECT 'CREATE DATABASE coa_registry OWNER coa_registry' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'coa_registry')\gexec
REVOKE ALL ON DATABASE coa_registry FROM PUBLIC;
GRANT CONNECT ON DATABASE coa_registry TO coa_registry;
SQL
echo "registry role and database are in place"
@@ -0,0 +1,45 @@
# /opt/coa/registry/compose.yaml (Phase 10). The Registry publishes no host port: Caddy reaches it as coa-registry:8080 on coa-ingress,
# and it reaches PostgreSQL as coa-postgres on coa-db. The image is built on the node from the repository (see docs/REGISTRY_DEPLOYMENT.md).
name: coa-registry
services:
registry:
image: coa-registry:${REGISTRY_TAG:-latest}
restart: unless-stopped
environment:
REGISTRY_LISTEN: "0.0.0.0:8080"
REGISTRY_TRUST_PROXY: "1"
REGISTRY_DB_HOST: coa-postgres
REGISTRY_DB_NAME: coa_registry
REGISTRY_DB_USER: coa_registry
REGISTRY_DB_PASSWORD_FILE: /run/secrets/registry_db_password
REGISTRY_DB_POOL: "8"
REGISTRY_DB_STATEMENT_TIMEOUT_MS: "5000"
REGISTRY_MAX_CONNECTIONS: "256"
REGISTRY_MAX_IN_FLIGHT: "64"
REGISTRY_REQUEST_TIMEOUT_SECS: "10"
# staging: enough registrations from one address for the Phase-10 gate; the built-in default is 10 per hour
REGISTRY_REGISTER_BURST: "${REGISTRY_REGISTER_BURST:-10}"
REGISTRY_REGISTER_PER_HOUR: "${REGISTRY_REGISTER_PER_HOUR:-10}"
RUST_LOG: "info"
secrets:
- registry_db_password
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
mem_limit: 256m
pids_limit: 128
networks:
coa-ingress:
aliases:
- coa-registry
coa-db: {}
secrets:
registry_db_password:
file: /opt/coa/secrets/registry_db_password
networks:
coa-ingress:
external: true
coa-db:
external: true
@@ -0,0 +1,23 @@
-- Registry schema, migration 1. Forward-only: a released migration is never edited.
-- Discovery and presence data only. There is deliberately no table for players, characters, accounts, sessions or credentials.
CREATE TABLE realms (
realm_id uuid PRIMARY KEY,
public_key bytea NOT NULL CHECK (octet_length(public_key) = 32),
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
last_seen_at timestamptz NOT NULL,
published boolean NOT NULL,
display_name text NOT NULL CHECK (char_length(display_name) BETWEEN 1 AND 80),
description text NOT NULL CHECK (octet_length(description) <= 1024),
language text NOT NULL CHECK (char_length(language) BETWEEN 2 AND 16),
ruleset text NOT NULL CHECK (ruleset IN ('coa', 'wildcard')),
manager_version text NOT NULL CHECK (char_length(manager_version) BETWEEN 1 AND 32),
capabilities jsonb NOT NULL CHECK (octet_length(capabilities::text) <= 65536),
capabilities_hash text NOT NULL CHECK (capabilities_hash ~ '^[0-9a-f]{64}$'),
metadata_revision bigint NOT NULL CHECK (metadata_revision >= 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);
+380
View File
@@ -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<dyn Fn() -> 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<S: Store> {
pub store: S,
pub clock: Clock,
pub cfg: ApiConfig,
ip_general: Limiter<IpAddr>,
ip_register: Limiter<IpAddr>,
realm_heartbeat: Limiter<RealmId>,
started: Instant,
}
impl<S: Store> AppState<S> {
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<String>) -> Self {
Self { status, code, message: message.into() }
}
fn malformed(message: impl Into<String>) -> 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<StoreError> 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<S: Store>(state: Arc<AppState<S>>) -> Router {
Router::new()
.route("/registry/v1/healthz", get(healthz::<S>))
.route("/registry/v1/realms/register", post(register::<S>))
.route("/registry/v1/realms/{realm_id}/heartbeat", post(heartbeat::<S>))
.route("/registry/v1/realms/{realm_id}/unpublish", post(unpublish::<S>))
.route("/registry/v1/realms/{realm_id}", get(self_info::<S>))
.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::<IpAddr>() {
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<S: Store>(st: &AppState<S>, 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<Vec<u8>, 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<S: Store>(st: &AppState<S>, 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<S: Store>(st: &AppState<S>, 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<S: Store>(st: &AppState<S>, 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<S: Store>(State(st): State<Arc<AppState<S>>>, ConnectInfo(Peer(peer)): ConnectInfo<Peer>, req: Request<Body>) -> 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<T: Serialize>(value: &T) -> (StatusCode, Response) {
(StatusCode::OK, Json(value).into_response())
}
async fn register<S: Store>(State(st): State<Arc<AppState<S>>>, ConnectInfo(Peer(peer)): ConnectInfo<Peer>, req: Request<Body>) -> 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<S: Store>(st: &AppState<S>, 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<S: Store>(State(st): State<Arc<AppState<S>>>, ConnectInfo(Peer(peer)): ConnectInfo<Peer>, Path(realm_text): Path<String>, req: Request<Body>) -> 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<S: Store>(State(st): State<Arc<AppState<S>>>, ConnectInfo(Peer(peer)): ConnectInfo<Peer>, Path(realm_text): Path<String>, req: Request<Body>) -> 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<S: Store>(State(st): State<Arc<AppState<S>>>, ConnectInfo(Peer(peer)): ConnectInfo<Peer>, Path(realm_text): Path<String>, req: Request<Body>) -> 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)
}
+57
View File
@@ -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<T: std::str::FromStr>(name: &str, default: T) -> Result<T, String> {
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<Self, String> {
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)?,
},
})
}
}
+11
View File
@@ -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;
+88
View File
@@ -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<K: Eq + Hash + Clone> {
rate: Rate,
max_keys: usize,
buckets: Mutex<HashMap<K, Bucket>>,
}
impl<K: Eq + Hash + Clone> Limiter<K> {
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<u32> = 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");
}
}
+87
View File
@@ -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<Semaphore>,
}
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<io::Result<()>> {
self.project().stream.poll_read(cx, buf)
}
}
impl AsyncWrite for LimitedIo {
fn poll_write(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll<io::Result<usize>> {
self.project().stream.poll_write(cx, buf)
}
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
self.project().stream.poll_flush(cx)
}
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
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::Addr> {
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<axum::serve::IncomingStream<'_, LimitedListener>> for Peer {
fn connect_info(stream: axum::serve::IncomingStream<'_, LimitedListener>) -> Self {
Peer(*stream.remote_addr())
}
}
impl axum::extract::connect_info::Connected<axum::serve::IncomingStream<'_, TcpListener>> for Peer {
fn connect_info(stream: axum::serve::IncomingStream<'_, TcpListener>) -> Self {
Peer(*stream.remote_addr())
}
}
+100
View File
@@ -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::<coa_registry::listener::Peer>()).with_graceful_shutdown(shutdown());
if let Err(e) = serve.await {
tracing::error!(error = %e, "server");
std::process::exit(1);
}
tracing::info!("registry stopped");
}
+227
View File
@@ -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<Self, String> {
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<Object> {
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<i32> = 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<RealmRow> {
let id: String = r.get(0);
let key: Vec<u8> = 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<i32> = r.get(15);
let capacity: Option<i32> = 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::<AdvertisedCapabilities>(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::Value> {
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<Option<[u8; 32]>> {
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<u8>>(0).try_into().map_err(|_| StoreError::Backend("a stored key is not 32 bytes".into()))).transpose()
}
async fn get(&self, id: &RealmId) -> StoreResult<Option<RealmRow>> {
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<HeartbeatOutcome> {
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<u64> {
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)
}
}
+286
View File
@@ -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<T> = Result<T, StoreError>;
#[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<u32>,
pub player_capacity: Option<u32>,
}
/// 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<AdvertisedCapabilities>,
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<HeartbeatPlan> {
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<Output = bool> + Send;
/// The key a realm id is bound to, if it is registered.
fn key_of(&self, id: &RealmId) -> impl Future<Output = StoreResult<Option<[u8; 32]>>> + Send;
fn get(&self, id: &RealmId) -> impl Future<Output = StoreResult<Option<RealmRow>>> + Send;
/// `Ok((row, created))`
fn register(&self, req: &RegisterRequest, key: [u8; 32], now: i64, ts: i64) -> impl Future<Output = StoreResult<(RealmRow, bool)>> + Send;
fn heartbeat(&self, id: &RealmId, hb: &HeartbeatRequest, now: i64, ts: i64) -> impl Future<Output = StoreResult<HeartbeatOutcome>> + Send;
/// The metadata revision after the change.
fn unpublish(&self, id: &RealmId, now: i64, ts: i64) -> impl Future<Output = StoreResult<u64>> + 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<HashMap<RealmId, RealmRow>>,
}
impl MemoryStore {
pub fn new() -> Self {
Self::default()
}
#[doc(hidden)]
pub fn get_for_test(&self, id: &RealmId) -> Option<RealmRow> {
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<T: Store> Store for std::sync::Arc<T> {
fn ping(&self) -> impl Future<Output = bool> + Send {
(**self).ping()
}
fn key_of(&self, id: &RealmId) -> impl Future<Output = StoreResult<Option<[u8; 32]>>> + Send {
(**self).key_of(id)
}
fn get(&self, id: &RealmId) -> impl Future<Output = StoreResult<Option<RealmRow>>> + Send {
(**self).get(id)
}
fn register(&self, req: &RegisterRequest, key: [u8; 32], now: i64, ts: i64) -> impl Future<Output = StoreResult<(RealmRow, bool)>> + Send {
(**self).register(req, key, now, ts)
}
fn heartbeat(&self, id: &RealmId, hb: &HeartbeatRequest, now: i64, ts: i64) -> impl Future<Output = StoreResult<HeartbeatOutcome>> + Send {
(**self).heartbeat(id, hb, now, ts)
}
fn unpublish(&self, id: &RealmId, now: i64, ts: i64) -> impl Future<Output = StoreResult<u64>> + Send {
(**self).unpublish(id, now, ts)
}
}
impl Store for MemoryStore {
async fn ping(&self) -> bool {
true
}
async fn key_of(&self, id: &RealmId) -> StoreResult<Option<[u8; 32]>> {
Ok(self.rows.lock().unwrap().get(id).map(|r| r.public_key))
}
async fn get(&self, id: &RealmId) -> StoreResult<Option<RealmRow>> {
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<HeartbeatOutcome> {
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<u64> {
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)
}
}
+356
View File
@@ -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!("<script>")),
("manager_version", json!("1.0 <b>")),
("player_count", json!(1_000_000)),
("capabilities_hash", json!("0".repeat(64))),
("public_key", json!("short")),
("ruleset", json!("wildcard")),
];
for (field, value) in cases {
let mut v = base.clone();
v[field] = value;
let r = h.send("POST", PATH_REGISTER, &serde_json::to_vec(&v).unwrap());
assert!(r.status == 400, "{field}: {} {:?}", r.status, r.body);
}
// what the protocol has no place for is not accepted, whatever it is
for extra in ["character", "snapshot", "password", "ra_password", "db_credentials", "canonical_revision"] {
let mut v = base.clone();
v[extra] = json!("secret");
let r = h.send("POST", PATH_REGISTER, &serde_json::to_vec(&v).unwrap());
assert_eq!((r.status, r.code().as_str()), (400, "malformed_request"), "{extra}");
}
let mut v = base.clone();
v["capabilities"]["content"]["client_catalog"]["../../etc/passwd"] = json!({"sha256": "a".repeat(64), "records": 1});
assert_eq!(h.send("POST", PATH_REGISTER, &serde_json::to_vec(&v).unwrap()).status, 400, "capabilities are checked field by field");
let mut v = base.clone();
v["capabilities"]["notes"] = json!("free text to store");
assert_eq!(h.send("POST", PATH_REGISTER, &serde_json::to_vec(&v).unwrap()).status, 400, "no free-form storage in the capabilities");
// ids and paths
let mut other = Host::new(s, 7);
other.ts = h.ts;
let r = h.send("GET", "/registry/v1/realms/not-a-uuid", b"");
assert_eq!(r.status, 400);
let path = format!("/registry/v1/realms/{}", other.realm.to_string().to_uppercase());
assert_eq!(h.send("GET", &path, b"").status, 400, "upper case is not the canonical spelling");
assert_eq!(h.send("GET", &format!("{}?x=1", path_self(&h.realm)), b"").status, 400, "no query on a signed endpoint");
let r = h.send("GET", &path_self(&other.realm), b"");
assert_eq!(r.status, 400, "the realm header names another realm than the path");
let r = h.send("GET", "/registry/v1/realms/018f2d9e-5c3a-4b21-8c4d-0e5f6a7b8c9d", b"");
assert_eq!(r.status, 400, "a version-4 UUID is not a realm id");
// nothing was stored by any of it
let mut clean = Host::new(s, 7);
clean.realm = h.realm;
clean.ts = h.ts + 100;
assert_eq!(clean.me().status, 404, "the hostile registrations left no realm behind");
}
/// 12: an unpublished realm stops being published and cannot heartbeat; registering again publishes it.
fn unpublishing(s: &TestServer) {
let mut h = Host::new(s, 8);
assert_eq!(h.register("Brief", 60).status, 200);
let r = h.unpublish();
assert_eq!((r.status, r.body["published"].as_bool(), r.body["metadata_revision"].as_u64()), (200, Some(false), Some(2)));
let me = h.me();
assert_eq!((me.body["published"].as_bool(), me.body["online"].as_bool()), (Some(false), Some(false)), "stopped, and not online even though it was just heard of");
let hb = h.heartbeat(60, false);
assert_eq!((hb.status, hb.code().as_str()), (409, "not_published"));
let r = h.unpublish();
assert_eq!((r.status, r.body["metadata_revision"].as_u64()), (200, Some(2)), "unpublishing twice changes nothing");
let r = h.register("Brief", 60);
assert_eq!((r.status, r.body["created"].as_bool(), r.body["published"].as_bool(), r.body["metadata_revision"].as_u64()), (200, Some(false), Some(true), Some(3)));
assert_eq!(h.heartbeat(60, false).status, 200);
let unknown = {
let mut u = Host::new(s, 9);
u.ts = s.time();
u.unpublish()
};
assert_eq!((unknown.status, unknown.code().as_str()), (404, "unknown_realm"));
}
fn healthz(s: &TestServer) {
let r = reqwest::blocking::get(format!("{}/registry/v1/healthz", s.base)).unwrap();
assert_eq!(r.status(), 200);
let v: serde_json::Value = r.json().unwrap();
assert_eq!((v["status"].as_str(), v["protocol_version"].as_u64()), (Some("ok"), Some(1)));
assert_eq!(reqwest::blocking::get(format!("{}/registry/v1/realms", s.base)).unwrap().status(), 404, "there is no public list");
assert_eq!(reqwest::blocking::get(format!("{}/admin", s.base)).unwrap().status(), 404, "and no admin surface");
}
fn all(s: &TestServer) {
healthz(s);
lifecycle(s);
presence(s);
metadata_changes(s);
takeover(s);
authentication(s);
hostile_input(s);
unpublishing(s);
}
#[test]
fn the_protocol_on_the_memory_store() {
all(&memory());
}
#[test]
fn a_flood_is_limited() {
let cfg = ApiConfig { register: Rate { burst: 3.0, refill: 0.0 }, heartbeat: Rate { burst: 2.0, refill: 0.0 }, general: Rate { burst: 1000.0, refill: 0.0 }, ..ApiConfig::default() };
let s = spawn(MemoryStore::new(), cfg);
let mut limited = 0;
for i in 0..6 {
let mut h = Host::new(&s, 20 + i);
if h.register("Flood", 60).status == 429 {
limited += 1;
}
}
assert_eq!(limited, 3, "three registrations from one address, then a limit");
let s = spawn(MemoryStore::new(), ApiConfig { heartbeat: Rate { burst: 2.0, refill: 0.0 }, ..ApiConfig::default() });
let mut h = Host::new(&s, 30);
assert_eq!(h.register("Beat", 60).status, 200);
let a = h.heartbeat(60, false).status;
let b = h.heartbeat(60, false).status;
let c = h.heartbeat(60, false);
assert_eq!((a, b, c.status, c.code().as_str()), (200, 200, 429, "rate_limited"), "heartbeats are limited per realm");
let mut other = Host::new(&s, 31);
assert_eq!(other.register("Other", 60).status, 200);
assert_eq!(other.heartbeat(60, false).status, 200, "another realm has its own allowance");
// forged requests cost their sender, and never the realm they pretend to be
let s = spawn(MemoryStore::new(), ApiConfig { general: Rate { burst: 12.0, refill: 0.0 }, heartbeat: Rate { burst: 2.0, refill: 0.0 }, ..ApiConfig::default() });
let mut victim = Host::new(&s, 40);
assert_eq!(victim.register("Victim", 60).status, 200);
let mut forger = Host::new(&s, 41);
forger.realm = victim.realm;
forger.ts = s.time() + 5;
let mut statuses = Vec::new();
for _ in 0..6 {
statuses.push(forger.heartbeat(60, false).status);
}
assert_eq!(&statuses[..2], &[401, 401]);
assert_eq!(*statuses.last().unwrap(), 429, "the forger runs out of allowance: {statuses:?}");
// the victim's own per-realm allowance was never spent by the forgeries (its address allowance may be, which is the forger's address too here)
}
#[test]
fn a_realm_key_is_what_identifies_it_not_its_address_or_name() {
let s = memory();
let mut a = Host::new(&s, 50);
let mut b = Host::new(&s, 51);
assert_eq!(a.register("Same name", 60).status, 200);
assert_eq!(b.register("Same name", 60).status, 200, "names are not identities");
assert_ne!(a.realm, b.realm);
let wrong = SigningKey::from_bytes(&[52; 32]);
let path = path_heartbeat(&a.realm);
let body = serde_json::to_vec(&a.heartbeat_body(60, false)).unwrap();
let h = sign_request(&wrong, "POST", &path, &a.realm, s.time() + 5, &body);
assert_eq!(a.send_with("POST", &path, &body, &h.pairs()).status, 401);
}
#[test]
#[ignore]
fn the_protocol_on_postgresql() {
let Ok(url) = std::env::var("REGISTRY_TEST_PG") else { return };
// host:port:dbname:user:password
let parts: Vec<&str> = url.splitn(5, ':').collect();
let cfg = coa_registry::pg::PgConfig { host: parts[0].into(), port: parts[1].parse().unwrap(), dbname: parts[2].into(), user: parts[3].into(), password: parts[4].into(), pool_size: 4, statement_timeout_ms: 5000 };
let rt = tokio::runtime::Runtime::new().unwrap();
let store = rt.block_on(async {
let s = coa_registry::pg::PgStore::connect(&cfg).await.unwrap();
s.migrate().await.unwrap();
s.migrate().await.unwrap();
s
});
let s = spawn(store, lax());
all(&s);
std::mem::forget(rt);
}
+189
View File
@@ -0,0 +1,189 @@
//! A Registry running in-process on an ephemeral port with a clock the test moves, and a signing client built from the protocol crate.
#![allow(dead_code)]
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::Arc;
use coa_registry::api::{router, ApiConfig, AppState};
use coa_registry::listener::Peer;
use coa_registry::store::Store;
use coa_registry_proto::caps::{AdvertisedCapabilities, CatalogEntry, CharacterFormats, ContentProfile, CoreIdentity, ExtensionSupport, Feature, FormatRange, Progression, Ruleset};
use coa_registry_proto::sign::{encode_public_key, sign_request};
use coa_registry_proto::*;
use ed25519_dalek::SigningKey;
pub struct TestServer {
pub base: String,
pub now: Arc<AtomicI64>,
_stop: tokio::sync::oneshot::Sender<()>,
}
pub const START: i64 = 1_790_000_000;
pub fn spawn<S: Store>(store: S, cfg: ApiConfig) -> TestServer {
let std_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
std_listener.set_nonblocking(true).unwrap();
let addr = std_listener.local_addr().unwrap();
let now = Arc::new(AtomicI64::new(START));
let clock = {
let now = now.clone();
Arc::new(move || now.load(Ordering::SeqCst))
};
let (stop, stopped) = tokio::sync::oneshot::channel::<()>();
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap();
rt.block_on(async move {
let state = Arc::new(AppState::new(store, clock, cfg));
let listener = tokio::net::TcpListener::from_std(std_listener).unwrap();
let app = router(state);
let _ = axum::serve(listener, app.into_make_service_with_connect_info::<Peer>()).with_graceful_shutdown(async move { let _ = stopped.await; }).await;
});
});
TestServer { base: format!("http://{addr}"), now, _stop: stop }
}
impl TestServer {
pub fn set_time(&self, t: i64) {
self.now.store(t, Ordering::SeqCst);
}
pub fn advance(&self, secs: i64) {
self.now.fetch_add(secs, Ordering::SeqCst);
}
pub fn time(&self) -> i64 {
self.now.load(Ordering::SeqCst)
}
}
pub fn caps(max_level: u32) -> 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: max_level, projection_protocol: 1, projection_policy_version: 1, progression_signature: "b".repeat(64), scaling_enabled: false }),
}
}
/// One realm's signing client.
pub struct Host {
pub base: String,
pub key: SigningKey,
pub realm: RealmId,
pub ts: i64,
http: reqwest::blocking::Client,
}
fn uuid_bytes() -> [u8; 16] {
*RealmId::new().as_uuid().as_bytes()
}
pub struct Reply {
pub status: u16,
pub body: serde_json::Value,
}
impl Reply {
pub fn code(&self) -> String {
self.body["error"]["code"].as_str().unwrap_or("").to_string()
}
}
impl Host {
pub fn new(server: &TestServer, seed: u8) -> Self {
Self { base: server.base.clone(), key: SigningKey::from_bytes(&[seed; 32]), realm: RealmId::new(), ts: server.time(), http: reqwest::blocking::Client::builder().timeout(std::time::Duration::from_secs(10)).build().unwrap() }
}
/// A client for a Registry that is not in this process (a real node): a random key, the real clock.
pub fn remote(base: &str) -> Self {
let mut seed = [0u8; 32];
seed[..16].copy_from_slice(uuid_bytes().as_slice());
seed[16..].copy_from_slice(uuid_bytes().as_slice());
let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
Self { base: base.trim_end_matches('/').to_string(), key: SigningKey::from_bytes(&seed), realm: RealmId::new(), ts, http: reqwest::blocking::Client::builder().timeout(std::time::Duration::from_secs(20)).build().unwrap() }
}
/// Bring the timestamp up to the real clock (after a pause); it never goes backwards.
pub fn sync_clock(&mut self) {
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
self.ts = self.ts.max(now);
}
pub fn register_body(&self, name: &str, level: u32) -> RegisterRequest {
let capabilities = caps(level);
RegisterRequest {
protocol_version: 1,
realm_id: self.realm,
public_key: encode_public_key(&self.key.verifying_key()),
display_name: name.into(),
description: "A test realm.".into(),
language: "en".into(),
ruleset: Ruleset::Coa,
manager_version: "0.6.6".into(),
capabilities_hash: capabilities.advert_hash(),
capabilities,
player_count: Some(1),
player_capacity: Some(50),
}
}
pub fn heartbeat_body(&self, level: u32, with_caps: bool) -> HeartbeatRequest {
let capabilities = caps(level);
HeartbeatRequest { protocol_version: 1, capabilities_hash: capabilities.advert_hash(), capabilities: with_caps.then_some(capabilities), manager_version: None, player_count: Some(2), player_capacity: Some(50), display_name: None, description: None, language: None }
}
/// A fully signed request; the timestamp advances by one for every call.
pub fn send(&mut self, method: &str, path: &str, body: &[u8]) -> Reply {
self.ts += 1;
let headers = sign_request(&self.key, method, path, &self.realm, self.ts, body);
self.send_with(method, path, body, &headers.pairs())
}
pub fn send_with(&self, method: &str, path: &str, body: &[u8], headers: &[(&str, String)]) -> Reply {
let url = format!("{}{}", self.base, path);
let mut req = match method {
"GET" => self.http.get(&url),
_ => self.http.post(&url).body(body.to_vec()),
};
for (n, v) in headers {
req = req.header(*n, v);
}
let resp = req.send().expect("the Registry answers");
let status = resp.status().as_u16();
let text = resp.text().unwrap_or_default();
Reply { status, body: serde_json::from_str(&text).unwrap_or(serde_json::Value::Null) }
}
pub fn register(&mut self, name: &str, level: u32) -> Reply {
let body = serde_json::to_vec(&self.register_body(name, level)).unwrap();
self.send("POST", PATH_REGISTER, &body)
}
pub fn heartbeat(&mut self, level: u32, with_caps: bool) -> Reply {
let body = serde_json::to_vec(&self.heartbeat_body(level, with_caps)).unwrap();
let path = path_heartbeat(&self.realm);
self.send("POST", &path, &body)
}
pub fn unpublish(&mut self) -> Reply {
let body = serde_json::to_vec(&UnpublishRequest { protocol_version: 1 }).unwrap();
let path = path_unpublish(&self.realm);
self.send("POST", &path, &body)
}
pub fn me(&mut self) -> Reply {
let path = path_self(&self.realm);
self.send("GET", &path, b"")
}
}
+62 -2
View File
@@ -33,6 +33,8 @@ struct AppState {
client_cancel: Mutex<Option<Cancel>>,
/// The portable play runtime (the Host loop): started with the application, stopped with it.
portable: Option<Arc<PortableRuntime>>,
/// Publishing this Manager's realms to the central Registry (Phase 10): its own loop, independent of Player Mode.
realm_registry: Option<Arc<coa_core::realm_registry::RegistryRuntime>>,
}
/// Starting and stopping the servers this Manager installed, for the portable runtime.
@@ -1650,7 +1652,7 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.manage(AppState { registry: Registry::at(dir.join("installs.json")), busy: Mutex::new(HashSet::new()), install_cancel: Mutex::new(None), client_cancel: Mutex::new(None), portable: start_portable(&dir) })
.manage(AppState { registry: Registry::at(dir.join("installs.json")), busy: Mutex::new(HashSet::new()), install_cancel: Mutex::new(None), client_cancel: Mutex::new(None), portable: start_portable(&dir), realm_registry: start_realm_registry(&dir) })
.invoke_handler(tauri::generate_handler![
default_install_dir,
scan_server,
@@ -1746,7 +1748,12 @@ pub fn run() {
portable_add_realm,
portable_remove_realm,
portable_diagnostics,
portable_launch
portable_launch,
registry_status,
registry_set_url,
registry_publish,
registry_unpublish,
registry_retry
])
.build(tauri::generate_context!())
.expect("error while building CoA Server Manager")
@@ -1756,10 +1763,63 @@ pub fn run() {
if let Some(rt) = app.state::<AppState>().portable.clone() {
rt.shutdown();
}
if let Some(rt) = app.state::<AppState>().realm_registry.clone() {
rt.shutdown();
}
}
});
}
// ---- Public listing (Registry) -----------------------------------------------------------------------------------------------
fn realm_registry(state: &State<'_, AppState>) -> std::result::Result<Arc<coa_core::realm_registry::RegistryRuntime>, UiError> {
state.realm_registry.clone().ok_or_else(|| Error::Invalid("the publishing service did not start".into()).into())
}
#[tauri::command]
fn registry_status(state: State<'_, AppState>) -> coa_core::realm_registry::RegistryStatus {
state.realm_registry.as_ref().map(|r| r.status()).unwrap_or_default()
}
#[tauri::command]
async fn registry_set_url(state: State<'_, AppState>, url: Option<String>) -> std::result::Result<(), UiError> {
let rt = realm_registry(&state)?;
tauri::async_runtime::spawn_blocking(move || rt.set_url(url)).await.map_err(|e| Error::Invalid(e.to_string()))?.map_err(UiError::from)
}
#[tauri::command]
async fn registry_publish(state: State<'_, AppState>, local_id: String, display_name: String, description: String, language: String) -> std::result::Result<(), UiError> {
let rt = realm_registry(&state)?;
tauri::async_runtime::spawn_blocking(move || rt.publish(&local_id, &display_name, &description, &language)).await.map_err(|e| Error::Invalid(e.to_string()))?.map_err(UiError::from)
}
#[tauri::command]
async fn registry_unpublish(state: State<'_, AppState>, local_id: String) -> std::result::Result<(), UiError> {
let rt = realm_registry(&state)?;
tauri::async_runtime::spawn_blocking(move || rt.unpublish(&local_id)).await.map_err(|e| Error::Invalid(e.to_string()))?.map_err(UiError::from)
}
#[tauri::command]
fn registry_retry(state: State<'_, AppState>, local_id: String) {
if let Some(r) = &state.realm_registry {
r.retry(&local_id);
}
}
fn start_realm_registry(dir: &std::path::Path) -> Option<Arc<coa_core::realm_registry::RegistryRuntime>> {
use coa_core::realm_registry::{FileKeyStore, LocalRealmsSource, RegistryRuntime};
let installs = Registry::at(dir.join("installs.json"));
let source = LocalRealmsSource::new(dir.join("portable").join("realms"), move || installs.list().unwrap_or_default());
let keys = FileKeyStore::new(dir.join("registry").join("keys"));
match RegistryRuntime::start(&dir.join("registry"), Arc::new(keys), Arc::new(source)) {
Ok(rt) => Some(Arc::new(rt)),
Err(e) => {
tracing::error!(error = %e, "the publishing service did not start");
None
}
}
}
fn start_portable(dir: &std::path::Path) -> Option<Arc<PortableRuntime>> {
let registry = Registry::at(dir.join("installs.json"));
let control = InstallControl { registry: Registry::at(dir.join("installs.json")) };
+22
View File
@@ -984,6 +984,28 @@ const en = {
"fr.lan.unavailable": "Selected address unavailable",
"fr.lan.unavailableText": "The selected address is not currently assigned to a detected local interface. Choose another address or switch to Automatic, or keep this address intentionally.",
"fr.lan.invalid": "Invalid IPv4 address",
"registry.title": "Public listing",
"registry.text": "Publish this realm in the central list so that players can find it. Only its name, description, language, version and capabilities are shared: no passwords, no characters, no accounts. The realm keeps running if the list is unreachable.",
"registry.url": "Registry address",
"registry.urlSave": "Save address",
"registry.name": "Realm name",
"registry.description": "Description",
"registry.descriptionHint": "Plain text, up to 1000 characters.",
"registry.language": "Language",
"registry.publish": "Publish this realm",
"registry.stop": "Stop publishing",
"registry.retry": "Try again",
"registry.noUrl": "Enter the Registry address first.",
"registry.revision": "listing version {n}",
"registry.retryIn": "Next attempt in {s} s.",
"registry.state.starting": "Starting to publish…",
"registry.state.waiting_for_realm": "Waiting for the realm to be ready…",
"registry.state.realm_stopped": "The realm is not running; it is listed as offline.",
"registry.state.online": "Published and online.",
"registry.state.retrying": "The Registry could not be reached; trying again. Your realm is not affected.",
"registry.state.rejected": "The Registry refused this realm. Publishing is stopped.",
"registry.state.unpublishing": "Removing the listing…",
"registry.state.disabled": "Not published.",
} as const;
export default en;
+22
View File
@@ -982,6 +982,28 @@ const ru: Partial<Record<keyof typeof en, string>> = {
"fr.lan.unavailable": "Выбранный адрес недоступен",
"fr.lan.unavailableText": "Выбранный адрес сейчас не назначен обнаруженному сетевому интерфейсу. Выберите другой адрес, переключитесь на автоматический выбор или намеренно сохраните этот адрес.",
"fr.lan.invalid": "Некорректный IPv4-адрес",
"registry.title": "Публичный список",
"registry.text": "Опубликуйте этот реалм в общем списке, чтобы игроки могли его найти. Передаются только название, описание, язык, версия и возможности реалма: никаких паролей, персонажей и аккаунтов. Если список недоступен, реалм продолжает работать.",
"registry.url": "Адрес реестра",
"registry.urlSave": "Сохранить адрес",
"registry.name": "Название реалма",
"registry.description": "Описание",
"registry.descriptionHint": "Простой текст, до 1000 символов.",
"registry.language": "Язык",
"registry.publish": "Опубликовать реалм",
"registry.stop": "Снять с публикации",
"registry.retry": "Повторить",
"registry.noUrl": "Сначала укажите адрес реестра.",
"registry.revision": "версия записи {n}",
"registry.retryIn": "Следующая попытка через {s} с.",
"registry.state.starting": "Начинаю публикацию…",
"registry.state.waiting_for_realm": "Жду, пока реалм будет готов…",
"registry.state.realm_stopped": "Реалм не запущен; в списке он отмечен как недоступный.",
"registry.state.online": "Опубликован и доступен.",
"registry.state.retrying": "Реестр недоступен, пробую снова. На ваш реалм это не влияет.",
"registry.state.rejected": "Реестр отклонил этот реалм. Публикация остановлена.",
"registry.state.unpublishing": "Убираю запись из списка…",
"registry.state.disabled": "Не опубликован.",
};
export default ru;
+49
View File
@@ -0,0 +1,49 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
export type PublishState = "starting" | "waiting_for_realm" | "realm_stopped" | "online" | "retrying" | "rejected" | "unpublishing" | "disabled";
export interface RealmPublishStatus {
local_id: string;
realm_id: string | null;
enabled: boolean;
state: PublishState;
display_name: string;
description: string;
language: string;
metadata_revision: number | null;
last_ok_unix: number | null;
last_error: string | null;
retry_in_secs: number | null;
}
export interface RegistryStatus {
url: string | null;
realms: RealmPublishStatus[];
}
export const realmRegistry = {
status: () => invoke<RegistryStatus>("registry_status"),
setUrl: (url: string | null) => invoke<void>("registry_set_url", { url }),
publish: (localId: string, displayName: string, description: string, language: string) =>
invoke<void>("registry_publish", { localId, displayName, description, language }),
unpublish: (localId: string) => invoke<void>("registry_unpublish", { localId }),
retry: (localId: string) => invoke<void>("registry_retry", { localId }),
};
/** The publishing state of this Manager's realms, refreshed while a screen shows it. The loop itself runs without the screen. */
export function useRegistryStatus(everyMs = 2000): { status: RegistryStatus | null; refresh: () => Promise<void> } {
const [status, setStatus] = useState<RegistryStatus | null>(null);
const busy = useRef(false);
const refresh = useCallback(async () => {
if (busy.current) return;
busy.current = true;
try { setStatus(await realmRegistry.status()); } catch { /* the service is not available */ } finally { busy.current = false; }
}, []);
useEffect(() => {
void refresh();
const timer = setInterval(() => void refresh(), everyMs);
return () => clearInterval(timer);
}, [refresh, everyMs]);
return { status, refresh };
}
+81
View File
@@ -0,0 +1,81 @@
import { useEffect, useState } from "react";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { useT, type Key } from "@/i18n";
import { asUiError } from "@/lib/api";
import { realmRegistry, useRegistryStatus } from "@/lib/registry";
const LANGUAGES = ["en", "ru", "de", "fr", "es", "zh", "pt", "pl", "uk"];
/** Publishing this server's realm to the central Registry: a name, a description, a language. Nothing else leaves this machine. */
export function PublishCard({ serverId, serverName }: { serverId: string; serverName: string }) {
const t = useT();
const localId = `srv-${serverId}`;
const { status, refresh } = useRegistryStatus();
const mine = status?.realms.find((r) => r.local_id === localId);
const published = !!mine?.enabled;
const [url, setUrl] = useState("");
const [name, setName] = useState(serverName);
const [description, setDescription] = useState("");
const [language, setLanguage] = useState("en");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => { if (status && status.url !== null) setUrl((u) => (u === "" ? status.url ?? "" : u)); }, [status?.url]);
useEffect(() => {
if (mine && mine.display_name) { setName(mine.display_name); setDescription(mine.description); setLanguage(mine.language || "en"); }
}, [mine?.display_name, mine?.description, mine?.language]);
async function run(action: () => Promise<void>) {
setBusy(true);
setError(null);
try { await action(); await refresh(); } catch (e) { setError(asUiError(e).technical); } finally { setBusy(false); }
}
const tone = mine?.state === "online" ? "text-ok" : mine?.state === "rejected" ? "text-bad" : mine?.state === "retrying" || mine?.state === "realm_stopped" || mine?.state === "waiting_for_realm" ? "text-warn" : "text-muted";
return (
<Card className="mt-6 p-6">
<h2 className="font-semibold">{t("registry.title")}</h2>
<p className="mt-1 text-sm text-muted">{t("registry.text")}</p>
<label htmlFor="registry-url" className="mt-4 block text-sm font-medium">{t("registry.url")}</label>
<div className="mt-1 flex gap-2">
<input id="registry-url" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://registry.example" className="min-w-0 flex-1 rounded-md border border-line bg-card px-3 py-2" />
<Button size="sm" disabled={busy || url.trim() === (status?.url ?? "")} onClick={() => void run(() => realmRegistry.setUrl(url.trim() || null))}>{t("registry.urlSave")}</Button>
</div>
{!published && <>
<label htmlFor="registry-name" className="mt-4 block text-sm font-medium">{t("registry.name")}</label>
<input id="registry-name" value={name} maxLength={80} onChange={(e) => setName(e.target.value)} className="mt-1 w-full rounded-md border border-line bg-card px-3 py-2" />
<label htmlFor="registry-description" className="mt-3 block text-sm font-medium">{t("registry.description")}</label>
<textarea id="registry-description" value={description} rows={3} onChange={(e) => setDescription(e.target.value)} className="mt-1 w-full rounded-md border border-line bg-card px-3 py-2" />
<p className="mt-1 text-xs text-muted">{t("registry.descriptionHint")}</p>
<label htmlFor="registry-language" className="mt-3 block text-sm font-medium">{t("registry.language")}</label>
<select id="registry-language" value={language} onChange={(e) => setLanguage(e.target.value)} className="mt-1 rounded-md border border-line bg-card px-3 py-2">
{LANGUAGES.map((l) => <option key={l} value={l}>{l}</option>)}
</select>
<div className="mt-4">
<Button variant="primary" disabled={busy || !status?.url || name.trim() === ""} onClick={() => void run(() => realmRegistry.publish(localId, name, description, language))}>
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}{t("registry.publish")}
</Button>
{!status?.url && <p className="mt-2 text-xs text-muted">{t("registry.noUrl")}</p>}
</div>
</>}
{mine && (published || mine.state === "unpublishing") && <div className="mt-4 rounded-md border border-line p-3 text-sm" role="status">
<p className={cn("font-medium", tone)} data-state={mine.state}>{t(`registry.state.${mine.state}` as Key)}</p>
<p className="mt-1 text-xs text-muted">{mine.display_name}{mine.metadata_revision ? ` · ${t("registry.revision", { n: mine.metadata_revision })}` : ""}</p>
{mine.last_error && <p className="mt-1 text-xs text-warn">{mine.last_error}</p>}
{mine.state === "retrying" && mine.retry_in_secs !== null && <p className="mt-1 text-xs text-muted">{t("registry.retryIn", { s: mine.retry_in_secs })}</p>}
<div className="mt-3 flex gap-2">
{mine.state === "rejected" && <Button size="sm" onClick={() => void run(async () => { await realmRegistry.retry(localId); })}>{t("registry.retry")}</Button>}
<Button size="sm" disabled={busy} onClick={() => void run(() => realmRegistry.unpublish(localId))}>{t("registry.stop")}</Button>
</div>
</div>}
{error && <p className="mt-3 text-sm text-bad" role="alert">{error}</p>}
</Card>
);
}
+3 -1
View File
@@ -13,12 +13,13 @@ import { hasKey, useHuman, useI18n, type Key } from "@/i18n";
import { LanguagePicker } from "@/components/LanguagePicker";
import { RealmStartupSettings } from "@/screens/RealmStartupSettings";
import { RepairCard } from "@/screens/RepairCard";
import { PublishCard } from "@/screens/PublishCard";
function mb(bytes: number) {
return bytes >= 1 << 20 ? `${(bytes / (1 << 20)).toFixed(1)} MB` : `${Math.max(1, Math.round(bytes / 1024))} KB`;
}
export function SettingsHome({ serverId }: { serverId: string }) {
export function SettingsHome({ serverId, serverName }: { serverId: string; serverName: string }) {
const { t } = useI18n();
const upd = useServerUpdate(serverId);
const human = useHuman();
@@ -171,6 +172,7 @@ export function SettingsHome({ serverId }: { serverId: string }) {
<DiagnosticsCard serverId={serverId} />
<RepairCard key={serverId} serverId={serverId} />
<PublishCard key={`publish-${serverId}`} serverId={serverId} serverName={serverName} />
<AboutCard />
+1 -1
View File
@@ -141,7 +141,7 @@ export function Shell(props: {
) : page === "friends" ? (
<FriendsPage key={server.id} serverId={server.id} />
) : page === "settings" ? (
<SettingsHome key={server.id} serverId={server.id} />
<SettingsHome key={server.id} serverId={server.id} serverName={server.name} />
) : page === "characters" ? (
<PortablePage key="characters" />
) : page === "players" ? (
+87
View File
@@ -0,0 +1,87 @@
"""Independent check of the documented Registry test vector with the RFC 8032 reference algorithm (pure Python, not the code under test)."""
import base64
import hashlib
p = 2**255 - 19
q = 2**252 + 27742317777372353535851937790883648493
d = -121665 * pow(121666, p - 2, p) % p
I = pow(2, (p - 1) // 4, p)
def sha512(s):
return hashlib.sha512(s).digest()
def inv(x):
return pow(x, p - 2, p)
def xrecover(y):
xx = (y * y - 1) * inv(d * y * y + 1)
x = pow(xx, (p + 3) // 8, p)
if (x * x - xx) % p != 0:
x = (x * I) % p
if x % 2 != 0:
x = p - x
return x
By = 4 * inv(5) % p
Bx = xrecover(By)
B = (Bx % p, By % p, 1, (Bx * By) % p)
def add(P, Q):
A = (P[1] - P[0]) * (Q[1] - Q[0]) % p
Bv = (P[1] + P[0]) * (Q[1] + Q[0]) % p
C = 2 * P[3] * Q[3] * d % p
D = 2 * P[2] * Q[2] % p
E, F, G, H = Bv - A, D - C, D + C, Bv + A
return (E * F % p, G * H % p, F * G % p, E * H % p)
def mul(s, P):
Q = (0, 1, 1, 0)
while s > 0:
if s & 1:
Q = add(Q, P)
P = add(P, P)
s >>= 1
return Q
def enc(P):
zi = inv(P[2])
x, y = P[0] * zi % p, P[1] * zi % p
return int.to_bytes(y | ((x & 1) << 255), 32, "little")
def clamp(h):
a = int.from_bytes(h[:32], "little")
a &= (1 << 254) - 8
a |= 1 << 254
return a
def public(seed):
return enc(mul(clamp(sha512(seed)), B))
def sign(seed, msg):
h = sha512(seed)
a = clamp(h)
A = enc(mul(a, B))
r = int.from_bytes(sha512(h[32:] + msg), "little") % q
R = enc(mul(r, B))
k = int.from_bytes(sha512(R + A + msg), "little") % q
S = (r + k * a) % q
return R + int.to_bytes(S, 32, "little")
b64 = lambda b: base64.urlsafe_b64encode(b).decode().rstrip("=")
seed = bytes([7]) * 32
realm = "018f2d9e-5c3a-7b21-8c4d-0e5f6a7b8c9d"
body = b'{"protocol_version":1}'
msg = "\n".join(["coa-registry-sig-v1", "1", "POST", f"/registry/v1/realms/{realm}/heartbeat", realm, "1790000000", hashlib.sha256(body).hexdigest()]).encode()
print("public key ", b64(public(seed)))
print("signature ", b64(sign(seed, msg)))