This commit is contained in:
+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",
|
||||
),
|
||||
])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user