Skip to main content

featherbit/admin/
debug.rs

1//! Admin API for debug mode: trace listing/retrieval and the plugin sandbox.
2//!
3//! Every route except `GET /api/debug/config` responds `404` while
4//! `debug.enabled` is false. A `403` would confirm the surface exists, and
5//! these endpoints dump request contexts and execute plugins — the highest
6//! value target on the box. The `404` is defence in depth behind the Basic Auth
7//! layer, not a replacement for it.
8//!
9//! Discoverability is preserved two ways: each gated rejection logs a warning
10//! naming the exact config key, and `GET /api/debug/config` always answers so
11//! the web UI can render an explanatory empty state instead of a mystery.
12
13use std::sync::Arc;
14
15use axum::extract::{Path, Query, State};
16use axum::http::StatusCode;
17use axum::response::IntoResponse;
18use axum::routing::{get, post};
19use axum::{Json, Router};
20
21use crate::debug::render::{apply_filter, render_trace, TraceFilter};
22use crate::debug::sandbox::SandboxRequest;
23use crate::state::SharedState;
24
25/// Builds the router for the `/api/debug/*` endpoints.
26pub fn router() -> Router<Arc<SharedState>> {
27    Router::new()
28        .route("/api/debug/config", get(get_config))
29        .route("/api/debug/traces", get(list_traces).delete(clear_traces))
30        .route("/api/debug/traces/{id}", get(get_trace))
31        .route("/api/debug/sandbox", post(run_sandbox))
32}
33
34/// The `404` returned when debug mode is off, matching the shape the rest of
35/// the Admin API uses for a missing resource.
36fn disabled(what: &str) -> axum::response::Response {
37    tracing::warn!(
38        "{} was requested but debug mode is off; set `debug.enabled: true` in \
39         system.yaml (or FEATHERBIT_DEBUG=true) and restart",
40        what
41    );
42    (
43        StatusCode::NOT_FOUND,
44        Json(serde_json::json!({"error": "not_found"})),
45    )
46        .into_response()
47}
48
49/// `GET /api/debug/config` — the effective debug settings.
50///
51/// Deliberately **not** gated: it returns only booleans the UI needs to explain
52/// itself, and answering it is what turns "the button does nothing" into "debug
53/// mode is off, here is the key to set".
54async fn get_config(State(state): State<Arc<SharedState>>) -> impl IntoResponse {
55    let d = &state.debug;
56    Json(serde_json::json!({
57        "enabled": d.enabled,
58        "sandbox": d.sandbox_enabled,
59        "trigger_header": d.trigger_header,
60        "trace_all": d.trace_all,
61        "capture_bodies": d.capture_bodies,
62        "max_traces": d.max_traces,
63        "max_steps": d.max_steps,
64        "max_body_bytes": d.max_body_bytes,
65        "traces_stored": d.len(),
66    }))
67}
68
69/// `GET /api/debug/traces` — summaries, newest first.
70///
71/// Optional query filters (`route`, `policy`, `status`, `source`, `limit`) let
72/// you narrow the buffer to the recent requests on one policy or route, then
73/// select one to inspect via `GET /api/debug/traces/{id}`.
74async fn list_traces(
75    State(state): State<Arc<SharedState>>,
76    Query(filter): Query<TraceFilter>,
77) -> impl IntoResponse {
78    if !state.debug.enabled {
79        return disabled("trace listing");
80    }
81    let traces = apply_filter(state.debug.list(), &filter);
82    // `retention` travels with every listing: an empty `traces` is ambiguous
83    // on its own, since a rotated-out match and a match that never happened
84    // look identical.
85    Json(serde_json::json!({
86        "traces": traces,
87        "retention": state.debug.retention(),
88    }))
89    .into_response()
90}
91
92/// `GET /api/debug/traces/{id}` — one trace with computed changes.
93///
94/// Errors: `404` if the id is unknown or has been evicted from the ring buffer.
95async fn get_trace(
96    State(state): State<Arc<SharedState>>,
97    Path(id): Path<String>,
98) -> impl IntoResponse {
99    if !state.debug.enabled {
100        return disabled("trace retrieval");
101    }
102    match state.debug.get(&id) {
103        Some(trace) => Json(render_trace(&trace)).into_response(),
104        None => (
105            StatusCode::NOT_FOUND,
106            Json(serde_json::json!({"error": "not_found"})),
107        )
108            .into_response(),
109    }
110}
111
112/// `DELETE /api/debug/traces` — empties the buffer.
113async fn clear_traces(State(state): State<Arc<SharedState>>) -> impl IntoResponse {
114    if !state.debug.enabled {
115        return disabled("trace clearing");
116    }
117    let removed = state.debug.clear();
118    Json(serde_json::json!({ "status": "cleared", "removed": removed })).into_response()
119}
120
121fn bad_request(msg: impl Into<String>) -> axum::response::Response {
122    (
123        StatusCode::BAD_REQUEST,
124        Json(serde_json::json!({"error": msg.into()})),
125    )
126        .into_response()
127}
128
129/// `POST /api/debug/sandbox` — run ad-hoc nodes or a named policy against a
130/// synthetic context.
131///
132/// Plugins execute **for real**: outbound callouts are made, rate-limit
133/// counters decrement, breakers trip, loggers fire. That is the point (a
134/// `key-auth` node that did not resolve real consumers would be a lie), but it
135/// is why the endpoint is gated behind `debug.enabled` + `debug.sandbox` on top
136/// of admin auth, and why every response carries a `warning`.
137///
138/// Errors: `400` for a malformed request or a plugin config the compiler
139/// rejects; `404` for an unknown policy; `504` if the run exceeds
140/// `debug.sandbox_timeout_seconds`.
141async fn run_sandbox(
142    State(state): State<Arc<SharedState>>,
143    Json(req): Json<SandboxRequest>,
144) -> impl IntoResponse {
145    use crate::debug::sandbox::{run_sandbox as run, SandboxError};
146    match run(&state, req).await {
147        Ok(r) => Json(serde_json::json!({
148            "mode": r.mode,
149            "policy": r.policy,
150            "warning": "plugins executed for real: outbound calls were made and shared \
151                        rate-limit/breaker state was mutated",
152            "stored_trace_id": r.stored_trace_id,
153            "trace": r.trace,
154        }))
155        .into_response(),
156        Err(SandboxError::Disabled) => disabled("sandbox"),
157        Err(SandboxError::SandboxDisabled) => disabled("sandbox (debug.sandbox is false)"),
158        Err(SandboxError::BadRequest(e)) => bad_request(e),
159        Err(SandboxError::UnknownPolicy(name)) => (
160            StatusCode::NOT_FOUND,
161            Json(serde_json::json!({"error": format!("unknown policy '{name}'")})),
162        )
163            .into_response(),
164        Err(SandboxError::Timeout(secs)) => (
165            StatusCode::GATEWAY_TIMEOUT,
166            Json(serde_json::json!({
167                "error": "sandbox_timeout",
168                "message": format!("run exceeded debug.sandbox_timeout_seconds ({}s)", secs),
169            })),
170        )
171            .into_response(),
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::config::{DebugConfig, GatewayConfig, SystemConfig};
179    use crate::config_store::FileConfigStore;
180    use crate::debug::store::TraceSummary;
181    use crate::debug::TraceSource;
182
183    fn state_with(debug: DebugConfig) -> Arc<SharedState> {
184        // Every section of both configs has a serde default, so an empty
185        // document is the cheapest way to get a valid baseline.
186        let system = SystemConfig {
187            debug,
188            ..serde_yaml::from_str::<SystemConfig>("{}").unwrap()
189        };
190        let gateway: GatewayConfig = serde_yaml::from_str("{}").unwrap();
191        Arc::new(
192            SharedState::new(
193                system,
194                gateway,
195                None,
196                Arc::new(FileConfigStore::new(std::path::PathBuf::from(
197                    "gateway.yaml",
198                ))),
199            )
200            .unwrap(),
201        )
202    }
203
204    /// The gate must not depend on the handler being reached — assert the
205    /// predicate the handlers share.
206    #[test]
207    fn test_disabled_by_default() {
208        let s = state_with(DebugConfig::default());
209        assert!(!s.debug.enabled);
210    }
211
212    #[tokio::test]
213    async fn test_config_endpoint_answers_even_when_disabled() {
214        let s = state_with(DebugConfig::default());
215        let resp = get_config(State(s)).await.into_response();
216        // This is the one endpoint that must not 404: the UI reads it to
217        // explain *why* debugging is unavailable.
218        assert_eq!(resp.status(), StatusCode::OK);
219    }
220
221    #[tokio::test]
222    async fn test_trace_routes_404_when_disabled() {
223        let s = state_with(DebugConfig::default());
224        assert_eq!(
225            list_traces(State(s.clone()), Query(TraceFilter::default()))
226                .await
227                .into_response()
228                .status(),
229            StatusCode::NOT_FOUND
230        );
231        assert_eq!(
232            get_trace(State(s.clone()), Path("any".to_string()))
233                .await
234                .into_response()
235                .status(),
236            StatusCode::NOT_FOUND
237        );
238        assert_eq!(
239            clear_traces(State(s.clone()))
240                .await
241                .into_response()
242                .status(),
243            StatusCode::NOT_FOUND
244        );
245        assert_eq!(
246            run_sandbox(State(s), Json(SandboxRequest::default()))
247                .await
248                .into_response()
249                .status(),
250            StatusCode::NOT_FOUND
251        );
252    }
253
254    #[tokio::test]
255    async fn test_sandbox_404s_when_only_sandbox_is_off() {
256        let s = state_with(DebugConfig {
257            enabled: true,
258            sandbox: false,
259            ..Default::default()
260        });
261        // Tracing still works...
262        assert_eq!(
263            list_traces(State(s.clone()), Query(TraceFilter::default()))
264                .await
265                .into_response()
266                .status(),
267            StatusCode::OK
268        );
269        // ...but plugin execution is separately withheld.
270        assert_eq!(
271            run_sandbox(State(s), Json(SandboxRequest::default()))
272                .await
273                .into_response()
274                .status(),
275            StatusCode::NOT_FOUND
276        );
277    }
278
279    #[tokio::test]
280    async fn test_sandbox_requires_exactly_one_mode() {
281        let s = state_with(DebugConfig {
282            enabled: true,
283            ..Default::default()
284        });
285        // Neither.
286        assert_eq!(
287            run_sandbox(State(s.clone()), Json(SandboxRequest::default()))
288                .await
289                .into_response()
290                .status(),
291            StatusCode::BAD_REQUEST
292        );
293        // Both.
294        let both = SandboxRequest {
295            nodes: Some(Vec::new()),
296            policy: Some("p".to_string()),
297            ..Default::default()
298        };
299        assert_eq!(
300            run_sandbox(State(s), Json(both))
301                .await
302                .into_response()
303                .status(),
304            StatusCode::BAD_REQUEST
305        );
306    }
307
308    #[tokio::test]
309    async fn test_sandbox_unknown_policy_404s() {
310        let s = state_with(DebugConfig {
311            enabled: true,
312            ..Default::default()
313        });
314        let req = SandboxRequest {
315            policy: Some("nope".to_string()),
316            ..Default::default()
317        };
318        assert_eq!(
319            run_sandbox(State(s), Json(req))
320                .await
321                .into_response()
322                .status(),
323            StatusCode::NOT_FOUND
324        );
325    }
326
327    fn summary(route: &str, policy: &str, status: u16, source: TraceSource) -> TraceSummary {
328        TraceSummary {
329            id: format!("{route}-{status}"),
330            seq: 0,
331            source,
332            started_ms: 0,
333            route: Some(route.to_string()),
334            policy: policy.to_string(),
335            method: "GET".to_string(),
336            path: "/x".to_string(),
337            status,
338            duration_us: 1,
339            step_count: 1,
340            error_count: 0,
341            captured_bodies: false,
342        }
343    }
344
345    fn sample() -> Vec<TraceSummary> {
346        vec![
347            summary("echo-api", "echo-policy", 200, TraceSource::Request),
348            summary("secure-api", "secure-policy", 401, TraceSource::Request),
349            summary("echo-api", "echo-policy", 502, TraceSource::Request),
350            summary("s", "echo-policy", 200, TraceSource::Sandbox),
351        ]
352    }
353
354    fn ids(traces: Vec<TraceSummary>) -> Vec<String> {
355        traces.into_iter().map(|t| t.id).collect()
356    }
357
358    #[test]
359    fn test_no_filter_returns_all() {
360        assert_eq!(apply_filter(sample(), &TraceFilter::default()).len(), 4);
361    }
362
363    #[test]
364    fn test_filter_by_route() {
365        let f = TraceFilter {
366            route: Some("echo-api".to_string()),
367            ..Default::default()
368        };
369        assert_eq!(apply_filter(sample(), &f).len(), 2);
370    }
371
372    #[test]
373    fn test_filter_by_policy_spans_routes() {
374        // echo-policy appears on a route and a sandbox run.
375        let f = TraceFilter {
376            policy: Some("echo-policy".to_string()),
377            ..Default::default()
378        };
379        assert_eq!(apply_filter(sample(), &f).len(), 3);
380    }
381
382    #[test]
383    fn test_filters_are_anded() {
384        let f = TraceFilter {
385            policy: Some("echo-policy".to_string()),
386            status: Some(200),
387            source: Some("request".to_string()),
388            ..Default::default()
389        };
390        assert_eq!(ids(apply_filter(sample(), &f)), vec!["echo-api-200"]);
391    }
392
393    #[test]
394    fn test_empty_filter_string_is_ignored() {
395        // A bare `?route=` must not filter everything out.
396        let f = TraceFilter {
397            route: Some("  ".to_string()),
398            ..Default::default()
399        };
400        assert_eq!(apply_filter(sample(), &f).len(), 4);
401    }
402
403    #[test]
404    fn test_limit_applies_after_filtering() {
405        let f = TraceFilter {
406            policy: Some("echo-policy".to_string()),
407            limit: Some(2),
408            ..Default::default()
409        };
410        assert_eq!(apply_filter(sample(), &f).len(), 2);
411    }
412
413    #[test]
414    fn test_filter_by_source() {
415        let f = TraceFilter {
416            source: Some("sandbox".to_string()),
417            ..Default::default()
418        };
419        assert_eq!(ids(apply_filter(sample(), &f)), vec!["s-200"]);
420    }
421
422    #[tokio::test]
423    async fn test_sandbox_rejects_unknown_plugin_type() {
424        let s = state_with(DebugConfig {
425            enabled: true,
426            ..Default::default()
427        });
428        let req = SandboxRequest {
429            nodes: Some(vec![crate::config::NodeConfig {
430                id: "x".to_string(),
431                node_type: "no-such-plugin".to_string(),
432                config: Default::default(),
433                config_ref: None,
434                position: None,
435            }]),
436            ..Default::default()
437        };
438        assert_eq!(
439            run_sandbox(State(s), Json(req))
440                .await
441                .into_response()
442                .status(),
443            StatusCode::BAD_REQUEST
444        );
445    }
446
447    /// A sandbox run of a policy that uses a supernode must expand it the
448    /// same way the data plane does — namespaced inner steps in the trace.
449    #[tokio::test]
450    async fn test_sandbox_expands_supernodes() {
451        let system = SystemConfig {
452            debug: DebugConfig {
453                enabled: true,
454                ..Default::default()
455            },
456            ..serde_yaml::from_str::<SystemConfig>("{}").unwrap()
457        };
458        let gateway: GatewayConfig = serde_yaml::from_str(
459            r#"
460supernodes:
461  - name: secured-call
462    nodes:
463      - { id: input,  type: input }
464      - { id: output, type: output }
465      - { id: error,  type: error }
466      - { id: c, type: cors }
467    edges:
468      - { from: input.out, to: c.in }
469      - { from: c.success, to: output.in }
470      - { from: c.preflight, to: output.in }
471policies:
472  - name: p
473    nodes:
474      - { id: listener, type: listener }
475      - { id: sec, type: supernode, config: { name: secured-call } }
476      - { id: client, type: client }
477    edges:
478      - { from: listener.out, to: sec.in }
479      - { from: sec.success, to: client.in }
480"#,
481        )
482        .unwrap();
483        let s = Arc::new(
484            SharedState::new(
485                system,
486                gateway,
487                None,
488                Arc::new(FileConfigStore::new(std::path::PathBuf::from(
489                    "gateway.yaml",
490                ))),
491            )
492            .unwrap(),
493        );
494
495        let req = SandboxRequest {
496            policy: Some("p".to_string()),
497            ..Default::default()
498        };
499        let resp = run_sandbox(State(s), Json(req)).await.into_response();
500        assert_eq!(resp.status(), StatusCode::OK);
501
502        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
503            .await
504            .unwrap();
505        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
506        let steps = v["trace"]["steps"].as_array().unwrap();
507        assert!(
508            steps
509                .iter()
510                .any(|s| s["node_id"].as_str().unwrap_or("").starts_with("sec/")),
511            "expected a namespaced sec/* step, got: {v}"
512        );
513    }
514
515    /// A sandbox run resolves config_ref exactly like the data plane: the
516    /// mocking node inherits the shared config and answers with its body.
517    #[tokio::test]
518    async fn test_sandbox_resolves_plugin_configs() {
519        let system = SystemConfig {
520            debug: DebugConfig {
521                enabled: true,
522                ..Default::default()
523            },
524            ..serde_yaml::from_str::<SystemConfig>("{}").unwrap()
525        };
526        let gateway: GatewayConfig = serde_yaml::from_str(
527            r#"
528plugin_configs:
529  - name: m
530    type: mocking
531    config: { response_status: 200, response_example: "from-shared", content_type: "text/plain" }
532policies:
533  - name: p
534    nodes:
535      - { id: listener, type: listener }
536      - { id: mock, type: mocking, config_ref: m }
537      - { id: client, type: client }
538    edges:
539      - { from: listener.out, to: mock.in }
540      - { from: mock.success, to: client.in }
541"#,
542        )
543        .unwrap();
544        let s = Arc::new(
545            SharedState::new(
546                system,
547                gateway,
548                None,
549                Arc::new(FileConfigStore::new(std::path::PathBuf::from(
550                    "gateway.yaml",
551                ))),
552            )
553            .unwrap(),
554        );
555
556        let req = SandboxRequest {
557            policy: Some("p".to_string()),
558            ..Default::default()
559        };
560        let resp = run_sandbox(State(s), Json(req)).await.into_response();
561        assert_eq!(resp.status(), StatusCode::OK);
562        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
563            .await
564            .unwrap();
565        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
566        let steps = v["trace"]["steps"].as_array().unwrap();
567        // The mocking node ran with the shared body -> response body captured
568        // in the final snapshot contains it (bodies off => assert via status).
569        assert!(
570            steps
571                .iter()
572                .any(|s| s["node_id"] == "mock" && s["outcome"]["kind"] == "success"),
573            "{v}"
574        );
575    }
576}