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 "regexp"
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 file, err := os.Open(envFile)
90 if err != nil {
91 if os.IsNotExist(err) {
92 return map[string]string{}, nil
93 }
94 return nil, fmt.Errorf("failed to open env file: %w", err)
95 }
96 defer file.Close()
97
98 secrets := make(map[string]string)
99 scanner := bufio.NewScanner(file)
100 for scanner.Scan() {
101 line := strings.TrimSpace(scanner.Text())
102 // Skip empty lines and comments
103 if line == "" || strings.HasPrefix(line, "#") {
104 continue
105 }
106 // Parse KEY=value
107 if idx := strings.Index(line, "="); idx > 0 {
108 key := line[:idx]
109 value := line[idx+1:]
110 secrets[key] = value
111 }
112 }
113
114 if err := scanner.Err(); err != nil {
115 return nil, fmt.Errorf("failed to read env file: %w", err)
116 }
117
118 return secrets, nil
119}
120
121// Set sets or updates multiple secrets for a domain, merging with existing.
122func (s *FileStore) Set(domainName string, secrets map[string]string) error {
123 // Validate domain first
124 if _, err := s.validateEnvFilePath(domainName); err != nil {
125 return err
126 }
127
128 // Ensure env directory exists
129 if err := os.MkdirAll(s.envDir, 0700); err != nil {
130 return fmt.Errorf("failed to create env directory: %w", err)
131 }
132
133 // Read existing secrets
134 existing, err := s.GetAll(domainName)
135 if err != nil {
136 return err
137 }
138
139 // Merge new secrets with existing
140 for key, value := range secrets {
141 existing[key] = value
142 }
143
144 // Write back atomically
145 return s.writeSecretsAtomic(domainName, existing)
146}
147
148// Delete removes a specific secret key from a domain.
149func (s *FileStore) Delete(domainName, key string) error {
150 // Validate domain first
151 if _, err := s.validateEnvFilePath(domainName); err != nil {
152 return err
153 }
154
155 // Read existing secrets
156 existing, err := s.GetAll(domainName)
157 if err != nil {
158 return err
159 }
160
161 // Remove the key
162 delete(existing, key)
163
164 // Write back atomically
165 return s.writeSecretsAtomic(domainName, existing)
166}
167
168// writeSecretsAtomic writes all secrets to the domain's env file atomically.
169// It writes to a temporary file first, syncs it, then renames to the final path.
170func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]string) error {
171 envFile, err := s.validateEnvFilePath(domainName)
172 if err != nil {
173 return err
174 }
175 tmpFile := envFile + ".tmp"
176
177 file, err := os.OpenFile(tmpFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
178 if err != nil {
179 return fmt.Errorf("failed to create temp env file: %w", err)
180 }
181
182 // Write header comment
183 if _, err := fmt.Fprintf(file, "# Environment variables for %s\n", domainName); err != nil {
184 file.Close()
185 os.Remove(tmpFile)
186 return fmt.Errorf("failed to write header: %w", err)
187 }
188 if _, err := fmt.Fprintf(file, "# Managed by Gordon admin API\n\n"); err != nil {
189 file.Close()
190 os.Remove(tmpFile)
191 return fmt.Errorf("failed to write header: %w", err)
192 }
193
194 // Write each secret
195 for key, value := range secrets {
196 if _, err := fmt.Fprintf(file, "%s=%s\n", key, value); err != nil {
197 file.Close()
198 os.Remove(tmpFile)
199 return fmt.Errorf("failed to write secret %s: %w", key, err)
200 }
201 }
202
203 // Sync to ensure data is on disk before rename
204 if err := file.Sync(); err != nil {
205 file.Close()
206 os.Remove(tmpFile)
207 return fmt.Errorf("failed to sync env file: %w", err)
208 }
209
210 if err := file.Close(); err != nil {
211 os.Remove(tmpFile)
212 return fmt.Errorf("failed to close env file: %w", err)
213 }
214
215 // Atomic rename
216 if err := os.Rename(tmpFile, envFile); err != nil {
217 os.Remove(tmpFile)
218 return fmt.Errorf("failed to rename env file: %w", err)
219 }
220
221 return nil
222}
223
224// domainRegex validates domain names to prevent path injection.
225// Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths).
226var domainRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`)
227
228// getEnvFilePath converts a domain to its env file path.
229// This must match the naming convention in envloader.FileLoader.getEnvFilePath.
230//
231// SECURITY: Validates domain and ensures the resulting path stays within envDir.
232func (s *FileStore) getEnvFilePath(domainName string) string {
233 // SECURITY: Reject domains that look like path traversal attempts
234 if strings.Contains(domainName, "..") {
235 s.log.Warn().
236 Str(zerowrap.FieldLayer, "adapter").
237 Str(zerowrap.FieldAdapter, "domainsecrets").
238 Str("domain", domainName).
239 Msg("rejected domain with path traversal sequence")
240 return ""
241 }
242
243 // SECURITY: Validate domain format
244 if !domainRegex.MatchString(domainName) {
245 s.log.Warn().
246 Str(zerowrap.FieldLayer, "adapter").
247 Str(zerowrap.FieldAdapter, "domainsecrets").
248 Str("domain", domainName).
249 Msg("rejected invalid domain format")
250 return ""
251 }
252
253 // Create domain-safe filename (replace dots and other chars with underscores)
254 safeDomain := strings.ReplaceAll(domainName, ".", "_")
255 safeDomain = strings.ReplaceAll(safeDomain, ":", "_")
256 safeDomain = strings.ReplaceAll(safeDomain, "/", "_")
257
258 fullPath := filepath.Join(s.envDir, safeDomain+".env")
259
260 // SECURITY: Final validation - ensure path stays within envDir
261 cleanPath := filepath.Clean(fullPath)
262 cleanEnvDir := filepath.Clean(s.envDir)
263 if !strings.HasPrefix(cleanPath, cleanEnvDir+string(filepath.Separator)) {
264 s.log.Error().
265 Str(zerowrap.FieldLayer, "adapter").
266 Str(zerowrap.FieldAdapter, "domainsecrets").
267 Str("domain", domainName).
268 Str("attempted_path", fullPath).
269 Msg("path traversal attempt blocked - path escapes env directory")
270 return ""
271 }
272
273 return fullPath
274}
275
276// validateEnvFilePath validates that a domain produces a valid env file path.
277// Returns an error if the domain is invalid or would result in path traversal.
278func (s *FileStore) validateEnvFilePath(domainName string) (string, error) {
279 path := s.getEnvFilePath(domainName)
280 if path == "" {
281 return "", domain.ErrPathTraversal
282 }
283 return path, nil
284}
285
286// ListAttachmentKeys finds attachment env files for a domain and returns their keys.
287// Attachment env files follow the naming pattern: gordon-{sanitized-domain}-{service}.env
288func (s *FileStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecrets, error) {
289 // Sanitize domain the same way container service does
290 sanitized := sanitizeDomainForContainer(domainName)
291 prefix := "gordon-" + sanitized + "-"
292
293 // List all files in env directory
294 entries, err := os.ReadDir(s.envDir)
295 if err != nil {
296 if os.IsNotExist(err) {
297 return nil, nil
298 }
299 return nil, fmt.Errorf("failed to read env directory: %w", err)
300 }
301
302 var results []out.AttachmentSecrets
303 for _, entry := range entries {
304 if entry.IsDir() {
305 continue
306 }
307 name := entry.Name()
308 // Check if this is an attachment file for the domain
309 if strings.HasPrefix(name, prefix) && strings.HasSuffix(name, ".env") {
310 // Extract service name from filename
311 // e.g., "gordon-git-bnema-dev-gitea-postgres.env" → "gitea-postgres"
312 serviceName := strings.TrimPrefix(name, prefix)
313 serviceName = strings.TrimSuffix(serviceName, ".env")
314 if serviceName == "" {
315 continue
316 }
317
318 // The full container name is the filename without .env
319 containerName := strings.TrimSuffix(name, ".env")
320
321 // Read keys from this file using existing method
322 // Note: We use the container name directly since it matches the env file naming
323 keys, err := s.listKeysFromFile(filepath.Join(s.envDir, name))
324 if err != nil {
325 s.log.Warn().
326 Err(err).
327 Str("file", name).
328 Str("domain", domainName).
329 Msg("failed to read attachment secrets file")
330 continue
331 }
332
333 if len(keys) > 0 {
334 results = append(results, out.AttachmentSecrets{
335 Service: containerName,
336 Keys: keys,
337 })
338 }
339 }
340 }
341
342 return results, nil
343}
344
345// sanitizeDomainForContainer matches the sanitizeName function in container service.
346func sanitizeDomainForContainer(domain string) string {
347 result := strings.ReplaceAll(domain, ".", "-")
348 result = strings.ReplaceAll(result, ":", "-")
349 result = strings.ReplaceAll(result, "/", "-")
350 return result
351}
352
353// listKeysFromFile reads secret keys from a specific env file path.
354func (s *FileStore) listKeysFromFile(filePath string) ([]string, error) {
355 file, err := os.Open(filePath)
356 if err != nil {
357 if os.IsNotExist(err) {
358 return []string{}, nil
359 }
360 return nil, fmt.Errorf("failed to open env file: %w", err)
361 }
362 defer file.Close()
363
364 var keys []string
365 scanner := bufio.NewScanner(file)
366 for scanner.Scan() {
367 line := strings.TrimSpace(scanner.Text())
368 // Skip empty lines and comments
369 if line == "" || strings.HasPrefix(line, "#") {
370 continue
371 }
372 // Extract key from KEY=value
373 if idx := strings.Index(line, "="); idx > 0 {
374 keys = append(keys, line[:idx])
375 }
376 }
377
378 if err := scanner.Err(); err != nil {
379 return nil, fmt.Errorf("failed to read env file: %w", err)
380 }
381
382 return keys, nil
383}