Added telegram bot
Build and Publish / Build and Publish Docker Image (push) Successful in 3m8s

This commit is contained in:
Ultradesu
2026-06-30 18:36:53 +03:00
parent 3def3afeda
commit 3a4bc23a58
12 changed files with 915 additions and 19 deletions
Generated
+4
View File
@@ -67,6 +67,8 @@ dependencies = [
"cot",
"curve25519-dalek",
"getrandom 0.3.4",
"hex",
"hmac",
"k8s-openapi",
"kube",
"miniz_oxide",
@@ -76,9 +78,11 @@ dependencies = [
"schemars 0.9.0",
"serde",
"serde_json",
"sha2",
"tokio",
"tracing",
"tracing-subscriber",
"url",
]
[[package]]
+5 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "amnezia-fellow"
version = "0.1.3"
version = "0.1.4"
edition = "2024"
description = "Amnezia VPN client manager with SSO, SQLite, and Kubernetes Secret sync"
@@ -19,5 +19,9 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
curve25519-dalek = "4.1"
getrandom = "0.3"
hex = "0.4"
hmac = "0.12"
kube = { version = "3.1.0", default-features = false, features = ["client", "rustls-tls", "ring"] }
k8s-openapi = { version = "0.27.1", features = ["v1_32"] }
sha2 = "0.10"
url = "2"
+18
View File
@@ -114,6 +114,24 @@ All settings use `AMNEZIA_FELLOW_` prefix. Priority is:
| `AMNEZIA_FELLOW_VPN_DNS` | DNS servers in generated configs | `1.1.1.1, 8.8.8.8` |
| `AMNEZIA_FELLOW_VPN_MTU` | MTU in generated configs | `1376` |
| `AMNEZIA_FELLOW_SWAGGER_ENABLED` | Serve Swagger UI at `/swagger/` | `false` |
| `AMNEZIA_FELLOW_TELEGRAM_BOT_ENABLED` | Enable Telegram bot/Web App integration | `false` |
| `AMNEZIA_FELLOW_TELEGRAM_BOT_USERNAME` | Telegram bot username, with or without `@` | empty |
| `AMNEZIA_FELLOW_TELEGRAM_BOT_TOKEN` | Telegram bot token used to verify Web App `initData` | empty |
## Telegram Bot
Configure the bot through the admin Settings page or the matching environment variables:
```bash
AMNEZIA_FELLOW_TELEGRAM_BOT_ENABLED=true
AMNEZIA_FELLOW_TELEGRAM_BOT_USERNAME=<BOT_USERNAME>
AMNEZIA_FELLOW_TELEGRAM_BOT_TOKEN=<BOT_TOKEN>
```
In BotFather, create or reuse the same bot and set its Web App URL to
`https://<APP_HOST>/configs`. The bot should reply with the sender's numeric
Telegram ID, and users must open the bot and send `/start` once before
notifications can be delivered.
## API
+97 -1
View File
@@ -180,6 +180,21 @@ fn config_display_entries(config: &AppConfig, sources: &ConfigSources) -> Vec<Co
config.swagger_enabled.to_string(),
defaults.swagger_enabled.to_string()
),
entry!(
telegram_bot_enabled,
config.telegram_bot_enabled.to_string(),
defaults.telegram_bot_enabled.to_string()
),
entry!(
telegram_bot_username,
config.telegram_bot_username.clone(),
defaults.telegram_bot_username.clone()
),
entry!(
telegram_bot_token,
config.telegram_bot_token.clone(),
defaults.telegram_bot_token.clone()
),
]
}
@@ -283,6 +298,9 @@ pub struct AdminSettingsRequest {
vpn_dns: String,
vpn_mtu: u16,
swagger_enabled: bool,
telegram_bot_enabled: bool,
telegram_bot_username: String,
telegram_bot_token: String,
}
#[derive(Debug, Serialize)]
@@ -749,15 +767,90 @@ fn settings_fields(config: &AppConfig, sources: &ConfigSources) -> Vec<AdminSett
config.swagger_enabled.to_string(),
defaults.swagger_enabled.to_string()
),
field!(
"telegram",
"bool",
telegram_bot_enabled,
config.telegram_bot_enabled.to_string(),
defaults.telegram_bot_enabled.to_string()
),
field!(
"telegram",
"text",
telegram_bot_username,
config.telegram_bot_username.clone(),
defaults.telegram_bot_username.clone()
),
field!(
"telegram",
"password",
telegram_bot_token,
config.telegram_bot_token.clone(),
defaults.telegram_bot_token.clone()
),
AdminSettingField {
key: "telegram_setup_instructions".into(),
env_var: String::new(),
value: telegram_setup_instructions(config),
default_value: String::new(),
source: "default",
secret: false,
kind: "info",
section: "telegram",
},
]
}
fn telegram_setup_instructions(config: &AppConfig) -> String {
let username = config
.telegram_bot_username
.trim()
.trim_start_matches('@')
.trim();
let username_value = if username.is_empty() {
"<BOT_USERNAME>"
} else {
username
};
let token_value = if config.telegram_bot_token.trim().is_empty() {
"<BOT_TOKEN>"
} else {
"<configured bot token>"
};
let bot_link = if username.is_empty() {
"https://t.me/<BOT_USERNAME>".to_owned()
} else {
format!("https://t.me/{username}")
};
let current_bot = if username.is_empty() {
"<BOT_USERNAME>".to_owned()
} else if config.telegram_bot_enabled {
format!("@{username}")
} else {
format!("@{username} (disabled)")
};
format!(
"Current bot: {current_bot}\n\
Bot link: {bot_link}\n\n\
Config:\n\
AMNEZIA_FELLOW_TELEGRAM_BOT_ENABLED=true\n\
AMNEZIA_FELLOW_TELEGRAM_BOT_USERNAME={username_value}\n\
AMNEZIA_FELLOW_TELEGRAM_BOT_TOKEN={token_value}\n\n\
BotFather:\n\
1. Create or reuse this bot and set the Web App URL to https://<APP_HOST>/configs.\n\
2. Make the bot reply with the sender numeric Telegram ID.\n\
3. Users must open the bot and send /start once before notifications can work."
)
}
async fn save_settings_request(db: &Database, data: &AdminSettingsRequest) -> cot::Result<()> {
let vpn_mtu = data.vpn_mtu.to_string();
let auth_password_enabled = data.auth_password_enabled.to_string();
let auth_sso_enabled = data.auth_sso_enabled.to_string();
let swagger_enabled = data.swagger_enabled.to_string();
let fields: [(&str, &str); 19] = [
let telegram_bot_enabled = data.telegram_bot_enabled.to_string();
let fields: [(&str, &str); 22] = [
("auth_password_enabled", &auth_password_enabled),
("auth_sso_enabled", &auth_sso_enabled),
("oidc_button_text", &data.oidc_button_text),
@@ -780,6 +873,9 @@ async fn save_settings_request(db: &Database, data: &AdminSettingsRequest) -> co
("vpn_dns", &data.vpn_dns),
("vpn_mtu", &vpn_mtu),
("swagger_enabled", &swagger_enabled),
("telegram_bot_enabled", &telegram_bot_enabled),
("telegram_bot_username", &data.telegram_bot_username),
("telegram_bot_token", &data.telegram_bot_token),
];
for (key, value) in fields {
let mut entry = ConfigEntry::new(key.to_owned(), value.to_owned());
+267 -1
View File
@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::time::Duration;
use cot::db::Database;
use cot::json::Json;
@@ -15,7 +16,7 @@ use serde::{Deserialize, Serialize};
use crate::config::AppConfig;
use crate::user::User;
use crate::{auth, vpn};
use crate::{auth, telegram, vpn};
// ---------------------------------------------------------------------------
// JSON error helper
@@ -143,6 +144,33 @@ struct ClientPath {
id: i64,
}
#[derive(Debug, Serialize, JsonSchema)]
struct TelegramLinkStatusResponse {
enabled: bool,
bot_username: String,
bot_url: String,
linked: bool,
declined: bool,
telegram_id: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
struct TelegramLinkResponse {
status: TelegramLinkStatusResponse,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct TelegramWebAppLinkRequest {
init_data: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct TelegramManualLinkRequest {
telegram_id: String,
}
const TELEGRAM_INIT_DATA_MAX_AGE: Duration = Duration::from_secs(86_400);
async fn vpn_clients_handler(
session: Session,
db: Database,
@@ -206,6 +234,129 @@ async fn vpn_status_handler(
Json(status).into_response()
}
async fn telegram_link_status_handler(
session: Session,
db: Database,
) -> cot::Result<cot::response::Response> {
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<TelegramWebAppLinkRequest>,
) -> cot::Result<cot::response::Response> {
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_manual_handler(
session: Session,
db: Database,
Json(request): Json<TelegramManualLinkRequest>,
) -> cot::Result<cot::response::Response> {
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_id = match normalize_manual_telegram_id(&request.telegram_id) {
Ok(value) => value,
Err(response) => return Ok(response),
};
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_decline_handler(
session: Session,
db: Database,
) -> cot::Result<cot::response::Response> {
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.set_telegram_id(&db, Some(""))
.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 create_vpn_client_handler(
session: Session,
db: Database,
@@ -446,6 +597,101 @@ fn client_view_with_owner(
view
}
async fn api_user_record(
session: &Session,
db: &Database,
) -> cot::Result<Result<User, cot::response::Response>> {
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("");
TelegramLinkStatusResponse {
enabled: config.telegram_bot_enabled,
bot_url: telegram_bot_url(&bot_username),
bot_username,
linked,
declined,
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}")
}
}
fn normalize_manual_telegram_id(telegram_id: &str) -> Result<String, cot::response::Response> {
let telegram_id = telegram_id.trim();
if telegram_id.is_empty()
|| telegram_id.len() > 32
|| !telegram_id.bytes().all(|byte| byte.is_ascii_digit())
{
return Err(json_error_typed(
cot::http::StatusCode::BAD_REQUEST,
"telegram_id_invalid",
"Telegram ID is invalid",
"Paste the numeric Telegram ID returned by the bot.",
"",
));
}
Ok(telegram_id.to_owned())
}
async fn link_telegram_id(
db: &Database,
user: &mut User,
telegram_id: &str,
) -> cot::Result<Option<cot::response::Response>> {
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.set_telegram_id(db, Some(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,
@@ -541,6 +787,26 @@ impl App for ApiApp {
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/manual",
api_post(telegram_link_manual_handler),
"api_telegram_link_manual",
),
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(
"/vpn-clients/sync",
api_post(sync_vpn_clients_handler),
+21
View File
@@ -107,6 +107,9 @@ pub struct ConfigSources {
pub vpn_dns: ConfigSource,
pub vpn_mtu: ConfigSource,
pub swagger_enabled: ConfigSource,
pub telegram_bot_enabled: ConfigSource,
pub telegram_bot_username: ConfigSource,
pub telegram_bot_token: ConfigSource,
}
impl Default for ConfigSources {
@@ -133,6 +136,9 @@ impl Default for ConfigSources {
vpn_dns: ConfigSource::Default,
vpn_mtu: ConfigSource::Default,
swagger_enabled: ConfigSource::Default,
telegram_bot_enabled: ConfigSource::Default,
telegram_bot_username: ConfigSource::Default,
telegram_bot_token: ConfigSource::Default,
}
}
}
@@ -230,6 +236,12 @@ pub struct AppConfig {
pub vpn_mtu: u16,
/// Whether the Swagger UI is served at /swagger/.
pub swagger_enabled: bool,
/// Whether Telegram bot/Web App integration is enabled.
pub telegram_bot_enabled: bool,
/// Public bot username, without or with the leading @.
pub telegram_bot_username: String,
/// Bot token used to verify Telegram Web App initData.
pub telegram_bot_token: String,
}
impl Default for AppConfig {
@@ -256,6 +268,9 @@ impl Default for AppConfig {
vpn_dns: "1.1.1.1, 8.8.8.8".into(),
vpn_mtu: 1376,
swagger_enabled: false,
telegram_bot_enabled: false,
telegram_bot_username: String::new(),
telegram_bot_token: String::new(),
}
}
}
@@ -282,6 +297,9 @@ impl_env_overrides!(
vpn_dns,
vpn_mtu,
swagger_enabled,
telegram_bot_enabled,
telegram_bot_username,
telegram_bot_token,
);
impl AppConfig {
@@ -357,6 +375,9 @@ impl AppConfig {
apply_db_field!(vpn_dns);
apply_db_field!(vpn_mtu);
apply_db_field!(swagger_enabled);
apply_db_field!(telegram_bot_enabled);
apply_db_field!(telegram_bot_username);
apply_db_field!(telegram_bot_token);
}
}
+21
View File
@@ -61,6 +61,11 @@ translations! {
// Kubernetes / VPN settings
settings_kubernetes: "Kubernetes" , "Kubernetes";
settings_vpn: "VPN" , "VPN";
settings_telegram: "Telegram" , "Telegram";
settings_telegram_enabled: "Telegram bot enabled" , "Telegram-бот включён";
settings_telegram_username: "Bot username" , "Username бота";
settings_telegram_token: "Bot token" , "Токен бота";
settings_telegram_setup_instructions: "Bot setup guide" , "Инструкция настройки бота";
// User management
nav_users: "Users" , "Пользователи";
@@ -155,6 +160,22 @@ translations! {
notice_server_list_error_title: "Server list unavailable" , "Список серверов недоступен";
notice_server_list_error_message: "Could not load VPN servers." , "Не удалось загрузить VPN-серверы.";
notice_detail: "Detail" , "Детали";
telegram_link_title: "Connect Telegram" , "Подключить Telegram";
telegram_webapp_message: "Save this Telegram account for VPN notifications and bot control." , "Сохранить этот Telegram-аккаунт для уведомлений и управления через бота.";
telegram_manual_message: "You can connect Telegram once and manage VPN keys from the bot." , "Можно один раз подключить Telegram и управлять VPN-ключами через бота.";
telegram_connect: "Connect" , "Подключить";
telegram_skip: "Do not ask again" , "Больше не предлагать";
telegram_guide_title: "How to get your Telegram ID" , "Как узнать Telegram ID";
telegram_open_bot: "Open bot" , "Открыть бота";
telegram_guide_start: "Open the bot and send /start once." , "Откройте бота и один раз отправьте /start.";
telegram_guide_get_id: "Ask the bot for your ID; it will reply with only the number." , "Напишите боту, он ответит только номером вашего ID.";
telegram_guide_paste: "Paste that number here and save it." , "Вставьте этот номер сюда и сохраните.";
telegram_id_label: "Telegram ID" , "Telegram ID";
telegram_id_placeholder: "Only digits" , "Только цифры";
telegram_save: "Save Telegram" , "Сохранить Telegram";
telegram_saved: "Telegram connected." , "Telegram подключён.";
telegram_declined: "Telegram prompt disabled." , "Предложение Telegram отключено.";
telegram_bot_unconfigured: "bot is not configured" , "бот не настроен";
// VPN server management
servers_empty: "No registered servers." , "Нет зарегистрированных серверов.";
+6
View File
@@ -4,6 +4,7 @@ mod auth;
mod config;
mod i18n;
mod oidc;
mod telegram;
mod user;
mod vpn;
@@ -319,6 +320,11 @@ impl Project for AmneziaFellowProject {
" API:\n",
" AMNEZIA_FELLOW_SWAGGER_ENABLED Enable Swagger UI at /swagger/ (default: false)\n",
"\n",
" Telegram:\n",
" AMNEZIA_FELLOW_TELEGRAM_BOT_ENABLED Enable Telegram bot/Web App integration (default: false)\n",
" AMNEZIA_FELLOW_TELEGRAM_BOT_USERNAME Telegram bot username, with or without @\n",
" AMNEZIA_FELLOW_TELEGRAM_BOT_TOKEN Telegram bot token for Web App initData verification\n",
"\n",
"QUICK START\n",
" export AMNEZIA_FELLOW_DATABASE_URL=sqlite://amnezia-fellow.sqlite3?mode=rwc\n",
" amnezia-fellow --listen 127.0.0.1:8000",
+212
View File
@@ -0,0 +1,212 @@
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use hmac::{Hmac, Mac};
use serde::Deserialize;
use sha2::Sha256;
use url::form_urlencoded;
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct TelegramWebAppUser {
pub id: i64,
pub username: Option<String>,
pub first_name: Option<String>,
pub last_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TelegramAuthError {
MissingHash,
MissingAuthDate,
MissingUser,
InvalidHash,
InvalidSignature,
InvalidAuthDate,
Expired,
FromFuture,
InvalidUser,
EmptyBotToken,
}
impl std::fmt::Display for TelegramAuthError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let message = match self {
Self::MissingHash => "Telegram initData is missing hash",
Self::MissingAuthDate => "Telegram initData is missing auth_date",
Self::MissingUser => "Telegram initData is missing user",
Self::InvalidHash => "Telegram initData hash is invalid",
Self::InvalidSignature => "Telegram initData signature does not match bot token",
Self::InvalidAuthDate => "Telegram initData auth_date is invalid",
Self::Expired => "Telegram initData is too old",
Self::FromFuture => "Telegram initData auth_date is in the future",
Self::InvalidUser => "Telegram initData user payload is invalid",
Self::EmptyBotToken => "Telegram bot token is not configured",
};
f.write_str(message)
}
}
impl std::error::Error for TelegramAuthError {}
pub fn validate_web_app_init_data(
init_data: &str,
bot_token: &str,
max_age: Duration,
) -> Result<TelegramWebAppUser, TelegramAuthError> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| TelegramAuthError::InvalidAuthDate)?
.as_secs();
validate_web_app_init_data_at(init_data, bot_token, max_age, now)
}
fn validate_web_app_init_data_at(
init_data: &str,
bot_token: &str,
max_age: Duration,
now: u64,
) -> Result<TelegramWebAppUser, TelegramAuthError> {
if bot_token.trim().is_empty() {
return Err(TelegramAuthError::EmptyBotToken);
}
let mut params = form_urlencoded::parse(init_data.as_bytes())
.into_owned()
.collect::<Vec<_>>();
let hash = params
.iter()
.find_map(|(key, value)| (key == "hash").then_some(value.as_str()))
.ok_or(TelegramAuthError::MissingHash)?;
let expected_hash = hex::decode(hash).map_err(|_| TelegramAuthError::InvalidHash)?;
params.retain(|(key, _)| key != "hash");
params.sort_by(|left, right| left.0.cmp(&right.0));
let data_check_string = params
.iter()
.map(|(key, value)| format!("{key}={value}"))
.collect::<Vec<_>>()
.join("\n");
verify_signature(bot_token, &data_check_string, &expected_hash)?;
let auth_date = params
.iter()
.find_map(|(key, value)| (key == "auth_date").then_some(value.as_str()))
.ok_or(TelegramAuthError::MissingAuthDate)?
.parse::<u64>()
.map_err(|_| TelegramAuthError::InvalidAuthDate)?;
if auth_date > now.saturating_add(300) {
return Err(TelegramAuthError::FromFuture);
}
if now.saturating_sub(auth_date) > max_age.as_secs() {
return Err(TelegramAuthError::Expired);
}
let user_json = params
.iter()
.find_map(|(key, value)| (key == "user").then_some(value.as_str()))
.ok_or(TelegramAuthError::MissingUser)?;
serde_json::from_str(user_json).map_err(|_| TelegramAuthError::InvalidUser)
}
fn verify_signature(
bot_token: &str,
data_check_string: &str,
expected_hash: &[u8],
) -> Result<(), TelegramAuthError> {
let mut secret_mac =
HmacSha256::new_from_slice(b"WebAppData").expect("HMAC accepts any key length");
secret_mac.update(bot_token.as_bytes());
let secret_key = secret_mac.finalize().into_bytes();
let mut data_mac =
HmacSha256::new_from_slice(&secret_key).expect("HMAC accepts any key length");
data_mac.update(data_check_string.as_bytes());
data_mac
.verify_slice(expected_hash)
.map_err(|_| TelegramAuthError::InvalidSignature)
}
#[cfg(test)]
mod tests {
use super::*;
fn signed_init_data(bot_token: &str, auth_date: u64, user_json: &str) -> String {
let data_check_string =
format!("auth_date={auth_date}\nquery_id=AAEAAAE\nuser={user_json}");
let mut secret_mac =
HmacSha256::new_from_slice(b"WebAppData").expect("HMAC accepts any key length");
secret_mac.update(bot_token.as_bytes());
let secret_key = secret_mac.finalize().into_bytes();
let mut data_mac =
HmacSha256::new_from_slice(&secret_key).expect("HMAC accepts any key length");
data_mac.update(data_check_string.as_bytes());
let hash = hex::encode(data_mac.finalize().into_bytes());
let mut serializer = form_urlencoded::Serializer::new(String::new());
serializer.append_pair("query_id", "AAEAAAE");
serializer.append_pair("user", user_json);
serializer.append_pair("auth_date", &auth_date.to_string());
serializer.append_pair("hash", &hash);
serializer.finish()
}
#[test]
fn validates_signed_web_app_init_data() {
let token = "123456:fake-token";
let init_data = signed_init_data(
token,
1_700_000_000,
r#"{"id":42,"first_name":"Alice","username":"alice"}"#,
);
let user = validate_web_app_init_data_at(
&init_data,
token,
Duration::from_secs(86_400),
1_700_000_001,
)
.unwrap();
assert_eq!(user.id, 42);
assert_eq!(user.username.as_deref(), Some("alice"));
}
#[test]
fn rejects_wrong_bot_token() {
let init_data = signed_init_data(
"123456:fake-token",
1_700_000_000,
r#"{"id":42,"first_name":"Alice"}"#,
);
let err = validate_web_app_init_data_at(
&init_data,
"123456:other-token",
Duration::from_secs(86_400),
1_700_000_001,
)
.unwrap_err();
assert_eq!(err, TelegramAuthError::InvalidSignature);
}
#[test]
fn rejects_expired_init_data() {
let token = "123456:fake-token";
let init_data = signed_init_data(token, 1_700_000_000, r#"{"id":42}"#);
let err = validate_web_app_init_data_at(
&init_data,
token,
Duration::from_secs(60),
1_700_000_061,
)
.unwrap_err();
assert_eq!(err, TelegramAuthError::Expired);
}
}
+64
View File
@@ -17,6 +17,7 @@ pub struct User {
email: Option<String>,
display_name: Option<String>,
avatar_url: Option<String>,
telegram_id: Option<String>,
role: LimitedString<32>,
is_active: bool,
}
@@ -53,6 +54,7 @@ impl User {
email: email.map(str::to_owned),
display_name: display_name.map(str::to_owned),
avatar_url: None,
telegram_id: None,
role: LimitedString::new(role).unwrap(),
is_active: true,
};
@@ -75,6 +77,7 @@ impl User {
email: email.map(str::to_owned),
display_name: display_name.map(str::to_owned),
avatar_url: None,
telegram_id: None,
role: LimitedString::new(role).unwrap(),
is_active: true,
};
@@ -117,6 +120,21 @@ impl User {
cot::db::query!(User, $email == Some(email)).get(db).await
}
/// Find a user linked to a non-empty Telegram ID.
pub async fn get_by_telegram_id(
db: &Database,
telegram_id: &str,
) -> cot::db::Result<Option<Self>> {
let telegram_id = telegram_id.trim();
if telegram_id.is_empty() {
return Ok(None);
}
let telegram_id = telegram_id.to_owned();
cot::db::query!(User, $telegram_id == Some(telegram_id))
.get(db)
.await
}
/// Count all users in the database.
pub async fn count_all(db: &Database) -> cot::db::Result<u64> {
Self::objects().count(db).await
@@ -140,6 +158,16 @@ impl User {
self.save(db).await
}
/// Store a Telegram link state. `Some("")` means the user declined linking.
pub async fn set_telegram_id(
&mut self,
db: &Database,
telegram_id: Option<&str>,
) -> cot::db::Result<()> {
self.telegram_id = telegram_id.map(str::to_owned);
self.save(db).await
}
/// Delete this user by primary key.
pub async fn delete_by_id(db: &Database, user_id: i64) -> cot::db::Result<()> {
cot::db::query!(User, $id == Auto::Fixed(user_id))
@@ -165,6 +193,10 @@ impl User {
self.display_name.clone().unwrap_or_default()
}
pub fn telegram_id(&self) -> Option<&str> {
self.telegram_id.as_deref()
}
pub fn role_str(&self) -> &str {
&self.role
}
@@ -391,9 +423,41 @@ pub mod db_migrations {
&[Operation::custom(create_oidc_link_indexes).build()];
}
// -- M0007: Telegram link state on amnezia_fellow__user ----------------
#[cot::db::migrations::migration_op]
async fn add_user_telegram_id(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> {
ctx.db
.raw("ALTER TABLE amnezia_fellow__user ADD COLUMN telegram_id TEXT")
.await?;
ctx.db
.raw(
"CREATE UNIQUE INDEX idx_amnezia_fellow_user_telegram_id \
ON amnezia_fellow__user (telegram_id) \
WHERE telegram_id IS NOT NULL AND telegram_id != ''",
)
.await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0007UserTelegramId;
impl migrations::Migration for M0007UserTelegramId {
const APP_NAME: &'static str = "amnezia_fellow";
const MIGRATION_NAME: &'static str = "m_0007_user_telegram_id";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"amnezia_fellow",
"m_0004_oidc_link_indexes",
)];
const OPERATIONS: &'static [Operation] = &[Operation::custom(add_user_telegram_id).build()];
}
pub const MIGRATIONS: &[&SyncDynMigration] = &[
&M0002CreateUser,
&M0003CreateOidcLink,
&M0004OidcLinkIndexes,
&M0007UserTelegramId,
];
}
+37 -16
View File
@@ -47,6 +47,9 @@
.section-tabs button.active { background: #17202a; color: #fff; border-color: #17202a; }
.settings-grid { display: grid; grid-template-columns: minmax(180px, .7fr) minmax(240px, 1fr) auto; gap: .55rem .75rem; align-items: center; background: #fff; border: 1px solid #dde2e6; border-radius: 6px; padding: .75rem; }
.settings-row { display: contents; }
.settings-info-row { grid-column: 1 / -1; border: 1px solid #dde2e6; border-left: 4px solid #2d6cdf; border-radius: 6px; background: #f8fafc; padding: .75rem; display: grid; gap: .45rem; }
.settings-info-title { color: #1d2733; font-weight: 750; }
.settings-info-text { margin: 0; color: #34414f; white-space: pre-wrap; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .84rem; line-height: 1.45; }
.field-name { font-weight: 650; color: #34414f; overflow-wrap: anywhere; }
.field-help { color: #53606d; font-size: .78rem; margin-top: .15rem; overflow-wrap: anywhere; }
.badge { display: inline-block; padding: .15rem .55rem; border-radius: 4px; font-size: .8rem; font-weight: 650; }
@@ -191,21 +194,33 @@
<template x-if="settings.fields.length > 0">
<div class="settings-grid">
<template x-for="field in visibleSettings()" :key="field.key">
<div class="settings-row">
<div class="field-name">
<span x-text="fieldLabel(field.key)"></span>
<div class="field-help" x-text="field.env_var"></div>
</div>
<div>
<template x-if="field.kind === 'bool'">
<input type="checkbox" x-model="settingsForm[field.key]">
</template>
<template x-if="field.kind !== 'bool'">
<input :type="field.kind === 'password' ? 'password' : field.kind" x-model="settingsForm[field.key]">
</template>
<div class="hint">default: <code x-text="field.default_value || '(empty)'"></code></div>
</div>
<span class="badge" :class="`badge-${field.source}`" x-text="field.source"></span>
<div :class="field.kind === 'info' ? 'settings-info-row' : 'settings-row'">
<template x-if="field.kind === 'info'">
<div>
<div class="settings-info-title" x-text="fieldLabel(field.key)"></div>
<pre class="settings-info-text" x-text="field.value"></pre>
</div>
</template>
<template x-if="field.kind !== 'info'">
<div class="field-name">
<span x-text="fieldLabel(field.key)"></span>
<div class="field-help" x-text="field.env_var"></div>
</div>
</template>
<template x-if="field.kind !== 'info'">
<div>
<template x-if="field.kind === 'bool'">
<input type="checkbox" x-model="settingsForm[field.key]">
</template>
<template x-if="field.kind !== 'bool'">
<input :type="field.kind === 'password' ? 'password' : field.kind" x-model="settingsForm[field.key]">
</template>
<div class="hint">default: <code x-text="field.default_value || '(empty)'"></code></div>
</div>
</template>
<template x-if="field.kind !== 'info'">
<span class="badge" :class="`badge-${field.source}`" x-text="field.source"></span>
</template>
</div>
</template>
</div>
@@ -316,7 +331,7 @@ function adminApp(initialView) {
settings: { fields: [] },
settingsForm: {},
settingsSection: 'auth',
settingSections: ['auth', 'oidc', 'kubernetes', 'vpn', 'api'],
settingSections: ['auth', 'oidc', 'kubernetes', 'vpn', 'api', 'telegram'],
debug: null,
userModalOpen: false,
userForm: {},
@@ -401,6 +416,7 @@ function adminApp(initialView) {
this.settings = await this.request('/admin/api/settings');
const form = {};
for (const field of this.settings.fields) {
if (field.kind === 'info') continue;
form[field.key] = field.kind === 'bool' ? field.value === 'true' : field.value;
}
this.settingsForm = form;
@@ -491,6 +507,7 @@ function adminApp(initialView) {
kubernetes: '{{ t.settings_kubernetes }}',
vpn: '{{ t.settings_vpn }}',
api: '{{ t.settings_api }}',
telegram: '{{ t.settings_telegram }}',
}[section] || section;
},
fieldLabel(key) {
@@ -501,6 +518,10 @@ function adminApp(initialView) {
oidc_admin_groups: '{{ t.settings_oidc_admin_groups }}',
oidc_client_groups: '{{ t.settings_oidc_client_groups }}',
swagger_enabled: '{{ t.settings_swagger }}',
telegram_bot_enabled: '{{ t.settings_telegram_enabled }}',
telegram_bot_username: '{{ t.settings_telegram_username }}',
telegram_bot_token: '{{ t.settings_telegram_token }}',
telegram_setup_instructions: '{{ t.settings_telegram_setup_instructions }}',
}[key] || key;
},
};
+163
View File
@@ -3,6 +3,7 @@
{% block title %}{{ t.configs_portal_heading }} | {{ t.site_name }}{% endblock title %}
{% block head_extra %}
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>
* { box-sizing: border-box; }
@@ -105,6 +106,11 @@
.qr-box { display: grid; place-items: center; padding: .75rem; border: 1px solid #d6dfda; border-radius: 8px; background: #fff; }
.qr-box svg { width: min(280px, 100%); height: auto; display: block; }
.modal-actions { display: flex; justify-content: flex-end; gap: .5rem; flex-wrap: wrap; }
.telegram-sheet { width: min(480px, 100%); }
.telegram-copy { color: #53616c; line-height: 1.45; margin: 0; }
.guide-list { margin: 0; padding-left: 1.2rem; color: #34424b; display: grid; gap: .45rem; line-height: 1.4; }
.guide-list a { color: #18342f; font-weight: 850; }
.guide-list a.disabled { color: #69777f; pointer-events: none; text-decoration: none; }
@media (max-width: 720px) {
.topbar-inner { align-items: flex-start; flex-direction: column; }
.top-actions { width: 100%; justify-content: space-between; }
@@ -324,6 +330,56 @@
</section>
</div>
</template>
<template x-if="telegramModal">
<div class="backdrop" x-cloak>
<section class="sheet telegram-sheet">
<div class="sheet-head">
<div class="sheet-title">
<h2>{{ t.telegram_link_title }}</h2>
<span x-text="telegramBotLabel()"></span>
</div>
</div>
<template x-if="telegramModal === 'webapp'">
<div>
<p class="telegram-copy">{{ t.telegram_webapp_message }}</p>
<div class="modal-actions">
<button type="button" class="secondary" @click="declineTelegramLink()" :disabled="telegramBusy">{{ t.telegram_skip }}</button>
<button type="button" @click="linkTelegramWebApp()" :disabled="telegramBusy">{{ t.telegram_save }}</button>
</div>
</div>
</template>
<template x-if="telegramModal === 'manualPrompt'">
<div>
<p class="telegram-copy">{{ t.telegram_manual_message }}</p>
<div class="modal-actions">
<button type="button" class="secondary" @click="declineTelegramLink()" :disabled="telegramBusy">{{ t.telegram_skip }}</button>
<button type="button" @click="telegramModal = 'manualGuide'">{{ t.telegram_connect }}</button>
</div>
</div>
</template>
<template x-if="telegramModal === 'manualGuide'">
<div class="field">
<p class="eyebrow">{{ t.telegram_guide_title }}</p>
<ol class="guide-list">
<li>{{ t.telegram_guide_start }} <a :href="telegramBotUrl()" target="_blank" rel="noreferrer" :class="{ disabled: !telegram.bot_url }">{{ t.telegram_open_bot }}</a></li>
<li>{{ t.telegram_guide_get_id }}</li>
<li>{{ t.telegram_guide_paste }}</li>
</ol>
<label for="telegram-id">{{ t.telegram_id_label }}</label>
<input id="telegram-id" x-model="telegramManualId" inputmode="numeric" pattern="[0-9]*" placeholder="{{ t.telegram_id_placeholder }}" @keydown.enter.prevent="saveTelegramManual()">
<div class="modal-actions">
<button type="button" class="secondary" @click="declineTelegramLink()" :disabled="telegramBusy">{{ t.telegram_skip }}</button>
<button type="button" @click="saveTelegramManual()" :disabled="telegramBusy || !telegramManualId.trim()">{{ t.telegram_save }}</button>
</div>
</div>
</template>
</section>
</div>
</template>
</div>
<script>
@@ -342,9 +398,25 @@ function clientPortal() {
serverConfigs: {},
serverModal: null,
qrModal: null,
telegram: {
enabled: false,
bot_username: '',
bot_url: '',
linked: false,
declined: false,
telegram_id: null,
isWebApp: false,
initData: '',
},
telegramModal: null,
telegramManualId: '',
telegramBusy: false,
telegramPromptChecked: false,
init() {
this.initTelegramWebApp();
this.load();
this.loadServerStatus();
this.loadTelegramStatus();
this.serverStatusTimer = setInterval(() => this.loadServerStatus(true), 30000);
},
async request(url, options = {}) {
@@ -386,6 +458,97 @@ function clientPortal() {
this.serverStatusBusy = false;
}
},
initTelegramWebApp() {
const webApp = window.Telegram && window.Telegram.WebApp;
if (!webApp) return;
try {
webApp.ready();
webApp.expand();
} catch (_) {}
this.telegram.isWebApp = Boolean(webApp.initData);
this.telegram.initData = webApp.initData || '';
},
async loadTelegramStatus() {
try {
const status = await this.request('/api/telegram-link/status');
this.applyTelegramStatus(status);
this.maybePromptTelegram();
} catch (e) {
console.warn('telegram status failed', e);
}
},
applyTelegramStatus(status) {
this.telegram = {
...this.telegram,
enabled: Boolean(status.enabled),
bot_username: status.bot_username || '',
bot_url: status.bot_url || '',
linked: Boolean(status.linked),
declined: Boolean(status.declined),
telegram_id: status.telegram_id || null,
};
},
maybePromptTelegram() {
if (this.telegramPromptChecked) return;
this.telegramPromptChecked = true;
if (!this.telegram.enabled || this.telegram.linked || this.telegram.declined) return;
this.telegramModal = (this.telegram.isWebApp && this.telegram.initData) ? 'webapp' : 'manualPrompt';
},
async linkTelegramWebApp() {
this.telegramBusy = true;
this.clearNotice();
try {
const data = await this.request('/api/telegram-link/webapp', {
method: 'POST',
body: JSON.stringify({ init_data: this.telegram.initData }),
});
this.applyTelegramStatus(data.status || {});
this.telegramModal = null;
this.setNotice('success', '{{ t.notice_success_title }}', '{{ t.telegram_saved }}');
} catch (e) {
this.showError(e);
} finally {
this.telegramBusy = false;
}
},
async saveTelegramManual() {
this.telegramBusy = true;
this.clearNotice();
try {
const data = await this.request('/api/telegram-link/manual', {
method: 'POST',
body: JSON.stringify({ telegram_id: this.telegramManualId }),
});
this.applyTelegramStatus(data.status || {});
this.telegramManualId = '';
this.telegramModal = null;
this.setNotice('success', '{{ t.notice_success_title }}', '{{ t.telegram_saved }}');
} catch (e) {
this.showError(e);
} finally {
this.telegramBusy = false;
}
},
async declineTelegramLink() {
this.telegramBusy = true;
this.clearNotice();
try {
const data = await this.request('/api/telegram-link/decline', { method: 'POST' });
this.applyTelegramStatus(data.status || {});
this.telegramModal = null;
this.setNotice('success', '{{ t.notice_success_title }}', '{{ t.telegram_declined }}');
} catch (e) {
this.showError(e);
} finally {
this.telegramBusy = false;
}
},
telegramBotUrl() {
return this.telegram.bot_url || '#';
},
telegramBotLabel() {
return this.telegram.bot_username ? `@${this.telegram.bot_username}` : '{{ t.telegram_bot_unconfigured }}';
},
async createClient() {
this.clearNotice();
this.busy = true;