-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathlib.rs
More file actions
2094 lines (1952 loc) · 109 KB
/
Copy pathlib.rs
File metadata and controls
2094 lines (1952 loc) · 109 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//! A work-in-progress [Tailscale](https://tailscale.com/blog/how-tailscale-works) library.
//!
//! `tailscale` allows Rust programs to connect to a tailnet and exchange traffic with peers over
//! TCP and UDP. It can communicate with other `tailscale`-based peers, `tailscaled` (the Tailscale
//! Go client), `tsnet`, and `libtailscale` via public DERP servers.
//!
//! <div class="warning">
//! `tailscale` is unstable and insecure.
//!
//! We welcome enthusiasm and interest, but please **do not** build production software using these
//! libraries or rely on it for data privacy until we have a chance to batten down some hatches and
//! complete a third-party audit.
//!
//! See the [Caveats section](#caveats) for more details.
//! </div>
//!
//! For language bindings, see the following crates:
//!
//! - C: [ts_ffi](https://docs.rs/ts_ffi)
//! - Python: [ts_python](https://docs.rs/ts_python)
//! - Elixir: [ts_elixir](https://docs.rs/ts_elixir)
//!
//! For instructions on how to run tests, lints, etc., see [CONTRIBUTING.md]. For the high-level
//! architecture and repository layout, see [ARCHITECTURE.md].
//!
//! ## Code Sample
//!
//! A simple UDP client that periodically sends messages to a tailnet peer at `100.64.0.1:5678`:
//!
//! ```no_run
//! # use std::{
//! # time::Duration,
//! # net::Ipv4Addr,
//! # error::Error,
//! # };
//! #
//! # #[tokio::main]
//! # async fn main() -> Result<(), Box<dyn Error>> {
//! // Open a new connection to the tailnet
//! let dev = tailscale::Device::new(
//! &tailscale::Config::default_with_key_file("tsrs_keys.json").await?,
//! Some("YOUR_AUTH_KEY_HERE".to_owned()),
//! ).await?;
//!
//! // Bind a UDP socket on our tailnet IP, port 1234
//! let sock = dev.udp_bind((dev.ipv4_addr().await?, 1234).into()).await?;
//!
//! // Send a packet containing "hello, world!" to 100.64.0.1:5678 once per second
//! loop {
//! sock.send_to((Ipv4Addr::new(100, 64, 0, 1), 5678).into(), b"hello, world!").await?;
//! tokio::time::sleep(Duration::from_secs(1)).await;
//! }
//! # }
//! ```
//!
//! Additional examples of using the `tailscale` crate can be found in the [`examples/`] directory.
//!
//! ## Using `tailscale`
//!
//! To use this crate or the language bindings, you will need to set the `TS_RS_EXPERIMENT` env var
//! to `this_is_unstable_software`. We'll remove this requirement after a third-party code/cryptography
//! audit and any necessary fixes.
//!
//! Under the hood, we use Tokio for our async runtime. You must also use Tokio, any kind and most
//! configurations of Tokio runtimes should work, but there must be one available when you call any
//! async API functions. The easiest way to do this is to use `#[tokio::main]`, see the
//! [Tokio docs](https://docs.rs/tokio) for more information. In the future, we would like to limit
//! our reliance on Tokio so that there are alternatives for users of other async runtimes.
//!
//! ## Caveats
//!
//! This software is still a work-in-progress! We are providing it in the open at this stage out of
//! a belief in open-source and to see where the community runs with it, but please be aware of a
//! few important considerations:
//!
//! - This implementation contains unaudited cryptography and hasn't undergone a comprehensive
//! security analysis. Conservatively, assume there could be a critical security hole meaning
//! anything you send or receive could be in the clear on the public Internet.
//! - There are no compatibility guarantees at the moment. This is early-days software - we may
//! break dependent code in order to get things right.
//! - Direct peer-to-peer connections via NAT traversal are implemented (STUN-discovered endpoints
//! and Disco, with `CallMeMaybe` hole-punching over DERP), with DERP relays as the fallback when
//! no direct path is available. Hard/symmetric NATs get the same single fixed-local-port candidate
//! (`EndpointSTUN4LocalPort`) Go Tailscale uses; behind a NAT with no static port mapping a flow
//! may still stay relayed through DERP, which caps its throughput. (Upstream Go does **not** do a
//! "256-port birthday-paradox spray" — that is a common misconception; the single-candidate guess
//! is the actual behavior, and this fork matches it.)
//!
//! ## Feature Flags
//!
//! - `axum`: enables the `axum` module, which enables you to run an `axum` HTTP server on top
//! of a [`netstack::TcpListener`].
//! - `tsnet`: enables the `tsnet` module — a Go-idiomatic `tsnet.Server`-shaped facade over
//! [`Device`] and [`Config`]. Set fields, then call `up`/`listen`/`dial`/`loopback`/`close`/…, and
//! the wrapped [`Device`] is built and started lazily on the first call (Go's "fields may be
//! changed until the first method call"). A thin ergonomics layer only — same engine, same typed
//! returns. See the [`tsnet` module docs] for the full Go `tsnet.Server` → Rust API mapping.
//!
//! ## Platform Support
//!
//! `tailscale` currently supports the following platforms:
//!
//! - Linux (x86_64 and ARM64)
//! - macOS (ARM64)
//!
//! ## Component crates
//!
//! The following crates are part of the tailscale-rs project and are dependencies of this one. For
//! many tasks, just this crate should be sufficient and these other crates are an implementation detail.
//! There are other crates too, see [ARCHITECTURE.md]
//! or the [GitHub repo](https://github.com/tailscale/tailscale-rs).
//!
//! - [ts_runtime](https://docs.rs/ts_runtime): for each API-level `Device`, the runtime uses an actor
//! architecture to manage the lifecycle of the control client, data plane components, netstack, etc.
//! A message bus passes updates and communications between these top-level actors.
//! - [ts_netcheck](https://docs.rs/ts_netcheck): checks network availability and reports latency to
//! DERP servers in different regions.
//! - [ts_netstack_smoltcp](https://docs.rs/ts_netstack_smoltcp): a [smoltcp](https://docs.rs/smoltcp)-based
//! network stack that processes Layer 3+ packets to/from the overlay network.
//! - [ts_control](https://docs.rs/ts_control): control plane client that handles registration,
//! authorization/authentication, configuration, and streaming updates.
//! - [ts_dataplane](https://docs.rs/ts_dataplane): wires all the individual data plane functions together,
//! flowing inbound and outbound packets through the components in the correct order.
//! - [ts_tunnel](https://docs.rs/ts_tunnel): a partial implementation of the WireGuard specification
//! that protects all data plane traffic, and is interoperable with other WireGuard clients, including Tailscale clients.
//! - [ts_cli_util](https://docs.rs/ts_cli_util): helpers for writing command line tools and initializing
//! logging, used in examples.
//! - [ts_disco_protocol](https://docs.rs/ts_disco_protocol): incomplete implementation of Tailscale's
//! discovery protocol (disco).
//!
//! [ARCHITECTURE.md]: https://github.com/tailscale/tailscale-rs/blob/main/ARCHITECTURE.md
//! [CONTRIBUTING.md]: https://github.com/tailscale/tailscale-rs/blob/main/CONTRIBUTING.md
//! [`examples/`]: https://github.com/tailscale/tailscale-rs/blob/main/examples/README.md
//! [open an issue]: https://github.com/tailscale/tailscale-rs/issues
//! [`axum` HTTP server]: https://docs.rs/axum/latest/axum/
//! [`tsnet` module docs]: https://docs.rs/geiserx_tailscale/latest/tailscale/tsnet/
use std::{
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
time::Duration,
};
#[doc(inline)]
pub use config::Config;
#[doc(inline)]
pub use error::{Error, InternalErrorKind};
// Re-exported so a downstream crate depending only on `tailscale` can name the auth-key secret type
// for [`Device::new_with_secret`] without taking a separate, version-pinned dependency on `secrecy`
// (which would risk a `SecretString`-type mismatch if the two `secrecy` majors diverged). Callers
// pass `tailscale::SecretString`; `secrecy` is a pure-Rust wrapper (no aws-lc/openssl/ring).
pub use secrecy::SecretString;
#[doc(inline)]
pub use ts_control::ExitNodeSelector;
#[doc(inline)]
pub use ts_control::Node as NodeInfo;
#[doc(inline)]
pub use ts_control::tls::{CertifiedKey, TlsAcceptor, TlsStream};
#[doc(inline)]
pub use ts_control::{CertError, MISSING_CERT_RPC, ServeConfig, ServeState, ServeTarget};
/// The netmap DNS configuration returned by [`Device::dns_config`] (Go `netmap.NetworkMap.DNS`).
#[doc(inline)]
pub use ts_control::{DnsConfig, DnsResolver, ExtraRecord};
#[doc(inline)]
pub use ts_control::{ExitProxyConfig, ExitProxyScheme};
pub use ts_control::{
IdTokenError, LogoutError, ServiceError, ServiceMode, SetDnsError, SetDnsInternalErrorKind,
SshAccept, SshAction, SshConnIdentity, SshDecision, SshDenyReason, SshPolicy, SshPrincipal,
SshRule, StableNodeId,
};
// Re-exported so the application data-path transport can be selected through the `tailscale`
// facade alone: `Config::transport_mode` is `TransportMode` (default `Netstack`; `Tun(TunConfig {
// name, mtu })` for a real kernel TUN interface). Both are `pub` in `ts_control` but were not
// reachable through this facade, forcing downstream crates to depend on `ts_control` directly just
// to name them.
pub use ts_control::{TransportMode, TunConfig};
#[doc(inline)]
pub use ts_netstack_smoltcp::PingError;
use ts_netstack_smoltcp::{CreateSocket, netcore::Channel};
#[doc(inline)]
pub use ts_runtime::fallback_tcp::{
FallbackConnFuture, FallbackConnHandler, FallbackDecision, FallbackTcpHandle,
};
#[doc(inline)]
pub use ts_runtime::taildrop::WaitingFile;
#[doc(inline)]
pub use ts_runtime::{
DeviceState, DnsQueryResult, ExitNodeSuggestion, FileTarget, IpnBusWatcher, NetcheckReport,
Notify, NotifyWatchOpt, RegionLatency, RegistrationError, Status, StatusNode, TkaLogEntry,
WhoIs,
};
/// The interactive-login URL type returned by [`Device::pop_browser_url`].
#[doc(inline)]
pub use url::Url;
#[cfg(feature = "axum")]
pub mod axum;
pub mod config;
mod dial;
mod error;
#[cfg(feature = "hyper")]
pub mod http;
mod loopback;
#[cfg(feature = "ssh")]
pub mod ssh;
#[cfg(feature = "tsnet")]
pub mod tsnet;
#[doc(inline)]
pub use dial::{ConnectedUdpSocket, DialConn};
#[doc(inline)]
pub use loopback::LoopbackHandle;
/// How a program connects to a tailnet and communicates with peers.
///
/// The `Device` connects to the control plane, registers itself with the tailnet, and communicates
/// with tailnet peers. Its tailnet identity is determined by the key state provided at
/// construction-time.
pub struct Device {
runtime: ts_runtime::Runtime,
/// Command channel to the application netstack. `None` in TUN transport mode, where there is
/// no userspace application netstack; the channel-driven socket APIs ([`Device::udp_bind`],
/// [`Device::tcp_listen`], [`Device::tcp_connect`], [`Device::ping`]) are unsupported there.
channel: Option<Channel>,
/// Whether IPv6 is enabled on the tailnet overlay (the `Config::enable_ipv6` gate, default
/// `false`). Captured at construction; used by [`Device::listen_service`] to decide whether an
/// IPv6 VIP-service address is bindable (the netstack only accepts IPv6 overlay addresses when
/// this is set).
enable_ipv6: bool,
/// The stored Serve config + its live per-port accept loops (`tsnet`'s `Get/SetServeConfig` +
/// serving runtime). Built lazily on the first [`Device::set_serve_config`] (it needs this
/// node's overlay IPv4, only known after registration). Held here so its accept loops abort when
/// the `Device` drops; `None` (empty config) until the first `set`.
serve: std::sync::Mutex<Option<ts_runtime::serve::ServeManager>>,
/// The live Funnel ingress manager (`tsnet`'s `ListenFunnel` data path), built on
/// [`Device::listen_funnel`](crate::Device::listen_funnel). Held here so its TLS-termination pump and the installed peerAPI
/// ingress sink stay alive for the device's life (and tear down when a new `listen_funnel`
/// replaces it, or the `Device` drops). `None` until the first `listen_funnel`.
funnel: std::sync::Mutex<Option<ts_runtime::funnel::FunnelManager>>,
}
/// Map a [`ts_runtime::taildrop::TaildropError`] to the device-facing [`Error`]. `Error` is a
/// `Copy` enum with no payload, so the I/O detail string is dropped, but the *kind* is preserved so
/// a caller can still distinguish the actionable cases: an invalid name →
/// [`InternalErrorKind::BadRequest`], an in-progress conflict → [`InternalErrorKind::AlreadyExists`],
/// a missing file → [`InternalErrorKind::NotFound`], and any other filesystem failure →
/// [`InternalErrorKind::Io`].
fn taildrop_err(e: ts_runtime::taildrop::TaildropError) -> Error {
use ts_runtime::taildrop::TaildropError;
match e {
TaildropError::InvalidFileName => Error::Internal(InternalErrorKind::BadRequest),
TaildropError::FileExists => Error::Internal(InternalErrorKind::AlreadyExists),
TaildropError::Io(io) if io.kind() == std::io::ErrorKind::NotFound => {
Error::Internal(InternalErrorKind::NotFound)
}
TaildropError::Io(_) => Error::Internal(InternalErrorKind::Io),
}
}
/// Map a [`ts_runtime::taildrop_send::TaildropSendError`] (the Taildrop *sender*) to the
/// device-facing [`Error`]. The send-side conflict/forbidden/unexpected-status cases all reduce to
/// `BadRequest` (the peer refused the transfer for a request-level reason), a dial failure or
/// timeout to `Timeout`, an invalid name to `BadRequest`, and any stream I/O failure to `Io`.
fn taildrop_send_err(e: ts_runtime::taildrop_send::TaildropSendError) -> Error {
use ts_runtime::taildrop_send::TaildropSendError;
match e {
TaildropSendError::Connect | TaildropSendError::Timeout => Error::Timeout,
TaildropSendError::InvalidName
| TaildropSendError::Forbidden
| TaildropSendError::Conflict
| TaildropSendError::UnexpectedStatus(_) => Error::Internal(InternalErrorKind::BadRequest),
TaildropSendError::Io => Error::Internal(InternalErrorKind::Io),
}
}
/// Resolve the effective registration auth key from `auth_key` plus the config's
/// workload-identity-federation (WIF) / OAuth-client fields.
///
/// With the `identity-federation` feature enabled, an OAuth client secret (`tskey-client-…`) or a
/// `client_id` + (`id_token` | `audience`) is exchanged for a Tailscale auth key against the SaaS
/// admin API before registration (Go `tsnet.Server`'s `resolveAuthKey`). Without the feature this is
/// a pure pass-through: `auth_key` is returned unchanged and the WIF config fields are ignored, so
/// the default build is byte-identical to before.
#[cfg(feature = "identity-federation")]
async fn resolve_auth_key(
config: &Config,
auth_key: Option<String>,
) -> Result<Option<String>, Error> {
let wif = ts_control::WifConfig {
auth_key,
client_id: config.client_id.clone(),
client_secret: config.client_secret.clone(),
id_token: config.id_token.clone(),
audience: config.audience.clone(),
tags: config.requested_tags.clone(),
};
ts_control::resolve_auth_key(&wif, &config.control_server_url)
.await
.map_err(|e| {
tracing::error!(error = %e, "resolving auth key via workload-identity federation");
Error::Internal(InternalErrorKind::BadRequest)
})
}
/// Pass-through when the `identity-federation` feature is disabled: the auth key is used as-is and
/// the WIF config fields have no effect (matching Go, where the federation path is compiled out
/// unless its optional feature is linked).
#[cfg(not(feature = "identity-federation"))]
async fn resolve_auth_key(
_config: &Config,
auth_key: Option<String>,
) -> Result<Option<String>, Error> {
Ok(auth_key)
}
impl Device {
/// Create a device from the given [`Config`] and auth key.
///
/// Internally, this will spawn multiple asynchronous actors onto a Tokio runtime.
///
/// # Example
///
/// ```rust,no_run
/// # #[tokio::main]
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// # use tailscale::*;
/// let dev = Device::new(
/// &Config::default_with_key_file("tsrs_keys.json").await?,
/// Some("MY_AUTH_KEY".to_string()),
/// ).await?;
/// # Ok(()) }
/// ```
pub async fn new(config: &Config, auth_key: Option<String>) -> Result<Self, Error> {
check_magic_env()?;
// Resolve the effective registration auth key. The explicit `auth_key` argument wins; if it
// is `None`, fall back to `config.auth_key` (Go `tsnet.Server.AuthKey`). When the
// `identity-federation` feature is enabled, the resolved key is further passed through the
// WIF / OAuth-client bootstrap, which exchanges an OAuth client secret (`tskey-client-…`) or
// an IdP-issued OIDC token for a Tailscale auth key before registration (SaaS-only).
let auth_key = auth_key.or_else(|| config.auth_key.clone());
let auth_key = resolve_auth_key(config, auth_key).await?;
let rt =
ts_runtime::Runtime::spawn(config.into(), auth_key, (&config.key_state).into()).await?;
// In TUN transport mode there is no application netstack, so the runtime has no command
// channel: that surfaces as `UnsupportedInTunMode`, which we map to a `None` channel rather
// than an error (the device is still usable for control-plane and peer-lookup APIs).
let channel = match rt.channel().await {
Ok(c) => Some(c),
Err(e) if e.kind == ts_runtime::ErrorKind::UnsupportedInTunMode => None,
Err(e) => return Err(e.into()),
};
Ok(Self {
runtime: rt,
channel,
enable_ipv6: config.enable_ipv6,
serve: std::sync::Mutex::new(None),
funnel: std::sync::Mutex::new(None),
})
}
/// Create a device from the given [`Config`] and a [`SecretString`] auth key.
///
/// This is a back-compat-preserving convenience over [`new`](Self::new) for callers that already
/// hold the registration auth key as a [`secrecy::SecretString`] (e.g. a daemon that keeps the
/// pre-auth key wrapped end-to-end). It lets the caller avoid materializing a plain `String` at
/// the engine boundary: the secret is exposed only on the last inch, immediately before being
/// handed to [`new`](Self::new).
///
/// # Honesty about the plaintext window
///
/// This closes the *caller's* boundary, **not** the engine's internal handling. The engine still
/// resolves the auth key to a plain `String` internally for registration (the plaintext `String`
/// window inside the engine is identical to calling [`new`](Self::new) directly) — this method
/// does not make the engine itself secret-clean. If you call [`new`](Self::new) you create that
/// `String` yourself; if you call this you do not, but the engine creates one either way.
///
/// Passing `None` is equivalent to `new(config, None)` (falls back to `config.auth_key`).
///
/// # Example
///
/// ```rust,no_run
/// # #[tokio::main]
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// # use tailscale::*;
/// let dev = Device::new_with_secret(
/// &Config::default_with_key_file("tsrs_keys.json").await?,
/// Some(SecretString::from("MY_AUTH_KEY")),
/// ).await?;
/// # Ok(()) }
/// ```
pub async fn new_with_secret(
config: &Config,
auth_key: Option<SecretString>,
) -> Result<Self, Error> {
use secrecy::ExposeSecret as _;
// Expose the secret on the last inch and delegate to `new`, so the spawn/registration path
// is shared verbatim (no duplicated runtime-spawn logic) and the engine-internal plaintext
// window is byte-for-byte identical to a direct `new` call.
let plain = auth_key.map(|s| s.expose_secret().to_string());
Self::new(config, plain).await
}
/// The application netstack command channel, or an error in TUN transport mode (no application
/// netstack exists).
fn channel(&self) -> Result<&Channel, Error> {
self.channel
.as_ref()
.ok_or(Error::Internal(InternalErrorKind::UnsupportedInTunMode))
}
/// Get this [`Device`]'s IPv4 tailnet address.
pub async fn ipv4_addr(&self) -> Result<Ipv4Addr, Error> {
self.runtime
.control
.ask(ts_runtime::control_runner::Ipv4)
.await
.map_err(ts_runtime::Error::from)?
.ok_or(Error::Internal(InternalErrorKind::Actor))
}
/// Get this [`Device`]'s IPv6 tailnet address.
pub async fn ipv6_addr(&self) -> Result<Ipv6Addr, Error> {
self.runtime
.control
.ask(ts_runtime::control_runner::Ipv6)
.await
.map_err(ts_runtime::Error::from)?
.ok_or(Error::Internal(InternalErrorKind::Actor))
}
/// This node's tailnet IPv4 and (when provisioned) IPv6 addresses as a pair — the Rust analog of
/// Go `tsnet.Server.TailscaleIPs() (ip4, ip6 netip.Addr)`.
///
/// Reads the self node's assigned addresses (the same source Go splits by family). The tailnet
/// is IPv4-only unless [`Config::enable_ipv6`](crate::config::Config) is set, so the IPv6 half is
/// `None` when no v6 address is assigned — the Rust shape for Go returning the zero `netip.Addr`
/// in that case (Go's IPv6-absent sentinel). Errors until the first netmap is received (no self
/// node yet), matching Go returning invalid addresses before the node has joined.
pub async fn tailscale_ips(&self) -> Result<(Ipv4Addr, Option<Ipv6Addr>), Error> {
let me = self.self_node().await?;
let v4 = me.tailnet_address.ipv4.addr();
let v6 = me.tailnet_address.ipv6.addr();
// The decoder synthesizes the unspecified `::` placeholder on an IPv4-only tailnet; surface
// a real v6 only when IPv6 is enabled AND a non-placeholder address was assigned.
let v6 = (self.enable_ipv6 && !v6.is_unspecified()).then_some(v6);
Ok((v4, v6))
}
/// Bind a UDP socket to the specified [`SocketAddr`].
///
/// Returns an error in TUN transport mode (there is no application netstack to bind on).
pub async fn udp_bind(&self, socket_addr: SocketAddr) -> Result<netstack::UdpSocket, Error> {
self.channel()?
.udp_bind(socket_addr)
.await
.map_err(Into::into)
}
/// Bind a TCP listener to the specified [`SocketAddr`].
///
/// Returns an error in TUN transport mode (there is no application netstack to listen on).
pub async fn tcp_listen(
&self,
socket_addr: SocketAddr,
) -> Result<netstack::TcpListener, Error> {
self.channel()?
.tcp_listen(socket_addr)
.await
.map_err(Into::into)
}
/// Register a fallback TCP handler (like `tsnet`'s `RegisterFallbackTCPHandler`).
///
/// The callback is consulted for every inbound TCP flow that matches **no** explicit
/// [`Device::tcp_listen`] listener, with the flow's `(src, dst)` addresses. It returns
/// `(handler, intercept)`:
/// - `(_, false)` — decline; the next registered callback is tried.
/// - `(Some(h), true)` — claim the flow; `h` is handed the accepted [`netstack::TcpStream`].
/// - `(None, true)` — claim and reject the flow (the connection is closed).
///
/// Multiple handlers may be registered; they are consulted in registration order and the first
/// to intercept wins. The returned [`FallbackTcpHandle`] deregisters the handler when dropped.
///
/// Handlers serve flows over the overlay netstack only — never a host socket — and a flow no
/// handler claims is closed (fail-closed), never direct-dialed.
///
/// Returns an error in TUN transport mode (there is no application netstack to attach to).
pub fn register_fallback_tcp_handler<F>(&self, cb: F) -> Result<FallbackTcpHandle, Error>
where
F: Fn(SocketAddr, SocketAddr) -> FallbackDecision + Send + Sync + 'static,
{
self.runtime
.register_fallback_tcp_handler(std::sync::Arc::new(cb))
.map_err(Into::into)
}
/// Resolve a tailnet peer (or this node) by MagicDNS name to its tailnet IPv4 address.
///
/// This is an in-process lookup against the netmap we already hold — like `tsnet`'s in-memory
/// `dnsMap`, it does not query any DNS server (there is no `100.100.100.100` resolver). The
/// `name` may be a bare hostname or a fully-qualified MagicDNS name, with or without a trailing
/// dot, in any case (matching is case-insensitive). Returns `Ok(None)` if no tailnet node has
/// that name.
///
/// Only MagicDNS names are resolved; names outside the tailnet are not looked up here, so the
/// caller's system resolver remains responsible for them. IPv6 is intentionally not resolved —
/// this fork operates IPv4-only on the tailnet.
pub async fn resolve(&self, name: &str) -> Result<Option<Ipv4Addr>, Error> {
if let Some(peer) = self.peer_by_name(name).await? {
return Ok(Some(peer.tailnet_address.ipv4.addr()));
}
// tsnet's dnsMap also resolves our own name; fall back to self when no peer matches.
let me = self.self_node().await?;
if me.matches_name(name) {
return Ok(Some(me.tailnet_address.ipv4.addr()));
}
Ok(None)
}
/// Run a real DNS query through the tailnet's MagicDNS responder (the `100.100.100.100`
/// forward path), returning the raw response, RCODE, and resolver(s) consulted — the analogue of
/// Go `LocalClient.QueryDNS`.
///
/// Unlike [`resolve`](Self::resolve) (an in-memory netmap lookup that answers only MagicDNS
/// A-records), this issues an actual query of any `qtype` and runs it through the live
/// responder: an authoritative tailnet name is answered locally, anything else is forwarded to
/// the configured split-DNS / recursive upstreams (or delegated to the active exit node's DoH).
/// The response is returned as raw bytes (matching Go's `QueryDNS`), since this fork's DNS wire
/// codec has no answer-record decoder; the caller parses records itself if needed.
///
/// `qtype` is the raw RFC 1035 TYPE value (`1`=A, `28`=AAAA, `12`=PTR, `16`=TXT, `33`=SRV,
/// `65`=HTTPS/SVCB, …). Anti-leak is inherited from the responder: a tailnet-suffix name never
/// egresses, recursive forwards delegate to the exit node when one is active, and only IPv4
/// upstreams are dialed.
///
/// Returns an [`Error::Internal`] with `InternalErrorKind::UnsupportedInTunMode` in TUN
/// transport mode (MagicDNS there is an in-packet intercept, not a queryable responder).
pub async fn query_dns(&self, name: &str, qtype: u16) -> Result<DnsQueryResult, Error> {
self.runtime
.query_dns(name, qtype)
.await
.map_err(Into::into)
}
/// Connect to a tailnet peer by MagicDNS name and port over TCP.
///
/// Resolves `name` via [`Device::resolve`] (an in-process netmap lookup, no DNS server), then
/// dials the resulting tailnet IPv4 address. Returns [`InternalErrorKind::BadRequest`] if the
/// name does not resolve to a tailnet node.
pub async fn connect_by_name(
&self,
name: &str,
port: u16,
) -> Result<netstack::TcpStream, Error> {
let addr = self
.resolve(name)
.await?
.ok_or(Error::Internal(InternalErrorKind::BadRequest))?;
self.tcp_connect((addr, port).into()).await
}
/// Resolve a `host:port` string to a tailnet [`SocketAddr`], honoring the family forced by a
/// `network` suffix. The host may be an IP literal (parsed directly) or a MagicDNS name
/// (resolved via [`Device::resolve`], which yields a tailnet IPv4). Shared by [`Device::dial`]
/// and [`Device::dial_tcp`]. The IPv4-only invariant is enforced here: a `…6` network, or any v6
/// destination, requires `Config::enable_ipv6` and otherwise returns
/// [`InternalErrorKind::BadRequest`] (a clean typed error rather than a downstream actor error).
async fn resolve_dial_addr(
&self,
network: dial::Network,
addr: &str,
) -> Result<SocketAddr, Error> {
let (host, port) = dial::split_host_port(addr)?;
// An IP literal is used directly; otherwise resolve the MagicDNS name (IPv4 only).
let ip: IpAddr = if let Ok(ip) = host.parse::<IpAddr>() {
ip
} else {
self.resolve(host)
.await?
.ok_or(Error::Internal(InternalErrorKind::BadRequest))?
.into()
};
dial::check_family(network.family, ip)?;
// IPv4-only invariant: a v6 destination is only reachable when IPv6 is provisioned.
if ip.is_ipv6() && !self.enable_ipv6 {
return Err(Error::Internal(InternalErrorKind::BadRequest));
}
Ok((ip, port).into())
}
/// Connect to a tailnet address over TCP or UDP, the Rust analog of Go
/// `tsnet.Server.Dial(ctx, network, address)`.
///
/// `network` is one of `"tcp"`, `"tcp4"`, `"tcp6"`, `"udp"`, `"udp4"`, `"udp6"`; `addr` is a
/// `host:port` string where `host` is a MagicDNS name, an IPv4 literal, or a bracketed IPv6
/// literal (`[2001:db8::1]:443`). The host is resolved in-process via [`Device::resolve`] (no DNS
/// server). Returns a [`DialConn`] whose arm matches the transport — use [`Device::dial_tcp`]
/// when you want the TCP stream directly.
///
/// Differences from Go (documented for parity): ports must be **numeric** (Go's `LookupPort`
/// also resolves named ports like `"http"`; this fork avoids a services-file dependency), and
/// `…6`/v6 destinations require `Config::enable_ipv6` (the tailnet is IPv4-only by default).
///
/// # Errors
/// [`InternalErrorKind::BadRequest`] for an unsupported `network`, a malformed/portless `addr`,
/// an unresolvable name, or a v6 destination while IPv6 is disabled; otherwise the transport's
/// own connect error.
pub async fn dial(&self, network: &str, addr: &str) -> Result<DialConn, Error> {
let net = dial::parse_network(network)?;
let remote = self.resolve_dial_addr(net, addr).await?;
match net.transport {
dial::Transport::Tcp => Ok(DialConn::Tcp(self.tcp_connect(remote).await?)),
dial::Transport::Udp => {
// Bind an ephemeral local UDP socket on this node's tailnet address of the SAME
// family as the remote, then connect it (Go's `Dial("udp", …)` returns a connected
// UDP `net.Conn`, with the local source picked by `IfElse(dst.Is6(), v6, v4)`). A v4
// local socket cannot send to a v6 peer, so the family must match `remote`. (TCP gets
// this for free: `tcp_connect` already picks the source family from `remote`.)
let local_ip: IpAddr = if remote.is_ipv6() {
self.ipv6_addr().await?.into()
} else {
self.ipv4_addr().await?.into()
};
let sock = self.udp_bind((local_ip, 0).into()).await?;
Ok(DialConn::Udp(ConnectedUdpSocket::new(sock, remote)))
}
}
}
/// Connect to a tailnet address over TCP, returning the stream directly — the common case of
/// [`Device::dial`] for `"tcp"`. `addr` is a `host:port` string (MagicDNS name or IP literal).
/// This is the building block for HTTP-over-tailnet: an embedder's `hyper`/`reqwest` client can
/// route requests by calling `dial_tcp(&format!("{host}:{port}"))` from its connector, mirroring
/// how Go `tsnet.Server.HTTPClient` sets `http.Transport.DialContext = Server.Dial`.
///
/// # Errors
/// As [`Device::dial`] for the `"tcp"` network.
pub async fn dial_tcp(&self, addr: &str) -> Result<netstack::TcpStream, Error> {
let remote = self
.resolve_dial_addr(
dial::Network {
transport: dial::Transport::Tcp,
family: dial::Family::Any,
},
addr,
)
.await?;
self.tcp_connect(remote).await
}
/// Connect to a tailnet address over UDP, returning a connected socket directly — the `"udp"`
/// sibling of [`dial_tcp`](Device::dial_tcp) and the common case of [`Device::dial`] for
/// `"udp"`. `addr` is a `host:port` string (MagicDNS name or IP literal).
///
/// Returns a [`ConnectedUdpSocket`] (`send`/`recv` against a fixed peer), the connected
/// UDP-`net.Conn` shape Go's `tsnet.Server.Dial("udp", …)` returns — as opposed to
/// [`listen_packet`](Device::listen_packet), which yields an unconnected `net.PacketConn`. An
/// ephemeral local UDP socket is bound on this node's tailnet address of the same family as the
/// resolved remote (a v4 local socket cannot send to a v6 peer).
///
/// # Errors
/// As [`Device::dial`] for the `"udp"` network (name resolution, the IPv4-only / `enable_ipv6`
/// family invariant, or TUN transport mode having no application netstack to bind on).
pub async fn dial_udp(&self, addr: &str) -> Result<ConnectedUdpSocket, Error> {
let remote = self
.resolve_dial_addr(
dial::Network {
transport: dial::Transport::Udp,
family: dial::Family::Any,
},
addr,
)
.await?;
let local_ip: IpAddr = if remote.is_ipv6() {
self.ipv6_addr().await?.into()
} else {
self.ipv4_addr().await?.into()
};
let sock = self.udp_bind((local_ip, 0).into()).await?;
Ok(ConnectedUdpSocket::new(sock, remote))
}
/// Bind a UDP socket from a `host:port` string, the Rust analog of Go
/// `tsnet.Server.ListenPacket(network, addr)`.
///
/// `network` is one of `"udp"`, `"udp4"`, `"udp6"`; `addr` must be a **valid IP literal**
/// `host:port` (Go's `ListenPacket` rejects a name or empty host — unlike `Listen`). An
/// unspecified host (`0.0.0.0`/`[::]`) binds on this node's tailnet address. Returns the
/// unconnected [`netstack::UdpSocket`] (a `net.PacketConn`).
///
/// # Errors
/// [`InternalErrorKind::BadRequest`] for a non-UDP/unsupported `network`, a malformed addr, a
/// non-IP host, a family mismatch, or a v6 bind while IPv6 is disabled.
pub async fn listen_packet(
&self,
network: &str,
addr: &str,
) -> Result<netstack::UdpSocket, Error> {
let net = dial::parse_network(network)?;
if net.transport != dial::Transport::Udp {
return Err(Error::Internal(InternalErrorKind::BadRequest));
}
let (host, port) = dial::split_host_port(addr)?;
// ListenPacket requires a valid IP host (Go rejects a name here).
let ip: IpAddr = host
.parse()
.map_err(|_| Error::Internal(InternalErrorKind::BadRequest))?;
dial::check_family(net.family, ip)?;
// A v6 bind (whether an explicit literal or an unspecified `[::]`) requires IPv6 to be
// provisioned — enforce the gate for BOTH cases (the unspecified `[::]` path used to skip it).
if ip.is_ipv6() && !self.enable_ipv6 {
return Err(Error::Internal(InternalErrorKind::BadRequest));
}
// An unspecified bind host (`0.0.0.0` / `[::]`) means "this node's tailnet address" — of the
// SAME family as the requested address, so a `udp6` `[::]:0` binds a v6 socket (it used to
// fall through to the v4 address regardless, silently yielding an IPv4 socket for a v6 listen).
let bind_ip: IpAddr = if ip.is_unspecified() {
if ip.is_ipv6() {
self.ipv6_addr().await?.into()
} else {
self.ipv4_addr().await?.into()
}
} else {
ip
};
self.udp_bind((bind_ip, port).into()).await
}
/// Connect to a TCP socket at the remote address.
///
/// Returns an error in TUN transport mode (there is no application netstack to dial from).
pub async fn tcp_connect(&self, remote: SocketAddr) -> Result<netstack::TcpStream, Error> {
let channel = self.channel()?;
let ip: IpAddr = match remote.is_ipv4() {
true => self.ipv4_addr().await?.into(),
false => self.ipv6_addr().await?.into(),
};
// TODO(npry): collision checking
let ephemeral_port = rand::random_range(49152..=u16::MAX);
channel
.tcp_connect((ip, ephemeral_port).into(), remote)
.await
.map_err(Into::into)
}
/// Start a SOCKS5 proxy on a host loopback address that dials into the tailnet (Go
/// `tsnet.Server.Loopback`, SOCKS5 half).
///
/// Binds a TCP listener on `127.0.0.1:0` (host loopback only — never an external interface) and
/// serves SOCKS5 (RFC 1928) with required username/password auth (RFC 1929): username `tsnet`,
/// password = the returned `proxy_cred`. Each `CONNECT` is dialed INTO the overlay via
/// [`Device::connect_by_name`] / [`Device::tcp_connect`] and spliced to the accepted host socket, so
/// a non-Rust host process can reach tailnet peers through the proxy. Returns the bound address, the
/// proxy credential, and a [`LoopbackHandle`] whose drop stops the listener.
///
/// Anti-leak: the listener is loopback-only and every connection egresses over the overlay, never a
/// host socket — the host's real origin IP is never used to reach the destination. Unlike Go, the
/// LocalAPI HTTP surface is not served (this fork exposes status/whois/id-token natively on
/// `Device`); only the SOCKS5 proxy is provided.
///
/// Returns an error in TUN transport mode (no application netstack to dial from).
pub async fn loopback(&self) -> Result<(std::net::SocketAddr, String, LoopbackHandle), Error> {
loopback::start(self.overlay_dialer().await?).await
}
/// Build an [`OverlayDialer`](loopback::OverlayDialer): the cloneable, `&Device`-free dialer that
/// resolves a MagicDNS name (or takes an IPv4 literal) and `tcp_connect`s it into the overlay,
/// reused by [`Device::loopback`] (SOCKS5) and the `hyper` [`http_connector`](Device::http_connector).
///
/// Captures only cloneable pieces — never `&self` — so the dialer (and anything built on it, like a
/// spawned accept loop or an HTTP connector) carries no borrow of the `Device`: a clone of the
/// netstack command channel, this device's own overlay IPv4 (fetched once), and a boxed resolver
/// closure over clones of the control + peer-tracker actor refs. The resolver replicates
/// [`Device::resolve`] (peer-by-name, falling back to this node's own name).
async fn overlay_dialer(&self) -> Result<loopback::OverlayDialer, Error> {
let channel = self.channel()?.clone();
let self_ipv4 = self.ipv4_addr().await?;
let control = self.runtime.control.clone();
let peer_tracker = self.runtime.peer_tracker.clone();
let resolve: loopback::Resolver = std::sync::Arc::new(move |name: String| {
let control = control.clone();
let peer_tracker = peer_tracker.clone();
Box::pin(async move {
let pt = peer_tracker
.upgrade()
.ok_or(Error::Internal(InternalErrorKind::Actor))?;
let peer = pt
.ask(ts_runtime::peer_tracker::PeerByName { name: name.clone() })
.await
.map_err(ts_runtime::Error::from)?;
if let Some(peer) = peer {
return Ok(Some(peer.tailnet_address.ipv4.addr()));
}
// tsnet's dnsMap also resolves our own name; fall back to self.
let me = control
.ask(ts_runtime::control_runner::SelfNode)
.await
.map_err(ts_runtime::Error::from)?
.ok_or(Error::Internal(InternalErrorKind::Actor))?;
if me.matches_name(&name) {
Ok(Some(me.tailnet_address.ipv4.addr()))
} else {
Ok(None)
}
}) as std::pin::Pin<Box<dyn std::future::Future<Output = _> + Send>>
});
Ok(loopback::OverlayDialer::new(channel, self_ipv4, resolve))
}
/// Build a [`hyper`-compatible connector](crate::http::TailnetConnector) that routes outbound HTTP
/// requests over the tailnet — the analog of Go `tsnet.Server.HTTPClient`, whose mechanism is
/// simply `http.Transport{DialContext: s.Dial}`.
///
/// Hand the returned connector to `hyper_util::client::legacy::Client::builder(...).build(conn)`;
/// each request's `Uri` host is resolved as a MagicDNS name (or IPv4 literal) and dialed into the
/// overlay (default port 80 for `http`, 443 for `https`), so the request egresses over the tailnet
/// rather than the host's network. TLS, redirects, and pooling are the hyper client's concern — the
/// connector only supplies the transport, exactly like Go's bare `DialContext` injection.
///
/// Available only with the **`hyper`** crate feature.
///
/// # Errors
/// Fails for the same reasons as [`Device::loopback`]'s setup: TUN transport mode (no application
/// netstack) or the node not yet having an overlay IPv4.
#[cfg(feature = "hyper")]
pub async fn http_connector(&self) -> Result<crate::http::TailnetConnector, Error> {
Ok(crate::http::TailnetConnector::new(
self.overlay_dialer().await?,
))
}
/// Get our node info.
pub async fn self_node(&self) -> Result<NodeInfo, Error> {
self.runtime
.control
.ask(ts_runtime::control_runner::SelfNode)
.await
.map_err(ts_runtime::Error::from)?
.ok_or(Error::Internal(InternalErrorKind::Actor))
}
/// The DNS names this node can obtain TLS certificates for — Go `tsnet.Server.CertDomains()`.
///
/// These are the `CertDomains` control pushed in the netmap DNS config: the names a TLS-serving
/// consumer (e.g. a `ListenTLS`/`GetCertificate`-style caller) should request a cert for. Returns
/// an empty `Vec` before the first netmap, or when control granted none — mirroring Go returning a
/// clone of `nm.DNS.CertDomains` (empty/`nil` when absent).
pub async fn cert_domains(&self) -> Result<Vec<String>, Error> {
self.runtime
.control
.ask(ts_runtime::control_runner::CertDomains)
.await
.map_err(ts_runtime::Error::from)
.map_err(Into::into)
}
/// The DNS configuration control pushed in the latest netmap — Go `tsnet`'s view of
/// `netmap.NetworkMap.DNS` (what `tailscale dns status` reports).
///
/// Returns the full [`DnsConfig`] — MagicDNS on/off, search domains, global + fallback resolvers,
/// split-DNS routes, extra records, cert domains — or `None` before the first netmap / when
/// control has sent no DNS config. A superset of [`cert_domains`](Device::cert_domains), which
/// remains a separate narrower accessor for the TLS-cert use. Mirrors Go reading a clone of
/// `nm.DNS` (absent ⇒ `None`).
pub async fn dns_config(&self) -> Result<Option<DnsConfig>, Error> {
self.runtime
.control
.ask(ts_runtime::control_runner::DnsConfig)
.await
.map_err(ts_runtime::Error::from)
.map_err(Into::into)
}
/// The URL control last asked this node to open in a browser (`MapResponse.PopBrowserURL`), or
/// `None` if control has never sent one.
///
/// This is the interactive-login / consent URL an embedder driving a non-authkey (interactive)
/// login must surface to the user — the Rust analog of Go `ipn` delivering `BrowseToURL` through
/// the notification bus. A daemon polls this after starting an interactive login to obtain the
/// auth URL to present.
///
/// **Sticky semantics** (Go `controlclient`'s `sess.lastPopBrowserURL`): once control sends a
/// URL it remains the returned value until control sends a *different* non-empty one — it is
/// **never cleared back to `None`** (control sends `PopBrowserURL` empty on nearly every netmap
/// tick; those empty updates are ignored, not treated as "clear"). So a non-`None` result does
/// **not** signal "control is asking *right now*" vs. "already handled" — it is the last URL
/// seen this session. A consumer that acts on it should de-duplicate on the URL value rather than
/// re-acting on every poll. For a push stream of *new* consent URLs (rather than polling this
/// sticky value), subscribe to [`watch_ipn_bus`](Self::watch_ipn_bus) and react to
/// [`Notify::browse_to_url`](crate::Notify::browse_to_url).
pub async fn pop_browser_url(&self) -> Result<Option<Url>, Error> {
self.runtime
.control
.ask(ts_runtime::control_runner::PopBrowserUrl)
.await
.map_err(ts_runtime::Error::from)
.map_err(Into::into)
}
/// This node's latest network-conditions report — the Rust analog of Go's `netcheck.Report` as
/// `tailscale netcheck` surfaces it.
///
/// Returns the [`NetcheckReport`]: the preferred (lowest-latency) DERP region and the per-region
/// latency map this node last measured. Empty (default) before the first measurement. This fork's
/// net-report path measures only DERP-region latency, so the report carries that subset rather
/// than fabricating the UDP/port-mapping fields Go also reports (see [`NetcheckReport`]).
pub async fn netcheck(&self) -> Result<NetcheckReport, Error> {
self.runtime
.control
.ask(ts_runtime::control_runner::Netcheck)
.await
.map_err(ts_runtime::Error::from)
.map_err(Into::into)
}
/// Suggest a reasonably good exit node to use, based on this node's current netmap and latest
/// network-conditions report — Go `tailscale exit-node suggest` / `LocalBackend.SuggestExitNode`.
///
/// Returns the suggested exit node's stable id + name as an [`ExitNodeSuggestion`]; engage it by
/// passing the id to [`Config::exit_node`](crate::config::Config) /
/// [`Device::set_exit_node`](crate::Device::set_exit_node) as a stable-id selector. The
/// suggestion uses the classic DERP-region-latency algorithm: among peers control marked
/// suggestable (the `suggest-exit-node` capability) that advertise an exit route and are online,
/// it prefers the one whose home DERP region this node measured as lowest-latency, and is
/// **sticky** — a prior suggestion that is still a good candidate is kept across calls, so
/// repeated calls don't flap between equally-good options.
///
/// Outcomes (mirroring Go):
/// - `Ok(Some(suggestion))` — a node was suggested.
/// - `Ok(None)` — no eligible candidate (no suggestion); **not** an error.
/// - `Err(`[`Error::NoPreferredDerp`]`)` — no netcheck has completed yet, so no preferred DERP
/// region is known; retry once connectivity has been measured.
///
/// ## Scope (Phase 1)
/// This ports Go's classic DERP path only. The traffic-steering path and the Mullvad
/// geographic-distance ranking (for exit nodes with no DERP home) are not yet implemented, and
/// the suggestion does not carry a `Location` (Go's `omitempty` field) — this fork's peer model
/// has none yet. The candidate exit-route check accepts a peer advertising `0.0.0.0/0` (this
/// fork is IPv4-only), rather than Go's both-`0.0.0.0/0`-and-`::/0` requirement.
pub async fn suggest_exit_node(&self) -> Result<Option<ExitNodeSuggestion>, Error> {
// The runtime returns the actor-gather outcome (outer) wrapping the algorithm outcome
// (inner: `Ok(None)` empty, or the `NoPreferredDerp` domain error). Flatten both into the
// device-facing `Error`.
self.runtime.suggest_exit_node().await?.map_err(Into::into)
}
/// This node's key-expiry instant as Unix seconds (`Node.KeyExpiry` in Go), or `Ok(None)` if
/// the key never expires.
///
/// Like Go, this fork is **reactive** about key expiry — it reports it rather than rotating the
/// node key in the background. A caller can schedule re-authentication around this time; on
/// expiry, re-create the [`Device`] (which re-registers), supplying a fresh node key + the prior
/// `old_node_key` to rotate, or the same key to refresh.
pub async fn self_key_expiry_unix(&self) -> Result<Option<i64>, Error> {
Ok(self.self_node().await?.key_expiry_unix())
}
/// Whether this node's key has expired as of now (`!KeyExpiry.IsZero() && KeyExpiry.Before(now)`
/// in Go). A key with no expiry is never expired. See [`Device::self_key_expiry_unix`] for the
/// reactive-rotation note.
pub async fn self_key_expired(&self) -> Result<bool, Error> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
// An unreadable clock (pre-epoch) is treated as the far future so a time-limited key
// looks expired — fail-safe toward prompting re-auth rather than trusting a stale key.
.unwrap_or(i64::MAX);
Ok(self.self_node().await?.key_expired_at_unix(now))
}
/// Fetch the current Tailscale SSH policy pushed by control, if any.
///
/// Returns `Ok(None)` when control has not sent an SSH policy. The SSH server treats an absent
/// or empty policy as **deny-all** (fail-closed). Used by the SSH auth path
/// ([`SshPolicy::evaluate`][ts_control::SshPolicy::evaluate]) to authorize incoming
/// connections.
pub async fn ssh_policy(&self) -> Result<Option<ts_control::SshPolicy>, Error> {
self.runtime
.control
.ask(ts_runtime::control_runner::CurrentSshPolicy)