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
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 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#[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;