use clap::CommandFactory;
use tokio_util::sync::CancellationToken;
use crate::cli::{Cli, Config};
use crate::{platform, surface};
pub(crate) fn is_piped_stdin() -> bool {
use std::io::IsTerminal;
!std::io::stdin().is_terminal()
}
pub(crate) fn print_top_level_help(api_endpoint: &str) {
if let Err(err) = surface::print_catalog(api_endpoint) {
tracing::debug!(error = %err, "Failed to render catalog, falling back to clap help");
let mut cmd = Cli::command();
let _ = cmd.print_help();
println!();
}
}
pub(crate) fn extract_help_query(raw_args: &[String]) -> Option<String> {
if raw_args.is_empty() {
return None;
}
if let Some(last) = raw_args.last() {
if last.ends_with('?') && last.len() > 1 {
let mut parts: Vec<&str> = raw_args[..raw_args.len() - 1]
.iter()
.map(|s| s.as_str())
.collect();
let trimmed = last.trim_end_matches('?');
if !trimmed.is_empty() {
parts.push(trimmed);
}
let parts: Vec<&str> = parts.into_iter().filter(|p| !p.starts_with('-')).collect();
if !parts.is_empty() {
return Some(parts.join(" "));
}
}
}
if let Some(first) = raw_args.first() {
if first.starts_with('?') && first.len() > 1 {
let cmd_name = first.trim_start_matches('?');
let mut parts = vec![cmd_name];
for arg in &raw_args[1..] {
if !arg.starts_with('-') {
parts.push(arg);
}
}
return Some(parts.join(" "));
}
}
None
}
pub(crate) async fn shutdown_signal(cancel: CancellationToken) {
tokio::select! {
result = tokio::signal::ctrl_c() => {
if let Err(e) = result {
tracing::error!(error = %e, "Failed to listen for Ctrl+C");
}
}
_ = cancel.cancelled() => {
}
}
}
pub(crate) fn startup_diagnostics(config: &Config, http_bind_ip: Option<std::net::IpAddr>) {
tracing::info!("Koi v{} starting", env!("CARGO_PKG_VERSION"));
tracing::info!("Platform: {}", std::env::consts::OS);
match hostname::get() {
Ok(h) => tracing::info!("Hostname: {}", h.to_string_lossy()),
Err(e) => tracing::warn!(error = %e, "Could not determine hostname"),
}
if config.no_mdns {
tracing::info!("mDNS capability: disabled");
} else {
tracing::info!("mDNS engine: mdns-sd");
}
if config.no_certmesh {
tracing::info!("Certmesh capability: disabled");
}
if config.no_dns {
tracing::info!("DNS capability: disabled");
} else {
tracing::info!(
"DNS: {}:{} (zone {})",
"0.0.0.0",
config.dns_port,
config.dns_zone
);
}
if config.no_health {
tracing::info!("Health capability: disabled");
} else {
tracing::info!("Health: service checks enabled");
}
if config.no_proxy {
tracing::info!("Proxy capability: disabled");
}
if let Some(bind_ip) = http_bind_ip {
log_http_bind(config, bind_ip);
} else {
tracing::info!("HTTP adapter: disabled");
}
if !config.no_ipc {
tracing::info!("IPC: {}", config.pipe_path.display());
} else {
tracing::info!("IPC adapter: disabled");
}
#[cfg(windows)]
platform::windows::check_firewall(config);
}
fn log_http_bind(config: &Config, bind_ip: std::net::IpAddr) {
let port = config.http_port;
if bind_ip.is_loopback() {
tracing::info!("HTTP: {bind_ip}:{port} (loopback only — use --http-bind to expose)");
return;
}
if bind_ip.is_unspecified() {
tracing::warn!(
"WARNING: Koi is reachable from your entire LAN. Mutations still require the \
daemon token; GET endpoints are readable by any device. (--http-bind 0.0.0.0)"
);
tracing::info!("HTTP: {bind_ip}:{port} (exposed) — mutations require x-koi-token");
} else if config.http_bind == "bridge" {
tracing::info!("HTTP: {bind_ip}:{port} (docker bridge) — mutations require x-koi-token");
} else {
tracing::warn!(
"WARNING: Koi is reachable on interface {bind_ip}. Mutations still require the \
daemon token; GET endpoints are readable by any device. (--http-bind {})",
config.http_bind
);
tracing::info!("HTTP: {bind_ip}:{port} (exposed) — mutations require x-koi-token");
}
tracing::info!("hint: containers read the token from a mounted secret; see `koi token --help`");
}
pub(crate) fn breadcrumb_endpoint(http_bind_ip: Option<std::net::IpAddr>, port: u16) -> String {
match http_bind_ip {
Some(ip) if !ip.is_unspecified() => format!("http://{ip}:{port}"),
_ => format!("http://127.0.0.1:{port}"),
}
}
pub(crate) fn resolve_http_bind_ip(mode: &str) -> anyhow::Result<std::net::IpAddr> {
use std::net::{IpAddr, Ipv4Addr};
match mode {
"loopback" => Ok(IpAddr::V4(Ipv4Addr::LOCALHOST)),
"0.0.0.0" => Ok(IpAddr::V4(Ipv4Addr::UNSPECIFIED)),
"bridge" => resolve_bridge_ip(),
other => other.parse::<IpAddr>().map_err(|_| {
anyhow::anyhow!(
"invalid --http-bind value '{other}': expected loopback, bridge, \
an IP address, or 0.0.0.0"
)
}),
}
}
fn resolve_bridge_ip() -> anyhow::Result<std::net::IpAddr> {
use std::net::IpAddr;
let ifaces = if_addrs::get_if_addrs()
.map_err(|e| anyhow::anyhow!("could not enumerate network interfaces: {e}"))?;
let is_v4 = |iface: &if_addrs::Interface| matches!(iface.addr.ip(), IpAddr::V4(_));
for name in ["docker0", "podman0", "cni-podman0"] {
if let Some(iface) = ifaces.iter().find(|i| i.name == name && is_v4(i)) {
return Ok(iface.addr.ip());
}
}
for iface in &ifaces {
if iface.is_loopback() || !is_v4(iface) {
continue;
}
let n = &iface.name;
if n.starts_with("docker")
|| n.starts_with("podman")
|| n.starts_with("br-")
|| n.starts_with("cni-")
{
return Ok(iface.addr.ip());
}
}
anyhow::bail!(
"no docker/podman bridge interface found (looked for docker0, podman0, br-*, …). \
Use --http-bind <ip> with the host IP that containers should reach."
)
}
#[cfg(test)]
mod http_bind_tests {
use super::{breadcrumb_endpoint, resolve_http_bind_ip};
use std::net::{IpAddr, Ipv4Addr};
#[test]
fn loopback_mode_resolves_to_localhost() {
assert_eq!(
resolve_http_bind_ip("loopback").unwrap(),
IpAddr::V4(Ipv4Addr::LOCALHOST)
);
}
#[test]
fn unspecified_mode_resolves_to_all_interfaces() {
assert_eq!(
resolve_http_bind_ip("0.0.0.0").unwrap(),
IpAddr::V4(Ipv4Addr::UNSPECIFIED)
);
}
#[test]
fn explicit_ipv4_is_parsed() {
assert_eq!(
resolve_http_bind_ip("192.168.1.42").unwrap(),
"192.168.1.42".parse::<IpAddr>().unwrap()
);
}
#[test]
fn explicit_ipv6_is_parsed() {
assert_eq!(
resolve_http_bind_ip("::1").unwrap(),
"::1".parse::<IpAddr>().unwrap()
);
}
#[test]
fn garbage_is_rejected() {
assert!(resolve_http_bind_ip("not-an-ip").is_err());
assert!(resolve_http_bind_ip("999.999.999.999").is_err());
}
#[test]
fn breadcrumb_advertises_loopback_for_unspecified() {
assert_eq!(
breadcrumb_endpoint(Some(IpAddr::V4(Ipv4Addr::UNSPECIFIED)), 5641),
"http://127.0.0.1:5641"
);
}
#[test]
fn breadcrumb_uses_specific_bind_ip() {
let ip: IpAddr = "172.17.0.1".parse().unwrap();
assert_eq!(
breadcrumb_endpoint(Some(ip), 5641),
"http://172.17.0.1:5641"
);
}
}
pub(crate) fn init_logging(
env_filter: tracing_subscriber::EnvFilter,
log_file: Option<&std::path::Path>,
) -> anyhow::Result<Vec<tracing_appender::non_blocking::WorkerGuard>> {
use tracing_subscriber::prelude::*;
let (nb_stderr, stderr_guard) = tracing_appender::non_blocking(std::io::stderr());
let stderr_layer = tracing_subscriber::fmt::layer().with_writer(nb_stderr);
if let Some(path) = log_file {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
let (nb_file, file_guard) = tracing_appender::non_blocking(file);
let file_layer = tracing_subscriber::fmt::layer().with_writer(nb_file);
tracing_subscriber::registry()
.with(env_filter)
.with(stderr_layer)
.with(file_layer)
.init();
Ok(vec![stderr_guard, file_guard])
} else {
tracing_subscriber::registry()
.with(env_filter)
.with(stderr_layer)
.init();
Ok(vec![stderr_guard])
}
}