use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use anyhow::{Context, Result, bail};
use tracing::{debug, info, warn};
use futures_util::StreamExt;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use crate::collector::{SharedDashboard, publish};
use crate::config::HostConfig;
use crate::model::{HostEntry, Snapshot};
use crate::store::MetricPoint;
pub trait AsyncStream: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Unpin {}
impl<T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Unpin> AsyncStream for T {}
pub type NodeWebSocket = tokio_tungstenite::WebSocketStream<Box<dyn AsyncStream>>;
pub fn ws_denied(err: &anyhow::Error) -> bool {
matches!(
err.downcast_ref::<tokio_tungstenite::tungstenite::Error>(),
Some(tokio_tungstenite::tungstenite::Error::Http(resp))
if matches!(resp.status().as_u16(), 401 | 403)
)
}
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
#[allow(clippy::duration_suboptimal_units)]
const PASSTHROUGH_TIMEOUT: Duration = Duration::from_secs(60);
#[allow(clippy::duration_suboptimal_units)]
const STREAM_SESSION_MAX: Duration = Duration::from_secs(24 * 3600);
#[allow(clippy::duration_suboptimal_units)]
const STREAM_STALL: Duration = Duration::from_secs(60);
#[derive(Clone)]
pub struct NodeClient {
http: reqwest::Client,
base: String,
node_host: Arc<OnceLock<String>>,
auth_header: Option<reqwest::header::HeaderValue>,
tls: Option<Arc<rustls::ClientConfig>>,
}
impl NodeClient {
pub fn from_config(cfg: &HostConfig) -> Result<Self> {
let base = cfg
.dockdoe
.as_deref()
.context("host has no dockdoe endpoint")?
.trim()
.trim_end_matches('/')
.to_string();
let auth_header = cfg
.token
.as_deref()
.map(|token| {
format!("Bearer {token}")
.parse::<reqwest::header::HeaderValue>()
.context("token contains characters not allowed in a header")
})
.transpose()?;
let mut headers = reqwest::header::HeaderMap::new();
if let Some(value) = &auth_header {
headers.insert(reqwest::header::AUTHORIZATION, value.clone());
}
let tls = base
.starts_with("https://")
.then(|| {
crate::docker::tls_client_config(cfg.tls_ca.as_deref(), cfg.tls_insecure)
.map(Arc::new)
})
.transpose()?;
let mut builder = reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT)
.connect_timeout(CONNECT_TIMEOUT)
.default_headers(headers);
builder = if cfg.tls_insecure {
warn!(node = %base, "tls_insecure is set: TLS server-certificate verification is disabled");
builder.danger_accept_invalid_certs(true)
} else {
let mut roots: Vec<reqwest::Certificate> = webpki_root_certs::TLS_SERVER_ROOT_CERTS
.iter()
.filter_map(|der| reqwest::Certificate::from_der(der.as_ref()).ok())
.collect();
if let Some(ca) = cfg.tls_ca.as_deref() {
let pem = std::fs::read(ca)
.with_context(|| format!("reading tls_ca {}", ca.display()))?;
let certs = reqwest::Certificate::from_pem_bundle(&pem)
.with_context(|| format!("parsing tls_ca {}", ca.display()))?;
if certs.is_empty() {
bail!("tls_ca {} contained no certificates", ca.display());
}
roots.extend(certs);
}
builder.tls_certs_only(roots)
};
Ok(Self {
http: builder
.build()
.context("building the HTTP client for the federated node")?,
base,
node_host: Arc::new(OnceLock::new()),
auth_header,
tls,
})
}
pub fn node_host(&self) -> Option<&str> {
self.node_host.get().map(String::as_str)
}
pub async fn exec_socket(&self, id: &str, cmd: &str) -> Result<NodeWebSocket> {
let nh = self
.node_host()
.context("node not resolved yet (unreachable since startup)")?;
let base: reqwest::Url = self
.base
.parse()
.with_context(|| format!("invalid node URL {}", self.base))?;
let host = base.host_str().context("node URL has no host")?;
let port = base
.port_or_known_default()
.context("node URL has no port")?;
let tcp = tokio::net::TcpStream::connect((host, port))
.await
.with_context(|| format!("connecting to node {host}:{port}"))?;
let stream: Box<dyn AsyncStream> = match &self.tls {
Some(tls) => {
let server = rustls::pki_types::ServerName::try_from(host.to_string())
.with_context(|| format!("invalid TLS server name {host:?}"))?;
Box::new(
tokio_rustls::TlsConnector::from(Arc::clone(tls))
.connect(server, tcp)
.await
.with_context(|| format!("TLS handshake with node {host}:{port}"))?,
)
}
None => Box::new(tcp),
};
let mut url = base;
let scheme = if self.tls.is_some() { "wss" } else { "ws" };
url.set_scheme(scheme)
.map_err(|()| anyhow::anyhow!("cannot make a WebSocket URL from {}", self.base))?;
url.set_path(&format!("/host/{nh}/ws/container/{id}/exec"));
if !cmd.is_empty() {
url.query_pairs_mut().append_pair("cmd", cmd);
}
let mut request = url
.as_str()
.into_client_request()
.context("building the WebSocket handshake request")?;
if let Some(auth) = &self.auth_header {
request
.headers_mut()
.insert(reqwest::header::AUTHORIZATION, auth.clone());
}
let (ws, _response) = tokio_tungstenite::client_async(request, stream)
.await
.with_context(|| format!("WebSocket handshake with node {}", self.base))?;
Ok(ws)
}
pub async fn forward_get(
&self,
api_path: &str,
query: Option<&str>,
) -> Result<reqwest::Response> {
let mut url = self.host_scoped(api_path)?;
if let Some(q) = query.filter(|q| !q.is_empty()) {
url.push('?');
url.push_str(q);
}
self.http
.get(&url)
.timeout(PASSTHROUGH_TIMEOUT)
.send()
.await
.with_context(|| format!("requesting {api_path} from node {}", self.base))
}
pub async fn forward_action(&self, api_path: &str) -> Result<(reqwest::StatusCode, String)> {
let url = self.host_scoped(api_path)?;
let resp = self
.http
.post(&url)
.send()
.await
.with_context(|| format!("posting {api_path} to node {}", self.base))?;
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
Ok((status, body))
}
async fn snapshot_events(&self, node_host: &str) -> Result<reqwest::Response> {
self.http
.get(format!("{}/host/{node_host}/api/events", self.base))
.header(reqwest::header::ACCEPT, "text/event-stream")
.timeout(STREAM_SESSION_MAX)
.send()
.await
.with_context(|| format!("opening the event stream of node {}", self.base))
}
pub async fn metric_points(&self, api_path: &str, since_ms: u64) -> Result<Vec<MetricPoint>> {
let resp = self
.forward_get(api_path, Some(&format!("since_ms={since_ms}")))
.await?;
let resp = check_status(resp)?;
resp.json()
.await
.with_context(|| format!("decoding {api_path} from node {}", self.base))
}
fn host_scoped(&self, api_path: &str) -> Result<String> {
let nh = self
.node_host()
.context("node not resolved yet (unreachable since startup)")?;
Ok(format!("{}/host/{nh}{api_path}", self.base))
}
pub async fn hosts(&self) -> Result<Vec<HostEntry>> {
self.get_json("/api/hosts").await
}
pub async fn snapshot(&self, node_host: &str) -> Result<Option<Snapshot>> {
let resp = self
.http
.get(format!("{}/host/{node_host}/api/snapshot", self.base))
.send()
.await
.with_context(|| format!("requesting snapshot from node {}", self.base))?;
if resp.status() == reqwest::StatusCode::SERVICE_UNAVAILABLE {
return Ok(None);
}
let resp = check_status(resp)?;
Ok(Some(resp.json().await.with_context(|| {
format!("decoding snapshot from node {}", self.base)
})?))
}
pub async fn resolve_node_host(&self, configured: Option<&str>) -> Result<HostEntry> {
let hosts = self
.hosts()
.await
.with_context(|| format!("listing hosts of node {}", self.base))?;
let picked = pick_host(&hosts, configured)?.clone();
if picked.version != VERSION {
warn!(
node = %self.base,
node_version = %picked.version,
hub_version = %VERSION,
"node runs a different DockDoe version; the API is additive, but keep them close"
);
}
Ok(picked)
}
async fn get_json<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T> {
let resp = self
.http
.get(format!("{}{path}", self.base))
.send()
.await
.with_context(|| format!("requesting {path} from node {}", self.base))?;
let resp = check_status(resp)?;
resp.json()
.await
.with_context(|| format!("decoding {path} from node {}", self.base))
}
}
pub async fn run(
host: String,
client: NodeClient,
configured_node_host: Option<String>,
interval: Duration,
shared: SharedDashboard,
read_only: Arc<AtomicBool>,
) {
info!(host = %host, node = %client.base, interval = ?interval, "starting federation");
let mut ticker = tokio::time::interval(interval);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let mut streaming = true;
loop {
ticker.tick().await;
if client.node_host().is_none() {
match client
.resolve_node_host(configured_node_host.as_deref())
.await
{
Ok(entry) => {
info!(
host = %host,
node = %client.base,
node_host = %entry.name,
node_version = %entry.version,
"federated node resolved"
);
let _ = client.node_host.set(entry.name);
}
Err(e) => {
warn!(host = %host, error = %format!("{e:#}"), "resolving federated node failed; retrying");
continue;
}
}
}
let target = client.node_host().expect("resolved above").to_string();
if streaming {
match consume_stream(&client, &target, &host, &shared, &read_only).await {
StreamEnd::Unsupported => {
info!(host = %host, "node has no snapshot stream (pre-0.12); polling instead");
streaming = false; }
StreamEnd::Disconnected => continue,
}
}
match client.snapshot(&target).await {
Ok(Some(snap)) => {
read_only.store(snap.read_only, Ordering::Relaxed);
publish(&shared, snap.dashboard);
}
Ok(None) => debug!(host = %host, "node is warming up; no snapshot yet"),
Err(e) => {
warn!(host = %host, error = %format!("{e:#}"), "polling node failed; keeping last snapshot");
}
}
}
}
enum StreamEnd {
Unsupported,
Disconnected,
}
async fn consume_stream(
client: &NodeClient,
target: &str,
host: &str,
shared: &SharedDashboard,
read_only: &AtomicBool,
) -> StreamEnd {
let resp = match client.snapshot_events(target).await {
Ok(resp) => resp,
Err(e) => {
warn!(host = %host, error = %format!("{e:#}"), "connecting to node stream failed");
return StreamEnd::Disconnected;
}
};
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return StreamEnd::Unsupported;
}
if !resp.status().is_success() {
warn!(host = %host, status = %resp.status(), "node rejected the event stream");
return StreamEnd::Disconnected;
}
let mut stream = resp.bytes_stream();
let mut parser = SseParser::default();
loop {
match tokio::time::timeout(STREAM_STALL, stream.next()).await {
Err(_) => {
warn!(host = %host, "node stream went silent; reconnecting");
return StreamEnd::Disconnected;
}
Ok(None) => {
debug!(host = %host, "node stream closed; reconnecting");
return StreamEnd::Disconnected;
}
Ok(Some(Err(e))) => {
warn!(host = %host, error = %format!("{e:#}"), "node stream failed; reconnecting");
return StreamEnd::Disconnected;
}
Ok(Some(Ok(chunk))) => {
for (event, data) in parser.push(&chunk) {
if event != "snapshot" {
continue; }
match serde_json::from_str::<Snapshot>(&data) {
Ok(snap) => {
read_only.store(snap.read_only, Ordering::Relaxed);
publish(shared, snap.dashboard);
}
Err(e) => warn!(host = %host, %e, "undecodable snapshot event"),
}
}
}
}
}
}
#[derive(Default)]
struct SseParser {
buf: String,
}
impl SseParser {
fn push(&mut self, chunk: &[u8]) -> Vec<(String, String)> {
self.buf
.push_str(&String::from_utf8_lossy(chunk).replace('\r', ""));
let mut out = Vec::new();
while let Some(pos) = self.buf.find("\n\n") {
let block: String = self.buf.drain(..pos + 2).collect();
let mut event = String::from("message");
let mut data: Vec<&str> = Vec::new();
for line in block.lines() {
if let Some(v) = line.strip_prefix("event:") {
event = v.trim_start().to_string();
} else if let Some(v) = line.strip_prefix("data:") {
data.push(v.strip_prefix(' ').unwrap_or(v));
}
}
if !data.is_empty() {
out.push((event, data.join("\n")));
}
}
out
}
}
fn check_status(resp: reqwest::Response) -> Result<reqwest::Response> {
match resp.status() {
s if s.is_success() => Ok(resp),
reqwest::StatusCode::UNAUTHORIZED => {
bail!(
"node rejected the API token (401): check this host's `token` against the node's `api_token`"
)
}
reqwest::StatusCode::SEE_OTHER => {
bail!(
"node redirected to its login page: set `token` here, and make sure the node is DockDoe ≥ 0.10 with `api_token` configured"
)
}
s => bail!("node answered {s}"),
}
}
fn pick_host<'a>(hosts: &'a [HostEntry], configured: Option<&str>) -> Result<&'a HostEntry> {
let names = || {
hosts
.iter()
.map(|h| h.name.as_str())
.collect::<Vec<_>>()
.join(", ")
};
match configured {
Some(want) => hosts
.iter()
.find(|h| h.name == want)
.with_context(|| format!("node has no host {want:?} (it has: {})", names())),
None => match hosts {
[only] => Ok(only),
[] => bail!("node reports no hosts"),
_ => bail!(
"node monitors several hosts ({}); set node_host to pick one",
names()
),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(name: &str) -> HostEntry {
HostEntry {
name: name.into(),
version: "0.10.0".into(),
}
}
#[test]
fn sse_parser_handles_chunking_comments_and_multiline_data() {
let mut p = SseParser::default();
assert!(p.push(b": keep-alive\n\n").is_empty());
assert!(p.push(b"event: snap").is_empty());
assert!(p.push(b"shot\ndata: {\"a\":").is_empty());
let events = p.push(b"1}\n\n");
assert_eq!(events, vec![("snapshot".into(), "{\"a\":1}".into())]);
let events = p.push(b"data: x\r\ndata: y\r\n\r\nevent: e2\ndata: z\n\n");
assert_eq!(
events,
vec![("message".into(), "x\ny".into()), ("e2".into(), "z".into())]
);
assert!(p.push(b"id: 7\nretry: 100\ndata: tail").is_empty());
assert_eq!(p.push(b"\n\n"), vec![("message".into(), "tail".into())]);
}
#[test]
fn pick_host_resolves_explicit_and_sole_hosts() {
let hosts = vec![entry("local"), entry("nas")];
assert_eq!(pick_host(&hosts, Some("nas")).unwrap().name, "nas");
let err = pick_host(&hosts, Some("prod")).unwrap_err().to_string();
assert!(err.contains("local, nas"), "got: {err}");
assert!(pick_host(&hosts, None).is_err());
let sole = vec![entry("local")];
assert_eq!(pick_host(&sole, None).unwrap().name, "local");
assert!(pick_host(&[], None).is_err());
}
}