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, ®istry, 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, ®istry, 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, ®istry, 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, ®istry, 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, ®istry, 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}