use std::path::PathBuf;
use std::sync::Arc;
use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::Shell;
use tokio::sync::{RwLock, mpsc};
use tokio_util::sync::CancellationToken;
use tracing::{error, info, warn};
use rf_crypto::channel::SecureChannel;
use rf_crypto::keys::StaticKey;
use rf_crypto::noise::{handshake, handshake_with_compat};
use rf_policy::templates::TemplateRegistry;
use rf_rpc::codec;
use rf_rpc::types::{Action, Request, Response, RpcResult};
use rf_transport::driver::{AsyncStream, Driver, Target};
use rf_transport::websocket::WebSocketDriver;
type AgentChannel = SecureChannel<
tokio::io::ReadHalf<Box<dyn AsyncStream>>,
tokio::io::WriteHalf<Box<dyn AsyncStream>>,
>;
#[derive(Parser)]
#[command(name = "rf", about = "RavenFabric — secure remote execution", version)]
struct Cli {
#[arg(short, long, env = "RF_RELAY", default_value = "ws://127.0.0.1:9090")]
relay: String,
#[arg(short = 'C', long, env = "RF_CONNECT")]
connect: Option<String>,
#[arg(short, long, default_value = "client.key")]
key_path: PathBuf,
#[arg(long)]
compat_mode: bool,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Exec {
#[arg(short, long)]
token: String,
#[arg(short, long, default_value_t = false)]
stream: bool,
#[arg(short, long, default_value_t = false)]
background: bool,
#[arg(long)]
reason: Option<String>,
command: String,
},
Shell {
#[arg(short, long)]
token: String,
#[arg(long, default_value = "80")]
cols: u16,
#[arg(long, default_value = "24")]
rows: u16,
},
Forward {
#[arg(short, long)]
token: String,
#[arg(short = 'L', long)]
local: String,
#[arg(short = 'R', long)]
remote: String,
},
Playbook {
file: PathBuf,
#[arg(short, long)]
token: String,
},
Dev {
#[arg(short, long, default_value = "9090")]
port: u16,
#[arg(short, long, default_value = "127.0.0.1")]
bind: String,
},
Status {
#[arg(short, long)]
token: String,
},
Completions {
shell: Shell,
},
Policy {
#[command(subcommand)]
action: PolicyAction,
},
Cp {
#[arg(short, long)]
token: String,
source: String,
dest: String,
#[arg(long, default_value = "262144")]
chunk_size: u32,
#[arg(short, long)]
recursive: bool,
#[arg(long)]
delta: bool,
},
#[command(name = "proxy")]
Proxy {
#[arg(short, long)]
token: String,
#[arg(long)]
target: String,
#[arg(short, long, default_value = "127.0.0.1:8080")]
listen: String,
#[arg(long)]
idle_timeout: Option<u32>,
#[arg(long)]
max_duration: Option<u32>,
#[arg(long)]
http: bool,
},
Secret {
#[command(subcommand)]
action: SecretAction,
},
Audit {
#[command(subcommand)]
action: AuditAction,
},
}
#[derive(Subcommand)]
enum AuditAction {
Verify {
file: PathBuf,
#[arg(short, long)]
key_file: PathBuf,
},
DeriveKey {
key_file: PathBuf,
},
}
#[derive(Subcommand)]
enum PolicyAction {
List,
Show {
name: String,
},
Validate {
#[arg(short, long)]
file: Option<PathBuf>,
#[arg(short, long)]
template: Option<String>,
},
Lint {
#[arg(short, long)]
file: Option<PathBuf>,
#[arg(short, long)]
template: Option<String>,
},
Compose {
templates: String,
},
}
#[derive(Subcommand)]
enum SecretAction {
Push {
#[arg(short, long)]
token: String,
#[arg(short, long)]
name: String,
#[arg(long)]
value: String,
#[arg(long, default_value = "0")]
grace_period: u64,
},
List {
#[arg(short, long)]
token: String,
},
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("rf=info,rf_relay=info")),
)
.init();
let cli = Cli::parse();
match cli.command {
Commands::Exec {
token,
command,
stream,
background,
reason,
} => {
exec_command(
&cli.relay,
cli.connect.as_deref(),
&cli.key_path,
&token,
&command,
stream,
background,
reason,
cli.compat_mode,
)
.await?;
}
Commands::Shell { token, cols, rows } => {
shell_command(
&cli.relay,
cli.connect.as_deref(),
&cli.key_path,
&token,
cols,
rows,
cli.compat_mode,
)
.await?;
}
Commands::Forward {
token,
local,
remote,
} => {
forward_command(
&cli.relay,
cli.connect.as_deref(),
&cli.key_path,
&token,
&local,
&remote,
cli.compat_mode,
)
.await?;
}
Commands::Playbook { file, token } => {
playbook_command(
&cli.relay,
cli.connect.as_deref(),
&cli.key_path,
&token,
&file,
cli.compat_mode,
)
.await?;
}
Commands::Dev { port, bind } => {
dev_mode(port, &bind).await?;
}
Commands::Status { token } => {
status_command(
&cli.relay,
cli.connect.as_deref(),
&cli.key_path,
&token,
cli.compat_mode,
)
.await?;
}
Commands::Completions { shell } => {
clap_complete::generate(shell, &mut Cli::command(), "rf", &mut std::io::stdout());
}
Commands::Policy { action } => {
policy_command(action)?;
}
Commands::Cp {
token,
source,
dest,
chunk_size,
recursive,
delta,
} => {
cp_command(
&cli.relay,
cli.connect.as_deref(),
&cli.key_path,
&token,
&source,
&dest,
chunk_size,
recursive,
delta,
cli.compat_mode,
)
.await?;
}
Commands::Proxy {
token,
target,
listen,
idle_timeout,
max_duration,
http,
} => {
proxy_command(
&cli.relay,
cli.connect.as_deref(),
&cli.key_path,
&token,
&target,
&listen,
idle_timeout,
max_duration,
http,
cli.compat_mode,
)
.await?;
}
Commands::Secret { action } => {
secret_command(
&cli.relay,
cli.connect.as_deref(),
&cli.key_path,
action,
cli.compat_mode,
)
.await?;
}
Commands::Audit { action } => {
audit_command(action)?;
}
}
Ok(())
}
fn split_relay_urls(relay_url: &str) -> Vec<String> {
relay_url
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect()
}
async fn dial_agent(
relay_url: &str,
direct_addr: Option<&str>,
key: &StaticKey,
token: &str,
compat_mode: bool,
) -> anyhow::Result<(AgentChannel, [u8; 32])> {
let driver = WebSocketDriver::new();
if let Some(addr) = direct_addr {
info!("connecting directly to agent: {}", addr);
let target = Target {
agent_id: String::new(),
relay_url: Some(addr.to_string()),
meet_token: None,
};
let mut stream = driver.dial(&target, &Default::default()).await?;
let (state, peer_key) = if compat_mode {
handshake_with_compat(&mut stream, true, key, true).await?
} else {
handshake(&mut stream, true, key).await?
};
info!("connected to agent: {}", hex::encode(peer_key));
let (stream_read, stream_write) = tokio::io::split(stream);
let chan = SecureChannel::new(stream_read, stream_write, state, peer_key);
return Ok((chan, peer_key));
}
let relay_urls = split_relay_urls(relay_url);
let mut last_err: Option<anyhow::Error> = None;
for relay in &relay_urls {
info!("connecting to relay: {}", relay);
let target = Target {
agent_id: String::new(),
relay_url: Some(relay.to_string()),
meet_token: Some(token.to_string()),
};
let mut stream = match driver.dial(&target, &Default::default()).await {
Ok(s) => s,
Err(e) => {
warn!("relay {} unreachable: {}; failing over", relay, e);
last_err = Some(e.into());
continue;
}
};
info!("performing Noise XX handshake...");
let (state, peer_key) = if compat_mode {
info!("compatibility mode enabled — using relaxed handshake timing");
match handshake_with_compat(&mut stream, true, key, true).await {
Ok(r) => r,
Err(e) => {
warn!("relay {} handshake failed: {}; failing over", relay, e);
last_err = Some(e.into());
continue;
}
}
} else {
match handshake(&mut stream, true, key).await {
Ok(r) => r,
Err(e) => {
warn!("relay {} handshake failed: {}; failing over", relay, e);
last_err = Some(e.into());
continue;
}
}
};
info!("connected to agent: {}", hex::encode(peer_key));
let (stream_read, stream_write) = tokio::io::split(stream);
let chan = SecureChannel::new(stream_read, stream_write, state, peer_key);
return Ok((chan, peer_key));
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("no relays configured")))
}
#[allow(clippy::too_many_arguments)]
async fn exec_command(
relay_url: &str,
direct_addr: Option<&str>,
key_path: &std::path::Path,
token: &str,
command: &str,
streaming: bool,
background: bool,
reason: Option<String>,
compat_mode: bool,
) -> anyhow::Result<()> {
let key = StaticKey::load_or_generate(key_path)?;
info!("client public key: {}", key.public_hex());
let (chan, _peer_key) = dial_agent(relay_url, direct_addr, &key, token, compat_mode).await?;
let action = if background {
Action::BackgroundExec {
command: command.to_string(),
env: Default::default(),
workdir: None,
}
} else if streaming {
Action::StreamExecute {
command: command.to_string(),
env: Default::default(),
workdir: None,
}
} else {
Action::Execute {
command: command.to_string(),
env: Default::default(),
workdir: None,
}
};
let request = Request {
id: uuid::Uuid::new_v4().to_string(),
action,
timeout_ms: Some(30_000),
reason,
};
let req_data = codec::encode(&request)?;
chan.send(&req_data).await?;
if streaming {
loop {
let resp_data = chan.recv().await?;
let response: Response = codec::decode(&resp_data)?;
match response.result {
RpcResult::StreamChunk {
stream: stream_type,
data,
} => {
use rf_rpc::types::StreamType;
match stream_type {
StreamType::Stdout => {
let out = String::from_utf8_lossy(&data);
print!("{out}");
}
StreamType::Stderr => {
let err = String::from_utf8_lossy(&data);
eprint!("{err}");
}
}
}
RpcResult::StreamEnd {
exit_code,
duration_ms,
} => {
info!("exit_code={} duration={}ms", exit_code, duration_ms);
eprintln!("remote exit code: {exit_code}");
if exit_code != 0 {
std::process::exit(exit_code);
}
break;
}
RpcResult::Denied { reason, rule } => {
error!("DENIED: {} (rule: {})", reason, rule);
std::process::exit(1);
}
RpcResult::Error { message } => {
error!("ERROR: {}", message);
std::process::exit(1);
}
RpcResult::Success {
stdout,
stderr,
exit_code,
duration_ms,
} => {
if !stdout.is_empty() {
print!("{stdout}");
}
if !stderr.is_empty() {
eprint!("{stderr}");
}
info!("exit_code={} duration={}ms", exit_code, duration_ms);
eprintln!("remote exit code (fallback): {exit_code}");
if exit_code != 0 {
std::process::exit(exit_code);
}
break;
}
_ => {
error!("unexpected response in streaming mode");
std::process::exit(1);
}
}
}
let _ = chan.close_notify().await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
return Ok(());
}
let resp_data = chan.recv().await?;
let response: Response = codec::decode(&resp_data)?;
match response.result {
RpcResult::Success {
stdout,
stderr,
exit_code,
duration_ms,
} => {
if !stdout.is_empty() {
print!("{stdout}");
}
if !stderr.is_empty() {
eprint!("{stderr}");
}
info!("exit_code={} duration={}ms", exit_code, duration_ms);
eprintln!("remote exit code: {exit_code}");
if exit_code != 0 {
std::process::exit(exit_code);
}
}
RpcResult::Denied { reason, rule } => {
error!("DENIED: {} (rule: {})", reason, rule);
std::process::exit(1);
}
RpcResult::Error { message } => {
error!("ERROR: {}", message);
std::process::exit(1);
}
RpcResult::StatusInfo { .. } => {
error!("unexpected StatusInfo response for exec");
std::process::exit(1);
}
RpcResult::StreamChunk { .. } | RpcResult::StreamEnd { .. } => {
error!("unexpected streaming response for non-streaming exec");
std::process::exit(1);
}
RpcResult::JobStarted { job_id, pid } => {
println!("background job started: {job_id} (pid {pid})");
}
RpcResult::JobStatus {
job_id,
running,
exit_code,
stdout,
stderr,
} => {
if running {
println!("job {job_id} is still running");
} else {
if let Some(out) = stdout {
print!("{out}");
}
if let Some(err) = stderr {
eprint!("{err}");
}
let code = exit_code.unwrap_or(-1);
info!("job {} completed, exit_code={}", job_id, code);
if code != 0 {
std::process::exit(code);
}
}
}
RpcResult::Pong { timestamp_ms } => {
println!("pong (timestamp: {timestamp_ms}ms)");
}
RpcResult::ShellOpened { session_id } => {
println!("shell session opened: {session_id}");
}
RpcResult::ShellOutput { data, .. } => {
let output = String::from_utf8_lossy(&data);
print!("{output}");
}
RpcResult::ShellExited {
session_id,
exit_code,
} => {
println!("shell session {session_id} exited (code {exit_code})");
}
RpcResult::ForwardStarted {
forward_id,
bind_addr,
} => {
println!("port forward started: {forward_id} on {bind_addr}");
}
RpcResult::ForwardStopped { forward_id } => {
println!("port forward stopped: {forward_id}");
}
RpcResult::HealthCheckResult {
success,
latency_ms,
error,
} => {
if success {
println!("health check OK ({latency_ms}ms)");
} else {
println!(
"health check FAILED ({}ms): {}",
latency_ms,
error.unwrap_or_default()
);
}
}
RpcResult::TailOutput { lines, path } => {
println!("--- {path} ---");
for line in lines {
println!("{line}");
}
}
_ => {
error!("unexpected response: {:?}", response.result);
std::process::exit(1);
}
}
let _ = chan.close_notify().await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
Ok(())
}
async fn status_command(
relay_url: &str,
direct_addr: Option<&str>,
key_path: &std::path::Path,
token: &str,
compat_mode: bool,
) -> anyhow::Result<()> {
let key = StaticKey::load_or_generate(key_path)?;
info!("client public key: {}", key.public_hex());
let (chan, peer_key) = dial_agent(relay_url, direct_addr, &key, token, compat_mode).await?;
let request = Request {
id: uuid::Uuid::new_v4().to_string(),
action: Action::Status,
timeout_ms: Some(5_000),
reason: None,
};
let req_data = codec::encode(&request)?;
chan.send(&req_data).await?;
let resp_data = chan.recv().await?;
let response: Response = codec::decode(&resp_data)?;
match response.result {
RpcResult::StatusInfo {
agent_id,
version,
uptime_seconds,
region,
} => {
println!("Agent: {agent_id}");
println!("Version: {version}");
println!("Uptime: {uptime_seconds}s");
if let Some(r) = region {
println!("Region: {r}");
}
println!("Peer: {}", hex::encode(peer_key));
println!("Status: connected");
}
RpcResult::Error { message } => {
error!("ERROR: {}", message);
std::process::exit(1);
}
_ => {
error!("unexpected response type");
std::process::exit(1);
}
}
let _ = chan.close_notify().await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
Ok(())
}
async fn forward_command(
relay_url: &str,
direct_addr: Option<&str>,
key_path: &std::path::Path,
token: &str,
local_addr: &str,
remote_addr: &str,
compat_mode: bool,
) -> anyhow::Result<()> {
let key = StaticKey::load_or_generate(key_path)?;
info!("client public key: {}", key.public_hex());
let (chan, _peer_key) = dial_agent(relay_url, direct_addr, &key, token, compat_mode).await?;
let request = Request {
id: uuid::Uuid::new_v4().to_string(),
action: Action::PortForward {
bind_addr: local_addr.to_string(),
target_addr: remote_addr.to_string(),
},
timeout_ms: Some(10_000),
reason: None,
};
let req_data = codec::encode(&request)?;
chan.send(&req_data).await?;
let resp_data = chan.recv().await?;
let response: Response = codec::decode(&resp_data)?;
match response.result {
RpcResult::ForwardStarted {
forward_id,
bind_addr,
} => {
println!("Port forward active: {bind_addr} → {remote_addr} (id: {forward_id})");
println!("Press Ctrl+C to stop.");
tokio::signal::ctrl_c().await?;
let close_req = Request {
id: uuid::Uuid::new_v4().to_string(),
action: Action::PortForwardClose {
forward_id: forward_id.clone(),
},
timeout_ms: Some(5_000),
reason: None,
};
let close_data = codec::encode(&close_req)?;
chan.send(&close_data).await?;
println!("\nForward stopped.");
}
RpcResult::Denied { reason, rule } => {
error!("DENIED: {} (rule: {})", reason, rule);
std::process::exit(1);
}
RpcResult::Error { message } => {
error!("ERROR: {}", message);
std::process::exit(1);
}
_ => {
error!("unexpected response");
std::process::exit(1);
}
}
Ok(())
}
async fn playbook_command(
relay_url: &str,
_direct_addr: Option<&str>,
key_path: &std::path::Path,
token: &str,
file: &std::path::Path,
compat_mode: bool,
) -> anyhow::Result<()> {
use rf_executor::orchestrator::{AgentResult, OrchestrationPlan, Orchestrator, TargetGrain};
use std::time::Instant;
let yaml_content = std::fs::read_to_string(file)
.map_err(|e| anyhow::anyhow!("failed to read playbook {}: {}", file.display(), e))?;
let plan: OrchestrationPlan = serde_yaml::from_str(&yaml_content)
.map_err(|e| anyhow::anyhow!("failed to parse playbook YAML: {e}"))?;
let agents = match &plan.target {
TargetGrain::Agents(ids) => ids.clone(),
_ => {
anyhow::bail!("playbook target must use 'agents: [...]' for CLI execution");
}
};
println!("Playbook: {}", file.display());
println!("Command: {}", plan.command);
println!("Strategy: {:?}", plan.strategy);
println!("Agents: {agents:?}");
println!("---");
let key = StaticKey::load_or_generate(key_path)?;
let start = Instant::now();
let mut orch = Orchestrator::new(plan.clone(), agents);
while let Some(batch) = orch.next_batch() {
println!("Executing batch: {batch:?}");
let mut batch_results = Vec::new();
for agent_id in &batch {
let agent_start = Instant::now();
let result = execute_on_agent(
relay_url,
&key,
token,
&plan.command,
plan.timeout_secs,
compat_mode,
)
.await;
let agent_result = match result {
Ok((stdout, stderr, exit_code)) => {
let success = exit_code == 0;
let symbol = if success { "✓" } else { "✗" };
println!(" {symbol} {agent_id} (exit {exit_code})");
AgentResult {
agent_id: agent_id.clone(),
success,
exit_code: Some(exit_code),
stdout,
stderr,
duration_ms: agent_start.elapsed().as_millis() as u64,
}
}
Err(e) => {
println!(" ✗ {agent_id} (error: {e})");
AgentResult {
agent_id: agent_id.clone(),
success: false,
exit_code: None,
stdout: String::new(),
stderr: e.to_string(),
duration_ms: agent_start.elapsed().as_millis() as u64,
}
}
};
batch_results.push(agent_result);
}
let should_continue = orch.record_batch(batch_results);
if !should_continue {
println!("--- Batch failed, stopping rollout ---");
if let Some(rollback_cmd) = orch.rollback_command() {
let agents_to_rollback = orch.agents_needing_rollback();
if !agents_to_rollback.is_empty() {
println!(
"Rolling back {} agents: {}",
agents_to_rollback.len(),
rollback_cmd
);
for agent_id in agents_to_rollback {
let rb_result = execute_on_agent(
relay_url,
&key,
token,
rollback_cmd,
plan.timeout_secs,
compat_mode,
)
.await;
let symbol = if rb_result.is_ok() { "↩" } else { "!" };
println!(" {symbol} {agent_id} rollback");
}
}
}
break;
}
}
let result = orch.finalize(start.elapsed().as_millis() as u64);
println!("---");
println!(
"Result: {} ({}/{} agents succeeded, {}ms)",
if result.success { "SUCCESS" } else { "FAILED" },
result.results.iter().filter(|r| r.success).count(),
result.results.len(),
result.total_duration_ms,
);
if result.rollback_triggered {
println!("Rollback was triggered.");
}
if !result.success {
std::process::exit(1);
}
Ok(())
}
async fn execute_on_agent(
relay_url: &str,
key: &StaticKey,
token: &str,
command: &str,
timeout_secs: u64,
compat_mode: bool,
) -> anyhow::Result<(String, String, i32)> {
let (chan, _peer_key) = dial_agent(relay_url, None, key, token, compat_mode).await?;
let request = Request {
id: uuid::Uuid::new_v4().to_string(),
action: Action::Execute {
command: command.to_string(),
env: Default::default(),
workdir: None,
},
timeout_ms: Some(timeout_secs * 1000),
reason: None,
};
let req_data = codec::encode(&request)?;
chan.send(&req_data).await?;
let resp_data = chan.recv().await?;
let response: Response = codec::decode(&resp_data)?;
match response.result {
RpcResult::Success {
stdout,
stderr,
exit_code,
..
} => Ok((stdout, stderr, exit_code)),
RpcResult::Denied { reason, .. } => {
anyhow::bail!("denied: {reason}");
}
RpcResult::Error { message } => {
anyhow::bail!("{message}");
}
_ => anyhow::bail!("unexpected response"),
}
}
#[cfg(unix)]
async fn shell_command(
relay_url: &str,
direct_addr: Option<&str>,
key_path: &std::path::Path,
token: &str,
cols: u16,
rows: u16,
compat_mode: bool,
) -> anyhow::Result<()> {
use std::os::unix::io::AsRawFd;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let key = StaticKey::load_or_generate(key_path)?;
info!("client public key: {}", key.public_hex());
let (chan, _peer_key) = dial_agent(relay_url, direct_addr, &key, token, compat_mode).await?;
let chan = Arc::new(chan);
let request = Request {
id: uuid::Uuid::new_v4().to_string(),
action: Action::Shell {
shell: None, rows,
cols,
env: Default::default(),
},
timeout_ms: None,
reason: None,
};
let req_data = codec::encode(&request)?;
chan.send(&req_data).await?;
let resp_data = chan.recv().await?;
let response: Response = codec::decode(&resp_data)?;
let session_id = match response.result {
RpcResult::ShellOpened { session_id } => session_id,
RpcResult::Denied { reason, rule } => {
error!("DENIED: {} (rule: {})", reason, rule);
std::process::exit(1);
}
RpcResult::Error { message } => {
error!("ERROR: {}", message);
std::process::exit(1);
}
_ => {
error!("unexpected response");
std::process::exit(1);
}
};
info!("shell session opened: {}", session_id);
let stdin_fd = std::io::stdin().as_raw_fd();
let orig_termios = unsafe {
let mut termios = std::mem::zeroed::<libc::termios>();
libc::tcgetattr(stdin_fd, &mut termios);
let orig = termios;
libc::cfmakeraw(&mut termios);
libc::tcsetattr(stdin_fd, libc::TCSANOW, &termios);
orig
};
struct RawModeGuard {
fd: i32,
termios: libc::termios,
}
impl Drop for RawModeGuard {
fn drop(&mut self) {
unsafe {
libc::tcsetattr(self.fd, libc::TCSANOW, &self.termios);
}
}
}
let _guard = RawModeGuard {
fd: stdin_fd,
termios: orig_termios,
};
let cancel = CancellationToken::new();
let session_id_clone = session_id.clone();
let chan_write = chan.clone();
let cancel_stdin = cancel.clone();
let sid_write = session_id.clone();
let stdin_task = tokio::spawn(async move {
let mut stdin = tokio::io::stdin();
let mut buf = [0u8; 1024];
loop {
tokio::select! {
() = cancel_stdin.cancelled() => break,
result = stdin.read(&mut buf) => {
match result {
Ok(0) => break,
Ok(n) => {
let req = Request {
id: uuid::Uuid::new_v4().to_string(),
action: Action::ShellInput {
session_id: sid_write.clone(),
data: buf[..n].to_vec(),
},
timeout_ms: None,
reason: None,
};
if let Ok(data) = codec::encode(&req) {
if chan_write.send(&data).await.is_err() {
break;
}
}
}
Err(_) => break,
}
}
}
}
});
let mut stdout = tokio::io::stdout();
loop {
tokio::select! {
() = cancel.cancelled() => break,
result = chan.recv() => {
match result {
Ok(data) => {
let response: Response = match codec::decode(&data) {
Ok(r) => r,
Err(_) => continue,
};
match response.result {
RpcResult::ShellOutput { data, .. } => {
if !data.is_empty() {
let _ = stdout.write_all(&data).await;
let _ = stdout.flush().await;
}
}
RpcResult::ShellExited { exit_code, .. } => {
cancel.cancel();
drop(_guard);
std::process::exit(exit_code);
}
_ => {}
}
}
Err(_) => break,
}
}
}
}
let close_req = Request {
id: uuid::Uuid::new_v4().to_string(),
action: Action::ShellClose {
session_id: session_id_clone,
},
timeout_ms: Some(5_000),
reason: None,
};
if let Ok(data) = codec::encode(&close_req) {
let _ = chan.send(&data).await;
}
stdin_task.abort();
Ok(())
}
#[cfg(not(unix))]
async fn shell_command(
_relay_url: &str,
_direct_addr: Option<&str>,
_key_path: &std::path::Path,
_token: &str,
_cols: u16,
_rows: u16,
_compat_mode: bool,
) -> anyhow::Result<()> {
anyhow::bail!("interactive shell is not supported on this platform");
}
async fn dev_mode(port: u16, bind: &str) -> anyhow::Result<()> {
let listen_addr = format!("{bind}:{port}");
let relay_url = format!("ws://127.0.0.1:{port}");
let dev_token = "dev";
println!("RavenFabric Dev Mode");
println!("====================");
println!("Relay: {listen_addr}");
println!("Token: {dev_token}");
println!();
println!("Usage:");
println!(" rf exec --token {dev_token} \"<command>\"");
println!();
println!("Press Ctrl+C to stop.");
println!();
let cancel = CancellationToken::new();
let relay_cancel = cancel.clone();
let relay_addr = listen_addr.clone();
let relay_handle = tokio::spawn(async move {
if let Err(e) = rf_relay::run_relay(&relay_addr, relay_cancel).await {
error!("relay error: {}", e);
}
});
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let agent_cancel = cancel.clone();
let agent_handle = tokio::spawn(async move {
run_dev_agent(&relay_url, dev_token, agent_cancel).await;
});
tokio::signal::ctrl_c().await?;
println!("\nShutting down...");
cancel.cancel();
let _ = tokio::join!(relay_handle, agent_handle);
Ok(())
}
async fn run_dev_agent(relay_url: &str, token: &str, cancel: CancellationToken) {
use rf_audit::logger::NullAuditLogger;
use rf_policy::rpc_policy::RpcPolicy;
let yaml = r#"
spec:
commands:
allow:
- pattern: ".*"
resources:
maxOutputBytes: 104857600
timeoutSeconds: 300
"#;
let policy = RpcPolicy::from_yaml(yaml).expect("dev policy must parse");
let policy = Arc::new(RwLock::new(policy));
let audit: Arc<dyn rf_audit::logger::AuditLogger> = Arc::new(NullAuditLogger);
let key = StaticKey::generate();
loop {
tokio::select! {
() = cancel.cancelled() => break,
result = connect_dev_agent(relay_url, token, &key, &policy, &audit) => {
match result {
Ok(()) => info!("dev agent session ended"),
Err(e) => error!("dev agent error: {}", e),
}
tokio::select! {
() = cancel.cancelled() => break,
() = tokio::time::sleep(tokio::time::Duration::from_millis(50)) => {}
}
}
}
}
}
async fn connect_dev_agent(
relay_url: &str,
token: &str,
key: &StaticKey,
policy: &Arc<RwLock<rf_policy::rpc_policy::RpcPolicy>>,
audit: &Arc<dyn rf_audit::logger::AuditLogger>,
) -> anyhow::Result<()> {
let driver = WebSocketDriver::new();
let target = Target {
agent_id: "dev-agent".to_string(),
relay_url: Some(relay_url.to_string()),
meet_token: Some(token.to_string()),
};
let mut stream = driver.dial(&target, &Default::default()).await?;
let (state, peer_key) = handshake(&mut stream, false, key).await?;
info!("dev agent connected, peer: {}", hex::encode(peer_key));
let (stream_read, stream_write) = tokio::io::split(stream);
let chan = SecureChannel::new(stream_read, stream_write, state, peer_key);
let executor =
rf_executor::command::Executor::new(policy.clone(), audit.clone(), hex::encode(peer_key))
.with_agent_id("dev-agent".to_string())
.with_start_time(std::time::Instant::now());
const READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
loop {
let data = match tokio::time::timeout(READ_TIMEOUT, chan.recv()).await {
Ok(Ok(d)) => d,
Ok(Err(_)) => break,
Err(_) => {
info!("dev agent read timeout — reconnecting");
break;
}
};
let request: Request = match codec::decode(&data) {
Ok(r) => r,
Err(e) => {
error!("decode error: {}", e);
continue;
}
};
info!("request: {} action={:?}", request.id, request.action);
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 chan.send(&resp_data).await.is_err() {
break;
}
}
continue;
}
let response: Response = executor.handle(request).await;
let resp_data = codec::encode(&response)?;
if chan.send(&resp_data).await.is_err() {
break;
}
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum LintSeverity {
Error,
Warning,
Info,
}
fn lint_policy_yaml(yaml_str: &str) -> Vec<(LintSeverity, String)> {
use serde_yaml::Value;
let mut findings: Vec<(LintSeverity, String)> = Vec::new();
let root: Value = match serde_yaml::from_str(yaml_str) {
Ok(v) => v,
Err(e) => {
findings.push((
LintSeverity::Error,
format!("failed to parse policy YAML: {e}"),
));
return findings;
}
};
let spec = match root.get("spec") {
Some(v) => v,
None => {
findings.push((
LintSeverity::Warning,
"policy has no 'spec' section — no rules defined".into(),
));
return findings;
}
};
let get_patterns = |section: &Value, key: &str| -> Vec<String> {
section
.get(key)
.and_then(|v| v.as_sequence())
.map(|arr| {
arr.iter()
.filter_map(|entry| entry.get("pattern").and_then(|p| p.as_str()))
.map(|s| s.to_string())
.collect()
})
.unwrap_or_default()
};
if let Some(commands) = spec.get("commands") {
let allow_patterns = get_patterns(commands, "allow");
for pattern in &allow_patterns {
let lower = pattern.to_lowercase();
if lower.contains("bash") || lower.contains("sh ") || lower.contains("/sh") {
findings.push((
LintSeverity::Warning,
format!("command allow pattern '{pattern}' allows shell access (bash/sh) — consider restricting to specific commands"),
));
}
if lower.contains("rm ") || lower.contains("rm -rf") || lower.contains("rm -r") {
findings.push((
LintSeverity::Warning,
format!(
"command allow pattern '{pattern}' allows rm — risk of destructive deletion"
),
));
}
if lower.contains("sudo") || lower.contains("su ") {
findings.push((
LintSeverity::Warning,
format!(
"command allow pattern '{pattern}' allows privilege escalation (sudo/su)"
),
));
}
if lower.contains("chmod") || lower.contains("chown") {
findings.push((
LintSeverity::Warning,
format!(
"command allow pattern '{pattern}' allows permission changes (chmod/chown)"
),
));
}
if lower.contains("wget") || lower.contains("curl ") || lower.contains("curl -") {
findings.push((
LintSeverity::Info,
format!("command allow pattern '{pattern}' allows network downloads (wget/curl) — ensure this is intentional"),
));
}
if !pattern.starts_with('^') && !pattern.starts_with('.') {
findings.push((
LintSeverity::Info,
format!("command allow pattern '{pattern}' is not anchored with ^ — may match unintended commands"),
));
}
}
let deny_patterns = get_patterns(commands, "deny");
for pattern in &deny_patterns {
if pattern == ".*" || pattern == "^.*" {
findings.push((
LintSeverity::Warning,
format!("command deny pattern '{pattern}' is a catch-all — consider more specific patterns"),
));
}
}
} else {
findings.push((LintSeverity::Warning, "no 'commands' rules defined — all commands are denied by default, but explicit rules are recommended".into()));
}
if spec.get("filesystem").is_none() {
findings.push((
LintSeverity::Info,
"no 'filesystem' rules defined — file access is unrestricted".into(),
));
}
if spec.get("network").is_none() {
findings.push((
LintSeverity::Info,
"no 'network' rules defined — network access is unrestricted".into(),
));
}
if let Some(resources) = spec.get("resources") {
let max_output = resources
.get("maxOutputBytes")
.and_then(|v| v.as_u64())
.unwrap_or(0);
if max_output == 0 {
findings.push((
LintSeverity::Warning,
"resources.maxOutputBytes is not set — output is unbounded, risk of memory exhaustion".into(),
));
}
let timeout = resources
.get("timeoutSeconds")
.and_then(|v| v.as_u64())
.unwrap_or(0);
if timeout == 0 {
findings.push((
LintSeverity::Warning,
"resources.timeoutSeconds is not set — execution may hang indefinitely".into(),
));
}
} else {
findings.push((
LintSeverity::Warning,
"no 'resources' section — output is unbounded and execution has no timeout".into(),
));
}
if let Some(fs) = spec.get("filesystem") {
let allow_paths: Vec<String> = fs
.get("allow")
.and_then(|v| v.as_sequence())
.map(|arr| {
arr.iter()
.filter_map(|e| e.get("path").and_then(|p| p.as_str()))
.map(|s| s.to_string())
.collect()
})
.unwrap_or_default();
let deny_paths: Vec<String> = fs
.get("deny")
.and_then(|v| v.as_sequence())
.map(|arr| {
arr.iter()
.filter_map(|e| e.get("path").and_then(|p| p.as_str()))
.map(|s| s.to_string())
.collect()
})
.unwrap_or_default();
for a in &allow_paths {
for d in &deny_paths {
if d.starts_with(a) || a.starts_with(d) {
findings.push((
LintSeverity::Warning,
format!("filesystem allow path '{a}' overlaps with deny path '{d}' — deny takes precedence"),
));
}
}
}
}
if let Some(http) = spec.get("http") {
if let Some(allow) = http.get("allow").and_then(|v| v.as_sequence()) {
for entry in allow {
let has_hostname = entry.get("hostname").and_then(|v| v.as_str()).is_some();
let has_cidr = entry.get("cidr").and_then(|v| v.as_str()).is_some();
if !has_hostname && !has_cidr {
findings.push((
LintSeverity::Warning,
"HTTP allow entry without hostname or CIDR — may match unintended targets"
.into(),
));
}
}
}
}
findings
}
fn policy_command(action: PolicyAction) -> anyhow::Result<()> {
let registry = TemplateRegistry::new();
match action {
PolicyAction::List => {
println!("Available policy templates:\n");
for template in registry.list() {
println!(" {:<30} [{}]", template.name, template.category);
println!(" {}\n", template.description);
}
}
PolicyAction::Show { name } => {
let template = registry.get(&name).ok_or_else(|| {
anyhow::anyhow!(
"template '{name}' not found. Use 'rf policy list' to see available templates."
)
})?;
println!("# Template: {}", template.name);
println!("# Category: {}", template.category);
println!("# {}\n", template.description);
println!("{}", template.yaml);
}
PolicyAction::Validate { file, template } => {
if let Some(template_name) = template {
let tmpl = registry
.get(&template_name)
.ok_or_else(|| anyhow::anyhow!("template '{template_name}' not found"))?;
match TemplateRegistry::validate_yaml(&tmpl.yaml) {
Ok(()) => println!("OK: template '{template_name}' is valid YAML"),
Err(e) => {
eprintln!("ERROR: template '{template_name}' has invalid YAML: {e}");
std::process::exit(1);
}
}
} else if let Some(path) = file {
let content = std::fs::read_to_string(&path)?;
match TemplateRegistry::validate_yaml(&content) {
Ok(()) => println!("OK: {} is valid policy YAML", path.display()),
Err(e) => {
eprintln!("ERROR: {} has invalid YAML: {}", path.display(), e);
std::process::exit(1);
}
}
} else {
eprintln!("ERROR: specify either --file or --template");
std::process::exit(1);
}
}
PolicyAction::Lint { file, template } => {
let yaml_content = if let Some(ref template_name) = template {
let tmpl = registry
.get(template_name)
.ok_or_else(|| anyhow::anyhow!("template '{template_name}' not found"))?;
tmpl.yaml.clone()
} else if let Some(path) = file.clone() {
std::fs::read_to_string(&path)?
} else {
eprintln!("ERROR: specify either --file or --template");
std::process::exit(1);
};
let findings = lint_policy_yaml(&yaml_content);
if findings.is_empty() {
println!("OK: no issues found");
} else {
let label = file
.as_ref()
.map(|p| p.display().to_string())
.or(template)
.unwrap_or_default();
println!("Lint findings for {label}:\n");
for (severity, msg) in &findings {
match severity {
LintSeverity::Error => eprintln!(" ERROR: {msg}"),
LintSeverity::Warning => println!(" WARNING: {msg}"),
LintSeverity::Info => println!(" INFO: {msg}"),
}
}
if findings
.iter()
.any(|(s, _)| matches!(s, LintSeverity::Error))
{
std::process::exit(1);
}
}
}
PolicyAction::Compose { templates } => {
let names: Vec<&str> = templates.split(',').map(|s| s.trim()).collect();
let mut refs = Vec::new();
for name in &names {
let tmpl = registry.get(name).ok_or_else(|| {
anyhow::anyhow!(
"template '{name}' not found. Use 'rf policy list' to see available."
)
})?;
refs.push(tmpl);
}
let composed = TemplateRegistry::compose(&refs)?;
println!("# Composed policy from: {templates}");
println!("# Conflict resolution: deny-wins\n");
println!("{composed}");
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn cp_command(
relay_url: &str,
direct_addr: Option<&str>,
key_path: &std::path::Path,
token: &str,
source: &str,
dest: &str,
chunk_size: u32,
recursive: bool,
delta: bool,
compat_mode: bool,
) -> anyhow::Result<()> {
let key = StaticKey::load_or_generate(key_path)?;
let (chan, _peer_key) = dial_agent(relay_url, direct_addr, &key, token, compat_mode).await?;
let chan = Arc::new(tokio::sync::Mutex::new(chan));
let is_push = !source.contains(':') && dest.contains(':');
let is_pull = source.contains(':') && !dest.contains(':');
if is_push && recursive {
let remote_base = dest.split_once(':').map_or(dest, |(_, p)| p);
let source_path = std::path::Path::new(source);
if !source_path.is_dir() {
anyhow::bail!("{source} is not a directory (use -r only with directories)");
}
let mut entries = Vec::new();
collect_dir_entries(source_path, source_path, &mut entries)?;
let total_files = entries.len();
eprintln!("uploading {total_files} files from {source} → agent:{remote_base}");
for (i, (rel_path, local_path)) in entries.iter().enumerate() {
let remote_file = format!("{remote_base}/{rel_path}");
let local_data = tokio::fs::read(local_path).await?;
push_single_file(&chan, &local_data, &remote_file, chunk_size).await?;
eprintln!("[{}/{}] {}", i + 1, total_files, rel_path);
}
eprintln!("done: {total_files} files transferred");
} else if is_push {
let remote_path = dest.split_once(':').map_or(dest, |(_, p)| p);
let sources: Vec<std::path::PathBuf> = if source.contains('*')
|| source.contains('?')
|| (source.contains('[') && source.contains(']'))
{
let matches: Result<Vec<_>, _> = glob::glob(source)
.map_err(|e| anyhow::anyhow!("invalid glob pattern '{source}': {e}"))?
.collect();
let paths = matches.map_err(|e| anyhow::anyhow!("glob error: {e}"))?;
if paths.is_empty() {
anyhow::bail!("glob pattern '{source}' matched no files");
}
paths
} else {
vec![std::path::PathBuf::from(source)]
};
for local_path in &sources {
let local_str = local_path.display().to_string();
let effective_remote = if sources.len() > 1 {
let fname = local_path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| local_str.clone());
format!("{remote_path}/{fname}")
} else {
remote_path.to_string()
};
let local_data = tokio::fs::read(local_path).await?;
let total = local_data.len();
if delta {
delta_push(
&chan,
&local_data,
&effective_remote,
chunk_size,
&local_str,
)
.await?;
continue;
}
use sha2::{Digest, Sha256};
let digest = Sha256::digest(&local_data);
let checksum: String = digest.iter().map(|b| format!("{b:02x}")).collect();
let ch = chan.lock().await;
let request = Request {
id: format!(
"cp-push-{}",
hex::encode(&local_data[..4.min(local_data.len())])
),
action: Action::FilePushStream {
path: effective_remote.clone(),
total_size: total as u64,
checksum: Some(checksum),
mode: None,
compress: false,
},
timeout_ms: Some(120000),
reason: None,
};
let encoded = codec::encode(&request)?;
ch.send(&encoded).await?;
let resp_bytes = ch.recv().await?;
let resp: Response = codec::decode(&resp_bytes)?;
match resp.result {
RpcResult::FileStreamReady { .. } => {}
RpcResult::Denied { reason, rule } => {
anyhow::bail!("denied: {reason} (rule: {rule})");
}
RpcResult::Error { message } => {
anyhow::bail!("error: {message}");
}
_ => anyhow::bail!("unexpected response to FilePushStream"),
}
const STREAM_CHUNK: usize = 65519;
let mut sent = 0usize;
while sent < total {
let end = (sent + STREAM_CHUNK).min(total);
ch.send(&local_data[sent..end]).await?;
sent = end;
let pct = (sent * 100) / total.max(1);
eprint!("\r{local_str} → {effective_remote}: {pct}%");
}
let done_bytes = ch.recv().await?;
drop(ch);
let done_resp: Response = codec::decode(&done_bytes)?;
match done_resp.result {
RpcResult::FileStreamDone {
bytes_transferred,
checksum_verified,
} => {
eprintln!(
"\r{local_str} → {effective_remote}: 100% ({bytes_transferred} bytes{})",
if checksum_verified {
", checksum verified"
} else {
""
}
);
}
RpcResult::Error { message } => {
anyhow::bail!("upload failed: {message}");
}
_ => anyhow::bail!("unexpected response after stream upload"),
}
eprintln!();
}
} else if is_pull {
let remote_path = source.split_once(':').map_or(source, |(_, p)| p);
let ch = chan.lock().await;
let request = Request {
id: "cp-pull-stream".into(),
action: Action::FilePullStream {
path: remote_path.to_string(),
compress: false,
},
timeout_ms: Some(120000),
reason: None,
};
let encoded = codec::encode(&request)?;
ch.send(&encoded).await?;
let resp_bytes = ch.recv().await?;
let resp: Response = codec::decode(&resp_bytes)?;
let (total_size, expected_checksum) = match resp.result {
RpcResult::FileStreamReady {
total_size,
checksum,
} => (total_size, checksum),
RpcResult::Denied { reason, rule } => {
anyhow::bail!("denied: {reason} (rule: {rule})");
}
RpcResult::Error { message } => {
anyhow::bail!("error: {message}");
}
_ => anyhow::bail!("unexpected response to FilePullStream"),
};
let mut file_data: Vec<u8> = Vec::with_capacity(total_size as usize);
while (file_data.len() as u64) < total_size {
let chunk = tokio::time::timeout(std::time::Duration::from_secs(30), ch.recv())
.await
.map_err(|_| {
anyhow::anyhow!(
"timeout waiting for file data (received {}/{} bytes)",
file_data.len(),
total_size
)
})??;
if chunk.is_empty() {
anyhow::bail!("connection closed before transfer complete");
}
file_data.extend_from_slice(&chunk);
let pct = (file_data.len() as u64 * 100) / total_size.max(1);
eprint!("\r{source} → {dest}: {pct}%");
}
drop(ch);
if let Some(expected) = expected_checksum {
use sha2::{Digest, Sha256};
let d = Sha256::digest(&file_data);
let actual: String = d.iter().map(|b| format!("{b:02x}")).collect();
if actual != expected {
anyhow::bail!("checksum mismatch: expected {expected}, got {actual}");
}
}
tokio::fs::write(dest, &file_data).await?;
eprintln!("\r{source} → {dest}: 100% ({total_size} bytes, checksum verified)");
} else {
anyhow::bail!(
"invalid copy syntax. Use: rf cp <local> <agent>:/path or rf cp <agent>:/path <local>"
);
}
let close_req = Request {
id: "close".into(),
action: Action::Ping,
timeout_ms: None,
reason: None,
};
let encoded = codec::encode(&close_req)?;
let ch = chan.lock().await;
let _ = ch.send(&encoded).await;
drop(ch);
Ok(())
}
async fn secret_command(
relay_url: &str,
direct_addr: Option<&str>,
key_path: &std::path::Path,
action: SecretAction,
compat_mode: bool,
) -> anyhow::Result<()> {
match action {
SecretAction::Push {
token,
name,
value,
grace_period,
} => {
let key = StaticKey::load_or_generate(key_path)?;
let (ch, _peer_key) =
dial_agent(relay_url, direct_addr, &key, &token, compat_mode).await?;
let req = Request {
id: uuid::Uuid::new_v4().to_string(),
action: Action::SealSecret {
name: name.clone(),
value,
grace_period_secs: grace_period,
},
timeout_ms: Some(30_000),
reason: None,
};
let encoded = codec::encode(&req)?;
ch.send(&encoded).await?;
let raw = ch.recv().await?;
let resp: Response = codec::decode(&raw)?;
match resp.result {
RpcResult::SecretSealed {
name: sealed_name,
value_hash,
rotated,
} => {
if rotated {
println!("Secret '{sealed_name}' rotated (hash: {value_hash})");
} else {
println!("Secret '{sealed_name}' sealed (hash: {value_hash})");
}
}
RpcResult::Denied { reason, .. } => {
anyhow::bail!("denied by agent policy: {reason}");
}
RpcResult::Error { message } => {
anyhow::bail!("agent error: {message}");
}
other => {
anyhow::bail!("unexpected response: {other:?}");
}
}
}
SecretAction::List { token } => {
let key = StaticKey::load_or_generate(key_path)?;
let (ch, _peer_key) =
dial_agent(relay_url, direct_addr, &key, &token, compat_mode).await?;
let req = Request {
id: uuid::Uuid::new_v4().to_string(),
action: Action::ListSecrets,
timeout_ms: Some(30_000),
reason: None,
};
let encoded = codec::encode(&req)?;
ch.send(&encoded).await?;
let raw = ch.recv().await?;
let resp: Response = codec::decode(&raw)?;
match resp.result {
RpcResult::SecretsList { names } => {
if names.is_empty() {
println!("No secrets stored on agent.");
} else {
println!("Secrets ({}):", names.len());
for n in &names {
println!(" {n}");
}
}
}
RpcResult::Denied { reason, .. } => {
anyhow::bail!("denied by agent policy: {reason}");
}
RpcResult::Error { message } => {
anyhow::bail!("agent error: {message}");
}
other => {
anyhow::bail!("unexpected response: {other:?}");
}
}
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn proxy_command(
relay_url: &str,
direct_addr: Option<&str>,
key_path: &std::path::Path,
token: &str,
target: &str,
listen: &str,
idle_timeout: Option<u32>,
max_duration: Option<u32>,
http_mode: bool,
compat_mode: bool,
) -> anyhow::Result<()> {
let key = StaticKey::load_or_generate(key_path)?;
let (probe_chan, _peer_key) =
dial_agent(relay_url, direct_addr, &key, token, compat_mode).await?;
let request = Request {
id: "proxy-test".into(),
action: Action::Proxy {
target: target.to_string(),
idle_timeout_secs: idle_timeout,
max_duration_secs: max_duration,
},
timeout_ms: Some(10000),
reason: None,
};
let encoded = codec::encode(&request)?;
probe_chan.send(&encoded).await?;
let resp_bytes = probe_chan.recv().await?;
let resp: Response = codec::decode(&resp_bytes)?;
match resp.result {
RpcResult::ProxyConnected {
proxy_id,
idle_timeout_secs: eff_idle,
max_duration_secs: eff_max,
} => {
eprintln!("proxy established: {listen} → agent → {target} (id: {proxy_id})");
eprintln!(" idle timeout: {eff_idle}s, max duration: {eff_max}s");
eprintln!(" concurrent tunnels: each connection uses a dedicated agent channel");
eprintln!("listening on {listen} (press Ctrl+C to stop)");
if http_mode {
eprintln!(" mode: HTTP-aware (per-request policy enforcement)");
}
let listener = tokio::net::TcpListener::bind(listen).await?;
let cancel = CancellationToken::new();
let cancel_clone = cancel.clone();
tokio::spawn(async move {
let _ = tokio::signal::ctrl_c().await;
cancel_clone.cancel();
});
loop {
tokio::select! {
_ = cancel.cancelled() => {
eprintln!("\nproxy stopped.");
break;
}
accept = listener.accept() => {
match accept {
Ok((stream, addr)) => {
eprintln!("connection from {addr}");
let relay_url = relay_url.to_string();
let direct_addr = direct_addr.map(str::to_string);
let key_clone = key.clone();
let token_clone = token.to_string();
let target_clone = target.to_string();
if http_mode {
let (http_chan, _) = match dial_agent(&relay_url, direct_addr.as_deref(), &key_clone, &token_clone, compat_mode).await {
Ok(c) => c,
Err(e) => {
eprintln!("http proxy connect failed: {e}");
continue;
}
};
let http_chan = Arc::new(tokio::sync::Mutex::new(http_chan));
tokio::spawn(async move {
if let Err(e) = handle_http_proxy_connection(
stream,
http_chan,
&target_clone,
)
.await
{
eprintln!("http proxy error: {e}");
}
});
} else {
let compat = compat_mode;
tokio::spawn(async move {
if let Err(e) = handle_proxy_connection(
stream,
relay_url,
direct_addr,
key_clone,
token_clone,
target_clone,
eff_idle,
eff_max,
compat,
)
.await
{
eprintln!("proxy connection error: {e}");
}
});
}
}
Err(e) => {
eprintln!("accept error: {e}");
}
}
}
}
}
}
RpcResult::Denied { reason, rule } => {
anyhow::bail!("proxy denied: {reason} (rule: {rule})");
}
RpcResult::Error { message } => {
anyhow::bail!("proxy error: {message}");
}
_ => anyhow::bail!("unexpected response"),
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn handle_proxy_connection(
local: tokio::net::TcpStream,
relay_url: String,
direct_addr: Option<String>,
key: StaticKey,
token: String,
target: String,
idle_timeout_secs: u32,
max_duration_secs: u32,
compat_mode: bool,
) -> anyhow::Result<()> {
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::time::{Duration, Instant};
let (chan, _peer_key) = dial_agent(
&relay_url,
direct_addr.as_deref(),
&key,
&token,
compat_mode,
)
.await?;
let chan = Arc::new(chan);
let request = Request {
id: format!("proxy-{}", rand_id()),
action: Action::ProxyOpen {
target: target.clone(),
idle_timeout_secs: Some(idle_timeout_secs),
max_duration_secs: Some(max_duration_secs),
},
timeout_ms: Some(15000),
reason: None,
};
let encoded = codec::encode(&request)?;
chan.send(&encoded).await?;
let resp_bytes = chan.recv().await?;
let resp: Response = codec::decode(&resp_bytes)?;
let (eff_idle, eff_max) = match resp.result {
RpcResult::ProxyReady {
idle_timeout_secs: i,
max_duration_secs: m,
..
} => (i, m),
RpcResult::Denied { reason, rule } => {
anyhow::bail!("proxy denied: {reason} (rule: {rule})");
}
RpcResult::Error { message } => {
anyhow::bail!("proxy error: {message}");
}
_ => anyhow::bail!("unexpected response to ProxyOpen"),
};
let deadline = Instant::now() + Duration::from_secs(u64::from(eff_max));
let idle_dur = Duration::from_secs(u64::from(eff_idle));
let (mut local_r, mut local_w) = local.into_split();
let chan_a = chan.clone();
let t_local_to_chan = tokio::spawn(async move {
let mut buf = vec![0u8; 65535];
loop {
match local_r.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => {
if chan_a.send(&buf[..n]).await.is_err() {
break;
}
}
}
}
});
let chan_b = chan;
let t_chan_to_local = tokio::spawn(async move {
loop {
match chan_b.recv().await {
Ok(data) if data.is_empty() => break, Ok(data) => {
if local_w.write_all(&data).await.is_err() {
break;
}
}
Err(_) => break,
}
}
});
let remaining = deadline.saturating_duration_since(Instant::now());
let _ = idle_dur; tokio::select! {
_ = t_local_to_chan => {}
_ = t_chan_to_local => {}
_ = tokio::time::sleep(remaining) => {
eprintln!("proxy connection closed: max duration ({eff_max}s) reached");
}
}
Ok(())
}
fn audit_command(action: AuditAction) -> anyhow::Result<()> {
match action {
AuditAction::Verify { file, key_file } => {
let key_bytes = std::fs::read(&key_file).map_err(|e| {
anyhow::anyhow!("failed to read key file '{}': {e}", key_file.display())
})?;
let hmac_key = if key_bytes.len() == 64 {
hex::decode(&key_bytes).unwrap_or(key_bytes)
} else {
key_bytes
};
if hmac_key.len() != 32 {
anyhow::bail!(
"HMAC key must be 32 bytes (got {}). Use a 32-byte raw key or 64-char hex string.",
hmac_key.len()
);
}
match rf_audit::logger::verify_audit_chain(&file, &hmac_key) {
Ok(()) => {
println!("OK: audit chain integrity verified for {}", file.display());
}
Err(e) => {
eprintln!("ERROR: audit chain verification failed: {e}");
std::process::exit(1);
}
}
}
AuditAction::DeriveKey { key_file } => {
let key_bytes = std::fs::read(&key_file).map_err(|e| {
anyhow::anyhow!("failed to read key file '{}': {e}", key_file.display())
})?;
if key_bytes.len() != 64 {
anyhow::bail!(
"agent key file must be 64 bytes (32-byte private + 32-byte public), got {}",
key_bytes.len()
);
}
let private_key = &key_bytes[..32];
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
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);
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()));
}
}
Ok(())
}
fn rand_id() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let t = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
format!("{t:x}")
}
async fn handle_http_proxy_connection(
mut local: tokio::net::TcpStream,
chan: Arc<tokio::sync::Mutex<AgentChannel>>,
target: &str,
) -> anyhow::Result<()> {
use std::collections::HashMap;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut buf = vec![0u8; 65536];
let mut total_read = 0usize;
loop {
if total_read >= buf.len() {
anyhow::bail!("request too large for header buffer");
}
let n = local.read(&mut buf[total_read..]).await?;
if n == 0 {
return Ok(()); }
total_read += n;
if buf[..total_read].windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
let mut headers_arr = [httparse::EMPTY_HEADER; 64];
let mut req = httparse::Request::new(&mut headers_arr);
let header_len = match req.parse(&buf[..total_read]) {
Ok(httparse::Status::Complete(len)) => len,
Ok(httparse::Status::Partial) => {
anyhow::bail!("incomplete HTTP request");
}
Err(e) => {
anyhow::bail!("invalid HTTP request: {e}");
}
};
let method = req.method.unwrap_or("GET").to_string();
let path = req.path.unwrap_or("/").to_string();
let mut headers = HashMap::new();
let mut content_length: usize = 0;
for h in req.headers.iter() {
if h.name.is_empty() {
break;
}
let value = String::from_utf8_lossy(h.value).to_string();
if h.name.eq_ignore_ascii_case("content-length") {
content_length = value.parse().unwrap_or(0);
}
headers.insert(h.name.to_string(), value);
}
let _body_so_far = total_read - header_len;
let mut body = buf[header_len..total_read].to_vec();
while body.len() < content_length {
let remaining = content_length - body.len();
let mut chunk = vec![0u8; remaining.min(8192)];
let n = local.read(&mut chunk).await?;
if n == 0 {
break;
}
body.extend_from_slice(&chunk[..n]);
}
let request = Request {
id: format!("http-{}", rand_id()),
action: Action::HttpForward {
target: target.to_string(),
method: method.clone(),
path: path.clone(),
headers,
body,
},
timeout_ms: Some(30000),
reason: None,
};
let encoded = codec::encode(&request)?;
let ch = chan.lock().await;
ch.send(&encoded).await?;
let resp_bytes = ch.recv().await?;
drop(ch);
let resp: Response = codec::decode(&resp_bytes)?;
match resp.result {
RpcResult::HttpResponse {
status_code,
headers: resp_headers,
body: resp_body,
latency_ms,
} => {
let status_text = http_status_text(status_code);
let mut raw_response = format!("HTTP/1.1 {status_code} {status_text}\r\n");
for (key, value) in &resp_headers {
raw_response.push_str(&format!("{key}: {value}\r\n"));
}
if !resp_headers
.keys()
.any(|k| k.eq_ignore_ascii_case("content-length"))
{
raw_response.push_str(&format!("Content-Length: {}\r\n", resp_body.len()));
}
raw_response.push_str("\r\n");
local.write_all(raw_response.as_bytes()).await?;
if !resp_body.is_empty() {
local.write_all(&resp_body).await?;
}
eprintln!(
" {method} {path} → {status_code} ({} bytes, {latency_ms}ms)",
resp_body.len()
);
}
RpcResult::Denied { reason, rule } => {
let body_text = format!("Denied: {reason} (rule: {rule})");
let raw = format!(
"HTTP/1.1 403 Forbidden\r\nContent-Length: {}\r\nContent-Type: text/plain\r\n\r\n{body_text}",
body_text.len()
);
local.write_all(raw.as_bytes()).await?;
eprintln!(" {method} {path} → 403 (denied: {reason})");
}
RpcResult::Error { message } => {
let body_text = format!("Proxy error: {message}");
let raw = format!(
"HTTP/1.1 502 Bad Gateway\r\nContent-Length: {}\r\nContent-Type: text/plain\r\n\r\n{body_text}",
body_text.len()
);
local.write_all(raw.as_bytes()).await?;
eprintln!(" {method} {path} → 502 ({message})");
}
_ => {
let raw = "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\n\r\n";
local.write_all(raw.as_bytes()).await?;
}
}
Ok(())
}
fn http_status_text(code: u16) -> &'static str {
match code {
200 => "OK",
201 => "Created",
204 => "No Content",
301 => "Moved Permanently",
302 => "Found",
304 => "Not Modified",
400 => "Bad Request",
401 => "Unauthorized",
403 => "Forbidden",
404 => "Not Found",
405 => "Method Not Allowed",
409 => "Conflict",
500 => "Internal Server Error",
502 => "Bad Gateway",
503 => "Service Unavailable",
504 => "Gateway Timeout",
_ => "OK",
}
}
fn collect_dir_entries(
base: &std::path::Path,
dir: &std::path::Path,
entries: &mut Vec<(String, std::path::PathBuf)>,
) -> anyhow::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
collect_dir_entries(base, &path, entries)?;
} else if path.is_file() {
let rel = path
.strip_prefix(base)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
entries.push((rel, path));
}
}
Ok(())
}
async fn push_single_file(
chan: &Arc<tokio::sync::Mutex<AgentChannel>>,
data: &[u8],
remote_path: &str,
_chunk_size: u32,
) -> anyhow::Result<()> {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(data);
let checksum: String = digest.iter().map(|b| format!("{b:02x}")).collect();
let total = data.len();
let ch = chan.lock().await;
let request = Request {
id: format!("cp-push-{}", hex::encode(&data[..4.min(total)])),
action: Action::FilePushStream {
path: remote_path.to_string(),
total_size: total as u64,
checksum: Some(checksum),
mode: None,
compress: false,
},
timeout_ms: Some(120000),
reason: None,
};
let encoded = codec::encode(&request)?;
ch.send(&encoded).await?;
let resp_bytes = ch.recv().await?;
let resp: Response = codec::decode(&resp_bytes)?;
match resp.result {
RpcResult::FileStreamReady { .. } => {}
RpcResult::Denied { reason, rule } => {
anyhow::bail!("denied: {reason} (rule: {rule})");
}
RpcResult::Error { message } => {
anyhow::bail!("error: {message}");
}
_ => anyhow::bail!("unexpected response to FilePushStream"),
}
const CHUNK: usize = 65519;
let mut sent = 0;
while sent < total {
let end = (sent + CHUNK).min(total);
ch.send(&data[sent..end]).await?;
sent = end;
}
let done_bytes = ch.recv().await?;
drop(ch);
let done_resp: Response = codec::decode(&done_bytes)?;
match done_resp.result {
RpcResult::FileStreamDone { .. } => Ok(()),
RpcResult::Error { message } => anyhow::bail!("upload failed: {message}"),
_ => anyhow::bail!("unexpected response after stream upload"),
}
}
async fn delta_push(
chan: &Arc<tokio::sync::Mutex<AgentChannel>>,
local_data: &[u8],
remote_path: &str,
block_size: u32,
display_label: &str,
) -> anyhow::Result<()> {
let bs = block_size.max(1024) as usize;
let query_req = Request {
id: format!(
"delta-query-{}",
hex::encode(&local_data[..4.min(local_data.len())])
),
action: Action::FileDeltaQuery {
path: remote_path.to_string(),
block_size,
},
timeout_ms: Some(60000),
reason: None,
};
let ch = chan.lock().await;
ch.send(&codec::encode(&query_req)?).await?;
let resp_bytes = ch.recv().await?;
drop(ch);
let resp: Response = codec::decode(&resp_bytes)?;
let (remote_blocks, file_missing) = match resp.result {
RpcResult::FileDeltaIndex {
blocks,
file_missing,
..
} => (blocks, file_missing),
RpcResult::Denied { reason, rule } => {
anyhow::bail!("denied: {reason} (rule: {rule})");
}
RpcResult::Error { message } => {
anyhow::bail!("delta query error: {message}");
}
_ => anyhow::bail!("unexpected response to FileDeltaQuery"),
};
if file_missing {
eprintln!("{display_label}: remote file missing, performing full transfer");
return push_single_file(chan, local_data, remote_path, block_size).await;
}
use sha2::{Digest, Sha256};
let remote_index: std::collections::HashMap<u64, &rf_rpc::types::BlockInfo> =
remote_blocks.iter().map(|b| (b.offset, b)).collect();
let mut patches: Vec<rf_rpc::types::DeltaPatch> = Vec::new();
let mut offset = 0u64;
let mut blocks_same = 0usize;
let mut blocks_changed = 0usize;
for chunk in local_data.chunks(bs) {
const MOD_ADLER: u32 = 65521;
let mut a: u32 = 1;
let mut b: u32 = 0;
for &byte in chunk {
a = (a + u32::from(byte)) % MOD_ADLER;
b = (b + a) % MOD_ADLER;
}
let local_adler = (b << 16) | a;
let local_sha: String = Sha256::digest(chunk)
.iter()
.map(|x| format!("{x:02x}"))
.collect();
let changed = if let Some(rb) = remote_index.get(&offset) {
rb.adler32 != local_adler || rb.sha256_hex != local_sha
} else {
true };
if changed {
patches.push(rf_rpc::types::DeltaPatch {
offset,
data: chunk.to_vec(),
});
blocks_changed += 1;
} else {
blocks_same += 1;
}
offset += chunk.len() as u64;
}
eprintln!(
"{display_label}: delta — {blocks_same} blocks unchanged, {blocks_changed} blocks to transfer"
);
if patches.is_empty() {
eprintln!("{display_label}: already up-to-date (0 bytes transferred)");
return Ok(());
}
let full_checksum: String = Sha256::digest(local_data)
.iter()
.map(|x| format!("{x:02x}"))
.collect();
let bytes_to_transfer: u64 = patches.iter().map(|p| p.data.len() as u64).sum();
let patch_req = Request {
id: format!(
"delta-patch-{}",
hex::encode(&local_data[..4.min(local_data.len())])
),
action: Action::FileDeltaPatch {
path: remote_path.to_string(),
block_size,
patches,
total_size: local_data.len() as u64,
checksum: Some(full_checksum),
mode: None,
},
timeout_ms: Some(120000),
reason: None,
};
let ch = chan.lock().await;
ch.send(&codec::encode(&patch_req)?).await?;
let done_bytes = ch.recv().await?;
drop(ch);
let done_resp: Response = codec::decode(&done_bytes)?;
match done_resp.result {
RpcResult::FileDeltaApplied {
checksum_verified, ..
} => {
eprintln!(
"{display_label}: delta applied ({bytes_to_transfer} bytes transferred{})",
if checksum_verified {
", checksum verified"
} else {
""
}
);
Ok(())
}
RpcResult::Denied { reason, rule } => anyhow::bail!("denied: {reason} (rule: {rule})"),
RpcResult::Error { message } => anyhow::bail!("delta patch failed: {message}"),
_ => anyhow::bail!("unexpected response to FileDeltaPatch"),
}
}
#[cfg(test)]
mod tests {
use super::split_relay_urls;
#[test]
fn split_single_relay() {
let urls = split_relay_urls("ws://127.0.0.1:9090");
assert_eq!(urls, vec!["ws://127.0.0.1:9090".to_string()]);
}
#[test]
fn split_multiple_relays_trims_whitespace() {
let urls = split_relay_urls("ws://a:1, ws://b:2 ,wss://c:3");
assert_eq!(
urls,
vec![
"ws://a:1".to_string(),
"ws://b:2".to_string(),
"wss://c:3".to_string()
]
);
}
#[test]
fn split_filters_empty_entries() {
let urls = split_relay_urls("ws://a:1,, ,ws://b:2,");
assert_eq!(urls, vec!["ws://a:1".to_string(), "ws://b:2".to_string()]);
}
#[test]
fn split_empty_string_returns_empty() {
let urls = split_relay_urls("");
assert!(urls.is_empty());
}
}