use crate::protocol::event_type::Event;
use crate::state::app_state::AppState;
use crate::state::server::ServerStatus;
use crate::state::ServerId;
use anyhow::{anyhow, bail, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
use std::io::Write;
use std::net::SocketAddr;
use std::sync::{Arc, LazyLock};
use std::time::Duration;
use tokio::io::AsyncWriteExt;
use tokio::sync::Semaphore;
use tracing::{debug, warn};
pub const MAX_INFLIGHT_DELIVERIES: usize = 128;
pub const MAX_PAYLOAD_BYTES: usize = 64 * 1024;
pub const MAX_RENDERED_BYTES: usize = 1024 * 1024;
const DELIVERY_CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
const DELIVERY_WRITE_TIMEOUT: Duration = Duration::from_secs(2);
static PIPE_INFLIGHT: LazyLock<Arc<Semaphore>> =
LazyLock::new(|| Arc::new(Semaphore::new(MAX_INFLIGHT_DELIVERIES)));
pub type PipeId = u32;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PipeSpec {
pub id: PipeId,
pub from: u32,
pub on: String,
pub to: u32,
#[serde(rename = "as")]
pub as_action: String,
pub map: BTreeMap<String, String>,
}
pub fn would_create_cycle(existing: &[PipeSpec], from: u32, to: u32) -> bool {
if from == to {
return true;
}
let mut stack = vec![to];
let mut seen = vec![to];
while let Some(node) = stack.pop() {
for e in existing.iter().filter(|e| e.from == node) {
if e.to == from {
return true;
}
if !seen.contains(&e.to) {
seen.push(e.to);
stack.push(e.to);
}
}
}
false
}
struct RenderBuffer(Vec<u8>);
impl Write for RenderBuffer {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
if bytes.len() > MAX_RENDERED_BYTES.saturating_sub(self.0.len()) {
return Err(std::io::Error::other(format!(
"pipe rendered mapping exceeds the {MAX_RENDERED_BYTES}-byte cap"
)));
}
self.0.extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn render_template(mut tmpl: &str, data: &Value) -> Result<String> {
let mut out = RenderBuffer(Vec::new());
while let Some(open) = tmpl.find('{') {
out.write_all(tmpl[..open].as_bytes())?;
let rest = &tmpl[open + 1..];
let Some(close) = rest.find('}') else {
out.write_all(tmpl[open..].as_bytes())?;
return Ok(String::from_utf8(out.0)?);
};
let mut value = Some(data);
for segment in rest[..close].trim().split('.') {
value = value.and_then(|v| v.get(segment));
}
match value {
Some(Value::String(value)) => out.write_all(value.as_bytes())?,
None | Some(Value::Null) => {}
Some(value) => serde_json::to_writer(&mut out, value)?,
}
tmpl = &rest[close + 1..];
}
out.write_all(tmpl.as_bytes())?;
Ok(String::from_utf8(out.0)?)
}
fn decode_payload(data: &str, encoding: &str) -> Result<Vec<u8>> {
match encoding {
"utf8" | "" => Ok(data.as_bytes().to_vec()),
"hex" => {
let cleaned: String = data
.trim()
.trim_start_matches("0x")
.chars()
.filter(|c| !c.is_ascii_whitespace() && *c != ':')
.collect();
hex::decode(&cleaned).map_err(|e| {
anyhow!(
"pipe payload declared encoding \"hex\" but is not valid hex ({e}); \
for text, use \"utf8\" or omit encoding"
)
})
}
other => bail!(
"invalid pipe payload encoding {other:?}; valid values are \"utf8\" (default) and \"hex\""
),
}
}
pub fn render_payload(spec: &PipeSpec, event_data: &Value) -> Result<Vec<u8>> {
let data_tmpl = spec.map.get("data").ok_or_else(|| {
anyhow!(
"pipe #{} map must produce a 'data' field to deliver to a socket sink",
spec.id
)
})?;
let data = render_template(data_tmpl, event_data)?;
let encoding = spec
.map
.get("encoding")
.map(|t| render_template(t, event_data))
.transpose()?
.unwrap_or_else(|| "utf8".to_string());
let bytes = decode_payload(&data, &encoding)?;
if bytes.len() > MAX_PAYLOAD_BYTES {
bail!(
"pipe #{} payload is {} bytes, over the {}-byte cap",
spec.id,
bytes.len(),
MAX_PAYLOAD_BYTES
);
}
Ok(bytes)
}
fn deliverable_addr(status: &ServerStatus, local_addr: Option<SocketAddr>) -> Option<SocketAddr> {
match status {
ServerStatus::Running => local_addr,
_ => None,
}
}
async fn deliver(addr: SocketAddr, payload: &[u8]) -> Result<()> {
let mut stream = tokio::time::timeout(
DELIVERY_CONNECT_TIMEOUT,
tokio::net::TcpStream::connect(addr),
)
.await
.map_err(|_| anyhow!("connect to {addr} timed out"))??;
tokio::time::timeout(DELIVERY_WRITE_TIMEOUT, stream.write_all(payload))
.await
.map_err(|_| anyhow!("write to {addr} timed out"))??;
let _ = stream.flush().await;
let _ = stream.shutdown().await;
Ok(())
}
pub async fn dispatch_pipes(state: &AppState, from: ServerId, event: &Event) {
let specs = state.pipes_matching(from, event.id()).await;
if specs.is_empty() {
return;
}
for spec in specs {
let payload = match render_payload(&spec, &event.data) {
Ok(p) => p,
Err(e) => {
warn!("pipe #{}: mapping failed, dropping: {}", spec.id, e);
continue;
}
};
let to_id = ServerId::new(spec.to);
let addr = match state.get_server(to_id).await {
Some(s) => match deliverable_addr(&s.status, s.local_addr) {
Some(a) => a,
None => {
warn!(
"pipe #{}: target server #{} is not running/bound, dropping delivery",
spec.id, spec.to
);
continue;
}
},
None => {
warn!(
"pipe #{}: target server #{} no longer exists, dropping delivery",
spec.id, spec.to
);
continue;
}
};
let permit = match PIPE_INFLIGHT.clone().try_acquire_owned() {
Ok(p) => p,
Err(_) => {
warn!(
"pipe #{}: {} deliveries already in flight (cap), dropping this one",
spec.id, MAX_INFLIGHT_DELIVERIES
);
continue;
}
};
let pipe_id = spec.id;
tokio::spawn(async move {
let _permit = permit; match deliver(addr, &payload).await {
Ok(()) => debug!(
"pipe #{}: delivered {} bytes to server #{} ({})",
pipe_id,
payload.len(),
to_id.as_u32(),
addr
),
Err(e) => warn!("pipe #{}: delivery to {} failed: {}", pipe_id, addr, e),
}
});
}
}
pub async fn execute_pipe_action(
action_name: &str,
action: &Value,
state: &AppState,
default_from: Option<ServerId>,
) -> Result<()> {
match action_name {
"create_pipe" => {
let on = action
.get("on")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("create_pipe requires an 'on' event type"))?
.to_string();
let to = action
.get("to")
.and_then(json_as_u32)
.ok_or_else(|| anyhow!("create_pipe requires a numeric 'to' server id"))?;
let as_action = action
.get("as")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("create_pipe requires an 'as' action"))?
.to_string();
let from =
match action.get("from") {
Some(value) => ServerId::new(json_as_u32(value).ok_or_else(|| {
anyhow!("create_pipe requires a numeric 'from' server id")
})?),
None => default_from.ok_or_else(|| {
anyhow!("create_pipe has no 'from' and no server context to default to")
})?,
};
let map: BTreeMap<String, String> = match action.get("map") {
Some(Value::Object(obj)) => obj
.iter()
.map(|(k, v)| {
v.as_str()
.map(|s| (k.clone(), s.to_string()))
.ok_or_else(|| {
anyhow!("create_pipe map field {k:?} must be a string template")
})
})
.collect::<Result<_>>()?,
_ => bail!("create_pipe requires a 'map' object of string templates"),
};
let id = state
.add_pipe(from, on, ServerId::new(to), as_action, map)
.await?;
debug!("create_pipe: created pipe #{}", id);
Ok(())
}
"remove_pipe" => {
let pipe_id = action
.get("pipe_id")
.and_then(json_as_u32)
.ok_or_else(|| anyhow!("remove_pipe requires a numeric 'pipe_id'"))?;
if state.remove_pipe(pipe_id).await.is_none() {
bail!("remove_pipe: no pipe #{pipe_id}");
}
Ok(())
}
other => bail!("unknown pipe action {other:?}"),
}
}
fn json_as_u32(v: &Value) -> Option<u32> {
v.as_u64()
.and_then(|n| u32::try_from(n).ok())
.or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))
}