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
+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());
}
}