Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e890d1c147 | ||
|
|
9614ff8a60 | ||
|
|
e8873f61a5 | ||
|
|
2dbbdb0252 | ||
|
|
3b4899f785 | ||
|
|
9ccab69836 |
Generated
+2
-1
@@ -61,8 +61,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "amnezia-fellow"
|
name = "amnezia-fellow"
|
||||||
version = "0.1.4"
|
version = "1.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"cot",
|
"cot",
|
||||||
"curve25519-dalek",
|
"curve25519-dalek",
|
||||||
|
|||||||
+4
-3
@@ -1,11 +1,12 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "amnezia-fellow"
|
name = "amnezia-fellow"
|
||||||
version = "0.1.5"
|
version = "1.0.1"
|
||||||
edition = "2024"
|
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]
|
[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"] }
|
schemars = { version = "0.9", features = ["derive"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
openidconnect = "4.0"
|
openidconnect = "4.0"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Amnezia VPN client manager written in Rust on top of the public [`cot`](https://cot.rs) framework.
|
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
|
## Quick Start
|
||||||
|
|
||||||
@@ -53,17 +53,20 @@ The OIDC groups claim is expected to be `groups`.
|
|||||||
|
|
||||||
## VPN Data Model
|
## 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
|
- owner user id
|
||||||
- display name
|
- display name
|
||||||
- assigned IPv4 address
|
- assigned IPv4 address
|
||||||
- public key
|
- public key
|
||||||
- private key
|
- private key
|
||||||
|
- optional owner-scoped connectivity group
|
||||||
- enabled flag
|
- enabled flag
|
||||||
- created/updated timestamps
|
- created/updated timestamps
|
||||||
|
|
||||||
The Kubernetes Secret is derived from the database. Active clients are rendered into one configured Secret key, `peers.conf` by default.
|
The Kubernetes Secret is derived from the database. Active clients are rendered
|
||||||
|
into `peers.conf`; exact same-group IPv4 pairs are rendered into
|
||||||
|
`policy.conf`. Clients without a group are isolated from other VPN clients.
|
||||||
|
|
||||||
## Kubernetes Sync
|
## Kubernetes Sync
|
||||||
|
|
||||||
@@ -95,7 +98,8 @@ All settings use `AMNEZIA_FELLOW_` prefix. Priority is:
|
|||||||
|
|
||||||
| Variable | Description | Default |
|
| 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_LOG_LEVEL` | Tracing filter | `info` |
|
||||||
| `AMNEZIA_FELLOW_AUTH_PASSWORD_ENABLED` | Enable password login | `true` |
|
| `AMNEZIA_FELLOW_AUTH_PASSWORD_ENABLED` | Enable password login | `true` |
|
||||||
| `AMNEZIA_FELLOW_AUTH_SSO_ENABLED` | Enable OIDC login | `false` |
|
| `AMNEZIA_FELLOW_AUTH_SSO_ENABLED` | Enable OIDC login | `false` |
|
||||||
@@ -118,6 +122,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_USERNAME` | Telegram bot username, with or without `@` | empty |
|
||||||
| `AMNEZIA_FELLOW_TELEGRAM_BOT_TOKEN` | Telegram bot token used to verify Web App `initData` | 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
|
## Telegram Bot
|
||||||
|
|
||||||
Configure the bot through the admin Settings page or the matching environment variables:
|
Configure the bot through the admin Settings page or the matching environment variables:
|
||||||
@@ -138,18 +159,20 @@ delivered.
|
|||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
The JSON API is session-authenticated:
|
The JSON API is session-authenticated unless noted:
|
||||||
|
|
||||||
- `GET /api/me`
|
- `GET /api/me`
|
||||||
- `GET /api/vpn-clients`
|
- `GET /api/vpn-clients`
|
||||||
- `GET /api/vpn-status`
|
- `GET /api/vpn-status`
|
||||||
- `GET /api/telegram-link/status`
|
- `GET /api/telegram-link/status`
|
||||||
|
- `POST /api/telegram-login/webapp` (public Telegram `initData` login)
|
||||||
- `POST /api/telegram-link/webapp`
|
- `POST /api/telegram-link/webapp`
|
||||||
- `POST /api/telegram-link/start`
|
- `POST /api/telegram-link/start`
|
||||||
- `POST /api/telegram-link/decline`
|
- `POST /api/telegram-link/decline`
|
||||||
- `POST /api/telegram-link/unlink`
|
- `POST /api/telegram-link/unlink`
|
||||||
- `POST /api/vpn-clients`
|
- `POST /api/vpn-clients`
|
||||||
- `POST /api/vpn-clients/{id}/enabled`
|
- `POST /api/vpn-clients/{id}/enabled`
|
||||||
|
- `POST /api/vpn-clients/{id}/group` changes the group and rotates the client key pair
|
||||||
- `DELETE /api/vpn-clients/{id}`
|
- `DELETE /api/vpn-clients/{id}`
|
||||||
- `GET /api/vpn-clients/{id}/config` returns `servers[]` with one raw AWG config and one Amnezia `vpn://` import link per registered endpoint
|
- `GET /api/vpn-clients/{id}/config` returns `servers[]` with one raw AWG config and one Amnezia `vpn://` import link per registered endpoint
|
||||||
- `POST /api/vpn-clients/sync`
|
- `POST /api/vpn-clients/sync`
|
||||||
|
|||||||
@@ -84,6 +84,11 @@ fn config_display_entries(config: &AppConfig, sources: &ConfigSources) -> Vec<Co
|
|||||||
config.database_url.clone(),
|
config.database_url.clone(),
|
||||||
defaults.database_url.clone()
|
defaults.database_url.clone()
|
||||||
),
|
),
|
||||||
|
entry!(
|
||||||
|
migrate_sqlite,
|
||||||
|
config.migrate_sqlite.clone(),
|
||||||
|
defaults.migrate_sqlite.clone()
|
||||||
|
),
|
||||||
entry!(
|
entry!(
|
||||||
oidc_issuer,
|
oidc_issuer,
|
||||||
config.oidc_issuer.clone(),
|
config.oidc_issuer.clone(),
|
||||||
|
|||||||
+144
@@ -87,11 +87,13 @@ async fn me_handler(session: Session, db: Database) -> cot::Result<cot::response
|
|||||||
struct VpnClientsResponse {
|
struct VpnClientsResponse {
|
||||||
role: String,
|
role: String,
|
||||||
clients: Vec<vpn::VpnClientView>,
|
clients: Vec<vpn::VpnClientView>,
|
||||||
|
groups: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
struct CreateVpnClientRequest {
|
struct CreateVpnClientRequest {
|
||||||
name: String,
|
name: String,
|
||||||
|
group_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
@@ -106,6 +108,11 @@ struct SetEnabledRequest {
|
|||||||
enabled: bool,
|
enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
struct SetGroupRequest {
|
||||||
|
group_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
struct DeleteVpnClientResponse {
|
struct DeleteVpnClientResponse {
|
||||||
sync: vpn::SecretSyncResult,
|
sync: vpn::SecretSyncResult,
|
||||||
@@ -162,6 +169,11 @@ struct TelegramLinkResponse {
|
|||||||
status: TelegramLinkStatusResponse,
|
status: TelegramLinkStatusResponse,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
struct TelegramLoginResponse {
|
||||||
|
redirect_to: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
struct TelegramWebAppLinkRequest {
|
struct TelegramWebAppLinkRequest {
|
||||||
init_data: String,
|
init_data: String,
|
||||||
@@ -184,6 +196,14 @@ async fn vpn_clients_handler(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(format!("failed to list clients: {e}")))?;
|
.map_err(|e| cot::Error::internal(format!("failed to list clients: {e}")))?;
|
||||||
let owner_map = owner_view_map(&db, &clients).await?;
|
let owner_map = owner_view_map(&db, &clients).await?;
|
||||||
|
let mut groups = clients
|
||||||
|
.iter()
|
||||||
|
.filter(|client| client.owner_user_id() == user.id)
|
||||||
|
.filter_map(vpn::VpnClient::group_name_str)
|
||||||
|
.map(str::to_owned)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
groups.sort();
|
||||||
|
groups.dedup();
|
||||||
let clients = clients
|
let clients = clients
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|client| client_view_with_owner(client, &owner_map))
|
.map(|client| client_view_with_owner(client, &owner_map))
|
||||||
@@ -192,6 +212,7 @@ async fn vpn_clients_handler(
|
|||||||
Json(VpnClientsResponse {
|
Json(VpnClientsResponse {
|
||||||
role: user.role.code().to_owned(),
|
role: user.role.code().to_owned(),
|
||||||
clients,
|
clients,
|
||||||
|
groups,
|
||||||
})
|
})
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
@@ -292,6 +313,71 @@ async fn telegram_link_webapp_handler(
|
|||||||
.into_response()
|
.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(
|
async fn telegram_link_start_handler(
|
||||||
session: Session,
|
session: Session,
|
||||||
db: Database,
|
db: Database,
|
||||||
@@ -397,6 +483,7 @@ async fn create_vpn_client_handler(
|
|||||||
&db,
|
&db,
|
||||||
user.id,
|
user.id,
|
||||||
&request.name,
|
&request.name,
|
||||||
|
request.group_name.as_deref(),
|
||||||
&config.vpn_client_cidr,
|
&config.vpn_client_cidr,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -423,6 +510,53 @@ async fn create_vpn_client_handler(
|
|||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn set_vpn_client_group_handler(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
Path(path): Path<ClientPath>,
|
||||||
|
Json(request): Json<SetGroupRequest>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let user = match auth::require_user_or_redirect(&session, &db).await {
|
||||||
|
Ok(user) => user,
|
||||||
|
Err(_) => {
|
||||||
|
return Ok(json_error(
|
||||||
|
cot::http::StatusCode::UNAUTHORIZED,
|
||||||
|
"not authenticated",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(mut client) = vpn::VpnClient::get_visible(&db, &user, path.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(format!("failed to load client: {e}")))?
|
||||||
|
else {
|
||||||
|
return Ok(json_error(cot::http::StatusCode::NOT_FOUND, "not found"));
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = client
|
||||||
|
.set_group_and_rotate_keys(&db, request.group_name.as_deref())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return Ok(json_error_typed(
|
||||||
|
cot::http::StatusCode::BAD_REQUEST,
|
||||||
|
"client_group_update_failed",
|
||||||
|
"Could not change group",
|
||||||
|
"The client group was not changed.",
|
||||||
|
&e.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
let (sync, notice) = sync_after_client_mutation(&db, &config).await;
|
||||||
|
let owner_map = owner_view_map(&db, std::slice::from_ref(&client)).await?;
|
||||||
|
|
||||||
|
Json(MutateVpnClientResponse {
|
||||||
|
client: client_view_with_owner(client, &owner_map),
|
||||||
|
sync,
|
||||||
|
notice,
|
||||||
|
})
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
async fn set_vpn_client_enabled_handler(
|
async fn set_vpn_client_enabled_handler(
|
||||||
session: Session,
|
session: Session,
|
||||||
db: Database,
|
db: Database,
|
||||||
@@ -831,6 +965,11 @@ impl App for ApiApp {
|
|||||||
api_post(telegram_link_webapp_handler),
|
api_post(telegram_link_webapp_handler),
|
||||||
"api_telegram_link_webapp",
|
"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(
|
Route::with_api_handler_and_name(
|
||||||
"/telegram-link/start",
|
"/telegram-link/start",
|
||||||
api_post(telegram_link_start_handler),
|
api_post(telegram_link_start_handler),
|
||||||
@@ -856,6 +995,11 @@ impl App for ApiApp {
|
|||||||
api_post(set_vpn_client_enabled_handler),
|
api_post(set_vpn_client_enabled_handler),
|
||||||
"api_vpn_client_enabled",
|
"api_vpn_client_enabled",
|
||||||
),
|
),
|
||||||
|
Route::with_api_handler_and_name(
|
||||||
|
"/vpn-clients/{id}/group",
|
||||||
|
api_post(set_vpn_client_group_handler),
|
||||||
|
"api_vpn_client_group",
|
||||||
|
),
|
||||||
Route::with_api_handler_and_name(
|
Route::with_api_handler_and_name(
|
||||||
"/vpn-clients/{id}",
|
"/vpn-clients/{id}",
|
||||||
api_delete(delete_vpn_client_handler),
|
api_delete(delete_vpn_client_handler),
|
||||||
|
|||||||
+17
-1
@@ -47,6 +47,15 @@ impl ConfigEntry {
|
|||||||
pub fn new(key: String, value: String) -> Self {
|
pub fn new(key: String, value: String) -> Self {
|
||||||
Self { key, value }
|
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 struct ConfigSources {
|
||||||
pub database_url: ConfigSource,
|
pub database_url: ConfigSource,
|
||||||
|
pub migrate_sqlite: ConfigSource,
|
||||||
pub oidc_issuer: ConfigSource,
|
pub oidc_issuer: ConfigSource,
|
||||||
pub oidc_client_id: ConfigSource,
|
pub oidc_client_id: ConfigSource,
|
||||||
pub oidc_client_secret: ConfigSource,
|
pub oidc_client_secret: ConfigSource,
|
||||||
@@ -116,6 +126,7 @@ impl Default for ConfigSources {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
database_url: ConfigSource::Default,
|
database_url: ConfigSource::Default,
|
||||||
|
migrate_sqlite: ConfigSource::Default,
|
||||||
oidc_issuer: ConfigSource::Default,
|
oidc_issuer: ConfigSource::Default,
|
||||||
oidc_client_id: ConfigSource::Default,
|
oidc_client_id: ConfigSource::Default,
|
||||||
oidc_client_secret: ConfigSource::Default,
|
oidc_client_secret: ConfigSource::Default,
|
||||||
@@ -194,8 +205,10 @@ macro_rules! impl_env_overrides {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
/// SQLite connection URL.
|
/// SQLite or PostgreSQL connection URL.
|
||||||
pub database_url: String,
|
pub database_url: String,
|
||||||
|
/// Optional SQLite database path/URL copied into PostgreSQL on startup.
|
||||||
|
pub migrate_sqlite: String,
|
||||||
/// OIDC issuer URL.
|
/// OIDC issuer URL.
|
||||||
pub oidc_issuer: String,
|
pub oidc_issuer: String,
|
||||||
/// OIDC client ID.
|
/// OIDC client ID.
|
||||||
@@ -248,6 +261,7 @@ impl Default for AppConfig {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
database_url: "sqlite://amnezia-fellow.sqlite3?mode=rwc".into(),
|
database_url: "sqlite://amnezia-fellow.sqlite3?mode=rwc".into(),
|
||||||
|
migrate_sqlite: String::new(),
|
||||||
oidc_issuer: String::new(),
|
oidc_issuer: String::new(),
|
||||||
oidc_client_id: String::new(),
|
oidc_client_id: String::new(),
|
||||||
oidc_client_secret: String::new(),
|
oidc_client_secret: String::new(),
|
||||||
@@ -277,6 +291,7 @@ impl Default for AppConfig {
|
|||||||
|
|
||||||
impl_env_overrides!(
|
impl_env_overrides!(
|
||||||
database_url,
|
database_url,
|
||||||
|
migrate_sqlite,
|
||||||
oidc_issuer,
|
oidc_issuer,
|
||||||
oidc_client_id,
|
oidc_client_id,
|
||||||
oidc_client_secret,
|
oidc_client_secret,
|
||||||
@@ -355,6 +370,7 @@ impl AppConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
apply_db_field!(database_url);
|
apply_db_field!(database_url);
|
||||||
|
apply_db_field!(migrate_sqlite);
|
||||||
apply_db_field!(oidc_issuer);
|
apply_db_field!(oidc_issuer);
|
||||||
apply_db_field!(oidc_client_id);
|
apply_db_field!(oidc_client_id);
|
||||||
apply_db_field!(oidc_client_secret);
|
apply_db_field!(oidc_client_secret);
|
||||||
|
|||||||
+12
-1
@@ -42,6 +42,9 @@ translations! {
|
|||||||
login_submit: "Sign in" , "Войти";
|
login_submit: "Sign in" , "Войти";
|
||||||
login_disabled: "Login is currently disabled." , "Вход сейчас отключён.";
|
login_disabled: "Login is currently disabled." , "Вход сейчас отключён.";
|
||||||
login_invalid: "Invalid username or password." , "Неверное имя пользователя или пароль.";
|
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
|
// Logout
|
||||||
nav_logout: "Logout" , "Выход";
|
nav_logout: "Logout" , "Выход";
|
||||||
@@ -140,6 +143,14 @@ translations! {
|
|||||||
configs_active_keys: "Active" , "Активные";
|
configs_active_keys: "Active" , "Активные";
|
||||||
configs_total_keys: "Total" , "Всего";
|
configs_total_keys: "Total" , "Всего";
|
||||||
configs_new_key: "New key" , "Новый ключ";
|
configs_new_key: "New key" , "Новый ключ";
|
||||||
|
configs_group: "Group" , "Группа";
|
||||||
|
configs_group_isolated: "Isolated" , "Изолирован";
|
||||||
|
configs_group_placeholder: "Leave empty for isolation" , "Оставьте пустым для изоляции";
|
||||||
|
configs_group_hint: "Clients with the same group name can connect to each other on the same VPN server." , "Клиенты с одинаковой группой могут обращаться друг к другу на одном VPN-сервере.";
|
||||||
|
configs_change_group: "Change group" , "Сменить группу";
|
||||||
|
configs_group_rotation_warning: "Changing the group rotates this client's keys. The old config will stop working; download or import a new one." , "При смене группы ключи клиента будут заменены. Старый конфиг перестанет работать — скачайте или импортируйте новый.";
|
||||||
|
configs_group_rotation_confirm: "Change the group and invalidate the old client config?" , "Сменить группу и сделать старый конфиг клиента недействительным?";
|
||||||
|
configs_group_changed: "Group changed. Update the client config." , "Группа изменена. Обновите конфиг клиента.";
|
||||||
configs_empty_title: "Create your first key" , "Создайте первый ключ";
|
configs_empty_title: "Create your first key" , "Создайте первый ключ";
|
||||||
configs_empty_hint: "It will appear here after creation." , "После создания он появится здесь.";
|
configs_empty_hint: "It will appear here after creation." , "После создания он появится здесь.";
|
||||||
configs_enabled_state: "active" , "активен";
|
configs_enabled_state: "active" , "активен";
|
||||||
@@ -169,7 +180,7 @@ translations! {
|
|||||||
telegram_open_bot: "Open 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_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_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_secret_label: "Secret code" , "Секретный код";
|
||||||
telegram_save: "Save Telegram" , "Сохранить Telegram";
|
telegram_save: "Save Telegram" , "Сохранить Telegram";
|
||||||
telegram_saved: "Telegram connected." , "Telegram подключён.";
|
telegram_saved: "Telegram connected." , "Telegram подключён.";
|
||||||
|
|||||||
+26
-5
@@ -4,12 +4,14 @@ mod auth;
|
|||||||
mod config;
|
mod config;
|
||||||
mod i18n;
|
mod i18n;
|
||||||
mod oidc;
|
mod oidc;
|
||||||
|
mod sqlite_migration;
|
||||||
mod telegram;
|
mod telegram;
|
||||||
mod user;
|
mod user;
|
||||||
mod vpn;
|
mod vpn;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
use cot::auth::PasswordVerificationResult;
|
use cot::auth::PasswordVerificationResult;
|
||||||
use cot::cli::CliMetadata;
|
use cot::cli::CliMetadata;
|
||||||
use cot::common_types::Password;
|
use cot::common_types::Password;
|
||||||
@@ -21,7 +23,7 @@ use cot::db::Database;
|
|||||||
use cot::form::{Form, FormResult};
|
use cot::form::{Form, FormResult};
|
||||||
use cot::html::Html;
|
use cot::html::Html;
|
||||||
use cot::middleware::SessionMiddleware;
|
use cot::middleware::SessionMiddleware;
|
||||||
use cot::project::RegisterAppsContext;
|
use cot::project::{ProjectContext, RegisterAppsContext};
|
||||||
use cot::request::extractors::{RequestForm, UrlQuery};
|
use cot::request::extractors::{RequestForm, UrlQuery};
|
||||||
use cot::response::IntoResponse;
|
use cot::response::IntoResponse;
|
||||||
use cot::router::method::get;
|
use cot::router::method::get;
|
||||||
@@ -65,14 +67,22 @@ struct ClientPortalTemplate {
|
|||||||
app_version: &'static str,
|
app_version: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Template)]
|
||||||
|
#[template(path = "telegram_login.html")]
|
||||||
|
struct TelegramLoginTemplate {
|
||||||
|
t: &'static Translations,
|
||||||
|
}
|
||||||
|
|
||||||
async fn configs_page(
|
async fn configs_page(
|
||||||
session: Session,
|
session: Session,
|
||||||
db: Database,
|
db: Database,
|
||||||
i18n: I18n,
|
i18n: I18n,
|
||||||
) -> cot::Result<cot::response::Response> {
|
) -> cot::Result<cot::response::Response> {
|
||||||
let user = match auth::require_user_or_redirect(&session, &db).await {
|
let user = match auth::get_session_user(&session, &db).await {
|
||||||
Ok(user) => user,
|
Some(user) => user,
|
||||||
Err(response) => return Ok(response),
|
None => {
|
||||||
|
return Html::new(TelegramLoginTemplate { t: i18n.t }.render()?).into_response();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let is_admin = user.role == auth::Role::Admin;
|
let is_admin = user.role == auth::Role::Admin;
|
||||||
@@ -181,11 +191,19 @@ struct AmneziaFellowApp {
|
|||||||
config: Arc<AppConfig>,
|
config: Arc<AppConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
impl App for AmneziaFellowApp {
|
impl App for AmneziaFellowApp {
|
||||||
fn name(&self) -> &'static str {
|
fn name(&self) -> &'static str {
|
||||||
env!("CARGO_PKG_NAME")
|
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 {
|
fn router(&self) -> Router {
|
||||||
Router::with_urls([
|
Router::with_urls([
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
@@ -296,8 +314,11 @@ impl Project for AmneziaFellowProject {
|
|||||||
" Priority: env var > DB override > compiled default.\n",
|
" Priority: env var > DB override > compiled default.\n",
|
||||||
"\n",
|
"\n",
|
||||||
" Database (required for most features):\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: 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",
|
"\n",
|
||||||
" Server:\n",
|
" Server:\n",
|
||||||
" AMNEZIA_FELLOW_LOG_LEVEL Tracing filter (default: info)\n",
|
" AMNEZIA_FELLOW_LOG_LEVEL Tracing filter (default: info)\n",
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
+236
-3
@@ -31,6 +31,7 @@ pub struct VpnClient {
|
|||||||
address: LimitedString<64>,
|
address: LimitedString<64>,
|
||||||
public_key: LimitedString<128>,
|
public_key: LimitedString<128>,
|
||||||
private_key: LimitedString<128>,
|
private_key: LimitedString<128>,
|
||||||
|
group_name: Option<String>,
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
created_at: LimitedString<64>,
|
created_at: LimitedString<64>,
|
||||||
updated_at: LimitedString<64>,
|
updated_at: LimitedString<64>,
|
||||||
@@ -45,6 +46,7 @@ pub struct VpnClientView {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub address: String,
|
pub address: String,
|
||||||
pub public_key: String,
|
pub public_key: String,
|
||||||
|
pub group_name: Option<String>,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
@@ -88,6 +90,7 @@ impl VpnClient {
|
|||||||
db: &Database,
|
db: &Database,
|
||||||
owner_user_id: i64,
|
owner_user_id: i64,
|
||||||
name: &str,
|
name: &str,
|
||||||
|
group_name: Option<&str>,
|
||||||
cidr: &str,
|
cidr: &str,
|
||||||
) -> cot::db::Result<Self> {
|
) -> cot::db::Result<Self> {
|
||||||
let keypair = generate_keypair().map_err(db_custom_error)?;
|
let keypair = generate_keypair().map_err(db_custom_error)?;
|
||||||
@@ -103,6 +106,7 @@ impl VpnClient {
|
|||||||
address: LimitedString::new(address.as_str()).unwrap(),
|
address: LimitedString::new(address.as_str()).unwrap(),
|
||||||
public_key: LimitedString::new(keypair.public_key.as_str()).unwrap(),
|
public_key: LimitedString::new(keypair.public_key.as_str()).unwrap(),
|
||||||
private_key: LimitedString::new(keypair.private_key.as_str()).unwrap(),
|
private_key: LimitedString::new(keypair.private_key.as_str()).unwrap(),
|
||||||
|
group_name: group_name_for_storage(group_name)?,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
created_at: LimitedString::new(now.as_str()).unwrap(),
|
created_at: LimitedString::new(now.as_str()).unwrap(),
|
||||||
updated_at: LimitedString::new(now.as_str()).unwrap(),
|
updated_at: LimitedString::new(now.as_str()).unwrap(),
|
||||||
@@ -118,6 +122,25 @@ impl VpnClient {
|
|||||||
self.save(db).await
|
self.save(db).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn set_group_and_rotate_keys(
|
||||||
|
&mut self,
|
||||||
|
db: &Database,
|
||||||
|
group_name: Option<&str>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
let group_name = group_name_for_storage(group_name)?;
|
||||||
|
if self.group_name == group_name {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let keypair = generate_keypair().map_err(db_custom_error)?;
|
||||||
|
self.public_key = LimitedString::new(keypair.public_key).unwrap();
|
||||||
|
self.private_key = LimitedString::new(keypair.private_key).unwrap();
|
||||||
|
self.group_name = group_name;
|
||||||
|
let now = now_timestamp();
|
||||||
|
self.updated_at = LimitedString::new(now).unwrap();
|
||||||
|
self.save(db).await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn delete_by_id(db: &Database, client_id: i64) -> cot::db::Result<()> {
|
pub async fn delete_by_id(db: &Database, client_id: i64) -> cot::db::Result<()> {
|
||||||
cot::db::query!(VpnClient, $id == Auto::Fixed(client_id))
|
cot::db::query!(VpnClient, $id == Auto::Fixed(client_id))
|
||||||
.delete(db)
|
.delete(db)
|
||||||
@@ -134,6 +157,7 @@ impl VpnClient {
|
|||||||
name: self.name.to_string(),
|
name: self.name.to_string(),
|
||||||
address: self.address.to_string(),
|
address: self.address.to_string(),
|
||||||
public_key: self.public_key.to_string(),
|
public_key: self.public_key.to_string(),
|
||||||
|
group_name: self.group_name.as_ref().map(ToString::to_string),
|
||||||
enabled: self.enabled,
|
enabled: self.enabled,
|
||||||
created_at: self.created_at.to_string(),
|
created_at: self.created_at.to_string(),
|
||||||
updated_at: self.updated_at.to_string(),
|
updated_at: self.updated_at.to_string(),
|
||||||
@@ -167,6 +191,10 @@ impl VpnClient {
|
|||||||
pub fn private_key_str(&self) -> &str {
|
pub fn private_key_str(&self) -> &str {
|
||||||
&self.private_key
|
&self.private_key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn group_name_str(&self) -> Option<&str> {
|
||||||
|
self.group_name.as_deref()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -211,6 +239,48 @@ pub fn render_peer_secret(clients: &[VpnClient]) -> String {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn render_client_policy(clients: &[VpnClient], cidr: &str) -> Result<String, String> {
|
||||||
|
parse_ipv4_cidr(cidr)?;
|
||||||
|
|
||||||
|
let mut groups = BTreeMap::<String, Vec<Ipv4Addr>>::new();
|
||||||
|
for client in clients.iter().filter(|client| client.enabled()) {
|
||||||
|
let Some(group_name) = client.group_name_str() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let address = client.address_str().parse::<Ipv4Addr>().map_err(|e| {
|
||||||
|
format!(
|
||||||
|
"client {} has invalid IPv4 address {:?}: {e}",
|
||||||
|
client.id_val(),
|
||||||
|
client.address_str()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if !ipv4_is_in_cidr(address, cidr)? {
|
||||||
|
return Err(format!(
|
||||||
|
"client {} address {} is outside VPN CIDR {cidr}",
|
||||||
|
client.id_val(),
|
||||||
|
address
|
||||||
|
));
|
||||||
|
}
|
||||||
|
groups
|
||||||
|
.entry(group_name.to_owned())
|
||||||
|
.or_default()
|
||||||
|
.push(address);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out =
|
||||||
|
format!("# Generated by amnezia-fellow. Exact allowed awg0-to-awg0 pairs for {cidr}.\n");
|
||||||
|
for addresses in groups.values_mut() {
|
||||||
|
addresses.sort_unstable();
|
||||||
|
addresses.dedup();
|
||||||
|
for source in addresses.iter() {
|
||||||
|
for destination in addresses.iter().filter(|address| *address != source) {
|
||||||
|
out.push_str(&format!("{source}/32 {destination}/32\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
fn client_name_for_storage(name: &str) -> cot::db::Result<LimitedString<255>> {
|
fn client_name_for_storage(name: &str) -> cot::db::Result<LimitedString<255>> {
|
||||||
let name = if name.is_empty() {
|
let name = if name.is_empty() {
|
||||||
"Amnezia client"
|
"Amnezia client"
|
||||||
@@ -221,6 +291,16 @@ fn client_name_for_storage(name: &str) -> cot::db::Result<LimitedString<255>> {
|
|||||||
.map_err(|e| db_custom_error(format!("client name is too long: {e}")))
|
.map_err(|e| db_custom_error(format!("client name is too long: {e}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn group_name_for_storage(group_name: Option<&str>) -> cot::db::Result<Option<String>> {
|
||||||
|
let Some(group_name) = group_name.map(str::trim).filter(|name| !name.is_empty()) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if group_name.len() > 255 {
|
||||||
|
return Err(db_custom_error("group name is too long".to_owned()));
|
||||||
|
}
|
||||||
|
Ok(Some(group_name.to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
fn escaped_peer_comment_value(value: &str) -> String {
|
fn escaped_peer_comment_value(value: &str) -> String {
|
||||||
let mut escaped = String::with_capacity(value.len() + 2);
|
let mut escaped = String::with_capacity(value.len() + 2);
|
||||||
escaped.push('"');
|
escaped.push('"');
|
||||||
@@ -432,6 +512,7 @@ pub struct SecretSyncResult {
|
|||||||
pub async fn sync_clients_secret(
|
pub async fn sync_clients_secret(
|
||||||
config: &AppConfig,
|
config: &AppConfig,
|
||||||
rendered_peers: String,
|
rendered_peers: String,
|
||||||
|
rendered_policy: String,
|
||||||
) -> Result<SecretSyncResult, String> {
|
) -> Result<SecretSyncResult, String> {
|
||||||
let client = Client::try_default()
|
let client = Client::try_default()
|
||||||
.await
|
.await
|
||||||
@@ -439,13 +520,18 @@ pub async fn sync_clients_secret(
|
|||||||
let api: Api<Secret> = Api::namespaced(client, &config.k8s_namespace);
|
let api: Api<Secret> = Api::namespaced(client, &config.k8s_namespace);
|
||||||
|
|
||||||
let desired = rendered_peers.into_bytes();
|
let desired = rendered_peers.into_bytes();
|
||||||
|
let desired_policy = rendered_policy.into_bytes();
|
||||||
let name = &config.k8s_clients_secret;
|
let name = &config.k8s_clients_secret;
|
||||||
let key = &config.k8s_clients_secret_key;
|
let key = &config.k8s_clients_secret_key;
|
||||||
|
let policy_key = "policy.conf";
|
||||||
|
|
||||||
match api.get_opt(name).await {
|
match api.get_opt(name).await {
|
||||||
Ok(Some(mut secret)) => {
|
Ok(Some(mut secret)) => {
|
||||||
let mut data = secret.data.take().unwrap_or_default();
|
let mut data = secret.data.take().unwrap_or_default();
|
||||||
if data.get(key).map(|value| value.0.as_slice()) == Some(desired.as_slice()) {
|
if data.get(key).map(|value| value.0.as_slice()) == Some(desired.as_slice())
|
||||||
|
&& data.get(policy_key).map(|value| value.0.as_slice())
|
||||||
|
== Some(desired_policy.as_slice())
|
||||||
|
{
|
||||||
return Ok(SecretSyncResult {
|
return Ok(SecretSyncResult {
|
||||||
changed: false,
|
changed: false,
|
||||||
message: "client Secret is already up to date".to_owned(),
|
message: "client Secret is already up to date".to_owned(),
|
||||||
@@ -453,6 +539,10 @@ pub async fn sync_clients_secret(
|
|||||||
}
|
}
|
||||||
|
|
||||||
data.insert(key.clone(), k8s_openapi::ByteString(desired));
|
data.insert(key.clone(), k8s_openapi::ByteString(desired));
|
||||||
|
data.insert(
|
||||||
|
policy_key.to_owned(),
|
||||||
|
k8s_openapi::ByteString(desired_policy),
|
||||||
|
);
|
||||||
secret.data = Some(data);
|
secret.data = Some(data);
|
||||||
mark_client_secret_updated(&mut secret);
|
mark_client_secret_updated(&mut secret);
|
||||||
api.replace(name, &PostParams::default(), &secret)
|
api.replace(name, &PostParams::default(), &secret)
|
||||||
@@ -466,6 +556,10 @@ pub async fn sync_clients_secret(
|
|||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
let mut data = BTreeMap::new();
|
let mut data = BTreeMap::new();
|
||||||
data.insert(key.clone(), k8s_openapi::ByteString(desired));
|
data.insert(key.clone(), k8s_openapi::ByteString(desired));
|
||||||
|
data.insert(
|
||||||
|
policy_key.to_owned(),
|
||||||
|
k8s_openapi::ByteString(desired_policy),
|
||||||
|
);
|
||||||
let secret = Secret {
|
let secret = Secret {
|
||||||
metadata: ObjectMeta {
|
metadata: ObjectMeta {
|
||||||
name: Some(name.clone()),
|
name: Some(name.clone()),
|
||||||
@@ -498,7 +592,8 @@ pub async fn sync_from_database(
|
|||||||
let clients = VpnClient::list_all(db)
|
let clients = VpnClient::list_all(db)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("failed to list VPN clients: {e}"))?;
|
.map_err(|e| format!("failed to list VPN clients: {e}"))?;
|
||||||
sync_clients_secret(config, render_peer_secret(&clients)).await
|
let policy = render_client_policy(&clients, &config.vpn_client_cidr)?;
|
||||||
|
sync_clients_secret(config, render_peer_secret(&clients), policy).await
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mark_client_secret_updated(secret: &mut Secret) {
|
fn mark_client_secret_updated(secret: &mut Secret) {
|
||||||
@@ -920,6 +1015,16 @@ fn parse_ipv4_cidr(cidr: &str) -> Result<(Ipv4Addr, u32), String> {
|
|||||||
Ok((u32_to_ipv4(ipv4_to_u32(ip) & mask), prefix))
|
Ok((u32_to_ipv4(ipv4_to_u32(ip) & mask), prefix))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ipv4_is_in_cidr(address: Ipv4Addr, cidr: &str) -> Result<bool, String> {
|
||||||
|
let (network, prefix) = parse_ipv4_cidr(cidr)?;
|
||||||
|
let mask = if prefix == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
u32::MAX << (32 - prefix)
|
||||||
|
};
|
||||||
|
Ok(ipv4_to_u32(address) & mask == ipv4_to_u32(network))
|
||||||
|
}
|
||||||
|
|
||||||
fn ipv4_to_u32(ip: Ipv4Addr) -> u32 {
|
fn ipv4_to_u32(ip: Ipv4Addr) -> u32 {
|
||||||
u32::from_be_bytes(ip.octets())
|
u32::from_be_bytes(ip.octets())
|
||||||
}
|
}
|
||||||
@@ -1031,13 +1136,112 @@ pub mod db_migrations {
|
|||||||
&[Operation::custom(create_vpn_client_indexes).build()];
|
&[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()];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn add_vpn_client_group_name(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"ALTER TABLE amnezia_fellow__vpn_client \
|
||||||
|
ADD COLUMN group_name VARCHAR(255)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX idx_amnezia_fellow_vpn_client_owner_group \
|
||||||
|
ON amnezia_fellow__vpn_client (owner_user_id, group_name)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0010VpnClientGroupName;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0010VpnClientGroupName {
|
||||||
|
const APP_NAME: &'static str = "amnezia_fellow";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0010_vpn_client_group_name";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"amnezia_fellow",
|
||||||
|
"m_0009_normalize_bigint_ids",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(add_vpn_client_group_name).build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
||||||
|
&M0005CreateVpnClient,
|
||||||
|
&M0006VpnClientIndexes,
|
||||||
|
&M0009NormalizeBigintIds,
|
||||||
|
&M0010VpnClientGroupName,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn test_client(id: i64, owner: i64, address: &str, group_name: Option<&str>) -> VpnClient {
|
||||||
|
VpnClient {
|
||||||
|
id: Auto::Fixed(id),
|
||||||
|
owner_user_id: owner,
|
||||||
|
name: LimitedString::new(format!("client-{id}")).unwrap(),
|
||||||
|
address: LimitedString::new(address).unwrap(),
|
||||||
|
public_key: LimitedString::new(format!("public-{id}")).unwrap(),
|
||||||
|
private_key: LimitedString::new(format!("private-{id}")).unwrap(),
|
||||||
|
group_name: group_name.map(str::to_owned),
|
||||||
|
enabled: true,
|
||||||
|
created_at: LimitedString::new("0").unwrap(),
|
||||||
|
updated_at: LimitedString::new("0").unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cidr_parser_normalizes_network() {
|
fn cidr_parser_normalizes_network() {
|
||||||
let (network, prefix) = parse_ipv4_cidr("10.8.42.7/16").unwrap();
|
let (network, prefix) = parse_ipv4_cidr("10.8.42.7/16").unwrap();
|
||||||
@@ -1052,6 +1256,33 @@ mod tests {
|
|||||||
assert_eq!(keypair.public_key.len(), 44);
|
assert_eq!(keypair.public_key.len(), 44);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn client_policy_allows_only_exact_pairs_with_same_group() {
|
||||||
|
let clients = vec![
|
||||||
|
test_client(1, 10, "10.8.0.2", Some("home")),
|
||||||
|
test_client(2, 10, "10.8.0.3", Some("home")),
|
||||||
|
test_client(3, 10, "10.8.0.4", Some("other")),
|
||||||
|
test_client(4, 11, "10.8.0.5", Some("home")),
|
||||||
|
test_client(5, 10, "10.8.0.6", None),
|
||||||
|
];
|
||||||
|
|
||||||
|
let policy = render_client_policy(&clients, "10.8.0.0/16").unwrap();
|
||||||
|
assert!(policy.contains("10.8.0.2/32 10.8.0.3/32\n"));
|
||||||
|
assert!(policy.contains("10.8.0.3/32 10.8.0.2/32\n"));
|
||||||
|
assert!(policy.contains("10.8.0.2/32 10.8.0.5/32\n"));
|
||||||
|
assert!(policy.contains("10.8.0.5/32 10.8.0.2/32\n"));
|
||||||
|
assert_eq!(
|
||||||
|
policy.lines().filter(|line| !line.starts_with('#')).count(),
|
||||||
|
6
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn client_policy_rejects_address_outside_vpn_cidr() {
|
||||||
|
let clients = vec![test_client(1, 10, "192.0.2.2", Some("home"))];
|
||||||
|
assert!(render_client_policy(&clients, "10.8.0.0/16").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn endpoint_parser_accepts_ipv4_and_bracketed_ipv6() {
|
fn endpoint_parser_accepts_ipv4_and_bracketed_ipv6() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1095,6 +1326,7 @@ mod tests {
|
|||||||
address: LimitedString::new("10.8.0.2").unwrap(),
|
address: LimitedString::new("10.8.0.2").unwrap(),
|
||||||
public_key: LimitedString::new("client-public-key").unwrap(),
|
public_key: LimitedString::new("client-public-key").unwrap(),
|
||||||
private_key: LimitedString::new("client-private-key").unwrap(),
|
private_key: LimitedString::new("client-private-key").unwrap(),
|
||||||
|
group_name: None,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
created_at: LimitedString::new("2026-06-16T00:00:00Z").unwrap(),
|
created_at: LimitedString::new("2026-06-16T00:00:00Z").unwrap(),
|
||||||
updated_at: LimitedString::new("2026-06-16T00:00:00Z").unwrap(),
|
updated_at: LimitedString::new("2026-06-16T00:00:00Z").unwrap(),
|
||||||
@@ -1160,6 +1392,7 @@ mod tests {
|
|||||||
address: LimitedString::new("10.8.0.2").unwrap(),
|
address: LimitedString::new("10.8.0.2").unwrap(),
|
||||||
public_key: LimitedString::new("client-public-key").unwrap(),
|
public_key: LimitedString::new("client-public-key").unwrap(),
|
||||||
private_key: LimitedString::new("client-private-key").unwrap(),
|
private_key: LimitedString::new("client-private-key").unwrap(),
|
||||||
|
group_name: None,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
created_at: LimitedString::new("2026-06-16T00:00:00Z").unwrap(),
|
created_at: LimitedString::new("2026-06-16T00:00:00Z").unwrap(),
|
||||||
updated_at: LimitedString::new("2026-06-16T00:00:00Z").unwrap(),
|
updated_at: LimitedString::new("2026-06-16T00:00:00Z").unwrap(),
|
||||||
|
|||||||
+137
-15
@@ -9,13 +9,13 @@
|
|||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
[x-cloak] { display: none !important; }
|
[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; }
|
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, input, select { 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 { 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.secondary { background: #fff; color: #18342f; border-color: #c7d4ce; }
|
||||||
button.ghost { background: transparent; color: #42515d; border-color: transparent; }
|
button.ghost { background: transparent; color: #42515d; border-color: transparent; }
|
||||||
button.danger { background: #a8312d; border-color: #a8312d; color: #fff; }
|
button.danger { background: #a8312d; border-color: #a8312d; color: #fff; }
|
||||||
button:disabled { opacity: .48; cursor: default; }
|
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; }
|
input, select { 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; }
|
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; }
|
.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 { position: sticky; top: 0; z-index: 10; background: rgba(255,255,255,.94); border-bottom: 1px solid #d6dfda; backdrop-filter: blur(12px); }
|
||||||
@@ -62,7 +62,8 @@
|
|||||||
.server-status-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #18232d; font-size: .86rem; font-weight: 850; }
|
.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-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; }
|
.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; }
|
.create { display: flex; justify-content: flex-end; padding: 0 1rem 1rem; }
|
||||||
|
.create-plus { width: 44px; min-width: 44px; height: 44px; padding: 0; font-size: 1.55rem; line-height: 1; }
|
||||||
.field { display: grid; gap: .32rem; min-width: 0; }
|
.field { display: grid; gap: .32rem; min-width: 0; }
|
||||||
.field label { color: #34424b; font-size: .83rem; font-weight: 800; }
|
.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 { 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); }
|
||||||
@@ -87,7 +88,7 @@
|
|||||||
.meta-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .55rem; }
|
.meta-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .55rem; }
|
||||||
.meta-item { min-width: 0; display: grid; gap: .24rem; }
|
.meta-item { min-width: 0; display: grid; gap: .24rem; }
|
||||||
.meta-label { color: #69777f; font-size: .77rem; font-weight: 800; }
|
.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 { display: grid; grid-template-columns: minmax(0, 1fr) auto auto auto; gap: .5rem; align-items: center; }
|
||||||
.key-actions .server-button { justify-self: stretch; }
|
.key-actions .server-button { justify-self: stretch; }
|
||||||
.key-updated { color: #69777f; font-size: .82rem; overflow-wrap: anywhere; }
|
.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); }
|
.backdrop { position: fixed; inset: 0; z-index: 30; display: grid; place-items: center; padding: 1rem; background: rgba(16, 28, 36, .42); }
|
||||||
@@ -115,12 +116,15 @@
|
|||||||
.modal-actions { display: flex; justify-content: flex-end; gap: .5rem; flex-wrap: wrap; }
|
.modal-actions { display: flex; justify-content: flex-end; gap: .5rem; flex-wrap: wrap; }
|
||||||
.telegram-sheet { width: min(480px, 100%); }
|
.telegram-sheet { width: min(480px, 100%); }
|
||||||
.telegram-copy { color: #53616c; line-height: 1.45; margin: 0; }
|
.telegram-copy { color: #53616c; line-height: 1.45; margin: 0; }
|
||||||
|
.form-stack { display: grid; gap: .8rem; }
|
||||||
|
.field-hint { margin: 0; color: #69777f; font-size: .82rem; line-height: 1.4; }
|
||||||
|
.warning-box { border: 1px solid #e5c76c; border-radius: 8px; background: #fff8df; color: #5f4911; padding: .7rem; font-size: .86rem; line-height: 1.4; }
|
||||||
.guide-list { margin: 0; padding-left: 1.2rem; color: #34424b; display: grid; gap: .45rem; line-height: 1.4; }
|
.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 { color: #18342f; font-weight: 850; }
|
||||||
.guide-list a.disabled { color: #69777f; pointer-events: none; text-decoration: none; }
|
.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 { 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; }
|
.secret-box code { display: block; padding: .55rem .65rem; font-size: .92rem; white-space: normal; overflow-wrap: anywhere; }
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 900px) {
|
||||||
.topbar-inner { align-items: flex-start; flex-direction: column; }
|
.topbar-inner { align-items: flex-start; flex-direction: column; }
|
||||||
.top-actions { width: 100%; justify-content: space-between; }
|
.top-actions { width: 100%; justify-content: space-between; }
|
||||||
.user-pill { max-width: 100%; }
|
.user-pill { max-width: 100%; }
|
||||||
@@ -219,11 +223,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="create">
|
<div class="create">
|
||||||
<div class="field">
|
<button type="button" class="create-plus" @click="openCreateClient()" :disabled="busy" title="{{ t.configs_new_key }}" aria-label="{{ t.configs_new_key }}">+</button>
|
||||||
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -265,10 +265,15 @@
|
|||||||
<span class="meta-label">{{ t.configs_public_key }}</span>
|
<span class="meta-label">{{ t.configs_public_key }}</span>
|
||||||
<code x-text="shortKey(client.public_key)"></code>
|
<code x-text="shortKey(client.public_key)"></code>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<span class="meta-label">{{ t.configs_group }}</span>
|
||||||
|
<code x-text="client.group_name || '{{ t.configs_group_isolated }}'"></code>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="key-actions">
|
<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="server-button" @click="openServers(client)" :disabled="busy || !client.enabled">{{ t.configs_choose_server }}</button>
|
||||||
|
<button type="button" class="secondary" @click="openGroupEditor(client)" :disabled="busy">{{ t.configs_change_group }}</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="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>
|
<button type="button" class="danger" @click="deleteClient(client)" :disabled="busy">{{ t.users_delete }}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -280,6 +285,45 @@
|
|||||||
</template>
|
</template>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<template x-if="clientModal">
|
||||||
|
<div class="backdrop" x-cloak @click.self="closeClientModal()">
|
||||||
|
<section class="sheet">
|
||||||
|
<div class="sheet-head">
|
||||||
|
<div class="sheet-title">
|
||||||
|
<h2 x-text="clientModal.mode === 'create' ? '{{ t.configs_new_key }}' : '{{ t.configs_change_group }}'"></h2>
|
||||||
|
<span x-show="clientModal.client" x-text="clientModal.client ? clientModal.client.name : ''"></span>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="ghost" @click="closeClientModal()">{{ t.admin_close }}</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-stack">
|
||||||
|
<template x-if="clientModal.mode === 'create'">
|
||||||
|
<div class="field">
|
||||||
|
<label for="client-modal-name">{{ t.configs_name }}</label>
|
||||||
|
<input id="client-modal-name" x-model="clientModal.name" placeholder="{{ t.configs_name_placeholder }}">
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="field">
|
||||||
|
<label for="client-modal-group">{{ t.configs_group }}</label>
|
||||||
|
<input id="client-modal-group" x-model="clientModal.groupName" list="vpn-client-groups" placeholder="{{ t.configs_group_placeholder }}">
|
||||||
|
<datalist id="vpn-client-groups">
|
||||||
|
<template x-for="group in groups" :key="group">
|
||||||
|
<option :value="group"></option>
|
||||||
|
</template>
|
||||||
|
</datalist>
|
||||||
|
<p class="field-hint">{{ t.configs_group_hint }}</p>
|
||||||
|
</div>
|
||||||
|
<template x-if="clientModal.mode === 'group'">
|
||||||
|
<div class="warning-box">{{ t.configs_group_rotation_warning }}</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="secondary" @click="closeClientModal()">{{ t.admin_close }}</button>
|
||||||
|
<button type="button" @click="clientModal.mode === 'create' ? createClient() : changeClientGroup()" :disabled="busy">{{ t.settings_save }}</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template x-if="serverModal">
|
<template x-if="serverModal">
|
||||||
<div class="backdrop" x-cloak @click.self="closeServers()">
|
<div class="backdrop" x-cloak @click.self="closeServers()">
|
||||||
<section class="sheet">
|
<section class="sheet">
|
||||||
@@ -433,7 +477,8 @@
|
|||||||
function clientPortal() {
|
function clientPortal() {
|
||||||
return {
|
return {
|
||||||
clients: [],
|
clients: [],
|
||||||
newName: '',
|
groups: [],
|
||||||
|
clientModal: null,
|
||||||
busy: false,
|
busy: false,
|
||||||
status: '',
|
status: '',
|
||||||
error: '',
|
error: '',
|
||||||
@@ -461,12 +506,15 @@ function clientPortal() {
|
|||||||
telegramModal: null,
|
telegramModal: null,
|
||||||
telegramBusy: false,
|
telegramBusy: false,
|
||||||
telegramPromptChecked: false,
|
telegramPromptChecked: false,
|
||||||
|
telegramStatusTimer: null,
|
||||||
|
telegramStatusPolling: false,
|
||||||
init() {
|
init() {
|
||||||
this.initTelegramWebApp();
|
this.initTelegramWebApp();
|
||||||
this.load();
|
this.load();
|
||||||
this.loadServerStatus();
|
this.loadServerStatus();
|
||||||
this.loadTelegramStatus();
|
this.loadTelegramStatus();
|
||||||
this.serverStatusTimer = setInterval(() => this.loadServerStatus(true), 30000);
|
this.serverStatusTimer = setInterval(() => this.loadServerStatus(true), 30000);
|
||||||
|
this.telegramStatusTimer = setInterval(() => this.pollTelegramStatus(), 2500);
|
||||||
},
|
},
|
||||||
async request(url, options = {}) {
|
async request(url, options = {}) {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
@@ -490,6 +538,7 @@ function clientPortal() {
|
|||||||
try {
|
try {
|
||||||
const data = await this.request('/api/vpn-clients');
|
const data = await this.request('/api/vpn-clients');
|
||||||
this.clients = data.clients || [];
|
this.clients = data.clients || [];
|
||||||
|
this.groups = data.groups || [];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.showError(e);
|
this.showError(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -517,11 +566,11 @@ function clientPortal() {
|
|||||||
this.telegram.isWebApp = Boolean(webApp.initData);
|
this.telegram.isWebApp = Boolean(webApp.initData);
|
||||||
this.telegram.initData = webApp.initData || '';
|
this.telegram.initData = webApp.initData || '';
|
||||||
},
|
},
|
||||||
async loadTelegramStatus() {
|
async loadTelegramStatus(options = {}) {
|
||||||
try {
|
try {
|
||||||
const status = await this.request('/api/telegram-link/status');
|
const status = await this.request('/api/telegram-link/status');
|
||||||
this.applyTelegramStatus(status);
|
this.applyTelegramStatus(status);
|
||||||
this.maybePromptTelegram();
|
if (options.prompt !== false) this.maybePromptTelegram();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('telegram status failed', e);
|
console.warn('telegram status failed', e);
|
||||||
}
|
}
|
||||||
@@ -596,6 +645,25 @@ function clientPortal() {
|
|||||||
this.telegramBusy = false;
|
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() {
|
async copyTelegramSecret() {
|
||||||
if (!this.telegram.pending_secret) return;
|
if (!this.telegram.pending_secret) return;
|
||||||
this.clearNotice();
|
this.clearNotice();
|
||||||
@@ -658,17 +726,42 @@ function clientPortal() {
|
|||||||
if (this.telegram.linked) return '{{ t.telegram_connected_message }}';
|
if (this.telegram.linked) return '{{ t.telegram_connected_message }}';
|
||||||
return '{{ t.telegram_not_connected_message }}';
|
return '{{ t.telegram_not_connected_message }}';
|
||||||
},
|
},
|
||||||
|
openCreateClient() {
|
||||||
|
this.clientModal = { mode: 'create', name: '', groupName: '', client: null };
|
||||||
|
},
|
||||||
|
openGroupEditor(client) {
|
||||||
|
this.clientModal = {
|
||||||
|
mode: 'group',
|
||||||
|
name: client.name || '',
|
||||||
|
groupName: client.group_name || '',
|
||||||
|
client,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
closeClientModal() {
|
||||||
|
if (!this.busy) this.clientModal = null;
|
||||||
|
},
|
||||||
|
rememberGroup(groupName) {
|
||||||
|
const group = String(groupName || '').trim();
|
||||||
|
if (group && !this.groups.includes(group)) {
|
||||||
|
this.groups.push(group);
|
||||||
|
this.groups.sort((left, right) => left.localeCompare(right));
|
||||||
|
}
|
||||||
|
},
|
||||||
async createClient() {
|
async createClient() {
|
||||||
|
if (!this.clientModal || this.clientModal.mode !== 'create') return;
|
||||||
this.clearNotice();
|
this.clearNotice();
|
||||||
this.busy = true;
|
this.busy = true;
|
||||||
try {
|
try {
|
||||||
|
const name = this.clientModal.name;
|
||||||
|
const groupName = this.clientModal.groupName.trim() || null;
|
||||||
const data = await this.request('/api/vpn-clients', {
|
const data = await this.request('/api/vpn-clients', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ name: this.newName }),
|
body: JSON.stringify({ name, group_name: groupName }),
|
||||||
});
|
});
|
||||||
this.clients.push(data.client);
|
this.clients.push(data.client);
|
||||||
delete this.serverConfigs[data.client.id];
|
delete this.serverConfigs[data.client.id];
|
||||||
this.newName = '';
|
this.rememberGroup(data.client.group_name);
|
||||||
|
this.clientModal = null;
|
||||||
this.applyResponseNotice(data, '{{ t.notice_create_success }}');
|
this.applyResponseNotice(data, '{{ t.notice_create_success }}');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.showError(e);
|
this.showError(e);
|
||||||
@@ -676,6 +769,35 @@ function clientPortal() {
|
|||||||
this.busy = false;
|
this.busy = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async changeClientGroup() {
|
||||||
|
if (!this.clientModal || this.clientModal.mode !== 'group') return;
|
||||||
|
const client = this.clientModal.client;
|
||||||
|
const groupName = this.clientModal.groupName.trim() || null;
|
||||||
|
if ((client.group_name || null) === groupName) {
|
||||||
|
this.clientModal = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!confirm('{{ t.configs_group_rotation_confirm }}')) return;
|
||||||
|
|
||||||
|
this.clearNotice();
|
||||||
|
this.busy = true;
|
||||||
|
try {
|
||||||
|
const data = await this.request(`/api/vpn-clients/${client.id}/group`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ group_name: groupName }),
|
||||||
|
});
|
||||||
|
const index = this.clients.findIndex((item) => item.id === client.id);
|
||||||
|
if (index !== -1) this.clients[index] = data.client;
|
||||||
|
delete this.serverConfigs[client.id];
|
||||||
|
this.rememberGroup(data.client.group_name);
|
||||||
|
this.clientModal = null;
|
||||||
|
this.applyResponseNotice(data, '{{ t.configs_group_changed }}');
|
||||||
|
} catch (e) {
|
||||||
|
this.showError(e);
|
||||||
|
} finally {
|
||||||
|
this.busy = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
async setEnabled(client, enabled) {
|
async setEnabled(client, enabled) {
|
||||||
this.clearNotice();
|
this.clearNotice();
|
||||||
this.busy = true;
|
this.busy = true;
|
||||||
|
|||||||
+467
-18
@@ -3,9 +3,11 @@
|
|||||||
{% block title %}{{ t.configs_heading }} | {{ t.site_name }}{% endblock title %}
|
{% block title %}{{ t.configs_heading }} | {{ t.site_name }}{% endblock title %}
|
||||||
|
|
||||||
{% block head_extra %}
|
{% 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>
|
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
<style>
|
<style>
|
||||||
* { box-sizing: border-box; }
|
* { 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; }
|
body { margin: 0; min-height: 100vh; background: #f6f7f8; color: #1d252d; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||||
a { color: inherit; }
|
a { color: inherit; }
|
||||||
.shell { min-height: 100vh; display: grid; grid-template-columns: 220px minmax(0, 1fr); }
|
.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; }
|
.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; }
|
.user-info { font-size: .875rem; color: #53606d; }
|
||||||
.app-version { font-size: .75rem; color: #9aa4ae; white-space: nowrap; }
|
.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, .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; }
|
.logout-link:hover, .lang-switch a:hover { background: #eef1f4; color: #1d252d; }
|
||||||
.lang-switch a.active { color: #1d252d; font-weight: 700; }
|
.lang-switch a.active { color: #1d252d; font-weight: 700; }
|
||||||
@@ -32,7 +41,22 @@
|
|||||||
button.secondary { background: #fff; color: #17202a; border-color: #cbd3db; }
|
button.secondary { background: #fff; color: #17202a; border-color: #cbd3db; }
|
||||||
button.danger { background: #9d2323; border-color: #9d2323; }
|
button.danger { background: #9d2323; border-color: #9d2323; }
|
||||||
button:disabled { opacity: .55; cursor: default; }
|
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; }
|
||||||
|
.mobile-client-list { display: none; }
|
||||||
|
.mobile-owner-group { display: grid; gap: .55rem; }
|
||||||
|
.mobile-owner-head { display: flex; align-items: center; justify-content: space-between; gap: .5rem; color: #34414f; font-size: .86rem; font-weight: 750; }
|
||||||
|
.mobile-client-card { min-width: 0; border: 1px solid #dde2e6; border-radius: 8px; background: #fff; padding: .75rem; display: grid; gap: .65rem; box-shadow: 0 4px 16px rgba(23, 32, 42, .05); }
|
||||||
|
.mobile-client-head { min-width: 0; display: flex; align-items: flex-start; justify-content: space-between; gap: .55rem; }
|
||||||
|
.mobile-client-name { min-width: 0; font-weight: 800; overflow-wrap: anywhere; }
|
||||||
|
.mobile-client-state { flex: 0 0 auto; border-radius: 999px; padding: .15rem .45rem; background: #e8ecef; color: #53606d; font-size: .72rem; font-weight: 750; }
|
||||||
|
.mobile-client-state.enabled { background: #dcefe4; color: #175c31; }
|
||||||
|
.mobile-client-meta { min-width: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .55rem; }
|
||||||
|
.mobile-meta-item { min-width: 0; display: grid; gap: .18rem; }
|
||||||
|
.mobile-meta-item.full { grid-column: 1 / -1; }
|
||||||
|
.mobile-meta-label { color: #6a7682; font-size: .72rem; font-weight: 700; }
|
||||||
|
.mobile-meta-value { min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
.mobile-client-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .4rem; }
|
||||||
|
.mobile-client-actions button { width: 100%; min-width: 0; padding-inline: .4rem; white-space: normal; }
|
||||||
th, td { text-align: left; padding: .65rem .75rem; border-bottom: 1px solid #edf0f2; font-size: .92rem; vertical-align: middle; }
|
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; }
|
th { background: #eef1f4; font-weight: 650; color: #34414f; }
|
||||||
tr:last-child td { border-bottom: 0; }
|
tr:last-child td { border-bottom: 0; }
|
||||||
@@ -104,17 +128,25 @@
|
|||||||
.qr-box { display: grid; place-items: center; padding: .75rem; border: 1px solid #dde2e6; border-radius: 6px; background: #fff; }
|
.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; }
|
.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; }
|
.modal-actions { display: flex; gap: .5rem; justify-content: flex-end; margin-top: .75rem; flex-wrap: wrap; }
|
||||||
@media (max-width: 760px) {
|
.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: 980px) {
|
||||||
.shell { grid-template-columns: 1fr; }
|
.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 h1 { margin: 0; white-space: nowrap; }
|
||||||
.sidebar a { margin: 0; white-space: nowrap; }
|
.sidebar a { margin: 0; white-space: nowrap; }
|
||||||
.toolbar { align-items: stretch; flex-direction: column; }
|
.toolbar { align-items: stretch; flex-direction: column; }
|
||||||
.actions { align-items: stretch; }
|
.actions { align-items: stretch; }
|
||||||
.actions input, .actions button { width: 100%; }
|
.actions input, .actions button { width: 100%; }
|
||||||
.main { padding: 1rem; }
|
.main { padding: 1rem; }
|
||||||
table { display: block; overflow-x: auto; }
|
.desktop-client-table { display: none; }
|
||||||
.row-actions { align-items: stretch; }
|
.mobile-client-list { display: grid; gap: .9rem; min-width: 0; }
|
||||||
|
.mobile-client-card code { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.servers-modal { width: 100%; max-height: calc(100vh - 2rem); }
|
.servers-modal { width: 100%; max-height: calc(100vh - 2rem); }
|
||||||
.server-card-actions { grid-template-columns: 1fr; }
|
.server-card-actions { grid-template-columns: 1fr; }
|
||||||
.server-card-actions button { flex: 1 1 100%; }
|
.server-card-actions button { flex: 1 1 100%; }
|
||||||
@@ -138,6 +170,10 @@
|
|||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<span class="app-version">v{{ app_version }}</span>
|
<span class="app-version">v{{ app_version }}</span>
|
||||||
<span class="user-info">{{ user_name }} ({{ user_role }})</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">
|
<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() == "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>
|
<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>
|
||||||
@@ -149,8 +185,7 @@
|
|||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<h2>{{ t.configs_heading }}</h2>
|
<h2>{{ t.configs_heading }}</h2>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<input x-model="newName" placeholder="{{ t.configs_name_placeholder }}">
|
<button @click="openCreateClient()" :disabled="busy">+ {{ t.configs_create }}</button>
|
||||||
<button @click="createClient()" :disabled="busy">{{ t.configs_create }}</button>
|
|
||||||
{% if is_admin %}
|
{% if is_admin %}
|
||||||
<button class="secondary" @click="sync()" :disabled="busy">{{ t.configs_sync }}</button>
|
<button class="secondary" @click="sync()" :disabled="busy">{{ t.configs_sync }}</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -212,7 +247,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template x-if="clients.length > 0">
|
<template x-if="clients.length > 0">
|
||||||
<table>
|
<table class="config-table desktop-client-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{ t.configs_name }}</th>
|
<th>{{ t.configs_name }}</th>
|
||||||
@@ -221,6 +256,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<th>{{ t.configs_address }}</th>
|
<th>{{ t.configs_address }}</th>
|
||||||
<th>{{ t.configs_public_key }}</th>
|
<th>{{ t.configs_public_key }}</th>
|
||||||
|
<th>{{ t.configs_group }}</th>
|
||||||
<th>{{ t.configs_enabled }}</th>
|
<th>{{ t.configs_enabled }}</th>
|
||||||
<th>{{ t.users_actions }}</th>
|
<th>{{ t.users_actions }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -229,7 +265,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% if is_admin %}
|
{% if is_admin %}
|
||||||
<tr class="owner-row">
|
<tr class="owner-row">
|
||||||
<td colspan="6">
|
<td colspan="7">
|
||||||
<span x-text="group.label"></span>
|
<span x-text="group.label"></span>
|
||||||
<span class="owner-meta" x-text="`(${group.clients.length})`"></span>
|
<span class="owner-meta" x-text="`(${group.clients.length})`"></span>
|
||||||
</td>
|
</td>
|
||||||
@@ -237,16 +273,18 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<template x-for="client in group.clients" :key="client.id">
|
<template x-for="client in group.clients" :key="client.id">
|
||||||
<tr>
|
<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 %}
|
{% if is_admin %}
|
||||||
<td x-text="ownerLabel(client)"></td>
|
<td data-label="{{ t.configs_owner }}" x-text="ownerLabel(client)"></td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<td><code x-text="client.address + '/32'"></code></td>
|
<td data-label="{{ t.configs_address }}"><code x-text="client.address + '/32'"></code></td>
|
||||||
<td><code x-text="shortKey(client.public_key)"></code></td>
|
<td data-label="{{ t.configs_public_key }}"><code x-text="shortKey(client.public_key)"></code></td>
|
||||||
<td x-text="client.enabled ? '{{ t.configs_yes }}' : '{{ t.configs_no }}'"></td>
|
<td data-label="{{ t.configs_group }}" x-text="client.group_name || '{{ t.configs_group_isolated }}'"></td>
|
||||||
<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">
|
<div class="row-actions">
|
||||||
<button class="secondary" @click="openServers(client)" :disabled="busy">{{ t.configs_servers }}</button>
|
<button class="secondary" @click="openServers(client)" :disabled="busy">{{ t.configs_servers }}</button>
|
||||||
|
<button class="secondary" @click="openGroupEditor(client)" :disabled="busy">{{ t.configs_change_group }}</button>
|
||||||
<button class="secondary" @click="setEnabled(client, !client.enabled)" :disabled="busy" x-text="client.enabled ? '{{ t.configs_disable }}' : '{{ t.configs_enable }}'"></button>
|
<button class="secondary" @click="setEnabled(client, !client.enabled)" :disabled="busy" x-text="client.enabled ? '{{ t.configs_disable }}' : '{{ t.configs_enable }}'"></button>
|
||||||
<button class="danger" @click="deleteClient(client)" :disabled="busy">{{ t.users_delete }}</button>
|
<button class="danger" @click="deleteClient(client)" :disabled="busy">{{ t.users_delete }}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -256,10 +294,89 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</template>
|
</template>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<div class="mobile-client-list">
|
||||||
|
<template x-for="ownerGroup in groupedClients()" :key="`mobile-${ownerGroup.key}`">
|
||||||
|
<section class="mobile-owner-group">
|
||||||
|
<div class="mobile-owner-head">
|
||||||
|
<span x-text="ownerGroup.label"></span>
|
||||||
|
<span x-text="ownerGroup.clients.length"></span>
|
||||||
|
</div>
|
||||||
|
<template x-for="client in ownerGroup.clients" :key="`mobile-client-${client.id}`">
|
||||||
|
<article class="mobile-client-card">
|
||||||
|
<div class="mobile-client-head">
|
||||||
|
<span class="mobile-client-name" x-text="client.name"></span>
|
||||||
|
<span class="mobile-client-state" :class="{ enabled: client.enabled }" x-text="client.enabled ? '{{ t.configs_enabled_state }}' : '{{ t.configs_disabled_state }}'"></span>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-client-meta">
|
||||||
|
<div class="mobile-meta-item">
|
||||||
|
<span class="mobile-meta-label">{{ t.configs_address }}</span>
|
||||||
|
<code class="mobile-meta-value" x-text="client.address + '/32'"></code>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-meta-item">
|
||||||
|
<span class="mobile-meta-label">{{ t.configs_group }}</span>
|
||||||
|
<span class="mobile-meta-value" x-text="client.group_name || '{{ t.configs_group_isolated }}'"></span>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-meta-item full">
|
||||||
|
<span class="mobile-meta-label">{{ t.configs_public_key }}</span>
|
||||||
|
<code class="mobile-meta-value" x-text="shortKey(client.public_key)"></code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-client-actions">
|
||||||
|
<button class="secondary" @click="openServers(client)" :disabled="busy">{{ t.configs_servers }}</button>
|
||||||
|
<button class="secondary" @click="openGroupEditor(client)" :disabled="busy">{{ t.configs_change_group }}</button>
|
||||||
|
<button class="secondary" @click="setEnabled(client, !client.enabled)" :disabled="busy" x-text="client.enabled ? '{{ t.configs_disable }}' : '{{ t.configs_enable }}'"></button>
|
||||||
|
<button class="danger" @click="deleteClient(client)" :disabled="busy">{{ t.users_delete }}</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<template x-if="clientModal">
|
||||||
|
<div class="modal-backdrop" @click.self="closeClientModal()">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-head">
|
||||||
|
<div class="modal-title">
|
||||||
|
<h3 x-text="clientModal.mode === 'create' ? '{{ t.configs_new_key }}' : '{{ t.configs_change_group }}'"></h3>
|
||||||
|
<span class="modal-subtitle" x-show="clientModal.client" x-text="clientModal.client ? clientModal.client.name : ''"></span>
|
||||||
|
</div>
|
||||||
|
<button class="secondary" @click="closeClientModal()">{{ t.admin_close }}</button>
|
||||||
|
</div>
|
||||||
|
<template x-if="clientModal.mode === 'create'">
|
||||||
|
<div class="detail-row">
|
||||||
|
<label class="detail-label" for="admin-client-name">{{ t.configs_name }}</label>
|
||||||
|
<input id="admin-client-name" x-model="clientModal.name" placeholder="{{ t.configs_name_placeholder }}">
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="detail-row">
|
||||||
|
<label class="detail-label" for="admin-client-group">{{ t.configs_group }}</label>
|
||||||
|
<div>
|
||||||
|
<input id="admin-client-group" x-model="clientModal.groupName" list="admin-vpn-client-groups" placeholder="{{ t.configs_group_placeholder }}">
|
||||||
|
<datalist id="admin-vpn-client-groups">
|
||||||
|
<template x-for="group in groups" :key="group">
|
||||||
|
<option :value="group"></option>
|
||||||
|
</template>
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template x-if="clientModal.mode === 'group'">
|
||||||
|
<div class="notice tone-warning">
|
||||||
|
<span>{{ t.configs_group_rotation_warning }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="secondary" @click="closeClientModal()">{{ t.admin_close }}</button>
|
||||||
|
<button @click="clientModal.mode === 'create' ? createClient() : changeClientGroup()" :disabled="busy">{{ t.settings_save }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template x-if="rolloutModal">
|
<template x-if="rolloutModal">
|
||||||
<div class="modal-backdrop" @click.self="closeRollout()">
|
<div class="modal-backdrop" @click.self="closeRollout()">
|
||||||
<div class="modal servers-modal">
|
<div class="modal servers-modal">
|
||||||
@@ -341,13 +458,98 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function configsPage() {
|
function configsPage() {
|
||||||
return {
|
return {
|
||||||
clients: [],
|
clients: [],
|
||||||
newName: '',
|
groups: [],
|
||||||
|
clientModal: null,
|
||||||
busy: false,
|
busy: false,
|
||||||
status: '',
|
status: '',
|
||||||
error: '',
|
error: '',
|
||||||
@@ -360,13 +562,34 @@ function configsPage() {
|
|||||||
serverConfigs: {},
|
serverConfigs: {},
|
||||||
serverModal: null,
|
serverModal: null,
|
||||||
qrModal: 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 %},
|
isAdmin: {% if is_admin %}true{% else %}false{% endif %},
|
||||||
init() {
|
init() {
|
||||||
|
this.initTelegramWebApp();
|
||||||
this.load();
|
this.load();
|
||||||
|
this.loadTelegramStatus();
|
||||||
if (this.isAdmin) {
|
if (this.isAdmin) {
|
||||||
this.loadRolloutStatus();
|
this.loadRolloutStatus();
|
||||||
this.rolloutTimer = setInterval(() => this.loadRolloutStatus(), 10000);
|
this.rolloutTimer = setInterval(() => this.loadRolloutStatus(), 10000);
|
||||||
}
|
}
|
||||||
|
this.telegramStatusTimer = setInterval(() => this.pollTelegramStatus(), 2500);
|
||||||
},
|
},
|
||||||
async request(url, options = {}) {
|
async request(url, options = {}) {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
@@ -384,29 +607,225 @@ function configsPage() {
|
|||||||
}
|
}
|
||||||
return data;
|
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() {
|
async load() {
|
||||||
this.error = '';
|
this.error = '';
|
||||||
this.busy = true;
|
this.busy = true;
|
||||||
try {
|
try {
|
||||||
const data = await this.request('/api/vpn-clients');
|
const data = await this.request('/api/vpn-clients');
|
||||||
this.clients = data.clients;
|
this.clients = data.clients;
|
||||||
|
this.groups = data.groups || [];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.error = e.message;
|
this.error = e.message;
|
||||||
} finally {
|
} finally {
|
||||||
this.busy = false;
|
this.busy = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
openCreateClient() {
|
||||||
|
this.clientModal = { mode: 'create', name: '', groupName: '', client: null };
|
||||||
|
},
|
||||||
|
openGroupEditor(client) {
|
||||||
|
this.clientModal = {
|
||||||
|
mode: 'group',
|
||||||
|
name: client.name || '',
|
||||||
|
groupName: client.group_name || '',
|
||||||
|
client,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
closeClientModal() {
|
||||||
|
if (!this.busy) this.clientModal = null;
|
||||||
|
},
|
||||||
|
rememberGroup(groupName) {
|
||||||
|
const group = String(groupName || '').trim();
|
||||||
|
if (group && !this.groups.includes(group)) {
|
||||||
|
this.groups.push(group);
|
||||||
|
this.groups.sort((left, right) => left.localeCompare(right));
|
||||||
|
}
|
||||||
|
},
|
||||||
async createClient() {
|
async createClient() {
|
||||||
|
if (!this.clientModal || this.clientModal.mode !== 'create') return;
|
||||||
this.clearNotice();
|
this.clearNotice();
|
||||||
this.busy = true;
|
this.busy = true;
|
||||||
try {
|
try {
|
||||||
|
const name = this.clientModal.name;
|
||||||
|
const groupName = this.clientModal.groupName.trim() || null;
|
||||||
const data = await this.request('/api/vpn-clients', {
|
const data = await this.request('/api/vpn-clients', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ name: this.newName }),
|
body: JSON.stringify({ name, group_name: groupName }),
|
||||||
});
|
});
|
||||||
this.clients.push(data.client);
|
this.clients.push(data.client);
|
||||||
delete this.serverConfigs[data.client.id];
|
delete this.serverConfigs[data.client.id];
|
||||||
this.newName = '';
|
this.rememberGroup(data.client.group_name);
|
||||||
|
this.clientModal = null;
|
||||||
this.applyResponseNotice(data, '{{ t.notice_create_success }}');
|
this.applyResponseNotice(data, '{{ t.notice_create_success }}');
|
||||||
if (this.isAdmin) await this.loadRolloutStatus();
|
if (this.isAdmin) await this.loadRolloutStatus();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -415,6 +834,36 @@ function configsPage() {
|
|||||||
this.busy = false;
|
this.busy = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async changeClientGroup() {
|
||||||
|
if (!this.clientModal || this.clientModal.mode !== 'group') return;
|
||||||
|
const client = this.clientModal.client;
|
||||||
|
const groupName = this.clientModal.groupName.trim() || null;
|
||||||
|
if ((client.group_name || null) === groupName) {
|
||||||
|
this.clientModal = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!confirm('{{ t.configs_group_rotation_confirm }}')) return;
|
||||||
|
|
||||||
|
this.clearNotice();
|
||||||
|
this.busy = true;
|
||||||
|
try {
|
||||||
|
const data = await this.request(`/api/vpn-clients/${client.id}/group`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ group_name: groupName }),
|
||||||
|
});
|
||||||
|
const index = this.clients.findIndex((item) => item.id === client.id);
|
||||||
|
if (index !== -1) this.clients[index] = data.client;
|
||||||
|
delete this.serverConfigs[client.id];
|
||||||
|
this.rememberGroup(data.client.group_name);
|
||||||
|
this.clientModal = null;
|
||||||
|
this.applyResponseNotice(data, '{{ t.configs_group_changed }}');
|
||||||
|
if (this.isAdmin) await this.loadRolloutStatus();
|
||||||
|
} catch (e) {
|
||||||
|
this.showError(e);
|
||||||
|
} finally {
|
||||||
|
this.busy = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
async setEnabled(client, enabled) {
|
async setEnabled(client, enabled) {
|
||||||
this.clearNotice();
|
this.clearNotice();
|
||||||
this.busy = true;
|
this.busy = true;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
{% block title %}{{ t.login_heading }} | {{ t.site_name }}{% endblock title %}
|
{% block title %}{{ t.login_heading }} | {{ t.site_name }}{% endblock title %}
|
||||||
|
|
||||||
{% block head_extra %}
|
{% block head_extra %}
|
||||||
|
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { 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; }
|
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() %}
|
{% if !message.is_empty() %}
|
||||||
<div class="flash">{{ message }}</div>
|
<div class="flash">{{ message }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<div id="telegram-login-flash" class="flash" style="display: none;"></div>
|
||||||
|
|
||||||
{% if !auth_password_enabled && !auth_sso_enabled %}
|
{% if !auth_password_enabled && !auth_sso_enabled %}
|
||||||
<p class="message">{{ t.login_disabled }}</p>
|
<p class="message">{{ t.login_disabled }}</p>
|
||||||
@@ -53,4 +55,37 @@
|
|||||||
<a class="sso-btn" href="/auth/oidc/start">{{ oidc_button_text }}</a>
|
<a class="sso-btn" href="/auth/oidc/start">{{ oidc_button_text }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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 %}
|
{% endblock body %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
Reference in New Issue
Block a user