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}