v2.8.0 6aced72

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

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