use std::net::IpAddr;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::api::{CorsConfig, SecurityConfig, ServerConfig};
use crate::cli::Args;
use crate::security::{AuthConfig, CapabilitySet, RateLimitConfig};
use crate::tunnel::{Cloudflared, CustomCommand, TunnelProvider};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub server: ServerSection,
pub security: SecuritySection,
pub transport: TransportSection,
pub logging: LoggingSection,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TransportMode {
#[default]
None,
Cloudflared,
Command,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct TransportSection {
pub mode: TransportMode,
pub command: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ServerSection {
pub host: String,
pub port: u16,
pub graceful_shutdown: bool,
}
impl Default for ServerSection {
fn default() -> Self {
Self {
host: "127.0.0.1".to_string(),
port: 3000,
graceful_shutdown: true,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct SecuritySection {
pub auth: AuthSection,
pub rate_limit: RateLimitSection,
pub cors: CorsSection,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct CorsSection {
pub allow_any: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct AuthSection {
pub enabled: bool,
pub api_keys: Vec<String>,
pub capabilities: Vec<String>,
pub preset: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct RateLimitSection {
pub enabled: bool,
pub requests_per_window: u32,
pub window_secs: u64,
}
impl Default for RateLimitSection {
fn default() -> Self {
Self {
enabled: true,
requests_per_window: 100,
window_secs: 60,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct LoggingSection {
pub level: String,
}
impl Default for LoggingSection {
fn default() -> Self {
Self {
level: "info".to_string(),
}
}
}
impl Config {
pub fn from_file(path: &Path) -> Result<Self, ConfigError> {
let content = std::fs::read_to_string(path).map_err(ConfigError::Io)?;
serde_json::from_str(&content).map_err(ConfigError::Json)
}
pub fn apply_env(&mut self) {
if let Ok(host) = std::env::var("SHELL_TUNNEL_HOST") {
self.server.host = host;
}
if let Ok(port) = std::env::var("SHELL_TUNNEL_PORT") {
if let Ok(port) = port.parse() {
self.server.port = port;
}
}
if let Ok(key) = std::env::var("SHELL_TUNNEL_API_KEY") {
if !key.is_empty() {
self.security.auth.enabled = true;
if !self.security.auth.api_keys.contains(&key) {
self.security.auth.api_keys.push(key);
}
}
}
if let Ok(level) = std::env::var("SHELL_TUNNEL_LOG_LEVEL") {
self.logging.level = level;
} else if let Ok(level) = std::env::var("RUST_LOG") {
self.logging.level = level;
}
}
pub fn apply_args(&mut self, args: &Args) {
if args.host_explicit {
self.server.host = args.host.to_string();
}
if args.port_explicit {
self.server.port = args.port;
}
if let Some(ref key) = args.api_key {
self.security.auth.enabled = true;
if !self.security.auth.api_keys.contains(key) {
self.security.auth.api_keys.push(key.clone());
}
}
if args.require_auth {
self.security.auth.enabled = true;
}
let scope_named = !args.capabilities.is_empty() || args.preset.is_some();
if scope_named {
self.security.auth.capabilities.clear();
self.security.auth.preset = None;
self.security.auth.enabled = true;
}
if !args.capabilities.is_empty() {
self.security.auth.capabilities = args.capabilities.clone();
}
if let Some(ref preset) = args.preset {
self.security.auth.preset = Some(preset.clone());
}
if args.no_auth {
self.security.auth.enabled = false;
}
if let Some(ref command) = args.tunnel_command {
self.transport.mode = TransportMode::Command;
self.transport.command = Some(command.clone());
} else if args.tunnel {
self.transport.mode = TransportMode::Cloudflared;
}
if args.no_rate_limit {
self.security.rate_limit.enabled = false;
}
if args.cors_allow_any {
self.security.cors.allow_any = true;
}
if let Some(ref level) = args.log_level {
self.logging.level = level.clone();
}
}
pub fn load(args: &Args) -> Result<Self, ConfigError> {
let mut config = Config::default();
if let Some(ref path) = args.config {
config = Config::from_file(path)?;
}
config.apply_env();
config.apply_args(args);
Ok(config)
}
pub fn allowed_hosts(&self, args: &Args, published: bool) -> Option<Vec<String>> {
let host: IpAddr = self.server.host.parse().ok()?;
if published || !host.is_loopback() {
return None;
}
let mut hosts = vec![
"localhost".to_string(),
"127.0.0.1".to_string(),
"::1".to_string(),
];
hosts.extend(args.allow_hosts.iter().cloned());
Some(hosts)
}
pub fn tunnel_provider(&self) -> Result<Option<Box<dyn TunnelProvider>>, ConfigError> {
match self.transport.mode {
TransportMode::None => Ok(None),
TransportMode::Cloudflared => Ok(Some(Box::new(Cloudflared))),
TransportMode::Command => {
let command = self
.transport
.command
.as_deref()
.filter(|c| !c.trim().is_empty())
.ok_or(ConfigError::MissingTunnelCommand)?;
Ok(Some(Box::new(CustomCommand::new(command))))
}
}
}
pub fn posture(&self, tunnel_configured: bool, relay_attached: bool) -> Posture {
if tunnel_configured || relay_attached {
return Posture::Exposed;
}
match self.server.host.parse::<IpAddr>() {
Ok(ip) if ip.is_loopback() => Posture::Local,
_ => Posture::Exposed,
}
}
pub fn harden_for_public_exposure(
&mut self,
args: &Args,
) -> Result<PublicExposure, ConfigError> {
if args.no_auth {
return Err(ConfigError::RemoteWithoutAuth);
}
self.security.auth.enabled = true;
let generated_key = self.ensure_api_key();
if self.security.auth.preset.is_none() && self.security.auth.capabilities.is_empty() {
self.security.auth.preset = Some("operator".to_string());
}
let mut warnings = Vec::new();
if !self.security.rate_limit.enabled {
warnings.push("rate limiting is disabled on a publicly reachable server".to_string());
}
Ok(PublicExposure {
generated_key,
warnings,
})
}
pub fn ensure_api_key(&mut self) -> Option<String> {
if !self.security.auth.enabled || !self.security.auth.api_keys.is_empty() {
return None;
}
let key = crate::security::generate_api_key();
self.security.auth.api_keys.push(key.clone());
Some(key)
}
pub fn to_server_config(&self) -> Result<ServerConfig, ConfigError> {
let host: IpAddr = self
.server
.host
.parse()
.map_err(|_| ConfigError::InvalidHost(self.server.host.clone()))?;
let mut security = if self.security.auth.enabled {
SecurityConfig::secure()
} else {
SecurityConfig::development()
};
security.auth = AuthConfig {
enabled: self.security.auth.enabled,
..AuthConfig::default()
};
security.rate_limit = RateLimitConfig {
enabled: self.security.rate_limit.enabled,
max_requests: self.security.rate_limit.requests_per_window,
window: std::time::Duration::from_secs(self.security.rate_limit.window_secs),
max_tracked_ips: 10000,
};
security.cors = CorsConfig {
allow_any: self.security.cors.allow_any,
};
if let Some(capabilities) = resolve_capabilities(
self.security.auth.preset.as_deref(),
&self.security.auth.capabilities,
)? {
security = security.with_capabilities(capabilities);
}
for key in &self.security.auth.api_keys {
security = security.with_api_key(key);
}
let mut server_config = ServerConfig::new(host.to_string(), self.server.port);
server_config = server_config.with_security(security);
if !self.server.graceful_shutdown {
server_config = server_config.without_graceful_shutdown();
}
Ok(server_config)
}
pub fn resolved_capabilities(&self) -> Result<Option<CapabilitySet>, ConfigError> {
resolve_capabilities(
self.security.auth.preset.as_deref(),
&self.security.auth.capabilities,
)
}
pub fn log_filter(&self) -> &str {
&self.logging.level
}
}
fn resolve_capabilities(
preset: Option<&str>,
capabilities: &[String],
) -> Result<Option<CapabilitySet>, ConfigError> {
if preset.is_none() && capabilities.is_empty() {
return Ok(None); }
let mut set = match preset {
Some(name) => crate::security::preset(name)
.ok_or_else(|| ConfigError::InvalidPreset(name.to_string()))?,
None => CapabilitySet::new(),
};
for capability in capabilities {
set.insert(capability.clone());
}
Ok(Some(set))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Posture {
Local,
Exposed,
}
#[derive(Debug, Clone, Default)]
pub struct PublicExposure {
pub generated_key: Option<String>,
pub warnings: Vec<String>,
}
#[derive(Debug)]
pub enum ConfigError {
Io(std::io::Error),
Json(serde_json::Error),
InvalidHost(String),
InvalidPreset(String),
RemoteWithoutAuth,
MissingTunnelCommand,
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(e) => write!(f, "failed to read config file: {}", e),
Self::Json(e) => write!(f, "failed to parse config file: {}", e),
Self::InvalidHost(host) => write!(f, "invalid host address: {}", host),
Self::InvalidPreset(name) if name == "read-only" => {
write!(
f,
"the 'read-only' preset was removed: it granted only session.read, so it could not read a file despite its name. Use file-read to read files, or capabilities session.read for the old behaviour — as --preset/--capabilities, or as security.auth.preset/security.auth.capabilities in a config file"
)
}
Self::InvalidPreset(name) => write!(
f,
"unknown role preset: '{}' (expected operator, file-write, file-read, or full-control)",
name
),
Self::MissingTunnelCommand => write!(
f,
"transport.mode is \"command\" but transport.command is not set (or use --tunnel-command)"
),
Self::RemoteWithoutAuth => write!(
f,
"--no-auth cannot be combined with a publicly reachable server: that would expose an unauthenticated shell. It is refused for a tunnel, a relay, and a non-loopback bind alike. Drop --no-auth (a key is generated for you), or bind loopback and drop the public path"
),
}
}
}
impl std::error::Error for ConfigError {}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_default_config() {
let config = Config::default();
assert_eq!(config.server.host, "127.0.0.1");
assert_eq!(config.server.port, 3000);
assert!(!config.security.auth.enabled);
assert!(config.security.rate_limit.enabled);
}
#[test]
fn test_config_from_json() {
let json = r#"{
"server": {
"host": "0.0.0.0",
"port": 8080
},
"security": {
"auth": {
"enabled": true,
"api_keys": ["key1", "key2"]
}
}
}"#;
let mut file = NamedTempFile::new().unwrap();
file.write_all(json.as_bytes()).unwrap();
let config = Config::from_file(file.path()).unwrap();
assert_eq!(config.server.host, "0.0.0.0");
assert_eq!(config.server.port, 8080);
assert!(config.security.auth.enabled);
assert_eq!(config.security.auth.api_keys.len(), 2);
}
#[test]
fn test_config_partial_json() {
let json = r#"{
"server": {
"port": 9000
}
}"#;
let mut file = NamedTempFile::new().unwrap();
file.write_all(json.as_bytes()).unwrap();
let config = Config::from_file(file.path()).unwrap();
assert_eq!(config.server.host, "127.0.0.1"); assert_eq!(config.server.port, 9000);
}
#[test]
fn test_apply_args() {
let mut config = Config::default();
let args = Args {
host: "192.168.1.1".parse().unwrap(),
host_explicit: true,
port: 5000,
port_explicit: true,
api_key: Some("test-key".to_string()),
no_rate_limit: true,
..Args::default()
};
config.apply_args(&args);
assert_eq!(config.server.host, "192.168.1.1");
assert_eq!(config.server.port, 5000);
assert!(config.security.auth.enabled);
assert!(config
.security
.auth
.api_keys
.contains(&"test-key".to_string()));
assert!(!config.security.rate_limit.enabled);
}
#[test]
fn a_configured_host_and_port_survive_when_no_flag_names_them() {
let mut config = Config::default();
config.server.host = "0.0.0.0".to_string();
config.server.port = 8080;
let nothing_passed = Args::default();
assert!(
!nothing_passed.port_explicit && !nothing_passed.host_explicit,
"the premise: no flag was given"
);
config.apply_args(¬hing_passed);
assert_eq!(config.server.host, "0.0.0.0");
assert_eq!(config.server.port, 8080);
}
#[test]
fn a_named_host_and_port_beat_the_configured_ones() {
let mut config = Config::default();
config.server.host = "0.0.0.0".to_string();
config.server.port = 8080;
config.apply_args(&Args {
host: "10.0.0.5".parse().expect("addr"),
host_explicit: true,
port: 9999,
port_explicit: true,
..Args::default()
});
assert_eq!(config.server.host, "10.0.0.5");
assert_eq!(config.server.port, 9999);
}
#[test]
fn a_configured_non_loopback_host_now_decides_the_posture() {
let mut config = Config::default();
config.server.host = "0.0.0.0".to_string();
config.apply_args(&Args::default());
assert_eq!(
config.posture(false, false),
Posture::Exposed,
"a bind address that now takes effect must also be seen by the posture"
);
let server = config.to_server_config().expect("valid config");
assert_eq!(
server.host, "0.0.0.0",
"the posture and the listener must read the same field"
);
}
#[test]
fn test_apply_no_auth() {
let mut config = Config::default();
config.security.auth.enabled = true;
let args = Args {
no_auth: true,
..Args::default()
};
config.apply_args(&args);
assert!(!config.security.auth.enabled);
}
#[test]
fn test_apply_require_auth() {
let mut config = Config::default();
assert!(!config.security.auth.enabled);
config.apply_args(&Args {
require_auth: true,
..Args::default()
});
assert!(config.security.auth.enabled);
}
#[test]
fn test_no_auth_overrides_require_auth() {
let mut config = Config::default();
config.apply_args(&Args {
require_auth: true,
no_auth: true,
..Args::default()
});
assert!(!config.security.auth.enabled);
}
#[test]
fn test_to_server_config() {
let config = Config::default();
let server_config = config.to_server_config().unwrap();
assert_eq!(server_config.host, "127.0.0.1");
assert_eq!(server_config.port, 3000);
}
#[test]
fn test_apply_args_capabilities_and_preset() {
let mut config = Config::default();
config.apply_args(&Args {
capabilities: vec!["exec".to_string(), "session.read".to_string()],
preset: Some("operator".to_string()),
..Args::default()
});
assert_eq!(
config.security.auth.capabilities,
vec!["exec", "session.read"]
);
assert_eq!(config.security.auth.preset, Some("operator".to_string()));
}
#[test]
fn test_scope_implies_auth_on() {
let mut by_preset = Config::default();
by_preset.apply_args(&Args {
preset: Some("file-read".to_string()),
..Args::default()
});
assert!(by_preset.security.auth.enabled);
let mut by_caps = Config::default();
by_caps.apply_args(&Args {
capabilities: vec!["session.read".to_string()],
..Args::default()
});
assert!(by_caps.security.auth.enabled);
}
#[test]
fn a_scope_named_on_the_command_line_replaces_the_files_scope() {
let mut config = Config::default();
config.security.auth.preset = Some("operator".to_string());
config.apply_args(&Args {
capabilities: vec!["fs.read".to_string()],
..Args::default()
});
assert_eq!(
config.security.auth.preset, None,
"the file's preset must not survive a scope named on the command line"
);
assert_eq!(config.security.auth.capabilities, vec!["fs.read"]);
assert_eq!(
resolve_capabilities(
config.security.auth.preset.as_deref(),
&config.security.auth.capabilities,
)
.expect("valid")
.expect("a scope was named")
.iter()
.collect::<Vec<_>>(),
vec!["fs.read"],
"and the resolved set is what was asked for, with no exec left in it"
);
}
#[test]
fn a_preset_named_on_the_command_line_replaces_the_files_capabilities() {
let mut config = Config::default();
config.security.auth.capabilities = vec!["exec".to_string()];
config.apply_args(&Args {
preset: Some("file-read".to_string()),
..Args::default()
});
assert!(
config.security.auth.capabilities.is_empty(),
"the file's capability list must not survive a preset named on the command line"
);
assert_eq!(config.security.auth.preset, Some("file-read".to_string()));
}
#[test]
fn a_preset_and_capabilities_on_one_command_line_still_union() {
let mut config = Config::default();
config.apply_args(&Args {
preset: Some("file-read".to_string()),
capabilities: vec!["session.read".to_string()],
..Args::default()
});
let resolved = resolve_capabilities(
config.security.auth.preset.as_deref(),
&config.security.auth.capabilities,
)
.expect("valid")
.expect("a scope was named");
assert!(resolved.satisfies("fs.read"), "from the preset");
assert!(resolved.satisfies("session.read"), "from the list");
}
#[test]
fn test_no_auth_overrides_scope_implied_auth() {
let mut config = Config::default();
config.apply_args(&Args {
preset: Some("file-read".to_string()),
no_auth: true,
..Args::default()
});
assert!(!config.security.auth.enabled);
}
#[test]
fn test_config_from_json_with_capabilities_and_preset() {
let json = r#"{
"security": {
"auth": {
"enabled": true,
"api_keys": ["scoped"],
"preset": "file-read",
"capabilities": ["exec"]
}
}
}"#;
let mut file = NamedTempFile::new().unwrap();
file.write_all(json.as_bytes()).unwrap();
let config = Config::from_file(file.path()).unwrap();
assert_eq!(config.security.auth.preset, Some("file-read".to_string()));
assert_eq!(config.security.auth.capabilities, vec!["exec"]);
let server_config = config.to_server_config().unwrap();
let caps = server_config
.security
.capabilities
.expect("capabilities scoped from file");
assert!(caps.satisfies("fs.read")); assert!(caps.satisfies("exec")); assert!(!caps.satisfies("session.manage"));
}
#[test]
fn test_resolve_capabilities_none_by_default() {
assert!(resolve_capabilities(None, &[]).unwrap().is_none());
}
#[test]
fn test_resolve_capabilities_preset_plus_extra() {
let set = resolve_capabilities(Some("file-read"), &["exec".to_string()])
.unwrap()
.unwrap();
assert!(set.satisfies("fs.read"));
assert!(set.satisfies("exec"));
assert!(!set.satisfies("session.manage"));
}
#[test]
fn test_resolve_capabilities_invalid_preset_errors() {
let err = resolve_capabilities(Some("superuser"), &[]);
assert!(matches!(err, Err(ConfigError::InvalidPreset(_))));
}
#[test]
fn test_to_server_config_scopes_capabilities() {
let mut config = Config::default();
config.security.auth.enabled = true;
config.security.auth.api_keys = vec!["scoped".to_string()];
config.security.auth.preset = Some("file-read".to_string());
let server_config = config.to_server_config().unwrap();
let caps = server_config
.security
.capabilities
.expect("capabilities scoped");
assert!(caps.satisfies("fs.read"));
assert!(!caps.satisfies("exec"));
}
#[test]
fn test_to_server_config_invalid_preset_errors() {
let mut config = Config::default();
config.security.auth.preset = Some("root".to_string());
assert!(matches!(
config.to_server_config(),
Err(ConfigError::InvalidPreset(_))
));
}
#[test]
fn the_read_only_refusal_names_its_replacement() {
let err = ConfigError::InvalidPreset("read-only".to_string());
let message = err.to_string();
assert!(
message.contains("file-read"),
"must point at the replacement: {message}"
);
assert!(
message.contains("session.read"),
"must offer the exact escape: {message}"
);
assert!(
message.contains("security.auth.preset"),
"must name the config key, not only the flags: {message}"
);
}
#[test]
fn an_unknown_preset_lists_the_valid_ones() {
let err = ConfigError::InvalidPreset("nonsense".to_string());
let message = err.to_string();
for name in ["operator", "file-write", "file-read", "full-control"] {
assert!(message.contains(name), "must list {name}: {message}");
}
assert!(
!message.contains("read-only"),
"must not advertise a removed preset: {message}"
);
}
#[test]
fn test_invalid_host() {
let mut config = Config::default();
config.server.host = "not-an-ip".to_string();
let result = config.to_server_config();
assert!(result.is_err());
}
#[test]
fn test_config_serialization() {
let config = Config::default();
let json = serde_json::to_string_pretty(&config).unwrap();
assert!(json.contains("\"host\""));
assert!(json.contains("\"port\""));
}
fn tunnel_args() -> Args {
Args {
tunnel: true,
..Default::default()
}
}
#[test]
fn test_public_exposure_refuses_no_auth() {
let mut config = Config::default();
let args = Args {
no_auth: true,
..tunnel_args()
};
let err = config.harden_for_public_exposure(&args).unwrap_err();
assert!(matches!(err, ConfigError::RemoteWithoutAuth));
assert!(err.to_string().contains("unauthenticated shell"));
}
#[test]
fn test_public_exposure_enables_auth_and_generates_a_key() {
let mut config = Config::default();
assert!(!config.security.auth.enabled);
let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
assert!(config.security.auth.enabled);
let key = exposure.generated_key.expect("a key must be generated");
assert!(key.starts_with("st_"));
assert_eq!(config.security.auth.api_keys, vec![key]);
}
#[test]
fn test_public_exposure_keeps_a_supplied_key() {
let mut config = Config::default();
config.security.auth.api_keys.push("my-key".to_string());
let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
assert!(exposure.generated_key.is_none());
assert_eq!(config.security.auth.api_keys, vec!["my-key".to_string()]);
}
#[test]
fn test_public_exposure_no_longer_warns_about_an_unscoped_token_because_it_scopes_it() {
let mut config = Config::default();
let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
assert!(
!exposure.warnings.iter().any(|w| w.contains("full control")),
"{:?}",
exposure.warnings
);
}
#[test]
fn test_public_exposure_does_not_warn_about_a_scoped_token() {
let mut config = Config::default();
config.security.auth.preset = Some("operator".to_string());
let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
assert!(
!exposure.warnings.iter().any(|w| w.contains("full control")),
"{:?}",
exposure.warnings
);
}
#[test]
fn test_public_exposure_warns_about_disabled_rate_limit() {
let mut config = Config::default();
config.security.rate_limit.enabled = false;
let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
assert!(exposure
.warnings
.iter()
.any(|w| w.contains("rate limiting")));
}
#[test]
fn test_public_exposure_is_quiet_on_a_scoped_loopback_setup() {
let mut config = Config::default();
config.security.auth.preset = Some("operator".to_string());
let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
assert!(exposure.warnings.is_empty(), "{:?}", exposure.warnings);
}
#[test]
fn exposure_scopes_the_issued_token_instead_of_warning_about_it() {
let mut config = Config::default();
assert!(config.security.auth.preset.is_none());
let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
assert_eq!(config.security.auth.preset.as_deref(), Some("operator"));
assert!(
!exposure.warnings.iter().any(|w| w.contains("full control")),
"the warning must be gone, not merely reworded: {:?}",
exposure.warnings
);
}
#[test]
fn the_exposed_token_is_not_a_wildcard() {
let mut config = Config::default();
config.harden_for_public_exposure(&tunnel_args()).unwrap();
let set = resolve_capabilities(
config.security.auth.preset.as_deref(),
&config.security.auth.capabilities,
)
.unwrap()
.expect("an exposed token must have an explicit set");
assert!(!set.is_wildcard());
assert!(set.satisfies("exec"));
assert!(set.satisfies("fs.write"));
}
#[test]
fn an_explicit_scope_is_left_alone() {
let mut config = Config::default();
config.security.auth.preset = Some("file-read".to_string());
config.harden_for_public_exposure(&tunnel_args()).unwrap();
assert_eq!(config.security.auth.preset.as_deref(), Some("file-read"));
}
#[test]
fn explicit_capabilities_are_left_alone_too() {
let mut config = Config::default();
config.security.auth.capabilities = vec!["exec".to_string()];
config.harden_for_public_exposure(&tunnel_args()).unwrap();
assert!(config.security.auth.preset.is_none());
assert_eq!(config.security.auth.capabilities, vec!["exec".to_string()]);
}
#[test]
fn a_non_loopback_bind_no_longer_warns_because_it_now_decides_the_posture() {
let mut config = Config::default();
config.server.host = "0.0.0.0".to_string();
let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
assert!(
!exposure.warnings.iter().any(|w| w.contains("binding")),
"posture covers this now: {:?}",
exposure.warnings
);
}
#[test]
fn a_disabled_rate_limit_still_warns() {
let mut config = Config::default();
config.security.rate_limit.enabled = false;
let exposure = config.harden_for_public_exposure(&tunnel_args()).unwrap();
assert!(exposure
.warnings
.iter()
.any(|w| w.contains("rate limiting")));
}
#[test]
fn a_loopback_server_answers_only_to_local_names() {
let config = Config::default();
let hosts = config
.allowed_hosts(&Args::default(), false)
.expect("a loopback server gets a list");
assert!(hosts.contains(&"localhost".to_string()));
assert!(hosts.contains(&"127.0.0.1".to_string()));
}
#[test]
fn a_published_server_is_not_host_checked() {
let config = Config::default();
assert!(config.allowed_hosts(&Args::default(), true).is_none());
}
#[test]
fn a_non_loopback_bind_is_not_host_checked() {
let mut config = Config::default();
config.server.host = "0.0.0.0".to_string();
assert!(config.allowed_hosts(&Args::default(), false).is_none());
}
#[test]
fn extra_allowed_hosts_join_the_defaults() {
let config = Config::default();
let args = Args {
allow_hosts: vec!["myapp.internal".to_string()],
..Default::default()
};
let hosts = config.allowed_hosts(&args, false).unwrap();
assert!(hosts.contains(&"myapp.internal".to_string()));
assert!(hosts.contains(&"localhost".to_string()));
}
#[test]
fn test_transport_defaults_to_local_only() {
let config = Config::default();
assert_eq!(config.transport.mode, TransportMode::None);
assert!(config.tunnel_provider().unwrap().is_none());
}
#[test]
fn test_transport_mode_from_config_file() {
let json = r#"{"transport":{"mode":"cloudflared"}}"#;
let config: Config = serde_json::from_str(json).unwrap();
assert_eq!(config.transport.mode, TransportMode::Cloudflared);
let provider = config.tunnel_provider().unwrap().expect("a provider");
assert_eq!(provider.name(), "cloudflared");
}
#[test]
fn test_transport_command_from_config_file() {
let json = r#"{"transport":{"mode":"command","command":"ngrok http 3000"}}"#;
let config: Config = serde_json::from_str(json).unwrap();
let provider = config.tunnel_provider().unwrap().expect("a provider");
assert_eq!(provider.name(), "tunnel-command");
}
#[test]
fn test_transport_command_mode_requires_a_command() {
let json = r#"{"transport":{"mode":"command"}}"#;
let config: Config = serde_json::from_str(json).unwrap();
let err = config.tunnel_provider().unwrap_err();
assert!(matches!(err, ConfigError::MissingTunnelCommand));
assert!(err.to_string().contains("transport.command"));
}
#[test]
fn test_cli_tunnel_overrides_config_file() {
let mut config: Config =
serde_json::from_str(r#"{"transport":{"mode":"command","command":"old"}}"#).unwrap();
config.apply_args(&Args {
tunnel: true,
..Default::default()
});
assert_eq!(config.transport.mode, TransportMode::Cloudflared);
}
#[test]
fn test_cli_tunnel_command_overrides_config_file() {
let mut config: Config =
serde_json::from_str(r#"{"transport":{"mode":"cloudflared"}}"#).unwrap();
config.apply_args(&Args {
tunnel_command: Some("bore local 3000 --to bore.pub".to_string()),
..Default::default()
});
assert_eq!(config.transport.mode, TransportMode::Command);
assert_eq!(
config.transport.command.as_deref(),
Some("bore local 3000 --to bore.pub")
);
}
#[test]
fn test_config_file_transport_survives_unrelated_args() {
let mut config: Config =
serde_json::from_str(r#"{"transport":{"mode":"cloudflared"}}"#).unwrap();
config.apply_args(&Args::default());
assert_eq!(config.transport.mode, TransportMode::Cloudflared);
}
#[test]
fn loopback_bind_without_a_public_path_is_local() {
let config = Config::default();
assert_eq!(config.server.host, "127.0.0.1");
assert_eq!(config.posture(false, false), Posture::Local);
}
#[test]
fn a_tunnel_or_a_relay_makes_it_exposed() {
let config = Config::default();
assert_eq!(config.posture(true, false), Posture::Exposed);
assert_eq!(config.posture(false, true), Posture::Exposed);
}
#[test]
fn a_non_loopback_bind_is_exposed_on_its_own() {
let mut config = Config::default();
config.server.host = "0.0.0.0".to_string();
assert_eq!(config.posture(false, false), Posture::Exposed);
config.server.host = "192.168.1.10".to_string();
assert_eq!(config.posture(false, false), Posture::Exposed);
config.server.host = "::".to_string();
assert_eq!(config.posture(false, false), Posture::Exposed);
}
#[test]
fn ipv6_loopback_is_local() {
let mut config = Config::default();
config.server.host = "::1".to_string();
assert_eq!(config.posture(false, false), Posture::Local);
}
#[test]
fn an_unparseable_host_is_exposed_rather_than_local() {
let mut config = Config::default();
config.server.host = "not-an-ip".to_string();
assert_eq!(config.posture(false, false), Posture::Exposed);
}
}