1use axum::{
4 extract::{Path, State},
5 http::StatusCode,
6 Json,
7};
8use serde::{Deserialize, Serialize};
9use serde_with::skip_serializing_none;
10
11use crate::prelude::*;
12use cloudillo_core::acme;
13use cloudillo_core::extract::Auth;
14use cloudillo_types::auth_adapter::{
15 CreateProxySiteData, ProxySiteConfig, ProxySiteData, UpdateProxySiteData,
16};
17use cloudillo_types::types::{serialize_timestamp_iso, serialize_timestamp_iso_opt, ApiResponse};
18
19fn default_proxy_type() -> String {
20 "basic".to_string()
21}
22
23#[derive(Debug, Deserialize)]
25#[serde(rename_all = "camelCase")]
26pub struct CreateProxySiteRequest {
27 pub domain: String,
28 pub backend_url: String,
29 #[serde(rename = "type", default = "default_proxy_type")]
30 pub typ: String,
31 #[serde(default)]
32 pub config: ProxySiteConfig,
33}
34
35#[skip_serializing_none]
37#[derive(Debug, Deserialize)]
38#[serde(rename_all = "camelCase")]
39pub struct UpdateProxySiteRequest {
40 pub backend_url: Option<String>,
41 pub status: Option<String>,
42 #[serde(rename = "type")]
43 pub typ: Option<String>,
44 pub config: Option<ProxySiteConfig>,
45}
46
47#[skip_serializing_none]
49#[derive(Debug, Serialize)]
50#[serde(rename_all = "camelCase")]
51pub struct ProxySiteResponse {
52 pub site_id: i64,
53 pub domain: Box<str>,
54 pub backend_url: Box<str>,
55 pub status: Box<str>,
56 #[serde(rename = "type")]
57 pub typ: Box<str>,
58 #[serde(serialize_with = "serialize_timestamp_iso_opt")]
59 pub cert_expires_at: Option<Timestamp>,
60 pub config: ProxySiteConfig,
61 #[serde(serialize_with = "serialize_timestamp_iso")]
62 pub created_at: Timestamp,
63 #[serde(serialize_with = "serialize_timestamp_iso")]
64 pub updated_at: Timestamp,
65}
66
67impl From<ProxySiteData> for ProxySiteResponse {
68 fn from(data: ProxySiteData) -> Self {
69 Self {
70 site_id: data.site_id,
71 domain: data.domain,
72 backend_url: data.backend_url,
73 status: data.status,
74 typ: data.proxy_type,
75 cert_expires_at: data.cert_expires_at,
76 config: data.config,
77 created_at: data.created_at,
78 updated_at: data.updated_at,
79 }
80 }
81}
82
83fn validate_proxy_type(typ: &str) -> ClResult<()> {
85 match typ {
86 "basic" | "advanced" => Ok(()),
87 _ => Err(Error::ValidationError(format!(
88 "unknown proxy type '{}', must be 'basic' or 'advanced'",
89 typ
90 ))),
91 }
92}
93
94fn validate_config_for_type(typ: &str, config: &ProxySiteConfig) -> ClResult<()> {
96 if typ == "basic" {
97 if config.proxy_protocol.is_some() {
98 return Err(Error::ValidationError(
99 "proxy_protocol is not allowed for 'basic' type".into(),
100 ));
101 }
102 if config.custom_headers.is_some() {
103 return Err(Error::ValidationError(
104 "custom_headers is not allowed for 'basic' type".into(),
105 ));
106 }
107 }
108 Ok(())
109}
110
111#[axum::debug_handler]
113pub async fn list_proxy_sites(
114 State(app): State<App>,
115) -> ClResult<(StatusCode, Json<ApiResponse<Vec<ProxySiteResponse>>>)> {
116 info!("GET /api/admin/proxy-sites - Listing proxy sites");
117
118 let sites = app.auth_adapter.list_proxy_sites().await?;
119 let sites: Vec<ProxySiteResponse> = sites.into_iter().map(ProxySiteResponse::from).collect();
120 let total = sites.len();
121
122 Ok((StatusCode::OK, Json(ApiResponse::with_pagination(sites, 0, total, total))))
123}
124
125#[axum::debug_handler]
127pub async fn create_proxy_site(
128 State(app): State<App>,
129 Auth(auth_ctx): Auth,
130 Json(body): Json<CreateProxySiteRequest>,
131) -> ClResult<(StatusCode, Json<ApiResponse<ProxySiteResponse>>)> {
132 info!(domain = %body.domain, "POST /api/admin/proxy-sites - Creating proxy site");
133
134 if body.domain.is_empty() {
136 return Err(Error::ValidationError("domain is required".into()));
137 }
138
139 url::Url::parse(&body.backend_url)
141 .map_err(|e| Error::ValidationError(format!("invalid backend URL: {}", e)))?;
142
143 validate_proxy_type(&body.typ)?;
145 validate_config_for_type(&body.typ, &body.config)?;
146
147 if app.auth_adapter.read_cert_by_domain(&body.domain).await.is_ok() {
149 return Err(Error::Conflict(format!(
150 "domain '{}' is already used by a tenant",
151 body.domain
152 )));
153 }
154
155 let data = CreateProxySiteData {
156 domain: &body.domain,
157 backend_url: &body.backend_url,
158 proxy_type: &body.typ,
159 config: &body.config,
160 created_by: Some(auth_ctx.tn_id.0 as i64),
161 };
162
163 let site = app.auth_adapter.create_proxy_site(&data).await?;
164
165 if let Err(e) = crate::reload_proxy_cache(&app).await {
167 warn!("Failed to reload proxy cache: {}", e);
168 }
169
170 if app.opts.acme_email.is_some() {
172 if let Err(e) = acme::renew_proxy_site_cert(&app, site.site_id, &site.domain).await {
173 warn!(domain = %site.domain, error = %e, "Failed to generate certificate for proxy site");
174 }
175 }
176
177 Ok((StatusCode::CREATED, Json(ApiResponse::new(ProxySiteResponse::from(site)))))
178}
179
180#[axum::debug_handler]
182pub async fn get_proxy_site(
183 State(app): State<App>,
184 Path(site_id): Path<i64>,
185) -> ClResult<(StatusCode, Json<ApiResponse<ProxySiteResponse>>)> {
186 info!(site_id = site_id, "GET /api/admin/proxy-sites/:site_id");
187
188 let site = app.auth_adapter.read_proxy_site(site_id).await?;
189 Ok((StatusCode::OK, Json(ApiResponse::new(ProxySiteResponse::from(site)))))
190}
191
192#[axum::debug_handler]
194pub async fn update_proxy_site(
195 State(app): State<App>,
196 Path(site_id): Path<i64>,
197 Json(body): Json<UpdateProxySiteRequest>,
198) -> ClResult<(StatusCode, Json<ApiResponse<ProxySiteResponse>>)> {
199 info!(site_id = site_id, "PATCH /api/admin/proxy-sites/:site_id");
200
201 if let Some(ref url) = body.backend_url {
203 url::Url::parse(url)
204 .map_err(|e| Error::ValidationError(format!("invalid backend URL: {}", e)))?;
205 }
206
207 if let Some(ref status) = body.status {
209 if !["A", "D"].contains(&status.as_str()) {
210 return Err(Error::ValidationError("status must be A (active) or D (disabled)".into()));
211 }
212 }
213
214 if let Some(ref typ) = body.typ {
216 validate_proxy_type(typ)?;
217 }
218
219 if let Some(ref config) = body.config {
221 let effective_type = if let Some(ref typ) = body.typ {
224 typ.clone()
225 } else {
226 let current = app.auth_adapter.read_proxy_site(site_id).await?;
227 current.proxy_type.to_string()
228 };
229 validate_config_for_type(&effective_type, config)?;
230 }
231
232 let data = UpdateProxySiteData {
233 backend_url: body.backend_url.as_deref(),
234 status: body.status.as_deref(),
235 proxy_type: body.typ.as_deref(),
236 config: body.config.as_ref(),
237 };
238
239 let site = app.auth_adapter.update_proxy_site(site_id, &data).await?;
240
241 if let Err(e) = crate::reload_proxy_cache(&app).await {
243 warn!("Failed to reload proxy cache: {}", e);
244 }
245
246 Ok((StatusCode::OK, Json(ApiResponse::new(ProxySiteResponse::from(site)))))
247}
248
249#[axum::debug_handler]
251pub async fn delete_proxy_site(
252 State(app): State<App>,
253 Path(site_id): Path<i64>,
254) -> ClResult<StatusCode> {
255 info!(site_id = site_id, "DELETE /api/admin/proxy-sites/:site_id");
256
257 let site = app.auth_adapter.read_proxy_site(site_id).await?;
259 let domain = site.domain.clone();
260
261 app.auth_adapter.delete_proxy_site(site_id).await?;
262
263 if let Err(e) = crate::reload_proxy_cache(&app).await {
265 warn!("Failed to reload proxy cache: {}", e);
266 }
267
268 if let Ok(mut certs) = app.certs.write() {
270 certs.remove(&domain);
271 }
272
273 Ok(StatusCode::NO_CONTENT)
274}
275
276#[axum::debug_handler]
278pub async fn trigger_cert_renewal(
279 State(app): State<App>,
280 Path(site_id): Path<i64>,
281) -> ClResult<(StatusCode, Json<ApiResponse<ProxySiteResponse>>)> {
282 info!(site_id = site_id, "POST /api/admin/proxy-sites/:site_id/renew-cert");
283
284 let site = app.auth_adapter.read_proxy_site(site_id).await?;
285
286 if app.opts.acme_email.is_some() {
288 if let Err(e) = acme::renew_proxy_site_cert(&app, site_id, &site.domain).await {
289 warn!(domain = %site.domain, error = %e, "Failed to renew certificate for proxy site");
290 }
291 }
292
293 let updated_site = app.auth_adapter.read_proxy_site(site_id).await?;
295
296 Ok((StatusCode::OK, Json(ApiResponse::new(ProxySiteResponse::from(updated_site)))))
297}
298
299