Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions proto/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ dialoguer = { version = "0.11", features = ["password"], optional = true }
hostname = { version = "0.4", optional = true }
qr2term = { version = "0.3", optional = true }
dirs = { version = "5", optional = true }
httparse = "1.10.1"
hmac = "0.12"

[profile.release]
# Default: optimize for speed on desktop/server platforms.
Expand Down
145 changes: 141 additions & 4 deletions proto/src/bin/ztlp-cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,28 @@ enum Commands {
/// Only used when --relay is set. Padded to 16 bytes in the packet.
#[arg(long, default_value = "ztlp-gateway")]
service_name: String,

/// Enable HTTP X-ZTLP-* header injection for passwordless admin auth.
///
/// When set, the FIRST HTTP request on each forwarded TCP connection
/// is rewritten on the wire to inject signed `X-ZTLP-Authenticated`,
/// `X-ZTLP-Admin-Email`, `X-ZTLP-Timestamp`, and `X-ZTLP-Signature`
/// headers. Upstream apps (Rails/Phoenix) verify the signature with
/// the same `--header-hmac-secret` and treat the request as
/// pre-authenticated for the listed admin pubkey.
#[arg(long, default_value_t = false)]
http_inject_headers: bool,

/// Shared HMAC-SHA256 secret used to sign injected `X-ZTLP-*` headers.
/// Must match the secret configured in the upstream HeaderVerifier.
#[arg(long)]
header_hmac_secret: Option<String>,

/// Map a peer Noise static-public-key hex to an admin email.
/// Repeatable. Format: `HEX=email@example.com`.
/// Example: `--admin-pubkey-email deadbeef...=ops@trs.com`
#[arg(long, value_name = "HEX=EMAIL")]
admin_pubkey_email: Vec<String>,
},

/// Manage ZTLP relay nodes
Expand Down Expand Up @@ -2776,9 +2798,91 @@ async fn cmd_listen(
max_sessions: usize,
relay_addr: Option<&str>,
service_name: &str,
http_inject_headers: bool,
header_hmac_secret: Option<&str>,
admin_pubkey_email: &[String],
) -> Result<(), Box<dyn std::error::Error>> {
let identity = load_or_generate_identity(key)?;

// ── Build HTTP injection config (passwordless admin auth) ──
// Validate flag + secret + map up front so we fail fast with a clear
// error before we ever accept a session.
let http_injection: Arc<tunnel::HttpInjectionConfig> = {
if http_inject_headers {
let secret =
header_hmac_secret.ok_or("--http-inject-headers requires --header-hmac-secret")?;
if secret.is_empty() {
return Err("--header-hmac-secret must not be empty".into());
}
let mut map: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for entry in admin_pubkey_email {
let mut parts = entry.splitn(2, '=');
let hex_str = parts
.next()
.ok_or_else(|| format!("invalid --admin-pubkey-email '{}'", entry))?
.trim();
let email = parts
.next()
.ok_or_else(|| {
format!(
"invalid --admin-pubkey-email '{}' (expected HEX=EMAIL)",
entry
)
})?
.trim();
if hex_str.is_empty() || email.is_empty() {
return Err(format!(
"invalid --admin-pubkey-email '{}' (HEX and EMAIL must be non-empty)",
entry
)
.into());
}
// Validate the LHS decodes as a 32-byte X25519 public key so a
// typo / wrong-length pubkey is caught at startup rather than
// silently never matching `remote_static_hex()` (which would
// look like the gateway "just won't inject headers" with no
// error). hex::decode tolerates mixed case; we lowercase for
// the map key to match remote_static_hex() output convention.
let decoded = hex::decode(hex_str).map_err(|_| {
format!(
"invalid --admin-pubkey-email '{}' (HEX must be valid hex characters)",
entry
)
})?;
if decoded.len() != 32 {
return Err(format!(
"invalid --admin-pubkey-email '{}' (HEX must be a 32-byte X25519 public key, got {} bytes)",
entry,
decoded.len()
)
.into());
}
map.insert(hex_str.to_lowercase(), email.to_string());
}
if map.is_empty() {
eprintln!(
"{} --http-inject-headers set but no --admin-pubkey-email entries; \
no peers will be treated as admins",
c_yellow("⚠")
);
} else {
eprintln!(
"{} HTTP header injection ENABLED for {} admin pubkey(s)",
c_cyan("✦"),
map.len()
);
}
Arc::new(tunnel::HttpInjectionConfig {
enabled: true,
hmac_secret: secret.to_string(),
admin_pubkey_to_email: map,
})
} else {
Arc::new(tunnel::HttpInjectionConfig::default())
}
};

let node = TransportNode::bind(bind).await?;
eprintln!("{} {}", c_cyan("Listening on:"), node.local_addr);
eprintln!("{} {}", c_cyan("NodeID:"), identity.node_id);
Expand Down Expand Up @@ -2875,6 +2979,7 @@ async fn cmd_listen(
&policy,
ns_server,
max_sessions,
http_injection.clone(),
)
.await;
}
Expand Down Expand Up @@ -3334,6 +3439,7 @@ async fn cmd_listen_multi_session(
policy: &PolicyEngine,
ns_server: &Option<String>,
max_sessions: usize,
http_injection: Arc<tunnel::HttpInjectionConfig>,
) -> Result<(), Box<dyn std::error::Error>> {
let registry = tunnel::ServiceRegistry::from_forward_args(forward)?;
let session_mgr = Arc::new(SessionManager::new(max_sessions));
Expand Down Expand Up @@ -3471,6 +3577,7 @@ async fn cmd_listen_multi_session(
ns_server,
&session_mgr,
&mut half_open_cache,
http_injection.clone(),
)
.await
{
Expand Down Expand Up @@ -3578,6 +3685,7 @@ async fn handle_new_session(
ns_server: &Option<String>,
session_mgr: &Arc<SessionManager>,
half_open_cache: &mut HalfOpenCache,
http_injection: Arc<tunnel::HttpInjectionConfig>,
) -> Result<(), Box<dyn std::error::Error>> {
let recv1_header = HandshakeHeader::deserialize(hello_data)?;
let session_id = recv1_header.session_id;
Expand Down Expand Up @@ -3789,7 +3897,17 @@ async fn handle_new_session(
// Run the bridge and capture outcome as a string (not Box<dyn Error>)
// to keep the future Send-safe.
let err_msg: Option<String> = {
match run_session_bridge(udp, pipeline, session_id, from, &forward_addr_owned, rx).await
match run_session_bridge(
udp,
pipeline,
session_id,
from,
&forward_addr_owned,
rx,
http_injection.clone(),
peer_pubkey_hex.clone(),
)
.await
{
Ok(()) => None,
Err(e) => Some(e.to_string()),
Expand Down Expand Up @@ -3892,13 +4010,16 @@ async fn wait_for_reset_on_socket(
/// shared socket.
///
/// Returns `String` errors (not `Box<dyn Error>`) so the future is `Send`.
#[allow(clippy::too_many_arguments)]
async fn run_session_bridge(
udp_send_socket: Arc<UdpSocket>,
pipeline: Arc<Mutex<Pipeline>>,
session_id: SessionId,
peer_addr: SocketAddr,
forward_addr: &str,
mut rx: tokio::sync::mpsc::Receiver<(Vec<u8>, std::net::SocketAddr)>,
http_injection: Arc<tunnel::HttpInjectionConfig>,
peer_pubkey_hex: Option<String>,
) -> Result<(), String> {
use tokio::net::TcpStream;

Expand All @@ -3911,6 +4032,7 @@ async fn run_session_bridge(
let recv_addr = recv_socket
.local_addr()
.map_err(|e| format!("recv socket addr: {}", e))?;

let fwd_socket = tokio::net::UdpSocket::bind("127.0.0.1:0")
.await
.map_err(|e| format!("bind fwd socket: {}", e))?;
Expand Down Expand Up @@ -3955,14 +4077,16 @@ async fn run_session_bridge(
});

// Run bridge with demuxed sockets (send via shared socket, recv via per-session socket)
let result = tunnel::run_bridge_demuxed(
let result = tunnel::run_bridge_demuxed_with_http_injection(
tcp_stream,
udp_send_socket.clone(),
recv_socket.clone(),
pipeline.clone(),
session_id,
peer_addr,
Vec::new(), // initial_packets already forwarded to recv_socket
http_injection.clone(),
peer_pubkey_hex.clone(),
)
.await
.map_err(|e| e.to_string())?;
Expand Down Expand Up @@ -4005,14 +4129,16 @@ async fn run_session_bridge(
forward_addr
);

last_outcome = tunnel::run_bridge_demuxed(
last_outcome = tunnel::run_bridge_demuxed_with_http_injection(
tcp_stream,
udp_send_socket.clone(),
recv_socket.clone(),
pipeline.clone(),
session_id,
peer_addr,
Vec::new(),
http_injection.clone(),
peer_pubkey_hex.clone(),
)
.await
.map_err(|e| e.to_string())?;
Expand Down Expand Up @@ -5910,6 +6036,7 @@ async fn cmd_scan(
}

/// Probe a UDP port by sending a ZTLP magic byte packet and checking for

/// any response (including ICMP unreachable via recv error).
async fn check_ztlp_udp(target: &str, port: u16) -> bool {
let addr = format!("{}:{}", target, port);
Expand Down Expand Up @@ -6548,7 +6675,11 @@ zone = {zone}
zone_comment = zone,
key_path = toml_string(&key_path.display().to_string()),
ns_server = toml_string(ns_server),
relay_str = if relay_str == "[]" { "".to_string() } else { format!("relay = {}", relay_str) },
relay_str = if relay_str == "[]" {
"".to_string()
} else {
format!("relay = {}", relay_str)
},
zone = toml_string(zone),
);

Expand Down Expand Up @@ -9937,6 +10068,9 @@ async fn main() {
max_sessions,
relay,
service_name,
http_inject_headers,
header_hmac_secret,
admin_pubkey_email,
} => {
cmd_listen(
bind,
Expand All @@ -9950,6 +10084,9 @@ async fn main() {
*max_sessions,
relay.as_deref(),
service_name,
*http_inject_headers,
header_hmac_secret.as_deref(),
admin_pubkey_email,
)
.await
}
Expand Down
Loading
Loading