Skip to main content

ant_core/node/daemon/
server.rs

1use std::net::SocketAddr;
2use std::path::PathBuf;
3use std::sync::Arc;
4use std::time::Instant;
5
6use axum::extract::{Path, State};
7use axum::http::StatusCode;
8use axum::response::sse::{Event, Sse};
9use axum::response::{Html, IntoResponse};
10use axum::routing::{get, post};
11use axum::{Json, Router};
12use tokio::sync::broadcast;
13use tokio::sync::RwLock;
14use tokio_util::sync::CancellationToken;
15
16use crate::error::Result;
17use crate::node::binary::NoopProgress;
18use crate::node::daemon::forward::runner::{ForwarderHandle, DEFAULT_POLL_INTERVAL};
19use crate::node::daemon::forward::{
20    apply_enable, classify_nodes, spawn_log_forwarder, ElasticsearchSink, LogForwardConfig,
21    LogForwardEnableRequest, LogForwardResult, LogForwardStatus, LogSink,
22};
23use crate::node::daemon::health::{DiskThresholds, FleetHealth};
24use crate::node::daemon::supervisor::{
25    spawn_eviction_monitor, spawn_liveness_monitor, Supervisor, EVICTION_POLL_INTERVAL,
26    LIVENESS_POLL_INTERVAL,
27};
28use crate::node::events::NodeEvent;
29use crate::node::registry::NodeRegistry;
30use crate::node::types::{
31    AddNodeOpts, AddNodeResult, DaemonConfig, DaemonStatus, NodeInfo, NodeStarted, NodeStatus,
32    NodeStatusResult, NodeStatusSummary, NodeStopped, RemoveNodeResult, ResetResult,
33    StartNodeResult, StopNodeResult,
34};
35
36/// Shared application state for the daemon HTTP server.
37pub struct AppState {
38    pub registry: Arc<RwLock<NodeRegistry>>,
39    pub supervisor: Arc<RwLock<Supervisor>>,
40    pub event_tx: broadcast::Sender<NodeEvent>,
41    pub start_time: Instant,
42    pub config: DaemonConfig,
43    /// The actual address the server bound to (resolves port 0 to real port).
44    pub bound_port: u16,
45    /// Latest fleet health snapshot, refreshed by the eviction monitor and served at
46    /// `GET /api/v1/health`.
47    pub health: Arc<RwLock<FleetHealth>>,
48    /// The running log forwarder, if the user has opted in. `None` while forwarding is disabled.
49    pub forwarder: Arc<RwLock<Option<ForwarderHandle>>>,
50    /// The daemon's shutdown token, kept so a forwarder started later by `enable` still stops when
51    /// the daemon does.
52    pub shutdown: CancellationToken,
53}
54
55/// Start the daemon HTTP server.
56///
57/// Returns the actual address the server bound to (useful when port is 0).
58pub async fn start(
59    config: DaemonConfig,
60    mut registry: NodeRegistry,
61    shutdown: CancellationToken,
62) -> Result<SocketAddr> {
63    let (event_tx, _) = broadcast::channel(256);
64
65    let addr = SocketAddr::new(config.listen_addr, config.port.unwrap_or(0));
66    let listener = tokio::net::TcpListener::bind(addr)
67        .await
68        .map_err(|e| crate::error::Error::BindError(e.to_string()))?;
69    let bound_addr = listener
70        .local_addr()
71        .map_err(|e| crate::error::Error::BindError(e.to_string()))?;
72
73    // Heal any stale `version` entries in the registry. If an earlier daemon ran without the
74    // upgrade-aware supervisor, the on-disk binary may have been replaced without the registry
75    // being updated. We re-read each binary's version and persist any differences before the
76    // supervisor comes up, so subsequent status queries reflect reality.
77    reconcile_registry_versions(&mut registry).await;
78
79    let registry = Arc::new(RwLock::new(registry));
80    let supervisor = Arc::new(RwLock::new(Supervisor::new(event_tx.clone())));
81
82    // Adopt node processes spawned by a previous daemon instance. Must run before
83    // `axum::serve` starts accepting requests — the window between supervisor
84    // creation and adoption is where `/api/v1/nodes/status` would otherwise report
85    // live nodes as Stopped (the supervisor's default when it has no runtime entry).
86    {
87        let reg = registry.read().await;
88        let mut sup = supervisor.write().await;
89        let adopted = sup.adopt_from_registry(&reg);
90        if !adopted.is_empty() {
91            tracing::info!(
92                "Adopted {} running node(s) from a previous daemon instance: {:?}",
93                adopted.len(),
94                adopted
95            );
96        }
97    }
98
99    let health = Arc::new(RwLock::new(FleetHealth::healthy()));
100
101    let state = Arc::new(AppState {
102        registry: registry.clone(),
103        supervisor: supervisor.clone(),
104        event_tx: event_tx.clone(),
105        start_time: Instant::now(),
106        config: config.clone(),
107        bound_port: bound_addr.port(),
108        health: health.clone(),
109        forwarder: Arc::new(RwLock::new(None)),
110        shutdown: shutdown.clone(),
111    });
112
113    // Background task: if the user has opted into beta log forwarding, resume it. The opt-in is
114    // persisted rather than held in daemon memory, so restarting the daemon does not silently stop
115    // shipping logs the user asked for.
116    start_forwarder_if_enabled(&state).await;
117
118    // Background task: monitor free disk space at node data directories. Refreshes the fleet health
119    // snapshot every tick and auto-evicts a node (smallest data dir) on any partition that has
120    // fallen to the eviction threshold while ≥2 nodes remain. The threshold is a fixed internal
121    // constant (mirroring ant-node's own refuse-to-store reserve), not user-configurable.
122    spawn_eviction_monitor(
123        registry.clone(),
124        supervisor.clone(),
125        event_tx.clone(),
126        health,
127        DiskThresholds::default(),
128        EVICTION_POLL_INTERVAL,
129        shutdown.clone(),
130    );
131
132    // Background task: poll adopted nodes' PIDs for OS liveness. Daemon-spawned nodes
133    // get exit detection via `monitor_node`'s owned `Child` handle; adopted nodes don't,
134    // so this poll is the only way the supervisor learns when one of them exits.
135    spawn_liveness_monitor(
136        registry,
137        supervisor,
138        event_tx,
139        LIVENESS_POLL_INTERVAL,
140        shutdown.clone(),
141    );
142
143    let app = build_router(state.clone());
144
145    // Write port and PID files
146    write_file(&config.port_file_path, &bound_addr.port().to_string())?;
147    write_file(&config.pid_file_path, &std::process::id().to_string())?;
148
149    let port_file = config.port_file_path.clone();
150    let pid_file = config.pid_file_path.clone();
151
152    tokio::spawn(async move {
153        axum::serve(listener, app)
154            .with_graceful_shutdown(shutdown.cancelled_owned())
155            .await
156            .ok();
157
158        // Clean up port and PID files on shutdown
159        let _ = std::fs::remove_file(&port_file);
160        let _ = std::fs::remove_file(&pid_file);
161    });
162
163    Ok(bound_addr)
164}
165
166fn build_router(state: Arc<AppState>) -> Router {
167    use axum::http::HeaderValue;
168    use tower_http::cors::{Any, CorsLayer};
169
170    // Restrict CORS to the daemon's own origin to prevent cross-origin CSRF
171    // attacks from malicious webpages. Non-browser clients (CLI, AI agents)
172    // don't send Origin headers so CORS doesn't affect them.
173    let origin = format!("http://127.0.0.1:{}", state.bound_port);
174    let cors = CorsLayer::new()
175        .allow_origin([origin.parse::<HeaderValue>().unwrap()])
176        .allow_methods(Any)
177        .allow_headers(Any);
178
179    Router::new()
180        .route("/console", get(get_console))
181        .route("/api/v1/status", get(get_status))
182        .route("/api/v1/health", get(get_health))
183        .route("/api/v1/events", get(get_events))
184        .route("/api/v1/nodes/status", get(get_nodes_status))
185        .route("/api/v1/nodes", post(post_nodes))
186        .route(
187            "/api/v1/nodes/{id}",
188            get(get_node_detail).delete(delete_node),
189        )
190        .route("/api/v1/nodes/{id}/start", post(post_start_node))
191        .route("/api/v1/nodes/start-all", post(post_start_all))
192        .route("/api/v1/nodes/{id}/stop", post(post_stop_node))
193        .route("/api/v1/nodes/stop-all", post(post_stop_all))
194        .route("/api/v1/reset", post(post_reset))
195        .route("/api/v1/logs/forward", get(get_log_forward))
196        .route("/api/v1/logs/forward/enable", post(post_log_forward_enable))
197        .route(
198            "/api/v1/logs/forward/disable",
199            post(post_log_forward_disable),
200        )
201        .route("/api/v1/openapi.json", get(get_openapi))
202        .layer(cors)
203        .with_state(state)
204}
205
206async fn get_status(State(state): State<Arc<AppState>>) -> Json<DaemonStatus> {
207    let registry = state.registry.read().await;
208    let supervisor = state.supervisor.read().await;
209    let (running, stopped, errored) = supervisor.node_counts();
210
211    Json(DaemonStatus {
212        running: true,
213        pid: Some(std::process::id()),
214        port: Some(state.bound_port),
215        uptime_secs: Some(state.start_time.elapsed().as_secs()),
216        nodes_total: registry.len() as u32,
217        nodes_running: running,
218        nodes_stopped: stopped,
219        nodes_errored: errored,
220    })
221}
222
223/// GET /api/v1/health — Current fleet health (overall level + per-check findings).
224///
225/// Refreshed by the eviction monitor; reflects disk pressure and the next eviction candidate.
226async fn get_health(State(state): State<Arc<AppState>>) -> Json<FleetHealth> {
227    Json(state.health.read().await.clone())
228}
229
230async fn get_events(
231    State(state): State<Arc<AppState>>,
232) -> Sse<impl futures_core::Stream<Item = std::result::Result<Event, std::convert::Infallible>>> {
233    let mut rx = state.event_tx.subscribe();
234
235    let stream = async_stream::stream! {
236        loop {
237            match rx.recv().await {
238                Ok(event) => {
239                    let event_type = event.event_type().to_string();
240                    if let Ok(data) = serde_json::to_string(&event) {
241                        yield Ok(Event::default().event(event_type).data(data));
242                    }
243                }
244                Err(broadcast::error::RecvError::Lagged(_)) => continue,
245                Err(broadcast::error::RecvError::Closed) => break,
246            }
247        }
248    };
249
250    Sse::new(stream)
251}
252
253/// GET /api/v1/nodes/status — Get status of all registered nodes.
254async fn get_nodes_status(State(state): State<Arc<AppState>>) -> Json<NodeStatusResult> {
255    let registry = state.registry.read().await;
256    let supervisor = state.supervisor.read().await;
257
258    let mut nodes = Vec::new();
259    let mut total_running = 0u32;
260    let mut total_stopped = 0u32;
261
262    for config in registry.list() {
263        // An evicted node has no live process: its persisted marker takes precedence over any
264        // runtime status the supervisor might still report.
265        let status = if config.eviction.is_some() {
266            NodeStatus::Evicted
267        } else {
268            supervisor
269                .node_status(config.id)
270                .unwrap_or(NodeStatus::Stopped)
271        };
272
273        match status {
274            NodeStatus::Running | NodeStatus::Starting => total_running += 1,
275            _ => total_stopped += 1,
276        }
277
278        let (pid, uptime_secs) = if config.eviction.is_some() {
279            (None, None)
280        } else {
281            (
282                supervisor.node_pid(config.id),
283                supervisor.node_uptime_secs(config.id),
284            )
285        };
286
287        nodes.push(NodeStatusSummary {
288            node_id: config.id,
289            name: config.service_name.clone(),
290            version: config.version.clone(),
291            status,
292            pid,
293            uptime_secs,
294            eviction: config.eviction.clone(),
295        });
296    }
297
298    Json(NodeStatusResult {
299        nodes,
300        total_running,
301        total_stopped,
302    })
303}
304
305/// GET /api/v1/nodes/:id — Get full detail for a single node.
306async fn get_node_detail(
307    State(state): State<Arc<AppState>>,
308    Path(id): Path<u32>,
309) -> std::result::Result<Json<NodeInfo>, (StatusCode, Json<serde_json::Value>)> {
310    let registry = state.registry.read().await;
311    let config = match registry.get(id) {
312        Ok(config) => config.clone(),
313        Err(_) => {
314            return Err((
315                StatusCode::NOT_FOUND,
316                Json(serde_json::json!({ "error": format!("Node not found: {id}") })),
317            ))
318        }
319    };
320
321    let supervisor = state.supervisor.read().await;
322    // A persisted eviction marker takes precedence over any runtime status.
323    let (status, pid, uptime_secs) = if config.eviction.is_some() {
324        (NodeStatus::Evicted, None, None)
325    } else {
326        (
327            supervisor.node_status(id).unwrap_or(NodeStatus::Stopped),
328            supervisor.node_pid(id),
329            supervisor.node_uptime_secs(id),
330        )
331    };
332
333    Ok(Json(NodeInfo {
334        config,
335        status,
336        pid,
337        uptime_secs,
338    }))
339}
340
341/// POST /api/v1/nodes — Add one or more nodes to the registry.
342async fn post_nodes(
343    State(state): State<Arc<AppState>>,
344    Json(opts): Json<AddNodeOpts>,
345) -> std::result::Result<(StatusCode, Json<AddNodeResult>), (StatusCode, Json<serde_json::Value>)> {
346    let registry_path = state.config.registry_path.clone();
347    let progress = NoopProgress;
348
349    match crate::node::add_nodes(opts, &registry_path, &progress).await {
350        Ok(result) => {
351            // Update the in-memory registry to stay in sync
352            let mut registry = state.registry.write().await;
353            if let Ok(fresh) = NodeRegistry::load(&registry_path) {
354                *registry = fresh;
355            }
356            Ok((StatusCode::CREATED, Json(result)))
357        }
358        Err(e) => Err((
359            StatusCode::BAD_REQUEST,
360            Json(serde_json::json!({ "error": e.to_string() })),
361        )),
362    }
363}
364
365/// DELETE /api/v1/nodes/:id — Remove a node from the registry.
366async fn delete_node(
367    State(state): State<Arc<AppState>>,
368    Path(id): Path<u32>,
369) -> std::result::Result<Json<RemoveNodeResult>, (StatusCode, Json<serde_json::Value>)> {
370    // Prevent removing a running node (would orphan the process)
371    let supervisor = state.supervisor.read().await;
372    if supervisor.is_running(id) {
373        return Err((
374            StatusCode::CONFLICT,
375            Json(serde_json::json!({
376                "error": format!("Cannot remove node {id} while it is running. Stop it first."),
377                "current_state": { "node_id": id, "status": "running" }
378            })),
379        ));
380    }
381    drop(supervisor);
382
383    let registry_path = state.config.registry_path.clone();
384
385    match crate::node::remove_node(id, &registry_path) {
386        Ok(result) => {
387            // Update the in-memory registry to stay in sync
388            let mut registry = state.registry.write().await;
389            if let Ok(fresh) = NodeRegistry::load(&registry_path) {
390                *registry = fresh;
391            }
392            Ok(Json(result))
393        }
394        Err(crate::error::Error::NodeNotFound(id)) => Err((
395            StatusCode::NOT_FOUND,
396            Json(serde_json::json!({ "error": format!("Node not found: {id}") })),
397        )),
398        Err(e) => Err((
399            StatusCode::INTERNAL_SERVER_ERROR,
400            Json(serde_json::json!({ "error": e.to_string() })),
401        )),
402    }
403}
404
405/// POST /api/v1/nodes/:id/start — Start a specific node.
406async fn post_start_node(
407    State(state): State<Arc<AppState>>,
408    Path(id): Path<u32>,
409) -> std::result::Result<Json<NodeStarted>, (StatusCode, Json<serde_json::Value>)> {
410    let registry = state.registry.read().await;
411    let config = match registry.get(id) {
412        Ok(config) => config.clone(),
413        Err(_) => {
414            return Err((
415                StatusCode::NOT_FOUND,
416                Json(serde_json::json!({ "error": format!("Node not found: {id}") })),
417            ))
418        }
419    };
420    drop(registry);
421
422    // An evicted node's data directory is gone; refuse to start it (recovery is to dismiss and
423    // re-add, not restart).
424    if config.eviction.is_some() {
425        return Err((
426            StatusCode::CONFLICT,
427            Json(serde_json::json!({
428                "error": format!(
429                    "Node {id} has been evicted and cannot be started. Dismiss it with \
430                     `ant node dismiss {id}` and add a new node instead."
431                ),
432                "current_state": { "node_id": id, "status": "evicted" }
433            })),
434        ));
435    }
436
437    let supervisor_ref = state.supervisor.clone();
438
439    // Acquire write lock once for atomic check-and-act (avoids TOCTOU race)
440    let mut supervisor = state.supervisor.write().await;
441    if supervisor.is_running(id) {
442        let pid = supervisor.node_pid(id);
443        let uptime_secs = supervisor.node_uptime_secs(id);
444        return Err((
445            StatusCode::CONFLICT,
446            Json(serde_json::json!({
447                "error": format!("Node {id} is already running"),
448                "current_state": {
449                    "node_id": id,
450                    "status": "running",
451                    "pid": pid,
452                    "uptime_secs": uptime_secs,
453                }
454            })),
455        ));
456    }
457
458    let registry_ref = state.registry.clone();
459    match supervisor
460        .start_node(&config, supervisor_ref, registry_ref)
461        .await
462    {
463        Ok(started) => Ok(Json(started)),
464        Err(crate::error::Error::NodeAlreadyRunning(id)) => {
465            let pid = supervisor.node_pid(id);
466            let uptime_secs = supervisor.node_uptime_secs(id);
467            Err((
468                StatusCode::CONFLICT,
469                Json(serde_json::json!({
470                    "error": format!("Node {id} is already running"),
471                    "current_state": {
472                        "node_id": id,
473                        "status": "running",
474                        "pid": pid,
475                        "uptime_secs": uptime_secs,
476                    }
477                })),
478            ))
479        }
480        Err(e) => Err((
481            StatusCode::INTERNAL_SERVER_ERROR,
482            Json(serde_json::json!({ "error": e.to_string() })),
483        )),
484    }
485}
486
487/// POST /api/v1/nodes/start-all — Start all registered nodes.
488async fn post_start_all(State(state): State<Arc<AppState>>) -> Json<StartNodeResult> {
489    let registry = state.registry.read().await;
490    // Evicted nodes have no data directory; skip them silently rather than attempting a spawn that
491    // would fail. They remain visible as `Evicted` in status until dismissed.
492    let configs: Vec<_> = registry
493        .list()
494        .into_iter()
495        .filter(|c| c.eviction.is_none())
496        .cloned()
497        .collect();
498    drop(registry);
499
500    let mut started = Vec::new();
501    let mut failed = Vec::new();
502    let mut already_running = Vec::new();
503
504    let supervisor_ref = state.supervisor.clone();
505    let registry_ref = state.registry.clone();
506
507    for config in &configs {
508        let mut supervisor = state.supervisor.write().await;
509        if supervisor.is_running(config.id) {
510            already_running.push(config.id);
511            continue;
512        }
513
514        match supervisor
515            .start_node(config, supervisor_ref.clone(), registry_ref.clone())
516            .await
517        {
518            Ok(result) => started.push(result),
519            Err(crate::error::Error::NodeAlreadyRunning(id)) => {
520                already_running.push(id);
521            }
522            Err(e) => {
523                failed.push(crate::node::types::NodeStartFailed {
524                    node_id: config.id,
525                    service_name: config.service_name.clone(),
526                    error: e.to_string(),
527                });
528            }
529        }
530    }
531
532    Json(StartNodeResult {
533        started,
534        failed,
535        already_running,
536    })
537}
538
539/// POST /api/v1/nodes/:id/stop — Stop a specific node.
540async fn post_stop_node(
541    State(state): State<Arc<AppState>>,
542    Path(id): Path<u32>,
543) -> std::result::Result<Json<NodeStopped>, (StatusCode, Json<serde_json::Value>)> {
544    let registry = state.registry.read().await;
545    let config = match registry.get(id) {
546        Ok(config) => config.clone(),
547        Err(_) => {
548            return Err((
549                StatusCode::NOT_FOUND,
550                Json(serde_json::json!({ "error": format!("Node not found: {id}") })),
551            ))
552        }
553    };
554    drop(registry);
555
556    // An evicted node is already stopped and its data directory deleted; there is nothing to stop.
557    if config.eviction.is_some() {
558        return Err((
559            StatusCode::CONFLICT,
560            Json(serde_json::json!({
561                "error": format!(
562                    "Node {id} has been evicted; there is nothing to stop. Dismiss it with \
563                     `ant node dismiss {id}`."
564                ),
565                "current_state": { "node_id": id, "status": "evicted" }
566            })),
567        ));
568    }
569
570    // Acquire write lock once for atomic check-and-act (avoids TOCTOU race)
571    let mut supervisor = state.supervisor.write().await;
572    if !supervisor.is_running(id) {
573        let status = supervisor
574            .node_status(id)
575            .unwrap_or(crate::node::types::NodeStatus::Stopped);
576        return Err((
577            StatusCode::CONFLICT,
578            Json(serde_json::json!({
579                "error": format!("Node {id} is not running"),
580                "current_state": {
581                    "node_id": id,
582                    "status": status,
583                }
584            })),
585        ));
586    }
587
588    match supervisor.stop_node(id).await {
589        Ok(()) => Ok(Json(NodeStopped {
590            node_id: id,
591            service_name: config.service_name,
592        })),
593        Err(crate::error::Error::NodeNotRunning(id)) => {
594            let status = supervisor
595                .node_status(id)
596                .unwrap_or(crate::node::types::NodeStatus::Stopped);
597            Err((
598                StatusCode::CONFLICT,
599                Json(serde_json::json!({
600                    "error": format!("Node {id} is not running"),
601                    "current_state": {
602                        "node_id": id,
603                        "status": status,
604                    }
605                })),
606            ))
607        }
608        Err(e) => Err((
609            StatusCode::INTERNAL_SERVER_ERROR,
610            Json(serde_json::json!({ "error": e.to_string() })),
611        )),
612    }
613}
614
615/// POST /api/v1/nodes/stop-all — Stop all running nodes.
616async fn post_stop_all(State(state): State<Arc<AppState>>) -> Json<StopNodeResult> {
617    let registry = state.registry.read().await;
618    // Skip evicted nodes — there is nothing to stop, and they should stay `Evicted` until dismissed.
619    let configs: Vec<(u32, String)> = registry
620        .list()
621        .into_iter()
622        .filter(|c| c.eviction.is_none())
623        .map(|c| (c.id, c.service_name.clone()))
624        .collect();
625    drop(registry);
626
627    let mut supervisor = state.supervisor.write().await;
628    let result = supervisor.stop_all_nodes(&configs).await;
629
630    Json(result)
631}
632
633/// POST /api/v1/reset — Reset all node state.
634async fn post_reset(
635    State(state): State<Arc<AppState>>,
636) -> std::result::Result<Json<ResetResult>, (StatusCode, Json<serde_json::Value>)> {
637    // Hold write lock for atomic check-and-act (prevents nodes being started
638    // between the running check and the reset operation)
639    let supervisor = state.supervisor.write().await;
640    let (running, _, _) = supervisor.node_counts();
641    if running > 0 {
642        return Err((
643            StatusCode::CONFLICT,
644            Json(serde_json::json!({
645                "error": format!("Cannot reset while nodes are running ({running} node(s) still running). Stop all nodes first."),
646                "nodes_running": running,
647            })),
648        ));
649    }
650    drop(supervisor);
651
652    let registry_path = state.config.registry_path.clone();
653
654    match crate::node::reset(&registry_path) {
655        Ok(result) => {
656            // Update the in-memory registry to stay in sync
657            let mut registry = state.registry.write().await;
658            if let Ok(fresh) = NodeRegistry::load(&registry_path) {
659                *registry = fresh;
660            }
661            Ok(Json(result))
662        }
663        Err(e) => Err((
664            StatusCode::INTERNAL_SERVER_ERROR,
665            Json(serde_json::json!({ "error": e.to_string() })),
666        )),
667    }
668}
669
670/// Load the persisted forwarding config, treating an unreadable one as disabled.
671///
672/// The daemon must come up whatever state that file is in; a broken config is reported through the
673/// status endpoint rather than by refusing to start.
674fn load_forward_config() -> LogForwardConfig {
675    LogForwardConfig::default_path()
676        .and_then(|path| LogForwardConfig::load(&path))
677        .unwrap_or_else(|error| {
678            tracing::warn!("log forwarding: could not read the saved config: {error}");
679            LogForwardConfig::disabled()
680        })
681}
682
683/// Build the sink a config describes.
684fn build_sink(config: &LogForwardConfig) -> Result<Arc<dyn LogSink>> {
685    let sink = ElasticsearchSink::new(config.endpoint_base(), &config.token)?;
686    Ok(Arc::new(sink))
687}
688
689/// Start a forwarder for the persisted config, if forwarding is enabled.
690async fn start_forwarder_if_enabled(state: &Arc<AppState>) {
691    let config = load_forward_config();
692    if !config.enabled {
693        return;
694    }
695    if let Err(error) = start_forwarder(state, config).await {
696        tracing::warn!("log forwarding: could not start: {error}");
697    }
698}
699
700/// Replace any running forwarder with one for `config`.
701async fn start_forwarder(state: &Arc<AppState>, config: LogForwardConfig) -> Result<()> {
702    let sink = build_sink(&config)?;
703    let offsets_path = crate::node::daemon::forward::OffsetStore::default_path()?;
704    let endpoint = sink.describe();
705
706    let handle = spawn_log_forwarder(
707        state.registry.clone(),
708        config,
709        sink,
710        offsets_path,
711        DEFAULT_POLL_INTERVAL,
712        state.shutdown.clone(),
713    );
714
715    let mut slot = state.forwarder.write().await;
716    if let Some(previous) = slot.replace(handle) {
717        // Awaited, not merely signalled: two forwarders tailing the same files and shipping to the
718        // same endpoint would otherwise overlap for the length of the old one's retry ladder.
719        previous.stop_and_wait().await;
720    }
721    tracing::info!("log forwarding: shipping node logs to {endpoint}");
722    Ok(())
723}
724
725/// Build the status response, merging persisted config with the live forwarder's counters.
726///
727/// The node lists always come from the registry rather than the forwarder's snapshot. They are
728/// derived from registry state, not runtime state, and reading them live keeps `status` consistent
729/// with what `enable` just reported — a snapshot taken before the forwarder's first poll would
730/// otherwise show no nodes at all a moment after `enable` listed them.
731async fn forward_status(state: &Arc<AppState>) -> LogForwardStatus {
732    let config = load_forward_config();
733    let mut status = LogForwardStatus::inactive(&config);
734
735    {
736        let registry = state.registry.read().await;
737        let (forwarding, skipped) = classify_nodes(&registry);
738        status.nodes_forwarding = forwarding;
739        status.nodes_skipped = skipped;
740    }
741
742    let slot = state.forwarder.read().await;
743    if let Some(handle) = slot.as_ref().filter(|handle| !handle.is_stopped()) {
744        status.active = true;
745        status.stats = handle.snapshot().await.stats;
746    }
747
748    status
749}
750
751/// GET /api/v1/logs/forward — Whether beta log forwarding is on, and what it is doing.
752async fn get_log_forward(State(state): State<Arc<AppState>>) -> Json<LogForwardStatus> {
753    Json(forward_status(&state).await)
754}
755
756/// POST /api/v1/logs/forward/enable — Opt into forwarding node logs to the beta endpoint.
757///
758/// Running this is the consent act. It is idempotent: enabling while already enabled re-reads the
759/// request, restarts the forwarder against it, and reports `already_in_state`.
760async fn post_log_forward_enable(
761    State(state): State<Arc<AppState>>,
762    Json(request): Json<LogForwardEnableRequest>,
763) -> std::result::Result<Json<LogForwardResult>, (StatusCode, Json<serde_json::Value>)> {
764    let stored = load_forward_config();
765    let was_enabled = stored.enabled;
766
767    let config = apply_enable(&stored, &request).map_err(|error| {
768        (
769            StatusCode::BAD_REQUEST,
770            Json(serde_json::json!({ "error": error.to_string() })),
771        )
772    })?;
773
774    let path = LogForwardConfig::default_path().map_err(internal_error)?;
775    config.save(&path).map_err(internal_error)?;
776
777    start_forwarder(&state, config.clone())
778        .await
779        .map_err(internal_error)?;
780
781    let registry = state.registry.read().await;
782    let (nodes_forwarding, nodes_skipped) = classify_nodes(&registry);
783
784    Ok(Json(LogForwardResult {
785        enabled: true,
786        already_in_state: was_enabled,
787        endpoint: config.endpoint.clone(),
788        min_level: config.min_level,
789        nodes_forwarding,
790        nodes_skipped,
791        pending_daemon_start: false,
792    }))
793}
794
795/// POST /api/v1/logs/forward/disable — Stop forwarding.
796///
797/// Nothing else about any node changes: no restart, no argument change, no data touched.
798async fn post_log_forward_disable(
799    State(state): State<Arc<AppState>>,
800) -> std::result::Result<Json<LogForwardResult>, (StatusCode, Json<serde_json::Value>)> {
801    let mut config = load_forward_config();
802    let was_enabled = config.enabled;
803
804    config.enabled = false;
805    let path = LogForwardConfig::default_path().map_err(internal_error)?;
806    config.save(&path).map_err(internal_error)?;
807
808    // Awaited rather than signalled. `disable` is a revocation of consent, so it must not return
809    // — and the CLI must not print "Log forwarding stopped" — while a request is still in flight.
810    if let Some(handle) = state.forwarder.write().await.take() {
811        handle.stop_and_wait().await;
812    }
813
814    Ok(Json(LogForwardResult {
815        enabled: false,
816        already_in_state: !was_enabled,
817        endpoint: config.endpoint.clone(),
818        min_level: config.min_level,
819        nodes_forwarding: Vec::new(),
820        nodes_skipped: Vec::new(),
821        pending_daemon_start: false,
822    }))
823}
824
825fn internal_error(error: crate::error::Error) -> (StatusCode, Json<serde_json::Value>) {
826    (
827        StatusCode::INTERNAL_SERVER_ERROR,
828        Json(serde_json::json!({ "error": error.to_string() })),
829    )
830}
831
832async fn get_openapi() -> impl IntoResponse {
833    // TODO: Migrate to utoipa-generated OpenAPI spec. Types already derive
834    // utoipa::ToSchema but this spec is still hand-written JSON.
835    let spec = serde_json::json!({
836        "openapi": "3.1.0",
837        "info": {
838            "title": "Ant Daemon API",
839            "version": "0.1.0",
840            "description": "REST API for the ant node management daemon"
841        },
842        "paths": {
843            "/api/v1/status": {
844                "get": {
845                    "summary": "Daemon status",
846                    "description": "Returns daemon health, uptime, and node count summary",
847                    "responses": {
848                        "200": {
849                            "description": "Daemon status",
850                            "content": {
851                                "application/json": {
852                                    "schema": { "$ref": "#/components/schemas/DaemonStatus" }
853                                }
854                            }
855                        }
856                    }
857                }
858            },
859            "/api/v1/events": {
860                "get": {
861                    "summary": "Event stream",
862                    "description": "SSE stream of real-time node events",
863                    "responses": {
864                        "200": {
865                            "description": "SSE event stream"
866                        }
867                    }
868                }
869            },
870            "/api/v1/nodes": {
871                "post": {
872                    "summary": "Add nodes",
873                    "description": "Add one or more nodes to the registry",
874                    "requestBody": {
875                        "required": true,
876                        "content": {
877                            "application/json": {
878                                "schema": { "$ref": "#/components/schemas/AddNodeOpts" }
879                            }
880                        }
881                    },
882                    "responses": {
883                        "201": {
884                            "description": "Nodes added",
885                            "content": {
886                                "application/json": {
887                                    "schema": { "$ref": "#/components/schemas/AddNodeResult" }
888                                }
889                            }
890                        },
891                        "400": {
892                            "description": "Invalid request"
893                        }
894                    }
895                }
896            },
897            "/api/v1/nodes/{id}": {
898                "delete": {
899                    "summary": "Remove node",
900                    "description": "Remove a node from the registry",
901                    "parameters": [{
902                        "name": "id",
903                        "in": "path",
904                        "required": true,
905                        "schema": { "type": "integer" }
906                    }],
907                    "responses": {
908                        "200": {
909                            "description": "Node removed",
910                            "content": {
911                                "application/json": {
912                                    "schema": { "$ref": "#/components/schemas/RemoveNodeResult" }
913                                }
914                            }
915                        },
916                        "404": {
917                            "description": "Node not found"
918                        }
919                    }
920                }
921            },
922            "/api/v1/nodes/{id}/start": {
923                "post": {
924                    "summary": "Start a node",
925                    "description": "Start a specific node by ID. Returns 409 if already running with current_state.",
926                    "parameters": [{
927                        "name": "id",
928                        "in": "path",
929                        "required": true,
930                        "schema": { "type": "integer" }
931                    }],
932                    "responses": {
933                        "200": {
934                            "description": "Node started",
935                            "content": {
936                                "application/json": {
937                                    "schema": { "$ref": "#/components/schemas/NodeStarted" }
938                                }
939                            }
940                        },
941                        "404": {
942                            "description": "Node not found"
943                        },
944                        "409": {
945                            "description": "Node already running (includes current_state)"
946                        },
947                        "500": {
948                            "description": "Failed to start node"
949                        }
950                    }
951                }
952            },
953            "/api/v1/nodes/start-all": {
954                "post": {
955                    "summary": "Start all nodes",
956                    "description": "Start all registered nodes. Returns per-node results.",
957                    "responses": {
958                        "200": {
959                            "description": "Start results",
960                            "content": {
961                                "application/json": {
962                                    "schema": { "$ref": "#/components/schemas/StartNodeResult" }
963                                }
964                            }
965                        }
966                    }
967                }
968            },
969            "/api/v1/nodes/{id}/stop": {
970                "post": {
971                    "summary": "Stop a node",
972                    "description": "Stop a specific node by ID. Returns 409 if already stopped with current_state.",
973                    "parameters": [{
974                        "name": "id",
975                        "in": "path",
976                        "required": true,
977                        "schema": { "type": "integer" }
978                    }],
979                    "responses": {
980                        "200": {
981                            "description": "Node stopped",
982                            "content": {
983                                "application/json": {
984                                    "schema": { "$ref": "#/components/schemas/NodeStopped" }
985                                }
986                            }
987                        },
988                        "404": {
989                            "description": "Node not found"
990                        },
991                        "409": {
992                            "description": "Node not running (includes current_state)"
993                        },
994                        "500": {
995                            "description": "Failed to stop node"
996                        }
997                    }
998                }
999            },
1000            "/api/v1/nodes/stop-all": {
1001                "post": {
1002                    "summary": "Stop all nodes",
1003                    "description": "Stop all running nodes. Returns per-node results.",
1004                    "responses": {
1005                        "200": {
1006                            "description": "Stop results",
1007                            "content": {
1008                                "application/json": {
1009                                    "schema": { "$ref": "#/components/schemas/StopNodeResult" }
1010                                }
1011                            }
1012                        }
1013                    }
1014                }
1015            },
1016            "/api/v1/reset": {
1017                "post": {
1018                    "summary": "Reset all node state",
1019                    "description": "Remove all node data directories, log directories, and clear the registry. Fails if any nodes are running.",
1020                    "responses": {
1021                        "200": {
1022                            "description": "Reset successful",
1023                            "content": {
1024                                "application/json": {
1025                                    "schema": { "$ref": "#/components/schemas/ResetResult" }
1026                                }
1027                            }
1028                        },
1029                        "409": {
1030                            "description": "Nodes still running"
1031                        }
1032                    }
1033                }
1034            },
1035            "/api/v1/logs/forward": {
1036                "get": {
1037                    "summary": "Log forwarding status",
1038                    "description": "Whether beta log forwarding is enabled, which nodes are being tailed, which are skipped for having no log directory, and delivery counters. Never returns the write token, only a fingerprint of it.",
1039                    "responses": {
1040                        "200": {
1041                            "description": "Forwarding status",
1042                            "content": {
1043                                "application/json": {
1044                                    "schema": { "$ref": "#/components/schemas/LogForwardStatus" }
1045                                }
1046                            }
1047                        }
1048                    }
1049                }
1050            },
1051            "/api/v1/logs/forward/enable": {
1052                "post": {
1053                    "summary": "Enable log forwarding",
1054                    "description": "Opt into forwarding managed nodes' logs to the beta endpoint. This call is the consent act. Omitted fields reuse the stored configuration, so re-enabling after a disable needs no arguments. Idempotent.",
1055                    "requestBody": {
1056                        "required": false,
1057                        "content": {
1058                            "application/json": {
1059                                "schema": { "$ref": "#/components/schemas/LogForwardEnableRequest" }
1060                            }
1061                        }
1062                    },
1063                    "responses": {
1064                        "200": {
1065                            "description": "Forwarding enabled",
1066                            "content": {
1067                                "application/json": {
1068                                    "schema": { "$ref": "#/components/schemas/LogForwardResult" }
1069                                }
1070                            }
1071                        },
1072                        "400": {
1073                            "description": "No token has ever been supplied, or the endpoint is not an http(s) URL"
1074                        }
1075                    }
1076                }
1077            },
1078            "/api/v1/logs/forward/disable": {
1079                "post": {
1080                    "summary": "Disable log forwarding",
1081                    "description": "Stop forwarding. Nothing else about any node changes: no restart, no argument change, no data touched. Idempotent.",
1082                    "responses": {
1083                        "200": {
1084                            "description": "Forwarding disabled",
1085                            "content": {
1086                                "application/json": {
1087                                    "schema": { "$ref": "#/components/schemas/LogForwardResult" }
1088                                }
1089                            }
1090                        }
1091                    }
1092                }
1093            }
1094        },
1095        "components": {
1096            "schemas": {
1097                "LogLevel": {
1098                    "type": "string",
1099                    "enum": ["trace", "debug", "info", "warn", "error"]
1100                },
1101                "ForwardingNode": {
1102                    "type": "object",
1103                    "properties": {
1104                        "node_id": { "type": "integer" },
1105                        "service": { "type": "string" },
1106                        "log_dir": { "type": "string" }
1107                    }
1108                },
1109                "SkippedNode": {
1110                    "type": "object",
1111                    "description": "A node that cannot be forwarded, with the reason. Almost always a node added without --log-dir-path, which writes no log files at all.",
1112                    "properties": {
1113                        "node_id": { "type": "integer" },
1114                        "service": { "type": "string" },
1115                        "reason": { "type": "string" }
1116                    }
1117                },
1118                "ForwardStats": {
1119                    "type": "object",
1120                    "description": "Counters since the daemon started; reset on restart.",
1121                    "properties": {
1122                        "events_forwarded": { "type": "integer" },
1123                        "events_dropped_by_level": { "type": "integer" },
1124                        "events_dropped_by_overflow": { "type": "integer" },
1125                        "batches_sent": { "type": "integer" },
1126                        "batches_failed": { "type": "integer" },
1127                        "last_success_unix": { "type": "integer", "nullable": true },
1128                        "last_error": { "type": "string", "nullable": true }
1129                    }
1130                },
1131                "LogForwardStatus": {
1132                    "type": "object",
1133                    "properties": {
1134                        "enabled": { "type": "boolean" },
1135                        "endpoint": { "type": "string" },
1136                        "index_prefix": { "type": "string" },
1137                        "min_level": { "$ref": "#/components/schemas/LogLevel" },
1138                        "token_fingerprint": { "type": "string", "nullable": true, "description": "Short non-reversible identifier for the configured token. The token itself is never returned." },
1139                        "active": { "type": "boolean", "description": "Whether the background forwarder is currently running." },
1140                        "nodes_forwarding": { "type": "array", "items": { "$ref": "#/components/schemas/ForwardingNode" } },
1141                        "nodes_skipped": { "type": "array", "items": { "$ref": "#/components/schemas/SkippedNode" } },
1142                        "stats": { "$ref": "#/components/schemas/ForwardStats" }
1143                    }
1144                },
1145                "LogForwardEnableRequest": {
1146                    "type": "object",
1147                    "properties": {
1148                        "token": { "type": "string", "nullable": true, "description": "Write-only Elasticsearch API key. Reuses the stored one when omitted." },
1149                        "endpoint": { "type": "string", "nullable": true },
1150                        "min_level": { "$ref": "#/components/schemas/LogLevel", "nullable": true }
1151                    }
1152                },
1153                "LogForwardResult": {
1154                    "type": "object",
1155                    "properties": {
1156                        "enabled": { "type": "boolean" },
1157                        "already_in_state": { "type": "boolean" },
1158                        "endpoint": { "type": "string" },
1159                        "min_level": { "$ref": "#/components/schemas/LogLevel" },
1160                        "nodes_forwarding": { "type": "array", "items": { "$ref": "#/components/schemas/ForwardingNode" } },
1161                        "nodes_skipped": { "type": "array", "items": { "$ref": "#/components/schemas/SkippedNode" } },
1162                        "pending_daemon_start": { "type": "boolean", "description": "Set when the config was saved but no forwarder could be started because the daemon is not running." }
1163                    }
1164                },
1165                "DaemonStatus": {
1166                    "type": "object",
1167                    "properties": {
1168                        "running": { "type": "boolean" },
1169                        "pid": { "type": "integer", "nullable": true },
1170                        "port": { "type": "integer", "nullable": true },
1171                        "uptime_secs": { "type": "integer", "nullable": true },
1172                        "nodes_total": { "type": "integer" },
1173                        "nodes_running": { "type": "integer" },
1174                        "nodes_stopped": { "type": "integer" },
1175                        "nodes_errored": { "type": "integer" }
1176                    }
1177                }
1178            }
1179        }
1180    });
1181    Json(spec)
1182}
1183
1184async fn get_console() -> Html<&'static str> {
1185    Html(include_str!("console.html"))
1186}
1187
1188fn write_file(path: &PathBuf, contents: &str) -> Result<()> {
1189    if let Some(parent) = path.parent() {
1190        std::fs::create_dir_all(parent)?;
1191    }
1192    std::fs::write(path, contents)?;
1193    Ok(())
1194}
1195
1196/// Refresh each registered node's `version` against what its on-disk binary reports.
1197///
1198/// Intended as a one-time pass at daemon startup to heal registries left in a stale state by
1199/// earlier daemon versions that didn't track auto-upgrades. Missing binaries and transient
1200/// `--version` failures are silently skipped so daemon startup never aborts on this.
1201async fn reconcile_registry_versions(registry: &mut NodeRegistry) {
1202    let node_ids: Vec<u32> = registry.list().iter().map(|c| c.id).collect();
1203    let mut changed = false;
1204
1205    for id in node_ids {
1206        let (binary_path, recorded_version) = match registry.get(id) {
1207            Ok(c) => (c.binary_path.clone(), c.version.clone()),
1208            Err(_) => continue,
1209        };
1210
1211        if !binary_path.exists() {
1212            continue;
1213        }
1214
1215        let Ok(disk_version) = crate::node::binary::extract_version(&binary_path).await else {
1216            continue;
1217        };
1218
1219        if disk_version == recorded_version {
1220            continue;
1221        }
1222
1223        if let Ok(entry) = registry.get_mut(id) {
1224            entry.version = disk_version;
1225            changed = true;
1226        }
1227    }
1228
1229    if changed {
1230        let _ = registry.save();
1231    }
1232}
1233
1234#[cfg(all(test, unix))]
1235mod tests {
1236    use super::*;
1237    use crate::node::registry::NodeRegistry;
1238    use crate::node::types::{EvmNetwork, NodeConfig};
1239    use std::collections::HashMap;
1240    use std::os::unix::fs::PermissionsExt;
1241
1242    fn write_fake_binary(path: &std::path::Path, stdout: &str) {
1243        let script = format!("#!/bin/sh\nprintf '%s\\n' '{stdout}'\n");
1244        std::fs::write(path, script).unwrap();
1245        let mut perm = std::fs::metadata(path).unwrap().permissions();
1246        perm.set_mode(0o755);
1247        std::fs::set_permissions(path, perm).unwrap();
1248    }
1249
1250    fn seed_config(binary_path: PathBuf, version: &str, data_dir: PathBuf) -> NodeConfig {
1251        NodeConfig {
1252            id: 0,
1253            service_name: String::new(),
1254            rewards_address: "0x0".into(),
1255            data_dir,
1256            log_dir: None,
1257            node_port: None,
1258            binary_path,
1259            version: version.into(),
1260            env_variables: HashMap::new(),
1261            bootstrap_peers: vec![],
1262            upgrade_channel: None,
1263            evm_network: EvmNetwork::default(),
1264            eviction: None,
1265        }
1266    }
1267
1268    #[tokio::test]
1269    async fn reconcile_updates_stale_version_and_persists() {
1270        let tmp = tempfile::tempdir().unwrap();
1271        let reg_path = tmp.path().join("registry.json");
1272        let bin_path = tmp.path().join("ant-node");
1273        write_fake_binary(&bin_path, "ant-node 0.10.11-rc.1");
1274
1275        let mut registry = NodeRegistry::load(&reg_path).unwrap();
1276        let id = registry.add(seed_config(
1277            bin_path.clone(),
1278            "0.10.1",
1279            tmp.path().join("data"),
1280        ));
1281        registry.save().unwrap();
1282
1283        reconcile_registry_versions(&mut registry).await;
1284
1285        assert_eq!(registry.get(id).unwrap().version, "0.10.11-rc.1");
1286
1287        let reloaded = NodeRegistry::load(&reg_path).unwrap();
1288        assert_eq!(reloaded.get(id).unwrap().version, "0.10.11-rc.1");
1289    }
1290
1291    #[tokio::test]
1292    async fn reconcile_leaves_matching_version_alone() {
1293        let tmp = tempfile::tempdir().unwrap();
1294        let reg_path = tmp.path().join("registry.json");
1295        let bin_path = tmp.path().join("ant-node");
1296        write_fake_binary(&bin_path, "ant-node 0.10.1");
1297
1298        let mut registry = NodeRegistry::load(&reg_path).unwrap();
1299        let id = registry.add(seed_config(
1300            bin_path.clone(),
1301            "0.10.1",
1302            tmp.path().join("data"),
1303        ));
1304
1305        reconcile_registry_versions(&mut registry).await;
1306
1307        assert_eq!(registry.get(id).unwrap().version, "0.10.1");
1308    }
1309
1310    #[tokio::test]
1311    async fn reconcile_skips_missing_binary() {
1312        let tmp = tempfile::tempdir().unwrap();
1313        let reg_path = tmp.path().join("registry.json");
1314
1315        let mut registry = NodeRegistry::load(&reg_path).unwrap();
1316        let id = registry.add(seed_config(
1317            tmp.path().join("does-not-exist"),
1318            "0.10.1",
1319            tmp.path().join("data"),
1320        ));
1321
1322        reconcile_registry_versions(&mut registry).await;
1323
1324        assert_eq!(registry.get(id).unwrap().version, "0.10.1");
1325    }
1326}