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}