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
39pub 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 pub fn rel(full: &str) -> &str {
50 full.strip_prefix(PREFIX).unwrap_or(full)
51 }
52}
53
54pub 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#[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;