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:
c4ch3c4d3
2026-06-03 17:49:55 -06:00
commit eb4ef2bc05
8 changed files with 1290 additions and 0 deletions
+110
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
/target
Cargo.lock
**/*.pem
**/*.json
.env
*.log
+25
View File
@@ -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"
+181
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"));
}
}