Summary
After a Device successfully registers and is authorized (AuthState::Authorized), the netmap update stream immediately fails to deserialize and the ControlRunner actor crashes, tearing down the whole Device with an opaque Error::Internal(Actor).
Environment
tailscale = "0.5.0"
- Real, ordinary tailnet (not a fresh/empty one) with several existing devices, at least one of which has NextDNS split-DNS configured for that specific device (producing a per-device NextDNS block-page URL in its DNS config, of the form
https://dns.nextdns.io/<id>?device_id=...&device_name=...&device_model=...&device_ip=...).
Observed error
ERROR ts_control::client::map_stream: deserializing netmap error=invalid type: string "https://dns.nextdns.io/<redacted>?device_id=<redacted>&device_name=<redacted>&device_model=&device_ip=<redacted>", expected a borrowed string at line 1 column 33219
ERROR actor.lifecycle{actor.name="ts_runtime::control_runner::ControlRunner" ...}:actor.handle_message{... kameo::message::StreamMessage<Arc<ts_control::client::map_stream::StateUpdate>, (), ()>}: ts_runtime::control_runner: state update stream terminated
Error: Internal(Actor)
"expected a borrowed string" (rather than a plain type mismatch) suggests this is a zero-copy (&str) deserialization target that can't borrow this particular string value directly from the input buffer — possibly because of how it's escaped/encoded in the JSON payload, or because the value straddles a buffer boundary. It reproduces consistently against this tailnet.
Impact
Any tailnet containing a device with this kind of NextDNS-linked DNS config field appears to make the whole Device unusable — Device::new() (or the first netmap update shortly after) crashes outright rather than skipping/tolerating the one field it can't parse. This is a harder blocker than #383 (registration error classification), since it happens even after successful authorization, on otherwise-unremarkable real-world tailnet state.
Suggested fix direction
Netmap/state deserialization has had similar fragility before (#123, closed in 0.3.0 for a different field) — it may be worth deserializing peer/DNS metadata fields more defensively in general (e.g. accepting an owned String instead of requiring a zero-copy &str borrow, or tolerating/skipping unparseable optional fields) rather than fixing this one field in isolation.
Repro
Happy to provide more detail/a minimal repro binary if useful — this was hit via a small throwaway Device::new() + tcp_listen() test program, not a customized setup.
Summary
After a
Devicesuccessfully registers and is authorized (AuthState::Authorized), the netmap update stream immediately fails to deserialize and theControlRunneractor crashes, tearing down the wholeDevicewith an opaqueError::Internal(Actor).Environment
tailscale= "0.5.0"https://dns.nextdns.io/<id>?device_id=...&device_name=...&device_model=...&device_ip=...).Observed error
"expected a borrowed string" (rather than a plain type mismatch) suggests this is a zero-copy (
&str) deserialization target that can't borrow this particular string value directly from the input buffer — possibly because of how it's escaped/encoded in the JSON payload, or because the value straddles a buffer boundary. It reproduces consistently against this tailnet.Impact
Any tailnet containing a device with this kind of NextDNS-linked DNS config field appears to make the whole
Deviceunusable —Device::new()(or the first netmap update shortly after) crashes outright rather than skipping/tolerating the one field it can't parse. This is a harder blocker than #383 (registration error classification), since it happens even after successful authorization, on otherwise-unremarkable real-world tailnet state.Suggested fix direction
Netmap/state deserialization has had similar fragility before (#123, closed in 0.3.0 for a different field) — it may be worth deserializing peer/DNS metadata fields more defensively in general (e.g. accepting an owned
Stringinstead of requiring a zero-copy&strborrow, or tolerating/skipping unparseable optional fields) rather than fixing this one field in isolation.Repro
Happy to provide more detail/a minimal repro binary if useful — this was hit via a small throwaway
Device::new()+tcp_listen()test program, not a customized setup.