use std::sync::Arc;
use tokio_util::sync::CancellationToken;
use crate::cli::Config;
use crate::infra::{
breadcrumb_endpoint, resolve_http_bind_ip, shutdown_signal, startup_diagnostics,
};
use crate::{adapters, platform};
pub(crate) async fn daemon_mode(config: Config) -> anyhow::Result<()> {
koi_config::dirs::ensure_data_dir();
let http_bind_ip = if config.no_http {
None
} else {
Some(resolve_http_bind_ip(&config.http_bind)?)
};
startup_diagnostics(&config, http_bind_ip);
let dat_token = {
use base64::Engine;
use rand::RngCore;
let mut token_bytes = [0u8; 32];
rand::rng().fill_bytes(&mut token_bytes);
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(token_bytes)
};
if !config.no_http {
let endpoint = breadcrumb_endpoint(http_bind_ip, config.http_port);
koi_config::breadcrumb::write_breadcrumb(&endpoint, &dat_token);
}
let cancel = CancellationToken::new();
let mut tasks = Vec::new();
let started_at = std::time::Instant::now();
let cores = koi_compose::cores::build_cores(
&koi_compose::cores::CoreSpec {
no_mdns: config.no_mdns,
no_certmesh: config.no_certmesh,
no_dns: config.no_dns,
no_health: config.no_health,
no_proxy: config.no_proxy,
no_udp: config.no_udp,
no_runtime: config.no_runtime,
data_dir: config.data_dir.clone(),
dns_config: config.dns_config(),
runtime: config.runtime.clone(),
http_port: config.http_port,
},
&cancel,
&mut tasks,
)
.await;
let dashboard_state = adapters::dashboard::build_dashboard_state(&cores, started_at, "daemon");
tasks.push(koi_dashboard::forward::spawn_event_forwarder(
koi_dashboard::forward::ForwarderCores {
mdns: cores.mdns.clone(),
certmesh: cores.certmesh.clone(),
dns: cores.dns.clone(),
health: cores.health.clone(),
proxy: cores.proxy.clone(),
runtime: cores.runtime.clone(),
},
dashboard_state.event_tx.clone(),
cancel.clone(),
));
let browser_state = cores
.mdns
.as_ref()
.map(|mdns| koi_dashboard::browser::build_state(mdns.clone(), cancel.clone()));
if !config.no_http {
let c = cores.clone();
let port = config.http_port;
let bind_ip = http_bind_ip.unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST));
let cancel_token = cancel.clone();
let ds = dashboard_state.clone();
let bs = browser_state.clone();
let dat = dat_token.clone();
tasks.push(tokio::spawn(async move {
if let Err(e) =
adapters::http::start(c, bind_ip, port, cancel_token, started_at, ds, bs, dat).await
{
tracing::error!(error = %e, "HTTP adapter failed");
}
}));
}
if let Some(ref certmesh) = cores.certmesh {
match certmesh.self_enroll().await {
Ok(enrollment) => {
let cm = certmesh.clone();
let port = config.mtls_port;
let token = cancel.clone();
tasks.push(tokio::spawn(async move {
if let Err(e) = adapters::mtls::start(
port,
cm,
&enrollment.cert_pem,
&enrollment.key_pem,
&enrollment.ca_cert_pem,
token,
)
.await
{
tracing::error!(error = %e, "mTLS adapter failed");
}
}));
}
Err(e) => {
tracing::info!(
reason = %e,
"mTLS adapter: skipped (CA not available for self-enrollment)"
);
}
}
}
if !config.no_ipc {
if let Some(ref mdns) = cores.mdns {
let c = mdns.clone();
let path = config.pipe_path.clone();
let token = cancel.clone();
tasks.push(tokio::spawn(async move {
if let Err(e) = adapters::pipe::start(c, path, token).await {
tracing::error!(error = %e, "IPC adapter failed");
}
}));
} else {
tracing::info!("IPC adapter: skipped (mDNS disabled)");
}
}
let mut http_announce_id: Option<String> = None;
if config.announce_http && !config.no_http {
if let Some(ref mdns) = cores.mdns {
let hostname = hostname::get()
.ok()
.and_then(|os| os.into_string().ok())
.unwrap_or_else(|| "unknown".to_string());
let mut txt = std::collections::HashMap::new();
txt.insert("path".to_string(), "/".to_string());
txt.insert("version".to_string(), env!("CARGO_PKG_VERSION").to_string());
txt.insert("api".to_string(), "v1".to_string());
txt.insert("dashboard".to_string(), "true".to_string());
let payload = koi_mdns::protocol::RegisterPayload {
name: format!("Koi ({hostname})"),
service_type: "_http._tcp".to_string(),
port: config.http_port,
ip: None,
lease_secs: None,
txt,
};
match mdns.register(payload) {
Ok(result) => {
tracing::info!(
id = %result.id,
port = config.http_port,
"HTTP server announced via mDNS"
);
http_announce_id = Some(result.id);
}
Err(e) => {
tracing::warn!(error = %e, "Failed to announce HTTP server via mDNS");
}
}
} else {
tracing::debug!("--announce-http set but mDNS is disabled — skipping");
}
}
if let Some(ref certmesh) = cores.certmesh {
let decider: koi_compose::certmesh::ApprovalDecider = Arc::new(prompt_enrollment_approval);
koi_compose::certmesh::spawn_enrollment_approval(certmesh, decider, &cancel, &mut tasks)
.await;
}
if let Err(e) = platform::register_service() {
tracing::warn!(error = %e, "Platform service registration failed");
}
tracing::info!("Ready.");
shutdown_signal(cancel.clone()).await;
tracing::info!("Shutting down...");
koi_compose::cores::ordered_shutdown(
&cancel,
tasks,
&cores,
http_announce_id,
crate::SHUTDOWN_TIMEOUT,
crate::SHUTDOWN_DRAIN,
)
.await;
koi_config::breadcrumb::delete_breadcrumb();
Ok(())
}
fn prompt_enrollment_approval(
hostname: &str,
profile: koi_certmesh::profiles::TrustProfile,
) -> koi_certmesh::ApprovalDecision {
eprintln!("Enrollment approval requested for '{hostname}' (profile: {profile})");
let approve = read_yes_no("Approve enrollment? [y/N]: ");
if !approve {
return koi_certmesh::ApprovalDecision::Denied;
}
let operator = if profile.requires_operator() {
let operator = read_line("Operator name: ");
if operator.is_empty() {
return koi_certmesh::ApprovalDecision::Denied;
}
Some(operator)
} else {
None
};
koi_certmesh::ApprovalDecision::Approved { operator }
}
fn read_yes_no(prompt: &str) -> bool {
let line = read_line(prompt);
matches!(line.as_str(), "y" | "yes")
}
fn read_line(prompt: &str) -> String {
eprintln!("{prompt}");
let mut line = String::new();
if std::io::stdin().read_line(&mut line).is_ok() {
line.trim().to_string()
} else {
String::new()
}
}