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, ProxyStatus};
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<ProxyStatus>,
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 proxies = runtime.status().await;
70    Json(ProxyStatusResponse { proxies })
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    if let Err(e) = ensure_backend_allowed(&entry.backend, entry.allow_remote) {
97        return map_error(e).into_response();
98    }
99    if entry.allow_remote {
100        tracing::warn!(backend = %entry.backend, "Proxy backend traffic is unencrypted");
101    }
102
103    match runtime.core().upsert(entry).await {
104        Ok(_) => {
105            if let Err(e) = runtime.reload().await {
106                tracing::warn!(error = %e, "Failed to reload proxy runtime after add");
107            }
108            Json(serde_json::json!({ "status": "ok" })).into_response()
109        }
110        Err(e) => map_error(e).into_response(),
111    }
112}
113
114#[utoipa::path(delete, path = "/remove/{name}", tag = "proxy",
115    summary = "Remove a proxy entry",
116    params(("name" = String, Path, description = "Proxy entry name")),
117    responses((status = 200, body = StatusOk)))]
118async fn remove_entry_handler(
119    Extension(runtime): Extension<Arc<ProxyRuntime>>,
120    Path(name): Path<String>,
121) -> impl IntoResponse {
122    match runtime.core().remove(&name).await {
123        Ok(_) => {
124            if let Err(e) = runtime.reload().await {
125                tracing::warn!(error = %e, "Failed to reload proxy runtime after remove");
126            }
127            Json(serde_json::json!({ "status": "ok" })).into_response()
128        }
129        Err(e) => map_error(e).into_response(),
130    }
131}
132
133fn map_error(err: ProxyError) -> impl IntoResponse {
134    match err {
135        ProxyError::InvalidConfig(msg) | ProxyError::Config(msg) => {
136            koi_common::http::error_response(ErrorCode::InvalidPayload, msg)
137        }
138        ProxyError::NotFound(msg) => koi_common::http::error_response(ErrorCode::NotFound, msg),
139        ProxyError::Io(msg) => koi_common::http::error_response(ErrorCode::IoError, msg),
140    }
141}
142
143/// OpenAPI documentation for the proxy domain.
144#[derive(utoipa::OpenApi)]
145#[openapi(
146    paths(
147        status_handler,
148        entries_handler,
149        add_entry_handler,
150        remove_entry_handler
151    ),
152    components(schemas(
153        AddProxyRequest,
154        ProxyEntry,
155        ProxyStatus,
156        ProxyStatusResponse,
157        ProxyEntriesResponse,
158        StatusOk,
159    ))
160)]
161pub struct ProxyApiDoc;