Skip to main content

trusty_console/
bind.rs

1//! Bind-address resolution for trusty-console.
2//!
3//! Why: The console needs to be reachable from tailnet clients across restarts
4//! without manual `--http 0.0.0.0:7788` overrides. This module centralises all
5//! bind-address logic — env-var defaults, `--tailscale` flag handling, and
6//! Tailscale-IPv4 detection — behind a clean, mockable boundary so the
7//! resolution logic can be unit-tested without a real tailnet.
8//! What: Exports `resolve_bind_addrs`, which returns the ordered list of
9//! `SocketAddr`s the server should bind; and `detect_tailscale_ipv4`, which
10//! shells out to `tailscale ip -4` (or accepts an injected command for tests).
11//! Test: `tests` module below; no real tailnet required.
12
13use std::net::{IpAddr, SocketAddr};
14use std::str::FromStr;
15
16use anyhow::{Context, Result};
17use tracing::{info, warn};
18
19// ─── public types ────────────────────────────────────────────────────────────
20
21/// How the server should bind its listeners.
22///
23/// Why: Captures the three meaningful bind modes so `resolve_bind_addrs` can
24/// return the right `SocketAddr` list for each without tangled string parsing.
25/// What: Three variants — local-only (default), tailscale (dual listener), or
26/// explicit (whatever the `--http` flag / `TRUSTY_CONSOLE_BIND` env var says).
27/// Test: Constructed by `BindMode::from_env_and_flags`; exercised below.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum BindMode {
30    /// Bind only `127.0.0.1:<port>` — default.
31    Local,
32    /// Bind `127.0.0.1:<port>` AND the detected Tailscale IPv4 `<ts-ip>:<port>`.
33    Tailscale,
34    /// Bind the explicit address string (e.g. `0.0.0.0:7788` or `127.0.0.1:9000`).
35    Explicit(String),
36}
37
38impl BindMode {
39    /// Determine the bind mode from the env var and CLI flags.
40    ///
41    /// Why: Encodes the precedence rule — explicit `--http` wins, then
42    /// `TRUSTY_CONSOLE_BIND`, then `--tailscale`, then local default — in one
43    /// place so callers never need to re-implement it.
44    /// What: Reads `TRUSTY_CONSOLE_BIND` from the process environment and
45    /// delegates to `from_flags_and_bind_env`; see that function for full
46    /// precedence rules.
47    ///
48    /// Test: `test_bind_mode_*` tests call `from_flags_and_bind_env` directly
49    /// with injected values to avoid parallel-test env-var races.
50    pub fn from_env_and_flags(
51        explicit_http: &str,
52        default_http: &str,
53        tailscale_flag: bool,
54    ) -> Self {
55        let bind_env = std::env::var("TRUSTY_CONSOLE_BIND").ok();
56        Self::from_flags_and_bind_env(
57            explicit_http,
58            default_http,
59            tailscale_flag,
60            bind_env.as_deref(),
61        )
62    }
63
64    /// Pure, deterministic core of bind-mode resolution (no env reads).
65    ///
66    /// Why: Separating the env read from the logic makes this function fully
67    /// testable without process-global env mutation — important because tests
68    /// run in parallel threads and `set_var`/`remove_var` races cause flakiness.
69    ///
70    /// What: Applies a four-level precedence rule — `--http` override > env
71    /// `"tailscale"` > env non-empty addr > `--tailscale` flag > local default.
72    ///
73    /// Test: `test_bind_mode_*` below call this function directly.
74    pub fn from_flags_and_bind_env(
75        explicit_http: &str,
76        default_http: &str,
77        tailscale_flag: bool,
78        bind_env: Option<&str>,
79    ) -> Self {
80        // 1. Explicit `--http` override (user changed it from the default).
81        if explicit_http != default_http {
82            return BindMode::Explicit(explicit_http.to_owned());
83        }
84
85        // 2. TRUSTY_CONSOLE_BIND env var.
86        if let Some(val) = bind_env {
87            let val = val.trim().to_lowercase();
88            if val == "tailscale" {
89                return BindMode::Tailscale;
90            }
91            if !val.is_empty() {
92                return BindMode::Explicit(val);
93            }
94        }
95
96        // 3. --tailscale flag.
97        if tailscale_flag {
98            return BindMode::Tailscale;
99        }
100
101        // 4. Default: local-only.
102        BindMode::Local
103    }
104}
105
106// ─── address resolution ───────────────────────────────────────────────────────
107
108/// Resolve the list of `SocketAddr`s to bind based on `mode` and `port`.
109///
110/// Why: Separating address resolution from bind/listen lets tests verify the
111/// correct addresses are produced without opening real sockets.
112/// What: Returns 1-2 `SocketAddr`s. In `Tailscale` mode runs
113/// `detect_tailscale_ipv4` — if that fails, logs a warning and falls back to
114/// local-only.  In `Explicit` mode parses the string directly.
115/// Test: `test_resolve_*` below; `detect_tailscale_ipv4` is
116/// injected via the `ip_detector` closure for unit-test isolation.
117pub fn resolve_bind_addrs(
118    mode: &BindMode,
119    default_port: u16,
120    ip_detector: impl FnOnce() -> Option<IpAddr>,
121) -> Vec<SocketAddr> {
122    match mode {
123        BindMode::Local => {
124            let addr = SocketAddr::from(([127, 0, 0, 1], default_port));
125            vec![addr]
126        }
127
128        BindMode::Tailscale => {
129            let loopback = SocketAddr::from(([127, 0, 0, 1], default_port));
130            match ip_detector() {
131                Some(ts_ip) => {
132                    let ts_addr = SocketAddr::new(ts_ip, default_port);
133                    info!("tailscale mode: binding loopback and {ts_addr}");
134                    vec![loopback, ts_addr]
135                }
136                None => {
137                    warn!(
138                        "tailscale mode requested but could not detect Tailscale IPv4 — \
139                         falling back to localhost-only"
140                    );
141                    vec![loopback]
142                }
143            }
144        }
145
146        BindMode::Explicit(addr_str) => match SocketAddr::from_str(addr_str) {
147            Ok(addr) => vec![addr],
148            Err(e) => {
149                warn!("could not parse bind address {addr_str:?}: {e}; falling back to localhost");
150                vec![SocketAddr::from(([127, 0, 0, 1], default_port))]
151            }
152        },
153    }
154}
155
156// ─── Tailscale IP detection ───────────────────────────────────────────────────
157
158/// Detect the machine's Tailscale IPv4 address by running `tailscale ip -4`.
159///
160/// Why: The canonical way to find the tailnet IP without parsing routing tables
161/// or iterating interfaces. The `tailscale` CLI is already required to use the
162/// tailnet, so it is a safe runtime dependency.
163/// What: Spawns `tailscale ip -4`, trims the output, parses it as an `IpAddr`.
164/// Returns `None` (with a tracing warning) when Tailscale is not installed,
165/// not running, or the output is unparseable.
166/// Test: Not called in unit tests — replaced by the `ip_detector` closure in
167/// `resolve_bind_addrs`; integration-tested via `--tailscale` on a live machine.
168pub fn detect_tailscale_ipv4() -> Option<IpAddr> {
169    let output = std::process::Command::new("tailscale")
170        .args(["ip", "-4"])
171        .output();
172
173    match output {
174        Err(e) => {
175            warn!("could not run `tailscale ip -4`: {e}");
176            None
177        }
178        Ok(out) if !out.status.success() => {
179            let stderr = String::from_utf8_lossy(&out.stderr);
180            warn!(
181                "tailscale ip -4 exited with status {}: {stderr}",
182                out.status
183            );
184            None
185        }
186        Ok(out) => {
187            let stdout = String::from_utf8_lossy(&out.stdout);
188            let raw = stdout.trim();
189            match IpAddr::from_str(raw) {
190                Ok(ip) => {
191                    info!("detected Tailscale IPv4: {ip}");
192                    Some(ip)
193                }
194                Err(e) => {
195                    warn!("could not parse Tailscale IP {raw:?}: {e}");
196                    None
197                }
198            }
199        }
200    }
201}
202
203/// Parse the port from an explicit bind address string, falling back to `default`.
204///
205/// Why: In Tailscale mode we need the port from the `--http` default when
206/// constructing dual-listener addresses; this helper avoids duplicating the
207/// parsing in `run_serve`.
208/// What: Attempts `addr.parse::<SocketAddr>().port()`; returns `default` on
209/// failure.
210/// Test: `test_port_from_addr` below.
211pub fn port_from_addr(addr: &str, default: u16) -> u16 {
212    addr.parse::<SocketAddr>()
213        .map(|a| a.port())
214        .unwrap_or(default)
215}
216
217/// Bind a TCP listener, logging and returning a descriptive error on failure.
218///
219/// Why: Wraps `TcpListener::bind` with uniform context so callers don't need
220/// to format their own error messages for each bind attempt.
221/// What: Awaits `tokio::net::TcpListener::bind(addr)` and attaches context.
222/// Test: Not unit-tested (network I/O); exercised by `run_serve` integration.
223pub async fn bind_listener(addr: SocketAddr) -> Result<tokio::net::TcpListener> {
224    tokio::net::TcpListener::bind(addr)
225        .await
226        .with_context(|| format!("failed to bind {addr}"))
227}
228
229// ─── tests ────────────────────────────────────────────────────────────────────
230
231#[cfg(test)]
232mod tests {
233    use std::net::{IpAddr, Ipv4Addr};
234
235    use super::*;
236
237    // ── BindMode::from_flags_and_bind_env ────────────────────────────────────
238    //
239    // All tests call the pure `from_flags_and_bind_env` overload so they are
240    // deterministic and free of process-global env mutation (which causes flaky
241    // races when tests run in parallel threads).
242
243    /// Why: --http override must produce Explicit regardless of flags/env.
244    /// What: passes a non-default http value with tailscale_flag=true; asserts Explicit.
245    /// Test: this test itself.
246    #[test]
247    fn test_bind_mode_explicit_http_wins() {
248        let mode = BindMode::from_flags_and_bind_env(
249            "0.0.0.0:9000",
250            "127.0.0.1:7788",
251            true,
252            Some("tailscale"),
253        );
254        assert_eq!(mode, BindMode::Explicit("0.0.0.0:9000".to_owned()));
255    }
256
257    /// Why: TRUSTY_CONSOLE_BIND=tailscale must produce Tailscale when --http is default.
258    /// What: passes bind_env=Some("tailscale"); asserts Tailscale.
259    /// Test: this test itself.
260    #[test]
261    fn test_bind_mode_env_tailscale() {
262        let mode = BindMode::from_flags_and_bind_env(
263            "127.0.0.1:7788",
264            "127.0.0.1:7788",
265            false,
266            Some("tailscale"),
267        );
268        assert_eq!(mode, BindMode::Tailscale);
269    }
270
271    /// Why: TRUSTY_CONSOLE_BIND=TAILSCALE (uppercase) must still produce Tailscale.
272    /// What: passes bind_env=Some("TAILSCALE"); asserts case-insensitive match.
273    /// Test: this test itself.
274    #[test]
275    fn test_bind_mode_env_tailscale_uppercase() {
276        let mode = BindMode::from_flags_and_bind_env(
277            "127.0.0.1:7788",
278            "127.0.0.1:7788",
279            false,
280            Some("TAILSCALE"),
281        );
282        assert_eq!(mode, BindMode::Tailscale);
283    }
284
285    /// Why: TRUSTY_CONSOLE_BIND=0.0.0.0:8080 must produce Explicit with that addr.
286    /// What: passes bind_env=Some("0.0.0.0:8080"); asserts Explicit.
287    /// Test: this test itself.
288    #[test]
289    fn test_bind_mode_env_explicit_addr() {
290        let mode = BindMode::from_flags_and_bind_env(
291            "127.0.0.1:7788",
292            "127.0.0.1:7788",
293            false,
294            Some("0.0.0.0:8080"),
295        );
296        assert_eq!(mode, BindMode::Explicit("0.0.0.0:8080".to_owned()));
297    }
298
299    /// Why: --tailscale flag must produce Tailscale when env is absent.
300    /// What: passes tailscale_flag=true, bind_env=None; asserts Tailscale.
301    /// Test: this test itself.
302    #[test]
303    fn test_bind_mode_tailscale_flag() {
304        let mode =
305            BindMode::from_flags_and_bind_env("127.0.0.1:7788", "127.0.0.1:7788", true, None);
306        assert_eq!(mode, BindMode::Tailscale);
307    }
308
309    /// Why: env var takes precedence over --tailscale flag.
310    /// What: passes bind_env=Some("0.0.0.0:9999") and tailscale_flag=true; asserts Explicit wins.
311    /// Test: this test itself.
312    #[test]
313    fn test_bind_mode_env_beats_tailscale_flag() {
314        let mode = BindMode::from_flags_and_bind_env(
315            "127.0.0.1:7788",
316            "127.0.0.1:7788",
317            true,
318            Some("0.0.0.0:9999"),
319        );
320        assert_eq!(mode, BindMode::Explicit("0.0.0.0:9999".to_owned()));
321    }
322
323    /// Why: no overrides must produce Local.
324    /// What: no env, no flag, http = default; asserts Local.
325    /// Test: this test itself.
326    #[test]
327    fn test_bind_mode_default_is_local() {
328        let mode =
329            BindMode::from_flags_and_bind_env("127.0.0.1:7788", "127.0.0.1:7788", false, None);
330        assert_eq!(mode, BindMode::Local);
331    }
332
333    /// Why: empty bind_env string must fall through to Local (not crash).
334    /// What: passes bind_env=Some(""); asserts Local.
335    /// Test: this test itself.
336    #[test]
337    fn test_bind_mode_env_empty_is_local() {
338        let mode =
339            BindMode::from_flags_and_bind_env("127.0.0.1:7788", "127.0.0.1:7788", false, Some(""));
340        assert_eq!(mode, BindMode::Local);
341    }
342
343    // ── resolve_bind_addrs ────────────────────────────────────────────────────
344
345    /// Why: Local mode must return exactly one loopback addr on the given port.
346    /// What: calls resolve_bind_addrs with Local mode; injected detector is never called.
347    /// Test: this test itself.
348    #[test]
349    fn test_resolve_local() {
350        let addrs = resolve_bind_addrs(&BindMode::Local, 7788, || panic!("should not call"));
351        assert_eq!(addrs, vec![SocketAddr::from(([127, 0, 0, 1], 7788))]);
352    }
353
354    /// Why: Tailscale mode with a valid IP must return loopback + tailscale addr.
355    /// What: injects a fixed Tailscale IP; asserts both addrs are returned.
356    /// Test: this test itself.
357    #[test]
358    fn test_resolve_tailscale_with_ip() {
359        let ts_ip = IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1));
360        let addrs = resolve_bind_addrs(&BindMode::Tailscale, 7788, || Some(ts_ip));
361        assert_eq!(addrs.len(), 2);
362        assert_eq!(addrs[0], SocketAddr::from(([127, 0, 0, 1], 7788)));
363        assert_eq!(addrs[1], SocketAddr::new(ts_ip, 7788));
364    }
365
366    /// Why: Tailscale mode without a Tailscale IP must fall back to localhost-only.
367    /// What: injects None from the detector; asserts single loopback addr.
368    /// Test: this test itself.
369    #[test]
370    fn test_resolve_tailscale_fallback() {
371        let addrs = resolve_bind_addrs(&BindMode::Tailscale, 7788, || None);
372        assert_eq!(addrs, vec![SocketAddr::from(([127, 0, 0, 1], 7788))]);
373    }
374
375    /// Why: Explicit mode with a valid addr must return that addr.
376    /// What: passes a valid addr string; asserts it is parsed correctly.
377    /// Test: this test itself.
378    #[test]
379    fn test_resolve_explicit_valid() {
380        let mode = BindMode::Explicit("0.0.0.0:9000".to_owned());
381        let addrs = resolve_bind_addrs(&mode, 7788, || panic!("should not call"));
382        assert_eq!(addrs, vec![SocketAddr::from(([0, 0, 0, 0], 9000))]);
383    }
384
385    /// Why: Explicit mode with an unparseable addr must fall back to localhost.
386    /// What: passes a garbage string; asserts single loopback addr on default port.
387    /// Test: this test itself.
388    #[test]
389    fn test_resolve_explicit_invalid_fallback() {
390        let mode = BindMode::Explicit("not-an-addr".to_owned());
391        let addrs = resolve_bind_addrs(&mode, 7788, || panic!("should not call"));
392        assert_eq!(addrs, vec![SocketAddr::from(([127, 0, 0, 1], 7788))]);
393    }
394
395    // ── port_from_addr ────────────────────────────────────────────────────────
396
397    /// Why: port_from_addr must extract the correct port from a valid addr string.
398    /// What: passes "127.0.0.1:7788"; asserts 7788.
399    /// Test: this test itself.
400    #[test]
401    fn test_port_from_addr_valid() {
402        assert_eq!(port_from_addr("127.0.0.1:7788", 7788), 7788);
403        assert_eq!(port_from_addr("0.0.0.0:9000", 7788), 9000);
404    }
405
406    /// Why: port_from_addr must return the default when the string is garbage.
407    /// What: passes "garbage"; asserts default 7788.
408    /// Test: this test itself.
409    #[test]
410    fn test_port_from_addr_invalid() {
411        assert_eq!(port_from_addr("garbage", 7788), 7788);
412    }
413
414    // ── parse tailscale ip output ─────────────────────────────────────────────
415
416    /// Why: we need to verify that the tailscale IP parser handles typical output
417    /// (with trailing newline) correctly.
418    /// What: simulates the parsing step in detect_tailscale_ipv4 inline.
419    /// Test: this test itself.
420    #[test]
421    fn test_parse_tailscale_output() {
422        let raw = "100.64.0.1\n";
423        let ip: IpAddr = raw.trim().parse().expect("parse");
424        assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1)));
425    }
426}