#[cfg_attr(not(any(target_arch = "wasm32", test)), allow(dead_code))]
mod reconnect;
#[cfg(target_arch = "wasm32")]
mod wasm32;
use crate::commands::COMMANDS;
#[cfg_attr(target_arch = "wasm32", allow(unused_imports))]
use exfiltrate_internal::command::{CommandContext, Response};
use exfiltrate_internal::rpc::{CommandInvocation, CommandResponse};
use std::sync::LazyLock;
#[cfg(not(target_arch = "wasm32"))]
use exfiltrate_internal::command::StreamError;
#[cfg(not(target_arch = "wasm32"))]
use exfiltrate_internal::rpc::Chunk;
#[cfg(not(target_arch = "wasm32"))]
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
#[cfg(not(target_arch = "wasm32"))]
use exfiltrate_internal::auth;
#[cfg(not(target_arch = "wasm32"))]
use exfiltrate_internal::rpc::{AuthChallenge, RPC};
#[cfg(not(target_arch = "wasm32"))]
use exfiltrate_internal::transport::{Address, Listener, Stream, Transport};
#[cfg(not(target_arch = "wasm32"))]
use exfiltrate_internal::wire::{
BACKOFF_DURATION, InFlightMessage, MAX_ATTACHMENTS, send_socket_frame,
};
#[cfg(not(target_arch = "wasm32"))]
use std::collections::HashMap;
#[cfg(not(target_arch = "wasm32"))]
use std::sync::{Arc, Mutex};
#[cfg(not(target_arch = "wasm32"))]
const MAX_CONCURRENT_COMMANDS: usize = 64;
pub struct Server {}
#[cfg(not(target_arch = "wasm32"))]
type WriteJob = Vec<Vec<u8>>;
#[cfg(not(target_arch = "wasm32"))]
#[derive(Default)]
struct RunningCommands {
tokens: HashMap<u32, Arc<AtomicBool>>,
}
#[cfg(not(target_arch = "wasm32"))]
struct Session {
challenge: Option<AuthChallenge>,
authenticated: bool,
}
#[cfg(not(target_arch = "wasm32"))]
impl Session {
fn admitted(&self) -> bool {
self.challenge.is_none() || self.authenticated
}
}
#[cfg(not(target_arch = "wasm32"))]
fn do_stream(stream: Stream) {
std::thread::Builder::new()
.name("exfiltrate::server do_stream".to_string())
.spawn(move || connection_loop(stream))
.expect("exfiltrate could not spawn a connection thread");
}
#[cfg(not(target_arch = "wasm32"))]
fn connection_loop(mut stream: Stream) {
let config = crate::config_snapshot();
let (writer, write_jobs) = std::sync::mpsc::channel::<WriteJob>();
let mut write_stream = match stream.try_clone_transport() {
Ok(clone) => clone,
Err(error) => {
crate::diagnostic(&format!("exfiltrate: cannot split the connection: {error}"));
return;
}
};
let writer_thread = std::thread::Builder::new()
.name("exfiltrate::server write".to_string())
.spawn(move || {
for job in write_jobs {
for frame in job {
if let Err(error) = send_socket_frame(&frame, &mut write_stream) {
crate::diagnostic(&format!("exfiltrate: write failed: {error}"));
return;
}
}
}
});
if let Err(error) = writer_thread {
crate::diagnostic(&format!("exfiltrate: cannot spawn the writer: {error}"));
return;
}
let subscriber = crate::events::attach(config.event_queue_capacity);
let running = Arc::new(Mutex::new(RunningCommands::default()));
let mut in_flight_message = InFlightMessage::new();
let mut session = Session {
challenge: None,
authenticated: false,
};
if auth_token().is_some() {
match auth::challenge() {
Ok(challenge) => session.challenge = Some(challenge),
Err(error) => {
crate::diagnostic(&format!(
"exfiltrate: refusing a connection because a challenge could not be \
generated: {error}"
));
return;
}
}
}
loop {
if !flush_events(subscriber, &writer) {
break;
}
match in_flight_message.read_stream(&mut stream) {
Err(error) => {
if error.kind() != std::io::ErrorKind::UnexpectedEof {
crate::diagnostic(&format!("exfiltrate: read failed: {error}"));
}
break;
}
Ok(exfiltrate_internal::wire::ReadStatus::WouldBlock) => {
std::thread::sleep(BACKOFF_DURATION);
}
Ok(exfiltrate_internal::wire::ReadStatus::Progress) => continue,
Ok(exfiltrate_internal::wire::ReadStatus::Completed(frame)) => {
let rpc = match rmp_serde::from_slice::<RPC>(&frame) {
Ok(rpc) => rpc,
Err(error) => {
crate::diagnostic(&format!(
"exfiltrate: could not parse a message from the client: {error}. \
This usually means the CLI and the linked library are different \
versions; run `exfiltrate status` to compare them."
));
break;
}
};
if !dispatch(rpc, subscriber, &writer, &running, &config, &mut session) {
break;
}
}
Ok(_) => {
crate::diagnostic("exfiltrate: unknown read status; ignoring");
}
}
}
crate::events::detach(subscriber);
drop(writer);
}
#[cfg(not(target_arch = "wasm32"))]
fn dispatch(
rpc: RPC,
subscriber: u64,
writer: &std::sync::mpsc::Sender<WriteJob>,
running: &Arc<Mutex<RunningCommands>>,
config: &crate::Config,
session: &mut Session,
) -> bool {
if !session.admitted() && !matches!(rpc, RPC::Hello(_) | RPC::AuthProof(_)) {
if let Some(reply_id) = rpc.reply_id() {
return send(
writer,
RPC::CommandResponse(CommandResponse::new(
false,
format!(
"this application requires a token. Set ${} on both ends, or pass \
--token.",
auth::TOKEN_ENV
)
.into(),
reply_id,
)),
);
}
crate::diagnostic("exfiltrate: a peer sent traffic before authenticating; closing");
return false;
}
match rpc {
RPC::Hello(peer) => {
if !peer.is_compatible() {
let local = config.build_info();
crate::diagnostic(&format!("exfiltrate: {}", peer.skew_message(&local)));
}
if let Some(challenge) = &session.challenge
&& !session.authenticated
&& !send(writer, RPC::AuthChallenge(challenge.clone()))
{
return false;
}
send(writer, RPC::Hello(config.build_info()))
}
RPC::AuthProof(offered) => {
let (Some(challenge), Some(token)) = (&session.challenge, auth_token()) else {
crate::diagnostic("exfiltrate: a peer offered a token where none is required");
return send(
writer,
RPC::AuthResult(exfiltrate_internal::rpc::AuthResult {
ok: true,
message: String::new(),
}),
);
};
let outcome = auth::verify(&token, challenge, &offered.proof);
let accepted = outcome.is_ok();
session.authenticated = accepted;
if !accepted {
crate::diagnostic("exfiltrate: a peer offered a token that was not accepted");
}
let answered = send(writer, RPC::AuthResult(auth::result_for(&outcome)));
answered && accepted
}
RPC::Command(command) => {
let reply_id = command.reply_id;
let token = Arc::new(AtomicBool::new(false));
{
let mut state = match running.lock() {
Ok(state) => state,
Err(_) => return false,
};
if state.tokens.len() >= MAX_CONCURRENT_COMMANDS {
return send(
writer,
RPC::CommandResponse(CommandResponse::new(
false,
format!(
"this connection already has {MAX_CONCURRENT_COMMANDS} commands \
in flight; wait for one to finish"
)
.into(),
reply_id,
)),
);
}
state.tokens.insert(reply_id, token.clone());
}
let command_writer = writer.clone();
let worker_running = running.clone();
let spawned = std::thread::Builder::new()
.name(format!("exfiltrate::command {}", command.name))
.spawn(move || {
run_command(command, token, &command_writer);
if let Ok(mut state) = worker_running.lock() {
state.tokens.remove(&reply_id);
}
});
if let Err(error) = spawned {
if let Ok(mut state) = running.lock() {
state.tokens.remove(&reply_id);
}
return send(
writer,
RPC::CommandResponse(CommandResponse::new(
false,
format!("could not spawn a thread for this command: {error}").into(),
reply_id,
)),
);
}
true
}
RPC::Cancel(cancel) => {
let known = running
.lock()
.ok()
.and_then(|state| {
state
.tokens
.get(&cancel.reply_id)
.map(|token| token.store(true, Ordering::Relaxed))
})
.is_some();
if !known {
crate::diagnostic(&format!(
"exfiltrate: cancel for reply {} arrived after the command finished",
cancel.reply_id
));
}
true
}
RPC::Subscribe(subscription) => {
if let Err(error) = crate::events::subscribe(subscriber, &subscription.topic) {
crate::diagnostic(&format!("exfiltrate: subscribe refused: {error}"));
}
true
}
RPC::Unsubscribe(subscription) => {
if let Err(error) = crate::events::unsubscribe(subscriber, &subscription.topic) {
crate::diagnostic(&format!("exfiltrate: unsubscribe refused: {error}"));
}
true
}
RPC::CommandResponse(_) | RPC::Chunk(_) | RPC::Event(_) => {
crate::diagnostic(
"exfiltrate: the client sent a message only a server may send; closing",
);
false
}
_ => {
crate::diagnostic("exfiltrate: ignoring an RPC variant this build does not know");
true
}
}
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, Clone, PartialEq, Eq)]
enum Credential {
None,
Configured(String),
Generated(String),
}
#[cfg(not(target_arch = "wasm32"))]
impl Credential {
fn token(&self) -> Option<String> {
match self {
Credential::None => None,
Credential::Configured(token) | Credential::Generated(token) => Some(token.clone()),
}
}
}
#[cfg(not(target_arch = "wasm32"))]
fn credential_for(
address: &Address,
configured: Option<String>,
) -> Result<Credential, exfiltrate_internal::auth::AuthError> {
if let Some(token) = configured {
return Ok(Credential::Configured(token));
}
if address.is_local() {
return Ok(Credential::None);
}
Ok(Credential::Generated(auth::generate_token()?))
}
#[cfg(not(target_arch = "wasm32"))]
static ENFORCED_TOKEN: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
#[cfg(not(target_arch = "wasm32"))]
fn auth_token() -> Option<String> {
ENFORCED_TOKEN.get().cloned().flatten()
}
#[cfg(not(target_arch = "wasm32"))]
fn run_command(
command: CommandInvocation,
token: Arc<AtomicBool>,
writer: &std::sync::mpsc::Sender<WriteJob>,
) {
let reply_id = command.reply_id;
let seq = Arc::new(AtomicU64::new(0));
let chunk_writer = writer.clone();
let chunk_seq = seq.clone();
let context = CommandContext::new(
token,
Arc::new(move |payload: Response| {
let chunk = RPC::Chunk(Chunk {
reply_id,
seq: chunk_seq.fetch_add(1, Ordering::Relaxed),
payload,
});
let bytes = rmp_serde::to_vec(&chunk)
.map_err(|error| StreamError::Disconnected(error.to_string()))?;
chunk_writer
.send(vec![bytes])
.map_err(|_| StreamError::Disconnected("the client disconnected".to_string()))
}),
);
let mut response = do_command(command, &context);
if response.response.attachment_count() > MAX_ATTACHMENTS {
response.success = false;
response.response =
format!("response exceeds the {MAX_ATTACHMENTS}-attachment limit").into();
}
let attachments = response.response.split_data();
response.num_attachments = match u32::try_from(attachments.len()) {
Ok(count) => count,
Err(_) => {
crate::diagnostic(&format!(
"exfiltrate: command {reply_id} had too many attachments"
));
return;
}
};
let mut job = match rmp_serde::to_vec(&RPC::CommandResponse(response)) {
Ok(bytes) => vec![bytes],
Err(error) => {
crate::diagnostic(&format!(
"exfiltrate: cannot encode reply {reply_id}: {error}"
));
return;
}
};
job.extend(attachments);
let _ = writer.send(job);
}
#[cfg(not(target_arch = "wasm32"))]
fn send(writer: &std::sync::mpsc::Sender<WriteJob>, rpc: RPC) -> bool {
match rmp_serde::to_vec(&rpc) {
Ok(bytes) => writer.send(vec![bytes]).is_ok(),
Err(error) => {
crate::diagnostic(&format!("exfiltrate: cannot encode {rpc}: {error}"));
true
}
}
}
#[cfg(not(target_arch = "wasm32"))]
fn flush_events(subscriber: u64, writer: &std::sync::mpsc::Sender<WriteJob>) -> bool {
for event in crate::events::drain(subscriber) {
if !send(writer, RPC::Event(event)) {
return false;
}
}
true
}
fn do_command(command: CommandInvocation, context: &CommandContext) -> CommandResponse {
let name = command.name.clone();
let found = COMMANDS.lock_sync_read().contains_key(name.as_str());
if !found {
return CommandResponse::new(
false,
format!(
"command not found: {name}. Run `exfiltrate list` to see what this \
application actually registered."
)
.into(),
command.reply_id,
);
}
let result = crate::panics::isolate(&name, || {
let registry = COMMANDS.lock_sync_read();
let Some(matcher) = registry.get(name.as_str()) else {
return Err(Response::String(format!(
"command {name} was unregistered while it was being invoked"
)));
};
matcher.execute_with(command.args, context)
});
match result {
Ok(response) => CommandResponse::new(true, response, command.reply_id),
Err(response) => CommandResponse::new(false, response, command.reply_id),
}
}
pub static SERVER: LazyLock<Server> = LazyLock::new(Server::new);
impl Server {
fn new() -> Server {
#[cfg(not(target_arch = "wasm32"))]
{
Self::new_native()
}
#[cfg(target_arch = "wasm32")]
{
Self::new_web()
}
}
#[cfg(not(target_arch = "wasm32"))]
fn new_native() -> Server {
let config = crate::config_snapshot();
let text = exfiltrate_internal::wire::resolve_addr(config.addr.as_deref());
let address = match Address::parse(&text) {
Ok(address) => address,
Err(error) => {
report_bind_failure_message(
&config,
format!("exfiltrate: {text} is not an address exfiltrate understands: {error}"),
);
return Server {};
}
};
let credential =
match credential_for(&address, auth::resolve_token(config.token.as_deref())) {
Ok(credential) => credential,
Err(error) => {
report_bind_failure_message(
&config,
format!(
"exfiltrate: {address} is reachable from outside this machine, so it\
\n needs a token, and one could not be generated: {error}\
\n No debug server was started. Set ${} on both ends, or listen\
\n somewhere only local callers can reach.",
auth::TOKEN_ENV
),
);
return Server {};
}
};
let _ = ENFORCED_TOKEN.set(credential.token());
let listener = match Listener::bind(&address) {
Ok(listener) => listener,
Err(error) => {
report_bind_failure(&config, &address, &error);
return Server {};
}
};
let bound = listener
.resolved_address()
.unwrap_or_else(|| address.clone())
.to_string();
crate::diagnostic(&format!("exfiltrate: listening on {bound}"));
match &credential {
Credential::Generated(token) => crate::diagnostic(&format!(
"exfiltrate: {bound} is reachable from outside this machine, so it needs\
\n a token. This run's token is:\
\n\
\n {token}\
\n\
\n Pass it with `exfiltrate --token {token}`, or export\
\n {env}={token} on both ends. It changes every run; set\
\n {env} here to pin one instead, and it will not be printed.\
\n A token authenticates but does not encrypt: debug output crossing a\
\n hostile network still wants a tunnel.",
env = auth::TOKEN_ENV
)),
Credential::Configured(token) if config.announce_token => crate::diagnostic(&format!(
"exfiltrate: this run's token is {token} — pass it with `exfiltrate --token \
{token}` or export {}={token}",
auth::TOKEN_ENV
)),
Credential::Configured(_) => {
crate::diagnostic("exfiltrate: connections must present a token")
}
Credential::None => {}
}
if config.instance_registry && !matches!(address, Address::Fd(_)) {
crate::instances::advertise(&config.build_info().app_name, &bound);
}
let spawned = std::thread::Builder::new()
.name("exfiltrate::listen".to_string())
.spawn(move || {
loop {
match listener.accept() {
Ok(Some(stream)) => do_stream(stream),
Ok(None) => break,
Err(error) => {
crate::diagnostic(&format!(
"exfiltrate: accept failed: {error}; still listening"
));
std::thread::sleep(BACKOFF_DURATION);
}
}
}
});
if let Err(error) = spawned {
crate::diagnostic(&format!(
"exfiltrate: could not spawn the listen thread: {error}"
));
}
Server {}
}
#[cfg(target_arch = "wasm32")]
fn new_web() -> Server {
wasm32::wasm32_go();
Server {}
}
}
#[cfg(not(target_arch = "wasm32"))]
fn report_bind_failure(config: &crate::Config, address: &Address, error: &std::io::Error) {
let message = match error.kind() {
std::io::ErrorKind::AddrInUse => format!(
"exfiltrate: {address} is already in use, so no debug server was started.\n \
Another copy of this program is probably running. Set EXFILTRATE_ADDR, or pass \
`Config::default().with_addr(\"127.0.0.1:0\")` to take any free port — \
`exfiltrate instances` will list it."
),
std::io::ErrorKind::PermissionDenied => format!(
"exfiltrate: permission denied binding {address}, so no debug server was started.\n \
You may be running in a sandbox that forbids listening sockets. Try a transport \
the sandbox does allow: `EXFILTRATE_ADDR=unix:/path/to/socket` needs only a \
directory this process can write, and `EXFILTRATE_ADDR=fd:3` needs nothing at \
all beyond a connected socketpair(2) the parent process passed in."
),
_ => {
format!("exfiltrate: could not bind {address}: {error}; no debug server was started.")
}
};
report_bind_failure_message(config, message);
}
#[cfg(not(target_arch = "wasm32"))]
fn report_bind_failure_message(config: &crate::Config, message: String) {
match config.on_bind_failure {
crate::BindFailure::Warn => crate::diagnostic(&message),
crate::BindFailure::Silent => {}
crate::BindFailure::Panic => panic!("{message}"),
}
}
#[cfg(test)]
#[path = "server_tests.rs"]
mod tests;