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    // #1187: a workspace build unifies rustls features, so any binary built
425    // alongside a reqwest user (the CLI, the backend) links BOTH aws-lc-rs and
426    // ring. rustls 0.23 then cannot auto-pick a process-level provider, and the
427    // first TLS handshake — a `wss://`/`tls://` broker — panics inside
428    // async-nats' connection task. That panic does not surface here: the
429    // client just goes dead. Every production connect funnels through this
430    // function, so installing ring here covers every binary. `install_default`
431    // returns Err when a provider is already installed — ignore it.
432    let _ = rustls::crypto::ring::default_provider().install_default();
433
434    // v0.38 / #137: offline-tolerant boot. Without
435    // `retry_on_initial_connect`, `opts.connect(url).await` blocks-then-
436    // errors when the broker is unreachable at startup — the agent
437    // process dies, SCM ticks its restart counter, and the offline-
438    // tolerant subsystems (local_scheduler, outbox drain) never spawn.
439    // With this flag, connect() returns `Ok(Client)` immediately and
440    // async-nats does the reconnect in the background; subscribe()
441    // calls queue the SUB frame until the link is up.
442    let opts = async_nats::ConnectOptions::new()
443        .retry_on_initial_connect()
444        // Names the connection in `nats server report connections`, in the
445        // broker's own logs, and in `/connz`. Free observability while the
446        // fleet is mid-migration: it shows which roles are connecting even
447        // before their credentials differ, which is exactly the window in
448        // which a wrongly-provisioned host is otherwise invisible. With an
449        // identity it also carries the pc_id, so #1270 can join the broker's
450        // per-connection `authorized_user` back onto the agents row.
451        .name(client_name(role, identity));
452    let opts = match resolve_token(role) {
453        Some(token) => opts.token(token),
454        None => opts,
455    };
456    let opts = match cb {
457        Some(cb) => opts.event_callback(cb),
458        None => opts,
459    };
460    opts.connect(url)
461        .await
462        .with_context(|| format!("connect to NATS at {url}"))
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468    use std::collections::HashMap;
469
470    /// A stand-in registry. Keys are `subkey\value`.
471    fn reg(entries: &[(&str, &str)]) -> impl Fn(&str, &str) -> Option<String> {
472        let map: HashMap<String, String> = entries
473            .iter()
474            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
475            .collect();
476        move |subkey: &str, value: &str| map.get(&format!(r"{subkey}\{value}")).cloned()
477    }
478
479    #[test]
480    fn role_subkeys_are_distinct_and_agent_matches_the_shared_path() {
481        assert_eq!(NatsRole::Backend.reg_subkey(), r"SOFTWARE\kanade\backend");
482        assert_eq!(NatsRole::Cli.reg_subkey(), r"SOFTWARE\kanade\cli");
483        // The agent's role key IS the historical shared key, so an agent
484        // never sees a migration at all.
485        assert_eq!(NatsRole::Agent.reg_subkey(), REG_SHARED_SUBKEY);
486    }
487
488    #[test]
489    fn an_unmigrated_fleet_keeps_presenting_the_shared_token() {
490        // The state of every host today: one token, under the agent path.
491        let registry = reg(&[(r"SOFTWARE\kanade\agent\NatsToken", "shared")]);
492        for role in [NatsRole::Agent, NatsRole::Backend, NatsRole::Cli] {
493            assert_eq!(
494                resolve_token_with(role, &registry, None).as_deref(),
495                Some("shared"),
496                "{role:?} must keep working before its own key is provisioned"
497            );
498        }
499    }
500
501    #[test]
502    fn a_role_key_wins_over_the_shared_one() {
503        let registry = reg(&[
504            (r"SOFTWARE\kanade\agent\NatsToken", "shared"),
505            (r"SOFTWARE\kanade\backend\NatsToken", "backend-only"),
506        ]);
507        // The migrated role uses its own credential...
508        assert_eq!(
509            resolve_token_with(NatsRole::Backend, &registry, None).as_deref(),
510            Some("backend-only")
511        );
512        // ...while a role that has not been migrated yet is unaffected.
513        assert_eq!(
514            resolve_token_with(NatsRole::Cli, &registry, None).as_deref(),
515            Some("shared")
516        );
517    }
518
519    #[test]
520    fn removing_a_role_key_falls_back_rather_than_failing() {
521        // Rollback of a per-host migration step: the role key is gone, and
522        // the host must return to the shared credential instead of
523        // connecting unauthenticated (which a broker with `authorization`
524        // would refuse — turning a rollback into an outage).
525        let registry = reg(&[(r"SOFTWARE\kanade\agent\NatsToken", "shared")]);
526        assert_eq!(
527            resolve_token_with(NatsRole::Backend, &registry, None).as_deref(),
528            Some("shared")
529        );
530    }
531
532    #[test]
533    fn the_registry_outranks_the_environment() {
534        // Unchanged from before roles existed: a dev shell's env var must
535        // not quietly override a provisioned production credential.
536        let registry = reg(&[(r"SOFTWARE\kanade\agent\NatsToken", "shared")]);
537        assert_eq!(
538            resolve_token_with(NatsRole::Agent, &registry, Some("from-env".into())).as_deref(),
539            Some("shared")
540        );
541    }
542
543    #[test]
544    fn the_environment_serves_hosts_with_no_registry_at_all() {
545        let empty = reg(&[]);
546        assert_eq!(
547            resolve_token_with(NatsRole::Cli, &empty, Some("from-env".into())).as_deref(),
548            Some("from-env")
549        );
550        // An empty env var is not a credential — it must fall through to
551        // "no token" so a dev broker without `authorization` still works,
552        // rather than presenting the empty string and being rejected.
553        assert_eq!(
554            resolve_token_with(NatsRole::Cli, &empty, Some(String::new())),
555            None
556        );
557        assert_eq!(resolve_token_with(NatsRole::Cli, &empty, None), None);
558    }
559
560    // ── #1270: connection naming ─────────────────────────────────────
561
562    #[test]
563    fn an_identity_round_trips_through_the_connection_name() {
564        // The pc_id is the join key between `/connz` and the agents table,
565        // so the name has to survive the trip unchanged — including the
566        // casing, which is NOT uniform across the fleet and which NATS
567        // subjects treat as significant.
568        for pc in ["PC001", "minipc", "Web%01", "ws-9"] {
569            let name = client_name(NatsRole::Agent, Some(pc));
570            let parsed = parse_client_name(&name).expect("our own name must parse");
571            assert_eq!(parsed.role, "agent");
572            assert_eq!(parsed.identity, Some(pc));
573        }
574    }
575
576    #[test]
577    fn a_role_without_an_identity_keeps_the_pre_1270_name() {
578        // The backend and the CLI are not per-host, and an agent that
579        // predates #1270 announces this shape too. Both must parse as "a
580        // kanade connection we cannot attribute" rather than as an error or
581        // as an empty pc_id.
582        assert_eq!(client_name(NatsRole::Backend, None), "kanade-backend");
583        let parsed = parse_client_name("kanade-agent").unwrap();
584        assert_eq!(parsed.role, "agent");
585        assert_eq!(parsed.identity, None);
586        // Whitespace-only is not an identity either — it would otherwise
587        // produce a name that parses back into a pc_id no row can match.
588        assert_eq!(client_name(NatsRole::Agent, Some("  ")), "kanade-agent");
589    }
590
591    #[test]
592    fn foreign_connections_do_not_parse_as_kanade_ones() {
593        // A `nats` CLI session or a monitoring tool shares the broker. The
594        // projector must skip those rather than attribute them to a host.
595        assert!(parse_client_name("NATS CLI Version 0.1.5").is_none());
596        assert!(parse_client_name("").is_none());
597        assert!(parse_client_name("kanade-").is_none());
598        assert!(parse_client_name("kanade-/PC001").is_none());
599        // A trailing separator with no identity is a role, not a pc_id.
600        assert_eq!(parse_client_name("kanade-agent/").unwrap().identity, None);
601    }
602
603    #[test]
604    fn an_unknown_role_is_preserved_rather_than_dropped() {
605        // A future build (or something impersonating one) naming a role this
606        // binary has never heard of is precisely the connection an operator
607        // wants to see.
608        let parsed = parse_client_name("kanade-relay/PC001").unwrap();
609        assert_eq!(parsed.role, "relay");
610        assert_eq!(parsed.identity, Some("PC001"));
611    }
612
613    // ── #1270: credential probe ──────────────────────────────────────
614
615    #[test]
616    fn the_probe_recognises_only_the_credential_we_present() {
617        let probe = CredentialProbe::from_token(Some("shared".into()));
618        assert_eq!(probe.kind(), CredentialKind::Token);
619        assert!(probe.is_ours("shared"));
620        assert!(!probe.is_ours("something-else"));
621        // The empty string is what the broker reports for a connection it
622        // did not authenticate at all. It must never read as "ours".
623        assert!(!probe.is_ours(""));
624    }
625
626    #[test]
627    fn a_probe_with_no_credential_matches_nothing() {
628        // Dev broker with no `authorization` block. We hold nothing, so we
629        // can prove nothing about anyone else's credential — including that
630        // it is safe to store.
631        let probe = CredentialProbe::from_token(None);
632        assert_eq!(probe.kind(), CredentialKind::None);
633        assert!(!probe.is_ours(""));
634        assert!(!probe.is_ours("anything"));
635    }
636
637    #[test]
638    fn a_username_is_not_a_secret_we_can_recognise() {
639        // `is_ours` answers "is this MY secret", and a user's secret is its
640        // password. Matching on the username instead would answer a much
641        // weaker question while reading like the strong one.
642        let probe = CredentialProbe::from_user("kanade-backend");
643        assert_eq!(probe.kind(), CredentialKind::User);
644        assert!(!probe.is_ours("kanade-backend"));
645    }
646
647    #[test]
648    fn the_probe_never_prints_the_credential() {
649        // `/connz` handling logs liberally; one `{:?}` on the probe must not
650        // be the thing that puts the fleet's token in a log file.
651        let probe = CredentialProbe::from_token(Some("super-secret-token".into()));
652        let rendered = format!("{probe:?}");
653        assert!(!rendered.contains("super-secret-token"), "{rendered}");
654        assert!(rendered.contains("redacted"), "{rendered}");
655        assert!(
656            format!("{:?}", CredentialProbe::from_token(None)).contains("none"),
657            "the no-credential case should be visible, just not the value"
658        );
659        // A username is not a secret — hiding it would cost diagnosability
660        // for nothing.
661        assert!(
662            format!("{:?}", CredentialProbe::from_user("kanade-backend"))
663                .contains("kanade-backend"),
664        );
665    }
666
667    #[test]
668    fn connect_leaves_a_process_crypto_provider_installed() {
669        // #1187: a binary that links both aws-lc-rs and ring (any workspace
670        // build) panics on its first wss handshake unless a process-level
671        // provider was installed first. That panic happens inside async-nats'
672        // background task, so the only thing a caller ever sees is a client
673        // that silently never works — pin the precondition here instead.
674        // `retry_on_initial_connect` makes connect return without a broker.
675        let rt = tokio::runtime::Builder::new_current_thread()
676            .enable_all()
677            .build()
678            .unwrap();
679        rt.block_on(async {
680            connect(NatsRole::Agent, "nats://127.0.0.1:1")
681                .await
682                .unwrap();
683        });
684        assert!(rustls::crypto::CryptoProvider::get_default().is_some());
685    }
686}