syslog_server_mcp/
probe.rs1use 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
10const 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ServerPhase {
29 Phase0,
30 Phase1,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum ProbeResult {
36 Phase(ServerPhase),
37 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 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 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}