Skip to main content

oxdock_cli/
endpoints.rs

1//! CLI endpoint mapping: virtual service keys to physical binds.
2//!
3//! Scripts declare logical endpoints (`NET_LISTEN("2251")`,
4//! `SSH_SERVE("demo-proxy")`); these flags decide what they bind to:
5//!
6//! - default: bare ports bind loopback, names open memory rendezvous;
7//! - `--listen 0.0.0.0:2251`: expose a logical port on an interface;
8//! - `-p 2222:2251`: map outer port 2222 to inner virtual 2251
9//!   (`-p 0:2251` takes an ephemeral outer port);
10//! - `--offline`: no sockets at all (conflicts with both above).
11//!
12//! [`build_registry`] turns the flags into a bound [`EndpointRegistry`];
13//! binding happens before script parsing so `EADDRINUSE` fails fast.
14
15use std::collections::HashSet;
16use std::net::SocketAddr;
17use std::sync::Arc;
18
19use anyhow::{Context, Result, bail};
20use oxdock_net_plugin::{BindingSpec, EndpointRegistry, VirtualEndpoint, parse_virtual_endpoint};
21
22/// Endpoint exposure flags from CLI args. Built by `Options::parse`,
23/// consumed by [`build_registry`].
24#[derive(Debug, Clone, Default)]
25pub struct EndpointFlags {
26    /// `--listen` addresses: each maps its own port as a virtual port.
27    pub listens: Vec<SocketAddr>,
28    /// `-p` mappings: (outer socket address, inner virtual endpoint).
29    /// A bare outer port binds all interfaces; outer port `0` takes an
30    /// ephemeral port, resolved at bind time.
31    pub publishes: Vec<(SocketAddr, VirtualEndpoint)>,
32    /// `--offline`: socketless run, conflicts with both lists above.
33    pub offline: bool,
34}
35
36/// Parse one `--listen` value: `[host:]port`. Bare digits bind loopback;
37/// otherwise a full socket address (the only place a wildcard or a
38/// non-loopback interface may appear). Port `0` is rejected: an exposed
39/// ephemeral port has no stable inner key, so it belongs to `-p 0:<inner>`.
40pub fn parse_listen_arg(raw: &str) -> Result<SocketAddr> {
41    let text = raw.trim();
42    if text.is_empty() {
43        bail!("--listen requires an address ([host:]port)");
44    }
45    if text.chars().all(|c| c.is_ascii_digit()) {
46        let port: u16 = text
47            .parse()
48            .map_err(|_| anyhow::anyhow!("--listen port must be numeric 1-65535, got {raw:?}"))?;
49        if port == 0 {
50            bail!(
51                "--listen port 0 has no stable service key (use -p 0:<endpoint> for an ephemeral outer port)"
52            );
53        }
54        return Ok(SocketAddr::from(([127, 0, 0, 1], port)));
55    }
56    let addr: SocketAddr = text.parse().with_context(|| {
57        format!("--listen address must be [host:]port (try 0.0.0.0:2251), got {raw:?}")
58    })?;
59    if addr.port() == 0 {
60        bail!(
61            "--listen port 0 has no stable service key (use -p 0:<endpoint> for an ephemeral outer port)"
62        );
63    }
64    Ok(addr)
65}
66
67/// Parse one `-p` value: `[host:]outer:inner` (Docker-style). The outer
68/// side is a bare port (all interfaces) or a full socket address; the
69/// inner side is a logical endpoint (fixed port or service name).
70/// Supported shapes: `2222:2251`, `127.0.0.1:1234:2251`,
71/// `127.0.0.1:1234:ssh-server`, `[::1]:8080:web`, `0:2251` (ephemeral
72/// outer port, resolved at bind time).
73pub fn parse_publish_arg(raw: &str) -> Result<(SocketAddr, VirtualEndpoint)> {
74    let text = raw.trim();
75    let Some((outer_text, inner_text)) = text.rsplit_once(':') else {
76        bail!(
77            "-p requires [host:]outer:inner (try -p 2222:2251 or -p 127.0.0.1:1234:ssh-server), got {raw:?}"
78        );
79    };
80    let inner = parse_virtual_endpoint(inner_text.trim(), "-p")
81        .with_context(|| format!("-p inner endpoint invalid in {raw:?}"))?;
82    let outer_text = outer_text.trim();
83    let addr = if let Ok(port) = outer_text.parse::<u16>() {
84        SocketAddr::from(([0, 0, 0, 0], port))
85    } else if let Ok(addr) = outer_text.parse::<SocketAddr>() {
86        addr
87    } else {
88        bail!(
89            "-p outer address invalid in {raw:?} (expected a port 0-65535 or a [host:]port address)"
90        );
91    };
92    Ok((addr, inner))
93}
94
95/// Build the run's endpoint registry from CLI flags and bind every mapped
96/// socket now: callers run this before parsing so bind conflicts fail
97/// fast, never parse-then-fail-on-bind.
98pub fn build_registry(flags: &EndpointFlags) -> Result<Arc<EndpointRegistry>> {
99    if flags.offline && (!flags.listens.is_empty() || !flags.publishes.is_empty()) {
100        bail!("--offline conflicts with --listen/-p (offline runs open no sockets)");
101    }
102    let registry = Arc::new(EndpointRegistry::new(flags.offline));
103    let mut inners: HashSet<String> = HashSet::new();
104    let mut outers: HashSet<SocketAddr> = HashSet::new();
105    for addr in &flags.listens {
106        let endpoint = VirtualEndpoint::Port(addr.port());
107        if !inners.insert(endpoint.key()) {
108            bail!("virtual endpoint '{endpoint}' is mapped twice");
109        }
110        if addr.port() != 0 && !outers.insert(*addr) {
111            bail!("outer socket address {addr} is mapped twice");
112        }
113        registry.add_mapping(&endpoint, BindingSpec::Exposed { addr: *addr })?;
114    }
115    for (addr, inner) in &flags.publishes {
116        if !inners.insert(inner.key()) {
117            bail!("virtual endpoint '{inner}' is mapped twice");
118        }
119        if addr.port() != 0 && !outers.insert(*addr) {
120            bail!("outer socket address {addr} is mapped twice");
121        }
122        registry.add_mapping(inner, BindingSpec::Exposed { addr: *addr })?;
123    }
124    registry.bind_all()?;
125    Ok(registry)
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn listen_forms() {
134        assert_eq!(
135            parse_listen_arg("2251").unwrap(),
136            SocketAddr::from(([127, 0, 0, 1], 2251))
137        );
138        assert_eq!(
139            parse_listen_arg("0.0.0.0:2251").unwrap(),
140            SocketAddr::from(([0, 0, 0, 0], 2251))
141        );
142        for bad in ["", "0", "2251:0", "0.0.0.0:0", "not-an-addr"] {
143            parse_listen_arg(bad).expect_err("bad listen must fail");
144        }
145    }
146
147    #[test]
148    fn publish_forms() {
149        assert_eq!(
150            parse_publish_arg("2222:2251").unwrap(),
151            (
152                SocketAddr::from(([0, 0, 0, 0], 2222)),
153                VirtualEndpoint::Port(2251)
154            )
155        );
156        assert_eq!(
157            parse_publish_arg("0:demo-proxy").unwrap(),
158            (
159                SocketAddr::from(([0, 0, 0, 0], 0)),
160                VirtualEndpoint::Name("demo-proxy".to_string())
161            )
162        );
163        for bad in ["", "2251", "abc:2251", "2222:0", "2222:127.0.0.1:2251"] {
164            parse_publish_arg(bad).expect_err("bad publish must fail");
165        }
166    }
167
168    #[test]
169    fn publish_three_part_host_port_forms() {
170        assert_eq!(
171            parse_publish_arg("127.0.0.1:1234:ssh-server").unwrap(),
172            (
173                "127.0.0.1:1234".parse().unwrap(),
174                VirtualEndpoint::Name("ssh-server".to_string())
175            )
176        );
177        assert_eq!(
178            parse_publish_arg("127.0.0.1:1234:2251").unwrap(),
179            (
180                "127.0.0.1:1234".parse().unwrap(),
181                VirtualEndpoint::Port(2251)
182            )
183        );
184        assert_eq!(
185            parse_publish_arg("[::1]:8080:web").unwrap(),
186            (
187                "[::1]:8080".parse().unwrap(),
188                VirtualEndpoint::Name("web".to_string())
189            )
190        );
191    }
192
193    #[test]
194    fn registry_rejects_conflicts() {
195        let flags = EndpointFlags {
196            offline: true,
197            listens: vec![SocketAddr::from(([0, 0, 0, 0], 2251))],
198            ..EndpointFlags::default()
199        };
200        build_registry(&flags).expect_err("offline plus listen must fail");
201        let flags = EndpointFlags {
202            publishes: vec![
203                (
204                    SocketAddr::from(([0, 0, 0, 0], 2222)),
205                    VirtualEndpoint::Port(2251),
206                ),
207                (
208                    SocketAddr::from(([0, 0, 0, 0], 2223)),
209                    VirtualEndpoint::Port(2251),
210                ),
211            ],
212            ..EndpointFlags::default()
213        };
214        build_registry(&flags).expect_err("duplicate inner must fail");
215        let flags = EndpointFlags {
216            publishes: vec![
217                (
218                    SocketAddr::from(([0, 0, 0, 0], 2222)),
219                    VirtualEndpoint::Port(2251),
220                ),
221                (
222                    SocketAddr::from(([0, 0, 0, 0], 2222)),
223                    VirtualEndpoint::Port(2252),
224                ),
225            ],
226            ..EndpointFlags::default()
227        };
228        build_registry(&flags).expect_err("duplicate outer must fail");
229    }
230}