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