Skip to main content

syslog_server_mcp/
probe.rs

1use crate::error::Result;
2use crate::notifier::ListChangedNotifier;
3use crate::rest_client::RestClient;
4use crate::tools::ToolRegistry;
5use reqwest::StatusCode;
6use std::sync::Arc;
7use std::time::Duration;
8use url::Url;
9
10// shorter than REST_TIMEOUT to fail fast on startup
11const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
12
13#[derive(Debug, Clone)]
14pub enum AuthPosture {
15    Enabled,
16    Disabled,
17    Unknown,
18}
19
20#[derive(Debug)]
21pub struct StartupProbeResult {
22    pub reachable: Result<()>,
23    pub auth_posture: AuthPosture,
24}
25
26/// Phase classification returned by the capability probe.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ServerPhase {
29    Phase0,
30    Phase1,
31}
32
33/// Result of one capability probe pass.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum ProbeResult {
36    Phase(ServerPhase),
37    /// One or more endpoint checks returned 5xx or a transport error.
38    Unknown,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42enum EndpointState {
43    Exists,
44    Absent,
45    Unknown,
46}
47
48pub struct CapabilityProbe;
49
50impl CapabilityProbe {
51    /// Probe the Phase 1 endpoint set with single-shot HEAD requests.
52    pub async fn detect_phase(rest: &RestClient) -> ProbeResult {
53        let (alert_history, events_aggregate, event_by_id) = tokio::join!(
54            rest.head(
55                "/api/v1/alert-history?since=2026-01-01T00:00:00Z&until=2026-01-01T01:00:00Z&limit=1"
56            ),
57            rest.head(
58                "/api/v1/events/aggregate?facets=histogram&since=2026-01-01T00:00:00Z&until=2026-01-01T01:00:00Z"
59            ),
60            rest.head("/api/v1/events/1"),
61        );
62
63        let states = [
64            classify_endpoint(&alert_history),
65            classify_endpoint(&events_aggregate),
66            classify_endpoint(&event_by_id),
67        ];
68
69        if states.contains(&EndpointState::Unknown) {
70            return ProbeResult::Unknown;
71        }
72        if states.iter().all(|state| *state == EndpointState::Exists) {
73            return ProbeResult::Phase(ServerPhase::Phase1);
74        }
75        ProbeResult::Phase(ServerPhase::Phase0)
76    }
77
78    /// Spawn a background task that re-probes and swaps the tool inventory on
79    /// phase transitions.
80    pub fn spawn_loop(
81        rest: RestClient,
82        registry: Arc<ToolRegistry>,
83        notifier: Arc<ListChangedNotifier>,
84        interval: Duration,
85        initial_phase: ServerPhase,
86    ) -> tokio::task::JoinHandle<()> {
87        tokio::spawn(async move {
88            let mut current = initial_phase;
89            let interval = if interval.is_zero() {
90                Duration::from_secs(1)
91            } else {
92                interval
93            };
94            let mut ticker = tokio::time::interval(interval);
95            ticker.tick().await;
96
97            loop {
98                ticker.tick().await;
99                match Self::detect_phase(&rest).await {
100                    ProbeResult::Phase(detected) if detected != current => {
101                        tracing::info!(
102                            from = ?current,
103                            to = ?detected,
104                            "capability phase changed; swapping tool inventory"
105                        );
106                        registry.swap(crate::transport::stdio::build_inventory(detected));
107                        notifier.broadcast_list_changed().await;
108                        current = detected;
109                    }
110                    ProbeResult::Phase(_) => {}
111                    ProbeResult::Unknown => {
112                        tracing::debug!(
113                            current = ?current,
114                            "capability probe returned unknown; keeping current phase"
115                        );
116                        tokio::time::sleep(Duration::from_secs(30)).await;
117                    }
118                }
119            }
120        })
121    }
122}
123
124fn classify_endpoint(status: &Result<StatusCode>) -> EndpointState {
125    match status {
126        Ok(status) if *status == StatusCode::NOT_FOUND => EndpointState::Absent,
127        Ok(status) if status.as_u16() < 500 => EndpointState::Exists,
128        _ => EndpointState::Unknown,
129    }
130}
131
132pub async fn run_probes(rest: &RestClient, base_url: &Url, insecure: bool) -> StartupProbeResult {
133    let reachable = check_reachable(rest).await;
134    let auth_posture = if reachable.is_ok() {
135        check_auth_posture(base_url, insecure).await
136    } else {
137        AuthPosture::Unknown
138    };
139    StartupProbeResult {
140        reachable,
141        auth_posture,
142    }
143}
144
145async fn check_reachable(rest: &RestClient) -> Result<()> {
146    rest.get_json::<serde_json::Value>("/health")
147        .await
148        .map(|_| ())
149}
150
151async fn check_auth_posture(base_url: &Url, insecure: bool) -> AuthPosture {
152    let url = match base_url.join("/api/v1/events?limit=1") {
153        Ok(u) => u,
154        Err(e) => {
155            tracing::debug!("auth-posture probe: bad URL: {e}");
156            return AuthPosture::Unknown;
157        }
158    };
159    let mut builder = reqwest::Client::builder().timeout(PROBE_TIMEOUT);
160    if insecure {
161        builder = builder.danger_accept_invalid_certs(true);
162    }
163    let client = match builder.build() {
164        Ok(c) => c,
165        Err(e) => {
166            tracing::debug!("auth-posture probe: client build failed: {e}");
167            return AuthPosture::Unknown;
168        }
169    };
170    match client.get(url).send().await {
171        Ok(resp) => match resp.status() {
172            StatusCode::UNAUTHORIZED => AuthPosture::Enabled,
173            StatusCode::OK => AuthPosture::Disabled,
174            status => {
175                tracing::debug!("auth-posture probe: unexpected status {status}");
176                AuthPosture::Unknown
177            }
178        },
179        Err(e) => {
180            tracing::debug!("auth-posture probe: request failed: {e}");
181            AuthPosture::Unknown
182        }
183    }
184}
185
186pub fn log_probe_results(result: &StartupProbeResult) {
187    match &result.reachable {
188        Ok(_) => tracing::info!("probe: REST reachable"),
189        Err(e) => tracing::warn!("probe: REST unreachable: {e}"),
190    }
191    match result.auth_posture {
192        AuthPosture::Enabled => tracing::info!("probe: auth enabled (healthy)"),
193        AuthPosture::Disabled => tracing::warn!(
194            "[WARN] SECURITY: syslog-server has auth disabled; Bearer tokens will be ignored, no audit attribution, no tenant scoping"
195        ),
196        AuthPosture::Unknown => tracing::info!("probe: auth posture unknown (couldn't probe)"),
197    }
198}