koi-proxy 0.4.1

TLS-terminating reverse proxy with automatic certificate management
Documentation
use std::sync::Arc;

use axum::extract::{Extension, Path};
use axum::response::{IntoResponse, Json};
use axum::routing::{delete, get, post};
use axum::Router;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;

use koi_common::error::ErrorCode;

use crate::config::ProxyEntry;
use crate::{ensure_backend_allowed, ProxyError, ProxyRuntime, ProxyStatus};

#[derive(Debug, Deserialize, ToSchema)]
struct AddProxyRequest {
    name: String,
    listen_port: u16,
    backend: String,
    #[serde(default)]
    allow_remote: bool,
}

#[derive(Debug, Serialize, ToSchema)]
struct ProxyStatusResponse {
    proxies: Vec<ProxyStatus>,
}

#[derive(Debug, Serialize, ToSchema)]
struct ProxyEntriesResponse {
    entries: Vec<ProxyEntry>,
}

#[derive(Debug, Serialize, ToSchema)]
struct StatusOk {
    status: String,
}

/// Route path constants - single source of truth for axum routing AND the command manifest.
pub mod paths {
    pub const PREFIX: &str = "/v1/proxy";

    pub const STATUS: &str = "/v1/proxy/status";
    pub const LIST: &str = "/v1/proxy/list";
    pub const ADD: &str = "/v1/proxy/add";
    pub const REMOVE: &str = "/v1/proxy/remove/{name}";

    /// Strip the crate nest prefix to get the relative path for axum routing.
    pub fn rel(full: &str) -> &str {
        full.strip_prefix(PREFIX).unwrap_or(full)
    }
}

/// Build proxy domain routes. The binary crate mounts these at `/v1/proxy/`.
pub fn routes(runtime: Arc<ProxyRuntime>) -> Router {
    use paths::rel;
    Router::new()
        .route(rel(paths::STATUS), get(status_handler))
        .route(rel(paths::LIST), get(entries_handler))
        .route(rel(paths::ADD), post(add_entry_handler))
        .route(rel(paths::REMOVE), delete(remove_entry_handler))
        .layer(Extension(runtime))
}

#[utoipa::path(get, path = "/status", tag = "proxy",
    summary = "Active proxy status",
    responses((status = 200, body = ProxyStatusResponse)))]
async fn status_handler(Extension(runtime): Extension<Arc<ProxyRuntime>>) -> impl IntoResponse {
    let proxies = runtime.status().await;
    Json(ProxyStatusResponse { proxies })
}

#[utoipa::path(get, path = "/list", tag = "proxy",
    summary = "List proxy entries",
    responses((status = 200, body = ProxyEntriesResponse)))]
async fn entries_handler(Extension(runtime): Extension<Arc<ProxyRuntime>>) -> impl IntoResponse {
    let entries = runtime.core().entries().await;
    Json(serde_json::json!({ "entries": entries }))
}

#[utoipa::path(post, path = "/add", tag = "proxy",
    summary = "Add or update a proxy entry",
    request_body = AddProxyRequest,
    responses((status = 200, body = StatusOk)))]
async fn add_entry_handler(
    Extension(runtime): Extension<Arc<ProxyRuntime>>,
    Json(payload): Json<AddProxyRequest>,
) -> impl IntoResponse {
    let entry = ProxyEntry {
        name: payload.name,
        listen_port: payload.listen_port,
        backend: payload.backend,
        allow_remote: payload.allow_remote,
    };

    if let Err(e) = ensure_backend_allowed(&entry.backend, entry.allow_remote) {
        return map_error(e).into_response();
    }
    if entry.allow_remote {
        tracing::warn!(backend = %entry.backend, "Proxy backend traffic is unencrypted");
    }

    match runtime.core().upsert(entry).await {
        Ok(_) => {
            if let Err(e) = runtime.reload().await {
                tracing::warn!(error = %e, "Failed to reload proxy runtime after add");
            }
            Json(serde_json::json!({ "status": "ok" })).into_response()
        }
        Err(e) => map_error(e).into_response(),
    }
}

#[utoipa::path(delete, path = "/remove/{name}", tag = "proxy",
    summary = "Remove a proxy entry",
    params(("name" = String, Path, description = "Proxy entry name")),
    responses((status = 200, body = StatusOk)))]
async fn remove_entry_handler(
    Extension(runtime): Extension<Arc<ProxyRuntime>>,
    Path(name): Path<String>,
) -> impl IntoResponse {
    match runtime.core().remove(&name).await {
        Ok(_) => {
            if let Err(e) = runtime.reload().await {
                tracing::warn!(error = %e, "Failed to reload proxy runtime after remove");
            }
            Json(serde_json::json!({ "status": "ok" })).into_response()
        }
        Err(e) => map_error(e).into_response(),
    }
}

fn map_error(err: ProxyError) -> impl IntoResponse {
    match err {
        ProxyError::InvalidConfig(msg) | ProxyError::Config(msg) => {
            koi_common::http::error_response(ErrorCode::InvalidPayload, msg)
        }
        ProxyError::NotFound(msg) => koi_common::http::error_response(ErrorCode::NotFound, msg),
        ProxyError::Io(msg) => koi_common::http::error_response(ErrorCode::IoError, msg),
    }
}

/// OpenAPI documentation for the proxy domain.
#[derive(utoipa::OpenApi)]
#[openapi(
    paths(
        status_handler,
        entries_handler,
        add_entry_handler,
        remove_entry_handler
    ),
    components(schemas(
        AddProxyRequest,
        ProxyEntry,
        ProxyStatus,
        ProxyStatusResponse,
        ProxyEntriesResponse,
        StatusOk,
    ))
)]
pub struct ProxyApiDoc;