5 Commits
Author SHA1 Message Date
Ultradesu 77cde17ef9 Added telegram bot WEB App
Build and Publish / Build and Publish Docker Image (push) Successful in 6m0s
2026-07-01 12:44:53 +03:00
Ultradesu 3a4bc23a58 Added telegram bot
Build and Publish / Build and Publish Docker Image (push) Successful in 3m8s
2026-06-30 18:36:53 +03:00
Ultradesu 3def3afeda Reworked user portal. devided admin and user UI
Build and Publish / Build and Publish Docker Image (push) Successful in 3m7s
2026-06-30 17:02:56 +03:00
Ultradesu c3db347da6 Changed server widget status texzt 2026-06-29 21:13:50 +03:00
Ultradesu 266b493966 Fix 2026-06-29 21:09:10 +03:00
14 changed files with 2634 additions and 121 deletions
Generated
+5 -1
View File
@@ -61,12 +61,14 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "amnezia-fellow"
version = "0.1.1"
version = "0.1.4"
dependencies = [
"base64 0.22.1",
"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]]
+7 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "amnezia-fellow"
version = "0.1.2"
version = "0.1.5"
edition = "2024"
description = "Amnezia VPN client manager with SSO, SQLite, and Kubernetes Secret sync"
@@ -9,8 +9,8 @@ cot = { version = "0.6.0", default-features = false, features = ["sqlite", "json
schemars = { version = "0.9", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
openidconnect = "4.0"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
tokio = { version = "1", features = ["sync"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1", features = ["rt", "sync", "time"] }
base64 = "0.22"
miniz_oxide = "0.8"
qrcode = "0.14"
@@ -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"
+26
View File
@@ -114,6 +114,27 @@ 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`. This app runs the bot in the same process with
`getUpdates` polling, so do not configure a separate webhook for the same bot.
For browser-based linking, users copy a one-time secret code from the portal and
send it to this bot; the app reads the Telegram sender ID from that message.
Users must open the bot and send `/start` once before notifications can be
delivered.
## API
@@ -122,6 +143,11 @@ The JSON API is session-authenticated:
- `GET /api/me`
- `GET /api/vpn-clients`
- `GET /api/vpn-status`
- `GET /api/telegram-link/status`
- `POST /api/telegram-link/webapp`
- `POST /api/telegram-link/start`
- `POST /api/telegram-link/decline`
- `POST /api/telegram-link/unlink`
- `POST /api/vpn-clients`
- `POST /api/vpn-clients/{id}/enabled`
- `DELETE /api/vpn-clients/{id}`
+98 -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,91 @@ 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. Keep webhook disabled: this process receives bot messages with getUpdates polling.\n\
3. Users open the portal, copy the one-time secret code and send it to this bot.\n\
4. 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 +874,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());
+429 -25
View File
@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::time::Duration;
use cot::db::Database;
use cot::json::Json;
@@ -15,14 +16,35 @@ use serde::{Deserialize, Serialize};
use crate::config::AppConfig;
use crate::user::User;
use crate::{auth, vpn};
use crate::{auth, telegram, vpn};
// ---------------------------------------------------------------------------
// JSON error helper
// ---------------------------------------------------------------------------
fn json_error(status: cot::http::StatusCode, message: &str) -> cot::response::Response {
let body = serde_json::json!({ "error": message });
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")
@@ -76,6 +98,7 @@ struct CreateVpnClientRequest {
struct MutateVpnClientResponse {
client: vpn::VpnClientView,
sync: vpn::SecretSyncResult,
notice: Option<ApiNotice>,
}
#[derive(Debug, Deserialize, JsonSchema)]
@@ -86,6 +109,16 @@ struct SetEnabledRequest {
#[derive(Debug, Serialize, JsonSchema)]
struct DeleteVpnClientResponse {
sync: vpn::SecretSyncResult,
notice: Option<ApiNotice>,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
struct ApiNotice {
kind: String,
code: String,
title: String,
message: String,
detail: String,
}
#[derive(Debug, Serialize, JsonSchema)]
@@ -111,6 +144,31 @@ 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<String>,
pending_expires_at: Option<i64>,
telegram_id: Option<String>,
}
#[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,
@@ -154,13 +212,171 @@ async fn vpn_status_handler(
tracing::debug!(user_id = user.id, "VPN rollout status requested");
let (config, _) = AppConfig::load_with_db(&db).await;
let status = vpn::read_rollout_status_from_kubernetes(&config)
.await
.map_err(|e| cot::Error::internal(format!("failed to read VPN rollout status: {e}")))?;
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<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_start_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.",
"",
));
}
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<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.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<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;
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,
@@ -177,18 +393,32 @@ async fn create_vpn_client_handler(
};
let (config, _) = AppConfig::load_with_db(&db).await;
let client =
vpn::VpnClient::create_for_owner(&db, user.id, &request.name, &config.vpn_client_cidr)
.await
.map_err(|e| cot::Error::internal(format!("failed to create client: {e}")))?;
let sync = vpn::sync_from_database(&db, &config)
.await
.map_err(|e| cot::Error::internal(format!("failed to sync client Secret: {e}")))?;
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()
}
@@ -221,14 +451,13 @@ async fn set_vpn_client_enabled_handler(
.await
.map_err(|e| cot::Error::internal(format!("failed to update client: {e}")))?;
let (config, _) = AppConfig::load_with_db(&db).await;
let sync = vpn::sync_from_database(&db, &config)
.await
.map_err(|e| cot::Error::internal(format!("failed to sync client Secret: {e}")))?;
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()
}
@@ -259,11 +488,9 @@ async fn delete_vpn_client_handler(
.await
.map_err(|e| cot::Error::internal(format!("failed to delete client: {e}")))?;
let (config, _) = AppConfig::load_with_db(&db).await;
let sync = vpn::sync_from_database(&db, &config)
.await
.map_err(|e| cot::Error::internal(format!("failed to sync client Secret: {e}")))?;
let (sync, notice) = sync_after_client_mutation(&db, &config).await;
Json(DeleteVpnClientResponse { sync }).into_response()
Json(DeleteVpnClientResponse { sync, notice }).into_response()
}
async fn vpn_client_config_handler(
@@ -289,9 +516,18 @@ async fn vpn_client_config_handler(
};
let (config, _) = AppConfig::load_with_db(&db).await;
let runtime = vpn::read_runtime_from_kubernetes(&config)
.await
.map_err(|e| cot::Error::internal(format!("failed to read VPN runtime: {e}")))?;
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(
@@ -381,6 +617,140 @@ 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("");
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<String> {
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<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.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<ApiNotice>) {
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<String> {
let code = QrCode::new(value.as_bytes())
.map_err(|e| cot::Error::internal(format!("failed to render QR code: {e}")))?;
@@ -411,9 +781,18 @@ async fn sync_vpn_clients_handler(
);
let (config, _) = AppConfig::load_with_db(&db).await;
let sync = vpn::sync_from_database(&db, &config)
.await
.map_err(|e| cot::Error::internal(format!("failed to sync client Secret: {e}")))?;
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()
}
@@ -442,6 +821,31 @@ 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/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),
+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);
}
}
+65 -1
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" , "Пользователи";
@@ -88,6 +93,7 @@ translations! {
configs_create: "Create" , "Создать";
configs_sync: "Sync Secret" , "Синхронизировать Secret";
configs_rollout_heading: "Apply status" , "Статус применения";
configs_server_status: "Server status" , "Статус серверов";
configs_refresh: "Refresh" , "Обновить";
configs_config_updated: "Config updated" , "Конфиг обновлён";
configs_loading_status: "Loading status..." , "Загрузка статуса...";
@@ -108,9 +114,12 @@ translations! {
configs_config_hash: "Config hash" , "Хэш конфига";
configs_status_applied: "applied" , "применён";
configs_status_starting: "starting" , "стартует";
configs_status_pending_restart: "pending restart" , "ждёт рестарт";
configs_status_pending_apply: "pending apply" , "ждёт применения";
configs_status_error: "reload error" , "ошибка reload";
configs_status_unknown: "unknown" , "неизвестно";
configs_status_online: "online" , "онлайн";
configs_status_offline: "offline" , "оффлайн";
configs_status_unavailable: "status unavailable" , "статус недоступен";
configs_never: "n/a" , "н/д";
configs_servers: "Servers" , "Серверы";
configs_loading_servers: "Loading servers..." , "Загрузка серверов...";
@@ -125,6 +134,61 @@ translations! {
configs_no: "no" , "нет";
configs_empty: "No configs yet." , "Конфигов пока нет.";
configs_name_placeholder: "Client name" , "Имя клиента";
configs_portal_kicker: "Client portal" , "Клиентский портал";
configs_portal_heading: "My VPN keys" , "Мои VPN-ключи";
configs_key_status: "Key status" , "Статус ключей";
configs_active_keys: "Active" , "Активные";
configs_total_keys: "Total" , "Всего";
configs_new_key: "New key" , "Новый ключ";
configs_empty_title: "Create your first key" , "Создайте первый ключ";
configs_empty_hint: "It will appear here after creation." , "После создания он появится здесь.";
configs_enabled_state: "active" , "активен";
configs_disabled_state: "disabled" , "отключён";
configs_copy_link: "Copy" , "Копировать";
configs_qr_code: "QR code" , "QR-код";
configs_choose_server: "Choose server" , "Выбрать сервер";
configs_updated: "Updated" , "Обновлён";
configs_key_ready: "Ready" , "Готов";
notice_success_title: "Done" , "Готово";
notice_warning_title: "Needs attention" , "Нужно внимание";
notice_error_title: "Something went wrong" , "Что-то сломалось";
notice_sync_warning_title: "Saved, but not applied" , "Сохранено, но не применено";
notice_sync_warning_message: "The key list changed, but VPN servers did not receive the new config yet." , "Список ключей изменён, но VPN-серверы пока не получили новый конфиг.";
notice_create_success: "Key created." , "Ключ создан.";
notice_update_success: "Key updated." , "Ключ обновлён.";
notice_delete_success: "Key deleted." , "Ключ удалён.";
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: "Connect Telegram by sending a one-time secret code to the bot." , "Подключите Telegram, отправив одноразовый секретный код боту.";
telegram_connect: "Connect" , "Подключить";
telegram_skip: "Do not ask again" , "Больше не предлагать";
telegram_guide_title: "Send this code to the bot" , "Отправьте этот код боту";
telegram_open_bot: "Open bot" , "Открыть бота";
telegram_guide_start: "Open the bot and send /start once if you have not done it before." , "Откройте бота и один раз отправьте /start, если ещё не делали этого.";
telegram_guide_get_id: "Send the secret code below to the bot." , "Отправьте боту секретный код ниже.";
telegram_guide_paste: "Return here and refresh the status." , "Вернитесь сюда и обновите статус.";
telegram_secret_label: "Secret code" , "Секретный код";
telegram_save: "Save Telegram" , "Сохранить Telegram";
telegram_saved: "Telegram connected." , "Telegram подключён.";
telegram_declined: "Telegram prompt disabled." , "Предложение Telegram отключено.";
telegram_bot_unconfigured: "bot is not configured" , "бот не настроен";
telegram_status_connected: "Telegram connected" , "Telegram подключён";
telegram_status_pending: "Telegram code is waiting" , "Код Telegram ожидает";
telegram_status_empty: "Telegram is not connected" , "Telegram не подключён";
telegram_status_disabled: "Telegram is disabled" , "Telegram отключён";
telegram_change: "Change" , "Сменить";
telegram_delete: "Remove" , "Удалить";
telegram_delete_confirm: "Remove Telegram from this account?" , "Удалить Telegram из этого аккаунта?";
telegram_unlinked: "Telegram removed." , "Telegram удалён.";
telegram_copy_secret: "Copy code" , "Копировать код";
telegram_secret_copied: "Secret code copied." , "Секретный код скопирован.";
telegram_refresh_status: "Check status" , "Проверить статус";
telegram_pending_message: "Waiting for a message with the secret code." , "Жду сообщение с секретным кодом.";
telegram_connected_message: "This account is ready for Telegram bot actions." , "Этот аккаунт готов к действиям через Telegram-бота.";
telegram_not_connected_message: "Telegram is not connected yet." , "Telegram пока не подключён.";
// VPN server management
servers_empty: "No registered servers." , "Нет зарегистрированных серверов.";
+38 -4
View File
@@ -4,6 +4,7 @@ mod auth;
mod config;
mod i18n;
mod oidc;
mod telegram;
mod user;
mod vpn;
@@ -55,6 +56,15 @@ struct ConfigsTemplate {
app_version: &'static str,
}
#[derive(Debug, Template)]
#[template(path = "client_portal.html")]
struct ClientPortalTemplate {
t: &'static Translations,
user_name: String,
user_role: String,
app_version: &'static str,
}
async fn configs_page(
session: Session,
db: Database,
@@ -64,12 +74,30 @@ async fn configs_page(
Ok(user) => user,
Err(response) => return Ok(response),
};
let is_admin = user.role == auth::Role::Admin;
let user_name = user.name;
let user_role = user.role.code().to_owned();
if is_admin {
return Html::new(
ConfigsTemplate {
t: i18n.t,
user_name,
user_role,
is_admin,
app_version: env!("CARGO_PKG_VERSION"),
}
.render()?,
)
.into_response();
}
Html::new(
ConfigsTemplate {
ClientPortalTemplate {
t: i18n.t,
user_name: user.name,
user_role: user.role.code().to_owned(),
is_admin: user.role == auth::Role::Admin,
user_name,
user_role,
app_version: env!("CARGO_PKG_VERSION"),
}
.render()?,
@@ -292,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",
@@ -379,6 +412,7 @@ fn main() -> impl Project {
tracing_subscriber::fmt().with_env_filter(filter).init();
tracing::info!("loaded config: {:?}", app_config);
telegram::spawn_bot_worker(Arc::clone(&app_config));
AmneziaFellowProject { app_config }
}
+578
View File
@@ -0,0 +1,578 @@
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use cot::db::Database;
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use url::form_urlencoded;
use crate::config::AppConfig;
use crate::user::User;
type HmacSha256 = Hmac<Sha256>;
const BOT_POLL_TIMEOUT_SECONDS: u64 = 25;
const BOT_IDLE_SLEEP: Duration = Duration::from_secs(15);
const BOT_ERROR_SLEEP: Duration = Duration::from_secs(5);
pub const TELEGRAM_LINK_CODE_TTL: Duration = Duration::from_secs(15 * 60);
#[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 {}
#[derive(Debug, Deserialize)]
struct TelegramApiResponse<T> {
ok: bool,
result: Option<T>,
description: Option<String>,
error_code: Option<i64>,
}
#[derive(Debug, Deserialize)]
struct TelegramUpdate {
update_id: i64,
message: Option<TelegramMessage>,
}
#[derive(Debug, Deserialize)]
struct TelegramMessage {
chat: TelegramChat,
from: Option<TelegramBotUser>,
text: Option<String>,
}
#[derive(Debug, Deserialize)]
struct TelegramChat {
id: i64,
}
#[derive(Debug, Deserialize)]
struct TelegramBotUser {
id: i64,
is_bot: bool,
}
#[derive(Debug, Serialize)]
struct GetUpdatesRequest {
offset: Option<i64>,
limit: u8,
timeout: u64,
allowed_updates: [&'static str; 1],
}
#[derive(Debug, Serialize)]
struct DeleteWebhookRequest {
drop_pending_updates: bool,
}
#[derive(Debug, Serialize)]
struct SendMessageRequest<'a> {
chat_id: i64,
text: &'a str,
disable_web_page_preview: bool,
}
pub fn spawn_bot_worker(config: Arc<AppConfig>) {
if config.database_url.trim().is_empty() {
tracing::warn!("Telegram bot worker disabled: database URL is empty");
return;
}
let database_url = config.database_url.clone();
tokio::spawn(async move {
run_bot_worker(database_url).await;
});
}
pub fn generate_link_code() -> Result<String, String> {
let mut bytes = [0_u8; 12];
getrandom::fill(&mut bytes)
.map_err(|e| format!("failed to generate Telegram link code: {e}"))?;
Ok(format!("af-{}", hex::encode(bytes)))
}
pub fn now_unix_seconds() -> i64 {
let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or_default();
i64::try_from(seconds).unwrap_or(i64::MAX)
}
pub fn link_code_expires_at(created_at: i64) -> i64 {
let ttl = i64::try_from(TELEGRAM_LINK_CODE_TTL.as_secs()).unwrap_or(i64::MAX);
created_at.saturating_add(ttl)
}
pub fn link_code_is_active(created_at: Option<i64>) -> bool {
created_at
.map(|created_at| now_unix_seconds() <= link_code_expires_at(created_at))
.unwrap_or(false)
}
fn normalize_link_code(text: &str) -> Option<String> {
text.split_whitespace().find_map(|part| {
let candidate = part.trim_matches(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '-'));
let candidate = candidate.to_ascii_lowercase();
let suffix = candidate.strip_prefix("af-")?;
(suffix.len() == 24 && suffix.bytes().all(|byte| byte.is_ascii_hexdigit()))
.then_some(candidate)
})
}
async fn run_bot_worker(database_url: String) {
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(BOT_POLL_TIMEOUT_SECONDS + 10))
.build()
.expect("valid reqwest client");
let mut offset = None;
let mut active_token = String::new();
let mut webhook_deleted = false;
loop {
let db = match Database::new(database_url.clone()).await {
Ok(db) => db,
Err(e) => {
tracing::warn!(error = %e, "Telegram bot worker could not open database");
tokio::time::sleep(BOT_ERROR_SLEEP).await;
continue;
}
};
loop {
let (config, _) = AppConfig::load_with_db(&db).await;
let token = config.telegram_bot_token.trim().to_owned();
if !config.telegram_bot_enabled || token.is_empty() {
offset = None;
active_token.clear();
webhook_deleted = false;
tokio::time::sleep(BOT_IDLE_SLEEP).await;
continue;
}
if active_token != token {
active_token = token.clone();
offset = None;
webhook_deleted = false;
}
if !webhook_deleted {
match delete_webhook(&http, &token).await {
Ok(()) => webhook_deleted = true,
Err(e) => {
tracing::warn!(error = %e, "Telegram bot worker could not delete webhook");
tokio::time::sleep(BOT_ERROR_SLEEP).await;
continue;
}
}
}
match get_updates(&http, &token, offset).await {
Ok(updates) => {
for update in updates {
offset = Some(update.update_id.saturating_add(1));
if let Err(e) = process_update(&db, &http, &token, update).await {
tracing::warn!(error = %e, "Telegram bot update was not processed");
}
}
}
Err(e) => {
tracing::warn!(error = %e, "Telegram bot polling failed");
tokio::time::sleep(BOT_ERROR_SLEEP).await;
}
}
}
}
}
async fn delete_webhook(http: &reqwest::Client, token: &str) -> Result<(), String> {
let url = telegram_api_url(token, "deleteWebhook");
let response = http
.post(url)
.json(&DeleteWebhookRequest {
drop_pending_updates: false,
})
.send()
.await
.map_err(reqwest_error_message)?
.json::<TelegramApiResponse<bool>>()
.await
.map_err(reqwest_error_message)?;
telegram_api_result(response).map(|_| ())
}
async fn get_updates(
http: &reqwest::Client,
token: &str,
offset: Option<i64>,
) -> Result<Vec<TelegramUpdate>, String> {
let url = telegram_api_url(token, "getUpdates");
let response = http
.post(url)
.json(&GetUpdatesRequest {
offset,
limit: 50,
timeout: BOT_POLL_TIMEOUT_SECONDS,
allowed_updates: ["message"],
})
.send()
.await
.map_err(reqwest_error_message)?
.json::<TelegramApiResponse<Vec<TelegramUpdate>>>()
.await
.map_err(reqwest_error_message)?;
telegram_api_result(response)
}
async fn send_message(
http: &reqwest::Client,
token: &str,
chat_id: i64,
text: &str,
) -> Result<(), String> {
let url = telegram_api_url(token, "sendMessage");
let response = http
.post(url)
.json(&SendMessageRequest {
chat_id,
text,
disable_web_page_preview: true,
})
.send()
.await
.map_err(reqwest_error_message)?
.json::<TelegramApiResponse<serde_json::Value>>()
.await
.map_err(reqwest_error_message)?;
telegram_api_result(response).map(|_| ())
}
async fn process_update(
db: &Database,
http: &reqwest::Client,
token: &str,
update: TelegramUpdate,
) -> Result<(), String> {
let Some(message) = update.message else {
return Ok(());
};
let Some(from) = message.from else {
return Ok(());
};
if from.is_bot {
return Ok(());
}
let Some(text) = message.text.as_deref() else {
return Ok(());
};
if text.trim().starts_with("/start") {
send_message(
http,
token,
message.chat.id,
"Open the VPN portal, press the Telegram button and send me the secret code shown there.",
)
.await?;
return Ok(());
}
let Some(code) = normalize_link_code(text) else {
send_message(
http,
token,
message.chat.id,
"I need the secret code from the VPN portal to connect your Telegram account.",
)
.await?;
return Ok(());
};
let Some(mut user) = User::get_by_telegram_link_code(db, &code)
.await
.map_err(|e| format!("failed to load Telegram link code: {e}"))?
else {
send_message(
http,
token,
message.chat.id,
"This code is unknown or already used. Generate a new code in the VPN portal.",
)
.await?;
return Ok(());
};
if !link_code_is_active(user.telegram_link_code_created_at()) {
user.set_telegram_link_code(db, None, None)
.await
.map_err(|e| format!("failed to clear expired Telegram link code: {e}"))?;
send_message(
http,
token,
message.chat.id,
"This code has expired. Generate a new code in the VPN portal.",
)
.await?;
return Ok(());
}
let telegram_id = from.id.to_string();
if let Some(existing) = User::get_by_telegram_id(db, &telegram_id)
.await
.map_err(|e| format!("failed to check Telegram ID: {e}"))?
{
if existing.id_val() != user.id_val() {
send_message(
http,
token,
message.chat.id,
"This Telegram account is already connected to another VPN account.",
)
.await?;
return Ok(());
}
}
user.complete_telegram_link(db, &telegram_id)
.await
.map_err(|e| format!("failed to save Telegram ID: {e}"))?;
send_message(
http,
token,
message.chat.id,
"Telegram connected. You can return to the VPN portal.",
)
.await?;
Ok(())
}
fn telegram_api_url(token: &str, method: &str) -> String {
format!("https://api.telegram.org/bot{token}/{method}")
}
fn telegram_api_result<T>(response: TelegramApiResponse<T>) -> Result<T, String> {
if response.ok {
response
.result
.ok_or_else(|| "Telegram API response did not include result".to_owned())
} else {
Err(match (response.error_code, response.description) {
(Some(code), Some(description)) => format!("Telegram API {code}: {description}"),
(_, Some(description)) => description,
(Some(code), None) => format!("Telegram API error {code}"),
(None, None) => "Telegram API error".to_owned(),
})
}
}
fn reqwest_error_message(error: reqwest::Error) -> String {
if error.is_timeout() {
"request timed out".to_owned()
} else if error.is_connect() {
"connection failed".to_owned()
} else if let Some(status) = error.status() {
format!("HTTP {status}")
} else {
"request failed".to_owned()
}
}
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);
}
}
+174
View File
@@ -17,6 +17,9 @@ pub struct User {
email: Option<String>,
display_name: Option<String>,
avatar_url: Option<String>,
telegram_id: Option<String>,
telegram_link_code: Option<String>,
telegram_link_code_created_at: Option<i64>,
role: LimitedString<32>,
is_active: bool,
}
@@ -53,6 +56,9 @@ impl User {
email: email.map(str::to_owned),
display_name: display_name.map(str::to_owned),
avatar_url: None,
telegram_id: None,
telegram_link_code: None,
telegram_link_code_created_at: None,
role: LimitedString::new(role).unwrap(),
is_active: true,
};
@@ -75,6 +81,9 @@ impl User {
email: email.map(str::to_owned),
display_name: display_name.map(str::to_owned),
avatar_url: None,
telegram_id: None,
telegram_link_code: None,
telegram_link_code_created_at: None,
role: LimitedString::new(role).unwrap(),
is_active: true,
};
@@ -117,6 +126,36 @@ 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
}
/// Find a user waiting for this Telegram link code.
pub async fn get_by_telegram_link_code(
db: &Database,
code: &str,
) -> cot::db::Result<Option<Self>> {
let code = code.trim();
if code.is_empty() {
return Ok(None);
}
let code = code.to_owned();
cot::db::query!(User, $telegram_link_code == Some(code))
.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 +179,56 @@ 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
}
/// Store or clear a pending Telegram link code.
pub async fn set_telegram_link_code(
&mut self,
db: &Database,
code: Option<&str>,
created_at: Option<i64>,
) -> cot::db::Result<()> {
self.telegram_link_code = code.map(str::to_owned);
self.telegram_link_code_created_at = code.and(created_at);
self.save(db).await
}
/// Complete Telegram linking and clear any pending code.
pub async fn complete_telegram_link(
&mut self,
db: &Database,
telegram_id: &str,
) -> cot::db::Result<()> {
self.telegram_id = Some(telegram_id.to_owned());
self.telegram_link_code = None;
self.telegram_link_code_created_at = None;
self.save(db).await
}
/// Remove Telegram credentials and pending link data.
pub async fn clear_telegram_link(&mut self, db: &Database) -> cot::db::Result<()> {
self.telegram_id = None;
self.telegram_link_code = None;
self.telegram_link_code_created_at = None;
self.save(db).await
}
/// Store an explicit opt-out and clear pending link data.
pub async fn decline_telegram_link(&mut self, db: &Database) -> cot::db::Result<()> {
self.telegram_id = Some(String::new());
self.telegram_link_code = None;
self.telegram_link_code_created_at = None;
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 +254,18 @@ impl User {
self.display_name.clone().unwrap_or_default()
}
pub fn telegram_id(&self) -> Option<&str> {
self.telegram_id.as_deref()
}
pub fn telegram_link_code(&self) -> Option<&str> {
self.telegram_link_code.as_deref()
}
pub fn telegram_link_code_created_at(&self) -> Option<i64> {
self.telegram_link_code_created_at
}
pub fn role_str(&self) -> &str {
&self.role
}
@@ -391,9 +492,82 @@ 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()];
}
// -- M0008: pending Telegram link code on amnezia_fellow__user ---------
#[cot::db::migrations::migration_op]
async fn add_user_telegram_link_code(
ctx: migrations::MigrationContext<'_>,
) -> cot::db::Result<()> {
ctx.db
.raw("ALTER TABLE amnezia_fellow__user ADD COLUMN telegram_link_code TEXT")
.await?;
ctx.db
.raw(
"ALTER TABLE amnezia_fellow__user \
ADD COLUMN telegram_link_code_created_at INTEGER",
)
.await?;
ctx.db
.raw(
"CREATE UNIQUE INDEX idx_amnezia_fellow_user_telegram_link_code \
ON amnezia_fellow__user (telegram_link_code) \
WHERE telegram_link_code IS NOT NULL AND telegram_link_code != ''",
)
.await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0008UserTelegramLinkCode;
impl migrations::Migration for M0008UserTelegramLinkCode {
const APP_NAME: &'static str = "amnezia_fellow";
const MIGRATION_NAME: &'static str = "m_0008_user_telegram_link_code";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"amnezia_fellow",
"m_0007_user_telegram_id",
)];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(add_user_telegram_link_code).build()];
}
pub const MIGRATIONS: &[&SyncDynMigration] = &[
&M0002CreateUser,
&M0003CreateOidcLink,
&M0004OidcLinkIndexes,
&M0007UserTelegramId,
&M0008UserTelegramLinkCode,
];
}
+55 -10
View File
@@ -95,15 +95,11 @@ impl VpnClient {
.await
.map_err(db_custom_error)?;
let now = now_timestamp();
let name = if name.trim().is_empty() {
"Amnezia client"
} else {
name.trim()
};
let name = client_name_for_storage(name)?;
let mut client = Self {
id: Auto::auto(),
owner_user_id,
name: LimitedString::new(name).unwrap(),
name,
address: LimitedString::new(address.as_str()).unwrap(),
public_key: LimitedString::new(keypair.public_key.as_str()).unwrap(),
private_key: LimitedString::new(keypair.private_key.as_str()).unwrap(),
@@ -207,7 +203,7 @@ pub fn render_peer_secret(clients: &[VpnClient]) -> String {
"# id={} owner={} name={}\n",
client.id_val(),
client.owner_user_id(),
client.name_str()
escaped_peer_comment_value(client.name_str())
));
out.push_str(&format!("PublicKey = {}\n", client.public_key_str()));
out.push_str(&format!("AllowedIPs = {}/32\n", client.address_str()));
@@ -215,6 +211,26 @@ pub fn render_peer_secret(clients: &[VpnClient]) -> String {
out
}
fn client_name_for_storage(name: &str) -> cot::db::Result<LimitedString<255>> {
let name = if name.is_empty() {
"Amnezia client"
} else {
name
};
LimitedString::new(name.to_owned())
.map_err(|e| db_custom_error(format!("client name is too long: {e}")))
}
fn escaped_peer_comment_value(value: &str) -> String {
let mut escaped = String::with_capacity(value.len() + 2);
escaped.push('"');
for ch in value.chars() {
escaped.extend(ch.escape_default());
}
escaped.push('"');
escaped
}
pub fn render_client_config(
client: &VpnClient,
server_public_key: &str,
@@ -820,10 +836,10 @@ fn rollout_status(
let effective_applied_at_ms = started_at_ms.into_iter().chain(config_applied_at_ms).max();
match (config_updated_at_ms, effective_applied_at_ms) {
(None, _) => "unknown".to_owned(),
(Some(_), None) => "pending_restart".to_owned(),
(Some(_), None) => "pending_apply".to_owned(),
(Some(updated), Some(started)) if started >= updated && ready => "applied".to_owned(),
(Some(updated), Some(started)) if started >= updated => "starting".to_owned(),
(Some(_), Some(_)) => "pending_restart".to_owned(),
(Some(_), Some(_)) => "pending_apply".to_owned(),
}
}
@@ -1062,6 +1078,35 @@ mod tests {
assert_eq!(vpn_url_description(" ", "phone"), "phone");
}
#[test]
fn client_name_for_storage_preserves_weird_names() {
let raw = r#" витя хуй !! "" 233; 3№№## ''' @ "#;
let stored = client_name_for_storage(raw).unwrap();
assert_eq!(stored.to_string(), raw);
}
#[test]
fn peer_secret_escapes_client_name_comment() {
let raw = "витя хуй !! \"\" 233; 3№№## ''' @\nAllowedIPs = 0.0.0.0/0";
let client = VpnClient {
id: Auto::Fixed(7),
owner_user_id: 1,
name: LimitedString::new(raw).unwrap(),
address: LimitedString::new("10.8.0.2").unwrap(),
public_key: LimitedString::new("client-public-key").unwrap(),
private_key: LimitedString::new("client-private-key").unwrap(),
enabled: true,
created_at: LimitedString::new("2026-06-16T00:00:00Z").unwrap(),
updated_at: LimitedString::new("2026-06-16T00:00:00Z").unwrap(),
};
let rendered = render_peer_secret(&[client]);
assert!(rendered.contains(r#"name="\u{432}\u{438}\u{442}\u{44f} "#));
assert!(rendered.contains(r#"\nAllowedIPs = 0.0.0.0/0""#));
assert!(!rendered.contains("\nAllowedIPs = 0.0.0.0/0\n"));
assert_eq!(rendered.matches("[Peer]").count(), 1);
}
#[test]
fn endpoint_name_overrides_parse_json_map() {
let config = AppConfig {
@@ -1088,7 +1133,7 @@ mod tests {
);
assert_eq!(
rollout_status(Some(2_000), Some(1_000), None, None, true),
"pending_restart"
"pending_apply"
);
}
+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;
},
};
+943
View File
@@ -0,0 +1,943 @@
{% extends "base.html" %}
{% 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; }
[x-cloak] { display: none !important; }
body { margin: 0; min-height: 100vh; background: #edf3ef; color: #18232d; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
button, input { font: inherit; }
button { min-height: 40px; border: 1px solid #18342f; border-radius: 8px; padding: .55rem .85rem; background: #18342f; color: #fff; cursor: pointer; font-weight: 750; }
button.secondary { background: #fff; color: #18342f; border-color: #c7d4ce; }
button.ghost { background: transparent; color: #42515d; border-color: transparent; }
button.danger { background: #a8312d; border-color: #a8312d; color: #fff; }
button:disabled { opacity: .48; cursor: default; }
input { width: 100%; min-height: 42px; border: 1px solid #c7d4ce; border-radius: 8px; padding: .55rem .7rem; color: #18232d; background: #fff; }
code { display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border-radius: 6px; background: #f1f4f2; color: #26313a; padding: .18rem .4rem; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .82rem; }
.portal { min-height: 100vh; display: flex; flex-direction: column; }
.topbar { position: sticky; top: 0; z-index: 10; background: rgba(255,255,255,.94); border-bottom: 1px solid #d6dfda; backdrop-filter: blur(12px); }
.topbar-inner { width: min(1040px, 100%); margin: 0 auto; display: flex; align-items: center; justify-content: space-between; gap: .85rem; padding: .75rem 1rem; }
.brand { display: flex; align-items: center; gap: .65rem; min-width: 0; color: inherit; text-decoration: none; }
.brand-mark { width: 34px; height: 34px; border-radius: 8px; display: grid; place-items: center; background: #18342f; color: #cce8da; font-weight: 900; }
.brand-text { display: grid; gap: .05rem; min-width: 0; }
.brand-title { font-weight: 850; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.brand-version { color: #7a858d; font-size: .75rem; }
.top-actions { display: flex; align-items: center; justify-content: flex-end; gap: .35rem; flex-wrap: wrap; }
.user-pill { color: #53616c; font-size: .86rem; max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.telegram-status-button { min-width: 38px; width: 38px; height: 38px; min-height: 38px; padding: 0; display: inline-grid; place-items: center; position: relative; border-color: #c7d4ce; background: #fff; color: #18342f; }
.telegram-status-button .telegram-mark { font-size: .72rem; font-weight: 900; letter-spacing: 0; }
.telegram-dot { position: absolute; right: 5px; top: 5px; width: .56rem; height: .56rem; border-radius: 999px; border: 2px solid #fff; background: #8a969e; }
.telegram-status-button.tone-connected .telegram-dot { background: #2f8f4e; }
.telegram-status-button.tone-pending .telegram-dot { background: #d39a00; }
.telegram-status-button.tone-empty .telegram-dot { background: #69777f; }
.telegram-status-button.tone-disabled .telegram-dot { background: #a8312d; }
.lang-switch { display: inline-flex; align-items: center; border: 1px solid #d6dfda; border-radius: 8px; background: #f7faf8; overflow: hidden; }
.lang-switch a { min-width: 38px; padding: .38rem .5rem; color: #53616c; text-align: center; text-decoration: none; font-size: .82rem; font-weight: 800; }
.lang-switch a.active { background: #18342f; color: #fff; }
.logout-link { color: #53616c; text-decoration: none; font-size: .88rem; font-weight: 750; padding: .45rem .55rem; border-radius: 8px; }
.logout-link:hover { background: #e4ebe7; color: #18232d; }
.page { width: min(1040px, 100%); margin: 0 auto; padding: 1rem; display: grid; gap: 1rem; }
.panel { border: 1px solid #d6dfda; border-radius: 8px; background: #fff; box-shadow: 0 10px 30px rgba(24, 52, 47, .08); }
.intro { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 1rem; align-items: end; padding: 1rem; }
.eyebrow { margin: 0 0 .3rem; color: #5b6b64; font-size: .78rem; font-weight: 850; letter-spacing: 0; text-transform: uppercase; }
h1 { margin: 0; font-size: 2rem; line-height: 1.08; letter-spacing: 0; }
.counters { display: flex; gap: .5rem; flex-wrap: wrap; justify-content: flex-end; }
.counter { min-width: 92px; border: 1px solid #dce5e0; border-radius: 8px; padding: .55rem .65rem; background: #f7faf8; display: grid; gap: .1rem; }
.counter strong { font-size: 1.25rem; line-height: 1; }
.counter span { color: #69777f; font-size: .78rem; font-weight: 750; }
.portal-server-status { display: grid; gap: .45rem; padding: 0 1rem 1rem; }
.portal-server-head { display: flex; align-items: center; justify-content: space-between; gap: .65rem; color: #34424b; font-size: .83rem; font-weight: 850; }
.mini-button { min-height: 28px; border-radius: 7px; padding: .2rem .5rem; background: #fff; color: #18342f; border-color: #c7d4ce; font-size: .76rem; }
.server-status-grid { max-height: 138px; overflow: auto; display: grid; grid-template-columns: repeat(auto-fit, minmax(142px, 1fr)); gap: .4rem; padding-right: .12rem; }
.server-status-chip { min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr); gap: .42rem .5rem; align-items: center; border: 1px solid #dce5e0; border-radius: 8px; background: #f7faf8; padding: .45rem .52rem; }
.server-status-dot { width: .58rem; height: .58rem; border-radius: 999px; background: #8a969e; }
.server-status-chip.tone-online .server-status-dot { background: #2f8f4e; }
.server-status-chip.tone-warning .server-status-dot { background: #d39a00; }
.server-status-chip.tone-error .server-status-dot { background: #a8312d; }
.server-status-chip.tone-offline .server-status-dot { background: #69777f; }
.server-status-text { min-width: 0; display: grid; gap: .08rem; }
.server-status-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #18232d; font-size: .86rem; font-weight: 850; }
.server-status-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #69777f; font-size: .74rem; font-weight: 750; }
.server-status-muted { color: #69777f; font-size: .82rem; }
.create { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .65rem; padding: 0 1rem 1rem; align-items: end; }
.field { display: grid; gap: .32rem; min-width: 0; }
.field label { color: #34424b; font-size: .83rem; font-weight: 800; }
.notice { border: 1px solid #d6dfda; border-left-width: 4px; border-radius: 8px; background: #fff; padding: .75rem .85rem; display: grid; gap: .25rem; box-shadow: 0 8px 24px rgba(24, 52, 47, .06); }
.notice strong { color: #18232d; }
.notice span { color: #53616c; font-size: .9rem; }
.notice details { color: #69777f; font-size: .82rem; }
.notice summary { cursor: pointer; font-weight: 750; }
.notice.tone-success { border-left-color: #2f8f4e; }
.notice.tone-warning { border-left-color: #d39a00; }
.notice.tone-error { border-left-color: #a8312d; }
.empty { padding: 1rem; color: #53616c; }
.empty strong { display: block; margin-bottom: .25rem; color: #18232d; }
.key-list { display: grid; gap: .75rem; }
.key-card { border: 1px solid #d6dfda; border-radius: 8px; background: #fff; padding: .85rem; display: grid; gap: .75rem; }
.key-card.is-disabled { background: #fafafa; }
.key-head { display: flex; align-items: flex-start; justify-content: space-between; gap: .75rem; min-width: 0; }
.key-title { min-width: 0; display: grid; gap: .32rem; }
.key-name { margin: 0; color: #18232d; font-size: 1.08rem; line-height: 1.2; overflow-wrap: anywhere; }
.status-chip { width: max-content; max-width: 100%; display: inline-flex; align-items: center; min-height: 24px; border-radius: 999px; padding: .15rem .5rem; font-size: .78rem; font-weight: 850; }
.status-chip.enabled { color: #155d36; background: #dcefe4; }
.status-chip.disabled { color: #69777f; background: #e8eeeb; }
.meta-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .55rem; }
.meta-item { min-width: 0; display: grid; gap: .24rem; }
.meta-label { color: #69777f; font-size: .77rem; font-weight: 800; }
.key-actions { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: .5rem; align-items: center; }
.key-actions .server-button { justify-self: stretch; }
.key-updated { color: #69777f; font-size: .82rem; overflow-wrap: anywhere; }
.backdrop { position: fixed; inset: 0; z-index: 30; display: grid; place-items: center; padding: 1rem; background: rgba(16, 28, 36, .42); }
.sheet { width: min(720px, 100%); max-height: min(760px, calc(100vh - 2rem)); border: 1px solid #d6dfda; border-radius: 8px; background: #fff; box-shadow: 0 20px 60px rgba(16, 28, 36, .25); padding: 1rem; display: flex; flex-direction: column; gap: .8rem; }
.sheet-head { display: flex; align-items: flex-start; justify-content: space-between; gap: .8rem; }
.sheet-title { min-width: 0; display: grid; gap: .2rem; }
.sheet-title h2 { margin: 0; font-size: 1.18rem; line-height: 1.2; }
.sheet-title span { color: #69777f; font-size: .86rem; overflow-wrap: anywhere; }
.server-list { border: 1px solid #d6dfda; border-radius: 8px; overflow: auto; }
.server-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .65rem; align-items: center; padding: .62rem .7rem; border-bottom: 1px solid #e7eee9; background: #fff; }
.server-row:last-child { border-bottom: 0; }
.server-row:hover { background: #f7faf8; }
.server-main { min-width: 0; display: grid; gap: .28rem; }
.server-main strong { overflow-wrap: anywhere; }
.server-actions { display: flex; align-items: center; gap: .32rem; }
.icon-button { position: relative; width: 36px; min-width: 36px; height: 36px; min-height: 36px; padding: 0; display: grid; place-items: center; border-radius: 8px; }
.icon-button svg { width: 17px; height: 17px; display: block; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.icon-button .qr-mark { font-size: .72rem; line-height: 1; font-weight: 900; letter-spacing: 0; }
.icon-button::after { content: attr(data-tip); position: absolute; right: calc(100% + .35rem); top: 50%; z-index: 4; width: max-content; max-width: 180px; padding: .38rem .5rem; border-radius: 6px; background: #18232d; color: #fff; font-size: .76rem; line-height: 1.2; font-weight: 750; opacity: 0; transform: translateY(-50%); pointer-events: none; transition: opacity .12s ease; }
.icon-button:hover::after, .icon-button:focus-visible::after { opacity: 1; }
.qr-modal { width: min(380px, 100%); border: 1px solid #d6dfda; border-radius: 8px; background: #fff; padding: 1rem; box-shadow: 0 20px 60px rgba(16, 28, 36, .25); display: grid; gap: .8rem; }
.qr-modal h2 { margin: 0; font-size: 1.08rem; overflow-wrap: anywhere; }
.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; }
.secret-box { display: grid; gap: .35rem; border: 1px solid #d6dfda; border-radius: 8px; background: #f7faf8; padding: .7rem; }
.secret-box code { display: block; padding: .55rem .65rem; font-size: .92rem; white-space: normal; overflow-wrap: anywhere; }
@media (max-width: 720px) {
.topbar-inner { align-items: flex-start; flex-direction: column; }
.top-actions { width: 100%; justify-content: space-between; }
.user-pill { max-width: 100%; }
.intro { grid-template-columns: 1fr; align-items: start; }
h1 { font-size: 1.55rem; }
.counters { justify-content: stretch; }
.counter { flex: 1 1 120px; }
.server-status-grid { grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); max-height: 156px; }
.create { grid-template-columns: 1fr; }
.create button { width: 100%; }
.meta-grid { grid-template-columns: 1fr; }
.key-head { flex-direction: column; }
.key-actions { grid-template-columns: 1fr 1fr; }
.key-actions .server-button { grid-column: 1 / -1; }
.key-actions button { width: 100%; }
.backdrop { align-items: end; padding: 0; }
.sheet { max-height: 92vh; border-radius: 8px 8px 0 0; }
.server-row { grid-template-columns: minmax(0, 1fr) auto; padding: .58rem .62rem; }
.server-actions { gap: .25rem; }
.icon-button { width: 34px; min-width: 34px; height: 34px; min-height: 34px; }
.qr-modal { width: 100%; border-radius: 8px 8px 0 0; }
}
</style>
{% endblock head_extra %}
{% block body %}
<div class="portal" x-data="clientPortal()" x-init="init()">
<header class="topbar">
<div class="topbar-inner">
<a class="brand" href="/configs">
<span class="brand-mark">A</span>
<span class="brand-text">
<span class="brand-title">{{ t.site_name }}</span>
<span class="brand-version">v{{ app_version }}</span>
</span>
</a>
<div class="top-actions">
<span class="user-pill">{{ user_name }} ({{ user_role }})</span>
<button type="button" x-show="telegram.enabled" class="telegram-status-button" :class="`tone-${telegramStatusTone()}`" @click="openTelegramManage()" :title="telegramStatusTitle()" :aria-label="telegramStatusTitle()">
<span class="telegram-mark">TG</span>
<span class="telegram-dot"></span>
</button>
<div class="lang-switch">
<a href="#"{% if t.lang.code() == "en" %} class="active"{% endif %} onclick="location.href='/set-lang?lang=en&next='+encodeURIComponent(location.pathname);return false">EN</a>
<a href="#"{% if t.lang.code() == "ru" %} class="active"{% endif %} onclick="location.href='/set-lang?lang=ru&next='+encodeURIComponent(location.pathname);return false">RU</a>
</div>
<a class="logout-link" href="/logout">{{ t.nav_logout }}</a>
</div>
</div>
</header>
<main class="page">
<section class="panel">
<div class="intro">
<div>
<p class="eyebrow">{{ t.configs_portal_kicker }}</p>
<h1>{{ t.configs_portal_heading }}</h1>
</div>
<div class="counters" aria-label="{{ t.configs_key_status }}">
<div class="counter">
<strong x-text="activeClients()"></strong>
<span>{{ t.configs_active_keys }}</span>
</div>
<div class="counter">
<strong x-text="clients.length"></strong>
<span>{{ t.configs_total_keys }}</span>
</div>
</div>
</div>
<div class="portal-server-status">
<div class="portal-server-head">
<span>{{ t.configs_server_status }}</span>
<button type="button" class="mini-button" @click="loadServerStatus()" :disabled="serverStatusBusy">{{ t.configs_refresh }}</button>
</div>
<template x-if="serverStatusBusy && serverPods().length === 0">
<div class="server-status-muted">{{ t.configs_loading_status }}</div>
</template>
<template x-if="serverStatusError && serverPods().length === 0 && !serverStatusBusy">
<div class="server-status-muted">{{ t.configs_status_unavailable }}</div>
</template>
<template x-if="serverStatus && serverPods().length === 0 && !serverStatusBusy && !serverStatusError">
<div class="server-status-muted">{{ t.configs_no_servers }}</div>
</template>
<template x-if="serverPods().length > 0">
<div class="server-status-grid">
<template x-for="pod in serverPods()" :key="pod.name">
<div class="server-status-chip" :class="`tone-${serverTone(pod)}`" :title="serverTooltip(pod)">
<span class="server-status-dot"></span>
<span class="server-status-text">
<span class="server-status-name" x-text="serverName(pod)"></span>
<span class="server-status-label" x-text="serverStatusLabel(pod)"></span>
</span>
</div>
</template>
</div>
</template>
</div>
<div class="create">
<div class="field">
<label for="client-name">{{ t.configs_new_key }}</label>
<input id="client-name" x-model="newName" @keydown.enter.prevent="createClient()" placeholder="{{ t.configs_name_placeholder }}">
</div>
<button type="button" @click="createClient()" :disabled="busy">{{ t.configs_create }}</button>
</div>
</section>
<template x-if="notice">
<section class="notice" :class="`tone-${notice.kind}`">
<strong x-text="notice.title"></strong>
<span x-text="notice.message"></span>
<details x-show="notice.detail">
<summary>{{ t.notice_detail }}</summary>
<span x-text="notice.detail"></span>
</details>
</section>
</template>
<template x-if="!busy && clients.length === 0">
<section class="panel empty">
<strong>{{ t.configs_empty_title }}</strong>
<span>{{ t.configs_empty_hint }}</span>
</section>
</template>
<template x-if="clients.length > 0">
<section class="key-list">
<template x-for="client in sortedClients()" :key="client.id">
<article class="key-card" :class="{ 'is-disabled': !client.enabled }">
<div class="key-head">
<div class="key-title">
<h2 class="key-name" x-text="client.name"></h2>
<span class="status-chip" :class="client.enabled ? 'enabled' : 'disabled'" x-text="client.enabled ? '{{ t.configs_enabled_state }}' : '{{ t.configs_disabled_state }}'"></span>
</div>
</div>
<div class="meta-grid">
<div class="meta-item">
<span class="meta-label">{{ t.configs_address }}</span>
<code x-text="client.address + '/32'"></code>
</div>
<div class="meta-item">
<span class="meta-label">{{ t.configs_public_key }}</span>
<code x-text="shortKey(client.public_key)"></code>
</div>
</div>
<div class="key-actions">
<button type="button" class="server-button" @click="openServers(client)" :disabled="busy || !client.enabled">{{ t.configs_choose_server }}</button>
<button type="button" class="secondary" @click="setEnabled(client, !client.enabled)" :disabled="busy" x-text="client.enabled ? '{{ t.configs_disable }}' : '{{ t.configs_enable }}'"></button>
<button type="button" class="danger" @click="deleteClient(client)" :disabled="busy">{{ t.users_delete }}</button>
</div>
<span class="key-updated" x-text="client.updated_at ? '{{ t.configs_updated }}: ' + formatDate(client.updated_at) : '{{ t.configs_key_ready }}'"></span>
</article>
</template>
</section>
</template>
</main>
<template x-if="serverModal">
<div class="backdrop" x-cloak @click.self="closeServers()">
<section class="sheet">
<div class="sheet-head">
<div class="sheet-title">
<h2>{{ t.configs_choose_server }}</h2>
<span x-text="serverModal.client.name"></span>
</div>
<button type="button" class="ghost" @click="closeServers()">{{ t.admin_close }}</button>
</div>
<input x-model="serverModal.filter" placeholder="{{ t.configs_server_search }}">
<template x-if="serverModal.loading">
<div class="empty">{{ t.configs_loading_servers }}</div>
</template>
<template x-if="!serverModal.loading && filteredServers().length === 0">
<div class="empty">{{ t.configs_no_servers }}</div>
</template>
<template x-if="!serverModal.loading && filteredServers().length > 0">
<div class="server-list">
<template x-for="server in filteredServers()" :key="server.endpoint_id || server.endpoint_name">
<article class="server-row">
<div class="server-main">
<strong x-text="server.endpoint_name"></strong>
<code x-text="server.endpoint"></code>
</div>
<div class="server-actions">
<button type="button" class="icon-button" @click="copyVpnUrl(serverModal.client, server)" :disabled="busy" data-tip="{{ t.configs_copy_link }}" title="{{ t.configs_copy_link }}" aria-label="{{ t.configs_copy_link }}">
<svg aria-hidden="true" viewBox="0 0 24 24">
<path d="M10 13a5 5 0 0 1 0-7l1-1a5 5 0 0 1 7 7l-1 1"></path>
<path d="M14 11a5 5 0 0 1 0 7l-1 1a5 5 0 0 1-7-7l1-1"></path>
</svg>
</button>
<button type="button" class="secondary icon-button" @click="openQr(serverModal.client, server)" :disabled="busy" data-tip="{{ t.configs_qr_code }}" title="{{ t.configs_qr_code }}" aria-label="{{ t.configs_qr_code }}">
<span class="qr-mark" aria-hidden="true">QR</span>
</button>
<button type="button" class="secondary icon-button" @click="downloadConfig(serverModal.client, server)" :disabled="busy" data-tip="{{ t.configs_download }}" title="{{ t.configs_download }}" aria-label="{{ t.configs_download }}">
<svg aria-hidden="true" viewBox="0 0 24 24">
<path d="M12 3v12"></path>
<path d="m7 10 5 5 5-5"></path>
<path d="M5 21h14"></path>
</svg>
</button>
</div>
</article>
</template>
</div>
</template>
</section>
</div>
</template>
<template x-if="qrModal">
<div class="backdrop" x-cloak @click.self="closeQr()">
<section class="qr-modal">
<h2 x-text="qrModal.title"></h2>
<div class="qr-box" x-html="qrModal.svg"></div>
<div class="modal-actions">
<button type="button" class="secondary" @click="copyText(qrModal.url)">{{ t.configs_copy_link }}</button>
<button type="button" @click="closeQr()">{{ t.admin_close }}</button>
</div>
</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 === 'manage'">
<div>
<p class="telegram-copy" x-text="telegramManageMessage()"></p>
<template x-if="telegram.linked">
<div class="secret-box">
<span>{{ t.telegram_status_connected }}</span>
<code x-text="telegram.telegram_id || ''"></code>
</div>
</template>
<template x-if="telegram.pending">
<div class="secret-box">
<span>{{ t.telegram_secret_label }}</span>
<code x-text="telegram.pending_secret || ''"></code>
</div>
</template>
<div class="modal-actions">
<button type="button" class="secondary" @click="telegramModal = null">{{ t.admin_close }}</button>
<template x-if="telegram.pending">
<button type="button" class="secondary" @click="copyTelegramSecret()" :disabled="telegramBusy || !telegram.pending_secret">{{ t.telegram_copy_secret }}</button>
</template>
<template x-if="telegram.pending">
<button type="button" class="secondary" @click="refreshTelegramStatus()" :disabled="telegramBusy">{{ t.telegram_refresh_status }}</button>
</template>
<button type="button" class="secondary" @click="startTelegramBotLink()" :disabled="telegramBusy" x-text="telegram.linked || telegram.pending ? '{{ t.telegram_change }}' : '{{ t.telegram_connect }}'"></button>
<template x-if="telegram.linked || telegram.pending || telegram.declined">
<button type="button" class="danger" @click="deleteTelegramLink()" :disabled="telegramBusy">{{ t.telegram_delete }}</button>
</template>
</div>
</div>
</template>
<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="startTelegramBotLink()" :disabled="telegramBusy">{{ 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>
<div class="secret-box">
<span>{{ t.telegram_secret_label }}</span>
<code x-text="telegram.pending_secret || ''"></code>
</div>
<div class="modal-actions">
<button type="button" class="secondary" @click="declineTelegramLink()" :disabled="telegramBusy">{{ t.telegram_skip }}</button>
<button type="button" class="secondary" @click="copyTelegramSecret()" :disabled="telegramBusy || !telegram.pending_secret">{{ t.telegram_copy_secret }}</button>
<button type="button" @click="refreshTelegramStatus()" :disabled="telegramBusy">{{ t.telegram_refresh_status }}</button>
</div>
</div>
</template>
</section>
</div>
</template>
</div>
<script>
function clientPortal() {
return {
clients: [],
newName: '',
busy: false,
status: '',
error: '',
notice: null,
serverStatus: null,
serverStatusBusy: false,
serverStatusError: '',
serverStatusTimer: null,
serverConfigs: {},
serverModal: null,
qrModal: null,
telegram: {
enabled: false,
bot_username: '',
bot_url: '',
linked: false,
declined: false,
pending: false,
pending_secret: null,
pending_expires_at: null,
telegram_id: null,
isWebApp: false,
initData: '',
},
telegramModal: null,
telegramBusy: false,
telegramPromptChecked: false,
init() {
this.initTelegramWebApp();
this.load();
this.loadServerStatus();
this.loadTelegramStatus();
this.serverStatusTimer = setInterval(() => this.loadServerStatus(true), 30000);
},
async request(url, options = {}) {
const response = await fetch(url, {
headers: { 'content-type': 'application/json' },
...options,
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(data.error || this.httpStatusMessage(response.status));
error.title = data.title || this.httpStatusTitle(response.status);
error.detail = data.detail || '';
error.code = data.code || '';
error.status = response.status;
throw error;
}
return data;
},
async load() {
this.error = '';
this.busy = true;
try {
const data = await this.request('/api/vpn-clients');
this.clients = data.clients || [];
} catch (e) {
this.showError(e);
} finally {
this.busy = false;
}
},
async loadServerStatus(silent = false) {
this.serverStatusError = '';
if (!silent) this.serverStatusBusy = true;
try {
this.serverStatus = await this.request('/api/vpn-status');
} catch (e) {
this.serverStatusError = e.message;
} finally {
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),
pending: Boolean(status.pending),
pending_secret: status.pending_secret || null,
pending_expires_at: status.pending_expires_at || null,
telegram_id: status.telegram_id || null,
};
},
maybePromptTelegram() {
if (this.telegramPromptChecked) return;
this.telegramPromptChecked = true;
if (!this.telegram.enabled || this.telegram.linked || this.telegram.pending || this.telegram.declined) return;
this.telegramModal = (this.telegram.isWebApp && this.telegram.initData) ? 'webapp' : 'manualPrompt';
},
openTelegramManage() {
if (!this.telegram.enabled) return;
if (this.telegram.linked || this.telegram.pending || this.telegram.declined) {
this.telegramModal = 'manage';
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 startTelegramBotLink() {
this.telegramBusy = true;
this.clearNotice();
try {
const data = await this.request('/api/telegram-link/start', { method: 'POST' });
this.applyTelegramStatus(data.status || {});
this.telegramModal = 'manualGuide';
} catch (e) {
this.showError(e);
} finally {
this.telegramBusy = false;
}
},
async refreshTelegramStatus() {
this.telegramBusy = true;
try {
await this.loadTelegramStatus();
if (this.telegram.linked) {
this.telegramModal = 'manage';
this.setNotice('success', '{{ t.notice_success_title }}', '{{ t.telegram_saved }}');
}
} finally {
this.telegramBusy = false;
}
},
async copyTelegramSecret() {
if (!this.telegram.pending_secret) return;
this.clearNotice();
try {
await this.copyText(this.telegram.pending_secret);
this.setNotice('success', '{{ t.notice_success_title }}', '{{ t.telegram_secret_copied }}');
} catch (e) {
this.showError(e);
}
},
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;
}
},
async deleteTelegramLink() {
if (!confirm('{{ t.telegram_delete_confirm }}')) return;
this.telegramBusy = true;
this.clearNotice();
try {
const data = await this.request('/api/telegram-link/unlink', { method: 'POST' });
this.applyTelegramStatus(data.status || {});
this.telegramModal = null;
this.setNotice('success', '{{ t.notice_success_title }}', '{{ t.telegram_unlinked }}');
} 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 }}';
},
telegramStatusTone() {
if (!this.telegram.enabled) return 'disabled';
if (this.telegram.pending) return 'pending';
if (this.telegram.linked) return 'connected';
return 'empty';
},
telegramStatusTitle() {
if (!this.telegram.enabled) return '{{ t.telegram_status_disabled }}';
if (this.telegram.pending) return '{{ t.telegram_status_pending }}';
if (this.telegram.linked) return '{{ t.telegram_status_connected }}';
return '{{ t.telegram_status_empty }}';
},
telegramManageMessage() {
if (this.telegram.pending) return '{{ t.telegram_pending_message }}';
if (this.telegram.linked) return '{{ t.telegram_connected_message }}';
return '{{ t.telegram_not_connected_message }}';
},
async createClient() {
this.clearNotice();
this.busy = true;
try {
const data = await this.request('/api/vpn-clients', {
method: 'POST',
body: JSON.stringify({ name: this.newName }),
});
this.clients.push(data.client);
delete this.serverConfigs[data.client.id];
this.newName = '';
this.applyResponseNotice(data, '{{ t.notice_create_success }}');
} catch (e) {
this.showError(e);
} finally {
this.busy = false;
}
},
async setEnabled(client, enabled) {
this.clearNotice();
this.busy = true;
try {
const data = await this.request(`/api/vpn-clients/${client.id}/enabled`, {
method: 'POST',
body: JSON.stringify({ enabled }),
});
const index = this.clients.findIndex((item) => item.id === client.id);
if (index !== -1) this.clients[index] = data.client;
delete this.serverConfigs[client.id];
this.applyResponseNotice(data, '{{ t.notice_update_success }}');
} catch (e) {
this.showError(e);
} finally {
this.busy = false;
}
},
async deleteClient(client) {
if (!confirm('{{ t.users_delete_confirm }}')) return;
this.clearNotice();
this.busy = true;
try {
const data = await this.request(`/api/vpn-clients/${client.id}`, { method: 'DELETE' });
this.clients = this.clients.filter((item) => item.id !== client.id);
delete this.serverConfigs[client.id];
this.applyResponseNotice(data, '{{ t.notice_delete_success }}');
} catch (e) {
this.showError(e);
} finally {
this.busy = false;
}
},
async openServers(client) {
this.clearNotice();
const cached = this.serverConfigs[client.id];
this.serverModal = {
client,
filter: '',
loading: !cached || cached.loading,
servers: cached ? cached.servers : [],
};
try {
const servers = await this.loadClientServers(client);
if (this.serverModal && this.serverModal.client.id === client.id) {
this.serverModal.loading = false;
this.serverModal.servers = servers;
}
} catch (e) {
if (this.serverModal && this.serverModal.client.id === client.id) {
this.serverModal.loading = false;
this.serverModal.servers = [];
}
this.showError(e, {
title: '{{ t.notice_server_list_error_title }}',
message: '{{ t.notice_server_list_error_message }}',
});
}
},
async loadClientServers(client) {
const cached = this.serverConfigs[client.id];
if (cached && !cached.loading) return cached.servers || [];
this.serverConfigs[client.id] = { loading: true, servers: [] };
try {
const data = await this.request(`/api/vpn-clients/${client.id}/config`);
const servers = data.servers || [];
this.serverConfigs[client.id] = { loading: false, servers };
return servers;
} catch (e) {
this.serverConfigs[client.id] = { loading: false, servers: [] };
throw e;
}
},
closeServers() {
this.serverModal = null;
},
filteredServers() {
if (!this.serverModal) return [];
const query = this.serverModal.filter.trim().toLowerCase();
if (!query) return this.serverModal.servers;
return this.serverModal.servers.filter((server) => {
return [server.endpoint_name, server.endpoint, server.endpoint_id]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(query));
});
},
async copyVpnUrl(client, server) {
this.clearNotice();
this.busy = true;
try {
await this.copyText(server.vpn_url);
this.setNotice('success', '{{ t.notice_success_title }}', `${server.endpoint_name}: {{ t.configs_vpn_url_copied }}`);
} catch (e) {
this.showError(e);
} finally {
this.busy = false;
}
},
async downloadConfig(client, server) {
this.clearNotice();
this.busy = true;
try {
const blob = new Blob([server.config], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${this.fileSlug(client.name || 'amnezia-client')}-${this.fileSlug(server.endpoint_name)}.conf`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
this.setNotice('success', '{{ t.notice_success_title }}', `${server.endpoint_name}: ${server.endpoint}`);
} catch (e) {
this.showError(e);
} finally {
this.busy = false;
}
},
openQr(client, server) {
this.qrModal = {
title: `${client.name || 'amnezia-client'} - ${server.endpoint_name}`,
svg: server.qr_svg,
url: server.vpn_url,
};
},
closeQr() {
this.qrModal = null;
},
async copyText(text) {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.select();
const copied = document.execCommand('copy');
textarea.remove();
if (!copied) {
throw new Error('copy failed');
}
},
sortedClients() {
return [...this.clients].sort((left, right) => {
if (left.enabled !== right.enabled) return left.enabled ? -1 : 1;
return String(left.name || '').localeCompare(String(right.name || ''));
});
},
activeClients() {
return this.clients.filter((client) => client.enabled).length;
},
serverPods() {
return (this.serverStatus && this.serverStatus.pods) || [];
},
serverName(pod) {
return pod.endpoint_name || pod.node_name || pod.name || '{{ t.configs_status_unknown }}';
},
serverTone(pod) {
if (!pod.ready) return 'offline';
if (pod.rollout_status === 'applied') return 'online';
if (pod.rollout_status === 'error') return 'error';
if (pod.rollout_status === 'pending_apply' || pod.rollout_status === 'pending_restart' || pod.rollout_status === 'starting') return 'warning';
return 'offline';
},
serverStatusLabel(pod) {
if (!pod.ready) return '{{ t.configs_status_offline }}';
const labels = {
applied: '{{ t.configs_status_online }}',
starting: '{{ t.configs_status_starting }}',
pending_apply: '{{ t.configs_status_pending_apply }}',
pending_restart: '{{ t.configs_status_pending_apply }}',
error: '{{ t.configs_status_error }}',
unknown: '{{ t.configs_status_unknown }}',
};
return labels[pod.rollout_status] || pod.rollout_status || '{{ t.configs_status_unknown }}';
},
serverTooltip(pod) {
return [
this.serverName(pod),
pod.endpoint || null,
pod.ready ? '{{ t.configs_ready }}' : '{{ t.configs_not_ready }}',
this.serverStatusLabel(pod),
].filter(Boolean).join(' - ');
},
shortKey(key) {
if (!key || key.length <= 16) return key || '';
return `${key.slice(0, 8)}...${key.slice(-8)}`;
},
fileSlug(value) {
return String(value || 'amnezia').trim().replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'amnezia';
},
clearNotice() {
this.error = '';
this.status = '';
this.notice = null;
},
setNotice(kind, title, message, detail = '') {
this.notice = { kind, title, message, detail };
this.error = kind === 'error' ? message : '';
this.status = kind === 'error' ? '' : message;
},
applyResponseNotice(data, fallbackMessage) {
if (data.notice) {
const notice = this.normalizeNotice(data.notice);
this.setNotice(notice.kind, notice.title, notice.message, notice.detail || '');
return;
}
this.setNotice('success', '{{ t.notice_success_title }}', (data.sync && data.sync.message) || fallbackMessage);
},
normalizeNotice(notice) {
if (notice.code === 'secret_sync_failed') {
return {
kind: 'warning',
title: '{{ t.notice_sync_warning_title }}',
message: '{{ t.notice_sync_warning_message }}',
detail: notice.detail || '',
};
}
return {
kind: notice.kind || 'warning',
title: notice.title || '{{ t.notice_warning_title }}',
message: notice.message || '',
detail: notice.detail || '',
};
},
showError(error, fallback = {}) {
this.setNotice(
'error',
fallback.title || error.title || '{{ t.notice_error_title }}',
fallback.message || error.message || '{{ t.notice_error_title }}',
error.detail || ''
);
},
httpStatusTitle(status) {
if (status === 401) return '{{ t.login_heading }}';
if (status === 403) return '{{ t.notice_error_title }}';
if (status === 404) return '{{ t.configs_empty }}';
if (status === 409) return '{{ t.notice_warning_title }}';
if (status === 503) return '{{ t.notice_server_list_error_title }}';
return '{{ t.notice_error_title }}';
},
httpStatusMessage(status) {
if (status === 401) return 'not authenticated';
if (status === 403) return 'access denied';
if (status === 404) return 'not found';
if (status === 409) return '{{ t.configs_no_servers }}';
if (status === 503) return '{{ t.notice_server_list_error_message }}';
return '{{ t.notice_error_title }}';
},
formatDate(value) {
if (!value) return '{{ t.configs_never }}';
const parsed = Date.parse(value);
if (Number.isNaN(parsed)) return value;
return new Date(parsed).toLocaleDateString();
},
};
}
</script>
{% endblock body %}
+158 -60
View File
@@ -14,7 +14,7 @@
.sidebar a { display: block; text-decoration: none; color: #d8dee6; padding: .5rem .55rem; border-radius: 4px; margin-bottom: .2rem; }
.sidebar a:hover, .sidebar a.active { background: #263544; color: #fff; }
.main-wrap { min-width: 0; display: flex; flex-direction: column; }
.topbar { min-height: 48px; display: flex; align-items: center; justify-content: flex-end; gap: 1rem; padding: .5rem 1.25rem; border-bottom: 1px solid #dde2e6; background: #fff; }
.topbar { min-height: 48px; display: flex; align-items: center; justify-content: flex-end; gap: 1rem; flex-wrap: wrap; padding: .5rem 1.25rem; border-bottom: 1px solid #dde2e6; background: #fff; }
.user-info { font-size: .875rem; color: #53606d; }
.app-version { font-size: .75rem; color: #9aa4ae; white-space: nowrap; }
.logout-link, .lang-switch a { font-size: .875rem; text-decoration: none; color: #53606d; padding: .25rem .45rem; border-radius: 4px; }
@@ -42,6 +42,14 @@
.status { margin: .75rem 0; min-height: 1.25rem; color: #53606d; }
.error { color: #9d2323; }
.empty { background: #fff; border: 1px solid #dde2e6; border-radius: 6px; padding: 1rem; color: #53606d; }
.notice { margin: .75rem 0; border: 1px solid #dde2e6; border-left-width: 4px; border-radius: 6px; background: #fff; padding: .7rem .8rem; display: grid; gap: .25rem; }
.notice strong { color: #1d252d; }
.notice span { color: #53606d; font-size: .9rem; }
.notice details { color: #6a7682; font-size: .82rem; }
.notice summary { cursor: pointer; font-weight: 700; }
.notice.tone-success { border-left-color: #2f8f4e; }
.notice.tone-warning { border-left-color: #d39a00; }
.notice.tone-error { border-left-color: #9d2323; }
.rollout { margin: 1rem 0 1.25rem; }
.rollout-meta { color: #53606d; font-size: .88rem; margin-bottom: .5rem; }
.rollout-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: .55rem; }
@@ -51,21 +59,24 @@
.rollout-tile:hover::after, .rollout-tile:focus-within::after { opacity: 1; transform: translateY(0); }
.rollout-tile-applied { border-left-color: #2f8f4e; }
.rollout-tile-starting { border-left-color: #d39a00; }
.rollout-tile-pending_restart { border-left-color: #c83f31; }
.rollout-tile-pending_apply { border-left-color: #d39a00; }
.rollout-tile-pending_restart { border-left-color: #d39a00; }
.rollout-tile-error { border-left-color: #c83f31; }
.rollout-tile-unknown { border-left-color: #7b8793; }
.rollout-tile-head { display: flex; align-items: center; min-width: 0; gap: .45rem; }
.rollout-dot { width: .55rem; height: .55rem; border-radius: 999px; flex: 0 0 auto; background: #7b8793; }
.rollout-dot-applied { background: #2f8f4e; }
.rollout-dot-starting { background: #d39a00; }
.rollout-dot-pending_restart { background: #c83f31; }
.rollout-dot-pending_apply { background: #d39a00; }
.rollout-dot-pending_restart { background: #d39a00; }
.rollout-dot-error { background: #c83f31; }
.rollout-dot-unknown { background: #7b8793; }
.rollout-server { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 700; color: #1d252d; }
.rollout-badge { display: inline-flex; align-items: center; min-height: 24px; padding: .15rem .45rem; border-radius: 4px; font-size: .82rem; font-weight: 700; white-space: nowrap; }
.rollout-applied { background: #dcefe4; color: #175c31; }
.rollout-starting { background: #fff0c2; color: #6f4e00; }
.rollout-pending_restart { background: #ffe3df; color: #8b2116; }
.rollout-pending_apply { background: #fff0c2; color: #6f4e00; }
.rollout-pending_restart { background: #fff0c2; color: #6f4e00; }
.rollout-error { background: #ffe3df; color: #8b2116; }
.rollout-unknown { background: #e8ecef; color: #53606d; }
.rollout-foot { display: flex; align-items: center; justify-content: space-between; gap: .5rem; color: #53606d; font-size: .82rem; }
@@ -81,9 +92,10 @@
.modal-subtitle { color: #53606d; font-size: .86rem; overflow-wrap: anywhere; }
.server-filter { width: 100%; }
.server-picker-list { display: grid; gap: .6rem; overflow: auto; padding-right: .15rem; }
.server-card { border: 1px solid #dde2e6; border-radius: 6px; padding: .7rem; display: grid; grid-template-columns: minmax(160px, 1fr) auto; gap: .65rem; align-items: center; }
.server-card { border: 1px solid #dde2e6; border-radius: 8px; padding: .8rem; display: grid; gap: .7rem; align-items: start; }
.server-card-main { display: grid; gap: .25rem; min-width: 0; }
.server-card-actions { display: flex; gap: .45rem; align-items: center; flex-wrap: wrap; justify-content: flex-end; }
.server-card-actions { display: grid; grid-template-columns: repeat(3, minmax(118px, 1fr)); gap: .45rem; align-items: center; }
.server-card-actions button { min-width: 0; }
.detail-list { display: grid; gap: .55rem; overflow: auto; }
.detail-row { display: grid; grid-template-columns: minmax(120px, .45fr) minmax(0, 1fr); gap: .75rem; align-items: start; padding: .55rem 0; border-bottom: 1px solid #edf0f2; }
.detail-row:last-child { border-bottom: 0; }
@@ -104,8 +116,7 @@
table { display: block; overflow-x: auto; }
.row-actions { align-items: stretch; }
.servers-modal { width: 100%; max-height: calc(100vh - 2rem); }
.server-card { grid-template-columns: 1fr; }
.server-card-actions { justify-content: stretch; }
.server-card-actions { grid-template-columns: 1fr; }
.server-card-actions button { flex: 1 1 100%; }
.detail-row { grid-template-columns: 1fr; gap: .2rem; }
.rollout-grid { grid-template-columns: 1fr; }
@@ -146,7 +157,16 @@
</div>
</div>
<div class="status" :class="{ 'error': error }" x-text="error || status"></div>
<template x-if="notice">
<section class="notice" :class="`tone-${notice.kind}`">
<strong x-text="notice.title"></strong>
<span x-text="notice.message"></span>
<details x-show="notice.detail">
<summary>{{ t.notice_detail }}</summary>
<span x-text="notice.detail"></span>
</details>
</section>
</template>
<section class="rollout">
<div class="panel-head">
@@ -331,6 +351,7 @@ function configsPage() {
busy: false,
status: '',
error: '',
notice: null,
rollout: null,
rolloutBusy: false,
rolloutError: '',
@@ -339,10 +360,13 @@ function configsPage() {
serverConfigs: {},
serverModal: null,
qrModal: null,
isAdmin: {% if is_admin %}true{% else %}false{% endif %},
init() {
this.load();
this.loadRolloutStatus();
this.rolloutTimer = setInterval(() => this.loadRolloutStatus(), 10000);
if (this.isAdmin) {
this.loadRolloutStatus();
this.rolloutTimer = setInterval(() => this.loadRolloutStatus(), 10000);
}
},
async request(url, options = {}) {
const response = await fetch(url, {
@@ -351,7 +375,12 @@ function configsPage() {
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.error || response.statusText);
const error = new Error(data.error || this.httpStatusMessage(response.status));
error.title = data.title || this.httpStatusTitle(response.status);
error.detail = data.detail || '';
error.code = data.code || '';
error.status = response.status;
throw error;
}
return data;
},
@@ -368,8 +397,7 @@ function configsPage() {
}
},
async createClient() {
this.error = '';
this.status = '';
this.clearNotice();
this.busy = true;
try {
const data = await this.request('/api/vpn-clients', {
@@ -379,17 +407,16 @@ function configsPage() {
this.clients.push(data.client);
delete this.serverConfigs[data.client.id];
this.newName = '';
this.status = data.sync.message;
await this.loadRolloutStatus();
this.applyResponseNotice(data, '{{ t.notice_create_success }}');
if (this.isAdmin) await this.loadRolloutStatus();
} catch (e) {
this.error = e.message;
this.showError(e);
} finally {
this.busy = false;
}
},
async setEnabled(client, enabled) {
this.error = '';
this.status = '';
this.clearNotice();
this.busy = true;
try {
const data = await this.request(`/api/vpn-clients/${client.id}/enabled`, {
@@ -399,18 +426,17 @@ function configsPage() {
const index = this.clients.findIndex((item) => item.id === client.id);
if (index !== -1) this.clients[index] = data.client;
delete this.serverConfigs[client.id];
this.status = data.sync.message;
await this.loadRolloutStatus();
this.applyResponseNotice(data, '{{ t.notice_update_success }}');
if (this.isAdmin) await this.loadRolloutStatus();
} catch (e) {
this.error = e.message;
this.showError(e);
} finally {
this.busy = false;
}
},
async deleteClient(client) {
if (!confirm('{{ t.users_delete_confirm }}')) return;
this.error = '';
this.status = '';
this.clearNotice();
this.busy = true;
try {
const data = await this.request(`/api/vpn-clients/${client.id}`, {
@@ -418,41 +444,53 @@ function configsPage() {
});
this.clients = this.clients.filter((item) => item.id !== client.id);
delete this.serverConfigs[client.id];
this.status = data.sync.message;
await this.loadRolloutStatus();
this.applyResponseNotice(data, '{{ t.notice_delete_success }}');
if (this.isAdmin) await this.loadRolloutStatus();
} catch (e) {
this.error = e.message;
this.showError(e);
} finally {
this.busy = false;
}
},
async openServers(client) {
this.error = '';
this.status = '';
this.clearNotice();
const cached = this.serverConfigs[client.id];
this.serverModal = {
client,
filter: '',
loading: !cached,
loading: !cached || cached.loading,
servers: cached ? cached.servers : [],
};
if (cached) return;
this.serverConfigs[client.id] = { loading: true, servers: [] };
try {
const data = await this.request(`/api/vpn-clients/${client.id}/config`);
this.serverConfigs[client.id] = { loading: false, servers: data.servers || [] };
const servers = await this.loadClientServers(client);
if (this.serverModal && this.serverModal.client.id === client.id) {
this.serverModal.loading = false;
this.serverModal.servers = data.servers || [];
this.serverModal.servers = servers;
}
} catch (e) {
this.serverConfigs[client.id] = { loading: false, servers: [] };
if (this.serverModal && this.serverModal.client.id === client.id) {
this.serverModal.loading = false;
this.serverModal.servers = [];
}
this.error = e.message;
this.showError(e, {
title: '{{ t.notice_server_list_error_title }}',
message: '{{ t.notice_server_list_error_message }}',
});
}
},
async loadClientServers(client) {
const cached = this.serverConfigs[client.id];
if (cached && !cached.loading) return cached.servers || [];
this.serverConfigs[client.id] = { loading: true, servers: [] };
try {
const data = await this.request(`/api/vpn-clients/${client.id}/config`);
const servers = data.servers || [];
this.serverConfigs[client.id] = { loading: false, servers };
return servers;
} catch (e) {
this.serverConfigs[client.id] = { loading: false, servers: [] };
throw e;
}
},
closeServers() {
@@ -469,35 +507,36 @@ function configsPage() {
});
},
async downloadConfig(client, server) {
this.error = '';
this.status = '';
this.clearNotice();
this.busy = true;
try {
const blob = new Blob([server.config], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${this.fileSlug(client.name || 'amnezia-client')}-${this.fileSlug(server.endpoint_name)}.conf`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
this.status = `${server.endpoint_name}: ${server.endpoint}`;
this.saveConfigFile(client, server);
} catch (e) {
this.error = e.message;
this.showError(e);
} finally {
this.busy = false;
}
},
saveConfigFile(client, server) {
const blob = new Blob([server.config], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${this.fileSlug(client.name || 'amnezia-client')}-${this.fileSlug(server.endpoint_name)}.conf`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
this.setNotice('success', '{{ t.notice_success_title }}', `${server.endpoint_name}: ${server.endpoint}`);
},
async copyVpnUrl(client, server) {
this.error = '';
this.status = '';
this.clearNotice();
this.busy = true;
try {
await this.copyText(server.vpn_url);
this.status = `${server.endpoint_name}: {{ t.configs_vpn_url_copied }}`;
this.setNotice('success', '{{ t.notice_success_title }}', `${server.endpoint_name}: {{ t.configs_vpn_url_copied }}`);
} catch (e) {
this.error = e.message;
this.showError(e);
} finally {
this.busy = false;
}
@@ -532,20 +571,20 @@ function configsPage() {
}
},
async sync() {
this.error = '';
this.status = '';
this.clearNotice();
this.busy = true;
try {
const data = await this.request('/api/vpn-clients/sync', { method: 'POST' });
this.status = data.message;
this.setNotice('success', '{{ t.notice_success_title }}', data.message);
await this.loadRolloutStatus();
} catch (e) {
this.error = e.message;
this.showError(e);
} finally {
this.busy = false;
}
},
async loadRolloutStatus() {
if (!this.isAdmin) return;
this.rolloutError = '';
this.rolloutBusy = true;
try {
@@ -598,11 +637,70 @@ function configsPage() {
fileSlug(value) {
return String(value || 'amnezia').trim().replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'amnezia';
},
clearNotice() {
this.error = '';
this.status = '';
this.notice = null;
},
setNotice(kind, title, message, detail = '') {
this.notice = { kind, title, message, detail };
this.error = kind === 'error' ? message : '';
this.status = kind === 'error' ? '' : message;
},
applyResponseNotice(data, fallbackMessage) {
if (data.notice) {
const notice = this.normalizeNotice(data.notice);
this.setNotice(notice.kind, notice.title, notice.message, notice.detail || '');
return;
}
this.setNotice('success', '{{ t.notice_success_title }}', (data.sync && data.sync.message) || fallbackMessage);
},
normalizeNotice(notice) {
if (notice.code === 'secret_sync_failed') {
return {
kind: 'warning',
title: '{{ t.notice_sync_warning_title }}',
message: '{{ t.notice_sync_warning_message }}',
detail: notice.detail || '',
};
}
return {
kind: notice.kind || 'warning',
title: notice.title || '{{ t.notice_warning_title }}',
message: notice.message || '',
detail: notice.detail || '',
};
},
showError(error, fallback = {}) {
this.setNotice(
'error',
fallback.title || error.title || '{{ t.notice_error_title }}',
fallback.message || error.message || '{{ t.notice_error_title }}',
error.detail || ''
);
},
httpStatusTitle(status) {
if (status === 401) return '{{ t.login_heading }}';
if (status === 403) return '{{ t.notice_error_title }}';
if (status === 404) return '{{ t.configs_empty }}';
if (status === 409) return '{{ t.notice_warning_title }}';
if (status === 503) return '{{ t.notice_server_list_error_title }}';
return '{{ t.notice_error_title }}';
},
httpStatusMessage(status) {
if (status === 401) return 'not authenticated';
if (status === 403) return 'access denied';
if (status === 404) return 'not found';
if (status === 409) return '{{ t.configs_no_servers }}';
if (status === 503) return '{{ t.notice_server_list_error_message }}';
return '{{ t.notice_error_title }}';
},
rolloutStatusLabel(value) {
const labels = {
applied: '{{ t.configs_status_applied }}',
starting: '{{ t.configs_status_starting }}',
pending_restart: '{{ t.configs_status_pending_restart }}',
pending_apply: '{{ t.configs_status_pending_apply }}',
pending_restart: '{{ t.configs_status_pending_apply }}',
error: '{{ t.configs_status_error }}',
unknown: '{{ t.configs_status_unknown }}',
};