rustlavel_vault/
plugin.rs1use crate::client::VaultClient;
28use crate::renew::Renewer;
29use crate::resolve::{self, Resolver};
30use rustlavel_core::{Config, Result};
31use rustlavel_http::plugin::{Plugin, Setup};
32use std::sync::Arc;
33
34const SKIP_VERIFY: &[&str] = &["VAULT_SKIP_VERIFY", "BAO_SKIP_VERIFY"];
37
38pub struct Vault {
44 client: VaultClient,
45 roots: Vec<String>,
46 renewer: Arc<Renewer>,
47 plain_http_is_fine: bool,
48}
49
50impl Vault {
51 pub fn new(client: VaultClient) -> Vault {
52 Vault {
53 client,
54 roots: Vec::new(),
55 renewer: Arc::new(Renewer::new()),
56 plain_http_is_fine: false,
57 }
58 }
59
60 pub fn from_env() -> Result<Vault> {
63 Ok(Vault::new(VaultClient::from_env()?))
64 }
65
66 pub fn resolving(mut self, root: impl Into<String>) -> Vault {
72 self.roots.push(root.into());
73 self
74 }
75
76 pub fn even_over_http(mut self) -> Vault {
83 self.plain_http_is_fine = true;
84 self
85 }
86
87 pub fn client(&self) -> &VaultClient {
88 &self.client
89 }
90
91 pub fn renewer(&self) -> &Arc<Renewer> {
96 &self.renewer
97 }
98
99 pub async fn resolve(&self, config: &Config) -> Result<usize> {
106 let roots = if self.roots.is_empty() { configured_roots(config) } else { self.roots.clone() };
107 if roots.is_empty() {
108 return Ok(0);
109 }
110
111 let borrowed: Vec<&str> = roots.iter().map(String::as_str).collect();
112 let resolved = Resolver::new(&self.client).config(config, &borrowed).await?;
113
114 if resolved > 0 {
115 rustlavel_core::info!(
116 "resolved {resolved} configuration {} from the secret store",
117 plural(resolved)
118 );
119 }
120 Ok(resolved)
121 }
122
123 pub async fn resolve_env(&self) -> Result<usize> {
129 let resolved = resolve::resolve_env(&self.client).await?;
130 if resolved > 0 {
131 rustlavel_core::info!(
132 "resolved {resolved} environment {} from the secret store",
133 plural(resolved)
134 );
135 }
136 Ok(resolved)
137 }
138
139 fn warn_about_unsafe_settings(&self, config: &Config) {
144 if !self.plain_http_is_fine && sends_the_token_in_the_clear(self.client.address()) {
145 rustlavel_core::warn!(
146 "the secret store address is `{}`, which is plain HTTP over a network. The token \
147 travels in clear text and a token is the whole store — anyone who can read the \
148 traffic can read every secret it guards. Use https, or `.even_over_http()` if \
149 the address really is behind a TLS proxy on this host.",
150 self.client.address()
151 );
152 }
153
154 for name in SKIP_VERIFY {
155 if std::env::var(name).is_ok_and(|value| truthy(&value)) {
156 rustlavel_core::warn!(
157 "{name} is set, so the certificate the secret store presents is not checked. \
158 That makes the connection encrypted but not authenticated: anything that \
159 can intercept it can present its own certificate, take the token and answer \
160 with secrets of its own choosing."
161 );
162 }
163 }
164
165 if config.is_production() && !self.client.has_token() {
166 rustlavel_core::warn!(
167 "no token for the secret store. Every read will be refused until something \
168 authenticates — set VAULT_TOKEN, or log in before the first read."
169 );
170 }
171 }
172}
173
174impl Plugin for Vault {
175 fn name(&self) -> &'static str {
176 "vault"
177 }
178
179 fn register(self: Box<Self>, setup: &mut Setup<'_>) {
180 self.warn_about_unsafe_settings(setup.config);
181
182 let renewer = self.renewer.clone();
186 setup.state(self.client.clone());
187 setup.state(renewer);
188 }
189}
190
191impl std::fmt::Debug for Vault {
194 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
195 f.debug_struct("Vault")
196 .field("client", &self.client)
197 .field("roots", &self.roots)
198 .field("renewer", &self.renewer)
199 .finish()
200 }
201}
202
203fn configured_roots(config: &Config) -> Vec<String> {
204 config.list("vault.resolve")
205}
206
207fn sends_the_token_in_the_clear(address: &str) -> bool {
214 let Some(rest) = address.strip_prefix("http://") else { return false };
215 let host = rest.split(['/', '?']).next().unwrap_or(rest);
216 let host = host.rsplit_once(':').map_or(host, |(head, _)| head);
217
218 !matches!(host.trim_matches(['[', ']']), "localhost" | "::1")
219 && !host.starts_with("127.")
220}
221
222fn plural(count: usize) -> &'static str {
223 if count == 1 { "value" } else { "values" }
224}
225
226fn truthy(value: &str) -> bool {
227 matches!(value.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233 use crate::fake::Fake;
234 use rustlavel_core::{Context, ContextBuilder, Json};
235 use rustlavel_http::Router;
236
237 fn register(vault: Vault, config: &Config) -> Context {
238 let mut router = Router::new();
239 let mut context: Option<ContextBuilder> = Some(Context::builder().config(config.clone()));
240 let mut setup = Setup { router: &mut router, config, context: &mut context };
241
242 Box::new(vault).register(&mut setup);
243
244 context.expect("context builder").build()
245 }
246
247 #[test]
248 fn registers_the_client_and_the_renewer_so_handlers_can_reach_them() {
249 let store = Fake::new().secret("secret/data/x", [("a", "1")]).store();
250 let context = register(Vault::new(store.client().clone()), &Config::new());
251
252 assert!(context.state::<VaultClient>().is_some(), "no client for a handler to use");
253 assert!(context.state::<Arc<Renewer>>().is_some(), "nothing to hand a lease to");
254 }
255
256 #[test]
257 fn a_plain_http_address_on_a_network_is_unsafe_and_loopback_is_not() {
258 assert!(sends_the_token_in_the_clear("http://vault.internal:8200"));
259 assert!(sends_the_token_in_the_clear("http://10.0.0.5:8200/"));
260 assert!(!sends_the_token_in_the_clear("https://vault.internal:8200"));
261 assert!(!sends_the_token_in_the_clear("http://127.0.0.1:8200"));
263 assert!(!sends_the_token_in_the_clear("http://localhost:8200"));
264 assert!(!sends_the_token_in_the_clear("http://[::1]:8200"));
265 }
266
267 #[test]
268 fn a_skip_verify_setting_is_recognised_however_it_is_spelled() {
269 assert!(truthy("1"));
270 assert!(truthy("TRUE"));
271 assert!(truthy(" yes "));
272 assert!(!truthy("0"));
273 assert!(!truthy(""));
274 }
275
276 #[tokio::test]
277 async fn resolves_the_roots_it_was_told_about_and_leaves_the_rest() {
278 let store = Fake::new()
279 .secret("secret/data/myapp", [("password", "s3cr3t")])
280 .secret("secret/data/other", [("password", "unused")])
281 .store();
282
283 let config = Config::new();
284 config.set("database.password", "vault:secret/data/myapp#password");
285 config.set("mail.password", "vault:secret/data/other#password");
286
287 let vault = Vault::new(store.client().clone()).resolving("database");
288 assert_eq!(vault.resolve(&config).await.unwrap(), 1);
289
290 assert_eq!(config.string("database.password", ""), "s3cr3t");
291 assert_eq!(config.string("mail.password", ""), "vault:secret/data/other#password");
292 store.assert_not_read("secret/data/other");
293 }
294
295 #[tokio::test]
296 async fn configuration_supplies_the_roots_when_the_builder_named_none() {
297 let store = Fake::new().secret("secret/data/myapp", [("password", "s3cr3t")]).store();
298
299 let config = Config::new();
300 config.set("vault.resolve", Json::Array(vec![Json::from("database")]));
301 config.set("database.password", "vault:secret/data/myapp#password");
302
303 assert_eq!(Vault::new(store.client().clone()).resolve(&config).await.unwrap(), 1);
304 assert_eq!(config.string("database.password", ""), "s3cr3t");
305 }
306
307 #[tokio::test]
308 async fn resolving_nothing_reads_nothing() {
309 let store = Fake::new().store();
310
311 assert_eq!(Vault::new(store.client().clone()).resolve(&Config::new()).await.unwrap(), 0);
312 store.assert_reads(0);
313 }
314
315 #[tokio::test]
316 async fn an_unresolvable_reference_refuses_to_boot() {
317 let store = Fake::new().denies("secret/data/myapp").store();
318 let config = Config::new();
319 config.set("database.password", "vault:secret/data/myapp#password");
320
321 let error = Vault::new(store.client().clone())
322 .resolving("database")
323 .resolve(&config)
324 .await
325 .expect_err("a refused secret must stop the boot")
326 .to_string();
327
328 assert!(error.contains("database.password"), "got {error}");
329 assert!(error.contains("secret/data/myapp"), "got {error}");
330 }
331
332 #[test]
333 fn the_plugin_never_prints_the_token() {
334 let store = Fake::new().store();
335 let printed = format!("{:?}", Vault::new(store.client().clone()));
336
337 assert!(!printed.contains("fake-token"), "got {printed}");
338 assert!(printed.contains("redacted"));
339 }
340}