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
18 changes: 13 additions & 5 deletions crates/truffle-core/src/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -371,13 +371,21 @@ const DNS_LABEL_LIMIT: usize = 63;
/// Budget computation for the slug lives here so every caller gets the same
/// answer.
pub fn tailscale_hostname(app_id: &AppId, device_name: &DeviceName) -> String {
// Budget = 63 - len("truffle-") - len(app_id) - 1 (for the separating hyphen).
let app_id_str = app_id.as_str();
let fixed_prefix_len = HOSTNAME_PREFIX.len() + app_id_str.len() + 1;
let budget = DNS_LABEL_LIMIT.saturating_sub(fixed_prefix_len);
prefixed_hostname(
&format!("{HOSTNAME_PREFIX}{}", app_id.as_str()),
device_name,
)
}

/// Compose `{prefix}-{slug}` (T8: an app-chosen prefix), the slug cut to what
/// the 63-character DNS label leaves after the prefix and its hyphen.
/// [`tailscale_hostname`] is this with the prefix `truffle-{app_id}`. The
/// prefix is the caller's to validate (`NodeBuilder::hostname_prefix`).
pub fn prefixed_hostname(prefix: &str, device_name: &DeviceName) -> String {
// Budget = 63 - len(prefix) - 1 (for the separating hyphen).
let budget = DNS_LABEL_LIMIT.saturating_sub(prefix.len() + 1);
let slug_part = slug(device_name.as_str(), budget);
format!("{HOSTNAME_PREFIX}{app_id_str}-{slug_part}")
format!("{prefix}-{slug_part}")
}

// ---------------------------------------------------------------------------
Expand Down
17 changes: 17 additions & 0 deletions crates/truffle-core/src/network/tailscale/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ pub(crate) struct StartCommandData {
/// sidecar's version before sending it.
#[serde(skip_serializing_if = "Option::is_none")]
pub client_log_upload: Option<bool>,
/// The node's app tag for `Hostinfo.App` (protocol 12), `truffle:{app_id}`:
/// peers admit this node by it rather than by the hostname prefix. Omitted
/// when None; an older sidecar ignores the field, and the node is then
/// recognised by its hostname alone.
#[serde(skip_serializing_if = "Option::is_none")]
pub app: Option<String>,
// NOTE: keep the manual Debug impl below in sync when adding fields.
}

Expand All @@ -73,6 +79,7 @@ impl std::fmt::Debug for StartCommandData {
.field("tags", &self.tags)
.field("idle_timeout_secs", &self.idle_timeout_secs)
.field("client_log_upload", &self.client_log_upload)
.field("app", &self.app)
.finish()
}
}
Expand Down Expand Up @@ -485,6 +492,13 @@ pub(crate) struct SidecarPeer {
/// admits no peer without one (`login_allowed` fails closed).
#[serde(default)]
pub login_name: Option<String>,
/// The peer's `Hostinfo.App` as control relays it (protocol 12). A truffle
/// node at protocol 12 announces `truffle:{app_id}`; `None` on an older
/// sidecar and for a peer that announces no app. When present it decides
/// app membership ([`super::provider::is_app_row`]); when absent the
/// hostname prefix does.
#[serde(default)]
pub app: Option<String>,
}

/// Data from `tsnet:peers` event.
Expand Down Expand Up @@ -746,6 +760,7 @@ mod tests {
tags: None,
idle_timeout_secs: None,
client_log_upload: None,
app: None,
};
let cmd = SidecarCommand {
command: command_type::START,
Expand Down Expand Up @@ -773,6 +788,7 @@ mod tests {
tags: None,
idle_timeout_secs: Some(300),
client_log_upload: None,
app: None,
};
let json = serde_json::to_string(&data).unwrap();
assert!(json.contains("\"idleTimeoutSecs\":300"));
Expand All @@ -790,6 +806,7 @@ mod tests {
tags: None,
idle_timeout_secs: None,
client_log_upload: None,
app: None,
};
let dbg = format!("{data:?}");
assert!(!dbg.contains("SECRET123"));
Expand Down
86 changes: 85 additions & 1 deletion crates/truffle-core/src/network/tailscale/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,18 @@ pub(crate) const KEY_EXPIRY_PROTOCOL_VERSION: u32 = 10;
/// embedded Tailscale version.
pub(crate) const LOGOUT_PROTOCOL_VERSION: u32 = 11;

/// The sidecar protocol that sets `Hostinfo.App` from `tsnet:start`'s `app`
/// and reports each peer's `Hostinfo.App` as `app` on its rows (T8).
pub(crate) const APP_TAG_PROTOCOL_VERSION: u32 = 12;

/// The prefix of the app tag a truffle node announces in `Hostinfo.App`.
pub(crate) const APP_TAG_PREFIX: &str = "truffle:";

/// The app tag a node of `app_id` announces in `Hostinfo.App`: `truffle:{app_id}`.
pub(crate) fn app_tag(app_id: &str) -> String {
format!("{APP_TAG_PREFIX}{app_id}")
}

/// How long [`TailscaleProvider::logout`] waits for the control server to
/// take the logout before it stops the node anyway (the sidecar's own bound
/// is 10 s).
Expand Down Expand Up @@ -486,7 +498,7 @@ impl TailscaleProvider {
app_id: &str,
login_allow: &[String],
) -> RowVerdict {
if !is_app_peer(&peer.hostname, app_id) {
if !is_app_row(&peer.hostname, peer.app.as_deref(), app_id) {
return RowVerdict::NotOurApp;
}
if login_allow.is_empty() {
Expand Down Expand Up @@ -1129,11 +1141,34 @@ impl TailscaleProvider {
/// has no trailing slug is rejected — we require at least one character
/// after the separator so that `truffle-playground-` (empty slug edge)
/// cannot masquerade as a real peer.
///
/// T8: this is the fallback for a row with no `Hostinfo.App` tag; a tagged
/// row is decided by [`is_app_row`].
pub(crate) fn is_app_peer(hostname: &str, app_id: &str) -> bool {
let prefix = format!("truffle-{app_id}-");
hostname.len() > prefix.len() && hostname.starts_with(&prefix)
}

/// Whether one sidecar row belongs to this app (T8).
///
/// A row that carries a tag decides by it: `Hostinfo.App` must be exactly
/// `truffle:{app_id}`, whatever the hostname, so an app may choose its own
/// prefix and a node of another app under the same prefix stays out. A row
/// with no tag (a sidecar below protocol 12, or a peer that announces no
/// app) falls back to the `truffle-{app_id}-` hostname prefix
/// ([`is_app_peer`]). An empty tag is the same fact as none.
///
/// The tag is the peer's own claim, exactly as its hostname is: it widens
/// nothing a same-tailnet node could not already claim by its name.
pub(crate) fn is_app_row(hostname: &str, app: Option<&str>, app_id: &str) -> bool {
match app.filter(|a| !a.is_empty()) {
Some(tag) => tag
.strip_prefix(APP_TAG_PREFIX)
.is_some_and(|id| id == app_id),
None => is_app_peer(hostname, app_id),
}
}

/// What one sidecar peer row means under the node's login gate (RFC 025 §3.3).
///
/// Three-valued on purpose: "may this row be admitted?" and "may a peer we
Expand Down Expand Up @@ -1225,6 +1260,9 @@ impl super::super::NetworkProvider for TailscaleProvider {
tags: self.config.tags.clone(),
idle_timeout_secs: self.config.idle_timeout_secs,
client_log_upload: (!self.config.client_log_upload).then_some(false),
// T8: always announced. A sidecar below protocol 12 ignores it,
// and peers then know this node by its hostname alone.
app: Some(app_tag(&self.config.app_id)),
};

// Spawn the sidecar
Expand Down Expand Up @@ -1329,6 +1367,26 @@ impl super::super::NetworkProvider for TailscaleProvider {
return Err(e);
}

// T8: a name outside `truffle-{app_id}-` is recognised by the
// tag alone, and a sidecar below protocol 12 cannot announce
// it. Say so; the node itself runs (peers it can see are
// unaffected), as an explicit hostname always has.
let version = self
.sidecar_protocol_version
.load(std::sync::atomic::Ordering::Relaxed);
if version < APP_TAG_PROTOCOL_VERSION
&& !is_app_peer(&self.config.hostname, &self.config.app_id)
{
tracing::warn!(
hostname = %self.config.hostname,
protocol = version,
"peers of this app will not recognise this node: its hostname \
is outside truffle-{}- and its sidecar cannot announce \
Hostinfo.App (protocol {APP_TAG_PROTOCOL_VERSION})",
self.config.app_id
);
}

// Fetch initial peer list
if let Some(ref sidecar) = *self.sidecar.lock().await {
let _ = sidecar.send_get_peers().await;
Expand Down Expand Up @@ -2617,6 +2675,7 @@ mod login_gate_tests {
key_expiry: None,
expired: false,
login_name: login.map(Into::into),
app: None,
}
}

Expand Down Expand Up @@ -4196,6 +4255,31 @@ done"#,
assert!(sent.iter().any(|c| c == "tsnet:logout"), "{sent:?}");
}

/// T8: every start announces the node's app tag, so a peer can admit it
/// by `Hostinfo.App` whatever its hostname. The tag is `truffle:{app_id}`.
#[tokio::test]
async fn the_start_carries_the_app_tag() {
let dir = tempfile::tempdir().unwrap();
let log = dir.path().join("commands.log");
let script = v11_script(dir.path(), &log, RUNNING_V11, " :");
let mut provider = TailscaleProvider::new(config(script, dir.path()));
tokio::time::timeout(START_GUARD, provider.start())
.await
.expect("start must complete")
.expect("start");
let start_line = std::fs::read_to_string(&log)
.unwrap()
.lines()
.find(|l| l.contains(r#""tsnet:start""#))
.expect("a tsnet:start line")
.to_string();
let _ = provider.stop().await;
assert!(
start_line.contains(r#""app":"truffle:playground""#),
"the app tag rides tsnet:start: {start_line}"
);
}

/// An older sidecar would ignore the opt-out and upload anyway, so it is
/// refused before tsnet:start is ever sent.
#[tokio::test]
Expand Down
7 changes: 7 additions & 0 deletions crates/truffle-core/src/network/tailscale/sidecar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ pub(crate) struct SidecarConfig {
/// `Some(false)` stops tsnet's client-log upload (protocol 11); `None`
/// keeps Tailscale's default.
pub client_log_upload: Option<bool>,
/// The app tag the sidecar sets as `Hostinfo.App` before it starts
/// (protocol 12), `truffle:{app_id}`.
pub app: Option<String>,
}

/// Manual `Debug`: `auth_key` (tailnet credential) and `session_token_hex`
Expand All @@ -59,6 +62,7 @@ impl std::fmt::Debug for SidecarConfig {
.field("tags", &self.tags)
.field("idle_timeout_secs", &self.idle_timeout_secs)
.field("client_log_upload", &self.client_log_upload)
.field("app", &self.app)
.finish()
}
}
Expand Down Expand Up @@ -864,6 +868,7 @@ impl GoSidecar {
tags: config.tags.clone(),
idle_timeout_secs: config.idle_timeout_secs,
client_log_upload: config.client_log_upload,
app: config.app.clone(),
};
self.send_command(SidecarCommand {
command: command_type::START,
Expand Down Expand Up @@ -1150,6 +1155,7 @@ mod config_debug_tests {
tags: None,
idle_timeout_secs: None,
client_log_upload: None,
app: None,
};
let dbg = format!("{config:?}");
assert!(!dbg.contains("SECRET123"));
Expand Down Expand Up @@ -1269,6 +1275,7 @@ mod pid_tests {
tags: None,
idle_timeout_secs: None,
client_log_upload: None,
app: None,
}
}

Expand Down
Loading
Loading