use std::path::Path;
use anyhow::{Context, Result, bail};
use serde::Deserialize;
#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ProtocolKind {
Tcp,
Udp,
#[default]
Both,
}
fn default_udp_idle_secs() -> u64 {
60
}
fn default_reconnect_min_secs() -> u64 {
1
}
fn default_reconnect_max_secs() -> u64 {
30
}
#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Transport {
#[default]
Tls,
Noise,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ForwardRule {
pub name: Option<String>,
pub listen: String,
pub target: String,
#[serde(default)]
pub protocol: ProtocolKind,
#[serde(default = "default_udp_idle_secs")]
pub udp_idle_secs: u64,
#[serde(default)]
pub transport: Option<Transport>,
#[serde(default)]
pub token: Option<String>,
}
impl ForwardRule {
pub fn label(&self) -> String {
self.name
.clone()
.unwrap_or_else(|| format!("{}->{}", self.listen, self.target))
}
pub fn validate(&self) -> Result<()> {
if self.transport == Some(Transport::Noise) && self.token.is_none() {
bail!(
"forward[{}]: transport = \"noise\" requires a token",
self.label()
);
}
Ok(())
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct ServeTunnel {
pub name: String,
pub external: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ServeConfig {
pub listen: String,
#[serde(default)]
pub token: String,
#[serde(default)]
pub tunnel: Vec<ServeTunnel>,
#[serde(default)]
pub tenants_file: Option<String>,
#[serde(default)]
pub sni_listen: Option<String>,
}
impl ServeConfig {
pub fn is_multi_tenant(&self) -> Result<bool> {
match (&self.tenants_file, &self.sni_listen) {
(Some(_), sni) => {
if !self.token.is_empty() || !self.tunnel.is_empty() {
bail!(
"serve: `tenants_file` cannot be combined with `token` or `[[serve.tunnel]]`"
);
}
if sni.is_none() {
bail!("serve: `tenants_file` requires `sni_listen`");
}
Ok(true)
}
(None, Some(_)) => bail!("serve: `sni_listen` requires `tenants_file`"),
(None, None) => Ok(false),
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct ConnectTunnel {
pub name: String,
pub target: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ConnectConfig {
pub server: String,
pub token: String,
#[serde(default)]
pub tunnel: Vec<ConnectTunnel>,
#[serde(default = "default_reconnect_min_secs")]
pub reconnect_min_secs: u64,
#[serde(default = "default_reconnect_max_secs")]
pub reconnect_max_secs: u64,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ClientRule {
pub name: Option<String>,
pub listen: String,
pub server: String,
#[serde(default)]
pub transport: Transport,
#[serde(default)]
pub token: Option<String>,
}
impl ClientRule {
pub fn label(&self) -> String {
self.name
.clone()
.unwrap_or_else(|| format!("{}->{}", self.listen, self.server))
}
pub fn validate(&self) -> Result<()> {
if self.transport == Transport::Noise && self.token.is_none() {
bail!(
"client[{}]: transport = \"noise\" requires a token",
self.label()
);
}
Ok(())
}
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct FileConfig {
#[serde(default)]
pub forward: Vec<ForwardRule>,
pub serve: Option<ServeConfig>,
pub connect: Option<ConnectConfig>,
#[serde(default)]
pub client: Vec<ClientRule>,
}
impl FileConfig {
pub fn load(path: &Path) -> Result<Self> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("reading config file {}", path.display()))?;
let cfg: FileConfig = toml::from_str(&text)
.with_context(|| format!("parsing config file {}", path.display()))?;
Ok(cfg)
}
}
pub fn parse_map_flag(raw: &str) -> Result<ForwardRule> {
let (rest, (protocol, transport)) = match raw.rsplit_once('/') {
Some((rest, proto)) => (rest, parse_protocol(proto)?),
None => (raw, (ProtocolKind::Both, None)),
};
let parts: Vec<&str> = rest.splitn(2, "->").map(str::trim).collect();
let (listen_raw, target_raw) = match parts.as_slice() {
[listen, target] => (*listen, *target),
_ => bail!("--map must look like LISTEN->TARGET[/proto], got: {raw}"),
};
let listen = normalize_listen(listen_raw)?;
Ok(ForwardRule {
name: None,
listen,
target: target_raw.to_string(),
protocol,
udp_idle_secs: default_udp_idle_secs(),
transport,
token: None,
})
}
pub fn parse_client_map_flag(raw: &str) -> Result<ClientRule> {
let (rest, transport) = match raw.rsplit_once('/') {
Some((rest, modifier)) => (rest, parse_client_transport(modifier)?),
None => (raw, Transport::Tls),
};
let parts: Vec<&str> = rest.splitn(2, "->").map(str::trim).collect();
let (listen_raw, server_raw) = match parts.as_slice() {
[listen, server] => (*listen, *server),
_ => bail!("--map must look like LOCAL_LISTEN->SERVER_ADDR[/proto], got: {raw}"),
};
let listen = normalize_client_listen(listen_raw)?;
Ok(ClientRule {
name: None,
listen,
server: server_raw.to_string(),
transport,
token: None,
})
}
fn parse_client_transport(modifier: &str) -> Result<Transport> {
match modifier.to_ascii_lowercase().as_str() {
"tls" => Ok(Transport::Tls),
"noise" => Ok(Transport::Noise),
other => bail!("unknown transport '{other}', expected tls or noise"),
}
}
fn normalize_client_listen(raw: &str) -> Result<String> {
if raw.parse::<u16>().is_ok() {
Ok(format!("127.0.0.1:{raw}"))
} else {
Ok(raw.to_string())
}
}
fn parse_protocol(s: &str) -> Result<(ProtocolKind, Option<Transport>)> {
let is_transport =
|part: &&str| part.eq_ignore_ascii_case("tls") || part.eq_ignore_ascii_case("noise");
let transport_parts: Vec<&str> = s.split('+').filter(is_transport).collect();
let transport = match transport_parts.as_slice() {
[] => None,
[t] if t.eq_ignore_ascii_case("tls") => Some(Transport::Tls),
[t] if t.eq_ignore_ascii_case("noise") => Some(Transport::Noise),
_ => bail!("'{s}' names more than one transport, expected at most one of +tls/+noise"),
};
let without_transport: Vec<&str> = s.split('+').filter(|p| !is_transport(p)).collect();
let protocol = match without_transport.join("+").to_ascii_lowercase().as_str() {
"" if transport.is_some() => ProtocolKind::Tcp,
"tcp" => ProtocolKind::Tcp,
"udp" => ProtocolKind::Udp,
"both" | "tcp+udp" | "udp+tcp" => ProtocolKind::Both,
other => bail!(
"unknown protocol '{other}', expected tcp, udp, both, optionally with a +tls/+noise modifier"
),
};
if transport.is_some() && protocol != ProtocolKind::Tcp {
bail!(
"+tls/+noise only support the tcp protocol, got '{s}' (UDP forwarding cannot be wrapped in a transport)"
);
}
Ok((protocol, transport))
}
fn normalize_listen(raw: &str) -> Result<String> {
if raw.parse::<u16>().is_ok() {
Ok(format!("0.0.0.0:{raw}"))
} else {
Ok(raw.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn map_flag_without_proto_is_plaintext_both() {
let rule = parse_map_flag("8080->10.0.0.5:80").unwrap();
assert_eq!(rule.protocol, ProtocolKind::Both);
assert_eq!(rule.transport, None);
}
#[test]
fn map_flag_bare_tls_means_tls_tcp() {
let rule = parse_map_flag("8080->10.0.0.5:80/tls").unwrap();
assert_eq!(rule.protocol, ProtocolKind::Tcp);
assert_eq!(rule.transport, Some(Transport::Tls));
}
#[test]
fn map_flag_tcp_plus_tls_means_tls_tcp() {
let rule = parse_map_flag("8080->10.0.0.5:80/tcp+tls").unwrap();
assert_eq!(rule.protocol, ProtocolKind::Tcp);
assert_eq!(rule.transport, Some(Transport::Tls));
}
#[test]
fn map_flag_bare_noise_means_noise_tcp() {
let rule = parse_map_flag("8080->10.0.0.5:80/noise").unwrap();
assert_eq!(rule.protocol, ProtocolKind::Tcp);
assert_eq!(rule.transport, Some(Transport::Noise));
}
#[test]
fn map_flag_both_transports_is_rejected() {
assert!(parse_map_flag("8080->10.0.0.5:80/tcp+tls+noise").is_err());
}
#[test]
fn map_flag_udp_plus_tls_is_rejected() {
assert!(parse_map_flag("5353->10.0.0.5:53/udp+tls").is_err());
}
#[test]
fn map_flag_both_plus_tls_is_rejected() {
assert!(parse_map_flag("8080->10.0.0.5:80/both+tls").is_err());
}
#[test]
fn client_map_flag_without_modifier_defaults_to_tls() {
let rule = parse_client_map_flag("1080->10.0.0.5:8443").unwrap();
assert_eq!(rule.transport, Transport::Tls);
}
#[test]
fn client_map_flag_noise_modifier_selects_noise() {
let rule = parse_client_map_flag("1080->10.0.0.5:8443/noise").unwrap();
assert_eq!(rule.transport, Transport::Noise);
}
#[test]
fn client_map_flag_unknown_modifier_is_rejected() {
assert!(parse_client_map_flag("1080->10.0.0.5:8443/quic").is_err());
}
}