v2.11.0 dca6537

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

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