Skip to main content

koi_proxy/
http.rs

1use std::sync::Arc;
2
3use axum::extract::{Extension, Path};
4use axum::response::{IntoResponse, Json};
5use axum::routing::{delete, get, post};
6use axum::Router;
7use serde::{Deserialize, Serialize};
8use utoipa::ToSchema;
9
10use koi_common::error::ErrorCode;
11
12use crate::config::ProxyEntry;
13use crate::{ensure_backend_allowed, ProxyError, ProxyRuntime};
14
15#[derive(Debug, Deserialize, ToSchema)]
16struct AddProxyRequest {
17    name: String,
18    listen_port: u16,
19    backend: String,
20    #[serde(default)]
21    allow_remote: bool,
22}
23
24#[derive(Debug, Serialize, ToSchema)]
25struct ProxyStatusResponse {
26    proxies: Vec<serde_json::Value>,
27}
28
29#[derive(Debug, Serialize, ToSchema)]
30struct ProxyEntriesResponse {
31    entries: Vec<ProxyEntry>,
32}
33
34#[derive(Debug, Serialize, ToSchema)]
35struct StatusOk {
36    status: String,
37}
38
39/// Route path constants - single source of truth for axum routing AND the command manifest.
40pub mod paths {
41    pub const PREFIX: &str = "/v1/proxy";
42
43    pub const STATUS: &str = "/v1/proxy/status";
44    pub const LIST: &str = "/v1/proxy/list";
45    pub const ADD: &str = "/v1/proxy/add";
46    pub const REMOVE: &str = "/v1/proxy/remove/{name}";
47
48    /// Strip the crate nest prefix to get the relative path for axum routing.
49    pub fn rel(full: &str) -> &str {
50        full.strip_prefix(PREFIX).unwrap_or(full)
51    }
52}
53
54/// Build proxy domain routes. The binary crate mounts these at `/v1/proxy/`.
55pub fn routes(runtime: Arc<ProxyRuntime>) -> Router {
56    use paths::rel;
57    Router::new()
58        .route(rel(paths::STATUS), get(status_handler))
59        .route(rel(paths::LIST), get(entries_handler))
60        .route(rel(paths::ADD), post(add_entry_handler))
61        .route(rel(paths::REMOVE), delete(remove_entry_handler))
62        .layer(Extension(runtime))
63}
64
65#[utoipa::path(get, path = "/status", tag = "proxy",
66    summary = "Active proxy status",
67    responses((status = 200, body = ProxyStatusResponse)))]
68async fn status_handler(Extension(runtime): Extension<Arc<ProxyRuntime>>) -> impl IntoResponse {
69    let status = runtime.status().await;
70    Json(serde_json::json!({ "proxies": status }))
71}
72
73#[utoipa::path(get, path = "/list", tag = "proxy",
74    summary = "List proxy entries",
75    responses((status = 200, body = ProxyEntriesResponse)))]
76async fn entries_handler(Extension(runtime): Extension<Arc<ProxyRuntime>>) -> impl IntoResponse {
77    let entries = runtime.core().entries().await;
78    Json(serde_json::json!({ "entries": entries }))
79}
80
81#[utoipa::path(post, path = "/add", tag = "proxy",
82    summary = "Add or update a proxy entry",
83    request_body = AddProxyRequest,
84    responses((status = 200, body = StatusOk)))]
85async fn add_entry_handler(
86    Extension(runtime): Extension<Arc<ProxyRuntime>>,
87    Json(payload): Json<AddProxyRequest>,
88) -> impl IntoResponse {
89    let entry = ProxyEntry {
90        name: payload.name,
91        listen_port: payload.listen_port,
92        backend: payload.backend,
93        allow_remote: payload.allow_remote,
94    };
95
96    let backend = match url::Url::parse(&entry.backend) {
97        Ok(url) => url,
98        Err(e) => {
99            return koi_common::http::error_response(
100                ErrorCode::InvalidPayload,
101                format!("invalid_backend: {e}"),
102            )
103            .into_response();
104        }
105    };
106
107    if let Err(e) = ensure_backend_allowed(&backend, entry.allow_remote) {
108        return map_error(e).into_response();
109    }
110    if entry.allow_remote {
111        let host = backend.host_str().unwrap_or("unknown");
112        tracing::warn!("Backend traffic to {} is unencrypted", host);
113    }
114
115    match runtime.core().upsert(entry).await {
116        Ok(_) => {
117            if let Err(e) = runtime.reload().await {
118                tracing::warn!(error = %e, "Failed to reload proxy runtime after add");
119            }
120            Json(serde_json::json!({ "status": "ok" })).into_response()
121        }
122        Err(e) => map_error(e).into_response(),
123    }
124}
125
126#[utoipa::path(delete, path = "/remove/{name}", tag = "proxy",
127    summary = "Remove a proxy entry",
128    params(("name" = String, Path, description = "Proxy entry name")),
129    responses((status = 200, body = StatusOk)))]
130async fn remove_entry_handler(
131    Extension(runtime): Extension<Arc<ProxyRuntime>>,
132    Path(name): Path<String>,
133) -> impl IntoResponse {
134    match runtime.core().remove(&name).await {
135        Ok(_) => {
136            if let Err(e) = runtime.reload().await {
137                tracing::warn!(error = %e, "Failed to reload proxy runtime after remove");
138            }
139            Json(serde_json::json!({ "status": "ok" })).into_response()
140        }
141        Err(e) => map_error(e).into_response(),
142    }
143}
144
145fn map_error(err: ProxyError) -> impl IntoResponse {
146    match err {
147        ProxyError::InvalidConfig(msg) | ProxyError::Config(msg) => {
148            koi_common::http::error_response(ErrorCode::InvalidPayload, msg)
149        }
150        ProxyError::NotFound(msg) => koi_common::http::error_response(ErrorCode::NotFound, msg),
151        ProxyError::Io(msg) | ProxyError::Forward(msg) => {
152            koi_common::http::error_response(ErrorCode::IoError, msg)
153        }
154    }
155}
156
157/// OpenAPI documentation for the proxy domain.
158#[derive(utoipa::OpenApi)]
159#[openapi(
160    paths(
161        status_handler,
162        entries_handler,
163        add_entry_handler,
164        remove_entry_handler
165    ),
166    components(schemas(
167        AddProxyRequest,
168        ProxyEntry,
169        ProxyStatusResponse,
170        ProxyEntriesResponse,
171        StatusOk,
172    ))
173)]
174pub struct ProxyApiDoc;