Skip to main content

ant_core/node/daemon/
client.rs

1use std::path::Path;
2use std::time::Duration;
3
4use crate::error::{Error, Result};
5use crate::node::daemon::forward::{LogForwardEnableRequest, LogForwardResult, LogForwardStatus};
6use crate::node::daemon::health::FleetHealth;
7use crate::node::process::detach;
8use crate::node::types::{
9    DaemonConfig, DaemonInfo, DaemonStartResult, DaemonStatus, DaemonStopResult, NodeStarted,
10    NodeStatusResult, NodeStopped, RemoveNodeResult, StartNodeResult, StopNodeResult,
11};
12
13/// Get the daemon's current status by querying its REST API.
14///
15/// If the daemon is not running, returns a `DaemonStatus` with `running: false`.
16pub async fn status(config: &DaemonConfig) -> Result<DaemonStatus> {
17    let port = match read_port_file(&config.port_file_path) {
18        Some(port) => port,
19        None => {
20            return Ok(DaemonStatus {
21                running: false,
22                pid: None,
23                port: None,
24                uptime_secs: None,
25                nodes_total: 0,
26                nodes_running: 0,
27                nodes_stopped: 0,
28                nodes_errored: 0,
29            });
30        }
31    };
32
33    let url = format!("http://127.0.0.1:{port}/api/v1/status");
34    match reqwest::get(&url).await {
35        Ok(resp) => resp
36            .json::<DaemonStatus>()
37            .await
38            .map_err(|e| Error::HttpRequest(e.to_string())),
39        Err(_) => Ok(DaemonStatus {
40            running: false,
41            pid: None,
42            port: Some(port),
43            uptime_secs: None,
44            nodes_total: 0,
45            nodes_running: 0,
46            nodes_stopped: 0,
47            nodes_errored: 0,
48        }),
49    }
50}
51
52/// Stop the running daemon.
53///
54/// Reads the PID from the PID file, validates the process is actually a daemon
55/// instance, sends SIGTERM (Unix) or Ctrl+C (Windows), and waits for the process
56/// to exit.
57pub async fn stop(config: &DaemonConfig) -> Result<DaemonStopResult> {
58    let pid = read_pid_file(&config.pid_file_path)?;
59
60    // Validate the process is actually our daemon before killing it.
61    // After a crash, the PID may have been reused by an unrelated process.
62    if !is_process_alive(pid) {
63        // Process is already dead — just clean up stale files
64        let _ = std::fs::remove_file(&config.pid_file_path);
65        let _ = std::fs::remove_file(&config.port_file_path);
66        return Ok(DaemonStopResult { pid });
67    }
68
69    if !validate_daemon_process(pid) {
70        // PID is alive but isn't our daemon — clean up stale files without killing
71        let _ = std::fs::remove_file(&config.pid_file_path);
72        let _ = std::fs::remove_file(&config.port_file_path);
73        return Err(Error::DaemonStopFailed(format!(
74            "PID {pid} is alive but does not appear to be the ant daemon (possible PID reuse). \
75             Stale PID file removed."
76        )));
77    }
78
79    send_terminate(pid);
80
81    // Wait for process to exit
82    for _ in 0..50 {
83        tokio::time::sleep(Duration::from_millis(100)).await;
84        if !is_process_alive(pid) {
85            break;
86        }
87    }
88
89    // Verify the process actually died
90    if is_process_alive(pid) {
91        return Err(Error::DaemonStopFailed(format!(
92            "Daemon (PID {pid}) is still alive after 5 seconds"
93        )));
94    }
95
96    // Clean up files if they still exist
97    let _ = std::fs::remove_file(&config.pid_file_path);
98    let _ = std::fs::remove_file(&config.port_file_path);
99
100    Ok(DaemonStopResult { pid })
101}
102
103/// Start the daemon as a detached background process.
104///
105/// If the daemon is already running, returns a result with `already_running: true`.
106/// Otherwise, spawns the daemon and polls for the port file to confirm startup.
107pub async fn start(config: &DaemonConfig) -> Result<DaemonStartResult> {
108    // Check if daemon is already running
109    if let Some(pid) = check_running(&config.pid_file_path) {
110        let port = read_port_file(&config.port_file_path);
111        return Ok(DaemonStartResult {
112            already_running: true,
113            pid,
114            port,
115        });
116    }
117
118    // Get the path to the current executable
119    let exe = std::env::current_exe()
120        .map_err(|e| Error::ProcessSpawn(format!("Failed to get current executable: {e}")))?;
121    let exe_str = exe
122        .to_str()
123        .ok_or_else(|| Error::ProcessSpawn("Executable path is not valid UTF-8".to_string()))?;
124
125    let args = daemon_run_args(config);
126    let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
127    let pid = detach::spawn_detached(exe_str, &arg_refs)?;
128
129    // Wait briefly for the daemon to write its port file
130    let mut port = None;
131    for _ in 0..20 {
132        tokio::time::sleep(Duration::from_millis(100)).await;
133        if let Some(p) = read_port_file(&config.port_file_path) {
134            port = Some(p);
135            break;
136        }
137    }
138
139    Ok(DaemonStartResult {
140        already_running: false,
141        pid,
142        port,
143    })
144}
145
146/// Start a specific node by ID via the daemon REST API.
147pub async fn start_node(config: &DaemonConfig, node_id: u32) -> Result<NodeStarted> {
148    let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
149
150    let url = format!("http://127.0.0.1:{port}/api/v1/nodes/{node_id}/start");
151    let resp = reqwest::Client::new()
152        .post(&url)
153        .send()
154        .await
155        .map_err(|e| Error::HttpRequest(e.to_string()))?;
156
157    if resp.status().is_success() {
158        resp.json::<NodeStarted>()
159            .await
160            .map_err(|e| Error::HttpRequest(e.to_string()))
161    } else {
162        let body = resp.text().await.unwrap_or_default();
163        Err(Error::HttpRequest(body))
164    }
165}
166
167/// Read the daemon's log-forwarding status.
168pub async fn log_forward_status(config: &DaemonConfig) -> Result<LogForwardStatus> {
169    let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
170
171    let url = format!("http://127.0.0.1:{port}/api/v1/logs/forward");
172    let resp = reqwest::get(&url)
173        .await
174        .map_err(|e| Error::HttpRequest(e.to_string()))?;
175
176    if resp.status().is_success() {
177        resp.json::<LogForwardStatus>()
178            .await
179            .map_err(|e| Error::HttpRequest(e.to_string()))
180    } else {
181        Err(Error::HttpRequest(resp.text().await.unwrap_or_default()))
182    }
183}
184
185/// Enable log forwarding via the daemon, so it starts shipping immediately.
186pub async fn log_forward_enable(
187    config: &DaemonConfig,
188    request: &LogForwardEnableRequest,
189) -> Result<LogForwardResult> {
190    post_log_forward(config, "enable", Some(request)).await
191}
192
193/// Disable log forwarding via the daemon.
194pub async fn log_forward_disable(config: &DaemonConfig) -> Result<LogForwardResult> {
195    post_log_forward(config, "disable", None).await
196}
197
198async fn post_log_forward(
199    config: &DaemonConfig,
200    action: &str,
201    body: Option<&LogForwardEnableRequest>,
202) -> Result<LogForwardResult> {
203    let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
204
205    let url = format!("http://127.0.0.1:{port}/api/v1/logs/forward/{action}");
206    let mut request = reqwest::Client::new().post(&url);
207    if let Some(body) = body {
208        request = request.json(body);
209    }
210
211    let resp = request
212        .send()
213        .await
214        .map_err(|e| Error::HttpRequest(e.to_string()))?;
215
216    if resp.status().is_success() {
217        resp.json::<LogForwardResult>()
218            .await
219            .map_err(|e| Error::HttpRequest(e.to_string()))
220    } else {
221        // The daemon returns `{"error": "..."}` for a rejected enable; surface just that text
222        // rather than the raw JSON envelope.
223        let body = resp.text().await.unwrap_or_default();
224        let message = serde_json::from_str::<serde_json::Value>(&body)
225            .ok()
226            .and_then(|value| {
227                value
228                    .get("error")
229                    .and_then(serde_json::Value::as_str)
230                    .map(str::to_string)
231            })
232            .unwrap_or(body);
233        Err(Error::HttpRequest(message))
234    }
235}
236
237/// Start all registered nodes via the daemon REST API.
238pub async fn start_all_nodes(config: &DaemonConfig) -> Result<StartNodeResult> {
239    let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
240
241    let url = format!("http://127.0.0.1:{port}/api/v1/nodes/start-all");
242    let resp = reqwest::Client::new()
243        .post(&url)
244        .send()
245        .await
246        .map_err(|e| Error::HttpRequest(e.to_string()))?;
247
248    if resp.status().is_success() {
249        resp.json::<StartNodeResult>()
250            .await
251            .map_err(|e| Error::HttpRequest(e.to_string()))
252    } else {
253        let body = resp.text().await.unwrap_or_default();
254        Err(Error::HttpRequest(body))
255    }
256}
257
258/// Stop a specific node by ID via the daemon REST API.
259pub async fn stop_node(config: &DaemonConfig, node_id: u32) -> Result<NodeStopped> {
260    let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
261
262    let url = format!("http://127.0.0.1:{port}/api/v1/nodes/{node_id}/stop");
263    let resp = reqwest::Client::new()
264        .post(&url)
265        .send()
266        .await
267        .map_err(|e| Error::HttpRequest(e.to_string()))?;
268
269    if resp.status().is_success() {
270        resp.json::<NodeStopped>()
271            .await
272            .map_err(|e| Error::HttpRequest(e.to_string()))
273    } else {
274        let body = resp.text().await.unwrap_or_default();
275        Err(Error::HttpRequest(body))
276    }
277}
278
279/// Dismiss a node — remove it from the registry — via the daemon REST API.
280///
281/// Intended for evicted nodes (whose data directory has already been deleted), but the daemon will
282/// remove any non-running node. Running nodes are rejected with a conflict error.
283pub async fn dismiss_node(config: &DaemonConfig, node_id: u32) -> Result<RemoveNodeResult> {
284    let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
285
286    let url = format!("http://127.0.0.1:{port}/api/v1/nodes/{node_id}");
287    let resp = reqwest::Client::new()
288        .delete(&url)
289        .send()
290        .await
291        .map_err(|e| Error::HttpRequest(e.to_string()))?;
292
293    if resp.status().is_success() {
294        resp.json::<RemoveNodeResult>()
295            .await
296            .map_err(|e| Error::HttpRequest(e.to_string()))
297    } else {
298        let body = resp.text().await.unwrap_or_default();
299        Err(Error::HttpRequest(body))
300    }
301}
302
303/// Get the current fleet health snapshot via the daemon REST API.
304pub async fn fleet_health(config: &DaemonConfig) -> Result<FleetHealth> {
305    let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
306
307    let url = format!("http://127.0.0.1:{port}/api/v1/health");
308    let resp = reqwest::get(&url)
309        .await
310        .map_err(|e| Error::HttpRequest(e.to_string()))?;
311
312    if resp.status().is_success() {
313        resp.json::<FleetHealth>()
314            .await
315            .map_err(|e| Error::HttpRequest(e.to_string()))
316    } else {
317        let body = resp.text().await.unwrap_or_default();
318        Err(Error::HttpRequest(body))
319    }
320}
321
322/// Get the status of all registered nodes via the daemon REST API.
323pub async fn node_status(config: &DaemonConfig) -> Result<NodeStatusResult> {
324    let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
325
326    let url = format!("http://127.0.0.1:{port}/api/v1/nodes/status");
327    let resp = reqwest::get(&url)
328        .await
329        .map_err(|e| Error::HttpRequest(e.to_string()))?;
330
331    if resp.status().is_success() {
332        resp.json::<NodeStatusResult>()
333            .await
334            .map_err(|e| Error::HttpRequest(e.to_string()))
335    } else {
336        let body = resp.text().await.unwrap_or_default();
337        Err(Error::HttpRequest(body))
338    }
339}
340
341/// Stop all running nodes via the daemon REST API.
342pub async fn stop_all_nodes(config: &DaemonConfig) -> Result<StopNodeResult> {
343    let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
344
345    let url = format!("http://127.0.0.1:{port}/api/v1/nodes/stop-all");
346    let resp = reqwest::Client::new()
347        .post(&url)
348        .send()
349        .await
350        .map_err(|e| Error::HttpRequest(e.to_string()))?;
351
352    if resp.status().is_success() {
353        resp.json::<StopNodeResult>()
354            .await
355            .map_err(|e| Error::HttpRequest(e.to_string()))
356    } else {
357        let body = resp.text().await.unwrap_or_default();
358        Err(Error::HttpRequest(body))
359    }
360}
361
362/// Get daemon connection info for programmatic use.
363///
364/// Reads PID and port files and checks if the process is alive.
365pub fn info(config: &DaemonConfig) -> DaemonInfo {
366    let pid = std::fs::read_to_string(&config.pid_file_path)
367        .ok()
368        .and_then(|s| s.trim().parse::<u32>().ok());
369
370    let port = read_port_file(&config.port_file_path);
371
372    let running = pid.is_some_and(is_process_alive);
373
374    DaemonInfo {
375        running,
376        pid,
377        port,
378        api_base: port.map(|p| format!("http://127.0.0.1:{p}/api/v1")),
379    }
380}
381
382/// Run the daemon in the foreground (the actual daemon process entry point).
383///
384/// Starts the HTTP server, sets up signal handling, and blocks until shutdown.
385pub async fn run(config: DaemonConfig) -> Result<()> {
386    use crate::node::daemon::server;
387    use crate::node::registry::NodeRegistry;
388
389    let registry = NodeRegistry::load(&config.registry_path)?;
390    let shutdown = tokio_util::sync::CancellationToken::new();
391
392    let shutdown_clone = shutdown.clone();
393    tokio::spawn(async move {
394        tokio::signal::ctrl_c().await.ok();
395        shutdown_clone.cancel();
396    });
397
398    let _addr = server::start(config, registry, shutdown.clone()).await?;
399
400    shutdown.cancelled().await;
401    // Give the server a moment to clean up
402    tokio::time::sleep(Duration::from_millis(100)).await;
403
404    Ok(())
405}
406
407/// Validate that a PID belongs to an ant daemon process by checking its
408/// command line. This guards against PID reuse after a daemon crash.
409#[cfg(unix)]
410fn validate_daemon_process(pid: u32) -> bool {
411    let cmdline_path = format!("/proc/{pid}/cmdline");
412    match std::fs::read(&cmdline_path) {
413        Ok(raw) => {
414            // /proc/PID/cmdline uses null bytes as separators.
415            // Check that the executable basename ends with "ant" and one
416            // of the arguments is "daemon". This avoids false positives
417            // from processes like "rant" or "phantom-daemon".
418            let args: Vec<String> = raw
419                .split(|&b| b == 0)
420                .filter(|s| !s.is_empty())
421                .map(|s| String::from_utf8_lossy(s).to_string())
422                .collect();
423            let exe_matches = args
424                .first()
425                .and_then(|exe| std::path::Path::new(exe).file_name())
426                .and_then(|name| name.to_str())
427                .is_some_and(|name| name == "ant" || name == "ant.exe");
428            let has_daemon_arg = args.iter().any(|a| a == "daemon");
429            exe_matches && has_daemon_arg
430        }
431        Err(_) => {
432            // On non-Linux Unix (macOS), /proc doesn't exist. Fall back to
433            // trusting the PID file since there's no cheap way to inspect
434            // the command line without shelling out.
435            true
436        }
437    }
438}
439
440#[cfg(windows)]
441fn validate_daemon_process(pid: u32) -> bool {
442    use windows_sys::Win32::Foundation::CloseHandle;
443    use windows_sys::Win32::System::Threading::{
444        OpenProcess, QueryFullProcessImageNameW, PROCESS_QUERY_LIMITED_INFORMATION,
445    };
446
447    unsafe {
448        let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
449        if handle.is_null() {
450            return false;
451        }
452        let mut buf = [0u16; 1024];
453        let mut size = buf.len() as u32;
454        let success = QueryFullProcessImageNameW(handle, 0, buf.as_mut_ptr(), &mut size);
455        CloseHandle(handle);
456
457        if success == 0 {
458            return false;
459        }
460        let path = String::from_utf16_lossy(&buf[..size as usize]);
461        // Check the executable basename, not just substring
462        std::path::Path::new(&path)
463            .file_stem()
464            .and_then(|s| s.to_str())
465            .is_some_and(|name| name == "ant")
466    }
467}
468
469/// Build the arg list passed to the detached `ant node daemon run` child.
470///
471/// Overrides are forwarded explicitly so the child binds to the same address
472/// and port the caller asked for. Unset fields fall through to the child's
473/// own defaults (loopback + OS-assigned port).
474fn daemon_run_args(config: &DaemonConfig) -> Vec<String> {
475    let defaults = DaemonConfig::default();
476    let mut args = vec!["node".to_string(), "daemon".to_string(), "run".to_string()];
477    if let Some(port) = config.port {
478        args.push("--port".to_string());
479        args.push(port.to_string());
480    }
481    if config.listen_addr != defaults.listen_addr {
482        args.push("--listen-addr".to_string());
483        args.push(config.listen_addr.to_string());
484    }
485    args
486}
487
488fn read_port_file(path: &Path) -> Option<u16> {
489    std::fs::read_to_string(path)
490        .ok()
491        .and_then(|s| s.trim().parse::<u16>().ok())
492}
493
494fn read_pid_file(path: &Path) -> Result<u32> {
495    let contents = std::fs::read_to_string(path).map_err(|_| Error::DaemonNotRunning)?;
496    contents
497        .trim()
498        .parse::<u32>()
499        .map_err(|_| Error::DaemonNotRunning)
500}
501
502/// Check if a daemon is running. Returns the PID if so.
503fn check_running(pid_file: &Path) -> Option<u32> {
504    let pid = read_pid_file(pid_file).ok()?;
505    if is_process_alive(pid) {
506        Some(pid)
507    } else {
508        None
509    }
510}
511
512#[cfg(unix)]
513fn pid_to_i32(pid: u32) -> Option<i32> {
514    i32::try_from(pid).ok().filter(|&p| p > 0)
515}
516
517#[cfg(unix)]
518fn send_terminate(pid: u32) {
519    if let Some(pid) = pid_to_i32(pid) {
520        unsafe {
521            libc::kill(pid, libc::SIGTERM);
522        }
523    }
524}
525
526#[cfg(windows)]
527fn send_terminate(pid: u32) {
528    use windows_sys::Win32::Foundation::CloseHandle;
529    use windows_sys::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE};
530
531    unsafe {
532        let handle = OpenProcess(PROCESS_TERMINATE, 0, pid);
533        if !handle.is_null() {
534            TerminateProcess(handle, 1);
535            CloseHandle(handle);
536        }
537    }
538}
539
540#[cfg(unix)]
541fn is_process_alive(pid: u32) -> bool {
542    let Some(pid) = pid_to_i32(pid) else {
543        return false;
544    };
545    let ret = unsafe { libc::kill(pid, 0) };
546    if ret == 0 {
547        return true;
548    }
549    // EPERM means the process exists but we lack permission to signal it
550    std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
551}
552
553#[cfg(windows)]
554fn is_process_alive(pid: u32) -> bool {
555    use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
556    use windows_sys::Win32::System::Threading::{
557        GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
558    };
559
560    unsafe {
561        let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
562        if handle.is_null() {
563            return false;
564        }
565        let mut exit_code: u32 = 0;
566        let success = GetExitCodeProcess(handle, &mut exit_code);
567        CloseHandle(handle);
568        success != 0 && exit_code == STILL_ACTIVE as u32
569    }
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575    use std::net::Ipv4Addr;
576
577    #[test]
578    fn run_args_default_config_has_no_overrides() {
579        let config = DaemonConfig::default();
580        let args = daemon_run_args(&config);
581        assert_eq!(args, vec!["node", "daemon", "run"]);
582    }
583
584    #[test]
585    fn run_args_forward_explicit_port() {
586        let config = DaemonConfig {
587            port: Some(8765),
588            ..DaemonConfig::default()
589        };
590        let args = daemon_run_args(&config);
591        assert_eq!(args, vec!["node", "daemon", "run", "--port", "8765"]);
592    }
593
594    #[test]
595    fn run_args_forward_explicit_listen_addr() {
596        let config = DaemonConfig {
597            listen_addr: std::net::IpAddr::V4(Ipv4Addr::UNSPECIFIED),
598            ..DaemonConfig::default()
599        };
600        let args = daemon_run_args(&config);
601        assert_eq!(
602            args,
603            vec!["node", "daemon", "run", "--listen-addr", "0.0.0.0"]
604        );
605    }
606
607    #[test]
608    fn run_args_forward_both_overrides() {
609        let config = DaemonConfig {
610            port: Some(8765),
611            listen_addr: std::net::IpAddr::V4(Ipv4Addr::UNSPECIFIED),
612            ..DaemonConfig::default()
613        };
614        let args = daemon_run_args(&config);
615        assert_eq!(
616            args,
617            vec![
618                "node",
619                "daemon",
620                "run",
621                "--port",
622                "8765",
623                "--listen-addr",
624                "0.0.0.0",
625            ]
626        );
627    }
628
629    #[test]
630    fn run_args_forward_explicit_zero_port() {
631        // Explicit `--port 0` is preserved so the user's intent (OS-assigned)
632        // round-trips through the spawn, even though the child's default would
633        // produce the same bind behavior.
634        let config = DaemonConfig {
635            port: Some(0),
636            ..DaemonConfig::default()
637        };
638        let args = daemon_run_args(&config);
639        assert_eq!(args, vec!["node", "daemon", "run", "--port", "0"]);
640    }
641}