use axum::{ extract::{Path, State}, routing::{get, post}, Json, Router, }; use serde::{Deserialize, Serialize}; use crate::errors::AppResult; use crate::state::AppState; /// Alias expires after 365 days of inactivity. const ALIAS_TTL_SECS: i64 = 365 * 24 * 3600; /// Grace period after expiry: 30 days before someone else can claim. const GRACE_PERIOD_SECS: i64 = 30 * 24 * 3600; pub fn routes() -> Router { Router::new() .route("/alias/register", post(register_alias)) .route("/alias/recover", post(recover_alias)) .route("/alias/renew", post(renew_alias)) .route("/alias/resolve/:name", get(resolve_alias)) .route("/alias/list", get(list_aliases)) .route("/alias/whois/:fingerprint", get(reverse_lookup)) .route("/alias/unregister", post(unregister_alias)) .route("/alias/admin-remove", post(admin_remove_alias)) } fn normalize_fp(fp: &str) -> String { fp.chars() .filter(|c| c.is_ascii_hexdigit()) .collect::() .to_lowercase() } fn normalize_alias(name: &str) -> String { name.trim() .to_lowercase() .chars() .filter(|c| c.is_alphanumeric() || *c == '_' || *c == '-') .collect() } fn now_ts() -> i64 { chrono::Utc::now().timestamp() } fn gen_recovery_key() -> String { use rand::RngCore; let mut bytes = [0u8; 16]; rand::rngs::OsRng.fill_bytes(&mut bytes); hex::encode(bytes) } /// Stored record for an alias. #[derive(Serialize, Deserialize, Clone)] struct AliasRecord { alias: String, fingerprint: String, recovery_key: String, registered_at: i64, last_active: i64, } impl AliasRecord { fn is_expired(&self) -> bool { now_ts() - self.last_active > ALIAS_TTL_SECS } fn is_past_grace(&self) -> bool { now_ts() - self.last_active > ALIAS_TTL_SECS + GRACE_PERIOD_SECS } fn expires_in_days(&self) -> i64 { let remaining = (self.last_active + ALIAS_TTL_SECS) - now_ts(); remaining / 86400 } } fn load_alias_record(db: &sled::Tree, alias: &str) -> Option { db.get(format!("rec:{}", alias).as_bytes()) .ok() .flatten() .and_then(|data| serde_json::from_slice(&data).ok()) } fn save_alias_record(db: &sled::Tree, record: &AliasRecord) -> anyhow::Result<()> { let data = serde_json::to_vec(record)?; db.insert(format!("rec:{}", record.alias).as_bytes(), data)?; // Forward + reverse index db.insert(format!("a:{}", record.alias).as_bytes(), record.fingerprint.as_bytes())?; db.insert(format!("fp:{}", record.fingerprint).as_bytes(), record.alias.as_bytes())?; db.flush()?; Ok(()) } fn delete_alias_record(db: &sled::Tree, record: &AliasRecord) -> anyhow::Result<()> { db.remove(format!("rec:{}", record.alias).as_bytes())?; db.remove(format!("a:{}", record.alias).as_bytes())?; db.remove(format!("fp:{}", record.fingerprint).as_bytes())?; db.flush()?; Ok(()) } #[derive(Deserialize)] struct RegisterRequest { alias: String, fingerprint: String, } /// Register an alias. Returns a recovery key on first registration. /// - One alias per fingerprint /// - Expired aliases (past grace period) can be reclaimed by anyone /// - Expired aliases (within grace period) can only be reclaimed by recovery key async fn register_alias( _auth: crate::auth_middleware::AuthFingerprint, State(state): State, Json(req): Json, ) -> AppResult> { let alias = normalize_alias(&req.alias); let fp = normalize_fp(&req.fingerprint); if alias.is_empty() || alias.len() > 32 { return Ok(Json(serde_json::json!({ "error": "alias must be 1-32 alphanumeric chars" }))); } // Reserve *Bot and *_bot suffixes for bots only let is_bot_name = alias.ends_with("bot") || alias.ends_with("_bot"); if is_bot_name { // Check if this fingerprint is registered as a bot let bot_key = format!("bot_fp:{}", fp); let is_registered_bot = state.db.tokens.get(bot_key.as_bytes()) .ok().flatten().is_some(); if !is_registered_bot { return Ok(Json(serde_json::json!({ "error": "aliases ending with 'Bot' or '_bot' are reserved for bots — register via /v1/bot/register first" }))); } } // Check existing record for this alias if let Some(existing) = load_alias_record(&state.db.aliases, &alias) { if existing.fingerprint == fp { // Same person — renew let mut updated = existing; updated.last_active = now_ts(); save_alias_record(&state.db.aliases, &updated)?; return Ok(Json(serde_json::json!({ "ok": true, "alias": alias, "fingerprint": fp, "renewed": true, "expires_in_days": updated.expires_in_days() }))); } if !existing.is_past_grace() { // Still active or in grace period — can't take it if existing.is_expired() { return Ok(Json(serde_json::json!({ "error": "alias expired but in grace period — use recovery key or wait", "grace_ends_in_days": (existing.last_active + ALIAS_TTL_SECS + GRACE_PERIOD_SECS - now_ts()) / 86400 }))); } return Ok(Json(serde_json::json!({ "error": "alias already taken" }))); } // Past grace period — clean up old record tracing::info!("Alias '{}' expired past grace, releasing from {}", alias, existing.fingerprint); delete_alias_record(&state.db.aliases, &existing)?; } // Check if alias is taken on federation peer (globally unique) if let Some(ref federation) = state.federation { if federation.is_alias_taken_remote(&alias).await { return Ok(Json(serde_json::json!({ "error": "alias already taken on federated server" }))); } } // Remove old alias for this fingerprint (one alias per person) if let Some(old_alias_bytes) = state.db.aliases.get(format!("fp:{}", fp).as_bytes())? { let old_alias = String::from_utf8_lossy(&old_alias_bytes).to_string(); if let Some(old_record) = load_alias_record(&state.db.aliases, &old_alias) { delete_alias_record(&state.db.aliases, &old_record)?; tracing::info!("Removed old alias '{}' for {}", old_alias, fp); } } let recovery_key = gen_recovery_key(); let record = AliasRecord { alias: alias.clone(), fingerprint: fp.clone(), recovery_key: recovery_key.clone(), registered_at: now_ts(), last_active: now_ts(), }; save_alias_record(&state.db.aliases, &record)?; tracing::info!("Alias '{}' registered for {}", alias, fp); Ok(Json(serde_json::json!({ "ok": true, "alias": alias, "fingerprint": fp, "recovery_key": recovery_key, "expires_in_days": record.expires_in_days(), "IMPORTANT": "Save your recovery key! It's the only way to reclaim this alias if you lose access." }))) } #[derive(Deserialize)] struct RecoverRequest { alias: String, recovery_key: String, new_fingerprint: String, } /// Recover an alias using the recovery key. Works even if expired (within or past grace). async fn recover_alias( _auth: crate::auth_middleware::AuthFingerprint, State(state): State, Json(req): Json, ) -> AppResult> { let alias = normalize_alias(&req.alias); let new_fp = normalize_fp(&req.new_fingerprint); let record = match load_alias_record(&state.db.aliases, &alias) { Some(r) => r, None => return Ok(Json(serde_json::json!({ "error": "alias not found" }))), }; if record.recovery_key != req.recovery_key { tracing::warn!("Failed recovery attempt for alias '{}'", alias); return Ok(Json(serde_json::json!({ "error": "invalid recovery key" }))); } // Delete old mappings delete_alias_record(&state.db.aliases, &record)?; // Remove any existing alias for the new fingerprint if let Some(old_alias_bytes) = state.db.aliases.get(format!("fp:{}", new_fp).as_bytes())? { let old_alias = String::from_utf8_lossy(&old_alias_bytes).to_string(); if let Some(old_record) = load_alias_record(&state.db.aliases, &old_alias) { delete_alias_record(&state.db.aliases, &old_record)?; } } let new_recovery_key = gen_recovery_key(); let new_record = AliasRecord { alias: alias.clone(), fingerprint: new_fp.clone(), recovery_key: new_recovery_key.clone(), registered_at: now_ts(), last_active: now_ts(), }; save_alias_record(&state.db.aliases, &new_record)?; tracing::info!("Alias '{}' recovered and transferred to {}", alias, new_fp); Ok(Json(serde_json::json!({ "ok": true, "alias": alias, "fingerprint": new_fp, "new_recovery_key": new_recovery_key, "IMPORTANT": "Your recovery key has been rotated. Save the new one!" }))) } #[derive(Deserialize)] struct RenewRequest { fingerprint: String, } /// Renew/heartbeat — resets the TTL. Called automatically on activity. async fn renew_alias( _auth: crate::auth_middleware::AuthFingerprint, State(state): State, Json(req): Json, ) -> AppResult> { let fp = normalize_fp(&req.fingerprint); let alias = match state.db.aliases.get(format!("fp:{}", fp).as_bytes())? { Some(data) => String::from_utf8_lossy(&data).to_string(), None => return Ok(Json(serde_json::json!({ "alias": null }))), }; if let Some(mut record) = load_alias_record(&state.db.aliases, &alias) { record.last_active = now_ts(); save_alias_record(&state.db.aliases, &record)?; return Ok(Json(serde_json::json!({ "ok": true, "alias": alias, "expires_in_days": record.expires_in_days() }))); } Ok(Json(serde_json::json!({ "alias": null }))) } /// Resolve an alias to a fingerprint. async fn resolve_alias( State(state): State, Path(name): Path, ) -> AppResult> { let alias = normalize_alias(&name); match load_alias_record(&state.db.aliases, &alias) { Some(record) => { if record.is_expired() { Ok(Json(serde_json::json!({ "alias": alias, "fingerprint": record.fingerprint, "expired": true, "warning": "this alias is expired and may be reclaimed" }))) } else { Ok(Json(serde_json::json!({ "alias": alias, "fingerprint": record.fingerprint, "expires_in_days": record.expires_in_days() }))) } } None => { // Try federation peer if let Some(ref federation) = state.federation { if let Some(fp) = federation.resolve_remote_alias(&alias).await { tracing::info!("Alias @{} resolved via federation: {}", alias, fp); return Ok(Json(serde_json::json!({ "alias": alias, "fingerprint": fp, "federated": true, }))); } } Ok(Json(serde_json::json!({ "error": "alias not found" }))) } } } /// Reverse lookup: fingerprint → alias. async fn reverse_lookup( State(state): State, Path(fingerprint): Path, ) -> AppResult> { let fp = normalize_fp(&fingerprint); match state.db.aliases.get(format!("fp:{}", fp).as_bytes())? { Some(data) => { let alias = String::from_utf8_lossy(&data).to_string(); if let Some(record) = load_alias_record(&state.db.aliases, &alias) { Ok(Json(serde_json::json!({ "fingerprint": fp, "alias": alias, "expired": record.is_expired(), "expires_in_days": record.expires_in_days() }))) } else { Ok(Json(serde_json::json!({ "fingerprint": fp, "alias": alias }))) } } None => Ok(Json(serde_json::json!({ "fingerprint": fp, "alias": null }))), } } /// List all aliases. async fn list_aliases( State(state): State, ) -> AppResult> { let mut aliases: Vec = Vec::new(); for item in state.db.aliases.scan_prefix(b"rec:") { if let Ok((_, data)) = item { if let Ok(record) = serde_json::from_slice::(&data) { aliases.push(serde_json::json!({ "alias": record.alias, "fingerprint": record.fingerprint, "expired": record.is_expired(), "expires_in_days": record.expires_in_days(), })); } } } Ok(Json(serde_json::json!({ "aliases": aliases, "count": aliases.len() }))) } #[derive(Deserialize)] struct UnregisterRequest { fingerprint: String, } /// Remove your own alias. async fn unregister_alias( _auth: crate::auth_middleware::AuthFingerprint, State(state): State, Json(req): Json, ) -> AppResult> { let fp = normalize_fp(&req.fingerprint); let alias = match state.db.aliases.get(format!("fp:{}", fp).as_bytes())? { Some(data) => String::from_utf8_lossy(&data).to_string(), None => return Ok(Json(serde_json::json!({ "error": "no alias registered" }))), }; if let Some(record) = load_alias_record(&state.db.aliases, &alias) { if record.fingerprint != fp { return Ok(Json(serde_json::json!({ "error": "not your alias" }))); } delete_alias_record(&state.db.aliases, &record)?; tracing::info!("Alias '{}' unregistered by {}", alias, fp); } Ok(Json(serde_json::json!({ "ok": true, "removed": alias }))) } /// Admin password (set via WARZONE_ADMIN_PASSWORD env var, defaults to "admin"). fn admin_password() -> String { std::env::var("WARZONE_ADMIN_PASSWORD").unwrap_or_else(|_| "admin".to_string()) } #[derive(Deserialize)] struct AdminRemoveRequest { alias: String, admin_password: String, } /// Admin: remove any alias. async fn admin_remove_alias( _auth: crate::auth_middleware::AuthFingerprint, State(state): State, Json(req): Json, ) -> AppResult> { if req.admin_password != admin_password() { return Ok(Json(serde_json::json!({ "error": "invalid admin password" }))); } let alias = normalize_alias(&req.alias); if let Some(record) = load_alias_record(&state.db.aliases, &alias) { delete_alias_record(&state.db.aliases, &record)?; tracing::info!("Alias '{}' removed by admin", alias); Ok(Json(serde_json::json!({ "ok": true, "removed": alias }))) } else { Ok(Json(serde_json::json!({ "error": "alias not found" }))) } }