use crate::daemon::frame;
use crate::daemon::protocol::{response_kind, Request, Response, WIRE_VERSION};
use crate::daemon::trust;
use anyhow::{bail, Context, Result};
use futures_util::{SinkExt, StreamExt};
use std::path::Path;
use std::time::Duration;
use tokio::net::UnixStream;
const CLIENT_KEYHOG_VERSION: &str = env!("CARGO_PKG_VERSION");
const DAEMON_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(2);
const DAEMON_REQUEST_TIMEOUT: Duration = Duration::from_secs(300);
const DAEMON_HEALTH_TIMEOUT: Duration = Duration::from_secs(5);
const DAEMON_SCAN_TEXT_TIMEOUT: Duration = Duration::from_secs(60);
fn request_timeout(request: &Request) -> Duration {
match request {
Request::Hello | Request::Health | Request::Shutdown => DAEMON_HEALTH_TIMEOUT,
Request::ScanText { .. } => DAEMON_SCAN_TEXT_TIMEOUT,
Request::ScanPath { .. } => DAEMON_REQUEST_TIMEOUT,
}
}
pub async fn connect(socket_path: &Path) -> Result<Client> {
connect_inner(socket_path, true).await
}
pub async fn connect_any_version(socket_path: &Path) -> Result<Client> {
connect_inner(socket_path, false).await
}
async fn connect_inner(socket_path: &Path, require_same_version: bool) -> Result<Client> {
trust::validate_socket_for_connect(socket_path)?;
let stream = tokio::time::timeout(Duration::from_secs(1), UnixStream::connect(socket_path))
.await
.with_context(|| {
format!(
"daemon client: connect timeout to {}",
socket_path.display()
)
})?
.with_context(|| format!("daemon client: connect to {}", socket_path.display()))?;
trust::verify_connected_peer(&stream, socket_path)?;
let mut client = Client {
transport: frame::client_transport(stream),
daemon_version: String::new(),
backend_policy: String::new(),
stale_reason: None,
};
client.send(&Request::Hello).await?;
let response = tokio::time::timeout(DAEMON_HANDSHAKE_TIMEOUT, client.recv())
.await
.with_context(|| {
format!(
"daemon client: handshake timeout waiting for Hello from {}",
socket_path.display()
)
})?
.with_context(|| {
format!(
"daemon client: handshake receive from {}",
socket_path.display()
)
})?;
match response {
Response::Hello {
wire_version,
keyhog_version,
git_hash,
detector_rules_digest,
backend_policy,
..
} if wire_version == WIRE_VERSION => {
validate_backend_policy(&backend_policy)?;
let expected_rules_digest = embedded_detector_rules_digest()?;
let mut mismatches = Vec::new();
if keyhog_version != CLIENT_KEYHOG_VERSION {
mismatches.push(format!(
"package version daemon={keyhog_version}, client={CLIENT_KEYHOG_VERSION}"
));
}
if git_hash != keyhog_core::git_hash() {
mismatches.push(format!(
"Git build daemon={git_hash}, client={}",
keyhog_core::git_hash()
));
}
if detector_rules_digest != expected_rules_digest {
mismatches.push(format!(
"detector rules daemon={detector_rules_digest}, client={expected_rules_digest}"
));
}
let stale_reason = (!mismatches.is_empty()).then(|| mismatches.join("; "));
if require_same_version && stale_reason.is_some() {
bail!(
"daemon identity mismatch at {}: {}. It may hold a different build, \
detector corpus, or scan pipeline and would return stale scan results. Restart it with \
`keyhog daemon stop && keyhog daemon start`, or pass `--daemon=off` to \
scan in-process.",
socket_path.display(),
stale_reason.as_deref().unwrap_or("unknown identity mismatch"), );
}
client.daemon_version = keyhog_version;
client.backend_policy = backend_policy;
client.stale_reason = stale_reason;
Ok(client)
}
Response::Hello {
wire_version,
keyhog_version,
..
} => bail!(
"daemon wire version mismatch: client expects {WIRE_VERSION}, daemon at {} reports {wire_version} (keyhog {keyhog_version}). Restart the daemon or pass --daemon=off.",
socket_path.display(),
),
other => bail!(
"daemon client: expected Hello reply, got {}. Restart the daemon or pass --daemon=off.",
response_kind(&other)
),
}
}
#[doc(hidden)]
pub(crate) mod testing {
pub(crate) use crate::daemon::trust::testing::{
connected_peer_uid, current_uid, validate_socket_for_connect,
};
}
#[path = "client_tests.rs"]
mod client_tests;
pub struct Client {
transport: frame::ClientTransport,
daemon_version: String,
backend_policy: String,
stale_reason: Option<String>,
}
impl Client {
pub(crate) fn daemon_version(&self) -> &str {
&self.daemon_version
}
pub(crate) fn backend_policy(&self) -> &str {
&self.backend_policy
}
pub(crate) fn is_stale(&self) -> bool {
self.stale_reason.is_some()
}
pub(crate) fn stale_reason(&self) -> Option<&str> {
self.stale_reason.as_deref()
}
pub(crate) async fn send(&mut self, request: &Request) -> Result<()> {
self.transport.send(request.clone()).await
}
pub(crate) async fn recv(&mut self) -> Result<Response> {
self.recv_with_timeout(DAEMON_REQUEST_TIMEOUT).await
}
pub(crate) async fn recv_with_timeout(&mut self, timeout: Duration) -> Result<Response> {
match tokio::time::timeout(timeout, self.transport.next()).await {
Err(_) => bail!(
"daemon client: no response within {}s. The daemon may be stuck \
or overloaded. Try `keyhog daemon stop && keyhog daemon start`, \
or rerun with `--daemon=off`.",
timeout.as_secs()
),
Ok(None) => bail!(
"daemon client: connection closed before response. \
The daemon may have crashed or been restarted mid-request. \
Try `keyhog daemon stop && keyhog daemon start`, or rerun \
the scan with `--daemon=off` to bypass the daemon path."
),
Ok(Some(frame)) => frame.context("daemon client: response frame error"),
}
}
pub(crate) async fn round_trip(&mut self, request: &Request) -> Result<Response> {
self.send(request).await?;
self.recv_with_timeout(request_timeout(request)).await
}
}
fn validate_backend_policy(policy: &str) -> Result<()> {
if matches!(
policy,
"autoroute" | "autoroute-recovery" | "autoroute-degraded"
) {
return Ok(());
}
if keyhog_scanner::hw_probe::parse_backend_str(policy)
.is_some_and(|backend| backend.label() == policy)
{
return Ok(());
}
bail!("daemon reported invalid backend policy {policy:?}. Restart it with this KeyHog build")
}
fn embedded_detector_rules_digest() -> Result<String> {
let detectors = keyhog_core::load_embedded_detectors_or_fail()
.context("daemon client: load embedded detector identity")?;
Ok(keyhog_core::hex_encode(&keyhog_core::compute_spec_hash(
&detectors,
)))
}