Skip to main content

kanade_shared/
nats_client.rs

1//! Shared NATS client constructor.
2//!
3//! Every binary names the [`NatsRole`] it connects as, and the token is
4//! resolved per role (first match wins):
5//!
6//!   1. Windows registry — `HKLM\SOFTWARE\kanade\<role>\NatsToken`
7//!      (`REG_SZ`). The role-specific credential. Hardened ACL (SYSTEM +
8//!      Admin only) keeps the token out of low-privilege users' reach,
9//!      which Machine-scope env vars cannot do.
10//!   2. Windows registry — `HKLM\SOFTWARE\kanade\agent\NatsToken`. The
11//!      **shared** credential every role used before roles existed. Kept as
12//!      a fallback so an existing deployment keeps working untouched; see
13//!      "Staged migration" below.
14//!   3. `$KANADE_NATS_TOKEN` environment variable. Dev / fallback path. The
15//!      agent service runs as LocalSystem so user-session env vars never
16//!      reach it; this branch only fires for `cargo run` / interactive
17//!      shells.
18//!   4. No token — connect unauthenticated. Works against a broker started
19//!      without `authorization { … }`.
20//!
21//! # Why roles exist here (#1155)
22//!
23//! The broker authorises a *connection*, and a connection is only as
24//! specific as the credential that opened it. While every binary presented
25//! the same token, the broker could not tell an agent from the backend from
26//! the CLI, so no `permissions` block could say "only the backend may
27//! subscribe `remote.frame.>`" — there was nothing to hang the rule on.
28//! That is why a shared token means a token holder can execute code on any
29//! endpoint and silently watch any remote-assistance session (#1140).
30//!
31//! Distinct credentials do not fix that by themselves; the broker config has
32//! to grow the matching `authorization { users: [...] }` entries. This
33//! module is the half that makes those entries *expressible*.
34//!
35//! # Staged migration
36//!
37//! Step 2 above is the whole migration strategy. A fleet running today has
38//! one token, provisioned at `…\kanade\agent\NatsToken` on every host
39//! regardless of role. After this change it keeps working: no role key
40//! exists, so every role falls through to the shared one and presents
41//! exactly what it presented before.
42//!
43//! Rolling out per-role credentials is then per-host and reversible — write
44//! `…\kanade\backend\NatsToken` on the backend host and it starts using it;
45//! delete it and it falls back. The broker only needs to start
46//! *distinguishing* the roles once every host has its own, so the config
47//! change lands last, when it can no longer lock anyone out.
48//!
49//! No deploy script writes a role key yet: `deploy-backend.ps1` still
50//! provisions the shared path, so today the role key is a manual registry
51//! write. That is deliberate — the scripted path should start writing role
52//! keys in the same change that teaches the broker to tell the roles apart,
53//! because until then a role key changes nothing and a script that writes
54//! only the role key (dropping the shared one) would strand the CLI on a
55//! backend-only host.
56//!
57//! The order matters and is deliberate: role key first, shared key second.
58//! The reverse would make the shared token permanent — a host that still has
59//! it (all of them, today) would never notice its role key.
60//!
61//! # What the broker will and will not accept (measured, #1270)
62//!
63//! Two nats-server behaviours constrain every plan built on this module, so
64//! they are recorded here rather than rediscovered:
65//!
66//! * A config may not carry **both** a `token` and a `users` array —
67//!   nats-server refuses to start: *"Can not have a token and a users
68//!   array"*. And once `users` are defined, a client presenting a token is
69//!   rejected with an Authorization Violation, even when the token equals a
70//!   user's password. So the shared token and a per-role `users` split
71//!   cannot coexist for a transition window: the flip is atomic, and every
72//!   host must already hold a credential of the new shape before it happens.
73//!   Resolving a *token* per role, which is all this module does today, is
74//!   therefore not sufficient for that split — the client has to learn to
75//!   present a user as well.
76//! * `/connz?auth=1` reports `authorized_user` per connection. Under
77//!   `users` that is the username — the per-host answer #1270 wants. Under
78//!   `token`, nats-server 2.14.3 reports the literal `[REDACTED]`: it hides
79//!   the credential, so token mode can say *that* a host is on the shared
80//!   token but never anything finer. Whether the value is hidden is the
81//!   broker build's choice, not ours, so a consumer must assume it may be
82//!   handling a secret; see [`CredentialProbe`] for the one question it can
83//!   safely ask about one.
84//!
85//! # Limits worth naming
86//!
87//! A per-role token still cannot express per-*agent* identity. A role
88//! credential permitted to subscribe `commands.pc.*` lets any agent holding
89//! it read another agent's inbox. This narrows a fleet-wide compromise to a
90//! fleet-wide **agent-role** compromise, which is better, not solved. The
91//! end state is per-agent identity (NKeys / NATS-JWT), for which the plan is
92//! to grow `ConnectOptions` here so every binary picks up the upgrade for
93//! free. Same for mTLS.
94
95use anyhow::{Context, Result};
96
97use crate::secrets;
98
99const ENV_TOKEN: &str = "KANADE_NATS_TOKEN";
100const REG_VALUE: &str = "NatsToken";
101
102/// Prefix every kanade connection announces in its `name`.
103const NAME_PREFIX: &str = "kanade-";
104
105/// Separator between the role and the host identity in a connection name.
106/// `/` is safe as a delimiter because the identity is a Windows computer
107/// name, which cannot contain one.
108const NAME_SEP: char = '/';
109
110/// Registry subkey holding the pre-#1155 shared credential. Also the agent's
111/// role key, which is not a coincidence — the shared token was provisioned
112/// under the agent's path because agents were the first thing to need it.
113const REG_SHARED_SUBKEY: &str = r"SOFTWARE\kanade\agent";
114
115/// Which kanade binary is opening the connection.
116///
117/// Named on every call rather than inferred, because the broker's view of a
118/// connection comes entirely from the credential it presents: a caller that
119/// picks the wrong role does not get a warning, it gets someone else's
120/// permissions.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum NatsRole {
123    /// The endpoint agent. The most numerous and least trusted role — one
124    /// compromised endpoint holds this credential.
125    Agent,
126    /// The backend. The only role that needs to see the whole fleet.
127    Backend,
128    /// The operator CLI, including the backend-down recovery path that
129    /// drives agents over NATS directly.
130    Cli,
131}
132
133impl NatsRole {
134    pub fn as_str(self) -> &'static str {
135        match self {
136            NatsRole::Agent => "agent",
137            NatsRole::Backend => "backend",
138            NatsRole::Cli => "cli",
139        }
140    }
141
142    /// Registry subkey holding this role's credential.
143    fn reg_subkey(self) -> String {
144        format!(r"SOFTWARE\kanade\{}", self.as_str())
145    }
146}
147
148/// Resolve a role's token, given a registry reader and the environment
149/// fallback.
150///
151/// Split from [`resolve_token`] so the *ordering* — the part that decides
152/// whether a migration is reversible — is testable without a Windows
153/// registry to write to.
154fn resolve_token_with(
155    role: NatsRole,
156    read_reg: impl Fn(&str, &str) -> Option<String>,
157    env: Option<String>,
158) -> Option<String> {
159    if let Some(t) = read_reg(&role.reg_subkey(), REG_VALUE) {
160        return Some(t);
161    }
162    if let Some(t) = read_reg(REG_SHARED_SUBKEY, REG_VALUE) {
163        return Some(t);
164    }
165    env.filter(|t| !t.is_empty())
166}
167
168fn resolve_token(role: NatsRole) -> Option<String> {
169    resolve_token_with(
170        role,
171        secrets::read_hklm_value,
172        std::env::var(ENV_TOKEN).ok(),
173    )
174}
175
176/// The `name` a kanade process announces on its NATS connection.
177///
178/// Without an identity this is `kanade-<role>`; with one it is
179/// `kanade-<role>/<identity>`. The broker echoes it back verbatim in
180/// `/connz`, which is what lets the backend attribute a connection — and
181/// therefore the credential the broker authenticated it with — to a pc_id
182/// (#1270). Nothing else on a connection carries the pc_id: the CID is
183/// assigned by the server and the IP is not a stable identifier on a fleet
184/// of laptops.
185///
186/// The name is client-supplied and therefore claimed, not proved. What
187/// `/connz` makes unforgeable is the *credential* half of the pair; a host
188/// can still lie about which pc_id it is. Under one fleet-wide token that
189/// changes nothing (every host can already impersonate every other), and
190/// closing it for good is per-agent identity, not a naming convention.
191pub fn client_name(role: NatsRole, identity: Option<&str>) -> String {
192    match identity.map(str::trim).filter(|s| !s.is_empty()) {
193        Some(id) => format!("{NAME_PREFIX}{}{NAME_SEP}{id}", role.as_str()),
194        None => format!("{NAME_PREFIX}{}", role.as_str()),
195    }
196}
197
198/// A connection name split back into its parts — see [`client_name`].
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200pub struct ClientName<'a> {
201    /// The role segment as announced. A `&str` rather than a [`NatsRole`]
202    /// on purpose: a connection from a future (or foreign) build may name a
203    /// role this binary does not know, and dropping it on the floor would
204    /// hide exactly the host worth looking at.
205    pub role: &'a str,
206    /// The host identity, when the connection carried one. `None` for the
207    /// backend / CLI (which are not per-host) and for agents predating
208    /// #1270 — those simply cannot be attributed.
209    pub identity: Option<&'a str>,
210}
211
212/// Parse a connection name produced by [`client_name`]. `None` for any name
213/// that is not a kanade connection at all (a `nats` CLI session, a
214/// monitoring tool), which the caller should ignore rather than guess about.
215pub fn parse_client_name(name: &str) -> Option<ClientName<'_>> {
216    let rest = name.strip_prefix(NAME_PREFIX)?;
217    Some(match rest.split_once(NAME_SEP) {
218        // An empty identity (`kanade-agent/`) is not an identity.
219        Some((role, id)) if !role.is_empty() && !id.is_empty() => ClientName {
220            role,
221            identity: Some(id),
222        },
223        Some((role, _)) if !role.is_empty() => ClientName {
224            role,
225            identity: None,
226        },
227        Some(_) => return None,
228        None if !rest.is_empty() => ClientName {
229            role: rest,
230            identity: None,
231        },
232        None => return None,
233    })
234}
235
236/// Answers "is this credential the one *we* present?" without handing the
237/// credential itself to the caller.
238///
239/// #1270: the NATS monitoring endpoint reports `authorized_user` per
240/// connection. A current nats-server hides that field for
241/// token-authenticated connections, but that is the broker build's
242/// behaviour, not a guarantee this side can lean on — a consumer of
243/// `/connz` has to treat the value as possibly being the fleet-wide secret.
244/// The one question it may safely answer about it is whether it equals the
245/// credential this process already holds, and that answer is enough to
246/// label a connection ("still on the shared token") without ever storing or
247/// serving the value.
248///
249/// Constructed once and reused: [`resolve_token`] hits the Windows registry,
250/// and the caller compares against every connection on the broker.
251pub struct CredentialProbe {
252    presented: Credential,
253}
254
255/// What a process presents when it connects.
256enum Credential {
257    /// Nothing — the dev path, against a broker with no `authorization`.
258    None,
259    /// A bearer token. The only shape [`connect`] can present today.
260    Token(String),
261    /// A named user. Reserved for the client half of #1266; see
262    /// [`CredentialKind::User`] for why the distinction matters here.
263    User { name: String },
264}
265
266/// Which shape of credential a [`CredentialProbe`] holds.
267#[derive(Debug, Clone, Copy, PartialEq, Eq)]
268pub enum CredentialKind {
269    None,
270    Token,
271    /// A named user — and, when the connection presenting it is **live**,
272    /// positive proof that the broker is running `users` rather than a
273    /// token: nats-server refuses to load a config carrying both a `token`
274    /// and a `users` array ("Can not have a token and a users array") and
275    /// rejects token authentication outright once `users` are defined.
276    ///
277    /// That proof is the only thing that makes a reported `authorized_user`
278    /// safe to record verbatim. Note what is *not* proof: holding no
279    /// credential locally. A process that never authenticated at all can
280    /// still read a monitoring endpoint, and inferring the broker's mode
281    /// from a local absence would let a misconfigured host store the very
282    /// secret the rest of this type exists to protect.
283    User,
284}
285
286/// Hand-written so a stray `{:?}` in a log line cannot print the credential.
287/// A username is not a secret and is shown; a token never is.
288impl std::fmt::Debug for CredentialProbe {
289    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
290        let rendered = match &self.presented {
291            Credential::None => "<none>".to_string(),
292            Credential::Token(_) => "<redacted token>".to_string(),
293            Credential::User { name } => format!("user {name}"),
294        };
295        f.debug_struct("CredentialProbe")
296            .field("presented", &rendered)
297            .finish()
298    }
299}
300
301impl CredentialProbe {
302    /// Resolve the credential `role` would present, exactly as [`connect`]
303    /// does.
304    pub fn for_role(role: NatsRole) -> Self {
305        Self {
306            presented: match resolve_token(role) {
307                Some(t) => Credential::Token(t),
308                None => Credential::None,
309            },
310        }
311    }
312
313    /// Build a probe around an explicitly-supplied token.
314    ///
315    /// The production path is [`Self::for_role`]; this exists so callers can
316    /// be tested against a known credential without a Windows registry to
317    /// write to — and, on a developer's machine, without accidentally
318    /// probing the real fleet token that `for_role` would find there.
319    pub fn from_token(token: Option<String>) -> Self {
320        Self {
321            presented: match token {
322                Some(t) => Credential::Token(t),
323                None => Credential::None,
324            },
325        }
326    }
327
328    /// Build a probe for a process that authenticates as a named user.
329    ///
330    /// Nothing constructs this in production yet — [`connect`] cannot
331    /// present a user (#1266). It exists so the consumers of
332    /// [`CredentialKind::User`] are testable now rather than written blind
333    /// later.
334    pub fn from_user(name: impl Into<String>) -> Self {
335        Self {
336            presented: Credential::User { name: name.into() },
337        }
338    }
339
340    /// Which shape of credential this process presents.
341    pub fn kind(&self) -> CredentialKind {
342        match &self.presented {
343            Credential::None => CredentialKind::None,
344            Credential::Token(_) => CredentialKind::Token,
345            Credential::User { .. } => CredentialKind::User,
346        }
347    }
348
349    /// Whether `candidate` is the **secret** this process presents.
350    ///
351    /// Only ever true for a token. A user's secret is its password, which
352    /// `authorized_user` never carries — matching a username here would
353    /// mean "this connection is on the same account", a different and much
354    /// weaker statement than the one callers use this for.
355    ///
356    /// A plain comparison: `candidate` comes from the broker's own report of
357    /// connections it already authenticated, not from an attacker-chosen
358    /// input, so there is no oracle to time.
359    pub fn is_ours(&self, candidate: &str) -> bool {
360        match &self.presented {
361            Credential::Token(t) => t == candidate,
362            Credential::None | Credential::User { .. } => false,
363        }
364    }
365}
366
367/// Connect to NATS at `url` as `role`. Resolves the bearer token from the
368/// registry (Windows) or `$KANADE_NATS_TOKEN`; connects unauthenticated when
369/// neither is set.
370///
371/// The connection is announced as `kanade-<role>` with no host identity —
372/// right for the backend and the CLI, which are not per-host. A role that
373/// has to be attributable to a specific machine (the agent) must use
374/// [`connect_with_event_callback`] and pass one; see [`client_name`].
375pub async fn connect(role: NatsRole, url: &str) -> Result<async_nats::Client> {
376    connect_inner(
377        role,
378        url,
379        None,
380        None::<fn(async_nats::Event) -> std::future::Ready<()>>,
381    )
382    .await
383}
384
385/// Same as [`connect`] but also wires an `event_callback` that fires
386/// whenever async-nats publishes a `ConnectEvent` (Connected,
387/// Disconnected, ServerError, etc.). The callback's `Future` runs on
388/// the async-nats internal task — keep it cheap and non-blocking
389/// (set a flag, send on a channel, that kind of thing) so the
390/// connection state machine isn't held up.
391///
392/// Used by the agent's v0.26 Layer 2 staleness tracker: the callback
393/// stamps a shared `Mutex<Option<Instant>>` on every Connected event,
394/// so `decide()` at fire time can answer "how long ago were we last
395/// definitely-talking-to-the-broker" without a polling loop.
396///
397/// `identity` names the host this connection belongs to (the agent's
398/// pc_id). It becomes part of the connection name the broker echoes in
399/// `/connz`, which is the only thing tying a connection — and the
400/// credential that opened it — back to a machine (#1270).
401pub async fn connect_with_event_callback<F, Fut>(
402    role: NatsRole,
403    url: &str,
404    identity: Option<&str>,
405    cb: F,
406) -> Result<async_nats::Client>
407where
408    F: Fn(async_nats::Event) -> Fut + Send + Sync + 'static,
409    Fut: std::future::Future<Output = ()> + Send + Sync + 'static,
410{
411    connect_inner(role, url, identity, Some(cb)).await
412}
413
414async fn connect_inner<F, Fut>(
415    role: NatsRole,
416    url: &str,
417    identity: Option<&str>,
418    cb: Option<F>,
419) -> Result<async_nats::Client>
420where
421    F: Fn(async_nats::Event) -> Fut + Send + Sync + 'static,
422    Fut: std::future::Future<Output = ()> + Send + Sync + 'static,
423{
424    // v0.38 / #137: offline-tolerant boot. Without
425    // `retry_on_initial_connect`, `opts.connect(url).await` blocks-then-
426    // errors when the broker is unreachable at startup — the agent
427    // process dies, SCM ticks its restart counter, and the offline-
428    // tolerant subsystems (local_scheduler, outbox drain) never spawn.
429    // With this flag, connect() returns `Ok(Client)` immediately and
430    // async-nats does the reconnect in the background; subscribe()
431    // calls queue the SUB frame until the link is up.
432    let opts = async_nats::ConnectOptions::new()
433        .retry_on_initial_connect()
434        // Names the connection in `nats server report connections`, in the
435        // broker's own logs, and in `/connz`. Free observability while the
436        // fleet is mid-migration: it shows which roles are connecting even
437        // before their credentials differ, which is exactly the window in
438        // which a wrongly-provisioned host is otherwise invisible. With an
439        // identity it also carries the pc_id, so #1270 can join the broker's
440        // per-connection `authorized_user` back onto the agents row.
441        .name(client_name(role, identity));
442    let opts = match resolve_token(role) {
443        Some(token) => opts.token(token),
444        None => opts,
445    };
446    let opts = match cb {
447        Some(cb) => opts.event_callback(cb),
448        None => opts,
449    };
450    opts.connect(url)
451        .await
452        .with_context(|| format!("connect to NATS at {url}"))
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458    use std::collections::HashMap;
459
460    /// A stand-in registry. Keys are `subkey\value`.
461    fn reg(entries: &[(&str, &str)]) -> impl Fn(&str, &str) -> Option<String> {
462        let map: HashMap<String, String> = entries
463            .iter()
464            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
465            .collect();
466        move |subkey: &str, value: &str| map.get(&format!(r"{subkey}\{value}")).cloned()
467    }
468
469    #[test]
470    fn role_subkeys_are_distinct_and_agent_matches_the_shared_path() {
471        assert_eq!(NatsRole::Backend.reg_subkey(), r"SOFTWARE\kanade\backend");
472        assert_eq!(NatsRole::Cli.reg_subkey(), r"SOFTWARE\kanade\cli");
473        // The agent's role key IS the historical shared key, so an agent
474        // never sees a migration at all.
475        assert_eq!(NatsRole::Agent.reg_subkey(), REG_SHARED_SUBKEY);
476    }
477
478    #[test]
479    fn an_unmigrated_fleet_keeps_presenting_the_shared_token() {
480        // The state of every host today: one token, under the agent path.
481        let registry = reg(&[(r"SOFTWARE\kanade\agent\NatsToken", "shared")]);
482        for role in [NatsRole::Agent, NatsRole::Backend, NatsRole::Cli] {
483            assert_eq!(
484                resolve_token_with(role, &registry, None).as_deref(),
485                Some("shared"),
486                "{role:?} must keep working before its own key is provisioned"
487            );
488        }
489    }
490
491    #[test]
492    fn a_role_key_wins_over_the_shared_one() {
493        let registry = reg(&[
494            (r"SOFTWARE\kanade\agent\NatsToken", "shared"),
495            (r"SOFTWARE\kanade\backend\NatsToken", "backend-only"),
496        ]);
497        // The migrated role uses its own credential...
498        assert_eq!(
499            resolve_token_with(NatsRole::Backend, &registry, None).as_deref(),
500            Some("backend-only")
501        );
502        // ...while a role that has not been migrated yet is unaffected.
503        assert_eq!(
504            resolve_token_with(NatsRole::Cli, &registry, None).as_deref(),
505            Some("shared")
506        );
507    }
508
509    #[test]
510    fn removing_a_role_key_falls_back_rather_than_failing() {
511        // Rollback of a per-host migration step: the role key is gone, and
512        // the host must return to the shared credential instead of
513        // connecting unauthenticated (which a broker with `authorization`
514        // would refuse — turning a rollback into an outage).
515        let registry = reg(&[(r"SOFTWARE\kanade\agent\NatsToken", "shared")]);
516        assert_eq!(
517            resolve_token_with(NatsRole::Backend, &registry, None).as_deref(),
518            Some("shared")
519        );
520    }
521
522    #[test]
523    fn the_registry_outranks_the_environment() {
524        // Unchanged from before roles existed: a dev shell's env var must
525        // not quietly override a provisioned production credential.
526        let registry = reg(&[(r"SOFTWARE\kanade\agent\NatsToken", "shared")]);
527        assert_eq!(
528            resolve_token_with(NatsRole::Agent, &registry, Some("from-env".into())).as_deref(),
529            Some("shared")
530        );
531    }
532
533    #[test]
534    fn the_environment_serves_hosts_with_no_registry_at_all() {
535        let empty = reg(&[]);
536        assert_eq!(
537            resolve_token_with(NatsRole::Cli, &empty, Some("from-env".into())).as_deref(),
538            Some("from-env")
539        );
540        // An empty env var is not a credential — it must fall through to
541        // "no token" so a dev broker without `authorization` still works,
542        // rather than presenting the empty string and being rejected.
543        assert_eq!(
544            resolve_token_with(NatsRole::Cli, &empty, Some(String::new())),
545            None
546        );
547        assert_eq!(resolve_token_with(NatsRole::Cli, &empty, None), None);
548    }
549
550    // ── #1270: connection naming ─────────────────────────────────────
551
552    #[test]
553    fn an_identity_round_trips_through_the_connection_name() {
554        // The pc_id is the join key between `/connz` and the agents table,
555        // so the name has to survive the trip unchanged — including the
556        // casing, which is NOT uniform across the fleet and which NATS
557        // subjects treat as significant.
558        for pc in ["PC001", "minipc", "Web%01", "ws-9"] {
559            let name = client_name(NatsRole::Agent, Some(pc));
560            let parsed = parse_client_name(&name).expect("our own name must parse");
561            assert_eq!(parsed.role, "agent");
562            assert_eq!(parsed.identity, Some(pc));
563        }
564    }
565
566    #[test]
567    fn a_role_without_an_identity_keeps_the_pre_1270_name() {
568        // The backend and the CLI are not per-host, and an agent that
569        // predates #1270 announces this shape too. Both must parse as "a
570        // kanade connection we cannot attribute" rather than as an error or
571        // as an empty pc_id.
572        assert_eq!(client_name(NatsRole::Backend, None), "kanade-backend");
573        let parsed = parse_client_name("kanade-agent").unwrap();
574        assert_eq!(parsed.role, "agent");
575        assert_eq!(parsed.identity, None);
576        // Whitespace-only is not an identity either — it would otherwise
577        // produce a name that parses back into a pc_id no row can match.
578        assert_eq!(client_name(NatsRole::Agent, Some("  ")), "kanade-agent");
579    }
580
581    #[test]
582    fn foreign_connections_do_not_parse_as_kanade_ones() {
583        // A `nats` CLI session or a monitoring tool shares the broker. The
584        // projector must skip those rather than attribute them to a host.
585        assert!(parse_client_name("NATS CLI Version 0.1.5").is_none());
586        assert!(parse_client_name("").is_none());
587        assert!(parse_client_name("kanade-").is_none());
588        assert!(parse_client_name("kanade-/PC001").is_none());
589        // A trailing separator with no identity is a role, not a pc_id.
590        assert_eq!(parse_client_name("kanade-agent/").unwrap().identity, None);
591    }
592
593    #[test]
594    fn an_unknown_role_is_preserved_rather_than_dropped() {
595        // A future build (or something impersonating one) naming a role this
596        // binary has never heard of is precisely the connection an operator
597        // wants to see.
598        let parsed = parse_client_name("kanade-relay/PC001").unwrap();
599        assert_eq!(parsed.role, "relay");
600        assert_eq!(parsed.identity, Some("PC001"));
601    }
602
603    // ── #1270: credential probe ──────────────────────────────────────
604
605    #[test]
606    fn the_probe_recognises_only_the_credential_we_present() {
607        let probe = CredentialProbe::from_token(Some("shared".into()));
608        assert_eq!(probe.kind(), CredentialKind::Token);
609        assert!(probe.is_ours("shared"));
610        assert!(!probe.is_ours("something-else"));
611        // The empty string is what the broker reports for a connection it
612        // did not authenticate at all. It must never read as "ours".
613        assert!(!probe.is_ours(""));
614    }
615
616    #[test]
617    fn a_probe_with_no_credential_matches_nothing() {
618        // Dev broker with no `authorization` block. We hold nothing, so we
619        // can prove nothing about anyone else's credential — including that
620        // it is safe to store.
621        let probe = CredentialProbe::from_token(None);
622        assert_eq!(probe.kind(), CredentialKind::None);
623        assert!(!probe.is_ours(""));
624        assert!(!probe.is_ours("anything"));
625    }
626
627    #[test]
628    fn a_username_is_not_a_secret_we_can_recognise() {
629        // `is_ours` answers "is this MY secret", and a user's secret is its
630        // password. Matching on the username instead would answer a much
631        // weaker question while reading like the strong one.
632        let probe = CredentialProbe::from_user("kanade-backend");
633        assert_eq!(probe.kind(), CredentialKind::User);
634        assert!(!probe.is_ours("kanade-backend"));
635    }
636
637    #[test]
638    fn the_probe_never_prints_the_credential() {
639        // `/connz` handling logs liberally; one `{:?}` on the probe must not
640        // be the thing that puts the fleet's token in a log file.
641        let probe = CredentialProbe::from_token(Some("super-secret-token".into()));
642        let rendered = format!("{probe:?}");
643        assert!(!rendered.contains("super-secret-token"), "{rendered}");
644        assert!(rendered.contains("redacted"), "{rendered}");
645        assert!(
646            format!("{:?}", CredentialProbe::from_token(None)).contains("none"),
647            "the no-credential case should be visible, just not the value"
648        );
649        // A username is not a secret — hiding it would cost diagnosability
650        // for nothing.
651        assert!(
652            format!("{:?}", CredentialProbe::from_user("kanade-backend"))
653                .contains("kanade-backend"),
654        );
655    }
656}