Skip to main content

isb_server/server/
tailnet.rs

1//! Tailnet identity, for `isb serve --superadmin-tailnet` and orgs' agent
2//! identities: who is at the other end of a TCP connection from a tailnet
3//! address, asked of the local tailscaled.
4//!
5//! - Only the real socket peer counts. Forwarded headers
6//!   (`X-Forwarded-For`, `Tailscale-User-Login`) are never read: anything on
7//!   the path could write them.
8//! - The peer must be a tailnet address (100.64.0.0/10, fd7a:115c:a1e0::/48)
9//!   and the request's `Host` one of this server's names (its tailnet
10//!   listen addresses, its MagicDNS names, the public URL's host), which
11//!   blocks DNS rebinding: a page on another site that resolves its name to
12//!   this address still sends its own `Host`.
13//! - tailscaled answers `whois` through its LocalAPI (the unix socket on
14//!   Linux), else the `tailscale whois --json` CLI (macOS). When neither
15//!   answers, nobody is a tailnet superadmin; why is logged once.
16//! - A tagged node is its tags (its login is `tagged-devices`); any other
17//!   node is its user's login name. The allow list names either.
18//! - Answers are cached per peer address for a minute, failures for five
19//!   seconds.
20
21use std::collections::HashMap;
22use std::io::{Read, Write};
23use std::net::{IpAddr, SocketAddr};
24use std::sync::atomic::{AtomicBool, Ordering};
25use std::sync::{Arc, Mutex};
26use std::time::{Duration, Instant};
27
28use serde_json::Value;
29
30use super::http::{Peer, Request};
31use crate::error::{Error, Result};
32
33/// Who tailscaled says is behind an address.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Whois {
36    /// The user's login name (`someone@example.com`; `tagged-devices` for
37    /// a tagged node).
38    pub login: String,
39    /// The node's MagicDNS name, without the trailing dot.
40    pub node: String,
41    pub tags: Vec<String>,
42}
43
44/// Asks tailscaled about one peer. Injectable, for tests.
45pub type WhoisFetcher = Arc<dyn Fn(SocketAddr) -> std::result::Result<Whois, String> + Send + Sync>;
46
47/// tailscaled's LocalAPI socket on Linux.
48pub const LOCALAPI_SOCKETS: &[&str] = &[
49    "/var/run/tailscale/tailscaled.sock",
50    "/run/tailscale/tailscaled.sock",
51];
52
53/// The CLI, on PATH or where the macOS app keeps it.
54const CLIS: &[&str] = &[
55    "tailscale",
56    "/Applications/Tailscale.app/Contents/MacOS/Tailscale",
57];
58
59const TTL: Duration = Duration::from_secs(60);
60const FAIL_TTL: Duration = Duration::from_secs(5);
61const MAX_CACHED: usize = 4096;
62
63/// 100.64.0.0/10 (IPv4, also v4-mapped) or fd7a:115c:a1e0::/48.
64pub fn is_tailnet_ip(ip: IpAddr) -> bool {
65    match ip {
66        IpAddr::V4(v) => {
67            let o = v.octets();
68            o[0] == 100 && (o[1] & 0xc0) == 64
69        }
70        IpAddr::V6(v) => match v.to_ipv4_mapped() {
71            Some(v4) => is_tailnet_ip(IpAddr::V4(v4)),
72            None => {
73                let s = v.segments();
74                s[0] == 0xfd7a && s[1] == 0x115c && s[2] == 0xa1e0
75            }
76        },
77    }
78}
79
80/// `--superadmin-tailnet`: login names and node tags.
81#[derive(Debug, Clone, PartialEq, Eq, Default)]
82pub struct AllowList {
83    pub logins: Vec<String>,
84    pub tags: Vec<String>,
85}
86
87impl AllowList {
88    /// Comma-separated `login@domain` and `tag:name` entries; at least one.
89    pub fn parse(list: &str) -> Result<AllowList> {
90        let mut a = AllowList::default();
91        for e in list.split(',').map(str::trim).filter(|e| !e.is_empty()) {
92            if let Some(t) = e.strip_prefix("tag:") {
93                if t.is_empty()
94                    || !t
95                        .bytes()
96                        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_'))
97                {
98                    return Err(Error::invalid(format!(
99                        "--superadmin-tailnet: bad tag {e:?}"
100                    )));
101                }
102                a.tags.push(e.to_ascii_lowercase());
103            } else if e.contains('@') && !e.contains(char::is_whitespace) {
104                a.logins.push(e.to_ascii_lowercase());
105            } else {
106                return Err(Error::invalid(format!(
107                    "--superadmin-tailnet: {e:?} is neither a login name (someone@example.com) nor a tag (tag:name)"
108                )));
109            }
110        }
111        if a.logins.is_empty() && a.tags.is_empty() {
112            return Err(Error::invalid(
113                "--superadmin-tailnet needs at least one login name or tag",
114            ));
115        }
116        Ok(a)
117    }
118
119    /// A tagged node by its tags only; any other by its user's login.
120    pub fn admits(&self, w: &Whois) -> bool {
121        if w.tags.is_empty() {
122            self.logins.iter().any(|l| l.eq_ignore_ascii_case(&w.login))
123        } else {
124            w.tags
125                .iter()
126                .any(|t| self.tags.iter().any(|a| a.eq_ignore_ascii_case(t)))
127        }
128    }
129
130    /// Every entry, as given (lowercased).
131    pub fn entries(&self) -> Vec<String> {
132        self.logins.iter().chain(&self.tags).cloned().collect()
133    }
134}
135
136/// The check `isb serve --superadmin-tailnet` runs on every TCP request.
137pub struct Tailnet {
138    allow: AllowList,
139    fetch: WhoisFetcher,
140    /// Lowercased hostnames (no port) a request's `Host` may name.
141    hosts: Vec<String>,
142    cache: Mutex<HashMap<IpAddr, (Instant, Option<Whois>)>>,
143    warned: AtomicBool,
144}
145
146impl std::fmt::Debug for Tailnet {
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        f.debug_struct("Tailnet")
149            .field("allow", &self.allow)
150            .field("hosts", &self.hosts)
151            .finish()
152    }
153}
154
155impl Tailnet {
156    pub fn new(allow: AllowList, fetch: WhoisFetcher, hosts: Vec<String>) -> Tailnet {
157        let mut hosts: Vec<String> = hosts
158            .into_iter()
159            .map(|h| host_only(&h))
160            .filter(|h| !h.is_empty())
161            .collect();
162        hosts.sort();
163        hosts.dedup();
164        Tailnet {
165            allow,
166            fetch,
167            hosts,
168            cache: Mutex::new(HashMap::new()),
169            warned: AtomicBool::new(false),
170        }
171    }
172
173    pub fn allow(&self) -> &AllowList {
174        &self.allow
175    }
176
177    pub fn hosts(&self) -> &[String] {
178        &self.hosts
179    }
180
181    /// The tailnet identity behind `req`: a TCP peer on a tailnet address,
182    /// a `Host` naming this server, and a whois answer. Who it is allowed
183    /// to be is the caller's question (the superadmin allow list, an org's
184    /// agent identities).
185    pub fn identify(&self, req: &Request) -> Option<Whois> {
186        let Peer::Tcp(peer) = &req.peer else {
187            return None;
188        };
189        if !is_tailnet_ip(peer.ip()) {
190            return None;
191        }
192        let host = req.header("host").map(host_only).unwrap_or_default();
193        if !self.hosts.contains(&host) {
194            eprintln!(
195                "isb serve: tailnet peer {peer} sent Host {host:?}, not one of this server's names; not identified"
196            );
197            return None;
198        }
199        self.whois(*peer)
200    }
201
202    /// The tailnet identity behind `req` when it is on the superadmin
203    /// allow list.
204    pub fn superadmin(&self, req: &Request) -> Option<Whois> {
205        let w = self.identify(req)?;
206        if self.allow.admits(&w) { Some(w) } else { None }
207    }
208
209    fn whois(&self, peer: SocketAddr) -> Option<Whois> {
210        let now = Instant::now();
211        if let Some((at, w)) = self.lock().get(&peer.ip()) {
212            let ttl = if w.is_some() { TTL } else { FAIL_TTL };
213            if now.duration_since(*at) < ttl {
214                return w.clone();
215            }
216        }
217        let got = match (self.fetch)(peer) {
218            Ok(w) => Some(w),
219            Err(e) => {
220                if !self.warned.swap(true, Ordering::Relaxed) {
221                    eprintln!(
222                        "isb serve: tailnet identities are unavailable until tailscaled answers: {e}"
223                    );
224                }
225                None
226            }
227        };
228        let mut c = self.lock();
229        if c.len() >= MAX_CACHED {
230            c.clear();
231        }
232        c.insert(peer.ip(), (now, got.clone()));
233        got
234    }
235
236    fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<IpAddr, (Instant, Option<Whois>)>> {
237        self.cache.lock().unwrap_or_else(|e| e.into_inner())
238    }
239}
240
241/// `host[:port]` or `[v6]:port` to its lowercased host, brackets kept for
242/// v6 so it compares with what a browser sends.
243pub fn host_only(h: &str) -> String {
244    let h = h.trim().trim_end_matches('.').to_ascii_lowercase();
245    if h.starts_with('[') {
246        return match h.find(']') {
247            Some(i) => h[..=i].to_string(),
248            None => h,
249        };
250    }
251    // A bare v6 address (from a listen address) gets brackets.
252    if h.matches(':').count() > 1 {
253        return format!("[{h}]");
254    }
255    match h.rsplit_once(':') {
256        Some((host, port)) if port.bytes().all(|b| b.is_ascii_digit()) => {
257            host.trim_end_matches('.').to_string()
258        }
259        _ => h,
260    }
261}
262
263/// Parse tailscale's whois JSON (LocalAPI and CLI share the shape).
264pub fn parse_whois(v: &Value) -> std::result::Result<Whois, String> {
265    let login = v["UserProfile"]["LoginName"]
266        .as_str()
267        .filter(|s| !s.is_empty())
268        .ok_or("whois: no UserProfile.LoginName")?
269        .to_string();
270    let node = v["Node"]["Name"]
271        .as_str()
272        .or_else(|| v["Node"]["ComputedName"].as_str())
273        .unwrap_or("")
274        .trim_end_matches('.')
275        .to_string();
276    let tags = v["Node"]["Tags"]
277        .as_array()
278        .map(|a| {
279            a.iter()
280                .filter_map(Value::as_str)
281                .map(str::to_ascii_lowercase)
282                .collect()
283        })
284        .unwrap_or_default();
285    Ok(Whois { login, node, tags })
286}
287
288/// GET a LocalAPI path over tailscaled's unix socket (HTTP/1.0, so the
289/// answer ends at EOF), within a few seconds.
290fn localapi_get(socket: &str, path: &str) -> std::result::Result<Value, String> {
291    use std::os::unix::net::UnixStream;
292    let mut s = UnixStream::connect(socket).map_err(|e| format!("{socket}: {e}"))?;
293    let t = Some(Duration::from_secs(3));
294    let _ = s.set_read_timeout(t);
295    let _ = s.set_write_timeout(t);
296    write!(
297        s,
298        "GET {path} HTTP/1.0\r\nHost: local-tailscaled.sock\r\n\r\n"
299    )
300    .map_err(|e| format!("{socket}: {e}"))?;
301    let mut buf = Vec::new();
302    s.take(4 << 20)
303        .read_to_end(&mut buf)
304        .map_err(|e| format!("{socket}: {e}"))?;
305    let text = String::from_utf8_lossy(&buf);
306    let (head, body) = text
307        .split_once("\r\n\r\n")
308        .ok_or_else(|| format!("{socket}: a malformed answer"))?;
309    let status = head.split_whitespace().nth(1).unwrap_or("");
310    if status != "200" {
311        return Err(format!(
312            "{socket}: LocalAPI {path} answered {status}: {}",
313            body.trim().chars().take(200).collect::<String>()
314        ));
315    }
316    serde_json::from_str(body).map_err(|e| format!("{socket}: {e}"))
317}
318
319/// Run the tailscale CLI with a deadline; its stdout as JSON.
320fn cli_json(args: &[&str]) -> std::result::Result<Value, String> {
321    use std::process::{Command, Stdio};
322    let mut last = String::from("no tailscale CLI found");
323    for cli in CLIS {
324        let child = Command::new(cli)
325            .args(args)
326            .stdin(Stdio::null())
327            .stdout(Stdio::piped())
328            .stderr(Stdio::piped())
329            .spawn();
330        let mut child = match child {
331            Ok(c) => c,
332            Err(e) => {
333                last = format!("{cli}: {e}");
334                continue;
335            }
336        };
337        let deadline = Instant::now() + Duration::from_secs(5);
338        let status = loop {
339            match child.try_wait() {
340                Ok(Some(s)) => break s,
341                Ok(None) if Instant::now() < deadline => {
342                    std::thread::sleep(Duration::from_millis(20))
343                }
344                _ => {
345                    let _ = child.kill();
346                    let _ = child.wait();
347                    return Err(format!("{cli} {}: timed out", args.join(" ")));
348                }
349            }
350        };
351        let mut out = Vec::new();
352        if let Some(mut o) = child.stdout.take() {
353            let _ = o.read_to_end(&mut out);
354        }
355        if !status.success() {
356            let mut err = String::new();
357            if let Some(mut e) = child.stderr.take() {
358                let _ = e.read_to_string(&mut err);
359            }
360            return Err(format!("{cli} {}: {}", args.join(" "), err.trim()));
361        }
362        return serde_json::from_slice(&out).map_err(|e| format!("{cli}: {e}"));
363    }
364    Err(last)
365}
366
367fn localapi_or_cli(path: &str, cli_args: &[&str]) -> std::result::Result<Value, String> {
368    let mut errs = Vec::new();
369    for s in LOCALAPI_SOCKETS {
370        if !std::path::Path::new(s).exists() {
371            continue;
372        }
373        match localapi_get(s, path) {
374            Ok(v) => return Ok(v),
375            Err(e) => errs.push(e),
376        }
377    }
378    match cli_json(cli_args) {
379        Ok(v) => Ok(v),
380        Err(e) => {
381            errs.push(e);
382            Err(errs.join("; "))
383        }
384    }
385}
386
387/// The real thing: tailscaled's LocalAPI, else the CLI.
388pub fn system_fetcher() -> WhoisFetcher {
389    Arc::new(|peer: SocketAddr| {
390        let addr = peer.to_string();
391        let v = localapi_or_cli(
392            &format!("/localapi/v0/whois?addr={}", urlencode(&addr)),
393            &["whois", "--json", &addr],
394        )?;
395        parse_whois(&v)
396    })
397}
398
399/// This node's MagicDNS name and short host name, for the `Host` check.
400/// Empty when tailscaled does not answer.
401pub fn self_names() -> Vec<String> {
402    let Ok(v) = localapi_or_cli(
403        "/localapi/v0/status?peers=false",
404        &["status", "--json", "--peers=false"],
405    ) else {
406        return Vec::new();
407    };
408    let mut out = Vec::new();
409    if let Some(d) = v["Self"]["DNSName"].as_str() {
410        let d = d.trim_end_matches('.').to_ascii_lowercase();
411        if let Some((short, _)) = d.split_once('.') {
412            out.push(short.to_string());
413        }
414        if !d.is_empty() {
415            out.push(d);
416        }
417    }
418    if let Some(h) = v["Self"]["HostName"].as_str() {
419        out.push(h.to_ascii_lowercase());
420    }
421    out
422}
423
424fn urlencode(s: &str) -> String {
425    s.bytes()
426        .map(|b| match b {
427            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'.' | b'-' | b'_' => (b as char).to_string(),
428            _ => format!("%{b:02X}"),
429        })
430        .collect()
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use std::sync::atomic::AtomicUsize;
437
438    fn req(peer: &str, host: &str, headers: &[(&str, &str)]) -> Request {
439        let mut h = vec![("Host".to_string(), host.to_string())];
440        h.extend(headers.iter().map(|(k, v)| (k.to_string(), v.to_string())));
441        Request {
442            method: "GET".into(),
443            path: "/api/v1/auth/me".into(),
444            query: None,
445            headers: h,
446            body: Vec::new(),
447            peer: Peer::Tcp(peer.parse().unwrap()),
448        }
449    }
450
451    fn who(login: &str, tags: &[&str]) -> Whois {
452        Whois {
453            login: login.into(),
454            node: "laptop.tail1.ts.net".into(),
455            tags: tags.iter().map(|t| t.to_string()).collect(),
456        }
457    }
458
459    #[test]
460    fn tailnet_ranges() {
461        for ip in [
462            "100.64.0.1",
463            "100.127.255.254",
464            "100.86.22.100",
465            "fd7a:115c:a1e0::1",
466            "::ffff:100.100.1.1",
467        ] {
468            assert!(is_tailnet_ip(ip.parse().unwrap()), "{ip}");
469        }
470        for ip in [
471            "100.63.255.255",
472            "100.128.0.1",
473            "127.0.0.1",
474            "10.0.0.1",
475            "fd7a:115c:a1e1::1",
476            "::1",
477        ] {
478            assert!(!is_tailnet_ip(ip.parse().unwrap()), "{ip}");
479        }
480    }
481
482    #[test]
483    fn allow_lists() {
484        assert!(AllowList::parse("").is_err());
485        assert!(AllowList::parse(" , ").is_err());
486        assert!(AllowList::parse("bob").is_err());
487        assert!(AllowList::parse("tag:").is_err());
488        assert!(AllowList::parse("tag:a b").is_err());
489        let a = AllowList::parse("Someone@Example.com, tag:agents").unwrap();
490        assert_eq!(a.entries(), vec!["someone@example.com", "tag:agents"]);
491        assert!(a.admits(&who("someone@example.com", &[])));
492        assert!(!a.admits(&who("other@example.com", &[])));
493        assert!(a.admits(&who("tagged-devices", &["tag:agents"])));
494        assert!(!a.admits(&who("tagged-devices", &["tag:other"])));
495        // A tagged node is its tags, never a login.
496        let l = AllowList::parse("someone@example.com").unwrap();
497        assert!(!l.admits(&who("someone@example.com", &["tag:x"])));
498    }
499
500    #[test]
501    fn whois_json() {
502        let v = serde_json::json!({
503            "Node": {"Name": "titan.tail9.ts.net.", "Tags": ["tag:Agents"]},
504            "UserProfile": {"LoginName": "tagged-devices"}
505        });
506        let w = parse_whois(&v).unwrap();
507        assert_eq!(w.node, "titan.tail9.ts.net");
508        assert_eq!(w.tags, vec!["tag:agents"]);
509        assert!(parse_whois(&serde_json::json!({})).is_err());
510    }
511
512    #[test]
513    fn hosts() {
514        assert_eq!(host_only("Titan.tail9.ts.net.:18995"), "titan.tail9.ts.net");
515        assert_eq!(host_only("100.86.22.100:18995"), "100.86.22.100");
516        assert_eq!(host_only("[fd7a::1]:80"), "[fd7a::1]");
517        assert_eq!(host_only("fd7a::1"), "[fd7a::1]");
518    }
519
520    #[test]
521    fn gate_checks_peer_host_and_list_and_caches() {
522        let calls = Arc::new(AtomicUsize::new(0));
523        let c = calls.clone();
524        let fetch: WhoisFetcher = Arc::new(move |p: SocketAddr| {
525            c.fetch_add(1, Ordering::SeqCst);
526            match p.ip().to_string().as_str() {
527                "100.64.0.1" => Ok(who("me@example.com", &[])),
528                "100.64.0.2" => Ok(who("other@example.com", &[])),
529                _ => Err("tailscaled is not running".into()),
530            }
531        });
532        let t = Tailnet::new(
533            AllowList::parse("me@example.com").unwrap(),
534            fetch,
535            vec!["100.86.22.100:18995".into(), "titan.tail9.ts.net".into()],
536        );
537        let ok = req("100.64.0.1:5555", "100.86.22.100:18995", &[]);
538        assert_eq!(t.superadmin(&ok).unwrap().login, "me@example.com");
539        // Cached: a second request does not ask again.
540        assert!(
541            t.superadmin(&req("100.64.0.1:6666", "titan.tail9.ts.net", &[]))
542                .is_some()
543        );
544        assert_eq!(calls.load(Ordering::SeqCst), 1);
545        // Not on the list.
546        assert!(
547            t.superadmin(&req("100.64.0.2:1", "100.86.22.100", &[]))
548                .is_none()
549        );
550        // DNS rebinding: another site's name.
551        assert!(
552            t.superadmin(&req("100.64.0.1:1", "evil.example:18995", &[]))
553                .is_none()
554        );
555        assert!(t.superadmin(&req("100.64.0.1:1", "", &[])).is_none());
556        // Not a tailnet peer, whatever the headers claim.
557        let spoof = req(
558            "127.0.0.1:1",
559            "100.86.22.100",
560            &[
561                ("Tailscale-User-Login", "me@example.com"),
562                ("X-Forwarded-For", "100.64.0.1"),
563            ],
564        );
565        assert!(t.superadmin(&spoof).is_none());
566        let spoof = req(
567            "10.1.2.3:1",
568            "100.86.22.100",
569            &[
570                ("Tailscale-User-Login", "me@example.com"),
571                ("X-Forwarded-For", "100.64.0.1"),
572            ],
573        );
574        assert!(t.superadmin(&spoof).is_none());
575        // tailscaled unreachable: nobody.
576        assert!(
577            t.superadmin(&req("100.64.0.9:1", "100.86.22.100", &[]))
578                .is_none()
579        );
580        // The unix socket is not a tailnet peer.
581        let mut u = ok.clone();
582        u.peer = Peer::Unix { uid: None };
583        assert!(t.superadmin(&u).is_none());
584    }
585}