commit eb4ef2bc058b65108d7b4ca5a48665b1611cc3fc Author: c4ch3c4d3 <23181631+c4ch3c4d3@users.noreply.github.com> Date: Wed Jun 3 17:49:55 2026 -0600 feat: project skeleton, CLI surface, generate command, secret redaction, and CI matrix - Create Rust binary crate with clap CLI (listen, connect, generate, version) - Implement generate command: CA/server/client certs, auth token, config.json - Strict secret redaction: Redacted type, PEM/token/config redaction utilities - Configurable addresses and ports with validation (fails before sockets open) - GitHub Actions CI matrix: Windows/Linux/macOS with fmt, clippy, test, build, release-build, and smoke check jobs - 39 unit tests covering CLI parsing, redaction, config, and credential generation - Cross-platform safe paths and clean-room implementation diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ae336a6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,110 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +env: + CARGO_TERM_COLOR: always + +jobs: + fmt: + name: cargo fmt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - run: cargo fmt --check + + clippy: + name: cargo clippy (${{ matrix.os }}) + needs: [fmt] + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - run: cargo clippy --all-targets -- -D warnings + + test: + name: cargo test (${{ matrix.os }}) + needs: [fmt] + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - run: cargo test --all-targets + + build: + name: cargo build (${{ matrix.os }}) + needs: [fmt] + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - run: cargo build + + release-build: + name: cargo build --release (${{ matrix.os }}) + needs: [test] + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - run: cargo build --release + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: rustunnel-${{ matrix.os }} + path: target/release/rustunnel${{ matrix.os == 'windows-latest' && '.exe' || '' }} + retention-days: 7 + + smoke: + name: CLI smoke (${{ matrix.os }}) + needs: [release-build] + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - run: cargo build --release + - name: rustunnel --help + run: target/release/rustunnel --help + - name: rustunnel version + run: target/release/rustunnel version + - name: rustunnel listen --help + run: target/release/rustunnel listen --help + - name: rustunnel connect --help + run: target/release/rustunnel connect --help + - name: rustunnel generate --help + run: target/release/rustunnel generate --help + - name: Invalid command fails + run: target/release/rustunnel invalid-cmd || true + shell: bash + - name: Invalid port fails + run: target/release/rustunnel listen --listen localhost:bad --cert x --key x --ca-cert x --auth-token x || true + shell: bash diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e6148da --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/target +Cargo.lock +**/*.pem +**/*.json +.env +*.log diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..c616dee --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "rustunnel" +version = "0.1.0" +edition = "2024" +description = "Cross-platform HTTPS mTLS lab/dev tunneling tool" +license = "MIT" +readme = "README.md" + +[dependencies] +clap = { version = "4", features = ["derive"] } +tokio = { version = "1", features = ["full"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +anyhow = "1" +thiserror = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +rcgen = "0.14" +x509-parser = "0.16" +time = { version = "0.3", features = ["formatting"] } +rand = "0.8" +hex = "0.4" + +[dev-dependencies] +tempfile = "3" diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..f0104c4 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,181 @@ +use clap::{Parser, Subcommand}; + +/// rustunnel - Cross-platform HTTPS mTLS lab/dev tunneling tool +#[derive(Parser, Debug)] +#[command(name = "rustunnel", version, about, long_about = None)] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Subcommand, Debug)] +pub enum Commands { + /// Start the HTTPS tunnel listener + /// + /// Binds an HTTPS listener that accepts mTLS connections from connectors. + /// Requires certificate and key files, and an auth token for session validation. + #[command(trailing_var_arg = true)] + Listen { + /// Address to bind the HTTPS listener + #[arg(long, default_value = "127.0.0.1:4180", value_name = "ADDR:PORT")] + listen: String, + + /// Path to the server certificate file (PEM) + #[arg(long, value_name = "PATH")] + cert: String, + + /// Path to the server private key file (PEM) + #[arg(long, value_name = "PATH")] + key: String, + + /// Path to the CA certificate file used to validate client certificates (PEM) + #[arg(long, value_name = "PATH")] + ca_cert: String, + + /// Auth/session token required from connecting clients + #[arg(long, value_name = "TOKEN")] + auth_token: String, + }, + + /// Connect to the HTTPS tunnel listener and expose a local SOCKS5 proxy + /// + /// Establishes an mTLS connection to the listener, authenticates, + /// then exposes a local SOCKS5 proxy for lab/dev traffic forwarding. + #[command(trailing_var_arg = true)] + Connect { + /// Address of the HTTPS tunnel listener to connect to + #[arg(long, default_value = "127.0.0.1:4180", value_name = "ADDR:PORT")] + target: String, + + /// Address to bind the local SOCKS5 proxy listener + #[arg(long, default_value = "127.0.0.1:1180", value_name = "ADDR:PORT")] + socks: String, + + /// Path to the client certificate file (PEM) + #[arg(long, value_name = "PATH")] + cert: String, + + /// Path to the client private key file (PEM) + #[arg(long, value_name = "PATH")] + key: String, + + /// Path to the CA certificate file used to validate the server certificate (PEM) + #[arg(long, value_name = "PATH")] + ca_cert: String, + + /// Auth/session token to authenticate with the listener + #[arg(long, value_name = "TOKEN")] + auth_token: String, + }, + + /// Generate local certificate and auth material + /// + /// Creates self-signed CA, server, and client certificates along with + /// an auth token and configuration file in the specified output directory. + #[command(trailing_var_arg = true)] + Generate { + /// Output directory for generated artifacts + #[arg(long, short, default_value = ".", value_name = "PATH")] + out: String, + + /// Common name for the CA certificate + #[arg(long, default_value = "rustunnel-ca", value_name = "CN")] + ca_name: String, + + /// Common name for the server certificate + #[arg(long, default_value = "rustunnel-server", value_name = "CN")] + server_name: String, + + /// Common name for the client certificate + #[arg(long, default_value = "rustunnel-client", value_name = "CN")] + client_name: String, + }, + + /// Print version information + #[command(trailing_var_arg = true)] + Version, +} + +/// Parse a "host:port" string into (host, port) with validation. +/// Returns an error if the format is invalid or the port is out of range. +pub fn parse_host_port(s: &str) -> Result<(String, u16), anyhow::Error> { + let parts: Vec<&str> = s.rsplitn(2, ':').collect(); + if parts.len() != 2 { + return Err(anyhow::anyhow!( + "invalid address format '{}': expected 'host:port'", + s + )); + } + let port: u16 = parts[0].parse().map_err(|_| { + anyhow::anyhow!( + "invalid port '{}' in address '{}': must be a number between 1 and 65535", + parts[0], + s + ) + })?; + if port == 0 { + return Err(anyhow::anyhow!( + "invalid port 0 in address '{}': port must be between 1 and 65535", + s + )); + } + Ok((parts[1].to_string(), port)) +} + +// Remove unused redact_address function + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_valid_host_port() { + let (host, port) = parse_host_port("127.0.0.1:4180").unwrap(); + assert_eq!(host, "127.0.0.1"); + assert_eq!(port, 4180); + } + + #[test] + fn parse_valid_hostname_port() { + let (host, port) = parse_host_port("localhost:8080").unwrap(); + assert_eq!(host, "localhost"); + assert_eq!(port, 8080); + } + + #[test] + fn parse_missing_port_fails() { + let result = parse_host_port("localhost"); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("invalid address format") + ); + } + + #[test] + fn parse_invalid_port_fails() { + let result = parse_host_port("localhost:notanumber"); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("invalid port")); + } + + #[test] + fn parse_zero_port_fails() { + let result = parse_host_port("localhost:0"); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("port must be between 1 and 65535") + ); + } + + #[test] + fn parse_port_too_large_fails() { + let result = parse_host_port("localhost:70000"); + assert!(result.is_err()); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..47d2947 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,118 @@ +use serde::{Deserialize, Serialize}; + +/// Configuration file generated by `rustunnel generate`. +/// Contains paths to certificates, keys, and auth token. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub listen_address: String, + pub listen_port: u16, + pub socks_address: String, + pub socks_port: u16, + pub ca_cert_path: String, + pub server_cert_path: String, + pub server_key_path: String, + pub client_cert_path: String, + pub client_key_path: String, + pub auth_token_path: String, +} + +impl Config { + /// Write configuration to a file as JSON. + pub fn write_to(&self, path: &std::path::Path) -> anyhow::Result<()> { + let json = serde_json::to_string_pretty(self)?; + std::fs::write(path, json)?; + Ok(()) + } + + /// Read configuration from a JSON file. + #[allow(dead_code)] + pub fn read_from(path: &std::path::Path) -> anyhow::Result { + let json = std::fs::read_to_string(path)?; + let config: Config = serde_json::from_str(&json)?; + Ok(config) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn default_config() -> Config { + Config { + listen_address: "127.0.0.1".into(), + listen_port: 4180, + socks_address: "127.0.0.1".into(), + socks_port: 1180, + ca_cert_path: "ca.pem".into(), + server_cert_path: "server.crt".into(), + server_key_path: "server.key".into(), + client_cert_path: "client.crt".into(), + client_key_path: "client.key".into(), + auth_token_path: "token.txt".into(), + } + } + + #[test] + fn config_roundtrip() { + let config = default_config(); + let tmp = std::env::temp_dir().join("rustunnel_config_test.json"); + config.write_to(&tmp).unwrap(); + let loaded = Config::read_from(&tmp).unwrap(); + assert_eq!(loaded.listen_address, config.listen_address); + assert_eq!(loaded.listen_port, config.listen_port); + assert_eq!(loaded.socks_address, config.socks_address); + assert_eq!(loaded.socks_port, config.socks_port); + std::fs::remove_file(&tmp).ok(); + } + + #[test] + fn config_serializes_valid_json() { + let config = default_config(); + let json = serde_json::to_string_pretty(&config).unwrap(); + // Verify it's valid JSON by deserializing + let _: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert!(json.contains("\"listen_address\"")); + assert!(json.contains("127.0.0.1")); + } + + #[test] + fn config_default_ports() { + let config = default_config(); + assert_eq!(config.listen_port, 4180); + assert_eq!(config.socks_port, 1180); + } + + #[test] + fn config_custom_ports() { + let mut config = default_config(); + config.listen_port = 9090; + config.socks_port = 8080; + assert_eq!(config.listen_port, 9090); + assert_eq!(config.socks_port, 8080); + } + + #[test] + fn config_cross_platform_paths() { + // Paths should work on Windows, Linux, and macOS + let config = default_config(); + // All paths are relative, which works cross-platform + assert!(!config.ca_cert_path.contains('/')); + assert!(!config.server_cert_path.contains('/')); + } + + #[test] + fn config_read_invalid_json_fails() { + let tmp = std::env::temp_dir().join("rustunnel_bad_config.json"); + std::fs::write(&tmp, "not valid json").unwrap(); + let result = Config::read_from(&tmp); + assert!(result.is_err()); + std::fs::remove_file(&tmp).ok(); + } + + #[test] + fn config_read_missing_file_fails() { + let path = std::env::temp_dir().join("nonexistent_config.json"); + let result = Config::read_from(&path); + assert!(result.is_err()); + } +} diff --git a/src/generate.rs b/src/generate.rs new file mode 100644 index 0000000..a2b35ea --- /dev/null +++ b/src/generate.rs @@ -0,0 +1,396 @@ +use std::path::Path; + +use anyhow::Context; +use rcgen::{ + BasicConstraints, Certificate, CertificateParams, CertifiedIssuer, DistinguishedName, DnType, + IsCa, KeyPair, SanType, SerialNumber, +}; + +use crate::config::Config; +use crate::redact::Redacted; + +/// Generate all credential material and write it to the output directory. +/// +/// Creates: +/// - `ca.pem` — CA certificate (PEM) +/// - `ca.key` — CA private key (PEM, redacted in logs) +/// - `server.crt` — Server certificate signed by CA (PEM) +/// - `server.key` — Server private key (PEM, redacted in logs) +/// - `client.crt` — Client certificate signed by CA (PEM) +/// - `client.key` — Client private key (PEM, redacted in logs) +/// - `token.txt` — Auth/session token (redacted in logs) +/// - `config.json` — Configuration file with paths and defaults +pub fn generate( + out_dir: &Path, + ca_name: &str, + server_name: &str, + client_name: &str, +) -> anyhow::Result<()> { + std::fs::create_dir_all(out_dir) + .with_context(|| format!("failed to create output directory: {}", out_dir.display()))?; + + // Generate CA + let ca = generate_ca(ca_name)?; + let ca_cert_path = out_dir.join("ca.pem"); + let ca_key_path = out_dir.join("ca.key"); + std::fs::write(&ca_cert_path, ca.as_ref().pem()).with_context(|| { + format!( + "failed to write CA certificate to {}", + ca_cert_path.display() + ) + })?; + std::fs::write(&ca_key_path, ca.key().serialize_pem()) + .with_context(|| format!("failed to write CA key to {}", ca_key_path.display()))?; + tracing::info!("Generated CA certificate at {}", ca_cert_path.display()); + tracing::info!("Generated CA key at {}", ca_key_path.display()); + + // Generate server certificate + let (server_cert, server_key) = generate_server_cert(&ca, server_name)?; + let server_cert_path = out_dir.join("server.crt"); + let server_key_path = out_dir.join("server.key"); + std::fs::write(&server_cert_path, server_cert.pem()).with_context(|| { + format!( + "failed to write server cert to {}", + server_cert_path.display() + ) + })?; + std::fs::write(&server_key_path, server_key.serialize_pem()).with_context(|| { + format!( + "failed to write server key to {}", + server_key_path.display() + ) + })?; + tracing::info!( + "Generated server certificate at {}", + server_cert_path.display() + ); + tracing::info!("Generated server key at {}", server_key_path.display()); + + // Generate client certificate + let (client_cert, client_key) = generate_client_cert(&ca, client_name)?; + let client_cert_path = out_dir.join("client.crt"); + let client_key_path = out_dir.join("client.key"); + std::fs::write(&client_cert_path, client_cert.pem()).with_context(|| { + format!( + "failed to write client cert to {}", + client_cert_path.display() + ) + })?; + std::fs::write(&client_key_path, client_key.serialize_pem()).with_context(|| { + format!( + "failed to write client key to {}", + client_key_path.display() + ) + })?; + tracing::info!( + "Generated client certificate at {}", + client_cert_path.display() + ); + tracing::info!("Generated client key at {}", client_key_path.display()); + + // Generate auth token + let auth_token = generate_auth_token(); + let auth_token_redacted = Redacted::new(auth_token.clone()); + let token_path = out_dir.join("token.txt"); + std::fs::write(&token_path, auth_token) + .with_context(|| format!("failed to write token to {}", token_path.display()))?; + tracing::info!( + "Generated auth token at {} (value: {})", + token_path.display(), + auth_token_redacted + ); + + // Generate config + let config = Config { + listen_address: "127.0.0.1".to_string(), + listen_port: 4180, + socks_address: "127.0.0.1".to_string(), + socks_port: 1180, + ca_cert_path: "ca.pem".to_string(), + server_cert_path: "server.crt".to_string(), + server_key_path: "server.key".to_string(), + client_cert_path: "client.crt".to_string(), + client_key_path: "client.key".to_string(), + auth_token_path: "token.txt".to_string(), + }; + let config_path = out_dir.join("config.json"); + config + .write_to(&config_path) + .with_context(|| format!("failed to write config to {}", config_path.display()))?; + tracing::info!("Generated config at {}", config_path.display()); + + tracing::info!( + "Generated credential material in {} ({} files)", + out_dir.display(), + list_generated_files(out_dir) + ); + + Ok(()) +} + +fn generate_ca(cn: &str) -> anyhow::Result> { + let mut params = CertificateParams::default(); + let mut dn = DistinguishedName::new(); + dn.push(DnType::CommonName, cn); + dn.push(DnType::OrganizationName, "rustunnel"); + params.distinguished_name = dn; + params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0)); + params.serial_number = Some(SerialNumber::from(1)); + + let key_pair = KeyPair::generate()?; + let ca = CertifiedIssuer::self_signed(params, key_pair)?; + Ok(ca) +} + +fn generate_server_cert( + ca: &CertifiedIssuer<'_, KeyPair>, + cn: &str, +) -> anyhow::Result<(Certificate, KeyPair)> { + let mut params = CertificateParams::default(); + let mut dn = DistinguishedName::new(); + dn.push(DnType::CommonName, cn); + dn.push(DnType::OrganizationName, "rustunnel"); + params.distinguished_name = dn; + params.is_ca = IsCa::NoCa; + params.subject_alt_names = vec![ + SanType::DnsName("localhost".try_into().unwrap()), + SanType::DnsName("rustunnel".try_into().unwrap()), + SanType::IpAddress(std::net::IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1))), + ]; + params.serial_number = Some(SerialNumber::from(2)); + + let key_pair = KeyPair::generate()?; + let cert = params.signed_by(&key_pair, ca)?; + Ok((cert, key_pair)) +} + +fn generate_client_cert( + ca: &CertifiedIssuer<'_, KeyPair>, + cn: &str, +) -> anyhow::Result<(Certificate, KeyPair)> { + let mut params = CertificateParams::default(); + let mut dn = DistinguishedName::new(); + dn.push(DnType::CommonName, cn); + dn.push(DnType::OrganizationName, "rustunnel"); + params.distinguished_name = dn; + params.is_ca = IsCa::NoCa; + params.serial_number = Some(SerialNumber::from(3)); + + let key_pair = KeyPair::generate()?; + let cert = params.signed_by(&key_pair, ca)?; + Ok((cert, key_pair)) +} + +fn generate_auth_token() -> String { + // Generate a 32-byte random token as hex + // Use rand::random to avoid `gen` keyword conflict in Rust 2024 + let bytes: [u8; 32] = rand::random(); + hex::encode(bytes) +} + +fn list_generated_files(dir: &Path) -> usize { + std::fs::read_dir(dir) + .map(|entries| entries.filter_map(|e| e.ok()).count()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::redact; + + fn temp_dir() -> std::path::PathBuf { + std::env::temp_dir().join(format!( + "rustunnel_generate_test_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )) + } + + #[test] + fn generate_creates_expected_files() { + let dir = temp_dir(); + generate(&dir, "test-ca", "test-server", "test-client").unwrap(); + + let expected_files = [ + "ca.pem", + "ca.key", + "server.crt", + "server.key", + "client.crt", + "client.key", + "token.txt", + "config.json", + ]; + + for file in &expected_files { + let path = dir.join(file); + assert!(path.exists(), "Expected file not found: {}", file); + let metadata = std::fs::metadata(&path).unwrap(); + assert!(metadata.len() > 0, "File is empty: {}", file); + } + + // Clean up + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn generate_creates_valid_ca_cert() { + let dir = temp_dir(); + generate(&dir, "test-ca", "test-server", "test-client").unwrap(); + + let ca_cert = std::fs::read_to_string(dir.join("ca.pem")).unwrap(); + assert!(ca_cert.contains("-----BEGIN CERTIFICATE-----")); + assert!(ca_cert.contains("-----END CERTIFICATE-----")); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn generate_creates_private_keys() { + let dir = temp_dir(); + generate(&dir, "test-ca", "test-server", "test-client").unwrap(); + + let ca_key = std::fs::read_to_string(dir.join("ca.key")).unwrap(); + assert!( + redact::is_sensitive(&ca_key), + "CA key should be detected as sensitive" + ); + + let server_key = std::fs::read_to_string(dir.join("server.key")).unwrap(); + assert!( + redact::is_sensitive(&server_key), + "Server key should be detected as sensitive" + ); + + let client_key = std::fs::read_to_string(dir.join("client.key")).unwrap(); + assert!( + redact::is_sensitive(&client_key), + "Client key should be detected as sensitive" + ); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn generate_token_is_non_empty() { + let dir = temp_dir(); + generate(&dir, "test-ca", "test-server", "test-client").unwrap(); + + let token = std::fs::read_to_string(dir.join("token.txt")).unwrap(); + assert!( + token.len() > 16, + "Token should be at least 16 chars, got {}", + token.len() + ); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn generate_config_is_valid_json() { + let dir = temp_dir(); + generate(&dir, "test-ca", "test-server", "test-client").unwrap(); + + let config_str = std::fs::read_to_string(dir.join("config.json")).unwrap(); + let config: Config = serde_json::from_str(&config_str).unwrap(); + assert_eq!(config.listen_address, "127.0.0.1"); + assert_eq!(config.listen_port, 4180); + assert_eq!(config.socks_address, "127.0.0.1"); + assert_eq!(config.socks_port, 1180); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn generate_no_hidden_files() { + let dir = temp_dir(); + generate(&dir, "test-ca", "test-server", "test-client").unwrap(); + + // Ensure no hidden or stealth files are created + for entry in std::fs::read_dir(&dir).unwrap() { + let entry = entry.unwrap(); + let name = entry.file_name().to_string_lossy().to_string(); + assert!(!name.starts_with('.'), "Hidden file found: {}", name); + assert!(!name.starts_with('~'), "Backup file found: {}", name); + } + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn generate_cross_platform_paths() { + let dir = temp_dir(); + generate(&dir, "test-ca", "test-server", "test-client").unwrap(); + + let config_str = std::fs::read_to_string(dir.join("config.json")).unwrap(); + let config: Config = serde_json::from_str(&config_str).unwrap(); + + // Config paths should be relative (cross-platform safe) + assert!(!config.ca_cert_path.contains('/')); + assert!(!config.server_cert_path.contains('/')); + assert!(!config.client_cert_path.contains('/')); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn generate_custom_names() { + let dir = temp_dir(); + generate(&dir, "custom-ca", "custom-server", "custom-client").unwrap(); + + // Files should still be created with standard names + assert!(dir.join("ca.pem").exists()); + assert!(dir.join("server.crt").exists()); + assert!(dir.join("client.crt").exists()); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn generate_creates_nested_output_dir() { + let dir = temp_dir().join("nested/deep/dir"); + generate(&dir, "test-ca", "test-server", "test-client").unwrap(); + + assert!(dir.join("ca.pem").exists()); + assert!(dir.join("config.json").exists()); + + // Clean up the full nested path + if let Some(root) = dir + .parent() + .and_then(|p| p.parent()) + .and_then(|p| p.parent()) + { + std::fs::remove_dir_all(root).ok(); + } + } + + #[test] + fn generated_certs_have_correct_cn() { + let dir = temp_dir(); + generate(&dir, "my-ca", "my-server", "my-client").unwrap(); + + // Verify certs contain expected CN + let ca_cert = std::fs::read_to_string(dir.join("ca.pem")).unwrap(); + // The cert content will be base64-encoded, so we just verify structure + assert!(ca_cert.contains("-----BEGIN CERTIFICATE-----")); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn generate_idempotent() { + let dir = temp_dir(); + // Generate twice should succeed + generate(&dir, "ca1", "srv1", "cli1").unwrap(); + generate(&dir, "ca2", "srv2", "cli2").unwrap(); + + // All files should still exist + assert!(dir.join("ca.pem").exists()); + assert!(dir.join("config.json").exists()); + + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..af516b6 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,216 @@ +mod cli; +mod config; +mod generate; +mod redact; + +use std::process; + +use clap::Parser; + +use crate::cli::Cli; + +fn main() { + // Initialize tracing/logger — secrets are never printed by design + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .without_time() + .init(); + + let cli = Cli::parse(); + + let exit_code = match cli.command { + crate::cli::Commands::Listen { + listen, + cert, + key, + ca_cert, + auth_token, + } => run_listen(listen, cert, key, ca_cert, auth_token), + crate::cli::Commands::Connect { + target, + socks, + cert, + key, + ca_cert, + auth_token, + } => run_connect(target, socks, cert, key, ca_cert, auth_token), + crate::cli::Commands::Generate { + out, + ca_name, + server_name, + client_name, + } => run_generate(&out, &ca_name, &server_name, &client_name), + crate::cli::Commands::Version => run_version(), + }; + + process::exit(exit_code); +} + +fn run_listen( + listen: String, + cert: String, + key: String, + ca_cert: String, + auth_token: String, +) -> i32 { + // Validate all inputs before opening any sockets + if cert.is_empty() { + eprintln!("Error: --cert is required for listen command"); + return 1; + } + if key.is_empty() { + eprintln!("Error: --key is required for listen command"); + return 1; + } + if ca_cert.is_empty() { + eprintln!("Error: --ca-cert is required for listen command"); + return 1; + } + if auth_token.is_empty() { + eprintln!("Error: --auth-token is required for listen command"); + return 1; + } + + let (host, port) = match cli::parse_host_port(&listen) { + Ok(v) => v, + Err(e) => { + eprintln!("Error: {}", e); + return 1; + } + }; + + // Verify files exist before opening sockets + if !std::path::Path::new(&cert).exists() { + eprintln!("Error: server certificate not found: {}", cert); + return 1; + } + if !std::path::Path::new(&key).exists() { + eprintln!("Error: server key not found: {}", key); + return 1; + } + if !std::path::Path::new(&ca_cert).exists() { + eprintln!("Error: CA certificate not found: {}", ca_cert); + return 1; + } + + tracing::info!( + "Starting HTTPS tunnel listener on {}:{} (HTTPS default transport)", + host, + port + ); + tracing::info!("Using server certificate: {}", redact::Redacted::new(&cert)); + tracing::info!("Using server key: {}", redact::Redacted::new(&key)); + tracing::info!("Using CA certificate: {}", redact::Redacted::new(&ca_cert)); + tracing::info!( + "Auth token configured: {}", + redact::Redacted::new(&auth_token) + ); + + // TODO: Implement HTTPS mTLS listener + tracing::info!("Listener implementation pending (Milestone 2)"); + 0 +} + +fn run_connect( + target: String, + socks: String, + cert: String, + key: String, + ca_cert: String, + auth_token: String, +) -> i32 { + // Validate all inputs before opening any sockets + if cert.is_empty() { + eprintln!("Error: --cert is required for connect command"); + return 1; + } + if key.is_empty() { + eprintln!("Error: --key is required for connect command"); + return 1; + } + if ca_cert.is_empty() { + eprintln!("Error: --ca-cert is required for connect command"); + return 1; + } + if auth_token.is_empty() { + eprintln!("Error: --auth-token is required for connect command"); + return 1; + } + + let (_target_host, target_port) = match cli::parse_host_port(&target) { + Ok(v) => v, + Err(e) => { + eprintln!("Error: {}", e); + return 1; + } + }; + + let (socks_host, socks_port) = match cli::parse_host_port(&socks) { + Ok(v) => v, + Err(e) => { + eprintln!("Error: {}", e); + return 1; + } + }; + + // Verify files exist before opening any sockets + if !std::path::Path::new(&cert).exists() { + eprintln!("Error: client certificate not found: {}", cert); + return 1; + } + if !std::path::Path::new(&key).exists() { + eprintln!("Error: client key not found: {}", key); + return 1; + } + if !std::path::Path::new(&ca_cert).exists() { + eprintln!("Error: CA certificate not found: {}", ca_cert); + return 1; + } + + tracing::info!( + "Connecting to HTTPS tunnel at {}:{} (HTTPS default transport)", + _target_host, + target_port + ); + tracing::info!("SOCKS5 proxy will listen on {}:{} ", socks_host, socks_port); + tracing::info!("Using client certificate: {}", redact::Redacted::new(&cert)); + tracing::info!("Using client key: {}", redact::Redacted::new(&key)); + tracing::info!("Using CA certificate: {}", redact::Redacted::new(&ca_cert)); + tracing::info!( + "Auth token configured: {}", + redact::Redacted::new(&auth_token) + ); + + // TODO: Implement HTTPS mTLS connector and SOCKS5 proxy + tracing::info!("Connector and SOCKS5 implementation pending (Milestone 2-3)"); + 0 +} + +fn run_generate(out: &str, ca_name: &str, server_name: &str, client_name: &str) -> i32 { + let out_dir = std::path::Path::new(out); + + match generate::generate(out_dir, ca_name, server_name, client_name) { + Ok(()) => { + println!("Credential material generated in {}", out_dir.display()); + println!( + "Files created: ca.pem, ca.key, server.crt, server.key, client.crt, client.key, token.txt, config.json" + ); + 0 + } + Err(e) => { + eprintln!("Error generating credentials: {}", e); + 1 + } + } +} + +fn run_version() -> i32 { + println!("rustunnel {}", env!("CARGO_PKG_VERSION")); + println!("Edition: {}", env!("CARGO_PKG_VERSION")); + println!("Platform: {}", std::env::consts::OS); + println!("Arch: {}", std::env::consts::ARCH); + 0 +} diff --git a/src/redact.rs b/src/redact.rs new file mode 100644 index 0000000..568201f --- /dev/null +++ b/src/redact.rs @@ -0,0 +1,238 @@ +use std::fmt; + +/// A wrapper type that redacts sensitive values when displayed or logged. +/// Used for private keys, auth tokens, and session secrets. +#[derive(Debug, Clone)] +pub struct Redacted(String); + +impl Redacted { + /// Create a new Redacted value. + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + /// Get the inner value. Use only when the value must be used programmatically + /// (e.g., written to a file). Never pass this directly to logging. + #[allow(dead_code)] + pub fn into_inner(self) -> String { + self.0 + } + + /// Get a reference to the inner value. Use with caution. + #[allow(dead_code)] + pub fn inner(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for Redacted { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let len = self.0.len(); + if len == 0 { + write!(f, "[REDACTED]") + } else if len <= 4 { + write!(f, "[REDACTED(len={})]", len) + } else { + write!( + f, + "[REDACTED(len={}...{})]", + &self.0[..2], + &self.0[len - 2..] + ) + } + } +} + +impl From<&str> for Redacted { + fn from(s: &str) -> Self { + Self(s.to_string()) + } +} + +impl From for Redacted { + fn from(s: String) -> Self { + Self(s) + } +} + +impl PartialEq for Redacted { + fn eq(&self, other: &Self) -> bool { + self.0 == other.0 + } +} + +/// Redact a PEM certificate or key block for safe logging. +/// Returns a string showing only the block type and line count. +#[allow(dead_code)] +pub fn redact_pem(pem: &str) -> String { + let lines = pem.lines().count(); + // Extract block type + let block_type = pem + .lines() + .find(|l| l.starts_with("-----BEGIN ")) + .map(|l| { + l.strip_prefix("-----BEGIN ") + .and_then(|s| s.strip_suffix(" -----")) + .unwrap_or("UNKNOWN") + }) + .unwrap_or("UNKNOWN"); + format!("[REDACTED-PEM type={} lines={}]", block_type, lines) +} + +/// Redact a JSON configuration containing sensitive fields. +/// Returns a display-safe representation. +#[allow(dead_code)] +pub fn redact_config_json(json: &str) -> String { + let sensitive_keys = ["auth_token", "token", "private_key", "secret"]; + let mut val = json.to_string(); + for key in &sensitive_keys { + let pat = format!("\"{}\":\"", key); + while let Some(start) = val.find(&pat) { + let after = &val[start + pat.len()..]; + // Skip if already redacted + if after.starts_with("[REDACTED]\"") { + break; + } + let end = after + .find('"') + .map(|p| start + pat.len() + p + 1) + .unwrap_or(val.len()); + let replacement = format!("\"{}\":\"[REDACTED]\"", key); + val.replace_range(start..end, &replacement); + } + } + val +} + +/// Check if a string looks like a private key and should be redacted. +#[allow(dead_code)] +pub fn is_sensitive(content: &str) -> bool { + content.contains("-----BEGIN") + && (content.contains("PRIVATE-KEY") + || content.contains("PRIVATE KEY") + || content.contains("EC PRIVATE-KEY") + || content.contains("EC PRIVATE KEY") + || content.contains("RSA PRIVATE-KEY") + || content.contains("RSA PRIVATE KEY")) +} + +/// Check if a string looks like an auth token and should be redacted. +#[allow(dead_code)] +pub fn is_auth_token(content: &str) -> bool { + content.len() > 16 + && content + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn redacted_display_hides_value() { + let secret = Redacted::new("my-secret-token-12345"); + let display = format!("{}", secret); + assert!(display.contains("REDACTED")); + assert!(!display.contains("my-secret")); + assert!(!display.contains("token")); + // Should show length hints + assert!(display.contains("len=")); + } + + #[test] + fn redacted_empty_shows_marker() { + let empty = Redacted::new(""); + assert_eq!(format!("{}", empty), "[REDACTED]"); + } + + #[test] + fn redacted_short_shows_length() { + let short = Redacted::new("abc"); + let display = format!("{}", short); + assert!(display.contains("REDACTED")); + assert!(!display.contains("abc")); + } + + #[test] + fn redacted_preserves_value_for_comparison() { + let a = Redacted::new("same-value"); + let b = Redacted::new("same-value"); + assert_eq!(a, b); + } + + #[test] + fn redacted_inner_returns_value() { + let secret = Redacted::new("real-value"); + assert_eq!(secret.inner(), "real-value"); + } + + #[test] + fn redact_pem_hides_content() { + let pem = "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBg...\n-----END PRIVATE KEY-----"; + let redacted = redact_pem(pem); + assert!(redacted.contains("REDACTED")); + assert!(!redacted.contains("MIIEvg")); + } + + #[test] + fn redact_config_json_hides_tokens() { + let json = r#"{"auth_token":"secret123","listen":"127.0.0.1:4180"}"#; + let redacted = redact_config_json(json); + assert!(redacted.contains("REDACTED")); + assert!(!redacted.contains("secret123")); + // Non-sensitive fields should remain + assert!(redacted.contains("127.0.0.1:4180")); + } + + #[test] + fn is_sensitive_detects_private_key() { + let pem = "-----BEGIN PRIVATE KEY-----\ndata"; + assert!(is_sensitive(pem)); + } + + #[test] + fn is_sensitive_detects_ec_private_key() { + let pem = "-----BEGIN EC PRIVATE KEY-----\ndata"; + assert!(is_sensitive(pem)); + } + + #[test] + fn is_sensitive_false_for_certificate() { + let pem = "-----BEGIN CERTIFICATE-----\ndata"; + assert!(!is_sensitive(pem)); + } + + #[test] + fn is_auth_token_long_alphanumeric() { + assert!(is_auth_token("abcdef1234567890ab")); + } + + #[test] + fn is_auth_token_short_is_false() { + assert!(!is_auth_token("short")); + } + + #[test] + fn tracing_logs_redact_secrets() { + // Verify that tracing with a Redacted value doesn't leak secrets + let secret = Redacted::new("sensitive-data"); + // The display impl ensures the formatted output is redacted + let display = format!("{}", secret); + assert!(!display.contains("sensitive-data")); + } + + #[test] + fn from_str_creates_redacted() { + let r: Redacted = "test-value".into(); + assert_eq!(r.inner(), "test-value"); + assert!(!format!("{}", r).contains("test-value")); + } + + #[test] + fn from_string_creates_redacted() { + let s = String::from("another-value"); + let r: Redacted = s.into(); + assert!(!format!("{}", r).contains("another-value")); + } +}