use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use clap::Parser;
use rand::Rng as _;
use serde::Deserialize;
use tokio::sync::{RwLock, mpsc};
use tracing::{error, info, warn};
use rf_audit::logger::FileAuditLogger;
use rf_audit::types::AuditEntry;
use rf_crypto::channel::SecureChannel;
use rf_crypto::keys::StaticKey;
use rf_crypto::noise::{handshake, handshake_with_compat};
use rf_crypto::secrets::SecretStore;
use rf_executor::command::Executor;
use rf_executor::metrics_server::RfCounters;
use rf_policy::rpc_policy::RpcPolicy;
use rf_rpc::codec;
use rf_rpc::types::{Action, Request, Response, RpcResult};
use rf_transport::driver::{Driver, Target};
use rf_transport::relay_select::{RelayCluster, RelaySelector};
use rf_transport::websocket::WebSocketDriver;
struct ConnectionTracker<'a>(&'a Option<RfCounters>);
impl Drop for ConnectionTracker<'_> {
fn drop(&mut self) {
if let Some(c) = self.0 {
c.3.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
}
}
}
#[derive(Parser)]
#[command(name = "rf-agent", about = "RavenFabric agent", version)]
struct Args {
#[arg(short, long, default_value = "raven.toml")]
config: PathBuf,
#[arg(short = 'i', long)]
id: Option<String>,
#[arg(short, long)]
relay: Option<String>,
#[arg(short, long)]
token: Option<String>,
#[arg(short, long)]
key_path: Option<PathBuf>,
#[arg(short, long)]
policy_path: Option<PathBuf>,
#[arg(short, long)]
audit_path: Option<PathBuf>,
#[arg(long)]
metrics_addr: Option<String>,
#[arg(short = 'L', long)]
listen: Option<String>,
#[arg(long)]
seal_key_path: Option<PathBuf>,
#[arg(long)]
compat_mode: bool,
#[arg(long)]
export_hmac_key: bool,
#[arg(long)]
constrained: bool,
}
#[derive(Debug, Deserialize, Default)]
struct Config {
#[serde(default)]
agent: AgentConfig,
#[serde(default)]
transport: TransportConfig,
}
#[derive(Debug, Deserialize, Default)]
struct AgentConfig {
id: Option<String>,
relay: Option<String>,
token: Option<String>,
key_path: Option<String>,
policy_path: Option<String>,
audit_path: Option<String>,
metrics_addr: Option<String>,
listen: Option<String>,
region: Option<String>,
seal_key_path: Option<String>,
audit_key_path: Option<String>,
#[serde(default)]
compat_mode: bool,
#[serde(default)]
constrained: bool,
}
#[derive(Debug, Deserialize)]
struct TransportConfig {
reconnect_interval: Option<u64>,
max_retries: Option<u64>,
#[serde(default)]
relay_clusters: Vec<RelayClusterConfig>,
}
#[derive(Debug, Deserialize, Default)]
struct RelayClusterConfig {
region: String,
#[serde(default)]
continent: Option<String>,
#[serde(default)]
country_code: Option<String>,
#[serde(default)]
latitude: Option<f64>,
#[serde(default)]
longitude: Option<f64>,
#[serde(default)]
relays: Vec<String>,
}
impl Default for TransportConfig {
fn default() -> Self {
Self {
reconnect_interval: Some(5),
max_retries: Some(0), relay_clusters: Vec::new(),
}
}
}
#[allow(dead_code)]
struct RelayList {
urls: Vec<String>,
current: usize,
failed: Vec<bool>,
rtt_ms: Vec<Option<u32>>,
}
impl RelayList {
fn new(urls: Vec<String>) -> Self {
let len = urls.len();
Self {
urls,
current: 0,
failed: vec![false; len],
rtt_ms: vec![None; len],
}
}
fn current_url(&self) -> &str {
&self.urls[self.current]
}
fn failover(&mut self) -> Option<&str> {
if self.current < self.failed.len() {
self.failed[self.current] = true;
}
let len = self.urls.len();
for offset in 1..=len {
let idx = (self.current + offset) % len;
if !self.failed[idx] {
self.current = idx;
return Some(&self.urls[idx]);
}
}
self.failed.fill(false);
self.current = 0;
(!self.urls.is_empty()).then(|| self.urls[0].as_str())
}
fn reset_failures(&mut self) {
self.failed.fill(false);
}
#[allow(dead_code)]
fn set_rtt(&mut self, rtt_ms: u32) {
if self.current < self.rtt_ms.len() {
self.rtt_ms[self.current] = Some(rtt_ms);
}
}
fn all_urls(&self) -> &[String] {
&self.urls
}
}
struct ResolvedConfig {
id: String,
relay: String,
relay_list: RelayList,
token: String,
key_path: PathBuf,
policy_path: PathBuf,
audit_path: PathBuf,
reconnect_interval: u64,
max_retries: u64,
metrics_addr: Option<String>,
listen: Option<String>,
region: Option<String>,
seal_key_path: PathBuf,
audit_key_path: Option<PathBuf>,
compat_mode: bool,
constrained: bool,
}
fn load_config(args: &Args) -> anyhow::Result<ResolvedConfig> {
let config: Config = if args.config.exists() {
let content = std::fs::read_to_string(&args.config)?;
toml::from_str(&content)?
} else {
Config::default()
};
let relay_urls: Vec<String> = if let Some(cli_relay) = args.relay.clone() {
vec![cli_relay]
} else {
let clusters: Vec<RelayCluster> = config
.transport
.relay_clusters
.iter()
.map(|c| RelayCluster {
region: c.region.clone(),
continent: c.continent.clone(),
country_code: c.country_code.clone(),
latitude: c.latitude,
longitude: c.longitude,
relays: c.relays.clone(),
})
.collect();
if clusters.is_empty() {
vec![
config
.agent
.relay
.clone()
.unwrap_or_else(|| "ws://127.0.0.1:9090".to_string()),
]
} else {
let selector = RelaySelector::from_clusters(clusters);
let region = config.agent.region.as_deref().unwrap_or("");
let all: Vec<String> = selector
.multi_relay_affinity(region)
.into_iter()
.map(|ep| ep.addr.clone())
.collect();
if all.is_empty() {
vec![
config
.agent
.relay
.clone()
.unwrap_or_else(|| "ws://127.0.0.1:9090".to_string()),
]
} else {
all
}
}
};
let relay = relay_urls[0].clone();
let relay_list = RelayList::new(relay_urls);
Ok(ResolvedConfig {
id: args
.id
.clone()
.or(config.agent.id)
.unwrap_or_else(|| "agent".to_string()),
relay: relay.clone(),
relay_list,
token: args
.token
.clone()
.or(config.agent.token)
.unwrap_or_else(|| "default".to_string()),
key_path: args
.key_path
.clone()
.or(config.agent.key_path.map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from("agent.key")),
policy_path: args
.policy_path
.clone()
.or(config.agent.policy_path.map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from("policy.yaml")),
audit_path: args
.audit_path
.clone()
.or(config.agent.audit_path.map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from("audit.jsonl")),
reconnect_interval: config.transport.reconnect_interval.unwrap_or(5),
max_retries: config.transport.max_retries.unwrap_or(0),
metrics_addr: args.metrics_addr.clone().or(config.agent.metrics_addr),
listen: args.listen.clone().or(config.agent.listen),
region: config.agent.region,
seal_key_path: args
.seal_key_path
.clone()
.or(config.agent.seal_key_path.map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from("seal.key")),
audit_key_path: config.agent.audit_key_path.map(PathBuf::from),
compat_mode: args.compat_mode || config.agent.compat_mode,
constrained: args.constrained || config.agent.constrained,
})
}
#[cfg(not(feature = "rt-single-thread"))]
#[tokio::main]
async fn main() -> anyhow::Result<()> {
agent_main().await
}
#[cfg(feature = "rt-single-thread")]
#[tokio::main(flavor = "current_thread")]
async fn main() -> anyhow::Result<()> {
agent_main().await
}
async fn agent_main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let args = Args::parse();
let cfg = load_config(&args)?;
let key = StaticKey::load_or_generate(&cfg.key_path)?;
info!("agent {} public key: {}", cfg.id, key.public_hex());
if args.export_hmac_key {
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
let private_key = key.private_bytes();
let salt = b"ravenfabric-audit-hmac-v1";
let mut extractor =
Hmac::<Sha256>::new_from_slice(salt).expect("HMAC accepts any key length");
extractor.update(private_key.as_slice());
let prk = extractor.finalize().into_bytes();
let info = b"ravenfabric-audit-hmac-key";
let mut expander =
Hmac::<Sha256>::new_from_slice(&prk).expect("HMAC accepts any key length");
expander.update(info);
expander.update(&[0x01]);
let hmac_key = expander.finalize().into_bytes();
println!("{}", hex::encode(hmac_key.as_slice()));
return Ok(());
}
let policy = RpcPolicy::load(&cfg.policy_path)?;
let policy = Arc::new(RwLock::new(policy));
info!("policy loaded from {}", cfg.policy_path.display());
let audit_key: Vec<u8> = if let Some(ref key_path) = cfg.audit_key_path {
let raw = std::fs::read(key_path)?;
if raw.len() == 32 {
info!("audit HMAC key loaded from {}", key_path.display());
raw
} else if raw.len() == 64 {
let decoded = hex::decode(&raw)?;
if decoded.len() != 32 {
anyhow::bail!(
"audit key hex decoding produced {} bytes, expected 32",
decoded.len()
);
}
info!("audit HMAC key loaded (hex) from {}", key_path.display());
decoded
} else {
anyhow::bail!(
"audit key must be 32 bytes raw or 64 hex chars, got {} bytes",
raw.len()
);
}
} else {
info!("audit HMAC key not configured — chain integrity verification disabled");
vec![]
};
let file_logger = FileAuditLogger::new(cfg.audit_path.clone(), audit_key)?;
let collector_config = if cfg.constrained {
info!("constrained mode: using reduced audit buffer (512 entries, 2s flush)");
rf_audit::collector::CollectorConfig::constrained()
} else {
rf_audit::collector::CollectorConfig::default()
.with_flush_interval(std::time::Duration::from_secs(5))
};
let buffered = rf_audit::collector::BufferedAuditCollector::new(file_logger, collector_config);
let audit: Arc<dyn rf_audit::logger::AuditLogger> = Arc::new(buffered);
info!(
"audit log: {} (buffered, flush every 5s)",
cfg.audit_path.display()
);
let secret_store = if cfg.seal_key_path.exists() {
let key_bytes = std::fs::read(&cfg.seal_key_path)?;
if key_bytes.len() != 32 {
anyhow::bail!("seal key must be exactly 32 bytes, got {}", key_bytes.len());
}
let mut seal_key = [0u8; 32];
seal_key.copy_from_slice(&key_bytes);
let store = Arc::new(tokio::sync::Mutex::new(SecretStore::new(seal_key)));
info!("secret store loaded from {}", cfg.seal_key_path.display());
Some(store)
} else {
info!(
"no seal key at {}, secrets disabled",
cfg.seal_key_path.display()
);
None
};
info!("agent {} starting", cfg.id);
let rf_counters: Option<RfCounters> = if let Some(ref addr) = cfg.metrics_addr {
use rf_executor::metrics_server::{
MetricsServerConfig, new_rf_collector_with_counters, start_metrics_server,
};
let (collector, counters) = new_rf_collector_with_counters();
let config = MetricsServerConfig {
bind_addr: addr.clone(),
};
match start_metrics_server(config, Some(collector)).await {
Ok(_handle) => info!("prometheus metrics endpoint on {}", addr),
Err(e) => warn!("failed to start metrics endpoint on {}: {}", addr, e),
}
Some(counters)
} else {
None
};
#[cfg(unix)]
let mut sighup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup())?;
#[cfg(unix)]
{
let policy_reload = policy.clone();
let policy_path_reload = cfg.policy_path.clone();
tokio::spawn(async move {
loop {
sighup.recv().await;
info!(
"SIGHUP received, reloading policy from {}",
policy_path_reload.display()
);
match RpcPolicy::load(&policy_path_reload) {
Ok(new_policy) => {
let mut w = policy_reload.write().await;
*w = new_policy;
info!("policy reloaded successfully");
}
Err(e) => {
error!("policy reload failed (keeping old policy): {}", e);
}
}
}
});
}
if let Some(ref listen_addr) = cfg.listen {
info!("direct-listen mode on {}", listen_addr);
run_listen_mode(
listen_addr,
&cfg,
&key,
&policy,
&audit,
&secret_store,
&rf_counters,
cfg.compat_mode,
)
.await?;
} else {
info!(
"relay mode: primary={}, {} relays configured",
cfg.relay,
cfg.relay_list.all_urls().len()
);
let probe_relays: Vec<String> = cfg.relay_list.all_urls().to_vec();
let probe_token = cfg.token.clone();
tokio::spawn(async move {
relay_health_prober(probe_relays, probe_token).await;
});
let mut attempt: u64 = 0;
let mut relay_list = RelayList::new(cfg.relay_list.all_urls().to_vec());
loop {
if cfg.max_retries > 0 && attempt >= cfg.max_retries {
error!("max retries ({}) exceeded, shutting down", cfg.max_retries);
break;
}
let current_relay = relay_list.current_url().to_string();
match run_session_for_relay(
¤t_relay,
&cfg,
&key,
&policy,
&audit,
&secret_store,
&rf_counters,
cfg.compat_mode,
)
.await
{
Ok(()) => {
info!("session ended cleanly");
attempt = 0; relay_list.reset_failures();
}
Err(e) => {
attempt += 1;
warn!(
"session error on {} (attempt {}): {}",
current_relay, attempt, e
);
if let Some(next) = relay_list.failover() {
info!("failing over to relay: {}", next);
} else {
warn!("all relays exhausted, retrying primary");
}
}
}
let base = cfg.reconnect_interval;
let backoff = base.saturating_mul(1u64 << attempt.min(5));
let capped = backoff.min(60);
let jitter = rand::rng().random_range(0..=capped / 4);
let wait = capped + jitter;
info!("reconnecting in {}s...", wait);
tokio::select! {
() = tokio::time::sleep(Duration::from_secs(wait)) => {}
_ = tokio::signal::ctrl_c() => {
info!("received SIGINT, shutting down");
break;
}
}
}
}
info!("agent {} shut down", cfg.id);
Ok(())
}
async fn run_listen_mode(
listen_addr: &str,
cfg: &ResolvedConfig,
key: &StaticKey,
policy: &Arc<RwLock<RpcPolicy>>,
audit: &Arc<dyn rf_audit::logger::AuditLogger>,
secret_store: &Option<Arc<tokio::sync::Mutex<SecretStore>>>,
counters: &Option<RfCounters>,
compat_mode: bool,
) -> anyhow::Result<()> {
let driver = WebSocketDriver::new();
let listener = driver.listen(listen_addr).await?;
info!("listening for direct connections on {}", listen_addr);
loop {
tokio::select! {
result = listener.accept() => {
match result {
Ok(stream) => {
info!("accepted direct connection");
let key = key.clone();
let policy = policy.clone();
let audit = audit.clone();
let agent_id = cfg.id.clone();
let secret_store = secret_store.clone();
let counters = counters.clone();
let compat = compat_mode;
tokio::spawn(async move {
if let Err(e) = handle_direct_connection(stream, &key, &policy, &audit, &agent_id, &secret_store, &counters, compat).await {
warn!("direct session error: {}", e);
}
});
}
Err(e) => {
error!("accept error: {}", e);
}
}
}
_ = tokio::signal::ctrl_c() => {
info!("received SIGINT, shutting down listener");
break;
}
}
}
Ok(())
}
async fn handle_direct_connection(
mut stream: Box<dyn rf_transport::driver::AsyncStream>,
key: &StaticKey,
policy: &Arc<RwLock<RpcPolicy>>,
audit: &Arc<dyn rf_audit::logger::AuditLogger>,
agent_id: &str,
secret_store: &Option<Arc<tokio::sync::Mutex<SecretStore>>>,
counters: &Option<RfCounters>,
compat_mode: bool,
) -> anyhow::Result<()> {
info!("performing Noise XX handshake...");
let handshake_start = std::time::Instant::now();
let (state, peer_key) = if compat_mode {
info!("compatibility mode enabled — using relaxed handshake timing");
handshake_with_compat(&mut stream, false, key, true).await?
} else {
handshake(&mut stream, false, key).await?
};
let handshake_latency_us = handshake_start.elapsed().as_micros() as u64;
info!("handshake complete, peer key: {}", hex::encode(peer_key));
if let Some(c) = counters {
c.4.fetch_add(1, std::sync::atomic::Ordering::Relaxed); c.5.fetch_add(handshake_latency_us, std::sync::atomic::Ordering::Relaxed); c.3.fetch_add(1, std::sync::atomic::Ordering::Relaxed); }
let _conn = ConnectionTracker(counters);
let (stream_read, stream_write) = tokio::io::split(stream);
let chan = Arc::new(SecureChannel::new(
stream_read,
stream_write,
state,
peer_key,
));
let mut executor_builder = Executor::new(policy.clone(), audit.clone(), hex::encode(peer_key))
.with_agent_id(agent_id.to_string())
.with_start_time(std::time::Instant::now());
if let Some(secrets) = secret_store {
executor_builder = executor_builder.with_secrets(secrets.clone());
}
if let Some(c) = counters {
executor_builder = executor_builder.with_counters(
Some(c.0.clone()),
Some(c.1.clone()),
Some(c.2.clone()),
Some(c.3.clone()),
Some(c.4.clone()),
Some(c.5.clone()),
);
}
let executor = executor_builder;
info!("direct session ready, waiting for RPC requests");
loop {
let data = match chan.recv().await {
Ok(d) => {
if d.is_empty() {
info!("received close-notify from peer");
return Ok(());
}
d
}
Err(rf_crypto::error::CryptoError::TamperDetected) => {
error!("TAMPER DETECTED: MAC verification failed");
let _ = audit.log(rf_audit::types::AuditEntry {
timestamp: chrono::Utc::now(),
request_id: "SECURITY".into(),
action: "tamper_detected".into(),
command: None,
decision: "abandon_path".into(),
matched_rule: "MAC verification failure".into(),
exit_code: None,
duration_ms: 0,
caller_key: String::new(),
reason: None,
prev_hash: None,
hmac: None,
});
return Err(anyhow::anyhow!("tamper detected"));
}
Err(rf_crypto::error::CryptoError::FrameInjection) => {
error!("FRAME INJECTION: unexpected bytes in protocol framing");
let _ = audit.log(rf_audit::types::AuditEntry {
timestamp: chrono::Utc::now(),
request_id: "SECURITY".into(),
action: "frame_injection".into(),
command: None,
decision: "abandon_path".into(),
matched_rule: "invalid frame size".into(),
exit_code: None,
duration_ms: 0,
caller_key: String::new(),
reason: None,
prev_hash: None,
hmac: None,
});
return Err(anyhow::anyhow!("frame injection detected"));
}
Err(e) => return Err(anyhow::anyhow!("channel recv: {e}")),
};
let request: Request = match codec::decode(&data) {
Ok(r) => r,
Err(e) => {
error!("failed to decode request: {}", e);
continue;
}
};
info!(
"received request: {} action={:?}",
request.id, request.action
);
if let Action::ProxyOpen {
ref target,
idle_timeout_secs,
max_duration_secs,
} = request.action
{
return handle_proxy_open(
&chan,
&request.id,
target,
idle_timeout_secs,
max_duration_secs,
policy,
audit,
hex::encode(peer_key),
)
.await;
}
if let Action::FilePushStream {
ref path,
total_size,
ref checksum,
mode,
compress,
} = request.action
{
return handle_file_push_stream(
&chan,
&request.id,
path,
total_size,
checksum.as_deref(),
mode,
compress,
policy,
audit,
hex::encode(peer_key),
)
.await;
}
if let Action::FilePullStream { ref path, compress } = request.action {
return handle_file_pull_stream(
&chan,
&request.id,
path,
compress,
policy,
audit,
hex::encode(peer_key),
)
.await;
}
if let Action::StreamExecute {
command,
env,
workdir,
} = &request.action
{
let (tx, mut rx) = mpsc::channel::<Response>(64);
let pol = policy.clone();
let aud = audit.clone();
let cmd = command.clone();
let env_map = env.clone();
let wd = workdir.clone();
let rid = request.id.clone();
let ck = hex::encode(peer_key);
tokio::spawn(async move {
rf_executor::streaming::stream_execute(rid, &cmd, &env_map, &wd, pol, aud, &ck, tx)
.await;
});
while let Some(resp) = rx.recv().await {
let resp_data = match codec::encode(&resp) {
Ok(d) => d,
Err(e) => {
error!("encode error: {}", e);
break;
}
};
if let Err(e) = chan.send(&resp_data).await {
error!("channel send: {}", e);
break;
}
}
continue;
}
let response: Response = executor.handle(request).await;
let resp_data = codec::encode(&response)?;
if let Err(e) = chan.send(&resp_data).await {
return Err(anyhow::anyhow!("channel send: {e}"));
}
}
}
async fn relay_health_prober(relays: Vec<String>, token: String) {
use tokio::time::interval;
let probe_interval = Duration::from_secs(300); let mut ticker = interval(probe_interval);
info!(
"relay health prober started: {} relays, interval={}s",
relays.len(),
probe_interval.as_secs()
);
for relay_url in &relays {
let rtt = probe_single_relay(relay_url, &token).await;
match rtt {
Some(ms) => info!("relay health: {} RTT={}ms", relay_url, ms),
None => warn!("relay health: {} UNREACHABLE", relay_url),
}
}
loop {
ticker.tick().await;
for relay_url in &relays {
let rtt = probe_single_relay(relay_url, &token).await;
match rtt {
Some(ms) => info!("relay health: {} RTT={}ms", relay_url, ms),
None => warn!("relay health: {} UNREACHABLE", relay_url),
}
}
}
}
async fn probe_single_relay(relay_url: &str, token: &str) -> Option<u32> {
let driver = WebSocketDriver::new();
let probe_token = format!("__health_probe__{token}");
let target = Target {
agent_id: "health-probe".into(),
relay_url: Some(relay_url.to_string()),
meet_token: Some(probe_token),
};
let start = Instant::now();
match tokio::time::timeout(Duration::from_secs(5), async {
let _stream = driver.dial(&target, &Default::default()).await?;
Ok::<_, anyhow::Error>(())
})
.await
{
Ok(Ok(())) => {
let rtt = start.elapsed().as_millis() as u32;
Some(rtt)
}
_ => None,
}
}
async fn run_session_for_relay(
relay_url: &str,
cfg: &ResolvedConfig,
key: &StaticKey,
policy: &Arc<RwLock<RpcPolicy>>,
audit: &Arc<dyn rf_audit::logger::AuditLogger>,
secret_store: &Option<Arc<tokio::sync::Mutex<SecretStore>>>,
counters: &Option<RfCounters>,
compat_mode: bool,
) -> anyhow::Result<()> {
let driver = WebSocketDriver::new();
let target = Target {
agent_id: cfg.id.clone(),
relay_url: Some(relay_url.to_string()),
meet_token: Some(cfg.token.clone()),
};
info!("connecting to relay: {}", relay_url);
let mut stream = driver.dial(&target, &Default::default()).await?;
info!("performing Noise XX handshake...");
let handshake_start = std::time::Instant::now();
let (state, peer_key) = if compat_mode {
info!("compatibility mode enabled — using relaxed handshake timing");
handshake_with_compat(&mut stream, false, key, true).await?
} else {
handshake(&mut stream, false, key).await?
};
let handshake_latency_us = handshake_start.elapsed().as_micros() as u64;
info!("handshake complete, peer key: {}", hex::encode(peer_key));
if let Some(c) = counters {
c.4.fetch_add(1, std::sync::atomic::Ordering::Relaxed); c.5.fetch_add(handshake_latency_us, std::sync::atomic::Ordering::Relaxed); c.3.fetch_add(1, std::sync::atomic::Ordering::Relaxed); }
let _conn = ConnectionTracker(counters);
let (stream_read, stream_write) = tokio::io::split(stream);
let chan = Arc::new(SecureChannel::new(
stream_read,
stream_write,
state,
peer_key,
));
let mut executor_builder = Executor::new(policy.clone(), audit.clone(), hex::encode(peer_key))
.with_agent_id(cfg.id.clone())
.with_region(cfg.region.clone())
.with_start_time(std::time::Instant::now());
if let Some(secrets) = secret_store {
executor_builder = executor_builder.with_secrets(secrets.clone());
}
if let Some(c) = counters {
executor_builder = executor_builder.with_counters(
Some(c.0.clone()),
Some(c.1.clone()),
Some(c.2.clone()),
Some(c.3.clone()),
Some(c.4.clone()),
Some(c.5.clone()),
);
}
let executor = executor_builder;
info!("agent {} ready, waiting for RPC requests", cfg.id);
loop {
let data = tokio::select! {
result = chan.recv() => {
match result {
Ok(d) => {
if d.is_empty() {
info!("received close-notify from peer");
return Ok(());
}
d
}
Err(rf_crypto::error::CryptoError::TamperDetected) => {
error!("TAMPER DETECTED: MAC verification failed — possible MITM attack");
let _ = audit.log(rf_audit::types::AuditEntry {
timestamp: chrono::Utc::now(),
request_id: "SECURITY".into(),
action: "tamper_detected".into(),
command: None,
decision: "abandon_path".into(),
matched_rule: "MAC verification failure".into(),
exit_code: None,
duration_ms: 0,
caller_key: String::new(),
reason: None,
prev_hash: None,
hmac: None
});
return Err(anyhow::anyhow!("tamper detected: MAC verification failed"));
}
Err(rf_crypto::error::CryptoError::FrameInjection) => {
error!("FRAME INJECTION: unexpected bytes in protocol framing");
let _ = audit.log(rf_audit::types::AuditEntry {
timestamp: chrono::Utc::now(),
request_id: "SECURITY".into(),
action: "frame_injection".into(),
command: None,
decision: "abandon_path".into(),
matched_rule: "invalid frame size".into(),
exit_code: None,
duration_ms: 0,
caller_key: String::new(),
reason: None,
prev_hash: None,
hmac: None
});
return Err(anyhow::anyhow!("frame injection detected"));
}
Err(e) => return Err(anyhow::anyhow!("channel recv: {e}")),
}
}
_ = tokio::signal::ctrl_c() => {
info!("received SIGINT during session, sending close-notify...");
if let Err(e) = chan.close_notify().await {
warn!("failed to send close-notify: {}", e);
}
return Ok(());
}
};
let request: Request = match codec::decode(&data) {
Ok(r) => r,
Err(e) => {
error!("failed to decode request: {}", e);
continue;
}
};
info!(
"received request: {} action={:?}",
request.id, request.action
);
if let Action::ProxyOpen {
ref target,
idle_timeout_secs,
max_duration_secs,
} = request.action
{
return handle_proxy_open(
&chan,
&request.id,
target,
idle_timeout_secs,
max_duration_secs,
policy,
audit,
hex::encode(peer_key),
)
.await;
}
if let Action::FilePushStream {
ref path,
total_size,
ref checksum,
mode,
compress,
} = request.action
{
return handle_file_push_stream(
&chan,
&request.id,
path,
total_size,
checksum.as_deref(),
mode,
compress,
policy,
audit,
hex::encode(peer_key),
)
.await;
}
if let Action::FilePullStream { ref path, compress } = request.action {
return handle_file_pull_stream(
&chan,
&request.id,
path,
compress,
policy,
audit,
hex::encode(peer_key),
)
.await;
}
if let Action::StreamExecute {
command,
env,
workdir,
} = &request.action
{
let (tx, mut rx) = mpsc::channel::<Response>(64);
let pol = policy.clone();
let aud = audit.clone();
let cmd = command.clone();
let env_map = env.clone();
let wd = workdir.clone();
let rid = request.id.clone();
let ck = hex::encode(peer_key);
tokio::spawn(async move {
rf_executor::streaming::stream_execute(rid, &cmd, &env_map, &wd, pol, aud, &ck, tx)
.await;
});
while let Some(resp) = rx.recv().await {
let resp_data = match codec::encode(&resp) {
Ok(d) => d,
Err(e) => {
error!("encode error: {}", e);
break;
}
};
if let Err(e) = chan.send(&resp_data).await {
error!("channel send: {}", e);
break;
}
}
continue;
}
let response: Response = executor.handle(request).await;
let resp_data = codec::encode(&response)?;
if let Err(e) = chan.send(&resp_data).await {
return Err(anyhow::anyhow!("channel send: {e}"));
}
}
}
async fn handle_proxy_open<R, W>(
chan: &Arc<SecureChannel<R, W>>,
request_id: &str,
target: &str,
idle_timeout_secs: Option<u32>,
max_duration_secs: Option<u32>,
policy: &Arc<RwLock<RpcPolicy>>,
audit: &Arc<dyn rf_audit::logger::AuditLogger>,
caller_key: String,
) -> anyhow::Result<()>
where
R: tokio::io::AsyncRead + Unpin + Send + 'static,
W: tokio::io::AsyncWrite + Unpin + Send + 'static,
{
let policy_guard = policy.read().await;
let decision = policy_guard.check_network_target(target);
let idle = idle_timeout_secs.unwrap_or(policy_guard.proxy_idle_timeout_seconds);
let max = max_duration_secs.unwrap_or(policy_guard.proxy_max_duration_seconds);
drop(policy_guard);
if !decision.allowed {
let _ = audit.log(AuditEntry {
timestamp: chrono::Utc::now(),
request_id: request_id.to_string(),
action: "proxy_open".into(),
command: Some(target.to_string()),
decision: "denied".into(),
matched_rule: decision.matched_rule.clone(),
exit_code: None,
duration_ms: 0,
caller_key: caller_key.clone(),
reason: None,
prev_hash: None,
hmac: None,
});
let response = Response {
id: request_id.to_string(),
result: RpcResult::Denied {
reason: decision.reason,
rule: decision.matched_rule,
},
};
let data = codec::encode(&response)?;
chan.send(&data).await?;
return Ok(());
}
let tcp = match tokio::net::TcpStream::connect(target).await {
Ok(t) => t,
Err(e) => {
let response = Response {
id: request_id.to_string(),
result: RpcResult::Error {
message: format!("connect to {target}: {e}"),
},
};
let data = codec::encode(&response)?;
chan.send(&data).await?;
return Ok(());
}
};
let proxy_id = format!("proxy-{}", &request_id[..8.min(request_id.len())]);
let _ = audit.log(AuditEntry {
timestamp: chrono::Utc::now(),
request_id: request_id.to_string(),
action: "proxy_open".into(),
command: Some(target.to_string()),
decision: "allowed".into(),
matched_rule: decision.matched_rule,
exit_code: None,
duration_ms: 0,
caller_key: caller_key.clone(),
reason: None,
prev_hash: None,
hmac: None,
});
let response = Response {
id: request_id.to_string(),
result: RpcResult::ProxyReady {
proxy_id: proxy_id.clone(),
idle_timeout_secs: idle,
max_duration_secs: max,
},
};
let data = codec::encode(&response)?;
chan.send(&data).await?;
run_proxy_tunnel(chan.clone(), tcp, idle, max).await?;
let _ = audit.log(AuditEntry {
timestamp: chrono::Utc::now(),
request_id: request_id.to_string(),
action: "proxy_close".into(),
command: Some(target.to_string()),
decision: "allowed".into(),
matched_rule: "tunnel-closed".into(),
exit_code: Some(0),
duration_ms: 0,
caller_key,
reason: None,
prev_hash: None,
hmac: None,
});
Ok(())
}
async fn run_proxy_tunnel<R, W>(
chan: Arc<SecureChannel<R, W>>,
tcp: tokio::net::TcpStream,
idle_secs: u32,
max_secs: u32,
) -> anyhow::Result<()>
where
R: tokio::io::AsyncRead + Unpin + Send + 'static,
W: tokio::io::AsyncWrite + Unpin + Send + 'static,
{
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::time::{Duration, Instant};
let deadline = Instant::now() + Duration::from_secs(u64::from(max_secs));
let idle_dur = Duration::from_secs(u64::from(idle_secs));
let (mut tcp_r, mut tcp_w) = tcp.into_split();
let chan_a = chan.clone();
let t_tcp_to_chan = tokio::spawn(async move {
let mut buf = vec![0u8; 65535];
loop {
match tcp_r.read(&mut buf).await {
Ok(0) => break,
Ok(n) => {
if chan_a.send(&buf[..n]).await.is_err() {
break;
}
}
Err(_) => break,
}
}
});
let chan_b = chan;
let t_chan_to_tcp = tokio::spawn(async move {
loop {
match chan_b.recv().await {
Ok(data) if data.is_empty() => break, Ok(data) => {
if tcp_w.write_all(&data).await.is_err() {
break;
}
}
Err(_) => break,
}
}
});
let remaining = deadline.saturating_duration_since(Instant::now());
tokio::select! {
_ = t_tcp_to_chan => {}
_ = t_chan_to_tcp => {}
_ = tokio::time::sleep(remaining) => {}
}
let _ = idle_dur;
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn handle_file_push_stream<R, W>(
chan: &Arc<SecureChannel<R, W>>,
request_id: &str,
path: &str,
total_size: u64,
checksum: Option<&str>,
mode: Option<u32>,
_compress: bool,
policy: &Arc<RwLock<RpcPolicy>>,
audit: &Arc<dyn rf_audit::logger::AuditLogger>,
caller_key: String,
) -> anyhow::Result<()>
where
R: tokio::io::AsyncRead + Unpin + Send + 'static,
W: tokio::io::AsyncWrite + Unpin + Send + 'static,
{
use sha2::{Digest, Sha256};
use std::path::Path;
use tokio::io::AsyncWriteExt;
let canonical = {
let p = Path::new(path);
if let Some(parent) = p.parent() {
if parent.exists() {
match std::fs::canonicalize(parent) {
Ok(c) => c
.join(p.file_name().unwrap_or_default())
.to_string_lossy()
.into_owned(),
Err(_) => path.to_string(),
}
} else {
path.to_string()
}
} else {
path.to_string()
}
};
let policy_guard = policy.read().await;
let decision = policy_guard.check_path(std::path::Path::new(&canonical));
let max_output = policy_guard.max_output_bytes;
drop(policy_guard);
if !decision.allowed {
let _ = audit.log(AuditEntry {
timestamp: chrono::Utc::now(),
request_id: request_id.to_string(),
action: "file_push_stream".into(),
command: Some(path.to_string()),
decision: "denied".into(),
matched_rule: decision.matched_rule.clone(),
exit_code: None,
duration_ms: 0,
caller_key: caller_key.clone(),
reason: None,
prev_hash: None,
hmac: None,
});
let response = Response {
id: request_id.to_string(),
result: RpcResult::Denied {
reason: decision.reason,
rule: decision.matched_rule,
},
};
let data = codec::encode(&response)?;
chan.send(&data).await?;
return Ok(());
}
let size_limit = if max_output > 0 { max_output } else { u64::MAX };
if total_size > size_limit {
let response = Response {
id: request_id.to_string(),
result: RpcResult::Error {
message: format!(
"file too large: {total_size} bytes exceeds limit of {size_limit}"
),
},
};
let data = codec::encode(&response)?;
chan.send(&data).await?;
return Ok(());
}
let _ = audit.log(AuditEntry {
timestamp: chrono::Utc::now(),
request_id: request_id.to_string(),
action: "file_push_stream".into(),
command: Some(path.to_string()),
decision: "allowed".into(),
matched_rule: decision.matched_rule,
exit_code: None,
duration_ms: 0,
caller_key: caller_key.clone(),
reason: None,
prev_hash: None,
hmac: None,
});
let ready = Response {
id: request_id.to_string(),
result: RpcResult::FileStreamReady {
total_size: 0,
checksum: None,
},
};
let data = codec::encode(&ready)?;
chan.send(&data).await?;
let dest_path = Path::new(path);
let parent = dest_path.parent().unwrap_or(Path::new("/tmp"));
let tmp_path = parent.join(format!(".raven_tmp_{request_id}"));
let result: anyhow::Result<(u64, bool)> = async {
let mut file = tokio::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&tmp_path)
.await
.map_err(|e| anyhow::anyhow!("open temp file: {e}"))?;
let mut hasher = Sha256::new();
let mut received: u64 = 0;
while received < total_size {
let chunk = chan
.recv()
.await
.map_err(|e| anyhow::anyhow!("recv: {e}"))?;
if chunk.is_empty() {
return Err(anyhow::anyhow!(
"connection closed before transfer complete"
));
}
received += chunk.len() as u64;
if received > total_size {
return Err(anyhow::anyhow!(
"client sent more bytes than declared total_size"
));
}
hasher.update(&chunk);
file.write_all(&chunk)
.await
.map_err(|e| anyhow::anyhow!("write: {e}"))?;
}
file.flush()
.await
.map_err(|e| anyhow::anyhow!("flush: {e}"))?;
drop(file);
let checksum_ok = if let Some(expected) = checksum {
let digest = hasher.finalize();
let actual: String = digest.iter().map(|b| format!("{b:02x}")).collect();
actual == expected
} else {
true };
if !checksum_ok {
return Err(anyhow::anyhow!("checksum mismatch"));
}
#[cfg(unix)]
if let Some(m) = mode {
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(m);
std::fs::set_permissions(&tmp_path, perms)
.map_err(|e| anyhow::anyhow!("chmod: {e}"))?;
}
tokio::fs::rename(&tmp_path, dest_path)
.await
.map_err(|e| anyhow::anyhow!("rename: {e}"))?;
Ok((received, checksum.is_none() || checksum_ok))
}
.await;
if result.is_err() {
let _ = tokio::fs::remove_file(&tmp_path).await;
}
let (bytes_transferred, checksum_verified) = match result {
Ok(v) => v,
Err(e) => {
let response = Response {
id: request_id.to_string(),
result: RpcResult::Error {
message: format!("stream upload failed: {e}"),
},
};
let data = codec::encode(&response)?;
chan.send(&data).await?;
return Err(e);
}
};
let _ = audit.log(AuditEntry {
timestamp: chrono::Utc::now(),
request_id: request_id.to_string(),
action: "file_push_stream_done".into(),
command: Some(path.to_string()),
decision: "allowed".into(),
matched_rule: "transfer-complete".into(),
exit_code: Some(0),
duration_ms: 0,
caller_key,
reason: None,
prev_hash: None,
hmac: None,
});
let done = Response {
id: request_id.to_string(),
result: RpcResult::FileStreamDone {
bytes_transferred,
checksum_verified,
},
};
let data = codec::encode(&done)?;
chan.send(&data).await?;
Ok(())
}
async fn handle_file_pull_stream<R, W>(
chan: &Arc<SecureChannel<R, W>>,
request_id: &str,
path: &str,
_compress: bool,
policy: &Arc<RwLock<RpcPolicy>>,
audit: &Arc<dyn rf_audit::logger::AuditLogger>,
caller_key: String,
) -> anyhow::Result<()>
where
R: tokio::io::AsyncRead + Unpin + Send + 'static,
W: tokio::io::AsyncWrite + Unpin + Send + 'static,
{
use sha2::{Digest, Sha256};
use std::path::Path;
let canonical = match std::fs::canonicalize(path) {
Ok(c) => c.to_string_lossy().into_owned(),
Err(_) => path.to_string(),
};
let policy_guard = policy.read().await;
let decision = policy_guard.check_path(std::path::Path::new(&canonical));
drop(policy_guard);
if !decision.allowed {
let _ = audit.log(AuditEntry {
timestamp: chrono::Utc::now(),
request_id: request_id.to_string(),
action: "file_pull_stream".into(),
command: Some(path.to_string()),
decision: "denied".into(),
matched_rule: decision.matched_rule.clone(),
exit_code: None,
duration_ms: 0,
caller_key: caller_key.clone(),
reason: None,
prev_hash: None,
hmac: None,
});
let response = Response {
id: request_id.to_string(),
result: RpcResult::Denied {
reason: decision.reason,
rule: decision.matched_rule,
},
};
let data = codec::encode(&response)?;
chan.send(&data).await?;
return Ok(());
}
let file_data = match tokio::fs::read(Path::new(path)).await {
Ok(d) => d,
Err(e) => {
let response = Response {
id: request_id.to_string(),
result: RpcResult::Error {
message: format!("read {path}: {e}"),
},
};
let data = codec::encode(&response)?;
chan.send(&data).await?;
return Ok(());
}
};
let total_size = file_data.len() as u64;
let digest = Sha256::digest(&file_data);
let checksum: String = digest.iter().map(|b| format!("{b:02x}")).collect();
let _ = audit.log(AuditEntry {
timestamp: chrono::Utc::now(),
request_id: request_id.to_string(),
action: "file_pull_stream".into(),
command: Some(path.to_string()),
decision: "allowed".into(),
matched_rule: decision.matched_rule,
exit_code: None,
duration_ms: 0,
caller_key: caller_key.clone(),
reason: None,
prev_hash: None,
hmac: None,
});
let ready = Response {
id: request_id.to_string(),
result: RpcResult::FileStreamReady {
total_size,
checksum: Some(checksum),
},
};
let data = codec::encode(&ready)?;
chan.send(&data).await?;
const CHUNK: usize = 65519;
let mut offset = 0;
while offset < file_data.len() {
let end = (offset + CHUNK).min(file_data.len());
chan.send(&file_data[offset..end])
.await
.map_err(|e| anyhow::anyhow!("send: {e}"))?;
offset = end;
}
chan.flush()
.await
.map_err(|e| anyhow::anyhow!("flush: {e}"))?;
let _ = audit.log(AuditEntry {
timestamp: chrono::Utc::now(),
request_id: request_id.to_string(),
action: "file_pull_stream_done".into(),
command: Some(path.to_string()),
decision: "allowed".into(),
matched_rule: "transfer-complete".into(),
exit_code: Some(0),
duration_ms: 0,
caller_key,
reason: None,
prev_hash: None,
hmac: None,
});
Ok(())
}