mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 03:51:01 +00:00
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:
1 parent
ec40f146aa
commit
1267bcc41e
56 files changed
+10198
-13
No files matched your search
@@ -0,0 +1,5 @@
|
||||
target
|
||||
**/target
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
@@ -4,3 +4,6 @@ dist
|
||||
*.log
|
||||
.env
|
||||
src-tauri/gen
|
||||
|
||||
# standalone service workspaces build into their own target folders
|
||||
services/*/target/
|
||||
Generated
+15
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
|
||||
Generated
+2749
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
Generated
+1918
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)?,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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")) };
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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)))
|
||||
Reference in new issue
Block a user