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// writeSecretsAtomic writes all secrets to the domain's env file atomically.
223// It writes to a temporary file first, syncs it, then renames to the final path.
224func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]string) error {
225 envFile, err := s.validateEnvFilePath(domainName)
226 if err != nil {
227 return err
228 }
229 tmpFile := envFile + ".tmp"
230
231 file, err := os.OpenFile(tmpFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
232 if err != nil {
233 return fmt.Errorf("failed to create temp env file: %w", err)
234 }
235
236 // Write header comment
237 if _, err := fmt.Fprintf(file, "# Environment variables for %s\n", domainName); err != nil {
238 file.Close()
239 os.Remove(tmpFile)
240 return fmt.Errorf("failed to write header: %w", err)
241 }
242 if _, err := fmt.Fprintf(file, "# Managed by Gordon admin API\n\n"); err != nil {
243 file.Close()
244 os.Remove(tmpFile)
245 return fmt.Errorf("failed to write header: %w", err)
246 }
247
248 // Write each secret in sorted order for deterministic output
249 keys := make([]string, 0, len(secrets))
250 for key := range secrets {
251 keys = append(keys, key)
252 }
253 sort.Strings(keys)
254
255 for _, key := range keys {
256 if _, err := fmt.Fprintf(file, "%s=%s\n", key, secrets[key]); err != nil {
257 file.Close()
258 os.Remove(tmpFile)
259 return fmt.Errorf("failed to write secret %s: %w", key, err)
260 }
261 }
262
263 // Sync to ensure data is on disk before rename
264 if err := file.Sync(); err != nil {
265 file.Close()
266 os.Remove(tmpFile)
267 return fmt.Errorf("failed to sync env file: %w", err)
268 }
269
270 if err := file.Close(); err != nil {
271 os.Remove(tmpFile)
272 return fmt.Errorf("failed to close env file: %w", err)
273 }
274
275 // Atomic rename
276 if err := os.Rename(tmpFile, envFile); err != nil {
277 os.Remove(tmpFile)
278 return fmt.Errorf("failed to rename env file: %w", err)
279 }
280
281 return nil
282}
283
284// getEnvFilePath converts a domain to its env file path.
285// This must match the naming convention in envloader.FileLoader.getEnvFilePath.
286//
287// SECURITY: Validates domain and ensures the resulting path stays within envDir.
288func (s *FileStore) getEnvFilePath(domainName string) string {
289 safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)
290 if err != nil {
291 s.log.Warn().
292 Str(zerowrap.FieldLayer, "adapter").
293 Str(zerowrap.FieldAdapter, "domainsecrets").
294 Str("domain", domainName).
295 Err(err).
296 Msg("rejected invalid domain")
297 return ""
298 }
299
300 fullPath := filepath.Join(s.envDir, safeDomain+".env")
301
302 // SECURITY: Final validation - ensure path stays within envDir
303 cleanPath := filepath.Clean(fullPath)
304 cleanEnvDir := filepath.Clean(s.envDir)
305 if !strings.HasPrefix(cleanPath, cleanEnvDir+string(filepath.Separator)) {
306 s.log.Error().
307 Str(zerowrap.FieldLayer, "adapter").
308 Str(zerowrap.FieldAdapter, "domainsecrets").
309 Str("domain", domainName).
310 Str("attempted_path", fullPath).
311 Msg("path traversal attempt blocked - path escapes env directory")
312 return ""
313 }
314
315 return fullPath
316}
317
318// validateEnvFilePath validates that a domain produces a valid env file path.
319// Returns an error if the domain is invalid or would result in path traversal.
320func (s *FileStore) validateEnvFilePath(domainName string) (string, error) {
321 path := s.getEnvFilePath(domainName)
322 if path == "" {
323 return "", domain.ErrPathTraversal
324 }
325 return path, nil
326}
327
328// getAttachmentEnvFilePath converts a container name to its attachment env file path.
329// Attachment env files follow the pattern: gordon-<containerName>.env
330//
331// SECURITY: Validates container name and ensures the resulting path stays within envDir.
332func (s *FileStore) getAttachmentEnvFilePath(containerName string) (string, error) {
333 if err := domain.ValidateContainerName(containerName); err != nil {
334 s.log.Warn().
335 Str(zerowrap.FieldLayer, "adapter").
336 Str(zerowrap.FieldAdapter, "domainsecrets").
337 Str("container", containerName).
338 Err(err).
339 Msg("rejected invalid container name")
340 return "", err
341 }
342
343 fullPath := filepath.Join(s.envDir, "gordon-"+containerName+".env")
344
345 // SECURITY: Final validation - ensure path stays within envDir
346 cleanPath := filepath.Clean(fullPath)
347 cleanEnvDir := filepath.Clean(s.envDir)
348 if !strings.HasPrefix(cleanPath, cleanEnvDir+string(filepath.Separator)) {
349 s.log.Error().
350 Str(zerowrap.FieldLayer, "adapter").
351 Str(zerowrap.FieldAdapter, "domainsecrets").
352 Str("container", containerName).
353 Str("attempted_path", fullPath).
354 Msg("path traversal attempt blocked - path escapes env directory")
355 return "", domain.ErrPathTraversal
356 }
357
358 return fullPath, nil
359}
360
361// validateAttachmentEnvFilePath validates that a container name produces a valid attachment env file path.
362// Returns an error if the container name is invalid or would result in path traversal.
363func (s *FileStore) validateAttachmentEnvFilePath(containerName string) (string, error) {
364 return s.getAttachmentEnvFilePath(containerName)
365}
366
367// writeAttachmentSecretsAtomic writes all secrets to the attachment's env file atomically.
368// It writes to a temporary file first, syncs it, then renames to the final path.
369func (s *FileStore) writeAttachmentSecretsAtomic(containerName string, secrets map[string]string) error {
370 envFile, err := s.validateAttachmentEnvFilePath(containerName)
371 if err != nil {
372 return err
373 }
374 tmpFile := envFile + ".tmp"
375
376 file, err := os.OpenFile(tmpFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
377 if err != nil {
378 return fmt.Errorf("failed to create temp attachment env file: %w", err)
379 }
380
381 // Write header comment
382 if _, err := fmt.Fprintf(file, "# Environment variables for attachment %s\n", containerName); err != nil {
383 file.Close()
384 os.Remove(tmpFile)
385 return fmt.Errorf("failed to write header: %w", err)
386 }
387 if _, err := fmt.Fprintf(file, "# Managed by Gordon admin API\n\n"); err != nil {
388 file.Close()
389 os.Remove(tmpFile)
390 return fmt.Errorf("failed to write header: %w", err)
391 }
392
393 // Write each secret in sorted order for deterministic output
394 keys := make([]string, 0, len(secrets))
395 for key := range secrets {
396 keys = append(keys, key)
397 }
398 sort.Strings(keys)
399
400 for _, key := range keys {
401 if _, err := fmt.Fprintf(file, "%s=%s\n", key, secrets[key]); err != nil {
402 file.Close()
403 os.Remove(tmpFile)
404 return fmt.Errorf("failed to write secret %s: %w", key, err)
405 }
406 }
407
408 // Sync to ensure data is on disk before rename
409 if err := file.Sync(); err != nil {
410 file.Close()
411 os.Remove(tmpFile)
412 return fmt.Errorf("failed to sync attachment env file: %w", err)
413 }
414
415 if err := file.Close(); err != nil {
416 os.Remove(tmpFile)
417 return fmt.Errorf("failed to close attachment env file: %w", err)
418 }
419
420 // Atomic rename
421 if err := os.Rename(tmpFile, envFile); err != nil {
422 os.Remove(tmpFile)
423 return fmt.Errorf("failed to rename attachment env file: %w", err)
424 }
425
426 return nil
427}
428
429// ListAttachmentKeys finds attachment env files for a domain and returns their keys.
430// Supports both new (collision-resistant) and legacy container naming for backwards compatibility.
431// Attachment env files follow the naming pattern: gordon-{sanitized-domain}-{service}.env
432func (s *FileStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecrets, error) {
433 // Try both new and legacy sanitization for backwards compatibility
434 sanitized := domain.SanitizeDomainForContainer(domainName)
435 sanitizedLegacy := domain.SanitizeDomainForContainerLegacy(domainName)
436 prefixes := []string{
437 "gordon-" + sanitized + "-", // New format (collision-resistant)
438 "gordon-" + sanitizedLegacy + "-", // Old format (buggy but backwards compatible)
439 }
440
441 // List all files in env directory
442 entries, err := os.ReadDir(s.envDir)
443 if err != nil {
444 if os.IsNotExist(err) {
445 return nil, nil
446 }
447 return nil, fmt.Errorf("failed to read env directory: %w", err)
448 }
449
450 seen := make(map[string]bool)
451 var results []out.AttachmentSecrets
452 for _, entry := range entries {
453 if entry.IsDir() {
454 continue
455 }
456 name := entry.Name()
457
458 // Check if this matches any of our prefixes
459 var matchedPrefix string
460 for _, prefix := range prefixes {
461 if strings.HasPrefix(name, prefix) && strings.HasSuffix(name, ".env") {
462 matchedPrefix = prefix
463 break
464 }
465 }
466 if matchedPrefix == "" {
467 continue
468 }
469
470 // Extract container name from filename
471 containerName := strings.TrimSuffix(name, ".env")
472
473 // Deduplicate results
474 if seen[containerName] {
475 continue
476 }
477 seen[containerName] = true
478
479 // Extract service name from filename
480 // e.g., "gordon-git-bnema-dev-gitea-postgres.env" → "gitea-postgres"
481 serviceName := strings.TrimPrefix(name, matchedPrefix)
482 serviceName = strings.TrimSuffix(serviceName, ".env")
483 if serviceName == "" {
484 continue
485 }
486
487 // Read keys from this file using existing method
488 // Note: We use the container name directly since it matches the env file naming
489 keys, err := s.listKeysFromFile(filepath.Join(s.envDir, name))
490 if err != nil {
491 s.log.Warn().
492 Err(err).
493 Str("file", name).
494 Str("domain", domainName).
495 Msg("failed to read attachment secrets file")
496 continue
497 }
498
499 if len(keys) > 0 {
500 results = append(results, out.AttachmentSecrets{
501 Service: containerName,
502 Keys: keys,
503 })
504 }
505 }
506
507 return results, nil
508}
509
510// listKeysFromFile reads secret keys from a specific env file path.
511func (s *FileStore) listKeysFromFile(filePath string) ([]string, error) {
512 file, err := os.Open(filePath)
513 if err != nil {
514 if os.IsNotExist(err) {
515 return []string{}, nil
516 }
517 return nil, fmt.Errorf("failed to open env file: %w", err)
518 }
519 defer file.Close()
520
521 var keys []string
522 scanner := bufio.NewScanner(file)
523 for scanner.Scan() {
524 line := strings.TrimSpace(scanner.Text())
525 // Skip empty lines and comments
526 if line == "" || strings.HasPrefix(line, "#") {
527 continue
528 }
529 // Extract key from KEY=value
530 if idx := strings.Index(line, "="); idx > 0 {
531 keys = append(keys, line[:idx])
532 }
533 }
534
535 if err := scanner.Err(); err != nil {
536 return nil, fmt.Errorf("failed to read env file: %w", err)
537 }
538
539 return keys, nil
540}