1mod config;
2mod events;
3mod handle;
4mod serve;
5pub mod testkit;
6
7use std::sync::Arc;
8
9use tokio::sync::broadcast;
10use tokio::task::JoinHandle;
11use tokio_util::sync::CancellationToken;
12
13use koi_client::KoiClient;
14
15pub use config::{DnsConfigBuilder, KoiConfig, ServiceMode};
16pub use events::KoiEvent;
17pub use handle::{
18 CertmeshHandle, DnsHandle, HealthHandle, KoiHandle, MdnsHandle, ProxyHandle,
19 DEFAULT_DISCOVER_WINDOW,
20};
21
22pub use koi_common::firewall::{FirewallPort, FirewallProtocol};
24pub use koi_certmesh::PeerClient;
26pub use koi_common::diagnosis::{CheckStatus, DiagnosisCheck, DiagnosisStatus, TrustDiagnosis};
27pub use koi_common::peer::Peer;
28pub use koi_common::posture::{Posture, PostureLevel};
29pub use koi_common::sealed::{Confidentiality, Opened, Sealed};
30pub use koi_common::types::ServiceRecord;
31pub use koi_config::state::DnsEntry;
32pub use koi_health::{HealthCheck, HealthSnapshot, ServiceCheckKind};
33pub use koi_mdns::protocol::{RegisterPayload, RegistrationResult};
34pub use koi_mdns::MdnsEvent;
35pub use koi_proxy::ProxyEntry;
36pub use serve::serve_adaptive;
38
39pub use koi_crypto::vault::{Vault, VaultError};
41
42pub use koi_runtime::{RuntimeBackendKind, RuntimeConfig};
44
45pub type Result<T> = std::result::Result<T, KoiError>;
46
47#[derive(Debug, thiserror::Error)]
48pub enum KoiError {
49 #[error("capability disabled: {0}")]
50 DisabledCapability(&'static str),
51 #[error("not available in client (remote) mode: {0}")]
52 RemoteUnsupported(&'static str),
53 #[error("mdns error: {0}")]
54 Mdns(#[from] koi_mdns::MdnsError),
55 #[error("dns error: {0}")]
56 Dns(#[from] koi_dns::DnsError),
57 #[error("health error: {0}")]
58 Health(#[from] koi_health::HealthError),
59 #[error("proxy error: {0}")]
60 Proxy(#[from] koi_proxy::ProxyError),
61 #[error("certmesh error: {0}")]
62 Certmesh(#[from] koi_certmesh::CertmeshError),
63 #[error("runtime error: {0}")]
64 Runtime(#[from] koi_runtime::RuntimeError),
65 #[error("client error: {0}")]
66 Client(#[from] koi_client::ClientError),
67 #[error("io error: {0}")]
68 Io(#[from] std::io::Error),
69 #[error("insecure configuration: {0}")]
70 InsecureConfig(String),
71}
72
73impl From<koi_compose::cores::BuildCoresError> for KoiError {
74 fn from(e: koi_compose::cores::BuildCoresError) -> Self {
75 use koi_compose::cores::BuildCoresError as B;
76 match e {
77 B::Mdns(e) => KoiError::Mdns(e),
78 B::Dns(e) => KoiError::Dns(e),
79 B::Proxy(e) => KoiError::Proxy(e),
80 B::Health(e) => KoiError::Health(e),
81 B::CertmeshInit(s) => KoiError::Io(std::io::Error::other(s)),
82 }
83 }
84}
85
86pub struct Builder {
87 config: KoiConfig,
88 event_handler: Option<Arc<dyn Fn(KoiEvent) + Send + Sync>>,
89 extra_firewall_ports: Vec<koi_common::firewall::FirewallPort>,
90}
91
92impl Builder {
93 pub fn new() -> Self {
94 Self {
95 config: KoiConfig::default(),
96 event_handler: None,
97 extra_firewall_ports: Vec::new(),
98 }
99 }
100
101 pub fn data_dir(mut self, path: impl Into<std::path::PathBuf>) -> Self {
102 self.config.data_dir = Some(path.into());
103 self
104 }
105
106 pub fn service_endpoint(mut self, endpoint: impl Into<String>) -> Self {
107 self.config.service_endpoint = endpoint.into();
108 self
109 }
110
111 pub fn service_mode(mut self, mode: ServiceMode) -> Self {
112 self.config.service_mode = mode;
113 self
114 }
115
116 pub fn http(mut self, enabled: bool) -> Self {
117 self.config.http_enabled = enabled;
118 self
119 }
120
121 pub fn mdns(mut self, enabled: bool) -> Self {
122 self.config.mdns_enabled = enabled;
123 self
124 }
125
126 pub fn dns<F>(mut self, configure: F) -> Self
127 where
128 F: FnOnce(DnsConfigBuilder) -> DnsConfigBuilder,
129 {
130 let builder = DnsConfigBuilder::new(self.config.dns_config.clone());
131 self.config.dns_config = configure(builder).build();
132 self
133 }
134
135 pub fn dns_enabled(mut self, enabled: bool) -> Self {
136 self.config.dns_enabled = enabled;
137 self
138 }
139
140 pub fn dns_auto_start(mut self, enabled: bool) -> Self {
141 self.config.dns_auto_start = enabled;
142 self
143 }
144
145 pub fn health(mut self, enabled: bool) -> Self {
146 self.config.health_enabled = enabled;
147 self
148 }
149
150 pub fn health_auto_start(mut self, enabled: bool) -> Self {
151 self.config.health_auto_start = enabled;
152 self
153 }
154
155 pub fn certmesh(mut self, enabled: bool) -> Self {
156 self.config.certmesh_enabled = enabled;
157 self
158 }
159
160 pub fn proxy(mut self, enabled: bool) -> Self {
161 self.config.proxy_enabled = enabled;
162 self
163 }
164
165 pub fn proxy_auto_start(mut self, enabled: bool) -> Self {
166 self.config.proxy_auto_start = enabled;
167 self
168 }
169
170 pub fn udp(mut self, enabled: bool) -> Self {
171 self.config.udp_enabled = enabled;
172 self
173 }
174
175 pub fn runtime(mut self, kind: koi_runtime::RuntimeBackendKind) -> Self {
180 self.config.runtime_enabled = true;
181 self.config.runtime_backend = kind;
182 self
183 }
184
185 pub fn runtime_auto(mut self) -> Self {
187 self.config.runtime_enabled = true;
188 self.config.runtime_backend = koi_runtime::RuntimeBackendKind::Auto;
189 self
190 }
191
192 pub fn orchestrator(mut self, enabled: bool) -> Self {
196 self.config.orchestrator_enabled = enabled;
197 self
198 }
199
200 pub fn certmesh_background(mut self, enabled: bool) -> Self {
205 self.config.certmesh_background_enabled = enabled;
206 self
207 }
208
209 pub fn http_port(mut self, port: u16) -> Self {
214 self.config.http_port = port;
215 self
216 }
217
218 pub fn dashboard(mut self, enabled: bool) -> Self {
219 self.config.dashboard_enabled = enabled;
220 self
221 }
222
223 pub fn api_docs(mut self, enabled: bool) -> Self {
224 self.config.api_docs_enabled = enabled;
225 self
226 }
227
228 pub fn mdns_browser(mut self, enabled: bool) -> Self {
229 self.config.mdns_browser_enabled = enabled;
230 self
231 }
232
233 pub fn announce_http(mut self, enabled: bool) -> Self {
239 self.config.announce_http = enabled;
240 self
241 }
242
243 pub fn http_token(mut self, token: impl Into<String>) -> Self {
248 self.config.http_token = Some(token.into());
249 self
250 }
251
252 pub fn events<F>(mut self, handler: F) -> Self
253 where
254 F: Fn(KoiEvent) + Send + Sync + 'static,
255 {
256 self.event_handler = Some(Arc::new(handler));
257 self
258 }
259
260 pub fn extra_firewall_ports(mut self, ports: Vec<koi_common::firewall::FirewallPort>) -> Self {
265 self.extra_firewall_ports = ports;
266 self
267 }
268
269 pub fn ensure_firewall_rules(self, prefix: &str) -> Self {
280 let mut all_ports = self.config.firewall_ports();
281 all_ports.extend(self.extra_firewall_ports.iter().cloned());
282
283 let count = koi_common::firewall::ensure_firewall_rules(prefix, &all_ports);
284 if count > 0 {
285 tracing::info!(count, "Firewall rules ensured");
286 }
287 self
288 }
289
290 pub fn build(self) -> Result<KoiEmbedded> {
291 Ok(KoiEmbedded {
292 config: self.config,
293 event_handler: self.event_handler,
294 })
295 }
296}
297
298impl Default for Builder {
299 fn default() -> Self {
300 Self::new()
301 }
302}
303
304pub struct KoiEmbedded {
305 config: KoiConfig,
306 event_handler: Option<Arc<dyn Fn(KoiEvent) + Send + Sync>>,
307}
308
309impl KoiEmbedded {
310 pub async fn start(self) -> Result<KoiHandle> {
311 let cancel = CancellationToken::new();
312 let (event_tx, _) = broadcast::channel(256);
313 let mut tasks: Vec<JoinHandle<()>> = Vec::new();
314
315 if self.config.service_mode != ServiceMode::EmbeddedOnly {
316 let client = Arc::new(KoiClient::new(&self.config.service_endpoint));
317 match self.config.service_mode {
318 ServiceMode::ClientOnly => {
319 tokio::task::spawn_blocking({
320 let client = Arc::clone(&client);
321 move || client.health()
322 })
323 .await
324 .map_err(map_join_error)??;
325 return Ok(KoiHandle::new_remote(client, event_tx, cancel, tasks));
326 }
327 ServiceMode::Auto => {
328 let health = tokio::task::spawn_blocking({
329 let client = Arc::clone(&client);
330 move || client.health()
331 })
332 .await;
333 if matches!(health, Ok(Ok(()))) {
334 return Ok(KoiHandle::new_remote(client, event_tx, cancel, tasks));
335 }
336 }
337 ServiceMode::EmbeddedOnly => {}
338 }
339 }
340
341 if self.config.http_enabled && self.config.announce_http && self.config.http_token.is_none()
347 {
348 return Err(KoiError::InsecureConfig(
349 "announce_http exposes the embedded HTTP adapter on 0.0.0.0; call \
350 .http_token(..) to require x-koi-token, or drop announce_http to bind loopback"
351 .into(),
352 ));
353 }
354
355 let cores = koi_compose::cores::build_cores(
361 &koi_compose::cores::CoreSpec {
362 no_mdns: !self.config.mdns_enabled,
363 no_certmesh: !self.config.certmesh_enabled,
364 no_dns: !self.config.dns_enabled,
365 no_health: !self.config.health_enabled,
366 no_proxy: !self.config.proxy_enabled,
367 no_udp: !self.config.udp_enabled,
368 no_runtime: !self.config.runtime_enabled,
369 data_dir: self.config.data_dir.clone(),
370 dns_config: self.config.dns_config.clone(),
371 runtime: self.config.runtime_backend.to_string(),
372 http_port: self.config.http_port,
373 dns_state_path: self
376 .config
377 .data_dir
378 .as_ref()
379 .map(|dir| dir.join("state").join("dns.json")),
380 proxy_data_dir: self.config.data_dir.clone(),
381 dns_auto_start: self.config.dns_auto_start,
382 health_auto_start: self.config.health_auto_start,
383 proxy_auto_start: self.config.proxy_auto_start,
384 spawn_orchestrator: self.config.orchestrator_enabled,
385 spawn_certmesh_loops: self.config.certmesh_background_enabled,
386 fail_fast: true,
387 },
388 &cancel,
389 &mut tasks,
390 )
391 .await?;
392 let koi_compose::cores::Cores {
393 mdns,
394 certmesh,
395 dns,
396 health,
397 proxy,
398 udp,
399 runtime,
400 mdns_snapshot: mdns_bridge,
401 } = cores;
402
403 let dashboard_state = if self.config.dashboard_enabled && self.config.http_enabled {
405 let started_at = std::time::Instant::now();
406 let snap_mdns = mdns.clone();
407 let snap_certmesh = certmesh.clone();
408 let snap_dns = dns.clone();
409 let snap_health = health.clone();
410 let snap_proxy = proxy.clone();
411 let snap_udp = udp.clone();
412 let snap_runtime = runtime.clone();
413
414 let snapshot_fn: koi_dashboard::dashboard::SnapshotFn = Arc::new(move || {
415 let m = snap_mdns.clone();
416 let cm = snap_certmesh.clone();
417 let d = snap_dns.clone();
418 let h = snap_health.clone();
419 let p = snap_proxy.clone();
420 let u = snap_udp.clone();
421 let rt = snap_runtime.clone();
422 Box::pin(async move { build_embedded_snapshot(m, cm, d, h, p, u, rt).await })
423 });
424
425 let (dash_event_tx, _) = broadcast::channel(256);
426 let ds = koi_dashboard::dashboard::DashboardState {
427 identity: koi_dashboard::dashboard::DashboardIdentity {
428 version: env!("CARGO_PKG_VERSION").to_string(),
429 platform: std::env::consts::OS.to_string(),
430 },
431 mode: "embedded",
432 snapshot_fn,
433 event_tx: dash_event_tx.clone(),
434 started_at,
435 };
436
437 tasks.push(koi_dashboard::forward::spawn_event_forwarder(
440 koi_dashboard::forward::ForwarderCores {
441 mdns: mdns.clone(),
442 certmesh: certmesh.clone(),
443 dns: dns.clone(),
444 health: health.clone(),
445 proxy: proxy.clone(),
446 runtime: runtime.clone(),
447 },
448 dash_event_tx,
449 cancel.clone(),
450 ));
451
452 Some(ds)
453 } else {
454 None
455 };
456
457 let browser_state = if self.config.mdns_browser_enabled && self.config.http_enabled {
460 if let Some(ref mdns_core) = mdns {
461 Some(koi_dashboard::browser::build_state(
462 mdns_core.clone(),
463 cancel.clone(),
464 ))
465 } else {
466 tracing::warn!("mdns_browser enabled but mDNS is disabled — skipping browser");
467 None
468 }
469 } else {
470 None
471 };
472
473 let mut http_addr: Option<std::net::SocketAddr> = None;
483 if self.config.http_enabled {
484 let http_cancel = cancel.clone();
485 let http_cores = koi_compose::cores::Cores {
486 mdns: mdns.clone(),
487 certmesh: certmesh.clone(),
488 dns: dns.clone(),
489 health: health.clone(),
490 proxy: proxy.clone(),
491 udp: udp.clone(),
492 runtime: runtime.clone(),
493 mdns_snapshot: mdns_bridge.clone(),
494 };
495 let exposed = self.config.announce_http;
498 let bind_ip = if exposed {
499 std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
500 } else {
501 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
502 };
503 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
504 let http_cfg = koi_serve::http::HttpConfig {
505 bind_ip,
506 port: self.config.http_port,
507 started_at: std::time::Instant::now(),
508 dashboard: dashboard_state,
509 browser: browser_state,
510 auth: self.config.http_token.clone(),
511 mdns_snapshot: mdns_bridge.clone(),
512 mcp_http: false,
513 admin_shutdown: false,
514 api_docs: self.config.api_docs_enabled,
515 daemon: false,
516 ready: Some(ready_tx),
517 };
518 tasks.push(tokio::spawn(async move {
519 if let Err(e) = koi_serve::http::start(http_cores, http_cfg, http_cancel).await {
520 tracing::error!(error = %e, "embedded HTTP adapter failed");
521 }
522 }));
523 http_addr = ready_rx.await.ok();
527 }
528
529 let announce_cores = koi_compose::cores::Cores {
535 mdns: mdns.clone(),
536 certmesh: certmesh.clone(),
537 dns: dns.clone(),
538 health: health.clone(),
539 proxy: proxy.clone(),
540 udp: udp.clone(),
541 runtime: runtime.clone(),
542 mdns_snapshot: mdns_bridge.clone(),
543 };
544 let announce_http_port = http_addr.map(|a| a.port()).unwrap_or(self.config.http_port);
550 koi_compose::self_announce::spawn(
551 &announce_cores,
552 koi_compose::self_announce::SelfAnnounceConfig {
553 http_port: announce_http_port,
554 dashboard_enabled: self.config.dashboard_enabled,
555 announce_http: self.config.announce_http
556 && self.config.http_enabled
557 && self.config.mdns_enabled,
558 announce_mcp: false,
559 dns_zone: self.config.dns_config.zone.clone(),
560 },
561 cancel.clone(),
562 &mut tasks,
563 );
564
565 if let Some(core) = &mdns {
570 spawn_event_mapper(
571 core.subscribe(),
572 map_mdns_event,
573 event_tx.clone(),
574 self.event_handler.clone(),
575 cancel.clone(),
576 &mut tasks,
577 );
578 }
579 if let Some(runtime) = &health {
580 spawn_event_mapper(
581 runtime.core().subscribe(),
582 |e| Some(map_health_event(e)),
583 event_tx.clone(),
584 self.event_handler.clone(),
585 cancel.clone(),
586 &mut tasks,
587 );
588 }
589 if let Some(runtime) = &dns {
590 spawn_event_mapper(
591 runtime.core().subscribe(),
592 |e| Some(map_dns_event(e)),
593 event_tx.clone(),
594 self.event_handler.clone(),
595 cancel.clone(),
596 &mut tasks,
597 );
598 }
599 if let Some(core) = &certmesh {
600 spawn_event_mapper(
601 core.subscribe(),
602 |e| Some(map_certmesh_event(e)),
603 event_tx.clone(),
604 self.event_handler.clone(),
605 cancel.clone(),
606 &mut tasks,
607 );
608 spawn_posture_watcher(
612 core.watch_posture(),
613 event_tx.clone(),
614 self.event_handler.clone(),
615 cancel.clone(),
616 &mut tasks,
617 );
618 }
619 if let Some(runtime_proxy) = &proxy {
620 spawn_event_mapper(
621 runtime_proxy.core().subscribe(),
622 |e| Some(map_proxy_event(e)),
623 event_tx.clone(),
624 self.event_handler.clone(),
625 cancel.clone(),
626 &mut tasks,
627 );
628 }
629 if let Some(runtime_core) = &runtime {
630 spawn_event_mapper(
631 runtime_core.subscribe(),
632 map_runtime_event,
633 event_tx.clone(),
634 self.event_handler.clone(),
635 cancel.clone(),
636 &mut tasks,
637 );
638 }
639
640 if self.config.orchestrator_enabled && runtime.is_none() {
645 tracing::warn!(
646 "orchestrator enabled but the runtime adapter is not — skipping orchestrator"
647 );
648 }
649
650 if self.config.certmesh_background_enabled {
655 if let Some(ref certmesh_core) = certmesh {
656 koi_compose::certmesh::spawn_enrollment_approval(
657 certmesh_core,
658 koi_compose::certmesh::deny_and_log_decider(),
659 &cancel,
660 &mut tasks,
661 )
662 .await;
663 } else {
664 tracing::warn!(
665 "certmesh_background enabled but certmesh is not — skipping certmesh loops"
666 );
667 }
668 }
669
670 Ok(KoiHandle::new_embedded(
671 mdns,
672 dns,
673 health,
674 certmesh,
675 proxy,
676 udp,
677 runtime,
678 http_addr,
679 self.config.data_dir.clone(),
680 event_tx,
681 cancel,
682 tasks,
683 ))
684 }
685}
686
687fn map_mdns_event(event: MdnsEvent) -> Option<KoiEvent> {
688 match event {
689 MdnsEvent::Found(record) => Some(KoiEvent::MdnsFound(record)),
690 MdnsEvent::Resolved(record) => Some(KoiEvent::MdnsResolved(record)),
691 MdnsEvent::Removed { name, service_type } => {
692 Some(KoiEvent::MdnsRemoved { name, service_type })
693 }
694 }
695}
696
697fn map_health_event(event: koi_health::HealthEvent) -> KoiEvent {
698 match event {
699 koi_health::HealthEvent::StatusChanged { name, status } => {
700 KoiEvent::HealthChanged { name, status }
701 }
702 }
703}
704
705fn map_dns_event(event: koi_dns::DnsEvent) -> KoiEvent {
706 match event {
707 koi_dns::DnsEvent::EntryUpdated { name, ip } => KoiEvent::DnsEntryUpdated { name, ip },
708 koi_dns::DnsEvent::EntryRemoved { name } => KoiEvent::DnsEntryRemoved { name },
709 }
710}
711
712fn map_certmesh_event(event: koi_certmesh::CertmeshEvent) -> KoiEvent {
713 match event {
714 koi_certmesh::CertmeshEvent::MemberJoined {
715 hostname,
716 fingerprint,
717 } => KoiEvent::CertmeshMemberJoined {
718 hostname,
719 fingerprint,
720 },
721 koi_certmesh::CertmeshEvent::MemberRevoked { hostname } => {
722 KoiEvent::CertmeshMemberRevoked { hostname }
723 }
724 koi_certmesh::CertmeshEvent::Destroyed => KoiEvent::CertmeshDestroyed,
725 }
726}
727
728fn map_proxy_event(event: koi_proxy::ProxyEvent) -> KoiEvent {
729 match event {
730 koi_proxy::ProxyEvent::EntryUpdated { entry } => KoiEvent::ProxyEntryUpdated { entry },
731 koi_proxy::ProxyEvent::EntryRemoved { name } => KoiEvent::ProxyEntryRemoved { name },
732 }
733}
734
735fn map_runtime_event(event: koi_runtime::RuntimeEvent) -> Option<KoiEvent> {
736 match event {
737 koi_runtime::RuntimeEvent::Started(instance) => Some(KoiEvent::RuntimeInstanceStarted {
738 name: instance.name,
739 backend: instance.backend,
740 }),
741 koi_runtime::RuntimeEvent::Stopped { name, .. } => {
742 Some(KoiEvent::RuntimeInstanceStopped { name })
743 }
744 _ => None,
747 }
748}
749
750fn spawn_event_mapper<E, F>(
757 mut rx: broadcast::Receiver<E>,
758 map: F,
759 tx: broadcast::Sender<KoiEvent>,
760 handler: Option<Arc<dyn Fn(KoiEvent) + Send + Sync>>,
761 cancel: CancellationToken,
762 tasks: &mut Vec<JoinHandle<()>>,
763) where
764 E: Clone + Send + 'static,
765 F: Fn(E) -> Option<KoiEvent> + Send + 'static,
766{
767 tasks.push(tokio::spawn(async move {
768 loop {
769 tokio::select! {
770 _ = cancel.cancelled() => break,
771 msg = rx.recv() => {
772 let Ok(event) = msg else { continue; };
773 if let Some(mapped) = map(event) {
774 emit_event(&tx, handler.as_ref(), mapped);
775 }
776 }
777 }
778 }
779 }));
780}
781
782fn spawn_posture_watcher(
788 mut rx: tokio::sync::watch::Receiver<koi_common::posture::Posture>,
789 tx: broadcast::Sender<KoiEvent>,
790 handler: Option<Arc<dyn Fn(KoiEvent) + Send + Sync>>,
791 cancel: CancellationToken,
792 tasks: &mut Vec<JoinHandle<()>>,
793) {
794 tasks.push(tokio::spawn(async move {
795 let mut last = *rx.borrow_and_update();
796 loop {
797 tokio::select! {
798 _ = cancel.cancelled() => break,
799 res = rx.changed() => {
800 if res.is_err() {
801 break; }
803 let to = *rx.borrow_and_update();
804 if to != last {
805 emit_event(&tx, handler.as_ref(), KoiEvent::PostureChanged { from: last, to });
806 last = to;
807 }
808 }
809 }
810 }
811 }));
812}
813
814fn emit_event(
815 tx: &broadcast::Sender<KoiEvent>,
816 handler: Option<&Arc<dyn Fn(KoiEvent) + Send + Sync>>,
817 event: KoiEvent,
818) {
819 if let Some(handler) = handler {
820 handler(event.clone());
821 }
822 let _ = tx.send(event);
823}
824
825pub(crate) fn map_join_error(err: tokio::task::JoinError) -> KoiError {
826 KoiError::Io(std::io::Error::other(err.to_string()))
827}
828
829async fn build_embedded_snapshot(
835 mdns: Option<Arc<koi_mdns::MdnsCore>>,
836 certmesh: Option<Arc<koi_certmesh::CertmeshCore>>,
837 dns: Option<Arc<koi_dns::DnsRuntime>>,
838 health: Option<Arc<koi_health::HealthRuntime>>,
839 proxy: Option<Arc<koi_proxy::ProxyRuntime>>,
840 udp: Option<Arc<koi_udp::UdpRuntime>>,
841 runtime: Option<Arc<koi_runtime::RuntimeCore>>,
842) -> serde_json::Value {
843 let cores = koi_compose::cores::Cores {
844 mdns,
845 certmesh,
846 dns,
847 health,
848 proxy,
849 udp,
850 runtime,
851 mdns_snapshot: None,
852 };
853 koi_compose::snapshot::build_dashboard_snapshot(&cores).await
854}
855
856#[cfg(test)]
857mod tests {
858 use super::*;
859 use koi_common::types::ServiceRecord;
860 use std::collections::HashMap;
861
862 fn sample_record() -> ServiceRecord {
863 ServiceRecord {
864 name: "Test Service".to_string(),
865 service_type: "_http._tcp".to_string(),
866 host: Some("host.local".to_string()),
867 ip: Some("10.0.0.1".to_string()),
868 port: Some(8080),
869 txt: HashMap::new(),
870 }
871 }
872
873 #[test]
876 fn koi_error_disabled_capability_display() {
877 let err = KoiError::DisabledCapability("mdns");
878 assert_eq!(err.to_string(), "capability disabled: mdns");
879 }
880
881 #[test]
882 fn koi_error_io_from_impl() {
883 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
884 let err: KoiError = io_err.into();
885 assert!(matches!(err, KoiError::Io(_)));
886 assert!(err.to_string().contains("file missing"));
887 }
888
889 #[test]
890 fn koi_error_debug_does_not_panic() {
891 let err = KoiError::DisabledCapability("proxy");
892 let debug = format!("{err:?}");
893 assert!(debug.contains("DisabledCapability"));
894 }
895
896 #[tokio::test]
899 async fn init_certmesh_core_honors_custom_data_dir_end_to_end() {
900 let base = koi_common::test::ensure_data_dir("koi-embedded-datadir-tests");
904 let data_dir = base.join("custom-data");
905 let paths = koi_certmesh::CertmeshPaths::with_data_dir(data_dir.clone());
906
907 let fresh =
911 koi_compose::cores::init_certmesh_core(Some(&data_dir)).expect("uninitialized core");
912 assert_eq!(
913 fresh.paths().data_dir(),
914 data_dir.as_path(),
915 "uninitialized core must keep the injected data_dir"
916 );
917
918 koi_certmesh::ca::create_ca("test-pass-strong", &[7u8; 32], &paths)
920 .expect("create CA under injected dir");
921 let roster = koi_certmesh::roster::Roster::new(false, true, Some("ops".to_string()));
923 koi_certmesh::roster::save_roster(&roster, &paths.roster_path())
924 .expect("save roster under injected dir");
925
926 let reopened =
929 koi_compose::cores::init_certmesh_core(Some(&data_dir)).expect("locked core");
930 assert_eq!(reopened.paths().data_dir(), data_dir.as_path());
931 reopened
932 .unlock("test-pass-strong")
933 .await
934 .expect("unlock CA from the injected data_dir");
935 }
936
937 #[test]
940 fn map_mdns_found() {
941 let record = sample_record();
942 let event = koi_mdns::MdnsEvent::Found(record.clone());
943 let mapped = map_mdns_event(event);
944 assert!(mapped.is_some());
945 match mapped.unwrap() {
946 KoiEvent::MdnsFound(r) => assert_eq!(r.name, "Test Service"),
947 other => panic!("expected MdnsFound, got {other:?}"),
948 }
949 }
950
951 #[test]
952 fn map_mdns_resolved() {
953 let record = sample_record();
954 let event = koi_mdns::MdnsEvent::Resolved(record);
955 let mapped = map_mdns_event(event);
956 assert!(mapped.is_some());
957 match mapped.unwrap() {
958 KoiEvent::MdnsResolved(r) => {
959 assert_eq!(r.port, Some(8080));
960 assert_eq!(r.service_type, "_http._tcp");
961 }
962 other => panic!("expected MdnsResolved, got {other:?}"),
963 }
964 }
965
966 #[test]
967 fn map_mdns_removed() {
968 let event = koi_mdns::MdnsEvent::Removed {
969 name: "Gone Service".to_string(),
970 service_type: "_http._tcp".to_string(),
971 };
972 let mapped = map_mdns_event(event);
973 assert!(mapped.is_some());
974 match mapped.unwrap() {
975 KoiEvent::MdnsRemoved { name, service_type } => {
976 assert_eq!(name, "Gone Service");
977 assert_eq!(service_type, "_http._tcp");
978 }
979 other => panic!("expected MdnsRemoved, got {other:?}"),
980 }
981 }
982
983 #[test]
986 fn map_health_status_changed_up() {
987 let event = koi_health::HealthEvent::StatusChanged {
988 name: "api".to_string(),
989 status: koi_health::HealthStatus::Up,
990 };
991 let mapped = map_health_event(event);
992 match mapped {
993 KoiEvent::HealthChanged { name, status } => {
994 assert_eq!(name, "api");
995 assert!(matches!(status, koi_health::HealthStatus::Up));
996 }
997 other => panic!("expected HealthChanged, got {other:?}"),
998 }
999 }
1000
1001 #[test]
1002 fn map_health_status_changed_down() {
1003 let event = koi_health::HealthEvent::StatusChanged {
1004 name: "db".to_string(),
1005 status: koi_health::HealthStatus::Down,
1006 };
1007 let mapped = map_health_event(event);
1008 match mapped {
1009 KoiEvent::HealthChanged { name, status } => {
1010 assert_eq!(name, "db");
1011 assert!(matches!(status, koi_health::HealthStatus::Down));
1012 }
1013 other => panic!("expected HealthChanged, got {other:?}"),
1014 }
1015 }
1016
1017 #[test]
1020 fn map_dns_entry_updated() {
1021 let event = koi_dns::DnsEvent::EntryUpdated {
1022 name: "grafana".to_string(),
1023 ip: "10.0.0.5".to_string(),
1024 };
1025 let mapped = map_dns_event(event);
1026 match mapped {
1027 KoiEvent::DnsEntryUpdated { name, ip } => {
1028 assert_eq!(name, "grafana");
1029 assert_eq!(ip, "10.0.0.5");
1030 }
1031 other => panic!("expected DnsEntryUpdated, got {other:?}"),
1032 }
1033 }
1034
1035 #[test]
1036 fn map_dns_entry_removed() {
1037 let event = koi_dns::DnsEvent::EntryRemoved {
1038 name: "old-host".to_string(),
1039 };
1040 let mapped = map_dns_event(event);
1041 match mapped {
1042 KoiEvent::DnsEntryRemoved { name } => {
1043 assert_eq!(name, "old-host");
1044 }
1045 other => panic!("expected DnsEntryRemoved, got {other:?}"),
1046 }
1047 }
1048
1049 #[test]
1052 fn map_certmesh_member_joined() {
1053 let event = koi_certmesh::CertmeshEvent::MemberJoined {
1054 hostname: "node-a".to_string(),
1055 fingerprint: "sha256:abc".to_string(),
1056 };
1057 let mapped = map_certmesh_event(event);
1058 match mapped {
1059 KoiEvent::CertmeshMemberJoined {
1060 hostname,
1061 fingerprint,
1062 } => {
1063 assert_eq!(hostname, "node-a");
1064 assert_eq!(fingerprint, "sha256:abc");
1065 }
1066 other => panic!("expected CertmeshMemberJoined, got {other:?}"),
1067 }
1068 }
1069
1070 #[test]
1071 fn map_certmesh_member_revoked() {
1072 let event = koi_certmesh::CertmeshEvent::MemberRevoked {
1073 hostname: "node-b".to_string(),
1074 };
1075 let mapped = map_certmesh_event(event);
1076 match mapped {
1077 KoiEvent::CertmeshMemberRevoked { hostname } => {
1078 assert_eq!(hostname, "node-b");
1079 }
1080 other => panic!("expected CertmeshMemberRevoked, got {other:?}"),
1081 }
1082 }
1083
1084 #[test]
1085 fn map_certmesh_destroyed() {
1086 let event = koi_certmesh::CertmeshEvent::Destroyed;
1087 let mapped = map_certmesh_event(event);
1088 assert!(matches!(mapped, KoiEvent::CertmeshDestroyed));
1089 }
1090
1091 #[tokio::test]
1092 async fn posture_watcher_emits_upgrade_and_degrade() {
1093 use koi_common::posture::Posture;
1094 let (tx_p, rx_p) = tokio::sync::watch::channel(Posture::OPEN);
1095 let (ev_tx, mut ev_rx) = broadcast::channel(16);
1096 let cancel = CancellationToken::new();
1097 let mut tasks = Vec::new();
1098 spawn_posture_watcher(rx_p, ev_tx, None, cancel.clone(), &mut tasks);
1099 tokio::task::yield_now().await;
1103
1104 tx_p.send(Posture::new(true, false)).unwrap();
1106 let ev = tokio::time::timeout(std::time::Duration::from_secs(1), ev_rx.recv())
1107 .await
1108 .expect("event arrives")
1109 .expect("recv ok");
1110 assert!(
1111 matches!(ev, KoiEvent::PostureChanged { from, to } if !from.signed && to.signed),
1112 "expected upgrade, got {ev:?}"
1113 );
1114
1115 tx_p.send(Posture::OPEN).unwrap();
1117 let ev = tokio::time::timeout(std::time::Duration::from_secs(1), ev_rx.recv())
1118 .await
1119 .expect("event arrives")
1120 .expect("recv ok");
1121 assert!(
1122 matches!(ev, KoiEvent::PostureChanged { from, to } if from.signed && !to.signed),
1123 "expected degrade, got {ev:?}"
1124 );
1125
1126 cancel.cancel();
1127 for t in tasks {
1128 let _ = t.await;
1129 }
1130 }
1131
1132 #[test]
1135 fn map_proxy_entry_updated() {
1136 let entry = koi_proxy::ProxyEntry {
1137 name: "web".to_string(),
1138 listen_port: 443,
1139 backend: "http://localhost:3000".to_string(),
1140 allow_remote: true,
1141 };
1142 let event = koi_proxy::ProxyEvent::EntryUpdated {
1143 entry: entry.clone(),
1144 };
1145 let mapped = map_proxy_event(event);
1146 match mapped {
1147 KoiEvent::ProxyEntryUpdated { entry } => {
1148 assert_eq!(entry.name, "web");
1149 assert_eq!(entry.listen_port, 443);
1150 assert!(entry.allow_remote);
1151 }
1152 other => panic!("expected ProxyEntryUpdated, got {other:?}"),
1153 }
1154 }
1155
1156 #[test]
1157 fn map_proxy_entry_removed() {
1158 let event = koi_proxy::ProxyEvent::EntryRemoved {
1159 name: "old-proxy".to_string(),
1160 };
1161 let mapped = map_proxy_event(event);
1162 match mapped {
1163 KoiEvent::ProxyEntryRemoved { name } => {
1164 assert_eq!(name, "old-proxy");
1165 }
1166 other => panic!("expected ProxyEntryRemoved, got {other:?}"),
1167 }
1168 }
1169
1170 #[test]
1173 fn map_join_error_produces_io_error() {
1174 let io_err = std::io::Error::other("simulated join error");
1177 let koi_err = KoiError::Io(io_err);
1178 assert!(koi_err.to_string().contains("simulated join error"));
1179 }
1180
1181 #[test]
1184 fn builder_default_config() {
1185 let builder = Builder::new();
1186 let embedded = builder.build().expect("build should succeed");
1187 assert!(embedded.config.mdns_enabled);
1188 assert!(!embedded.config.http_enabled);
1189 assert_eq!(embedded.config.http_port, 5641);
1190 }
1191
1192 #[test]
1193 fn builder_default_trait() {
1194 let builder = Builder::default();
1195 let embedded = builder.build().expect("build should succeed");
1196 assert_eq!(embedded.config.service_endpoint, "http://127.0.0.1:5641");
1197 }
1198
1199 #[test]
1200 fn builder_fluent_overrides() {
1201 let embedded = Builder::new()
1202 .http(true)
1203 .mdns(false)
1204 .dns_enabled(false)
1205 .health(true)
1206 .certmesh(true)
1207 .proxy(true)
1208 .udp(true)
1209 .http_port(9000)
1210 .dashboard(true)
1211 .api_docs(true)
1212 .mdns_browser(true)
1213 .announce_http(true)
1214 .dns_auto_start(true)
1215 .health_auto_start(true)
1216 .proxy_auto_start(true)
1217 .service_endpoint("http://10.0.0.1:8080")
1218 .service_mode(ServiceMode::EmbeddedOnly)
1219 .data_dir("/tmp/koi-test")
1220 .build()
1221 .expect("build should succeed");
1222
1223 assert!(embedded.config.http_enabled);
1224 assert!(!embedded.config.mdns_enabled);
1225 assert!(!embedded.config.dns_enabled);
1226 assert!(embedded.config.health_enabled);
1227 assert!(embedded.config.certmesh_enabled);
1228 assert!(embedded.config.proxy_enabled);
1229 assert!(embedded.config.udp_enabled);
1230 assert_eq!(embedded.config.http_port, 9000);
1231 assert!(embedded.config.dashboard_enabled);
1232 assert!(embedded.config.api_docs_enabled);
1233 assert!(embedded.config.mdns_browser_enabled);
1234 assert!(embedded.config.announce_http);
1235 assert!(embedded.config.dns_auto_start);
1236 assert!(embedded.config.health_auto_start);
1237 assert!(embedded.config.proxy_auto_start);
1238 assert_eq!(embedded.config.service_endpoint, "http://10.0.0.1:8080");
1239 assert_eq!(embedded.config.service_mode, ServiceMode::EmbeddedOnly);
1240 assert_eq!(
1241 embedded.config.data_dir,
1242 Some(std::path::PathBuf::from("/tmp/koi-test"))
1243 );
1244 }
1245
1246 #[test]
1247 fn orchestrator_and_certmesh_background_are_opt_in() {
1248 let default_cfg = Builder::new().build().expect("build should succeed");
1250 assert!(!default_cfg.config.orchestrator_enabled);
1251 assert!(!default_cfg.config.certmesh_background_enabled);
1252
1253 let opted = Builder::new()
1255 .runtime_auto()
1256 .orchestrator(true)
1257 .certmesh(true)
1258 .certmesh_background(true)
1259 .build()
1260 .expect("build should succeed");
1261 assert!(opted.config.orchestrator_enabled);
1262 assert!(opted.config.certmesh_background_enabled);
1263 }
1264
1265 #[test]
1266 fn builder_dns_configure_closure() {
1267 let embedded = Builder::new()
1268 .dns(|b| b.port(5353).zone("home").local_ttl(120))
1269 .build()
1270 .expect("build should succeed");
1271
1272 assert_eq!(embedded.config.dns_config.port, 5353);
1273 assert_eq!(embedded.config.dns_config.zone, "home");
1274 assert_eq!(embedded.config.dns_config.local_ttl, 120);
1275 }
1276
1277 #[test]
1278 fn builder_event_handler() {
1279 use std::sync::atomic::{AtomicBool, Ordering};
1280 let called = Arc::new(AtomicBool::new(false));
1281 let called_clone = called.clone();
1282
1283 let embedded = Builder::new()
1284 .events(move |_event| {
1285 called_clone.store(true, Ordering::SeqCst);
1286 })
1287 .build()
1288 .expect("build should succeed");
1289
1290 assert!(embedded.event_handler.is_some());
1291 }
1292
1293 #[test]
1294 fn builder_extra_firewall_ports() {
1295 use koi_common::firewall::{FirewallPort, FirewallProtocol};
1296 let extra = vec![FirewallPort::new("Custom", FirewallProtocol::Tcp, 12345)];
1297 let _builder = Builder::new().extra_firewall_ports(extra);
1298 }
1300
1301 #[test]
1304 fn result_type_works_with_ok() {
1305 let result: Result<i32> = Ok(42);
1306 assert!(matches!(result, Ok(42)));
1307 }
1308
1309 #[test]
1310 fn result_type_works_with_err() {
1311 let result: Result<i32> = Err(KoiError::DisabledCapability("test"));
1312 assert!(result.is_err());
1313 }
1314}