use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::time::Duration;
use shell_tunnel::config::{Posture, PublicExposure};
use shell_tunnel::relay::{serve_relay_on, RelayConfig};
use shell_tunnel::security::CapabilitySet;
use shell_tunnel::tunnel::{self, TunnelHandle};
use shell_tunnel::{logging, parse_args, print_help, print_version, Args, Config};
use tracing::{info, warn};
macro_rules! outln {
() => {{
use std::io::Write as _;
let _ = writeln!(std::io::stdout().lock());
}};
($($arg:tt)*) => {{
use std::io::Write as _;
let _ = writeln!(std::io::stdout().lock(), $($arg)*);
}};
}
macro_rules! errln {
() => {{
use std::io::Write as _;
let _ = writeln!(std::io::stderr().lock());
}};
($($arg:tt)*) => {{
use std::io::Write as _;
let _ = writeln!(std::io::stderr().lock(), $($arg)*);
}};
}
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 posture = config.posture(provider.is_some(), args.relay_url.is_some());
let mut exposure = if posture == Posture::Exposed {
match config.harden_for_public_exposure(&args) {
Ok(exposure) => exposure,
Err(e) => {
eprintln!("Configuration error: {}", e);
std::process::exit(1);
}
}
} else {
PublicExposure::default()
};
if exposure.generated_key.is_none() {
exposure.generated_key = config.ensure_api_key();
}
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() {
let given = if args.tls_self_signed {
"--tls-self-signed applies"
} else {
"--tls-cert/--tls-key apply"
};
eprintln!("Configuration error: {given} 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 — and pass --require-auth");
eprintln!("with it. A proxy does not change the bind address, so a loopback-bound gateway");
eprintln!("goes on treating itself as local, with authentication off, while the proxy");
eprintln!("publishes it to whoever can reach the proxy.");
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, posture == Posture::Exposed);
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()
};
let audit_log = effective_audit_log(args.audit_log.as_deref(), posture);
if let (Some(path), Some(jail)) = (audit_log.as_ref(), fs_root.jail_path()) {
match audit_log_is_inside_fs_root(path, jail) {
Ok(true) => {
if args.audit_log.is_some() {
eprintln!(
"--audit-log {} cannot be used: it resolves inside --fs-root {}",
path.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.");
} else {
eprintln!(
"A publicly reachable server writes an audit trail, and its default location ({}) resolves inside --fs-root {}",
DEFAULT_AUDIT_LOG,
jail.display()
);
eprintln!("An fs.write token could delete or overwrite the trail recording its own actions. Pass --audit-log with a path outside the fs root.");
}
std::process::exit(2);
}
Ok(false) => {}
Err(e) => {
eprintln!(
"--audit-log {} cannot be checked against --fs-root {}: {e}",
path.display(),
jail.display()
);
std::process::exit(2);
}
}
}
let audit = match &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) => {
if args.audit_log.is_some() {
eprintln!("--audit-log {} cannot be used: {e}", path.display());
eprintln!("The parent directory must exist and be writable.");
} else {
eprintln!("A publicly reachable server writes an audit trail, and its default location ({}) cannot be created: {e}", DEFAULT_AUDIT_LOG);
eprintln!("Start the server in a writable directory, or pass --audit-log with a path elsewhere.");
}
std::process::exit(1);
}
}
}
None => std::sync::Arc::new(shell_tunnel::audit::AuditSink::Disabled),
};
if audit.is_enabled() && posture == Posture::Local {
info!("audit trail: {}", audit_log.as_ref().unwrap().display());
}
let resolved = config.resolved_capabilities().ok().flatten();
let scope = token_scope(config.security.auth.preset.as_deref(), resolved.as_ref());
for line in posture_banner(
posture,
&scope,
audit_log.as_deref(),
!args.allow_hosts.is_empty(),
) {
outln!("{line}");
}
if provider.is_none() && args.relay_url.is_none() {
if let Some(key) = &exposure.generated_key {
outln!("{}", generated_api_key_lines(key));
}
}
let chunk_size = resolve_chunk_size(&args);
outln!("File API: {}", fs_root.describe());
if chunk_size != shell_tunnel::fs::DEFAULT_CHUNK_SIZE {
outln!(" upload chunk size: {chunk_size} bytes");
if args.fs_chunk_size.is_none() {
outln!(" (a relayed chunk must finish inside the relay's request deadline; pass --fs-chunk-size to override)");
}
}
if fs_root.jail_path().is_none()
&& file_scope_is_the_whole_grant(config.security.auth.enabled, resolved.as_ref())
{
outln!(" this token holds the file API without `exec`, so --fs-root is the only confinement it has — and it was not given");
}
if fs_root.jail_path().is_none() && audit_log.is_some() {
outln!(" the audit log is within this scope — nothing is outside a machine-wide file API");
}
let state = shell_tunnel::AppState::new()
.with_audit(audit)
.with_fs_root(fs_root);
let state = state.with_chunk_size(chunk_size);
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 {
for warning in &exposure.warnings {
warn!("{}", warning);
}
let listener = bind_or_exit(&server_config).await;
return shell_tunnel::api::serve_on(listener, 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 listener = bind_or_exit(&server_config).await;
let server = tokio::spawn(shell_tunnel::api::serve_on(listener, 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 bind_or_exit(config: &shell_tunnel::ServerConfig) -> tokio::net::TcpListener {
match shell_tunnel::api::bind(config).await {
Ok(listener) => listener,
Err(shell_tunnel::ShellTunnelError::Io(e)) => {
eprintln!(
"{}",
shell_tunnel::error::explain_bind_failure("server", &config.bind_address(), &e)
);
std::process::exit(1);
}
Err(e) => {
eprintln!("{e}");
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_missing: 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;
if let Ok(covered) = files.covered_names(&names) {
cert_missing = names
.iter()
.filter(|name| !covered.contains(name))
.cloned()
.collect();
cert_names = covered;
}
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
};
let listener = match shell_tunnel::relay::bind_relay(&config).await {
Ok(listener) => listener,
Err(shell_tunnel::ShellTunnelError::Io(e)) => {
eprintln!(
"{}",
shell_tunnel::error::explain_bind_failure("relay", &bind.to_string(), &e)
);
std::process::exit(1);
}
Err(e) => {
eprintln!("{e}");
std::process::exit(1);
}
};
match &reachable {
Some(url) => outln!("\nRelay: {url}"),
None => outln!("\nRelay: listening on {bind}"),
}
if generated {
outln!("Enroll token: {enroll_token} (generated)");
outln!("{}", generated_enroll_token_note());
}
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();
let token_arg = if generated {
enroll_token.to_string()
} else {
"<token>".to_string()
};
outln!(
"Devices join with:\n shell-tunnel --relay {join_url} --enroll-token {token_arg}{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
};
errln!(
"Note: --public-base named no port, so the URLs above use this relay's port {}.",
bind.port()
);
errln!(
" Fronted by a proxy on port {implied}? Re-run with that port in --public-base."
);
errln!();
}
#[cfg(feature = "tls")]
if args.tls_self_signed {
let lines = certificate_banner(
generated_cert,
&cert_names,
&cert_missing,
args.tls_cert.as_deref(),
args.tls_key.as_deref(),
cert_fingerprint.is_some(),
);
if !lines.is_empty() {
for line in lines {
outln!("{line}");
}
outln!();
}
}
serve_relay_on(listener, 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 (enrolled_tx, mut enrolled_rx) = tokio::sync::mpsc::unbounded_channel();
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(),
enrolled: Some(enrolled_tx),
};
for warning in &exposure.warnings {
warn!("{}", warning);
}
if let Some(key) = &exposure.generated_key {
outln!("{}", generated_api_key_lines(key));
}
let generated_key = exposure.generated_key.clone();
tokio::spawn(async move {
let mut announced = false;
while let Some(url) = enrolled_rx.recv().await {
if announced {
info!("re-attached to relay as {url}");
} else {
print_relay_banner(&url, generated_key.as_deref());
announced = true;
}
}
});
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) => generated_api_key_lines(key),
None => "API key: (the key you configured)".to_string(),
};
let key_value = generated_key.unwrap_or("$SHELL_TUNNEL_API_KEY");
outln!(
"\nPublic URL: {url} (via {provider})\n{key_line}\n{}\n",
try_curl_block(url, key_value),
provider = tunnel.provider(),
);
}
fn try_curl_block(url: &str, key_value: &str) -> String {
format!("Try: curl -X POST {url}/api/v1/execute \\\n -H \"Authorization: Bearer {key_value}\" \\\n -H \"Content-Type: application/json\" \\\n -d '{{\"command\":\"echo hi\"}}'")
}
#[cfg(feature = "relay-client")]
fn print_relay_banner(url: &str, generated_key: Option<&str>) {
let key_value = generated_key.unwrap_or("$SHELL_TUNNEL_API_KEY");
outln!(
"\nPublic URL: {url} (via relay)\n{}\n",
try_curl_block(url, key_value)
);
}
const DEFAULT_AUDIT_LOG: &str = "shell-tunnel-audit.jsonl";
fn effective_audit_log(explicit: Option<&Path>, posture: Posture) -> Option<PathBuf> {
match (explicit, posture) {
(Some(path), _) => Some(path.to_path_buf()),
(None, Posture::Exposed) => Some(PathBuf::from(DEFAULT_AUDIT_LOG)),
(None, Posture::Local) => None,
}
}
#[derive(Debug, PartialEq, Eq)]
enum TokenScope {
Wildcard,
Preset { name: String, listed: Vec<String> },
Explicit(Vec<String>),
}
fn token_scope(preset: Option<&str>, capabilities: Option<&CapabilitySet>) -> TokenScope {
let Some(set) = capabilities else {
return TokenScope::Wildcard;
};
if set.is_wildcard() {
return TokenScope::Wildcard;
}
let mut listed: Vec<String> = set.iter().cloned().collect();
listed.sort();
match preset {
Some(name) if shell_tunnel::security::preset(name).as_ref() == Some(set) => {
TokenScope::Preset {
name: name.to_string(),
listed,
}
}
_ => TokenScope::Explicit(listed),
}
}
fn scope_lines(prefix: &str, listed: &[String]) -> Vec<String> {
let quoted: Vec<String> = listed.iter().map(|c| format!("`{c}`")).collect();
let mut lines = vec![format!(
"Reachable: from other machines — tokens are scoped to {prefix}{}",
quoted.join(", ")
)];
if !narrows_something(listed) {
lines.push(
" that is every capability this version defines, so nothing is withheld today; the wildcard differs only for capabilities added later".to_string(),
);
}
lines
}
fn narrows_something(listed: &[String]) -> bool {
!shell_tunnel::security::KNOWN_CAPABILITIES
.iter()
.all(|known| listed.iter().any(|held| held == known))
}
fn resolve_chunk_size(args: &shell_tunnel::cli::Args) -> usize {
let Some(size) = args.fs_chunk_size else {
return match args.relay_url.is_some() {
true => shell_tunnel::fs::RELAY_CHUNK_SIZE,
false => shell_tunnel::fs::DEFAULT_CHUNK_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.");
eprintln!("Size is not the only relay constraint — a relayed chunk must also finish inside the relay's 120s request deadline, which is why a relay-joined device advertises a smaller size than this ceiling allows.");
std::process::exit(2);
}
if args.relay_url.is_some() && size > shell_tunnel::fs::RELAY_CHUNK_SIZE {
let safe = shell_tunnel::fs::RELAY_CHUNK_SIZE;
errln!("warning: --fs-chunk-size {size} is larger than the {safe} bytes this device would advertise over a relay.");
errln!(" A relayed request body is forwarded whole and must complete inside the relay's 120s deadline, so an oversized chunk fails at 0 bytes with 504 on a slow link rather than transferring slowly.");
errln!(" Drop the flag to use the relay-safe size.");
}
size
}
fn file_scope_is_the_whole_grant(auth_enabled: bool, capabilities: Option<&CapabilitySet>) -> bool {
let Some(set) = capabilities.filter(|_| auth_enabled) else {
return false;
};
!set.satisfies("exec") && (set.satisfies("fs.read") || set.satisfies("fs.write"))
}
fn generated_enroll_token_note() -> &'static str {
" not saved: a restart generates a new one and every attached device's join line stops working. Pass --enroll-token to keep it across restarts."
}
fn generated_api_key_lines(key: &str) -> String {
format!("API key: {key} (generated)\n not saved: a restart generates a new one and every existing caller is refused. Pass --api-key (or SHELL_TUNNEL_API_KEY) to keep it across restarts.")
}
fn posture_banner(
posture: Posture,
scope: &TokenScope,
audit_log: Option<&Path>,
allow_host_given: bool,
) -> Vec<String> {
if posture == Posture::Local {
return Vec::new();
}
let mut lines = match scope {
TokenScope::Wildcard => vec!["Reachable: from other machines — tokens hold the wildcard `*`: every capability, including any added in later versions".to_string()],
TokenScope::Preset { name, listed } => scope_lines(&format!("`{name}`: "), listed),
TokenScope::Explicit(listed) => scope_lines("", listed),
};
if allow_host_given {
lines.push(
" --allow-host was not applied — a published server answers to any Host, since it is reached under names it cannot know".to_string(),
);
}
if let Some(path) = audit_log {
lines.push(format!("Audit trail: {}", path.display()));
}
lines
}
#[cfg(feature = "tls")]
fn certificate_banner(
generated: bool,
covered: &[String],
missing: &[String],
cert: Option<&Path>,
key: Option<&Path>,
fingerprint_known: bool,
) -> Vec<String> {
let mut lines = Vec::new();
if generated {
lines.push("Generated a self-signed certificate; restarts reuse it.".to_string());
}
if !covered.is_empty() {
lines.push(format!("Certificate covers: {}", covered.join(", ")));
}
if missing.is_empty() {
if let Some(cert) = cert {
if fingerprint_known {
lines.push(
"Nothing needs copying: the fingerprint in the join line is the trust anchor."
.to_string(),
);
lines.push(format!(
"(Alternative: copy {} to devices and join with --relay-ca.)",
cert.display()
));
} else {
lines.push(format!(
"Copy {} to each device for --relay-ca.",
cert.display()
));
}
}
return lines;
}
let absent = missing.join(", ");
if generated {
lines.push(format!(" but not {absent} — a name has to be a DNS name or an IP address to go into a certificate, and that one is neither."));
} else {
lines.push(format!(" but not {absent} — an existing certificate is reused rather than reissued, so a name added to --public-base afterwards is not in it."));
}
if fingerprint_known {
lines.push(" Devices joining with --relay-fingerprint are unaffected: it pins this certificate and never checks the name.".to_string());
}
lines.push(format!(" --relay-ca cannot be used for {absent} — a device dialling that name refuses this certificate."));
if let (Some(cert), Some(key)) = (cert, key) {
lines.push(format!(
" To reissue: stop the relay, delete {} and {}, and start it again.",
cert.display(),
key.display()
));
lines.push(" Every device pinning the old fingerprint then has to be given the new one.".to_string());
}
lines
}
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_exposed_run_gets_an_audit_trail_it_did_not_ask_for() {
let derived = effective_audit_log(None, Posture::Exposed);
assert_eq!(derived, Some(PathBuf::from(DEFAULT_AUDIT_LOG)));
}
#[test]
fn a_local_run_still_creates_nothing() {
assert_eq!(effective_audit_log(None, Posture::Local), None);
}
#[test]
fn an_explicit_audit_path_wins_in_either_posture() {
let chosen = PathBuf::from("/var/log/st.jsonl");
assert_eq!(
effective_audit_log(Some(&chosen), Posture::Exposed),
Some(chosen.clone())
);
assert_eq!(
effective_audit_log(Some(&chosen), Posture::Local),
Some(chosen)
);
}
fn resolved(name: &str) -> CapabilitySet {
shell_tunnel::security::preset(name).expect("preset exists")
}
#[test]
fn the_exposed_banner_names_the_scope_and_the_trail() {
let path = PathBuf::from("shell-tunnel-audit.jsonl");
let scope = token_scope(Some("operator"), Some(&resolved("operator")));
let lines = posture_banner(Posture::Exposed, &scope, Some(&path), false);
let text = lines.join("\n");
assert!(text.contains("Reachable:"), "{text}");
assert!(
text.contains("operator"),
"must name the scope in force: {text}"
);
assert!(
text.contains("shell-tunnel-audit.jsonl"),
"must name the trail: {text}"
);
assert!(lines.len() >= 2, "{lines:?}");
}
#[test]
fn a_wildcard_scope_is_never_described_as_scoped() {
let scope = token_scope(Some("full-control"), Some(&resolved("full-control")));
let text = posture_banner(Posture::Exposed, &scope, None, false).join("\n");
assert!(
!text.contains("operator"),
"a wildcard token must not be reported as `operator`: {text}"
);
assert!(
!text.contains("scoped"),
"the wildcard is the absence of scoping — saying `scoped` here is the lie being fixed: {text}"
);
assert!(
text.contains("wildcard"),
"it must say plainly that the token holds the wildcard: {text}"
);
}
#[test]
fn a_scope_that_withholds_nothing_says_so() {
let scope = token_scope(Some("operator"), Some(&resolved("operator")));
let text = posture_banner(Posture::Exposed, &scope, None, false).join("\n");
for capability in shell_tunnel::security::KNOWN_CAPABILITIES {
assert!(
text.contains(capability),
"a reader has to see the grant, not a word standing in for it — {capability} is missing: {text}"
);
}
assert!(
text.contains("nothing is withheld today"),
"the banner must say outright that this scope takes nothing away: {text}"
);
}
#[test]
fn a_scope_that_withholds_something_does_not_say_it_withholds_nothing() {
let scope = token_scope(Some("file-read"), Some(&resolved("file-read")));
let text = posture_banner(Posture::Exposed, &scope, None, false).join("\n");
assert!(text.contains("fs.read"), "{text}");
assert!(
!text.contains("nothing is withheld"),
"file-read holds one capability of five; saying nothing is withheld would be \
false in the reassuring direction: {text}"
);
assert!(
!text.contains("exec"),
"a preset without exec must not name it: {text}"
);
}
#[test]
fn an_unresolved_scope_is_the_wildcard() {
assert_eq!(token_scope(None, None), TokenScope::Wildcard);
}
#[test]
fn an_explicit_preset_is_named_rather_than_the_promoted_one() {
let scope = token_scope(Some("file-read"), Some(&resolved("file-read")));
let text = posture_banner(Posture::Exposed, &scope, None, false).join("\n");
assert!(text.contains("file-read"), "{text}");
assert!(
!text.contains("operator"),
"the preset in force is file-read, not the default: {text}"
);
}
#[test]
fn an_explicit_capability_list_is_spelled_out() {
let caps: CapabilitySet = ["exec", "fs.read"].into_iter().collect();
let scope = token_scope(None, Some(&caps));
let text = posture_banner(Posture::Exposed, &scope, None, false).join("\n");
assert!(text.contains("exec"), "{text}");
assert!(text.contains("fs.read"), "{text}");
}
#[test]
fn a_preset_with_extras_is_listed_rather_than_named() {
let mut caps = resolved("file-read");
caps.insert("exec");
let scope = token_scope(Some("file-read"), Some(&caps));
let text = posture_banner(Posture::Exposed, &scope, None, false).join("\n");
assert!(
!text.contains("file-read"),
"naming the preset would hide the `exec` unioned on top of it: {text}"
);
assert!(text.contains("exec"), "{text}");
}
#[test]
fn an_explicit_capability_list_is_ordered() {
let caps: CapabilitySet = ["session.read", "exec", "fs.read"].into_iter().collect();
match token_scope(None, Some(&caps)) {
TokenScope::Explicit(listed) => {
assert_eq!(listed, vec!["exec", "fs.read", "session.read"]);
}
other => panic!("expected an explicit list, got {other:?}"),
}
}
#[test]
fn the_file_presets_have_nothing_but_the_file_api() {
assert!(file_scope_is_the_whole_grant(
true,
Some(&resolved("file-read"))
));
assert!(file_scope_is_the_whole_grant(
true,
Some(&resolved("file-write"))
));
}
#[test]
fn an_unauthenticated_server_has_no_token_to_describe() {
assert!(!file_scope_is_the_whole_grant(
false,
Some(&resolved("file-read"))
));
assert!(!file_scope_is_the_whole_grant(
false,
Some(&resolved("file-write"))
));
let named: CapabilitySet = ["fs.read"].into_iter().collect();
assert!(!file_scope_is_the_whole_grant(false, Some(&named)));
}
#[test]
fn a_token_holding_exec_is_not_confined_by_the_file_root() {
assert!(!file_scope_is_the_whole_grant(
true,
Some(&resolved("operator"))
));
assert!(!file_scope_is_the_whole_grant(
true,
Some(&resolved("full-control"))
));
assert!(resolved("full-control").is_wildcard());
}
#[test]
fn an_unresolved_scope_is_not_treated_as_file_only() {
assert!(!file_scope_is_the_whole_grant(true, None));
}
#[test]
fn an_explicit_file_capability_list_counts_too() {
let read: CapabilitySet = ["fs.read"].into_iter().collect();
assert!(file_scope_is_the_whole_grant(true, Some(&read)));
let with_exec: CapabilitySet = ["fs.read", "exec"].into_iter().collect();
assert!(!file_scope_is_the_whole_grant(true, Some(&with_exec)));
let sessions: CapabilitySet = ["session.read"].into_iter().collect();
assert!(!file_scope_is_the_whole_grant(true, Some(&sessions)));
}
#[test]
fn the_exposed_banner_says_allow_host_did_nothing() {
let scope = token_scope(Some("operator"), Some(&resolved("operator")));
let text = posture_banner(Posture::Exposed, &scope, None, true).join(
"
",
);
assert!(
text.contains("--allow-host"),
"the flag has to be named, not merely alluded to: {text}"
);
assert!(
text.contains("not applied"),
"and it has to say the flag did nothing: {text}"
);
}
#[test]
fn the_exposed_banner_is_silent_about_an_allow_host_nobody_gave() {
let scope = token_scope(Some("operator"), Some(&resolved("operator")));
let text = posture_banner(Posture::Exposed, &scope, None, false).join(
"
",
);
assert!(!text.contains("--allow-host"), "{text}");
}
#[test]
fn a_generated_enroll_token_says_it_is_not_saved() {
let note = generated_enroll_token_note();
assert!(
note.contains("not saved"),
"the fact has to be stated, not implied: {note}"
);
assert!(
note.contains("--enroll-token"),
"and it has to name the flag that fixes it: {note}"
);
assert!(note.starts_with(" "), "{note:?}");
}
#[test]
fn the_local_banner_says_nothing() {
assert!(posture_banner(Posture::Local, &TokenScope::Wildcard, None, true).is_empty());
}
#[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)"
);
}
#[cfg(feature = "tls")]
mod certificate {
use super::super::*;
fn names(listed: &[&str]) -> Vec<String> {
listed.iter().map(|n| n.to_string()).collect()
}
fn cert_pair() -> (PathBuf, PathBuf) {
(
PathBuf::from("shell-tunnel-cert.pem"),
PathBuf::from("shell-tunnel-key.pem"),
)
}
#[test]
fn a_name_the_certificate_lacks_is_never_reported_as_covered() {
let (cert, key) = cert_pair();
let lines = certificate_banner(
false,
&names(&["relay-host", "localhost"]),
&names(&["relay.example.com"]),
Some(&cert),
Some(&key),
true,
);
let covers = lines
.iter()
.find(|line| line.starts_with("Certificate covers:"))
.expect("the covered names are still announced");
assert!(
!covers.contains("relay.example.com"),
"an absent name must not appear on the coverage line: {covers}"
);
assert!(
lines.iter().any(|line| line.contains("relay.example.com")),
"and its absence has to be stated rather than left silent: {lines:?}"
);
}
#[test]
fn a_certificate_missing_a_name_does_not_offer_the_ca_alternative() {
let (cert, key) = cert_pair();
let text = certificate_banner(
false,
&names(&["relay-host"]),
&names(&["relay.example.com"]),
Some(&cert),
Some(&key),
true,
)
.join("\n");
assert!(
!text.contains("Alternative: copy"),
"the --relay-ca alternative cannot be offered for a name the certificate lacks: {text}"
);
assert!(
text.contains("--relay-fingerprint"),
"the path that does still work has to be named: {text}"
);
}
#[test]
fn a_missing_name_comes_with_the_way_out() {
let (cert, key) = cert_pair();
let text = certificate_banner(
false,
&names(&["relay-host"]),
&names(&["relay.example.com"]),
Some(&cert),
Some(&key),
true,
)
.join("\n");
assert!(text.contains("shell-tunnel-cert.pem"), "{text}");
assert!(text.contains("shell-tunnel-key.pem"), "{text}");
assert!(
text.contains("fingerprint"),
"reissuing invalidates every pinned device, which is why it is not the default: {text}"
);
}
#[test]
fn a_freshly_generated_certificate_is_not_blamed_on_reuse() {
let (cert, key) = cert_pair();
let text = certificate_banner(
true,
&names(&["relay-host"]),
&names(&["under_score"]),
Some(&cert),
Some(&key),
true,
)
.join("\n");
assert!(
!text.contains("reused rather than reissued"),
"nothing was reused on this run: {text}"
);
}
#[test]
fn a_certificate_covering_everything_still_offers_the_ca_alternative() {
let (cert, key) = cert_pair();
let text = certificate_banner(
false,
&names(&["relay.example.com", "localhost"]),
&[],
Some(&cert),
Some(&key),
true,
)
.join("\n");
assert!(text.contains("Certificate covers: relay.example.com, localhost"));
assert!(text.contains("Nothing needs copying"), "{text}");
assert!(text.contains("Alternative: copy"), "{text}");
assert!(
!text.contains("but not"),
"nothing is missing, so nothing is withheld: {text}"
);
}
#[test]
fn an_unreadable_fingerprint_is_not_described_as_a_working_path() {
let (cert, key) = cert_pair();
let text = certificate_banner(
false,
&names(&["relay-host"]),
&names(&["relay.example.com"]),
Some(&cert),
Some(&key),
false,
)
.join("\n");
assert!(
!text.contains("are unaffected"),
"there is no fingerprint in the join line to be unaffected: {text}"
);
}
}
}