Skip to main content

mc_minder/api/
mod.rs

1use anyhow::Result;
2use log::info;
3use std::future::Future;
4use std::sync::Arc;
5use std::sync::atomic::{AtomicBool, Ordering};
6use std::time::Duration;
7use tokio::sync::RwLock;
8use warp::Filter;
9use serde::{Serialize, Deserialize};
10
11use crate::command_sender::MultiCommandSender;
12
13/// Snapshot of MC server status at a point in time.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct McStatusSnapshot {
16    pub online: bool,
17    pub players_online: i32,
18    pub players_max: i32,
19    pub version: String,
20    pub latency_ms: u64,
21    pub motd: String,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub error: Option<String>,
24    /// TPS from RCON (Paper/Purpur servers), None if unavailable (P6-1)
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub tps: Option<f64>,
27    /// Alert status: "ok", "warning", "critical" (P6-3)
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub alert: Option<String>,
30}
31
32impl McStatusSnapshot {
33    pub fn offline(error: &str) -> Self {
34        Self {
35            online: false,
36            players_online: 0,
37            players_max: 0,
38            version: String::new(),
39            latency_ms: 0,
40            motd: String::new(),
41            error: Some(error.to_string()),
42            tps: None,
43            alert: Some("offline".to_string()),
44        }
45    }
46}
47
48#[derive(Debug, Serialize, Deserialize)]
49pub struct StatusResponse {
50    pub status: String,
51    pub uptime: u64,
52    pub mc_status: McStatusSnapshot,
53    pub rcon_available: bool,
54}
55
56#[derive(Debug, Serialize, Deserialize)]
57pub struct CommandRequest {
58    pub command: String,
59}
60
61#[derive(Debug, Serialize, Deserialize)]
62pub struct CommandResponse {
63    pub success: bool,
64    pub result: String,
65}
66
67pub struct HttpApi {
68    port: u16,
69    sender: Arc<RwLock<MultiCommandSender>>,
70    start_time: std::time::Instant,
71    pub mc_status_cache: Arc<RwLock<Option<(McStatusSnapshot, std::time::Instant)>>>,
72    rcon_available: bool,
73    mc_port: u16,
74    mc_status_config: crate::config::McStatusConfig,
75    ping_lock: AtomicBool,
76}
77
78impl HttpApi {
79    pub fn new(
80        port: u16,
81        sender: Arc<RwLock<MultiCommandSender>>,
82        rcon_available: bool,
83        mc_port: u16,
84        mc_status_config: crate::config::McStatusConfig,
85    ) -> Self {
86        Self {
87            port,
88            sender,
89            start_time: std::time::Instant::now(),
90            mc_status_cache: Arc::new(RwLock::new(None)),
91            rcon_available,
92            mc_port,
93            mc_status_config,
94            ping_lock: AtomicBool::new(false),
95        }
96    }
97
98    pub async fn fetch_mc_status(&self) -> McStatusSnapshot {
99        // Check cache validity
100        {
101            let cache = self.mc_status_cache.read().await;
102            if let Some((ref snapshot, ref time)) = *cache {
103                let age = time.elapsed().as_secs();
104                if age < self.mc_status_config.ping_interval_secs {
105                    return snapshot.clone();
106                }
107            }
108        }
109
110        // Prevent concurrent pings
111        if self.ping_lock.swap(true, Ordering::Acquire) {
112            let cache = self.mc_status_cache.read().await;
113            if let Some((ref snapshot, _)) = *cache {
114                return snapshot.clone();
115            }
116            return McStatusSnapshot::offline("status probe busy, retry later");
117        }
118
119        // Use a simple struct to guarantee lock release on cancel/drop
120        struct PingGuard<'a>(&'a AtomicBool);
121        impl<'a> Drop for PingGuard<'a> {
122            fn drop(&mut self) {
123                self.0.store(false, Ordering::Release);
124            }
125        }
126        let _guard = PingGuard(&self.ping_lock);
127
128        let timeout = Duration::from_secs(self.mc_status_config.ping_timeout_secs);
129        let result = mc_status_probe::ping("127.0.0.1", self.mc_port, timeout, None).await;
130
131        // Guard auto-releases on return/cancel/drop — no manual store(false) needed
132
133        let snapshot = match result {
134            Ok(r) => McStatusSnapshot {
135                online: true,
136                players_online: r.players_online,
137                players_max: r.players_max,
138                version: r.version_name,
139                latency_ms: r.latency_ms,
140                motd: r.description,
141                error: None,
142                tps: None,
143                alert: None,
144            },
145            Err(e) => McStatusSnapshot::offline(&e.to_string()),
146        };
147
148        // Update cache
149        {
150            let mut cache = self.mc_status_cache.write().await;
151            *cache = Some((snapshot.clone(), std::time::Instant::now()));
152        }
153
154        snapshot
155    }
156
157    pub async fn start<S>(self: Arc<Self>, shutdown: S) -> Result<()>
158    where
159        S: Future<Output = ()> + Send + 'static,
160    {
161        let start_time = self.start_time;
162        let preferred_port = self.port;
163        let cache = self.mc_status_cache.clone();
164        let rcon_available = self.rcon_available;
165
166        // Status route
167        let status_cache = cache.clone();
168        let status_route = warp::path("status")
169            .and(warp::get())
170            .and_then(move || {
171                let cache = status_cache.clone();
172                let st = start_time;
173                let rcon_ok = rcon_available;
174                async move {
175                    let mc_status = {
176                        let cached = cache.read().await;
177                        match *cached {
178                            Some((ref snapshot, _)) => snapshot.clone(),
179                            None => McStatusSnapshot::offline("no status data yet"),
180                        }
181                    };
182                    let response = StatusResponse {
183                        status: "running".to_string(),
184                        uptime: st.elapsed().as_secs(),
185                        mc_status,
186                        rcon_available: rcon_ok,
187                    };
188                    Ok::<_, warp::Rejection>(warp::reply::json(&response))
189                }
190            });
191
192        // Command route — with RCON availability precheck
193        let sender = self.sender.clone();
194        let command_route = warp::path("command")
195            .and(warp::post())
196            .and(warp::body::json())
197            .and_then(move |req: CommandRequest| {
198                let sender = sender.clone();
199                let rcon_ok = rcon_available;
200                async move {
201                    if !rcon_ok {
202                        let response = CommandResponse {
203                            success: false,
204                            result: "RCON is not configured or not available".to_string(),
205                        };
206                        return Ok(warp::reply::json(&response));
207                    }
208                    let mut sender_guard = sender.write().await;
209                    match sender_guard.send_command(&req.command).await {
210                        Ok(response_text) => {
211                            let response = CommandResponse {
212                                success: true,
213                                result: response_text.trim().to_string(),
214                            };
215                            Ok::<_, warp::Rejection>(warp::reply::json(&response))
216                        }
217                        Err(e) => {
218                            let response = CommandResponse {
219                                success: false,
220                                result: e.to_string(),
221                            };
222                            Ok(warp::reply::json(&response))
223                        }
224                    }
225                }
226            });
227
228        let routes = status_route
229            .or(command_route)
230            .with(warp::cors().allow_any_origin());
231
232        info!("Starting HTTP API server on port {}", preferred_port);
233
234        warp::serve(routes)
235            .bind_with_graceful_shutdown(([0, 0, 0, 0], preferred_port), async { shutdown.await })
236            .1
237            .await;
238
239        Ok(())
240    }
241}