use std::collections::HashMap;
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use std::time::{Duration, Instant};
use crate::admin::{AdminClaim, ClaimError};
use crate::chat_commands::{self, CommandContext, RouterStatus};
use crate::token::{TokenManager, constant_time_eq};
pub const DEFAULT_SECRET_TTL_SECS: u64 = 120;
pub const DEFAULT_RATE_LIMIT_PER_MINUTE: u32 = 5;
const RATE_LIMIT_WINDOW: Duration = Duration::from_secs(60);
#[derive(Debug, Clone)]
pub struct ChatAdminConfig {
pub telegram_bot_token: Option<String>,
pub vk_bot_token: Option<String>,
pub vk_group_id: Option<u64>,
pub secret_ttl: Duration,
pub rate_limit_per_minute: u32,
}
impl Default for ChatAdminConfig {
fn default() -> Self {
Self {
telegram_bot_token: None,
vk_bot_token: None,
vk_group_id: None,
secret_ttl: Duration::from_secs(DEFAULT_SECRET_TTL_SECS),
rate_limit_per_minute: DEFAULT_RATE_LIMIT_PER_MINUTE,
}
}
}
impl ChatAdminConfig {
#[must_use]
pub fn telegram_enabled(&self) -> bool {
self.telegram_bot_token
.as_ref()
.is_some_and(|token| !token.trim().is_empty())
}
#[must_use]
pub fn vk_enabled(&self) -> bool {
self.vk_bot_token
.as_ref()
.is_some_and(|token| !token.trim().is_empty())
&& self.vk_group_id.is_some()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ChatChannel {
Telegram,
Vk,
}
impl ChatChannel {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Telegram => "telegram",
Self::Vk => "vk",
}
}
}
impl std::fmt::Display for ChatChannel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Reply {
pub text: String,
pub secret: bool,
}
impl Reply {
#[must_use]
pub fn plain(text: impl Into<String>) -> Self {
Self {
text: text.into(),
secret: false,
}
}
#[must_use]
pub fn secret(text: impl Into<String>) -> Self {
Self {
text: text.into(),
secret: true,
}
}
}
#[derive(Debug)]
struct Session {
credential: Option<String>,
pending_claim: Option<String>,
recent: Vec<Instant>,
last_seen: Instant,
}
impl Default for Session {
fn default() -> Self {
Self {
credential: None,
pending_claim: None,
recent: Vec::new(),
last_seen: Instant::now(),
}
}
}
const SESSION_IDLE_TTL: Duration = Duration::from_secs(3600);
const MAX_SESSIONS: usize = 512;
pub struct ChatAdmin {
admin: Arc<AdminClaim>,
#[cfg(test)]
pub(crate) tokens: TokenManager,
#[cfg(not(test))]
tokens: TokenManager,
admin_key: Option<String>,
config: ChatAdminConfig,
status: Option<Arc<dyn RouterStatus>>,
sessions: Mutex<HashMap<(ChatChannel, String), Session>>,
}
impl ChatAdmin {
#[must_use]
pub fn new(
admin: Arc<AdminClaim>,
tokens: TokenManager,
admin_key: Option<String>,
config: ChatAdminConfig,
) -> Self {
Self {
admin,
tokens,
admin_key: admin_key.filter(|key| !key.is_empty()),
config,
status: None,
sessions: Mutex::new(HashMap::new()),
}
}
#[must_use]
pub fn with_status(mut self, status: Arc<dyn RouterStatus>) -> Self {
self.status = Some(status);
self
}
#[must_use]
pub const fn config(&self) -> &ChatAdminConfig {
&self.config
}
#[must_use]
pub fn admin_claim(&self) -> &AdminClaim {
&self.admin
}
pub fn handle(&self, channel: ChatChannel, user_id: &str, text: &str) -> Reply {
self.prune(channel, user_id);
let text = text.trim();
if text.is_empty() {
return Reply::plain(HELP);
}
let (word, rest) = split_command(text);
let command = normalise(word);
match command.as_str() {
"start" => self.start(channel, user_id),
"confirm" => self.confirm(channel, user_id, rest),
"auth" | "login" => self.authenticate(channel, user_id, rest),
"logout" => self.logout(channel, user_id),
"help" => Reply::plain(HELP),
_ => self.dispatch(channel, user_id, text, &command, rest),
}
}
fn dispatch(
&self,
channel: ChatChannel,
user_id: &str,
text: &str,
command: &str,
rest: &str,
) -> Reply {
if looks_like_credential(text) {
if self.has_pending_claim(channel, user_id) {
return self.confirm(channel, user_id, text);
}
return self.authenticate(channel, user_id, text);
}
let Some(credential) = self.authorised_credential(channel, user_id) else {
return self.unauthorised_reply();
};
if matches!(
command,
"issue" | "new" | "rotate" | "rotate-token" | "reissue"
) && !self.allow(channel, user_id)
{
return Reply::plain(RATE_LIMITED);
}
let context = CommandContext {
admin: &self.admin,
tokens: &self.tokens,
credential: &credential,
secret_ttl: self.config.secret_ttl,
status: self.status.as_deref(),
};
if command == "rotate" {
return self.rotate(channel, user_id, &context);
}
chat_commands::execute(&context, command, rest)
.unwrap_or_else(|| Reply::plain(format!("Unknown command `{command}`.\n\n{HELP}")))
}
fn rotate(&self, channel: ChatChannel, user_id: &str, context: &CommandContext<'_>) -> Reply {
match chat_commands::rotate(context) {
Ok(replacement) => {
if let Some(session) = self.locked().get_mut(&(channel, user_id.to_string())) {
session.credential = Some(replacement.clone());
}
Reply::secret(format!(
"Admin credential rotated. The previous one no longer \
works — including in the web UI.\n\n{replacement}{note}",
note = chat_commands::deletion_note(self.config.secret_ttl),
))
}
Err(message) => Reply::plain(message),
}
}
fn start(&self, channel: ChatChannel, user_id: &str) -> Reply {
if self.authorised_credential(channel, user_id).is_some() {
return Reply::plain(format!("You are signed in as admin.\n\n{HELP}"));
}
if !self.allow(channel, user_id) {
return Reply::plain(RATE_LIMITED);
}
match self.admin.begin() {
Ok(candidate) => {
self.locked()
.entry((channel, user_id.to_string()))
.or_default()
.pending_claim = Some(candidate.claim_id);
Reply::secret(format!(
"You may claim administration of this router.\n\n\
Copy this token, then send it back to me with /confirm:\n\n\
/confirm {token}\n\nThe claim is only final once you \
send it back — if this message never reached you, nothing \
is locked and you can run /start again. The candidate \
expires in {ttl}s.{note}",
token = candidate.token,
ttl = candidate.expires_in_secs,
note = self.deletion_note(),
))
}
Err(ClaimError::AlreadyClaimed | ClaimError::ProvisionedByEnvironment) => {
Reply::plain(ALREADY_CLAIMED)
}
Err(e) => Reply::plain(format!("Could not start a claim: {e}")),
}
}
fn confirm(&self, channel: ChatChannel, user_id: &str, token: &str) -> Reply {
if !self.allow(channel, user_id) {
return Reply::plain(RATE_LIMITED);
}
let token = token.trim();
if token.is_empty() {
return Reply::plain("Send `/confirm <token>` with the token I just gave you.");
}
let claim_id = self
.locked()
.get(&(channel, user_id.to_string()))
.and_then(|session| session.pending_claim.clone());
let Some(claim_id) = claim_id else {
return Reply::plain(
"No claim is outstanding for this conversation. Send /start first.",
);
};
match self.admin.confirm(&claim_id, token) {
Ok(()) => {
{
let mut sessions = self.locked();
let session = sessions.entry((channel, user_id.to_string())).or_default();
session.pending_claim = None;
session.credential = Some(token.to_string());
drop(sessions);
}
Reply::plain(format!(
"Administration claimed. Keep that token — it is not shown \
again and it is the same credential the web UI uses.\n\n{HELP}"
))
}
Err(e) => {
self.forget_claim(channel, user_id);
Reply::plain(format!("Claim failed: {e}"))
}
}
}
fn authenticate(&self, channel: ChatChannel, user_id: &str, token: &str) -> Reply {
if !self.allow(channel, user_id) {
return Reply::plain(RATE_LIMITED);
}
let token = token.trim();
if token.is_empty() {
return Reply::plain("Send `/auth <admin token>`.");
}
if !self.credential_valid(token) {
return Reply::plain("That credential is not valid for administration.");
}
self.locked()
.entry((channel, user_id.to_string()))
.or_default()
.credential = Some(token.to_string());
Reply::plain(format!("Signed in as admin.\n\n{HELP}"))
}
fn logout(&self, channel: ChatChannel, user_id: &str) -> Reply {
self.locked().remove(&(channel, user_id.to_string()));
Reply::plain("Signed out. Send /auth <admin token> to sign in again.")
}
fn authorised_credential(&self, channel: ChatChannel, user_id: &str) -> Option<String> {
let cached = self
.locked()
.get(&(channel, user_id.to_string()))
.and_then(|session| session.credential.clone())?;
if self.credential_valid(&cached) {
return Some(cached);
}
if let Some(session) = self.locked().get_mut(&(channel, user_id.to_string())) {
session.credential = None;
}
None
}
fn credential_valid(&self, token: &str) -> bool {
if token.is_empty() {
return false;
}
if self.admin.verify(token) {
return true;
}
if self.tokens.validate_admin_token(token).is_ok() {
return true;
}
self.admin_key
.as_deref()
.is_some_and(|key| constant_time_eq(token, key))
}
fn has_pending_claim(&self, channel: ChatChannel, user_id: &str) -> bool {
self.locked()
.get(&(channel, user_id.to_string()))
.is_some_and(|session| session.pending_claim.is_some())
}
fn forget_claim(&self, channel: ChatChannel, user_id: &str) {
if let Some(session) = self.locked().get_mut(&(channel, user_id.to_string())) {
session.pending_claim = None;
}
}
fn unauthorised_reply(&self) -> Reply {
if self.admin.is_claimed() {
Reply::plain(
"Administration is already claimed. Send `/auth <admin token>` \
with an admin credential to continue.",
)
} else {
Reply::plain(
"Nobody administers this router yet. Send /start to claim it, or \
`/auth <admin token>` if you already hold an admin credential.",
)
}
}
fn allow(&self, channel: ChatChannel, user_id: &str) -> bool {
let limit = self.config.rate_limit_per_minute;
if limit == 0 {
return true;
}
let now = Instant::now();
let mut sessions = self.locked();
let session = sessions.entry((channel, user_id.to_string())).or_default();
session
.recent
.retain(|at| now.duration_since(*at) < RATE_LIMIT_WINDOW);
let allowed = session.recent.len() < limit as usize;
if allowed {
session.recent.push(now);
}
drop(sessions);
allowed
}
fn prune(&self, channel: ChatChannel, user_id: &str) {
let now = Instant::now();
let mut sessions = self.locked();
sessions.retain(|_, session| now.duration_since(session.last_seen) < SESSION_IDLE_TTL);
sessions
.entry((channel, user_id.to_string()))
.or_default()
.last_seen = now;
while sessions.len() > MAX_SESSIONS {
let current = (channel, user_id.to_string());
let Some(oldest) = sessions
.iter()
.filter(|(key, _)| **key != current)
.min_by_key(|(_, session)| (session.credential.is_some(), session.last_seen))
.map(|(key, _)| key.clone())
else {
break;
};
sessions.remove(&oldest);
}
drop(sessions);
}
fn deletion_note(&self) -> String {
chat_commands::deletion_note(self.config.secret_ttl)
}
fn locked(&self) -> MutexGuard<'_, HashMap<(ChatChannel, String), Session>> {
self.sessions.lock().unwrap_or_else(PoisonError::into_inner)
}
}
fn split_command(text: &str) -> (&str, &str) {
match text.split_once(char::is_whitespace) {
Some((word, rest)) => (word, rest.trim()),
None => (text, ""),
}
}
fn normalise(word: &str) -> String {
let word = word.strip_prefix('/').unwrap_or(word);
let word = word.split_once('@').map_or(word, |(name, _)| name);
word.to_lowercase()
}
fn looks_like_credential(text: &str) -> bool {
!text.contains(char::is_whitespace)
&& (text.starts_with(crate::admin::ADMIN_TOKEN_PREFIX)
|| text.starts_with(crate::token::TOKEN_PREFIX))
}
const ALREADY_CLAIMED: &str = "Administration of this router is already claimed \
(here, through the web UI, or by deployment configuration). Send \
`/auth <admin token>` with an admin credential.";
const RATE_LIMITED: &str = "Too many attempts. Wait a minute and try again.";
pub const HELP: &str = "Commands:\n\
/status — credential state, accounts and usage\n\
/tokens — list issued tokens (ids, labels and limits, never values)\n\
/issue [label] [ttl_hours] [max_requests] [key=value …] — issue a token;\n\
\x20 options: label, ttl_hours, max_requests, max_tokens,\n\
\x20 rate_limit_per_minute, account\n\
/show <id> — every constraint, counter and state for one token\n\
/rotate-token <id> [key=value …] — reissue a token, keeping its limits\n\
/revoke <id> — revoke a token\n\
/rotate — replace the admin credential\n\
/auth <token> — sign in with an admin credential\n\
/logout — forget the credential bound to this chat";
#[cfg(test)]
mod tests {
use super::*;
fn core(env_key: Option<String>) -> ChatAdmin {
ChatAdmin::new(
Arc::new(AdminClaim::in_memory(
env_key.clone(),
Duration::from_secs(120),
)),
TokenManager::new("secret-for-chat-admin-tests"),
env_key,
ChatAdminConfig {
rate_limit_per_minute: 0,
..ChatAdminConfig::default()
},
)
}
fn token_from(reply: &Reply) -> String {
reply
.text
.split_whitespace()
.find(|word| word.starts_with(crate::admin::ADMIN_TOKEN_PREFIX))
.expect("the mint reply carries a token")
.to_string()
}
#[test]
fn start_mints_a_candidate_that_authorises_nothing() {
let chat = core(None);
let reply = chat.handle(ChatChannel::Telegram, "1", "/start");
assert!(reply.secret, "a minted token must not linger in the chat");
let token = token_from(&reply);
assert!(!chat.admin.is_claimed(), "a mint alone must not claim");
assert!(!chat.admin.verify(&token));
let listed = chat.handle(ChatChannel::Telegram, "1", "/tokens");
assert!(listed.text.contains("/start"));
}
#[test]
fn confirm_activates_the_claim_and_signs_the_user_in() {
let chat = core(None);
let minted = chat.handle(ChatChannel::Telegram, "1", "/start");
let token = token_from(&minted);
let reply = chat.handle(ChatChannel::Telegram, "1", &format!("/confirm {token}"));
assert!(reply.text.contains("Administration claimed"));
assert!(chat.admin.verify(&token));
assert!(
chat.handle(ChatChannel::Telegram, "1", "/tokens")
.text
.contains("No tokens")
);
}
#[test]
fn a_bare_token_is_accepted_as_the_confirmation() {
let chat = core(None);
let token = token_from(&chat.handle(ChatChannel::Vk, "7", "/start"));
let reply = chat.handle(ChatChannel::Vk, "7", &token);
assert!(reply.text.contains("Administration claimed"));
}
#[test]
fn an_unconfirmed_mint_leaves_the_router_claimable() {
let chat = core(None);
let _abandoned = chat.handle(ChatChannel::Telegram, "1", "/start");
assert!(!chat.admin.is_claimed());
let second = chat.handle(ChatChannel::Telegram, "2", "/start");
let token = token_from(&second);
chat.handle(ChatChannel::Telegram, "2", &format!("/confirm {token}"));
assert!(chat.admin.verify(&token));
}
#[test]
fn a_claim_made_in_the_web_ui_closes_start_for_chat() {
let chat = core(None);
let candidate = chat.admin.begin().expect("web UI mint");
chat.admin
.confirm(&candidate.claim_id, &candidate.token)
.expect("web UI confirm");
let reply = chat.handle(ChatChannel::Telegram, "1", "/start");
assert!(reply.text.contains("already claimed"));
assert!(!reply.secret);
}
#[test]
fn a_claim_made_in_chat_closes_the_web_ui_bootstrap() {
let chat = core(None);
let token = token_from(&chat.handle(ChatChannel::Telegram, "1", "/start"));
chat.handle(ChatChannel::Telegram, "1", &format!("/confirm {token}"));
assert_eq!(chat.admin.begin().unwrap_err(), ClaimError::AlreadyClaimed);
assert!(!chat.admin.status().bootstrap_open);
}
#[test]
fn a_second_chat_user_must_present_a_credential() {
let chat = core(None);
let token = token_from(&chat.handle(ChatChannel::Telegram, "1", "/start"));
chat.handle(ChatChannel::Telegram, "1", &format!("/confirm {token}"));
assert!(
chat.handle(ChatChannel::Telegram, "2", "/tokens")
.text
.contains("/auth")
);
assert!(
chat.handle(ChatChannel::Telegram, "2", "/auth la_admin_nope")
.text
.contains("not valid")
);
assert!(
chat.handle(ChatChannel::Telegram, "2", &format!("/auth {token}"))
.text
.contains("Signed in")
);
assert!(
chat.handle(ChatChannel::Telegram, "2", "/tokens")
.text
.contains("No tokens")
);
}
#[test]
fn an_admin_scoped_jwt_is_accepted_as_a_credential() {
let chat = core(None);
let jwt = chat
.tokens
.issue_admin_token(1, "ops")
.expect("issue admin jwt");
assert!(
chat.handle(ChatChannel::Telegram, "3", &format!("/auth {jwt}"))
.text
.contains("Signed in")
);
let client = chat.tokens.issue_token(1, "client").expect("issue");
assert!(
chat.handle(ChatChannel::Telegram, "4", &format!("/auth {client}"))
.text
.contains("not valid")
);
}
#[test]
fn revoking_the_credential_unbinds_the_chat_user() {
let chat = core(None);
let jwt = chat.tokens.issue_admin_token(1, "ops").expect("issue");
chat.handle(ChatChannel::Telegram, "5", &format!("/auth {jwt}"));
let claims = chat.tokens.validate_token(&jwt).expect("validate");
chat.tokens.revoke_token(&claims.sub).expect("revoke");
assert!(
chat.handle(ChatChannel::Telegram, "5", "/tokens")
.text
.contains("/auth")
);
}
#[test]
fn sessions_do_not_leak_across_channels_or_users() {
let chat = core(None);
let jwt = chat.tokens.issue_admin_token(1, "ops").expect("issue");
chat.handle(ChatChannel::Telegram, "9", &format!("/auth {jwt}"));
assert!(
chat.handle(ChatChannel::Vk, "9", "/tokens")
.text
.contains("/auth"),
"the same numeric id on another platform is another person"
);
}
#[test]
fn logout_forgets_the_binding() {
let chat = core(None);
let jwt = chat.tokens.issue_admin_token(1, "ops").expect("issue");
chat.handle(ChatChannel::Telegram, "6", &format!("/auth {jwt}"));
chat.handle(ChatChannel::Telegram, "6", "/logout");
assert!(
chat.handle(ChatChannel::Telegram, "6", "/tokens")
.text
.contains("/auth")
);
}
#[test]
fn an_environment_provisioned_key_starts_claimed() {
let chat = core(Some("env-key".into()));
let reply = chat.handle(ChatChannel::Telegram, "1", "/start");
assert!(reply.text.contains("already claimed"));
assert!(
chat.handle(ChatChannel::Telegram, "1", "/auth env-key")
.text
.contains("Signed in")
);
}
#[test]
fn sensitive_commands_are_rate_limited() {
let chat = ChatAdmin::new(
Arc::new(AdminClaim::in_memory(None, Duration::from_secs(120))),
TokenManager::new("secret-for-rate-limit-tests"),
None,
ChatAdminConfig {
rate_limit_per_minute: 2,
..ChatAdminConfig::default()
},
);
assert!(
!chat
.handle(ChatChannel::Telegram, "1", "/auth la_admin_wrong")
.text
.contains("Too many")
);
assert!(
!chat
.handle(ChatChannel::Telegram, "1", "/auth la_admin_wrong")
.text
.contains("Too many")
);
assert!(
chat.handle(ChatChannel::Telegram, "1", "/auth la_admin_wrong")
.text
.contains("Too many")
);
assert!(
!chat
.handle(ChatChannel::Telegram, "2", "/auth la_admin_wrong")
.text
.contains("Too many")
);
}
#[test]
fn a_flood_of_strangers_cannot_grow_the_session_map_without_bound() {
let chat = core(None);
for user in 0..(MAX_SESSIONS * 2) {
chat.handle(ChatChannel::Telegram, &user.to_string(), "/help");
}
assert!(
chat.locked().len() <= MAX_SESSIONS,
"session map grew to {}",
chat.locked().len()
);
}
#[test]
fn the_active_conversation_survives_the_cap() {
let chat = core(Some("env-key".into()));
chat.handle(ChatChannel::Telegram, "admin", "/auth env-key");
for user in 0..(MAX_SESSIONS * 2) {
chat.handle(ChatChannel::Vk, &user.to_string(), "/help");
}
chat.handle(ChatChannel::Telegram, "admin", "/help");
assert!(
chat.locked()
.get(&(ChatChannel::Telegram, "admin".to_string()))
.is_some()
);
}
#[test]
fn telegram_style_command_suffixes_are_understood() {
assert_eq!(normalise("/start@router_admin_bot"), "start");
assert_eq!(normalise("STATUS"), "status");
}
#[test]
fn unknown_commands_get_the_help_text() {
let chat = core(Some("env-key".into()));
chat.handle(ChatChannel::Telegram, "1", "/auth env-key");
let reply = chat.handle(ChatChannel::Telegram, "1", "/nonsense");
assert!(reply.text.contains("Unknown command"));
}
#[test]
fn channels_are_independently_configurable() {
let mut config = ChatAdminConfig::default();
assert!(!config.telegram_enabled() && !config.vk_enabled());
config.telegram_bot_token = Some("123:abc".into());
assert!(config.telegram_enabled() && !config.vk_enabled());
config.vk_bot_token = Some("vk-token".into());
assert!(
!config.vk_enabled(),
"VK long polling needs a group id as well as a token"
);
config.vk_group_id = Some(42);
assert!(config.vk_enabled());
}
}