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:
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/target
|
||||||
|
Cargo.lock
|
||||||
|
**/*.pem
|
||||||
|
**/*.json
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
+25
@@ -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
@@ -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