Skip to main content

synapse_pingora/
api.rs

1//! Management HTTP API for runtime configuration and monitoring.
2//!
3//! Provides REST endpoints for:
4//! - Health status (`GET /health`)
5//! - Prometheus metrics (`GET /metrics`)
6//! - Configuration reload (`POST /reload`)
7//! - Site management (`GET/POST/PUT/DELETE /sites`)
8//! - WAF statistics (`GET /waf/stats`)
9//! - Site-specific configuration (`PUT /sites/:hostname/waf`, etc.)
10
11use parking_lot::RwLock;
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::sync::Arc;
15
16use crate::access::AccessListManager;
17use crate::actor::{ActorManager, ActorState, ActorStatsSnapshot};
18use crate::block_log::{BlockEvent, BlockLog};
19use crate::config_manager::{
20    AccessListRequest, ConfigManager, CreateSiteRequest, MutationResult, RateLimitRequest,
21    SiteDetailResponse, SiteWafRequest, UpdateSiteRequest,
22};
23use crate::correlation::CampaignManager;
24use crate::crawler::CrawlerDetector;
25use crate::dlp::DlpScanner;
26use crate::entity::{EntityManager, EntitySnapshot};
27use crate::health::{HealthChecker, HealthResponse};
28use crate::horizon::{HorizonClient, ThreatSignal};
29use crate::intelligence::{Signal, SignalManager, SignalQueryOptions, SignalSummary};
30use crate::metrics::MetricsRegistry;
31use crate::payload::{EndpointSortBy, PayloadManager};
32use crate::ratelimit::{RateLimitManager, RateLimitStats};
33use crate::reload::{ConfigReloader, ReloadResult};
34use crate::session::{SessionManager, SessionState, SessionStatsSnapshot};
35use crate::trends::{
36    AnomalyQueryOptions, TimeRange, TopSignalType, TrendQueryOptions, TrendsManager,
37};
38use crate::waf::{
39    Action as SynapseAction, Header as SynapseHeader, Request as SynapseRequest, Synapse, TraceSink,
40};
41
42/// API response wrapper.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct ApiResponse<T> {
45    /// Whether the operation succeeded
46    pub success: bool,
47    /// Response data (if successful)
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub data: Option<T>,
50    /// Error message (if failed)
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub error: Option<String>,
53}
54
55impl<T> ApiResponse<T> {
56    /// Creates a successful response.
57    pub fn ok(data: T) -> Self {
58        Self {
59            success: true,
60            data: Some(data),
61            error: None,
62        }
63    }
64
65    /// Creates an error response.
66    pub fn err(message: impl Into<String>) -> Self {
67        Self {
68            success: false,
69            data: None,
70            error: Some(message.into()),
71        }
72    }
73}
74
75/// API endpoint handlers.
76pub struct ApiHandler {
77    /// Health checker
78    health: Arc<HealthChecker>,
79    /// Metrics registry
80    metrics: Arc<MetricsRegistry>,
81    /// Configuration reloader
82    reloader: Option<Arc<ConfigReloader>>,
83    /// Rate limit manager
84    rate_limiter: Arc<RwLock<RateLimitManager>>,
85    /// Access list manager
86    access_lists: Arc<RwLock<AccessListManager>>,
87    /// Configuration manager for CRUD operations
88    config_manager: Option<Arc<ConfigManager>>,
89    /// API authentication token (if enabled)
90    auth_token: Option<String>,
91    /// Entity manager for per-IP tracking (dashboard feature)
92    entity_manager: Option<Arc<EntityManager>>,
93    /// Block log for recent block events (dashboard feature)
94    block_log: Option<Arc<BlockLog>>,
95    /// Campaign manager for threat correlation (dashboard feature)
96    campaign_manager: Option<Arc<CampaignManager>>,
97    /// Actor manager for behavioral tracking (Phase 5)
98    actor_manager: Option<Arc<ActorManager>>,
99    /// Session manager for session validation and hijack detection (Phase 5)
100    session_manager: Option<Arc<SessionManager>>,
101    /// Synapse detection engine for dry-run evaluation (Phase 2)
102    synapse_engine: Option<Arc<RwLock<Synapse>>>,
103    /// Payload profiling manager (Phase 6)
104    payload_manager: Option<Arc<PayloadManager>>,
105    /// Trends/anomaly detection manager (Phase 6)
106    trends_manager: Option<Arc<TrendsManager>>,
107    /// Signal intelligence manager (Phase 6)
108    signal_manager: Option<Arc<SignalManager>>,
109    /// Crawler/bot detection (Phase 6)
110    crawler_detector: Option<Arc<CrawlerDetector>>,
111    /// DLP scanner for sensitive data detection (Phase 4)
112    dlp_scanner: Option<Arc<DlpScanner>>,
113    /// Signal Horizon client (Phase 6)
114    horizon_client: Option<Arc<HorizonClient>>,
115    /// Signal dispatcher facade (labs-pdb2)
116    signal_dispatcher: Arc<crate::signals::dispatcher::SignalDispatcher>,
117}
118
119/// Timing-safe comparison of API keys without early exits.
120fn constant_time_eq(expected: &[u8], provided: &[u8]) -> bool {
121    let mut diff = expected.len() ^ provided.len();
122    for (idx, expected_byte) in expected.iter().enumerate() {
123        let provided_byte = provided.get(idx).copied().unwrap_or(0);
124        diff |= (expected_byte ^ provided_byte) as usize;
125    }
126    diff == 0
127}
128
129impl ApiHandler {
130    /// Creates a new API handler builder.
131    pub fn builder() -> ApiHandlerBuilder {
132        ApiHandlerBuilder::default()
133    }
134
135    pub fn access_lists(&self) -> Arc<RwLock<AccessListManager>> {
136        Arc::clone(&self.access_lists)
137    }
138
139    /// Returns the DLP scanner (if configured).
140    pub fn dlp_scanner(&self) -> Option<Arc<DlpScanner>> {
141        self.dlp_scanner.as_ref().map(Arc::clone)
142    }
143
144    /// Signal dispatcher facade (labs-pdb2)
145    pub fn signal_dispatcher(&self) -> Arc<crate::signals::dispatcher::SignalDispatcher> {
146        Arc::clone(&self.signal_dispatcher)
147    }
148
149    /// Report a threat signal to Signal Horizon (Phase 6).
150    pub async fn report_signal(&self, signal: ThreatSignal) -> Result<(), String> {
151        self.dispatch_horizon_signal(signal).await
152    }
153
154    /// Check if an IP or fingerprint is blocked by Signal Horizon blocklist.
155    pub fn is_horizon_blocked(&self, ip: Option<&str>, fingerprint: Option<&str>) -> bool {
156        match &self.horizon_client {
157            Some(client) => client.is_blocked(ip, fingerprint),
158            None => false,
159        }
160    }
161
162    /// Force a blocklist sync with Signal Horizon.
163    pub async fn sync_horizon_blocklist(&self) -> Result<(), String> {
164        match &self.horizon_client {
165            Some(client) => {
166                client.flush_signals().await; // Flush any pending signals first
167                                              // Blocklist sync is handled automatically by client reconnect/heartbeat
168                                              // but we can expose it if needed.
169                Ok(())
170            }
171            None => Err("Horizon client not available".to_string()),
172        }
173    }
174
175    /// Dispatch a signal to Signal Horizon without exposing the client.
176    pub async fn dispatch_horizon_signal(&self, signal: ThreatSignal) -> Result<(), String> {
177        match &self.horizon_client {
178            Some(client) => {
179                if !client.circuit_breaker().allow_request().await {
180                    return Err("Horizon circuit breaker open".to_string());
181                }
182                client.report_signal(signal);
183                Ok(())
184            }
185            None => Err("Horizon client not available".to_string()),
186        }
187    }
188
189    /// Handles GET /health request.
190    pub fn handle_health(&self) -> ApiResponse<HealthResponse> {
191        ApiResponse::ok(self.health.check())
192    }
193
194    /// Handles GET /metrics request.
195    /// Returns Prometheus exposition format.
196    pub fn handle_metrics(&self) -> String {
197        self.metrics.render_prometheus()
198    }
199
200    /// Handles POST /reload request.
201    pub fn handle_reload(&self) -> ApiResponse<ReloadResultResponse> {
202        match &self.reloader {
203            Some(reloader) => {
204                let result = reloader.reload();
205                ApiResponse::ok(ReloadResultResponse::from(result))
206            }
207            None => ApiResponse::err("Configuration reloader not available"),
208        }
209    }
210
211    /// Handles GET /sites request.
212    pub fn handle_list_sites(&self) -> ApiResponse<SiteListResponse> {
213        // Try ConfigReloader first (legacy path)
214        if let Some(reloader) = &self.reloader {
215            let config = reloader.config();
216            let config_read = config.read();
217            let sites: Vec<SiteInfo> = config_read
218                .sites
219                .iter()
220                .map(|s| SiteInfo {
221                    hostname: s.hostname.clone(),
222                    upstreams: s
223                        .upstreams
224                        .iter()
225                        .map(|u| format!("{}:{}", u.host, u.port))
226                        .collect(),
227                    tls_enabled: s.tls.is_some(),
228                    waf_enabled: s.waf.as_ref().map(|w| w.enabled).unwrap_or(true),
229                })
230                .collect();
231            return ApiResponse::ok(SiteListResponse { sites });
232        }
233
234        // Fall back to ConfigManager (multi-site mode)
235        if let Some(config_manager) = &self.config_manager {
236            let sites = config_manager.get_sites_info();
237            return ApiResponse::ok(SiteListResponse { sites });
238        }
239
240        // Legacy single-backend mode: return empty sites (not an error)
241        ApiResponse::ok(SiteListResponse { sites: vec![] })
242    }
243
244    /// Handles GET /stats request.
245    pub fn handle_stats(&self) -> ApiResponse<StatsResponse> {
246        let rate_limit_stats = self.rate_limiter.read().stats();
247        let uptime = self.health.uptime();
248
249        ApiResponse::ok(StatsResponse {
250            uptime_secs: uptime.as_secs(),
251            rate_limit: rate_limit_stats,
252            access_list_sites: self.access_lists.read().site_count(),
253        })
254    }
255
256    /// Handles GET /waf/stats request.
257    pub fn handle_waf_stats(&self) -> ApiResponse<WafStatsResponse> {
258        let health = self.health.check();
259        ApiResponse::ok(WafStatsResponse {
260            enabled: health.waf.enabled,
261            analyzed: health.waf.analyzed,
262            blocked: health.waf.blocked,
263            block_rate_percent: health.waf.block_rate_percent,
264            avg_detection_us: health.waf.avg_detection_us,
265        })
266    }
267
268    /// Handles GET /debug/profiles request.
269    /// Note: This requires the profiles_getter callback to be set; returns empty vec if not available.
270    /// In the full binary context, profiles are retrieved via DetectionEngine which uses thread-local storage.
271    pub fn handle_get_profiles(&self) -> ApiResponse<Vec<crate::profiler::EndpointProfile>> {
272        // Library context: profiles_getter not available, return empty
273        // The binary (main.rs) should provide profiles via a route handler that calls DetectionEngine directly
274        ApiResponse::ok(Vec::new())
275    }
276
277    /// Handles POST /api/profiles/reset request.
278    /// Clears all learned endpoint behavioral baselines.
279    pub fn handle_reset_profiles(&self) {
280        // Reset profiling metrics in the registry
281        // The actual profile store reset will be handled by the Profiler in pipeline context
282        self.metrics.reset_profiles();
283        tracing::info!("Endpoint profiles reset via API");
284    }
285
286    /// Handles POST /api/schemas/reset request.
287    /// Clears all learned API schemas from the schema learner.
288    pub fn handle_reset_schemas(&self) {
289        // Reset schema metrics in the registry
290        // Full schema learner reset requires pipeline access
291        self.metrics.reset_schemas();
292        tracing::info!("Schema learner reset via API");
293    }
294
295    // =========================================================================
296    // CRUD Mutation Handlers (Phase 5)
297    // =========================================================================
298
299    /// Handles POST /sites request - creates a new site.
300    pub fn handle_create_site(&self, request: CreateSiteRequest) -> ApiResponse<MutationResult> {
301        match &self.config_manager {
302            Some(manager) => match manager.create_site(request) {
303                Ok(result) => ApiResponse::ok(result),
304                Err(e) => ApiResponse::err(e.to_string()),
305            },
306            None => ApiResponse::err("Configuration manager not available"),
307        }
308    }
309
310    /// Handles GET /sites/:hostname request - gets site details.
311    pub fn handle_get_site(&self, hostname: &str) -> ApiResponse<SiteDetailResponse> {
312        match &self.config_manager {
313            Some(manager) => match manager.get_site(hostname) {
314                Ok(site) => ApiResponse::ok(site),
315                Err(e) => ApiResponse::err(e.to_string()),
316            },
317            None => ApiResponse::err("Configuration manager not available"),
318        }
319    }
320
321    /// Handles PUT /sites/:hostname request - updates site configuration.
322    pub fn handle_update_site(
323        &self,
324        hostname: &str,
325        request: UpdateSiteRequest,
326    ) -> ApiResponse<MutationResult> {
327        match &self.config_manager {
328            Some(manager) => match manager.update_site(hostname, request) {
329                Ok(result) => ApiResponse::ok(result),
330                Err(e) => ApiResponse::err(e.to_string()),
331            },
332            None => ApiResponse::err("Configuration manager not available"),
333        }
334    }
335
336    /// Handles DELETE /sites/:hostname request - deletes a site.
337    pub fn handle_delete_site(&self, hostname: &str) -> ApiResponse<MutationResult> {
338        match &self.config_manager {
339            Some(manager) => match manager.delete_site(hostname) {
340                Ok(result) => ApiResponse::ok(result),
341                Err(e) => ApiResponse::err(e.to_string()),
342            },
343            None => ApiResponse::err("Configuration manager not available"),
344        }
345    }
346
347    /// Handles PUT /sites/:hostname/waf request - updates WAF configuration.
348    pub fn handle_update_site_waf(
349        &self,
350        hostname: &str,
351        request: SiteWafRequest,
352    ) -> ApiResponse<MutationResult> {
353        match &self.config_manager {
354            Some(manager) => match manager.update_site_waf(hostname, request) {
355                Ok(result) => ApiResponse::ok(result),
356                Err(e) => ApiResponse::err(e.to_string()),
357            },
358            None => ApiResponse::err("Configuration manager not available"),
359        }
360    }
361
362    /// Handles PUT /sites/:hostname/rate-limit request - updates rate limit configuration.
363    pub fn handle_update_site_rate_limit(
364        &self,
365        hostname: &str,
366        request: RateLimitRequest,
367    ) -> ApiResponse<MutationResult> {
368        match &self.config_manager {
369            Some(manager) => match manager.update_site_rate_limit(hostname, request) {
370                Ok(result) => ApiResponse::ok(result),
371                Err(e) => ApiResponse::err(e.to_string()),
372            },
373            None => ApiResponse::err("Configuration manager not available"),
374        }
375    }
376
377    /// Handles PUT /sites/:hostname/access-list request - updates access list.
378    pub fn handle_update_site_access_list(
379        &self,
380        hostname: &str,
381        request: AccessListRequest,
382    ) -> ApiResponse<MutationResult> {
383        match &self.config_manager {
384            Some(manager) => match manager.update_site_access_list(hostname, request) {
385                Ok(result) => ApiResponse::ok(result),
386                Err(e) => ApiResponse::err(e.to_string()),
387            },
388            None => ApiResponse::err("Configuration manager not available"),
389        }
390    }
391
392    /// Handles GET /config request - retrieves full configuration.
393    pub fn handle_get_config(&self) -> ApiResponse<crate::config::ConfigFile> {
394        match &self.config_manager {
395            Some(manager) => ApiResponse::ok(manager.get_full_config()),
396            None => ApiResponse::err("Configuration manager not available"),
397        }
398    }
399
400    /// Handles POST /config request - updates full configuration.
401    pub fn handle_update_config(
402        &self,
403        config: crate::config::ConfigFile,
404    ) -> ApiResponse<MutationResult> {
405        match &self.config_manager {
406            Some(manager) => match manager.update_full_config(config) {
407                Ok(result) => ApiResponse::ok(result),
408                Err(e) => ApiResponse::err(e.to_string()),
409            },
410            None => ApiResponse::err("Configuration manager not available"),
411        }
412    }
413
414    /// Validates the API authentication token using constant-time comparison.
415    ///
416    /// Uses `subtle::ConstantTimeEq` to prevent timing attacks that could
417    /// allow attackers to guess valid tokens character-by-character.
418    pub fn validate_auth(&self, token: Option<&str>) -> bool {
419        match (self.auth_token.as_deref(), token) {
420            (Some(expected), Some(provided)) => {
421                let expected_bytes = expected.as_bytes();
422                let provided_bytes = provided.as_bytes();
423                // Constant-time comparison: prevents timing attacks
424                constant_time_eq(expected_bytes, provided_bytes)
425            }
426            _ => false,
427        }
428    }
429
430    /// Returns the metrics registry for recording.
431    pub fn metrics(&self) -> Arc<MetricsRegistry> {
432        Arc::clone(&self.metrics)
433    }
434
435    /// Returns the health checker.
436    pub fn health(&self) -> Arc<HealthChecker> {
437        Arc::clone(&self.health)
438    }
439
440    /// Returns the entity manager (if configured).
441    pub fn entity_manager(&self) -> Option<Arc<EntityManager>> {
442        self.entity_manager.as_ref().map(Arc::clone)
443    }
444
445    /// Returns the block log (if configured).
446    pub fn block_log(&self) -> Option<Arc<BlockLog>> {
447        self.block_log.as_ref().map(Arc::clone)
448    }
449
450    /// Returns the config manager (if configured).
451    pub fn config_manager(&self) -> Option<&Arc<ConfigManager>> {
452        self.config_manager.as_ref()
453    }
454
455    /// Returns the campaign manager (if configured).
456    pub fn campaign_manager(&self) -> Option<&Arc<CampaignManager>> {
457        self.campaign_manager.as_ref()
458    }
459
460    /// Returns the actor manager (if configured).
461    pub fn actor_manager(&self) -> Option<Arc<ActorManager>> {
462        self.actor_manager.as_ref().map(Arc::clone)
463    }
464
465    /// Returns the session manager (if configured).
466    pub fn session_manager(&self) -> Option<Arc<SessionManager>> {
467        self.session_manager.as_ref().map(Arc::clone)
468    }
469
470    /// Returns the signal manager (if configured).
471    pub fn signal_manager(&self) -> Option<Arc<SignalManager>> {
472        self.signal_manager.as_ref().map(Arc::clone)
473    }
474
475    /// Returns the synapse engine (if configured).
476    pub fn synapse_engine(&self) -> Option<Arc<RwLock<Synapse>>> {
477        self.synapse_engine.as_ref().map(Arc::clone)
478    }
479
480    /// Evaluates a request against the WAF rules (dry-run mode).
481    /// Returns the detection result without actually blocking.
482    pub fn evaluate_request(
483        &self,
484        method: &str,
485        uri: &str,
486        headers: &[(String, String)],
487        body: Option<&[u8]>,
488        client_ip: &str,
489    ) -> Option<EvaluateResult> {
490        let engine = self.synapse_engine.as_ref()?;
491
492        let start = std::time::Instant::now();
493
494        // Build Synapse Request
495        let synapse_headers: Vec<SynapseHeader> = headers
496            .iter()
497            .map(|(name, value)| SynapseHeader::new(name, value))
498            .collect();
499
500        let request = SynapseRequest {
501            method,
502            path: uri,
503            query: None,
504            headers: synapse_headers,
505            body,
506            client_ip,
507            is_static: false,
508        };
509
510        // Run detection
511        let verdict = engine.read().analyze(&request);
512        let elapsed = start.elapsed();
513
514        Some(EvaluateResult {
515            blocked: matches!(verdict.action, SynapseAction::Block),
516            risk_score: verdict.risk_score,
517            matched_rules: verdict.matched_rules.clone(),
518            block_reason: verdict.block_reason.clone(),
519            detection_time_us: elapsed.as_micros() as u64,
520        })
521    }
522
523    /// Evaluates a request against the WAF rules and streams trace events.
524    pub fn evaluate_request_trace(
525        &self,
526        method: &str,
527        uri: &str,
528        headers: &[(String, String)],
529        body: Option<&[u8]>,
530        client_ip: &str,
531        trace: &mut dyn TraceSink,
532    ) -> Option<EvaluateResult> {
533        let engine = self.synapse_engine.as_ref()?;
534
535        let start = std::time::Instant::now();
536
537        let synapse_headers: Vec<SynapseHeader> = headers
538            .iter()
539            .map(|(name, value)| SynapseHeader::new(name, value))
540            .collect();
541
542        let request = SynapseRequest {
543            method,
544            path: uri,
545            query: None,
546            headers: synapse_headers,
547            body,
548            client_ip,
549            is_static: false,
550        };
551
552        let verdict = engine.read().analyze_with_trace(&request, trace);
553        let elapsed = start.elapsed();
554
555        Some(EvaluateResult {
556            blocked: matches!(verdict.action, SynapseAction::Block),
557            risk_score: verdict.risk_score,
558            matched_rules: verdict.matched_rules.clone(),
559            block_reason: verdict.block_reason.clone(),
560            detection_time_us: elapsed.as_micros() as u64,
561        })
562    }
563
564    /// Handles GET /_sensor/actors request - returns actors (most recently seen first).
565    pub fn handle_list_actors(&self, limit: usize) -> Vec<ActorState> {
566        match &self.actor_manager {
567            Some(manager) => manager.list_actors(limit, 0),
568            None => Vec::new(),
569        }
570    }
571
572    /// Handles GET /_sensor/actors/stats request - returns actor statistics.
573    pub fn handle_actor_stats(&self) -> Option<ActorStatsSnapshot> {
574        self.actor_manager
575            .as_ref()
576            .map(|manager| manager.stats().snapshot())
577    }
578
579    /// Handles GET /_sensor/sessions request - returns active sessions.
580    pub fn handle_list_sessions(&self, limit: usize) -> Vec<SessionState> {
581        match &self.session_manager {
582            Some(manager) => manager.list_sessions(limit, 0),
583            None => Vec::new(),
584        }
585    }
586
587    /// Handles GET /_sensor/sessions/stats request - returns session statistics.
588    pub fn handle_session_stats(&self) -> Option<SessionStatsSnapshot> {
589        self.session_manager
590            .as_ref()
591            .map(|manager| manager.stats().snapshot())
592    }
593
594    /// Handles GET /_sensor/entities request - returns top entities by risk.
595    pub fn handle_list_entities(&self, limit: usize) -> Vec<EntitySnapshot> {
596        match &self.entity_manager {
597            Some(manager) => manager.list_top_risk(limit),
598            None => Vec::new(),
599        }
600    }
601
602    /// Handles GET /_sensor/blocks request - returns recent block events.
603    pub fn handle_list_blocks(&self, limit: usize) -> Vec<BlockEvent> {
604        match &self.block_log {
605            Some(log) => log.recent(limit),
606            None => Vec::new(),
607        }
608    }
609
610    // =========================================================================
611    // Payload Profiling Endpoints
612    // =========================================================================
613
614    /// Handles GET /_sensor/payload/stats - returns payload profiling summary.
615    pub fn handle_payload_stats(&self) -> ApiResponse<PayloadSummaryResponse> {
616        match &self.payload_manager {
617            Some(manager) => ApiResponse::ok(PayloadSummaryResponse::from(manager.get_summary())),
618            None => ApiResponse::err("Payload manager not available"),
619        }
620    }
621
622    /// Handles GET /_sensor/payload/endpoints - returns top endpoints by traffic.
623    pub fn handle_payload_endpoints(
624        &self,
625        limit: usize,
626    ) -> ApiResponse<Vec<EndpointPayloadSummary>> {
627        match &self.payload_manager {
628            Some(manager) => {
629                let endpoints = manager.list_top_endpoints(limit, EndpointSortBy::RequestCount);
630                let summaries: Vec<EndpointPayloadSummary> = endpoints
631                    .into_iter()
632                    .map(|stats| EndpointPayloadSummary {
633                        template: stats.template,
634                        request_count: stats.request_count,
635                        avg_request_size: stats.request.avg_bytes(),
636                        avg_response_size: stats.response.avg_bytes(),
637                    })
638                    .collect();
639                ApiResponse::ok(summaries)
640            }
641            None => ApiResponse::err("Payload manager not available"),
642        }
643    }
644
645    /// Handles GET /_sensor/payload/anomalies - returns recent payload anomalies.
646    pub fn handle_payload_anomalies(
647        &self,
648        limit: usize,
649    ) -> ApiResponse<Vec<PayloadAnomalyResponse>> {
650        match &self.payload_manager {
651            Some(manager) => {
652                let anomalies = manager.get_anomalies(limit);
653                let responses: Vec<PayloadAnomalyResponse> = anomalies
654                    .into_iter()
655                    .map(|a| PayloadAnomalyResponse {
656                        anomaly_type: format!("{:?}", a.anomaly_type),
657                        severity: format!("{:?}", a.severity),
658                        risk_applied: a.risk_applied,
659                        template: a.template,
660                        entity_id: a.entity_id,
661                        detected_at_ms: a.detected_at,
662                        description: a.description,
663                    })
664                    .collect();
665                ApiResponse::ok(responses)
666            }
667            None => ApiResponse::err("Payload manager not available"),
668        }
669    }
670
671    // =========================================================================
672    // Trends/Anomaly Detection Endpoints
673    // =========================================================================
674
675    /// Handles GET /_sensor/trends/summary - returns trends summary.
676    pub fn handle_trends_summary(&self) -> ApiResponse<TrendsSummaryResponse> {
677        match &self.trends_manager {
678            Some(manager) => {
679                let summary = manager.get_summary(TrendQueryOptions::default());
680                let signal_counts: HashMap<String, usize> = summary
681                    .by_category
682                    .iter()
683                    .map(|(category, data)| (category.to_string(), data.count))
684                    .collect();
685                ApiResponse::ok(TrendsSummaryResponse {
686                    total_signals: summary.total_signals,
687                    signal_counts,
688                    top_signal_types: summary.top_signal_types.clone(),
689                    time_range: summary.time_range,
690                    anomaly_count: summary.anomaly_count,
691                })
692            }
693            None => ApiResponse::err("Trends manager not available"),
694        }
695    }
696
697    /// Handles GET /_sensor/trends/anomalies - returns detected anomalies.
698    pub fn handle_trends_anomalies(&self, limit: usize) -> ApiResponse<Vec<TrendsAnomalyResponse>> {
699        match &self.trends_manager {
700            Some(manager) => {
701                let mut opts = AnomalyQueryOptions::default();
702                opts.limit = Some(limit);
703                let anomalies = manager.get_anomalies(opts);
704                let responses: Vec<TrendsAnomalyResponse> = anomalies
705                    .into_iter()
706                    .map(|a| TrendsAnomalyResponse {
707                        anomaly_type: format!("{:?}", a.anomaly_type),
708                        severity: format!("{:?}", a.severity),
709                        entities: a.entities,
710                        description: a.description,
711                        detected_at_ms: a.detected_at,
712                    })
713                    .collect();
714                ApiResponse::ok(responses)
715            }
716            None => ApiResponse::err("Trends manager not available"),
717        }
718    }
719
720    // =========================================================================
721    // Signal Intelligence Endpoints
722    // =========================================================================
723
724    /// Handles GET /_sensor/signals - returns recent intelligence signals.
725    pub fn handle_signals(&self, options: SignalQueryOptions) -> ApiResponse<SignalListResponse> {
726        match &self.signal_manager {
727            Some(manager) => {
728                let signals = manager.list_signals(options);
729                let summary = manager.summary();
730                ApiResponse::ok(SignalListResponse { signals, summary })
731            }
732            None => ApiResponse::err("Signal manager not available"),
733        }
734    }
735
736    // =========================================================================
737    // Crawler/Bot Detection Endpoints
738    // =========================================================================
739
740    /// Handles GET /_sensor/crawler/stats - returns crawler detection stats.
741    pub fn handle_crawler_stats(&self) -> ApiResponse<CrawlerStatsResponse> {
742        match &self.crawler_detector {
743            Some(detector) => {
744                let stats = detector.stats();
745                let total = stats.cache_hits + stats.cache_misses;
746                let cache_hit_rate = if total > 0 {
747                    stats.cache_hits as f64 / total as f64
748                } else {
749                    0.0
750                };
751                ApiResponse::ok(CrawlerStatsResponse {
752                    total_verifications: stats.total_verifications,
753                    verified_crawlers: stats.verified_crawlers,
754                    unverified_crawlers: stats.unverified_crawlers,
755                    bad_bots: stats.bad_bots,
756                    cache_hit_rate,
757                })
758            }
759            None => ApiResponse::err("Crawler detector not available"),
760        }
761    }
762
763    // =========================================================================
764    // Signal Horizon Endpoints
765    // =========================================================================
766
767    /// Handles GET /_sensor/horizon/stats - returns Signal Horizon connection stats.
768    pub fn handle_horizon_stats(&self) -> ApiResponse<HorizonStatsResponse> {
769        match &self.horizon_client {
770            Some(client) => {
771                let stats = client.stats();
772                ApiResponse::ok(HorizonStatsResponse {
773                    signals_sent: stats.signals_sent,
774                    signals_acked: stats.signals_acked,
775                    signals_queued: stats.signals_queued,
776                    signals_dropped: stats.signals_dropped,
777                    batches_sent: stats.batches_sent,
778                    heartbeats_sent: stats.heartbeats_sent,
779                    heartbeat_failures: stats.heartbeat_failures,
780                    reconnect_attempts: stats.reconnect_attempts,
781                    blocklist_size: client.blocklist_size(),
782                })
783            }
784            None => ApiResponse::err("Horizon client not available"),
785        }
786    }
787
788    /// Handles GET /_sensor/horizon/blocklist - returns blocklist entries.
789    pub fn handle_horizon_blocklist(
790        &self,
791        limit: usize,
792    ) -> ApiResponse<Vec<BlocklistEntryResponse>> {
793        match &self.horizon_client {
794            Some(client) => {
795                let blocklist = client.blocklist();
796                let mut entries: Vec<BlocklistEntryResponse> = blocklist
797                    .all_ips()
798                    .into_iter()
799                    .chain(blocklist.all_fingerprints())
800                    .take(limit)
801                    .map(|e| BlocklistEntryResponse {
802                        entry_type: format!("{:?}", e.block_type),
803                        value: e.indicator,
804                        reason: e.reason.unwrap_or_default(),
805                        source: e.source,
806                        expires_at: e.expires_at,
807                    })
808                    .collect();
809                entries.truncate(limit);
810                ApiResponse::ok(entries)
811            }
812            None => ApiResponse::err("Horizon client not available"),
813        }
814    }
815}
816
817// =============================================================================
818// Response Types for New Endpoints
819// =============================================================================
820
821/// Payload profiling summary response.
822#[derive(Debug, Clone, Serialize, Deserialize)]
823pub struct PayloadSummaryResponse {
824    pub total_endpoints: usize,
825    pub total_entities: usize,
826    pub total_requests: u64,
827    pub total_request_bytes: u64,
828    pub total_response_bytes: u64,
829    pub avg_request_size: f64,
830    pub avg_response_size: f64,
831    pub active_anomalies: usize,
832}
833
834impl From<crate::payload::PayloadSummary> for PayloadSummaryResponse {
835    fn from(s: crate::payload::PayloadSummary) -> Self {
836        Self {
837            total_endpoints: s.total_endpoints,
838            total_entities: s.total_entities,
839            total_requests: s.total_requests,
840            total_request_bytes: s.total_request_bytes,
841            total_response_bytes: s.total_response_bytes,
842            avg_request_size: s.avg_request_size,
843            avg_response_size: s.avg_response_size,
844            active_anomalies: s.active_anomalies,
845        }
846    }
847}
848
849/// Per-endpoint payload summary.
850#[derive(Debug, Clone, Serialize, Deserialize)]
851pub struct EndpointPayloadSummary {
852    pub template: String,
853    pub request_count: u64,
854    pub avg_request_size: f64,
855    pub avg_response_size: f64,
856}
857
858/// Payload anomaly response.
859#[derive(Debug, Clone, Serialize, Deserialize)]
860pub struct PayloadAnomalyResponse {
861    pub anomaly_type: String,
862    pub severity: String,
863    pub risk_applied: Option<f64>,
864    pub template: String,
865    pub entity_id: String,
866    pub detected_at_ms: i64,
867    pub description: String,
868}
869
870/// Trends summary response.
871#[derive(Debug, Clone, Serialize, Deserialize)]
872pub struct TrendsSummaryResponse {
873    pub total_signals: usize,
874    pub signal_counts: HashMap<String, usize>,
875    pub top_signal_types: Vec<TopSignalType>,
876    pub time_range: TimeRange,
877    pub anomaly_count: usize,
878}
879
880/// Trends anomaly response.
881#[derive(Debug, Clone, Serialize, Deserialize)]
882pub struct TrendsAnomalyResponse {
883    pub anomaly_type: String,
884    pub severity: String,
885    pub entities: Vec<String>,
886    pub description: String,
887    pub detected_at_ms: i64,
888}
889
890/// Signal list response.
891#[derive(Debug, Clone, Serialize, Deserialize)]
892pub struct SignalListResponse {
893    pub signals: Vec<Signal>,
894    pub summary: SignalSummary,
895}
896
897/// Crawler detection stats response.
898#[derive(Debug, Clone, Serialize, Deserialize)]
899pub struct CrawlerStatsResponse {
900    pub total_verifications: u64,
901    pub verified_crawlers: u64,
902    pub unverified_crawlers: u64,
903    pub bad_bots: u64,
904    pub cache_hit_rate: f64,
905}
906
907/// Signal Horizon stats response.
908#[derive(Debug, Clone, Serialize, Deserialize)]
909pub struct HorizonStatsResponse {
910    pub signals_sent: u64,
911    pub signals_acked: u64,
912    pub signals_queued: u64,
913    pub signals_dropped: u64,
914    pub batches_sent: u64,
915    pub heartbeats_sent: u64,
916    pub heartbeat_failures: u64,
917    pub reconnect_attempts: u32,
918    pub blocklist_size: usize,
919}
920
921/// Blocklist entry response from Signal Horizon.
922#[derive(Debug, Clone, Serialize, Deserialize)]
923pub struct BlocklistEntryResponse {
924    pub entry_type: String,
925    pub value: String,
926    pub reason: String,
927    pub source: String,
928    pub expires_at: Option<String>,
929}
930
931/// Builder for ApiHandler.
932#[derive(Default)]
933pub struct ApiHandlerBuilder {
934    health: Option<Arc<HealthChecker>>,
935    metrics: Option<Arc<MetricsRegistry>>,
936    reloader: Option<Arc<ConfigReloader>>,
937    rate_limiter: Option<Arc<RwLock<RateLimitManager>>>,
938    access_lists: Option<Arc<RwLock<AccessListManager>>>,
939    config_manager: Option<Arc<ConfigManager>>,
940    auth_token: Option<String>,
941    entity_manager: Option<Arc<EntityManager>>,
942    block_log: Option<Arc<BlockLog>>,
943    campaign_manager: Option<Arc<CampaignManager>>,
944    actor_manager: Option<Arc<ActorManager>>,
945    session_manager: Option<Arc<SessionManager>>,
946    synapse_engine: Option<Arc<RwLock<Synapse>>>,
947    payload_manager: Option<Arc<PayloadManager>>,
948    trends_manager: Option<Arc<TrendsManager>>,
949    signal_manager: Option<Arc<SignalManager>>,
950    crawler_detector: Option<Arc<CrawlerDetector>>,
951    dlp_scanner: Option<Arc<DlpScanner>>,
952    horizon_client: Option<Arc<HorizonClient>>,
953}
954
955impl ApiHandlerBuilder {
956    /// Sets the health checker.
957    pub fn health(mut self, health: Arc<HealthChecker>) -> Self {
958        self.health = Some(health);
959        self
960    }
961
962    /// Sets the metrics registry.
963    pub fn metrics(mut self, metrics: Arc<MetricsRegistry>) -> Self {
964        self.metrics = Some(metrics);
965        self
966    }
967
968    /// Sets the configuration reloader.
969    pub fn reloader(mut self, reloader: Arc<ConfigReloader>) -> Self {
970        self.reloader = Some(reloader);
971        self
972    }
973
974    /// Sets the rate limit manager.
975    pub fn rate_limiter(mut self, rate_limiter: Arc<RwLock<RateLimitManager>>) -> Self {
976        self.rate_limiter = Some(rate_limiter);
977        self
978    }
979
980    /// Sets the access list manager.
981    pub fn access_lists(mut self, access_lists: Arc<RwLock<AccessListManager>>) -> Self {
982        self.access_lists = Some(access_lists);
983        self
984    }
985
986    /// Sets the configuration manager for CRUD operations.
987    pub fn config_manager(mut self, config_manager: Arc<ConfigManager>) -> Self {
988        self.config_manager = Some(config_manager);
989        self
990    }
991
992    /// Sets the API authentication token.
993    pub fn auth_token(mut self, token: impl Into<String>) -> Self {
994        self.auth_token = Some(token.into());
995        self
996    }
997
998    /// Sets the entity manager for dashboard entity tracking.
999    pub fn entity_manager(mut self, entity_manager: Arc<EntityManager>) -> Self {
1000        self.entity_manager = Some(entity_manager);
1001        self
1002    }
1003
1004    /// Sets the block log for dashboard block event history.
1005    pub fn block_log(mut self, block_log: Arc<BlockLog>) -> Self {
1006        self.block_log = Some(block_log);
1007        self
1008    }
1009
1010    /// Sets the campaign manager for threat correlation.
1011    pub fn campaign_manager(mut self, manager: Arc<CampaignManager>) -> Self {
1012        self.campaign_manager = Some(manager);
1013        self
1014    }
1015
1016    /// Sets the actor manager for behavioral tracking.
1017    pub fn actor_manager(mut self, manager: Arc<ActorManager>) -> Self {
1018        self.actor_manager = Some(manager);
1019        self
1020    }
1021
1022    /// Sets the session manager for session validation and hijack detection.
1023    pub fn session_manager(mut self, manager: Arc<SessionManager>) -> Self {
1024        self.session_manager = Some(manager);
1025        self
1026    }
1027
1028    /// Sets the synapse detection engine for dry-run evaluation.
1029    pub fn synapse_engine(mut self, engine: Arc<RwLock<Synapse>>) -> Self {
1030        self.synapse_engine = Some(engine);
1031        self
1032    }
1033
1034    /// Sets the payload profiling manager.
1035    pub fn payload_manager(mut self, manager: Arc<PayloadManager>) -> Self {
1036        self.payload_manager = Some(manager);
1037        self
1038    }
1039
1040    /// Sets the trends/anomaly detection manager.
1041    pub fn trends_manager(mut self, manager: Arc<TrendsManager>) -> Self {
1042        self.trends_manager = Some(manager);
1043        self
1044    }
1045
1046    /// Sets the signal intelligence manager.
1047    pub fn signal_manager(mut self, manager: Arc<SignalManager>) -> Self {
1048        self.signal_manager = Some(manager);
1049        self
1050    }
1051
1052    /// Sets the crawler/bot detector.
1053    pub fn crawler_detector(mut self, detector: Arc<CrawlerDetector>) -> Self {
1054        self.crawler_detector = Some(detector);
1055        self
1056    }
1057
1058    /// Sets the DLP scanner.
1059    pub fn dlp_scanner(mut self, scanner: Arc<DlpScanner>) -> Self {
1060        self.dlp_scanner = Some(scanner);
1061        self
1062    }
1063
1064    /// Sets the Signal Horizon client.
1065    pub fn horizon_client(mut self, client: Arc<HorizonClient>) -> Self {
1066        self.horizon_client = Some(client);
1067        self
1068    }
1069
1070    /// Builds the API handler.
1071    pub fn build(self) -> ApiHandler {
1072        let metrics = self
1073            .metrics
1074            .unwrap_or_else(|| Arc::new(MetricsRegistry::new()));
1075        ApiHandler {
1076            health: self
1077                .health
1078                .unwrap_or_else(|| Arc::new(HealthChecker::default())),
1079            metrics: metrics.clone(),
1080            reloader: self.reloader,
1081            rate_limiter: self
1082                .rate_limiter
1083                .unwrap_or_else(|| Arc::new(RwLock::new(RateLimitManager::new()))),
1084            access_lists: self
1085                .access_lists
1086                .unwrap_or_else(|| Arc::new(RwLock::new(AccessListManager::new()))),
1087            config_manager: self.config_manager,
1088            auth_token: self.auth_token,
1089            entity_manager: self.entity_manager,
1090            block_log: self.block_log,
1091            campaign_manager: self.campaign_manager,
1092            actor_manager: self.actor_manager,
1093            session_manager: self.session_manager,
1094            synapse_engine: self.synapse_engine,
1095            payload_manager: self.payload_manager,
1096            trends_manager: self.trends_manager,
1097            signal_manager: self.signal_manager.clone(),
1098            crawler_detector: self.crawler_detector,
1099            dlp_scanner: self.dlp_scanner,
1100            horizon_client: self.horizon_client.clone(),
1101            signal_dispatcher: {
1102                let mut sinks: Vec<Arc<dyn crate::horizon::SignalSink>> = Vec::new();
1103                if let Some(ref client) = self.horizon_client {
1104                    sinks.push(Arc::clone(client) as Arc<dyn crate::horizon::SignalSink>);
1105                }
1106                Arc::new(crate::signals::dispatcher::SignalDispatcher::new(
1107                    sinks,
1108                    self.signal_manager,
1109                    metrics,
1110                ))
1111            },
1112        }
1113    }
1114}
1115
1116/// Response for reload operation.
1117#[derive(Debug, Clone, Serialize, Deserialize)]
1118pub struct ReloadResultResponse {
1119    pub success: bool,
1120    #[serde(skip_serializing_if = "Option::is_none")]
1121    pub error: Option<String>,
1122    pub sites_loaded: usize,
1123    pub certs_loaded: usize,
1124    pub duration_ms: u64,
1125}
1126
1127/// Result of a dry-run WAF evaluation.
1128#[derive(Debug, Clone, Serialize, Deserialize)]
1129pub struct EvaluateResult {
1130    /// Whether the request would have been blocked
1131    pub blocked: bool,
1132    /// Calculated risk score
1133    pub risk_score: u16,
1134    /// Rules that matched
1135    pub matched_rules: Vec<u32>,
1136    /// Reason for blocking (if blocked)
1137    pub block_reason: Option<String>,
1138    /// Time taken for detection in microseconds
1139    pub detection_time_us: u64,
1140}
1141
1142impl From<ReloadResult> for ReloadResultResponse {
1143    fn from(r: ReloadResult) -> Self {
1144        Self {
1145            success: r.success,
1146            error: r.error,
1147            sites_loaded: r.sites_loaded,
1148            certs_loaded: r.certs_loaded,
1149            duration_ms: r.duration_ms,
1150        }
1151    }
1152}
1153
1154/// Response for site list.
1155#[derive(Debug, Clone, Serialize, Deserialize)]
1156pub struct SiteListResponse {
1157    pub sites: Vec<SiteInfo>,
1158}
1159
1160/// Information about a single site.
1161#[derive(Debug, Clone, Serialize, Deserialize)]
1162pub struct SiteInfo {
1163    pub hostname: String,
1164    pub upstreams: Vec<String>,
1165    pub tls_enabled: bool,
1166    pub waf_enabled: bool,
1167}
1168
1169/// Response for stats endpoint.
1170#[derive(Debug, Clone, Serialize, Deserialize)]
1171pub struct StatsResponse {
1172    pub uptime_secs: u64,
1173    pub rate_limit: RateLimitStats,
1174    pub access_list_sites: usize,
1175}
1176
1177/// Response for WAF stats endpoint.
1178#[derive(Debug, Clone, Serialize, Deserialize)]
1179pub struct WafStatsResponse {
1180    pub enabled: bool,
1181    pub analyzed: u64,
1182    pub blocked: u64,
1183    pub block_rate_percent: f64,
1184    pub avg_detection_us: u64,
1185}
1186
1187/// HTTP method for API routing.
1188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1189pub enum HttpMethod {
1190    Get,
1191    Post,
1192    Put,
1193    Delete,
1194}
1195
1196/// API route definition.
1197#[derive(Debug, Clone)]
1198pub struct ApiRoute {
1199    pub method: HttpMethod,
1200    pub path: &'static str,
1201    pub description: &'static str,
1202    pub auth_required: bool,
1203}
1204
1205/// Available API routes.
1206pub const API_ROUTES: &[ApiRoute] = &[
1207    // Health and monitoring (no auth)
1208    ApiRoute {
1209        method: HttpMethod::Get,
1210        path: "/health",
1211        description: "Health check endpoint",
1212        auth_required: false,
1213    },
1214    ApiRoute {
1215        method: HttpMethod::Get,
1216        path: "/metrics",
1217        description: "Prometheus metrics endpoint",
1218        auth_required: false,
1219    },
1220    // Configuration management (auth required)
1221    ApiRoute {
1222        method: HttpMethod::Post,
1223        path: "/reload",
1224        description: "Reload configuration from file",
1225        auth_required: true,
1226    },
1227    ApiRoute {
1228        method: HttpMethod::Get,
1229        path: "/sites",
1230        description: "List all configured sites",
1231        auth_required: true,
1232    },
1233    ApiRoute {
1234        method: HttpMethod::Post,
1235        path: "/sites",
1236        description: "Create a new site",
1237        auth_required: true,
1238    },
1239    ApiRoute {
1240        method: HttpMethod::Get,
1241        path: "/sites/:hostname",
1242        description: "Get site details",
1243        auth_required: true,
1244    },
1245    ApiRoute {
1246        method: HttpMethod::Put,
1247        path: "/sites/:hostname",
1248        description: "Update site configuration",
1249        auth_required: true,
1250    },
1251    ApiRoute {
1252        method: HttpMethod::Delete,
1253        path: "/sites/:hostname",
1254        description: "Delete a site",
1255        auth_required: true,
1256    },
1257    ApiRoute {
1258        method: HttpMethod::Put,
1259        path: "/sites/:hostname/waf",
1260        description: "Update site WAF configuration",
1261        auth_required: true,
1262    },
1263    ApiRoute {
1264        method: HttpMethod::Put,
1265        path: "/sites/:hostname/rate-limit",
1266        description: "Update site rate limit configuration",
1267        auth_required: true,
1268    },
1269    ApiRoute {
1270        method: HttpMethod::Put,
1271        path: "/sites/:hostname/access-list",
1272        description: "Update site access list",
1273        auth_required: true,
1274    },
1275    // Statistics
1276    ApiRoute {
1277        method: HttpMethod::Get,
1278        path: "/stats",
1279        description: "Runtime statistics",
1280        auth_required: true,
1281    },
1282    ApiRoute {
1283        method: HttpMethod::Get,
1284        path: "/waf/stats",
1285        description: "WAF statistics",
1286        auth_required: true,
1287    },
1288];
1289
1290#[cfg(test)]
1291mod tests {
1292    use super::*;
1293
1294    #[test]
1295    fn test_api_response_ok() {
1296        let response: ApiResponse<String> = ApiResponse::ok("test".to_string());
1297        assert!(response.success);
1298        assert_eq!(response.data, Some("test".to_string()));
1299        assert!(response.error.is_none());
1300    }
1301
1302    #[test]
1303    fn test_api_response_err() {
1304        let response: ApiResponse<String> = ApiResponse::err("error message");
1305        assert!(!response.success);
1306        assert!(response.data.is_none());
1307        assert_eq!(response.error, Some("error message".to_string()));
1308    }
1309
1310    #[test]
1311    fn test_api_handler_builder() {
1312        let handler = ApiHandler::builder().auth_token("secret").build();
1313
1314        assert!(handler.validate_auth(Some("secret")));
1315        assert!(!handler.validate_auth(Some("wrong")));
1316        assert!(!handler.validate_auth(None));
1317    }
1318
1319    #[test]
1320    fn test_api_handler_no_auth() {
1321        let handler = ApiHandler::builder().build();
1322
1323        // No configured auth token should deny access
1324        assert!(!handler.validate_auth(None));
1325        assert!(!handler.validate_auth(Some("anything")));
1326    }
1327
1328    #[test]
1329    fn test_handle_health() {
1330        let handler = ApiHandler::builder().build();
1331        let response = handler.handle_health();
1332
1333        assert!(response.success);
1334        assert!(response.data.is_some());
1335    }
1336
1337    #[test]
1338    fn test_handle_metrics() {
1339        let handler = ApiHandler::builder().build();
1340        let metrics = handler.handle_metrics();
1341
1342        assert!(metrics.contains("synapse_"));
1343    }
1344
1345    #[test]
1346    fn test_handle_stats() {
1347        let handler = ApiHandler::builder().build();
1348        let response = handler.handle_stats();
1349
1350        assert!(response.success);
1351        let stats = response.data.unwrap();
1352        assert!(stats.uptime_secs < 1); // Just created
1353    }
1354
1355    #[test]
1356    fn test_handle_waf_stats() {
1357        let handler = ApiHandler::builder().build();
1358        let response = handler.handle_waf_stats();
1359
1360        assert!(response.success);
1361        let waf = response.data.unwrap();
1362        assert!(waf.enabled);
1363    }
1364
1365    #[test]
1366    fn test_handle_reload_no_reloader() {
1367        let handler = ApiHandler::builder().build();
1368        let response = handler.handle_reload();
1369
1370        assert!(!response.success);
1371        assert!(response.error.is_some());
1372    }
1373
1374    #[test]
1375    fn test_handle_list_sites_no_reloader() {
1376        let handler = ApiHandler::builder().build();
1377        let response = handler.handle_list_sites();
1378
1379        // Returns success with empty sites for legacy single-backend mode
1380        assert!(response.success);
1381        assert!(response.error.is_none());
1382        assert!(response.data.unwrap().sites.is_empty());
1383    }
1384
1385    #[test]
1386    fn test_api_routes() {
1387        assert!(!API_ROUTES.is_empty());
1388
1389        // Health should not require auth
1390        let health_route = API_ROUTES.iter().find(|r| r.path == "/health").unwrap();
1391        assert!(!health_route.auth_required);
1392
1393        // Reload should require auth
1394        let reload_route = API_ROUTES.iter().find(|r| r.path == "/reload").unwrap();
1395        assert!(reload_route.auth_required);
1396    }
1397
1398    #[test]
1399    fn test_reload_result_response() {
1400        let result = ReloadResult {
1401            success: true,
1402            error: None,
1403            sites_loaded: 5,
1404            certs_loaded: 3,
1405            duration_ms: 100,
1406        };
1407
1408        let response = ReloadResultResponse::from(result);
1409        assert!(response.success);
1410        assert_eq!(response.sites_loaded, 5);
1411        assert_eq!(response.certs_loaded, 3);
1412    }
1413
1414    #[test]
1415    fn test_site_info_serialization() {
1416        let site = SiteInfo {
1417            hostname: "example.com".to_string(),
1418            upstreams: vec!["127.0.0.1:8080".to_string()],
1419            tls_enabled: true,
1420            waf_enabled: true,
1421        };
1422
1423        let json = serde_json::to_string(&site).unwrap();
1424        assert!(json.contains("example.com"));
1425        assert!(json.contains("tls_enabled"));
1426    }
1427}