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
This commit is contained in:
+181
@@ -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());
|
||||
}
|
||||
}
|
||||
+118
@@ -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<Self> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
+396
@@ -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<CertifiedIssuer<'static, 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::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();
|
||||
}
|
||||
}
|
||||
+216
@@ -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
|
||||
}
|
||||
+238
@@ -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<String>) -> 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<String> 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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user