Skip to main content

tailscale/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use std::{
4    net::{IpAddr, SocketAddr},
5    path::PathBuf,
6    sync::{Arc, Mutex, Once},
7    time::Duration,
8};
9
10use pyo3::{exceptions::PyValueError, prelude::*};
11use pyo3_async_runtimes::tokio::future_into_py;
12use tracing_subscriber::filter::LevelFilter;
13
14use crate::ip_or_str::IpRepr;
15
16extern crate tailscale as ts;
17
18type PyFut<'p> = PyResult<Bound<'p, PyAny>>;
19
20mod ip_or_str;
21mod key_state;
22mod node_info;
23mod serve;
24mod status;
25mod tcp;
26mod udp;
27
28use key_state::Keystate;
29use node_info::NodeInfo;
30use serve::{ServeConfigArg, ServiceModeArg};
31use status::{Status, WhoIs};
32
33/// Tailscale API.
34#[pymodule]
35pub mod _internal {
36    use super::*;
37    #[pymodule_export]
38    use crate::{
39        Device, Keystate, LocalClient, LoopbackHandle, Server,
40        tcp::{TcpListener, TcpStream},
41        udp::UdpSocket,
42    };
43
44    /// Connect to tailscale using the specified parameters.
45    ///
46    /// The forwarding/routing keyword arguments mirror `tailscale.Config`:
47    ///
48    /// - `accept_routes` (bool): accept and route to subnet routes peers advertise.
49    /// - `exit_node` (str): route internet-bound traffic through this peer (IP or MagicDNS name).
50    /// - `advertise_routes` (list[str]): CIDRs to advertise as a subnet router.
51    /// - `advertise_exit_node` (bool): advertise this node as an exit node.
52    /// - `forward_tcp_ports` / `forward_udp_ports` (list[int]): ports the inbound forwarder splices.
53    /// - `forward_all_ports` (bool): forward every TCP/UDP port on advertised routes.
54    /// - `forward_exit_egress` (bool): actually egress exit-node flows via this host's real IP.
55    #[pyfunction]
56    #[pyo3(signature = (
57        key_file_path=None, /, auth_key=None, *, control_server_url=None, hostname=None, tags=None, keys=None,
58        accept_routes=None, exit_node=None, advertise_routes=None, advertise_exit_node=None,
59        forward_tcp_ports=None, forward_udp_ports=None, forward_all_ports=None, forward_exit_egress=None
60    ))]
61    #[allow(clippy::too_many_arguments)]
62    pub fn connect(
63        py: Python<'_>,
64        key_file_path: Option<String>,
65        auth_key: Option<String>,
66        control_server_url: Option<String>,
67        hostname: Option<String>,
68        tags: Option<Vec<String>>,
69        keys: Option<Keystate>,
70        accept_routes: Option<bool>,
71        exit_node: Option<String>,
72        advertise_routes: Option<Vec<String>>,
73        advertise_exit_node: Option<bool>,
74        forward_tcp_ports: Option<Vec<u16>>,
75        forward_udp_ports: Option<Vec<u16>>,
76        forward_all_ports: Option<bool>,
77        forward_exit_egress: Option<bool>,
78    ) -> PyFut<'_> {
79        static TRACING_ONCE: Once = Once::new();
80        TRACING_ONCE.call_once(|| {
81            tracing_subscriber::fmt()
82                .with_env_filter(
83                    tracing_subscriber::EnvFilter::builder()
84                        .with_default_directive(LevelFilter::INFO.into())
85                        .from_env_lossy(),
86                )
87                .init();
88        });
89
90        future_into_py(py, async move {
91            let mut config = if let Some(key_file_path) = key_file_path {
92                ts::Config::default_with_key_file(key_file_path)
93                    .await
94                    .map_err(py_value_err)?
95            } else {
96                ts::Config::default()
97            };
98
99            config.client_name = Some("ts_python".to_owned());
100            if let Some(control_server_url) = control_server_url {
101                config.control_server_url = control_server_url.parse().map_err(py_value_err)?;
102            }
103
104            if let Some(hostname) = hostname {
105                config.requested_hostname = Some(hostname);
106            }
107
108            if let Some(tags) = tags {
109                config.requested_tags = tags;
110            }
111
112            if let Some(keys) = &keys {
113                config.key_state = keys.try_into().map_err(|_| py_value_err("invalid keys"))?;
114            }
115
116            if let Some(accept_routes) = accept_routes {
117                config.accept_routes = accept_routes;
118            }
119
120            if let Some(exit_node) = exit_node {
121                // `ExitNodeSelector::from_str` is infallible (non-IP strings become MagicDNS
122                // names), matching the Go CLI's `--exit-node`.
123                config.exit_node = Some(exit_node.parse().map_err(py_value_err)?);
124            }
125
126            if let Some(advertise_routes) = advertise_routes {
127                config.advertise_routes = advertise_routes
128                    .iter()
129                    .map(|cidr| cidr.parse())
130                    .collect::<Result<Vec<_>, _>>()
131                    .map_err(py_value_err)?;
132            }
133
134            if let Some(advertise_exit_node) = advertise_exit_node {
135                config.advertise_exit_node = advertise_exit_node;
136            }
137
138            if let Some(forward_tcp_ports) = forward_tcp_ports {
139                config.forward_tcp_ports = forward_tcp_ports;
140            }
141
142            if let Some(forward_udp_ports) = forward_udp_ports {
143                config.forward_udp_ports = forward_udp_ports;
144            }
145
146            if let Some(forward_all_ports) = forward_all_ports {
147                config.forward_all_ports = forward_all_ports;
148            }
149
150            if let Some(forward_exit_egress) = forward_exit_egress {
151                config.forward_exit_egress = forward_exit_egress;
152            }
153
154            let dev = ts::Device::new(&config, auth_key)
155                .await
156                .map_err(py_value_err)?;
157
158            Ok(Device { dev: Arc::new(dev) })
159        })
160    }
161}
162
163/// Tailscale client.
164#[pyclass(frozen, module = "tailscale")]
165pub struct Device {
166    dev: Arc<ts::Device>,
167}
168
169#[pymethods]
170impl Device {
171    /// Bind a new UDP socket on the given `addr`.
172    ///
173    /// `addr` must be given as (host, port). Presently, `host` must be an IP.
174    pub fn udp_bind<'p>(&self, py: Python<'p>, addr: (IpRepr, u16)) -> PyFut<'p> {
175        let dev = self.dev.clone();
176        let ip: Result<IpAddr, _> = addr.0.try_into();
177
178        future_into_py(py, async move {
179            let ip = ip?;
180
181            let sock = dev
182                .udp_bind((ip, addr.1).into())
183                .await
184                .map_err(py_value_err)?;
185
186            Ok(udp::UdpSocket {
187                sock: Arc::new(sock),
188            })
189        })
190    }
191
192    /// Bind a new TCP listen socket on the given `addr` and `port`.
193    ///
194    /// `addr` must be given as (host, port). Presently, `host` must be an IP.
195    pub fn tcp_listen<'p>(&self, py: Python<'p>, addr: (IpRepr, u16)) -> PyFut<'p> {
196        let dev = self.dev.clone();
197        let ip: Result<IpAddr, _> = addr.0.try_into();
198
199        future_into_py(py, async move {
200            let ip = ip?;
201
202            let listener = dev
203                .tcp_listen((ip, addr.1).into())
204                .await
205                .map_err(py_value_err)?;
206
207            Ok(tcp::TcpListener {
208                listener: Arc::new(listener),
209            })
210        })
211    }
212
213    /// Create a new TCP connection to the given `addr`.
214    ///
215    /// `addr` must be given as (host, port). Presently, `host` must be an IP.
216    pub fn tcp_connect<'p>(&self, py: Python<'p>, addr: (IpRepr, u16)) -> PyFut<'p> {
217        let dev = self.dev.clone();
218        let ip: Result<IpAddr, _> = addr.0.try_into();
219
220        future_into_py(py, async move {
221            let ip = ip?;
222
223            let sock = dev
224                .tcp_connect((ip, addr.1).into())
225                .await
226                .map_err(|e| PyValueError::new_err(e.to_string()))?;
227
228            Ok(tcp::TcpStream {
229                sock: Arc::new(sock),
230            })
231        })
232    }
233
234    /// Get the device's IPv4 tailnet address.
235    pub fn ipv4_addr<'p>(&self, py: Python<'p>) -> PyFut<'p> {
236        let dev = self.dev.clone();
237
238        future_into_py(py, async move {
239            let ip = dev.ipv4_addr().await.map_err(py_value_err)?;
240            Ok(ip)
241        })
242    }
243
244    /// Get the device's IPv6 tailnet address.
245    pub fn ipv6_addr<'p>(&self, py: Python<'p>) -> PyFut<'p> {
246        let dev = self.dev.clone();
247
248        future_into_py(py, async move {
249            let ip = dev.ipv6_addr().await.map_err(py_value_err)?;
250            Ok(ip)
251        })
252    }
253
254    /// Look up info about a peer by its name.
255    ///
256    /// `name` may be an unqualified hostname or a fully-qualified name.
257    pub fn peer_by_name<'p>(&self, py: Python<'p>, name: String) -> PyFut<'p> {
258        let dev = self.dev.clone();
259
260        future_into_py(py, async move {
261            let node = dev.peer_by_name(&name).await.map_err(py_value_err)?;
262
263            Ok(node.map(|node| NodeInfo::from(&node)))
264        })
265    }
266
267    /// Get this device's node info.
268    pub fn self_node<'p>(&self, py: Python<'p>) -> PyFut<'p> {
269        let dev = self.dev.clone();
270
271        future_into_py(py, async move {
272            let node = dev.self_node().await.map_err(py_value_err)?;
273            Ok(NodeInfo::from(&node))
274        })
275    }
276
277    /// Look up a peer by its tailnet IP address.
278    pub fn peer_by_tailnet_ip<'p>(&self, py: Python<'p>, ip: IpRepr) -> PyFut<'p> {
279        let dev = self.dev.clone();
280
281        future_into_py(py, async move {
282            let ip = ip.try_into().map_err(py_value_err)?;
283            let node = dev.peer_by_tailnet_ip(ip).await.map_err(py_value_err)?;
284
285            Ok(node.map(|node| NodeInfo::from(&node)))
286        })
287    }
288
289    /// Look up peer(s) with the most specific route match for the given address.
290    ///
291    /// If more than one peer has the same route covering the same address, more than one
292    /// result may be returned.
293    pub fn peers_with_route<'p>(&self, py: Python<'p>, ip: IpRepr) -> PyFut<'p> {
294        let dev = self.dev.clone();
295
296        future_into_py(py, async move {
297            let ip = ip.try_into().map_err(py_value_err)?;
298            let nodes = dev.peers_with_route(ip).await.map_err(py_value_err)?;
299
300            Ok(nodes
301                .into_iter()
302                .map(|node| NodeInfo::from(&node))
303                .collect::<Vec<_>>())
304        })
305    }
306
307    // --- Lane 1: Status / WhoIs / netmap snapshot ---
308
309    /// Snapshot of this device and its tailnet peers (like `tailscale status`).
310    ///
311    /// Returns a dict `{"self_node": <node>|None, "peers": [<node>, ...]}` where each node carries
312    /// `stable_id`, `display_name`, `ipv4`, `ipv6`, `online`, `allowed_routes`, and `is_exit_node`.
313    pub fn status<'p>(&self, py: Python<'p>) -> PyFut<'p> {
314        let dev = self.dev.clone();
315
316        future_into_py(py, async move {
317            let status = dev.status().await.map_err(py_value_err)?;
318            Ok(Status::from(&status))
319        })
320    }
321
322    /// Map a tailnet source `addr` to the node that owns its IP (like `tsnet`'s `WhoIs`).
323    ///
324    /// `addr` may be an `ip` or `host:port` string; only the IP is used. Returns `None` if no
325    /// tailnet node owns that address.
326    pub fn whois<'p>(&self, py: Python<'p>, addr: String) -> PyFut<'p> {
327        let dev = self.dev.clone();
328
329        future_into_py(py, async move {
330            let socket_addr = parse_whois_addr(&addr)?;
331            let whois = dev.whois(socket_addr).await.map_err(py_value_err)?;
332            Ok(whois.as_ref().map(WhoIs::from))
333        })
334    }
335
336    /// One-shot snapshot of the current netmap peers (the current value of the netmap watch).
337    ///
338    /// Returns the list of peer nodes as of now, in the same shape as `status()["peers"]`. Mirrors
339    /// reading the current value off `tsnet`'s `WatchIPNBus` subscription.
340    pub fn netmap<'p>(&self, py: Python<'p>) -> PyFut<'p> {
341        let dev = self.dev.clone();
342
343        future_into_py(py, async move {
344            let rx = dev.watch_netmap().await.map_err(py_value_err)?;
345            let nodes = rx.borrow();
346            Ok(nodes
347                .iter()
348                .map(status::StatusNode::from)
349                .collect::<Vec<_>>())
350        })
351    }
352
353    // --- Lane 2: MagicDNS ---
354
355    /// Resolve a tailnet peer (or this node) by MagicDNS `name` to its tailnet IPv4 address.
356    ///
357    /// Returns the IPv4 address as a string, or `None` if no tailnet node has that name. This is an
358    /// in-process netmap lookup — it does not query any DNS server. IPv6 is not resolved (this fork
359    /// is IPv4-only on the tailnet).
360    pub fn resolve<'p>(&self, py: Python<'p>, name: String) -> PyFut<'p> {
361        let dev = self.dev.clone();
362
363        future_into_py(py, async move {
364            let ip = dev.resolve(&name).await.map_err(py_value_err)?;
365            Ok(ip.map(|ip| ip.to_string()))
366        })
367    }
368
369    /// Connect to a tailnet peer by MagicDNS `name` and `port` over TCP.
370    ///
371    /// Resolves `name` via [`Device::resolve`] (an in-process netmap lookup, no DNS server), then
372    /// dials the resulting tailnet IPv4 address. Raises if the name does not resolve to a tailnet
373    /// node. Returns the same `TcpStream` as `tcp_connect`.
374    pub fn connect_by_name<'p>(&self, py: Python<'p>, name: String, port: u16) -> PyFut<'p> {
375        let dev = self.dev.clone();
376
377        future_into_py(py, async move {
378            let sock = dev
379                .connect_by_name(&name, port)
380                .await
381                .map_err(py_value_err)?;
382
383            Ok(tcp::TcpStream {
384                sock: Arc::new(sock),
385            })
386        })
387    }
388
389    // --- Lane 4: Ping ---
390
391    /// Ping a tailnet peer over the overlay with an ICMPv4 echo (like `tailscale ping`).
392    ///
393    /// `addr` is the peer's tailnet IP; `timeout_ms` is the timeout in milliseconds. Returns the
394    /// round-trip time in milliseconds (a float), or raises on timeout / unsupported IPv6
395    /// destination. The echo is sent from this device's own tailnet IPv4 over the overlay netstack
396    /// — never a host socket.
397    pub fn ping<'p>(&self, py: Python<'p>, addr: IpRepr, timeout_ms: u64) -> PyFut<'p> {
398        let dev = self.dev.clone();
399        let ip: Result<IpAddr, _> = addr.try_into();
400
401        future_into_py(py, async move {
402            let ip = ip?;
403            let rtt = dev
404                .ping(ip, Duration::from_millis(timeout_ms))
405                .await
406                .map_err(py_value_err)?;
407            Ok(rtt.as_secs_f64() * 1000.0)
408        })
409    }
410
411    // --- Lane 5: TLS / Serve ---
412
413    /// Obtain a TLS certificate for a node's MagicDNS `name` (like `tsnet`'s `GetCertificate`).
414    ///
415    /// **Fail-closed.** This fork has no client-side ACME engine and no `set-dns` RPC, so this
416    /// ALWAYS raises a Python exception carrying the underlying `CertError` (issuance is
417    /// unimplemented). It NEVER self-signs and NEVER returns a placeholder certificate. When ACME
418    /// issuance lands upstream, this starts succeeding with no API change.
419    pub fn get_certificate<'p>(&self, py: Python<'p>, name: String) -> PyFut<'p> {
420        let dev = self.dev.clone();
421
422        future_into_py(py, async move {
423            // Always Err(CertError::Unimplemented) today; propagate it faithfully, never swallow.
424            dev.get_certificate(&name).await.map_err(py_value_err)?;
425            Ok(())
426        })
427    }
428
429    /// Build a TLS listener config for `serve_config` on the overlay (like `tsnet`'s `ListenTLS`).
430    ///
431    /// `serve_config` is a mapping `{"name": str, "port": int, "target": <target>}` where `target`
432    /// is `"accept"` or `{"proxy": "host:port"}`.
433    ///
434    /// **Fail-closed.** Delegates to [`Device::get_certificate`]; because no real certificate can be
435    /// issued in this fork, this ALWAYS raises the same `CertError` rather than ever serving a
436    /// self-signed cert or downgrading to plaintext. The serve config is validated first, so an
437    /// off-tailnet name / zero port / empty proxy target raises a distinct error.
438    pub fn listen_tls<'p>(&self, py: Python<'p>, serve_config: ServeConfigArg) -> PyFut<'p> {
439        let dev = self.dev.clone();
440        let cfg = serve_config.0;
441
442        future_into_py(py, async move {
443            // Always Err(CertError) today; propagate it faithfully, never swallow.
444            dev.listen_tls(&cfg).await.map_err(py_value_err)?;
445            Ok(())
446        })
447    }
448
449    // --- Lane: identity / metrics / key-expiry ---
450
451    /// Fetch an OIDC **ID token** from control scoped to `audience` (like `tailscale id-token`).
452    ///
453    /// Returns the signed JWT as a string. The `sub` claim is this node's MagicDNS name and the
454    /// `aud` claim is `audience`, suitable for workload-identity federation (AWS/GCP). Raises if
455    /// control does not support id-token issuance.
456    pub fn fetch_id_token<'p>(&self, py: Python<'p>, audience: String) -> PyFut<'p> {
457        let dev = self.dev.clone();
458
459        future_into_py(py, async move {
460            let token = dev.fetch_id_token(&audience).await.map_err(py_value_err)?;
461            Ok(token)
462        })
463    }
464
465    /// Snapshot this process's client metrics in Prometheus text exposition format.
466    ///
467    /// The metric registry is process-global, so the returned text covers every `Device` in the
468    /// process. Synchronous — no overlay round-trip is involved.
469    pub fn metrics(&self) -> String {
470        self.dev.metrics()
471    }
472
473    /// This node's key-expiry instant as Unix seconds, or `None` if the key never expires.
474    ///
475    /// This fork is reactive about key expiry (it reports rather than rotating in the background);
476    /// schedule re-authentication around this time.
477    pub fn self_key_expiry_unix<'p>(&self, py: Python<'p>) -> PyFut<'p> {
478        let dev = self.dev.clone();
479
480        future_into_py(py, async move {
481            let expiry = dev.self_key_expiry_unix().await.map_err(py_value_err)?;
482            Ok(expiry)
483        })
484    }
485
486    /// Whether this node's key has expired as of now. A key with no expiry is never expired.
487    pub fn self_key_expired<'p>(&self, py: Python<'p>) -> PyFut<'p> {
488        let dev = self.dev.clone();
489
490        future_into_py(py, async move {
491            let expired = dev.self_key_expired().await.map_err(py_value_err)?;
492            Ok(expired)
493        })
494    }
495
496    // --- Lane: Taildrop ---
497
498    /// List the Taildrop files this device has fully received and not yet consumed.
499    ///
500    /// Returns a list of dicts `{"name": str, "size": int}`, sorted by name. Returns an empty list
501    /// when Taildrop is disabled (fail-closed, never an error). Synchronous (a local filesystem
502    /// listing).
503    pub fn taildrop_waiting_files(&self) -> PyResult<Vec<(String, u64)>> {
504        let files = self.dev.taildrop_waiting_files().map_err(py_value_err)?;
505        Ok(files.into_iter().map(|f| (f.name, f.size)).collect())
506    }
507
508    /// Delete a received Taildrop file by `name` (path-traversal-safe; validated in the store).
509    ///
510    /// Raises when Taildrop is disabled, the name is invalid, or the file does not exist.
511    /// Synchronous (a local filesystem delete).
512    pub fn taildrop_delete_file(&self, name: String) -> PyResult<()> {
513        self.dev.taildrop_delete_file(&name).map_err(py_value_err)
514    }
515
516    /// Save a received Taildrop file by `name` to `dst_path` on the local filesystem.
517    ///
518    /// Opens the received file via the store (path-traversal-safe) and copies its bytes to
519    /// `dst_path`, returning the number of bytes written. Pyo3 cannot hand back a raw file handle,
520    /// so this save-to-path shape is the Pythonic equivalent of Go's `OpenFile`. Synchronous (local
521    /// filesystem I/O). Raises when Taildrop is disabled, the name is invalid, the source file does
522    /// not exist, or `dst_path` cannot be written.
523    pub fn taildrop_save_file(&self, name: String, dst_path: String) -> PyResult<u64> {
524        let (mut src, _size) = self.dev.taildrop_open_file(&name).map_err(py_value_err)?;
525        let mut dst = std::fs::File::create(&dst_path).map_err(py_value_err)?;
526        let copied = std::io::copy(&mut src, &mut dst).map_err(py_value_err)?;
527        Ok(copied)
528    }
529
530    /// Send a local file at `src_path` to tailnet peer `peer_name` via Taildrop (Go `PushFile`).
531    ///
532    /// Resolves `peer_name` via [`peer_by_name`][Self::peer_by_name], opens `src_path` as a tokio
533    /// file, and streams it to the peer's peerAPI over the encrypted overlay (never a host socket).
534    /// `file_name` is the base name the receiver sees. Raises when the peer is unknown, the peer
535    /// advertises no IPv4 peerAPI, or the transfer fails.
536    pub fn send_file<'p>(
537        &self,
538        py: Python<'p>,
539        peer_name: String,
540        file_name: String,
541        src_path: String,
542    ) -> PyFut<'p> {
543        let dev = self.dev.clone();
544
545        future_into_py(py, async move {
546            let peer = dev
547                .peer_by_name(&peer_name)
548                .await
549                .map_err(py_value_err)?
550                .ok_or_else(|| py_value_err(format!("no tailnet peer named {peer_name:?}")))?;
551
552            let file = tokio::fs::File::open(&src_path)
553                .await
554                .map_err(py_value_err)?;
555            let len = file.metadata().await.map_err(py_value_err)?.len();
556
557            dev.send_file(&peer, &file_name, len, file)
558                .await
559                .map_err(py_value_err)?;
560            Ok(())
561        })
562    }
563
564    // --- Lane: packet capture ---
565
566    /// Begin a debug packet capture, writing a pcap of every dataplane packet to `dst_path`.
567    ///
568    /// Opens `dst_path` and streams a classic pcap (Tailscale `LINKTYPE_USER0`) of every plaintext
569    /// IP packet — outbound (pre-encrypt) and inbound (post-decrypt) — until
570    /// [`stop_capture`][Self::stop_capture] is called. Records are buffered and flushed on stop.
571    /// Opens in Wireshark with Tailscale's `ts-dissector.lua`.
572    pub fn capture_pcap<'p>(&self, py: Python<'p>, dst_path: String) -> PyFut<'p> {
573        let dev = self.dev.clone();
574
575        future_into_py(py, async move {
576            let file = std::fs::File::create(&dst_path).map_err(py_value_err)?;
577            dev.capture_pcap(std::io::BufWriter::new(file))
578                .await
579                .map_err(py_value_err)?;
580            Ok(())
581        })
582    }
583
584    /// Stop a packet capture started by [`capture_pcap`][Self::capture_pcap].
585    ///
586    /// Clears the dataplane capture hook; the writer is dropped and its buffered bytes flushed.
587    /// Idempotent — stopping when no capture is installed is a no-op.
588    pub fn stop_capture<'p>(&self, py: Python<'p>) -> PyFut<'p> {
589        let dev = self.dev.clone();
590
591        future_into_py(py, async move {
592            dev.stop_capture().await.map_err(py_value_err)?;
593            Ok(())
594        })
595    }
596
597    // --- Lane: loopback SOCKS5 proxy ---
598
599    /// Start a host-loopback SOCKS5 proxy that dials into the tailnet (Go `tsnet.Loopback`).
600    ///
601    /// Returns a tuple `(addr, proxy_cred, handle)` where `addr` is the bound `127.0.0.1:port`
602    /// string, `proxy_cred` is the SOCKS5 password (username is `tsnet`), and `handle` is a
603    /// [`LoopbackHandle`] whose `.stop()` (or garbage collection) stops the proxy. Hold the handle
604    /// for exactly as long as you want the proxy alive. Raises in TUN transport mode.
605    pub fn loopback<'p>(&self, py: Python<'p>) -> PyFut<'p> {
606        let dev = self.dev.clone();
607
608        future_into_py(py, async move {
609            let (addr, cred, handle) = dev.loopback().await.map_err(py_value_err)?;
610            Ok((
611                addr.to_string(),
612                cred,
613                LoopbackHandle {
614                    inner: Mutex::new(Some(handle)),
615                },
616            ))
617        })
618    }
619
620    // --- Lane: Tailnet Lock (TKA) ---
621
622    /// Fetch the current Tailnet Lock (TKA) status pushed by control, if any.
623    ///
624    /// Returns `None` when control has sent no `TKAInfo`, else a dict `{"head": str,
625    /// "disabled": bool}` where `head` is the base32 (no-pad) `AUMHash` of the latest applied
626    /// Authority Update Message.
627    pub fn tka_status<'p>(&self, py: Python<'p>) -> PyFut<'p> {
628        let dev = self.dev.clone();
629
630        future_into_py(py, async move {
631            let status = dev.tka_status().await.map_err(py_value_err)?;
632            Ok(status.map(|s| (s.head, s.disabled)))
633        })
634    }
635
636    // --- Lane: Serve / Funnel / Services ---
637
638    /// Build a Funnel TLS listener config for `serve_config` (like `tsnet`'s `ListenFunnel`).
639    ///
640    /// `serve_config` has the same shape as [`listen_tls`][Self::listen_tls]. `funnel_only` (default
641    /// `False`) rejects tailnet-internal connections, serving only public Funnel ingress.
642    ///
643    /// **Fail-closed.** Enforces the node-attribute / port gates first, then obtains the node's
644    /// `*.ts.net` cert via the ACME-aware path (raising `FunnelError` on cert failure — never
645    /// plaintext or a self-signed cert). On success the funnel ingress listener is registered; the
646    /// returned `FunnelAcceptedReceiver` is dropped here (Python holds no Rust receiver), so this
647    /// surfaces only the gate/cert outcome. The public ingress relay that feeds it is Tailscale
648    /// infrastructure, present only against real Tailscale SaaS.
649    #[pyo3(signature = (serve_config, funnel_only=false))]
650    pub fn listen_funnel<'p>(
651        &self,
652        py: Python<'p>,
653        serve_config: ServeConfigArg,
654        funnel_only: bool,
655    ) -> PyFut<'p> {
656        let dev = self.dev.clone();
657        let cfg = serve_config.0;
658        let opts = ts_control::FunnelOptions { funnel_only };
659
660        future_into_py(py, async move {
661            // Drop the returned FunnelAcceptedReceiver (Python holds no Rust receiver); propagate any
662            // gate/cert FunnelError faithfully.
663            dev.listen_funnel(&cfg, opts).await.map_err(py_value_err)?;
664            Ok(())
665        })
666    }
667
668    /// Host a Tailscale **VIP service** (`svc:<label>`) by `service_name` (like `ListenService`).
669    ///
670    /// `mode` is a dict `{"mode": "tcp"|"http", "port": int}`. Returns a [`TcpListener`] bound on the
671    /// service's control-assigned VIP over the overlay netstack.
672    ///
673    /// **Fail-closed.** The `service_name` must be a valid `svc:<dns-label>`, this node must be
674    /// tagged, and control must have assigned the service a VIP on this node; any unmet precondition
675    /// raises before binding.
676    pub fn listen_service<'p>(
677        &self,
678        py: Python<'p>,
679        service_name: String,
680        mode: ServiceModeArg,
681    ) -> PyFut<'p> {
682        let dev = self.dev.clone();
683        let mode = mode.0;
684
685        future_into_py(py, async move {
686            let listener = dev
687                .listen_service(&service_name, mode)
688                .await
689                .map_err(py_value_err)?;
690
691            Ok(tcp::TcpListener {
692                listener: Arc::new(listener),
693            })
694        })
695    }
696}
697
698/// Handle that keeps a loopback SOCKS5 proxy alive (returned by [`Device::loopback`]).
699///
700/// Dropping this handle — or calling [`stop`][Self::stop] / letting Python garbage-collect it —
701/// stops the accept loop and frees the bound `127.0.0.1` port. Hold it for exactly as long as you
702/// want the proxy.
703#[pyclass(module = "tailscale")]
704pub struct LoopbackHandle {
705    inner: Mutex<Option<ts::LoopbackHandle>>,
706}
707
708#[pymethods]
709impl LoopbackHandle {
710    /// Stop the loopback SOCKS5 proxy now. Idempotent — a second call is a no-op.
711    pub fn stop(&self) {
712        // Take + drop the inner handle; its Drop aborts the accept loop.
713        drop(self.inner.lock().ok().and_then(|mut g| g.take()));
714    }
715
716    /// Stop the proxy when the Python object is garbage-collected. Equivalent to [`stop`][Self::stop].
717    pub fn __del__(&self) {
718        self.stop();
719    }
720}
721
722/// A `tsnet.Server`-shaped embedded Tailscale node, exposing the two surfaces the plain
723/// [`connect`]-built [`Device`] does not: the **dual-credential loopback** and the **LocalClient**.
724///
725/// Construct it with the Go-`tsnet.Server` fields (all optional keyword arguments); the wrapped node
726/// is built lazily on the first [`loopback`][Self::loopback] / [`local_client`][Self::local_client]
727/// call. Fork config supersets beyond Go `tsnet` parity (exit nodes, forwarding) remain on
728/// [`connect`]/[`Device`]; this is the Go-parity `Server` surface. Cleanup happens on garbage
729/// collection (the loopback listeners and node shut down when the last reference drops).
730#[pyclass(frozen, module = "tailscale")]
731pub struct Server {
732    inner: Arc<ts::tsnet::Server>,
733}
734
735#[pymethods]
736impl Server {
737    /// Create a new server (Go `&tsnet.Server{Hostname, AuthKey, ControlURL, Ephemeral, Dir}`).
738    ///
739    /// All arguments are optional keyword arguments: `hostname`, `auth_key`, `control_url`,
740    /// `ephemeral` (default `False`, matching Go), `dir` (a state directory persisting this node's
741    /// identity keys across runs; `None` gives a fresh ephemeral in-memory identity), and `tags`
742    /// (ACL tags to advertise). No network I/O happens here — the node is built on first use.
743    #[new]
744    #[pyo3(signature = (
745        *, hostname=None, auth_key=None, control_url=None, ephemeral=false, dir=None, tags=None
746    ))]
747    pub fn new(
748        hostname: Option<String>,
749        auth_key: Option<String>,
750        control_url: Option<String>,
751        ephemeral: bool,
752        dir: Option<String>,
753        tags: Option<Vec<String>>,
754    ) -> Self {
755        let mut s = ts::tsnet::Server::new();
756        s.hostname = hostname;
757        s.auth_key = auth_key;
758        s.control_url = control_url;
759        s.ephemeral = ephemeral;
760        s.dir = dir.map(PathBuf::from);
761        if let Some(tags) = tags {
762            s.advertise_tags = tags;
763        }
764        Server { inner: Arc::new(s) }
765    }
766
767    /// Start (once) the loopback surface and return `(socks_addr, proxy_cred, localapi_addr,
768    /// localapi_cred)` (Go `Loopback() (addr, proxyCred, localAPICred, err)`).
769    ///
770    /// `socks_addr` is the SOCKS5 proxy's bound `127.0.0.1:port` (password `proxy_cred`, username
771    /// `tsnet`); `localapi_addr` is the in-process LocalAPI HTTP server's bound `127.0.0.1:port`
772    /// (HTTP Basic-auth password `localapi_cred`). Both listeners live for the server's lifetime, so
773    /// repeated calls return the same addresses and credentials. Raises in TUN transport mode.
774    pub fn loopback<'p>(&self, py: Python<'p>) -> PyFut<'p> {
775        let srv = self.inner.clone();
776
777        future_into_py(py, async move {
778            let lb = srv.loopback().await.map_err(py_value_err)?;
779            Ok((
780                lb.address.to_string(),
781                lb.proxy_cred,
782                lb.local_api_address.to_string(),
783                lb.local_api_cred,
784            ))
785        })
786    }
787
788    /// A [`LocalClient`] for this node's in-process LocalAPI HTTP server (Go
789    /// `tsnet.Server.LocalClient()`), starting the loopback surface if needed.
790    pub fn local_client<'p>(&self, py: Python<'p>) -> PyFut<'p> {
791        let srv = self.inner.clone();
792
793        future_into_py(py, async move {
794            let lc = srv.local_client().await.map_err(py_value_err)?;
795            Ok(LocalClient { inner: lc })
796        })
797    }
798}
799
800/// A minimal client for this node's in-process LocalAPI HTTP server (Go
801/// `tsnet.Server.LocalClient()` → `*local.Client`), obtained from [`Server::local_client`].
802///
803/// It authenticates every request with the loopback's LocalAPI credential and speaks plain HTTP to
804/// `127.0.0.1`. LocalAPI responses are JSON/text, decoded here as UTF-8 `str`.
805#[pyclass(frozen, module = "tailscale")]
806pub struct LocalClient {
807    inner: ts::tsnet::LocalClient,
808}
809
810#[pymethods]
811impl LocalClient {
812    /// `GET /localapi/v0/status` — the node + peer status as a JSON `str` (Go
813    /// `LocalClient().Status`, over the loopback). Raises if the server answers non-`200`.
814    pub fn status<'p>(&self, py: Python<'p>) -> PyFut<'p> {
815        let lc = self.inner.clone();
816
817        future_into_py(py, async move {
818            let body = lc.status().await.map_err(py_value_err)?;
819            Ok(String::from_utf8_lossy(&body).into_owned())
820        })
821    }
822
823    /// Perform an authenticated `GET` against an arbitrary LocalAPI `path` (e.g.
824    /// `"/localapi/v0/status"`), returning `(http_status_code, body)` where `body` is the response
825    /// decoded as a UTF-8 `str`.
826    pub fn get<'p>(&self, py: Python<'p>, path: String) -> PyFut<'p> {
827        let lc = self.inner.clone();
828
829        future_into_py(py, async move {
830            let (code, body) = lc.get(&path).await.map_err(py_value_err)?;
831            Ok((code, String::from_utf8_lossy(&body).into_owned()))
832        })
833    }
834
835    /// The `127.0.0.1:port` address of the LocalAPI HTTP server this client talks to.
836    #[getter]
837    pub fn address(&self) -> String {
838        self.inner.address().to_string()
839    }
840
841    /// The LocalAPI credential (HTTP Basic-auth password) this client sends.
842    #[getter]
843    pub fn credential(&self) -> String {
844        self.inner.credential().to_owned()
845    }
846}
847
848/// Parse a WhoIs `addr` argument: a bare IP or an `ip:port`/`[ip6]:port` string. Only the IP
849/// matters to `whois`; a bare IP is given port 0.
850fn parse_whois_addr(addr: &str) -> PyResult<SocketAddr> {
851    if let Ok(sock) = addr.parse::<SocketAddr>() {
852        return Ok(sock);
853    }
854    let ip: IpAddr = addr.parse().map_err(py_value_err)?;
855    Ok(SocketAddr::new(ip, 0))
856}
857
858fn sockaddr_as_tuple(s: SocketAddr) -> (IpAddr, u16) {
859    (s.ip(), s.port())
860}
861
862fn py_value_err(e: impl ToString) -> PyErr {
863    PyValueError::new_err(e.to_string())
864}
865
866#[cfg(test)]
867mod tests {
868    use super::*;
869
870    #[test]
871    fn whois_addr_accepts_bare_ip() {
872        let sock = parse_whois_addr("100.64.0.7").unwrap();
873        assert_eq!(sock.ip(), "100.64.0.7".parse::<IpAddr>().unwrap());
874        assert_eq!(sock.port(), 0);
875    }
876
877    #[test]
878    fn whois_addr_accepts_ip_port() {
879        let sock = parse_whois_addr("100.64.0.7:443").unwrap();
880        assert_eq!(sock.ip(), "100.64.0.7".parse::<IpAddr>().unwrap());
881        assert_eq!(sock.port(), 443);
882    }
883
884    #[test]
885    fn whois_addr_rejects_garbage() {
886        assert!(parse_whois_addr("not-an-ip").is_err());
887    }
888}