Init
Build and Publish / Build and Publish Docker Image (push) Successful in 5m0s

This commit is contained in:
Ultradesu
2026-06-29 15:50:25 +03:00
commit e5aacc9b41
24 changed files with 11050 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
**/.git
**/target
**/.claude
**/.agents
**/.codex
**/*.sqlite3
**/NUL
**/nul
+51
View File
@@ -0,0 +1,51 @@
name: Build and Publish
on:
push:
tags:
- 'v*.*.*'
env:
IMAGE_NAME: ultradesu/amnezia-fellow
jobs:
build_docker:
name: Build and Publish Docker Image
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata
id: meta
run: |
VERSION=$(grep '^version' Cargo.toml | head -1 | cut -d'"' -f2)
echo "cargo_version=${VERSION}" >> $GITHUB_OUTPUT
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
TAG_NAME=${GITHUB_REF#refs/tags/}
echo "docker_tags=${IMAGE_NAME}:${TAG_NAME},${IMAGE_NAME}:${VERSION},${IMAGE_NAME}:latest" >> $GITHUB_OUTPUT
elif [[ "${{ github.ref }}" == refs/heads/* ]]; then
BRANCH=${GITHUB_REF#refs/heads/}
echo "docker_tags=${IMAGE_NAME}:${BRANCH},${IMAGE_NAME}:${VERSION},${IMAGE_NAME}:$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
else
echo "docker_tags=${IMAGE_NAME}:$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
fi
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.docker_tags }}
cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_NAME }}:buildcache,mode=max
+2
View File
@@ -0,0 +1,2 @@
*.sqlite3
/target/
Generated
+4544
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "amnezia-fellow"
version = "0.1.1"
edition = "2024"
description = "Amnezia VPN client manager with SSO, SQLite, and Kubernetes Secret sync"
[dependencies]
cot = { version = "0.6.0", default-features = false, features = ["sqlite", "json", "openapi", "swagger-ui"] }
schemars = { version = "0.9", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
openidconnect = "4.0"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
tokio = { version = "1", features = ["sync"] }
base64 = "0.22"
miniz_oxide = "0.8"
qrcode = "0.14"
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
curve25519-dalek = "4.1"
getrandom = "0.3"
kube = { version = "3.1.0", default-features = false, features = ["client", "rustls-tls", "ring"] }
k8s-openapi = { version = "0.27.1", features = ["v1_32"] }
+26
View File
@@ -0,0 +1,26 @@
FROM rust:1-slim AS builder
RUN apt-get update \
&& apt-get install -y --no-install-recommends pkg-config libssl-dev ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY Cargo.toml Cargo.lock* ./
COPY build.rs ./build.rs
COPY src ./src
COPY templates ./templates
RUN cargo build --release
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /data
COPY --from=builder /app/target/release/amnezia-fellow /usr/local/bin/amnezia-fellow
EXPOSE 8000
CMD ["amnezia-fellow", "--listen", "0.0.0.0:8000"]
+138
View File
@@ -0,0 +1,138 @@
# amnezia-fellow
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.
## Quick Start
```bash
export AMNEZIA_FELLOW_DATABASE_URL=sqlite://amnezia-fellow.sqlite3?mode=rwc
cargo run -- --listen 127.0.0.1:8000
```
Open `http://localhost:8000/admin/setup` to create the first local admin account.
## Docker
The image is built by GitHub Actions and published to Docker Hub as
`ultradesu/amnezia-fellow`.
Required repository secrets:
- `DOCKERHUB_USERNAME`
- `DOCKERHUB_TOKEN`
Publishing runs for version tags matching `v*.*.*`. A tag like `v0.1.0` pushes:
- `ultradesu/amnezia-fellow:v0.1.0`
- `ultradesu/amnezia-fellow:0.1.0`
- `ultradesu/amnezia-fellow:latest`
Local image build:
```bash
docker build -t amnezia-fellow .
docker run --rm -p 8000:8000 -v "$PWD/data:/data" amnezia-fellow
```
## Roles
There are two roles:
- `admin`: full access, sees all client configs.
- `client`: sees and manages only their own configs.
OIDC provisioning is deny-by-default:
- users in `AMNEZIA_FELLOW_OIDC_ADMIN_GROUPS` become `admin`;
- users in `AMNEZIA_FELLOW_OIDC_CLIENT_GROUPS` become `client`;
- users outside both group lists cannot sign in.
The OIDC groups claim is expected to be `groups`.
## VPN Data Model
SQLite stores all client data needed to restore configs:
- owner user id
- display name
- assigned IPv4 address
- public key
- private key
- enabled flag
- created/updated timestamps
The Kubernetes Secret is derived from the database. Active clients are rendered into one configured Secret key, `peers.conf` by default.
## Kubernetes Sync
The app is intended to run inside Kubernetes with a ServiceAccount and RBAC that can read and update Secrets and list Pods in the Amnezia namespace.
It reads:
- server public key from `AMNEZIA_FELLOW_K8S_SERVER_SECRET`, key `server-public-key`;
- node endpoints from `AMNEZIA_FELLOW_K8S_ENDPOINTS_SECRET`; Secret data keys are used as Amnezia server names in generated `vpn://` links;
- AmneziaWG pods selected by `app=amneziawg` for config rollout status;
- writes rendered client peers to `AMNEZIA_FELLOW_K8S_CLIENTS_SECRET`.
It does not modify the server Secret. Argo/ExternalSecrets can keep managing `amneziawg-server`, while amnezia-fellow owns only the client Secret content.
## UI
The main user interface is `/configs`. It is an Alpine.js reactive page backed by JSON API endpoints:
- clients and admins use the same page;
- the backend filters data by role;
- admins get an extra manual Secret sync action.
- clients and admins see AmneziaWG pod rollout status and uptime.
## Environment Variables
All settings use `AMNEZIA_FELLOW_` prefix. Priority is:
`environment variable > database override > compiled default`
| Variable | Description | Default |
| --- | --- | --- |
| `AMNEZIA_FELLOW_DATABASE_URL` | SQLite connection URL | `sqlite://amnezia-fellow.sqlite3?mode=rwc` |
| `AMNEZIA_FELLOW_LOG_LEVEL` | Tracing filter | `info` |
| `AMNEZIA_FELLOW_AUTH_PASSWORD_ENABLED` | Enable password login | `true` |
| `AMNEZIA_FELLOW_AUTH_SSO_ENABLED` | Enable OIDC login | `false` |
| `AMNEZIA_FELLOW_OIDC_ISSUER` | OIDC issuer URL | empty |
| `AMNEZIA_FELLOW_OIDC_CLIENT_ID` | OIDC client ID | empty |
| `AMNEZIA_FELLOW_OIDC_CLIENT_SECRET` | OIDC client secret | empty |
| `AMNEZIA_FELLOW_OIDC_BUTTON_TEXT` | SSO button label | `Sign in with SSO` |
| `AMNEZIA_FELLOW_OIDC_ADMIN_GROUPS` | Comma-separated admin groups | empty |
| `AMNEZIA_FELLOW_OIDC_CLIENT_GROUPS` | Comma-separated client groups | empty |
| `AMNEZIA_FELLOW_K8S_NAMESPACE` | Amnezia namespace | `amnezia` |
| `AMNEZIA_FELLOW_K8S_CLIENTS_SECRET` | Client peers Secret | `amneziawg-clients` |
| `AMNEZIA_FELLOW_K8S_CLIENTS_SECRET_KEY` | Secret data key for rendered peers | `peers.conf` |
| `AMNEZIA_FELLOW_K8S_SERVER_SECRET` | Server config Secret | `amneziawg-server` |
| `AMNEZIA_FELLOW_K8S_ENDPOINTS_SECRET` | Node endpoints Secret | `amneziawg-endpoints` |
| `AMNEZIA_FELLOW_VPN_CLIENT_CIDR` | Client address pool | `10.8.0.0/16` |
| `AMNEZIA_FELLOW_VPN_DNS` | DNS servers in generated configs | `1.1.1.1, 8.8.8.8` |
| `AMNEZIA_FELLOW_VPN_MTU` | MTU in generated configs | `1376` |
| `AMNEZIA_FELLOW_SWAGGER_ENABLED` | Serve Swagger UI at `/swagger/` | `false` |
## API
The JSON API is session-authenticated:
- `GET /api/me`
- `GET /api/vpn-clients`
- `GET /api/vpn-status`
- `POST /api/vpn-clients`
- `POST /api/vpn-clients/{id}/enabled`
- `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
- `POST /api/vpn-clients/sync`
## Development
```bash
cargo fmt
cargo check
```
The project depends on public crates only; `cot = "0.6.0"` is pulled from crates.io.
+17
View File
@@ -0,0 +1,17 @@
fn main() {
println!(
"cargo::rustc-env=AMNEZIA_FELLOW_TARGET={}",
std::env::var("TARGET").unwrap()
);
let rustc = std::env::var("RUSTC").unwrap_or_else(|_| "rustc".into());
let output = std::process::Command::new(rustc)
.arg("--version")
.output()
.expect("failed to run rustc --version");
let version = String::from_utf8_lossy(&output.stdout);
println!(
"cargo::rustc-env=AMNEZIA_FELLOW_RUSTC_VERSION={}",
version.trim()
);
}
+317
View File
@@ -0,0 +1,317 @@
pub mod views;
use cot::db::Database;
use cot::db::migrations::SyncDynMigration;
use cot::json::Json;
use cot::request::extractors::{Path, RequestForm};
use cot::response::IntoResponse;
use cot::router::method::{get, post};
use cot::router::{Route, Router};
use cot::session::Session;
use cot::{App, Body};
use serde::Deserialize;
use crate::auth::{self, AuthenticatedUser, Role};
use crate::i18n::I18n;
use crate::user::User;
use views::{AdminSettingsRequest, AdminUserRequest, AdminVpnServersRequest, SetupForm};
/// Build-time metadata baked in by `build.rs` and Cargo env vars.
#[derive(Debug)]
pub struct BuildInfo {
pub pkg_name: &'static str,
pub pkg_version: &'static str,
pub profile: &'static str,
pub target: &'static str,
pub rustc_version: &'static str,
}
pub static BUILD_INFO: BuildInfo = BuildInfo {
pkg_name: env!("CARGO_PKG_NAME"),
pkg_version: env!("CARGO_PKG_VERSION"),
profile: if cfg!(debug_assertions) {
"debug"
} else {
"release"
},
target: env!("AMNEZIA_FELLOW_TARGET"),
rustc_version: env!("AMNEZIA_FELLOW_RUSTC_VERSION"),
};
pub struct AdminApp;
impl AdminApp {
pub fn new() -> Self {
Self
}
}
#[derive(Debug, Deserialize)]
struct PathId {
id: i64,
}
fn json_error(status: cot::http::StatusCode, message: &str) -> cot::response::Response {
let body = serde_json::json!({ "error": message });
cot::http::Response::builder()
.status(status)
.header(cot::http::header::CONTENT_TYPE, "application/json")
.body(Body::fixed(body.to_string()))
.expect("valid response")
}
async fn require_admin_json(
session: &Session,
db: &Database,
) -> Result<AuthenticatedUser, cot::response::Response> {
let Some(user) = auth::get_session_user(session, db).await else {
return Err(json_error(
cot::http::StatusCode::UNAUTHORIZED,
"not authenticated",
));
};
if user.role != Role::Admin {
return Err(json_error(
cot::http::StatusCode::FORBIDDEN,
"admin role required",
));
}
Ok(user)
}
impl App for AdminApp {
fn name(&self) -> &'static str {
"admin"
}
fn router(&self) -> Router {
Router::with_urls([
// -- Setup (first-run, no auth required) --------------------------
Route::with_handler_and_name(
"/setup",
get(|i18n: I18n, db: Database| async move {
let count = User::count_all(&db).await.unwrap_or(1);
if count > 0 {
return Ok(auth::redirect("/admin/"));
}
views::setup_page(i18n, String::new())
.await?
.into_response()
})
.post(
|i18n: I18n, db: Database, session: Session,
form: RequestForm<SetupForm>| async move {
let count = User::count_all(&db).await.unwrap_or(1);
if count > 0 {
return Ok(auth::redirect("/admin/"));
}
views::setup_submit(i18n, &db, &session, form).await
},
),
"admin_setup",
),
// -- Alpine admin shell -------------------------------------------
Route::with_handler_and_name(
"/",
|session: Session, db: Database, i18n: I18n| async move {
let count = User::count_all(&db).await.unwrap_or(0);
if count == 0 {
return Ok(auth::redirect("/admin/setup"));
}
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::admin_app(admin, i18n, "dashboard")
.await?
.into_response()
},
"admin_index",
),
Route::with_handler_and_name(
"/users",
|session: Session, db: Database, i18n: I18n| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::admin_app(admin, i18n, "users")
.await?
.into_response()
},
"admin_users",
),
Route::with_handler_and_name(
"/settings",
|session: Session, db: Database, i18n: I18n| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::admin_app(admin, i18n, "settings")
.await?
.into_response()
},
"admin_settings",
),
Route::with_handler_and_name(
"/servers",
|session: Session, db: Database, i18n: I18n| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::admin_app(admin, i18n, "servers")
.await?
.into_response()
},
"admin_servers",
),
Route::with_handler_and_name(
"/debug",
|session: Session, db: Database, i18n: I18n| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::admin_app(admin, i18n, "debug")
.await?
.into_response()
},
"admin_debug",
),
Route::with_handler_and_name(
"/users/new",
get(|| async { Ok::<_, cot::Error>(auth::redirect("/admin/users")) }),
"admin_users_new",
),
Route::with_handler_and_name(
"/users/{id}/edit",
get(|| async { Ok::<_, cot::Error>(auth::redirect("/admin/users")) }),
"admin_users_edit",
),
// -- Alpine JSON API ----------------------------------------------
Route::with_handler_and_name(
"/api/summary",
|session: Session, db: Database| async move {
let admin = match require_admin_json(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::admin_summary_api(admin, &db).await
},
"admin_api_summary",
),
Route::with_handler_and_name(
"/api/debug",
|session: Session, db: Database, i18n: I18n| async move {
let admin = match require_admin_json(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::admin_debug_api(admin, i18n, &db).await
},
"admin_api_debug",
),
Route::with_handler_and_name(
"/api/settings",
get(|session: Session, db: Database| async move {
let admin = match require_admin_json(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::admin_settings_api(admin, &db).await
})
.post(
|session: Session, db: Database, Json(request): Json<AdminSettingsRequest>| async move {
let admin = match require_admin_json(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::admin_settings_save_api(admin, &db, Json(request)).await
},
),
"admin_api_settings",
),
Route::with_handler_and_name(
"/api/users",
get(|session: Session, db: Database| async move {
let admin = match require_admin_json(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::admin_users_api(admin, &db).await
})
.post(
|session: Session, db: Database, Json(request): Json<AdminUserRequest>| async move {
let admin = match require_admin_json(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::admin_user_create_api(admin, &db, Json(request)).await
},
),
"admin_api_users",
),
Route::with_handler_and_name(
"/api/users/{id}",
post(
|session: Session, db: Database, path: Path<PathId>,
Json(request): Json<AdminUserRequest>| async move {
let admin = match require_admin_json(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::admin_user_update_api(admin, &db, path.0.id, Json(request)).await
},
),
"admin_api_user_update",
),
Route::with_handler_and_name(
"/api/users/{id}/delete",
post(
|session: Session, db: Database, path: Path<PathId>| async move {
let admin = match require_admin_json(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::admin_user_delete_api(admin, &db, path.0.id).await
},
),
"admin_api_user_delete",
),
Route::with_handler_and_name(
"/api/servers",
get(|session: Session, db: Database| async move {
let admin = match require_admin_json(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::admin_vpn_servers_api(admin, &db).await
})
.post(
|session: Session, db: Database, Json(request): Json<AdminVpnServersRequest>| async move {
let admin = match require_admin_json(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::admin_vpn_servers_save_api(admin, &db, Json(request)).await
},
),
"admin_api_servers",
),
])
}
fn migrations(&self) -> Vec<Box<SyncDynMigration>> {
let mut all =
cot::db::migrations::wrap_migrations(crate::config::db_migrations::MIGRATIONS);
all.extend(cot::db::migrations::wrap_migrations(
crate::user::db_migrations::MIGRATIONS,
));
all.extend(cot::db::migrations::wrap_migrations(
crate::vpn::db_migrations::MIGRATIONS,
));
all
}
}
+842
View File
@@ -0,0 +1,842 @@
use std::collections::{BTreeMap, HashSet};
use cot::db::{Database, Model};
use cot::form::{Form, FormResult};
use cot::html::Html;
use cot::json::Json;
use cot::request::extractors::RequestForm;
use cot::response::IntoResponse;
use cot::session::Session;
use cot::{Body, Template};
use serde::{Deserialize, Serialize};
use super::BUILD_INFO;
use crate::auth::{self, AuthenticatedUser};
use crate::config::{AppConfig, ConfigEntry, ConfigSources};
use crate::i18n::{I18n, Translations};
use crate::user::User;
use crate::vpn;
/// A config entry for display in the unified debug table.
#[derive(Debug, Serialize)]
pub struct ConfigDisplayEntry {
pub key: String,
pub env_var: String,
pub value: String,
pub default_value: String,
pub source: &'static str,
}
fn json_error(status: cot::http::StatusCode, message: &str) -> cot::response::Response {
let body = serde_json::json!({ "error": message });
cot::http::Response::builder()
.status(status)
.header(cot::http::header::CONTENT_TYPE, "application/json")
.body(Body::fixed(body.to_string()))
.expect("valid response")
}
/// Secret field names that should be redacted in the debug view.
const SECRET_FIELDS: &[&str] = &["database_url", "oidc_client_secret"];
fn is_secret(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
SECRET_FIELDS.iter().any(|s| lower.contains(s))
|| lower.contains("secret")
|| lower.contains("token")
}
fn redact(value: &str) -> String {
if value.is_empty() {
String::new()
} else {
"********".into()
}
}
fn config_display_entries(config: &AppConfig, sources: &ConfigSources) -> Vec<ConfigDisplayEntry> {
let defaults = AppConfig::default();
macro_rules! entry {
($field:ident, $value:expr, $default:expr) => {{
let raw = $value;
let default_raw = $default;
let secret = is_secret(stringify!($field));
let display = if secret { redact(&raw) } else { raw };
let default_display = if secret {
redact(&default_raw)
} else {
default_raw
};
ConfigDisplayEntry {
key: stringify!($field).into(),
env_var: format!("AMNEZIA_FELLOW_{}", stringify!($field).to_ascii_uppercase()),
value: display,
default_value: default_display,
source: sources.$field.code(),
}
}};
}
vec![
entry!(
database_url,
config.database_url.clone(),
defaults.database_url.clone()
),
entry!(
oidc_issuer,
config.oidc_issuer.clone(),
defaults.oidc_issuer.clone()
),
entry!(
oidc_client_id,
config.oidc_client_id.clone(),
defaults.oidc_client_id.clone()
),
entry!(
oidc_client_secret,
config.oidc_client_secret.clone(),
defaults.oidc_client_secret.clone()
),
entry!(
log_level,
config.log_level.clone(),
defaults.log_level.clone()
),
entry!(
auth_password_enabled,
config.auth_password_enabled.to_string(),
defaults.auth_password_enabled.to_string()
),
entry!(
auth_sso_enabled,
config.auth_sso_enabled.to_string(),
defaults.auth_sso_enabled.to_string()
),
entry!(
oidc_button_text,
config.oidc_button_text.clone(),
defaults.oidc_button_text.clone()
),
entry!(
oidc_admin_groups,
config.oidc_admin_groups.clone(),
defaults.oidc_admin_groups.clone()
),
entry!(
oidc_client_groups,
config.oidc_client_groups.clone(),
defaults.oidc_client_groups.clone()
),
entry!(
k8s_namespace,
config.k8s_namespace.clone(),
defaults.k8s_namespace.clone()
),
entry!(
k8s_clients_secret,
config.k8s_clients_secret.clone(),
defaults.k8s_clients_secret.clone()
),
entry!(
k8s_clients_secret_key,
config.k8s_clients_secret_key.clone(),
defaults.k8s_clients_secret_key.clone()
),
entry!(
k8s_server_secret,
config.k8s_server_secret.clone(),
defaults.k8s_server_secret.clone()
),
entry!(
k8s_endpoints_secret,
config.k8s_endpoints_secret.clone(),
defaults.k8s_endpoints_secret.clone()
),
entry!(
vpn_disabled_endpoints,
config.vpn_disabled_endpoints.clone(),
defaults.vpn_disabled_endpoints.clone()
),
entry!(
vpn_endpoint_name_overrides,
config.vpn_endpoint_name_overrides.clone(),
defaults.vpn_endpoint_name_overrides.clone()
),
entry!(
vpn_client_cidr,
config.vpn_client_cidr.clone(),
defaults.vpn_client_cidr.clone()
),
entry!(vpn_dns, config.vpn_dns.clone(), defaults.vpn_dns.clone()),
entry!(
vpn_mtu,
config.vpn_mtu.to_string(),
defaults.vpn_mtu.to_string()
),
entry!(
swagger_enabled,
config.swagger_enabled.to_string(),
defaults.swagger_enabled.to_string()
),
]
}
#[derive(Debug, Template)]
#[template(path = "admin/app.html")]
struct AdminAppTemplate {
t: &'static Translations,
user_name: String,
user_role: String,
initial_view: String,
app_version: &'static str,
}
pub async fn admin_app(
admin: AuthenticatedUser,
i18n: I18n,
initial_view: &str,
) -> cot::Result<Html> {
let template = AdminAppTemplate {
t: i18n.t,
user_name: admin.name,
user_role: admin.role.code().to_owned(),
initial_view: initial_view.to_owned(),
app_version: env!("CARGO_PKG_VERSION"),
};
Ok(Html::new(template.render()?))
}
#[derive(Debug, Serialize)]
pub struct AdminSummaryResponse {
users_count: u64,
admin_users_count: usize,
client_users_count: usize,
active_users_count: usize,
build: BuildInfoView,
}
#[derive(Debug, Serialize)]
pub struct BuildInfoView {
pkg_name: &'static str,
pkg_version: &'static str,
profile: &'static str,
target: &'static str,
rustc_version: &'static str,
}
impl From<&'static super::BuildInfo> for BuildInfoView {
fn from(build: &'static super::BuildInfo) -> Self {
Self {
pkg_name: build.pkg_name,
pkg_version: build.pkg_version,
profile: build.profile,
target: build.target,
rustc_version: build.rustc_version,
}
}
}
#[derive(Debug, Serialize)]
pub struct AdminDebugResponse {
build: BuildInfoView,
db_status: String,
config_entries: Vec<ConfigDisplayEntry>,
}
#[derive(Debug, Serialize)]
pub struct AdminSettingsResponse {
fields: Vec<AdminSettingField>,
}
#[derive(Debug, Serialize)]
pub struct AdminSettingField {
key: String,
env_var: String,
value: String,
default_value: String,
source: &'static str,
secret: bool,
kind: &'static str,
section: &'static str,
}
#[derive(Debug, Deserialize)]
pub struct AdminSettingsRequest {
auth_password_enabled: bool,
auth_sso_enabled: bool,
oidc_button_text: String,
oidc_issuer: String,
oidc_client_id: String,
oidc_client_secret: String,
oidc_admin_groups: String,
oidc_client_groups: String,
k8s_namespace: String,
k8s_clients_secret: String,
k8s_clients_secret_key: String,
k8s_server_secret: String,
k8s_endpoints_secret: String,
vpn_disabled_endpoints: String,
vpn_endpoint_name_overrides: String,
vpn_client_cidr: String,
vpn_dns: String,
vpn_mtu: u16,
swagger_enabled: bool,
}
#[derive(Debug, Serialize)]
pub struct AdminUserView {
id: i64,
username: String,
email: String,
display_name: String,
role: String,
active: bool,
}
impl From<User> for AdminUserView {
fn from(user: User) -> Self {
Self {
id: user.id_val(),
username: user.username_str().to_owned(),
email: user.email_str(),
display_name: user.display_name_str(),
role: user.role_str().to_owned(),
active: user.is_active(),
}
}
}
#[derive(Debug, Serialize)]
pub struct AdminUsersResponse {
users: Vec<AdminUserView>,
}
#[derive(Debug, Serialize)]
pub struct AdminUserResponse {
user: AdminUserView,
}
#[derive(Debug, Deserialize)]
pub struct AdminUserRequest {
username: String,
email: String,
display_name: String,
password: String,
role: String,
}
#[derive(Debug, Serialize)]
pub struct AdminDeleteResponse {
deleted: bool,
}
#[derive(Debug, Serialize)]
pub struct AdminVpnServersResponse {
servers: Vec<AdminVpnServerView>,
}
#[derive(Debug, Serialize)]
pub struct AdminVpnServerView {
name: String,
display_name: String,
endpoint: String,
enabled: bool,
}
#[derive(Debug, Deserialize)]
pub struct AdminVpnServersRequest {
servers: Vec<AdminVpnServerUpdate>,
}
#[derive(Debug, Deserialize)]
pub struct AdminVpnServerUpdate {
name: String,
display_name: String,
enabled: bool,
}
pub async fn admin_summary_api(
_admin: AuthenticatedUser,
db: &Database,
) -> cot::Result<cot::response::Response> {
let users = User::list_all(db)
.await
.map_err(|e| cot::Error::internal(format!("failed to list users: {e}")))?;
let users_count = users.len() as u64;
let admin_users_count = users
.iter()
.filter(|user| user.role_str() == "admin")
.count();
let client_users_count = users
.iter()
.filter(|user| user.role_str() == "client")
.count();
let active_users_count = users.iter().filter(|user| user.is_active()).count();
Json(AdminSummaryResponse {
users_count,
admin_users_count,
client_users_count,
active_users_count,
build: (&BUILD_INFO).into(),
})
.into_response()
}
pub async fn admin_debug_api(
_admin: AuthenticatedUser,
i18n: I18n,
db: &Database,
) -> cot::Result<cot::response::Response> {
let (config, sources) = AppConfig::load_with_db(db).await;
let db_status = match db.raw("SELECT 1").await {
Ok(_) => i18n.t.debug_db_connected.to_owned(),
Err(e) => format!("{}: {e}", i18n.t.debug_db_error),
};
Json(AdminDebugResponse {
build: (&BUILD_INFO).into(),
db_status,
config_entries: config_display_entries(&config, &sources),
})
.into_response()
}
pub async fn admin_settings_api(
_admin: AuthenticatedUser,
db: &Database,
) -> cot::Result<cot::response::Response> {
let (config, sources) = AppConfig::load_with_db(db).await;
Json(AdminSettingsResponse {
fields: settings_fields(&config, &sources),
})
.into_response()
}
pub async fn admin_settings_save_api(
_admin: AuthenticatedUser,
db: &Database,
Json(request): Json<AdminSettingsRequest>,
) -> cot::Result<cot::response::Response> {
save_settings_request(db, &request).await?;
admin_settings_api(_admin, db).await
}
pub async fn admin_users_api(
_admin: AuthenticatedUser,
db: &Database,
) -> cot::Result<cot::response::Response> {
let users = User::list_all(db)
.await
.map_err(|e| cot::Error::internal(format!("failed to list users: {e}")))?
.into_iter()
.map(AdminUserView::from)
.collect();
Json(AdminUsersResponse { users }).into_response()
}
pub async fn admin_vpn_servers_api(
_admin: AuthenticatedUser,
db: &Database,
) -> cot::Result<cot::response::Response> {
let (config, _) = AppConfig::load_with_db(db).await;
let disabled = vpn::disabled_endpoint_names(&config);
let runtime = vpn::read_runtime_from_kubernetes(&config)
.await
.map_err(|e| cot::Error::internal(format!("failed to read VPN endpoints: {e}")))?;
let servers = runtime
.endpoints
.into_iter()
.map(|endpoint| AdminVpnServerView {
enabled: !disabled.contains(&endpoint.name),
display_name: endpoint.display_name,
name: endpoint.name,
endpoint: endpoint.endpoint,
})
.collect();
Json(AdminVpnServersResponse { servers }).into_response()
}
pub async fn admin_vpn_servers_save_api(
admin: AuthenticatedUser,
db: &Database,
Json(request): Json<AdminVpnServersRequest>,
) -> cot::Result<cot::response::Response> {
let mut name_overrides = BTreeMap::new();
let disabled = request
.servers
.into_iter()
.filter_map(|server| {
let name = server.name.trim().to_owned();
if name.is_empty() {
return None;
}
let display_name = server.display_name.trim();
if !display_name.is_empty() && display_name != name {
name_overrides.insert(name.clone(), display_name.to_owned());
}
(!server.enabled).then_some(name)
})
.collect::<HashSet<_>>();
let mut disabled = disabled.into_iter().collect::<Vec<_>>();
disabled.sort();
let name_overrides = serde_json::to_string(&name_overrides)
.map_err(|e| cot::Error::internal(format!("failed to serialize VPN server names: {e}")))?;
let mut disabled_entry =
ConfigEntry::new("vpn_disabled_endpoints".to_owned(), disabled.join(","));
disabled_entry
.save(db)
.await
.map_err(|e| cot::Error::internal(format!("failed to save VPN server settings: {e}")))?;
let mut names_entry =
ConfigEntry::new("vpn_endpoint_name_overrides".to_owned(), name_overrides);
names_entry
.save(db)
.await
.map_err(|e| cot::Error::internal(format!("failed to save VPN server names: {e}")))?;
admin_vpn_servers_api(admin, db).await
}
pub async fn admin_user_create_api(
_admin: AuthenticatedUser,
db: &Database,
Json(request): Json<AdminUserRequest>,
) -> cot::Result<cot::response::Response> {
if request.password.trim().is_empty() {
return Ok(json_error(
cot::http::StatusCode::BAD_REQUEST,
"password is required",
));
}
validate_role(&request.role)?;
let email = optional_str(&request.email);
let display_name = optional_str(&request.display_name);
let user = User::create(
db,
request.username.trim(),
email,
display_name,
&request.password,
&request.role,
)
.await
.map_err(|e| cot::Error::internal(format!("failed to create user: {e}")))?;
Json(AdminUserResponse {
user: AdminUserView::from(user),
})
.into_response()
}
pub async fn admin_user_update_api(
_admin: AuthenticatedUser,
db: &Database,
user_id: i64,
Json(request): Json<AdminUserRequest>,
) -> cot::Result<cot::response::Response> {
validate_role(&request.role)?;
let Some(mut user) = User::get_by_id(db, user_id)
.await
.map_err(|e| cot::Error::internal(format!("failed to load user: {e}")))?
else {
return Ok(json_error(cot::http::StatusCode::NOT_FOUND, "not found"));
};
let email = optional_str(&request.email);
let display_name = optional_str(&request.display_name);
let new_password = optional_str(&request.password);
user.update_fields(
db,
request.username.trim(),
email,
display_name,
new_password,
&request.role,
)
.await
.map_err(|e| cot::Error::internal(format!("failed to update user: {e}")))?;
Json(AdminUserResponse {
user: AdminUserView::from(user),
})
.into_response()
}
pub async fn admin_user_delete_api(
_admin: AuthenticatedUser,
db: &Database,
user_id: i64,
) -> cot::Result<cot::response::Response> {
User::delete_by_id(db, user_id)
.await
.map_err(|e| cot::Error::internal(format!("failed to delete user: {e}")))?;
Json(AdminDeleteResponse { deleted: true }).into_response()
}
fn optional_str(value: &str) -> Option<&str> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
fn validate_role(role: &str) -> cot::Result<()> {
if matches!(role, "admin" | "client") {
Ok(())
} else {
Err(cot::Error::internal(format!("invalid role: {role}")))
}
}
fn settings_fields(config: &AppConfig, sources: &ConfigSources) -> Vec<AdminSettingField> {
let defaults = AppConfig::default();
macro_rules! field {
($section:expr, $kind:expr, $field:ident, $value:expr, $default:expr) => {{
let raw = $value;
let default_raw = $default;
let secret = is_secret(stringify!($field));
AdminSettingField {
key: stringify!($field).into(),
env_var: format!("AMNEZIA_FELLOW_{}", stringify!($field).to_ascii_uppercase()),
value: raw,
default_value: default_raw,
source: sources.$field.code(),
secret,
kind: $kind,
section: $section,
}
}};
}
vec![
field!(
"auth",
"bool",
auth_password_enabled,
config.auth_password_enabled.to_string(),
defaults.auth_password_enabled.to_string()
),
field!(
"auth",
"bool",
auth_sso_enabled,
config.auth_sso_enabled.to_string(),
defaults.auth_sso_enabled.to_string()
),
field!(
"oidc",
"text",
oidc_button_text,
config.oidc_button_text.clone(),
defaults.oidc_button_text.clone()
),
field!(
"oidc",
"text",
oidc_issuer,
config.oidc_issuer.clone(),
defaults.oidc_issuer.clone()
),
field!(
"oidc",
"text",
oidc_client_id,
config.oidc_client_id.clone(),
defaults.oidc_client_id.clone()
),
field!(
"oidc",
"password",
oidc_client_secret,
config.oidc_client_secret.clone(),
defaults.oidc_client_secret.clone()
),
field!(
"oidc",
"text",
oidc_admin_groups,
config.oidc_admin_groups.clone(),
defaults.oidc_admin_groups.clone()
),
field!(
"oidc",
"text",
oidc_client_groups,
config.oidc_client_groups.clone(),
defaults.oidc_client_groups.clone()
),
field!(
"kubernetes",
"text",
k8s_namespace,
config.k8s_namespace.clone(),
defaults.k8s_namespace.clone()
),
field!(
"kubernetes",
"text",
k8s_clients_secret,
config.k8s_clients_secret.clone(),
defaults.k8s_clients_secret.clone()
),
field!(
"kubernetes",
"text",
k8s_clients_secret_key,
config.k8s_clients_secret_key.clone(),
defaults.k8s_clients_secret_key.clone()
),
field!(
"kubernetes",
"text",
k8s_server_secret,
config.k8s_server_secret.clone(),
defaults.k8s_server_secret.clone()
),
field!(
"kubernetes",
"text",
k8s_endpoints_secret,
config.k8s_endpoints_secret.clone(),
defaults.k8s_endpoints_secret.clone()
),
field!(
"vpn",
"text",
vpn_disabled_endpoints,
config.vpn_disabled_endpoints.clone(),
defaults.vpn_disabled_endpoints.clone()
),
field!(
"vpn",
"text",
vpn_endpoint_name_overrides,
config.vpn_endpoint_name_overrides.clone(),
defaults.vpn_endpoint_name_overrides.clone()
),
field!(
"vpn",
"text",
vpn_client_cidr,
config.vpn_client_cidr.clone(),
defaults.vpn_client_cidr.clone()
),
field!(
"vpn",
"text",
vpn_dns,
config.vpn_dns.clone(),
defaults.vpn_dns.clone()
),
field!(
"vpn",
"number",
vpn_mtu,
config.vpn_mtu.to_string(),
defaults.vpn_mtu.to_string()
),
field!(
"api",
"bool",
swagger_enabled,
config.swagger_enabled.to_string(),
defaults.swagger_enabled.to_string()
),
]
}
async fn save_settings_request(db: &Database, data: &AdminSettingsRequest) -> cot::Result<()> {
let vpn_mtu = data.vpn_mtu.to_string();
let auth_password_enabled = data.auth_password_enabled.to_string();
let auth_sso_enabled = data.auth_sso_enabled.to_string();
let swagger_enabled = data.swagger_enabled.to_string();
let fields: [(&str, &str); 19] = [
("auth_password_enabled", &auth_password_enabled),
("auth_sso_enabled", &auth_sso_enabled),
("oidc_button_text", &data.oidc_button_text),
("oidc_issuer", &data.oidc_issuer),
("oidc_client_id", &data.oidc_client_id),
("oidc_client_secret", &data.oidc_client_secret),
("oidc_admin_groups", &data.oidc_admin_groups),
("oidc_client_groups", &data.oidc_client_groups),
("k8s_namespace", &data.k8s_namespace),
("k8s_clients_secret", &data.k8s_clients_secret),
("k8s_clients_secret_key", &data.k8s_clients_secret_key),
("k8s_server_secret", &data.k8s_server_secret),
("k8s_endpoints_secret", &data.k8s_endpoints_secret),
("vpn_disabled_endpoints", &data.vpn_disabled_endpoints),
(
"vpn_endpoint_name_overrides",
&data.vpn_endpoint_name_overrides,
),
("vpn_client_cidr", &data.vpn_client_cidr),
("vpn_dns", &data.vpn_dns),
("vpn_mtu", &vpn_mtu),
("swagger_enabled", &swagger_enabled),
];
for (key, value) in fields {
let mut entry = ConfigEntry::new(key.to_owned(), value.to_owned());
if let Err(e) = entry.save(db).await {
tracing::error!(key, error = %e, "failed to save config entry");
return Err(e.into());
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// First-run setup page
// ---------------------------------------------------------------------------
#[derive(Debug, Template)]
#[template(path = "admin/setup.html")]
struct SetupTemplate {
t: &'static Translations,
message: String,
}
pub async fn setup_page(i18n: I18n, message: String) -> cot::Result<Html> {
let template = SetupTemplate { t: i18n.t, message };
Ok(Html::new(template.render()?))
}
#[derive(Debug, Form)]
pub struct SetupForm {
username: String,
password: String,
confirm_password: String,
}
pub async fn setup_submit(
i18n: I18n,
db: &Database,
session: &Session,
form: RequestForm<SetupForm>,
) -> cot::Result<cot::response::Response> {
let RequestForm(result) = form;
let data = match result {
FormResult::Ok(data) => data,
FormResult::ValidationError(_) => {
return setup_page(i18n, String::new()).await?.into_response();
}
};
if data.password != data.confirm_password {
let msg = i18n.t.setup_mismatch.to_owned();
return setup_page(i18n, msg).await?.into_response();
}
let user = User::create(db, &data.username, None, None, &data.password, "admin")
.await
.map_err(|e| cot::Error::internal(format!("failed to create admin: {e}")))?;
auth::login(session, user.id_val()).await?;
Ok(auth::redirect("/admin/"))
}
+467
View File
@@ -0,0 +1,467 @@
use std::collections::HashMap;
use cot::db::Database;
use cot::json::Json;
use cot::request::extractors::Path;
use cot::response::IntoResponse;
use cot::router::method::openapi::{api_delete, api_get, api_post};
use cot::router::{Route, Router};
use cot::session::Session;
use cot::{App, Body};
use qrcode::QrCode;
use qrcode::render::svg;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::config::AppConfig;
use crate::user::User;
use crate::{auth, vpn};
// ---------------------------------------------------------------------------
// JSON error helper
// ---------------------------------------------------------------------------
fn json_error(status: cot::http::StatusCode, message: &str) -> cot::response::Response {
let body = serde_json::json!({ "error": message });
cot::http::Response::builder()
.status(status)
.header(cot::http::header::CONTENT_TYPE, "application/json")
.body(Body::fixed(body.to_string()))
.expect("valid response")
}
// ---------------------------------------------------------------------------
// GET /api/me
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize, JsonSchema)]
struct MeResponse {
id: i64,
name: String,
role: String,
}
async fn me_handler(session: Session, db: Database) -> cot::Result<cot::response::Response> {
let Some(user) = auth::get_session_user(&session, &db).await else {
return Ok(json_error(
cot::http::StatusCode::UNAUTHORIZED,
"not authenticated",
));
};
Json(MeResponse {
id: user.id,
name: user.name,
role: user.role.code().to_owned(),
})
.into_response()
}
// ---------------------------------------------------------------------------
// VPN client API
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize, JsonSchema)]
struct VpnClientsResponse {
role: String,
clients: Vec<vpn::VpnClientView>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct CreateVpnClientRequest {
name: String,
}
#[derive(Debug, Serialize, JsonSchema)]
struct MutateVpnClientResponse {
client: vpn::VpnClientView,
sync: vpn::SecretSyncResult,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct SetEnabledRequest {
enabled: bool,
}
#[derive(Debug, Serialize, JsonSchema)]
struct DeleteVpnClientResponse {
sync: vpn::SecretSyncResult,
}
#[derive(Debug, Serialize, JsonSchema)]
struct ClientServerConfigResponse {
endpoint_id: String,
endpoint_name: String,
endpoint: String,
config: String,
vpn_url: String,
qr_payload: String,
qr_svg: String,
}
#[derive(Debug, Serialize, JsonSchema)]
struct ClientConfigResponse {
id: i64,
name: String,
servers: Vec<ClientServerConfigResponse>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct ClientPath {
id: i64,
}
async fn vpn_clients_handler(
session: Session,
db: Database,
) -> cot::Result<cot::response::Response> {
let Some(user) = auth::get_session_user(&session, &db).await else {
return Ok(json_error(
cot::http::StatusCode::UNAUTHORIZED,
"not authenticated",
));
};
let clients = vpn::VpnClient::list_visible(&db, &user)
.await
.map_err(|e| cot::Error::internal(format!("failed to list clients: {e}")))?;
let owner_map = owner_view_map(&db, &clients).await?;
let clients = clients
.into_iter()
.map(|client| client_view_with_owner(client, &owner_map))
.collect();
Json(VpnClientsResponse {
role: user.role.code().to_owned(),
clients,
})
.into_response()
}
async fn vpn_status_handler(
session: Session,
db: Database,
) -> 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",
));
}
};
tracing::debug!(user_id = user.id, "VPN rollout status requested");
let (config, _) = AppConfig::load_with_db(&db).await;
let status = vpn::read_rollout_status_from_kubernetes(&config)
.await
.map_err(|e| cot::Error::internal(format!("failed to read VPN rollout status: {e}")))?;
Json(status).into_response()
}
async fn create_vpn_client_handler(
session: Session,
db: Database,
Json(request): Json<CreateVpnClientRequest>,
) -> 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 (config, _) = AppConfig::load_with_db(&db).await;
let client =
vpn::VpnClient::create_for_owner(&db, user.id, &request.name, &config.vpn_client_cidr)
.await
.map_err(|e| cot::Error::internal(format!("failed to create client: {e}")))?;
let sync = vpn::sync_from_database(&db, &config)
.await
.map_err(|e| cot::Error::internal(format!("failed to sync client Secret: {e}")))?;
let owner_map = owner_view_map(&db, std::slice::from_ref(&client)).await?;
Json(MutateVpnClientResponse {
client: client_view_with_owner(client, &owner_map),
sync,
})
.into_response()
}
async fn set_vpn_client_enabled_handler(
session: Session,
db: Database,
Path(path): Path<ClientPath>,
Json(request): Json<SetEnabledRequest>,
) -> 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"));
};
client
.set_enabled(&db, request.enabled)
.await
.map_err(|e| cot::Error::internal(format!("failed to update client: {e}")))?;
let (config, _) = AppConfig::load_with_db(&db).await;
let sync = vpn::sync_from_database(&db, &config)
.await
.map_err(|e| cot::Error::internal(format!("failed to sync client Secret: {e}")))?;
let owner_map = owner_view_map(&db, std::slice::from_ref(&client)).await?;
Json(MutateVpnClientResponse {
client: client_view_with_owner(client, &owner_map),
sync,
})
.into_response()
}
async fn delete_vpn_client_handler(
session: Session,
db: Database,
Path(path): Path<ClientPath>,
) -> 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(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"));
};
vpn::VpnClient::delete_by_id(&db, client.id_val())
.await
.map_err(|e| cot::Error::internal(format!("failed to delete client: {e}")))?;
let (config, _) = AppConfig::load_with_db(&db).await;
let sync = vpn::sync_from_database(&db, &config)
.await
.map_err(|e| cot::Error::internal(format!("failed to sync client Secret: {e}")))?;
Json(DeleteVpnClientResponse { sync }).into_response()
}
async fn vpn_client_config_handler(
session: Session,
db: Database,
Path(path): Path<ClientPath>,
) -> 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(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"));
};
let (config, _) = AppConfig::load_with_db(&db).await;
let runtime = vpn::read_runtime_from_kubernetes(&config)
.await
.map_err(|e| cot::Error::internal(format!("failed to read VPN runtime: {e}")))?;
let endpoints = vpn::filter_enabled_endpoints(runtime.endpoints, &config);
if endpoints.is_empty() {
return Ok(json_error(
cot::http::StatusCode::CONFLICT,
"no VPN endpoints are enabled",
));
};
let mut servers = Vec::with_capacity(endpoints.len());
for endpoint in endpoints {
let config_text = vpn::render_client_config(
&client,
&runtime.server_public_key,
&endpoint.endpoint,
&config,
);
let qr_payload = vpn::render_vpn_payload(
&client,
&runtime.server_public_key,
&endpoint.display_name,
&endpoint.endpoint,
&config,
)
.map_err(|e| cot::Error::internal(format!("failed to render VPN QR payload: {e}")))?;
let vpn_url = vpn::render_vpn_url(&qr_payload);
servers.push(ClientServerConfigResponse {
endpoint_id: endpoint.name,
endpoint_name: endpoint.display_name,
endpoint: endpoint.endpoint,
config: config_text,
qr_svg: render_qr_svg(&qr_payload)?,
qr_payload,
vpn_url,
});
}
Json(ClientConfigResponse {
id: client.id_val(),
name: client.name_str().to_owned(),
servers,
})
.into_response()
}
#[derive(Debug, Clone)]
struct OwnerView {
username: String,
display_name: String,
}
async fn owner_view_map(
db: &Database,
clients: &[vpn::VpnClient],
) -> cot::Result<HashMap<i64, OwnerView>> {
let owner_ids = clients
.iter()
.map(vpn::VpnClient::owner_user_id)
.collect::<std::collections::HashSet<_>>();
let users = User::list_all(db)
.await
.map_err(|e| cot::Error::internal(format!("failed to list users: {e}")))?;
Ok(users
.into_iter()
.filter(|user| owner_ids.contains(&user.id_val()))
.map(|user| {
(
user.id_val(),
OwnerView {
username: user.username_str().to_owned(),
display_name: user.display_name_str(),
},
)
})
.collect())
}
fn client_view_with_owner(
client: vpn::VpnClient,
owner_map: &HashMap<i64, OwnerView>,
) -> vpn::VpnClientView {
let mut view = client.view();
if let Some(owner) = owner_map.get(&view.owner_user_id) {
view.owner_username = owner.username.clone();
view.owner_display_name = owner.display_name.clone();
}
view
}
fn render_qr_svg(value: &str) -> cot::Result<String> {
let code = QrCode::new(value.as_bytes())
.map_err(|e| cot::Error::internal(format!("failed to render QR code: {e}")))?;
Ok(code
.render::<svg::Color>()
.min_dimensions(256, 256)
.dark_color(svg::Color("#17202a"))
.light_color(svg::Color("#ffffff"))
.build())
}
async fn sync_vpn_clients_handler(
session: Session,
db: Database,
) -> cot::Result<cot::response::Response> {
let user = match auth::require_admin_or_redirect(&session, &db).await {
Ok(user) => user,
Err(_) => {
return Ok(json_error(
cot::http::StatusCode::FORBIDDEN,
"admin role required",
));
}
};
tracing::info!(
admin_user_id = user.id,
"manual client Secret sync requested"
);
let (config, _) = AppConfig::load_with_db(&db).await;
let sync = vpn::sync_from_database(&db, &config)
.await
.map_err(|e| cot::Error::internal(format!("failed to sync client Secret: {e}")))?;
Json(sync).into_response()
}
// ---------------------------------------------------------------------------
// App
// ---------------------------------------------------------------------------
pub struct ApiApp;
impl App for ApiApp {
fn name(&self) -> &'static str {
"api"
}
fn router(&self) -> Router {
Router::with_urls([
Route::with_api_handler_and_name("/me", api_get(me_handler), "api_me"),
Route::with_api_handler_and_name(
"/vpn-clients",
api_get(vpn_clients_handler).post(create_vpn_client_handler),
"api_vpn_clients",
),
Route::with_api_handler_and_name(
"/vpn-status",
api_get(vpn_status_handler),
"api_vpn_status",
),
Route::with_api_handler_and_name(
"/vpn-clients/sync",
api_post(sync_vpn_clients_handler),
"api_vpn_clients_sync",
),
Route::with_api_handler_and_name(
"/vpn-clients/{id}/enabled",
api_post(set_vpn_client_enabled_handler),
"api_vpn_client_enabled",
),
Route::with_api_handler_and_name(
"/vpn-clients/{id}",
api_delete(delete_vpn_client_handler),
"api_vpn_client_delete",
),
Route::with_api_handler_and_name(
"/vpn-clients/{id}/config",
api_get(vpn_client_config_handler),
"api_vpn_client_config",
),
])
}
}
+146
View File
@@ -0,0 +1,146 @@
use cot::Body;
use cot::db::Database;
use cot::response::IntoResponse;
use cot::session::Session;
use crate::user::User;
// ---------------------------------------------------------------------------
// Role enum
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
Admin,
Client,
}
impl Role {
pub fn code(self) -> &'static str {
match self {
Role::Admin => "admin",
Role::Client => "client",
}
}
pub fn from_code(s: &str) -> Option<Self> {
match s {
"admin" => Some(Role::Admin),
"client" => Some(Role::Client),
_ => None,
}
}
}
// ---------------------------------------------------------------------------
// Session-based auth
// ---------------------------------------------------------------------------
const SESSION_USER_ID: &str = "user_id";
#[derive(Debug, Clone)]
pub struct AuthenticatedUser {
pub id: i64,
pub name: String,
pub role: Role,
}
/// Read `user_id` from the session, fetch the `User` from DB, return
/// `AuthenticatedUser` if the user exists and is active.
pub async fn get_session_user(session: &Session, db: &Database) -> Option<AuthenticatedUser> {
let user_id: i64 = session.get(SESSION_USER_ID).await.ok()??;
let user = User::get_by_id(db, user_id).await.ok()??;
if !user.is_active() {
return None;
}
let name = {
let display = user.display_name_str();
if display.is_empty() {
user.username_str().to_owned()
} else {
display
}
};
Some(AuthenticatedUser {
id: user.id_val(),
name,
role: user.role(),
})
}
/// Return `Ok(user)` if the session belongs to an active admin, otherwise
/// `Err(response)` — a redirect to `/login` or a 403.
pub async fn require_admin_or_redirect(
session: &Session,
db: &Database,
) -> Result<AuthenticatedUser, cot::response::Response> {
let user = require_user_or_redirect(session, db).await?;
if user.role != Role::Admin {
return Err("Forbidden"
.with_status(cot::http::StatusCode::FORBIDDEN)
.into_response()
.expect("valid response"));
}
Ok(user)
}
/// Return `Ok(user)` if the session belongs to an active user, otherwise
/// `Err(response)` - a redirect to `/login`.
pub async fn require_user_or_redirect(
session: &Session,
db: &Database,
) -> Result<AuthenticatedUser, cot::response::Response> {
let Some(user) = get_session_user(session, db).await else {
return Err(redirect("/login"));
};
Ok(user)
}
/// Insert user_id into the session and cycle the session ID.
pub async fn login(session: &Session, user_id: i64) -> cot::Result<()> {
session
.cycle_id()
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
session
.insert(SESSION_USER_ID, user_id)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
Ok(())
}
/// Flush (destroy) the session.
pub async fn logout(session: &Session) -> cot::Result<()> {
session
.flush()
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
Ok(())
}
/// Build a 303 See Other redirect response.
pub fn redirect(location: &str) -> cot::response::Response {
cot::http::Response::builder()
.status(cot::http::StatusCode::SEE_OTHER)
.header(cot::http::header::LOCATION, location)
.body(Body::fixed(""))
.expect("valid response")
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn role_roundtrip() {
assert_eq!(Role::from_code("admin"), Some(Role::Admin));
assert_eq!(Role::from_code("client"), Some(Role::Client));
assert_eq!(Role::from_code("other"), None);
assert_eq!(Role::Admin.code(), "admin");
assert_eq!(Role::Client.code(), "client");
}
}
+441
View File
@@ -0,0 +1,441 @@
/// Application-level configuration for amnezia-fellow.
///
/// Every field is available both as an `AMNEZIA_FELLOW_`-prefixed environment
/// variable and through the admin UI. The resolution order is:
///
/// env var > DB override > compiled default
use std::collections::HashMap;
use cot::db::migrations::{self, Field, Operation, SyncDynMigration};
use cot::db::{Database, DatabaseField, Identifier, LimitedString, Model};
use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
// ConfigSource - tracks where each field's effective value came from
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigSource {
Default,
Database,
Env,
}
impl ConfigSource {
pub fn code(self) -> &'static str {
match self {
Self::Default => "default",
Self::Database => "database",
Self::Env => "env",
}
}
}
// ---------------------------------------------------------------------------
// ConfigEntry - DB model for amnezia_fellow__config_entry
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
#[cot::db::model]
pub struct ConfigEntry {
#[model(primary_key)]
key: String,
value: String,
}
impl ConfigEntry {
pub fn new(key: String, value: String) -> Self {
Self { key, value }
}
}
// ---------------------------------------------------------------------------
// Migration
// ---------------------------------------------------------------------------
pub mod db_migrations {
use super::*;
#[derive(Debug, Copy, Clone)]
pub struct M0001CreateConfigEntry;
impl migrations::Migration for M0001CreateConfigEntry {
const APP_NAME: &'static str = "amnezia_fellow";
const MIGRATION_NAME: &'static str = "m_0001_create_config_entry";
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[];
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
.table_name(Identifier::new("amnezia_fellow__config_entry"))
.fields(&[
Field::new(
Identifier::new("key"),
<LimitedString<255> as DatabaseField>::TYPE,
)
.primary_key()
.set_null(<LimitedString<255> as DatabaseField>::NULLABLE),
Field::new(Identifier::new("value"), <String as DatabaseField>::TYPE)
.set_null(<String as DatabaseField>::NULLABLE),
])
.build()];
}
pub const MIGRATIONS: &[&SyncDynMigration] = &[&M0001CreateConfigEntry];
}
// ---------------------------------------------------------------------------
// ConfigSources - parallel struct tracking the source of each field
// ---------------------------------------------------------------------------
pub struct ConfigSources {
pub database_url: ConfigSource,
pub oidc_issuer: ConfigSource,
pub oidc_client_id: ConfigSource,
pub oidc_client_secret: ConfigSource,
pub log_level: ConfigSource,
pub auth_password_enabled: ConfigSource,
pub auth_sso_enabled: ConfigSource,
pub oidc_button_text: ConfigSource,
pub oidc_admin_groups: ConfigSource,
pub oidc_client_groups: ConfigSource,
pub k8s_namespace: ConfigSource,
pub k8s_clients_secret: ConfigSource,
pub k8s_clients_secret_key: ConfigSource,
pub k8s_server_secret: ConfigSource,
pub k8s_endpoints_secret: ConfigSource,
pub vpn_disabled_endpoints: ConfigSource,
pub vpn_endpoint_name_overrides: ConfigSource,
pub vpn_client_cidr: ConfigSource,
pub vpn_dns: ConfigSource,
pub vpn_mtu: ConfigSource,
pub swagger_enabled: ConfigSource,
}
impl Default for ConfigSources {
fn default() -> Self {
Self {
database_url: ConfigSource::Default,
oidc_issuer: ConfigSource::Default,
oidc_client_id: ConfigSource::Default,
oidc_client_secret: ConfigSource::Default,
log_level: ConfigSource::Default,
auth_password_enabled: ConfigSource::Default,
auth_sso_enabled: ConfigSource::Default,
oidc_button_text: ConfigSource::Default,
oidc_admin_groups: ConfigSource::Default,
oidc_client_groups: ConfigSource::Default,
k8s_namespace: ConfigSource::Default,
k8s_clients_secret: ConfigSource::Default,
k8s_clients_secret_key: ConfigSource::Default,
k8s_server_secret: ConfigSource::Default,
k8s_endpoints_secret: ConfigSource::Default,
vpn_disabled_endpoints: ConfigSource::Default,
vpn_endpoint_name_overrides: ConfigSource::Default,
vpn_client_cidr: ConfigSource::Default,
vpn_dns: ConfigSource::Default,
vpn_mtu: ConfigSource::Default,
swagger_enabled: ConfigSource::Default,
}
}
}
// ---------------------------------------------------------------------------
// Env-var helper
// ---------------------------------------------------------------------------
fn env_override<T: std::str::FromStr>(field: &str) -> Option<T> {
let key = format!("AMNEZIA_FELLOW_{}", field.to_ascii_uppercase());
match std::env::var(&key) {
Ok(val) => match val.parse::<T>() {
Ok(v) => Some(v),
Err(_) => {
tracing::warn!("ignoring invalid value for {key}: {val:?}");
None
}
},
Err(_) => None,
}
}
// ---------------------------------------------------------------------------
// Macro: generates apply_env_overrides + apply_env_overrides_tracked
// ---------------------------------------------------------------------------
macro_rules! impl_env_overrides {
($($field:ident),* $(,)?) => {
impl AppConfig {
pub fn apply_env_overrides(&mut self) {
$(
if let Some(v) = env_override(stringify!($field)) {
self.$field = v;
}
)*
}
pub fn apply_env_overrides_tracked(&mut self, sources: &mut ConfigSources) {
$(
if let Some(v) = env_override(stringify!($field)) {
self.$field = v;
sources.$field = ConfigSource::Env;
}
)*
}
}
};
}
// ---------------------------------------------------------------------------
// AppConfig
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
/// SQLite connection URL.
pub database_url: String,
/// OIDC issuer URL.
pub oidc_issuer: String,
/// OIDC client ID.
pub oidc_client_id: String,
/// OIDC client secret.
pub oidc_client_secret: String,
/// Tracing log level filter.
pub log_level: String,
/// Whether password-based login is enabled.
pub auth_password_enabled: bool,
/// Whether SSO (OIDC) login is enabled.
pub auth_sso_enabled: bool,
/// Label shown on the SSO login button.
pub oidc_button_text: String,
/// Comma-separated OIDC group names that grant admin role.
pub oidc_admin_groups: String,
/// Comma-separated OIDC group names that grant client role.
pub oidc_client_groups: String,
/// Kubernetes namespace containing Amnezia secrets.
pub k8s_namespace: String,
/// Secret containing rendered client peers.
pub k8s_clients_secret: String,
/// Data key inside `k8s_clients_secret` used for rendered peers.
pub k8s_clients_secret_key: String,
/// Secret containing server-side Amnezia/WireGuard configuration.
pub k8s_server_secret: String,
/// Secret containing node endpoint data.
pub k8s_endpoints_secret: String,
/// Comma-separated endpoint names hidden from users.
pub vpn_disabled_endpoints: String,
/// JSON object mapping endpoint Secret keys to UI display names.
pub vpn_endpoint_name_overrides: String,
/// CIDR from which new client addresses are allocated.
pub vpn_client_cidr: String,
/// DNS servers written to generated client configs.
pub vpn_dns: String,
/// MTU written to generated client configs.
pub vpn_mtu: u16,
/// Whether the Swagger UI is served at /swagger/.
pub swagger_enabled: bool,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
database_url: "sqlite://amnezia-fellow.sqlite3?mode=rwc".into(),
oidc_issuer: String::new(),
oidc_client_id: String::new(),
oidc_client_secret: String::new(),
log_level: "info".into(),
auth_password_enabled: true,
auth_sso_enabled: false,
oidc_button_text: "Sign in with SSO".into(),
oidc_admin_groups: String::new(),
oidc_client_groups: String::new(),
k8s_namespace: "amnezia".into(),
k8s_clients_secret: "amneziawg-clients".into(),
k8s_clients_secret_key: "peers.conf".into(),
k8s_server_secret: "amneziawg-server".into(),
k8s_endpoints_secret: "amneziawg-endpoints".into(),
vpn_disabled_endpoints: String::new(),
vpn_endpoint_name_overrides: "{}".into(),
vpn_client_cidr: "10.8.0.0/16".into(),
vpn_dns: "1.1.1.1, 8.8.8.8".into(),
vpn_mtu: 1376,
swagger_enabled: false,
}
}
}
impl_env_overrides!(
database_url,
oidc_issuer,
oidc_client_id,
oidc_client_secret,
log_level,
auth_password_enabled,
auth_sso_enabled,
oidc_button_text,
oidc_admin_groups,
oidc_client_groups,
k8s_namespace,
k8s_clients_secret,
k8s_clients_secret_key,
k8s_server_secret,
k8s_endpoints_secret,
vpn_disabled_endpoints,
vpn_endpoint_name_overrides,
vpn_client_cidr,
vpn_dns,
vpn_mtu,
swagger_enabled,
);
impl AppConfig {
/// Build config from defaults, then overlay env vars. Used at startup
/// before the DB is available.
pub fn load() -> Self {
let mut cfg = Self::default();
cfg.apply_env_overrides();
cfg
}
/// Build config with full 3-layer resolution and track each field source.
pub async fn load_with_db(db: &Database) -> (Self, ConfigSources) {
let mut cfg = Self::default();
let mut sources = ConfigSources::default();
cfg.apply_db_overrides(db, &mut sources).await;
cfg.apply_env_overrides_tracked(&mut sources);
(cfg, sources)
}
async fn apply_db_overrides(&mut self, db: &Database, sources: &mut ConfigSources) {
let rows = match ConfigEntry::objects().all(db).await {
Ok(rows) => rows,
Err(e) => {
tracing::warn!("failed to read app config from database: {e}");
return;
}
};
let map: HashMap<String, String> = rows
.into_iter()
.map(|entry| (entry.key.to_string(), entry.value))
.collect();
macro_rules! apply_db_field {
($field:ident) => {
if let Some(val) = map.get(stringify!($field)) {
match val.parse() {
Ok(v) => {
self.$field = v;
sources.$field = ConfigSource::Database;
}
Err(_) => {
tracing::warn!(
"ignoring invalid DB config value for {}: {:?}",
stringify!($field),
val,
);
}
}
}
};
}
apply_db_field!(database_url);
apply_db_field!(oidc_issuer);
apply_db_field!(oidc_client_id);
apply_db_field!(oidc_client_secret);
apply_db_field!(log_level);
apply_db_field!(auth_password_enabled);
apply_db_field!(auth_sso_enabled);
apply_db_field!(oidc_button_text);
apply_db_field!(oidc_admin_groups);
apply_db_field!(oidc_client_groups);
apply_db_field!(k8s_namespace);
apply_db_field!(k8s_clients_secret);
apply_db_field!(k8s_clients_secret_key);
apply_db_field!(k8s_server_secret);
apply_db_field!(k8s_endpoints_secret);
apply_db_field!(vpn_disabled_endpoints);
apply_db_field!(vpn_endpoint_name_overrides);
apply_db_field!(vpn_client_cidr);
apply_db_field!(vpn_dns);
apply_db_field!(vpn_mtu);
apply_db_field!(swagger_enabled);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn defaults_are_sane() {
let cfg = AppConfig::default();
assert_eq!(cfg.database_url, "sqlite://amnezia-fellow.sqlite3?mode=rwc");
assert_eq!(cfg.log_level, "info");
assert_eq!(cfg.vpn_client_cidr, "10.8.0.0/16");
}
struct EnvGuard {
key: &'static str,
}
impl Drop for EnvGuard {
fn drop(&mut self) {
unsafe {
unset(self.key);
}
}
}
// SAFETY: callers hold ENV_LOCK while mutating process-wide env vars.
unsafe fn set(k: &str, v: &str) {
unsafe { std::env::set_var(k, v) };
}
unsafe fn unset(k: &str) {
unsafe { std::env::remove_var(k) };
}
unsafe fn scoped_set(k: &'static str, v: &str) -> EnvGuard {
unsafe {
set(k, v);
}
EnvGuard { key: k }
}
#[test]
fn env_override_string_field() {
let _guard = ENV_LOCK.lock().unwrap();
let _env = unsafe { scoped_set("AMNEZIA_FELLOW_OIDC_ISSUER", "https://example.com") };
let cfg = AppConfig::load();
assert_eq!(cfg.oidc_issuer, "https://example.com");
}
#[test]
fn env_override_bool_field() {
let _guard = ENV_LOCK.lock().unwrap();
let _env = unsafe { scoped_set("AMNEZIA_FELLOW_AUTH_SSO_ENABLED", "true") };
let cfg = AppConfig::load();
assert!(cfg.auth_sso_enabled);
}
#[test]
fn source_tracking_env() {
let _guard = ENV_LOCK.lock().unwrap();
let _env =
unsafe { scoped_set("AMNEZIA_FELLOW_OIDC_ISSUER", "https://tracked.example.com") };
let mut cfg = AppConfig::default();
let mut sources = ConfigSources::default();
cfg.apply_env_overrides_tracked(&mut sources);
assert_eq!(cfg.oidc_issuer, "https://tracked.example.com");
assert_eq!(sources.oidc_issuer, ConfigSource::Env);
assert_eq!(sources.database_url, ConfigSource::Default);
}
#[test]
fn config_source_codes() {
assert_eq!(ConfigSource::Default.code(), "default");
assert_eq!(ConfigSource::Database.code(), "database");
assert_eq!(ConfigSource::Env.code(), "env");
}
}
+224
View File
@@ -0,0 +1,224 @@
mod phrases;
pub use phrases::Translations;
use cot::request::RequestHead;
use cot::request::extractors::FromRequestHead;
use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
// Lang enum
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Lang {
En,
Ru,
}
impl Lang {
pub fn code(self) -> &'static str {
match self {
Lang::En => "en",
Lang::Ru => "ru",
}
}
pub fn from_code(s: &str) -> Option<Self> {
match s {
"en" => Some(Lang::En),
"ru" => Some(Lang::Ru),
_ => None,
}
}
}
// ---------------------------------------------------------------------------
// translations! macro
// ---------------------------------------------------------------------------
macro_rules! translations {
( $( $key:ident : $en:expr , $ru:expr );* $(;)? ) => {
#[derive(Debug)]
pub struct Translations {
pub lang: $crate::i18n::Lang,
$( pub $key: &'static str, )*
}
static EN: Translations = Translations {
lang: $crate::i18n::Lang::En,
$( $key: $en, )*
};
static RU: Translations = Translations {
lang: $crate::i18n::Lang::Ru,
$( $key: $ru, )*
};
impl Translations {
pub fn for_lang(lang: $crate::i18n::Lang) -> &'static Self {
match lang {
$crate::i18n::Lang::En => &EN,
$crate::i18n::Lang::Ru => &RU,
}
}
}
};
}
pub(crate) use translations;
// ---------------------------------------------------------------------------
// Cookie helpers
// ---------------------------------------------------------------------------
const COOKIE_NAME: &str = "amnezia_fellow_lang";
/// Build a `Set-Cookie` header value that persists the language choice for 1 year.
pub fn lang_cookie(lang: Lang) -> String {
format!(
"{COOKIE_NAME}={}; Path=/; SameSite=Lax; Max-Age=31536000",
lang.code()
)
}
/// Parse the language cookie from the `Cookie` request header.
fn lang_from_cookie(headers: &cot::http::HeaderMap) -> Option<Lang> {
let raw = headers.get(cot::http::header::COOKIE)?.to_str().ok()?;
for part in raw.split(';') {
let part = part.trim();
if let Some(value) = part.strip_prefix("amnezia_fellow_lang=") {
return Lang::from_code(value.trim());
}
}
None
}
// ---------------------------------------------------------------------------
// Accept-Language parsing
// ---------------------------------------------------------------------------
/// Parse the Accept-Language header and return the best matching `Lang`.
fn parse_accept_language(header: &str) -> Option<Lang> {
let mut langs: Vec<(&str, u16)> = header
.split(',')
.filter_map(|part| {
let part = part.trim();
let (tag, quality) = if let Some((tag, q)) = part.split_once(";q=") {
let q = q.trim().parse::<f32>().ok()?;
(tag.trim(), (q * 1000.0) as u16)
} else {
(part, 1000)
};
Some((tag, quality))
})
.collect();
langs.sort_by(|a, b| b.1.cmp(&a.1));
for (tag, _) in langs {
let primary = tag.split('-').next().unwrap_or(tag);
if let Some(lang) = Lang::from_code(primary) {
return Some(lang);
}
}
None
}
// ---------------------------------------------------------------------------
// Language resolution
// ---------------------------------------------------------------------------
fn resolve_lang(headers: &cot::http::HeaderMap) -> Lang {
// 1. Explicit cookie override.
if let Some(lang) = lang_from_cookie(headers) {
return lang;
}
// 2. Accept-Language header.
if let Some(value) = headers.get(cot::http::header::ACCEPT_LANGUAGE) {
if let Ok(s) = value.to_str() {
if let Some(lang) = parse_accept_language(s) {
return lang;
}
}
}
// 3. Default.
Lang::En
}
// ---------------------------------------------------------------------------
// I18n extractor
// ---------------------------------------------------------------------------
pub struct I18n {
pub t: &'static Translations,
}
impl FromRequestHead for I18n {
async fn from_request_head(head: &RequestHead) -> cot::Result<Self> {
let lang = resolve_lang(&head.headers);
Ok(I18n {
t: Translations::for_lang(lang),
})
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lang_roundtrip() {
assert_eq!(Lang::from_code("en"), Some(Lang::En));
assert_eq!(Lang::from_code("ru"), Some(Lang::Ru));
assert_eq!(Lang::from_code("de"), None);
assert_eq!(Lang::En.code(), "en");
assert_eq!(Lang::Ru.code(), "ru");
}
#[test]
fn parse_simple_accept_language() {
assert_eq!(parse_accept_language("ru"), Some(Lang::Ru));
assert_eq!(parse_accept_language("en-US"), Some(Lang::En));
}
#[test]
fn parse_weighted_accept_language() {
assert_eq!(
parse_accept_language("en-US,en;q=0.9,ru;q=0.8"),
Some(Lang::En)
);
assert_eq!(
parse_accept_language("ru-RU,ru;q=0.9,en;q=0.5"),
Some(Lang::Ru)
);
}
#[test]
fn parse_unknown_falls_through() {
assert_eq!(parse_accept_language("de;q=1.0,ru;q=0.5"), Some(Lang::Ru));
assert_eq!(parse_accept_language("de,fr,ja"), None);
}
#[test]
fn cookie_parsing() {
let mut headers = cot::http::HeaderMap::new();
headers.insert(
cot::http::header::COOKIE,
"other=x; amnezia_fellow_lang=ru; foo=bar".parse().unwrap(),
);
assert_eq!(lang_from_cookie(&headers), Some(Lang::Ru));
}
#[test]
fn cookie_missing() {
let headers = cot::http::HeaderMap::new();
assert_eq!(lang_from_cookie(&headers), None);
}
}
+135
View File
@@ -0,0 +1,135 @@
use super::translations;
translations! {
// Global
site_name: "amnezia-fellow" , "amnezia-fellow";
// Navigation / sidebar
nav_admin: "admin" , "админка";
nav_configs: "Configs" , "Конфиги";
nav_dashboard: "Dashboard" , "Панель управления";
nav_debug: "Debug" , "Отладка";
nav_servers: "Servers" , "Серверы";
admin_close: "Close" , "Закрыть";
debug_field: "Field" , "Поле";
debug_value: "Value" , "Значение";
debug_source: "Source" , "Источник";
// Navigation (settings)
nav_settings: "Settings" , "Настройки";
// Debug page — DB status
debug_db_status: "Database" , "База данных";
debug_db_connected: "connected" , "подключена";
debug_db_error: "error" , "ошибка";
settings_oidc: "OIDC Configuration" , "Настройки OIDC";
settings_save: "Save" , "Сохранить";
settings_saved: "Settings saved." , "Настройки сохранены.";
// Auth settings
settings_auth: "Authentication" , "Аутентификация";
settings_password_login: "Password login" , "Вход по паролю";
settings_sso_login: "SSO login" , "Вход через SSO";
settings_oidc_button: "SSO button text" , "Текст кнопки SSO";
// Login page
login_heading: "Sign in" , "Вход";
login_username: "Username" , "Имя пользователя";
login_password: "Password" , "Пароль";
login_submit: "Sign in" , "Войти";
login_disabled: "Login is currently disabled." , "Вход сейчас отключён.";
login_invalid: "Invalid username or password." , "Неверное имя пользователя или пароль.";
// Logout
nav_logout: "Logout" , "Выход";
// Setup page
setup_heading: "Create Admin Account" , "Создание аккаунта администратора";
setup_username: "Username" , "Имя пользователя";
setup_password: "Password" , "Пароль";
setup_confirm: "Confirm password" , "Подтверждение пароля";
setup_submit: "Create" , "Создать";
setup_mismatch: "Passwords do not match." , "Пароли не совпадают.";
// OIDC help
settings_oidc_admin_groups: "Admin groups" , "Группы администраторов";
settings_oidc_client_groups: "Client groups" , "Группы клиентов";
// Kubernetes / VPN settings
settings_kubernetes: "Kubernetes" , "Kubernetes";
settings_vpn: "VPN" , "VPN";
// User management
nav_users: "Users" , "Пользователи";
users_heading: "Users" , "Пользователи";
users_add: "Add user" , "Добавить пользователя";
users_username: "Username" , "Имя пользователя";
users_email: "Email" , "Email";
users_display_name: "Display name" , "Отображаемое имя";
users_role: "Role" , "Роль";
users_active: "Active" , "Активен";
users_actions: "Actions" , "Действия";
users_edit: "Edit" , "Редактировать";
users_delete: "Delete" , "Удалить";
users_delete_confirm: "Are you sure?" , "Вы уверены?";
users_new_heading: "New user" , "Новый пользователь";
users_edit_heading: "Edit user" , "Редактирование пользователя";
users_password_hint: "Leave blank to keep current" , "Оставьте пустым, чтобы не менять";
// VPN configs
configs_heading: "VPN configs" , "VPN-конфиги";
configs_name: "Name" , "Имя";
configs_owner: "Owner" , "Владелец";
configs_address: "Address" , "Адрес";
configs_public_key: "Public key" , "Публичный ключ";
configs_enabled: "Enabled" , "Включён";
configs_create: "Create" , "Создать";
configs_sync: "Sync Secret" , "Синхронизировать Secret";
configs_rollout_heading: "Apply status" , "Статус применения";
configs_refresh: "Refresh" , "Обновить";
configs_config_updated: "Config updated" , "Конфиг обновлён";
configs_loading_status: "Loading status..." , "Загрузка статуса...";
configs_no_pods: "No AmneziaWG pods." , "Нет подов AmneziaWG.";
configs_server: "Server" , "Сервер";
configs_pod: "Pod" , "Pod";
configs_rollout: "Rollout" , "Применение";
configs_ready: "Ready" , "Готов";
configs_not_ready: "Not ready" , "Не готов";
configs_phase: "Phase" , "Фаза";
configs_uptime: "Uptime" , "Аптайм";
configs_restarts: "Restarts" , "Рестарты";
configs_status_applied: "applied" , "применён";
configs_status_starting: "starting" , "стартует";
configs_status_pending_restart: "pending restart" , "ждёт рестарт";
configs_status_unknown: "unknown" , "неизвестно";
configs_never: "n/a" , "н/д";
configs_servers: "Servers" , "Серверы";
configs_loading_servers: "Loading servers..." , "Загрузка серверов...";
configs_no_servers: "No registered servers." , "Нет зарегистрированных серверов.";
configs_server_search: "Filter servers" , "Фильтр серверов";
configs_download: "Download" , "Скачать";
configs_copy_vpn_url: "Copy vpn://" , "Копировать vpn://";
configs_vpn_url_copied: "VPN link copied" , "VPN-ссылка скопирована";
configs_enable: "Enable" , "Включить";
configs_disable: "Disable" , "Отключить";
configs_yes: "yes" , "да";
configs_no: "no" , "нет";
configs_empty: "No configs yet." , "Конфигов пока нет.";
configs_name_placeholder: "Client name" , "Имя клиента";
// VPN server management
servers_empty: "No registered servers." , "Нет зарегистрированных серверов.";
servers_display_name: "Display name" , "Отображаемое имя";
servers_technical_name: "Technical name" , "Техническое имя";
servers_endpoint: "Endpoint" , "Endpoint";
// API settings
settings_api: "API" , "API";
settings_swagger: "Swagger UI" , "Swagger UI";
// OIDC login errors
login_oidc_error: "SSO login failed. Please try again." , "Ошибка входа через SSO. Попробуйте ещё раз.";
login_sso_disabled: "SSO login is not configured." , "Вход через SSO не настроен.";
}
+384
View File
@@ -0,0 +1,384 @@
mod admin;
mod api;
mod auth;
mod config;
mod i18n;
mod oidc;
mod user;
mod vpn;
use std::sync::Arc;
use cot::auth::PasswordVerificationResult;
use cot::cli::CliMetadata;
use cot::common_types::Password;
use cot::config::{
DatabaseConfig, MiddlewareConfig, ProjectConfig, SessionMiddlewareConfig, SessionStoreConfig,
SessionStoreTypeConfig,
};
use cot::db::Database;
use cot::form::{Form, FormResult};
use cot::html::Html;
use cot::middleware::SessionMiddleware;
use cot::project::RegisterAppsContext;
use cot::request::extractors::{RequestForm, UrlQuery};
use cot::response::IntoResponse;
use cot::router::method::get;
use cot::router::{Route, Router};
use cot::session::Session;
use cot::static_files::StaticFilesMiddleware;
use cot::{App, AppBuilder, Body, Project, Template};
use serde::Deserialize;
use crate::config::AppConfig;
use crate::i18n::{I18n, Translations};
use crate::user::User;
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
async fn index(session: Session, db: Database) -> cot::Result<cot::response::Response> {
if auth::get_session_user(&session, &db).await.is_none() {
return Ok(auth::redirect("/login"));
}
Ok(auth::redirect("/configs"))
}
#[derive(Debug, Template)]
#[template(path = "configs.html")]
struct ConfigsTemplate {
t: &'static Translations,
user_name: String,
user_role: String,
is_admin: bool,
app_version: &'static str,
}
async fn configs_page(
session: Session,
db: Database,
i18n: I18n,
) -> cot::Result<cot::response::Response> {
let user = match auth::require_user_or_redirect(&session, &db).await {
Ok(user) => user,
Err(response) => return Ok(response),
};
Html::new(
ConfigsTemplate {
t: i18n.t,
user_name: user.name,
user_role: user.role.code().to_owned(),
is_admin: user.role == auth::Role::Admin,
app_version: env!("CARGO_PKG_VERSION"),
}
.render()?,
)
.into_response()
}
#[derive(Deserialize)]
struct SetLangQuery {
lang: String,
next: Option<String>,
}
async fn set_lang(
UrlQuery(query): UrlQuery<SetLangQuery>,
) -> cot::Result<cot::http::Response<Body>> {
let lang = i18n::Lang::from_code(&query.lang).unwrap_or(i18n::Lang::En);
let next = query.next.as_deref().unwrap_or("/");
let response = cot::http::Response::builder()
.status(cot::http::StatusCode::SEE_OTHER)
.header(cot::http::header::LOCATION, next)
.header(cot::http::header::SET_COOKIE, i18n::lang_cookie(lang))
.body(Body::fixed(""))
.expect("valid response");
Ok(response)
}
// ---------------------------------------------------------------------------
// Login page
// ---------------------------------------------------------------------------
#[derive(Debug, Template)]
#[template(path = "login.html")]
struct LoginTemplate {
t: &'static Translations,
auth_password_enabled: bool,
auth_sso_enabled: bool,
oidc_button_text: String,
message: String,
}
async fn login_page_handler(
i18n: I18n,
_startup_config: &AppConfig,
db: Database,
message: String,
) -> cot::Result<Html> {
let (config, _) = AppConfig::load_with_db(&db).await;
let template = LoginTemplate {
t: i18n.t,
auth_password_enabled: config.auth_password_enabled,
auth_sso_enabled: config.auth_sso_enabled,
oidc_button_text: config.oidc_button_text,
message,
};
Ok(Html::new(template.render()?))
}
#[derive(Debug, Form)]
struct LoginForm {
username: String,
password: String,
}
// ---------------------------------------------------------------------------
// Logout
// ---------------------------------------------------------------------------
async fn logout_handler(session: Session) -> cot::Result<cot::response::Response> {
auth::logout(&session).await?;
Ok(auth::redirect("/login"))
}
// ---------------------------------------------------------------------------
// App
// ---------------------------------------------------------------------------
struct AmneziaFellowApp {
config: Arc<AppConfig>,
}
impl App for AmneziaFellowApp {
fn name(&self) -> &'static str {
env!("CARGO_PKG_NAME")
}
fn router(&self) -> Router {
Router::with_urls([
Route::with_handler_and_name(
"/admin",
get(|| async { Ok::<_, cot::Error>(auth::redirect("/admin/")) }),
"admin_redirect",
),
Route::with_handler_and_name(
"/swagger",
get(|| async { Ok::<_, cot::Error>(auth::redirect("/swagger/")) }),
"swagger_redirect",
),
Route::with_handler_and_name("/", index, "index"),
Route::with_handler_and_name("/configs", get(configs_page), "configs"),
Route::with_handler_and_name(
"/login",
get({
let config = Arc::clone(&self.config);
move |i18n: I18n, db: Database| {
let config = Arc::clone(&config);
async move {
// No users at all → redirect to first-run setup
if User::count_all(&db).await.unwrap_or(0) == 0 {
return Ok(auth::redirect("/admin/setup"));
}
login_page_handler(i18n, &config, db, String::new())
.await?
.into_response()
}
}
})
.post({
let config = Arc::clone(&self.config);
move |i18n: I18n,
db: Database,
session: Session,
form: RequestForm<LoginForm>| {
let config = Arc::clone(&config);
async move {
let RequestForm(result) = form;
let data = match result {
FormResult::Ok(data) => data,
FormResult::ValidationError(_) => {
let msg = i18n.t.login_invalid.to_owned();
return login_page_handler(i18n, &config, db, msg)
.await?
.into_response();
}
};
// Try to authenticate
if let Ok(Some(user)) = User::get_by_username(&db, &data.username).await
{
if let Some(hash) = user.password_ref() {
let password = Password::new(&data.password);
match hash.verify(&password) {
PasswordVerificationResult::Ok
| PasswordVerificationResult::OkObsolete(_) => {
auth::login(&session, user.id_val()).await?;
return Ok(auth::redirect("/"));
}
PasswordVerificationResult::Invalid => {}
}
}
}
let msg = i18n.t.login_invalid.to_owned();
login_page_handler(i18n, &config, db, msg)
.await?
.into_response()
}
}
}),
"login",
),
Route::with_handler_and_name("/logout", get(logout_handler), "logout"),
Route::with_handler_and_name("/set-lang", set_lang, "set_lang"),
Route::with_handler_and_name(
"/auth/oidc/start",
get(oidc::oidc_start_handler),
"oidc_start",
),
Route::with_handler_and_name(
"/auth/oidc/callback",
get(oidc::oidc_callback_handler),
"oidc_callback",
),
])
}
}
// ---------------------------------------------------------------------------
// Project
// ---------------------------------------------------------------------------
struct AmneziaFellowProject {
app_config: Arc<AppConfig>,
}
impl Project for AmneziaFellowProject {
fn cli_metadata(&self) -> CliMetadata {
CliMetadata {
description: concat!(
env!("CARGO_PKG_DESCRIPTION"),
"\n\n",
"CONFIGURATION\n",
" All settings are available as AMNEZIA_FELLOW_-prefixed environment variables.\n",
" Priority: env var > DB override > compiled default.\n",
"\n",
" Database (required for most features):\n",
" AMNEZIA_FELLOW_DATABASE_URL SQLite connection URL\n",
" Example: sqlite:///data/amnezia-fellow.sqlite3?mode=rwc\n",
"\n",
" Server:\n",
" AMNEZIA_FELLOW_LOG_LEVEL Tracing filter (default: info)\n",
"\n",
" Authentication:\n",
" AMNEZIA_FELLOW_AUTH_PASSWORD_ENABLED Enable password login (default: true)\n",
" AMNEZIA_FELLOW_AUTH_SSO_ENABLED Enable SSO/OIDC login (default: false)\n",
" AMNEZIA_FELLOW_OIDC_ISSUER OIDC issuer URL\n",
" AMNEZIA_FELLOW_OIDC_CLIENT_ID OIDC client ID\n",
" AMNEZIA_FELLOW_OIDC_CLIENT_SECRET OIDC client secret\n",
" AMNEZIA_FELLOW_OIDC_BUTTON_TEXT SSO button label\n",
" AMNEZIA_FELLOW_OIDC_ADMIN_GROUPS OIDC groups that grant admin role\n",
" AMNEZIA_FELLOW_OIDC_CLIENT_GROUPS OIDC groups that grant client role\n",
"\n",
" Kubernetes:\n",
" AMNEZIA_FELLOW_K8S_NAMESPACE Namespace with Amnezia secrets\n",
" AMNEZIA_FELLOW_K8S_CLIENTS_SECRET Client peer Secret name\n",
" AMNEZIA_FELLOW_VPN_CLIENT_CIDR Client address pool\n",
"\n",
" API:\n",
" AMNEZIA_FELLOW_SWAGGER_ENABLED Enable Swagger UI at /swagger/ (default: false)\n",
"\n",
"QUICK START\n",
" export AMNEZIA_FELLOW_DATABASE_URL=sqlite://amnezia-fellow.sqlite3?mode=rwc\n",
" amnezia-fellow --listen 127.0.0.1:8000",
),
..cot::cli::metadata!()
}
}
fn config(&self, _config_name: &str) -> cot::Result<ProjectConfig> {
let mut builder = ProjectConfig::builder();
builder.debug(cfg!(debug_assertions));
if !self.app_config.database_url.is_empty() {
builder.database(
DatabaseConfig::builder()
.url(self.app_config.database_url.as_str())
.build(),
);
builder.middlewares(
MiddlewareConfig::builder()
.session(
SessionMiddlewareConfig::builder()
.store(
SessionStoreConfig::builder()
.store_type(SessionStoreTypeConfig::Database)
.build(),
)
.build(),
)
.build(),
);
}
Ok(builder.build())
}
fn middlewares(
&self,
handler: cot::project::RootHandlerBuilder,
context: &cot::project::MiddlewareContext,
) -> cot::project::RootHandler {
handler
.middleware(StaticFilesMiddleware::from_context(context))
.middleware(
SessionMiddleware::from_context(context).same_site(cot::config::SameSite::Lax),
)
.build()
}
fn register_apps(&self, apps: &mut AppBuilder, _context: &RegisterAppsContext) {
apps.register(cot::session::db::SessionApp::new());
apps.register_with_views(
AmneziaFellowApp {
config: Arc::clone(&self.app_config),
},
"",
);
apps.register_with_views(admin::AdminApp::new(), "/admin");
apps.register_with_views(api::ApiApp, "/api");
if self.app_config.swagger_enabled {
apps.register_with_views(cot::openapi::swagger_ui::SwaggerUi::new(), "/swagger");
}
}
}
// ---------------------------------------------------------------------------
// Entrypoint
// ---------------------------------------------------------------------------
#[cot::main]
fn main() -> impl Project {
let app_config = Arc::new(AppConfig::load());
// Initialise tracing subscriber with the configured log level.
// AMNEZIA_FELLOW_LOG_LEVEL (or the default "info") is parsed as an
// EnvFilter directive, so values like "debug" all work.
let filter =
tracing_subscriber::EnvFilter::try_new(&app_config.log_level).unwrap_or_else(|e| {
eprintln!(
"WARNING: invalid AMNEZIA_FELLOW_LOG_LEVEL {:?}: {e}; falling back to \"info\"",
app_config.log_level,
);
tracing_subscriber::EnvFilter::new("info")
});
tracing_subscriber::fmt().with_env_filter(filter).init();
tracing::info!("loaded config: {:?}", app_config);
AmneziaFellowProject { app_config }
}
+589
View File
@@ -0,0 +1,589 @@
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::sync::LazyLock;
use std::time::Instant;
use cot::db::Database;
use cot::session::Session;
use openidconnect::core::{CoreClient, CoreProviderMetadata};
use openidconnect::{
AuthorizationCode, ClientId, ClientSecret, CsrfToken, EndpointMaybeSet, EndpointNotSet,
EndpointSet, IssuerUrl, Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope,
};
use cot::request::RequestHead;
use cot::request::extractors::FromRequestHead;
use crate::auth;
use crate::config::AppConfig;
use crate::i18n::I18n;
use crate::user::{OidcLink, User};
// ---------------------------------------------------------------------------
// Request origin extractor (scheme + host from headers)
// ---------------------------------------------------------------------------
/// Extracts the origin (e.g. "http://127.0.0.1:3001") from the request so we
/// can build the correct OIDC redirect URI.
pub struct RequestOrigin(pub String);
impl FromRequestHead for RequestOrigin {
async fn from_request_head(head: &RequestHead) -> cot::Result<Self> {
let scheme = head
.headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.unwrap_or("http");
let host = head
.headers
.get(cot::http::header::HOST)
.and_then(|v| v.to_str().ok())
.unwrap_or("localhost");
Ok(RequestOrigin(format!("{scheme}://{host}")))
}
}
// ---------------------------------------------------------------------------
// Session keys for OIDC flow state
// ---------------------------------------------------------------------------
const SESSION_CSRF_STATE: &str = "oidc_csrf_state";
const SESSION_NONCE: &str = "oidc_nonce";
const SESSION_PKCE_VERIFIER: &str = "oidc_pkce_verifier";
const SESSION_REDIRECT_URI: &str = "oidc_redirect_uri";
// ---------------------------------------------------------------------------
// Provider cache
// ---------------------------------------------------------------------------
/// Concrete client type returned by `from_provider_metadata` + `set_redirect_uri`.
/// The provider metadata discovery sets auth URL to EndpointSet, and token/userinfo
/// endpoints to EndpointMaybeSet. The remaining endpoints stay EndpointNotSet.
type ConfiguredClient = CoreClient<
EndpointSet,
EndpointNotSet,
EndpointNotSet,
EndpointNotSet,
EndpointMaybeSet,
EndpointMaybeSet,
>;
struct CachedProvider {
client: ConfiguredClient,
fetched_at: Instant,
config_hash: u64,
}
static PROVIDER_CACHE: LazyLock<tokio::sync::RwLock<Option<CachedProvider>>> =
LazyLock::new(|| tokio::sync::RwLock::new(None));
/// TTL for cached provider metadata (1 hour).
const PROVIDER_TTL_SECS: u64 = 3600;
/// Compute a hash of the OIDC configuration values so we can detect changes.
fn config_hash(issuer: &str, client_id: &str, client_secret: &str) -> u64 {
let mut hasher = DefaultHasher::new();
issuer.hash(&mut hasher);
client_id.hash(&mut hasher);
client_secret.hash(&mut hasher);
hasher.finish()
}
fn oidc_http_client() -> reqwest::Client {
reqwest::ClientBuilder::new()
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("valid reqwest client")
}
/// Get or refresh the cached OIDC provider. Returns a cloned `ConfiguredClient`.
async fn get_or_refresh_provider(
config: &AppConfig,
http: &reqwest::Client,
) -> Result<ConfiguredClient, String> {
let hash = config_hash(
&config.oidc_issuer,
&config.oidc_client_id,
&config.oidc_client_secret,
);
// Fast path: check if we have a valid cached provider.
{
let cache = PROVIDER_CACHE.read().await;
if let Some(ref cached) = *cache {
if cached.config_hash == hash
&& cached.fetched_at.elapsed().as_secs() < PROVIDER_TTL_SECS
{
return Ok(cached.client.clone());
}
}
}
// Slow path: discover provider metadata + JWKS.
// Strip /.well-known/openid-configuration suffix if the user pasted the
// full discovery URL, so discover_async doesn't double-append it.
let issuer = config
.oidc_issuer
.trim_end_matches('/')
.strip_suffix("/.well-known/openid-configuration")
.unwrap_or(config.oidc_issuer.trim_end_matches('/'))
.to_owned();
let issuer_url = IssuerUrl::new(issuer).map_err(|e| format!("invalid issuer URL: {e}"))?;
let metadata = CoreProviderMetadata::discover_async(issuer_url, http)
.await
.map_err(|e| format!("OIDC discovery failed: {e}"))?;
let client = CoreClient::from_provider_metadata(
metadata,
ClientId::new(config.oidc_client_id.clone()),
Some(ClientSecret::new(config.oidc_client_secret.clone())),
);
let mut cache = PROVIDER_CACHE.write().await;
*cache = Some(CachedProvider {
client: client.clone(),
fetched_at: Instant::now(),
config_hash: hash,
});
Ok(client)
}
// ---------------------------------------------------------------------------
// GET /auth/oidc/start
// ---------------------------------------------------------------------------
pub async fn oidc_start_handler(
origin: RequestOrigin,
i18n: I18n,
db: Database,
session: Session,
) -> cot::Result<cot::response::Response> {
let (config, _) = AppConfig::load_with_db(&db).await;
// Validate SSO is enabled and configured.
if !config.auth_sso_enabled
|| config.oidc_issuer.is_empty()
|| config.oidc_client_id.is_empty()
|| config.oidc_client_secret.is_empty()
{
tracing::warn!("OIDC start requested but SSO is not configured");
return redirect_login_with_error(i18n.t.login_sso_disabled);
}
let http = oidc_http_client();
let client = match get_or_refresh_provider(&config, &http).await {
Ok(c) => c,
Err(e) => {
tracing::error!("OIDC provider error: {e}");
return redirect_login_with_error(i18n.t.login_oidc_error);
}
};
// Build redirect URI from the actual request origin.
let redirect_uri_str = format!("{}/auth/oidc/callback", origin.0);
let redirect_url = RedirectUrl::new(redirect_uri_str.clone())
.map_err(|e| cot::Error::internal(format!("bad redirect URI: {e}")))?;
let client = client.set_redirect_uri(redirect_url);
// Build PKCE challenge.
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
// Build authorization URL.
// The openid scope is added automatically by the crate; only add email + profile.
let (auth_url, csrf_state, nonce) = client
.authorize_url(
openidconnect::AuthenticationFlow::<openidconnect::core::CoreResponseType>::AuthorizationCode,
CsrfToken::new_random,
Nonce::new_random,
)
.add_scope(Scope::new("email".to_string()))
.add_scope(Scope::new("profile".to_string()))
.set_pkce_challenge(pkce_challenge)
.url();
// Store OIDC flow state in the session.
session
.insert(SESSION_CSRF_STATE, csrf_state.secret().clone())
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
session
.insert(SESSION_NONCE, nonce.secret().clone())
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
session
.insert(SESSION_PKCE_VERIFIER, pkce_verifier.secret().clone())
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
session
.insert(SESSION_REDIRECT_URI, redirect_uri_str)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
Ok(auth::redirect(auth_url.as_str()))
}
// ---------------------------------------------------------------------------
// GET /auth/oidc/callback
// ---------------------------------------------------------------------------
use serde::Deserialize;
#[derive(Deserialize)]
pub struct OidcCallbackQuery {
code: String,
state: String,
}
pub async fn oidc_callback_handler(
i18n: I18n,
db: Database,
session: Session,
cot::request::extractors::UrlQuery(query): cot::request::extractors::UrlQuery<
OidcCallbackQuery,
>,
) -> cot::Result<cot::response::Response> {
let (config, _) = AppConfig::load_with_db(&db).await;
// Retrieve OIDC flow state from the session.
let saved_csrf: Option<String> = session
.get(SESSION_CSRF_STATE)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let saved_nonce: Option<String> = session
.get(SESSION_NONCE)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let saved_pkce: Option<String> = session
.get(SESSION_PKCE_VERIFIER)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let saved_redirect_uri: Option<String> = session
.get(SESSION_REDIRECT_URI)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
// Validate CSRF state.
let Some(saved_csrf) = saved_csrf else {
tracing::warn!("OIDC callback: no CSRF state in session");
return redirect_login_with_error(i18n.t.login_oidc_error);
};
if query.state != saved_csrf {
tracing::warn!("OIDC callback: CSRF state mismatch");
return redirect_login_with_error(i18n.t.login_oidc_error);
}
let Some(nonce_str) = saved_nonce else {
tracing::warn!("OIDC callback: no nonce in session");
return redirect_login_with_error(i18n.t.login_oidc_error);
};
let Some(pkce_str) = saved_pkce else {
tracing::warn!("OIDC callback: no PKCE verifier in session");
return redirect_login_with_error(i18n.t.login_oidc_error);
};
let nonce = Nonce::new(nonce_str);
let pkce_verifier = PkceCodeVerifier::new(pkce_str);
let http = oidc_http_client();
let client = match get_or_refresh_provider(&config, &http).await {
Ok(c) => c,
Err(e) => {
tracing::error!("OIDC provider error during callback: {e}");
return redirect_login_with_error(i18n.t.login_oidc_error);
}
};
// Restore the redirect URI that was used in the authorization request.
let client = if let Some(ref uri) = saved_redirect_uri {
let redirect_url = RedirectUrl::new(uri.clone())
.map_err(|e| cot::Error::internal(format!("bad redirect URI from session: {e}")))?;
client.set_redirect_uri(redirect_url)
} else {
client
};
// Exchange code for tokens.
let token_request = match client.exchange_code(AuthorizationCode::new(query.code.clone())) {
Ok(req) => req,
Err(e) => {
tracing::error!("OIDC token endpoint not configured: {e}");
return redirect_login_with_error(i18n.t.login_oidc_error);
}
};
let token_response = token_request
.set_pkce_verifier(pkce_verifier)
.request_async(&http)
.await;
let token_response = match token_response {
Ok(t) => t,
Err(e) => {
tracing::error!("OIDC token exchange failed: {e}");
return redirect_login_with_error(i18n.t.login_oidc_error);
}
};
// Verify and extract ID token claims.
use openidconnect::TokenResponse;
let id_token = match token_response.id_token() {
Some(t) => t,
None => {
tracing::error!("OIDC response missing ID token");
return redirect_login_with_error(i18n.t.login_oidc_error);
}
};
let claims = match id_token.claims(&client.id_token_verifier(), &nonce) {
Ok(c) => c,
Err(e) => {
tracing::error!("OIDC ID token verification failed: {e}");
return redirect_login_with_error(i18n.t.login_oidc_error);
}
};
let sub = claims.subject().to_string();
let issuer = claims.issuer().to_string();
let email = claims.email().map(|e| e.to_string());
let name = claims
.name()
.and_then(|n| n.get(None))
.map(|n| n.to_string());
// Extract groups from the raw JWT payload (second dot-separated segment).
// The token is already signature-verified above, so we only need to decode
// the payload to read the non-standard `groups` claim.
let groups: Vec<String> = (|| {
use base64::Engine;
let raw = id_token.to_string();
let payload_b64 = raw.split('.').nth(1)?;
// JWT payloads use URL-safe base64; try without padding first, then
// fall back to the padded variant (some providers add trailing '=').
let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload_b64)
.or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload_b64))
.ok()?;
let value: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?;
let arr = value.get("groups")?.as_array()?;
Some(
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect(),
)
})()
.unwrap_or_default();
tracing::info!(
"OIDC login: sub={sub}, groups={groups:?}, admin_groups={:?}, client_groups={:?}",
config.oidc_admin_groups,
config.oidc_client_groups,
);
// User provisioning logic.
let user = match provision_user(
&db,
&issuer,
&sub,
email.as_deref(),
name.as_deref(),
&groups,
&config.oidc_admin_groups,
&config.oidc_client_groups,
)
.await
{
Ok(u) => u,
Err(e) => {
tracing::error!("OIDC user provisioning failed: {e}");
return redirect_login_with_error(i18n.t.login_oidc_error);
}
};
// Log the user in.
auth::login(&session, user.id_val()).await?;
// Clear OIDC session keys.
let _: Option<String> = session
.remove(SESSION_CSRF_STATE)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let _: Option<String> = session
.remove(SESSION_NONCE)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let _: Option<String> = session
.remove(SESSION_PKCE_VERIFIER)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let _: Option<String> = session
.remove(SESSION_REDIRECT_URI)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
Ok(auth::redirect("/"))
}
// ---------------------------------------------------------------------------
// User provisioning
// ---------------------------------------------------------------------------
/// Resolve the role based on strict OIDC group membership.
/// Users outside both configured group sets are denied.
fn resolve_role(
groups: &[String],
admin_groups: &str,
client_groups: &str,
) -> Result<&'static str, String> {
let admin_set: std::collections::HashSet<&str> = admin_groups
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
for g in groups {
if admin_set.contains(g.as_str()) {
return Ok(auth::Role::Admin.code());
}
}
let client_set: std::collections::HashSet<&str> = client_groups
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
for g in groups {
if client_set.contains(g.as_str()) {
return Ok(auth::Role::Client.code());
}
}
Err("OIDC user is not a member of an allowed group".to_owned())
}
async fn provision_user(
db: &Database,
issuer: &str,
sub: &str,
email: Option<&str>,
name: Option<&str>,
groups: &[String],
admin_groups: &str,
client_groups: &str,
) -> Result<User, String> {
let role = resolve_role(groups, admin_groups, client_groups)?;
// 1. Check for existing OIDC link.
if let Some(mut link) = OidcLink::find_by_issuer_sub(db, issuer, sub)
.await
.map_err(|e| format!("DB error finding OIDC link: {e}"))?
{
// Fetch the linked user.
match User::get_by_id(db, link.user_id()).await {
Ok(Some(mut user)) => {
// Update cached claims.
link.update_claims(db, email, name)
.await
.map_err(|e| format!("DB error updating OIDC link: {e}"))?;
// Always update role on login.
user.update_role(db, role)
.await
.map_err(|e| format!("DB error updating user role: {e}"))?;
return Ok(user);
}
Ok(None) => {
// User was deleted but the OIDC link is stale — remove it
// and fall through to re-create the user below.
tracing::warn!(
"OIDC link points to deleted user {}; removing stale link",
link.user_id(),
);
link.delete(db)
.await
.map_err(|e| format!("DB error deleting stale OIDC link: {e}"))?;
}
Err(e) => return Err(format!("DB error fetching user: {e}")),
}
}
// 2. No existing link — try to find a user by email.
if let Some(email_str) = email {
if let Some(mut user) = User::get_by_email(db, email_str)
.await
.map_err(|e| format!("DB error finding user by email: {e}"))?
{
// Create OIDC link for existing user.
OidcLink::create_link(db, user.id_val(), issuer, sub, email, name)
.await
.map_err(|e| format!("DB error creating OIDC link: {e}"))?;
user.update_role(db, role)
.await
.map_err(|e| format!("DB error updating user role: {e}"))?;
return Ok(user);
}
}
// 3. Create a brand-new user + OIDC link.
// Generate a unique username from the sub or email.
let username = if let Some(email_str) = email {
email_str.split('@').next().unwrap_or(sub).to_owned()
} else {
sub.to_owned()
};
// Ensure username uniqueness by appending a suffix if needed.
let mut candidate = username.clone();
let mut suffix = 0u32;
loop {
match User::get_by_username(db, &candidate).await {
Ok(None) => break,
Ok(Some(_)) => {
suffix += 1;
candidate = format!("{username}_{suffix}");
}
Err(e) => return Err(format!("DB error checking username: {e}")),
}
}
let user = User::create_oidc(db, &candidate, email, name, role)
.await
.map_err(|e| format!("DB error creating user: {e}"))?;
OidcLink::create_link(db, user.id_val(), issuer, sub, email, name)
.await
.map_err(|e| format!("DB error creating OIDC link: {e}"))?;
Ok(user)
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn redirect_login_with_error(message: &str) -> cot::Result<cot::response::Response> {
let encoded = urlencoded(message);
Ok(auth::redirect(&format!("/login?error={encoded}")))
}
/// Minimal percent-encoding for query parameter values.
fn urlencoded(s: &str) -> String {
let mut out = String::with_capacity(s.len() * 2);
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
_ => {
out.push('%');
out.push_str(&format!("{b:02X}"));
}
}
}
out
}
+399
View File
@@ -0,0 +1,399 @@
use cot::auth::PasswordHash;
use cot::common_types::Password;
use cot::db::{Auto, Database, LimitedString, Model};
// ---------------------------------------------------------------------------
// User model
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
#[cot::db::model]
pub struct User {
#[model(primary_key)]
id: Auto<i64>,
#[model(unique)]
username: LimitedString<255>,
password: Option<String>,
email: Option<String>,
display_name: Option<String>,
avatar_url: Option<String>,
role: LimitedString<32>,
is_active: bool,
}
// ---------------------------------------------------------------------------
// User helper methods
// ---------------------------------------------------------------------------
impl User {
/// List all users.
pub async fn list_all(db: &Database) -> cot::db::Result<Vec<Self>> {
Self::objects().all(db).await
}
/// Get a user by primary key.
pub async fn get_by_id(db: &Database, user_id: i64) -> cot::db::Result<Option<Self>> {
Self::get_by_primary_key(db, Auto::Fixed(user_id)).await
}
/// Create a new user and insert it into the database.
pub async fn create(
db: &Database,
username: &str,
email: Option<&str>,
display_name: Option<&str>,
password: &str,
role: &str,
) -> cot::db::Result<Self> {
let hash = PasswordHash::from_password(&Password::new(password));
let mut user = Self {
id: Auto::auto(),
username: LimitedString::new(username).unwrap(),
password: Some(hash.into_string()),
email: email.map(str::to_owned),
display_name: display_name.map(str::to_owned),
avatar_url: None,
role: LimitedString::new(role).unwrap(),
is_active: true,
};
user.insert(db).await?;
Ok(user)
}
/// Create a user without a password (for OIDC-only accounts).
pub async fn create_oidc(
db: &Database,
username: &str,
email: Option<&str>,
display_name: Option<&str>,
role: &str,
) -> cot::db::Result<Self> {
let mut user = Self {
id: Auto::auto(),
username: LimitedString::new(username).unwrap(),
password: None,
email: email.map(str::to_owned),
display_name: display_name.map(str::to_owned),
avatar_url: None,
role: LimitedString::new(role).unwrap(),
is_active: true,
};
user.insert(db).await?;
Ok(user)
}
/// Update an existing user. If `new_password` is `Some`, the password hash
/// is replaced; otherwise the existing hash is kept.
pub async fn update_fields(
&mut self,
db: &Database,
username: &str,
email: Option<&str>,
display_name: Option<&str>,
new_password: Option<&str>,
role: &str,
) -> cot::db::Result<()> {
self.username = LimitedString::new(username).unwrap();
self.email = email.map(str::to_owned);
self.display_name = display_name.map(str::to_owned);
if let Some(pw) = new_password {
self.password = Some(PasswordHash::from_password(&Password::new(pw)).into_string());
}
self.role = LimitedString::new(role).unwrap();
self.save(db).await
}
/// Look up a user by username.
pub async fn get_by_username(db: &Database, username: &str) -> cot::db::Result<Option<Self>> {
let Ok(username) = LimitedString::<255>::new(username) else {
return Ok(None);
};
cot::db::query!(User, $username == username).get(db).await
}
/// Find a user by email address.
pub async fn get_by_email(db: &Database, email: &str) -> cot::db::Result<Option<Self>> {
let email = email.to_owned();
cot::db::query!(User, $email == Some(email)).get(db).await
}
/// Count all users in the database.
pub async fn count_all(db: &Database) -> cot::db::Result<u64> {
Self::objects().count(db).await
}
/// Return a reference to the password hash, if set.
pub fn password_ref(&self) -> Option<PasswordHash> {
self.password
.as_ref()
.and_then(|hash| PasswordHash::new(hash.clone()).ok())
}
/// Parse the stored role code into a `Role`, defaulting to `Client`.
pub fn role(&self) -> crate::auth::Role {
crate::auth::Role::from_code(&self.role).unwrap_or(crate::auth::Role::Client)
}
/// Update the user's role and persist the change.
pub async fn update_role(&mut self, db: &Database, role: &str) -> cot::db::Result<()> {
self.role = LimitedString::new(role).unwrap();
self.save(db).await
}
/// Delete this user by primary key.
pub async fn delete_by_id(db: &Database, user_id: i64) -> cot::db::Result<()> {
cot::db::query!(User, $id == Auto::Fixed(user_id))
.delete(db)
.await?;
Ok(())
}
// Accessor helpers for templates
pub fn id_val(&self) -> i64 {
self.id.unwrap()
}
pub fn username_str(&self) -> &str {
&self.username
}
pub fn email_str(&self) -> String {
self.email.clone().unwrap_or_default()
}
pub fn display_name_str(&self) -> String {
self.display_name.clone().unwrap_or_default()
}
pub fn role_str(&self) -> &str {
&self.role
}
pub fn is_active(&self) -> bool {
self.is_active
}
}
// ---------------------------------------------------------------------------
// OidcLink model
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
#[cot::db::model]
pub struct OidcLink {
#[model(primary_key)]
id: Auto<i64>,
user_id: i64,
issuer: LimitedString<255>,
sub: LimitedString<255>,
email: Option<String>,
name: Option<String>,
avatar_url: Option<String>,
}
// ---------------------------------------------------------------------------
// OidcLink helper methods
// ---------------------------------------------------------------------------
impl OidcLink {
/// Find an OIDC link by issuer + subject.
pub async fn find_by_issuer_sub(
db: &Database,
issuer: &str,
sub: &str,
) -> cot::db::Result<Option<Self>> {
let Ok(issuer) = LimitedString::<255>::new(issuer) else {
return Ok(None);
};
let Ok(sub) = LimitedString::<255>::new(sub) else {
return Ok(None);
};
cot::db::query!(OidcLink, $issuer == issuer && $sub == sub)
.get(db)
.await
}
/// Create a new OIDC link for a user.
pub async fn create_link(
db: &Database,
user_id: i64,
issuer: &str,
sub: &str,
email: Option<&str>,
name: Option<&str>,
) -> cot::db::Result<Self> {
let mut link = Self {
id: Auto::auto(),
user_id,
issuer: LimitedString::new(issuer).unwrap(),
sub: LimitedString::new(sub).unwrap(),
email: email.map(str::to_owned),
name: name.map(str::to_owned),
avatar_url: None,
};
link.insert(db).await?;
Ok(link)
}
/// Update cached claims (email, name) on an existing link.
pub async fn update_claims(
&mut self,
db: &Database,
email: Option<&str>,
name: Option<&str>,
) -> cot::db::Result<()> {
self.email = email.map(str::to_owned);
self.name = name.map(str::to_owned);
self.save(db).await
}
/// Delete this OIDC link by primary key.
pub async fn delete(self, db: &Database) -> cot::db::Result<()> {
let link_id = self.id;
cot::db::query!(OidcLink, $id == link_id).delete(db).await?;
Ok(())
}
/// Accessor for the linked user ID.
pub fn user_id(&self) -> i64 {
self.user_id
}
}
// ---------------------------------------------------------------------------
// Migrations
// ---------------------------------------------------------------------------
pub mod db_migrations {
use cot::db::migrations::{self, Field, Operation, SyncDynMigration};
use cot::db::{DatabaseField, Identifier, LimitedString};
// -- M0002: create amnezia_fellow__user --------------------------------
#[derive(Debug, Copy, Clone)]
pub struct M0002CreateUser;
impl migrations::Migration for M0002CreateUser {
const APP_NAME: &'static str = "amnezia_fellow";
const MIGRATION_NAME: &'static str = "m_0002_create_user";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"amnezia_fellow",
"m_0001_create_config_entry",
)];
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
.table_name(Identifier::new("amnezia_fellow__user"))
.fields(&[
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
.primary_key()
.auto(),
Field::new(
Identifier::new("username"),
<LimitedString<255> as DatabaseField>::TYPE,
)
.unique(),
Field::new(Identifier::new("password"), <String as DatabaseField>::TYPE)
.set_null(true),
Field::new(Identifier::new("email"), <String as DatabaseField>::TYPE)
.set_null(true),
Field::new(
Identifier::new("display_name"),
<String as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("avatar_url"),
<String as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("role"),
<LimitedString<32> as DatabaseField>::TYPE,
),
Field::new(Identifier::new("is_active"), <bool as DatabaseField>::TYPE),
])
.build()];
}
// -- M0003: create amnezia_fellow__oidc_link ---------------------------
#[derive(Debug, Copy, Clone)]
pub struct M0003CreateOidcLink;
impl migrations::Migration for M0003CreateOidcLink {
const APP_NAME: &'static str = "amnezia_fellow";
const MIGRATION_NAME: &'static str = "m_0003_create_oidc_link";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"amnezia_fellow",
"m_0002_create_user",
)];
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
.table_name(Identifier::new("amnezia_fellow__oidc_link"))
.fields(&[
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
.primary_key()
.auto(),
Field::new(Identifier::new("user_id"), <i64 as DatabaseField>::TYPE),
Field::new(
Identifier::new("issuer"),
<LimitedString<255> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("sub"),
<LimitedString<255> as DatabaseField>::TYPE,
),
Field::new(Identifier::new("email"), <String as DatabaseField>::TYPE)
.set_null(true),
Field::new(Identifier::new("name"), <String as DatabaseField>::TYPE).set_null(true),
Field::new(
Identifier::new("avatar_url"),
<String as DatabaseField>::TYPE,
)
.set_null(true),
])
.build()];
}
// -- M0004: indexes on amnezia_fellow__oidc_link -----------------------
#[cot::db::migrations::migration_op]
async fn create_oidc_link_indexes(
ctx: migrations::MigrationContext<'_>,
) -> cot::db::Result<()> {
ctx.db
.raw(
"CREATE UNIQUE INDEX idx_amnezia_fellow_oidc_link_issuer_sub \
ON amnezia_fellow__oidc_link (issuer, sub)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX idx_amnezia_fellow_oidc_link_user_id \
ON amnezia_fellow__oidc_link (user_id)",
)
.await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0004OidcLinkIndexes;
impl migrations::Migration for M0004OidcLinkIndexes {
const APP_NAME: &'static str = "amnezia_fellow";
const MIGRATION_NAME: &'static str = "m_0004_oidc_link_indexes";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"amnezia_fellow",
"m_0003_create_oidc_link",
)];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(create_oidc_link_indexes).build()];
}
pub const MIGRATIONS: &[&SyncDynMigration] = &[
&M0002CreateUser,
&M0003CreateOidcLink,
&M0004OidcLinkIndexes,
];
}
+1090
View File
File diff suppressed because it is too large Load Diff
+509
View File
@@ -0,0 +1,509 @@
{% extends "base.html" %}
{% block title %}{{ t.nav_admin }} | {{ t.site_name }}{% endblock title %}
{% block head_extra %}
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: #f6f7f8; color: #1d252d; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
a { color: inherit; }
.shell { min-height: 100vh; display: grid; grid-template-columns: 220px minmax(0, 1fr); }
.sidebar { background: #17202a; color: #f4f7fb; padding: 1rem; }
.sidebar h1 { font-size: 1.05rem; line-height: 1.25; margin: 0 0 1.25rem; }
.sidebar a, .sidebar button { width: 100%; display: block; text-align: left; text-decoration: none; color: #d8dee6; padding: .5rem .55rem; border-radius: 4px; margin-bottom: .2rem; border: 0; background: transparent; cursor: pointer; font: inherit; }
.sidebar a:hover, .sidebar button:hover, .sidebar .active { background: #263544; color: #fff; }
.main-wrap { min-width: 0; display: flex; flex-direction: column; }
.topbar { min-height: 48px; display: flex; align-items: center; justify-content: flex-end; gap: 1rem; padding: .5rem 1.25rem; border-bottom: 1px solid #dde2e6; background: #fff; }
.user-info { font-size: .875rem; color: #53606d; }
.app-version { font-size: .75rem; color: #9aa4ae; white-space: nowrap; }
.logout-link, .lang-switch a { font-size: .875rem; text-decoration: none; color: #53606d; padding: .25rem .45rem; border-radius: 4px; }
.logout-link:hover, .lang-switch a:hover { background: #eef1f4; color: #1d252d; }
.lang-switch a.active { color: #1d252d; font-weight: 700; }
.main { width: min(1180px, 100%); padding: 1.5rem; }
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; }
.toolbar h2 { font-size: 1.5rem; margin: 0; }
.actions, .row-actions { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
input, select { min-height: 36px; border: 1px solid #cbd3db; border-radius: 4px; padding: .45rem .55rem; background: #fff; color: #1d252d; width: 100%; }
input[type="checkbox"] { width: auto; min-height: auto; }
button { min-height: 36px; border: 1px solid #17202a; border-radius: 4px; padding: .45rem .75rem; background: #17202a; color: #fff; cursor: pointer; }
button.secondary { background: #fff; color: #17202a; border-color: #cbd3db; }
button.danger { background: #9d2323; border-color: #9d2323; }
button:disabled { opacity: .55; cursor: default; }
table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #dde2e6; border-radius: 6px; overflow: hidden; }
th, td { text-align: left; padding: .65rem .75rem; border-bottom: 1px solid #edf0f2; font-size: .92rem; vertical-align: middle; }
th { background: #eef1f4; font-weight: 650; color: #34414f; }
tr:last-child td { border-bottom: 0; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .84rem; background: #f1f3f5; padding: .15rem .35rem; border-radius: 3px; overflow-wrap: anywhere; }
.status { margin: .75rem 0; min-height: 1.25rem; color: #53606d; }
.error { color: #9d2323; }
.empty { background: #fff; border: 1px solid #dde2e6; border-radius: 6px; padding: 1rem; color: #53606d; }
.summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: .75rem; margin-bottom: 1rem; }
.summary-tile { background: #fff; border: 1px solid #dde2e6; border-radius: 6px; padding: .8rem; }
.summary-value { display: block; font-size: 1.6rem; font-weight: 750; line-height: 1.1; }
.summary-label { color: #53606d; font-size: .85rem; }
.section-tabs { display: flex; gap: .4rem; margin: .5rem 0 1rem; flex-wrap: wrap; }
.section-tabs button { min-height: 32px; padding: .35rem .6rem; }
.section-tabs button.active { background: #17202a; color: #fff; border-color: #17202a; }
.settings-grid { display: grid; grid-template-columns: minmax(180px, .7fr) minmax(240px, 1fr) auto; gap: .55rem .75rem; align-items: center; background: #fff; border: 1px solid #dde2e6; border-radius: 6px; padding: .75rem; }
.settings-row { display: contents; }
.field-name { font-weight: 650; color: #34414f; overflow-wrap: anywhere; }
.field-help { color: #53606d; font-size: .78rem; margin-top: .15rem; overflow-wrap: anywhere; }
.badge { display: inline-block; padding: .15rem .55rem; border-radius: 4px; font-size: .8rem; font-weight: 650; }
.badge-default { background: #e8ecef; color: #53606d; }
.badge-database { background: #d7edf1; color: #0f5560; }
.badge-env { background: #fff0c2; color: #6f4e00; }
.servers-list { display: grid; gap: .75rem; }
.server-admin-row { background: #fff; border: 1px solid #dde2e6; border-radius: 6px; padding: .8rem; display: grid; grid-template-columns: minmax(220px, 1fr) minmax(260px, 1.2fr) auto; gap: .75rem; align-items: center; }
.server-admin-name { display: grid; gap: .25rem; min-width: 0; }
.server-admin-name label { font-weight: 650; color: #34414f; font-size: .85rem; }
.server-admin-meta { display: grid; gap: .25rem; min-width: 0; }
.server-admin-meta span { color: #53606d; font-size: .78rem; }
.toggle-row { display: inline-flex; align-items: center; gap: .45rem; color: #34414f; font-weight: 650; white-space: nowrap; }
.modal-backdrop { position: fixed; inset: 0; background: rgba(23,32,42,.35); display: grid; place-items: center; padding: 1rem; z-index: 20; }
.modal { width: min(560px, 100%); background: #fff; border-radius: 6px; border: 1px solid #dde2e6; box-shadow: 0 16px 48px rgba(23,32,42,.24); padding: 1rem; }
.modal h3 { margin: 0 0 .75rem; font-size: 1.1rem; }
.form-grid { display: grid; gap: .7rem; }
.form-row label { display: block; font-weight: 650; color: #34414f; font-size: .9rem; margin-bottom: .25rem; }
.hint { color: #53606d; font-size: .78rem; margin-top: .2rem; }
.debug-grid { display: grid; gap: 1rem; }
.debug-build { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: .55rem; }
@media (max-width: 760px) {
.shell { grid-template-columns: 1fr; }
.sidebar { display: flex; align-items: center; gap: .75rem; overflow-x: auto; }
.sidebar h1 { margin: 0; white-space: nowrap; }
.sidebar a, .sidebar button { margin: 0; white-space: nowrap; width: auto; }
.toolbar { align-items: stretch; flex-direction: column; }
.actions { align-items: stretch; }
.actions button { width: 100%; }
.main { padding: 1rem; }
table { display: block; overflow-x: auto; }
.settings-grid { grid-template-columns: 1fr; }
.server-admin-row { grid-template-columns: 1fr; align-items: stretch; }
.toggle-row { justify-content: space-between; }
}
</style>
{% endblock head_extra %}
{% block body %}
<div class="shell" x-data="adminApp('{{ initial_view }}')" x-init="init()">
<nav class="sidebar">
<h1>{{ t.site_name }}</h1>
<a href="/configs">{{ t.nav_configs }}</a>
<button :class="{ active: view === 'dashboard' }" @click="navigate('dashboard')">{{ t.nav_dashboard }}</button>
<button :class="{ active: view === 'users' }" @click="navigate('users')">{{ t.nav_users }}</button>
<button :class="{ active: view === 'servers' }" @click="navigate('servers')">{{ t.nav_servers }}</button>
<button :class="{ active: view === 'settings' }" @click="navigate('settings')">{{ t.nav_settings }}</button>
<button :class="{ active: view === 'debug' }" @click="navigate('debug')">{{ t.nav_debug }}</button>
</nav>
<div class="main-wrap">
<div class="topbar">
<span class="app-version">v{{ app_version }}</span>
<span class="user-info">{{ user_name }} ({{ user_role }})</span>
<div class="lang-switch">
<a href="#"{% if t.lang.code() == "en" %} class="active"{% endif %} onclick="location.href='/set-lang?lang=en&next='+encodeURIComponent(location.pathname);return false">EN</a>
<a href="#"{% if t.lang.code() == "ru" %} class="active"{% endif %} onclick="location.href='/set-lang?lang=ru&next='+encodeURIComponent(location.pathname);return false">RU</a>
</div>
<a class="logout-link" href="/logout">{{ t.nav_logout }}</a>
</div>
<main class="main">
<div class="toolbar">
<h2 x-text="title()"></h2>
<div class="actions">
<button class="secondary" @click="reload()" :disabled="busy">{{ t.configs_refresh }}</button>
<template x-if="view === 'users'">
<button @click="openUserModal(null)" :disabled="busy">{{ t.users_add }}</button>
</template>
<template x-if="view === 'settings'">
<button @click="saveSettings()" :disabled="busy">{{ t.settings_save }}</button>
</template>
<template x-if="view === 'servers'">
<button @click="saveServers()" :disabled="busy">{{ t.settings_save }}</button>
</template>
</div>
</div>
<div class="status" :class="{ error: error }" x-text="error || status"></div>
<section x-show="view === 'dashboard'">
<template x-if="summary">
<div>
<div class="summary-grid">
<div class="summary-tile"><span class="summary-value" x-text="summary.users_count"></span><span class="summary-label">{{ t.nav_users }}</span></div>
<div class="summary-tile"><span class="summary-value" x-text="summary.admin_users_count"></span><span class="summary-label">admin</span></div>
<div class="summary-tile"><span class="summary-value" x-text="summary.client_users_count"></span><span class="summary-label">client</span></div>
<div class="summary-tile"><span class="summary-value" x-text="summary.active_users_count"></span><span class="summary-label">{{ t.users_active }}</span></div>
</div>
<div class="debug-build">
<div class="summary-tile"><span class="summary-label">Version</span><br><code x-text="summary.build.pkg_version"></code></div>
<div class="summary-tile"><span class="summary-label">Profile</span><br><code x-text="summary.build.profile"></code></div>
<div class="summary-tile"><span class="summary-label">Target</span><br><code x-text="summary.build.target"></code></div>
</div>
</div>
</template>
</section>
<section x-show="view === 'users'">
<template x-if="users.length === 0 && !busy">
<div class="empty">{{ t.users_heading }}: 0</div>
</template>
<template x-if="users.length > 0">
<table>
<thead>
<tr>
<th>{{ t.users_username }}</th>
<th>{{ t.users_email }}</th>
<th>{{ t.users_display_name }}</th>
<th>{{ t.users_role }}</th>
<th>{{ t.users_active }}</th>
<th>{{ t.users_actions }}</th>
</tr>
</thead>
<tbody>
<template x-for="user in users" :key="user.id">
<tr>
<td x-text="user.username"></td>
<td x-text="user.email"></td>
<td x-text="user.display_name"></td>
<td><code x-text="user.role"></code></td>
<td x-text="user.active ? '{{ t.configs_yes }}' : '{{ t.configs_no }}'"></td>
<td>
<div class="row-actions">
<button class="secondary" @click="openUserModal(user)">{{ t.users_edit }}</button>
<button class="danger" @click="deleteUser(user)">{{ t.users_delete }}</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</template>
</section>
<section x-show="view === 'settings'">
<div class="section-tabs">
<template x-for="section in settingSections" :key="section">
<button class="secondary" :class="{ active: settingsSection === section }" @click="settingsSection = section" x-text="sectionLabel(section)"></button>
</template>
</div>
<template x-if="settings.fields.length > 0">
<div class="settings-grid">
<template x-for="field in visibleSettings()" :key="field.key">
<div class="settings-row">
<div class="field-name">
<span x-text="fieldLabel(field.key)"></span>
<div class="field-help" x-text="field.env_var"></div>
</div>
<div>
<template x-if="field.kind === 'bool'">
<input type="checkbox" x-model="settingsForm[field.key]">
</template>
<template x-if="field.kind !== 'bool'">
<input :type="field.kind === 'password' ? 'password' : field.kind" x-model="settingsForm[field.key]">
</template>
<div class="hint">default: <code x-text="field.default_value || '(empty)'"></code></div>
</div>
<span class="badge" :class="`badge-${field.source}`" x-text="field.source"></span>
</div>
</template>
</div>
</template>
</section>
<section x-show="view === 'servers'">
<template x-if="servers.length === 0 && !busy">
<div class="empty">{{ t.servers_empty }}</div>
</template>
<template x-if="servers.length > 0">
<div class="servers-list">
<template x-for="server in servers" :key="server.name">
<div class="server-admin-row">
<div class="server-admin-name">
<label>{{ t.servers_display_name }}</label>
<input x-model="server.display_name" :placeholder="server.name">
</div>
<div class="server-admin-meta">
<span>{{ t.servers_technical_name }}</span>
<code x-text="server.name"></code>
<span>{{ t.servers_endpoint }}</span>
<code x-text="server.endpoint"></code>
</div>
<label class="toggle-row">
<span>{{ t.configs_enabled }}</span>
<input type="checkbox" x-model="server.enabled">
</label>
</div>
</template>
</div>
</template>
</section>
<section x-show="view === 'debug'" class="debug-grid">
<template x-if="debug">
<div>
<div class="debug-build">
<div class="summary-tile"><span class="summary-label">Package</span><br><code x-text="debug.build.pkg_name"></code></div>
<div class="summary-tile"><span class="summary-label">Version</span><br><code x-text="debug.build.pkg_version"></code></div>
<div class="summary-tile"><span class="summary-label">Rustc</span><br><code x-text="debug.build.rustc_version"></code></div>
<div class="summary-tile"><span class="summary-label">{{ t.debug_db_status }}</span><br><code x-text="debug.db_status"></code></div>
</div>
<table>
<thead>
<tr>
<th>{{ t.debug_field }}</th>
<th>{{ t.debug_value }}</th>
<th>{{ t.debug_source }}</th>
</tr>
</thead>
<tbody>
<template x-for="entry in debug.config_entries" :key="entry.key">
<tr>
<td><code x-text="entry.key"></code><div class="hint" x-text="entry.env_var"></div></td>
<td><code x-text="entry.value || '(empty)'"></code></td>
<td><span class="badge" :class="`badge-${entry.source}`" x-text="entry.source"></span></td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
</section>
</main>
</div>
<template x-if="userModalOpen">
<div class="modal-backdrop" @click.self="closeUserModal()">
<div class="modal">
<h3 x-text="userForm.id ? '{{ t.users_edit_heading }}' : '{{ t.users_new_heading }}'"></h3>
<div class="form-grid">
<div class="form-row"><label>{{ t.users_username }}</label><input x-model="userForm.username"></div>
<div class="form-row"><label>{{ t.users_email }}</label><input type="email" x-model="userForm.email"></div>
<div class="form-row"><label>{{ t.users_display_name }}</label><input x-model="userForm.display_name"></div>
<div class="form-row">
<label>{{ t.login_password }}</label>
<input type="password" x-model="userForm.password">
<template x-if="userForm.id"><div class="hint">{{ t.users_password_hint }}</div></template>
</div>
<div class="form-row">
<label>{{ t.users_role }}</label>
<select x-model="userForm.role">
<option value="client">client</option>
<option value="admin">admin</option>
</select>
</div>
<div class="actions">
<button @click="saveUser()">{{ t.settings_save }}</button>
<button class="secondary" @click="closeUserModal()">{{ t.admin_close }}</button>
</div>
</div>
</div>
</div>
</template>
</div>
<script>
function adminApp(initialView) {
return {
view: initialView || 'dashboard',
busy: false,
status: '',
error: '',
summary: null,
users: [],
servers: [],
settings: { fields: [] },
settingsForm: {},
settingsSection: 'auth',
settingSections: ['auth', 'oidc', 'kubernetes', 'vpn', 'api'],
debug: null,
userModalOpen: false,
userForm: {},
init() {
this.loadForView();
window.addEventListener('popstate', () => {
this.view = this.viewFromPath(location.pathname);
this.loadForView();
});
},
async request(url, options = {}) {
const response = await fetch(url, {
headers: { 'content-type': 'application/json' },
...options,
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || response.statusText);
return data;
},
navigate(view) {
this.view = view;
history.pushState({}, '', this.pathForView(view));
this.loadForView();
},
pathForView(view) {
return {
dashboard: '/admin/',
users: '/admin/users',
servers: '/admin/servers',
settings: '/admin/settings',
debug: '/admin/debug',
}[view] || '/admin/';
},
viewFromPath(path) {
if (path.endsWith('/users')) return 'users';
if (path.endsWith('/servers')) return 'servers';
if (path.endsWith('/settings')) return 'settings';
if (path.endsWith('/debug')) return 'debug';
return 'dashboard';
},
title() {
return {
dashboard: '{{ t.nav_dashboard }}',
users: '{{ t.nav_users }}',
servers: '{{ t.nav_servers }}',
settings: '{{ t.nav_settings }}',
debug: '{{ t.nav_debug }}',
}[this.view] || '{{ t.nav_admin }}';
},
async loadForView() {
this.error = '';
if (this.view === 'dashboard') await this.loadSummary();
if (this.view === 'users') await this.loadUsers();
if (this.view === 'servers') await this.loadServers();
if (this.view === 'settings') await this.loadSettings();
if (this.view === 'debug') await this.loadDebug();
},
async reload() {
await this.loadForView();
},
async loadSummary() {
this.busy = true;
try { this.summary = await this.request('/admin/api/summary'); }
catch (e) { this.error = e.message; }
finally { this.busy = false; }
},
async loadUsers() {
this.busy = true;
try { this.users = (await this.request('/admin/api/users')).users; }
catch (e) { this.error = e.message; }
finally { this.busy = false; }
},
async loadServers() {
this.busy = true;
try { this.servers = (await this.request('/admin/api/servers')).servers; }
catch (e) { this.error = e.message; }
finally { this.busy = false; }
},
async loadSettings() {
this.busy = true;
try {
this.settings = await this.request('/admin/api/settings');
const form = {};
for (const field of this.settings.fields) {
form[field.key] = field.kind === 'bool' ? field.value === 'true' : field.value;
}
this.settingsForm = form;
} catch (e) { this.error = e.message; }
finally { this.busy = false; }
},
async loadDebug() {
this.busy = true;
try { this.debug = await this.request('/admin/api/debug'); }
catch (e) { this.error = e.message; }
finally { this.busy = false; }
},
visibleSettings() {
return this.settings.fields.filter((field) => field.section === this.settingsSection);
},
async saveSettings() {
this.error = '';
this.status = '';
this.busy = true;
try {
const body = { ...this.settingsForm, vpn_mtu: Number(this.settingsForm.vpn_mtu || 0) };
this.settings = await this.request('/admin/api/settings', {
method: 'POST',
body: JSON.stringify(body),
});
this.status = '{{ t.settings_saved }}';
} catch (e) { this.error = e.message; }
finally { this.busy = false; }
},
async saveServers() {
this.error = '';
this.status = '';
this.busy = true;
try {
const data = await this.request('/admin/api/servers', {
method: 'POST',
body: JSON.stringify({ servers: this.servers }),
});
this.servers = data.servers;
this.status = '{{ t.settings_saved }}';
} catch (e) { this.error = e.message; }
finally { this.busy = false; }
},
openUserModal(user) {
this.userForm = user ? { ...user, password: '' } : {
id: null, username: '', email: '', display_name: '', password: '', role: 'client',
};
this.userModalOpen = true;
},
closeUserModal() {
this.userModalOpen = false;
this.userForm = {};
},
async saveUser() {
this.error = '';
this.status = '';
const isEdit = Boolean(this.userForm.id);
const url = isEdit ? `/admin/api/users/${this.userForm.id}` : '/admin/api/users';
try {
const data = await this.request(url, {
method: 'POST',
body: JSON.stringify(this.userForm),
});
if (isEdit) {
const index = this.users.findIndex((user) => user.id === data.user.id);
if (index !== -1) this.users[index] = data.user;
} else {
this.users.push(data.user);
}
this.closeUserModal();
this.status = '{{ t.settings_saved }}';
} catch (e) { this.error = e.message; }
},
async deleteUser(user) {
if (!confirm('{{ t.users_delete_confirm }}')) return;
this.error = '';
this.status = '';
try {
await this.request(`/admin/api/users/${user.id}/delete`, { method: 'POST' });
this.users = this.users.filter((item) => item.id !== user.id);
this.status = '{{ t.settings_saved }}';
} catch (e) { this.error = e.message; }
},
sectionLabel(section) {
return {
auth: '{{ t.settings_auth }}',
oidc: '{{ t.settings_oidc }}',
kubernetes: '{{ t.settings_kubernetes }}',
vpn: '{{ t.settings_vpn }}',
api: '{{ t.settings_api }}',
}[section] || section;
},
fieldLabel(key) {
return {
auth_password_enabled: '{{ t.settings_password_login }}',
auth_sso_enabled: '{{ t.settings_sso_login }}',
oidc_button_text: '{{ t.settings_oidc_button }}',
oidc_admin_groups: '{{ t.settings_oidc_admin_groups }}',
oidc_client_groups: '{{ t.settings_oidc_client_groups }}',
swagger_enabled: '{{ t.settings_swagger }}',
}[key] || key;
},
};
}
</script>
{% endblock body %}
+38
View File
@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}{{ t.setup_heading }} | {{ t.site_name }}{% endblock title %}
{% block head_extra %}
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui, -apple-system, sans-serif; background: #f5f5f5; color: #333; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
.setup-card { background: #fff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,.12); padding: 2rem 2.5rem; width: 100%; max-width: 400px; }
.setup-card h1 { margin-bottom: 1.5rem; font-size: 1.5rem; text-align: center; }
.setup-card label { display: block; margin-bottom: .25rem; font-weight: 600; font-size: .9rem; }
.setup-card input[type="text"],
.setup-card input[type="password"] { width: 100%; padding: .5rem .7rem; margin-bottom: 1rem; border: 1px solid #ccc; border-radius: 4px; font-size: .95rem; }
.setup-card button { display: block; width: 100%; padding: .6rem; border: none; border-radius: 4px; font-size: 1rem; cursor: pointer; background: #1a1a2e; color: #fff; }
.setup-card button:hover { background: #16213e; }
.setup-card .flash { color: #856404; background: #fff3cd; padding: .5rem .75rem; border-radius: 4px; margin-bottom: 1rem; text-align: center; font-size: .9rem; }
</style>
{% endblock head_extra %}
{% block body %}
<div class="setup-card">
<h1>{{ t.setup_heading }}</h1>
{% if !message.is_empty() %}
<div class="flash">{{ message }}</div>
{% endif %}
<form method="post" action="/admin/setup">
<label for="username">{{ t.setup_username }}</label>
<input type="text" name="username" id="username" required>
<label for="password">{{ t.setup_password }}</label>
<input type="password" name="password" id="password" required>
<label for="confirm_password">{{ t.setup_confirm }}</label>
<input type="password" name="confirm_password" id="confirm_password" required>
<button type="submit">{{ t.setup_submit }}</button>
</form>
</div>
{% endblock body %}
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="{{ t.lang.code() }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ t.site_name }}{% endblock title %}</title>
{% block head_extra %}{% endblock head_extra %}
</head>
<body>
{% block body %}
{% block content %}{% endblock content %}
{% endblock body %}
</body>
</html>
+590
View File
@@ -0,0 +1,590 @@
{% extends "base.html" %}
{% block title %}{{ t.configs_heading }} | {{ t.site_name }}{% endblock title %}
{% block head_extra %}
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: #f6f7f8; color: #1d252d; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
a { color: inherit; }
.shell { min-height: 100vh; display: grid; grid-template-columns: 220px minmax(0, 1fr); }
.sidebar { background: #17202a; color: #f4f7fb; padding: 1rem; }
.sidebar h1 { font-size: 1.05rem; line-height: 1.25; margin: 0 0 1.25rem; }
.sidebar a { display: block; text-decoration: none; color: #d8dee6; padding: .5rem .55rem; border-radius: 4px; margin-bottom: .2rem; }
.sidebar a:hover, .sidebar a.active { background: #263544; color: #fff; }
.main-wrap { min-width: 0; display: flex; flex-direction: column; }
.topbar { min-height: 48px; display: flex; align-items: center; justify-content: flex-end; gap: 1rem; padding: .5rem 1.25rem; border-bottom: 1px solid #dde2e6; background: #fff; }
.user-info { font-size: .875rem; color: #53606d; }
.app-version { font-size: .75rem; color: #9aa4ae; white-space: nowrap; }
.logout-link, .lang-switch a { font-size: .875rem; text-decoration: none; color: #53606d; padding: .25rem .45rem; border-radius: 4px; }
.logout-link:hover, .lang-switch a:hover { background: #eef1f4; color: #1d252d; }
.lang-switch a.active { color: #1d252d; font-weight: 700; }
.main { width: min(1180px, 100%); padding: 1.5rem; }
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; }
.toolbar h2 { font-size: 1.5rem; margin: 0; }
.panel-head { display: flex; align-items: center; justify-content: space-between; gap: .75rem; margin-bottom: .6rem; }
.panel-head h3 { font-size: 1rem; margin: 0; color: #34414f; }
.actions { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
.row-actions { display: flex; gap: .45rem; align-items: center; flex-wrap: wrap; }
input { min-height: 36px; border: 1px solid #cbd3db; border-radius: 4px; padding: .45rem .55rem; background: #fff; color: #1d252d; }
button { min-height: 36px; border: 1px solid #17202a; border-radius: 4px; padding: .45rem .75rem; background: #17202a; color: #fff; cursor: pointer; }
button.secondary { background: #fff; color: #17202a; border-color: #cbd3db; }
button.danger { background: #9d2323; border-color: #9d2323; }
button:disabled { opacity: .55; cursor: default; }
table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #dde2e6; border-radius: 6px; overflow: hidden; }
th, td { text-align: left; padding: .65rem .75rem; border-bottom: 1px solid #edf0f2; font-size: .92rem; vertical-align: middle; }
th { background: #eef1f4; font-weight: 650; color: #34414f; }
tr:last-child td { border-bottom: 0; }
.owner-row td { background: #f8fafb; color: #34414f; font-weight: 750; border-top: 1px solid #dde2e6; }
.owner-meta { color: #53606d; font-weight: 500; margin-left: .35rem; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .84rem; background: #f1f3f5; padding: .15rem .35rem; border-radius: 3px; }
.status { margin: .75rem 0; min-height: 1.25rem; color: #53606d; }
.error { color: #9d2323; }
.empty { background: #fff; border: 1px solid #dde2e6; border-radius: 6px; padding: 1rem; color: #53606d; }
.rollout { margin: 1rem 0 1.25rem; }
.rollout-meta { color: #53606d; font-size: .88rem; margin-bottom: .5rem; }
.rollout-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: .55rem; }
.rollout-tile { position: relative; min-height: 86px; border: 1px solid #dde2e6; border-left-width: 4px; border-radius: 6px; background: #fff; padding: .65rem .7rem; display: grid; gap: .45rem; align-content: space-between; }
.rollout-tile::after { content: attr(data-tooltip); position: absolute; left: .25rem; top: calc(100% + .45rem); z-index: 5; width: max-content; max-width: min(380px, 80vw); padding: .55rem .65rem; border-radius: 4px; background: #17202a; color: #fff; font-size: .78rem; line-height: 1.35; white-space: pre-line; box-shadow: 0 8px 24px rgba(23, 32, 42, .22); opacity: 0; pointer-events: none; transform: translateY(-3px); transition: opacity .12s ease, transform .12s ease; }
.rollout-tile:hover::after, .rollout-tile:focus-within::after { opacity: 1; transform: translateY(0); }
.rollout-tile-applied { border-left-color: #2f8f4e; }
.rollout-tile-starting { border-left-color: #d39a00; }
.rollout-tile-pending_restart { border-left-color: #c83f31; }
.rollout-tile-unknown { border-left-color: #7b8793; }
.rollout-tile-head { display: flex; align-items: center; min-width: 0; gap: .45rem; }
.rollout-dot { width: .55rem; height: .55rem; border-radius: 999px; flex: 0 0 auto; background: #7b8793; }
.rollout-dot-applied { background: #2f8f4e; }
.rollout-dot-starting { background: #d39a00; }
.rollout-dot-pending_restart { background: #c83f31; }
.rollout-dot-unknown { background: #7b8793; }
.rollout-server { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 700; color: #1d252d; }
.rollout-badge { display: inline-flex; align-items: center; min-height: 24px; padding: .15rem .45rem; border-radius: 4px; font-size: .82rem; font-weight: 700; white-space: nowrap; }
.rollout-applied { background: #dcefe4; color: #175c31; }
.rollout-starting { background: #fff0c2; color: #6f4e00; }
.rollout-pending_restart { background: #ffe3df; color: #8b2116; }
.rollout-unknown { background: #e8ecef; color: #53606d; }
.rollout-foot { display: flex; align-items: center; justify-content: space-between; gap: .5rem; color: #53606d; font-size: .82rem; }
.server-name { font-weight: 650; color: #34414f; overflow-wrap: anywhere; }
.server-state { color: #53606d; }
.modal-backdrop { position: fixed; inset: 0; background: rgba(23,32,42,.35); display: grid; place-items: center; padding: 1rem; z-index: 20; }
.modal { width: min(420px, 100%); background: #fff; border-radius: 6px; border: 1px solid #dde2e6; box-shadow: 0 16px 48px rgba(23,32,42,.24); padding: 1rem; }
.modal h3 { margin: 0 0 .75rem; font-size: 1.1rem; }
.servers-modal { width: min(820px, 100%); max-height: min(760px, calc(100vh - 2rem)); display: flex; flex-direction: column; gap: .75rem; }
.modal-head { display: flex; align-items: flex-start; justify-content: space-between; gap: .75rem; }
.modal-title { display: grid; gap: .2rem; min-width: 0; }
.modal-title h3 { margin: 0; }
.modal-subtitle { color: #53606d; font-size: .86rem; overflow-wrap: anywhere; }
.server-filter { width: 100%; }
.server-picker-list { display: grid; gap: .6rem; overflow: auto; padding-right: .15rem; }
.server-card { border: 1px solid #dde2e6; border-radius: 6px; padding: .7rem; display: grid; grid-template-columns: minmax(160px, 1fr) auto; gap: .65rem; align-items: center; }
.server-card-main { display: grid; gap: .25rem; min-width: 0; }
.server-card-actions { display: flex; gap: .45rem; align-items: center; flex-wrap: wrap; justify-content: flex-end; }
.qr-box { display: grid; place-items: center; padding: .75rem; border: 1px solid #dde2e6; border-radius: 6px; background: #fff; }
.qr-box svg { width: min(280px, 100%); height: auto; display: block; }
.modal-actions { display: flex; gap: .5rem; justify-content: flex-end; margin-top: .75rem; flex-wrap: wrap; }
@media (max-width: 760px) {
.shell { grid-template-columns: 1fr; }
.sidebar { display: flex; align-items: center; gap: .75rem; overflow-x: auto; }
.sidebar h1 { margin: 0; white-space: nowrap; }
.sidebar a { margin: 0; white-space: nowrap; }
.toolbar { align-items: stretch; flex-direction: column; }
.actions { align-items: stretch; }
.actions input, .actions button { width: 100%; }
.main { padding: 1rem; }
table { display: block; overflow-x: auto; }
.row-actions { align-items: stretch; }
.servers-modal { width: 100%; max-height: calc(100vh - 2rem); }
.server-card { grid-template-columns: 1fr; }
.server-card-actions { justify-content: stretch; }
.server-card-actions button { flex: 1 1 100%; }
.rollout-grid { grid-template-columns: 1fr; }
}
</style>
{% endblock head_extra %}
{% block body %}
<div class="shell" x-data="configsPage()" x-init="init()">
<nav class="sidebar">
<h1>{{ t.site_name }}</h1>
<a href="/configs" class="active">{{ t.nav_configs }}</a>
{% if is_admin %}
<a href="/admin/">{{ t.nav_admin }}</a>
{% endif %}
</nav>
<div class="main-wrap">
<div class="topbar">
<span class="app-version">v{{ app_version }}</span>
<span class="user-info">{{ user_name }} ({{ user_role }})</span>
<div class="lang-switch">
<a href="#"{% if t.lang.code() == "en" %} class="active"{% endif %} onclick="location.href='/set-lang?lang=en&next='+encodeURIComponent(location.pathname);return false">EN</a>
<a href="#"{% if t.lang.code() == "ru" %} class="active"{% endif %} onclick="location.href='/set-lang?lang=ru&next='+encodeURIComponent(location.pathname);return false">RU</a>
</div>
<a class="logout-link" href="/logout">{{ t.nav_logout }}</a>
</div>
<main class="main">
<div class="toolbar">
<h2>{{ t.configs_heading }}</h2>
<div class="actions">
<input x-model="newName" placeholder="{{ t.configs_name_placeholder }}">
<button @click="createClient()" :disabled="busy">{{ t.configs_create }}</button>
{% if is_admin %}
<button class="secondary" @click="sync()" :disabled="busy">{{ t.configs_sync }}</button>
{% endif %}
</div>
</div>
<div class="status" :class="{ 'error': error }" x-text="error || status"></div>
<section class="rollout">
<div class="panel-head">
<h3>{{ t.configs_rollout_heading }}</h3>
<button class="secondary" @click="loadRolloutStatus()" :disabled="rolloutBusy">{{ t.configs_refresh }}</button>
</div>
<template x-if="rollout">
<div class="rollout-meta">
{{ t.configs_config_updated }}:
<span x-text="formatTime(rollout.config_updated_at_ms)"></span>
</div>
</template>
<template x-if="rolloutError">
<div class="status error" x-text="rolloutError"></div>
</template>
<template x-if="!rollout && rolloutBusy">
<div class="empty">{{ t.configs_loading_status }}</div>
</template>
<template x-if="rollout && rollout.pods.length === 0">
<div class="empty">{{ t.configs_no_pods }}</div>
</template>
<template x-if="rollout && rollout.pods.length > 0">
<div class="rollout-grid">
<template x-for="pod in rollout.pods" :key="pod.name">
<div class="rollout-tile" :class="`rollout-tile-${pod.rollout_status}`" :data-tooltip="podTooltip(pod)" :title="podTooltip(pod)" tabindex="0">
<div class="rollout-tile-head">
<span class="rollout-dot" :class="`rollout-dot-${pod.rollout_status}`"></span>
<span class="rollout-server" x-text="pod.endpoint_name || pod.node_name || pod.name"></span>
</div>
<span class="rollout-badge" :class="`rollout-${pod.rollout_status}`" x-text="rolloutStatusLabel(pod.rollout_status)"></span>
<div class="rollout-foot">
<span x-text="formatDuration(pod.uptime_seconds)"></span>
<span x-text="pod.ready ? '{{ t.configs_ready }}' : '{{ t.configs_not_ready }}'"></span>
</div>
</div>
</template>
</div>
</template>
</section>
<template x-if="!busy && clients.length === 0">
<div class="empty">{{ t.configs_empty }}</div>
</template>
<template x-if="clients.length > 0">
<table>
<thead>
<tr>
<th>{{ t.configs_name }}</th>
{% if is_admin %}
<th>{{ t.configs_owner }}</th>
{% endif %}
<th>{{ t.configs_address }}</th>
<th>{{ t.configs_public_key }}</th>
<th>{{ t.configs_enabled }}</th>
<th>{{ t.users_actions }}</th>
</tr>
</thead>
<template x-for="group in groupedClients()" :key="group.key">
<tbody>
{% if is_admin %}
<tr class="owner-row">
<td colspan="6">
<span x-text="group.label"></span>
<span class="owner-meta" x-text="`(${group.clients.length})`"></span>
</td>
</tr>
{% endif %}
<template x-for="client in group.clients" :key="client.id">
<tr>
<td x-text="client.name"></td>
{% if is_admin %}
<td x-text="ownerLabel(client)"></td>
{% endif %}
<td><code x-text="client.address + '/32'"></code></td>
<td><code x-text="shortKey(client.public_key)"></code></td>
<td x-text="client.enabled ? '{{ t.configs_yes }}' : '{{ t.configs_no }}'"></td>
<td>
<div class="row-actions">
<button class="secondary" @click="openServers(client)" :disabled="busy">{{ t.configs_servers }}</button>
<button class="secondary" @click="setEnabled(client, !client.enabled)" :disabled="busy" x-text="client.enabled ? '{{ t.configs_disable }}' : '{{ t.configs_enable }}'"></button>
<button class="danger" @click="deleteClient(client)" :disabled="busy">{{ t.users_delete }}</button>
</div>
</td>
</tr>
</template>
</tbody>
</template>
</table>
</template>
</main>
</div>
<template x-if="serverModal">
<div class="modal-backdrop" @click.self="closeServers()">
<div class="modal servers-modal">
<div class="modal-head">
<div class="modal-title">
<h3>{{ t.configs_servers }}</h3>
<div class="modal-subtitle" x-text="serverModal.client.name"></div>
</div>
<button class="secondary" @click="closeServers()">{{ t.admin_close }}</button>
</div>
<input class="server-filter" x-model="serverModal.filter" placeholder="{{ t.configs_server_search }}">
<template x-if="serverModal.loading">
<div class="empty">{{ t.configs_loading_servers }}</div>
</template>
<template x-if="!serverModal.loading && filteredServers().length === 0">
<div class="empty">{{ t.configs_no_servers }}</div>
</template>
<template x-if="!serverModal.loading && filteredServers().length > 0">
<div class="server-picker-list">
<template x-for="server in filteredServers()" :key="server.endpoint_id || server.endpoint_name">
<div class="server-card">
<div class="server-card-main">
<span class="server-name" x-text="server.endpoint_name"></span>
<code x-text="server.endpoint"></code>
</div>
<div class="server-card-actions">
<button class="secondary" @click="copyVpnUrl(serverModal.client, server)" :disabled="busy">{{ t.configs_copy_vpn_url }}</button>
<button class="secondary" @click="openQr(serverModal.client, server)" :disabled="busy">QR</button>
<button class="secondary" @click="downloadConfig(serverModal.client, server)" :disabled="busy">{{ t.configs_download }}</button>
</div>
</div>
</template>
</div>
</template>
</div>
</div>
</template>
<template x-if="qrModal">
<div class="modal-backdrop" @click.self="closeQr()">
<div class="modal">
<h3 x-text="qrModal.title"></h3>
<div class="qr-box" x-html="qrModal.svg"></div>
<div class="modal-actions">
<button class="secondary" @click="copyText(qrModal.url)">{{ t.configs_copy_vpn_url }}</button>
<button @click="closeQr()">{{ t.admin_close }}</button>
</div>
</div>
</div>
</template>
</div>
<script>
function configsPage() {
return {
clients: [],
newName: '',
busy: false,
status: '',
error: '',
rollout: null,
rolloutBusy: false,
rolloutError: '',
rolloutTimer: null,
serverConfigs: {},
serverModal: null,
qrModal: null,
init() {
this.load();
this.loadRolloutStatus();
this.rolloutTimer = setInterval(() => this.loadRolloutStatus(), 10000);
},
async request(url, options = {}) {
const response = await fetch(url, {
headers: { 'content-type': 'application/json' },
...options,
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.error || response.statusText);
}
return data;
},
async load() {
this.error = '';
this.busy = true;
try {
const data = await this.request('/api/vpn-clients');
this.clients = data.clients;
} catch (e) {
this.error = e.message;
} finally {
this.busy = false;
}
},
async createClient() {
this.error = '';
this.status = '';
this.busy = true;
try {
const data = await this.request('/api/vpn-clients', {
method: 'POST',
body: JSON.stringify({ name: this.newName }),
});
this.clients.push(data.client);
delete this.serverConfigs[data.client.id];
this.newName = '';
this.status = data.sync.message;
await this.loadRolloutStatus();
} catch (e) {
this.error = e.message;
} finally {
this.busy = false;
}
},
async setEnabled(client, enabled) {
this.error = '';
this.status = '';
this.busy = true;
try {
const data = await this.request(`/api/vpn-clients/${client.id}/enabled`, {
method: 'POST',
body: JSON.stringify({ enabled }),
});
const index = this.clients.findIndex((item) => item.id === client.id);
if (index !== -1) this.clients[index] = data.client;
delete this.serverConfigs[client.id];
this.status = data.sync.message;
await this.loadRolloutStatus();
} catch (e) {
this.error = e.message;
} finally {
this.busy = false;
}
},
async deleteClient(client) {
if (!confirm('{{ t.users_delete_confirm }}')) return;
this.error = '';
this.status = '';
this.busy = true;
try {
const data = await this.request(`/api/vpn-clients/${client.id}`, {
method: 'DELETE',
});
this.clients = this.clients.filter((item) => item.id !== client.id);
delete this.serverConfigs[client.id];
this.status = data.sync.message;
await this.loadRolloutStatus();
} catch (e) {
this.error = e.message;
} finally {
this.busy = false;
}
},
async openServers(client) {
this.error = '';
this.status = '';
const cached = this.serverConfigs[client.id];
this.serverModal = {
client,
filter: '',
loading: !cached,
servers: cached ? cached.servers : [],
};
if (cached) return;
this.serverConfigs[client.id] = { loading: true, servers: [] };
try {
const data = await this.request(`/api/vpn-clients/${client.id}/config`);
this.serverConfigs[client.id] = { loading: false, servers: data.servers || [] };
if (this.serverModal && this.serverModal.client.id === client.id) {
this.serverModal.loading = false;
this.serverModal.servers = data.servers || [];
}
} catch (e) {
this.serverConfigs[client.id] = { loading: false, servers: [] };
if (this.serverModal && this.serverModal.client.id === client.id) {
this.serverModal.loading = false;
this.serverModal.servers = [];
}
this.error = e.message;
}
},
closeServers() {
this.serverModal = null;
},
filteredServers() {
if (!this.serverModal) return [];
const query = this.serverModal.filter.trim().toLowerCase();
if (!query) return this.serverModal.servers;
return this.serverModal.servers.filter((server) => {
return [server.endpoint_name, server.endpoint, server.endpoint_id]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(query));
});
},
async downloadConfig(client, server) {
this.error = '';
this.status = '';
this.busy = true;
try {
const blob = new Blob([server.config], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${this.fileSlug(client.name || 'amnezia-client')}-${this.fileSlug(server.endpoint_name)}.conf`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
this.status = `${server.endpoint_name}: ${server.endpoint}`;
} catch (e) {
this.error = e.message;
} finally {
this.busy = false;
}
},
async copyVpnUrl(client, server) {
this.error = '';
this.status = '';
this.busy = true;
try {
await this.copyText(server.vpn_url);
this.status = `${server.endpoint_name}: {{ t.configs_vpn_url_copied }}`;
} catch (e) {
this.error = e.message;
} finally {
this.busy = false;
}
},
openQr(client, server) {
this.qrModal = {
title: `${client.name || 'amnezia-client'} · ${server.endpoint_name}`,
svg: server.qr_svg,
url: server.vpn_url,
};
},
closeQr() {
this.qrModal = null;
},
async copyText(text) {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.select();
const copied = document.execCommand('copy');
textarea.remove();
if (!copied) {
throw new Error('copy failed');
}
},
async sync() {
this.error = '';
this.status = '';
this.busy = true;
try {
const data = await this.request('/api/vpn-clients/sync', { method: 'POST' });
this.status = data.message;
await this.loadRolloutStatus();
} catch (e) {
this.error = e.message;
} finally {
this.busy = false;
}
},
async loadRolloutStatus() {
this.rolloutError = '';
this.rolloutBusy = true;
try {
this.rollout = await this.request('/api/vpn-status');
} catch (e) {
this.rolloutError = e.message;
} finally {
this.rolloutBusy = false;
}
},
clientServers(client) {
return (this.serverConfigs[client.id] && this.serverConfigs[client.id].servers) || [];
},
isLoadingServers(client) {
return Boolean(this.serverConfigs[client.id] && this.serverConfigs[client.id].loading);
},
ownerLabel(client) {
return client.owner_display_name || client.owner_username || `#${client.owner_user_id}`;
},
groupedClients() {
const groups = new Map();
for (const client of this.clients) {
const key = String(client.owner_user_id);
if (!groups.has(key)) {
groups.set(key, {
key,
label: this.ownerLabel(client),
clients: [],
});
}
groups.get(key).clients.push(client);
}
return Array.from(groups.values()).sort((left, right) => left.label.localeCompare(right.label));
},
shortKey(key) {
if (!key || key.length <= 16) return key || '';
return `${key.slice(0, 8)}...${key.slice(-8)}`;
},
fileSlug(value) {
return String(value || 'amnezia').trim().replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'amnezia';
},
rolloutStatusLabel(value) {
const labels = {
applied: '{{ t.configs_status_applied }}',
starting: '{{ t.configs_status_starting }}',
pending_restart: '{{ t.configs_status_pending_restart }}',
unknown: '{{ t.configs_status_unknown }}',
};
return labels[value] || value || '{{ t.configs_status_unknown }}';
},
podTooltip(pod) {
return [
`{{ t.configs_server }}: ${pod.endpoint_name || pod.node_name || pod.name}`,
`{{ t.configs_pod }}: ${pod.name}`,
pod.endpoint ? `Endpoint: ${pod.endpoint}` : null,
`{{ t.configs_rollout }}: ${this.rolloutStatusLabel(pod.rollout_status)}`,
`{{ t.configs_ready }}: ${pod.ready ? '{{ t.configs_yes }}' : '{{ t.configs_no }}'}`,
`{{ t.configs_phase }}: ${pod.phase || '{{ t.configs_status_unknown }}'}`,
`{{ t.configs_uptime }}: ${this.formatDuration(pod.uptime_seconds)}`,
`{{ t.configs_restarts }}: ${pod.restart_count}`,
`{{ t.configs_config_updated }}: ${this.formatTime(this.rollout && this.rollout.config_updated_at_ms)}`,
pod.message ? `Message: ${pod.message}` : null,
].filter(Boolean).join('\n');
},
formatTime(ms) {
if (!ms) return '{{ t.configs_never }}';
return new Date(ms).toLocaleString();
},
formatDuration(seconds) {
if (seconds === null || seconds === undefined) return '{{ t.configs_never }}';
let remaining = Number(seconds);
const days = Math.floor(remaining / 86400);
remaining %= 86400;
const hours = Math.floor(remaining / 3600);
remaining %= 3600;
const minutes = Math.floor(remaining / 60);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
},
};
}
</script>
{% endblock body %}
+56
View File
@@ -0,0 +1,56 @@
{% extends "base.html" %}
{% block title %}{{ t.login_heading }} | {{ t.site_name }}{% endblock title %}
{% block head_extra %}
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui, -apple-system, sans-serif; background: #f5f5f5; color: #333; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
.login-card { background: #fff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,.12); padding: 2rem 2.5rem; width: 100%; max-width: 400px; }
.login-card h1 { margin-bottom: 1.5rem; font-size: 1.5rem; text-align: center; }
.login-card label { display: block; margin-bottom: .25rem; font-weight: 600; font-size: .9rem; }
.login-card input[type="text"],
.login-card input[type="password"] { width: 100%; padding: .5rem .7rem; margin-bottom: 1rem; border: 1px solid #ccc; border-radius: 4px; font-size: .95rem; }
.login-card button,
.login-card .sso-btn { display: block; width: 100%; padding: .6rem; border: none; border-radius: 4px; font-size: 1rem; cursor: pointer; text-align: center; text-decoration: none; }
.login-card button { background: #1a1a2e; color: #fff; }
.login-card button:hover { background: #16213e; }
.login-card .sso-btn { background: #4a90d9; color: #fff; margin-top: .75rem; }
.login-card .sso-btn:hover { background: #357abd; }
.login-card .divider { text-align: center; margin: 1rem 0; color: #999; font-size: .85rem; }
.login-card .message { text-align: center; color: #666; margin-bottom: 1rem; }
.login-card .flash { color: #856404; background: #fff3cd; padding: .5rem .75rem; border-radius: 4px; margin-bottom: 1rem; text-align: center; font-size: .9rem; }
</style>
{% endblock head_extra %}
{% block body %}
<div class="login-card">
<h1>{{ t.login_heading }}</h1>
{% if !message.is_empty() %}
<div class="flash">{{ message }}</div>
{% endif %}
{% if !auth_password_enabled && !auth_sso_enabled %}
<p class="message">{{ t.login_disabled }}</p>
{% endif %}
{% if auth_password_enabled %}
<form method="post" action="/login">
<label for="username">{{ t.login_username }}</label>
<input type="text" name="username" id="username" required>
<label for="password">{{ t.login_password }}</label>
<input type="password" name="password" id="password" required>
<button type="submit">{{ t.login_submit }}</button>
</form>
{% endif %}
{% if auth_password_enabled && auth_sso_enabled %}
<div class="divider">&mdash; or &mdash;</div>
{% endif %}
{% if auth_sso_enabled %}
<a class="sso-btn" href="/auth/oidc/start">{{ oidc_button_text }}</a>
{% endif %}
</div>
{% endblock body %}