sentinel_proxy/acme/dns/
credentials.rs1use std::fs;
9use std::os::unix::fs::PermissionsExt;
10use std::path::Path;
11
12use serde::Deserialize;
13use tracing::{debug, warn};
14
15use super::provider::DnsProviderError;
16
17#[derive(Debug, Default)]
19pub struct CredentialLoader;
20
21impl CredentialLoader {
22 pub fn load_from_file(path: &Path) -> Result<Credentials, DnsProviderError> {
32 #[cfg(unix)]
34 {
35 let metadata = fs::metadata(path).map_err(|e| {
36 DnsProviderError::Credentials(format!(
37 "Failed to read credentials file '{}': {}",
38 path.display(),
39 e
40 ))
41 })?;
42
43 let mode = metadata.permissions().mode();
44 let file_mode = mode & 0o777;
45
46 if file_mode & 0o077 != 0 {
48 warn!(
49 path = %path.display(),
50 mode = format!("{:o}", file_mode),
51 "Credentials file has overly permissive permissions (should be 0600 or 0400)"
52 );
53 }
55 }
56
57 let content = fs::read_to_string(path).map_err(|e| {
58 DnsProviderError::Credentials(format!(
59 "Failed to read credentials file '{}': {}",
60 path.display(),
61 e
62 ))
63 })?;
64
65 Self::parse_credentials(&content, path)
66 }
67
68 pub fn load_from_env(var_name: &str) -> Result<Credentials, DnsProviderError> {
70 let value = std::env::var(var_name).map_err(|_| {
71 DnsProviderError::Credentials(format!(
72 "Environment variable '{}' not set",
73 var_name
74 ))
75 })?;
76
77 if value.trim().starts_with('{') {
79 Self::parse_json_credentials(&value)
80 } else {
81 Ok(Credentials::Token(value.trim().to_string()))
82 }
83 }
84
85 fn parse_credentials(content: &str, path: &Path) -> Result<Credentials, DnsProviderError> {
87 let trimmed = content.trim();
88
89 if trimmed.starts_with('{') {
91 return Self::parse_json_credentials(trimmed);
92 }
93
94 if trimmed.is_empty() {
96 return Err(DnsProviderError::Credentials(format!(
97 "Credentials file '{}' is empty",
98 path.display()
99 )));
100 }
101
102 debug!(path = %path.display(), "Loaded credentials as plain text token");
103 Ok(Credentials::Token(trimmed.to_string()))
104 }
105
106 fn parse_json_credentials(json: &str) -> Result<Credentials, DnsProviderError> {
108 #[derive(Deserialize)]
110 struct TokenFormat {
111 token: Option<String>,
112 api_token: Option<String>,
113 }
114
115 #[derive(Deserialize)]
116 struct KeySecretFormat {
117 api_key: String,
118 api_secret: String,
119 }
120
121 #[derive(Deserialize)]
122 struct ApiKeyOnlyFormat {
123 api_key: Option<String>,
124 }
125
126 if let Ok(parsed) = serde_json::from_str::<KeySecretFormat>(json) {
128 debug!("Loaded credentials as JSON key+secret");
129 return Ok(Credentials::KeySecret {
130 key: parsed.api_key,
131 secret: parsed.api_secret,
132 });
133 }
134
135 if let Ok(parsed) = serde_json::from_str::<TokenFormat>(json) {
137 if let Some(token) = parsed.token.or(parsed.api_token) {
138 debug!("Loaded credentials as JSON token");
139 return Ok(Credentials::Token(token));
140 }
141 }
142
143 if let Ok(parsed) = serde_json::from_str::<ApiKeyOnlyFormat>(json) {
145 if let Some(key) = parsed.api_key {
146 debug!("Loaded credentials as JSON api_key token");
147 return Ok(Credentials::Token(key));
148 }
149 }
150
151 Err(DnsProviderError::Credentials(
152 "Invalid JSON credentials format. Expected {\"token\": \"...\"} or {\"api_key\": \"...\", \"api_secret\": \"...\"}".to_string()
153 ))
154 }
155}
156
157#[derive(Debug, Clone)]
159pub enum Credentials {
160 Token(String),
162 KeySecret { key: String, secret: String },
164}
165
166impl Credentials {
167 pub fn token(&self) -> Option<&str> {
169 match self {
170 Credentials::Token(t) => Some(t),
171 Credentials::KeySecret { .. } => None,
172 }
173 }
174
175 pub fn key(&self) -> Option<&str> {
177 match self {
178 Credentials::KeySecret { key, .. } => Some(key),
179 Credentials::Token(_) => None,
180 }
181 }
182
183 pub fn secret(&self) -> Option<&str> {
185 match self {
186 Credentials::KeySecret { secret, .. } => Some(secret),
187 Credentials::Token(_) => None,
188 }
189 }
190
191 pub fn as_bearer_token(&self) -> &str {
193 match self {
194 Credentials::Token(t) => t,
195 Credentials::KeySecret { key, .. } => key,
196 }
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use std::io::Write;
204 use tempfile::NamedTempFile;
205
206 #[test]
207 fn test_load_json_token() {
208 let mut file = NamedTempFile::new().unwrap();
209 writeln!(file, r#"{{"token": "test-token-123"}}"#).unwrap();
210
211 let creds = CredentialLoader::load_from_file(file.path()).unwrap();
212 assert_eq!(creds.token(), Some("test-token-123"));
213 }
214
215 #[test]
216 fn test_load_json_api_token() {
217 let mut file = NamedTempFile::new().unwrap();
218 writeln!(file, r#"{{"api_token": "api-token-456"}}"#).unwrap();
219
220 let creds = CredentialLoader::load_from_file(file.path()).unwrap();
221 assert_eq!(creds.token(), Some("api-token-456"));
222 }
223
224 #[test]
225 fn test_load_json_key_secret() {
226 let mut file = NamedTempFile::new().unwrap();
227 writeln!(file, r#"{{"api_key": "key123", "api_secret": "secret456"}}"#).unwrap();
228
229 let creds = CredentialLoader::load_from_file(file.path()).unwrap();
230 assert_eq!(creds.key(), Some("key123"));
231 assert_eq!(creds.secret(), Some("secret456"));
232 }
233
234 #[test]
235 fn test_load_plain_text() {
236 let mut file = NamedTempFile::new().unwrap();
237 writeln!(file, "plain-text-token").unwrap();
238
239 let creds = CredentialLoader::load_from_file(file.path()).unwrap();
240 assert_eq!(creds.token(), Some("plain-text-token"));
241 }
242
243 #[test]
244 fn test_load_plain_text_with_whitespace() {
245 let mut file = NamedTempFile::new().unwrap();
246 writeln!(file, " token-with-spaces \n").unwrap();
247
248 let creds = CredentialLoader::load_from_file(file.path()).unwrap();
249 assert_eq!(creds.token(), Some("token-with-spaces"));
250 }
251
252 #[test]
253 fn test_empty_file_error() {
254 let file = NamedTempFile::new().unwrap();
255 let result = CredentialLoader::load_from_file(file.path());
256 assert!(result.is_err());
257 }
258
259 #[test]
260 fn test_invalid_json_error() {
261 let mut file = NamedTempFile::new().unwrap();
262 writeln!(file, r#"{{"invalid": "format"}}"#).unwrap();
263
264 let result = CredentialLoader::load_from_file(file.path());
265 assert!(result.is_err());
266 }
267
268 #[test]
269 fn test_load_json_api_key_only() {
270 let mut file = NamedTempFile::new().unwrap();
271 writeln!(file, r#"{{"api_key": "just-a-key"}}"#).unwrap();
272
273 let creds = CredentialLoader::load_from_file(file.path()).unwrap();
274 assert_eq!(creds.token(), Some("just-a-key"));
276 }
277
278 #[test]
279 fn test_load_json_with_extra_fields() {
280 let mut file = NamedTempFile::new().unwrap();
281 writeln!(file, r#"{{"token": "my-token", "extra": "field", "another": 123}}"#).unwrap();
282
283 let creds = CredentialLoader::load_from_file(file.path()).unwrap();
284 assert_eq!(creds.token(), Some("my-token"));
285 }
286
287 #[test]
288 fn test_credentials_as_bearer_token() {
289 let token_creds = Credentials::Token("my-token".to_string());
290 assert_eq!(token_creds.as_bearer_token(), "my-token");
291
292 let key_secret_creds = Credentials::KeySecret {
293 key: "my-key".to_string(),
294 secret: "my-secret".to_string(),
295 };
296 assert_eq!(key_secret_creds.as_bearer_token(), "my-key");
297 }
298
299 #[test]
300 fn test_credentials_accessors() {
301 let token_creds = Credentials::Token("my-token".to_string());
302 assert_eq!(token_creds.token(), Some("my-token"));
303 assert_eq!(token_creds.key(), None);
304 assert_eq!(token_creds.secret(), None);
305
306 let key_secret_creds = Credentials::KeySecret {
307 key: "my-key".to_string(),
308 secret: "my-secret".to_string(),
309 };
310 assert_eq!(key_secret_creds.token(), None);
311 assert_eq!(key_secret_creds.key(), Some("my-key"));
312 assert_eq!(key_secret_creds.secret(), Some("my-secret"));
313 }
314
315 #[test]
316 fn test_load_from_env() {
317 std::env::set_var("TEST_DNS_TOKEN_12345", "env-token-value");
318
319 let creds = CredentialLoader::load_from_env("TEST_DNS_TOKEN_12345").unwrap();
320 assert_eq!(creds.token(), Some("env-token-value"));
321
322 std::env::remove_var("TEST_DNS_TOKEN_12345");
323 }
324
325 #[test]
326 fn test_load_from_env_json() {
327 std::env::set_var("TEST_DNS_JSON_12345", r#"{"token": "json-env-token"}"#);
328
329 let creds = CredentialLoader::load_from_env("TEST_DNS_JSON_12345").unwrap();
330 assert_eq!(creds.token(), Some("json-env-token"));
331
332 std::env::remove_var("TEST_DNS_JSON_12345");
333 }
334
335 #[test]
336 fn test_load_from_env_not_set() {
337 let result = CredentialLoader::load_from_env("NONEXISTENT_VAR_12345");
338 assert!(result.is_err());
339 }
340
341 #[test]
342 fn test_nonexistent_file() {
343 let result = CredentialLoader::load_from_file(std::path::Path::new("/nonexistent/path/to/creds.json"));
344 assert!(result.is_err());
345 }
346
347 #[test]
348 fn test_whitespace_only_file() {
349 let mut file = NamedTempFile::new().unwrap();
350 writeln!(file, " \n\t \n").unwrap();
351
352 let result = CredentialLoader::load_from_file(file.path());
353 assert!(result.is_err());
354 }
355
356 #[test]
357 fn test_malformed_json() {
358 let mut file = NamedTempFile::new().unwrap();
359 writeln!(file, r#"{{"token": "unclosed"#).unwrap();
360
361 let result = CredentialLoader::load_from_file(file.path());
362 assert!(result.is_err());
365 }
366}