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