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
1 change: 1 addition & 0 deletions crates/truffle-core/examples/peer_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ async fn main() {
idle_timeout_secs: None,
start_timeout: Some(truffle_core::network::tailscale::DEFAULT_START_TIMEOUT),
login_allow: Vec::new(),
client_log_upload: true,
};
let mut provider = TailscaleProvider::new(config);

Expand Down
1 change: 1 addition & 0 deletions crates/truffle-core/examples/transport_peer_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ async fn create_provider(role: &str) -> TailscaleProvider {
idle_timeout_secs: None,
start_timeout: Some(truffle_core::network::tailscale::DEFAULT_START_TIMEOUT),
login_allow: Vec::new(),
client_log_upload: true,
};

let mut provider = TailscaleProvider::new(config);
Expand Down
114 changes: 114 additions & 0 deletions crates/truffle-core/src/network/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,120 @@ pub enum NetworkStatusEvent {
/// The process exit code; `None` when a signal ended it.
exit_code: Option<i32>,
},
/// The node's Tailscale backend changed state after it came up.
///
/// The one to act on is [`BackendState::NeedsLogin`]: the node's key
/// expired, or an admin removed or logged out the device in the console,
/// and it is off the tailnet until someone logs it in again. Since
/// sidecar protocol 11 a change arrives within about a second; older
/// sidecars notice it at their next once-a-minute check.
BackendStateChanged {
/// The new state.
state: BackendState,
},
/// The tailnet requires client logging, and this node opted out of it
/// with [`NodeBuilder::client_log_upload(false)`](crate::NodeBuilder::client_log_upload)
/// (sidecar protocol 11).
///
/// A tailnet with network flow logs on refuses a node that does not send
/// Tailscale its client logs: Tailscale stops the node rather than run it
/// unlogged, and records that it should stay down. While `start()` waits,
/// it fails right away; a node already running when an admin turns flow
/// logs on stops. Either way it stays down until the node is built again:
/// with uploads allowed, or after the tailnet's flow logs are turned off.
/// Tell the user which.
LoggingRequired,
/// Tailnet Lock is on and this node's key is not signed, so the node is
/// locked out (sidecar protocol 11).
///
/// Unlike [`NeedsApproval`](Self::NeedsApproval), a locked-out node does
/// come up — `start()` returns — but it reaches no peer, and no peer
/// reaches it, until a signing node signs its key (`tailscale lock sign`).
/// Raised before `start()` returns when the node starts unsigned, and
/// whenever it becomes unsigned later. [`Signed`](Self::Signed) follows
/// once it is signed.
NeedsSignature,
/// A node that [needed a signature](Self::NeedsSignature) is no longer
/// locked out: a signing node signed it, or Tailnet Lock was turned off
/// (sidecar protocol 11).
Signed,
}

/// A Tailscale backend state (Tailscale's `ipn.State`), as
/// [`NetworkStatusEvent::BackendStateChanged`] reports it.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum BackendState {
/// The backend has not started.
NoState,
/// The node must log in: its key expired, or it was logged out or
/// removed in the admin console.
NeedsLogin,
/// The node waits for an admin to approve it (device approval).
NeedsMachineAuth,
/// The node is stopped. A node that opted out of client logging on a
/// tailnet that requires them is stopped (see
/// [`NetworkStatusEvent::LoggingRequired`]).
Stopped,
/// The node is connecting.
Starting,
/// The node is on the tailnet.
Running,
/// A state this version of truffle does not know, as Tailscale named it.
Other(String),
}

impl BackendState {
/// Read Tailscale's name for a state (`ipn.State.String()`).
pub fn from_wire(state: &str) -> BackendState {
match state {
"NoState" => BackendState::NoState,
"NeedsLogin" => BackendState::NeedsLogin,
"NeedsMachineAuth" => BackendState::NeedsMachineAuth,
"Stopped" => BackendState::Stopped,
"Starting" => BackendState::Starting,
"Running" => BackendState::Running,
other => BackendState::Other(other.to_string()),
}
}

/// Tailscale's name for the state.
pub fn as_str(&self) -> &str {
match self {
BackendState::NoState => "NoState",
BackendState::NeedsLogin => "NeedsLogin",
BackendState::NeedsMachineAuth => "NeedsMachineAuth",
BackendState::Stopped => "Stopped",
BackendState::Starting => "Starting",
BackendState::Running => "Running",
BackendState::Other(other) => other,
}
}
}

/// The Tailscale client a node's sidecar embeds (sidecar protocol 11).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct TailscaleVersion {
/// The Tailscale release, e.g. `1.104.1`: what to show a person, and
/// what to compare when Tailscale's admin console says a client is out
/// of date.
pub version: String,
/// The full version string the node reports to its control server, as
/// the admin console lists it, e.g. `1.104.1-dev20261020-g0123abcd`
/// (the suffix names the embedding build, not a Tailscale build).
pub long: String,
}

impl TailscaleVersion {
/// Read the sidecar's pair; `None` when it did not name a release.
pub(crate) fn from_wire(version: Option<&str>, long: Option<&str>) -> Option<TailscaleVersion> {
let version = version.map(str::trim).filter(|v| !v.is_empty())?;
Some(TailscaleVersion {
version: version.to_string(),
long: long.map(str::trim).unwrap_or_default().to_string(),
})
}
}

/// When a node's key expires.
Expand Down
2 changes: 1 addition & 1 deletion crates/truffle-core/src/network/tailscale/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,4 @@ mod sidecar;
mod tests;

pub use bridge::BridgeStats;
pub use provider::{TailscaleConfig, TailscaleProvider, DEFAULT_START_TIMEOUT};
pub use provider::{TailscaleConfig, TailscaleProvider, DEFAULT_START_TIMEOUT, LOGOUT_TIMEOUT};
51 changes: 51 additions & 0 deletions crates/truffle-core/src/network/tailscale/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ pub(crate) struct StartCommandData {
/// when None so old sidecars ignore it (RFC 021 §6.5).
#[serde(skip_serializing_if = "Option::is_none")]
pub idle_timeout_secs: Option<u64>,
/// `Some(false)` stops tsnet's client-log upload (protocol 11). Omitted
/// otherwise; an older sidecar would ignore it, so the provider asks the
/// sidecar's version before sending it.
#[serde(skip_serializing_if = "Option::is_none")]
pub client_log_upload: Option<bool>,
// NOTE: keep the manual Debug impl below in sync when adding fields.
}

Expand All @@ -67,10 +72,19 @@ impl std::fmt::Debug for StartCommandData {
.field("ephemeral", &self.ephemeral)
.field("tags", &self.tags)
.field("idle_timeout_secs", &self.idle_timeout_secs)
.field("client_log_upload", &self.client_log_upload)
.finish()
}
}

/// Data payload for a command that carries only a correlation id
/// (`tsnet:logout`, `tsnet:version`; protocol 11).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct RequestCommandData {
pub request_id: String,
}

/// Data payload for `bridge:dial`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
Expand Down Expand Up @@ -269,6 +283,8 @@ pub(crate) struct EgressProxyCommandData {
pub(crate) mod command_type {
pub const START: &str = "tsnet:start";
pub const STOP: &str = "tsnet:stop";
pub const LOGOUT: &str = "tsnet:logout";
pub const VERSION: &str = "tsnet:version";
pub const GET_PEERS: &str = "tsnet:getPeers";
pub const DIAL: &str = "bridge:dial";
pub const LISTEN: &str = "tsnet:listen";
Expand Down Expand Up @@ -310,6 +326,10 @@ pub(crate) mod event_type {
pub const AUTH_REQUIRED: &str = "tsnet:authRequired";
pub const NEEDS_APPROVAL: &str = "tsnet:needsApproval";
pub const STATE_CHANGE: &str = "tsnet:stateChange";
pub const LOGGED_OUT: &str = "tsnet:loggedOut";
pub const VERSION_RESULT: &str = "tsnet:versionResult";
pub const LOCK_STATUS: &str = "tsnet:lockStatus";
pub const LOGGING_REQUIRED: &str = "tsnet:loggingRequired";
pub const KEY_EXPIRING: &str = "tsnet:keyExpiring";
pub const HEALTH_WARNING: &str = "tsnet:healthWarning";
pub const PEERS: &str = "tsnet:peers";
Expand Down Expand Up @@ -374,6 +394,34 @@ pub(crate) struct StatusEventData {
/// status means key expiry is disabled; older sidecars never send it.
#[serde(default)]
pub key_expiry: Option<String>,
/// The Tailscale release the sidecar embeds, e.g. `1.104.1` (protocol
/// 11), on every status.
#[serde(default)]
pub tailscale_version: Option<String>,
/// The version string the node reports to its control server (protocol
/// 11).
#[serde(default)]
pub tailscale_version_long: Option<String>,
}

/// Data from `tsnet:versionResult` (protocol 11).
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct VersionResultEventData {
pub protocol_version: u32,
#[serde(default)]
pub tailscale_version: Option<String>,
#[serde(default)]
pub tailscale_version_long: Option<String>,
}

/// Data from `tsnet:lockStatus` (protocol 11): Tailnet Lock is on for the
/// tailnet, and whether this node's key is signed.
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LockStatusEventData {
pub enabled: bool,
pub signed: bool,
}

/// Data from `tsnet:authRequired` event.
Expand Down Expand Up @@ -697,6 +745,7 @@ mod tests {
ephemeral: None,
tags: None,
idle_timeout_secs: None,
client_log_upload: None,
};
let cmd = SidecarCommand {
command: command_type::START,
Expand All @@ -723,6 +772,7 @@ mod tests {
ephemeral: None,
tags: None,
idle_timeout_secs: Some(300),
client_log_upload: None,
};
let json = serde_json::to_string(&data).unwrap();
assert!(json.contains("\"idleTimeoutSecs\":300"));
Expand All @@ -739,6 +789,7 @@ mod tests {
ephemeral: None,
tags: None,
idle_timeout_secs: None,
client_log_upload: None,
};
let dbg = format!("{data:?}");
assert!(!dbg.contains("SECRET123"));
Expand Down
Loading
Loading