1use std::sync::Arc;
6
7use axum::extract::{Path, State};
8use axum::http::StatusCode;
9use axum::response::IntoResponse;
10use axum::routing::get;
11use axum::{Json, Router};
12
13use crate::config::PolicyConfig;
14use crate::state::SharedState;
15
16pub fn router() -> Router<Arc<SharedState>> {
18 Router::new()
19 .route("/api/policies", get(list_policies))
20 .route(
21 "/api/policies/{name}",
22 get(get_policy).put(update_policy).delete(delete_policy),
23 )
24 .route("/api/plugins", get(list_plugin_types))
25 .route("/api/scripts", get(list_scripts))
26}
27
28async fn list_policies(State(state): State<Arc<SharedState>>) -> impl IntoResponse {
30 let gw = state.gateway.read().await;
31 Json(&gw.policies).into_response()
32}
33
34async fn get_policy(
38 State(state): State<Arc<SharedState>>,
39 Path(name): Path<String>,
40) -> impl IntoResponse {
41 let gw = state.gateway.read().await;
42 match gw.policies.iter().find(|p| p.name == name) {
43 Some(policy) => Json(policy).into_response(),
44 None => (
45 StatusCode::NOT_FOUND,
46 Json(serde_json::json!({"error": "not_found"})),
47 )
48 .into_response(),
49 }
50}
51
52async fn update_policy(
59 State(state): State<Arc<SharedState>>,
60 Path(name): Path<String>,
61 Json(mut policy): Json<PolicyConfig>,
62) -> impl IntoResponse {
63 policy.name = name.clone();
64 let candidate = {
65 let gw = state.gateway.read().await;
66 let mut candidate = gw.clone();
67 if let Some(existing) = candidate.policies.iter_mut().find(|p| p.name == name) {
68 *existing = policy;
69 } else {
70 candidate.policies.push(policy);
71 }
72 candidate
73 };
74
75 match state.config_store.clone().commit(&state, candidate).await {
76 Ok(_) => Json(serde_json::json!({"status": "updated"})).into_response(),
77 Err(e) => (
78 StatusCode::BAD_REQUEST,
79 Json(serde_json::json!({"error": e})),
80 )
81 .into_response(),
82 }
83}
84
85async fn delete_policy(
91 State(state): State<Arc<SharedState>>,
92 Path(name): Path<String>,
93) -> impl IntoResponse {
94 let candidate = {
95 let gw = state.gateway.read().await;
96 let mut candidate = gw.clone();
97 let before = candidate.policies.len();
98 candidate.policies.retain(|p| p.name != name);
99 if candidate.policies.len() == before {
100 return (
101 StatusCode::NOT_FOUND,
102 Json(serde_json::json!({"error": "not_found"})),
103 )
104 .into_response();
105 }
106 candidate
107 };
108
109 match state.config_store.clone().commit(&state, candidate).await {
110 Ok(_) => Json(serde_json::json!({"status": "deleted"})).into_response(),
111 Err(e) => (
112 StatusCode::BAD_REQUEST,
113 Json(serde_json::json!({"error": e})),
114 )
115 .into_response(),
116 }
117}
118
119async fn list_scripts(State(state): State<Arc<SharedState>>) -> impl IntoResponse {
128 let mut scripts = Vec::new();
129
130 let config_path = state
132 .config_path
133 .as_deref()
134 .unwrap_or(std::path::Path::new("config"));
135 let plugins_dir = config_path
136 .parent()
137 .unwrap_or(std::path::Path::new("."))
138 .join("plugins");
139
140 if let Ok(entries) = std::fs::read_dir(&plugins_dir) {
141 for entry in entries.flatten() {
142 let path = entry.path();
143 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
144 let name = path.file_stem().and_then(|n| n.to_str()).unwrap_or("");
145 let runtime = match ext {
146 "lua" => "lua",
147 _ => continue,
148 };
149 scripts.push(serde_json::json!({
150 "name": name,
151 "file": path.to_string_lossy(),
152 "runtime": runtime,
153 }));
154 }
155 }
156
157 Json(serde_json::json!({ "scripts": scripts }))
158}
159
160async fn list_plugin_types() -> impl IntoResponse {
170 Json(serde_json::json!({ "plugins": plugin_catalog() }))
171}
172
173fn plugin_catalog() -> Vec<serde_json::Value> {
175 const CATALOG: &[(&str, &str)] = &[
176 ("listener", "Route entry point — receives incoming request"),
178 ("client", "Route exit point — sends response to client"),
179 ("upstream", "Forward to a load-balanced backend pool"),
180 ("proxy-rewrite", "Rewrite path, add/remove headers"),
181 (
182 "response-rewrite",
183 "Rewrite response status, headers, and body",
184 ),
185 (
186 "body-transformer",
187 "Rewrite request/response JSON bodies via templates",
188 ),
189 (
190 "degraphql",
191 "Expose a REST endpoint backed by a GraphQL upstream",
192 ),
193 ("redirect", "HTTP redirect, or force HTTP→HTTPS"),
194 ("echo", "Wrap or replace the response body (demo/testing)"),
195 ("gzip", "Compress the response body with gzip"),
196 ("brotli", "Compress the response body with Brotli"),
197 ("request-id", "Attach a unique request-id header"),
198 (
199 "real-ip",
200 "Recover the client IP from a trusted proxy header",
201 ),
202 ("error-handler", "Custom error responses"),
204 (
205 "error-page",
206 "Replace 404/500/502/503 bodies with configured pages",
207 ),
208 (
209 "exit-transformer",
210 "Remap status and body of gateway-generated exits",
211 ),
212 (
213 "mocking",
214 "Respond with a configured mock instead of proxying",
215 ),
216 ("cors", "CORS header management"),
218 ("csrf", "Double-submit CSRF token validation"),
219 ("ip-restriction", "Allow/deny by IP/CIDR"),
220 ("ua-restriction", "Allow/deny by User-Agent regex"),
221 ("referer-restriction", "Allow/deny by Referer host"),
222 ("uri-blocker", "Block requests matching URI regex rules"),
223 ("request-size-limit", "Reject oversized requests"),
224 (
225 "request-validation",
226 "Validate headers/body against JSON Schema",
227 ),
228 (
229 "data-mask",
230 "Mask sensitive fields in bodies, headers, query",
231 ),
232 ("rate-limit", "Token bucket rate limiting"),
234 ("limit-count", "Fixed-window request-count limiting"),
235 (
236 "limit-conn",
237 "Concurrent-request limiting (acquire/release pair)",
238 ),
239 ("api-breaker", "Circuit breaker on unhealthy upstreams"),
240 ("traffic-split", "Weighted / conditional traffic steering"),
241 ("proxy-mirror", "Fire-and-forget clone to a shadow upstream"),
242 (
243 "proxy-cache",
244 "Cache upstream responses (lookup/store pair)",
245 ),
246 ("fault-injection", "Inject delays and abort responses"),
247 (
248 "workflow",
249 "Ordered rules — reject or rate-limit the first match",
250 ),
251 (
252 "traffic-label",
253 "Tag matching requests with headers and labels",
254 ),
255 ("key-auth", "API key authentication"),
257 ("basic-auth", "HTTP Basic authentication"),
258 ("jwt-auth", "JWT validation"),
259 (
260 "hmac-auth",
261 "HMAC request signing (access key / secret key)",
262 ),
263 ("jwe-decrypt", "Decrypt a JWE token into a forwarded header"),
264 (
265 "multi-auth",
266 "Chain auth plugins — accept the first that succeeds",
267 ),
268 ("ldap-auth", "Authenticate Basic credentials against LDAP"),
269 (
270 "consumer-restriction",
271 "Allow/deny by consumer name or group",
272 ),
273 ("acl", "Allow/deny by consumer group"),
274 (
275 "attach-consumer-label",
276 "Copy consumer labels into upstream headers",
277 ),
278 (
280 "forward-auth",
281 "Delegate the decision to an external HTTP service",
282 ),
283 ("opa", "Delegate authorization to Open Policy Agent"),
284 ("authz-casbin", "Embedded Casbin RBAC/ABAC enforcement"),
285 ("authz-keycloak", "Keycloak UMA permission check"),
286 (
287 "authz-casdoor",
288 "Casdoor introspection or interactive OAuth login",
289 ),
290 (
291 "openid-connect",
292 "OIDC bearer validation or interactive login",
293 ),
294 ("cas-auth", "CAS ticket validation or interactive SSO login"),
295 ("wolf-rbac", "Wolf RBAC token check"),
296 ("dingtalk-auth", "DingTalk code/token validation"),
297 ("feishu-auth", "Feishu/Lark code/token validation"),
298 (
300 "serverless-pre-function",
301 "Run inline Lua before the upstream",
302 ),
303 (
304 "serverless-post-function",
305 "Run inline Lua after the upstream",
306 ),
307 (
308 "oas-validator",
309 "Validate requests against an OpenAPI 3 spec",
310 ),
311 ("aws-lambda", "Invoke an AWS Lambda function"),
312 ("azure-functions", "Invoke an Azure Function"),
313 ("openwhisk", "Invoke an Apache OpenWhisk action"),
314 ("openfunction", "Invoke an OpenFunction function"),
315 ("logging", "Structured access logging"),
317 ("http-logger", "Ship logs to an HTTP endpoint"),
318 ("tcp-logger", "Ship logs over a raw TCP socket"),
319 ("udp-logger", "Ship logs over a raw UDP socket"),
320 ("syslog", "Ship logs via syslog (RFC 5424)"),
321 ("file-logger", "Append logs to a local file"),
322 (
323 "error-log-logger",
324 "Ship request-level errors to a TCP sink",
325 ),
326 ("elasticsearch-logger", "Bulk-index logs into Elasticsearch"),
327 ("clickhouse-logger", "Insert logs into ClickHouse"),
328 ("loki-logger", "Push logs to Grafana Loki"),
329 ("splunk-hec-logging", "Ship logs to Splunk HEC"),
330 ("datadog", "Emit DogStatsD metrics to the Datadog agent"),
331 ("loggly", "Ship logs to SolarWinds Loggly"),
332 ("google-cloud-logging", "Ship logs to Google Cloud Logging"),
333 ("sls-logger", "Ship logs to Alibaba Cloud SLS"),
334 ("tencent-cloud-cls", "Ship logs to Tencent Cloud CLS"),
335 ("skywalking-logger", "Ship logs to Apache SkyWalking"),
336 ("lago", "Meter requests as Lago billing events"),
337 ("prometheus", "Per-consumer request counters"),
339 ("opentelemetry", "OTLP/HTTP trace export (W3C traceparent)"),
340 ("zipkin", "Zipkin v2 trace export (B3 propagation)"),
341 ("skywalking", "SkyWalking segment export (sw8 propagation)"),
342 ("script", "Custom plugin logic written in Lua"),
344 ];
345
346 CATALOG
347 .iter()
348 .map(|(t, d)| serde_json::json!({"type": t, "description": d}))
349 .collect()
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355
356 fn factory_types() -> Vec<String> {
361 include_str!("../plugins/mod.rs")
362 .lines()
363 .filter_map(|line| {
364 let line = line.trim();
365 let rest = line.strip_prefix('"')?;
366 let (name, tail) = rest.split_once('"')?;
367 tail.trim_start()
368 .starts_with("=>")
369 .then(|| name.to_string())
370 })
371 .collect()
372 }
373
374 #[test]
379 fn test_catalog_covers_factory() {
380 let catalog: Vec<String> = plugin_catalog()
381 .iter()
382 .map(|p| p["type"].as_str().unwrap().to_string())
383 .collect();
384
385 let missing: Vec<_> = factory_types()
386 .iter()
387 .filter(|t| !catalog.contains(t))
388 .cloned()
389 .collect();
390 assert!(
391 missing.is_empty(),
392 "registered plugins missing from the UI catalog: {missing:?}"
393 );
394 }
395
396 #[test]
399 fn test_catalog_has_no_unknown_types() {
400 let factory = factory_types();
401 let unknown: Vec<_> = plugin_catalog()
402 .iter()
403 .map(|p| p["type"].as_str().unwrap().to_string())
404 .filter(|t| !factory.contains(t))
405 .collect();
406 assert!(
407 unknown.is_empty(),
408 "catalog advertises types create_plugin cannot build: {unknown:?}"
409 );
410 }
411
412 #[test]
413 fn test_catalog_has_no_duplicates() {
414 let mut seen = std::collections::HashSet::new();
415 for p in plugin_catalog() {
416 let t = p["type"].as_str().unwrap().to_string();
417 assert!(seen.insert(t.clone()), "duplicate catalog entry: {t}");
418 }
419 }
420
421 fn types_with_an_icon() -> Vec<String> {
424 include_str!("../../ui/src/pluginMeta.tsx")
425 .lines()
426 .filter_map(|line| {
427 let line = line.trim();
428 if !line.contains("color:") || !line.contains("icon:") {
432 return None;
433 }
434 let (key, _) = line.split_once(':')?;
435 let key = key.trim().trim_matches('\'');
436 let plausible = !key.is_empty()
437 && key
438 .chars()
439 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
440 plausible.then(|| key.to_string())
441 })
442 .collect()
443 }
444
445 #[test]
449 fn test_every_catalog_plugin_has_an_icon() {
450 let with_icon = types_with_an_icon();
451 let missing: Vec<_> = plugin_catalog()
452 .iter()
453 .map(|p| p["type"].as_str().unwrap().to_string())
454 .filter(|t| !with_icon.contains(t))
455 .collect();
456 assert!(
457 missing.is_empty(),
458 "plugins with no icon in ui/src/pluginMeta.tsx (they fall back to the generic cube): {missing:?}"
459 );
460 }
461}