Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e890d1c147 | ||
|
|
9614ff8a60 | ||
|
|
e8873f61a5 | ||
|
|
2dbbdb0252 | ||
|
|
3b4899f785 | ||
|
|
9ccab69836 | ||
|
|
77cde17ef9 | ||
|
|
3a4bc23a58 | ||
|
|
3def3afeda | ||
|
|
c3db347da6 | ||
|
|
266b493966 | ||
|
|
0be4512293 |
Generated
+6
-1
@@ -61,12 +61,15 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "amnezia-fellow"
|
name = "amnezia-fellow"
|
||||||
version = "0.1.1"
|
version = "1.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"cot",
|
"cot",
|
||||||
"curve25519-dalek",
|
"curve25519-dalek",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
|
"hex",
|
||||||
|
"hmac",
|
||||||
"k8s-openapi",
|
"k8s-openapi",
|
||||||
"kube",
|
"kube",
|
||||||
"miniz_oxide",
|
"miniz_oxide",
|
||||||
@@ -76,9 +79,11 @@ dependencies = [
|
|||||||
"schemars 0.9.0",
|
"schemars 0.9.0",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+10
-5
@@ -1,16 +1,17 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "amnezia-fellow"
|
name = "amnezia-fellow"
|
||||||
version = "0.1.1"
|
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"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
tokio = { version = "1", features = ["sync"] }
|
tokio = { version = "1", features = ["rt", "sync", "time"] }
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
miniz_oxide = "0.8"
|
miniz_oxide = "0.8"
|
||||||
qrcode = "0.14"
|
qrcode = "0.14"
|
||||||
@@ -19,5 +20,9 @@ tracing = "0.1"
|
|||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
curve25519-dalek = "4.1"
|
curve25519-dalek = "4.1"
|
||||||
getrandom = "0.3"
|
getrandom = "0.3"
|
||||||
|
hex = "0.4"
|
||||||
|
hmac = "0.12"
|
||||||
kube = { version = "3.1.0", default-features = false, features = ["client", "rustls-tls", "ring"] }
|
kube = { version = "3.1.0", default-features = false, features = ["client", "rustls-tls", "ring"] }
|
||||||
k8s-openapi = { version = "0.27.1", features = ["v1_32"] }
|
k8s-openapi = { version = "0.27.1", features = ["v1_32"] }
|
||||||
|
sha2 = "0.10"
|
||||||
|
url = "2"
|
||||||
|
|||||||
@@ -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` |
|
||||||
@@ -114,16 +118,61 @@ All settings use `AMNEZIA_FELLOW_` prefix. Priority is:
|
|||||||
| `AMNEZIA_FELLOW_VPN_DNS` | DNS servers in generated configs | `1.1.1.1, 8.8.8.8` |
|
| `AMNEZIA_FELLOW_VPN_DNS` | DNS servers in generated configs | `1.1.1.1, 8.8.8.8` |
|
||||||
| `AMNEZIA_FELLOW_VPN_MTU` | MTU in generated configs | `1376` |
|
| `AMNEZIA_FELLOW_VPN_MTU` | MTU in generated configs | `1376` |
|
||||||
| `AMNEZIA_FELLOW_SWAGGER_ENABLED` | Serve Swagger UI at `/swagger/` | `false` |
|
| `AMNEZIA_FELLOW_SWAGGER_ENABLED` | Serve Swagger UI at `/swagger/` | `false` |
|
||||||
|
| `AMNEZIA_FELLOW_TELEGRAM_BOT_ENABLED` | Enable Telegram bot/Web App integration | `false` |
|
||||||
|
| `AMNEZIA_FELLOW_TELEGRAM_BOT_USERNAME` | Telegram bot username, with or without `@` | empty |
|
||||||
|
| `AMNEZIA_FELLOW_TELEGRAM_BOT_TOKEN` | Telegram bot token used to verify Web App `initData` | empty |
|
||||||
|
|
||||||
|
## PostgreSQL Migration
|
||||||
|
|
||||||
|
To move from SQLite to PostgreSQL, start the app with a PostgreSQL database URL
|
||||||
|
and point `AMNEZIA_FELLOW_MIGRATE_SQLITE` at the existing SQLite file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AMNEZIA_FELLOW_DATABASE_URL=postgresql://user:pass@postgres:5432/amnezia_fellow
|
||||||
|
AMNEZIA_FELLOW_MIGRATE_SQLITE=/data/amnezia-fellow.sqlite3
|
||||||
|
```
|
||||||
|
|
||||||
|
On startup, the app runs its migrations on PostgreSQL, opens the SQLite source
|
||||||
|
read/write, applies any missing app migrations there, copies config entries,
|
||||||
|
database sessions, users, OIDC links, Telegram link state, and VPN clients,
|
||||||
|
then writes an import marker into PostgreSQL. If the marker already exists, the
|
||||||
|
import is skipped. If PostgreSQL already contains app data but has no marker,
|
||||||
|
startup fails instead of merging two databases implicitly.
|
||||||
|
|
||||||
|
## Telegram Bot
|
||||||
|
|
||||||
|
Configure the bot through the admin Settings page or the matching environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AMNEZIA_FELLOW_TELEGRAM_BOT_ENABLED=true
|
||||||
|
AMNEZIA_FELLOW_TELEGRAM_BOT_USERNAME=<BOT_USERNAME>
|
||||||
|
AMNEZIA_FELLOW_TELEGRAM_BOT_TOKEN=<BOT_TOKEN>
|
||||||
|
```
|
||||||
|
|
||||||
|
In BotFather, create or reuse the same bot and set its Web App URL to
|
||||||
|
`https://<APP_HOST>/configs`. This app runs the bot in the same process with
|
||||||
|
`getUpdates` polling, so do not configure a separate webhook for the same bot.
|
||||||
|
For browser-based linking, users copy a one-time secret code from the portal and
|
||||||
|
send it to this bot; the app reads the Telegram sender ID from that message.
|
||||||
|
Users must open the bot and send `/start` once before notifications can be
|
||||||
|
delivered.
|
||||||
|
|
||||||
## API
|
## 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`
|
||||||
|
- `POST /api/telegram-login/webapp` (public Telegram `initData` login)
|
||||||
|
- `POST /api/telegram-link/webapp`
|
||||||
|
- `POST /api/telegram-link/start`
|
||||||
|
- `POST /api/telegram-link/decline`
|
||||||
|
- `POST /api/telegram-link/unlink`
|
||||||
- `POST /api/vpn-clients`
|
- `POST /api/vpn-clients`
|
||||||
- `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`
|
||||||
|
|||||||
+103
-1
@@ -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(),
|
||||||
@@ -180,6 +185,21 @@ fn config_display_entries(config: &AppConfig, sources: &ConfigSources) -> Vec<Co
|
|||||||
config.swagger_enabled.to_string(),
|
config.swagger_enabled.to_string(),
|
||||||
defaults.swagger_enabled.to_string()
|
defaults.swagger_enabled.to_string()
|
||||||
),
|
),
|
||||||
|
entry!(
|
||||||
|
telegram_bot_enabled,
|
||||||
|
config.telegram_bot_enabled.to_string(),
|
||||||
|
defaults.telegram_bot_enabled.to_string()
|
||||||
|
),
|
||||||
|
entry!(
|
||||||
|
telegram_bot_username,
|
||||||
|
config.telegram_bot_username.clone(),
|
||||||
|
defaults.telegram_bot_username.clone()
|
||||||
|
),
|
||||||
|
entry!(
|
||||||
|
telegram_bot_token,
|
||||||
|
config.telegram_bot_token.clone(),
|
||||||
|
defaults.telegram_bot_token.clone()
|
||||||
|
),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,6 +303,9 @@ pub struct AdminSettingsRequest {
|
|||||||
vpn_dns: String,
|
vpn_dns: String,
|
||||||
vpn_mtu: u16,
|
vpn_mtu: u16,
|
||||||
swagger_enabled: bool,
|
swagger_enabled: bool,
|
||||||
|
telegram_bot_enabled: bool,
|
||||||
|
telegram_bot_username: String,
|
||||||
|
telegram_bot_token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -749,15 +772,91 @@ fn settings_fields(config: &AppConfig, sources: &ConfigSources) -> Vec<AdminSett
|
|||||||
config.swagger_enabled.to_string(),
|
config.swagger_enabled.to_string(),
|
||||||
defaults.swagger_enabled.to_string()
|
defaults.swagger_enabled.to_string()
|
||||||
),
|
),
|
||||||
|
field!(
|
||||||
|
"telegram",
|
||||||
|
"bool",
|
||||||
|
telegram_bot_enabled,
|
||||||
|
config.telegram_bot_enabled.to_string(),
|
||||||
|
defaults.telegram_bot_enabled.to_string()
|
||||||
|
),
|
||||||
|
field!(
|
||||||
|
"telegram",
|
||||||
|
"text",
|
||||||
|
telegram_bot_username,
|
||||||
|
config.telegram_bot_username.clone(),
|
||||||
|
defaults.telegram_bot_username.clone()
|
||||||
|
),
|
||||||
|
field!(
|
||||||
|
"telegram",
|
||||||
|
"password",
|
||||||
|
telegram_bot_token,
|
||||||
|
config.telegram_bot_token.clone(),
|
||||||
|
defaults.telegram_bot_token.clone()
|
||||||
|
),
|
||||||
|
AdminSettingField {
|
||||||
|
key: "telegram_setup_instructions".into(),
|
||||||
|
env_var: String::new(),
|
||||||
|
value: telegram_setup_instructions(config),
|
||||||
|
default_value: String::new(),
|
||||||
|
source: "default",
|
||||||
|
secret: false,
|
||||||
|
kind: "info",
|
||||||
|
section: "telegram",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn telegram_setup_instructions(config: &AppConfig) -> String {
|
||||||
|
let username = config
|
||||||
|
.telegram_bot_username
|
||||||
|
.trim()
|
||||||
|
.trim_start_matches('@')
|
||||||
|
.trim();
|
||||||
|
let username_value = if username.is_empty() {
|
||||||
|
"<BOT_USERNAME>"
|
||||||
|
} else {
|
||||||
|
username
|
||||||
|
};
|
||||||
|
let token_value = if config.telegram_bot_token.trim().is_empty() {
|
||||||
|
"<BOT_TOKEN>"
|
||||||
|
} else {
|
||||||
|
"<configured bot token>"
|
||||||
|
};
|
||||||
|
let bot_link = if username.is_empty() {
|
||||||
|
"https://t.me/<BOT_USERNAME>".to_owned()
|
||||||
|
} else {
|
||||||
|
format!("https://t.me/{username}")
|
||||||
|
};
|
||||||
|
let current_bot = if username.is_empty() {
|
||||||
|
"<BOT_USERNAME>".to_owned()
|
||||||
|
} else if config.telegram_bot_enabled {
|
||||||
|
format!("@{username}")
|
||||||
|
} else {
|
||||||
|
format!("@{username} (disabled)")
|
||||||
|
};
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"Current bot: {current_bot}\n\
|
||||||
|
Bot link: {bot_link}\n\n\
|
||||||
|
Config:\n\
|
||||||
|
AMNEZIA_FELLOW_TELEGRAM_BOT_ENABLED=true\n\
|
||||||
|
AMNEZIA_FELLOW_TELEGRAM_BOT_USERNAME={username_value}\n\
|
||||||
|
AMNEZIA_FELLOW_TELEGRAM_BOT_TOKEN={token_value}\n\n\
|
||||||
|
BotFather:\n\
|
||||||
|
1. Create or reuse this bot and set the Web App URL to https://<APP_HOST>/configs.\n\
|
||||||
|
2. Keep webhook disabled: this process receives bot messages with getUpdates polling.\n\
|
||||||
|
3. Users open the portal, copy the one-time secret code and send it to this bot.\n\
|
||||||
|
4. Users must open the bot and send /start once before notifications can work."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async fn save_settings_request(db: &Database, data: &AdminSettingsRequest) -> cot::Result<()> {
|
async fn save_settings_request(db: &Database, data: &AdminSettingsRequest) -> cot::Result<()> {
|
||||||
let vpn_mtu = data.vpn_mtu.to_string();
|
let vpn_mtu = data.vpn_mtu.to_string();
|
||||||
let auth_password_enabled = data.auth_password_enabled.to_string();
|
let auth_password_enabled = data.auth_password_enabled.to_string();
|
||||||
let auth_sso_enabled = data.auth_sso_enabled.to_string();
|
let auth_sso_enabled = data.auth_sso_enabled.to_string();
|
||||||
let swagger_enabled = data.swagger_enabled.to_string();
|
let swagger_enabled = data.swagger_enabled.to_string();
|
||||||
let fields: [(&str, &str); 19] = [
|
let telegram_bot_enabled = data.telegram_bot_enabled.to_string();
|
||||||
|
let fields: [(&str, &str); 22] = [
|
||||||
("auth_password_enabled", &auth_password_enabled),
|
("auth_password_enabled", &auth_password_enabled),
|
||||||
("auth_sso_enabled", &auth_sso_enabled),
|
("auth_sso_enabled", &auth_sso_enabled),
|
||||||
("oidc_button_text", &data.oidc_button_text),
|
("oidc_button_text", &data.oidc_button_text),
|
||||||
@@ -780,6 +879,9 @@ async fn save_settings_request(db: &Database, data: &AdminSettingsRequest) -> co
|
|||||||
("vpn_dns", &data.vpn_dns),
|
("vpn_dns", &data.vpn_dns),
|
||||||
("vpn_mtu", &vpn_mtu),
|
("vpn_mtu", &vpn_mtu),
|
||||||
("swagger_enabled", &swagger_enabled),
|
("swagger_enabled", &swagger_enabled),
|
||||||
|
("telegram_bot_enabled", &telegram_bot_enabled),
|
||||||
|
("telegram_bot_username", &data.telegram_bot_username),
|
||||||
|
("telegram_bot_token", &data.telegram_bot_token),
|
||||||
];
|
];
|
||||||
for (key, value) in fields {
|
for (key, value) in fields {
|
||||||
let mut entry = ConfigEntry::new(key.to_owned(), value.to_owned());
|
let mut entry = ConfigEntry::new(key.to_owned(), value.to_owned());
|
||||||
|
|||||||
+573
-25
@@ -1,4 +1,5 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use cot::db::Database;
|
use cot::db::Database;
|
||||||
use cot::json::Json;
|
use cot::json::Json;
|
||||||
@@ -15,14 +16,35 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
use crate::user::User;
|
use crate::user::User;
|
||||||
use crate::{auth, vpn};
|
use crate::{auth, telegram, vpn};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// JSON error helper
|
// JSON error helper
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
fn json_error(status: cot::http::StatusCode, message: &str) -> cot::response::Response {
|
fn json_error(status: cot::http::StatusCode, message: &str) -> cot::response::Response {
|
||||||
let body = serde_json::json!({ "error": message });
|
json_error_typed(
|
||||||
|
status,
|
||||||
|
status.canonical_reason().unwrap_or("request_failed"),
|
||||||
|
status.canonical_reason().unwrap_or("Request failed"),
|
||||||
|
message,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_error_typed(
|
||||||
|
status: cot::http::StatusCode,
|
||||||
|
code: &str,
|
||||||
|
title: &str,
|
||||||
|
message: &str,
|
||||||
|
detail: &str,
|
||||||
|
) -> cot::response::Response {
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"code": code,
|
||||||
|
"title": title,
|
||||||
|
"error": message,
|
||||||
|
"detail": detail,
|
||||||
|
});
|
||||||
cot::http::Response::builder()
|
cot::http::Response::builder()
|
||||||
.status(status)
|
.status(status)
|
||||||
.header(cot::http::header::CONTENT_TYPE, "application/json")
|
.header(cot::http::header::CONTENT_TYPE, "application/json")
|
||||||
@@ -65,17 +87,20 @@ 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)]
|
||||||
struct MutateVpnClientResponse {
|
struct MutateVpnClientResponse {
|
||||||
client: vpn::VpnClientView,
|
client: vpn::VpnClientView,
|
||||||
sync: vpn::SecretSyncResult,
|
sync: vpn::SecretSyncResult,
|
||||||
|
notice: Option<ApiNotice>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
@@ -83,9 +108,24 @@ 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,
|
||||||
|
notice: Option<ApiNotice>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||||
|
struct ApiNotice {
|
||||||
|
kind: String,
|
||||||
|
code: String,
|
||||||
|
title: String,
|
||||||
|
message: String,
|
||||||
|
detail: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
@@ -111,6 +151,36 @@ struct ClientPath {
|
|||||||
id: i64,
|
id: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
struct TelegramLinkStatusResponse {
|
||||||
|
enabled: bool,
|
||||||
|
bot_username: String,
|
||||||
|
bot_url: String,
|
||||||
|
linked: bool,
|
||||||
|
declined: bool,
|
||||||
|
pending: bool,
|
||||||
|
pending_secret: Option<String>,
|
||||||
|
pending_expires_at: Option<i64>,
|
||||||
|
telegram_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
struct TelegramLinkResponse {
|
||||||
|
status: TelegramLinkStatusResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
struct TelegramLoginResponse {
|
||||||
|
redirect_to: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
struct TelegramWebAppLinkRequest {
|
||||||
|
init_data: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
const TELEGRAM_INIT_DATA_MAX_AGE: Duration = Duration::from_secs(86_400);
|
||||||
|
|
||||||
async fn vpn_clients_handler(
|
async fn vpn_clients_handler(
|
||||||
session: Session,
|
session: Session,
|
||||||
db: Database,
|
db: Database,
|
||||||
@@ -126,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))
|
||||||
@@ -134,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()
|
||||||
}
|
}
|
||||||
@@ -154,13 +233,236 @@ async fn vpn_status_handler(
|
|||||||
tracing::debug!(user_id = user.id, "VPN rollout status requested");
|
tracing::debug!(user_id = user.id, "VPN rollout status requested");
|
||||||
|
|
||||||
let (config, _) = AppConfig::load_with_db(&db).await;
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
let status = vpn::read_rollout_status_from_kubernetes(&config)
|
let mut status = match vpn::read_rollout_status_from_kubernetes(&config).await {
|
||||||
.await
|
Ok(status) => status,
|
||||||
.map_err(|e| cot::Error::internal(format!("failed to read VPN rollout status: {e}")))?;
|
Err(e) => {
|
||||||
|
return Ok(json_error_typed(
|
||||||
|
cot::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"vpn_status_unavailable",
|
||||||
|
"Server status unavailable",
|
||||||
|
"Could not load VPN server status.",
|
||||||
|
&e,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if user.role != auth::Role::Admin {
|
||||||
|
let disabled = vpn::disabled_endpoint_names(&config);
|
||||||
|
status.pods.retain(|pod| !disabled.contains(&pod.node_name));
|
||||||
|
}
|
||||||
|
|
||||||
Json(status).into_response()
|
Json(status).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn telegram_link_status_handler(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let user = match api_user_record(&session, &db).await? {
|
||||||
|
Ok(user) => user,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
|
||||||
|
Json(telegram_status_response(&config, &user)).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn telegram_link_webapp_handler(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
Json(request): Json<TelegramWebAppLinkRequest>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let mut user = match api_user_record(&session, &db).await? {
|
||||||
|
Ok(user) => user,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
if !config.telegram_bot_enabled {
|
||||||
|
return Ok(json_error_typed(
|
||||||
|
cot::http::StatusCode::CONFLICT,
|
||||||
|
"telegram_disabled",
|
||||||
|
"Telegram is disabled",
|
||||||
|
"Telegram integration is disabled by the administrator.",
|
||||||
|
"",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let telegram_user = match telegram::validate_web_app_init_data(
|
||||||
|
&request.init_data,
|
||||||
|
&config.telegram_bot_token,
|
||||||
|
TELEGRAM_INIT_DATA_MAX_AGE,
|
||||||
|
) {
|
||||||
|
Ok(user) => user,
|
||||||
|
Err(e) => {
|
||||||
|
return Ok(json_error_typed(
|
||||||
|
cot::http::StatusCode::BAD_REQUEST,
|
||||||
|
"telegram_webapp_auth_failed",
|
||||||
|
"Telegram verification failed",
|
||||||
|
"Could not verify Telegram Web App data.",
|
||||||
|
&e.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let telegram_id = telegram_user.id.to_string();
|
||||||
|
if let Some(response) = link_telegram_id(&db, &mut user, &telegram_id).await? {
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
Json(TelegramLinkResponse {
|
||||||
|
status: telegram_status_response(&config, &user),
|
||||||
|
})
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn telegram_login_webapp_handler(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
Json(request): Json<TelegramWebAppLinkRequest>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
if !config.telegram_bot_enabled {
|
||||||
|
return Ok(json_error_typed(
|
||||||
|
cot::http::StatusCode::CONFLICT,
|
||||||
|
"telegram_disabled",
|
||||||
|
"Telegram is disabled",
|
||||||
|
"Telegram integration is disabled by the administrator.",
|
||||||
|
"",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let telegram_user = match telegram::validate_web_app_init_data(
|
||||||
|
&request.init_data,
|
||||||
|
&config.telegram_bot_token,
|
||||||
|
TELEGRAM_INIT_DATA_MAX_AGE,
|
||||||
|
) {
|
||||||
|
Ok(user) => user,
|
||||||
|
Err(e) => {
|
||||||
|
return Ok(json_error_typed(
|
||||||
|
cot::http::StatusCode::BAD_REQUEST,
|
||||||
|
"telegram_webapp_auth_failed",
|
||||||
|
"Telegram verification failed",
|
||||||
|
"Could not verify Telegram Web App data.",
|
||||||
|
&e.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let telegram_id = telegram_user.id.to_string();
|
||||||
|
let Some(user) = User::get_by_telegram_id(&db, &telegram_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(format!("failed to load Telegram user: {e}")))?
|
||||||
|
else {
|
||||||
|
return Ok(json_error_typed(
|
||||||
|
cot::http::StatusCode::UNAUTHORIZED,
|
||||||
|
"telegram_account_not_linked",
|
||||||
|
"Telegram is not linked",
|
||||||
|
"This Telegram account is not connected to any VPN account.",
|
||||||
|
"",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
if !user.is_active() {
|
||||||
|
return Ok(json_error_typed(
|
||||||
|
cot::http::StatusCode::FORBIDDEN,
|
||||||
|
"user_inactive",
|
||||||
|
"Account is inactive",
|
||||||
|
"This account is inactive.",
|
||||||
|
"",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
auth::login(&session, user.id_val()).await?;
|
||||||
|
|
||||||
|
Json(TelegramLoginResponse {
|
||||||
|
redirect_to: "/configs".to_owned(),
|
||||||
|
})
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn telegram_link_start_handler(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let mut user = match api_user_record(&session, &db).await? {
|
||||||
|
Ok(user) => user,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
if !config.telegram_bot_enabled {
|
||||||
|
return Ok(json_error_typed(
|
||||||
|
cot::http::StatusCode::CONFLICT,
|
||||||
|
"telegram_disabled",
|
||||||
|
"Telegram is disabled",
|
||||||
|
"Telegram integration is disabled by the administrator.",
|
||||||
|
"",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let code = create_unique_telegram_link_code(&db).await?;
|
||||||
|
let now = telegram::now_unix_seconds();
|
||||||
|
user.set_telegram_link_code(&db, Some(&code), Some(now))
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(format!("failed to save Telegram link code: {e}")))?;
|
||||||
|
if user.telegram_id() == Some("") {
|
||||||
|
user.set_telegram_id(&db, None).await.map_err(|e| {
|
||||||
|
cot::Error::internal(format!("failed to reset Telegram preference: {e}"))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Json(TelegramLinkResponse {
|
||||||
|
status: telegram_status_response(&config, &user),
|
||||||
|
})
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn telegram_link_decline_handler(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let mut user = match api_user_record(&session, &db).await? {
|
||||||
|
Ok(user) => user,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
if !config.telegram_bot_enabled {
|
||||||
|
return Ok(json_error_typed(
|
||||||
|
cot::http::StatusCode::CONFLICT,
|
||||||
|
"telegram_disabled",
|
||||||
|
"Telegram is disabled",
|
||||||
|
"Telegram integration is disabled by the administrator.",
|
||||||
|
"",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
user.decline_telegram_link(&db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(format!("failed to save Telegram preference: {e}")))?;
|
||||||
|
|
||||||
|
Json(TelegramLinkResponse {
|
||||||
|
status: telegram_status_response(&config, &user),
|
||||||
|
})
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn telegram_link_unlink_handler(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let mut user = match api_user_record(&session, &db).await? {
|
||||||
|
Ok(user) => user,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
|
||||||
|
user.clear_telegram_link(&db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(format!("failed to clear Telegram link: {e}")))?;
|
||||||
|
|
||||||
|
Json(TelegramLinkResponse {
|
||||||
|
status: telegram_status_response(&config, &user),
|
||||||
|
})
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
async fn create_vpn_client_handler(
|
async fn create_vpn_client_handler(
|
||||||
session: Session,
|
session: Session,
|
||||||
db: Database,
|
db: Database,
|
||||||
@@ -177,18 +479,80 @@ async fn create_vpn_client_handler(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let (config, _) = AppConfig::load_with_db(&db).await;
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
let client =
|
let client = match vpn::VpnClient::create_for_owner(
|
||||||
vpn::VpnClient::create_for_owner(&db, user.id, &request.name, &config.vpn_client_cidr)
|
&db,
|
||||||
.await
|
user.id,
|
||||||
.map_err(|e| cot::Error::internal(format!("failed to create client: {e}")))?;
|
&request.name,
|
||||||
let sync = vpn::sync_from_database(&db, &config)
|
request.group_name.as_deref(),
|
||||||
.await
|
&config.vpn_client_cidr,
|
||||||
.map_err(|e| cot::Error::internal(format!("failed to sync client Secret: {e}")))?;
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(client) => client,
|
||||||
|
Err(e) => {
|
||||||
|
return Ok(json_error_typed(
|
||||||
|
cot::http::StatusCode::BAD_REQUEST,
|
||||||
|
"client_create_failed",
|
||||||
|
"Could not create key",
|
||||||
|
"The key was not created.",
|
||||||
|
&e.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (sync, notice) = sync_after_client_mutation(&db, &config).await;
|
||||||
|
|
||||||
let owner_map = owner_view_map(&db, std::slice::from_ref(&client)).await?;
|
let owner_map = owner_view_map(&db, std::slice::from_ref(&client)).await?;
|
||||||
Json(MutateVpnClientResponse {
|
Json(MutateVpnClientResponse {
|
||||||
client: client_view_with_owner(client, &owner_map),
|
client: client_view_with_owner(client, &owner_map),
|
||||||
sync,
|
sync,
|
||||||
|
notice,
|
||||||
|
})
|
||||||
|
.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()
|
.into_response()
|
||||||
}
|
}
|
||||||
@@ -221,14 +585,13 @@ async fn set_vpn_client_enabled_handler(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(format!("failed to update client: {e}")))?;
|
.map_err(|e| cot::Error::internal(format!("failed to update client: {e}")))?;
|
||||||
let (config, _) = AppConfig::load_with_db(&db).await;
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
let sync = vpn::sync_from_database(&db, &config)
|
let (sync, notice) = sync_after_client_mutation(&db, &config).await;
|
||||||
.await
|
|
||||||
.map_err(|e| cot::Error::internal(format!("failed to sync client Secret: {e}")))?;
|
|
||||||
|
|
||||||
let owner_map = owner_view_map(&db, std::slice::from_ref(&client)).await?;
|
let owner_map = owner_view_map(&db, std::slice::from_ref(&client)).await?;
|
||||||
Json(MutateVpnClientResponse {
|
Json(MutateVpnClientResponse {
|
||||||
client: client_view_with_owner(client, &owner_map),
|
client: client_view_with_owner(client, &owner_map),
|
||||||
sync,
|
sync,
|
||||||
|
notice,
|
||||||
})
|
})
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
@@ -259,11 +622,9 @@ async fn delete_vpn_client_handler(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(format!("failed to delete client: {e}")))?;
|
.map_err(|e| cot::Error::internal(format!("failed to delete client: {e}")))?;
|
||||||
let (config, _) = AppConfig::load_with_db(&db).await;
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
let sync = vpn::sync_from_database(&db, &config)
|
let (sync, notice) = sync_after_client_mutation(&db, &config).await;
|
||||||
.await
|
|
||||||
.map_err(|e| cot::Error::internal(format!("failed to sync client Secret: {e}")))?;
|
|
||||||
|
|
||||||
Json(DeleteVpnClientResponse { sync }).into_response()
|
Json(DeleteVpnClientResponse { sync, notice }).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn vpn_client_config_handler(
|
async fn vpn_client_config_handler(
|
||||||
@@ -289,9 +650,18 @@ async fn vpn_client_config_handler(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let (config, _) = AppConfig::load_with_db(&db).await;
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
let runtime = vpn::read_runtime_from_kubernetes(&config)
|
let runtime = match vpn::read_runtime_from_kubernetes(&config).await {
|
||||||
.await
|
Ok(runtime) => runtime,
|
||||||
.map_err(|e| cot::Error::internal(format!("failed to read VPN runtime: {e}")))?;
|
Err(e) => {
|
||||||
|
return Ok(json_error_typed(
|
||||||
|
cot::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"vpn_runtime_unavailable",
|
||||||
|
"VPN servers are unavailable",
|
||||||
|
"Could not load VPN server list.",
|
||||||
|
&e,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
let endpoints = vpn::filter_enabled_endpoints(runtime.endpoints, &config);
|
let endpoints = vpn::filter_enabled_endpoints(runtime.endpoints, &config);
|
||||||
if endpoints.is_empty() {
|
if endpoints.is_empty() {
|
||||||
return Ok(json_error(
|
return Ok(json_error(
|
||||||
@@ -381,6 +751,140 @@ fn client_view_with_owner(
|
|||||||
view
|
view
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn api_user_record(
|
||||||
|
session: &Session,
|
||||||
|
db: &Database,
|
||||||
|
) -> cot::Result<Result<User, cot::response::Response>> {
|
||||||
|
let Some(auth_user) = auth::get_session_user(session, db).await else {
|
||||||
|
return Ok(Err(json_error(
|
||||||
|
cot::http::StatusCode::UNAUTHORIZED,
|
||||||
|
"not authenticated",
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
let Some(user) = User::get_by_id(db, auth_user.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(format!("failed to load user: {e}")))?
|
||||||
|
else {
|
||||||
|
return Ok(Err(json_error(
|
||||||
|
cot::http::StatusCode::UNAUTHORIZED,
|
||||||
|
"not authenticated",
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
Ok(Ok(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn telegram_status_response(config: &AppConfig, user: &User) -> TelegramLinkStatusResponse {
|
||||||
|
let bot_username = normalized_telegram_bot_username(&config.telegram_bot_username);
|
||||||
|
let telegram_id = user.telegram_id().map(str::to_owned);
|
||||||
|
let linked = telegram_id
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|value| !value.is_empty());
|
||||||
|
let declined = telegram_id.as_deref() == Some("");
|
||||||
|
let pending = user.telegram_link_code().is_some()
|
||||||
|
&& telegram::link_code_is_active(user.telegram_link_code_created_at());
|
||||||
|
let pending_secret = pending
|
||||||
|
.then(|| user.telegram_link_code().map(str::to_owned))
|
||||||
|
.flatten();
|
||||||
|
let pending_expires_at = pending
|
||||||
|
.then(|| {
|
||||||
|
user.telegram_link_code_created_at()
|
||||||
|
.map(telegram::link_code_expires_at)
|
||||||
|
})
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
TelegramLinkStatusResponse {
|
||||||
|
enabled: config.telegram_bot_enabled,
|
||||||
|
bot_url: telegram_bot_url(&bot_username),
|
||||||
|
bot_username,
|
||||||
|
linked,
|
||||||
|
declined,
|
||||||
|
pending,
|
||||||
|
pending_secret,
|
||||||
|
pending_expires_at,
|
||||||
|
telegram_id: linked.then_some(telegram_id).flatten(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalized_telegram_bot_username(username: &str) -> String {
|
||||||
|
username.trim().trim_start_matches('@').trim().to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn telegram_bot_url(username: &str) -> String {
|
||||||
|
if username.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("https://t.me/{username}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_unique_telegram_link_code(db: &Database) -> cot::Result<String> {
|
||||||
|
for _ in 0..8 {
|
||||||
|
let code = telegram::generate_link_code().map_err(cot::Error::internal)?;
|
||||||
|
let existing = User::get_by_telegram_link_code(db, &code)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
cot::Error::internal(format!("failed to check Telegram link code: {e}"))
|
||||||
|
})?;
|
||||||
|
if existing.is_none() {
|
||||||
|
return Ok(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(cot::Error::internal(
|
||||||
|
"failed to generate unique Telegram link code",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn link_telegram_id(
|
||||||
|
db: &Database,
|
||||||
|
user: &mut User,
|
||||||
|
telegram_id: &str,
|
||||||
|
) -> cot::Result<Option<cot::response::Response>> {
|
||||||
|
if let Some(existing) = User::get_by_telegram_id(db, telegram_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(format!("failed to check Telegram ID: {e}")))?
|
||||||
|
{
|
||||||
|
if existing.id_val() != user.id_val() {
|
||||||
|
return Ok(Some(json_error_typed(
|
||||||
|
cot::http::StatusCode::CONFLICT,
|
||||||
|
"telegram_id_taken",
|
||||||
|
"Telegram ID is already linked",
|
||||||
|
"This Telegram ID is already connected to another account.",
|
||||||
|
"",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
user.complete_telegram_link(db, telegram_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(format!("failed to save Telegram ID: {e}")))?;
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sync_after_client_mutation(
|
||||||
|
db: &Database,
|
||||||
|
config: &AppConfig,
|
||||||
|
) -> (vpn::SecretSyncResult, Option<ApiNotice>) {
|
||||||
|
match vpn::sync_from_database(db, config).await {
|
||||||
|
Ok(sync) => (sync, None),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "client data changed but Secret sync failed");
|
||||||
|
(
|
||||||
|
vpn::SecretSyncResult {
|
||||||
|
changed: false,
|
||||||
|
message: "client data saved; Secret sync failed".to_owned(),
|
||||||
|
},
|
||||||
|
Some(ApiNotice {
|
||||||
|
kind: "warning".to_owned(),
|
||||||
|
code: "secret_sync_failed".to_owned(),
|
||||||
|
title: "Saved locally".to_owned(),
|
||||||
|
message: "The key list was updated, but VPN servers did not receive the new config yet.".to_owned(),
|
||||||
|
detail: e,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn render_qr_svg(value: &str) -> cot::Result<String> {
|
fn render_qr_svg(value: &str) -> cot::Result<String> {
|
||||||
let code = QrCode::new(value.as_bytes())
|
let code = QrCode::new(value.as_bytes())
|
||||||
.map_err(|e| cot::Error::internal(format!("failed to render QR code: {e}")))?;
|
.map_err(|e| cot::Error::internal(format!("failed to render QR code: {e}")))?;
|
||||||
@@ -411,9 +915,18 @@ async fn sync_vpn_clients_handler(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let (config, _) = AppConfig::load_with_db(&db).await;
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
let sync = vpn::sync_from_database(&db, &config)
|
let sync = match vpn::sync_from_database(&db, &config).await {
|
||||||
.await
|
Ok(sync) => sync,
|
||||||
.map_err(|e| cot::Error::internal(format!("failed to sync client Secret: {e}")))?;
|
Err(e) => {
|
||||||
|
return Ok(json_error_typed(
|
||||||
|
cot::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"secret_sync_failed",
|
||||||
|
"Secret sync failed",
|
||||||
|
"Could not apply client config to VPN servers.",
|
||||||
|
&e,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
Json(sync).into_response()
|
Json(sync).into_response()
|
||||||
}
|
}
|
||||||
@@ -442,6 +955,36 @@ impl App for ApiApp {
|
|||||||
api_get(vpn_status_handler),
|
api_get(vpn_status_handler),
|
||||||
"api_vpn_status",
|
"api_vpn_status",
|
||||||
),
|
),
|
||||||
|
Route::with_api_handler_and_name(
|
||||||
|
"/telegram-link/status",
|
||||||
|
api_get(telegram_link_status_handler),
|
||||||
|
"api_telegram_link_status",
|
||||||
|
),
|
||||||
|
Route::with_api_handler_and_name(
|
||||||
|
"/telegram-link/webapp",
|
||||||
|
api_post(telegram_link_webapp_handler),
|
||||||
|
"api_telegram_link_webapp",
|
||||||
|
),
|
||||||
|
Route::with_api_handler_and_name(
|
||||||
|
"/telegram-login/webapp",
|
||||||
|
api_post(telegram_login_webapp_handler),
|
||||||
|
"api_telegram_login_webapp",
|
||||||
|
),
|
||||||
|
Route::with_api_handler_and_name(
|
||||||
|
"/telegram-link/start",
|
||||||
|
api_post(telegram_link_start_handler),
|
||||||
|
"api_telegram_link_start",
|
||||||
|
),
|
||||||
|
Route::with_api_handler_and_name(
|
||||||
|
"/telegram-link/decline",
|
||||||
|
api_post(telegram_link_decline_handler),
|
||||||
|
"api_telegram_link_decline",
|
||||||
|
),
|
||||||
|
Route::with_api_handler_and_name(
|
||||||
|
"/telegram-link/unlink",
|
||||||
|
api_post(telegram_link_unlink_handler),
|
||||||
|
"api_telegram_link_unlink",
|
||||||
|
),
|
||||||
Route::with_api_handler_and_name(
|
Route::with_api_handler_and_name(
|
||||||
"/vpn-clients/sync",
|
"/vpn-clients/sync",
|
||||||
api_post(sync_vpn_clients_handler),
|
api_post(sync_vpn_clients_handler),
|
||||||
@@ -452,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),
|
||||||
|
|||||||
+38
-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,
|
||||||
@@ -107,12 +117,16 @@ pub struct ConfigSources {
|
|||||||
pub vpn_dns: ConfigSource,
|
pub vpn_dns: ConfigSource,
|
||||||
pub vpn_mtu: ConfigSource,
|
pub vpn_mtu: ConfigSource,
|
||||||
pub swagger_enabled: ConfigSource,
|
pub swagger_enabled: ConfigSource,
|
||||||
|
pub telegram_bot_enabled: ConfigSource,
|
||||||
|
pub telegram_bot_username: ConfigSource,
|
||||||
|
pub telegram_bot_token: ConfigSource,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ConfigSources {
|
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,
|
||||||
@@ -133,6 +147,9 @@ impl Default for ConfigSources {
|
|||||||
vpn_dns: ConfigSource::Default,
|
vpn_dns: ConfigSource::Default,
|
||||||
vpn_mtu: ConfigSource::Default,
|
vpn_mtu: ConfigSource::Default,
|
||||||
swagger_enabled: ConfigSource::Default,
|
swagger_enabled: ConfigSource::Default,
|
||||||
|
telegram_bot_enabled: ConfigSource::Default,
|
||||||
|
telegram_bot_username: ConfigSource::Default,
|
||||||
|
telegram_bot_token: ConfigSource::Default,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,8 +205,10 @@ macro_rules! impl_env_overrides {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[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.
|
||||||
@@ -230,12 +249,19 @@ pub struct AppConfig {
|
|||||||
pub vpn_mtu: u16,
|
pub vpn_mtu: u16,
|
||||||
/// Whether the Swagger UI is served at /swagger/.
|
/// Whether the Swagger UI is served at /swagger/.
|
||||||
pub swagger_enabled: bool,
|
pub swagger_enabled: bool,
|
||||||
|
/// Whether Telegram bot/Web App integration is enabled.
|
||||||
|
pub telegram_bot_enabled: bool,
|
||||||
|
/// Public bot username, without or with the leading @.
|
||||||
|
pub telegram_bot_username: String,
|
||||||
|
/// Bot token used to verify Telegram Web App initData.
|
||||||
|
pub telegram_bot_token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppConfig {
|
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(),
|
||||||
@@ -256,12 +282,16 @@ impl Default for AppConfig {
|
|||||||
vpn_dns: "1.1.1.1, 8.8.8.8".into(),
|
vpn_dns: "1.1.1.1, 8.8.8.8".into(),
|
||||||
vpn_mtu: 1376,
|
vpn_mtu: 1376,
|
||||||
swagger_enabled: false,
|
swagger_enabled: false,
|
||||||
|
telegram_bot_enabled: false,
|
||||||
|
telegram_bot_username: String::new(),
|
||||||
|
telegram_bot_token: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
@@ -282,6 +312,9 @@ impl_env_overrides!(
|
|||||||
vpn_dns,
|
vpn_dns,
|
||||||
vpn_mtu,
|
vpn_mtu,
|
||||||
swagger_enabled,
|
swagger_enabled,
|
||||||
|
telegram_bot_enabled,
|
||||||
|
telegram_bot_username,
|
||||||
|
telegram_bot_token,
|
||||||
);
|
);
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
@@ -337,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);
|
||||||
@@ -357,6 +391,9 @@ impl AppConfig {
|
|||||||
apply_db_field!(vpn_dns);
|
apply_db_field!(vpn_dns);
|
||||||
apply_db_field!(vpn_mtu);
|
apply_db_field!(vpn_mtu);
|
||||||
apply_db_field!(swagger_enabled);
|
apply_db_field!(swagger_enabled);
|
||||||
|
apply_db_field!(telegram_bot_enabled);
|
||||||
|
apply_db_field!(telegram_bot_username);
|
||||||
|
apply_db_field!(telegram_bot_token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+83
-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" , "Выход";
|
||||||
@@ -61,6 +64,11 @@ translations! {
|
|||||||
// Kubernetes / VPN settings
|
// Kubernetes / VPN settings
|
||||||
settings_kubernetes: "Kubernetes" , "Kubernetes";
|
settings_kubernetes: "Kubernetes" , "Kubernetes";
|
||||||
settings_vpn: "VPN" , "VPN";
|
settings_vpn: "VPN" , "VPN";
|
||||||
|
settings_telegram: "Telegram" , "Telegram";
|
||||||
|
settings_telegram_enabled: "Telegram bot enabled" , "Telegram-бот включён";
|
||||||
|
settings_telegram_username: "Bot username" , "Username бота";
|
||||||
|
settings_telegram_token: "Bot token" , "Токен бота";
|
||||||
|
settings_telegram_setup_instructions: "Bot setup guide" , "Инструкция настройки бота";
|
||||||
|
|
||||||
// User management
|
// User management
|
||||||
nav_users: "Users" , "Пользователи";
|
nav_users: "Users" , "Пользователи";
|
||||||
@@ -88,22 +96,33 @@ translations! {
|
|||||||
configs_create: "Create" , "Создать";
|
configs_create: "Create" , "Создать";
|
||||||
configs_sync: "Sync Secret" , "Синхронизировать Secret";
|
configs_sync: "Sync Secret" , "Синхронизировать Secret";
|
||||||
configs_rollout_heading: "Apply status" , "Статус применения";
|
configs_rollout_heading: "Apply status" , "Статус применения";
|
||||||
|
configs_server_status: "Server status" , "Статус серверов";
|
||||||
configs_refresh: "Refresh" , "Обновить";
|
configs_refresh: "Refresh" , "Обновить";
|
||||||
configs_config_updated: "Config updated" , "Конфиг обновлён";
|
configs_config_updated: "Config updated" , "Конфиг обновлён";
|
||||||
configs_loading_status: "Loading status..." , "Загрузка статуса...";
|
configs_loading_status: "Loading status..." , "Загрузка статуса...";
|
||||||
configs_no_pods: "No AmneziaWG pods." , "Нет подов AmneziaWG.";
|
configs_no_pods: "No AmneziaWG pods." , "Нет подов AmneziaWG.";
|
||||||
configs_server: "Server" , "Сервер";
|
configs_server: "Server" , "Сервер";
|
||||||
|
configs_endpoint: "Endpoint" , "Endpoint";
|
||||||
|
configs_message: "Message" , "Сообщение";
|
||||||
configs_pod: "Pod" , "Pod";
|
configs_pod: "Pod" , "Pod";
|
||||||
|
configs_node: "Node" , "Нода";
|
||||||
configs_rollout: "Rollout" , "Применение";
|
configs_rollout: "Rollout" , "Применение";
|
||||||
configs_ready: "Ready" , "Готов";
|
configs_ready: "Ready" , "Готов";
|
||||||
configs_not_ready: "Not ready" , "Не готов";
|
configs_not_ready: "Not ready" , "Не готов";
|
||||||
configs_phase: "Phase" , "Фаза";
|
configs_phase: "Phase" , "Фаза";
|
||||||
configs_uptime: "Uptime" , "Аптайм";
|
configs_uptime: "Uptime" , "Аптайм";
|
||||||
configs_restarts: "Restarts" , "Рестарты";
|
configs_restarts: "Restarts" , "Рестарты";
|
||||||
|
configs_config_applied: "Config applied" , "Конфиг применён";
|
||||||
|
configs_reload_status: "Reload status" , "Статус reload";
|
||||||
|
configs_config_hash: "Config hash" , "Хэш конфига";
|
||||||
configs_status_applied: "applied" , "применён";
|
configs_status_applied: "applied" , "применён";
|
||||||
configs_status_starting: "starting" , "стартует";
|
configs_status_starting: "starting" , "стартует";
|
||||||
configs_status_pending_restart: "pending restart" , "ждёт рестарт";
|
configs_status_pending_apply: "pending apply" , "ждёт применения";
|
||||||
|
configs_status_error: "reload error" , "ошибка reload";
|
||||||
configs_status_unknown: "unknown" , "неизвестно";
|
configs_status_unknown: "unknown" , "неизвестно";
|
||||||
|
configs_status_online: "online" , "онлайн";
|
||||||
|
configs_status_offline: "offline" , "оффлайн";
|
||||||
|
configs_status_unavailable: "status unavailable" , "статус недоступен";
|
||||||
configs_never: "n/a" , "н/д";
|
configs_never: "n/a" , "н/д";
|
||||||
configs_servers: "Servers" , "Серверы";
|
configs_servers: "Servers" , "Серверы";
|
||||||
configs_loading_servers: "Loading servers..." , "Загрузка серверов...";
|
configs_loading_servers: "Loading servers..." , "Загрузка серверов...";
|
||||||
@@ -118,6 +137,69 @@ translations! {
|
|||||||
configs_no: "no" , "нет";
|
configs_no: "no" , "нет";
|
||||||
configs_empty: "No configs yet." , "Конфигов пока нет.";
|
configs_empty: "No configs yet." , "Конфигов пока нет.";
|
||||||
configs_name_placeholder: "Client name" , "Имя клиента";
|
configs_name_placeholder: "Client name" , "Имя клиента";
|
||||||
|
configs_portal_kicker: "Client portal" , "Клиентский портал";
|
||||||
|
configs_portal_heading: "My VPN keys" , "Мои VPN-ключи";
|
||||||
|
configs_key_status: "Key status" , "Статус ключей";
|
||||||
|
configs_active_keys: "Active" , "Активные";
|
||||||
|
configs_total_keys: "Total" , "Всего";
|
||||||
|
configs_new_key: "New key" , "Новый ключ";
|
||||||
|
configs_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_hint: "It will appear here after creation." , "После создания он появится здесь.";
|
||||||
|
configs_enabled_state: "active" , "активен";
|
||||||
|
configs_disabled_state: "disabled" , "отключён";
|
||||||
|
configs_copy_link: "Copy" , "Копировать";
|
||||||
|
configs_qr_code: "QR code" , "QR-код";
|
||||||
|
configs_choose_server: "Choose server" , "Выбрать сервер";
|
||||||
|
configs_updated: "Updated" , "Обновлён";
|
||||||
|
configs_key_ready: "Ready" , "Готов";
|
||||||
|
notice_success_title: "Done" , "Готово";
|
||||||
|
notice_warning_title: "Needs attention" , "Нужно внимание";
|
||||||
|
notice_error_title: "Something went wrong" , "Что-то сломалось";
|
||||||
|
notice_sync_warning_title: "Saved, but not applied" , "Сохранено, но не применено";
|
||||||
|
notice_sync_warning_message: "The key list changed, but VPN servers did not receive the new config yet." , "Список ключей изменён, но VPN-серверы пока не получили новый конфиг.";
|
||||||
|
notice_create_success: "Key created." , "Ключ создан.";
|
||||||
|
notice_update_success: "Key updated." , "Ключ обновлён.";
|
||||||
|
notice_delete_success: "Key deleted." , "Ключ удалён.";
|
||||||
|
notice_server_list_error_title: "Server list unavailable" , "Список серверов недоступен";
|
||||||
|
notice_server_list_error_message: "Could not load VPN servers." , "Не удалось загрузить VPN-серверы.";
|
||||||
|
notice_detail: "Detail" , "Детали";
|
||||||
|
telegram_link_title: "Connect Telegram" , "Подключить Telegram";
|
||||||
|
telegram_webapp_message: "Save this Telegram account for VPN notifications and bot control." , "Сохранить этот Telegram-аккаунт для уведомлений и управления через бота.";
|
||||||
|
telegram_manual_message: "Connect Telegram by sending a one-time secret code to the bot." , "Подключите Telegram, отправив одноразовый секретный код боту.";
|
||||||
|
telegram_connect: "Connect" , "Подключить";
|
||||||
|
telegram_skip: "Do not ask again" , "Больше не предлагать";
|
||||||
|
telegram_guide_title: "Send this code to the bot" , "Отправьте этот код боту";
|
||||||
|
telegram_open_bot: "Open bot" , "Открыть бота";
|
||||||
|
telegram_guide_start: "Open the bot and send /start once if you have not done it before." , "Откройте бота и один раз отправьте /start, если ещё не делали этого.";
|
||||||
|
telegram_guide_get_id: "Send the secret code below to the bot." , "Отправьте боту секретный код ниже.";
|
||||||
|
telegram_guide_paste: "Return here; the status updates automatically." , "Вернитесь сюда; статус обновится автоматически.";
|
||||||
|
telegram_secret_label: "Secret code" , "Секретный код";
|
||||||
|
telegram_save: "Save Telegram" , "Сохранить Telegram";
|
||||||
|
telegram_saved: "Telegram connected." , "Telegram подключён.";
|
||||||
|
telegram_declined: "Telegram prompt disabled." , "Предложение Telegram отключено.";
|
||||||
|
telegram_bot_unconfigured: "bot is not configured" , "бот не настроен";
|
||||||
|
telegram_status_connected: "Telegram connected" , "Telegram подключён";
|
||||||
|
telegram_status_pending: "Telegram code is waiting" , "Код Telegram ожидает";
|
||||||
|
telegram_status_empty: "Telegram is not connected" , "Telegram не подключён";
|
||||||
|
telegram_status_disabled: "Telegram is disabled" , "Telegram отключён";
|
||||||
|
telegram_change: "Change" , "Сменить";
|
||||||
|
telegram_delete: "Remove" , "Удалить";
|
||||||
|
telegram_delete_confirm: "Remove Telegram from this account?" , "Удалить Telegram из этого аккаунта?";
|
||||||
|
telegram_unlinked: "Telegram removed." , "Telegram удалён.";
|
||||||
|
telegram_copy_secret: "Copy code" , "Копировать код";
|
||||||
|
telegram_secret_copied: "Secret code copied." , "Секретный код скопирован.";
|
||||||
|
telegram_refresh_status: "Check status" , "Проверить статус";
|
||||||
|
telegram_pending_message: "Waiting for a message with the secret code." , "Жду сообщение с секретным кодом.";
|
||||||
|
telegram_connected_message: "This account is ready for Telegram bot actions." , "Этот аккаунт готов к действиям через Telegram-бота.";
|
||||||
|
telegram_not_connected_message: "Telegram is not connected yet." , "Telegram пока не подключён.";
|
||||||
|
|
||||||
// VPN server management
|
// VPN server management
|
||||||
servers_empty: "No registered servers." , "Нет зарегистрированных серверов.";
|
servers_empty: "No registered servers." , "Нет зарегистрированных серверов.";
|
||||||
|
|||||||
+64
-9
@@ -4,11 +4,14 @@ mod auth;
|
|||||||
mod config;
|
mod config;
|
||||||
mod i18n;
|
mod i18n;
|
||||||
mod oidc;
|
mod oidc;
|
||||||
|
mod sqlite_migration;
|
||||||
|
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;
|
||||||
@@ -20,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;
|
||||||
@@ -55,21 +58,56 @@ struct ConfigsTemplate {
|
|||||||
app_version: &'static str,
|
app_version: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Template)]
|
||||||
|
#[template(path = "client_portal.html")]
|
||||||
|
struct ClientPortalTemplate {
|
||||||
|
t: &'static Translations,
|
||||||
|
user_name: String,
|
||||||
|
user_role: String,
|
||||||
|
app_version: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 user_name = user.name;
|
||||||
|
let user_role = user.role.code().to_owned();
|
||||||
|
|
||||||
|
if is_admin {
|
||||||
|
return Html::new(
|
||||||
|
ConfigsTemplate {
|
||||||
|
t: i18n.t,
|
||||||
|
user_name,
|
||||||
|
user_role,
|
||||||
|
is_admin,
|
||||||
|
app_version: env!("CARGO_PKG_VERSION"),
|
||||||
|
}
|
||||||
|
.render()?,
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
Html::new(
|
Html::new(
|
||||||
ConfigsTemplate {
|
ClientPortalTemplate {
|
||||||
t: i18n.t,
|
t: i18n.t,
|
||||||
user_name: user.name,
|
user_name,
|
||||||
user_role: user.role.code().to_owned(),
|
user_role,
|
||||||
is_admin: user.role == auth::Role::Admin,
|
|
||||||
app_version: env!("CARGO_PKG_VERSION"),
|
app_version: env!("CARGO_PKG_VERSION"),
|
||||||
}
|
}
|
||||||
.render()?,
|
.render()?,
|
||||||
@@ -153,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(
|
||||||
@@ -268,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",
|
||||||
@@ -292,6 +341,11 @@ impl Project for AmneziaFellowProject {
|
|||||||
" API:\n",
|
" API:\n",
|
||||||
" AMNEZIA_FELLOW_SWAGGER_ENABLED Enable Swagger UI at /swagger/ (default: false)\n",
|
" AMNEZIA_FELLOW_SWAGGER_ENABLED Enable Swagger UI at /swagger/ (default: false)\n",
|
||||||
"\n",
|
"\n",
|
||||||
|
" Telegram:\n",
|
||||||
|
" AMNEZIA_FELLOW_TELEGRAM_BOT_ENABLED Enable Telegram bot/Web App integration (default: false)\n",
|
||||||
|
" AMNEZIA_FELLOW_TELEGRAM_BOT_USERNAME Telegram bot username, with or without @\n",
|
||||||
|
" AMNEZIA_FELLOW_TELEGRAM_BOT_TOKEN Telegram bot token for Web App initData verification\n",
|
||||||
|
"\n",
|
||||||
"QUICK START\n",
|
"QUICK START\n",
|
||||||
" export AMNEZIA_FELLOW_DATABASE_URL=sqlite://amnezia-fellow.sqlite3?mode=rwc\n",
|
" export AMNEZIA_FELLOW_DATABASE_URL=sqlite://amnezia-fellow.sqlite3?mode=rwc\n",
|
||||||
" amnezia-fellow --listen 127.0.0.1:8000",
|
" amnezia-fellow --listen 127.0.0.1:8000",
|
||||||
@@ -379,6 +433,7 @@ fn main() -> impl Project {
|
|||||||
tracing_subscriber::fmt().with_env_filter(filter).init();
|
tracing_subscriber::fmt().with_env_filter(filter).init();
|
||||||
|
|
||||||
tracing::info!("loaded config: {:?}", app_config);
|
tracing::info!("loaded config: {:?}", app_config);
|
||||||
|
telegram::spawn_bot_worker(Arc::clone(&app_config));
|
||||||
|
|
||||||
AmneziaFellowProject { app_config }
|
AmneziaFellowProject { app_config }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use cot::db::migrations::{self, MigrationEngine, SyncDynMigration};
|
||||||
|
use cot::db::{Database, Model};
|
||||||
|
use cot::session::db::Session as CotSession;
|
||||||
|
|
||||||
|
use crate::config::{AppConfig, ConfigEntry};
|
||||||
|
use crate::user::{OidcLink, User};
|
||||||
|
use crate::vpn::VpnClient;
|
||||||
|
|
||||||
|
const MARKER_KEY: &str = "sqlite_migration_completed_at";
|
||||||
|
const SOURCE_KEY: &str = "sqlite_migration_source";
|
||||||
|
|
||||||
|
pub async fn run_if_requested(config: &AppConfig, target_db: &Database) -> cot::Result<()> {
|
||||||
|
let source = config.migrate_sqlite.trim();
|
||||||
|
if source.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !is_postgres_url(&config.database_url) {
|
||||||
|
tracing::warn!(
|
||||||
|
"AMNEZIA_FELLOW_MIGRATE_SQLITE is set but the active database is not PostgreSQL; skipping SQLite import"
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ConfigEntry::get_by_key(target_db, MARKER_KEY)
|
||||||
|
.await
|
||||||
|
.map_err(internal)?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
tracing::info!("SQLite import marker exists; skipping SQLite import");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if target_has_app_data(target_db).await? {
|
||||||
|
return Err(cot::Error::internal(
|
||||||
|
"PostgreSQL target already contains amnezia-fellow data and has no SQLite import marker; refusing to merge automatically",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let source_url = sqlite_source_url(source)?;
|
||||||
|
tracing::info!("Importing amnezia-fellow data from SQLite into PostgreSQL");
|
||||||
|
|
||||||
|
let source_db = Database::new(source_url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(format!("failed to open SQLite source: {e}")))?;
|
||||||
|
|
||||||
|
run_app_migrations(&source_db).await?;
|
||||||
|
copy_app_data(&source_db, target_db).await?;
|
||||||
|
reset_postgres_sequences(target_db).await?;
|
||||||
|
write_marker(target_db, source).await?;
|
||||||
|
|
||||||
|
tracing::info!("SQLite import into PostgreSQL completed");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_postgres_url(url: &str) -> bool {
|
||||||
|
url.starts_with("postgresql:")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sqlite_source_url(source: &str) -> cot::Result<String> {
|
||||||
|
if source.starts_with("sqlite:") {
|
||||||
|
return Ok(source.to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !Path::new(source).exists() {
|
||||||
|
return Err(cot::Error::internal(format!(
|
||||||
|
"SQLite import source does not exist: {source}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let prefix = if Path::new(source).is_absolute() {
|
||||||
|
"sqlite://"
|
||||||
|
} else {
|
||||||
|
"sqlite:"
|
||||||
|
};
|
||||||
|
Ok(format!("{prefix}{source}?mode=rw"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_app_migrations(db: &Database) -> cot::Result<()> {
|
||||||
|
let engine = MigrationEngine::new(app_migrations())
|
||||||
|
.map_err(|e| cot::Error::internal(format!("failed to build migration engine: {e}")))?;
|
||||||
|
engine
|
||||||
|
.run(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(format!("failed to migrate SQLite source: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app_migrations() -> Vec<Box<SyncDynMigration>> {
|
||||||
|
let mut all = migrations::wrap_migrations(cot::session::db::migrations::MIGRATIONS);
|
||||||
|
all.extend(migrations::wrap_migrations(
|
||||||
|
crate::config::db_migrations::MIGRATIONS,
|
||||||
|
));
|
||||||
|
all.extend(migrations::wrap_migrations(
|
||||||
|
crate::user::db_migrations::MIGRATIONS,
|
||||||
|
));
|
||||||
|
all.extend(migrations::wrap_migrations(
|
||||||
|
crate::vpn::db_migrations::MIGRATIONS,
|
||||||
|
));
|
||||||
|
all
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn target_has_app_data(db: &Database) -> cot::Result<bool> {
|
||||||
|
let config_entries = ConfigEntry::objects().count(db).await.map_err(internal)?;
|
||||||
|
let users = User::objects().count(db).await.map_err(internal)?;
|
||||||
|
let oidc_links = OidcLink::objects().count(db).await.map_err(internal)?;
|
||||||
|
let clients = VpnClient::objects().count(db).await.map_err(internal)?;
|
||||||
|
Ok(config_entries > 0 || users > 0 || oidc_links > 0 || clients > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn copy_app_data(source_db: &Database, target_db: &Database) -> cot::Result<()> {
|
||||||
|
for mut entry in ConfigEntry::objects().all(source_db).await.map_err(internal)? {
|
||||||
|
if matches!(
|
||||||
|
entry.key_str(),
|
||||||
|
"database_url" | "migrate_sqlite" | MARKER_KEY | SOURCE_KEY
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
entry.save(target_db).await.map_err(internal)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for mut session in CotSession::objects()
|
||||||
|
.all(source_db)
|
||||||
|
.await
|
||||||
|
.map_err(internal)?
|
||||||
|
{
|
||||||
|
session.save(target_db).await.map_err(internal)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for mut user in User::list_all(source_db).await.map_err(internal)? {
|
||||||
|
user.save(target_db).await.map_err(internal)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for mut link in OidcLink::objects().all(source_db).await.map_err(internal)? {
|
||||||
|
link.save(target_db).await.map_err(internal)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for mut client in VpnClient::list_all(source_db).await.map_err(internal)? {
|
||||||
|
client.save(target_db).await.map_err(internal)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reset_postgres_sequences(db: &Database) -> cot::Result<()> {
|
||||||
|
for table in [
|
||||||
|
"cot__session",
|
||||||
|
"amnezia_fellow__user",
|
||||||
|
"amnezia_fellow__oidc_link",
|
||||||
|
"amnezia_fellow__vpn_client",
|
||||||
|
] {
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT setval(pg_get_serial_sequence('{table}', 'id'), \
|
||||||
|
COALESCE((SELECT MAX(id) FROM {table}), 1), \
|
||||||
|
(SELECT COUNT(*) > 0 FROM {table}))"
|
||||||
|
);
|
||||||
|
db.raw(&sql).await.map_err(internal)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_marker(db: &Database, source: &str) -> cot::Result<()> {
|
||||||
|
let mut marker = ConfigEntry::new(MARKER_KEY.to_owned(), now_unix_seconds().to_string());
|
||||||
|
marker.save(db).await.map_err(internal)?;
|
||||||
|
|
||||||
|
let mut source_entry = ConfigEntry::new(SOURCE_KEY.to_owned(), source.to_owned());
|
||||||
|
source_entry.save(db).await.map_err(internal)?;
|
||||||
|
|
||||||
|
let mut migrate_entry = ConfigEntry::new("migrate_sqlite".to_owned(), String::new());
|
||||||
|
migrate_entry.save(db).await.map_err(internal)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_unix_seconds() -> i64 {
|
||||||
|
let seconds = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_secs())
|
||||||
|
.unwrap_or_default();
|
||||||
|
i64::try_from(seconds).unwrap_or(i64::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn internal(error: impl std::fmt::Display) -> cot::Error {
|
||||||
|
cot::Error::internal(error.to_string())
|
||||||
|
}
|
||||||
+578
@@ -0,0 +1,578 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use cot::db::Database;
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::Sha256;
|
||||||
|
use url::form_urlencoded;
|
||||||
|
|
||||||
|
use crate::config::AppConfig;
|
||||||
|
use crate::user::User;
|
||||||
|
|
||||||
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
const BOT_POLL_TIMEOUT_SECONDS: u64 = 25;
|
||||||
|
const BOT_IDLE_SLEEP: Duration = Duration::from_secs(15);
|
||||||
|
const BOT_ERROR_SLEEP: Duration = Duration::from_secs(5);
|
||||||
|
pub const TELEGRAM_LINK_CODE_TTL: Duration = Duration::from_secs(15 * 60);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct TelegramWebAppUser {
|
||||||
|
pub id: i64,
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub first_name: Option<String>,
|
||||||
|
pub last_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum TelegramAuthError {
|
||||||
|
MissingHash,
|
||||||
|
MissingAuthDate,
|
||||||
|
MissingUser,
|
||||||
|
InvalidHash,
|
||||||
|
InvalidSignature,
|
||||||
|
InvalidAuthDate,
|
||||||
|
Expired,
|
||||||
|
FromFuture,
|
||||||
|
InvalidUser,
|
||||||
|
EmptyBotToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for TelegramAuthError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
let message = match self {
|
||||||
|
Self::MissingHash => "Telegram initData is missing hash",
|
||||||
|
Self::MissingAuthDate => "Telegram initData is missing auth_date",
|
||||||
|
Self::MissingUser => "Telegram initData is missing user",
|
||||||
|
Self::InvalidHash => "Telegram initData hash is invalid",
|
||||||
|
Self::InvalidSignature => "Telegram initData signature does not match bot token",
|
||||||
|
Self::InvalidAuthDate => "Telegram initData auth_date is invalid",
|
||||||
|
Self::Expired => "Telegram initData is too old",
|
||||||
|
Self::FromFuture => "Telegram initData auth_date is in the future",
|
||||||
|
Self::InvalidUser => "Telegram initData user payload is invalid",
|
||||||
|
Self::EmptyBotToken => "Telegram bot token is not configured",
|
||||||
|
};
|
||||||
|
f.write_str(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for TelegramAuthError {}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct TelegramApiResponse<T> {
|
||||||
|
ok: bool,
|
||||||
|
result: Option<T>,
|
||||||
|
description: Option<String>,
|
||||||
|
error_code: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct TelegramUpdate {
|
||||||
|
update_id: i64,
|
||||||
|
message: Option<TelegramMessage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct TelegramMessage {
|
||||||
|
chat: TelegramChat,
|
||||||
|
from: Option<TelegramBotUser>,
|
||||||
|
text: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct TelegramChat {
|
||||||
|
id: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct TelegramBotUser {
|
||||||
|
id: i64,
|
||||||
|
is_bot: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct GetUpdatesRequest {
|
||||||
|
offset: Option<i64>,
|
||||||
|
limit: u8,
|
||||||
|
timeout: u64,
|
||||||
|
allowed_updates: [&'static str; 1],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct DeleteWebhookRequest {
|
||||||
|
drop_pending_updates: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct SendMessageRequest<'a> {
|
||||||
|
chat_id: i64,
|
||||||
|
text: &'a str,
|
||||||
|
disable_web_page_preview: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn_bot_worker(config: Arc<AppConfig>) {
|
||||||
|
if config.database_url.trim().is_empty() {
|
||||||
|
tracing::warn!("Telegram bot worker disabled: database URL is empty");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let database_url = config.database_url.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
run_bot_worker(database_url).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_link_code() -> Result<String, String> {
|
||||||
|
let mut bytes = [0_u8; 12];
|
||||||
|
getrandom::fill(&mut bytes)
|
||||||
|
.map_err(|e| format!("failed to generate Telegram link code: {e}"))?;
|
||||||
|
Ok(format!("af-{}", hex::encode(bytes)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn now_unix_seconds() -> i64 {
|
||||||
|
let seconds = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_secs())
|
||||||
|
.unwrap_or_default();
|
||||||
|
i64::try_from(seconds).unwrap_or(i64::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn link_code_expires_at(created_at: i64) -> i64 {
|
||||||
|
let ttl = i64::try_from(TELEGRAM_LINK_CODE_TTL.as_secs()).unwrap_or(i64::MAX);
|
||||||
|
created_at.saturating_add(ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn link_code_is_active(created_at: Option<i64>) -> bool {
|
||||||
|
created_at
|
||||||
|
.map(|created_at| now_unix_seconds() <= link_code_expires_at(created_at))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_link_code(text: &str) -> Option<String> {
|
||||||
|
text.split_whitespace().find_map(|part| {
|
||||||
|
let candidate = part.trim_matches(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '-'));
|
||||||
|
let candidate = candidate.to_ascii_lowercase();
|
||||||
|
let suffix = candidate.strip_prefix("af-")?;
|
||||||
|
(suffix.len() == 24 && suffix.bytes().all(|byte| byte.is_ascii_hexdigit()))
|
||||||
|
.then_some(candidate)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_bot_worker(database_url: String) {
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(BOT_POLL_TIMEOUT_SECONDS + 10))
|
||||||
|
.build()
|
||||||
|
.expect("valid reqwest client");
|
||||||
|
let mut offset = None;
|
||||||
|
let mut active_token = String::new();
|
||||||
|
let mut webhook_deleted = false;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let db = match Database::new(database_url.clone()).await {
|
||||||
|
Ok(db) => db,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "Telegram bot worker could not open database");
|
||||||
|
tokio::time::sleep(BOT_ERROR_SLEEP).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
let token = config.telegram_bot_token.trim().to_owned();
|
||||||
|
if !config.telegram_bot_enabled || token.is_empty() {
|
||||||
|
offset = None;
|
||||||
|
active_token.clear();
|
||||||
|
webhook_deleted = false;
|
||||||
|
tokio::time::sleep(BOT_IDLE_SLEEP).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if active_token != token {
|
||||||
|
active_token = token.clone();
|
||||||
|
offset = None;
|
||||||
|
webhook_deleted = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !webhook_deleted {
|
||||||
|
match delete_webhook(&http, &token).await {
|
||||||
|
Ok(()) => webhook_deleted = true,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "Telegram bot worker could not delete webhook");
|
||||||
|
tokio::time::sleep(BOT_ERROR_SLEEP).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match get_updates(&http, &token, offset).await {
|
||||||
|
Ok(updates) => {
|
||||||
|
for update in updates {
|
||||||
|
offset = Some(update.update_id.saturating_add(1));
|
||||||
|
if let Err(e) = process_update(&db, &http, &token, update).await {
|
||||||
|
tracing::warn!(error = %e, "Telegram bot update was not processed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "Telegram bot polling failed");
|
||||||
|
tokio::time::sleep(BOT_ERROR_SLEEP).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_webhook(http: &reqwest::Client, token: &str) -> Result<(), String> {
|
||||||
|
let url = telegram_api_url(token, "deleteWebhook");
|
||||||
|
let response = http
|
||||||
|
.post(url)
|
||||||
|
.json(&DeleteWebhookRequest {
|
||||||
|
drop_pending_updates: false,
|
||||||
|
})
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(reqwest_error_message)?
|
||||||
|
.json::<TelegramApiResponse<bool>>()
|
||||||
|
.await
|
||||||
|
.map_err(reqwest_error_message)?;
|
||||||
|
telegram_api_result(response).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_updates(
|
||||||
|
http: &reqwest::Client,
|
||||||
|
token: &str,
|
||||||
|
offset: Option<i64>,
|
||||||
|
) -> Result<Vec<TelegramUpdate>, String> {
|
||||||
|
let url = telegram_api_url(token, "getUpdates");
|
||||||
|
let response = http
|
||||||
|
.post(url)
|
||||||
|
.json(&GetUpdatesRequest {
|
||||||
|
offset,
|
||||||
|
limit: 50,
|
||||||
|
timeout: BOT_POLL_TIMEOUT_SECONDS,
|
||||||
|
allowed_updates: ["message"],
|
||||||
|
})
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(reqwest_error_message)?
|
||||||
|
.json::<TelegramApiResponse<Vec<TelegramUpdate>>>()
|
||||||
|
.await
|
||||||
|
.map_err(reqwest_error_message)?;
|
||||||
|
telegram_api_result(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_message(
|
||||||
|
http: &reqwest::Client,
|
||||||
|
token: &str,
|
||||||
|
chat_id: i64,
|
||||||
|
text: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let url = telegram_api_url(token, "sendMessage");
|
||||||
|
let response = http
|
||||||
|
.post(url)
|
||||||
|
.json(&SendMessageRequest {
|
||||||
|
chat_id,
|
||||||
|
text,
|
||||||
|
disable_web_page_preview: true,
|
||||||
|
})
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(reqwest_error_message)?
|
||||||
|
.json::<TelegramApiResponse<serde_json::Value>>()
|
||||||
|
.await
|
||||||
|
.map_err(reqwest_error_message)?;
|
||||||
|
telegram_api_result(response).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn process_update(
|
||||||
|
db: &Database,
|
||||||
|
http: &reqwest::Client,
|
||||||
|
token: &str,
|
||||||
|
update: TelegramUpdate,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let Some(message) = update.message else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let Some(from) = message.from else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if from.is_bot {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(text) = message.text.as_deref() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if text.trim().starts_with("/start") {
|
||||||
|
send_message(
|
||||||
|
http,
|
||||||
|
token,
|
||||||
|
message.chat.id,
|
||||||
|
"Open the VPN portal, press the Telegram button and send me the secret code shown there.",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(code) = normalize_link_code(text) else {
|
||||||
|
send_message(
|
||||||
|
http,
|
||||||
|
token,
|
||||||
|
message.chat.id,
|
||||||
|
"I need the secret code from the VPN portal to connect your Telegram account.",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(mut user) = User::get_by_telegram_link_code(db, &code)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("failed to load Telegram link code: {e}"))?
|
||||||
|
else {
|
||||||
|
send_message(
|
||||||
|
http,
|
||||||
|
token,
|
||||||
|
message.chat.id,
|
||||||
|
"This code is unknown or already used. Generate a new code in the VPN portal.",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
if !link_code_is_active(user.telegram_link_code_created_at()) {
|
||||||
|
user.set_telegram_link_code(db, None, None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("failed to clear expired Telegram link code: {e}"))?;
|
||||||
|
send_message(
|
||||||
|
http,
|
||||||
|
token,
|
||||||
|
message.chat.id,
|
||||||
|
"This code has expired. Generate a new code in the VPN portal.",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let telegram_id = from.id.to_string();
|
||||||
|
if let Some(existing) = User::get_by_telegram_id(db, &telegram_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("failed to check Telegram ID: {e}"))?
|
||||||
|
{
|
||||||
|
if existing.id_val() != user.id_val() {
|
||||||
|
send_message(
|
||||||
|
http,
|
||||||
|
token,
|
||||||
|
message.chat.id,
|
||||||
|
"This Telegram account is already connected to another VPN account.",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
user.complete_telegram_link(db, &telegram_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("failed to save Telegram ID: {e}"))?;
|
||||||
|
send_message(
|
||||||
|
http,
|
||||||
|
token,
|
||||||
|
message.chat.id,
|
||||||
|
"Telegram connected. You can return to the VPN portal.",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn telegram_api_url(token: &str, method: &str) -> String {
|
||||||
|
format!("https://api.telegram.org/bot{token}/{method}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn telegram_api_result<T>(response: TelegramApiResponse<T>) -> Result<T, String> {
|
||||||
|
if response.ok {
|
||||||
|
response
|
||||||
|
.result
|
||||||
|
.ok_or_else(|| "Telegram API response did not include result".to_owned())
|
||||||
|
} else {
|
||||||
|
Err(match (response.error_code, response.description) {
|
||||||
|
(Some(code), Some(description)) => format!("Telegram API {code}: {description}"),
|
||||||
|
(_, Some(description)) => description,
|
||||||
|
(Some(code), None) => format!("Telegram API error {code}"),
|
||||||
|
(None, None) => "Telegram API error".to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reqwest_error_message(error: reqwest::Error) -> String {
|
||||||
|
if error.is_timeout() {
|
||||||
|
"request timed out".to_owned()
|
||||||
|
} else if error.is_connect() {
|
||||||
|
"connection failed".to_owned()
|
||||||
|
} else if let Some(status) = error.status() {
|
||||||
|
format!("HTTP {status}")
|
||||||
|
} else {
|
||||||
|
"request failed".to_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_web_app_init_data(
|
||||||
|
init_data: &str,
|
||||||
|
bot_token: &str,
|
||||||
|
max_age: Duration,
|
||||||
|
) -> Result<TelegramWebAppUser, TelegramAuthError> {
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map_err(|_| TelegramAuthError::InvalidAuthDate)?
|
||||||
|
.as_secs();
|
||||||
|
validate_web_app_init_data_at(init_data, bot_token, max_age, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_web_app_init_data_at(
|
||||||
|
init_data: &str,
|
||||||
|
bot_token: &str,
|
||||||
|
max_age: Duration,
|
||||||
|
now: u64,
|
||||||
|
) -> Result<TelegramWebAppUser, TelegramAuthError> {
|
||||||
|
if bot_token.trim().is_empty() {
|
||||||
|
return Err(TelegramAuthError::EmptyBotToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut params = form_urlencoded::parse(init_data.as_bytes())
|
||||||
|
.into_owned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let hash = params
|
||||||
|
.iter()
|
||||||
|
.find_map(|(key, value)| (key == "hash").then_some(value.as_str()))
|
||||||
|
.ok_or(TelegramAuthError::MissingHash)?;
|
||||||
|
let expected_hash = hex::decode(hash).map_err(|_| TelegramAuthError::InvalidHash)?;
|
||||||
|
|
||||||
|
params.retain(|(key, _)| key != "hash");
|
||||||
|
params.sort_by(|left, right| left.0.cmp(&right.0));
|
||||||
|
let data_check_string = params
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| format!("{key}={value}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
verify_signature(bot_token, &data_check_string, &expected_hash)?;
|
||||||
|
|
||||||
|
let auth_date = params
|
||||||
|
.iter()
|
||||||
|
.find_map(|(key, value)| (key == "auth_date").then_some(value.as_str()))
|
||||||
|
.ok_or(TelegramAuthError::MissingAuthDate)?
|
||||||
|
.parse::<u64>()
|
||||||
|
.map_err(|_| TelegramAuthError::InvalidAuthDate)?;
|
||||||
|
if auth_date > now.saturating_add(300) {
|
||||||
|
return Err(TelegramAuthError::FromFuture);
|
||||||
|
}
|
||||||
|
if now.saturating_sub(auth_date) > max_age.as_secs() {
|
||||||
|
return Err(TelegramAuthError::Expired);
|
||||||
|
}
|
||||||
|
|
||||||
|
let user_json = params
|
||||||
|
.iter()
|
||||||
|
.find_map(|(key, value)| (key == "user").then_some(value.as_str()))
|
||||||
|
.ok_or(TelegramAuthError::MissingUser)?;
|
||||||
|
serde_json::from_str(user_json).map_err(|_| TelegramAuthError::InvalidUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_signature(
|
||||||
|
bot_token: &str,
|
||||||
|
data_check_string: &str,
|
||||||
|
expected_hash: &[u8],
|
||||||
|
) -> Result<(), TelegramAuthError> {
|
||||||
|
let mut secret_mac =
|
||||||
|
HmacSha256::new_from_slice(b"WebAppData").expect("HMAC accepts any key length");
|
||||||
|
secret_mac.update(bot_token.as_bytes());
|
||||||
|
let secret_key = secret_mac.finalize().into_bytes();
|
||||||
|
|
||||||
|
let mut data_mac =
|
||||||
|
HmacSha256::new_from_slice(&secret_key).expect("HMAC accepts any key length");
|
||||||
|
data_mac.update(data_check_string.as_bytes());
|
||||||
|
data_mac
|
||||||
|
.verify_slice(expected_hash)
|
||||||
|
.map_err(|_| TelegramAuthError::InvalidSignature)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn signed_init_data(bot_token: &str, auth_date: u64, user_json: &str) -> String {
|
||||||
|
let data_check_string =
|
||||||
|
format!("auth_date={auth_date}\nquery_id=AAEAAAE\nuser={user_json}");
|
||||||
|
|
||||||
|
let mut secret_mac =
|
||||||
|
HmacSha256::new_from_slice(b"WebAppData").expect("HMAC accepts any key length");
|
||||||
|
secret_mac.update(bot_token.as_bytes());
|
||||||
|
let secret_key = secret_mac.finalize().into_bytes();
|
||||||
|
|
||||||
|
let mut data_mac =
|
||||||
|
HmacSha256::new_from_slice(&secret_key).expect("HMAC accepts any key length");
|
||||||
|
data_mac.update(data_check_string.as_bytes());
|
||||||
|
let hash = hex::encode(data_mac.finalize().into_bytes());
|
||||||
|
|
||||||
|
let mut serializer = form_urlencoded::Serializer::new(String::new());
|
||||||
|
serializer.append_pair("query_id", "AAEAAAE");
|
||||||
|
serializer.append_pair("user", user_json);
|
||||||
|
serializer.append_pair("auth_date", &auth_date.to_string());
|
||||||
|
serializer.append_pair("hash", &hash);
|
||||||
|
serializer.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_signed_web_app_init_data() {
|
||||||
|
let token = "123456:fake-token";
|
||||||
|
let init_data = signed_init_data(
|
||||||
|
token,
|
||||||
|
1_700_000_000,
|
||||||
|
r#"{"id":42,"first_name":"Alice","username":"alice"}"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let user = validate_web_app_init_data_at(
|
||||||
|
&init_data,
|
||||||
|
token,
|
||||||
|
Duration::from_secs(86_400),
|
||||||
|
1_700_000_001,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(user.id, 42);
|
||||||
|
assert_eq!(user.username.as_deref(), Some("alice"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_wrong_bot_token() {
|
||||||
|
let init_data = signed_init_data(
|
||||||
|
"123456:fake-token",
|
||||||
|
1_700_000_000,
|
||||||
|
r#"{"id":42,"first_name":"Alice"}"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let err = validate_web_app_init_data_at(
|
||||||
|
&init_data,
|
||||||
|
"123456:other-token",
|
||||||
|
Duration::from_secs(86_400),
|
||||||
|
1_700_000_001,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(err, TelegramAuthError::InvalidSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_expired_init_data() {
|
||||||
|
let token = "123456:fake-token";
|
||||||
|
let init_data = signed_init_data(token, 1_700_000_000, r#"{"id":42}"#);
|
||||||
|
|
||||||
|
let err = validate_web_app_init_data_at(
|
||||||
|
&init_data,
|
||||||
|
token,
|
||||||
|
Duration::from_secs(60),
|
||||||
|
1_700_000_061,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(err, TelegramAuthError::Expired);
|
||||||
|
}
|
||||||
|
}
|
||||||
+174
@@ -17,6 +17,9 @@ pub struct User {
|
|||||||
email: Option<String>,
|
email: Option<String>,
|
||||||
display_name: Option<String>,
|
display_name: Option<String>,
|
||||||
avatar_url: Option<String>,
|
avatar_url: Option<String>,
|
||||||
|
telegram_id: Option<String>,
|
||||||
|
telegram_link_code: Option<String>,
|
||||||
|
telegram_link_code_created_at: Option<i64>,
|
||||||
role: LimitedString<32>,
|
role: LimitedString<32>,
|
||||||
is_active: bool,
|
is_active: bool,
|
||||||
}
|
}
|
||||||
@@ -53,6 +56,9 @@ impl User {
|
|||||||
email: email.map(str::to_owned),
|
email: email.map(str::to_owned),
|
||||||
display_name: display_name.map(str::to_owned),
|
display_name: display_name.map(str::to_owned),
|
||||||
avatar_url: None,
|
avatar_url: None,
|
||||||
|
telegram_id: None,
|
||||||
|
telegram_link_code: None,
|
||||||
|
telegram_link_code_created_at: None,
|
||||||
role: LimitedString::new(role).unwrap(),
|
role: LimitedString::new(role).unwrap(),
|
||||||
is_active: true,
|
is_active: true,
|
||||||
};
|
};
|
||||||
@@ -75,6 +81,9 @@ impl User {
|
|||||||
email: email.map(str::to_owned),
|
email: email.map(str::to_owned),
|
||||||
display_name: display_name.map(str::to_owned),
|
display_name: display_name.map(str::to_owned),
|
||||||
avatar_url: None,
|
avatar_url: None,
|
||||||
|
telegram_id: None,
|
||||||
|
telegram_link_code: None,
|
||||||
|
telegram_link_code_created_at: None,
|
||||||
role: LimitedString::new(role).unwrap(),
|
role: LimitedString::new(role).unwrap(),
|
||||||
is_active: true,
|
is_active: true,
|
||||||
};
|
};
|
||||||
@@ -117,6 +126,36 @@ impl User {
|
|||||||
cot::db::query!(User, $email == Some(email)).get(db).await
|
cot::db::query!(User, $email == Some(email)).get(db).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Find a user linked to a non-empty Telegram ID.
|
||||||
|
pub async fn get_by_telegram_id(
|
||||||
|
db: &Database,
|
||||||
|
telegram_id: &str,
|
||||||
|
) -> cot::db::Result<Option<Self>> {
|
||||||
|
let telegram_id = telegram_id.trim();
|
||||||
|
if telegram_id.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let telegram_id = telegram_id.to_owned();
|
||||||
|
cot::db::query!(User, $telegram_id == Some(telegram_id))
|
||||||
|
.get(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find a user waiting for this Telegram link code.
|
||||||
|
pub async fn get_by_telegram_link_code(
|
||||||
|
db: &Database,
|
||||||
|
code: &str,
|
||||||
|
) -> cot::db::Result<Option<Self>> {
|
||||||
|
let code = code.trim();
|
||||||
|
if code.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let code = code.to_owned();
|
||||||
|
cot::db::query!(User, $telegram_link_code == Some(code))
|
||||||
|
.get(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
/// Count all users in the database.
|
/// Count all users in the database.
|
||||||
pub async fn count_all(db: &Database) -> cot::db::Result<u64> {
|
pub async fn count_all(db: &Database) -> cot::db::Result<u64> {
|
||||||
Self::objects().count(db).await
|
Self::objects().count(db).await
|
||||||
@@ -140,6 +179,56 @@ impl User {
|
|||||||
self.save(db).await
|
self.save(db).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Store a Telegram link state. `Some("")` means the user declined linking.
|
||||||
|
pub async fn set_telegram_id(
|
||||||
|
&mut self,
|
||||||
|
db: &Database,
|
||||||
|
telegram_id: Option<&str>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
self.telegram_id = telegram_id.map(str::to_owned);
|
||||||
|
self.save(db).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store or clear a pending Telegram link code.
|
||||||
|
pub async fn set_telegram_link_code(
|
||||||
|
&mut self,
|
||||||
|
db: &Database,
|
||||||
|
code: Option<&str>,
|
||||||
|
created_at: Option<i64>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
self.telegram_link_code = code.map(str::to_owned);
|
||||||
|
self.telegram_link_code_created_at = code.and(created_at);
|
||||||
|
self.save(db).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete Telegram linking and clear any pending code.
|
||||||
|
pub async fn complete_telegram_link(
|
||||||
|
&mut self,
|
||||||
|
db: &Database,
|
||||||
|
telegram_id: &str,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
self.telegram_id = Some(telegram_id.to_owned());
|
||||||
|
self.telegram_link_code = None;
|
||||||
|
self.telegram_link_code_created_at = None;
|
||||||
|
self.save(db).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove Telegram credentials and pending link data.
|
||||||
|
pub async fn clear_telegram_link(&mut self, db: &Database) -> cot::db::Result<()> {
|
||||||
|
self.telegram_id = None;
|
||||||
|
self.telegram_link_code = None;
|
||||||
|
self.telegram_link_code_created_at = None;
|
||||||
|
self.save(db).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store an explicit opt-out and clear pending link data.
|
||||||
|
pub async fn decline_telegram_link(&mut self, db: &Database) -> cot::db::Result<()> {
|
||||||
|
self.telegram_id = Some(String::new());
|
||||||
|
self.telegram_link_code = None;
|
||||||
|
self.telegram_link_code_created_at = None;
|
||||||
|
self.save(db).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Delete this user by primary key.
|
/// Delete this user by primary key.
|
||||||
pub async fn delete_by_id(db: &Database, user_id: i64) -> cot::db::Result<()> {
|
pub async fn delete_by_id(db: &Database, user_id: i64) -> cot::db::Result<()> {
|
||||||
cot::db::query!(User, $id == Auto::Fixed(user_id))
|
cot::db::query!(User, $id == Auto::Fixed(user_id))
|
||||||
@@ -165,6 +254,18 @@ impl User {
|
|||||||
self.display_name.clone().unwrap_or_default()
|
self.display_name.clone().unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn telegram_id(&self) -> Option<&str> {
|
||||||
|
self.telegram_id.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn telegram_link_code(&self) -> Option<&str> {
|
||||||
|
self.telegram_link_code.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn telegram_link_code_created_at(&self) -> Option<i64> {
|
||||||
|
self.telegram_link_code_created_at
|
||||||
|
}
|
||||||
|
|
||||||
pub fn role_str(&self) -> &str {
|
pub fn role_str(&self) -> &str {
|
||||||
&self.role
|
&self.role
|
||||||
}
|
}
|
||||||
@@ -391,9 +492,82 @@ pub mod db_migrations {
|
|||||||
&[Operation::custom(create_oidc_link_indexes).build()];
|
&[Operation::custom(create_oidc_link_indexes).build()];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- M0007: Telegram link state on amnezia_fellow__user ----------------
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn add_user_telegram_id(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw("ALTER TABLE amnezia_fellow__user ADD COLUMN telegram_id TEXT")
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE UNIQUE INDEX idx_amnezia_fellow_user_telegram_id \
|
||||||
|
ON amnezia_fellow__user (telegram_id) \
|
||||||
|
WHERE telegram_id IS NOT NULL AND telegram_id != ''",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0007UserTelegramId;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0007UserTelegramId {
|
||||||
|
const APP_NAME: &'static str = "amnezia_fellow";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0007_user_telegram_id";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"amnezia_fellow",
|
||||||
|
"m_0004_oidc_link_indexes",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] = &[Operation::custom(add_user_telegram_id).build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- M0008: pending Telegram link code on amnezia_fellow__user ---------
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn add_user_telegram_link_code(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw("ALTER TABLE amnezia_fellow__user ADD COLUMN telegram_link_code TEXT")
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"ALTER TABLE amnezia_fellow__user \
|
||||||
|
ADD COLUMN telegram_link_code_created_at INTEGER",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE UNIQUE INDEX idx_amnezia_fellow_user_telegram_link_code \
|
||||||
|
ON amnezia_fellow__user (telegram_link_code) \
|
||||||
|
WHERE telegram_link_code IS NOT NULL AND telegram_link_code != ''",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0008UserTelegramLinkCode;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0008UserTelegramLinkCode {
|
||||||
|
const APP_NAME: &'static str = "amnezia_fellow";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0008_user_telegram_link_code";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"amnezia_fellow",
|
||||||
|
"m_0007_user_telegram_id",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(add_user_telegram_link_code).build()];
|
||||||
|
}
|
||||||
|
|
||||||
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
||||||
&M0002CreateUser,
|
&M0002CreateUser,
|
||||||
&M0003CreateOidcLink,
|
&M0003CreateOidcLink,
|
||||||
&M0004OidcLinkIndexes,
|
&M0004OidcLinkIndexes,
|
||||||
|
&M0007UserTelegramId,
|
||||||
|
&M0008UserTelegramLinkCode,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+347
-14
@@ -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)?;
|
||||||
@@ -95,18 +98,15 @@ impl VpnClient {
|
|||||||
.await
|
.await
|
||||||
.map_err(db_custom_error)?;
|
.map_err(db_custom_error)?;
|
||||||
let now = now_timestamp();
|
let now = now_timestamp();
|
||||||
let name = if name.trim().is_empty() {
|
let name = client_name_for_storage(name)?;
|
||||||
"Amnezia client"
|
|
||||||
} else {
|
|
||||||
name.trim()
|
|
||||||
};
|
|
||||||
let mut client = Self {
|
let mut client = Self {
|
||||||
id: Auto::auto(),
|
id: Auto::auto(),
|
||||||
owner_user_id,
|
owner_user_id,
|
||||||
name: LimitedString::new(name).unwrap(),
|
name,
|
||||||
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(),
|
||||||
@@ -122,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)
|
||||||
@@ -138,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(),
|
||||||
@@ -171,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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -207,7 +231,7 @@ pub fn render_peer_secret(clients: &[VpnClient]) -> String {
|
|||||||
"# id={} owner={} name={}\n",
|
"# id={} owner={} name={}\n",
|
||||||
client.id_val(),
|
client.id_val(),
|
||||||
client.owner_user_id(),
|
client.owner_user_id(),
|
||||||
client.name_str()
|
escaped_peer_comment_value(client.name_str())
|
||||||
));
|
));
|
||||||
out.push_str(&format!("PublicKey = {}\n", client.public_key_str()));
|
out.push_str(&format!("PublicKey = {}\n", client.public_key_str()));
|
||||||
out.push_str(&format!("AllowedIPs = {}/32\n", client.address_str()));
|
out.push_str(&format!("AllowedIPs = {}/32\n", client.address_str()));
|
||||||
@@ -215,6 +239,78 @@ 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>> {
|
||||||
|
let name = if name.is_empty() {
|
||||||
|
"Amnezia client"
|
||||||
|
} else {
|
||||||
|
name
|
||||||
|
};
|
||||||
|
LimitedString::new(name.to_owned())
|
||||||
|
.map_err(|e| db_custom_error(format!("client name is too long: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn 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 {
|
||||||
|
let mut escaped = String::with_capacity(value.len() + 2);
|
||||||
|
escaped.push('"');
|
||||||
|
for ch in value.chars() {
|
||||||
|
escaped.extend(ch.escape_default());
|
||||||
|
}
|
||||||
|
escaped.push('"');
|
||||||
|
escaped
|
||||||
|
}
|
||||||
|
|
||||||
pub fn render_client_config(
|
pub fn render_client_config(
|
||||||
client: &VpnClient,
|
client: &VpnClient,
|
||||||
server_public_key: &str,
|
server_public_key: &str,
|
||||||
@@ -399,6 +495,12 @@ fn qcompress(data: &[u8]) -> Result<Vec<u8>, String> {
|
|||||||
|
|
||||||
const CLIENT_SECRET_UPDATED_AT_ANNOTATION: &str =
|
const CLIENT_SECRET_UPDATED_AT_ANNOTATION: &str =
|
||||||
"amnezia-fellow.hexor.cy/client-secret-updated-at-ms";
|
"amnezia-fellow.hexor.cy/client-secret-updated-at-ms";
|
||||||
|
const CLIENT_SECRET_APPLIED_AT_ANNOTATION: &str =
|
||||||
|
"amnezia-fellow.hexor.cy/client-secret-applied-at-ms";
|
||||||
|
const CLIENT_SECRET_APPLIED_HASH_ANNOTATION: &str =
|
||||||
|
"amnezia-fellow.hexor.cy/client-secret-applied-hash";
|
||||||
|
const CLIENT_SECRET_RELOAD_STATUS_ANNOTATION: &str =
|
||||||
|
"amnezia-fellow.hexor.cy/client-secret-reload-status";
|
||||||
const AMNEZIAWG_POD_LABEL_SELECTOR: &str = "app=amneziawg";
|
const AMNEZIAWG_POD_LABEL_SELECTOR: &str = "app=amneziawg";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||||
@@ -410,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
|
||||||
@@ -417,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(),
|
||||||
@@ -431,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)
|
||||||
@@ -444,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()),
|
||||||
@@ -476,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) {
|
||||||
@@ -613,6 +730,9 @@ pub struct VpnPodRolloutStatus {
|
|||||||
pub ready: bool,
|
pub ready: bool,
|
||||||
pub restart_count: i32,
|
pub restart_count: i32,
|
||||||
pub started_at_ms: Option<i64>,
|
pub started_at_ms: Option<i64>,
|
||||||
|
pub config_applied_at_ms: Option<i64>,
|
||||||
|
pub config_applied_hash: Option<String>,
|
||||||
|
pub config_reload_status: Option<String>,
|
||||||
pub uptime_seconds: Option<u64>,
|
pub uptime_seconds: Option<u64>,
|
||||||
pub rollout_status: String,
|
pub rollout_status: String,
|
||||||
pub message: Option<String>,
|
pub message: Option<String>,
|
||||||
@@ -705,6 +825,7 @@ fn pod_rollout_status(
|
|||||||
config_updated_at_ms: Option<i64>,
|
config_updated_at_ms: Option<i64>,
|
||||||
now_ms: i64,
|
now_ms: i64,
|
||||||
) -> VpnPodRolloutStatus {
|
) -> VpnPodRolloutStatus {
|
||||||
|
let annotations = pod.metadata.annotations.clone().unwrap_or_default();
|
||||||
let name = pod.metadata.name.unwrap_or_default();
|
let name = pod.metadata.name.unwrap_or_default();
|
||||||
let node_name = pod
|
let node_name = pod
|
||||||
.spec
|
.spec
|
||||||
@@ -728,11 +849,26 @@ fn pod_rollout_status(
|
|||||||
let started_at_ms = status
|
let started_at_ms = status
|
||||||
.and_then(|status| status.start_time.as_ref())
|
.and_then(|status| status.start_time.as_ref())
|
||||||
.map(time_to_millis);
|
.map(time_to_millis);
|
||||||
|
let config_applied_at_ms = annotation_i64(&annotations, CLIENT_SECRET_APPLIED_AT_ANNOTATION);
|
||||||
|
let config_applied_hash = annotations
|
||||||
|
.get(CLIENT_SECRET_APPLIED_HASH_ANNOTATION)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.cloned();
|
||||||
|
let config_reload_status = annotations
|
||||||
|
.get(CLIENT_SECRET_RELOAD_STATUS_ANNOTATION)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.cloned();
|
||||||
let uptime_seconds = started_at_ms.map(|started| {
|
let uptime_seconds = started_at_ms.map(|started| {
|
||||||
let elapsed_ms = now_ms.saturating_sub(started);
|
let elapsed_ms = now_ms.saturating_sub(started);
|
||||||
(elapsed_ms / 1000) as u64
|
(elapsed_ms / 1000) as u64
|
||||||
});
|
});
|
||||||
let rollout_status = rollout_status(config_updated_at_ms, started_at_ms, ready);
|
let rollout_status = rollout_status(
|
||||||
|
config_updated_at_ms,
|
||||||
|
started_at_ms,
|
||||||
|
config_applied_at_ms,
|
||||||
|
config_reload_status.as_deref(),
|
||||||
|
ready,
|
||||||
|
);
|
||||||
let message = status.and_then(|status| status.message.clone().or(status.reason.clone()));
|
let message = status.and_then(|status| status.message.clone().or(status.reason.clone()));
|
||||||
|
|
||||||
VpnPodRolloutStatus {
|
VpnPodRolloutStatus {
|
||||||
@@ -744,6 +880,9 @@ fn pod_rollout_status(
|
|||||||
ready,
|
ready,
|
||||||
restart_count,
|
restart_count,
|
||||||
started_at_ms,
|
started_at_ms,
|
||||||
|
config_applied_at_ms,
|
||||||
|
config_applied_hash,
|
||||||
|
config_reload_status,
|
||||||
uptime_seconds,
|
uptime_seconds,
|
||||||
rollout_status,
|
rollout_status,
|
||||||
message,
|
message,
|
||||||
@@ -781,14 +920,21 @@ fn amneziawg_restart_count(status: &k8s_openapi::api::core::v1::PodStatus) -> i3
|
|||||||
fn rollout_status(
|
fn rollout_status(
|
||||||
config_updated_at_ms: Option<i64>,
|
config_updated_at_ms: Option<i64>,
|
||||||
started_at_ms: Option<i64>,
|
started_at_ms: Option<i64>,
|
||||||
|
config_applied_at_ms: Option<i64>,
|
||||||
|
config_reload_status: Option<&str>,
|
||||||
ready: bool,
|
ready: bool,
|
||||||
) -> String {
|
) -> String {
|
||||||
match (config_updated_at_ms, started_at_ms) {
|
if config_reload_status == Some("error") {
|
||||||
|
return "error".to_owned();
|
||||||
|
}
|
||||||
|
|
||||||
|
let effective_applied_at_ms = started_at_ms.into_iter().chain(config_applied_at_ms).max();
|
||||||
|
match (config_updated_at_ms, effective_applied_at_ms) {
|
||||||
(None, _) => "unknown".to_owned(),
|
(None, _) => "unknown".to_owned(),
|
||||||
(Some(_), None) => "pending_restart".to_owned(),
|
(Some(_), None) => "pending_apply".to_owned(),
|
||||||
(Some(updated), Some(started)) if started >= updated && ready => "applied".to_owned(),
|
(Some(updated), Some(started)) if started >= updated && ready => "applied".to_owned(),
|
||||||
(Some(updated), Some(started)) if started >= updated => "starting".to_owned(),
|
(Some(updated), Some(started)) if started >= updated => "starting".to_owned(),
|
||||||
(Some(_), Some(_)) => "pending_restart".to_owned(),
|
(Some(_), Some(_)) => "pending_apply".to_owned(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -802,6 +948,10 @@ fn client_secret_updated_at_ms(secret: &Secret) -> Option<i64> {
|
|||||||
.ok()
|
.ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn annotation_i64(annotations: &BTreeMap<String, String>, key: &str) -> Option<i64> {
|
||||||
|
annotations.get(key)?.parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
fn time_to_millis(time: &Time) -> i64 {
|
fn time_to_millis(time: &Time) -> i64 {
|
||||||
time.0.as_millisecond()
|
time.0.as_millisecond()
|
||||||
}
|
}
|
||||||
@@ -865,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())
|
||||||
}
|
}
|
||||||
@@ -976,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();
|
||||||
@@ -997,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!(
|
||||||
@@ -1023,6 +1309,36 @@ mod tests {
|
|||||||
assert_eq!(vpn_url_description(" ", "phone"), "phone");
|
assert_eq!(vpn_url_description(" ", "phone"), "phone");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn client_name_for_storage_preserves_weird_names() {
|
||||||
|
let raw = r#" витя хуй !! "" 233; 3№№## ''' @ "#;
|
||||||
|
let stored = client_name_for_storage(raw).unwrap();
|
||||||
|
assert_eq!(stored.to_string(), raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn peer_secret_escapes_client_name_comment() {
|
||||||
|
let raw = "витя хуй !! \"\" 233; 3№№## ''' @\nAllowedIPs = 0.0.0.0/0";
|
||||||
|
let client = VpnClient {
|
||||||
|
id: Auto::Fixed(7),
|
||||||
|
owner_user_id: 1,
|
||||||
|
name: LimitedString::new(raw).unwrap(),
|
||||||
|
address: LimitedString::new("10.8.0.2").unwrap(),
|
||||||
|
public_key: LimitedString::new("client-public-key").unwrap(),
|
||||||
|
private_key: LimitedString::new("client-private-key").unwrap(),
|
||||||
|
group_name: None,
|
||||||
|
enabled: true,
|
||||||
|
created_at: LimitedString::new("2026-06-16T00:00:00Z").unwrap(),
|
||||||
|
updated_at: LimitedString::new("2026-06-16T00:00:00Z").unwrap(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let rendered = render_peer_secret(&[client]);
|
||||||
|
assert!(rendered.contains(r#"name="\u{432}\u{438}\u{442}\u{44f} "#));
|
||||||
|
assert!(rendered.contains(r#"\nAllowedIPs = 0.0.0.0/0""#));
|
||||||
|
assert!(!rendered.contains("\nAllowedIPs = 0.0.0.0/0\n"));
|
||||||
|
assert_eq!(rendered.matches("[Peer]").count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn endpoint_name_overrides_parse_json_map() {
|
fn endpoint_name_overrides_parse_json_map() {
|
||||||
let config = AppConfig {
|
let config = AppConfig {
|
||||||
@@ -1037,6 +1353,22 @@ mod tests {
|
|||||||
assert!(!overrides.contains_key("empty"));
|
assert!(!overrides.contains_key("empty"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rollout_status_uses_hot_reload_annotations() {
|
||||||
|
assert_eq!(
|
||||||
|
rollout_status(Some(2_000), Some(1_000), Some(3_000), Some("applied"), true),
|
||||||
|
"applied"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rollout_status(Some(2_000), Some(1_000), None, Some("error"), true),
|
||||||
|
"error"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rollout_status(Some(2_000), Some(1_000), None, None, true),
|
||||||
|
"pending_apply"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn qcompress_uses_qt_wire_format() {
|
fn qcompress_uses_qt_wire_format() {
|
||||||
let payload = br#"{"description":"de-fsn1"}"#;
|
let payload = br#"{"description":"de-fsn1"}"#;
|
||||||
@@ -1060,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(),
|
||||||
|
|||||||
+37
-16
@@ -47,6 +47,9 @@
|
|||||||
.section-tabs button.active { background: #17202a; color: #fff; border-color: #17202a; }
|
.section-tabs button.active { background: #17202a; color: #fff; border-color: #17202a; }
|
||||||
.settings-grid { display: grid; grid-template-columns: minmax(180px, .7fr) minmax(240px, 1fr) auto; gap: .55rem .75rem; align-items: center; background: #fff; border: 1px solid #dde2e6; border-radius: 6px; padding: .75rem; }
|
.settings-grid { display: grid; grid-template-columns: minmax(180px, .7fr) minmax(240px, 1fr) auto; gap: .55rem .75rem; align-items: center; background: #fff; border: 1px solid #dde2e6; border-radius: 6px; padding: .75rem; }
|
||||||
.settings-row { display: contents; }
|
.settings-row { display: contents; }
|
||||||
|
.settings-info-row { grid-column: 1 / -1; border: 1px solid #dde2e6; border-left: 4px solid #2d6cdf; border-radius: 6px; background: #f8fafc; padding: .75rem; display: grid; gap: .45rem; }
|
||||||
|
.settings-info-title { color: #1d2733; font-weight: 750; }
|
||||||
|
.settings-info-text { margin: 0; color: #34414f; white-space: pre-wrap; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .84rem; line-height: 1.45; }
|
||||||
.field-name { font-weight: 650; color: #34414f; overflow-wrap: anywhere; }
|
.field-name { font-weight: 650; color: #34414f; overflow-wrap: anywhere; }
|
||||||
.field-help { color: #53606d; font-size: .78rem; margin-top: .15rem; overflow-wrap: anywhere; }
|
.field-help { color: #53606d; font-size: .78rem; margin-top: .15rem; overflow-wrap: anywhere; }
|
||||||
.badge { display: inline-block; padding: .15rem .55rem; border-radius: 4px; font-size: .8rem; font-weight: 650; }
|
.badge { display: inline-block; padding: .15rem .55rem; border-radius: 4px; font-size: .8rem; font-weight: 650; }
|
||||||
@@ -191,21 +194,33 @@
|
|||||||
<template x-if="settings.fields.length > 0">
|
<template x-if="settings.fields.length > 0">
|
||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
<template x-for="field in visibleSettings()" :key="field.key">
|
<template x-for="field in visibleSettings()" :key="field.key">
|
||||||
<div class="settings-row">
|
<div :class="field.kind === 'info' ? 'settings-info-row' : 'settings-row'">
|
||||||
<div class="field-name">
|
<template x-if="field.kind === 'info'">
|
||||||
<span x-text="fieldLabel(field.key)"></span>
|
<div>
|
||||||
<div class="field-help" x-text="field.env_var"></div>
|
<div class="settings-info-title" x-text="fieldLabel(field.key)"></div>
|
||||||
</div>
|
<pre class="settings-info-text" x-text="field.value"></pre>
|
||||||
<div>
|
</div>
|
||||||
<template x-if="field.kind === 'bool'">
|
</template>
|
||||||
<input type="checkbox" x-model="settingsForm[field.key]">
|
<template x-if="field.kind !== 'info'">
|
||||||
</template>
|
<div class="field-name">
|
||||||
<template x-if="field.kind !== 'bool'">
|
<span x-text="fieldLabel(field.key)"></span>
|
||||||
<input :type="field.kind === 'password' ? 'password' : field.kind" x-model="settingsForm[field.key]">
|
<div class="field-help" x-text="field.env_var"></div>
|
||||||
</template>
|
</div>
|
||||||
<div class="hint">default: <code x-text="field.default_value || '(empty)'"></code></div>
|
</template>
|
||||||
</div>
|
<template x-if="field.kind !== 'info'">
|
||||||
<span class="badge" :class="`badge-${field.source}`" x-text="field.source"></span>
|
<div>
|
||||||
|
<template x-if="field.kind === 'bool'">
|
||||||
|
<input type="checkbox" x-model="settingsForm[field.key]">
|
||||||
|
</template>
|
||||||
|
<template x-if="field.kind !== 'bool'">
|
||||||
|
<input :type="field.kind === 'password' ? 'password' : field.kind" x-model="settingsForm[field.key]">
|
||||||
|
</template>
|
||||||
|
<div class="hint">default: <code x-text="field.default_value || '(empty)'"></code></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="field.kind !== 'info'">
|
||||||
|
<span class="badge" :class="`badge-${field.source}`" x-text="field.source"></span>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -316,7 +331,7 @@ function adminApp(initialView) {
|
|||||||
settings: { fields: [] },
|
settings: { fields: [] },
|
||||||
settingsForm: {},
|
settingsForm: {},
|
||||||
settingsSection: 'auth',
|
settingsSection: 'auth',
|
||||||
settingSections: ['auth', 'oidc', 'kubernetes', 'vpn', 'api'],
|
settingSections: ['auth', 'oidc', 'kubernetes', 'vpn', 'api', 'telegram'],
|
||||||
debug: null,
|
debug: null,
|
||||||
userModalOpen: false,
|
userModalOpen: false,
|
||||||
userForm: {},
|
userForm: {},
|
||||||
@@ -401,6 +416,7 @@ function adminApp(initialView) {
|
|||||||
this.settings = await this.request('/admin/api/settings');
|
this.settings = await this.request('/admin/api/settings');
|
||||||
const form = {};
|
const form = {};
|
||||||
for (const field of this.settings.fields) {
|
for (const field of this.settings.fields) {
|
||||||
|
if (field.kind === 'info') continue;
|
||||||
form[field.key] = field.kind === 'bool' ? field.value === 'true' : field.value;
|
form[field.key] = field.kind === 'bool' ? field.value === 'true' : field.value;
|
||||||
}
|
}
|
||||||
this.settingsForm = form;
|
this.settingsForm = form;
|
||||||
@@ -491,6 +507,7 @@ function adminApp(initialView) {
|
|||||||
kubernetes: '{{ t.settings_kubernetes }}',
|
kubernetes: '{{ t.settings_kubernetes }}',
|
||||||
vpn: '{{ t.settings_vpn }}',
|
vpn: '{{ t.settings_vpn }}',
|
||||||
api: '{{ t.settings_api }}',
|
api: '{{ t.settings_api }}',
|
||||||
|
telegram: '{{ t.settings_telegram }}',
|
||||||
}[section] || section;
|
}[section] || section;
|
||||||
},
|
},
|
||||||
fieldLabel(key) {
|
fieldLabel(key) {
|
||||||
@@ -501,6 +518,10 @@ function adminApp(initialView) {
|
|||||||
oidc_admin_groups: '{{ t.settings_oidc_admin_groups }}',
|
oidc_admin_groups: '{{ t.settings_oidc_admin_groups }}',
|
||||||
oidc_client_groups: '{{ t.settings_oidc_client_groups }}',
|
oidc_client_groups: '{{ t.settings_oidc_client_groups }}',
|
||||||
swagger_enabled: '{{ t.settings_swagger }}',
|
swagger_enabled: '{{ t.settings_swagger }}',
|
||||||
|
telegram_bot_enabled: '{{ t.settings_telegram_enabled }}',
|
||||||
|
telegram_bot_username: '{{ t.settings_telegram_username }}',
|
||||||
|
telegram_bot_token: '{{ t.settings_telegram_token }}',
|
||||||
|
telegram_setup_instructions: '{{ t.settings_telegram_setup_instructions }}',
|
||||||
}[key] || key;
|
}[key] || key;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+693
-83
File diff suppressed because it is too large
Load Diff
@@ -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