diff --git a/src/config.rs b/src/config.rs index 9bc6bcc..ce6534b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -292,6 +292,97 @@ pub struct Config { /// requires control to assign it a VIP and the node to be tagged. pub advertise_services: Vec, + /// Whether to advertise this node as an **app connector** (`tailscale set --advertise-connector`, + /// Go `Prefs.AppConnector.Advertise`). Defaults to `false`. + /// + /// When `true`, registration and every map request set `HostInfo.AppConnector = Some(true)`, + /// mirroring Go's `applyPrefsToHostinfoLocked` (`hi.AppConnector.Set(prefs.AppConnector().Advertise)`). + /// This advertises only the *capability* to control — the faithful engine minimum, exactly the + /// boundary Go draws between advertising and the data path. The app-connector data path itself + /// (control-pushed connector domain routes, the 4via6 domain→route mapping, the per-domain DNS + /// observation that learns target IPs) is a separate subsystem this fork does not implement, so a + /// node advertising this serves no connector traffic until that layer exists — identical in effect + /// to Go advertising the bool before control has assigned any domains. + pub advertise_app_connector: bool, + + /// Whether this node opts in to admin-console-triggered auto-updates + /// (`tailscale set --auto-update`, Go `Prefs.AutoUpdate.Apply`). Defaults to `None`. + /// + /// When `Some(true)`, registration and every map request set `HostInfo.AllowsUpdate = true`, + /// mirroring Go's `applyPrefsToHostinfoLocked` + /// (`hi.AllowsUpdate = … || prefs.AutoUpdate().Apply.EqualBool(true)`), so the admin console knows + /// the node accepts remote update triggers. This advertises the bool only: **this fork runs no + /// updater** (it is an embeddable engine, not a packaged daemon), so it never *applies* an update — + /// the self-update machinery is a daemon / OS-package concern. `Some(false)` and `None` both leave + /// `AllowsUpdate` unset (advertise that the node does not accept remote updates); the tri-state + /// mirrors Go's `opt.Bool` (unset vs explicitly-off vs on). + pub auto_update_apply: Option, + + /// Whether a background updater should *check* for available updates (Go `Prefs.AutoUpdate.Check`). + /// Defaults to `false`. + /// + /// **Carried pref only — the engine never acts on it and it is never sent to control.** In Go this + /// gates a purely local background update-check loop in the daemon; it is not part of `Hostinfo` + /// and never crosses the control wire. This fork has no updater (engine, not daemon), so the value + /// is stored and threaded through to [`ts_control::Config`] solely so a downstream daemon can carry + /// the pref. Storing it (rather than dropping it) is the faithful mirror of tsnet's pref state. + pub auto_update_check: bool, + + /// The OS username permitted to operate this node over a local management API + /// (`tailscale set --operator`, Go `Prefs.OperatorUser`). Defaults to `None`. + /// + /// **Carried pref only — the engine never acts on it and it is never sent to control.** In Go this + /// is purely a daemon-side LocalAPI authorization check (which Unix uid may drive the daemon + /// without root); it never touches the control protocol. This fork is a pure engine with no local + /// API to gate, so the value is stored and threaded through to [`ts_control::Config`] solely for a + /// downstream daemon that exposes a local API to consult. Faithful mirror of tsnet pref state. + pub operator_user: Option, + + /// A local display label for this node's login profile (Go `Prefs.ProfileName`, set via + /// `tailscale switch` / profile management). Defaults to `None`. + /// + /// **Carried pref only — the engine never acts on it and it is never sent to control.** In Go this + /// is a client-local cosmetic name for the login profile; it is never advertised in `Hostinfo` + /// (distinct from the [`requested_hostname`](Config::requested_hostname) the node requests). The + /// value is stored and threaded through to [`ts_control::Config`] solely for a downstream daemon's + /// profile UI. Faithful mirror of tsnet pref state. + pub node_nickname: Option, + + /// Whether device-posture identity collection is enabled (`tailscale set --posture-checking`, + /// Go `Prefs.PostureChecking`). Defaults to `false`. + /// + /// **Carried pref only — the engine never acts on it and it is never sent to control.** There is + /// deliberately **no `Hostinfo.PostureChecking` field** to wire it to: posture is a + /// control-to-node (c2n) *pull* mechanism — control requests posture attributes (serial numbers, + /// etc.) from the node on demand — which this fork does not implement. With no c2n posture + /// responder, control simply never pulls posture identity, byte-for-byte identical to the + /// posture-disabled case, so storing the pref is the faithful mirror. The value is threaded through + /// to [`ts_control::Config`] for a downstream daemon that implements the c2n posture endpoint. + pub posture_checking: bool, + + /// Whether this node runs a local web client (`tailscale set --webclient`, + /// Go `Prefs.RunWebClient`). Defaults to `false`. + /// + /// **Carried pref only — the engine never acts on it and it is never sent to control.** In Go this + /// gates a daemon-hosted local web-client HTTP server (the device-management web UI on + /// `100.x:5252`); it is a separate subsystem, not advertised in `Hostinfo`. This fork has no + /// web-client server, so the value is stored and threaded through to [`ts_control::Config`] solely + /// for a downstream daemon that does. Faithful mirror of tsnet pref state. + pub run_web_client: bool, + + /// Whether a peer using this node as an exit node may also reach this node's **local LAN** + /// (`tailscale set --exit-node-allow-lan-access`, Go `Prefs.ExitNodeAllowLANAccess`). Defaults to + /// `false`. + /// + /// **Carried pref only for now — the engine does not yet act on it and it is never sent to + /// control.** In Go this is an **OS-router route-shaping** flag: when acting as an exit node it + /// controls whether the host router excludes the local LAN ranges from the routes pulled through + /// the tunnel. On a platform with no host router it has "no effect" — and this fork's default data + /// path is the userspace netstack, which has no host-route layer to shape. The value is stored and + /// threaded through to [`ts_control::Config`] so a downstream daemon (or a future host-route layer + /// in this engine) can consume it; until such a layer exists it is inert. Never advertised. + pub exit_node_allow_lan_access: bool, + /// Filesystem directory that received Taildrop files land in, or `None` to disable Taildrop /// (the default, fail-closed). /// @@ -541,6 +632,14 @@ impl From<&Config> for ts_control::Config { // `Device::listen_funnel` starts a listener. Not derived from the embedder config. ingress_active: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), advertise_services: value.advertise_services.clone(), + advertise_app_connector: value.advertise_app_connector, + auto_update_apply: value.auto_update_apply, + auto_update_check: value.auto_update_check, + operator_user: value.operator_user.clone(), + node_nickname: value.node_nickname.clone(), + posture_checking: value.posture_checking, + run_web_client: value.run_web_client, + exit_node_allow_lan_access: value.exit_node_allow_lan_access, allow_http_key_fetch: value.allow_http_key_fetch, } } @@ -576,6 +675,14 @@ impl Default for Config { transport_mode: ts_control::TransportMode::default(), wire_ingress: false, advertise_services: vec![], + advertise_app_connector: false, + auto_update_apply: None, + auto_update_check: false, + operator_user: None, + node_nickname: None, + posture_checking: false, + run_web_client: false, + exit_node_allow_lan_access: false, taildrop_dir: None, auth_key: None, client_id: None, @@ -618,6 +725,14 @@ mod tests { advertise_routes: vec!["10.0.0.0/24".parse().unwrap()], requested_tags: vec!["tag:exit".to_owned()], advertise_services: vec!["svc:samba".to_owned()], + advertise_app_connector: true, + auto_update_apply: Some(true), + auto_update_check: true, + operator_user: Some("alice".to_owned()), + node_nickname: Some("laptop".to_owned()), + posture_checking: true, + run_web_client: true, + exit_node_allow_lan_access: true, ephemeral: false, exit_proxy: Some(ExitProxyConfig { addr: "198.51.100.9:8080".parse().unwrap(), @@ -674,6 +789,43 @@ mod tests { mtu: Some(1280), }) ); + // up/set pref fields cross the boundary: two advertise-side, six store-only carried prefs. + assert!(control.advertise_app_connector); + assert_eq!(control.auto_update_apply, Some(true)); + assert!(control.auto_update_check); + assert_eq!(control.operator_user.as_deref(), Some("alice")); + assert_eq!(control.node_nickname.as_deref(), Some("laptop")); + assert!(control.posture_checking); + assert!(control.run_web_client); + assert!(control.exit_node_allow_lan_access); + } + + /// All eight up/set pref fields default off/None on a fresh top-level `Config`, and the defaults + /// cross the `From<&Config>` boundary unchanged. Fail-closed: a default node advertises no + /// app-connector / auto-update and carries no operator/nickname/posture/webclient/LAN-access pref. + #[test] + fn from_config_default_up_set_pref_fields_off() { + let cfg = Config::default(); + // Defaults on the top-level config. + assert!(!cfg.advertise_app_connector); + assert_eq!(cfg.auto_update_apply, None); + assert!(!cfg.auto_update_check); + assert_eq!(cfg.operator_user, None); + assert_eq!(cfg.node_nickname, None); + assert!(!cfg.posture_checking); + assert!(!cfg.run_web_client); + assert!(!cfg.exit_node_allow_lan_access); + + // And they cross the boundary defaulted off. + let control: ts_control::Config = (&cfg).into(); + assert!(!control.advertise_app_connector); + assert_eq!(control.auto_update_apply, None); + assert!(!control.auto_update_check); + assert_eq!(control.operator_user, None); + assert_eq!(control.node_nickname, None); + assert!(!control.posture_checking); + assert!(!control.run_web_client); + assert!(!control.exit_node_allow_lan_access); } #[test] diff --git a/ts_control/src/config.rs b/ts_control/src/config.rs index a88397c..b408e2b 100644 --- a/ts_control/src/config.rs +++ b/ts_control/src/config.rs @@ -441,6 +441,99 @@ pub struct Config { #[serde(default)] pub advertise_services: Vec, + /// Whether to advertise this node as an **app connector** (Go `Prefs.AppConnector.Advertise` / + /// `tailscale set --advertise-connector`). When `true`, this *is* read inside `ts_control`: it + /// sets `HostInfo.AppConnector = Some(true)` on registration and every map request, mirroring Go's + /// `applyPrefsToHostinfoLocked` (`hi.AppConnector.Set(prefs.AppConnector().Advertise)`). + /// + /// Advertising the bool is the **faithful engine minimum** — exactly the boundary Go draws. The + /// actual app-connector *data path* (control pushing the connector's domain routes, the 4via6 + /// domain→route mapping, the per-domain DNS observation that learns target IPs) is a separate + /// subsystem this fork does not implement; advertising the capability without that data path is + /// identical in effect to Go advertising it before control has assigned any domains. Defaults to + /// `false` (fail-closed): a node offers itself as an app connector only when explicitly opted in. + #[serde(default)] + pub advertise_app_connector: bool, + + /// Whether this node opts in to control-console-triggered auto-updates (Go + /// `Prefs.AutoUpdate.Apply` / `tailscale set --auto-update`). When `Some(true)`, this *is* read + /// inside `ts_control`: it sets `HostInfo.AllowsUpdate = true` on registration and every map + /// request, mirroring Go's `applyPrefsToHostinfoLocked` + /// (`hi.AllowsUpdate = … || prefs.AutoUpdate().Apply.EqualBool(true)`), so the admin console knows + /// the node accepts remote update triggers. + /// + /// Advertising the bool is the faithful engine minimum: this fork runs **no updater** (it is an + /// embeddable engine, not a packaged daemon), so it never *applies* an update — the actual + /// self-update machinery is a daemon/OS-package concern. `Some(false)` and `None` both leave + /// `AllowsUpdate` at its default `false` (the node advertises it does not accept remote updates); + /// the tri-state mirrors Go's `opt.Bool` (unset vs explicitly-off vs on). Defaults to `None`. + #[serde(default)] + pub auto_update_apply: Option, + + /// Whether this node's (hypothetical) background updater should *check* for available updates + /// (Go `Prefs.AutoUpdate.Check`). **Carried pref only — not read inside `ts_control` and never + /// sent to control.** In Go this gates a purely local background update-check loop in the daemon; + /// it is not part of `Hostinfo` and never crosses the control wire, so storing it is the faithful + /// mirror of tsnet state. This fork has no updater (engine, not daemon), so the pref is carried + /// for a downstream daemon to consult and has no effect inside the engine. Defaults to `false`. + #[serde(default)] + pub auto_update_check: bool, + + /// The OS username permitted to operate this node over the local API (Go `Prefs.OperatorUser` / + /// `tailscale set --operator`). **Carried pref only — not read inside `ts_control` and never sent + /// to control.** In Go this is purely a daemon-side LocalAPI authorization check (which Unix uid + /// may drive the daemon without root); it never touches the control protocol. Storing it is the + /// faithful mirror of tsnet state — a downstream daemon that exposes a local API consults it; the + /// engine itself has no local API to gate. Defaults to `None` (no operator delegated). + #[serde(default)] + pub operator_user: Option, + + /// A local display label for this node's profile (Go `Prefs.ProfileName`, set by + /// `tailscale switch`/profile management). **Carried pref only — not read inside `ts_control` and + /// never sent to control.** In Go this is a client-local cosmetic name for the login profile; it + /// is never advertised in `Hostinfo` (distinct from the `Hostinfo.Hostname` the node requests). + /// Storing it faithfully mirrors tsnet state for a downstream daemon's profile UI; the engine + /// makes no use of it. Defaults to `None`. + #[serde(default)] + pub node_nickname: Option, + + /// Whether device posture identity collection is enabled (Go `Prefs.PostureChecking` / + /// `tailscale set --posture-checking`). **Carried pref only — not read inside `ts_control` and + /// never sent to control.** + /// + /// There is deliberately **no `Hostinfo.PostureChecking` field to wire it to**: posture is a + /// control-to-node (c2n) *pull* mechanism — control requests posture attributes (serial numbers, + /// etc.) from the node on demand — which this fork does not implement. Storing the pref is + /// therefore the faithful mirror: with no c2n posture responder, control simply never pulls + /// posture identity, which is byte-for-byte identical to the posture-disabled case. A downstream + /// daemon that implements the c2n posture endpoint consults this pref to decide whether to answer. + /// Defaults to `false` (fail-closed: no posture identity collected). + #[serde(default)] + pub posture_checking: bool, + + /// Whether this node runs a local web client (Go `Prefs.RunWebClient` / + /// `tailscale set --webclient`). **Carried pref only — not read inside `ts_control` and never + /// sent to control.** In Go this gates a daemon-hosted local web-client HTTP server (the + /// device-management web UI on `100.x:5252`); it is a separate subsystem, not advertised in + /// `Hostinfo`. This fork has no web-client server, so storing the pref faithfully mirrors tsnet + /// state for a downstream daemon that does; the engine never acts on it. Defaults to `false`. + #[serde(default)] + pub run_web_client: bool, + + /// Whether a peer using this node as an exit node may also reach this node's **local LAN** + /// (Go `Prefs.ExitNodeAllowLANAccess` / `tailscale set --exit-node-allow-lan-access`). + /// **Carried pref only for now — not read inside `ts_control` and never sent to control.** + /// + /// In Go this is an **OS-router route-shaping** flag: when acting as an exit node it controls + /// whether the host router excludes the local LAN ranges from the routes pulled through the + /// tunnel. On a platform with no host router it has "no effect" — and this fork's default data + /// path is the userspace netstack with no host-route layer, so there is nothing to shape today. + /// The pref is stored so a downstream daemon (or a future host-route layer in this engine) can + /// consume it; until such a layer exists it is inert. It is never advertised to control. Defaults + /// to `false`. + #[serde(default)] + pub exit_node_allow_lan_access: bool, + /// Whether to automatically re-authenticate (rotate the node key + re-register with the stored /// auth key, Go `doLogin`) when control reports this node's node key has expired, instead of /// going terminally offline. @@ -668,6 +761,14 @@ impl Default for Config { wire_ingress: false, ingress_active: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), advertise_services: Vec::new(), + advertise_app_connector: false, + auto_update_apply: None, + auto_update_check: false, + operator_user: None, + node_nickname: None, + posture_checking: false, + run_web_client: false, + exit_node_allow_lan_access: false, reauth_on_expiry: default_true(), allow_http_key_fetch: false, } @@ -899,6 +1000,122 @@ mod tests { ); } + /// All eight up/set pref fields default off/None on a fresh `ts_control::Config`: the two + /// advertise-side ones (`advertise_app_connector`, `auto_update_apply`) and the six store-only + /// carried prefs. Fail-closed: a default node advertises no app-connector / auto-update and + /// carries no operator/nickname/posture/webclient/LAN-access preference. + #[test] + fn up_set_pref_fields_default_off() { + let cfg = Config::default(); + // Advertise-side. + assert!(!cfg.advertise_app_connector); + assert_eq!(cfg.auto_update_apply, None); + // Store-only carried prefs. + assert!(!cfg.auto_update_check); + assert_eq!(cfg.operator_user, None); + assert_eq!(cfg.node_nickname, None); + assert!(!cfg.posture_checking); + assert!(!cfg.run_web_client); + assert!(!cfg.exit_node_allow_lan_access); + } + + /// End-to-end: a `Config` with `advertise_app_connector` / `auto_update_apply` set drives the + /// `HostInfo.AppConnector` / `HostInfo.AllowsUpdate` wire fields through the SAME expressions the + /// streaming map request (`client.rs`) and registration (`register.rs`) use. Guards that the + /// advertise fields reach the wire, and that the default config omits both keys. + #[test] + fn advertise_prefs_drive_host_info_wire_fields() { + use crate::map_request_builder::MapRequestBuilder; + + let node_state = ts_keys::NodeState::generate(); + + // Advertising config: mirrors `.app_connector(config.advertise_app_connector)` and + // `.allows_update(config.auto_update_apply == Some(true))` from client.rs. + let cfg = Config { + advertise_app_connector: true, + auto_update_apply: Some(true), + ..Default::default() + }; + let req = MapRequestBuilder::new(&node_state) + .app_connector(cfg.advertise_app_connector) + .allows_update(cfg.auto_update_apply == Some(true)) + .build(); + let hi = req.host_info.unwrap(); + assert_eq!(hi.app_connector, Some(true)); + assert!(hi.allows_update); + let v = serde_json::to_value(&hi).unwrap(); + assert_eq!( + v.get("AppConnector").and_then(serde_json::Value::as_bool), + Some(true) + ); + assert_eq!( + v.get("AllowsUpdate").and_then(serde_json::Value::as_bool), + Some(true) + ); + + // Default config (advertise off): `AppConnector` is sent as `false` (Go calls + // `hi.AppConnector.Set(advertise)` unconditionally, and `.Set(false)` marshals to `false`, not + // omitted), while `AllowsUpdate` (a plain `omitzero` bool) IS omitted when false. This + // asymmetry matches Go's wire bytes exactly: a default node sends `AppConnector:false` but no + // `AllowsUpdate` key. + let cfg = Config::default(); + let req = MapRequestBuilder::new(&node_state) + .app_connector(cfg.advertise_app_connector) + .allows_update(cfg.auto_update_apply == Some(true)) + .build(); + let hi = req.host_info.unwrap(); + assert_eq!(hi.app_connector, Some(false)); + assert!(!hi.allows_update); + let v = serde_json::to_value(&hi).unwrap(); + assert_eq!( + v.get("AppConnector").and_then(serde_json::Value::as_bool), + Some(false), + "default node sends AppConnector:false (Go .Set(false)), not an omitted key" + ); + assert!( + v.get("AllowsUpdate").is_none(), + "AllowsUpdate is an omitzero bool, omitted when false" + ); + + // `auto_update_apply == Some(false)` advertises NO update (AllowsUpdate stays unset), + // matching the `== Some(true)` gate. + let cfg = Config { + auto_update_apply: Some(false), + ..Default::default() + }; + let req = MapRequestBuilder::new(&node_state) + .allows_update(cfg.auto_update_apply == Some(true)) + .build(); + assert!(!req.host_info.unwrap().allows_update); + } + + /// The pref fields deserialize from their snake_case keys (a daemon persists the config as JSON) + /// and a config that predates the fields still loads with them defaulted off (the `#[serde(default)]` + /// on each). + #[test] + fn up_set_pref_fields_deserialize_and_default_when_absent() { + // Absent: defaults apply. + let cfg: Config = serde_json::from_str(r#"{"server_url":"https://example.com/"}"#).unwrap(); + assert!(!cfg.advertise_app_connector); + assert_eq!(cfg.auto_update_apply, None); + assert!(!cfg.posture_checking); + assert_eq!(cfg.operator_user, None); + + // Present: parsed. + let cfg: Config = serde_json::from_str( + r#"{"server_url":"https://example.com/","advertise_app_connector":true,"auto_update_apply":true,"auto_update_check":true,"operator_user":"alice","node_nickname":"laptop","posture_checking":true,"run_web_client":true,"exit_node_allow_lan_access":true}"#, + ) + .unwrap(); + assert!(cfg.advertise_app_connector); + assert_eq!(cfg.auto_update_apply, Some(true)); + assert!(cfg.auto_update_check); + assert_eq!(cfg.operator_user.as_deref(), Some("alice")); + assert_eq!(cfg.node_nickname.as_deref(), Some("laptop")); + assert!(cfg.posture_checking); + assert!(cfg.run_web_client); + assert!(cfg.exit_node_allow_lan_access); + } + #[test] fn deduplicates_routes() { let cfg = Config { diff --git a/ts_control/src/map_request_builder.rs b/ts_control/src/map_request_builder.rs index 953186a..69ff873 100644 --- a/ts_control/src/map_request_builder.rs +++ b/ts_control/src/map_request_builder.rs @@ -210,6 +210,34 @@ impl<'a> MapRequestBuilder<'a> { self } + /// Advertise whether this node is acting as an **app connector** (`HostInfo.AppConnector`), + /// mirroring Go's `applyPrefsToHostinfoLocked` (`hi.AppConnector.Set(prefs.AppConnector().Advertise)`). + /// Go calls `.Set(advertise)` **unconditionally**, so a default Go/tsnet node always sends the key: + /// `true` → `AppConnector:true`, and `false` (the default) → `AppConnector:false` — NOT omitted. + /// Go's `opt.Bool` `omitzero` only drops the key when it was never `Set` (the empty string); a + /// `.Set(false)` is a non-empty value that marshals to `false`. So we set `Some(value)` (never + /// `None` on this path): `false` serializes to `"AppConnector":false` to match Go's wire bytes + /// byte-for-byte — omitting it would be a not-tailscaled fingerprint tell (a default node that + /// advertises a dense Hostinfo but is *missing* `AppConnector` where Go always has it). Same + /// `Some(false)`-is-a-real-signal reasoning as [`HostInfo::container`][ts_control_serde::HostInfo]. + /// This advertises only the capability bool — the app-connector data path (domain routes / 4via6) + /// is a separate subsystem. + pub fn app_connector(mut self, value: bool) -> Self { + self.host_info_mut().app_connector = Some(value); + self + } + + /// Advertise that this node accepts admin-console-triggered remote updates + /// (`HostInfo.AllowsUpdate`), mirroring Go's `applyPrefsToHostinfoLocked` + /// (`hi.AllowsUpdate = … || prefs.AutoUpdate().Apply.EqualBool(true)`). `false` (the default) + /// leaves the bool unset and the field is omitted from the wire request (the crate's + /// `skip_serializing_if = is_default` rule for bools). This fork runs no updater; the flag only + /// advertises that the node *would* accept a remote update trigger. + pub fn allows_update(mut self, value: bool) -> Self { + self.host_info_mut().allows_update = value; + self + } + /// Set the opaque VIP-services hash this node advertises (`HostInfo.ServicesHash`), the /// advertise-side signal that tells control to (re)fetch the node's hosted VIP-service list via /// the c2n `GET /vip-services` endpoint when it changes. Compute it with @@ -360,6 +388,96 @@ mod tests { assert!(!req.host_info.unwrap().wire_ingress); } + /// `app_connector(true)` sets `HostInfo.AppConnector = Some(true)`, mirroring Go's + /// `hi.AppConnector.Set(prefs.AppConnector().Advertise)`. The serialized wire key is `AppConnector` + /// carrying the JSON bool `true`. + #[test] + fn app_connector_setter_populates_host_info_and_wire_key() { + let node_state = ts_keys::NodeState::generate(); + + let req = MapRequestBuilder::new(&node_state) + .app_connector(true) + .build(); + let hi = req.host_info.unwrap(); + assert_eq!(hi.app_connector, Some(true)); + + let v = serde_json::to_value(&hi).unwrap(); + assert_eq!( + v.get("AppConnector").and_then(serde_json::Value::as_bool), + Some(true), + "AppConnector is a JSON bool under the Go wire key `AppConnector`" + ); + } + + /// `app_connector(false)` sends `AppConnector:false` — NOT omitted. Go calls + /// `hi.AppConnector.Set(advertise)` unconditionally, and `.Set(false)` is a non-empty `opt.Bool` + /// that marshals to `false` (only a never-`Set` `opt.Bool` is `omitzero`-dropped). So a default Go + /// node always sends the key; a fork node that omitted it would be a not-tailscaled tell. The + /// register + streaming-map paths always call `.app_connector(...)`, so the wire always carries it. + #[test] + fn app_connector_false_sends_false_wire_key() { + let node_state = ts_keys::NodeState::generate(); + + // Explicit false -> Some(false) -> "AppConnector":false on the wire (matches Go .Set(false)). + let req = MapRequestBuilder::new(&node_state) + .app_connector(false) + .build(); + let hi = req.host_info.unwrap(); + assert_eq!(hi.app_connector, Some(false)); + let v = serde_json::to_value(&hi).unwrap(); + assert_eq!( + v.get("AppConnector").and_then(serde_json::Value::as_bool), + Some(false), + "a non-advertising node sends AppConnector:false (Go .Set(false)), not an omitted key" + ); + + // The bare builder default (setter never called) leaves it None — but the actual register and + // map-poll paths always call `.app_connector(config.advertise_app_connector)`, so this + // never-set state is not what reaches control. + let req = MapRequestBuilder::new(&node_state).build(); + assert_eq!(req.host_info.unwrap().app_connector, None); + } + + /// `allows_update(true)` sets `HostInfo.AllowsUpdate = true`, mirroring Go's + /// `hi.AllowsUpdate = … || prefs.AutoUpdate().Apply.EqualBool(true)`. The serialized wire key is + /// `AllowsUpdate` carrying the JSON bool `true`. + #[test] + fn allows_update_setter_populates_host_info_and_wire_key() { + let node_state = ts_keys::NodeState::generate(); + + let req = MapRequestBuilder::new(&node_state) + .allows_update(true) + .build(); + let hi = req.host_info.unwrap(); + assert!(hi.allows_update); + + let v = serde_json::to_value(&hi).unwrap(); + assert_eq!( + v.get("AllowsUpdate").and_then(serde_json::Value::as_bool), + Some(true), + "AllowsUpdate is a JSON bool under the Go wire key `AllowsUpdate`" + ); + } + + /// `allows_update(false)` (and the default) leaves the bool unset, so it is omitted from the wire + /// request (the crate's `skip_serializing_if = is_default` rule for bools). + #[test] + fn allows_update_false_omits_wire_key() { + let node_state = ts_keys::NodeState::generate(); + + let req = MapRequestBuilder::new(&node_state) + .allows_update(false) + .build(); + let hi = req.host_info.unwrap(); + assert!(!hi.allows_update); + let v = serde_json::to_value(&hi).unwrap(); + assert!(v.get("AllowsUpdate").is_none()); + + // Default (setter never called) is also false. + let req = MapRequestBuilder::new(&node_state).build(); + assert!(!req.host_info.unwrap().allows_update); + } + #[test] fn services_hash_setter_populates_host_info() { let node_state = ts_keys::NodeState::generate(); diff --git a/ts_control/src/tokio/client.rs b/ts_control/src/tokio/client.rs index 9daf020..94d23a5 100644 --- a/ts_control/src/tokio/client.rs +++ b/ts_control/src/tokio/client.rs @@ -752,6 +752,12 @@ async fn run_once( .ingress_active .load(core::sync::atomic::Ordering::Relaxed), ) + // App-connector advertise (Go `Prefs.AppConnector.Advertise` -> `Hostinfo.AppConnector`) and + // auto-update-apply advertise (Go `Prefs.AutoUpdate.Apply` -> `Hostinfo.AllowsUpdate`). Both + // carry on every map poll, like `wire_ingress`, so control persistently sees the advertised + // capability rather than only at registration. + .app_connector(config.advertise_app_connector) + .allows_update(config.auto_update_apply == Some(true)) .map_session(&session.handle, session.seq); // Carry the whole current NetInfo on the streaming re-register too (Go attaches `c.netinfo` to // every `sendMapRequest`), so a reconnect re-advertises the last-known home/UDP/NAT facets diff --git a/ts_control/src/tokio/register.rs b/ts_control/src/tokio/register.rs index a6fc8dc..dcb3763 100644 --- a/ts_control/src/tokio/register.rs +++ b/ts_control/src/tokio/register.rs @@ -202,6 +202,16 @@ pub async fn register( // capver-113 Funnel "wire me up server-side" signal. IngressEnabled stays false: // listen_funnel is fail-closed in this fork, so no Funnel endpoint ever goes live. wire_ingress: config.wire_ingress, + // App-connector advertise (Go `Prefs.AppConnector.Advertise` -> + // `hi.AppConnector.Set(advertise)`, called UNCONDITIONALLY): always send the key — + // `true` -> `AppConnector:true`, `false` (default) -> `AppConnector:false` (Go's `.Set(false)` + // is a non-empty `opt.Bool` that marshals to `false`, NOT omitted). `Some(value)` here so a + // default node's wire bytes match Go's; omitting it would be a not-tailscaled tell. + // Advertise the capability bool only; the data path is separate. + app_connector: Some(config.advertise_app_connector), + // Auto-update-apply advertise (Go `Prefs.AutoUpdate.Apply` -> `hi.AllowsUpdate`): the node + // accepts admin-console remote update triggers. This fork runs no updater; advertise only. + allows_update: config.auto_update_apply == Some(true), // Advertise-side VIP services hash (empty when no services are advertised). services_hash: &services_hash, ..Default::default()