Skip to main content

tailscale/
lib.rs

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