1// Package domainsecrets implements the DomainSecretStore adapter using filesystem-based env files.
2package domainsecrets
3
4import (
5 "bufio"
6 "fmt"
7 "os"
8 "path/filepath"
9 "sort"
10 "strings"
11
12 "github.com/bnema/zerowrap"
13
14 "github.com/bnema/gordon/internal/boundaries/out"
15 "github.com/bnema/gordon/internal/domain"
16)
17
18// FileStore implements the DomainSecretStore interface using filesystem-based env files.
19// This adapter is responsible only for file I/O operations; domain validation
20// should be performed by the use case layer before calling these methods.
21type FileStore struct {
22 envDir string
23 log zerowrap.Logger
24}
25
26// NewFileStore creates a new file-based domain secret store.
27func NewFileStore(envDir string, log zerowrap.Logger) (*FileStore, error) {
28 // Ensure env directory exists
29 if err := os.MkdirAll(envDir, 0700); err != nil {
30 return nil, fmt.Errorf("failed to create env directory %s: %w", envDir, err)
31 }
32
33 log.Debug().
34 Str(zerowrap.FieldLayer, "adapter").
35 Str(zerowrap.FieldAdapter, "domainsecrets").
36 Str("env_dir", envDir).
37 Msg("domain secret store initialized")
38
39 return &FileStore{
40 envDir: envDir,
41 log: log,
42 }, nil
43}
44
45// ListKeys returns the list of secret keys for a domain (not values).
46func (s *FileStore) ListKeys(domainName string) ([]string, error) {
47 envFile, err := s.validateEnvFilePath(domainName)
48 if err != nil {
49 return nil, err
50 }
51
52 file, err := os.Open(envFile)
53 if err != nil {
54 if os.IsNotExist(err) {
55 return []string{}, nil
56 }
57 return nil, fmt.Errorf("failed to open env file: %w", err)
58 }
59 defer file.Close()
60
61 var keys []string
62 scanner := bufio.NewScanner(file)
63 for scanner.Scan() {
64 line := strings.TrimSpace(scanner.Text())
65 // Skip empty lines and comments
66 if line == "" || strings.HasPrefix(line, "#") {
67 continue
68 }
69 // Extract key from KEY=value
70 if idx := strings.Index(line, "="); idx > 0 {
71 keys = append(keys, line[:idx])
72 }
73 }
74
75 if err := scanner.Err(); err != nil {
76 return nil, fmt.Errorf("failed to read env file: %w", err)
77 }
78
79 return keys, nil
80}
81
82// GetAll returns all secrets for a domain as a key-value map.
83func (s *FileStore) GetAll(domainName string) (map[string]string, error) {
84 envFile, err := s.validateEnvFilePath(domainName)
85 if err != nil {
86 return nil, err
87 }
88
89 data, err := os.ReadFile(envFile)
90 if err != nil {
91 if os.IsNotExist(err) {
92 return map[string]string{}, nil
93 }
94 return nil, fmt.Errorf("failed to read env file: %w", err)
95 }
96
97 secrets, err := domain.ParseEnvData(data)
98 if err != nil {
99 return nil, fmt.Errorf("failed to parse env file: %w", err)
100 }
101
102 return secrets, nil
103}
104
105// Set sets or updates multiple secrets for a domain, merging with existing.
106func (s *FileStore) Set(domainName string, secrets map[string]string) error {
107 // Validate domain first
108 if _, err := s.validateEnvFilePath(domainName); err != nil {
109 return err
110 }
111
112 // Ensure env directory exists
113 if err := os.MkdirAll(s.envDir, 0700); err != nil {
114 return fmt.Errorf("failed to create env directory: %w", err)
115 }
116
117 // Read existing secrets
118 existing, err := s.GetAll(domainName)
119 if err != nil {
120 return err
121 }
122
123 // Merge new secrets with existing
124 for key, value := range secrets {
125 existing[key] = value
126 }
127
128 // Write back atomically
129 return s.writeSecretsAtomic(domainName, existing)
130}
131
132// Delete removes a specific secret key from a domain.
133func (s *FileStore) Delete(domainName, key string) error {
134 // Validate domain first
135 if _, err := s.validateEnvFilePath(domainName); err != nil {
136 return err
137 }
138
139 // Read existing secrets
140 existing, err := s.GetAll(domainName)
141 if err != nil {
142 return err
143 }
144
145 // Remove the key
146 delete(existing, key)
147
148 // Write back atomically
149 return s.writeSecretsAtomic(domainName, existing)
150}
151
152// SetAttachment sets or updates multiple secrets for an attachment container.
153func (s *FileStore) SetAttachment(containerName string, secrets map[string]string) error {
154 // Validate container name first
155 if _, err := s.validateAttachmentEnvFilePath(containerName); err != nil {
156 return err
157 }
158
159 // Ensure env directory exists
160 if err := os.MkdirAll(s.envDir, 0700); err != nil {
161 return fmt.Errorf("failed to create env directory: %w", err)
162 }
163
164 // Read existing secrets
165 existing, err := s.GetAllAttachment(containerName)
166 if err != nil {
167 return err
168 }
169
170 // Merge new secrets with existing
171 for key, value := range secrets {
172 existing[key] = value
173 }
174
175 // Write back atomically
176 return s.writeAttachmentSecretsAtomic(containerName, existing)
177}
178
179// DeleteAttachment removes a specific secret key from an attachment container.
180func (s *FileStore) DeleteAttachment(containerName, key string) error {
181 // Validate container name first
182 if _, err := s.validateAttachmentEnvFilePath(containerName); err != nil {
183 return err
184 }
185
186 // Read existing secrets
187 existing, err := s.GetAllAttachment(containerName)
188 if err != nil {
189 return err
190 }
191
192 // Remove the key (no-op if not present)
193 delete(existing, key)
194
195 // Write back atomically
196 return s.writeAttachmentSecretsAtomic(containerName, existing)
197}
198
199// GetAllAttachment returns all secrets for an attachment container as a key-value map.
200func (s *FileStore) GetAllAttachment(containerName string) (map[string]string, error) {
201 envFile, err := s.validateAttachmentEnvFilePath(containerName)
202 if err != nil {
203 return nil, err
204 }
205
206 data, err := os.ReadFile(envFile)
207 if err != nil {
208 if os.IsNotExist(err) {
209 return map[string]string{}, nil
210 }
211 return nil, fmt.Errorf("failed to read attachment env file: %w", err)
212 }
213
214 secrets, err := domain.ParseEnvData(data)
215 if err != nil {
216 return nil, fmt.Errorf("failed to parse attachment env file: %w", err)
217 }
218
219 return secrets, nil
220}
221
222// validateSecretValues checks that no secret value contains newline characters.
223func validateSecretValues(secrets map[string]string) error {
224 for key, value := range secrets {
225 if strings.ContainsAny(value, "\n\r") {
226 return fmt.Errorf("secret value for %q contains newline characters", key)
227 }
228 }
229 return nil
230}
231
232// writeSecretsAtomic writes all secrets to the domain's env file atomically.
233// It writes to a temporary file first, syncs it, then renames to the final path.
234func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]string) error {
235 if err := validateSecretValues(secrets); err != nil {
236 return fmt.Errorf("invalid domain secret values for %q: %w", domainName, err)
237 }
238
239 envFile, err := s.validateEnvFilePath(domainName)
240 if err != nil {
241 return err
242 }
243 tmpFile := envFile + ".tmp"
244
245 file, err := os.OpenFile(tmpFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
246 if err != nil {
247 return fmt.Errorf("failed to create temp env file: %w", err)
248 }
249
250 // Write header comment
251 if _, err := fmt.Fprintf(file, "# Environment variables for %s\n", domainName); err != nil {
252 file.Close()
253 os.Remove(tmpFile)
254 return fmt.Errorf("failed to write header: %w", err)
255 }
256 if _, err := fmt.Fprintf(file, "# Managed by Gordon admin API\n\n"); err != nil {
257 file.Close()
258 os.Remove(tmpFile)
259 return fmt.Errorf("failed to write header: %w", err)
260 }
261
262 // Write each secret in sorted order for deterministic output
263 keys := make([]string, 0, len(secrets))
264 for key := range secrets {
265 keys = append(keys, key)
266 }
267 sort.Strings(keys)
268
269 for _, key := range keys {
270 if _, err := fmt.Fprintf(file, "%s=%s\n", key, secrets[key]); err != nil {
271 file.Close()
272 os.Remove(tmpFile)
273 return fmt.Errorf("failed to write secret %s: %w", key, err)
274 }
275 }
276
277 // Sync to ensure data is on disk before rename
278 if err := file.Sync(); err != nil {
279 file.Close()
280 os.Remove(tmpFile)
281 return fmt.Errorf("failed to sync env file: %w", err)
282 }
283
284 if err := file.Close(); err != nil {
285 os.Remove(tmpFile)
286 return fmt.Errorf("failed to close env file: %w", err)
287 }
288
289 // Atomic rename
290 if err := os.Rename(tmpFile, envFile); err != nil {
291 os.Remove(tmpFile)
292 return fmt.Errorf("failed to rename env file: %w", err)
293 }
294
295 return nil
296}
297
298// getEnvFilePath converts a domain to its env file path.
299// This must match the naming convention in envloader.FileLoader.getEnvFilePath.
300//
301// SECURITY: Validates domain and ensures the resulting path stays within envDir.
302func (s *FileStore) getEnvFilePath(domainName string) (string, error) {
303 storageKey, err := domain.NewEnvStorageKey(domainName)
304 if err != nil {
305 s.log.Warn().
306 Str(zerowrap.FieldLayer, "adapter").
307 Str(zerowrap.FieldAdapter, "domainsecrets").
308 Str("domain", domainName).
309 Err(err).
310 Msg("rejected invalid domain")
311 return "", err
312 }
313
314 fullPath := filepath.Join(s.envDir, storageKey.FileName())
315
316 // SECURITY: Final validation - ensure path stays within envDir
317 cleanPath := filepath.Clean(fullPath)
318 cleanEnvDir := filepath.Clean(s.envDir)
319 if !strings.HasPrefix(cleanPath, cleanEnvDir+string(filepath.Separator)) {
320 s.log.Error().
321 Str(zerowrap.FieldLayer, "adapter").
322 Str(zerowrap.FieldAdapter, "domainsecrets").
323 Str("domain", domainName).
324 Str("attempted_path", fullPath).
325 Msg("path traversal attempt blocked - path escapes env directory")
326 return "", domain.ErrPathTraversal
327 }
328
329 return fullPath, nil
330}
331
332// validateEnvFilePath validates that a domain produces a valid env file path.
333// Returns an error if the domain is invalid or would result in path traversal.
334func (s *FileStore) validateEnvFilePath(domainName string) (string, error) {
335 return s.getEnvFilePath(domainName)
336}
337
338// getAttachmentEnvFilePath converts a container name to its attachment env file path.
339// Attachment env files follow the pattern: gordon-<containerName>.env
340//
341// SECURITY: Validates container name and ensures the resulting path stays within envDir.
342func (s *FileStore) getAttachmentEnvFilePath(containerName string) (string, error) {
343 if err := domain.ValidateContainerName(containerName); err != nil {
344 s.log.Warn().
345 Str(zerowrap.FieldLayer, "adapter").
346 Str(zerowrap.FieldAdapter, "domainsecrets").
347 Str("container", containerName).
348 Err(err).
349 Msg("rejected invalid container name")
350 return "", err
351 }
352
353 fullPath := filepath.Join(s.envDir, "gordon-"+containerName+".env")
354
355 // SECURITY: Final validation - ensure path stays within envDir
356 cleanPath := filepath.Clean(fullPath)
357 cleanEnvDir := filepath.Clean(s.envDir)
358 if !strings.HasPrefix(cleanPath, cleanEnvDir+string(filepath.Separator)) {
359 s.log.Error().
360 Str(zerowrap.FieldLayer, "adapter").
361 Str(zerowrap.FieldAdapter, "domainsecrets").
362 Str("container", containerName).
363 Str("attempted_path", fullPath).
364 Msg("path traversal attempt blocked - path escapes env directory")
365 return "", domain.ErrPathTraversal
366 }
367
368 return fullPath, nil
369}
370
371// validateAttachmentEnvFilePath validates that a container name produces a valid attachment env file path.
372// Returns an error if the container name is invalid or would result in path traversal.
373func (s *FileStore) validateAttachmentEnvFilePath(containerName string) (string, error) {
374 return s.getAttachmentEnvFilePath(containerName)
375}
376
377// writeAttachmentSecretsAtomic writes all secrets to the attachment's env file atomically.
378// It writes to a temporary file first, syncs it, then renames to the final path.
379func (s *FileStore) writeAttachmentSecretsAtomic(containerName string, secrets map[string]string) error {
380 if err := validateSecretValues(secrets); err != nil {
381 return fmt.Errorf("invalid attachment secret values for %q: %w", containerName, err)
382 }
383
384 envFile, err := s.validateAttachmentEnvFilePath(containerName)
385 if err != nil {
386 return err
387 }
388 tmpFile := envFile + ".tmp"
389
390 file, err := os.OpenFile(tmpFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
391 if err != nil {
392 return fmt.Errorf("failed to create temp attachment env file: %w", err)
393 }
394
395 // Write header comment
396 if _, err := fmt.Fprintf(file, "# Environment variables for attachment %s\n", containerName); err != nil {
397 file.Close()
398 os.Remove(tmpFile)
399 return fmt.Errorf("failed to write header: %w", err)
400 }
401 if _, err := fmt.Fprintf(file, "# Managed by Gordon admin API\n\n"); err != nil {
402 file.Close()
403 os.Remove(tmpFile)
404 return fmt.Errorf("failed to write header: %w", err)
405 }
406
407 // Write each secret in sorted order for deterministic output
408 keys := make([]string, 0, len(secrets))
409 for key := range secrets {
410 keys = append(keys, key)
411 }
412 sort.Strings(keys)
413
414 for _, key := range keys {
415 if _, err := fmt.Fprintf(file, "%s=%s\n", key, secrets[key]); err != nil {
416 file.Close()
417 os.Remove(tmpFile)
418 return fmt.Errorf("failed to write secret %s: %w", key, err)
419 }
420 }
421
422 // Sync to ensure data is on disk before rename
423 if err := file.Sync(); err != nil {
424 file.Close()
425 os.Remove(tmpFile)
426 return fmt.Errorf("failed to sync attachment env file: %w", err)
427 }
428
429 if err := file.Close(); err != nil {
430 os.Remove(tmpFile)
431 return fmt.Errorf("failed to close attachment env file: %w", err)
432 }
433
434 // Atomic rename
435 if err := os.Rename(tmpFile, envFile); err != nil {
436 os.Remove(tmpFile)
437 return fmt.Errorf("failed to rename attachment env file: %w", err)
438 }
439
440 return nil
441}
442
443// ListAttachmentKeys finds attachment env files for a domain and returns their keys.
444// Supports both new (collision-resistant) and legacy container naming for backwards compatibility.
445// Attachment env files follow the naming pattern: gordon-{sanitized-domain}-{service}.env
446func (s *FileStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecrets, error) {
447 // Try both new and legacy sanitization for backwards compatibility
448 sanitized := domain.SanitizeDomainForContainer(domainName)
449 sanitizedLegacy := domain.SanitizeDomainForContainerLegacy(domainName)
450 prefixes := []string{
451 "gordon-" + sanitized + "-", // New format (collision-resistant)
452 "gordon-" + sanitizedLegacy + "-", // Old format (buggy but backwards compatible)
453 }
454
455 // List all files in env directory
456 entries, err := os.ReadDir(s.envDir)
457 if err != nil {
458 if os.IsNotExist(err) {
459 return nil, nil
460 }
461 return nil, fmt.Errorf("failed to read env directory: %w", err)
462 }
463
464 seen := make(map[string]bool)
465 var results []out.AttachmentSecrets
466 for _, entry := range entries {
467 if entry.IsDir() {
468 continue
469 }
470 name := entry.Name()
471
472 // Check if this matches any of our prefixes
473 var matchedPrefix string
474 for _, prefix := range prefixes {
475 if strings.HasPrefix(name, prefix) && strings.HasSuffix(name, ".env") {
476 matchedPrefix = prefix
477 break
478 }
479 }
480 if matchedPrefix == "" {
481 continue
482 }
483
484 // Extract container name from filename
485 containerName := strings.TrimSuffix(name, ".env")
486
487 // Deduplicate results
488 if seen[containerName] {
489 continue
490 }
491 seen[containerName] = true
492
493 // Extract service name from filename
494 // e.g., "gordon-git-bnema-dev-gitea-postgres.env" → "gitea-postgres"
495 serviceName := strings.TrimPrefix(name, matchedPrefix)
496 serviceName = strings.TrimSuffix(serviceName, ".env")
497 if serviceName == "" {
498 continue
499 }
500
501 // Read keys from this file using existing method
502 // Note: We use the container name directly since it matches the env file naming
503 keys, err := s.listKeysFromFile(filepath.Join(s.envDir, name))
504 if err != nil {
505 s.log.Warn().
506 Err(err).
507 Str("file", name).
508 Str("domain", domainName).
509 Msg("failed to read attachment secrets file")
510 continue
511 }
512
513 if len(keys) > 0 {
514 results = append(results, out.AttachmentSecrets{
515 Service: containerName,
516 Keys: keys,
517 })
518 }
519 }
520
521 return results, nil
522}
523
524// listKeysFromFile reads secret keys from a specific env file path.
525func (s *FileStore) listKeysFromFile(filePath string) ([]string, error) {
526 file, err := os.Open(filePath)
527 if err != nil {
528 if os.IsNotExist(err) {
529 return []string{}, nil
530 }
531 return nil, fmt.Errorf("failed to open env file: %w", err)
532 }
533 defer file.Close()
534
535 var keys []string
536 scanner := bufio.NewScanner(file)
537 for scanner.Scan() {
538 line := strings.TrimSpace(scanner.Text())
539 // Skip empty lines and comments
540 if line == "" || strings.HasPrefix(line, "#") {
541 continue
542 }
543 // Extract key from KEY=value
544 if idx := strings.Index(line, "="); idx > 0 {
545 keys = append(keys, line[:idx])
546 }
547 }
548
549 if err := scanner.Err(); err != nil {
550 return nil, fmt.Errorf("failed to read env file: %w", err)
551 }
552
553 return keys, nil
554}