Files
amnezia-fellow/src/api/mod.rs
T

567 lines
16 KiB
Rust
Raw Normal View History

2026-06-29 15:50:25 +03:00
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 {
json_error_typed(
status,
status.canonical_reason().unwrap_or("request_failed"),
status.canonical_reason().unwrap_or("Request failed"),
message,
"",
)
}
fn json_error_typed(
status: cot::http::StatusCode,
code: &str,
title: &str,
message: &str,
detail: &str,
) -> cot::response::Response {
let body = serde_json::json!({
"code": code,
"title": title,
"error": message,
"detail": detail,
});
2026-06-29 15:50:25 +03:00
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,
notice: Option<ApiNotice>,
2026-06-29 15:50:25 +03:00
}
#[derive(Debug, Deserialize, JsonSchema)]
struct SetEnabledRequest {
enabled: bool,
}
#[derive(Debug, Serialize, JsonSchema)]
struct DeleteVpnClientResponse {
sync: vpn::SecretSyncResult,
notice: Option<ApiNotice>,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
struct ApiNotice {
kind: String,
code: String,
title: String,
message: String,
detail: String,
2026-06-29 15:50:25 +03:00
}
#[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 mut status = match vpn::read_rollout_status_from_kubernetes(&config).await {
Ok(status) => status,
Err(e) => {
return Ok(json_error_typed(
cot::http::StatusCode::SERVICE_UNAVAILABLE,
"vpn_status_unavailable",
"Server status unavailable",
"Could not load VPN server status.",
&e,
));
}
};
if user.role != auth::Role::Admin {
let disabled = vpn::disabled_endpoint_names(&config);
status.pods.retain(|pod| !disabled.contains(&pod.node_name));
}
2026-06-29 15:50:25 +03:00
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 = match vpn::VpnClient::create_for_owner(
&db,
user.id,
&request.name,
&config.vpn_client_cidr,
)
.await
{
Ok(client) => client,
Err(e) => {
return Ok(json_error_typed(
cot::http::StatusCode::BAD_REQUEST,
"client_create_failed",
"Could not create key",
"The key was not created.",
&e.to_string(),
));
}
};
let (sync, notice) = sync_after_client_mutation(&db, &config).await;
2026-06-29 15:50:25 +03:00
let owner_map = owner_view_map(&db, std::slice::from_ref(&client)).await?;
Json(MutateVpnClientResponse {
client: client_view_with_owner(client, &owner_map),
sync,
notice,
2026-06-29 15:50:25 +03:00
})
.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, notice) = sync_after_client_mutation(&db, &config).await;
2026-06-29 15:50:25 +03:00
let owner_map = owner_view_map(&db, std::slice::from_ref(&client)).await?;
Json(MutateVpnClientResponse {
client: client_view_with_owner(client, &owner_map),
sync,
notice,
2026-06-29 15:50:25 +03:00
})
.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, notice) = sync_after_client_mutation(&db, &config).await;
2026-06-29 15:50:25 +03:00
Json(DeleteVpnClientResponse { sync, notice }).into_response()
2026-06-29 15:50:25 +03:00
}
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 = match vpn::read_runtime_from_kubernetes(&config).await {
Ok(runtime) => runtime,
Err(e) => {
return Ok(json_error_typed(
cot::http::StatusCode::SERVICE_UNAVAILABLE,
"vpn_runtime_unavailable",
"VPN servers are unavailable",
"Could not load VPN server list.",
&e,
));
}
};
2026-06-29 15:50:25 +03:00
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
}
async fn sync_after_client_mutation(
db: &Database,
config: &AppConfig,
) -> (vpn::SecretSyncResult, Option<ApiNotice>) {
match vpn::sync_from_database(db, config).await {
Ok(sync) => (sync, None),
Err(e) => {
tracing::warn!(error = %e, "client data changed but Secret sync failed");
(
vpn::SecretSyncResult {
changed: false,
message: "client data saved; Secret sync failed".to_owned(),
},
Some(ApiNotice {
kind: "warning".to_owned(),
code: "secret_sync_failed".to_owned(),
title: "Saved locally".to_owned(),
message: "The key list was updated, but VPN servers did not receive the new config yet.".to_owned(),
detail: e,
}),
)
}
}
}
2026-06-29 15:50:25 +03:00
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 = match vpn::sync_from_database(&db, &config).await {
Ok(sync) => sync,
Err(e) => {
return Ok(json_error_typed(
cot::http::StatusCode::SERVICE_UNAVAILABLE,
"secret_sync_failed",
"Secret sync failed",
"Could not apply client config to VPN servers.",
&e,
));
}
};
2026-06-29 15:50:25 +03:00
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",
),
])
}
}