Skip to main content

cloudillo_proxy/
admin.rs

1//! Admin CRUD handlers for proxy site management
2
3use 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/// Request body for creating a proxy site
24#[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/// Request body for updating a proxy site
36#[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/// Response type for proxy site operations
48#[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
83/// Validate that the proxy type is a known value
84fn 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
94/// Validate that the config is compatible with the given proxy type
95fn 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/// GET /api/admin/proxy-sites - List all proxy sites
112#[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/// POST /api/admin/proxy-sites - Create a new proxy site
126#[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	// Validate domain is not empty
135	if body.domain.is_empty() {
136		return Err(Error::ValidationError("domain is required".into()));
137	}
138
139	// Validate backend URL
140	url::Url::parse(&body.backend_url)
141		.map_err(|e| Error::ValidationError(format!("invalid backend URL: {}", e)))?;
142
143	// Validate proxy type and config compatibility
144	validate_proxy_type(&body.typ)?;
145	validate_config_for_type(&body.typ, &body.config)?;
146
147	// Check domain is not already a tenant domain
148	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	// Reload proxy cache
166	if let Err(e) = crate::reload_proxy_cache(&app).await {
167		warn!("Failed to reload proxy cache: {}", e);
168	}
169
170	// Generate certificate immediately (best-effort, daily cron will retry on failure)
171	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/// GET /api/admin/proxy-sites/:site_id - Get a proxy site
181#[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/// PATCH /api/admin/proxy-sites/:site_id - Update a proxy site
193#[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	// Validate backend URL if provided
202	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	// Validate status if provided
208	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	// Validate proxy type if provided
215	if let Some(ref typ) = body.typ {
216		validate_proxy_type(typ)?;
217	}
218
219	// Validate config compatibility with the effective type
220	if let Some(ref config) = body.config {
221		// If type is being changed, validate against the new type;
222		// otherwise fetch the current site to get the existing type
223		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	// Reload proxy cache
242	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/// DELETE /api/admin/proxy-sites/:site_id - Delete a proxy site
250#[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	// Read site first to get domain for cache invalidation
258	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	// Reload proxy cache
264	if let Err(e) = crate::reload_proxy_cache(&app).await {
265		warn!("Failed to reload proxy cache: {}", e);
266	}
267
268	// Invalidate cert cache
269	if let Ok(mut certs) = app.certs.write() {
270		certs.remove(&domain);
271	}
272
273	Ok(StatusCode::NO_CONTENT)
274}
275
276/// POST /api/admin/proxy-sites/:site_id/renew-cert - Trigger certificate renewal
277#[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	// Perform ACME renewal immediately (best-effort, daily cron will retry on failure)
287	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	// Re-read the site to return updated cert info
294	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// vim: ts=4