Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ pub enum Error {
#[error("the environment variable `{}` was not set", crate::ENV_MAGIC_VAR)]
UnstableEnvVar,

/// No exit-node suggestion could be made because this node has no measured preferred DERP
/// region yet — the Rust analog of Go's `ErrNoPreferredDERP` ("no preferred DERP, try again
/// later"). Returned by [`Device::suggest_exit_node`](crate::Device::suggest_exit_node) before
/// the first netcheck has completed; callers should treat it as transient and retry once
/// connectivity has been measured. Distinct from a *successful* "no suggestion" (an empty
/// candidate set), which is `Ok(None)`.
#[error("no preferred DERP, try again later")]
NoPreferredDerp,

/// An error occurred which can not be anticipated or handled by a library user.
///
/// This is likely due to a bug in our code or a rare and unexpected error.
Expand All @@ -43,6 +52,14 @@ pub enum Error {
Internal(InternalErrorKind),
}

impl From<ts_runtime::SuggestExitNodeError> for Error {
fn from(value: ts_runtime::SuggestExitNodeError) -> Self {
match value {
ts_runtime::SuggestExitNodeError::NoPreferredDerp => Error::NoPreferredDerp,
}
}
}

/// Informational detail on the kind of internal error.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
Expand Down
35 changes: 33 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,8 @@ pub use ts_runtime::fallback_tcp::{
pub use ts_runtime::taildrop::WaitingFile;
#[doc(inline)]
pub use ts_runtime::{
DeviceState, DnsQueryResult, FileTarget, IpnBusWatcher, NetcheckReport, Notify, NotifyWatchOpt,
RegionLatency, RegistrationError, Status, StatusNode, WhoIs,
DeviceState, DnsQueryResult, ExitNodeSuggestion, FileTarget, IpnBusWatcher, NetcheckReport,
Notify, NotifyWatchOpt, RegionLatency, RegistrationError, Status, StatusNode, WhoIs,
};
/// The interactive-login URL type returned by [`Device::pop_browser_url`].
#[doc(inline)]
Expand Down Expand Up @@ -924,6 +924,37 @@ impl Device {
.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.
///
Expand Down
9 changes: 5 additions & 4 deletions ts_control/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,11 @@ pub use ssh_policy::{
};
pub use tka::TkaStatus;
pub use ts_control_serde::{
Endpoint, EndpointType, TkaBootstrapRequest, TkaBootstrapResponse, TkaDisableRequest,
TkaDisableResponse, TkaInitBeginRequest, TkaInitBeginResponse, TkaInitFinishRequest,
TkaInitFinishResponse, TkaSignInfo, TkaSubmitSignatureRequest, TkaSubmitSignatureResponse,
TkaSyncOfferRequest, TkaSyncOfferResponse, TkaSyncSendRequest, TkaSyncSendResponse, UserId,
Endpoint, EndpointType, NODE_ATTR_SUGGEST_EXIT_NODE, TkaBootstrapRequest, TkaBootstrapResponse,
TkaDisableRequest, TkaDisableResponse, TkaInitBeginRequest, TkaInitBeginResponse,
TkaInitFinishRequest, TkaInitFinishResponse, TkaSignInfo, TkaSubmitSignatureRequest,
TkaSubmitSignatureResponse, TkaSyncOfferRequest, TkaSyncOfferResponse, TkaSyncSendRequest,
TkaSyncSendResponse, UserId,
};
#[cfg(feature = "identity-federation")]
pub use wif::{WifConfig, WifError, resolve_auth_key};
Expand Down
4 changes: 2 additions & 2 deletions ts_control_serde/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ pub use ping::{PingRequest, PingResponse, PingType};
pub use register::{RegisterAuth, RegisterRequest, RegisterResponse, SignatureType};
pub use service::{Service, ServiceProto};
pub use service_vip::{
C2NVIPServicesResponse, NODE_ATTR_SERVICE_HOST, ProtoPortRange, SERVICE_NAME_PREFIX,
ServiceIpMappings, ServiceName, VipService, VipServiceOwned,
C2NVIPServicesResponse, NODE_ATTR_SERVICE_HOST, NODE_ATTR_SUGGEST_EXIT_NODE, ProtoPortRange,
SERVICE_NAME_PREFIX, ServiceIpMappings, ServiceName, VipService, VipServiceOwned,
};
pub use set_dns::{SetDnsRequest, SetDnsResponse};
pub use ssh_policy::{SSHAction, SSHPolicy, SSHPrincipal, SSHRecorderFailureAction, SSHRule};
Expand Down
8 changes: 8 additions & 0 deletions ts_control_serde/src/service_vip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ use serde::{Deserialize, Serialize};
/// to host VIP services. The cap's value deserializes as [`ServiceIpMappings`].
pub const NODE_ATTR_SERVICE_HOST: &str = "service-host";

/// The node-capability key marking a peer as eligible to be *suggested* as an exit node
/// (`tailcfg.NodeAttrSuggestExitNode`). Control sets it on the exit-node candidates a client may
/// auto-pick from; the exit-node suggestion algorithm (`Device::suggest_exit_node`) requires this
/// cap in a peer's `CapMap` for the peer to be a candidate. The cap's value is empty (the key's
/// presence is the whole signal), so unlike [`NODE_ATTR_SERVICE_HOST`] it carries no payload to
/// deserialize — consumers check key presence via `Node::has_node_attr`.
pub const NODE_ATTR_SUGGEST_EXIT_NODE: &str = "suggest-exit-node";

/// The `svc:` prefix every [`ServiceName`] carries (`tailcfg` `serviceNamePrefix`).
pub const SERVICE_NAME_PREFIX: &str = "svc:";

Expand Down
Loading