7 Commits
Author SHA1 Message Date
Ultradesu 3b4899f785 Fixed migrations
Build and Publish / Build and Publish Docker Image (push) Successful in 3m12s
2026-07-06 10:46:50 +03:00
Ultradesu 9ccab69836 AWG: Added migration
Build and Publish / Build and Publish Docker Image (push) Successful in 3m11s
2026-07-01 13:53:03 +03:00
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
15 changed files with 3206 additions and 130 deletions
Generated
+6 -1
View File
@@ -61,12 +61,15 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "amnezia-fellow"
version = "0.1.1"
version = "0.1.6"
dependencies = [
"async-trait",
"base64 0.22.1",
"cot",
"curve25519-dalek",
"getrandom 0.3.4",
"hex",
"hmac",
"k8s-openapi",
"kube",
"miniz_oxide",
@@ -76,9 +79,11 @@ dependencies = [
"schemars 0.9.0",
"serde",
"serde_json",
"sha2",
"tokio",
"tracing",
"tracing-subscriber",
"url",
]
[[package]]
+10 -5
View File
@@ -1,16 +1,17 @@
[package]
name = "amnezia-fellow"
version = "0.1.2"
version = "0.1.7"
edition = "2024"
description = "Amnezia VPN client manager with SSO, SQLite, and Kubernetes Secret sync"
description = "Amnezia VPN client manager with SSO, SQLite/PostgreSQL, and Kubernetes Secret sync"
[dependencies]
cot = { version = "0.6.0", default-features = false, features = ["sqlite", "json", "openapi", "swagger-ui"] }
async-trait = "0.1"
cot = { version = "0.6.0", default-features = false, features = ["sqlite", "postgres", "json", "openapi", "swagger-ui"] }
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 +20,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"
+47 -3
View File
@@ -2,7 +2,7 @@
Amnezia VPN client manager written in Rust on top of the public [`cot`](https://cot.rs) framework.
The app uses SQLite as the source of truth, authenticates users through OIDC/SSO, renders AmneziaWG client peers into a Kubernetes Secret, and avoids updating that Secret when the rendered content is byte-for-byte identical.
The app uses SQLite or PostgreSQL as the source of truth, authenticates users through OIDC/SSO, renders AmneziaWG client peers into a Kubernetes Secret, and avoids updating that Secret when the rendered content is byte-for-byte identical.
## Quick Start
@@ -53,7 +53,7 @@ The OIDC groups claim is expected to be `groups`.
## VPN Data Model
SQLite stores all client data needed to restore configs:
The database stores all client data needed to restore configs:
- owner user id
- display name
@@ -95,7 +95,8 @@ All settings use `AMNEZIA_FELLOW_` prefix. Priority is:
| Variable | Description | Default |
| --- | --- | --- |
| `AMNEZIA_FELLOW_DATABASE_URL` | SQLite connection URL | `sqlite://amnezia-fellow.sqlite3?mode=rwc` |
| `AMNEZIA_FELLOW_DATABASE_URL` | SQLite or PostgreSQL connection URL. PostgreSQL URLs must start with `postgresql://`. | `sqlite://amnezia-fellow.sqlite3?mode=rwc` |
| `AMNEZIA_FELLOW_MIGRATE_SQLITE` | Optional SQLite path/URL imported into PostgreSQL once on startup | empty |
| `AMNEZIA_FELLOW_LOG_LEVEL` | Tracing filter | `info` |
| `AMNEZIA_FELLOW_AUTH_PASSWORD_ENABLED` | Enable password login | `true` |
| `AMNEZIA_FELLOW_AUTH_SSO_ENABLED` | Enable OIDC login | `false` |
@@ -114,6 +115,44 @@ 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 |
## PostgreSQL Migration
To move from SQLite to PostgreSQL, start the app with a PostgreSQL database URL
and point `AMNEZIA_FELLOW_MIGRATE_SQLITE` at the existing SQLite file:
```bash
AMNEZIA_FELLOW_DATABASE_URL=postgresql://user:pass@postgres:5432/amnezia_fellow
AMNEZIA_FELLOW_MIGRATE_SQLITE=/data/amnezia-fellow.sqlite3
```
On startup, the app runs its migrations on PostgreSQL, opens the SQLite source
read/write, applies any missing app migrations there, copies config entries,
database sessions, users, OIDC links, Telegram link state, and VPN clients,
then writes an import marker into PostgreSQL. If the marker already exists, the
import is skipped. If PostgreSQL already contains app data but has no marker,
startup fails instead of merging two databases implicitly.
## 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 +161,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}`
+103 -1
View File
@@ -84,6 +84,11 @@ fn config_display_entries(config: &AppConfig, sources: &ConfigSources) -> Vec<Co
config.database_url.clone(),
defaults.database_url.clone()
),
entry!(
migrate_sqlite,
config.migrate_sqlite.clone(),
defaults.migrate_sqlite.clone()
),
entry!(
oidc_issuer,
config.oidc_issuer.clone(),
@@ -180,6 +185,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 +303,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 +772,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 +879,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),
+38 -1
View File
@@ -47,6 +47,15 @@ impl ConfigEntry {
pub fn new(key: String, value: String) -> Self {
Self { key, value }
}
pub fn key_str(&self) -> &str {
&self.key
}
pub async fn get_by_key(db: &Database, key: &str) -> cot::db::Result<Option<Self>> {
let key = key.to_owned();
cot::db::query!(ConfigEntry, $key == key).get(db).await
}
}
// ---------------------------------------------------------------------------
@@ -87,6 +96,7 @@ pub mod db_migrations {
pub struct ConfigSources {
pub database_url: ConfigSource,
pub migrate_sqlite: ConfigSource,
pub oidc_issuer: ConfigSource,
pub oidc_client_id: ConfigSource,
pub oidc_client_secret: ConfigSource,
@@ -107,12 +117,16 @@ 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 {
fn default() -> Self {
Self {
database_url: ConfigSource::Default,
migrate_sqlite: ConfigSource::Default,
oidc_issuer: ConfigSource::Default,
oidc_client_id: ConfigSource::Default,
oidc_client_secret: ConfigSource::Default,
@@ -133,6 +147,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,
}
}
}
@@ -188,8 +205,10 @@ macro_rules! impl_env_overrides {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
/// SQLite connection URL.
/// SQLite or PostgreSQL connection URL.
pub database_url: String,
/// Optional SQLite database path/URL copied into PostgreSQL on startup.
pub migrate_sqlite: String,
/// OIDC issuer URL.
pub oidc_issuer: String,
/// OIDC client ID.
@@ -230,12 +249,19 @@ 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 {
fn default() -> Self {
Self {
database_url: "sqlite://amnezia-fellow.sqlite3?mode=rwc".into(),
migrate_sqlite: String::new(),
oidc_issuer: String::new(),
oidc_client_id: String::new(),
oidc_client_secret: String::new(),
@@ -256,12 +282,16 @@ 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(),
}
}
}
impl_env_overrides!(
database_url,
migrate_sqlite,
oidc_issuer,
oidc_client_id,
oidc_client_secret,
@@ -282,6 +312,9 @@ impl_env_overrides!(
vpn_dns,
vpn_mtu,
swagger_enabled,
telegram_bot_enabled,
telegram_bot_username,
telegram_bot_token,
);
impl AppConfig {
@@ -337,6 +370,7 @@ impl AppConfig {
}
apply_db_field!(database_url);
apply_db_field!(migrate_sqlite);
apply_db_field!(oidc_issuer);
apply_db_field!(oidc_client_id);
apply_db_field!(oidc_client_secret);
@@ -357,6 +391,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." , "Нет зарегистрированных серверов.";
+53 -6
View File
@@ -4,11 +4,14 @@ mod auth;
mod config;
mod i18n;
mod oidc;
mod sqlite_migration;
mod telegram;
mod user;
mod vpn;
use std::sync::Arc;
use async_trait::async_trait;
use cot::auth::PasswordVerificationResult;
use cot::cli::CliMetadata;
use cot::common_types::Password;
@@ -20,7 +23,7 @@ use cot::db::Database;
use cot::form::{Form, FormResult};
use cot::html::Html;
use cot::middleware::SessionMiddleware;
use cot::project::RegisterAppsContext;
use cot::project::{ProjectContext, RegisterAppsContext};
use cot::request::extractors::{RequestForm, UrlQuery};
use cot::response::IntoResponse;
use cot::router::method::get;
@@ -55,6 +58,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 +76,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()?,
@@ -153,11 +183,19 @@ struct AmneziaFellowApp {
config: Arc<AppConfig>,
}
#[async_trait]
impl App for AmneziaFellowApp {
fn name(&self) -> &'static str {
env!("CARGO_PKG_NAME")
}
async fn init(&self, context: &mut ProjectContext) -> cot::Result<()> {
if let Some(db) = context.try_database() {
sqlite_migration::run_if_requested(&self.config, db).await?;
}
Ok(())
}
fn router(&self) -> Router {
Router::with_urls([
Route::with_handler_and_name(
@@ -268,8 +306,11 @@ impl Project for AmneziaFellowProject {
" Priority: env var > DB override > compiled default.\n",
"\n",
" Database (required for most features):\n",
" AMNEZIA_FELLOW_DATABASE_URL SQLite connection URL\n",
" AMNEZIA_FELLOW_DATABASE_URL SQLite or PostgreSQL connection URL\n",
" Example: sqlite:///data/amnezia-fellow.sqlite3?mode=rwc\n",
" Example: postgresql://user:pass@postgres:5432/amnezia_fellow\n",
" AMNEZIA_FELLOW_MIGRATE_SQLITE SQLite path/URL to import into PostgreSQL once\n",
" Example: /data/amnezia-fellow.sqlite3\n",
"\n",
" Server:\n",
" AMNEZIA_FELLOW_LOG_LEVEL Tracing filter (default: info)\n",
@@ -292,6 +333,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 +425,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 }
}
+186
View File
@@ -0,0 +1,186 @@
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use cot::db::migrations::{self, MigrationEngine, SyncDynMigration};
use cot::db::{Database, Model};
use cot::session::db::Session as CotSession;
use crate::config::{AppConfig, ConfigEntry};
use crate::user::{OidcLink, User};
use crate::vpn::VpnClient;
const MARKER_KEY: &str = "sqlite_migration_completed_at";
const SOURCE_KEY: &str = "sqlite_migration_source";
pub async fn run_if_requested(config: &AppConfig, target_db: &Database) -> cot::Result<()> {
let source = config.migrate_sqlite.trim();
if source.is_empty() {
return Ok(());
}
if !is_postgres_url(&config.database_url) {
tracing::warn!(
"AMNEZIA_FELLOW_MIGRATE_SQLITE is set but the active database is not PostgreSQL; skipping SQLite import"
);
return Ok(());
}
if ConfigEntry::get_by_key(target_db, MARKER_KEY)
.await
.map_err(internal)?
.is_some()
{
tracing::info!("SQLite import marker exists; skipping SQLite import");
return Ok(());
}
if target_has_app_data(target_db).await? {
return Err(cot::Error::internal(
"PostgreSQL target already contains amnezia-fellow data and has no SQLite import marker; refusing to merge automatically",
));
}
let source_url = sqlite_source_url(source)?;
tracing::info!("Importing amnezia-fellow data from SQLite into PostgreSQL");
let source_db = Database::new(source_url)
.await
.map_err(|e| cot::Error::internal(format!("failed to open SQLite source: {e}")))?;
run_app_migrations(&source_db).await?;
copy_app_data(&source_db, target_db).await?;
reset_postgres_sequences(target_db).await?;
write_marker(target_db, source).await?;
tracing::info!("SQLite import into PostgreSQL completed");
Ok(())
}
fn is_postgres_url(url: &str) -> bool {
url.starts_with("postgresql:")
}
fn sqlite_source_url(source: &str) -> cot::Result<String> {
if source.starts_with("sqlite:") {
return Ok(source.to_owned());
}
if !Path::new(source).exists() {
return Err(cot::Error::internal(format!(
"SQLite import source does not exist: {source}"
)));
}
let prefix = if Path::new(source).is_absolute() {
"sqlite://"
} else {
"sqlite:"
};
Ok(format!("{prefix}{source}?mode=rw"))
}
async fn run_app_migrations(db: &Database) -> cot::Result<()> {
let engine = MigrationEngine::new(app_migrations())
.map_err(|e| cot::Error::internal(format!("failed to build migration engine: {e}")))?;
engine
.run(db)
.await
.map_err(|e| cot::Error::internal(format!("failed to migrate SQLite source: {e}")))
}
fn app_migrations() -> Vec<Box<SyncDynMigration>> {
let mut all = migrations::wrap_migrations(cot::session::db::migrations::MIGRATIONS);
all.extend(migrations::wrap_migrations(
crate::config::db_migrations::MIGRATIONS,
));
all.extend(migrations::wrap_migrations(
crate::user::db_migrations::MIGRATIONS,
));
all.extend(migrations::wrap_migrations(
crate::vpn::db_migrations::MIGRATIONS,
));
all
}
async fn target_has_app_data(db: &Database) -> cot::Result<bool> {
let config_entries = ConfigEntry::objects().count(db).await.map_err(internal)?;
let users = User::objects().count(db).await.map_err(internal)?;
let oidc_links = OidcLink::objects().count(db).await.map_err(internal)?;
let clients = VpnClient::objects().count(db).await.map_err(internal)?;
Ok(config_entries > 0 || users > 0 || oidc_links > 0 || clients > 0)
}
async fn copy_app_data(source_db: &Database, target_db: &Database) -> cot::Result<()> {
for mut entry in ConfigEntry::objects().all(source_db).await.map_err(internal)? {
if matches!(
entry.key_str(),
"database_url" | "migrate_sqlite" | MARKER_KEY | SOURCE_KEY
) {
continue;
}
entry.save(target_db).await.map_err(internal)?;
}
for mut session in CotSession::objects()
.all(source_db)
.await
.map_err(internal)?
{
session.save(target_db).await.map_err(internal)?;
}
for mut user in User::list_all(source_db).await.map_err(internal)? {
user.save(target_db).await.map_err(internal)?;
}
for mut link in OidcLink::objects().all(source_db).await.map_err(internal)? {
link.save(target_db).await.map_err(internal)?;
}
for mut client in VpnClient::list_all(source_db).await.map_err(internal)? {
client.save(target_db).await.map_err(internal)?;
}
Ok(())
}
async fn reset_postgres_sequences(db: &Database) -> cot::Result<()> {
for table in [
"cot__session",
"amnezia_fellow__user",
"amnezia_fellow__oidc_link",
"amnezia_fellow__vpn_client",
] {
let sql = format!(
"SELECT setval(pg_get_serial_sequence('{table}', 'id'), \
COALESCE((SELECT MAX(id) FROM {table}), 1), \
(SELECT COUNT(*) > 0 FROM {table}))"
);
db.raw(&sql).await.map_err(internal)?;
}
Ok(())
}
async fn write_marker(db: &Database, source: &str) -> cot::Result<()> {
let mut marker = ConfigEntry::new(MARKER_KEY.to_owned(), now_unix_seconds().to_string());
marker.save(db).await.map_err(internal)?;
let mut source_entry = ConfigEntry::new(SOURCE_KEY.to_owned(), source.to_owned());
source_entry.save(db).await.map_err(internal)?;
let mut migrate_entry = ConfigEntry::new("migrate_sqlite".to_owned(), String::new());
migrate_entry.save(db).await.map_err(internal)?;
Ok(())
}
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)
}
fn internal(error: impl std::fmt::Display) -> cot::Error {
cot::Error::internal(error.to_string())
}
+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,
];
}
+106 -11
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(),
}
}
@@ -1015,7 +1031,57 @@ pub mod db_migrations {
&[Operation::custom(create_vpn_client_indexes).build()];
}
pub const MIGRATIONS: &[&SyncDynMigration] = &[&M0005CreateVpnClient, &M0006VpnClientIndexes];
#[cot::db::migrations::migration_op]
async fn normalize_bigint_ids(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> {
if ctx
.db
.raw("SELECT current_setting('server_version_num')")
.await
.is_err()
{
return Ok(());
}
for sql in [
"ALTER TABLE amnezia_fellow__user ALTER COLUMN id TYPE BIGINT",
"ALTER TABLE amnezia_fellow__user \
ALTER COLUMN telegram_link_code_created_at TYPE BIGINT",
"ALTER TABLE amnezia_fellow__oidc_link ALTER COLUMN id TYPE BIGINT",
"ALTER TABLE amnezia_fellow__oidc_link ALTER COLUMN user_id TYPE BIGINT",
"ALTER TABLE amnezia_fellow__vpn_client ALTER COLUMN id TYPE BIGINT",
"ALTER TABLE amnezia_fellow__vpn_client ALTER COLUMN owner_user_id TYPE BIGINT",
] {
ctx.db.raw(sql).await?;
}
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0009NormalizeBigintIds;
impl migrations::Migration for M0009NormalizeBigintIds {
const APP_NAME: &'static str = "amnezia_fellow";
const MIGRATION_NAME: &'static str = "m_0009_normalize_bigint_ids";
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
migrations::MigrationDependency::migration(
"amnezia_fellow",
"m_0006_vpn_client_indexes",
),
migrations::MigrationDependency::migration(
"amnezia_fellow",
"m_0008_user_telegram_link_code",
),
];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(normalize_bigint_ids).build()];
}
pub const MIGRATIONS: &[&SyncDynMigration] = &[
&M0005CreateVpnClient,
&M0006VpnClientIndexes,
&M0009NormalizeBigintIds,
];
}
#[cfg(test)]
@@ -1062,6 +1128,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 +1183,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 %}
+431 -60
View File
@@ -3,9 +3,11 @@
{% block title %}{{ t.configs_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: #f6f7f8; color: #1d252d; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
a { color: inherit; }
.shell { min-height: 100vh; display: grid; grid-template-columns: 220px minmax(0, 1fr); }
@@ -14,9 +16,16 @@
.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; }
.telegram-status-button { min-width: 36px; width: 36px; height: 36px; min-height: 36px; padding: 0; display: inline-grid; place-items: center; position: relative; border-color: #cbd3db; background: #fff; color: #17202a; }
.telegram-status-button .telegram-mark { font-size: .7rem; font-weight: 900; letter-spacing: 0; }
.telegram-dot { position: absolute; right: 4px; top: 4px; width: .55rem; height: .55rem; border-radius: 999px; border: 2px solid #fff; background: #7b8793; }
.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: #9d2323; }
.logout-link, .lang-switch a { font-size: .875rem; text-decoration: none; color: #53606d; padding: .25rem .45rem; border-radius: 4px; }
.logout-link:hover, .lang-switch a:hover { background: #eef1f4; color: #1d252d; }
.lang-switch a.active { color: #1d252d; font-weight: 700; }
@@ -42,6 +51,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 +68,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 +101,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; }
@@ -92,6 +113,13 @@
.qr-box { display: grid; place-items: center; padding: .75rem; border: 1px solid #dde2e6; border-radius: 6px; background: #fff; }
.qr-box svg { width: min(280px, 100%); height: auto; display: block; }
.modal-actions { display: flex; gap: .5rem; justify-content: flex-end; margin-top: .75rem; flex-wrap: wrap; }
.telegram-modal { width: min(480px, 100%); display: grid; gap: .8rem; }
.telegram-copy { color: #53606d; line-height: 1.45; margin: 0; }
.guide-list { margin: 0; padding-left: 1.2rem; color: #34414f; display: grid; gap: .45rem; line-height: 1.4; }
.guide-list a { color: #17202a; font-weight: 800; }
.guide-list a.disabled { color: #6a7682; pointer-events: none; text-decoration: none; }
.secret-box { display: grid; gap: .35rem; border: 1px solid #dde2e6; border-radius: 6px; background: #f8fafb; padding: .7rem; }
.secret-box code { display: block; padding: .55rem .65rem; font-size: .92rem; white-space: normal; overflow-wrap: anywhere; }
@media (max-width: 760px) {
.shell { grid-template-columns: 1fr; }
.sidebar { display: flex; align-items: center; gap: .75rem; overflow-x: auto; }
@@ -104,8 +132,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; }
@@ -127,6 +154,10 @@
<div class="topbar">
<span class="app-version">v{{ app_version }}</span>
<span class="user-info">{{ user_name }} ({{ user_role }})</span>
<button type="button" x-cloak 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>
@@ -146,7 +177,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">
@@ -321,6 +361,90 @@
</div>
</div>
</template>
<template x-if="telegramModal">
<div class="modal-backdrop" @click.self="telegramModal = null">
<div class="modal telegram-modal">
<div class="modal-head">
<div class="modal-title">
<h3>{{ t.telegram_link_title }}</h3>
<div class="modal-subtitle" x-text="telegramBotLabel()"></div>
</div>
<button class="secondary" @click="telegramModal = null">{{ t.admin_close }}</button>
</div>
<template x-if="telegramModal === 'manage'">
<div>
<p class="telegram-copy" x-text="telegramManageMessage()"></p>
<template x-if="telegram.linked">
<div class="detail-row">
<span class="detail-label">{{ t.telegram_status_connected }}</span>
<span class="detail-value"><code x-text="telegram.telegram_id || ''"></code></span>
</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">
<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>
<p class="telegram-copy">{{ 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>
</div>
</div>
</template>
</div>
<script>
@@ -331,6 +455,7 @@ function configsPage() {
busy: false,
status: '',
error: '',
notice: null,
rollout: null,
rolloutBusy: false,
rolloutError: '',
@@ -339,10 +464,31 @@ function configsPage() {
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,
isAdmin: {% if is_admin %}true{% else %}false{% endif %},
init() {
this.initTelegramWebApp();
this.load();
this.loadRolloutStatus();
this.rolloutTimer = setInterval(() => this.loadRolloutStatus(), 10000);
this.loadTelegramStatus();
if (this.isAdmin) {
this.loadRolloutStatus();
this.rolloutTimer = setInterval(() => this.loadRolloutStatus(), 10000);
}
},
async request(url, options = {}) {
const response = await fetch(url, {
@@ -351,10 +497,166 @@ 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;
},
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 load() {
this.error = '';
this.busy = true;
@@ -368,8 +670,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 +680,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 +699,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 +717,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 +780,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 +844,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 +910,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 }}',
};