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}