This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
**/.git
|
||||
**/target
|
||||
**/.claude
|
||||
**/.agents
|
||||
**/.codex
|
||||
**/*.sqlite3
|
||||
**/NUL
|
||||
**/nul
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
*.sqlite3
|
||||
/target/
|
||||
Generated
+4544
File diff suppressed because it is too large
Load Diff
+23
@@ -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
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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">— or —</div>
|
||||
{% endif %}
|
||||
|
||||
{% if auth_sso_enabled %}
|
||||
<a class="sso-btn" href="/auth/oidc/start">{{ oidc_button_text }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock body %}
|
||||
Reference in New Issue
Block a user