grapsus_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!("Environment variable '{}' not set", var_name))
72 })?;
73
74 if value.trim().starts_with('{') {
76 Self::parse_json_credentials(&value)
77 } else {
78 Ok(Credentials::Token(value.trim().to_string()))
79 }
80 }
81
82 fn parse_credentials(content: &str, path: &Path) -> Result<Credentials, DnsProviderError> {
84 let trimmed = content.trim();
85
86 if trimmed.starts_with('{') {
88 return Self::parse_json_credentials(trimmed);
89 }
90
91 if trimmed.is_empty() {
93 return Err(DnsProviderError::Credentials(format!(
94 "Credentials file '{}' is empty",
95 path.display()
96 )));
97 }
98
99 debug!(path = %path.display(), "Loaded credentials as plain text token");
100 Ok(Credentials::Token(trimmed.to_string()))
101 }
102
103 fn parse_json_credentials(json: &str) -> Result<Credentials, DnsProviderError> {
105 #[derive(Deserialize)]
107 struct TokenFormat {
108 token: Option<String>,
109 api_token: Option<String>,
110 }
111
112 #[derive(Deserialize)]
113 struct KeySecretFormat {
114 api_key: String,
115 api_secret: String,
116 }
117
118 #[derive(Deserialize)]
119 struct ApiKeyOnlyFormat {
120 api_key: Option<String>,
121 }
122
123 if let Ok(parsed) = serde_json::from_str::<KeySecretFormat>(json) {
125 debug!("Loaded credentials as JSON key+secret");
126 return Ok(Credentials::KeySecret {
127 key: parsed.api_key,
128 secret: parsed.api_secret,
129 });
130 }
131
132 if let Ok(parsed) = serde_json::from_str::<TokenFormat>(json) {
134 if let Some(token) = parsed.token.or(parsed.api_token) {
135 debug!("Loaded credentials as JSON token");
136 return Ok(Credentials::Token(token));
137 }
138 }
139
140 if let Ok(parsed) = serde_json::from_str::<ApiKeyOnlyFormat>(json) {
142 if let Some(key) = parsed.api_key {
143 debug!("Loaded credentials as JSON api_key token");
144 return Ok(Credentials::Token(key));
145 }
146 }
147
148 Err(DnsProviderError::Credentials(
149 "Invalid JSON credentials format. Expected {\"token\": \"...\"} or {\"api_key\": \"...\", \"api_secret\": \"...\"}".to_string()
150 ))
151 }
152}
153
154#[derive(Debug, Clone)]
156pub enum Credentials {
157 Token(String),
159 KeySecret { key: String, secret: String },
161}
162
163impl Credentials {
164 pub fn token(&self) -> Option<&str> {
166 match self {
167 Credentials::Token(t) => Some(t),
168 Credentials::KeySecret { .. } => None,
169 }
170 }
171
172 pub fn key(&self) -> Option<&str> {
174 match self {
175 Credentials::KeySecret { key, .. } => Some(key),
176 Credentials::Token(_) => None,
177 }
178 }
179
180 pub fn secret(&self) -> Option<&str> {
182 match self {
183 Credentials::KeySecret { secret, .. } => Some(secret),
184 Credentials::Token(_) => None,
185 }
186 }
187
188 pub fn as_bearer_token(&self) -> &str {
190 match self {
191 Credentials::Token(t) => t,
192 Credentials::KeySecret { key, .. } => key,
193 }
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use std::io::Write;
201 use tempfile::NamedTempFile;
202
203 #[test]
204 fn test_load_json_token() {
205 let mut file = NamedTempFile::new().unwrap();
206 writeln!(file, r#"{{"token": "test-token-123"}}"#).unwrap();
207
208 let creds = CredentialLoader::load_from_file(file.path()).unwrap();
209 assert_eq!(creds.token(), Some("test-token-123"));
210 }
211
212 #[test]
213 fn test_load_json_api_token() {
214 let mut file = NamedTempFile::new().unwrap();
215 writeln!(file, r#"{{"api_token": "api-token-456"}}"#).unwrap();
216
217 let creds = CredentialLoader::load_from_file(file.path()).unwrap();
218 assert_eq!(creds.token(), Some("api-token-456"));
219 }
220
221 #[test]
222 fn test_load_json_key_secret() {
223 let mut file = NamedTempFile::new().unwrap();
224 writeln!(
225 file,
226 r#"{{"api_key": "key123", "api_secret": "secret456"}}"#
227 )
228 .unwrap();
229
230 let creds = CredentialLoader::load_from_file(file.path()).unwrap();
231 assert_eq!(creds.key(), Some("key123"));
232 assert_eq!(creds.secret(), Some("secret456"));
233 }
234
235 #[test]
236 fn test_load_plain_text() {
237 let mut file = NamedTempFile::new().unwrap();
238 writeln!(file, "plain-text-token").unwrap();
239
240 let creds = CredentialLoader::load_from_file(file.path()).unwrap();
241 assert_eq!(creds.token(), Some("plain-text-token"));
242 }
243
244 #[test]
245 fn test_load_plain_text_with_whitespace() {
246 let mut file = NamedTempFile::new().unwrap();
247 writeln!(file, " token-with-spaces \n").unwrap();
248
249 let creds = CredentialLoader::load_from_file(file.path()).unwrap();
250 assert_eq!(creds.token(), Some("token-with-spaces"));
251 }
252
253 #[test]
254 fn test_empty_file_error() {
255 let file = NamedTempFile::new().unwrap();
256 let result = CredentialLoader::load_from_file(file.path());
257 assert!(result.is_err());
258 }
259
260 #[test]
261 fn test_invalid_json_error() {
262 let mut file = NamedTempFile::new().unwrap();
263 writeln!(file, r#"{{"invalid": "format"}}"#).unwrap();
264
265 let result = CredentialLoader::load_from_file(file.path());
266 assert!(result.is_err());
267 }
268
269 #[test]
270 fn test_load_json_api_key_only() {
271 let mut file = NamedTempFile::new().unwrap();
272 writeln!(file, r#"{{"api_key": "just-a-key"}}"#).unwrap();
273
274 let creds = CredentialLoader::load_from_file(file.path()).unwrap();
275 assert_eq!(creds.token(), Some("just-a-key"));
277 }
278
279 #[test]
280 fn test_load_json_with_extra_fields() {
281 let mut file = NamedTempFile::new().unwrap();
282 writeln!(
283 file,
284 r#"{{"token": "my-token", "extra": "field", "another": 123}}"#
285 )
286 .unwrap();
287
288 let creds = CredentialLoader::load_from_file(file.path()).unwrap();
289 assert_eq!(creds.token(), Some("my-token"));
290 }
291
292 #[test]
293 fn test_credentials_as_bearer_token() {
294 let token_creds = Credentials::Token("my-token".to_string());
295 assert_eq!(token_creds.as_bearer_token(), "my-token");
296
297 let key_secret_creds = Credentials::KeySecret {
298 key: "my-key".to_string(),
299 secret: "my-secret".to_string(),
300 };
301 assert_eq!(key_secret_creds.as_bearer_token(), "my-key");
302 }
303
304 #[test]
305 fn test_credentials_accessors() {
306 let token_creds = Credentials::Token("my-token".to_string());
307 assert_eq!(token_creds.token(), Some("my-token"));
308 assert_eq!(token_creds.key(), None);
309 assert_eq!(token_creds.secret(), None);
310
311 let key_secret_creds = Credentials::KeySecret {
312 key: "my-key".to_string(),
313 secret: "my-secret".to_string(),
314 };
315 assert_eq!(key_secret_creds.token(), None);
316 assert_eq!(key_secret_creds.key(), Some("my-key"));
317 assert_eq!(key_secret_creds.secret(), Some("my-secret"));
318 }
319
320 #[test]
321 fn test_load_from_env() {
322 std::env::set_var("TEST_DNS_TOKEN_12345", "env-token-value");
323
324 let creds = CredentialLoader::load_from_env("TEST_DNS_TOKEN_12345").unwrap();
325 assert_eq!(creds.token(), Some("env-token-value"));
326
327 std::env::remove_var("TEST_DNS_TOKEN_12345");
328 }
329
330 #[test]
331 fn test_load_from_env_json() {
332 std::env::set_var("TEST_DNS_JSON_12345", r#"{"token": "json-env-token"}"#);
333
334 let creds = CredentialLoader::load_from_env("TEST_DNS_JSON_12345").unwrap();
335 assert_eq!(creds.token(), Some("json-env-token"));
336
337 std::env::remove_var("TEST_DNS_JSON_12345");
338 }
339
340 #[test]
341 fn test_load_from_env_not_set() {
342 let result = CredentialLoader::load_from_env("NONEXISTENT_VAR_12345");
343 assert!(result.is_err());
344 }
345
346 #[test]
347 fn test_nonexistent_file() {
348 let result = CredentialLoader::load_from_file(std::path::Path::new(
349 "/nonexistent/path/to/creds.json",
350 ));
351 assert!(result.is_err());
352 }
353
354 #[test]
355 fn test_whitespace_only_file() {
356 let mut file = NamedTempFile::new().unwrap();
357 writeln!(file, " \n\t \n").unwrap();
358
359 let result = CredentialLoader::load_from_file(file.path());
360 assert!(result.is_err());
361 }
362
363 #[test]
364 fn test_malformed_json() {
365 let mut file = NamedTempFile::new().unwrap();
366 writeln!(file, r#"{{"token": "unclosed"#).unwrap();
367
368 let result = CredentialLoader::load_from_file(file.path());
369 assert!(result.is_err());
372 }
373}