detached 6ee1d33

feat(secrets): add pass-backed domain secrets migration

commit
6ee1d3375c7e026411678eb62c75dc169a4ec46f
author
bnema · · 2026-01-30 20:30 UTC
parent
6aced72
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +919 -99
M docs/cli/secrets.md +4 -0
@@ -2,6 +2,10 @@
22
33 Manage secrets on local or remote Gordon instances.
44
5+Storage depends on the secrets backend:
6+- `pass`: secrets are stored in pass under `gordon/env/<domain>/<KEY>`
7+- `sops` or `unsafe`: secrets are stored in domain `.env` files
8+
59 ## gordon secrets
610
711 ### Subcommands
@@ -2,6 +2,10 @@
2 2
3 Manage secrets on local or remote Gordon instances.3 Manage secrets on local or remote Gordon instances.
4 4
5+Storage depends on the secrets backend:
6+- `pass`: secrets are stored in pass under `gordon/env/<domain>/<KEY>`
7+- `sops` or `unsafe`: secrets are stored in domain `.env` files
8+
5 ## gordon secrets9 ## gordon secrets
6 10
7 ### Subcommands11 ### Subcommands
M docs/config/env.md +15 -0
@@ -34,6 +34,21 @@ Gordon loads environment variables from files named after the domain:
3434 | `api.company.io` | `api_company_io.env` |
3535 | `staging.app.dev` | `staging_app_dev.env` |
3636
37+## Backend Behavior
38+
39+The `auth.secrets_backend` setting changes where route secrets live:
40+
41+### Pass Backend
42+
43+- Env files are not used for route secrets.
44+- Use `gordon secrets set` to store per-domain secrets in pass.
45+- Existing `.env` files are migrated on startup and renamed to `.env.migrated`.
46+
47+### SOPS or Unsafe Backend
48+
49+- Env files remain the source of truth.
50+- Use `${sops:...}` syntax for encrypted values when `secrets_backend = "sops"`.
51+
3752 ## File Format
3853
3954 Standard `.env` file format:
@@ -34,6 +34,21 @@ Gordon loads environment variables from files named after the domain:
34 | `api.company.io` | `api_company_io.env` |34 | `api.company.io` | `api_company_io.env` |
35 | `staging.app.dev` | `staging_app_dev.env` |35 | `staging.app.dev` | `staging_app_dev.env` |
36 36
37+## Backend Behavior
38+
39+The `auth.secrets_backend` setting changes where route secrets live:
40+
41+### Pass Backend
42+
43+- Env files are not used for route secrets.
44+- Use `gordon secrets set` to store per-domain secrets in pass.
45+- Existing `.env` files are migrated on startup and renamed to `.env.migrated`.
46+
47+### SOPS or Unsafe Backend
48+
49+- Env files remain the source of truth.
50+- Use `${sops:...}` syntax for encrypted values when `secrets_backend = "sops"`.
51+
37 ## File Format52 ## File Format
38 53
39 Standard `.env` file format:54 Standard `.env` file format:
M docs/config/secrets.md +8 -0
@@ -54,6 +54,10 @@ token_secret = "gordon/auth/token_secret"
5454 - Standard Unix tooling
5555 - Works with team GPG keys
5656
57+**Route secrets storage:**
58+- `gordon secrets set` stores per-domain secrets in pass under `gordon/env/<domain>/<KEY>`
59+- Existing `.env` files are auto-migrated on startup and renamed to `.env.migrated`
60+
5761 ### SOPS
5862
5963 Uses Mozilla SOPS for encrypted file-based secrets:
@@ -85,6 +89,10 @@ DB_PASSWORD=${sops:secrets.yaml:database.password}
8589 - YAML/JSON file encryption
8690 - Git-friendly (encrypted files in repo)
8791
92+**Route secrets storage:**
93+- Domain secrets stay in `.env` files
94+- Use `${sops:...}` syntax to resolve encrypted values
95+
8896 ### Unsafe (Development Only)
8997
9098 Stores secrets as plain text files:
@@ -54,6 +54,10 @@ token_secret = "gordon/auth/token_secret"
54 - Standard Unix tooling54 - Standard Unix tooling
55 - Works with team GPG keys55 - Works with team GPG keys
56 56
57+**Route secrets storage:**
58+- `gordon secrets set` stores per-domain secrets in pass under `gordon/env/<domain>/<KEY>`
59+- Existing `.env` files are auto-migrated on startup and renamed to `.env.migrated`
60+
57 ### SOPS61 ### SOPS
58 62
59 Uses Mozilla SOPS for encrypted file-based secrets:63 Uses Mozilla SOPS for encrypted file-based secrets:
@@ -85,6 +89,10 @@ DB_PASSWORD=${sops:secrets.yaml:database.password}
85 - YAML/JSON file encryption89 - YAML/JSON file encryption
86 - Git-friendly (encrypted files in repo)90 - Git-friendly (encrypted files in repo)
87 91
92+**Route secrets storage:**
93+- Domain secrets stay in `.env` files
94+- Use `${sops:...}` syntax to resolve encrypted values
95+
88 ### Unsafe (Development Only)96 ### Unsafe (Development Only)
89 97
90 Stores secrets as plain text files:98 Stores secrets as plain text files:
A internal/adapters/out/domainsecrets/pass_store.go +459 -0
@@ -0,0 +1,459 @@
1+// Package domainsecrets implements the DomainSecretStore adapter using pass.
2+package domainsecrets
3+
4+import (
5+ "context"
6+ "fmt"
7+ "os/exec"
8+ "regexp"
9+ "sort"
10+ "strings"
11+ "time"
12+
13+ "github.com/bnema/zerowrap"
14+
15+ "github.com/bnema/gordon/internal/adapters/out/secrets"
16+ "github.com/bnema/gordon/internal/boundaries/out"
17+ "github.com/bnema/gordon/internal/domain"
18+)
19+
20+// ansiRegex matches ANSI escape sequences for stripping from pass output.
21+var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`)
22+
23+const (
24+ passDomainSecretsPath = "gordon/env" //nolint:gosec // Not a credential, this is a pass store path.
25+ passAttachmentPath = "gordon/env/attachments" //nolint:gosec // Not a credential, this is a pass store path.
26+)
27+
28+// PassStore implements the DomainSecretStore interface using the pass password manager.
29+type PassStore struct {
30+ timeout time.Duration
31+ log zerowrap.Logger
32+}
33+
34+// NewPassStore creates a new pass-based domain secret store.
35+func NewPassStore(log zerowrap.Logger) (*PassStore, error) {
36+ if err := exec.Command("pass", "version").Run(); err != nil {
37+ return nil, fmt.Errorf("pass is not available: %w", err)
38+ }
39+
40+ log.Debug().
41+ Str(zerowrap.FieldLayer, "adapter").
42+ Str(zerowrap.FieldAdapter, "domainsecrets").
43+ Str("provider", "pass").
44+ Msg("domain secret store initialized")
45+
46+ return &PassStore{
47+ timeout: 10 * time.Second,
48+ log: log,
49+ }, nil
50+}
51+
52+// ListKeys returns the list of secret keys for a domain (not values).
53+func (s *PassStore) ListKeys(domainName string) ([]string, error) {
54+ manifestPath, err := s.manifestPath(domainName)
55+ if err != nil {
56+ return nil, err
57+ }
58+
59+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
60+ defer cancel()
61+
62+ content, exists, err := s.passShow(ctx, manifestPath)
63+ if err != nil {
64+ return nil, err
65+ }
66+ if !exists {
67+ return []string{}, nil
68+ }
69+
70+ keys := []string{}
71+ for _, line := range strings.Split(content, "\n") {
72+ key := strings.TrimSpace(line)
73+ if key == "" {
74+ continue
75+ }
76+ keys = append(keys, key)
77+ }
78+
79+ return keys, nil
80+}
81+
82+// GetAll returns all secrets for a domain as a key-value map.
83+func (s *PassStore) GetAll(domainName string) (map[string]string, error) {
84+ keys, err := s.ListKeys(domainName)
85+ if err != nil {
86+ return nil, err
87+ }
88+
89+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
90+ defer cancel()
91+
92+ secretsMap := make(map[string]string)
93+ for _, key := range keys {
94+ keyPath, err := s.keyPath(domainName, key)
95+ if err != nil {
96+ return nil, err
97+ }
98+
99+ value, exists, err := s.passShow(ctx, keyPath)
100+ if err != nil {
101+ return nil, err
102+ }
103+ if !exists {
104+ s.log.Warn().
105+ Str(zerowrap.FieldLayer, "adapter").
106+ Str(zerowrap.FieldAdapter, "domainsecrets").
107+ Str("domain", domainName).
108+ Str("key", key).
109+ Msg("secret listed in manifest but missing in pass")
110+ continue
111+ }
112+ secretsMap[key] = value
113+ }
114+
115+ return secretsMap, nil
116+}
117+
118+// Set sets or updates multiple secrets for a domain.
119+func (s *PassStore) Set(domainName string, secretsMap map[string]string) error {
120+ if _, err := s.domainPath(domainName); err != nil {
121+ return err
122+ }
123+
124+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
125+ defer cancel()
126+
127+ for key, value := range secretsMap {
128+ keyPath, err := s.keyPath(domainName, key)
129+ if err != nil {
130+ return err
131+ }
132+ if err := s.passInsert(ctx, keyPath, value); err != nil {
133+ return fmt.Errorf("failed to store secret %s: %w", key, err)
134+ }
135+ }
136+
137+ existingKeys, err := s.ListKeys(domainName)
138+ if err != nil {
139+ return err
140+ }
141+
142+ keySet := make(map[string]struct{}, len(existingKeys)+len(secretsMap))
143+ for _, key := range existingKeys {
144+ keySet[key] = struct{}{}
145+ }
146+ for key := range secretsMap {
147+ keySet[key] = struct{}{}
148+ }
149+
150+ keys := make([]string, 0, len(keySet))
151+ for key := range keySet {
152+ keys = append(keys, key)
153+ }
154+ sort.Strings(keys)
155+
156+ manifestPath, err := s.manifestPath(domainName)
157+ if err != nil {
158+ return err
159+ }
160+
161+ if err := s.passInsert(ctx, manifestPath, strings.Join(keys, "\n")); err != nil {
162+ return fmt.Errorf("failed to update manifest: %w", err)
163+ }
164+
165+ return nil
166+}
167+
168+// Delete removes a specific secret key from a domain.
169+func (s *PassStore) Delete(domainName, key string) error {
170+ if _, err := s.domainPath(domainName); err != nil {
171+ return err
172+ }
173+
174+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
175+ defer cancel()
176+
177+ keyPath, err := s.keyPath(domainName, key)
178+ if err != nil {
179+ return err
180+ }
181+ if err := s.passRemove(ctx, keyPath); err != nil {
182+ return err
183+ }
184+
185+ keys, err := s.ListKeys(domainName)
186+ if err != nil {
187+ return err
188+ }
189+
190+ updated := make([]string, 0, len(keys))
191+ for _, existingKey := range keys {
192+ if existingKey == key {
193+ continue
194+ }
195+ updated = append(updated, existingKey)
196+ }
197+ sort.Strings(updated)
198+
199+ manifestPath, err := s.manifestPath(domainName)
200+ if err != nil {
201+ return err
202+ }
203+
204+ if err := s.passInsert(ctx, manifestPath, strings.Join(updated, "\n")); err != nil {
205+ return fmt.Errorf("failed to update manifest: %w", err)
206+ }
207+
208+ return nil
209+}
210+
211+// ListAttachmentKeys finds attachment secrets for a domain from pass.
212+func (s *PassStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecrets, error) {
213+ if _, err := s.domainPath(domainName); err != nil {
214+ return nil, err
215+ }
216+
217+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
218+ defer cancel()
219+
220+ containers, err := s.listTopLevelEntries(ctx, passAttachmentPath)
221+ if err != nil {
222+ return nil, err
223+ }
224+
225+ sanitizedDomain := sanitizeDomainForContainer(domainName)
226+ prefix := "gordon-" + sanitizedDomain + "-"
227+
228+ var results []out.AttachmentSecrets
229+ for _, containerName := range containers {
230+ if !strings.HasPrefix(containerName, prefix) {
231+ continue
232+ }
233+
234+ manifestPath := fmt.Sprintf("%s/%s/.keys", passAttachmentPath, containerName)
235+ if err := secrets.ValidatePath(manifestPath); err != nil {
236+ return nil, err
237+ }
238+
239+ content, exists, err := s.passShow(ctx, manifestPath)
240+ if err != nil {
241+ return nil, err
242+ }
243+ if !exists {
244+ continue
245+ }
246+
247+ keys := []string{}
248+ for _, line := range strings.Split(content, "\n") {
249+ key := strings.TrimSpace(line)
250+ if key == "" {
251+ continue
252+ }
253+ keys = append(keys, key)
254+ }
255+
256+ if len(keys) > 0 {
257+ results = append(results, out.AttachmentSecrets{
258+ Service: containerName,
259+ Keys: keys,
260+ })
261+ }
262+ }
263+
264+ return results, nil
265+}
266+
267+func (s *PassStore) domainPath(domainName string) (string, error) {
268+ safeDomain, err := s.sanitizeDomain(domainName)
269+ if err != nil {
270+ return "", err
271+ }
272+ path := fmt.Sprintf("%s/%s", passDomainSecretsPath, safeDomain)
273+ if err := secrets.ValidatePath(path); err != nil {
274+ return "", err
275+ }
276+ return path, nil
277+}
278+
279+func (s *PassStore) keyPath(domainName, key string) (string, error) {
280+ domainPath, err := s.domainPath(domainName)
281+ if err != nil {
282+ return "", err
283+ }
284+ path := fmt.Sprintf("%s/%s", domainPath, key)
285+ if err := secrets.ValidatePath(path); err != nil {
286+ return "", err
287+ }
288+ return path, nil
289+}
290+
291+func (s *PassStore) manifestPath(domainName string) (string, error) {
292+ domainPath, err := s.domainPath(domainName)
293+ if err != nil {
294+ return "", err
295+ }
296+ path := fmt.Sprintf("%s/.keys", domainPath)
297+ if err := secrets.ValidatePath(path); err != nil {
298+ return "", err
299+ }
300+ return path, nil
301+}
302+
303+func (s *PassStore) sanitizeDomain(domainName string) (string, error) {
304+ safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)
305+ if err != nil {
306+ s.log.Warn().
307+ Str(zerowrap.FieldLayer, "adapter").
308+ Str(zerowrap.FieldAdapter, "domainsecrets").
309+ Str("domain", domainName).
310+ Err(err).
311+ Msg("rejected invalid domain")
312+ return "", domain.ErrPathTraversal
313+ }
314+ return safeDomain, nil
315+}
316+
317+// ManifestExists checks if the manifest exists for a domain.
318+func (s *PassStore) ManifestExists(domainName string) (bool, error) {
319+ manifestPath, err := s.manifestPath(domainName)
320+ if err != nil {
321+ return false, err
322+ }
323+
324+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
325+ defer cancel()
326+
327+ _, exists, err := s.passShow(ctx, manifestPath)
328+ if err != nil {
329+ return false, err
330+ }
331+
332+ return exists, nil
333+}
334+
335+func (s *PassStore) passInsert(ctx context.Context, path, value string) error {
336+ cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path)
337+ cmd.Stdin = strings.NewReader(value)
338+ output, err := cmd.CombinedOutput()
339+ if err != nil {
340+ return fmt.Errorf("pass insert failed: %s: %w", strings.TrimSpace(string(output)), err)
341+ }
342+ return nil
343+}
344+
345+func (s *PassStore) passRemove(ctx context.Context, path string) error {
346+ cmd := exec.CommandContext(ctx, "pass", "rm", "-f", path)
347+ output, err := cmd.CombinedOutput()
348+ if err != nil {
349+ if passEntryMissing(string(output)) {
350+ return nil
351+ }
352+ return fmt.Errorf("pass rm failed: %s: %w", strings.TrimSpace(string(output)), err)
353+ }
354+ return nil
355+}
356+
357+func (s *PassStore) passShow(ctx context.Context, path string) (string, bool, error) {
358+ cmd := exec.CommandContext(ctx, "pass", "show", path)
359+ output, err := cmd.CombinedOutput()
360+ if err != nil {
361+ if passEntryMissing(string(output)) {
362+ return "", false, nil
363+ }
364+ return "", false, fmt.Errorf("pass show failed: %s: %w", strings.TrimSpace(string(output)), err)
365+ }
366+ return strings.TrimSpace(string(output)), true, nil
367+}
368+
369+func (s *PassStore) listTopLevelEntries(ctx context.Context, basePath string) ([]string, error) {
370+ if err := secrets.ValidatePath(basePath); err != nil {
371+ return nil, err
372+ }
373+
374+ cmd := exec.CommandContext(ctx, "pass", "ls", basePath)
375+ output, err := cmd.CombinedOutput()
376+ if err != nil {
377+ if passEntryMissing(string(output)) {
378+ return []string{}, nil
379+ }
380+ return nil, fmt.Errorf("pass ls failed: %s: %w", strings.TrimSpace(string(output)), err)
381+ }
382+
383+ entries := []string{}
384+ for _, entry := range parsePassListOutput(basePath, string(output)) {
385+ if entry.depth == 1 {
386+ entries = append(entries, entry.name)
387+ }
388+ }
389+
390+ return entries, nil
391+}
392+
393+type passListEntry struct {
394+ name string
395+ depth int
396+}
397+
398+func parsePassListOutput(basePath, output string) []passListEntry {
399+ lines := strings.Split(output, "\n")
400+ entries := []passListEntry{}
401+
402+ for _, line := range lines {
403+ line = ansiRegex.ReplaceAllString(line, "")
404+ if strings.TrimSpace(line) == "" {
405+ continue
406+ }
407+
408+ depth := 0
409+ for {
410+ switch {
411+ case strings.HasPrefix(line, "│ "):
412+ line = strings.TrimPrefix(line, "│ ")
413+ depth++
414+ case strings.HasPrefix(line, "| "):
415+ line = strings.TrimPrefix(line, "| ")
416+ depth++
417+ case strings.HasPrefix(line, " "):
418+ line = strings.TrimPrefix(line, " ")
419+ depth++
420+ default:
421+ goto prefixDone
422+ }
423+ }
424+ prefixDone:
425+
426+ switch {
427+ case strings.HasPrefix(line, "├── "):
428+ line = strings.TrimPrefix(line, "├── ")
429+ depth++
430+ case strings.HasPrefix(line, "└── "):
431+ line = strings.TrimPrefix(line, "└── ")
432+ depth++
433+ case strings.HasPrefix(line, "|-- "):
434+ line = strings.TrimPrefix(line, "|-- ")
435+ depth++
436+ case strings.HasPrefix(line, "`-- "):
437+ line = strings.TrimPrefix(line, "`-- ")
438+ depth++
439+ }
440+
441+ name := strings.TrimSpace(line)
442+ if name == "" || name == basePath {
443+ continue
444+ }
445+ if depth == 0 {
446+ continue
447+ }
448+
449+ entries = append(entries, passListEntry{name: name, depth: depth})
450+ }
451+
452+ return entries
453+}
454+
455+func passEntryMissing(output string) bool {
456+ lower := strings.ToLower(output)
457+ return strings.Contains(lower, "not in the password store") ||
458+ strings.Contains(lower, "password store is empty")
459+}
@@ -0,0 +1,459 @@
1+// Package domainsecrets implements the DomainSecretStore adapter using pass.
2+package domainsecrets
3+
4+import (
5+ "context"
6+ "fmt"
7+ "os/exec"
8+ "regexp"
9+ "sort"
10+ "strings"
11+ "time"
12+
13+ "github.com/bnema/zerowrap"
14+
15+ "github.com/bnema/gordon/internal/adapters/out/secrets"
16+ "github.com/bnema/gordon/internal/boundaries/out"
17+ "github.com/bnema/gordon/internal/domain"
18+)
19+
20+// ansiRegex matches ANSI escape sequences for stripping from pass output.
21+var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`)
22+
23+const (
24+ passDomainSecretsPath = "gordon/env" //nolint:gosec // Not a credential, this is a pass store path.
25+ passAttachmentPath = "gordon/env/attachments" //nolint:gosec // Not a credential, this is a pass store path.
26+)
27+
28+// PassStore implements the DomainSecretStore interface using the pass password manager.
29+type PassStore struct {
30+ timeout time.Duration
31+ log zerowrap.Logger
32+}
33+
34+// NewPassStore creates a new pass-based domain secret store.
35+func NewPassStore(log zerowrap.Logger) (*PassStore, error) {
36+ if err := exec.Command("pass", "version").Run(); err != nil {
37+ return nil, fmt.Errorf("pass is not available: %w", err)
38+ }
39+
40+ log.Debug().
41+ Str(zerowrap.FieldLayer, "adapter").
42+ Str(zerowrap.FieldAdapter, "domainsecrets").
43+ Str("provider", "pass").
44+ Msg("domain secret store initialized")
45+
46+ return &PassStore{
47+ timeout: 10 * time.Second,
48+ log: log,
49+ }, nil
50+}
51+
52+// ListKeys returns the list of secret keys for a domain (not values).
53+func (s *PassStore) ListKeys(domainName string) ([]string, error) {
54+ manifestPath, err := s.manifestPath(domainName)
55+ if err != nil {
56+ return nil, err
57+ }
58+
59+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
60+ defer cancel()
61+
62+ content, exists, err := s.passShow(ctx, manifestPath)
63+ if err != nil {
64+ return nil, err
65+ }
66+ if !exists {
67+ return []string{}, nil
68+ }
69+
70+ keys := []string{}
71+ for _, line := range strings.Split(content, "\n") {
72+ key := strings.TrimSpace(line)
73+ if key == "" {
74+ continue
75+ }
76+ keys = append(keys, key)
77+ }
78+
79+ return keys, nil
80+}
81+
82+// GetAll returns all secrets for a domain as a key-value map.
83+func (s *PassStore) GetAll(domainName string) (map[string]string, error) {
84+ keys, err := s.ListKeys(domainName)
85+ if err != nil {
86+ return nil, err
87+ }
88+
89+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
90+ defer cancel()
91+
92+ secretsMap := make(map[string]string)
93+ for _, key := range keys {
94+ keyPath, err := s.keyPath(domainName, key)
95+ if err != nil {
96+ return nil, err
97+ }
98+
99+ value, exists, err := s.passShow(ctx, keyPath)
100+ if err != nil {
101+ return nil, err
102+ }
103+ if !exists {
104+ s.log.Warn().
105+ Str(zerowrap.FieldLayer, "adapter").
106+ Str(zerowrap.FieldAdapter, "domainsecrets").
107+ Str("domain", domainName).
108+ Str("key", key).
109+ Msg("secret listed in manifest but missing in pass")
110+ continue
111+ }
112+ secretsMap[key] = value
113+ }
114+
115+ return secretsMap, nil
116+}
117+
118+// Set sets or updates multiple secrets for a domain.
119+func (s *PassStore) Set(domainName string, secretsMap map[string]string) error {
120+ if _, err := s.domainPath(domainName); err != nil {
121+ return err
122+ }
123+
124+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
125+ defer cancel()
126+
127+ for key, value := range secretsMap {
128+ keyPath, err := s.keyPath(domainName, key)
129+ if err != nil {
130+ return err
131+ }
132+ if err := s.passInsert(ctx, keyPath, value); err != nil {
133+ return fmt.Errorf("failed to store secret %s: %w", key, err)
134+ }
135+ }
136+
137+ existingKeys, err := s.ListKeys(domainName)
138+ if err != nil {
139+ return err
140+ }
141+
142+ keySet := make(map[string]struct{}, len(existingKeys)+len(secretsMap))
143+ for _, key := range existingKeys {
144+ keySet[key] = struct{}{}
145+ }
146+ for key := range secretsMap {
147+ keySet[key] = struct{}{}
148+ }
149+
150+ keys := make([]string, 0, len(keySet))
151+ for key := range keySet {
152+ keys = append(keys, key)
153+ }
154+ sort.Strings(keys)
155+
156+ manifestPath, err := s.manifestPath(domainName)
157+ if err != nil {
158+ return err
159+ }
160+
161+ if err := s.passInsert(ctx, manifestPath, strings.Join(keys, "\n")); err != nil {
162+ return fmt.Errorf("failed to update manifest: %w", err)
163+ }
164+
165+ return nil
166+}
167+
168+// Delete removes a specific secret key from a domain.
169+func (s *PassStore) Delete(domainName, key string) error {
170+ if _, err := s.domainPath(domainName); err != nil {
171+ return err
172+ }
173+
174+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
175+ defer cancel()
176+
177+ keyPath, err := s.keyPath(domainName, key)
178+ if err != nil {
179+ return err
180+ }
181+ if err := s.passRemove(ctx, keyPath); err != nil {
182+ return err
183+ }
184+
185+ keys, err := s.ListKeys(domainName)
186+ if err != nil {
187+ return err
188+ }
189+
190+ updated := make([]string, 0, len(keys))
191+ for _, existingKey := range keys {
192+ if existingKey == key {
193+ continue
194+ }
195+ updated = append(updated, existingKey)
196+ }
197+ sort.Strings(updated)
198+
199+ manifestPath, err := s.manifestPath(domainName)
200+ if err != nil {
201+ return err
202+ }
203+
204+ if err := s.passInsert(ctx, manifestPath, strings.Join(updated, "\n")); err != nil {
205+ return fmt.Errorf("failed to update manifest: %w", err)
206+ }
207+
208+ return nil
209+}
210+
211+// ListAttachmentKeys finds attachment secrets for a domain from pass.
212+func (s *PassStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecrets, error) {
213+ if _, err := s.domainPath(domainName); err != nil {
214+ return nil, err
215+ }
216+
217+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
218+ defer cancel()
219+
220+ containers, err := s.listTopLevelEntries(ctx, passAttachmentPath)
221+ if err != nil {
222+ return nil, err
223+ }
224+
225+ sanitizedDomain := sanitizeDomainForContainer(domainName)
226+ prefix := "gordon-" + sanitizedDomain + "-"
227+
228+ var results []out.AttachmentSecrets
229+ for _, containerName := range containers {
230+ if !strings.HasPrefix(containerName, prefix) {
231+ continue
232+ }
233+
234+ manifestPath := fmt.Sprintf("%s/%s/.keys", passAttachmentPath, containerName)
235+ if err := secrets.ValidatePath(manifestPath); err != nil {
236+ return nil, err
237+ }
238+
239+ content, exists, err := s.passShow(ctx, manifestPath)
240+ if err != nil {
241+ return nil, err
242+ }
243+ if !exists {
244+ continue
245+ }
246+
247+ keys := []string{}
248+ for _, line := range strings.Split(content, "\n") {
249+ key := strings.TrimSpace(line)
250+ if key == "" {
251+ continue
252+ }
253+ keys = append(keys, key)
254+ }
255+
256+ if len(keys) > 0 {
257+ results = append(results, out.AttachmentSecrets{
258+ Service: containerName,
259+ Keys: keys,
260+ })
261+ }
262+ }
263+
264+ return results, nil
265+}
266+
267+func (s *PassStore) domainPath(domainName string) (string, error) {
268+ safeDomain, err := s.sanitizeDomain(domainName)
269+ if err != nil {
270+ return "", err
271+ }
272+ path := fmt.Sprintf("%s/%s", passDomainSecretsPath, safeDomain)
273+ if err := secrets.ValidatePath(path); err != nil {
274+ return "", err
275+ }
276+ return path, nil
277+}
278+
279+func (s *PassStore) keyPath(domainName, key string) (string, error) {
280+ domainPath, err := s.domainPath(domainName)
281+ if err != nil {
282+ return "", err
283+ }
284+ path := fmt.Sprintf("%s/%s", domainPath, key)
285+ if err := secrets.ValidatePath(path); err != nil {
286+ return "", err
287+ }
288+ return path, nil
289+}
290+
291+func (s *PassStore) manifestPath(domainName string) (string, error) {
292+ domainPath, err := s.domainPath(domainName)
293+ if err != nil {
294+ return "", err
295+ }
296+ path := fmt.Sprintf("%s/.keys", domainPath)
297+ if err := secrets.ValidatePath(path); err != nil {
298+ return "", err
299+ }
300+ return path, nil
301+}
302+
303+func (s *PassStore) sanitizeDomain(domainName string) (string, error) {
304+ safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)
305+ if err != nil {
306+ s.log.Warn().
307+ Str(zerowrap.FieldLayer, "adapter").
308+ Str(zerowrap.FieldAdapter, "domainsecrets").
309+ Str("domain", domainName).
310+ Err(err).
311+ Msg("rejected invalid domain")
312+ return "", domain.ErrPathTraversal
313+ }
314+ return safeDomain, nil
315+}
316+
317+// ManifestExists checks if the manifest exists for a domain.
318+func (s *PassStore) ManifestExists(domainName string) (bool, error) {
319+ manifestPath, err := s.manifestPath(domainName)
320+ if err != nil {
321+ return false, err
322+ }
323+
324+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
325+ defer cancel()
326+
327+ _, exists, err := s.passShow(ctx, manifestPath)
328+ if err != nil {
329+ return false, err
330+ }
331+
332+ return exists, nil
333+}
334+
335+func (s *PassStore) passInsert(ctx context.Context, path, value string) error {
336+ cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path)
337+ cmd.Stdin = strings.NewReader(value)
338+ output, err := cmd.CombinedOutput()
339+ if err != nil {
340+ return fmt.Errorf("pass insert failed: %s: %w", strings.TrimSpace(string(output)), err)
341+ }
342+ return nil
343+}
344+
345+func (s *PassStore) passRemove(ctx context.Context, path string) error {
346+ cmd := exec.CommandContext(ctx, "pass", "rm", "-f", path)
347+ output, err := cmd.CombinedOutput()
348+ if err != nil {
349+ if passEntryMissing(string(output)) {
350+ return nil
351+ }
352+ return fmt.Errorf("pass rm failed: %s: %w", strings.TrimSpace(string(output)), err)
353+ }
354+ return nil
355+}
356+
357+func (s *PassStore) passShow(ctx context.Context, path string) (string, bool, error) {
358+ cmd := exec.CommandContext(ctx, "pass", "show", path)
359+ output, err := cmd.CombinedOutput()
360+ if err != nil {
361+ if passEntryMissing(string(output)) {
362+ return "", false, nil
363+ }
364+ return "", false, fmt.Errorf("pass show failed: %s: %w", strings.TrimSpace(string(output)), err)
365+ }
366+ return strings.TrimSpace(string(output)), true, nil
367+}
368+
369+func (s *PassStore) listTopLevelEntries(ctx context.Context, basePath string) ([]string, error) {
370+ if err := secrets.ValidatePath(basePath); err != nil {
371+ return nil, err
372+ }
373+
374+ cmd := exec.CommandContext(ctx, "pass", "ls", basePath)
375+ output, err := cmd.CombinedOutput()
376+ if err != nil {
377+ if passEntryMissing(string(output)) {
378+ return []string{}, nil
379+ }
380+ return nil, fmt.Errorf("pass ls failed: %s: %w", strings.TrimSpace(string(output)), err)
381+ }
382+
383+ entries := []string{}
384+ for _, entry := range parsePassListOutput(basePath, string(output)) {
385+ if entry.depth == 1 {
386+ entries = append(entries, entry.name)
387+ }
388+ }
389+
390+ return entries, nil
391+}
392+
393+type passListEntry struct {
394+ name string
395+ depth int
396+}
397+
398+func parsePassListOutput(basePath, output string) []passListEntry {
399+ lines := strings.Split(output, "\n")
400+ entries := []passListEntry{}
401+
402+ for _, line := range lines {
403+ line = ansiRegex.ReplaceAllString(line, "")
404+ if strings.TrimSpace(line) == "" {
405+ continue
406+ }
407+
408+ depth := 0
409+ for {
410+ switch {
411+ case strings.HasPrefix(line, "│ "):
412+ line = strings.TrimPrefix(line, "│ ")
413+ depth++
414+ case strings.HasPrefix(line, "| "):
415+ line = strings.TrimPrefix(line, "| ")
416+ depth++
417+ case strings.HasPrefix(line, " "):
418+ line = strings.TrimPrefix(line, " ")
419+ depth++
420+ default:
421+ goto prefixDone
422+ }
423+ }
424+ prefixDone:
425+
426+ switch {
427+ case strings.HasPrefix(line, "├── "):
428+ line = strings.TrimPrefix(line, "├── ")
429+ depth++
430+ case strings.HasPrefix(line, "└── "):
431+ line = strings.TrimPrefix(line, "└── ")
432+ depth++
433+ case strings.HasPrefix(line, "|-- "):
434+ line = strings.TrimPrefix(line, "|-- ")
435+ depth++
436+ case strings.HasPrefix(line, "`-- "):
437+ line = strings.TrimPrefix(line, "`-- ")
438+ depth++
439+ }
440+
441+ name := strings.TrimSpace(line)
442+ if name == "" || name == basePath {
443+ continue
444+ }
445+ if depth == 0 {
446+ continue
447+ }
448+
449+ entries = append(entries, passListEntry{name: name, depth: depth})
450+ }
451+
452+ return entries
453+}
454+
455+func passEntryMissing(output string) bool {
456+ lower := strings.ToLower(output)
457+ return strings.Contains(lower, "not in the password store") ||
458+ strings.Contains(lower, "password store is empty")
459+}
A internal/adapters/out/domainsecrets/pass_store_test.go +74 -0
@@ -0,0 +1,74 @@
1+package domainsecrets
2+
3+import (
4+ "fmt"
5+ "os/exec"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/gordon/internal/domain"
14+)
15+
16+func requirePass(t *testing.T) {
17+ if err := exec.Command("pass", "version").Run(); err != nil {
18+ t.Skip("pass not available")
19+ }
20+ if err := exec.Command("pass", "ls").Run(); err != nil {
21+ t.Skip("pass store not initialized")
22+ }
23+}
24+
25+func cleanupPassDomain(t *testing.T, domainName string, keys []string) {
26+ safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)
27+ if err != nil {
28+ return
29+ }
30+
31+ for _, key := range keys {
32+ path := fmt.Sprintf("%s/%s/%s", passDomainSecretsPath, safeDomain, key)
33+ _ = exec.Command("pass", "rm", "-f", path).Run()
34+ }
35+
36+ manifestPath := fmt.Sprintf("%s/%s/.keys", passDomainSecretsPath, safeDomain)
37+ _ = exec.Command("pass", "rm", "-f", manifestPath).Run()
38+}
39+
40+func TestPassStore_SetGetDelete(t *testing.T) {
41+ requirePass(t)
42+
43+ store, err := NewPassStore(testLogger())
44+ require.NoError(t, err)
45+
46+ domainName := fmt.Sprintf("test.%d.example.com", time.Now().UnixNano())
47+ keys := []string{"API_KEY", "DB_PASSWORD"}
48+ defer cleanupPassDomain(t, domainName, keys)
49+
50+ secretsMap := map[string]string{
51+ "API_KEY": "alpha",
52+ "DB_PASSWORD": "bravo",
53+ }
54+
55+ err = store.Set(domainName, secretsMap)
56+ require.NoError(t, err)
57+
58+ keysList, err := store.ListKeys(domainName)
59+ require.NoError(t, err)
60+ assert.Len(t, keysList, 2)
61+ assert.ElementsMatch(t, keys, keysList)
62+
63+ values, err := store.GetAll(domainName)
64+ require.NoError(t, err)
65+ assert.Equal(t, "alpha", strings.TrimSpace(values["API_KEY"]))
66+ assert.Equal(t, "bravo", strings.TrimSpace(values["DB_PASSWORD"]))
67+
68+ err = store.Delete(domainName, "DB_PASSWORD")
69+ require.NoError(t, err)
70+
71+ keysList, err = store.ListKeys(domainName)
72+ require.NoError(t, err)
73+ assert.ElementsMatch(t, []string{"API_KEY"}, keysList)
74+}
@@ -0,0 +1,74 @@
1+package domainsecrets
2+
3+import (
4+ "fmt"
5+ "os/exec"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/gordon/internal/domain"
14+)
15+
16+func requirePass(t *testing.T) {
17+ if err := exec.Command("pass", "version").Run(); err != nil {
18+ t.Skip("pass not available")
19+ }
20+ if err := exec.Command("pass", "ls").Run(); err != nil {
21+ t.Skip("pass store not initialized")
22+ }
23+}
24+
25+func cleanupPassDomain(t *testing.T, domainName string, keys []string) {
26+ safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)
27+ if err != nil {
28+ return
29+ }
30+
31+ for _, key := range keys {
32+ path := fmt.Sprintf("%s/%s/%s", passDomainSecretsPath, safeDomain, key)
33+ _ = exec.Command("pass", "rm", "-f", path).Run()
34+ }
35+
36+ manifestPath := fmt.Sprintf("%s/%s/.keys", passDomainSecretsPath, safeDomain)
37+ _ = exec.Command("pass", "rm", "-f", manifestPath).Run()
38+}
39+
40+func TestPassStore_SetGetDelete(t *testing.T) {
41+ requirePass(t)
42+
43+ store, err := NewPassStore(testLogger())
44+ require.NoError(t, err)
45+
46+ domainName := fmt.Sprintf("test.%d.example.com", time.Now().UnixNano())
47+ keys := []string{"API_KEY", "DB_PASSWORD"}
48+ defer cleanupPassDomain(t, domainName, keys)
49+
50+ secretsMap := map[string]string{
51+ "API_KEY": "alpha",
52+ "DB_PASSWORD": "bravo",
53+ }
54+
55+ err = store.Set(domainName, secretsMap)
56+ require.NoError(t, err)
57+
58+ keysList, err := store.ListKeys(domainName)
59+ require.NoError(t, err)
60+ assert.Len(t, keysList, 2)
61+ assert.ElementsMatch(t, keys, keysList)
62+
63+ values, err := store.GetAll(domainName)
64+ require.NoError(t, err)
65+ assert.Equal(t, "alpha", strings.TrimSpace(values["API_KEY"]))
66+ assert.Equal(t, "bravo", strings.TrimSpace(values["DB_PASSWORD"]))
67+
68+ err = store.Delete(domainName, "DB_PASSWORD")
69+ require.NoError(t, err)
70+
71+ keysList, err = store.ListKeys(domainName)
72+ require.NoError(t, err)
73+ assert.ElementsMatch(t, []string{"API_KEY"}, keysList)
74+}
M internal/adapters/out/domainsecrets/store.go +9 -44
@@ -6,7 +6,6 @@ import (
66 "fmt"
77 "os"
88 "path/filepath"
9- "regexp"
109 "strings"
1110
1211 "github.com/bnema/zerowrap"
@@ -86,33 +85,17 @@ func (s *FileStore) GetAll(domainName string) (map[string]string, error) {
8685 return nil, err
8786 }
8887
89- file, err := os.Open(envFile)
88+ data, err := os.ReadFile(envFile)
9089 if err != nil {
9190 if os.IsNotExist(err) {
9291 return map[string]string{}, nil
9392 }
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- }
93+ return nil, fmt.Errorf("failed to read env file: %w", err)
11294 }
11395
114- if err := scanner.Err(); err != nil {
115- return nil, fmt.Errorf("failed to read env file: %w", err)
96+ secrets, err := domain.ParseEnvData(data)
97+ if err != nil {
98+ return nil, fmt.Errorf("failed to parse env file: %w", err)
11699 }
117100
118101 return secrets, nil
@@ -221,40 +204,22 @@ func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]str
221204 return nil
222205 }
223206
224-// domainRegex validates domain names to prevent path injection.
225-// Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths).
226-var domainRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`)
227-
228207 // getEnvFilePath converts a domain to its env file path.
229208 // This must match the naming convention in envloader.FileLoader.getEnvFilePath.
230209 //
231210 // SECURITY: Validates domain and ensures the resulting path stays within envDir.
232211 func (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) {
212+ safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)
213+ if err != nil {
245214 s.log.Warn().
246215 Str(zerowrap.FieldLayer, "adapter").
247216 Str(zerowrap.FieldAdapter, "domainsecrets").
248217 Str("domain", domainName).
249- Msg("rejected invalid domain format")
218+ Err(err).
219+ Msg("rejected invalid domain")
250220 return ""
251221 }
252222
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-
258223 fullPath := filepath.Join(s.envDir, safeDomain+".env")
259224
260225 // SECURITY: Final validation - ensure path stays within envDir
@@ -6,7 +6,6 @@ import (
6 "fmt"6 "fmt"
7 "os"7 "os"
8 "path/filepath"8 "path/filepath"
9- "regexp"
10 "strings"9 "strings"
11 10
12 "github.com/bnema/zerowrap"11 "github.com/bnema/zerowrap"
@@ -86,33 +85,17 @@ func (s *FileStore) GetAll(domainName string) (map[string]string, error) {
86 return nil, err85 return nil, err
87 }86 }
88 87
89- file, err := os.Open(envFile)88+ data, err := os.ReadFile(envFile)
90 if err != nil {89 if err != nil {
91 if os.IsNotExist(err) {90 if os.IsNotExist(err) {
92 return map[string]string{}, nil91 return map[string]string{}, nil
93 }92 }
94- return nil, fmt.Errorf("failed to open env file: %w", err)93+ return nil, fmt.Errorf("failed to read 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 }94 }
113 95
114- if err := scanner.Err(); err != nil {96+ secrets, err := domain.ParseEnvData(data)
115- return nil, fmt.Errorf("failed to read env file: %w", err)97+ if err != nil {
98+ return nil, fmt.Errorf("failed to parse env file: %w", err)
116 }99 }
117 100
118 return secrets, nil101 return secrets, nil
@@ -221,40 +204,22 @@ func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]str
221 return nil204 return nil
222 }205 }
223 206
224-// domainRegex validates domain names to prevent path injection.
225-// Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths).
226-var 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.207 // getEnvFilePath converts a domain to its env file path.
229 // This must match the naming convention in envloader.FileLoader.getEnvFilePath.208 // This must match the naming convention in envloader.FileLoader.getEnvFilePath.
230 //209 //
231 // SECURITY: Validates domain and ensures the resulting path stays within envDir.210 // SECURITY: Validates domain and ensures the resulting path stays within envDir.
232 func (s *FileStore) getEnvFilePath(domainName string) string {211 func (s *FileStore) getEnvFilePath(domainName string) string {
233- // SECURITY: Reject domains that look like path traversal attempts212+ safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)
234- if strings.Contains(domainName, "..") {213+ if err != nil {
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().214 s.log.Warn().
246 Str(zerowrap.FieldLayer, "adapter").215 Str(zerowrap.FieldLayer, "adapter").
247 Str(zerowrap.FieldAdapter, "domainsecrets").216 Str(zerowrap.FieldAdapter, "domainsecrets").
248 Str("domain", domainName).217 Str("domain", domainName).
249- Msg("rejected invalid domain format")218+ Err(err).
219+ Msg("rejected invalid domain")
250 return ""220 return ""
251 }221 }
252 222
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")223 fullPath := filepath.Join(s.envDir, safeDomain+".env")
259 224
260 // SECURITY: Final validation - ensure path stays within envDir225 // SECURITY: Final validation - ensure path stays within envDir
A internal/adapters/out/envloader/pass_loader.go +90 -0
@@ -0,0 +1,90 @@
1+// Package envloader implements the environment variable loader adapter.
2+package envloader
3+
4+import (
5+ "context"
6+ "fmt"
7+ "sort"
8+
9+ "github.com/bnema/zerowrap"
10+
11+ "github.com/bnema/gordon/internal/adapters/out/domainsecrets"
12+)
13+
14+// PassLoader implements the EnvLoader interface using pass-backed secrets.
15+type PassLoader struct {
16+ store *domainsecrets.PassStore
17+ log zerowrap.Logger
18+}
19+
20+// NewPassLoader creates a new pass-based environment loader.
21+func NewPassLoader(store *domainsecrets.PassStore, log zerowrap.Logger) (*PassLoader, error) {
22+ if store == nil {
23+ return nil, fmt.Errorf("pass store is required")
24+ }
25+
26+ log.Debug().
27+ Str(zerowrap.FieldLayer, "adapter").
28+ Str(zerowrap.FieldAdapter, "envloader").
29+ Str("provider", "pass").
30+ Msg("env loader initialized")
31+
32+ return &PassLoader{
33+ store: store,
34+ log: log,
35+ }, nil
36+}
37+
38+// LoadEnv loads environment variables for a given domain.
39+func (l *PassLoader) LoadEnv(ctx context.Context, domain string) ([]string, error) {
40+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
41+ zerowrap.FieldLayer: "adapter",
42+ zerowrap.FieldAdapter: "envloader",
43+ zerowrap.FieldAction: "LoadEnv",
44+ "domain": domain,
45+ })
46+ log := zerowrap.FromCtx(ctx)
47+
48+ secretsMap, err := l.store.GetAll(domain)
49+ if err != nil {
50+ return nil, log.WrapErr(err, "failed to load env from pass")
51+ }
52+
53+ keys := make([]string, 0, len(secretsMap))
54+ for key := range secretsMap {
55+ keys = append(keys, key)
56+ }
57+ sort.Strings(keys)
58+
59+ envVars := make([]string, 0, len(keys))
60+ for _, key := range keys {
61+ envVars = append(envVars, fmt.Sprintf("%s=%s", key, secretsMap[key]))
62+ }
63+
64+ log.Info().Int(zerowrap.FieldCount, len(envVars)).Msg("loaded environment variables for route")
65+
66+ return envVars, nil
67+}
68+
69+// CreateEnvFile is a no-op for pass-backed loader.
70+func (l *PassLoader) CreateEnvFile(ctx context.Context, domain string) error {
71+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
72+ zerowrap.FieldLayer: "adapter",
73+ zerowrap.FieldAdapter: "envloader",
74+ zerowrap.FieldAction: "CreateEnvFile",
75+ "domain": domain,
76+ })
77+ log := zerowrap.FromCtx(ctx)
78+ log.Debug().Msg("pass loader does not create env files")
79+ return nil
80+}
81+
82+// EnvFileExists checks if a manifest exists for a domain in pass.
83+func (l *PassLoader) EnvFileExists(domain string) bool {
84+ exists, err := l.store.ManifestExists(domain)
85+ if err != nil {
86+ l.log.Warn().Err(err).Str("domain", domain).Msg("failed to check pass manifest")
87+ return false
88+ }
89+ return exists
90+}
@@ -0,0 +1,90 @@
1+// Package envloader implements the environment variable loader adapter.
2+package envloader
3+
4+import (
5+ "context"
6+ "fmt"
7+ "sort"
8+
9+ "github.com/bnema/zerowrap"
10+
11+ "github.com/bnema/gordon/internal/adapters/out/domainsecrets"
12+)
13+
14+// PassLoader implements the EnvLoader interface using pass-backed secrets.
15+type PassLoader struct {
16+ store *domainsecrets.PassStore
17+ log zerowrap.Logger
18+}
19+
20+// NewPassLoader creates a new pass-based environment loader.
21+func NewPassLoader(store *domainsecrets.PassStore, log zerowrap.Logger) (*PassLoader, error) {
22+ if store == nil {
23+ return nil, fmt.Errorf("pass store is required")
24+ }
25+
26+ log.Debug().
27+ Str(zerowrap.FieldLayer, "adapter").
28+ Str(zerowrap.FieldAdapter, "envloader").
29+ Str("provider", "pass").
30+ Msg("env loader initialized")
31+
32+ return &PassLoader{
33+ store: store,
34+ log: log,
35+ }, nil
36+}
37+
38+// LoadEnv loads environment variables for a given domain.
39+func (l *PassLoader) LoadEnv(ctx context.Context, domain string) ([]string, error) {
40+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
41+ zerowrap.FieldLayer: "adapter",
42+ zerowrap.FieldAdapter: "envloader",
43+ zerowrap.FieldAction: "LoadEnv",
44+ "domain": domain,
45+ })
46+ log := zerowrap.FromCtx(ctx)
47+
48+ secretsMap, err := l.store.GetAll(domain)
49+ if err != nil {
50+ return nil, log.WrapErr(err, "failed to load env from pass")
51+ }
52+
53+ keys := make([]string, 0, len(secretsMap))
54+ for key := range secretsMap {
55+ keys = append(keys, key)
56+ }
57+ sort.Strings(keys)
58+
59+ envVars := make([]string, 0, len(keys))
60+ for _, key := range keys {
61+ envVars = append(envVars, fmt.Sprintf("%s=%s", key, secretsMap[key]))
62+ }
63+
64+ log.Info().Int(zerowrap.FieldCount, len(envVars)).Msg("loaded environment variables for route")
65+
66+ return envVars, nil
67+}
68+
69+// CreateEnvFile is a no-op for pass-backed loader.
70+func (l *PassLoader) CreateEnvFile(ctx context.Context, domain string) error {
71+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
72+ zerowrap.FieldLayer: "adapter",
73+ zerowrap.FieldAdapter: "envloader",
74+ zerowrap.FieldAction: "CreateEnvFile",
75+ "domain": domain,
76+ })
77+ log := zerowrap.FromCtx(ctx)
78+ log.Debug().Msg("pass loader does not create env files")
79+ return nil
80+}
81+
82+// EnvFileExists checks if a manifest exists for a domain in pass.
83+func (l *PassLoader) EnvFileExists(domain string) bool {
84+ exists, err := l.store.ManifestExists(domain)
85+ if err != nil {
86+ l.log.Warn().Err(err).Str("domain", domain).Msg("failed to check pass manifest")
87+ return false
88+ }
89+ return exists
90+}
A internal/app/migrate_env.go +116 -0
@@ -0,0 +1,116 @@
1+package app
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+
9+ "github.com/bnema/zerowrap"
10+
11+ "github.com/bnema/gordon/internal/adapters/out/domainsecrets"
12+ "github.com/bnema/gordon/internal/domain"
13+)
14+
15+func migrateEnvFilesToPass(envDir string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error {
16+ entries, err := readEnvDir(envDir)
17+ if err != nil {
18+ return err
19+ }
20+
21+ for _, entry := range entries {
22+ if entry.IsDir() {
23+ continue
24+ }
25+ name := entry.Name()
26+ if !isPlainEnvFile(name) {
27+ continue
28+ }
29+ if err := migrateEnvFile(envDir, name, passStore, log); err != nil {
30+ return err
31+ }
32+ }
33+
34+ return nil
35+}
36+
37+func readEnvDir(envDir string) ([]os.DirEntry, error) {
38+ entries, err := os.ReadDir(envDir)
39+ if err != nil {
40+ if os.IsNotExist(err) {
41+ return nil, nil
42+ }
43+ return nil, fmt.Errorf("failed to read env directory: %w", err)
44+ }
45+ return entries, nil
46+}
47+
48+func isPlainEnvFile(name string) bool {
49+ return strings.HasSuffix(name, ".env") && !strings.HasSuffix(name, ".env.migrated")
50+}
51+
52+func migrateEnvFile(envDir, name string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error {
53+ filePath := filepath.Join(envDir, name)
54+ data, err := os.ReadFile(filePath)
55+ if err != nil {
56+ return fmt.Errorf("failed to read env file %s: %w", filePath, err)
57+ }
58+
59+ domainName := strings.TrimSuffix(name, ".env")
60+ secrets, err := domain.ParseEnvData(data)
61+ if err != nil {
62+ return fmt.Errorf("failed to parse env file %s: %w", filePath, err)
63+ }
64+
65+ existingKeys, err := passStore.ListKeys(domainName)
66+ if err != nil {
67+ return fmt.Errorf("failed to read pass keys for %s: %w", domainName, err)
68+ }
69+
70+ missing := missingSecrets(existingKeys, secrets)
71+ if len(missing) > 0 {
72+ if err := passStore.Set(domainName, missing); err != nil {
73+ return fmt.Errorf("failed to migrate secrets for %s: %w", domainName, err)
74+ }
75+ }
76+
77+ migratedPath := filePath + ".migrated"
78+ if err := os.Rename(filePath, migratedPath); err != nil {
79+ return fmt.Errorf("failed to rename env file %s: %w", filePath, err)
80+ }
81+
82+ log.Info().
83+ Int(zerowrap.FieldCount, len(missing)).
84+ Str("domain", domainName).
85+ Msg("migrated secrets for domain from plain text to pass")
86+ log.Info().
87+ Str("file", migratedPath).
88+ Msg("original file renamed to .env.migrated - you can safely remove it")
89+
90+ return nil
91+}
92+
93+func missingSecrets(existingKeys []string, secrets map[string]string) map[string]string {
94+ if len(secrets) == 0 {
95+ return nil
96+ }
97+
98+ existingSet := make(map[string]struct{}, len(existingKeys))
99+ for _, key := range existingKeys {
100+ existingSet[key] = struct{}{}
101+ }
102+
103+ missing := make(map[string]string)
104+ for key, value := range secrets {
105+ if _, exists := existingSet[key]; exists {
106+ continue
107+ }
108+ missing[key] = value
109+ }
110+
111+ if len(missing) == 0 {
112+ return nil
113+ }
114+
115+ return missing
116+}
@@ -0,0 +1,116 @@
1+package app
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+
9+ "github.com/bnema/zerowrap"
10+
11+ "github.com/bnema/gordon/internal/adapters/out/domainsecrets"
12+ "github.com/bnema/gordon/internal/domain"
13+)
14+
15+func migrateEnvFilesToPass(envDir string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error {
16+ entries, err := readEnvDir(envDir)
17+ if err != nil {
18+ return err
19+ }
20+
21+ for _, entry := range entries {
22+ if entry.IsDir() {
23+ continue
24+ }
25+ name := entry.Name()
26+ if !isPlainEnvFile(name) {
27+ continue
28+ }
29+ if err := migrateEnvFile(envDir, name, passStore, log); err != nil {
30+ return err
31+ }
32+ }
33+
34+ return nil
35+}
36+
37+func readEnvDir(envDir string) ([]os.DirEntry, error) {
38+ entries, err := os.ReadDir(envDir)
39+ if err != nil {
40+ if os.IsNotExist(err) {
41+ return nil, nil
42+ }
43+ return nil, fmt.Errorf("failed to read env directory: %w", err)
44+ }
45+ return entries, nil
46+}
47+
48+func isPlainEnvFile(name string) bool {
49+ return strings.HasSuffix(name, ".env") && !strings.HasSuffix(name, ".env.migrated")
50+}
51+
52+func migrateEnvFile(envDir, name string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error {
53+ filePath := filepath.Join(envDir, name)
54+ data, err := os.ReadFile(filePath)
55+ if err != nil {
56+ return fmt.Errorf("failed to read env file %s: %w", filePath, err)
57+ }
58+
59+ domainName := strings.TrimSuffix(name, ".env")
60+ secrets, err := domain.ParseEnvData(data)
61+ if err != nil {
62+ return fmt.Errorf("failed to parse env file %s: %w", filePath, err)
63+ }
64+
65+ existingKeys, err := passStore.ListKeys(domainName)
66+ if err != nil {
67+ return fmt.Errorf("failed to read pass keys for %s: %w", domainName, err)
68+ }
69+
70+ missing := missingSecrets(existingKeys, secrets)
71+ if len(missing) > 0 {
72+ if err := passStore.Set(domainName, missing); err != nil {
73+ return fmt.Errorf("failed to migrate secrets for %s: %w", domainName, err)
74+ }
75+ }
76+
77+ migratedPath := filePath + ".migrated"
78+ if err := os.Rename(filePath, migratedPath); err != nil {
79+ return fmt.Errorf("failed to rename env file %s: %w", filePath, err)
80+ }
81+
82+ log.Info().
83+ Int(zerowrap.FieldCount, len(missing)).
84+ Str("domain", domainName).
85+ Msg("migrated secrets for domain from plain text to pass")
86+ log.Info().
87+ Str("file", migratedPath).
88+ Msg("original file renamed to .env.migrated - you can safely remove it")
89+
90+ return nil
91+}
92+
93+func missingSecrets(existingKeys []string, secrets map[string]string) map[string]string {
94+ if len(secrets) == 0 {
95+ return nil
96+ }
97+
98+ existingSet := make(map[string]struct{}, len(existingKeys))
99+ for _, key := range existingKeys {
100+ existingSet[key] = struct{}{}
101+ }
102+
103+ missing := make(map[string]string)
104+ for key, value := range secrets {
105+ if _, exists := existingSet[key]; exists {
106+ continue
107+ }
108+ missing[key] = value
109+ }
110+
111+ if len(missing) == 0 {
112+ return nil
113+ }
114+
115+ return missing
116+}
M internal/app/run.go +65 -49
@@ -119,7 +119,7 @@ type services struct {
119119 eventBus *eventbus.InMemory
120120 blobStorage *filesystem.BlobStorage
121121 manifestStorage *filesystem.ManifestStorage
122- envLoader *envloader.FileLoader
122+ envLoader out.EnvLoader
123123 logWriter *logwriter.LogWriter
124124 tokenStore out.TokenStore
125125 configSvc *config.Service
@@ -256,11 +256,6 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
256256 return nil, err
257257 }
258258
259- // Create env loader
260- if svc.envLoader, err = createEnvLoader(cfg, log); err != nil {
261- return nil, err
262- }
263-
264259 // Create log writer
265260 if svc.logWriter, err = createLogWriter(cfg, log); err != nil {
266261 return nil, err
@@ -292,6 +287,36 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
292287 return nil, log.WrapErr(err, "failed to load configuration")
293288 }
294289
290+ // Determine env directory for admin API
291+ svc.envDir = resolveEnvDir(cfg)
292+
293+ backend := resolveSecretsBackend(cfg.Auth.SecretsBackend)
294+ var domainSecretStore out.DomainSecretStore
295+ var passStore *domainsecrets.PassStore
296+
297+ switch backend {
298+ case domain.SecretsBackendPass:
299+ passStore, err = domainsecrets.NewPassStore(log)
300+ if err != nil {
301+ return nil, log.WrapErr(err, "failed to create pass domain secret store")
302+ }
303+ if err := migrateEnvFilesToPass(svc.envDir, passStore, log); err != nil {
304+ return nil, log.WrapErr(err, "failed to migrate env files to pass")
305+ }
306+ domainSecretStore = passStore
307+ default:
308+ domainSecretStore, err = domainsecrets.NewFileStore(svc.envDir, log)
309+ if err != nil {
310+ return nil, log.WrapErr(err, "failed to create domain secret store")
311+ }
312+ }
313+
314+ if svc.envLoader, err = createEnvLoader(backend, svc.envDir, passStore, log); err != nil {
315+ return nil, err
316+ }
317+
318+ secretSvc := secretsSvc.NewService(domainSecretStore, log)
319+
295320 svc.containerSvc = createContainerService(v, cfg, svc)
296321 svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus)
297322 svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxy.Config{
@@ -308,23 +333,6 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
308333 svc.authHandler = authhandler.NewHandler(svc.authSvc, internalAuth, log)
309334 }
310335
311- // Determine env directory for admin API
312- dataDir := cfg.Server.DataDir
313- if dataDir == "" {
314- dataDir = DefaultDataDir()
315- }
316- svc.envDir = cfg.Env.Dir
317- if svc.envDir == "" {
318- svc.envDir = filepath.Join(dataDir, "env")
319- }
320-
321- // Create domain secret store and service
322- domainSecretStore, err := domainsecrets.NewFileStore(svc.envDir, log)
323- if err != nil {
324- return nil, log.WrapErr(err, "failed to create domain secret store")
325- }
326- secretSvc := secretsSvc.NewService(domainSecretStore, log)
327-
328336 // Create health service for route health checking
329337 prober := httpprober.New()
330338 healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log)
@@ -391,36 +399,35 @@ func createStorage(cfg Config, log zerowrap.Logger) (*filesystem.BlobStorage, *f
391399 }
392400
393401 // createEnvLoader creates the environment loader with secret providers.
394-func createEnvLoader(cfg Config, log zerowrap.Logger) (*envloader.FileLoader, error) {
395- dataDir := cfg.Server.DataDir
396- if dataDir == "" {
397- dataDir = DefaultDataDir()
398- }
399-
400- envDir := cfg.Env.Dir
401- if envDir == "" {
402- envDir = filepath.Join(dataDir, "env")
403- }
402+func createEnvLoader(backend domain.SecretsBackend, envDir string, passStore *domainsecrets.PassStore, log zerowrap.Logger) (out.EnvLoader, error) {
403+ switch backend {
404+ case domain.SecretsBackendPass:
405+ loader, err := envloader.NewPassLoader(passStore, log)
406+ if err != nil {
407+ return nil, log.WrapErr(err, "failed to create pass env loader")
408+ }
409+ return loader, nil
410+ default:
411+ loader, err := envloader.NewFileLoader(envDir, log)
412+ if err != nil {
413+ return nil, log.WrapErr(err, "failed to create env loader")
414+ }
404415
405- envLoader, err := envloader.NewFileLoader(envDir, log)
406- if err != nil {
407- return nil, log.WrapErr(err, "failed to create env loader")
408- }
416+ // Register secret providers
417+ passProvider := secrets.NewPassProvider(log)
418+ if passProvider.IsAvailable() {
419+ loader.RegisterSecretProvider(passProvider)
420+ log.Debug().Msg("pass secret provider registered")
421+ }
409422
410- // Register secret providers
411- passProvider := secrets.NewPassProvider(log)
412- if passProvider.IsAvailable() {
413- envLoader.RegisterSecretProvider(passProvider)
414- log.Debug().Msg("pass secret provider registered")
415- }
423+ sopsProvider := secrets.NewSopsProvider(log)
424+ if sopsProvider.IsAvailable() {
425+ loader.RegisterSecretProvider(sopsProvider)
426+ log.Debug().Msg("sops secret provider registered")
427+ }
416428
417- sopsProvider := secrets.NewSopsProvider(log)
418- if sopsProvider.IsAvailable() {
419- envLoader.RegisterSecretProvider(sopsProvider)
420- log.Debug().Msg("sops secret provider registered")
429+ return loader, nil
421430 }
422-
423- return envLoader, nil
424431 }
425432
426433 // createLogWriter creates the container log writer.
@@ -634,6 +641,15 @@ func resolveDataDir(dataDir string) string {
634641 return dataDir
635642 }
636643
644+func resolveEnvDir(cfg Config) string {
645+ dataDir := resolveDataDir(cfg.Server.DataDir)
646+ envDir := cfg.Env.Dir
647+ if envDir == "" {
648+ envDir = filepath.Join(dataDir, "env")
649+ }
650+ return envDir
651+}
652+
637653 func createTokenStore(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (out.TokenStore, error) {
638654 // Token store is always created since tokens work in both auth modes
639655 store, err := tokenstore.NewStore(backend, dataDir, log)
@@ -119,7 +119,7 @@ type services struct {
119 eventBus *eventbus.InMemory119 eventBus *eventbus.InMemory
120 blobStorage *filesystem.BlobStorage120 blobStorage *filesystem.BlobStorage
121 manifestStorage *filesystem.ManifestStorage121 manifestStorage *filesystem.ManifestStorage
122- envLoader *envloader.FileLoader122+ envLoader out.EnvLoader
123 logWriter *logwriter.LogWriter123 logWriter *logwriter.LogWriter
124 tokenStore out.TokenStore124 tokenStore out.TokenStore
125 configSvc *config.Service125 configSvc *config.Service
@@ -256,11 +256,6 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
256 return nil, err256 return nil, err
257 }257 }
258 258
259- // Create env loader
260- if svc.envLoader, err = createEnvLoader(cfg, log); err != nil {
261- return nil, err
262- }
263-
264 // Create log writer259 // Create log writer
265 if svc.logWriter, err = createLogWriter(cfg, log); err != nil {260 if svc.logWriter, err = createLogWriter(cfg, log); err != nil {
266 return nil, err261 return nil, err
@@ -292,6 +287,36 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
292 return nil, log.WrapErr(err, "failed to load configuration")287 return nil, log.WrapErr(err, "failed to load configuration")
293 }288 }
294 289
290+ // Determine env directory for admin API
291+ svc.envDir = resolveEnvDir(cfg)
292+
293+ backend := resolveSecretsBackend(cfg.Auth.SecretsBackend)
294+ var domainSecretStore out.DomainSecretStore
295+ var passStore *domainsecrets.PassStore
296+
297+ switch backend {
298+ case domain.SecretsBackendPass:
299+ passStore, err = domainsecrets.NewPassStore(log)
300+ if err != nil {
301+ return nil, log.WrapErr(err, "failed to create pass domain secret store")
302+ }
303+ if err := migrateEnvFilesToPass(svc.envDir, passStore, log); err != nil {
304+ return nil, log.WrapErr(err, "failed to migrate env files to pass")
305+ }
306+ domainSecretStore = passStore
307+ default:
308+ domainSecretStore, err = domainsecrets.NewFileStore(svc.envDir, log)
309+ if err != nil {
310+ return nil, log.WrapErr(err, "failed to create domain secret store")
311+ }
312+ }
313+
314+ if svc.envLoader, err = createEnvLoader(backend, svc.envDir, passStore, log); err != nil {
315+ return nil, err
316+ }
317+
318+ secretSvc := secretsSvc.NewService(domainSecretStore, log)
319+
295 svc.containerSvc = createContainerService(v, cfg, svc)320 svc.containerSvc = createContainerService(v, cfg, svc)
296 svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus)321 svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus)
297 svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxy.Config{322 svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxy.Config{
@@ -308,23 +333,6 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
308 svc.authHandler = authhandler.NewHandler(svc.authSvc, internalAuth, log)333 svc.authHandler = authhandler.NewHandler(svc.authSvc, internalAuth, log)
309 }334 }
310 335
311- // Determine env directory for admin API
312- dataDir := cfg.Server.DataDir
313- if dataDir == "" {
314- dataDir = DefaultDataDir()
315- }
316- svc.envDir = cfg.Env.Dir
317- if svc.envDir == "" {
318- svc.envDir = filepath.Join(dataDir, "env")
319- }
320-
321- // Create domain secret store and service
322- domainSecretStore, err := domainsecrets.NewFileStore(svc.envDir, log)
323- if err != nil {
324- return nil, log.WrapErr(err, "failed to create domain secret store")
325- }
326- secretSvc := secretsSvc.NewService(domainSecretStore, log)
327-
328 // Create health service for route health checking336 // Create health service for route health checking
329 prober := httpprober.New()337 prober := httpprober.New()
330 healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log)338 healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log)
@@ -391,36 +399,35 @@ func createStorage(cfg Config, log zerowrap.Logger) (*filesystem.BlobStorage, *f
391 }399 }
392 400
393 // createEnvLoader creates the environment loader with secret providers.401 // createEnvLoader creates the environment loader with secret providers.
394-func createEnvLoader(cfg Config, log zerowrap.Logger) (*envloader.FileLoader, error) {402+func createEnvLoader(backend domain.SecretsBackend, envDir string, passStore *domainsecrets.PassStore, log zerowrap.Logger) (out.EnvLoader, error) {
395- dataDir := cfg.Server.DataDir403+ switch backend {
396- if dataDir == "" {404+ case domain.SecretsBackendPass:
397- dataDir = DefaultDataDir()405+ loader, err := envloader.NewPassLoader(passStore, log)
398- }406+ if err != nil {
399-407+ return nil, log.WrapErr(err, "failed to create pass env loader")
400- envDir := cfg.Env.Dir408+ }
401- if envDir == "" {409+ return loader, nil
402- envDir = filepath.Join(dataDir, "env")410+ default:
403- }411+ loader, err := envloader.NewFileLoader(envDir, log)
412+ if err != nil {
413+ return nil, log.WrapErr(err, "failed to create env loader")
414+ }
404 415
405- envLoader, err := envloader.NewFileLoader(envDir, log)416+ // Register secret providers
406- if err != nil {417+ passProvider := secrets.NewPassProvider(log)
407- return nil, log.WrapErr(err, "failed to create env loader")418+ if passProvider.IsAvailable() {
408- }419+ loader.RegisterSecretProvider(passProvider)
420+ log.Debug().Msg("pass secret provider registered")
421+ }
409 422
410- // Register secret providers423+ sopsProvider := secrets.NewSopsProvider(log)
411- passProvider := secrets.NewPassProvider(log)424+ if sopsProvider.IsAvailable() {
412- if passProvider.IsAvailable() {425+ loader.RegisterSecretProvider(sopsProvider)
413- envLoader.RegisterSecretProvider(passProvider)426+ log.Debug().Msg("sops secret provider registered")
414- log.Debug().Msg("pass secret provider registered")427+ }
415- }
416 428
417- sopsProvider := secrets.NewSopsProvider(log)429+ return loader, nil
418- if sopsProvider.IsAvailable() {
419- envLoader.RegisterSecretProvider(sopsProvider)
420- log.Debug().Msg("sops secret provider registered")
421 }430 }
422-
423- return envLoader, nil
424 }431 }
425 432
426 // createLogWriter creates the container log writer.433 // createLogWriter creates the container log writer.
@@ -634,6 +641,15 @@ func resolveDataDir(dataDir string) string {
634 return dataDir641 return dataDir
635 }642 }
636 643
644+func resolveEnvDir(cfg Config) string {
645+ dataDir := resolveDataDir(cfg.Server.DataDir)
646+ envDir := cfg.Env.Dir
647+ if envDir == "" {
648+ envDir = filepath.Join(dataDir, "env")
649+ }
650+ return envDir
651+}
652+
637 func createTokenStore(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (out.TokenStore, error) {653 func createTokenStore(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (out.TokenStore, error) {
638 // Token store is always created since tokens work in both auth modes654 // Token store is always created since tokens work in both auth modes
639 store, err := tokenstore.NewStore(backend, dataDir, log)655 store, err := tokenstore.NewStore(backend, dataDir, log)
A internal/domain/env.go +70 -0
@@ -0,0 +1,70 @@
1+package domain
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "regexp"
7+ "strings"
8+)
9+
10+// envDomainRegex validates domain names used for env secrets.
11+// Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths).
12+var envDomainRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`)
13+
14+// SanitizeDomainForEnvFile validates and sanitizes a domain name for env file storage.
15+// Returns a safe domain string suitable for filenames.
16+func SanitizeDomainForEnvFile(domainName string) (string, error) {
17+ if domainName == "" {
18+ return "", ErrPathTraversal
19+ }
20+
21+ if strings.Contains(domainName, "..") {
22+ return "", ErrPathTraversal
23+ }
24+
25+ if !envDomainRegex.MatchString(domainName) {
26+ return "", ErrPathTraversal
27+ }
28+
29+ safeDomain := strings.ReplaceAll(domainName, ".", "_")
30+ safeDomain = strings.ReplaceAll(safeDomain, ":", "_")
31+ safeDomain = strings.ReplaceAll(safeDomain, "/", "_")
32+ return safeDomain, nil
33+}
34+
35+// ParseEnvData parses env data into a key-value map.
36+func ParseEnvData(data []byte) (map[string]string, error) {
37+ secrets := make(map[string]string)
38+ scanner := bufio.NewScanner(bytes.NewReader(data))
39+ for scanner.Scan() {
40+ line := strings.TrimSpace(scanner.Text())
41+ if line == "" || strings.HasPrefix(line, "#") {
42+ continue
43+ }
44+
45+ parts := strings.SplitN(line, "=", 2)
46+ if len(parts) != 2 {
47+ continue
48+ }
49+
50+ key := strings.TrimSpace(parts[0])
51+ value := strings.TrimSpace(parts[1])
52+
53+ if len(value) >= 2 {
54+ if (strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"")) ||
55+ (strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'")) {
56+ value = value[1 : len(value)-1]
57+ }
58+ }
59+
60+ if key != "" {
61+ secrets[key] = value
62+ }
63+ }
64+
65+ if err := scanner.Err(); err != nil {
66+ return nil, err
67+ }
68+
69+ return secrets, nil
70+}
@@ -0,0 +1,70 @@
1+package domain
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "regexp"
7+ "strings"
8+)
9+
10+// envDomainRegex validates domain names used for env secrets.
11+// Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths).
12+var envDomainRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`)
13+
14+// SanitizeDomainForEnvFile validates and sanitizes a domain name for env file storage.
15+// Returns a safe domain string suitable for filenames.
16+func SanitizeDomainForEnvFile(domainName string) (string, error) {
17+ if domainName == "" {
18+ return "", ErrPathTraversal
19+ }
20+
21+ if strings.Contains(domainName, "..") {
22+ return "", ErrPathTraversal
23+ }
24+
25+ if !envDomainRegex.MatchString(domainName) {
26+ return "", ErrPathTraversal
27+ }
28+
29+ safeDomain := strings.ReplaceAll(domainName, ".", "_")
30+ safeDomain = strings.ReplaceAll(safeDomain, ":", "_")
31+ safeDomain = strings.ReplaceAll(safeDomain, "/", "_")
32+ return safeDomain, nil
33+}
34+
35+// ParseEnvData parses env data into a key-value map.
36+func ParseEnvData(data []byte) (map[string]string, error) {
37+ secrets := make(map[string]string)
38+ scanner := bufio.NewScanner(bytes.NewReader(data))
39+ for scanner.Scan() {
40+ line := strings.TrimSpace(scanner.Text())
41+ if line == "" || strings.HasPrefix(line, "#") {
42+ continue
43+ }
44+
45+ parts := strings.SplitN(line, "=", 2)
46+ if len(parts) != 2 {
47+ continue
48+ }
49+
50+ key := strings.TrimSpace(parts[0])
51+ value := strings.TrimSpace(parts[1])
52+
53+ if len(value) >= 2 {
54+ if (strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"")) ||
55+ (strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'")) {
56+ value = value[1 : len(value)-1]
57+ }
58+ }
59+
60+ if key != "" {
61+ secrets[key] = value
62+ }
63+ }
64+
65+ if err := scanner.Err(); err != nil {
66+ return nil, err
67+ }
68+
69+ return secrets, nil
70+}
M wiki/guides/secrets-pass.md +7 -6
@@ -106,17 +106,18 @@ token_secret = "gordon/auth/token_secret"
106106 # password_hash = "gordon/auth/password_hash"
107107 ```
108108
109-### Using Secrets in Environment Files
109+### Using Route Secrets
110110
111-Reference pass secrets in your app's environment files:
111+With the pass backend, per-domain secrets are stored in pass, not `.env` files.
112112
113113 ```bash
114-# ~/.gordon/env/app_mydomain_com.env
115-DATABASE_URL=postgresql://user:${pass:myapp/db/password}@postgres:5432/app
116-API_KEY=${pass:myapp/api/key}
117-JWT_SECRET=${pass:myapp/jwt/secret}
114+# Store secrets for a domain
115+gordon secrets set app.mydomain.com DATABASE_URL "postgresql://user:pass@postgres:5432/app"
116+gordon secrets set app.mydomain.com API_KEY "your-api-key"
118117 ```
119118
119+Gordon migrates existing `.env` files on startup and renames them to `.env.migrated`.
120+
120121 ## Organizing Secrets
121122
122123 Recommended directory structure in pass:
@@ -106,17 +106,18 @@ token_secret = "gordon/auth/token_secret"
106 # password_hash = "gordon/auth/password_hash"106 # password_hash = "gordon/auth/password_hash"
107 ```107 ```
108 108
109-### Using Secrets in Environment Files109+### Using Route Secrets
110 110
111-Reference pass secrets in your app's environment files:111+With the pass backend, per-domain secrets are stored in pass, not `.env` files.
112 112
113 ```bash113 ```bash
114-# ~/.gordon/env/app_mydomain_com.env114+# Store secrets for a domain
115-DATABASE_URL=postgresql://user:${pass:myapp/db/password}@postgres:5432/app115+gordon secrets set app.mydomain.com DATABASE_URL "postgresql://user:pass@postgres:5432/app"
116-API_KEY=${pass:myapp/api/key}116+gordon secrets set app.mydomain.com API_KEY "your-api-key"
117-JWT_SECRET=${pass:myapp/jwt/secret}
118 ```117 ```
119 118
119+Gordon migrates existing `.env` files on startup and renames them to `.env.migrated`.
120+
120 ## Organizing Secrets121 ## Organizing Secrets
121 122
122 Recommended directory structure in pass:123 Recommended directory structure in pass:
M wiki/guides/secrets-sops.md +2 -0
@@ -208,6 +208,8 @@ The path format is `file:key.path` where:
208208 - `file` is the SOPS-encrypted file path
209209 - `key.path` is dot-notation to the value
210210
211+Route secrets remain in `.env` files with `${sops:...}` references. The SOPS backend is used for auth secrets (like `token_secret`) and provider lookups.
212+
211213 ### Using Secrets in Environment Files
212214
213215 Reference SOPS secrets in your app's environment files:
@@ -208,6 +208,8 @@ The path format is `file:key.path` where:
208 - `file` is the SOPS-encrypted file path208 - `file` is the SOPS-encrypted file path
209 - `key.path` is dot-notation to the value209 - `key.path` is dot-notation to the value
210 210
211+Route secrets remain in `.env` files with `${sops:...}` references. The SOPS backend is used for auth secrets (like `token_secret`) and provider lookups.
212+
211 ### Using Secrets in Environment Files213 ### Using Secrets in Environment Files
212 214
213 Reference SOPS secrets in your app's environment files:215 Reference SOPS secrets in your app's environment files:

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