use std::collections::HashMap; use std::time::Duration; use cot::db::Database; use cot::json::Json; use cot::request::extractors::Path; use cot::response::IntoResponse; use cot::router::method::openapi::{api_delete, api_get, api_post}; use cot::router::{Route, Router}; use cot::session::Session; use cot::{App, Body}; use qrcode::QrCode; use qrcode::render::svg; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::config::AppConfig; use crate::user::User; use crate::{auth, telegram, vpn}; // --------------------------------------------------------------------------- // JSON error helper // --------------------------------------------------------------------------- fn json_error(status: cot::http::StatusCode, message: &str) -> cot::response::Response { json_error_typed( status, status.canonical_reason().unwrap_or("request_failed"), status.canonical_reason().unwrap_or("Request failed"), message, "", ) } fn json_error_typed( status: cot::http::StatusCode, code: &str, title: &str, message: &str, detail: &str, ) -> cot::response::Response { let body = serde_json::json!({ "code": code, "title": title, "error": message, "detail": detail, }); cot::http::Response::builder() .status(status) .header(cot::http::header::CONTENT_TYPE, "application/json") .body(Body::fixed(body.to_string())) .expect("valid response") } // --------------------------------------------------------------------------- // GET /api/me // --------------------------------------------------------------------------- #[derive(Debug, Serialize, JsonSchema)] struct MeResponse { id: i64, name: String, role: String, } async fn me_handler(session: Session, db: Database) -> cot::Result { let Some(user) = auth::get_session_user(&session, &db).await else { return Ok(json_error( cot::http::StatusCode::UNAUTHORIZED, "not authenticated", )); }; Json(MeResponse { id: user.id, name: user.name, role: user.role.code().to_owned(), }) .into_response() } // --------------------------------------------------------------------------- // VPN client API // --------------------------------------------------------------------------- #[derive(Debug, Serialize, JsonSchema)] struct VpnClientsResponse { role: String, clients: Vec, } #[derive(Debug, Deserialize, JsonSchema)] struct CreateVpnClientRequest { name: String, } #[derive(Debug, Serialize, JsonSchema)] struct MutateVpnClientResponse { client: vpn::VpnClientView, sync: vpn::SecretSyncResult, notice: Option, } #[derive(Debug, Deserialize, JsonSchema)] struct SetEnabledRequest { enabled: bool, } #[derive(Debug, Serialize, JsonSchema)] struct DeleteVpnClientResponse { sync: vpn::SecretSyncResult, notice: Option, } #[derive(Debug, Clone, Serialize, JsonSchema)] struct ApiNotice { kind: String, code: String, title: String, message: String, detail: String, } #[derive(Debug, Serialize, JsonSchema)] struct ClientServerConfigResponse { endpoint_id: String, endpoint_name: String, endpoint: String, config: String, vpn_url: String, qr_payload: String, qr_svg: String, } #[derive(Debug, Serialize, JsonSchema)] struct ClientConfigResponse { id: i64, name: String, servers: Vec, } #[derive(Debug, Deserialize, JsonSchema)] struct ClientPath { id: i64, } #[derive(Debug, Serialize, JsonSchema)] struct TelegramLinkStatusResponse { enabled: bool, bot_username: String, bot_url: String, linked: bool, declined: bool, pending: bool, pending_secret: Option, pending_expires_at: Option, telegram_id: Option, } #[derive(Debug, Serialize, JsonSchema)] struct TelegramLinkResponse { status: TelegramLinkStatusResponse, } #[derive(Debug, Deserialize, JsonSchema)] struct TelegramWebAppLinkRequest { init_data: String, } const TELEGRAM_INIT_DATA_MAX_AGE: Duration = Duration::from_secs(86_400); async fn vpn_clients_handler( session: Session, db: Database, ) -> cot::Result { let Some(user) = auth::get_session_user(&session, &db).await else { return Ok(json_error( cot::http::StatusCode::UNAUTHORIZED, "not authenticated", )); }; let clients = vpn::VpnClient::list_visible(&db, &user) .await .map_err(|e| cot::Error::internal(format!("failed to list clients: {e}")))?; let owner_map = owner_view_map(&db, &clients).await?; let clients = clients .into_iter() .map(|client| client_view_with_owner(client, &owner_map)) .collect(); Json(VpnClientsResponse { role: user.role.code().to_owned(), clients, }) .into_response() } async fn vpn_status_handler( session: Session, db: Database, ) -> cot::Result { let user = match auth::require_user_or_redirect(&session, &db).await { Ok(user) => user, Err(_) => { return Ok(json_error( cot::http::StatusCode::UNAUTHORIZED, "not authenticated", )); } }; tracing::debug!(user_id = user.id, "VPN rollout status requested"); let (config, _) = AppConfig::load_with_db(&db).await; let mut status = match vpn::read_rollout_status_from_kubernetes(&config).await { Ok(status) => status, Err(e) => { return Ok(json_error_typed( cot::http::StatusCode::SERVICE_UNAVAILABLE, "vpn_status_unavailable", "Server status unavailable", "Could not load VPN server status.", &e, )); } }; if user.role != auth::Role::Admin { let disabled = vpn::disabled_endpoint_names(&config); status.pods.retain(|pod| !disabled.contains(&pod.node_name)); } Json(status).into_response() } async fn telegram_link_status_handler( session: Session, db: Database, ) -> cot::Result { let user = match api_user_record(&session, &db).await? { Ok(user) => user, Err(response) => return Ok(response), }; let (config, _) = AppConfig::load_with_db(&db).await; Json(telegram_status_response(&config, &user)).into_response() } async fn telegram_link_webapp_handler( session: Session, db: Database, Json(request): Json, ) -> cot::Result { let mut user = match api_user_record(&session, &db).await? { Ok(user) => user, Err(response) => return Ok(response), }; let (config, _) = AppConfig::load_with_db(&db).await; if !config.telegram_bot_enabled { return Ok(json_error_typed( cot::http::StatusCode::CONFLICT, "telegram_disabled", "Telegram is disabled", "Telegram integration is disabled by the administrator.", "", )); } let telegram_user = match telegram::validate_web_app_init_data( &request.init_data, &config.telegram_bot_token, TELEGRAM_INIT_DATA_MAX_AGE, ) { Ok(user) => user, Err(e) => { return Ok(json_error_typed( cot::http::StatusCode::BAD_REQUEST, "telegram_webapp_auth_failed", "Telegram verification failed", "Could not verify Telegram Web App data.", &e.to_string(), )); } }; let telegram_id = telegram_user.id.to_string(); if let Some(response) = link_telegram_id(&db, &mut user, &telegram_id).await? { return Ok(response); } Json(TelegramLinkResponse { status: telegram_status_response(&config, &user), }) .into_response() } async fn telegram_link_start_handler( session: Session, db: Database, ) -> cot::Result { let mut user = match api_user_record(&session, &db).await? { Ok(user) => user, Err(response) => return Ok(response), }; let (config, _) = AppConfig::load_with_db(&db).await; if !config.telegram_bot_enabled { return Ok(json_error_typed( cot::http::StatusCode::CONFLICT, "telegram_disabled", "Telegram is disabled", "Telegram integration is disabled by the administrator.", "", )); } let code = create_unique_telegram_link_code(&db).await?; let now = telegram::now_unix_seconds(); user.set_telegram_link_code(&db, Some(&code), Some(now)) .await .map_err(|e| cot::Error::internal(format!("failed to save Telegram link code: {e}")))?; if user.telegram_id() == Some("") { user.set_telegram_id(&db, None).await.map_err(|e| { cot::Error::internal(format!("failed to reset Telegram preference: {e}")) })?; } Json(TelegramLinkResponse { status: telegram_status_response(&config, &user), }) .into_response() } async fn telegram_link_decline_handler( session: Session, db: Database, ) -> cot::Result { let mut user = match api_user_record(&session, &db).await? { Ok(user) => user, Err(response) => return Ok(response), }; let (config, _) = AppConfig::load_with_db(&db).await; if !config.telegram_bot_enabled { return Ok(json_error_typed( cot::http::StatusCode::CONFLICT, "telegram_disabled", "Telegram is disabled", "Telegram integration is disabled by the administrator.", "", )); } user.decline_telegram_link(&db) .await .map_err(|e| cot::Error::internal(format!("failed to save Telegram preference: {e}")))?; Json(TelegramLinkResponse { status: telegram_status_response(&config, &user), }) .into_response() } async fn telegram_link_unlink_handler( session: Session, db: Database, ) -> cot::Result { let mut user = match api_user_record(&session, &db).await? { Ok(user) => user, Err(response) => return Ok(response), }; let (config, _) = AppConfig::load_with_db(&db).await; user.clear_telegram_link(&db) .await .map_err(|e| cot::Error::internal(format!("failed to clear Telegram link: {e}")))?; Json(TelegramLinkResponse { status: telegram_status_response(&config, &user), }) .into_response() } async fn create_vpn_client_handler( session: Session, db: Database, Json(request): Json, ) -> cot::Result { let user = match auth::require_user_or_redirect(&session, &db).await { Ok(user) => user, Err(_) => { return Ok(json_error( cot::http::StatusCode::UNAUTHORIZED, "not authenticated", )); } }; let (config, _) = AppConfig::load_with_db(&db).await; let client = match vpn::VpnClient::create_for_owner( &db, user.id, &request.name, &config.vpn_client_cidr, ) .await { Ok(client) => client, Err(e) => { return Ok(json_error_typed( cot::http::StatusCode::BAD_REQUEST, "client_create_failed", "Could not create key", "The key was not created.", &e.to_string(), )); } }; let (sync, notice) = sync_after_client_mutation(&db, &config).await; let owner_map = owner_view_map(&db, std::slice::from_ref(&client)).await?; Json(MutateVpnClientResponse { client: client_view_with_owner(client, &owner_map), sync, notice, }) .into_response() } async fn set_vpn_client_enabled_handler( session: Session, db: Database, Path(path): Path, Json(request): Json, ) -> cot::Result { let user = match auth::require_user_or_redirect(&session, &db).await { Ok(user) => user, Err(_) => { return Ok(json_error( cot::http::StatusCode::UNAUTHORIZED, "not authenticated", )); } }; let Some(mut client) = vpn::VpnClient::get_visible(&db, &user, path.id) .await .map_err(|e| cot::Error::internal(format!("failed to load client: {e}")))? else { return Ok(json_error(cot::http::StatusCode::NOT_FOUND, "not found")); }; client .set_enabled(&db, request.enabled) .await .map_err(|e| cot::Error::internal(format!("failed to update client: {e}")))?; let (config, _) = AppConfig::load_with_db(&db).await; let (sync, notice) = sync_after_client_mutation(&db, &config).await; let owner_map = owner_view_map(&db, std::slice::from_ref(&client)).await?; Json(MutateVpnClientResponse { client: client_view_with_owner(client, &owner_map), sync, notice, }) .into_response() } async fn delete_vpn_client_handler( session: Session, db: Database, Path(path): Path, ) -> cot::Result { let user = match auth::require_user_or_redirect(&session, &db).await { Ok(user) => user, Err(_) => { return Ok(json_error( cot::http::StatusCode::UNAUTHORIZED, "not authenticated", )); } }; let Some(client) = vpn::VpnClient::get_visible(&db, &user, path.id) .await .map_err(|e| cot::Error::internal(format!("failed to load client: {e}")))? else { return Ok(json_error(cot::http::StatusCode::NOT_FOUND, "not found")); }; vpn::VpnClient::delete_by_id(&db, client.id_val()) .await .map_err(|e| cot::Error::internal(format!("failed to delete client: {e}")))?; let (config, _) = AppConfig::load_with_db(&db).await; let (sync, notice) = sync_after_client_mutation(&db, &config).await; Json(DeleteVpnClientResponse { sync, notice }).into_response() } async fn vpn_client_config_handler( session: Session, db: Database, Path(path): Path, ) -> cot::Result { let user = match auth::require_user_or_redirect(&session, &db).await { Ok(user) => user, Err(_) => { return Ok(json_error( cot::http::StatusCode::UNAUTHORIZED, "not authenticated", )); } }; let Some(client) = vpn::VpnClient::get_visible(&db, &user, path.id) .await .map_err(|e| cot::Error::internal(format!("failed to load client: {e}")))? else { return Ok(json_error(cot::http::StatusCode::NOT_FOUND, "not found")); }; let (config, _) = AppConfig::load_with_db(&db).await; let runtime = match vpn::read_runtime_from_kubernetes(&config).await { Ok(runtime) => runtime, Err(e) => { return Ok(json_error_typed( cot::http::StatusCode::SERVICE_UNAVAILABLE, "vpn_runtime_unavailable", "VPN servers are unavailable", "Could not load VPN server list.", &e, )); } }; let endpoints = vpn::filter_enabled_endpoints(runtime.endpoints, &config); if endpoints.is_empty() { return Ok(json_error( cot::http::StatusCode::CONFLICT, "no VPN endpoints are enabled", )); }; let mut servers = Vec::with_capacity(endpoints.len()); for endpoint in endpoints { let config_text = vpn::render_client_config( &client, &runtime.server_public_key, &endpoint.endpoint, &config, ); let qr_payload = vpn::render_vpn_payload( &client, &runtime.server_public_key, &endpoint.display_name, &endpoint.endpoint, &config, ) .map_err(|e| cot::Error::internal(format!("failed to render VPN QR payload: {e}")))?; let vpn_url = vpn::render_vpn_url(&qr_payload); servers.push(ClientServerConfigResponse { endpoint_id: endpoint.name, endpoint_name: endpoint.display_name, endpoint: endpoint.endpoint, config: config_text, qr_svg: render_qr_svg(&qr_payload)?, qr_payload, vpn_url, }); } Json(ClientConfigResponse { id: client.id_val(), name: client.name_str().to_owned(), servers, }) .into_response() } #[derive(Debug, Clone)] struct OwnerView { username: String, display_name: String, } async fn owner_view_map( db: &Database, clients: &[vpn::VpnClient], ) -> cot::Result> { let owner_ids = clients .iter() .map(vpn::VpnClient::owner_user_id) .collect::>(); let users = User::list_all(db) .await .map_err(|e| cot::Error::internal(format!("failed to list users: {e}")))?; Ok(users .into_iter() .filter(|user| owner_ids.contains(&user.id_val())) .map(|user| { ( user.id_val(), OwnerView { username: user.username_str().to_owned(), display_name: user.display_name_str(), }, ) }) .collect()) } fn client_view_with_owner( client: vpn::VpnClient, owner_map: &HashMap, ) -> vpn::VpnClientView { let mut view = client.view(); if let Some(owner) = owner_map.get(&view.owner_user_id) { view.owner_username = owner.username.clone(); view.owner_display_name = owner.display_name.clone(); } view } async fn api_user_record( session: &Session, db: &Database, ) -> cot::Result> { let Some(auth_user) = auth::get_session_user(session, db).await else { return Ok(Err(json_error( cot::http::StatusCode::UNAUTHORIZED, "not authenticated", ))); }; let Some(user) = User::get_by_id(db, auth_user.id) .await .map_err(|e| cot::Error::internal(format!("failed to load user: {e}")))? else { return Ok(Err(json_error( cot::http::StatusCode::UNAUTHORIZED, "not authenticated", ))); }; Ok(Ok(user)) } fn telegram_status_response(config: &AppConfig, user: &User) -> TelegramLinkStatusResponse { let bot_username = normalized_telegram_bot_username(&config.telegram_bot_username); let telegram_id = user.telegram_id().map(str::to_owned); let linked = telegram_id .as_deref() .is_some_and(|value| !value.is_empty()); let declined = telegram_id.as_deref() == Some(""); let pending = user.telegram_link_code().is_some() && telegram::link_code_is_active(user.telegram_link_code_created_at()); let pending_secret = pending .then(|| user.telegram_link_code().map(str::to_owned)) .flatten(); let pending_expires_at = pending .then(|| { user.telegram_link_code_created_at() .map(telegram::link_code_expires_at) }) .flatten(); TelegramLinkStatusResponse { enabled: config.telegram_bot_enabled, bot_url: telegram_bot_url(&bot_username), bot_username, linked, declined, pending, pending_secret, pending_expires_at, telegram_id: linked.then_some(telegram_id).flatten(), } } fn normalized_telegram_bot_username(username: &str) -> String { username.trim().trim_start_matches('@').trim().to_owned() } fn telegram_bot_url(username: &str) -> String { if username.is_empty() { String::new() } else { format!("https://t.me/{username}") } } async fn create_unique_telegram_link_code(db: &Database) -> cot::Result { for _ in 0..8 { let code = telegram::generate_link_code().map_err(cot::Error::internal)?; let existing = User::get_by_telegram_link_code(db, &code) .await .map_err(|e| { cot::Error::internal(format!("failed to check Telegram link code: {e}")) })?; if existing.is_none() { return Ok(code); } } Err(cot::Error::internal( "failed to generate unique Telegram link code", )) } async fn link_telegram_id( db: &Database, user: &mut User, telegram_id: &str, ) -> cot::Result> { if let Some(existing) = User::get_by_telegram_id(db, telegram_id) .await .map_err(|e| cot::Error::internal(format!("failed to check Telegram ID: {e}")))? { if existing.id_val() != user.id_val() { return Ok(Some(json_error_typed( cot::http::StatusCode::CONFLICT, "telegram_id_taken", "Telegram ID is already linked", "This Telegram ID is already connected to another account.", "", ))); } } user.complete_telegram_link(db, telegram_id) .await .map_err(|e| cot::Error::internal(format!("failed to save Telegram ID: {e}")))?; Ok(None) } async fn sync_after_client_mutation( db: &Database, config: &AppConfig, ) -> (vpn::SecretSyncResult, Option) { match vpn::sync_from_database(db, config).await { Ok(sync) => (sync, None), Err(e) => { tracing::warn!(error = %e, "client data changed but Secret sync failed"); ( vpn::SecretSyncResult { changed: false, message: "client data saved; Secret sync failed".to_owned(), }, Some(ApiNotice { kind: "warning".to_owned(), code: "secret_sync_failed".to_owned(), title: "Saved locally".to_owned(), message: "The key list was updated, but VPN servers did not receive the new config yet.".to_owned(), detail: e, }), ) } } } fn render_qr_svg(value: &str) -> cot::Result { let code = QrCode::new(value.as_bytes()) .map_err(|e| cot::Error::internal(format!("failed to render QR code: {e}")))?; Ok(code .render::() .min_dimensions(256, 256) .dark_color(svg::Color("#17202a")) .light_color(svg::Color("#ffffff")) .build()) } async fn sync_vpn_clients_handler( session: Session, db: Database, ) -> cot::Result { let user = match auth::require_admin_or_redirect(&session, &db).await { Ok(user) => user, Err(_) => { return Ok(json_error( cot::http::StatusCode::FORBIDDEN, "admin role required", )); } }; tracing::info!( admin_user_id = user.id, "manual client Secret sync requested" ); let (config, _) = AppConfig::load_with_db(&db).await; let sync = match vpn::sync_from_database(&db, &config).await { Ok(sync) => sync, Err(e) => { return Ok(json_error_typed( cot::http::StatusCode::SERVICE_UNAVAILABLE, "secret_sync_failed", "Secret sync failed", "Could not apply client config to VPN servers.", &e, )); } }; Json(sync).into_response() } // --------------------------------------------------------------------------- // App // --------------------------------------------------------------------------- pub struct ApiApp; impl App for ApiApp { fn name(&self) -> &'static str { "api" } fn router(&self) -> Router { Router::with_urls([ Route::with_api_handler_and_name("/me", api_get(me_handler), "api_me"), Route::with_api_handler_and_name( "/vpn-clients", api_get(vpn_clients_handler).post(create_vpn_client_handler), "api_vpn_clients", ), Route::with_api_handler_and_name( "/vpn-status", api_get(vpn_status_handler), "api_vpn_status", ), Route::with_api_handler_and_name( "/telegram-link/status", api_get(telegram_link_status_handler), "api_telegram_link_status", ), Route::with_api_handler_and_name( "/telegram-link/webapp", api_post(telegram_link_webapp_handler), "api_telegram_link_webapp", ), Route::with_api_handler_and_name( "/telegram-link/start", api_post(telegram_link_start_handler), "api_telegram_link_start", ), Route::with_api_handler_and_name( "/telegram-link/decline", api_post(telegram_link_decline_handler), "api_telegram_link_decline", ), Route::with_api_handler_and_name( "/telegram-link/unlink", api_post(telegram_link_unlink_handler), "api_telegram_link_unlink", ), Route::with_api_handler_and_name( "/vpn-clients/sync", api_post(sync_vpn_clients_handler), "api_vpn_clients_sync", ), Route::with_api_handler_and_name( "/vpn-clients/{id}/enabled", api_post(set_vpn_client_enabled_handler), "api_vpn_client_enabled", ), Route::with_api_handler_and_name( "/vpn-clients/{id}", api_delete(delete_vpn_client_handler), "api_vpn_client_delete", ), Route::with_api_handler_and_name( "/vpn-clients/{id}/config", api_get(vpn_client_config_handler), "api_vpn_client_config", ), ]) } }