Skip to main content

erbridge_core/
config.rs

1use std::path::Path;
2
3use anyhow::{Context, Result, bail};
4use serde::Deserialize;
5
6#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
7#[serde(rename_all = "lowercase")]
8pub enum ProtocolKind {
9    Tcp,
10    Udp,
11    #[default]
12    Both,
13}
14
15fn default_udp_idle_secs() -> u64 {
16    60
17}
18
19fn default_reconnect_min_secs() -> u64 {
20    1
21}
22
23fn default_reconnect_max_secs() -> u64 {
24    30
25}
26
27/// How a mapping's listen-side TCP leg is secured. Replaces the old
28/// `secure: bool` field: a config using `secure`/`token` in this shape fails
29/// to load with a message pointing here, rather than silently keeping the
30/// previous behavior (see README's Security notes).
31#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
32#[serde(rename_all = "lowercase")]
33pub enum Transport {
34    /// A fresh self-signed cert generated at startup: confidentiality
35    /// against passive eavesdropping, no peer-identity verification (see
36    /// `tls` module docs). Interoperable with any TLS client (`curl -k`,
37    /// `openssl s_client`, ...); `token` is optional here and, if set, is
38    /// exchanged as a plaintext frame right after the handshake.
39    #[default]
40    Tls,
41    /// The Noise protocol (see `noise` module docs), with `token` mandatory
42    /// and hashed into a pre-shared key mixed into the handshake itself.
43    /// Only interoperable with another erbridge instance.
44    Noise,
45}
46
47#[derive(Debug, Clone, Deserialize)]
48#[serde(deny_unknown_fields)]
49pub struct ForwardRule {
50    pub name: Option<String>,
51    pub listen: String,
52    pub target: String,
53    #[serde(default)]
54    pub protocol: ProtocolKind,
55    #[serde(default = "default_udp_idle_secs")]
56    pub udp_idle_secs: u64,
57    /// Wraps the external (listen-side) TCP connection per [`Transport`].
58    /// `None` leaves it plaintext. Only applies to the TCP leg; UDP
59    /// forwarding is unaffected. The leg from erbridge to `target` remains
60    /// plaintext either way.
61    #[serde(default)]
62    pub transport: Option<Transport>,
63    /// Meaning depends on `transport`: see [`Transport::Tls`] and
64    /// [`Transport::Noise`]. Required when `transport = "noise"`.
65    #[serde(default)]
66    pub token: Option<String>,
67}
68
69impl ForwardRule {
70    pub fn label(&self) -> String {
71        self.name
72            .clone()
73            .unwrap_or_else(|| format!("{}->{}", self.listen, self.target))
74    }
75
76    /// `transport = "noise"` has no anonymous form (see [`Transport::Noise`]),
77    /// so a token is mandatory there; `tls` and plaintext leave it optional.
78    pub fn validate(&self) -> Result<()> {
79        if self.transport == Some(Transport::Noise) && self.token.is_none() {
80            bail!(
81                "forward[{}]: transport = \"noise\" requires a token",
82                self.label()
83            );
84        }
85        Ok(())
86    }
87}
88
89#[derive(Debug, Clone, Deserialize)]
90pub struct ServeTunnel {
91    pub name: String,
92    pub external: String,
93}
94
95#[derive(Debug, Clone, Default, Deserialize)]
96pub struct ServeConfig {
97    /// Control address A listens on, waiting for B (`connect`) to dial in.
98    pub listen: String,
99    /// Shared secret for the single-tenant mode. Must be empty when
100    /// `tenants_file` is set.
101    #[serde(default)]
102    pub token: String,
103    #[serde(default)]
104    pub tunnel: Vec<ServeTunnel>,
105    /// Multi-tenant mode: path of the tenant file. Each tenant has its own
106    /// token and is reached by its own SNI host.
107    #[serde(default)]
108    pub tenants_file: Option<String>,
109    /// Multi-tenant mode: address that accepts TLS connections and routes them
110    /// by SNI. Required with `tenants_file`, rejected without it.
111    #[serde(default)]
112    pub sni_listen: Option<String>,
113}
114
115impl ServeConfig {
116    /// Whether this is the multi-tenant mode, after checking that the options
117    /// of the two modes are not mixed.
118    pub fn is_multi_tenant(&self) -> Result<bool> {
119        match (&self.tenants_file, &self.sni_listen) {
120            (Some(_), sni) => {
121                if !self.token.is_empty() || !self.tunnel.is_empty() {
122                    bail!(
123                        "serve: `tenants_file` cannot be combined with `token` or `[[serve.tunnel]]`"
124                    );
125                }
126                if sni.is_none() {
127                    bail!("serve: `tenants_file` requires `sni_listen`");
128                }
129                Ok(true)
130            }
131            (None, Some(_)) => bail!("serve: `sni_listen` requires `tenants_file`"),
132            (None, None) => Ok(false),
133        }
134    }
135}
136
137#[derive(Debug, Clone, Deserialize)]
138pub struct ConnectTunnel {
139    pub name: String,
140    pub target: String,
141}
142
143#[derive(Debug, Clone, Deserialize)]
144pub struct ConnectConfig {
145    /// A's control address to dial.
146    pub server: String,
147    pub token: String,
148    #[serde(default)]
149    pub tunnel: Vec<ConnectTunnel>,
150    #[serde(default = "default_reconnect_min_secs")]
151    pub reconnect_min_secs: u64,
152    #[serde(default = "default_reconnect_max_secs")]
153    pub reconnect_max_secs: u64,
154}
155
156#[derive(Debug, Clone, Deserialize)]
157#[serde(deny_unknown_fields)]
158pub struct ClientRule {
159    pub name: Option<String>,
160    /// Local plaintext address this side listens on.
161    pub listen: String,
162    /// Address of a `forward` mapping with a `transport` set on the far side.
163    pub server: String,
164    /// Which transport the far side's mapping uses. Defaults to `tls` to
165    /// match the transport a bare `secure = true` mapping used before
166    /// `transport` existed.
167    #[serde(default)]
168    pub transport: Transport,
169    /// Must match that mapping's `token`. Required when `transport = "noise"`.
170    #[serde(default)]
171    pub token: Option<String>,
172}
173
174impl ClientRule {
175    pub fn label(&self) -> String {
176        self.name
177            .clone()
178            .unwrap_or_else(|| format!("{}->{}", self.listen, self.server))
179    }
180
181    pub fn validate(&self) -> Result<()> {
182        if self.transport == Transport::Noise && self.token.is_none() {
183            bail!(
184                "client[{}]: transport = \"noise\" requires a token",
185                self.label()
186            );
187        }
188        Ok(())
189    }
190}
191
192#[derive(Debug, Clone, Deserialize, Default)]
193pub struct FileConfig {
194    #[serde(default)]
195    pub forward: Vec<ForwardRule>,
196    pub serve: Option<ServeConfig>,
197    pub connect: Option<ConnectConfig>,
198    #[serde(default)]
199    pub client: Vec<ClientRule>,
200}
201
202impl FileConfig {
203    pub fn load(path: &Path) -> Result<Self> {
204        let text = std::fs::read_to_string(path)
205            .with_context(|| format!("reading config file {}", path.display()))?;
206        let cfg: FileConfig = toml::from_str(&text)
207            .with_context(|| format!("parsing config file {}", path.display()))?;
208        Ok(cfg)
209    }
210}
211
212/// Parses a `--map LISTEN:TARGET_HOST:TARGET_PORT[/proto]` CLI shorthand into a
213/// `ForwardRule`. `LISTEN` may be `PORT` or `HOST:PORT`. `proto` may add a
214/// `+tls` or `+noise` modifier (e.g. `tcp+tls`, or bare `tls`/`noise` as
215/// shorthand for `tcp+tls`/`tcp+noise`) to wrap the listen-side TCP
216/// connection per [`Transport`].
217pub fn parse_map_flag(raw: &str) -> Result<ForwardRule> {
218    let (rest, (protocol, transport)) = match raw.rsplit_once('/') {
219        Some((rest, proto)) => (rest, parse_protocol(proto)?),
220        None => (raw, (ProtocolKind::Both, None)),
221    };
222    let parts: Vec<&str> = rest.splitn(2, "->").map(str::trim).collect();
223    let (listen_raw, target_raw) = match parts.as_slice() {
224        [listen, target] => (*listen, *target),
225        _ => bail!("--map must look like LISTEN->TARGET[/proto], got: {raw}"),
226    };
227    let listen = normalize_listen(listen_raw)?;
228    Ok(ForwardRule {
229        name: None,
230        listen,
231        target: target_raw.to_string(),
232        protocol,
233        udp_idle_secs: default_udp_idle_secs(),
234        transport,
235        token: None,
236    })
237}
238
239/// Parses a `--map LOCAL_LISTEN->SERVER_ADDR[/tls|/noise]` CLI shorthand for
240/// `client` mode into a `ClientRule`. `LOCAL_LISTEN` may be a bare port
241/// (binds 127.0.0.1) or host:port; `SERVER_ADDR` must be `host:port`. The
242/// modifier names the far side mapping's transport; omitting it defaults to
243/// `tls`, matching the transport `client` always spoke before `transport`
244/// existed.
245pub fn parse_client_map_flag(raw: &str) -> Result<ClientRule> {
246    let (rest, transport) = match raw.rsplit_once('/') {
247        Some((rest, modifier)) => (rest, parse_client_transport(modifier)?),
248        None => (raw, Transport::Tls),
249    };
250    let parts: Vec<&str> = rest.splitn(2, "->").map(str::trim).collect();
251    let (listen_raw, server_raw) = match parts.as_slice() {
252        [listen, server] => (*listen, *server),
253        _ => bail!("--map must look like LOCAL_LISTEN->SERVER_ADDR[/proto], got: {raw}"),
254    };
255    let listen = normalize_client_listen(listen_raw)?;
256    Ok(ClientRule {
257        name: None,
258        listen,
259        server: server_raw.to_string(),
260        transport,
261        token: None,
262    })
263}
264
265fn parse_client_transport(modifier: &str) -> Result<Transport> {
266    match modifier.to_ascii_lowercase().as_str() {
267        "tls" => Ok(Transport::Tls),
268        "noise" => Ok(Transport::Noise),
269        other => bail!("unknown transport '{other}', expected tls or noise"),
270    }
271}
272
273/// Accepts a bare port ("1080") as shorthand for "127.0.0.1:1080" -- unlike
274/// `forward`'s external listeners, this is a local-only endpoint by default.
275fn normalize_client_listen(raw: &str) -> Result<String> {
276    if raw.parse::<u16>().is_ok() {
277        Ok(format!("127.0.0.1:{raw}"))
278    } else {
279        Ok(raw.to_string())
280    }
281}
282
283fn parse_protocol(s: &str) -> Result<(ProtocolKind, Option<Transport>)> {
284    let is_transport =
285        |part: &&str| part.eq_ignore_ascii_case("tls") || part.eq_ignore_ascii_case("noise");
286    let transport_parts: Vec<&str> = s.split('+').filter(is_transport).collect();
287    let transport = match transport_parts.as_slice() {
288        [] => None,
289        [t] if t.eq_ignore_ascii_case("tls") => Some(Transport::Tls),
290        [t] if t.eq_ignore_ascii_case("noise") => Some(Transport::Noise),
291        _ => bail!("'{s}' names more than one transport, expected at most one of +tls/+noise"),
292    };
293    let without_transport: Vec<&str> = s.split('+').filter(|p| !is_transport(p)).collect();
294    let protocol = match without_transport.join("+").to_ascii_lowercase().as_str() {
295        "" if transport.is_some() => ProtocolKind::Tcp,
296        "tcp" => ProtocolKind::Tcp,
297        "udp" => ProtocolKind::Udp,
298        "both" | "tcp+udp" | "udp+tcp" => ProtocolKind::Both,
299        other => bail!(
300            "unknown protocol '{other}', expected tcp, udp, both, optionally with a +tls/+noise modifier"
301        ),
302    };
303    if transport.is_some() && protocol != ProtocolKind::Tcp {
304        bail!(
305            "+tls/+noise only support the tcp protocol, got '{s}' (UDP forwarding cannot be wrapped in a transport)"
306        );
307    }
308    Ok((protocol, transport))
309}
310
311/// Accepts a bare port ("8080") as shorthand for "0.0.0.0:8080".
312fn normalize_listen(raw: &str) -> Result<String> {
313    if raw.parse::<u16>().is_ok() {
314        Ok(format!("0.0.0.0:{raw}"))
315    } else {
316        Ok(raw.to_string())
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    #[test]
325    fn map_flag_without_proto_is_plaintext_both() {
326        let rule = parse_map_flag("8080->10.0.0.5:80").unwrap();
327        assert_eq!(rule.protocol, ProtocolKind::Both);
328        assert_eq!(rule.transport, None);
329    }
330
331    #[test]
332    fn map_flag_bare_tls_means_tls_tcp() {
333        let rule = parse_map_flag("8080->10.0.0.5:80/tls").unwrap();
334        assert_eq!(rule.protocol, ProtocolKind::Tcp);
335        assert_eq!(rule.transport, Some(Transport::Tls));
336    }
337
338    #[test]
339    fn map_flag_tcp_plus_tls_means_tls_tcp() {
340        let rule = parse_map_flag("8080->10.0.0.5:80/tcp+tls").unwrap();
341        assert_eq!(rule.protocol, ProtocolKind::Tcp);
342        assert_eq!(rule.transport, Some(Transport::Tls));
343    }
344
345    #[test]
346    fn map_flag_bare_noise_means_noise_tcp() {
347        let rule = parse_map_flag("8080->10.0.0.5:80/noise").unwrap();
348        assert_eq!(rule.protocol, ProtocolKind::Tcp);
349        assert_eq!(rule.transport, Some(Transport::Noise));
350    }
351
352    #[test]
353    fn map_flag_both_transports_is_rejected() {
354        assert!(parse_map_flag("8080->10.0.0.5:80/tcp+tls+noise").is_err());
355    }
356
357    #[test]
358    fn map_flag_udp_plus_tls_is_rejected() {
359        assert!(parse_map_flag("5353->10.0.0.5:53/udp+tls").is_err());
360    }
361
362    #[test]
363    fn map_flag_both_plus_tls_is_rejected() {
364        assert!(parse_map_flag("8080->10.0.0.5:80/both+tls").is_err());
365    }
366
367    #[test]
368    fn client_map_flag_without_modifier_defaults_to_tls() {
369        let rule = parse_client_map_flag("1080->10.0.0.5:8443").unwrap();
370        assert_eq!(rule.transport, Transport::Tls);
371    }
372
373    #[test]
374    fn client_map_flag_noise_modifier_selects_noise() {
375        let rule = parse_client_map_flag("1080->10.0.0.5:8443/noise").unwrap();
376        assert_eq!(rule.transport, Transport::Noise);
377    }
378
379    #[test]
380    fn client_map_flag_unknown_modifier_is_rejected() {
381        assert!(parse_client_map_flag("1080->10.0.0.5:8443/quic").is_err());
382    }
383}