v2.9.0 8ebdfe0

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// 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}

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 8

Tags 106

↑↓ moveenter openesc close