Skip to main content

Device

Struct Device 

Source
pub struct Device { /* private fields */ }
Expand description

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.

Implementations§

Source§

impl Device

Source

pub async fn authorize_ssh( &self, remote: SocketAddr, requested_user: &str, ) -> Result<SshDecision, Error>

Authorize an incoming Tailscale SSH connection from remote requesting local user requested_user, against the control-pushed SSH policy.

Fail-closed. This is the Rust analogue of Go tailssh’s policy evaluation. It:

  1. resolves remote’s IP to a known tailnet peer — an unknown source is denied;
  2. fetches the current SshPolicyno policy means deny-all;
  3. evaluates the policy (first-match-wins, default-deny) against the peer’s identity.

Returns the SshDecision. Callers MUST reject the connection on any SshDecision::Deny. Any lookup error is surfaced as Err and must also be treated as a rejection by the caller — the connection is never allowed on the error path.

NOTE: userLogin-principal matching requires the connecting peer’s owner login, which this fork’s domain node model does not yet retain (it is reported as None); such principals therefore never match here. Node-id / node-IP / any principals match normally.

Source§

impl Device

Source

pub async fn serve_ssh<H>( self: Arc<Self>, config: Config, listen_addr: SocketAddr, ) -> Result<(), Error>
where H: TailnetServer + Handler + Send + 'static, H::Error: Debug,

Serve an ssh service on the given TCP address.

This is a minimal helper that just wires up the relevant pieces. All the authentication and actual SSH server logic must be implemented by the caller in the TailnetServer (H) and configured by config.

Source

pub async fn listen_ssh( self: Arc<Self>, config: Config, listen_addr: SocketAddr, ) -> Result<(), Error>

Run a turnkey Tailscale SSH server on listen_addr (tailnet overlay) that grants authorized connections an interactive login shell as their policy-mapped local user.

Authorization is the control-pushed SSH policy (see [Device::authorize_ssh]) — fail-closed: unknown source, no policy, no matching rule, or any error rejects. The accepted connection’s local_user is resolved against the local passwd database and the login shell is spawned in a PTY after dropping privileges to that user’s uid/gid (the daemon must run as root to do so; if it cannot, the session fails closed). Mirrors Go tailssh’s incubator shell path.

Only the interactive login-shell path is implemented: pty-req<shell> -l, window-changeTIOCSWINSZ, and an exit-status on shell exit. The exec form (<shell> -c <cmd>) is not supported because ChannelEvent does not surface an SSH exec request in this fork’s channel abstraction.

Source

pub async fn serve_ssh_tui<App>( self: Arc<Self>, config: Config, listen_addr: SocketAddr, ) -> Result<(), Error>
where App: RatatuiApp + Default + Send + 'static,

Serve an SSH TUI service on the given TCP address.

Wrapper around serve_ssh to specifically use ChannelServer around a RatatuiTerm using App.

Source§

impl Device

Source

pub async fn new( config: &Config, auth_key: Option<String>, ) -> Result<Self, Error>

Create a device from the given Config and auth key.

Internally, this will spawn multiple asynchronous actors onto a Tokio runtime.

§Example
let dev = Device::new(
    &Config::default_with_key_file("tsrs_keys.json").await?,
    Some("MY_AUTH_KEY".to_string()),
).await?;
Source

pub async fn new_with_secret( config: &Config, auth_key: Option<SecretString>, ) -> Result<Self, Error>

Create a device from the given Config and a SecretString auth key.

This is a back-compat-preserving convenience over 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.

§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 directly) — this method does not make the engine itself secret-clean. If you call 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
let dev = Device::new_with_secret(
    &Config::default_with_key_file("tsrs_keys.json").await?,
    Some(SecretString::from("MY_AUTH_KEY")),
).await?;
Source

pub async fn ipv4_addr(&self) -> Result<Ipv4Addr, Error>

Get this Device’s IPv4 tailnet address.

Source

pub async fn ipv6_addr(&self) -> Result<Ipv6Addr, Error>

Get this Device’s IPv6 tailnet address.

Source

pub async fn tailscale_ips(&self) -> Result<(Ipv4Addr, Option<Ipv6Addr>), Error>

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 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.

Source

pub async fn udp_bind( &self, socket_addr: SocketAddr, ) -> Result<UdpSocket, Error>

Bind a UDP socket to the specified SocketAddr.

Returns an error in TUN transport mode (there is no application netstack to bind on).

Source

pub async fn tcp_listen( &self, socket_addr: SocketAddr, ) -> Result<TcpListener, Error>

Bind a TCP listener to the specified SocketAddr.

Returns an error in TUN transport mode (there is no application netstack to listen on).

Source

pub fn register_fallback_tcp_handler<F>( &self, cb: F, ) -> Result<FallbackTcpHandle, Error>
where F: Fn(SocketAddr, SocketAddr) -> FallbackDecision + Send + Sync + 'static,

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).

Source

pub async fn resolve(&self, name: &str) -> Result<Option<Ipv4Addr>, Error>

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.

Source

pub async fn connect_by_name( &self, name: &str, port: u16, ) -> Result<TcpStream, Error>

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.

Source

pub async fn dial(&self, network: &str, addr: &str) -> Result<DialConn, Error>

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.

Source

pub async fn dial_tcp(&self, addr: &str) -> Result<TcpStream, Error>

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.

Source

pub async fn dial_udp(&self, addr: &str) -> Result<ConnectedUdpSocket, Error>

Connect to a tailnet address over UDP, returning a connected socket directly — the "udp" sibling of 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, 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).

Source

pub async fn listen_packet( &self, network: &str, addr: &str, ) -> Result<UdpSocket, Error>

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.

Source

pub async fn tcp_connect(&self, remote: SocketAddr) -> Result<TcpStream, Error>

Connect to a TCP socket at the remote address.

Returns an error in TUN transport mode (there is no application netstack to dial from).

Source

pub async fn loopback( &self, ) -> Result<(SocketAddr, String, LoopbackHandle), Error>

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).

Source

pub async fn self_node(&self) -> Result<NodeInfo, Error>

Get our node info.

Source

pub async fn cert_domains(&self) -> Result<Vec<String>, Error>

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).

Source

pub async fn dns_config(&self) -> Result<Option<DnsConfig>, Error>

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, which remains a separate narrower accessor for the TLS-cert use. Mirrors Go reading a clone of nm.DNS (absent ⇒ None).

Source

pub async fn pop_browser_url(&self) -> Result<Option<Url>, Error>

The URL control last asked this node to open in a browser (MapResponse.PopBrowserURL), or None if control has sent none.

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. None until control sends one; the value is replaced (not accumulated) each time control pushes a new one.

Source

pub async fn netcheck(&self) -> Result<NetcheckReport, Error>

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).

Source

pub async fn self_key_expiry_unix(&self) -> Result<Option<i64>, Error>

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.

Source

pub async fn self_key_expired(&self) -> Result<bool, Error>

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.

Source

pub async fn ssh_policy(&self) -> Result<Option<SshPolicy>, Error>

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) to authorize incoming connections.

Source

pub async fn peer_by_name(&self, name: &str) -> Result<Option<NodeInfo>, Error>

Look up a peer by name.

Source

pub async fn peer_by_tailnet_ip( &self, ip: IpAddr, ) -> Result<Option<NodeInfo>, Error>

Look up a peer by ip.

Source

pub async fn peers_with_route(&self, ip: IpAddr) -> Result<Vec<NodeInfo>, Error>

Look up the peer(s) with the most-specific route matches for ip.

This reports which peers advertise a route covering ip, independent of this device’s accept_routes setting — analogous to the Go client’s informational PrimaryRoutes. It is not a reachability oracle: with accept_routes off, the dataplane will not actually route to (or accept return traffic from) advertised subnet routes even if this returns a peer.

Source

pub fn taildrop_waiting_files(&self) -> Result<Vec<WaitingFile>, Error>

List the Taildrop files this device has fully received and not yet consumed (Go LocalAPI WaitingFiles).

Returns the files waiting under the configured taildrop_dir, sorted by name. Returns an empty list when Taildrop is disabled (Config::taildrop_dir unset) — fail-closed, never an error for the disabled case. A filesystem error while listing surfaces as InternalErrorKind::Actor.

Source

pub fn taildrop_open_file(&self, name: &str) -> Result<(File, u64), Error>

Open a received Taildrop file by name for reading, returning the handle and its size (Go LocalAPI OpenFile).

The name is validated (path-traversal-safe) inside the store before any path is built. Returns InternalErrorKind::BadRequest when Taildrop is disabled or the name is invalid, and InternalErrorKind::Actor for a filesystem error (e.g. the file does not exist).

Source

pub fn taildrop_delete_file(&self, name: &str) -> Result<(), Error>

Delete a received Taildrop file by name (Go LocalAPI DeleteFile).

The name is validated (path-traversal-safe) inside the store before any path is built. Returns InternalErrorKind::BadRequest when Taildrop is disabled or the name is invalid, and InternalErrorKind::Actor for a filesystem error (e.g. the file does not exist).

Source

pub async fn send_file<R>( &self, peer: &NodeInfo, name: &str, content_length: u64, reader: R, ) -> Result<(), Error>
where R: AsyncRead + Unpin,

Send a local file to a tailnet peer via Taildrop (Go PushFile / tailscale file cp).

Pushes content_length bytes from reader to the peer’s peerAPI as PUT /v0/put/<name> over the overlay netstack — the sending counterpart to the receive store surfaced by Device::taildrop_waiting_files. The transfer rides the encrypted WireGuard overlay, never a host socket. The body is streamed from offset 0 (no resume).

The destination is derived solely from peer’s own node record (NodeInfo::peerapi_addr): its advertised tailnet IPv4 and peerapi4 port. The caller obtains peer from Device::peer_by_name / Device::peer_by_tailnet_ip, so it is always a current netmap peer — a raw control-supplied or attacker-chosen address can never be targeted. As defense in depth, the resolved address is additionally asserted to be a Tailscale CGNAT IP before dialing.

Returns InternalErrorKind::BadRequest when the peer advertises no IPv4 peerAPI (so it cannot receive files), when the name is invalid, or when the peer refuses the transfer (403/409/unexpected status); Error::Timeout on a dial failure or timeout; and InternalErrorKind::Io on a mid-transfer stream error.

Source

pub async fn file_targets(&self) -> Result<Vec<FileTarget>, Error>

List the tailnet peers this node can Taildrop a file to — the Rust analog of Go’s LocalAPI FileTargets.

Each FileTarget pairs a peer’s node record with the http://ip:port base of its peerAPI; pass target.node straight to Device::send_file. A peer qualifies when it advertises a reachable IPv4 peerAPI and is either owned by the same user as this node or explicitly granted the file-sharing-target capability — mirroring upstream’s send-path filter. The list is gated on this node holding the file-sharing capability (control grants it when the admin enables Taildrop); absent that, the result is empty (fail-closed, not an error). Sorted by the peer’s MagicDNS name. Targets are listed regardless of online state (matching upstream — an offline target’s send_file simply times out). Empty before the first netmap.

Source

pub async fn capture_pcap<W>(&self, writer: W) -> Result<(), Error>
where W: Write + Send + 'static,

Begin a debug packet capture, streaming a pcap of every packet crossing the dataplane to writer (Go tsnet.Server.CapturePcap).

Installs a capture hook on the running dataplane: from now until Device::stop_capture is called (or another capture replaces this one), a copy of every plaintext IP packet on the datapath — outbound (pre-encrypt) and inbound (post-decrypt) — is framed and written to writer. The 24-byte pcap global header is written immediately on success.

The format is byte-faithful classic pcap with Tailscale’s LINKTYPE_USER0 + 4-byte path preamble per record (see ts_runtime::capture); a resulting file opens in Wireshark, and with Tailscale’s ts-dissector.lua the direction/path of each packet decodes.

The hook runs inline on the single-threaded dataplane step, so writer must not block for long — a slow writer back-pressures the datapath. Records are not flushed per packet (that would be a syscall on every packet on the dataplane thread); buffered bytes are flushed when the writer is dropped on Device::stop_capture. Wrap writer in a std::io::BufWriter if you want buffering. A write error is swallowed per-packet (the capture silently drops that record) rather than tearing down the datapath; call Device::stop_capture to end it. Returns an error only if the dataplane actor is unreachable or the initial global-header write fails.

Source

pub async fn stop_capture(&self) -> Result<(), Error>

Stop a debug packet capture started by Device::capture_pcap (Go ClearCaptureSink).

Clears the dataplane capture hook; the writer is dropped (its remaining buffered bytes are flushed by its own Drop). Idempotent — clearing when no capture is installed is a no-op. Returns an error only if the dataplane actor is unreachable.

Source

pub async fn status(&self) -> Result<Status, Error>

Snapshot of this device and its tailnet peers (like tailscale status).

Combines this node’s self info with the current peer set: each StatusNode reports the stable id, display name, tailnet IPs, advertised routes, and exit-node flag. (Per-peer online/user/capabilities are honestly None/empty in this fork — the domain node model does not yet carry the wire-level liveness/login fields; see ts_runtime::status docs.)

Source

pub async fn tka_status(&self) -> Result<Option<TkaStatus>, Error>

Fetch the current Tailnet Lock (TKA) status pushed by control, if any.

Returns Ok(None) when control has sent no TKAInfo (tailnet lock not in use, or no change observed yet). The returned TkaStatus carries the authority head (a base32 AUMHash, decode with tka::AumHash::from_base32) and the disablement signal. Signature verification of a peer’s node-key signature against the authority is performed with the tka module’s tka::Authority.

Source

pub async fn fetch_id_token( &self, audience: &str, ) -> Result<String, IdTokenError>

Request an OIDC ID token from control for this node, scoped to audience (workload- identity federation, like tailscale’s id-token LocalAPI).

Returns a signed JWT whose sub claim is this node’s MagicDNS name and whose aud claim is audience, suitable for presenting to a third-party relying party (e.g. AWS/GCP workload-identity federation). The node is the token subject, not the authenticator — this is token issuance over the Noise transport (POST /machine/id-token), not a login path. Requires the control plane to support capability version ≥ 30.

Source

pub async fn set_dns(&self, name: &str, value: &str) -> Result<(), SetDnsError>

Publish a TXT DNS record for this node into the tailnet’s ts.net zone via control’s /machine/set-dns RPC — the Rust analog of Go tailscale.com/client/tailscale’s LocalClient.SetDNS(ctx, name, value).

name is the full record name (e.g. _acme-challenge.host.tailnet.ts.net) and value is the record value (e.g. the base64url DNS-01 digest). Like Go’s SetDNS, this publishes a TXT record specifically — its canonical use is satisfying an ACME DNS-01 challenge so a CA can verify control of a *.ts.net name. Issuance over the Noise transport (POST /machine/set-dns), not a login path.

Source

pub async fn logout(&self) -> Result<(), LogoutError>

Log this node out of the tailnet — deregister it from the control plane (the equivalent of Go tsnet’s LocalClient.Logout).

Re-POSTs /machine/register with this node’s current node key and a past expiry, which the control plane honors by expiring the node now: it drops out of every peer’s netmap and must re-register (re-authenticate) to rejoin.

This is primarily for non-ephemeral nodes. An ephemeral node is garbage-collected by control shortly after it disconnects, but a persistent node lingers in the tailnet (visible to peers, counting against the machine limit) for up to ~24h after the process exits unless explicitly logged out. Call this before shutdown to deregister immediately. Calling it on an ephemeral node simply brings the GC forward; it is idempotent, so logging out an already-gone node is not an error.

This is a control-plane state change only: it does not tear down the local datapath (do that via shutdown), and it does not delete or rotate the on-disk node key — re-registering with the same key (a fresh Device::new) is the re-login path.

Source

pub fn metrics(&self) -> String

Snapshot this node’s client metrics in Prometheus text exposition format.

Mirrors Go Tailscale’s clientmetric registry: process-global counters/gauges incremented on the datapath hot loops (e.g. magicsock_send_udp, magicsock_recv_data_bytes_udp), rendered as # TYPE <name> <kind>\n<name> <value>\n per metric, sorted by name. (Go tsnet exposes no metrics method of its own, so this is the fork’s clean public surface.) The registry is process-global, so the output covers every Device in the process.

Source

pub async fn whois(&self, addr: SocketAddr) -> Result<Option<WhoIs>, Error>

Map a tailnet source addr to the node that owns its IP (like tsnet’s WhoIs).

Only the IP of addr is used; the port is ignored. Returns Ok(None) if no tailnet node owns that address.

Source

pub async fn set_exit_node( &self, exit_node: Option<ExitNodeSelector>, ) -> Result<(), Error>

Change the selected exit node at runtime, without recreating the Device — the equivalent of Go tsnet’s LocalClient.EditPrefs(ExitNodeID/ExitNodeIP).

The peer may be named by stable node ID, tailnet IP, or MagicDNS name via ExitNodeSelector (a bare IP or name parses with selector.parse()); this is the same selector type as Config::exit_node, so the construction-time and runtime paths are identical. Passing None clears the exit node — internet-bound traffic is then dropped (fail-closed) unless this node egresses directly.

The change is applied immediately: the new selector is re-resolved against the live peer set and the outbound route + inbound source filter are recomputed at once. A selector for a peer not yet in the netmap simply takes effect once that peer appears.

Only NEW flows use the changed exit; in-flight connections are not torn down and continue egressing via the previously-selected exit until they close.

Source

pub fn exit_node(&self) -> Option<ExitNodeSelector>

The currently-selected exit node, or None if none is selected.

Source

pub async fn set_advertise_routes( &self, routes: Vec<IpNet>, ) -> Result<(), Error>

Change the subnet routes this node advertises at runtime — Go tailscale set --advertise-routes. This is the runtime equivalent of Config::advertise_routes: the node re-advertises the prefixes to control (so it is granted the subnet-router role for them) AND starts forwarding them on the data path, applied together so the two never disagree.

routes is filtered to the IPv4-only, deduplicated set this fork honors (IPv6 prefixes are dropped under the IPv6-off posture). This sets the explicit subnet prefixes only; it does not affect the exit-node 0.0.0.0/0 advertisement. Only NEW forwarded flows use the changed set; in-flight flows keep their existing routing until they close.

Source

pub async fn rebind(&self) -> Result<(), Error>

Re-bind the underlay UDP socket after a network/link change — Wi-Fi switch, sleep/wake, or any event that invalidates the device’s local address/NAT mapping. This is the Rust analog of Go magicsock’s Conn.Rebind().

The embedder owns deciding when to call this (it watches the OS for link changes — there is no built-in network monitor); rebind is the engine half that does the socket work:

  • Re-binds the underlay UDP socket, preferring the same local port (so the advertised endpoint stays stable) and falling back to an ephemeral port. The IPv4-only-by-default invariant is preserved.
  • Invalidates the now-stale local mapping: learned reflexive (STUN) addresses and every peer’s confirmed direct path are cleared, while candidate endpoints are kept — so peers are re-probed over the new socket and relay over DERP (never a direct host dial) until a path re-confirms. Endpoint discovery re-runs on its normal cadence.
  • Leaves peers, control, the netmap, disco keys, and DERP connections untouched; existing WireGuard sessions survive (they ride whatever underlay carries them).

A no-op if the underlay socket failed to bind at startup (the device is DERP-only). Existing connectivity is preserved on a re-bind error (the old socket is kept; the error is returned).

Source

pub fn active_exit_node(&self) -> Option<StableNodeId>

The stable id of the exit node traffic is currently egressing through, or None if none is engaged (the equivalent of Go tsnet’s Status.ExitNodeStatus.ID).

This differs from exit_node, which returns the configured selector: the active exit node is the route updater’s resolved, fail-closed answer. It is None when no exit node is configured, the configured selector matches no current peer, or the matched peer no longer advertises a default route (egress is then dropped, fail-closed). Match the id against Status::peers (via status) for details.

Source

pub async fn watch_netmap(&self) -> Result<Receiver<Vec<StatusNode>>, Error>

Watch for netmap changes: the returned receiver’s value is the current set of peer StatusNodes and updates on every netmap change (like subscribing to ipn notifications).

Source

pub fn device_state(&self) -> DeviceState

The current device connection-DeviceState (Connecting / Running / NeedsLogin / Expired / Failed).

Source

pub fn watch_state(&self) -> Receiver<DeviceState>

Watch the device connection-DeviceState, reacting push-style to control connection transitions instead of polling status.

Returns a tokio::sync::watch::Receiver; await its changed to be woken on each transition. The initial value is the current state.

Source

pub async fn wait_until_running( &self, timeout: Option<Duration>, ) -> Result<(), RegistrationError>

Wait until the device finishes registering, returning a typed outcome — the clean replacement for polling ipv4_addr in a loop.

Resolves Ok(()) once the device is DeviceState::Running. On a non-running outcome it returns a typed RegistrationError:

  • AuthRejected — bad/expired/unknown auth key; permanent (re-pair).
  • NeedsLogin — interactive authorization required; not permanent (the runtime keeps retrying and reaches Running once the user authorizes). Auth-key callers treat this as failure; interactive callers should ignore it and drive the flow via watch_state.
  • NetworkUnreachabletransient (retry).
  • Timeout — no settled state within timeout (None waits indefinitely).

KeyExpired is not produced here (a key expires only after the node is up); observe it via watch_state. Use RegistrationError::is_permanent to branch “re-pair” vs. “retry / drive login”.

Source

pub async fn ping( &self, dst: IpAddr, timeout: Duration, ) -> Result<Duration, PingError>

Ping a tailnet peer over the overlay with an ICMPv4 echo, returning the round-trip time (like tailscale ping).

The echo is sent from this device’s own tailnet IPv4 over the overlay netstack — never a host socket. IPv6 destinations return PingError::Ipv6Unsupported (this fork is IPv4-only on the tailnet). A peer answers from its own OS stack; this netstack does not auto-reply to echo requests.

In TUN transport mode there is no application netstack to ping from; this surfaces as PingError::Timeout (the same error this method already uses for an unavailable source address — PingError carries no dedicated “unsupported” variant).

Source

pub async fn direct_path( &self, dst: IpAddr, ) -> Result<Option<(SocketAddr, Duration)>, Error>

The current direct path to the peer at tailnet IP dst: its confirmed direct UDP endpoint and that path’s last-measured round-trip latency, or None when traffic to the peer is relayed via DERP (no trusted direct path right now), the peer is unknown, or it has no disco key.

This is the direct-path analog of Go’s tailscale ping/PeerStatus connectivity: a present result means packets reach the peer directly at the returned address, with roughly the returned RTT. The latency is a live snapshot taken from the most recent disco ping/pong that confirmed the path (up to one probe interval stale) — not a fresh on-demand round-trip. Unlike ping (an ICMP echo over the netstack), this reports the underlay path the data plane actually uses, distinguishing a direct connection from a DERP-relayed one.

Source

pub async fn ping_disco( &self, dst: IpAddr, timeout: Duration, ) -> Result<Option<(SocketAddr, Duration)>, Error>

Send a disco ping to the peer at tailnet IP dst now and await the pong — a fresh, on-demand round-trip measurement (Go’s tailscale ping, PingType::Disco). Returns the endpoint that answered and the measured RTT, or None if no pong arrives within timeout (or the peer is unknown / has no candidate direct path).

Unlike direct_path — which reports the last periodic probe’s RTT from cache — this actively sends a ping and waits for the reply, so the latency is current. A None here means “no direct path confirmed within the timeout” (the peer may still be reachable via DERP). Unlike ping (an ICMP echo over the netstack), this measures the disco/underlay path the data plane uses for direct connections.

Source

pub async fn get_certificate( &self, name: &str, ) -> Result<CertifiedKey, CertError>

See the no-acme variant for the contract; with acme this issues a real cert via the runtime’s ACME engine (Device → Runtime → ControlRunner → issue_certificate_via_setdns).

Source

pub async fn listen_tls( &self, cfg: &ServeConfig, ) -> Result<TlsAcceptor, CertError>

Build a TlsAcceptor terminating TLS for cfg.name on the overlay (like tsnet’s ListenTLS).

Obtains the certificate via Device::get_certificate — so with the acme feature this issues a real Let’s Encrypt cert (when the control plane answers set-dns), and without it (or when issuance is unavailable) it surfaces the same fail-closed ts_control::CertError rather than ever serving a self-signed cert or downgrading to plaintext. Terminate accepted overlay streams with ts_control::accept_tls.

Source

pub fn get_serve_config(&self) -> ServeState

The currently-stored Serve config (like tsnet’s GetServeConfig).

Returns the config last passed to Device::set_serve_config, or an empty ts_control::ServeState (no ports) if none was ever set. Pure read — does not touch the network.

Source

pub async fn set_serve_config( &self, state: ServeState, ) -> Result<ServeAcceptedReceiver, Error>

Replace this node’s Serve config and (re)bind its tailnet ports (like tsnet’s SetServeConfig, REPLACE semantics).

state becomes the whole config (full-replace reconcile: every previously-bound serve port’s accept loop is torn down and the new config’s ports are bound from scratch). For each configured port the manager binds an overlay listener on this node’s tailnet IPv4 and dispatches per ts_control::ServeTarget:

  • Accept — the TLS-terminated stream is handed back over the returned ServeAcceptedReceiver (the in-process stand-in for ListenTLS’s net.Listener).
  • Proxy — reverse-proxy the decrypted stream to a local host backend.
  • Text — write a fixed body and close.
  • TcpForward — forward the raw (non-TLS) stream to a local host backend.

Fail-closed. state.validate() runs first. Every TLS-terminating port’s acceptor is obtained up-front via Device::listen_tls (the ACME-aware cert path); if any cert cannot be issued the whole call fails with that ts_control::CertError and nothing is bound — a TLS port never downgrades to plaintext.

Anti-leak. Listeners bind the overlay netstack only (never a host socket). The Proxy/TcpForward backend dial is a local host socket to the embedder’s own backend (like Go’s reverse-proxy to 127.0.0.1), intentionally NOT routed through the exit-egress forwarder. A backend dial failure drops that connection; it never falls back.

Returns an error in TUN transport mode (there is no application netstack to bind on). The previous config’s accept loops (and any earlier ServeAcceptedReceiver) stop when this returns; the new receiver delivers every Accept-port connection.

Source

pub async fn listen_funnel( &self, cfg: &ServeConfig, opts: FunnelOptions, ) -> Result<FunnelAcceptedReceiver, FunnelError>

Expose a tailnet TLS service to the public internet via Tailscale Funnel (like tsnet’s ListenFunnel), returning a FunnelAcceptedReceiver that delivers each TLS-terminated public connection.

Two fail-closed gates, then the live ingress listener. First the node-attribute gate is fully enforced from this node’s own capability map (mirroring Go ipn.NodeCanFunnel + ipn.CheckFunnelPort): the tailnet admin must have enabled HTTPS and granted the funnel node attribute, and cfg.port must be in the set the funnel-ports capability allows — otherwise this returns ts_control::FunnelError::NotAllowed / ts_control::FunnelError::PortNotAllowed before touching any cert or network. Then the node’s *.ts.net certificate is obtained via the ACME-aware Device::get_certificate (the Funnel hostname is the node’s MagicDNS name, so its DNS-01 cert matches); fail-closed on ts_control::FunnelError::Cert — no self-signed or plaintext fallback.

On success a FunnelManager is registered: its ingress sink is installed into the runtime’s peerAPI /v0/ingress slot (making that route live without restarting the peerAPI server), and the HostInfo.IngressEnabled map-request signal is set so control routes Funnel traffic to this node. Public Funnel bytes arrive as a relay POST to /v0/ingress, are membership-gated + 101-hijacked into a raw stream, TLS-terminated by the manager, and delivered over the returned receiver.

Where the relay comes from. The public ingress relay + DNS mapping that feed /v0/ingress are Tailscale infrastructure (ts_control::MISSING_FUNNEL_RELAY), provisioned automatically against real Tailscale SaaS with a Funnel-enabled ACL; against a self-hosted control plane no relay exists, so the listener is correct but never fed.

Anti-leak: Funnel TLS terminates only on the overlay netstack (the hijacked ingress stream arrives on the overlay peerAPI listener), never a host socket; there is no self-signed or plaintext fallback. A new listen_funnel replaces the previous manager (its pump + sink tear down); dropping the Device tears it down too.

Source

pub async fn listen_service( &self, name: &str, mode: ServiceMode, ) -> Result<TcpListener, ServiceError>

Host a Tailscale VIP service (svc:<label>) by binding an overlay listener on the service’s control-assigned virtual IP (like tsnet’s ListenService).

Fail-closed. Mirrors Go tsnet.Server.ListenService’s preconditions, enforced from this node’s own netmap state (ts_control::resolve_service_listen): the name must be a valid svc:<dns-label>, this node must be tagged (Go ErrUntaggedServiceHost), and control must have assigned the service a VIP address on this node (delivered via the service-host node-capability — see ts_control::Node::service_addresses). Any unmet precondition returns a typed ts_control::ServiceError before binding anything.

When all hold, this binds a tcp_listen on the service VIP and the configured mode port over the overlay netstack (never a host socket) and returns the listener. The netstack already accepts packets for control-assigned VIPs (they are injected alongside the node’s own tailnet address), so the listener is reachable by tailnet peers.

The Tun/L3 service mode is unsupported (a TODO in upstream tsnet); only TCP/HTTP modes (which bind the same VIP:port at the listen layer) are offered. Returns an error in TUN transport mode (there is no application netstack to bind on).

Source

pub async fn shutdown(self, timeout: Option<Duration>) -> bool

Attempt to gracefully shut down this device’s runtime.

Reports whether the device was fully shut down before the timeout. It is still shut down if it timed out, just more violently and with potential resource leaks.

If timeout is None, then shutdown will never time-out.

Auto Trait Implementations§

§

impl !Freeze for Device

§

impl !RefUnwindSafe for Device

§

impl !UnwindSafe for Device

§

impl Send for Device

§

impl Sync for Device

§

impl Unpin for Device

§

impl UnsafeUnpin for Device

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> Downcast for T
where T: Any,

Source§

fn into_any(self: Box<T>) -> Box<dyn Any>

Converts Box<dyn Trait> (where Trait: Downcast) to Box<dyn Any>, which can then be downcast into Box<dyn ConcreteType> where ConcreteType implements Trait.
Source§

fn into_any_rc(self: Rc<T>) -> Rc<dyn Any>

Converts Rc<Trait> (where Trait: Downcast) to Rc<Any>, which can then be further downcast into Rc<ConcreteType> where ConcreteType implements Trait.
Source§

fn as_any(&self) -> &(dyn Any + 'static)

Converts &Trait (where Trait: Downcast) to &Any. This is needed since Rust cannot generate &Any’s vtable from &Trait’s.
Source§

fn as_any_mut(&mut self) -> &mut (dyn Any + 'static)

Converts &mut Trait (where Trait: Downcast) to &Any. This is needed since Rust cannot generate &mut Any’s vtable from &mut Trait’s.
Source§

impl<T> DowncastSend for T
where T: Any + Send,

Source§

fn into_any_send(self: Box<T>) -> Box<dyn Any + Send>

Converts Box<Trait> (where Trait: DowncastSend) to Box<dyn Any + Send>, which can then be downcast into Box<ConcreteType> where ConcreteType implements Trait.
Source§

impl<T> DowncastSync for T
where T: Any + Send + Sync,

Source§

fn into_any_sync(self: Box<T>) -> Box<dyn Any + Send + Sync>

Converts Box<Trait> (where Trait: DowncastSync) to Box<dyn Any + Send + Sync>, which can then be downcast into Box<ConcreteType> where ConcreteType implements Trait.
Source§

fn into_any_arc(self: Arc<T>) -> Arc<dyn Any + Send + Sync>

Converts Arc<Trait> (where Trait: DowncastSync) to Arc<Any>, which can then be downcast into Arc<ConcreteType> where ConcreteType implements Trait.
Source§

impl<A, T> DynMessage<A> for T
where A: Actor + Message<T>, T: Send + 'static,

Source§

fn handle_dyn<'a>( self: Box<T>, state: &'a mut A, actor_ref: ActorRef<A>, tx: Option<Sender<Result<Box<dyn Any + Send>, SendError<Box<dyn Any + Send>, Box<dyn Any + Send>>>>>, stop: &'a mut bool, ) -> Pin<Box<dyn Future<Output = Result<(), Box<dyn ReplyError>>> + Send + 'a>>

Handles the dyn message with the provided actor state, ref, and reply sender.
Source§

fn as_any(self: Box<T>) -> Box<dyn Any>

Casts the type to a Box<dyn Any>.
Source§

impl<T> ErasedDestructor for T
where T: 'static,

Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<A, B, T> HttpServerConnExec<A, B> for T
where B: Body,

Source§

impl<T> Instrument for T

Source§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided Span, returning an Instrumented wrapper. Read more
Source§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> IntoEither for T

Source§

fn into_either(self, into_left: bool) -> Either<Self, Self>

Converts self into a Left variant of Either<Self, Self> if into_left is true. Converts self into a Right variant of Either<Self, Self> otherwise. Read more
Source§

fn into_either_with<F>(self, into_left: F) -> Either<Self, Self>
where F: FnOnce(&Self) -> bool,

Converts self into a Left variant of Either<Self, Self> if into_left(&self) returns true. Converts self into a Right variant of Either<Self, Self> otherwise. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
Source§

impl<V, T> VZip<V> for T
where V: MultiLane<T>,

Source§

fn vzip(self) -> V

Source§

impl<T> WithSubscriber for T

Source§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a WithDispatch wrapper. Read more
Source§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a WithDispatch wrapper. Read more