use std::net::SocketAddr;
use std::path::Path;
use std::time::Duration;
use shell_tunnel::config::PublicExposure;
use shell_tunnel::relay::{serve_relay, RelayConfig};
use shell_tunnel::tunnel::{self, TunnelHandle};
use shell_tunnel::{logging, parse_args, print_help, print_version, Args, Config};
use tracing::{info, warn};
fn main() -> shell_tunnel::Result<()> {
let args = match parse_args() {
Ok(args) => args,
Err(e) => {
eprintln!("Error: {}", e);
eprintln!("Use --help for usage information");
std::process::exit(1);
}
};
if args.help {
print_help();
return Ok(());
}
if args.version {
print_version();
return Ok(());
}
#[cfg(feature = "self-update")]
{
use shell_tunnel::update;
if args.check_update {
match update::check_update() {
Ok(info) => {
println!("Current version: {}", info.current);
println!("Latest version: {}", info.latest);
if info.update_available {
println!("\nUpdate available! Run with --update to install.");
} else {
println!("\nYou are running the latest version.");
}
}
Err(e) => {
eprintln!("Failed to check for updates: {}", e);
std::process::exit(1);
}
}
return Ok(());
}
if args.update {
println!("Checking for updates...");
match update::self_update() {
Ok(true) => {
println!("Successfully updated! Please restart shell-tunnel.");
}
Ok(false) => {
println!("Already running the latest version.");
}
Err(e) => {
eprintln!("Update failed: {}", e);
std::process::exit(1);
}
}
return Ok(());
}
}
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?
.block_on(async_main(args))
}
async fn async_main(args: Args) -> shell_tunnel::Result<()> {
if args.relay {
return run_relay(&args).await;
}
let mut config = match Config::load(&args) {
Ok(config) => config,
Err(e) => {
eprintln!("Configuration error: {}", e);
std::process::exit(1);
}
};
let provider = match config.tunnel_provider() {
Ok(provider) => provider,
Err(e) => {
eprintln!("Configuration error: {}", e);
std::process::exit(1);
}
};
let public = provider.is_some() || args.relay_url.is_some();
let exposure = if public {
match config.harden_for_public_exposure(&args) {
Ok(exposure) => exposure,
Err(e) => {
eprintln!("Configuration error: {}", e);
std::process::exit(1);
}
}
} else {
PublicExposure::default()
};
if args.relay_url.is_some() && args.enroll_token.is_none() {
eprintln!("Configuration error: --relay requires --enroll-token");
std::process::exit(1);
}
if args.tls_cert.is_some() {
eprintln!("Configuration error: --tls-cert/--tls-key apply to `shell-tunnel relay`.");
eprintln!("A gateway is reached through a tunnel or a relay, which carry their own TLS;");
eprintln!("to expose one directly, put a reverse proxy in front.");
std::process::exit(1);
}
#[cfg(not(feature = "relay-client"))]
if args.relay_url.is_some() {
eprintln!("Configuration error: this build has no relay client.");
eprintln!("Rebuild with `--features relay-client`, or use --tunnel.");
std::process::exit(1);
}
std::env::set_var("RUST_LOG", config.log_filter());
logging::init();
info!("shell-tunnel v{}", env!("CARGO_PKG_VERSION"));
#[cfg(feature = "self-update")]
if !args.no_update_check {
shell_tunnel::update::background_update_check();
}
let allowed_hosts = config.allowed_hosts(&args, public);
let server_config = match config.to_server_config() {
Ok(mut c) => {
if let Some(hosts) = allowed_hosts {
c.security = c.security.with_allowed_hosts(hosts);
}
c
}
Err(e) => {
eprintln!("Configuration error: {}", e);
std::process::exit(1);
}
};
let fs_root = if let Some(fs_root) = args.fs_root.as_ref() {
if !fs_root.is_dir() {
eprintln!(
"--fs-root {} cannot be used: not a directory",
fs_root.display()
);
eprintln!("The directory must exist and be readable.");
std::process::exit(2);
}
match shell_tunnel::FsRoot::new(fs_root) {
Ok(root) => root,
Err(e) => {
eprintln!("--fs-root {} cannot be used: {e}", fs_root.display());
eprintln!("The directory must exist and be readable.");
std::process::exit(2);
}
}
} else {
shell_tunnel::FsRoot::machine_wide()
};
if let (Some(audit_log), Some(jail)) = (args.audit_log.as_ref(), fs_root.jail_path()) {
match audit_log_is_inside_fs_root(audit_log, jail) {
Ok(true) => {
eprintln!(
"--audit-log {} cannot be used: it resolves inside --fs-root {}",
audit_log.display(),
jail.display()
);
eprintln!(
"An fs.write token could delete or overwrite the trail recording its own actions. Point --audit-log outside the fs root."
);
std::process::exit(2);
}
Ok(false) => {}
Err(e) => {
eprintln!(
"--audit-log {} cannot be checked against --fs-root {}: {e}",
audit_log.display(),
jail.display()
);
std::process::exit(2);
}
}
}
let audit = match &args.audit_log {
Some(path) => {
match shell_tunnel::audit::AuditSink::file_with_limit(path, args.audit_max_bytes) {
Ok(sink) => std::sync::Arc::new(sink),
Err(e) => {
eprintln!("Configuration error: {}", e);
std::process::exit(1);
}
}
}
None => std::sync::Arc::new(shell_tunnel::audit::AuditSink::Disabled),
};
if audit.is_enabled() {
info!(
"audit trail: {}",
args.audit_log.as_ref().unwrap().display()
);
}
println!("File API: {}", fs_root.describe());
if fs_root.jail_path().is_none() && args.audit_log.is_some() {
println!(" the audit log is within this scope — as it already is for `exec`");
}
let state = shell_tunnel::AppState::new()
.with_audit(audit)
.with_fs_root(fs_root);
let state = match args.fs_chunk_size {
Some(size) if size == 0 || size >= shell_tunnel::fs::MAX_CHUNK_SIZE => {
eprintln!("--fs-chunk-size {size} is out of range.");
eprintln!("It must be between 1 and 8388607 bytes: a relayed request body is capped at 8 MiB, so a larger chunk fails with 413 on every relayed transfer.");
std::process::exit(2);
}
Some(size) => state.with_chunk_size(size),
None => state,
};
if let Some(root) = state.fs.as_ref() {
let removed = shell_tunnel::api::fs::sweep_orphaned_uploads(root, &state.audit);
if removed > 0 {
info!("removed {removed} orphaned upload staging file(s)");
}
}
if state.fs.is_some() {
let uploads = state.uploads.clone();
let audit = state.audit.clone();
tokio::spawn(async move {
let mut ticker = tokio::time::interval(Duration::from_secs(300));
loop {
ticker.tick().await;
let uploads = uploads.clone();
let audit = audit.clone();
let dropped = tokio::task::spawn_blocking(move || {
shell_tunnel::api::fs::sweep_expired_uploads(
&uploads,
&audit,
shell_tunnel::fs::SESSION_TTL,
)
})
.await
.unwrap_or(0);
if dropped > 0 {
info!("swept {dropped} expired upload session(s)");
}
}
});
}
#[cfg(feature = "relay-client")]
if let Some(relay_url) = args.relay_url.clone() {
return run_with_relay(server_config, &args, relay_url, exposure, state).await;
}
let Some(provider) = provider else {
return shell_tunnel::api::serve_with_state(server_config, state).await;
};
let local: SocketAddr = server_config
.bind_address()
.parse()
.expect("bind address is built from a parsed IpAddr and a u16 port");
let server = tokio::spawn(shell_tunnel::api::serve_with_state(server_config, state));
wait_until_listening(local, Duration::from_secs(5)).await;
let mut tunnel = match tokio::task::spawn_blocking(move || {
tunnel::start(provider.as_ref(), local, tunnel::URL_TIMEOUT)
})
.await
.expect("tunnel supervisor task panicked")
{
Ok(handle) => handle,
Err(e) => {
eprintln!("{}", e);
server.abort();
std::process::exit(1);
}
};
for warning in &exposure.warnings {
warn!("{}", warning);
}
print_banner(&tunnel, exposure.generated_key.as_deref());
tokio::select! {
result = server => result.expect("server task panicked"),
() = tunnel_died(&mut tunnel) => {
eprintln!("Tunnel closed: the public URL is no longer reachable. Shutting down.");
std::process::exit(1);
}
}
}
async fn run_relay(args: &Args) -> shell_tunnel::Result<()> {
let bind = SocketAddr::new(args.host, args.port);
let (enroll_token, generated) = match &args.enroll_token {
Some(token) => (token.clone(), false),
None => (shell_tunnel::security::generate_api_key(), true),
};
let mut config = RelayConfig::new(bind, &enroll_token);
if let Some(base) = &args.public_base {
config = config.with_public_base(base);
}
if args.no_rate_limit {
config = config.without_rate_limit();
}
#[cfg(feature = "tls")]
let mut generated_cert = false;
#[cfg(feature = "tls")]
let mut cert_names: Vec<String> = Vec::new();
#[cfg(feature = "tls")]
let mut cert_fingerprint: Option<String> = None;
#[cfg(feature = "tls")]
if let (Some(cert), Some(key)) = (&args.tls_cert, &args.tls_key) {
let files = shell_tunnel::tls::TlsFiles::new(cert, key);
if args.tls_self_signed {
let names = shell_tunnel::tls::certificate_names(args.public_base.as_deref(), bind);
match files.ensure_self_signed(&names) {
Ok(created) => {
generated_cert = created;
cert_names = names;
cert_fingerprint = files.fingerprint().ok();
}
Err(e) => {
eprintln!("Configuration error: {}", e);
std::process::exit(1);
}
}
}
config = config.with_tls(files);
}
#[cfg(not(feature = "tls"))]
if args.tls_cert.is_some() {
eprintln!("Configuration error: this build cannot serve TLS.");
eprintln!("Rebuild with `--features tls`, or put a reverse proxy in front.");
std::process::exit(1);
}
std::env::set_var("RUST_LOG", args.log_level.as_deref().unwrap_or("info"));
logging::init();
let scheme = if args.tls_cert.is_some() {
"https"
} else {
"http"
};
let reachable = if args.public_base.is_some() {
Some(config.public_base_or(None))
} else if !bind.ip().is_unspecified() {
Some(format!("{scheme}://{bind}"))
} else {
None
};
match &reachable {
Some(url) => println!("\nRelay: {url}"),
None => println!("\nRelay: listening on {bind}"),
}
if generated {
println!("Enroll token: {enroll_token} (generated)");
}
let join_url = reachable.unwrap_or_else(|| format!("{scheme}://<this-host>:{}", bind.port()));
#[cfg(feature = "tls")]
let ca_flag = match &cert_fingerprint {
Some(fp) => format!(" --relay-fingerprint {fp}"),
None => String::new(),
};
#[cfg(not(feature = "tls"))]
let ca_flag = String::new();
println!(
"Devices join with:\n shell-tunnel --relay {join_url} --enroll-token <token>{ca_flag}\n"
);
if let Some(corrected) = config
.public_base
.as_deref()
.and_then(|base| shell_tunnel::relay::public_base_port_hint(base, bind.port()))
{
let implied = if corrected.starts_with("https") {
443
} else {
80
};
eprintln!(
"Note: --public-base named no port, so the URLs above use this relay's port {}.",
bind.port()
);
eprintln!(
" Fronted by a proxy on port {implied}? Re-run with that port in --public-base."
);
eprintln!();
}
#[cfg(feature = "tls")]
if args.tls_self_signed {
if generated_cert {
println!("Generated a self-signed certificate; restarts reuse it.");
}
if !cert_names.is_empty() {
println!("Certificate covers: {}", cert_names.join(", "));
}
if let Some(cert) = &args.tls_cert {
if cert_fingerprint.is_some() {
println!(
"Nothing needs copying: the fingerprint in the join line is the trust anchor."
);
println!(
"(Alternative: copy {} to devices and join with --relay-ca.)\n",
cert.display()
);
} else {
println!("Copy {} to each device for --relay-ca.\n", cert.display());
}
}
}
serve_relay(config).await
}
#[cfg(feature = "relay-client")]
async fn run_with_relay(
server_config: shell_tunnel::ServerConfig,
args: &Args,
relay_url: String,
exposure: PublicExposure,
state: shell_tunnel::AppState,
) -> shell_tunnel::Result<()> {
use shell_tunnel::relay::client::{run as run_relay_client, RelayClientConfig};
let mut server_config = server_config;
if !args.port_explicit {
server_config.port = 0;
}
let listener = shell_tunnel::api::bind(&server_config).await?;
let local = listener
.local_addr()
.map_err(shell_tunnel::ShellTunnelError::Io)?;
let server = tokio::spawn(shell_tunnel::api::serve_on(listener, server_config, state));
let client_config = RelayClientConfig {
relay_url,
enroll_token: args
.enroll_token
.clone()
.expect("checked before logging starts"),
local,
label: None,
device_name: args
.device_name
.clone()
.or_else(shell_tunnel::relay::client::default_device_name),
fingerprint: args.relay_fingerprint.clone(),
ca_file: args.relay_ca.clone(),
};
for warning in &exposure.warnings {
warn!("{}", warning);
}
if let Some(key) = &exposure.generated_key {
println!("API key: {key} (generated)");
}
tokio::select! {
result = server => result.expect("server task panicked"),
result = run_relay_client(client_config) => result,
}
}
async fn wait_until_listening(addr: SocketAddr, timeout: Duration) {
let deadline = tokio::time::Instant::now() + timeout;
while tokio::time::Instant::now() < deadline {
if tokio::net::TcpStream::connect(addr).await.is_ok() {
return;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
}
async fn tunnel_died(tunnel: &mut TunnelHandle) {
while tunnel.is_alive() {
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
fn print_banner(tunnel: &TunnelHandle, generated_key: Option<&str>) {
let url = tunnel.public_url();
let key_line = match generated_key {
Some(key) => format!("API key: {key} (generated)"),
None => "API key: (the key you configured)".to_string(),
};
let key_value = generated_key.unwrap_or("$SHELL_TUNNEL_API_KEY");
println!(
"\nPublic URL: {url} (via {provider})\n\
{key_line}\n\
Try: curl -X POST {url}/api/v1/execute \\\n\
\x20 -H \"Authorization: Bearer {key_value}\" \\\n\
\x20 -H \"Content-Type: application/json\" \\\n\
\x20 -d '{{\"command\":\"echo hi\"}}'\n",
provider = tunnel.provider(),
);
}
fn audit_log_is_inside_fs_root(audit_log: &Path, fs_root: &Path) -> std::io::Result<bool> {
let file_name = audit_log.file_name().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"audit log path has no file name",
)
})?;
let parent = match audit_log.parent() {
Some(p) if !p.as_os_str().is_empty() => p,
_ => Path::new("."),
};
Ok(parent.canonicalize()?.join(file_name).starts_with(fs_root))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn audit_log_under_the_fs_root_is_detected() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path().join("root");
std::fs::create_dir(&root).expect("mkdir root");
let audit_log = root.join("audit.jsonl");
let canonical_root = root.canonicalize().expect("canonicalize root");
assert!(
audit_log_is_inside_fs_root(&audit_log, &canonical_root)
.expect("the parent directory exists"),
"an audit log inside the root must be detected as inside it"
);
}
#[test]
fn audit_log_outside_the_fs_root_is_not_flagged() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path().join("root");
std::fs::create_dir(&root).expect("mkdir root");
let audit_log = dir.path().join("audit.jsonl");
let canonical_root = root.canonicalize().expect("canonicalize root");
assert!(
!audit_log_is_inside_fs_root(&audit_log, &canonical_root)
.expect("the parent directory exists"),
"a sibling audit log must not be flagged as inside the root"
);
}
#[test]
fn a_nested_audit_log_under_the_fs_root_is_detected() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path().join("root");
std::fs::create_dir_all(root.join("logs")).expect("mkdir root/logs");
let audit_log = root.join("logs").join("audit.jsonl");
let canonical_root = root.canonicalize().expect("canonicalize root");
assert!(
audit_log_is_inside_fs_root(&audit_log, &canonical_root)
.expect("the parent directory exists"),
"a nested audit log must be detected as inside the root too"
);
}
#[test]
fn an_uncheckable_audit_log_path_is_an_error_not_a_silent_pass() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path().join("root");
std::fs::create_dir(&root).expect("mkdir root");
let canonical_root = root.canonicalize().expect("canonicalize root");
let never_created = dir.path().join("nonexistent").join("audit.jsonl");
assert!(
audit_log_is_inside_fs_root(&never_created, &canonical_root).is_err(),
"a path whose parent cannot be canonicalised must surface as Err, not Ok(false)"
);
}
}