1use async_nats::{Client, ConnectOptions, ServerAddr};
6use std::fmt;
7use std::path::PathBuf;
8use std::time::Duration;
9
10const CONNECTION_TIMEOUT: Duration = Duration::from_secs(2);
11const PING_INTERVAL: Duration = Duration::from_secs(10);
12
13pub async fn connect_options(client_name: &str, auth: &NatsAuth) -> eyre::Result<ConnectOptions> {
18 Ok(auth
19 .apply(ConnectOptions::new())
20 .await?
21 .name(client_name)
22 .retry_on_initial_connect()
23 .connection_timeout(CONNECTION_TIMEOUT)
24 .ping_interval(PING_INTERVAL)
25 .max_reconnects(None::<usize>))
26}
27
28pub async fn connect(raw_urls: &str, client_name: &str, auth: &NatsAuth) -> eyre::Result<Client> {
32 let servers = parse_server_list(raw_urls)?;
33 Ok(connect_options(client_name, auth)
34 .await?
35 .connect(servers)
36 .await?)
37}
38
39pub fn parse_server_list(raw: &str) -> eyre::Result<Vec<ServerAddr>> {
41 let servers = raw
42 .split(',')
43 .map(str::trim)
44 .filter(|s| !s.is_empty())
45 .map(|s| {
46 s.parse::<ServerAddr>()
47 .map_err(|e| eyre::eyre!("invalid NATS server URL '{}': {}", redact_urls(s), e))
48 })
49 .collect::<eyre::Result<Vec<_>>>()?;
50 if servers.is_empty() {
51 eyre::bail!("NATS_URL lists no servers");
52 }
53 Ok(servers)
54}
55
56pub fn redact_urls(raw: &str) -> String {
58 raw.split(',')
59 .map(|entry| {
60 let trimmed = entry.trim();
61 let (scheme, rest) = match trimmed.find("://") {
62 Some(i) => trimmed.split_at(i + 3),
63 None => ("", trimmed),
64 };
65 let authority_end = rest.find('/').unwrap_or(rest.len());
66 match rest[..authority_end].rfind('@') {
67 Some(at) => format!("{}***@{}", scheme, &rest[at + 1..]),
68 None => trimmed.to_string(),
69 }
70 })
71 .collect::<Vec<_>>()
72 .join(",")
73}
74
75#[derive(Clone, Default, PartialEq, Eq)]
79pub enum NatsAuth {
80 #[default]
81 Anonymous,
82 CredentialsFile(PathBuf),
83 NKeySeed(String),
84 UserPassword {
85 user: String,
86 password: String,
87 },
88}
89
90impl fmt::Debug for NatsAuth {
91 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92 match self {
93 NatsAuth::Anonymous => f.write_str("Anonymous"),
94 NatsAuth::CredentialsFile(path) => {
95 f.debug_tuple("CredentialsFile").field(path).finish()
96 }
97 NatsAuth::NKeySeed(_) => f.write_str("NKeySeed(***)"),
98 NatsAuth::UserPassword { user, .. } => f
99 .debug_struct("UserPassword")
100 .field("user", user)
101 .field("password", &"***")
102 .finish(),
103 }
104 }
105}
106
107impl NatsAuth {
108 pub fn from_env() -> eyre::Result<Self> {
109 Self::from_vars(|name| std::env::var(name).ok().filter(|v| !v.is_empty()))
110 }
111
112 pub fn from_vars(lookup: impl Fn(&str) -> Option<String>) -> eyre::Result<Self> {
113 let creds = lookup("NATS_CREDS_FILE").map(|path| NatsAuth::CredentialsFile(path.into()));
114 let nkey = lookup("NATS_NKEY_SEED").map(NatsAuth::NKeySeed);
115 let user = match (lookup("NATS_USER"), lookup("NATS_PASSWORD")) {
116 (Some(user), Some(password)) => Some(NatsAuth::UserPassword { user, password }),
117 (None, None) => None,
118 _ => eyre::bail!("NATS_USER and NATS_PASSWORD must be set together"),
119 };
120 let mut configured = [creds, nkey, user].into_iter().flatten();
121 match (configured.next(), configured.next()) {
122 (None, _) => Ok(NatsAuth::Anonymous),
123 (Some(auth), None) => Ok(auth),
124 (Some(_), Some(_)) => eyre::bail!(
125 "set only one of NATS_CREDS_FILE, NATS_NKEY_SEED or NATS_USER/NATS_PASSWORD"
126 ),
127 }
128 }
129
130 pub async fn apply(&self, options: ConnectOptions) -> eyre::Result<ConnectOptions> {
132 Ok(match self {
133 NatsAuth::Anonymous => options,
134 NatsAuth::CredentialsFile(path) => {
135 options.credentials_file(path).await.map_err(|e| {
136 eyre::eyre!("cannot read NATS credentials file {}: {e}", path.display())
137 })?
138 }
139 NatsAuth::NKeySeed(seed) => options.nkey(seed.clone()),
140 NatsAuth::UserPassword { user, password } => {
141 options.user_and_password(user.clone(), password.clone())
142 }
143 })
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150 use std::collections::HashMap;
151
152 fn vars(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
153 let map: HashMap<String, String> = pairs
154 .iter()
155 .map(|(k, v)| (k.to_string(), v.to_string()))
156 .collect();
157 move |name| map.get(name).cloned()
158 }
159
160 #[test]
161 fn single_server_url_parses() {
162 assert_eq!(parse_server_list("nats://nats:4222").unwrap().len(), 1);
163 }
164
165 #[test]
166 fn comma_separated_servers_parse_with_whitespace_and_blanks() {
167 assert_eq!(
168 parse_server_list(" nats://a:4222 , ,nats://b:4222,")
169 .unwrap()
170 .len(),
171 2
172 );
173 }
174
175 #[test]
176 fn empty_server_list_is_an_error() {
177 assert!(parse_server_list("").is_err());
178 assert!(parse_server_list(" , ,").is_err());
179 }
180
181 #[test]
182 fn redacts_credentials_only() {
183 assert_eq!(redact_urls("nats://nats:4222"), "nats://nats:4222");
184 assert_eq!(
185 redact_urls("nats://user:s3cret@nats-0:4222, tls://tok@nats-1:4222"),
186 "nats://***@nats-0:4222,tls://***@nats-1:4222"
187 );
188 assert_eq!(redact_urls("user:pw@host:4222"), "***@host:4222");
189 }
190
191 #[test]
192 fn auth_is_anonymous_unless_configured() {
193 assert_eq!(NatsAuth::from_vars(vars(&[])).unwrap(), NatsAuth::Anonymous);
194 assert_eq!(
195 NatsAuth::from_vars(vars(&[("NATS_CREDS_FILE", "/etc/nats/platform.creds")])).unwrap(),
196 NatsAuth::CredentialsFile("/etc/nats/platform.creds".into())
197 );
198 assert_eq!(
199 NatsAuth::from_vars(vars(&[("NATS_USER", "platform"), ("NATS_PASSWORD", "pw")]))
200 .unwrap(),
201 NatsAuth::UserPassword {
202 user: "platform".into(),
203 password: "pw".into()
204 }
205 );
206 }
207
208 #[test]
209 fn auth_rejects_half_or_several_methods() {
210 assert!(NatsAuth::from_vars(vars(&[("NATS_USER", "platform")])).is_err());
211 assert!(NatsAuth::from_vars(vars(&[
212 ("NATS_NKEY_SEED", "SUA"),
213 ("NATS_CREDS_FILE", "/x.creds")
214 ]))
215 .is_err());
216 }
217
218 #[test]
219 fn debug_never_prints_secrets() {
220 let rendered = format!(
221 "{:?} {:?}",
222 NatsAuth::NKeySeed("SUASECRET".into()),
223 NatsAuth::UserPassword {
224 user: "u".into(),
225 password: "hunter2".into()
226 }
227 );
228 assert!(
229 !rendered.contains("SUASECRET") && !rendered.contains("hunter2"),
230 "{rendered}"
231 );
232 }
233
234 #[tokio::test]
235 async fn missing_credentials_file_is_an_error() {
236 let err = NatsAuth::CredentialsFile("/nonexistent/cufflink.creds".into())
237 .apply(ConnectOptions::new())
238 .await
239 .unwrap_err();
240 assert!(
241 err.to_string().contains("/nonexistent/cufflink.creds"),
242 "{err}"
243 );
244 }
245}