//! OpenFang daemon server — boots the kernel and serves the HTTP API. use crate::channel_bridge; use crate::middleware; use crate::rate_limiter; use crate::routes::{self, AppState}; use crate::webchat; use crate::ws; use axum::Router; use openfang_kernel::OpenFangKernel; use std::net::SocketAddr; use std::path::Path; use std::sync::Arc; use std::time::Instant; use tower_http::compression::CompressionLayer; use tower_http::cors::CorsLayer; use tower_http::trace::TraceLayer; use tracing::info; /// Daemon info written to `~/.openfang/daemon.json` so the CLI can find us. #[derive(serde::Serialize, serde::Deserialize)] pub struct DaemonInfo { pub pid: u32, pub listen_addr: String, pub started_at: String, pub version: String, pub platform: String, } /// Build the full API router with all routes, middleware, and state. /// /// This is extracted from `run_daemon()` so that embedders (e.g. openfang-desktop) /// can create the router without starting the full daemon lifecycle. /// /// Returns `(router, shared_state)`. The caller can use `state.bridge_manager` /// to shut down the bridge on exit. pub async fn build_router( kernel: Arc, listen_addr: SocketAddr, ) -> (Router<()>, Arc) { // Start channel bridges (Telegram, etc.) let bridge = channel_bridge::start_channel_bridge(kernel.clone()).await; let channels_config = kernel.config.channels.clone(); let state = Arc::new(AppState { kernel: kernel.clone(), started_at: Instant::now(), peer_registry: kernel.peer_registry.as_ref().map(|r| Arc::new(r.clone())), bridge_manager: tokio::sync::Mutex::new(bridge), channels_config: tokio::sync::RwLock::new(channels_config), shutdown_notify: Arc::new(tokio::sync::Notify::new()), }); // CORS: allow localhost origins by default. If API key is set, the API // is protected anyway. For development, permissive CORS is convenient. let cors = if state.kernel.config.api_key.is_empty() { // No auth → restrict CORS to localhost origins (include both 127.0.0.1 and localhost) let port = listen_addr.port(); let mut origins: Vec = vec![ format!("http://{listen_addr}").parse().unwrap(), format!("http://localhost:{port}").parse().unwrap(), ]; // Also allow common dev ports for p in [3000u16, 8080] { if p != port { if let Ok(v) = format!("http://127.0.0.1:{p}").parse() { origins.push(v); } if let Ok(v) = format!("http://localhost:{p}").parse() { origins.push(v); } } } CorsLayer::new() .allow_origin(origins) .allow_methods(tower_http::cors::Any) .allow_headers(tower_http::cors::Any) } else { // Auth enabled → restrict CORS to localhost + configured origins. // SECURITY: CorsLayer::permissive() is dangerous — any website could // make cross-origin requests. Restrict to known origins instead. let mut origins: Vec = vec![ format!("http://{listen_addr}").parse().unwrap(), "http://localhost:4200".parse().unwrap(), "http://127.0.0.1:4200".parse().unwrap(), "http://localhost:8080".parse().unwrap(), "http://127.0.0.1:8080".parse().unwrap(), ]; // Add the actual listen address variants if listen_addr.port() != 4200 && listen_addr.port() != 8080 { if let Ok(v) = format!("http://localhost:{}", listen_addr.port()).parse() { origins.push(v); } if let Ok(v) = format!("http://127.0.0.1:{}", listen_addr.port()).parse() { origins.push(v); } } CorsLayer::new() .allow_origin(origins) .allow_methods(tower_http::cors::Any) .allow_headers(tower_http::cors::Any) }; let api_key = state.kernel.config.api_key.clone(); let gcra_limiter = rate_limiter::create_rate_limiter(); let app = Router::new() .route("/", axum::routing::get(webchat::webchat_page)) .route("/logo.png", axum::routing::get(webchat::logo_png)) .route("/favicon.ico", axum::routing::get(webchat::favicon_ico)) .route( "/api/metrics", axum::routing::get(routes::prometheus_metrics), ) .route("/api/health", axum::routing::get(routes::health)) .route( "/api/health/detail", axum::routing::get(routes::health_detail), ) .route("/api/status", axum::routing::get(routes::status)) .route("/api/version", axum::routing::get(routes::version)) .route( "/api/agents", axum::routing::get(routes::list_agents).post(routes::spawn_agent), ) .route( "/api/agents/{id}", axum::routing::get(routes::get_agent).delete(routes::kill_agent), ) .route( "/api/agents/{id}/mode", axum::routing::put(routes::set_agent_mode), ) .route("/api/profiles", axum::routing::get(routes::list_profiles)) .route( "/api/agents/{id}/message", axum::routing::post(routes::send_message), ) .route( "/api/agents/{id}/message/stream", axum::routing::post(routes::send_message_stream), ) .route( "/api/agents/{id}/session", axum::routing::get(routes::get_agent_session), ) .route( "/api/agents/{id}/sessions", axum::routing::get(routes::list_agent_sessions).post(routes::create_agent_session), ) .route( "/api/agents/{id}/sessions/{session_id}/switch", axum::routing::post(routes::switch_agent_session), ) .route( "/api/agents/{id}/session/reset", axum::routing::post(routes::reset_session), ) .route( "/api/agents/{id}/session/compact", axum::routing::post(routes::compact_session), ) .route( "/api/agents/{id}/stop", axum::routing::post(routes::stop_agent), ) .route( "/api/agents/{id}/model", axum::routing::put(routes::set_model), ) .route( "/api/agents/{id}/skills", axum::routing::get(routes::get_agent_skills).put(routes::set_agent_skills), ) .route( "/api/agents/{id}/mcp_servers", axum::routing::get(routes::get_agent_mcp_servers).put(routes::set_agent_mcp_servers), ) .route( "/api/agents/{id}/identity", axum::routing::patch(routes::update_agent_identity), ) .route( "/api/agents/{id}/config", axum::routing::patch(routes::patch_agent_config), ) .route( "/api/agents/{id}/clone", axum::routing::post(routes::clone_agent), ) .route( "/api/agents/{id}/files", axum::routing::get(routes::list_agent_files), ) .route( "/api/agents/{id}/files/{filename}", axum::routing::get(routes::get_agent_file).put(routes::set_agent_file), ) .route( "/api/agents/{id}/deliveries", axum::routing::get(routes::get_agent_deliveries), ) .route( "/api/agents/{id}/upload", axum::routing::post(routes::upload_file), ) .route("/api/agents/{id}/ws", axum::routing::get(ws::agent_ws)) // Upload serving .route( "/api/uploads/{file_id}", axum::routing::get(routes::serve_upload), ) // Channel endpoints .route("/api/channels", axum::routing::get(routes::list_channels)) .route( "/api/channels/{name}/configure", axum::routing::post(routes::configure_channel).delete(routes::remove_channel), ) .route( "/api/channels/{name}/test", axum::routing::post(routes::test_channel), ) .route( "/api/channels/reload", axum::routing::post(routes::reload_channels), ) // WhatsApp QR login flow .route( "/api/channels/whatsapp/qr/start", axum::routing::post(routes::whatsapp_qr_start), ) .route( "/api/channels/whatsapp/qr/status", axum::routing::get(routes::whatsapp_qr_status), ) // Template endpoints .route("/api/templates", axum::routing::get(routes::list_templates)) .route( "/api/templates/{name}", axum::routing::get(routes::get_template), ) // Memory endpoints .route( "/api/memory/agents/{id}/kv", axum::routing::get(routes::get_agent_kv), ) .route( "/api/memory/agents/{id}/kv/{key}", axum::routing::get(routes::get_agent_kv_key) .put(routes::set_agent_kv_key) .delete(routes::delete_agent_kv_key), ) // Trigger endpoints .route( "/api/triggers", axum::routing::get(routes::list_triggers).post(routes::create_trigger), ) .route( "/api/triggers/{id}", axum::routing::delete(routes::delete_trigger).put(routes::update_trigger), ) // Schedule (cron job) endpoints .route( "/api/schedules", axum::routing::get(routes::list_schedules).post(routes::create_schedule), ) .route( "/api/schedules/{id}", axum::routing::delete(routes::delete_schedule).put(routes::update_schedule), ) .route( "/api/schedules/{id}/run", axum::routing::post(routes::run_schedule), ) // Workflow endpoints .route( "/api/workflows", axum::routing::get(routes::list_workflows).post(routes::create_workflow), ) .route( "/api/workflows/{id}/run", axum::routing::post(routes::run_workflow), ) .route( "/api/workflows/{id}/runs", axum::routing::get(routes::list_workflow_runs), ) // AOL (Agent Orchestration Language) endpoints .route( "/api/aol/compile", axum::routing::post(crate::aol_routes::compile_workflow), ) .route( "/api/aol/validate", axum::routing::post(crate::aol_routes::validate_workflow_handler), ) .route( "/api/aol/execute", axum::routing::post(crate::aol_routes::execute_workflow_handler), ) .route( "/api/aol/workflows", axum::routing::get(crate::aol_routes::list_aol_workflows), ) .route( "/api/aol/workflows/{id}", axum::routing::get(crate::aol_routes::get_aol_workflow) .delete(crate::aol_routes::delete_aol_workflow), ) .route( "/api/aol/executions", axum::routing::get(crate::aol_routes::list_executions), ) .route( "/api/aol/executions/{id}", axum::routing::get(crate::aol_routes::get_execution), ) // Skills endpoints .route("/api/skills", axum::routing::get(routes::list_skills)) .route( "/api/skills/install", axum::routing::post(routes::install_skill), ) .route( "/api/skills/uninstall", axum::routing::post(routes::uninstall_skill), ) .route( "/api/marketplace/search", axum::routing::get(routes::marketplace_search), ) // ClawHub (OpenClaw ecosystem) endpoints .route( "/api/clawhub/search", axum::routing::get(routes::clawhub_search), ) .route( "/api/clawhub/browse", axum::routing::get(routes::clawhub_browse), ) .route( "/api/clawhub/skill/{slug}", axum::routing::get(routes::clawhub_skill_detail), ) .route( "/api/clawhub/install", axum::routing::post(routes::clawhub_install), ) // Hands endpoints .route("/api/hands", axum::routing::get(routes::list_hands)) .route( "/api/hands/active", axum::routing::get(routes::list_active_hands), ) .route("/api/hands/{hand_id}", axum::routing::get(routes::get_hand)) .route( "/api/hands/{hand_id}/activate", axum::routing::post(routes::activate_hand), ) .route( "/api/hands/{hand_id}/check-deps", axum::routing::post(routes::check_hand_deps), ) .route( "/api/hands/{hand_id}/install-deps", axum::routing::post(routes::install_hand_deps), ) .route( "/api/hands/instances/{id}/pause", axum::routing::post(routes::pause_hand), ) .route( "/api/hands/instances/{id}/resume", axum::routing::post(routes::resume_hand), ) .route( "/api/hands/instances/{id}", axum::routing::delete(routes::deactivate_hand), ) .route( "/api/hands/instances/{id}/stats", axum::routing::get(routes::hand_stats), ) .route( "/api/hands/instances/{id}/browser", axum::routing::get(routes::hand_instance_browser), ) // MCP server endpoints .route( "/api/mcp/servers", axum::routing::get(routes::list_mcp_servers), ) // Audit endpoints .route( "/api/audit/recent", axum::routing::get(routes::audit_recent), ) .route( "/api/audit/verify", axum::routing::get(routes::audit_verify), ) // Live log streaming (SSE) .route("/api/logs/stream", axum::routing::get(routes::logs_stream)) // Peer/Network endpoints .route("/api/peers", axum::routing::get(routes::list_peers)) .route( "/api/network/status", axum::routing::get(routes::network_status), ) // Tools endpoint .route("/api/tools", axum::routing::get(routes::list_tools)) // Config endpoints .route("/api/config", axum::routing::get(routes::get_config)) .route( "/api/config/schema", axum::routing::get(routes::config_schema), ) .route("/api/config/set", axum::routing::post(routes::config_set)) // Approval endpoints .route( "/api/approvals", axum::routing::get(routes::list_approvals).post(routes::create_approval), ) .route( "/api/approvals/{id}/approve", axum::routing::post(routes::approve_request), ) .route( "/api/approvals/{id}/reject", axum::routing::post(routes::reject_request), ) // Usage endpoints .route("/api/usage", axum::routing::get(routes::usage_stats)) .route( "/api/usage/summary", axum::routing::get(routes::usage_summary), ) .route( "/api/usage/by-model", axum::routing::get(routes::usage_by_model), ) .route("/api/usage/daily", axum::routing::get(routes::usage_daily)) // Budget endpoints .route( "/api/budget", axum::routing::get(routes::budget_status).put(routes::update_budget), ) .route( "/api/budget/agents", axum::routing::get(routes::agent_budget_ranking), ) .route( "/api/budget/agents/{id}", axum::routing::get(routes::agent_budget_status), ) // Session endpoints .route("/api/sessions", axum::routing::get(routes::list_sessions)) .route( "/api/sessions/{id}", axum::routing::delete(routes::delete_session), ) .route( "/api/sessions/{id}/label", axum::routing::put(routes::set_session_label), ) .route( "/api/agents/{id}/sessions/by-label/{label}", axum::routing::get(routes::find_session_by_label), ) // Agent update .route( "/api/agents/{id}/update", axum::routing::put(routes::update_agent), ) // Security dashboard endpoint .route("/api/security", axum::routing::get(routes::security_status)) // Model catalog endpoints .route("/api/models", axum::routing::get(routes::list_models)) .route( "/api/models/aliases", axum::routing::get(routes::list_aliases), ) .route( "/api/models/custom", axum::routing::post(routes::add_custom_model), ) .route( "/api/models/custom/{*id}", axum::routing::delete(routes::remove_custom_model), ) .route("/api/models/{*id}", axum::routing::get(routes::get_model)) .route("/api/providers", axum::routing::get(routes::list_providers)) // Copilot OAuth (must be before parametric {name} routes) .route( "/api/providers/github-copilot/oauth/start", axum::routing::post(routes::copilot_oauth_start), ) .route( "/api/providers/github-copilot/oauth/poll/{poll_id}", axum::routing::get(routes::copilot_oauth_poll), ) .route( "/api/providers/{name}/key", axum::routing::post(routes::set_provider_key).delete(routes::delete_provider_key), ) .route( "/api/providers/{name}/test", axum::routing::post(routes::test_provider), ) .route( "/api/providers/{name}/url", axum::routing::put(routes::set_provider_url), ) .route( "/api/skills/create", axum::routing::post(routes::create_skill), ) // Migration endpoints .route( "/api/migrate/detect", axum::routing::get(routes::migrate_detect), ) .route( "/api/migrate/scan", axum::routing::post(routes::migrate_scan), ) .route("/api/migrate", axum::routing::post(routes::run_migrate)) // Cron job management endpoints .route( "/api/cron/jobs", axum::routing::get(routes::list_cron_jobs).post(routes::create_cron_job), ) .route( "/api/cron/jobs/{id}", axum::routing::delete(routes::delete_cron_job), ) .route( "/api/cron/jobs/{id}/enable", axum::routing::put(routes::toggle_cron_job), ) .route( "/api/cron/jobs/{id}/status", axum::routing::get(routes::cron_job_status), ) // Webhook trigger endpoints (external event injection) .route("/hooks/wake", axum::routing::post(routes::webhook_wake)) .route("/hooks/agent", axum::routing::post(routes::webhook_agent)) .route("/api/shutdown", axum::routing::post(routes::shutdown)) // Chat commands endpoint (dynamic slash menu) .route("/api/commands", axum::routing::get(routes::list_commands)) // Config reload endpoint .route( "/api/config/reload", axum::routing::post(routes::config_reload), ) // Agent binding routes .route( "/api/bindings", axum::routing::get(routes::list_bindings).post(routes::add_binding), ) .route( "/api/bindings/{index}", axum::routing::delete(routes::remove_binding), ) // A2A (Agent-to-Agent) Protocol endpoints .route( "/.well-known/agent.json", axum::routing::get(routes::a2a_agent_card), ) .route("/a2a/agents", axum::routing::get(routes::a2a_list_agents)) .route( "/a2a/tasks/send", axum::routing::post(routes::a2a_send_task), ) .route("/a2a/tasks/{id}", axum::routing::get(routes::a2a_get_task)) .route( "/a2a/tasks/{id}/cancel", axum::routing::post(routes::a2a_cancel_task), ) // A2A management (outbound) endpoints .route( "/api/a2a/agents", axum::routing::get(routes::a2a_list_external_agents), ) .route( "/api/a2a/discover", axum::routing::post(routes::a2a_discover_external), ) .route( "/api/a2a/send", axum::routing::post(routes::a2a_send_external), ) .route( "/api/a2a/tasks/{id}/status", axum::routing::get(routes::a2a_external_task_status), ) // Integration management endpoints .route( "/api/integrations", axum::routing::get(routes::list_integrations), ) .route( "/api/integrations/available", axum::routing::get(routes::list_available_integrations), ) .route( "/api/integrations/add", axum::routing::post(routes::add_integration), ) .route( "/api/integrations/{id}", axum::routing::delete(routes::remove_integration), ) .route( "/api/integrations/{id}/reconnect", axum::routing::post(routes::reconnect_integration), ) .route( "/api/integrations/health", axum::routing::get(routes::integrations_health), ) .route( "/api/integrations/reload", axum::routing::post(routes::reload_integrations), ) // Device pairing endpoints .route( "/api/pairing/request", axum::routing::post(routes::pairing_request), ) .route( "/api/pairing/complete", axum::routing::post(routes::pairing_complete), ) .route( "/api/pairing/devices", axum::routing::get(routes::pairing_devices), ) .route( "/api/pairing/devices/{id}", axum::routing::delete(routes::pairing_remove_device), ) .route( "/api/pairing/notify", axum::routing::post(routes::pairing_notify), ) // MCP HTTP endpoint (exposes MCP protocol over HTTP) .route("/mcp", axum::routing::post(routes::mcp_http)) // OpenAI-compatible API .route( "/v1/chat/completions", axum::routing::post(crate::openai_compat::chat_completions), ) .route( "/v1/models", axum::routing::get(crate::openai_compat::list_models), ) .layer(axum::middleware::from_fn_with_state( api_key, middleware::auth, )) .layer(axum::middleware::from_fn_with_state( gcra_limiter, rate_limiter::gcra_rate_limit, )) .layer(axum::middleware::from_fn(middleware::security_headers)) .layer(axum::middleware::from_fn(middleware::request_logging)) .layer(CompressionLayer::new()) .layer(TraceLayer::new_for_http()) .layer(cors) .with_state(state.clone()); (app, state) } /// Start the OpenFang daemon: boot kernel + HTTP API server. /// /// This function blocks until Ctrl+C or a shutdown request. pub async fn run_daemon( kernel: OpenFangKernel, listen_addr: &str, daemon_info_path: Option<&Path>, ) -> Result<(), Box> { let addr: SocketAddr = listen_addr.parse()?; let kernel = Arc::new(kernel); kernel.set_self_handle(); kernel.start_background_agents(); // Config file hot-reload watcher (polls every 30 seconds) { let k = kernel.clone(); let config_path = kernel.config.home_dir.join("config.toml"); tokio::spawn(async move { let mut last_modified = std::fs::metadata(&config_path) .and_then(|m| m.modified()) .ok(); loop { tokio::time::sleep(std::time::Duration::from_secs(30)).await; let current = std::fs::metadata(&config_path) .and_then(|m| m.modified()) .ok(); if current != last_modified && current.is_some() { last_modified = current; tracing::info!("Config file changed, reloading..."); match k.reload_config() { Ok(plan) => { if plan.has_changes() { tracing::info!("Config hot-reload applied: {:?}", plan.hot_actions); } else { tracing::debug!("Config hot-reload: no actionable changes"); } } Err(e) => tracing::warn!("Config hot-reload failed: {e}"), } } } }); } let (app, state) = build_router(kernel.clone(), addr).await; // Write daemon info file if let Some(info_path) = daemon_info_path { // Check if another daemon is already running with this PID file if info_path.exists() { if let Ok(existing) = std::fs::read_to_string(info_path) { if let Ok(info) = serde_json::from_str::(&existing) { if is_process_alive(info.pid) { return Err(format!( "Another daemon (PID {}) is already running at {}", info.pid, info.listen_addr ) .into()); } } } // Stale PID file, remove it let _ = std::fs::remove_file(info_path); } let daemon_info = DaemonInfo { pid: std::process::id(), listen_addr: addr.to_string(), started_at: chrono::Utc::now().to_rfc3339(), version: env!("CARGO_PKG_VERSION").to_string(), platform: std::env::consts::OS.to_string(), }; if let Ok(json) = serde_json::to_string_pretty(&daemon_info) { let _ = std::fs::write(info_path, json); // SECURITY: Restrict daemon info file permissions (contains PID and port). restrict_permissions(info_path); } } info!("OpenFang API server listening on http://{addr}"); info!("WebChat UI available at http://{addr}/",); info!("WebSocket endpoint: ws://{addr}/api/agents/{{id}}/ws",); let listener = tokio::net::TcpListener::bind(addr).await?; // Run server with graceful shutdown. // SECURITY: `into_make_service_with_connect_info` injects the peer // SocketAddr so the auth middleware can check for loopback connections. let api_shutdown = state.shutdown_notify.clone(); axum::serve( listener, app.into_make_service_with_connect_info::(), ) .with_graceful_shutdown(shutdown_signal(api_shutdown)) .await?; // Clean up daemon info file if let Some(info_path) = daemon_info_path { let _ = std::fs::remove_file(info_path); } // Stop channel bridges if let Some(ref mut b) = *state.bridge_manager.lock().await { b.stop().await; } // Shutdown kernel kernel.shutdown(); info!("OpenFang daemon stopped"); Ok(()) } /// SECURITY: Restrict file permissions to owner-only (0600) on Unix. /// On non-Unix platforms this is a no-op. #[cfg(unix)] fn restrict_permissions(path: &Path) { use std::os::unix::fs::PermissionsExt; let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); } #[cfg(not(unix))] fn restrict_permissions(_path: &Path) {} /// Read daemon info from the standard location. pub fn read_daemon_info(home_dir: &Path) -> Option { let info_path = home_dir.join("daemon.json"); let contents = std::fs::read_to_string(info_path).ok()?; serde_json::from_str(&contents).ok() } /// Wait for an OS termination signal OR an API shutdown request. /// /// On Unix: listens for SIGINT, SIGTERM, and API notify. /// On Windows: listens for Ctrl+C and API notify. async fn shutdown_signal(api_shutdown: Arc) { #[cfg(unix)] { use tokio::signal::unix::{signal, SignalKind}; let mut sigint = signal(SignalKind::interrupt()).expect("Failed to listen for SIGINT"); let mut sigterm = signal(SignalKind::terminate()).expect("Failed to listen for SIGTERM"); tokio::select! { _ = sigint.recv() => { info!("Received SIGINT (Ctrl+C), shutting down..."); } _ = sigterm.recv() => { info!("Received SIGTERM, shutting down..."); } _ = api_shutdown.notified() => { info!("Shutdown requested via API, shutting down..."); } } } #[cfg(not(unix))] { tokio::select! { _ = tokio::signal::ctrl_c() => { info!("Ctrl+C received, shutting down..."); } _ = api_shutdown.notified() => { info!("Shutdown requested via API, shutting down..."); } } } } /// Check if a process with the given PID is still alive. fn is_process_alive(pid: u32) -> bool { #[cfg(unix)] { // Use kill -0 to check if process exists without sending a signal std::process::Command::new("kill") .args(["-0", &pid.to_string()]) .output() .map(|o| o.status.success()) .unwrap_or(false) } #[cfg(windows)] { // tasklist /FI "PID eq N" returns "INFO: No tasks..." when no match, // or a table row with the PID when found. Check exit code and that // "INFO:" is NOT in the output to confirm the process exists. std::process::Command::new("tasklist") .args(["/FI", &format!("PID eq {pid}"), "/NH"]) .output() .map(|o| { o.status.success() && { let out = String::from_utf8_lossy(&o.stdout); !out.contains("INFO:") && out.contains(&pid.to_string()) } }) .unwrap_or(false) } #[cfg(not(any(unix, windows)))] { let _ = pid; false } }