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