Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-agent/src/swarm_queue.rs
atlas 87b1f94ac6 move agent_token from swarm-queue-client to swarm-agent-verify
per #5471, verifiers stop depending on the queue client. the module is
store-free and needs only thiserror, so swarm-agent-verify gains one
dependency and loses its swarm-queue-client edge. consumers import it
from swarm_agent_verify; swarm-logs and swarm-metrics, whose only use of
swarm-queue-client was this module, drop that dependency, as does
swarm-logs-auth.
2026-10-18 16:58:39 +02:00

811 lines
30 KiB
Rust

//! This agent's swarm-queue connection, made with the agent's own credential.
//!
//! The credential is minted per agent at swarm level. This process reads it
//! from the swarm secret store under the agent's own certificate and holds it
//! in memory only ([`AgentSecret`]), and publishes on the agent's own
//! hive-free subjects.
//!
//! **The connection is required.** [`init`] fails when the queue's address or
//! the store's coordinates are missing, and [`client`] retries the first
//! connect on a doubling backoff and then exits the process, so the harness
//! unit fails and systemd restarts it.
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use anyhow::{Context as _, anyhow};
use backon::{ExponentialBuilder, Retryable as _};
use tokio::sync::OnceCell;
use swarm_secret_client::{
SecretStore,
client::{DEFAULT_CERT_MOUNT, ENV_ADDR, ENV_CACERT, Settings},
policy, queue,
};
/// Where the swarm queue listens, as this container reaches it.
const ENV_NATS_URL: &str = "HIVE_AGENT_NATS_URL";
/// Names the agent whose queue secret this process reads. The cert-auth role
/// and the store path are both built from it by `swarm_secret_client`.
const ENV_AGENT_NAME: &str = "HIVE_AGENT_NAME";
/// How long one read of the store may take. The read runs inside a queue
/// connection attempt, and a store that never answers would otherwise stall
/// every reconnect behind it.
const STORE_READ_TIMEOUT: Duration = Duration::from_secs(10);
/// The first wait between first-connect attempts, doubled per failure up to
/// [`CONNECT_MAX_WAIT`].
const CONNECT_FIRST_WAIT: Duration = Duration::from_secs(5);
/// The longest wait between first-connect attempts.
const CONNECT_MAX_WAIT: Duration = Duration::from_secs(160);
/// Waits of 5s doubling to 160s, about five minutes in all. After the last
/// attempt fails the process exits.
const CONNECT_RETRIES: usize = 6;
/// Logged at `error` once every first-connect attempt has failed, just before
/// the process exits. The Grafana rule `hh-swarm-queue-connect-failed`
/// (`nix/host-modules/swarm-grafana.nix`) matches this exact text: change both
/// together.
const CONNECT_EXHAUSTED: &str =
"no swarm queue connection; exiting, since this agent cannot run without one";
/// Where to connect, resolved once at boot by [`init`].
static AGENT: OnceLock<AgentPath> = OnceLock::new();
/// The one connection every publisher in this process shares. Separate from
/// [`AGENT`] because resolving the coordinates is synchronous boot work and
/// connecting is not — see [`client`].
static CLIENT: OnceCell<async_nats::Client> = OnceCell::const_new();
/// What connecting with this agent's own credential needs.
#[derive(Debug, PartialEq, Eq)]
struct AgentPath {
url: String,
store: Arc<StoreSource>,
}
/// This agent's queue secret as the store holds it: where the store is, the
/// role this agent logs in as, and the path it reads.
#[derive(Debug, PartialEq, Eq)]
struct StoreSource {
settings: Settings,
role: String,
path: String,
}
/// Where [`AgentSecret`] reads the secret from. A trait so the retry rules can
/// be exercised without a store.
trait SecretSource: Send + Sync + 'static {
/// The store path, for log lines and errors. Never the value.
fn origin(&self) -> &str;
/// The value stored now, or `None` when nothing is stored.
fn fetch(&self) -> impl Future<Output = anyhow::Result<Option<String>>> + Send;
}
impl SecretSource for Arc<StoreSource> {
fn origin(&self) -> &str {
&self.path
}
/// A fresh login per read: reads happen once per connection attempt, and a
/// token held between them would be one more secret in memory to expire.
async fn fetch(&self) -> anyhow::Result<Option<String>> {
let store = SecretStore::connect(&self.settings, &self.role, DEFAULT_CERT_MOUNT)
.await
.context("logging in to the swarm secret store as this agent")?;
// `read_optional`: only a 404 is "nothing stored". A 403 stays an
// error, so a policy that stopped covering the path is not reported
// as a secret that was deleted.
let credential: Option<queue::AgentCredential> = store
.read_optional(&self.path)
.await
.with_context(|| format!("reading {} from the store", self.path))?;
Ok(credential.map(|c| c.value))
}
}
/// This agent's queue secret, held in memory between connection attempts.
///
/// No `Debug`: [`Held`] carries the secret.
struct AgentSecret<S> {
source: S,
timeout: Duration,
held: Mutex<Held>,
}
#[derive(Default)]
struct Held {
value: Option<String>,
/// `value` was read by [`AgentSecret::prime`] and no attempt has
/// presented it yet.
unpresented: bool,
}
impl<S: SecretSource> AgentSecret<S> {
fn new(source: S) -> Self {
Self {
source,
timeout: STORE_READ_TIMEOUT,
held: Mutex::default(),
}
}
fn held(&self) -> std::sync::MutexGuard<'_, Held> {
// A poisoned lock only means another attempt panicked mid-update; the
// worst it left is a stale value, which the next read replaces.
self.held
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Read the secret before the first connection attempt. An `Err` here
/// fails that attempt.
async fn prime(&self) -> anyhow::Result<()> {
let value = self
.read()
.await?
.with_context(|| format!("nothing is stored at {}", self.source.origin()))?;
*self.held() = Held {
value: Some(value),
unpresented: true,
};
Ok(())
}
/// The secret to present on one connection attempt.
///
/// The value [`prime`](Self::prime) read serves the first attempt. Every
/// later attempt reads the store again, so after the queue refuses a
/// re-minted secret the next attempt presents the current one. A store
/// that cannot be read leaves the held value in use; a store that holds
/// nothing drops it. Either way the attempt is one read, and the next one
/// waits out the connection's reconnect backoff.
async fn for_attempt(&self) -> anyhow::Result<String> {
{
let mut held = self.held();
if std::mem::take(&mut held.unpresented)
&& let Some(value) = &held.value
{
return Ok(value.clone());
}
}
match self.read().await {
Ok(Some(value)) => {
self.held().value = Some(value.clone());
Ok(value)
}
Ok(None) => {
self.held().value = None;
tracing::warn!(
path = %self.source.origin(),
"nothing is stored at this agent's queue credential path; its queue \
connection retries under backoff"
);
Err(anyhow!("nothing is stored at {}", self.source.origin()))
}
Err(e) => {
let held = self.held().value.clone();
tracing::warn!(
error = format!("{e:#}"),
presenting_held = held.is_some(),
"re-reading this agent's queue credential from the store failed"
);
held.ok_or(e)
}
}
}
/// One bounded read. An empty stored value is no value.
async fn read(&self) -> anyhow::Result<Option<String>> {
let value = tokio::time::timeout(self.timeout, self.source.fetch())
.await
.map_err(|_| anyhow!("the store did not answer within {:?}", self.timeout))??;
Ok(value.map(|v| v.trim().to_owned()).filter(|v| !v.is_empty()))
}
}
/// Where this agent reads its own queue secret from.
///
/// Taken through a lookup so it can be asserted without mutating the process
/// environment. The shape follows `hive_matrix_mcp::credential`'s
/// `store_settings`, which reads the same variables for the same identity.
///
/// # Errors
/// No store address, or one without the agent name or the identity beside it.
fn store_source(get: impl Fn(&str) -> Option<String>) -> anyhow::Result<StoreSource> {
let (settings, role, agent) = agent_store(get)?;
Ok(StoreSource {
settings,
role,
path: queue::agent_queue_path(&agent)?,
})
}
/// Where the store is, the cert-auth role this agent logs in as, and the
/// agent's name, from `get`.
///
/// # Errors
/// No store address, or one without the agent name or the identity beside it.
pub(crate) fn agent_store(
get: impl Fn(&str) -> Option<String>,
) -> anyhow::Result<(Settings, String, String)> {
if get(ENV_ADDR).is_none_or(|v| v.is_empty()) {
anyhow::bail!(
"{ENV_ADDR} is unset or empty, so there is no secret store to read this agent's \
secrets from"
);
}
let agent = get(ENV_AGENT_NAME)
.filter(|v| !v.is_empty())
.with_context(|| format!("{ENV_AGENT_NAME} is unset or empty"))?;
let settings = Settings::from_lookup(|k| get(k).filter(|v| k != ENV_CACERT || ca_is_usable(v)))
.context("reading the swarm secret store's coordinates from the environment")?;
let role = policy::agent_object_name(&agent)?;
Ok((settings, role, agent))
}
/// Whether the CA bundle at `path` is a file with bytes in it. `BAO_CACERT`
/// names a systemd credential that may not have been delivered; absent means
/// the container's own trust store.
fn ca_is_usable(path: &str) -> bool {
std::fs::metadata(path).is_ok_and(|m| m.len() > 0)
}
/// What connecting needs: the queue's address and a store to read this agent's
/// secret from.
///
/// # Errors
/// Either one missing.
fn agent_path(get: impl Fn(&str) -> Option<String>) -> anyhow::Result<AgentPath> {
let url = get(ENV_NATS_URL)
.filter(|v| !v.is_empty())
.with_context(|| format!("{ENV_NATS_URL} is unset or empty"))?;
Ok(AgentPath {
url,
store: Arc::new(store_source(&get)?),
})
}
/// Resolve this agent's queue coordinates and record them for [`client`].
///
/// # Errors
/// A coordinate is missing. The harness exits on it: an agent without a queue
/// connection is invisible to the operator at swarm level.
pub fn init() -> anyhow::Result<()> {
let agent = agent_path(|k| std::env::var(k).ok())
.context("resolving this agent's swarm queue coordinates")?;
tracing::info!(
url = %agent.url,
path = %agent.store.path,
"this agent connects to the swarm queue with its own credential from the secret store"
);
let _ = AGENT.set(agent);
Ok(())
}
/// The shared queue connection, made on first call and memoized for the rest
/// of the process.
///
/// One connection per process, not per publisher: the agent authenticates as
/// one client, so a second `connect` would be a second live connection for the
/// same identity rather than a second credential.
///
/// The first call retries the connect (see [`CONNECT_RETRIES`]), and every other
/// caller waits on it. When no attempt succeeds the process exits with status
/// 1, so this never returns without a client.
pub async fn client() -> async_nats::Client {
CLIENT.get_or_init(connect_or_exit).await.clone()
}
async fn connect_or_exit() -> async_nats::Client {
let connected = match AGENT.get() {
Some(agent) => retry_first_connect(|| connect_once(agent)).await,
None => Err(anyhow!("swarm_queue::init has not run")),
};
connected.unwrap_or_else(|e| {
log_connect_exhausted(&e);
std::process::exit(1)
})
}
fn log_connect_exhausted(e: &anyhow::Error) {
tracing::error!(error = format!("{e:#}"), "{CONNECT_EXHAUSTED}");
}
/// One connection attempt with this agent's own credential.
async fn connect_once(agent: &AgentPath) -> anyhow::Result<async_nats::Client> {
let secret = AgentSecret::new(Arc::clone(&agent.store));
let name = crate::identity::label();
let client = connect_as_agent(&agent.url, name, secret).await?;
tracing::info!(
url = %agent.url,
"connected to the swarm queue with this agent's own credential"
);
Ok(client)
}
/// Run `connect` until it succeeds, waiting between attempts as
/// [`CONNECT_RETRIES`] says, and return the last error once every attempt has
/// failed.
async fn retry_first_connect<T, C, CFut>(connect: C) -> anyhow::Result<T>
where
C: FnMut() -> CFut,
CFut: Future<Output = anyhow::Result<T>>,
{
let mut attempt = 1;
connect
.retry(
ExponentialBuilder::default()
.with_min_delay(CONNECT_FIRST_WAIT)
.with_max_delay(CONNECT_MAX_WAIT)
.with_max_times(CONNECT_RETRIES),
)
.notify(|e, wait| {
tracing::warn!(
error = format!("{e:#}"),
attempt,
retry_in = ?wait,
"connecting to the swarm queue failed"
);
attempt += 1;
})
.await
.map_err(|e| {
e.context(format!(
"{} connection attempts failed",
CONNECT_RETRIES + 1
))
})
}
/// Connect presenting this agent's own token as `name`.
///
/// The secret is read before the first attempt, and any failure of that read
/// or of the first attempt, a refusal included, is returned for
/// [`retry_first_connect`] to retry. Once connected, each reconnect attempt
/// takes its secret from [`AgentSecret::for_attempt`].
async fn connect_as_agent<S: SecretSource>(
url: &str,
name: String,
secret: AgentSecret<S>,
) -> anyhow::Result<async_nats::Client> {
secret.prime().await?;
let secret = Arc::new(secret);
let agent = name.clone();
// No extra trust anchor: the container trusts the store's root, which
// `hive_c0re::meta` embeds at build time.
swarm_queue_client::connect_with_token(url, None, &agent, move || {
let secret = Arc::clone(&secret);
let name = name.clone();
// Spawned so the future handed back is only a `JoinHandle`, which is
// `Sync` as async-nats's auth callback requires; the store read is not.
let attempt = tokio::spawn(async move {
let value = secret.for_attempt().await.map_err(|e| format!("{e:#}"))?;
swarm_agent_verify::agent_token::format_agent_token(&name, &value)
.map_err(|e| e.to_string())
});
async move { attempt.await.map_err(|e| e.to_string())? }
})
.await
.map_err(|e| anyhow!(swarm_queue_client::chain(&e)))
}
#[cfg(test)]
mod tests {
use std::collections::VecDeque;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use anyhow::anyhow;
use tracing_subscriber::layer::SubscriberExt as _;
use super::{
AgentPath, AgentSecret, CONNECT_RETRIES, SecretSource, StoreSource, agent_path,
connect_as_agent, log_connect_exhausted, retry_first_connect, store_source,
};
/// A lookup standing in for a container that was handed a store and the
/// queue's address.
fn with_store(k: &str) -> Option<String> {
match k {
"BAO_ADDR" => Some("https://bao.t.local:8200".to_owned()),
"BAO_CLIENT_CERT" => Some("/run/credentials/x/cert".to_owned()),
"BAO_CLIENT_KEY" => Some("/run/credentials/x/key".to_owned()),
"HIVE_AGENT_NAME" => Some("a1".to_owned()),
"HIVE_AGENT_NATS_URL" => Some("nats://10.42.0.1:4222".to_owned()),
_ => None,
}
}
fn source() -> StoreSource {
store_source(with_store).expect("a complete store environment")
}
#[test]
fn a_store_address_reads_the_agents_own_path_as_its_own_role() {
let s = source();
assert_eq!(s.path, "swarm/agents/a1/queue");
assert_eq!(s.role, "hive-agent-a1");
}
#[test]
fn a_missing_store_address_is_an_error() {
for addr in [None, Some(String::new())] {
let e = store_source(|k| {
if k == "BAO_ADDR" {
addr.clone()
} else {
with_store(k)
}
})
.expect_err("no store to read the credential from");
assert!(format!("{e:#}").contains("BAO_ADDR"), "{e:#}");
}
}
#[test]
fn a_store_address_without_the_agent_or_its_identity_is_an_error() {
for var in ["HIVE_AGENT_NAME", "BAO_CLIENT_CERT", "BAO_CLIENT_KEY"] {
let e = store_source(|k| if k == var { None } else { with_store(k) })
.expect_err("a half-delivered store");
assert!(format!("{e:#}").contains(var), "dropping {var}: {e:#}");
}
}
/// `BAO_CACERT` is always named and may point at nothing; that is the
/// container's own trust store, not a CA path that fails the handshake.
#[test]
fn an_undelivered_ca_is_dropped() {
let with_missing_ca = store_source(|k| {
if k == "BAO_CACERT" {
Some("/nonexistent/ca".to_owned())
} else {
with_store(k)
}
})
.expect("complete");
assert_eq!(with_missing_ca, source());
}
#[test]
fn a_store_and_the_queue_address_are_all_an_agent_connects_with() {
assert_eq!(
agent_path(with_store).expect("complete"),
AgentPath {
url: "nats://10.42.0.1:4222".to_owned(),
store: Arc::new(source()),
}
);
}
#[test]
fn without_a_store_or_the_queue_address_an_agent_has_nothing_to_connect_with() {
for var in ["HIVE_AGENT_NATS_URL", "BAO_ADDR"] {
let e = agent_path(|k| if k == var { None } else { with_store(k) })
.expect_err("a coordinate is missing");
assert!(format!("{e:#}").contains(var), "dropping {var}: {e:#}");
}
}
/// Runs `retry_first_connect` against a connect that fails `failures`
/// times and then succeeds, on paused time so each wait costs nothing and
/// the elapsed time is the backoff itself.
async fn retry_with(failures: usize) -> (anyhow::Result<usize>, usize, Duration) {
let mut calls = 0;
let started = tokio::time::Instant::now();
let result = retry_first_connect(|| {
calls += 1;
let n = calls;
async move {
if n > failures {
Ok(n)
} else {
Err(anyhow!("attempt {n} refused"))
}
}
})
.await;
(result, calls, started.elapsed())
}
#[tokio::test(start_paused = true)]
async fn a_first_connect_that_succeeds_waits_for_nothing() {
let (result, calls, waited) = retry_with(0).await;
assert_eq!(result.expect("connected"), 1);
assert_eq!(calls, 1);
assert_eq!(waited, Duration::ZERO);
}
/// 5s doubling, one wait per failed attempt, and the attempt after the
/// last wait is the one that connects.
#[tokio::test(start_paused = true)]
async fn a_failed_connect_is_retried_on_a_doubling_backoff() {
let (result, calls, waited) = retry_with(3).await;
assert_eq!(result.expect("connected on the fourth attempt"), 4);
assert_eq!(calls, 4);
assert_eq!(waited, Duration::from_secs(5 + 10 + 20));
}
/// About five minutes of waits, then the last attempt's error rather than
/// another wait.
#[tokio::test(start_paused = true)]
async fn the_retry_gives_up_with_the_last_error_after_about_five_minutes() {
let (result, calls, waited) = retry_with(usize::MAX).await;
let e = result.expect_err("every attempt failed");
assert_eq!(calls, CONNECT_RETRIES + 1);
assert_eq!(waited, Duration::from_secs(5 + 10 + 20 + 40 + 80 + 160));
assert!(
(Duration::from_mins(4)..=Duration::from_mins(6)).contains(&waited),
"{waited:?}"
);
assert!(
format!("{e:#}").contains(&format!("attempt {calls} refused")),
"{e:#}"
);
}
/// The level and message of every event.
#[derive(Clone, Default)]
struct Messages(Arc<Mutex<Vec<(tracing::Level, String)>>>);
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for Messages {
fn on_event(
&self,
event: &tracing::Event<'_>,
_: tracing_subscriber::layer::Context<'_, S>,
) {
let mut message = String::new();
event.record(
&mut |field: &tracing::field::Field, value: &dyn std::fmt::Debug| {
if field.name() == "message" {
message = format!("{value:?}");
}
},
);
self.0
.lock()
.expect("unpoisoned")
.push((*event.metadata().level(), message));
}
}
/// The literal is the `_msg` that `hh-swarm-queue-connect-failed` in
/// `nix/host-modules/swarm-grafana.nix` matches.
#[test]
fn an_exhausted_connect_logs_the_line_the_alert_rule_matches() {
let messages = Messages::default();
let subscriber = tracing_subscriber::registry().with(messages.clone());
tracing::subscriber::with_default(subscriber, || {
log_connect_exhausted(&anyhow!("7 connection attempts failed"));
});
assert_eq!(
*messages.0.lock().expect("unpoisoned"),
[(
tracing::Level::ERROR,
"no swarm queue connection; exiting, since this agent cannot run without one"
.to_owned()
)]
);
}
enum Answer {
Value(&'static str),
Absent,
Fails,
Hangs,
}
/// A store that answers each read with the next scripted answer and
/// counts the reads.
struct Scripted {
answers: Mutex<VecDeque<Answer>>,
reads: AtomicUsize,
}
fn scripted(answers: impl IntoIterator<Item = Answer>) -> Arc<Scripted> {
Arc::new(Scripted {
answers: Mutex::new(answers.into_iter().collect()),
reads: AtomicUsize::new(0),
})
}
impl SecretSource for Arc<Scripted> {
fn origin(&self) -> &'static str {
"swarm/agents/a1/queue"
}
async fn fetch(&self) -> anyhow::Result<Option<String>> {
self.reads.fetch_add(1, Ordering::SeqCst);
let next = self.answers.lock().expect("lock").pop_front();
match next.expect("a read nobody scripted") {
Answer::Value(v) => Ok(Some(v.to_owned())),
Answer::Absent => Ok(None),
Answer::Fails => Err(anyhow!("the store is unreachable")),
Answer::Hangs => std::future::pending().await,
}
}
}
fn reads(s: &Arc<Scripted>) -> usize {
s.reads.load(Ordering::SeqCst)
}
/// (a) The first attempt presents what was just read, and does not read
/// the store a second time for it.
#[tokio::test]
async fn the_first_attempt_presents_the_value_read_before_it() {
let store = scripted([Answer::Value("v1\n")]);
let secret = AgentSecret::new(Arc::clone(&store));
secret.prime().await.expect("the store holds a secret");
assert_eq!(secret.for_attempt().await.expect("primed"), "v1");
assert_eq!(reads(&store), 1);
}
/// (b) After the queue refuses a re-minted secret, the next attempt reads
/// the store and presents what it holds now: one read per attempt.
#[tokio::test]
async fn each_later_attempt_presents_what_the_store_holds_now() {
let store = scripted([
Answer::Value("v1"),
Answer::Value("v1"),
Answer::Value("v2"),
]);
let secret = AgentSecret::new(Arc::clone(&store));
secret.prime().await.expect("the store holds a secret");
assert_eq!(secret.for_attempt().await.expect("primed"), "v1");
assert_eq!(secret.for_attempt().await.expect("re-read"), "v1");
assert_eq!(secret.for_attempt().await.expect("re-read"), "v2");
assert_eq!(reads(&store), 3);
}
/// (b) Bounded: a store that never answers costs one attempt the read
/// timeout, and the attempt goes ahead with the value it already holds.
#[tokio::test]
async fn a_store_that_does_not_answer_is_given_up_on() {
let store = scripted([Answer::Value("v1"), Answer::Hangs, Answer::Hangs]);
let mut secret = AgentSecret::new(Arc::clone(&store));
secret.timeout = Duration::from_millis(20);
secret.prime().await.expect("the store holds a secret");
assert_eq!(secret.for_attempt().await.expect("primed"), "v1");
assert_eq!(
secret
.for_attempt()
.await
.expect("the held value stands in"),
"v1"
);
assert_eq!(reads(&store), 2);
let cold = AgentSecret::new(scripted([Answer::Hangs]));
let mut cold = cold;
cold.timeout = Duration::from_millis(20);
let e = cold.prime().await.expect_err("nothing to stand in");
assert!(format!("{e:#}").contains("did not answer"), "{e:#}");
}
/// (b) A secret the store no longer holds (the agent was destroyed) is not
/// presented again, even when a later read fails.
#[tokio::test]
async fn a_secret_removed_from_the_store_is_dropped() {
let store = scripted([Answer::Value("v1"), Answer::Absent, Answer::Fails]);
let secret = AgentSecret::new(Arc::clone(&store));
secret.prime().await.expect("the store holds a secret");
assert_eq!(secret.for_attempt().await.expect("primed"), "v1");
let gone = secret.for_attempt().await.expect_err("nothing stored");
assert!(
format!("{gone:#}").contains("swarm/agents/a1/queue"),
"{gone:#}"
);
secret
.for_attempt()
.await
.expect_err("the dropped value does not come back");
}
/// (c) Nothing to connect with before the first attempt fails that attempt.
/// The queue is never dialled: nothing listens at the address given.
#[tokio::test]
async fn no_secret_before_the_first_connect_fails_the_attempt() {
for (answer, says) in [
(Answer::Fails, "unreachable"),
(Answer::Absent, "nothing is stored"),
(Answer::Value(" \n"), "nothing is stored"),
] {
let secret = AgentSecret::new(scripted([answer]));
let e = connect_as_agent("nats://127.0.0.1:9", "a1".to_owned(), secret)
.await
.expect_err("no secret to present");
assert!(format!("{e:#}").contains(says), "{e:#}");
}
}
/// Serve one NATS handshake on `sock`: send INFO, read CONNECT up to the
/// PING, and return the token presented.
async fn handshake(sock: &mut tokio::net::TcpStream, port: u16) -> String {
use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _};
let info = format!(
"INFO {{\"server_id\":\"fake\",\"version\":\"2.10.0\",\"go\":\"go\",\
\"host\":\"127.0.0.1\",\"port\":{port},\"headers\":true,\
\"max_payload\":1048576,\"proto\":1,\"auth_required\":true,\
\"nonce\":\"n0nce\"}}\r\n"
);
sock.write_all(info.as_bytes()).await.expect("send INFO");
let mut lines = tokio::io::BufReader::new(sock).lines();
let mut token = String::new();
while let Some(line) = lines.next_line().await.expect("read") {
if let Some(json) = line.strip_prefix("CONNECT ") {
let v: serde_json::Value = serde_json::from_str(json).expect("CONNECT is JSON");
token = v["auth_token"].as_str().unwrap_or_default().to_owned();
} else if line.starts_with("PING") {
break;
}
}
token
}
/// (b) end to end through async-nats: connected with `v1`, the connection
/// drops, the reconnect presenting `v1` is refused, and the next attempt
/// reads `v2` and is accepted.
#[tokio::test]
async fn a_refused_reconnect_reconnects_with_the_secret_read_after_it() {
use tokio::io::AsyncWriteExt as _;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let port = listener.local_addr().expect("addr").port();
let (seen_tx, mut seen_rx) = tokio::sync::mpsc::unbounded_channel();
tokio::spawn(async move {
let mut kept = Vec::new();
for n in 0.. {
let (mut sock, _) = listener.accept().await.expect("accept");
let token = handshake(&mut sock, port).await;
let accept = n == 0 || token.rsplit_once('.').is_some_and(|(_, s)| s == "v2");
let reply: &[u8] = if accept {
b"PONG\r\n"
} else {
b"-ERR 'Authorization Violation'\r\n"
};
let _ = sock.write_all(reply).await;
let _ = seen_tx.send(token);
// The first connection is dropped to force a reconnect; the
// one accepted after the refusal stays open.
if accept && n > 0 {
kept.push(sock);
}
}
});
let store = scripted([
Answer::Value("v1"),
Answer::Value("v1"),
Answer::Value("v2"),
]);
let secret = AgentSecret::new(Arc::clone(&store));
let url = format!("nats://127.0.0.1:{port}");
let _client = connect_as_agent(&url, "a1".to_owned(), secret)
.await
.expect("the first connect is accepted");
let mut seen = Vec::new();
while seen.len() < 3 {
let token = tokio::time::timeout(Duration::from_secs(20), seen_rx.recv())
.await
.expect("the client keeps reconnecting")
.expect("the server is running");
seen.push(token);
}
let secrets: Vec<&str> = seen
.iter()
.map(|t| t.rsplit_once('.').map_or("", |(_, s)| s))
.collect();
assert_eq!(secrets, ["v1", "v1", "v2"]);
assert_eq!(reads(&store), 3);
}
}