v2.31.1 f37fe67

store.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close