684 lines
19 KiB
Rust
684 lines
19 KiB
Rust
mod cli;
|
|
mod config;
|
|
mod connkey;
|
|
mod errors;
|
|
mod framing;
|
|
mod generate;
|
|
mod redact;
|
|
mod signal;
|
|
mod socks5;
|
|
mod tls;
|
|
mod tunnel;
|
|
|
|
use std::path::Path;
|
|
use std::process;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use clap::Parser;
|
|
|
|
use crate::cli::Cli;
|
|
use crate::connkey::ConnectionKey;
|
|
use crate::tunnel::{
|
|
ClientTlsMaterial, ConnectorConfig, ListenerConfig, PerformanceConfig, ServerTlsMaterial,
|
|
};
|
|
|
|
fn main() {
|
|
// Install the default crypto provider (ring) for rustls
|
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
|
|
// 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,
|
|
advertise,
|
|
connection_key,
|
|
socks,
|
|
cert,
|
|
key,
|
|
ca_cert,
|
|
auth_token,
|
|
auth_token_file,
|
|
insecure_skip_tls_verify,
|
|
max_open_files,
|
|
max_tunnel_sessions,
|
|
max_socks_connections,
|
|
max_streams_per_tunnel,
|
|
handshake_timeout_ms,
|
|
connect_timeout_ms,
|
|
stream_open_timeout_ms,
|
|
} => run_listen(
|
|
listen,
|
|
advertise,
|
|
connection_key,
|
|
socks,
|
|
cert,
|
|
key,
|
|
ca_cert,
|
|
auth_token,
|
|
auth_token_file,
|
|
insecure_skip_tls_verify,
|
|
max_open_files,
|
|
max_tunnel_sessions,
|
|
max_socks_connections,
|
|
max_streams_per_tunnel,
|
|
handshake_timeout_ms,
|
|
connect_timeout_ms,
|
|
stream_open_timeout_ms,
|
|
),
|
|
crate::cli::Commands::Connect {
|
|
connection_key_arg,
|
|
target,
|
|
connection_key,
|
|
socks,
|
|
cert,
|
|
key,
|
|
ca_cert,
|
|
auth_token,
|
|
auth_token_file,
|
|
insecure_skip_tls_verify,
|
|
max_open_files,
|
|
max_socks_connections,
|
|
max_streams_per_tunnel,
|
|
handshake_timeout_ms,
|
|
connect_timeout_ms,
|
|
stream_open_timeout_ms,
|
|
} => run_connect(
|
|
connection_key_arg,
|
|
target,
|
|
connection_key,
|
|
socks,
|
|
cert,
|
|
key,
|
|
ca_cert,
|
|
auth_token,
|
|
auth_token_file,
|
|
insecure_skip_tls_verify,
|
|
max_open_files,
|
|
max_socks_connections,
|
|
max_streams_per_tunnel,
|
|
handshake_timeout_ms,
|
|
connect_timeout_ms,
|
|
stream_open_timeout_ms,
|
|
),
|
|
crate::cli::Commands::Generate {
|
|
out,
|
|
ca_name,
|
|
server_name,
|
|
client_name,
|
|
} => run_generate(&out, &ca_name, &server_name, &client_name),
|
|
crate::cli::Commands::Keygen {
|
|
target,
|
|
ca_name,
|
|
server_name,
|
|
client_name,
|
|
} => run_keygen(&target, &ca_name, &server_name, &client_name),
|
|
crate::cli::Commands::Version => run_version(),
|
|
};
|
|
|
|
process::exit(exit_code);
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn run_listen(
|
|
listen: String,
|
|
advertise: Option<String>,
|
|
connection_key: Option<String>,
|
|
socks: Option<String>,
|
|
cert: String,
|
|
key: String,
|
|
ca_cert: String,
|
|
auth_token: String,
|
|
auth_token_file: Option<String>,
|
|
insecure_skip_tls_verify: bool,
|
|
max_open_files: u64,
|
|
max_tunnel_sessions: usize,
|
|
max_socks_connections: usize,
|
|
max_streams_per_tunnel: usize,
|
|
handshake_timeout_ms: u64,
|
|
connect_timeout_ms: u64,
|
|
stream_open_timeout_ms: u64,
|
|
) -> i32 {
|
|
configure_performance(
|
|
max_open_files,
|
|
max_tunnel_sessions,
|
|
max_socks_connections,
|
|
max_streams_per_tunnel,
|
|
handshake_timeout_ms,
|
|
connect_timeout_ms,
|
|
stream_open_timeout_ms,
|
|
);
|
|
|
|
let (host, port) = match cli::parse_host_port(&listen) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
eprintln!("Error: {}", e);
|
|
return 1;
|
|
}
|
|
};
|
|
|
|
// Resolve hostname to SocketAddr — no panic on failure
|
|
let bind_addr = match tokio::runtime::Runtime::new()
|
|
.unwrap()
|
|
.block_on(tunnel::resolve_host(&host, port))
|
|
{
|
|
Ok(addrs) => addrs[0],
|
|
Err(e) => {
|
|
eprintln!("Error: failed to resolve '{}': {}", host, e);
|
|
return 1;
|
|
}
|
|
};
|
|
|
|
let socks_addr = if let Some(socks) = socks {
|
|
let (socks_host, socks_port) = match cli::parse_host_port(&socks) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
eprintln!("Error: {}", e);
|
|
return 1;
|
|
}
|
|
};
|
|
match tokio::runtime::Runtime::new()
|
|
.unwrap()
|
|
.block_on(tunnel::resolve_host(&socks_host, socks_port))
|
|
{
|
|
Ok(addrs) => Some(addrs[0]),
|
|
Err(e) => {
|
|
eprintln!(
|
|
"Error: failed to resolve SOCKS5 address '{}': {}",
|
|
socks_host, e
|
|
);
|
|
return 1;
|
|
}
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let (tls, auth_token, generated_key) = if let Some(raw_key) = connection_key {
|
|
match ConnectionKey::decode(&raw_key) {
|
|
Ok(k) => (
|
|
ServerTlsMaterial::Pem {
|
|
cert: Arc::new(k.server_cert_pem),
|
|
key: Arc::new(k.server_key_pem),
|
|
ca_cert: Arc::new(k.ca_cert_pem),
|
|
},
|
|
k.auth_token,
|
|
None,
|
|
),
|
|
Err(e) => {
|
|
eprintln!("Error: {}", e);
|
|
return 1;
|
|
}
|
|
}
|
|
} else if cert.is_empty()
|
|
&& key.is_empty()
|
|
&& ca_cert.is_empty()
|
|
&& auth_token.is_empty()
|
|
&& auth_token_file.is_none()
|
|
{
|
|
let target = advertise.unwrap_or_else(|| {
|
|
if host == "0.0.0.0" || host == "::" {
|
|
format!("127.0.0.1:{}", port)
|
|
} else {
|
|
listen.clone()
|
|
}
|
|
});
|
|
if let Err(e) = cli::parse_host_port(&target) {
|
|
eprintln!("Error: invalid --advertise value: {}", e);
|
|
return 1;
|
|
}
|
|
match generate_key(
|
|
&target,
|
|
"rustunnel-ca",
|
|
"rustunnel-server",
|
|
"rustunnel-client",
|
|
) {
|
|
Ok((key, encoded)) => (
|
|
ServerTlsMaterial::Pem {
|
|
cert: Arc::new(key.server_cert_pem),
|
|
key: Arc::new(key.server_key_pem),
|
|
ca_cert: Arc::new(key.ca_cert_pem),
|
|
},
|
|
key.auth_token,
|
|
Some(encoded),
|
|
),
|
|
Err(e) => {
|
|
eprintln!("Error generating connection key: {}", e);
|
|
return 1;
|
|
}
|
|
}
|
|
} else {
|
|
if cert.is_empty() {
|
|
eprintln!("Error: --cert is required for listen command unless using a connection key");
|
|
return 1;
|
|
}
|
|
if key.is_empty() {
|
|
eprintln!("Error: --key is required for listen command unless using a connection key");
|
|
return 1;
|
|
}
|
|
if ca_cert.is_empty() {
|
|
eprintln!(
|
|
"Error: --ca-cert is required for listen command unless using a connection key"
|
|
);
|
|
return 1;
|
|
}
|
|
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;
|
|
}
|
|
let token_path: Option<&Path> = auth_token_file.as_deref().map(Path::new);
|
|
let token_value: Option<&str> = if auth_token.is_empty() {
|
|
None
|
|
} else {
|
|
Some(&auth_token)
|
|
};
|
|
let auth_token = match tunnel::load_auth_token(token_value, token_path) {
|
|
Ok(t) => t,
|
|
Err(e) => {
|
|
eprintln!("Error: {}", e);
|
|
return 1;
|
|
}
|
|
};
|
|
(
|
|
ServerTlsMaterial::from_paths(&cert, &key, &ca_cert),
|
|
auth_token,
|
|
None,
|
|
)
|
|
};
|
|
|
|
tracing::info!(
|
|
"Starting HTTPS tunnel listener on {}:{} (HTTPS default transport, endpoint: /tunnel)",
|
|
host,
|
|
port
|
|
);
|
|
if let Some(key) = generated_key {
|
|
println!("Connection key:\n{}\n", key);
|
|
println!("Connect with:\nrustunnel connect {}", key);
|
|
}
|
|
tracing::info!(
|
|
"Auth token configured: {}",
|
|
redact::Redacted::new(&auth_token)
|
|
);
|
|
|
|
if insecure_skip_tls_verify {
|
|
tracing::warn!(
|
|
"Listener running with --insecure-skip-tls-verify: client certificate verification is DISABLED"
|
|
);
|
|
}
|
|
|
|
let config = ListenerConfig {
|
|
bind_addr,
|
|
socks_addr,
|
|
tls,
|
|
auth_token: Arc::new(auth_token),
|
|
insecure_skip_tls_verify,
|
|
};
|
|
|
|
// Run the async listener
|
|
if let Err(e) = tokio::runtime::Runtime::new()
|
|
.unwrap()
|
|
.block_on(tunnel::run_listener(config))
|
|
{
|
|
eprintln!("Error: {}", e);
|
|
return 1;
|
|
}
|
|
|
|
0
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn run_connect(
|
|
connection_key_arg: Option<String>,
|
|
target: Option<String>,
|
|
connection_key: Option<String>,
|
|
socks: String,
|
|
cert: String,
|
|
key: String,
|
|
ca_cert: String,
|
|
auth_token: String,
|
|
auth_token_file: Option<String>,
|
|
insecure_skip_tls_verify: bool,
|
|
max_open_files: u64,
|
|
max_socks_connections: usize,
|
|
max_streams_per_tunnel: usize,
|
|
handshake_timeout_ms: u64,
|
|
connect_timeout_ms: u64,
|
|
stream_open_timeout_ms: u64,
|
|
) -> i32 {
|
|
configure_performance(
|
|
max_open_files,
|
|
1,
|
|
max_socks_connections,
|
|
max_streams_per_tunnel,
|
|
handshake_timeout_ms,
|
|
connect_timeout_ms,
|
|
stream_open_timeout_ms,
|
|
);
|
|
|
|
let raw_connection_key = connection_key_arg.or(connection_key);
|
|
let decoded_key = if let Some(raw) = raw_connection_key {
|
|
match ConnectionKey::decode(&raw) {
|
|
Ok(k) => Some(k),
|
|
Err(e) => {
|
|
eprintln!("Error: {}", e);
|
|
return 1;
|
|
}
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let target_value = match select_connect_target(target.as_deref(), decoded_key.as_ref()) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
eprintln!("Error: {}", e);
|
|
return 1;
|
|
}
|
|
};
|
|
if should_warn_target_override(target.as_deref(), decoded_key.as_ref()) {
|
|
tracing::warn!(
|
|
"--target overrides the listener address embedded in the connection key; TLS trust still uses the key's CA/client certificate material"
|
|
);
|
|
}
|
|
let (target_host, target_port) = match cli::parse_host_port(target_value) {
|
|
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;
|
|
}
|
|
};
|
|
|
|
let (tls, auth_token) = if let Some(k) = decoded_key {
|
|
(
|
|
ClientTlsMaterial::Pem {
|
|
cert: Arc::new(k.client_cert_pem),
|
|
key: Arc::new(k.client_key_pem),
|
|
ca_cert: Arc::new(k.ca_cert_pem),
|
|
},
|
|
k.auth_token,
|
|
)
|
|
} else {
|
|
if cert.is_empty() {
|
|
eprintln!(
|
|
"Error: --cert is required for connect command unless using a connection key"
|
|
);
|
|
return 1;
|
|
}
|
|
if key.is_empty() {
|
|
eprintln!("Error: --key is required for connect command unless using a connection key");
|
|
return 1;
|
|
}
|
|
if ca_cert.is_empty() {
|
|
eprintln!(
|
|
"Error: --ca-cert is required for connect command unless using a connection key"
|
|
);
|
|
return 1;
|
|
}
|
|
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;
|
|
}
|
|
let token_path: Option<&Path> = auth_token_file.as_deref().map(Path::new);
|
|
let token_value: Option<&str> = if auth_token.is_empty() {
|
|
None
|
|
} else {
|
|
Some(&auth_token)
|
|
};
|
|
let auth_token = match tunnel::load_auth_token(token_value, token_path) {
|
|
Ok(t) => t,
|
|
Err(e) => {
|
|
eprintln!("Error: {}", e);
|
|
return 1;
|
|
}
|
|
};
|
|
(
|
|
ClientTlsMaterial::from_paths(&cert, &key, &ca_cert),
|
|
auth_token,
|
|
)
|
|
};
|
|
|
|
tracing::info!(
|
|
"Connecting to HTTPS tunnel at {}:{} (HTTPS default transport)",
|
|
target_host,
|
|
target_port
|
|
);
|
|
tracing::info!(
|
|
"SOCKS5 proxy at {}:{} ready for local clients",
|
|
socks_host,
|
|
socks_port
|
|
);
|
|
tracing::info!(
|
|
"Auth token configured: {}",
|
|
redact::Redacted::new(&auth_token)
|
|
);
|
|
|
|
// Resolve SOCKS5 bind address
|
|
let socks_addr = match tokio::runtime::Runtime::new()
|
|
.unwrap()
|
|
.block_on(tunnel::resolve_host(&socks_host, socks_port))
|
|
{
|
|
Ok(addrs) => addrs[0],
|
|
Err(e) => {
|
|
eprintln!(
|
|
"Error: failed to resolve SOCKS5 address '{}': {}",
|
|
socks_host, e
|
|
);
|
|
return 1;
|
|
}
|
|
};
|
|
|
|
if insecure_skip_tls_verify {
|
|
tracing::warn!(
|
|
"Connector running with --insecure-skip-tls-verify: server certificate verification is DISABLED"
|
|
);
|
|
}
|
|
|
|
let config = ConnectorConfig {
|
|
target_host,
|
|
target_port,
|
|
tls,
|
|
auth_token: Arc::new(auth_token),
|
|
insecure_skip_tls_verify,
|
|
};
|
|
|
|
// Run the async connector with SOCKS5 proxy
|
|
if let Err(e) = tokio::runtime::Runtime::new()
|
|
.unwrap()
|
|
.block_on(tunnel::run_connector_with_socks(config, socks_addr))
|
|
{
|
|
eprintln!("Error: {}", e);
|
|
return 1;
|
|
}
|
|
|
|
0
|
|
}
|
|
|
|
fn configure_performance(
|
|
max_open_files: u64,
|
|
max_tunnel_sessions: usize,
|
|
max_socks_connections: usize,
|
|
max_streams_per_tunnel: usize,
|
|
handshake_timeout_ms: u64,
|
|
connect_timeout_ms: u64,
|
|
stream_open_timeout_ms: u64,
|
|
) {
|
|
tunnel::set_performance_config(PerformanceConfig {
|
|
max_open_files,
|
|
max_tunnel_sessions,
|
|
max_socks_connections,
|
|
max_streams_per_tunnel,
|
|
handshake_timeout: Duration::from_millis(handshake_timeout_ms),
|
|
connect_timeout: Duration::from_millis(connect_timeout_ms),
|
|
stream_open_timeout: Duration::from_millis(stream_open_timeout_ms),
|
|
});
|
|
}
|
|
|
|
fn select_connect_target<'a>(
|
|
target: Option<&'a str>,
|
|
decoded_key: Option<&'a ConnectionKey>,
|
|
) -> anyhow::Result<&'a str> {
|
|
if let Some(target) = target {
|
|
return Ok(target);
|
|
}
|
|
|
|
if let Some(k) = decoded_key {
|
|
return Ok(k.target.as_str());
|
|
}
|
|
|
|
anyhow::bail!("--target is required for connect unless using a connection key")
|
|
}
|
|
|
|
fn should_warn_target_override(target: Option<&str>, decoded_key: Option<&ConnectionKey>) -> bool {
|
|
matches!((target, decoded_key), (Some(target), Some(k)) if target != k.target)
|
|
}
|
|
|
|
fn generate_key(
|
|
target: &str,
|
|
ca_name: &str,
|
|
server_name: &str,
|
|
client_name: &str,
|
|
) -> anyhow::Result<(ConnectionKey, String)> {
|
|
let (host, _) = cli::parse_host_port(target)?;
|
|
let material = generate::generate_material_with_server_host(
|
|
ca_name,
|
|
server_name,
|
|
client_name,
|
|
Some(&host),
|
|
)?;
|
|
let key = ConnectionKey::new(target.to_string(), material);
|
|
let encoded = key.encode()?;
|
|
Ok((key, encoded))
|
|
}
|
|
|
|
fn run_keygen(target: &str, ca_name: &str, server_name: &str, client_name: &str) -> i32 {
|
|
if let Err(e) = cli::parse_host_port(target) {
|
|
eprintln!("Error: {}", e);
|
|
return 1;
|
|
}
|
|
match generate_key(target, ca_name, server_name, client_name) {
|
|
Ok((_key, encoded)) => {
|
|
println!("{}", encoded);
|
|
0
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Error generating connection key: {}", e);
|
|
1
|
|
}
|
|
}
|
|
}
|
|
|
|
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: 2024");
|
|
println!("Platform: {}", std::env::consts::OS);
|
|
println!("Arch: {}", std::env::consts::ARCH);
|
|
0
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn test_connection_key(target: &str) -> ConnectionKey {
|
|
ConnectionKey {
|
|
version: 2,
|
|
target: target.to_string(),
|
|
ca_cert_pem: "ca".to_string(),
|
|
server_cert_pem: "server-cert".to_string(),
|
|
server_key_pem: "server-key".to_string(),
|
|
client_cert_pem: "client-cert".to_string(),
|
|
client_key_pem: "client-key".to_string(),
|
|
auth_token: "token".to_string(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn explicit_connect_target_overrides_key_target() {
|
|
let key = test_connection_key("198.51.100.10:443");
|
|
let selected = select_connect_target(Some("23.167.32.42:443"), Some(&key)).unwrap();
|
|
assert_eq!(selected, "23.167.32.42:443");
|
|
}
|
|
|
|
#[test]
|
|
fn connect_uses_key_target_when_explicit_target_absent() {
|
|
let key = test_connection_key("198.51.100.10:443");
|
|
let selected = select_connect_target(None, Some(&key)).unwrap();
|
|
assert_eq!(selected, "198.51.100.10:443");
|
|
}
|
|
|
|
#[test]
|
|
fn connect_target_is_required_without_key() {
|
|
let err = select_connect_target(None, None).unwrap_err();
|
|
assert_eq!(
|
|
err.to_string(),
|
|
"--target is required for connect unless using a connection key"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn warns_when_explicit_target_differs_from_key_target() {
|
|
let key = test_connection_key("198.51.100.10:443");
|
|
assert!(should_warn_target_override(
|
|
Some("23.167.32.42:443"),
|
|
Some(&key)
|
|
));
|
|
assert!(!should_warn_target_override(
|
|
Some("198.51.100.10:443"),
|
|
Some(&key)
|
|
));
|
|
assert!(!should_warn_target_override(Some("23.167.32.42:443"), None));
|
|
}
|
|
}
|