Files
featherChat/warzone/crates/warzone-server/src/routes/aliases.rs
Siavash Sameni 210fbbb35b feat: bot alias reservation + BOT_API.md documentation
- Aliases ending with Bot/bot/_bot reserved for registered bots only
- Non-bot users get clear error directing to /v1/bot/register
- Bot registration auto-creates alias (@name_bot suffix)
- BOT_API.md: full developer guide with endpoints, examples, echo bot
- LLM_HELP.md: expanded bot section with update types + Python example

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 07:34:45 +04:00

437 lines
15 KiB
Rust

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<AppState> {
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::<String>()
.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<AliasRecord> {
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<AppState>,
Json(req): Json<RegisterRequest>,
) -> AppResult<Json<serde_json::Value>> {
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<AppState>,
Json(req): Json<RecoverRequest>,
) -> AppResult<Json<serde_json::Value>> {
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<AppState>,
Json(req): Json<RenewRequest>,
) -> AppResult<Json<serde_json::Value>> {
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<AppState>,
Path(name): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
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<AppState>,
Path(fingerprint): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
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<AppState>,
) -> AppResult<Json<serde_json::Value>> {
let mut aliases: Vec<serde_json::Value> = 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::<AliasRecord>(&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<AppState>,
Json(req): Json<UnregisterRequest>,
) -> AppResult<Json<serde_json::Value>> {
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<AppState>,
Json(req): Json<AdminRemoveRequest>,
) -> AppResult<Json<serde_json::Value>> {
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" })))
}
}