4 Commits
Author SHA1 Message Date
ab e8873f61a5 Fixed mobile UI
Build and Publish / Build and Publish Docker Image (push) Successful in 3m7s
2026-07-06 16:57:26 +03:00
ab 2dbbdb0252 Fixed TG login
Build and Publish / Build and Publish Docker Image (push) Successful in 5m9s
2026-07-06 16:30:16 +03:00
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
14 changed files with 835 additions and 29 deletions
Generated
+2 -1
View File
@@ -61,8 +61,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "amnezia-fellow"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"async-trait",
"base64 0.22.1",
"cot",
"curve25519-dalek",
+4 -3
View File
@@ -1,11 +1,12 @@
[package]
name = "amnezia-fellow"
version = "0.1.5"
version = "1.0.0"
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"
+23 -4
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` |
@@ -118,6 +119,23 @@ All settings use `AMNEZIA_FELLOW_` prefix. Priority is:
| `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:
@@ -138,12 +156,13 @@ delivered.
## API
The JSON API is session-authenticated:
The JSON API is session-authenticated unless noted:
- `GET /api/me`
- `GET /api/vpn-clients`
- `GET /api/vpn-status`
- `GET /api/telegram-link/status`
- `POST /api/telegram-login/webapp` (public Telegram `initData` login)
- `POST /api/telegram-link/webapp`
- `POST /api/telegram-link/start`
- `POST /api/telegram-link/decline`
+5
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(),
+75
View File
@@ -162,6 +162,11 @@ struct TelegramLinkResponse {
status: TelegramLinkStatusResponse,
}
#[derive(Debug, Serialize, JsonSchema)]
struct TelegramLoginResponse {
redirect_to: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct TelegramWebAppLinkRequest {
init_data: String,
@@ -292,6 +297,71 @@ async fn telegram_link_webapp_handler(
.into_response()
}
async fn telegram_login_webapp_handler(
session: Session,
db: Database,
Json(request): Json<TelegramWebAppLinkRequest>,
) -> cot::Result<cot::response::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();
let Some(user) = User::get_by_telegram_id(&db, &telegram_id)
.await
.map_err(|e| cot::Error::internal(format!("failed to load Telegram user: {e}")))?
else {
return Ok(json_error_typed(
cot::http::StatusCode::UNAUTHORIZED,
"telegram_account_not_linked",
"Telegram is not linked",
"This Telegram account is not connected to any VPN account.",
"",
));
};
if !user.is_active() {
return Ok(json_error_typed(
cot::http::StatusCode::FORBIDDEN,
"user_inactive",
"Account is inactive",
"This account is inactive.",
"",
));
}
auth::login(&session, user.id_val()).await?;
Json(TelegramLoginResponse {
redirect_to: "/configs".to_owned(),
})
.into_response()
}
async fn telegram_link_start_handler(
session: Session,
db: Database,
@@ -831,6 +901,11 @@ impl App for ApiApp {
api_post(telegram_link_webapp_handler),
"api_telegram_link_webapp",
),
Route::with_api_handler_and_name(
"/telegram-login/webapp",
api_post(telegram_login_webapp_handler),
"api_telegram_login_webapp",
),
Route::with_api_handler_and_name(
"/telegram-link/start",
api_post(telegram_link_start_handler),
+17 -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,
@@ -116,6 +126,7 @@ 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,
@@ -194,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.
@@ -248,6 +261,7 @@ 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(),
@@ -277,6 +291,7 @@ impl Default for AppConfig {
impl_env_overrides!(
database_url,
migrate_sqlite,
oidc_issuer,
oidc_client_id,
oidc_client_secret,
@@ -355,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);
+4 -1
View File
@@ -42,6 +42,9 @@ translations! {
login_submit: "Sign in" , "Войти";
login_disabled: "Login is currently disabled." , "Вход сейчас отключён.";
login_invalid: "Invalid username or password." , "Неверное имя пользователя или пароль.";
login_telegram_wait: "Signing in with Telegram..." , "Входим через Telegram...";
login_telegram_fallback: "Open the regular login page" , "Открыть обычный вход";
login_telegram_failed: "Telegram login failed. Use regular login." , "Не удалось войти через Telegram. Используйте обычный вход.";
// Logout
nav_logout: "Logout" , "Выход";
@@ -169,7 +172,7 @@ translations! {
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_guide_paste: "Return here; the status updates automatically." , "Вернитесь сюда; статус обновится автоматически.";
telegram_secret_label: "Secret code" , "Секретный код";
telegram_save: "Save Telegram" , "Сохранить Telegram";
telegram_saved: "Telegram connected." , "Telegram подключён.";
+26 -5
View File
@@ -4,12 +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;
@@ -21,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;
@@ -65,14 +67,22 @@ struct ClientPortalTemplate {
app_version: &'static str,
}
#[derive(Debug, Template)]
#[template(path = "telegram_login.html")]
struct TelegramLoginTemplate {
t: &'static Translations,
}
async fn configs_page(
session: Session,
db: Database,
i18n: I18n,
) -> cot::Result<cot::response::Response> {
let user = match auth::require_user_or_redirect(&session, &db).await {
Ok(user) => user,
Err(response) => return Ok(response),
let user = match auth::get_session_user(&session, &db).await {
Some(user) => user,
None => {
return Html::new(TelegramLoginTemplate { t: i18n.t }.render()?).into_response();
}
};
let is_admin = user.role == auth::Role::Admin;
@@ -181,11 +191,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(
@@ -296,8 +314,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",
+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())
}
+51 -1
View File
@@ -1031,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)]
+24 -2
View File
@@ -461,12 +461,15 @@ function clientPortal() {
telegramModal: null,
telegramBusy: false,
telegramPromptChecked: false,
telegramStatusTimer: null,
telegramStatusPolling: false,
init() {
this.initTelegramWebApp();
this.load();
this.loadServerStatus();
this.loadTelegramStatus();
this.serverStatusTimer = setInterval(() => this.loadServerStatus(true), 30000);
this.telegramStatusTimer = setInterval(() => this.pollTelegramStatus(), 2500);
},
async request(url, options = {}) {
const response = await fetch(url, {
@@ -517,11 +520,11 @@ function clientPortal() {
this.telegram.isWebApp = Boolean(webApp.initData);
this.telegram.initData = webApp.initData || '';
},
async loadTelegramStatus() {
async loadTelegramStatus(options = {}) {
try {
const status = await this.request('/api/telegram-link/status');
this.applyTelegramStatus(status);
this.maybePromptTelegram();
if (options.prompt !== false) this.maybePromptTelegram();
} catch (e) {
console.warn('telegram status failed', e);
}
@@ -596,6 +599,25 @@ function clientPortal() {
this.telegramBusy = false;
}
},
async pollTelegramStatus() {
if (this.telegramStatusPolling || this.telegramBusy) return;
if (!this.telegram.enabled || !this.telegram.pending) return;
if (this.telegramModal !== 'manualGuide' && this.telegramModal !== 'manage') return;
this.telegramStatusPolling = true;
const wasPending = this.telegram.pending;
try {
await this.loadTelegramStatus({ prompt: false });
if (wasPending && this.telegram.linked) {
this.telegramModal = 'manage';
this.setNotice('success', '{{ t.notice_success_title }}', '{{ t.telegram_saved }}');
} else if (wasPending && !this.telegram.pending) {
this.telegramModal = 'manage';
}
} finally {
this.telegramStatusPolling = false;
}
},
async copyTelegramSecret() {
if (!this.telegram.pending_secret) return;
this.clearNotice();
+321 -11
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); }
@@ -17,6 +19,13 @@
.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; }
@@ -32,7 +41,7 @@
button.secondary { background: #fff; color: #17202a; border-color: #cbd3db; }
button.danger { background: #9d2323; border-color: #9d2323; }
button:disabled { opacity: .55; cursor: default; }
table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #dde2e6; border-radius: 6px; overflow: hidden; }
.config-table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #dde2e6; border-radius: 6px; overflow: hidden; }
th, td { text-align: left; padding: .65rem .75rem; border-bottom: 1px solid #edf0f2; font-size: .92rem; vertical-align: middle; }
th { background: #eef1f4; font-weight: 650; color: #34414f; }
tr:last-child td { border-bottom: 0; }
@@ -104,17 +113,39 @@
.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; }
.sidebar { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; overflow-x: visible; }
.sidebar h1 { margin: 0; white-space: nowrap; }
.sidebar a { margin: 0; white-space: nowrap; }
.toolbar { align-items: stretch; flex-direction: column; }
.actions { align-items: stretch; }
.actions input, .actions button { width: 100%; }
.main { padding: 1rem; }
table { display: block; overflow-x: auto; }
.row-actions { align-items: stretch; }
.config-table { display: block; border: 0; border-radius: 0; background: transparent; overflow: visible; }
.config-table thead { display: none; }
.config-table tbody { display: grid; gap: .65rem; margin-bottom: .75rem; }
.config-table tr { display: grid; min-width: 0; border: 1px solid #dde2e6; border-radius: 8px; background: #fff; overflow: hidden; box-shadow: 0 4px 16px rgba(23, 32, 42, .05); }
.config-table tr.owner-row { border: 0; border-radius: 0; background: transparent; box-shadow: none; margin: .2rem 0 -.2rem; }
.config-table tr.owner-row td { display: flex; align-items: center; gap: .25rem; border: 0; padding: .15rem .1rem; background: transparent; color: #34414f; }
.config-table tr.owner-row td::before { display: none; }
.config-table td { display: grid; grid-template-columns: minmax(84px, .4fr) minmax(0, 1fr); gap: .55rem; align-items: center; min-width: 0; padding: .55rem .65rem; }
.config-table td::before { content: attr(data-label); min-width: 0; color: #53606d; font-size: .78rem; font-weight: 750; }
.config-table td:last-child { border-bottom: 0; }
.config-table code { max-width: 100%; white-space: normal; overflow-wrap: anywhere; }
.client-name-cell { display: block !important; padding: .7rem .65rem .55rem !important; color: #1d252d; font-size: 1rem; font-weight: 800; overflow-wrap: anywhere; }
.client-name-cell::before, .client-actions-cell::before { display: none; }
.client-actions-cell { display: block !important; padding: .65rem !important; }
.row-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: stretch; width: 100%; }
.row-actions button { width: 100%; min-width: 0; white-space: normal; }
.row-actions button:first-child { grid-column: 1 / -1; }
.servers-modal { width: 100%; max-height: calc(100vh - 2rem); }
.server-card-actions { grid-template-columns: 1fr; }
.server-card-actions button { flex: 1 1 100%; }
@@ -138,6 +169,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>
@@ -212,7 +247,7 @@
</template>
<template x-if="clients.length > 0">
<table>
<table class="config-table">
<thead>
<tr>
<th>{{ t.configs_name }}</th>
@@ -237,14 +272,14 @@
{% endif %}
<template x-for="client in group.clients" :key="client.id">
<tr>
<td x-text="client.name"></td>
<td class="client-name-cell" data-label="{{ t.configs_name }}" x-text="client.name"></td>
{% if is_admin %}
<td x-text="ownerLabel(client)"></td>
<td data-label="{{ t.configs_owner }}" x-text="ownerLabel(client)"></td>
{% endif %}
<td><code x-text="client.address + '/32'"></code></td>
<td><code x-text="shortKey(client.public_key)"></code></td>
<td x-text="client.enabled ? '{{ t.configs_yes }}' : '{{ t.configs_no }}'"></td>
<td>
<td data-label="{{ t.configs_address }}"><code x-text="client.address + '/32'"></code></td>
<td data-label="{{ t.configs_public_key }}"><code x-text="shortKey(client.public_key)"></code></td>
<td data-label="{{ t.configs_enabled }}" x-text="client.enabled ? '{{ t.configs_yes }}' : '{{ t.configs_no }}'"></td>
<td class="client-actions-cell" data-label="{{ t.users_actions }}">
<div class="row-actions">
<button class="secondary" @click="openServers(client)" :disabled="busy">{{ t.configs_servers }}</button>
<button class="secondary" @click="setEnabled(client, !client.enabled)" :disabled="busy" x-text="client.enabled ? '{{ t.configs_disable }}' : '{{ t.configs_enable }}'"></button>
@@ -341,6 +376,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>
@@ -360,13 +479,34 @@ 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,
telegramStatusTimer: null,
telegramStatusPolling: false,
isAdmin: {% if is_admin %}true{% else %}false{% endif %},
init() {
this.initTelegramWebApp();
this.load();
this.loadTelegramStatus();
if (this.isAdmin) {
this.loadRolloutStatus();
this.rolloutTimer = setInterval(() => this.loadRolloutStatus(), 10000);
}
this.telegramStatusTimer = setInterval(() => this.pollTelegramStatus(), 2500);
},
async request(url, options = {}) {
const response = await fetch(url, {
@@ -384,6 +524,176 @@ function configsPage() {
}
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(options = {}) {
try {
const status = await this.request('/api/telegram-link/status');
this.applyTelegramStatus(status);
if (options.prompt !== false) 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 pollTelegramStatus() {
if (this.telegramStatusPolling || this.telegramBusy) return;
if (!this.telegram.enabled || !this.telegram.pending) return;
if (this.telegramModal !== 'manualGuide' && this.telegramModal !== 'manage') return;
this.telegramStatusPolling = true;
const wasPending = this.telegram.pending;
try {
await this.loadTelegramStatus({ prompt: false });
if (wasPending && this.telegram.linked) {
this.telegramModal = 'manage';
this.setNotice('success', '{{ t.notice_success_title }}', '{{ t.telegram_saved }}');
} else if (wasPending && !this.telegram.pending) {
this.telegramModal = 'manage';
}
} finally {
this.telegramStatusPolling = 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;
+35
View File
@@ -3,6 +3,7 @@
{% block title %}{{ t.login_heading }} | {{ t.site_name }}{% endblock title %}
{% block head_extra %}
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui, -apple-system, sans-serif; background: #f5f5f5; color: #333; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
@@ -30,6 +31,7 @@
{% if !message.is_empty() %}
<div class="flash">{{ message }}</div>
{% endif %}
<div id="telegram-login-flash" class="flash" style="display: none;"></div>
{% if !auth_password_enabled && !auth_sso_enabled %}
<p class="message">{{ t.login_disabled }}</p>
@@ -53,4 +55,37 @@
<a class="sso-btn" href="/auth/oidc/start">{{ oidc_button_text }}</a>
{% endif %}
</div>
<script>
(async () => {
const webApp = window.Telegram && window.Telegram.WebApp;
if (!webApp || !webApp.initData) return;
try {
webApp.ready();
webApp.expand();
} catch (_) {}
try {
const response = await fetch('/api/telegram-login/webapp', {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ init_data: webApp.initData }),
});
const data = await response.json().catch(() => ({}));
if (response.ok) {
window.location.replace(data.redirect_to || '/configs');
return;
}
const flash = document.getElementById('telegram-login-flash');
flash.textContent = data.error || data.detail || '{{ t.login_telegram_failed }}';
flash.style.display = 'block';
} catch (_) {
const flash = document.getElementById('telegram-login-flash');
flash.textContent = '{{ t.login_telegram_failed }}';
flash.style.display = 'block';
}
})();
</script>
{% endblock body %}
+62
View File
@@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block title %}{{ t.login_heading }} | {{ t.site_name }}{% endblock title %}
{% block head_extra %}
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<style>
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 1rem; background: #f5f5f5; color: #333; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
.telegram-login-card { width: min(380px, 100%); border-radius: 8px; background: #fff; box-shadow: 0 2px 8px rgba(0,0,0,.12); padding: 2rem; text-align: center; display: grid; gap: .8rem; }
.telegram-login-card h1 { margin: 0; font-size: 1.35rem; }
.telegram-login-card p { margin: 0; color: #666; line-height: 1.45; }
.telegram-login-card a { color: #1a1a2e; font-weight: 700; }
</style>
{% endblock head_extra %}
{% block body %}
<div class="telegram-login-card">
<h1>{{ t.login_heading }}</h1>
<p id="telegram-login-status">{{ t.login_telegram_wait }}</p>
<a href="/login">{{ t.login_telegram_fallback }}</a>
</div>
<script>
(async () => {
const status = document.getElementById('telegram-login-status');
const fallback = () => window.location.replace('/login');
const webApp = window.Telegram && window.Telegram.WebApp;
try {
if (webApp) {
webApp.ready();
webApp.expand();
}
} catch (_) {}
if (!webApp || !webApp.initData) {
fallback();
return;
}
try {
const response = await fetch('/api/telegram-login/webapp', {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ init_data: webApp.initData }),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
status.textContent = data.error || data.detail || '{{ t.login_telegram_failed }}';
setTimeout(fallback, 1800);
return;
}
window.location.replace(data.redirect_to || '/configs');
} catch (_) {
status.textContent = '{{ t.login_telegram_failed }}';
setTimeout(fallback, 1800);
}
})();
</script>
{% endblock body %}