diff --git a/crates/truffle-core/src/identity.rs b/crates/truffle-core/src/identity.rs index f296300..fb1f074 100644 --- a/crates/truffle-core/src/identity.rs +++ b/crates/truffle-core/src/identity.rs @@ -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}") } // --------------------------------------------------------------------------- diff --git a/crates/truffle-core/src/network/tailscale/protocol.rs b/crates/truffle-core/src/network/tailscale/protocol.rs index 9794d7b..5fbfb82 100644 --- a/crates/truffle-core/src/network/tailscale/protocol.rs +++ b/crates/truffle-core/src/network/tailscale/protocol.rs @@ -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, + /// 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, // NOTE: keep the manual Debug impl below in sync when adding fields. } @@ -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() } } @@ -485,6 +492,13 @@ pub(crate) struct SidecarPeer { /// admits no peer without one (`login_allowed` fails closed). #[serde(default)] pub login_name: Option, + /// 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, } /// Data from `tsnet:peers` event. @@ -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, @@ -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")); @@ -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")); diff --git a/crates/truffle-core/src/network/tailscale/provider.rs b/crates/truffle-core/src/network/tailscale/provider.rs index a3c7343..f8219e3 100644 --- a/crates/truffle-core/src/network/tailscale/provider.rs +++ b/crates/truffle-core/src/network/tailscale/provider.rs @@ -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). @@ -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() { @@ -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 @@ -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 @@ -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; @@ -2617,6 +2675,7 @@ mod login_gate_tests { key_expiry: None, expired: false, login_name: login.map(Into::into), + app: None, } } @@ -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] diff --git a/crates/truffle-core/src/network/tailscale/sidecar.rs b/crates/truffle-core/src/network/tailscale/sidecar.rs index a7d0c17..d54546b 100644 --- a/crates/truffle-core/src/network/tailscale/sidecar.rs +++ b/crates/truffle-core/src/network/tailscale/sidecar.rs @@ -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, + /// The app tag the sidecar sets as `Hostinfo.App` before it starts + /// (protocol 12), `truffle:{app_id}`. + pub app: Option, } /// Manual `Debug`: `auth_key` (tailnet credential) and `session_token_hex` @@ -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() } } @@ -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, @@ -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")); @@ -1269,6 +1275,7 @@ mod pid_tests { tags: None, idle_timeout_secs: None, client_log_upload: None, + app: None, } } diff --git a/crates/truffle-core/src/network/tailscale/tests.rs b/crates/truffle-core/src/network/tailscale/tests.rs index e643112..591ce46 100644 --- a/crates/truffle-core/src/network/tailscale/tests.rs +++ b/crates/truffle-core/src/network/tailscale/tests.rs @@ -190,6 +190,7 @@ fn command_start_serialization() { tags: Some(vec!["tag:truffle".to_string()]), idle_timeout_secs: None, client_log_upload: None, + app: None, }; let cmd = SidecarCommand { command: command_type::START, @@ -452,6 +453,7 @@ fn filter_app_peers_only() { key_expiry: None, expired: false, login_name: Some("alice@example.com".into()), + app: None, }, // Different app — rejected. SidecarPeer { @@ -469,6 +471,7 @@ fn filter_app_peers_only() { key_expiry: None, expired: false, login_name: Some("bob@example.com".into()), + app: None, }, // Non-truffle host — rejected. SidecarPeer { @@ -486,6 +489,7 @@ fn filter_app_peers_only() { key_expiry: None, expired: false, login_name: Some("carol@example.com".into()), + app: None, }, // Empty-slug edge (`truffle-playground-` with nothing after the // hyphen) — rejected per `is_app_peer` requiring trailing content. @@ -504,6 +508,7 @@ fn filter_app_peers_only() { key_expiry: None, expired: false, login_name: Some("alice@example.com".into()), + app: None, }, // Another playground peer — kept. SidecarPeer { @@ -521,6 +526,7 @@ fn filter_app_peers_only() { key_expiry: None, expired: false, login_name: Some("bob@example.com".into()), + app: None, }, ]; @@ -562,6 +568,7 @@ fn peer_row(id: &str, hostname: &str, login: Option<&str>) -> SidecarPeer { key_expiry: None, expired: false, login_name: login.map(Into::into), + app: None, } } @@ -1249,3 +1256,80 @@ fn udp_capability_matches_go_fixture_and_is_binding_specific() { super::provider::udp_relay_token(&[18; 32], "bind-123") ); } + +// ── T8: app membership from the Hostinfo tag, the prefix as fallback ── + +#[test] +fn a_present_tag_decides_membership_whatever_the_hostname() { + use super::provider::is_app_row; + // A protocol-12 peer of this app with an app-chosen prefix is ours. + assert!(is_app_row( + "vibefield-studio", + Some("truffle:playground"), + "playground" + )); + // A tag naming another app is not ours, even under our hostname prefix. + assert!(!is_app_row( + "truffle-playground-alice", + Some("truffle:chat"), + "playground" + )); + // A tag that is not truffle's at all is not ours either. + assert!(!is_app_row( + "truffle-playground-alice", + Some("golinks"), + "playground" + )); + // A same-prefix node of a different app is NOT admitted (the petition's + // third verification). + assert!(!is_app_row( + "vibefield-bob", + Some("truffle:chat"), + "playground" + )); +} + +#[test] +fn no_tag_falls_back_to_the_hostname_prefix() { + use super::provider::is_app_row; + // An older sidecar (below protocol 12) reports no tag: the prefix decides. + assert!(is_app_row("truffle-playground-alice", None, "playground")); + assert!(!is_app_row("vibefield-studio", None, "playground")); + // An empty tag is the same fact as none. + assert!(is_app_row( + "truffle-playground-alice", + Some(""), + "playground" + )); + assert!(!is_app_row("truffle-chat-alice", Some(""), "playground")); +} + +#[test] +fn classify_row_admits_a_tagged_peer_with_a_custom_prefix() { + let mut row = peer_row("node9", "vibefield-studio", Some("alice@example.com")); + assert_eq!( + TailscaleProvider::classify_row(&row, "playground", &[]), + super::provider::RowVerdict::NotOurApp, + "untagged, the custom prefix is a stranger" + ); + row.app = Some("truffle:playground".into()); + assert_eq!( + TailscaleProvider::classify_row(&row, "playground", &[]), + super::provider::RowVerdict::Allowed + ); +} + +#[test] +fn a_peer_row_reads_app_from_the_wire() { + let row: SidecarPeer = serde_json::from_str( + r#"{"id":"n1","hostname":"vibefield-studio","dnsName":"vibefield-studio.tailnet.ts.net", + "tailscaleIPs":["100.64.0.2"],"online":true,"app":"truffle:playground"}"#, + ) + .unwrap(); + assert_eq!(row.app.as_deref(), Some("truffle:playground")); + let old: SidecarPeer = serde_json::from_str( + r#"{"id":"n1","hostname":"truffle-playground-a","dnsName":"x","tailscaleIPs":[],"online":true}"#, + ) + .unwrap(); + assert_eq!(old.app, None, "a protocol-11 row has no app"); +} diff --git a/crates/truffle-core/src/node.rs b/crates/truffle-core/src/node.rs index 7908f54..9c1bfc9 100644 --- a/crates/truffle-core/src/node.rs +++ b/crates/truffle-core/src/node.rs @@ -348,6 +348,9 @@ pub struct Node { stopped: std::sync::atomic::AtomicBool, /// Structured ownership of node-scoped background tasks. pub(crate) tasks: NodeTasks, + /// The app-chosen hostname prefix (T8), when the builder set one: a + /// hello-less peer named `{prefix}-{slug}` resolves by its slug. + hostname_prefix: Option, } /// Structured ownership of the node's background tasks. @@ -425,6 +428,7 @@ impl Node { proxy_state: Arc::new(crate::proxy::ProxyState::new()), stopped: std::sync::atomic::AtomicBool::new(false), tasks: NodeTasks::new(), + hostname_prefix: None, }; // Spawn the envelope router task. @@ -735,6 +739,13 @@ impl Node { self } + /// Record the app-chosen hostname prefix (called by the builder, T8), so + /// a hello-less peer named `{prefix}-{slug}` resolves by its slug. + pub(crate) fn with_hostname_prefix(mut self, prefix: Option) -> Self { + self.hostname_prefix = prefix; + self + } + /// Record the session WebSocket port (called by the builder) so the /// reserved-port guard tracks a customized `ws_port`. pub(crate) fn with_ws_port(mut self, port: u16) -> Self { @@ -829,7 +840,7 @@ impl Node { .peers() .await .into_iter() - .map(|s| Self::project_peer(s, &app_id)) + .map(|s| Self::project_peer(s, &app_id, self.hostname_prefix.as_deref())) .collect() } @@ -850,14 +861,14 @@ impl Node { } /// Project a session [`PeerState`] to the public [`Peer`] view. - fn project_peer(s: PeerState, app_id: &str) -> Peer { + fn project_peer(s: PeerState, app_id: &str, prefix: Option<&str>) -> Peer { // RFC 022: `device_name` stays None until identity is known. // Prefer a stripped hostname slug for `display_name` when we // have no hello name yet (raw-transport / pre-identity peers). let bare = s .identity .is_none() - .then(|| hostname_slug(&s.name, app_id).map(str::to_string)) + .then(|| hostname_slug(&s.name, app_id, prefix).map(str::to_string)) .flatten(); let mut peer = Peer::from(s); if let Some(bare) = bare { @@ -897,7 +908,11 @@ impl Node { match self.resolve_peer(query).await { Ok(state) => { let app_id = self.network.local_identity().app_id; - return Ok(Some(Self::project_peer(state, &app_id))); + return Ok(Some(Self::project_peer( + state, + &app_id, + self.hostname_prefix.as_deref(), + ))); } Err(NodeError::PeerNotFound(_)) => {} Err(e) => return Err(e), @@ -916,7 +931,11 @@ impl Node { match self.resolve_peer(query).await { Ok(state) => { let app_id = self.network.local_identity().app_id; - return Ok(Some(Self::project_peer(state, &app_id))); + return Ok(Some(Self::project_peer( + state, + &app_id, + self.hostname_prefix.as_deref(), + ))); } Err(NodeError::PeerNotFound(_)) => {} Err(e) => return Err(e), @@ -932,7 +951,11 @@ impl Node { Ok(Ok(_)) => match self.resolve_peer(query).await { Ok(state) => { let app_id = self.network.local_identity().app_id; - return Ok(Some(Self::project_peer(state, &app_id))); + return Ok(Some(Self::project_peer( + state, + &app_id, + self.hostname_prefix.as_deref(), + ))); } Err(NodeError::PeerNotFound(_)) => continue, Err(e) => return Err(e), @@ -998,7 +1021,10 @@ impl Node { if !ref_slug.is_empty() { let slug_hits: Vec<&PeerState> = peers .iter() - .filter(|p| hostname_slug(&p.name, &app_id) == Some(ref_slug.as_str())) + .filter(|p| { + hostname_slug(&p.name, &app_id, self.hostname_prefix.as_deref()) + == Some(ref_slug.as_str()) + }) .collect(); if slug_hits.len() > 1 { return Err(NodeError::AmbiguousPeer { @@ -1633,11 +1659,21 @@ impl Drop for Node { /// The bare device-name slug from a Layer 3 hostname, when it follows this /// app's `truffle-{app_id}-{slug}` convention (RFC 017). `None` otherwise. -fn hostname_slug<'a>(hostname: &'a str, app_id: &str) -> Option<&'a str> { +/// +/// T8: under an app-chosen `prefix`, `{prefix}-{slug}` resolves too; the +/// convention still does, for a peer on an older release. +fn hostname_slug<'a>(hostname: &'a str, app_id: &str, prefix: Option<&str>) -> Option<&'a str> { + let under = |p: &str| { + hostname + .strip_prefix(p)? + .strip_prefix('-') + .filter(|slug| !slug.is_empty()) + }; hostname - .strip_prefix("truffle-")? - .strip_prefix(app_id)? - .strip_prefix('-') + .strip_prefix("truffle-") + .and_then(|rest| rest.strip_prefix(app_id)) + .and_then(|rest| rest.strip_prefix('-')) + .or_else(|| prefix.and_then(under)) } /// A valid single DNS label: 1–63 chars of `[a-z0-9-]`, lowercase, no @@ -1699,6 +1735,8 @@ pub struct NodeBuilder { /// RFC 023 §6.4: explicit Tailscale hostname, bypassing the /// `truffle-{app_id}-{slug}` convention (pretty serving URLs). hostname: Option, + /// T8: an app-chosen hostname prefix; the name is `{prefix}-{slug}`. + hostname_prefix: Option, sidecar_path: Option, state_dir: Option, auth_key: Option, @@ -1748,6 +1786,7 @@ impl std::fmt::Debug for NodeBuilder { .field("device_name", &self.device_name) .field("device_id", &self.device_id) .field("hostname", &self.hostname) + .field("hostname_prefix", &self.hostname_prefix) .field("sidecar_path", &self.sidecar_path) .field("state_dir", &self.state_dir) .field("auth_key", &self.auth_key.as_ref().map(|_| "[REDACTED]")) @@ -1855,6 +1894,7 @@ impl Default for NodeBuilder { state_dir: None, auth_key: None, hostname: None, + hostname_prefix: None, ephemeral: false, ws_port: 9417, idle_timeout_secs: None, @@ -1899,7 +1939,12 @@ impl NodeBuilder { /// Tradeoff: hello-less peers with a custom hostname are not resolvable /// by bare device name (the hostname-slug convention no longer matches); /// full hostname, dnsName, IP, device-id, and post-hello identity - /// resolution are unaffected. Tailscale dedupes hostname collisions by + /// resolution are unaffected. App membership does not ride the name + /// (T8): peers admit this node by its `Hostinfo.App` tag (sidecar + /// protocol 12), though a peer on an older release reads the + /// `truffle-{app_id}-` prefix only and will not see it. For an app-chosen + /// name that keeps bare-name resolution, see + /// [`hostname_prefix`](Self::hostname_prefix). Tailscale dedupes hostname collisions by /// suffixing `-1`/`-2` — read the granted name from /// `local_info().dns_name` instead of string-building URLs. pub fn hostname(mut self, s: impl Into) -> Result { @@ -1909,6 +1954,37 @@ impl NodeBuilder { Ok(self) } + /// Choose the hostname's prefix (T8): the node is named + /// `{prefix}-{slug(device_name)}` instead of `truffle-{app_id}-{slug}`, so + /// the name a person reads on Tailscale's own pages is the app's. + /// + /// App membership no longer rides the name: every node announces + /// `Hostinfo.App = "truffle:{app_id}"` (sidecar protocol 12), and peers + /// admit by that tag first, by the `truffle-{app_id}-` prefix only for a + /// row with no tag. A hello-less peer named `{prefix}-{slug}` still + /// resolves by its slug. + /// + /// **Rollout:** a peer on a release before this one reads the name only, + /// and will not see a node with a custom prefix. Turn the prefix on once + /// every shipped device reads the tag. Existing nodes take the new name at + /// their next start (tsnet re-sends the hostname each start). + /// + /// Validated as a single lowercase DNS label of 1–61 characters (room for + /// the hyphen and at least one slug character). Exclusive with + /// [`hostname`](Self::hostname): `build()` refuses both. + pub fn hostname_prefix(mut self, s: impl Into) -> Result { + let raw: String = s.into(); + validate_hostname_label(&raw).map_err(NodeError::BuildError)?; + if raw.len() > 61 { + return Err(NodeError::BuildError(format!( + "invalid hostname_prefix {raw:?}: at most 61 chars, to leave room \ + for the hyphen and a slug within the 63-char label" + ))); + } + self.hostname_prefix = Some(raw); + Ok(self) + } + /// Override the auto-generated device ID. /// /// Validates that `s` is a well-formed ULID. When provided, the value @@ -2137,9 +2213,17 @@ impl NodeBuilder { // An explicit builder hostname (RFC 023 §6.4) wins over the // `truffle-{app_id}-{slug}` convention; see the setter for what // that costs (bare-name resolution of hello-less peers). - let tailscale_host = match self.hostname.clone() { - Some(hostname) => hostname, - None => identity::tailscale_hostname(&app_id, &device_name), + // T8: or an app-chosen prefix composes `{prefix}-{slug}`. The two + // are one question with two answers, so both at once is refused. + let tailscale_host = match (self.hostname.clone(), self.hostname_prefix.as_deref()) { + (Some(_), Some(_)) => { + return Err(NodeError::BuildError( + "hostname and hostname_prefix are exclusive: set one".into(), + )) + } + (Some(hostname), None) => hostname, + (None, Some(prefix)) => identity::prefixed_hostname(prefix, &device_name), + (None, None) => identity::tailscale_hostname(&app_id, &device_name), }; // 5. Resolve state_dir. Default: @@ -2225,6 +2309,7 @@ impl NodeBuilder { pub async fn build(self) -> Result, NodeError> { let ws_port = self.ws_port; let eager_identity = self.eager_identity; + let hostname_prefix = self.hostname_prefix.clone(); let config = self.prepare_config()?; let state_dir = PathBuf::from(&config.state_dir); @@ -2271,7 +2356,8 @@ impl NodeBuilder { let codec: Arc = Arc::new(JsonCodec); let node = Node::from_parts(network, session, codec) .with_state_dir(state_dir) - .with_ws_port(ws_port); + .with_ws_port(ws_port) + .with_hostname_prefix(hostname_prefix); tracing::info!("node: started successfully"); Ok(node) @@ -2294,6 +2380,7 @@ impl NodeBuilder { ) -> Result, NodeError> { let ws_port = self.ws_port; let eager_identity = self.eager_identity; + let hostname_prefix = self.hostname_prefix.clone(); let config = self.prepare_config()?; let state_dir = PathBuf::from(&config.state_dir); @@ -2364,7 +2451,8 @@ impl NodeBuilder { let codec: Arc = Arc::new(JsonCodec); let node = Node::from_parts(network, session, codec) .with_state_dir(state_dir) - .with_ws_port(ws_port); + .with_ws_port(ws_port) + .with_hostname_prefix(hostname_prefix); tracing::info!("node: started successfully (with auth handler)"); Ok(node) @@ -3629,6 +3717,103 @@ mod tests { assert!(NodeBuilder::default().hostname(too_long).is_err()); } + /// T8: an app-chosen prefix composes `{prefix}-{slug(device_name)}`, + /// validated like a hostname, and never alongside a whole hostname. + #[test] + fn test_builder_hostname_prefix_composes_the_name() { + let base = || { + NodeBuilder::default() + .app_id("demo") + .unwrap() + .sidecar_path("/opt/sidecar") + .device_name("Alice's Studio") + .state_dir("/tmp/truffle-hostname-prefix-test") + }; + let cfg = base() + .hostname_prefix("vibefield") + .unwrap() + .prepare_config() + .unwrap(); + assert_eq!(cfg.hostname, "vibefield-alice-s-studio"); + // The app is still the app: membership rides the tag, not the name. + assert_eq!(cfg.app_id, "demo"); + // Unset, the convention stands. + assert_eq!( + base().prepare_config().unwrap().hostname, + "truffle-demo-alice-s-studio" + ); + // A long device name is cut to the label's budget, never past 63. + let long = base() + .device_name("x".repeat(80)) + .hostname_prefix("vibefield") + .unwrap() + .prepare_config() + .unwrap(); + assert_eq!(long.hostname.len(), 63); + assert!(long.hostname.starts_with("vibefield-x")); + + for bad in ["", "Vibe", "has.dot", "-lead", "trail-"] { + assert!( + NodeBuilder::default().hostname_prefix(bad).is_err(), + "expected prefix {bad:?} to be rejected" + ); + } + // The prefix must leave room for its hyphen and a slug. + assert!(NodeBuilder::default() + .hostname_prefix("x".repeat(61)) + .is_ok()); + assert!(NodeBuilder::default() + .hostname_prefix("x".repeat(62)) + .is_err()); + + // A whole hostname and a prefix are two answers to one question. + let both = base() + .hostname("dashboard") + .unwrap() + .hostname_prefix("vibefield") + .unwrap() + .prepare_config(); + assert!(matches!(both, Err(NodeError::BuildError(m)) if m.contains("hostname_prefix"))); + } + + /// T8: a hello-less peer resolves by its slug under the node's own + /// prefix as well as under the `truffle-{app_id}-` convention. + #[test] + fn test_hostname_slug_learns_the_prefix() { + assert_eq!( + hostname_slug("vibefield-studio", "demo", Some("vibefield")), + Some("studio") + ); + assert_eq!( + hostname_slug("truffle-demo-studio", "demo", Some("vibefield")), + Some("studio"), + "an older peer keeps the convention" + ); + assert_eq!(hostname_slug("vibefield-studio", "demo", None), None); + assert_eq!(hostname_slug("vibefield-", "demo", Some("vibefield")), None); + assert_eq!( + hostname_slug("vibefieldx-a", "demo", Some("vibefield")), + None + ); + } + + #[tokio::test] + async fn test_peers_device_name_strips_the_app_prefix_before_hello() { + let ws_port = random_port().await; + let (node, event_tx, _net) = make_test_node("node-1", ws_port).await; + let node = node.with_hostname_prefix(Some("vibefield".into())); + + let mut peer = make_loopback_peer("nodeid-9"); + peer.hostname = "vibefield-ec2-smoke".to_string(); + let _ = event_tx.send(NetworkPeerEvent::Joined(peer)); + tokio::time::sleep(Duration::from_millis(50)).await; + + let peers = node.peers().await; + assert_eq!(peers.len(), 1); + assert_eq!(peers[0].display_name, "ec2-smoke"); + assert_eq!(node.resolve_peer("ec2-smoke").await.unwrap().id, "nodeid-9"); + } + /// RFC 025 §3.1: the builder's allow-list must reach the provider config /// — a door that does not carry the gate is worse than no door. #[test] diff --git a/packages/sidecar-slim/app_tag_v12_test.go b/packages/sidecar-slim/app_tag_v12_test.go new file mode 100644 index 0000000..11ad66b --- /dev/null +++ b/packages/sidecar-slim/app_tag_v12_test.go @@ -0,0 +1,117 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "tailscale.com/hostinfo" + "tailscale.com/ipn" + "tailscale.com/tailcfg" +) + +// Protocol 12 (T8): a node announces its app in Hostinfo.App, and every peer +// row carries the peer's Hostinfo.App, so the core admits by the tag rather +// than by the hostname prefix. + +func TestStartDataReadsApp(t *testing.T) { + var d startData + if err := json.Unmarshal([]byte(`{"hostname":"vibefield-studio","app":"truffle:vibefield"}`), &d); err != nil { + t.Fatal(err) + } + if d.App != "truffle:vibefield" { + t.Fatalf("App = %q", d.App) + } +} + +// applyApp must reach the Hostinfo tsnet sends to control, and an empty tag +// must clear a previous start's. +func TestApplyAppSetsHostinfo(t *testing.T) { + t.Cleanup(func() { hostinfo.SetApp("") }) + applyApp("truffle:vibefield") + if got := hostinfo.New().App; got != "truffle:vibefield" { + t.Fatalf("Hostinfo.App = %q, want truffle:vibefield", got) + } + applyApp("") + if got := hostinfo.New().App; got != "" { + t.Fatalf("Hostinfo.App after clearing = %q, want empty", got) + } +} + +func nodeWithApp(id tailcfg.NodeID, app string) *tailcfg.Node { + return &tailcfg.Node{ID: id, Hostinfo: (&tailcfg.Hostinfo{App: app}).View()} +} + +// The cache asks the LocalAPI once per peer, follows full-node notifies (a +// Hostinfo change arrives as one), forgets removed peers, and never caches a +// failed lookup. +func TestPeerAppCacheFetchesOnceAndFollowsNotifies(t *testing.T) { + ctx := context.Background() + var c peerAppCache + fetches := 0 + app := "truffle:a" + fetch := func(_ context.Context, id tailcfg.NodeID) (*tailcfg.Node, error) { + fetches++ + return nodeWithApp(id, app), nil + } + if got := c.appFor(ctx, 7, fetch); got != "truffle:a" { + t.Fatalf("first lookup = %q", got) + } + if got := c.appFor(ctx, 7, fetch); got != "truffle:a" || fetches != 1 { + t.Fatalf("second lookup = %q after %d fetches, want a cache hit", got, fetches) + } + c.observe(&ipn.Notify{PeersChanged: []*tailcfg.Node{nodeWithApp(7, "truffle:b")}}) + if got := c.appFor(ctx, 7, fetch); got != "truffle:b" || fetches != 1 { + t.Fatalf("after a full-node notify = %q (%d fetches), want truffle:b from the notify", got, fetches) + } + c.observe(&ipn.Notify{PeersRemoved: []tailcfg.NodeID{7}}) + app = "truffle:c" + if got := c.appFor(ctx, 7, fetch); got != "truffle:c" || fetches != 2 { + t.Fatalf("after removal = %q (%d fetches), want a fresh lookup", got, fetches) + } + failing := func(context.Context, tailcfg.NodeID) (*tailcfg.Node, error) { + fetches++ + return nil, errors.New("no such peer") + } + if got := c.appFor(ctx, 9, failing); got != "" { + t.Fatalf("failed lookup = %q, want empty", got) + } + before := fetches + c.appFor(ctx, 9, failing) + if fetches != before+1 { + t.Fatal("a failed lookup must not be cached") + } + c.reset() + c.appFor(ctx, 7, fetch) + if fetches != before+2 { + t.Fatal("reset must forget every peer") + } +} + +// A node control sent without Hostinfo has no app, and must not crash the +// watcher (its view's accessors would dereference nil). +func TestPeerAppCacheToleratesANodeWithoutHostinfo(t *testing.T) { + var c peerAppCache + c.observe(&ipn.Notify{PeersChanged: []*tailcfg.Node{{ID: 3}}}) + fetch := func(context.Context, tailcfg.NodeID) (*tailcfg.Node, error) { + return &tailcfg.Node{ID: 4}, nil + } + if got := c.appFor(context.Background(), 3, fetch); got != "" { + t.Fatalf("observed node without Hostinfo: app = %q", got) + } + if got := c.appFor(context.Background(), 4, fetch); got != "" { + t.Fatalf("fetched node without Hostinfo: app = %q", got) + } +} + +// A tag arriving after a peer joined (its Hostinfo landed late) must surface +// as an update, or the core would never learn it. +func TestWatchPeerChangedSeesApp(t *testing.T) { + old := peerInfo{ID: "n1", Hostname: "vibefield-studio"} + cur := old + cur.App = "truffle:vibefield" + if !watchPeerChanged(old, cur) { + t.Fatal("an App change must count as a change") + } +} diff --git a/packages/sidecar-slim/main.go b/packages/sidecar-slim/main.go index 080c31e..c0de77c 100644 --- a/packages/sidecar-slim/main.go +++ b/packages/sidecar-slim/main.go @@ -40,6 +40,7 @@ import ( "tailscale.com/client/local" "tailscale.com/client/tailscale/apitype" "tailscale.com/envknob" + "tailscale.com/hostinfo" "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" "tailscale.com/logtail" @@ -107,6 +108,11 @@ type startData struct { // Tailscale (v11; see optOutOfClientLogs). nil or true keeps Tailscale's // default, which is to upload them. ClientLogUpload *bool `json:"clientLogUpload,omitempty"` + // App is the node's app tag (v12), "truffle:{app_id}", set as + // Hostinfo.App before the server starts so control relays it to every + // peer, which admits this node by it rather than by its hostname. Empty + // clears it. + App string `json:"app,omitempty"` } type dialData struct { @@ -153,11 +159,15 @@ type dialResultData struct { // can lead to, every status carries tailscaleVersion and tailscaleVersionLong, // tsnet:lockStatus (Tailnet Lock) when the node first runs and on every change, // and backend state changes (tsnet:stateChange) are seen through the IPN bus -// within a second rather than at the next minute's poll. +// within a second rather than at the next minute's poll; v12 = tsnet:start's +// app sets the node's Hostinfo.App ("truffle:{app_id}", T8), and every peer +// row (tsnet:peers, tsnet:peerChanged) carries app, the peer's Hostinfo.App +// as control relays it (absent when the peer announces none), so the core +// admits peers by that tag rather than by the hostname prefix. // An absent value makes the core treat the sidecar as v1. // Bump on any addition to the command surface the core must not send blind, // or to an event the core relies on. -const sidecarProtocolVersion = 11 +const sidecarProtocolVersion = 12 type statusData struct { State string `json:"state"` @@ -215,6 +225,9 @@ type peerInfo struct { // Tailscale's "tagged-devices" pseudo-user; an unknown user id omits the // field entirely rather than guessing. LoginName string `json:"loginName,omitempty"` + // App is the peer's Hostinfo.App as control relayed it (v12): a truffle + // node announces "truffle:{app_id}". Omitted when the peer announces none. + App string `json:"app,omitempty"` } type peersData struct { @@ -758,6 +771,7 @@ type shim struct { bridgePort uint16 idleTimeout time.Duration // bridged-conn idle-reap deadline (RFC 021 §6.5) controlURL string // coordination server of this lifecycle (v10) + apps peerAppCache // each peer's Hostinfo.App (v12); its own lock ctx context.Context cancel context.CancelFunc @@ -1164,6 +1178,9 @@ func (s *shim) handleStart(data json.RawMessage) { if d.ClientLogUpload != nil && !*d.ClientLogUpload { optOutOfClientLogs() } + // v12: the app tag, before srv.Start() sends the first Hostinfo. + applyApp(d.App) + s.apps.reset() srv := &tsnet.Server{ Hostname: d.Hostname, @@ -1521,7 +1538,9 @@ func (s *shim) handleGetPeers() { peers := make([]peerInfo, 0, len(status.Peer)) for _, peer := range status.Peer { - peers = append(peers, statusPeerToInfo(peer, status.User)) + pi := statusPeerToInfo(peer, status.User) + pi.App = s.apps.appFor(ctx, peer.NodeID, lc.PeerByID) + peers = append(peers, pi) } s.sendEvent("tsnet:peers", peersData{Peers: peers}) @@ -1934,6 +1953,7 @@ func (s *shim) startPeerWatch(lc *local.Client) { current := make(map[string]peerInfo, len(newStatus.Peer)) for _, peer := range newStatus.Peer { pi := statusPeerToInfo(peer, newStatus.User) + pi.App = s.apps.appFor(ctx, peer.NodeID, lc.PeerByID) current[pi.ID] = pi } if !seeded { @@ -2016,6 +2036,9 @@ func (s *shim) startPeerWatch(lc *local.Client) { len(n.PeersChanged) == 0 && len(n.PeersRemoved) == 0 && len(n.PeerChangedPatch) == 0 { continue } + // v12: full nodes carry Hostinfo; fold them in before the + // diff they trigger reads the cache. + s.apps.observe(&n) select { case notifyCh <- struct{}{}: default: @@ -2047,6 +2070,87 @@ func (s *shim) startPeerWatch(lc *local.Client) { }() } +// applyApp sets the process's Hostinfo.App (v12), which tsnet sends to +// control with the rest of the Hostinfo and control relays to every peer +// (measured on a real tailnet, T8: a peer's row keeps App, Hostname, OS and +// Services; Package is stripped). hostinfo.SetApp is process-wide, which is +// right here: one sidecar process carries one node. It must run before +// srv.Start(); an empty app clears a previous start's. +func applyApp(app string) { + hostinfo.SetApp(app) +} + +// peerAppCache holds each peer's Hostinfo.App (v12). ipnstate.PeerStatus does +// not carry it, so the first sight of a peer asks the LocalAPI for its full +// node (PeerByID); after that the IPN bus keeps the entry current, because a +// Hostinfo change arrives as a full node in Notify.PeersChanged. +type peerAppCache struct { + mu sync.Mutex + m map[tailcfg.NodeID]string +} + +// observe folds one IPN bus notify into the cache: full nodes replace their +// entry, removed peers are forgotten. +func (c *peerAppCache) observe(n *ipn.Notify) { + if len(n.PeersChanged) == 0 && len(n.PeersRemoved) == 0 { + return + } + c.mu.Lock() + defer c.mu.Unlock() + if c.m == nil { + c.m = make(map[tailcfg.NodeID]string) + } + for _, node := range n.PeersChanged { + if node != nil { + c.m[node.ID] = nodeApp(node) + } + } + for _, id := range n.PeersRemoved { + delete(c.m, id) + } +} + +// nodeApp reads a node's Hostinfo.App. A node control sent without Hostinfo +// has an invalid view, whose accessors would dereference nil: it has no app. +func nodeApp(node *tailcfg.Node) string { + if !node.Hostinfo.Valid() { + return "" + } + return node.Hostinfo.App() +} + +// reset forgets every peer (a new start may join another tailnet). +func (c *peerAppCache) reset() { + c.mu.Lock() + c.m = nil + c.mu.Unlock() +} + +// appFor returns the peer's Hostinfo.App, asking fetch (LocalClient.PeerByID) +// on a miss. A failed lookup yields "" and is not cached, so the next diff +// asks again; the core then decides by the hostname for that row. +func (c *peerAppCache) appFor(ctx context.Context, id tailcfg.NodeID, fetch func(context.Context, tailcfg.NodeID) (*tailcfg.Node, error)) string { + c.mu.Lock() + app, ok := c.m[id] + c.mu.Unlock() + if ok { + return app + } + node, err := fetch(ctx, id) + if err != nil || node == nil { + debugf("peer app lookup %v: %v", id, err) + return "" + } + app = nodeApp(node) + c.mu.Lock() + if c.m == nil { + c.m = make(map[tailcfg.NodeID]string) + } + c.m[id] = app + c.mu.Unlock() + return app +} + // loginNameForUser resolves a tailcfg.UserID through a status User map to the // owner's login (RFC 025 §3.3). An unknown id yields "" — the caller omits the // field rather than fabricating a login. A tagged node's id resolves to @@ -2107,6 +2211,9 @@ func watchPeerChanged(old, new peerInfo) bool { if old.Hostname != new.Hostname { return true } + if old.App != new.App { + return true + } if old.DNSName != new.DNSName { return true } diff --git a/packages/sidecar-slim/main_test.go b/packages/sidecar-slim/main_test.go index f863036..8edd32c 100644 --- a/packages/sidecar-slim/main_test.go +++ b/packages/sidecar-slim/main_test.go @@ -467,12 +467,13 @@ func TestShouldWrapTLS(t *testing.T) { // The pin is deliberate: v5 = loginName; v6 = authenticated UDP; v7 = egress; // v8 = follow-up "running" status once the self login is known; v9 = capMap, // tags and userId on WhoIs identities + proxy acceptAppCaps; v10 = controlUrl -// and keyExpiry; v11 = logout, version, the client-log opt-out, lock status. +// and keyExpiry; v11 = logout, version, the client-log opt-out, lock status; +// v12 = the app tag (Hostinfo.App from tsnet:start's app, and app on peer rows). // Update this test AND provider.rs capability gates when bumping the version. // Login gating still requires v5; authenticated UDP still requires v6. func TestStatusEventAdvertisesProtocolVersion(t *testing.T) { - if sidecarProtocolVersion != 11 { - t.Fatalf("sidecarProtocolVersion = %d, want 11 (logout, version, the client-log opt-out, lock status)", sidecarProtocolVersion) + if sidecarProtocolVersion != 12 { + t.Fatalf("sidecarProtocolVersion = %d, want 12 (the app tag: Hostinfo.App and app on peer rows)", sidecarProtocolVersion) } var buf bytes.Buffer @@ -480,7 +481,7 @@ func TestStatusEventAdvertisesProtocolVersion(t *testing.T) { s.sendStatus("running", "host", "host.tail.ts.net", "100.64.0.1", "", "") // The core matches the camelCase key exactly, as a JSON integer. - if !bytes.Contains(buf.Bytes(), []byte(`"protocolVersion":11`)) { + if !bytes.Contains(buf.Bytes(), []byte(`"protocolVersion":12`)) { t.Errorf("status payload missing protocolVersion on the wire: %s", strings.TrimSpace(buf.String())) } diff --git a/packages/sidecar-slim/status_v11_test.go b/packages/sidecar-slim/status_v11_test.go index 0766fa9..6507e02 100644 --- a/packages/sidecar-slim/status_v11_test.go +++ b/packages/sidecar-slim/status_v11_test.go @@ -120,7 +120,7 @@ func TestStatusesCarryTheEmbeddedTailscaleVersion(t *testing.T) { if !strings.HasPrefix(d.TailscaleVersionLong, want) { t.Fatalf("tailscaleVersionLong = %q, want it to start with %q", d.TailscaleVersionLong, want) } - if d.ProtocolVersion != 11 { + if d.ProtocolVersion != sidecarProtocolVersion { t.Fatalf("protocolVersion = %d", d.ProtocolVersion) } }