v2.11.0 dca6537

service.go RawHistoryFolder

1// Package secrets implements the secret management use case.
2package secrets
3
4import (
5	"context"
6	"errors"
7	"strings"
8
9	"github.com/bnema/zerowrap"
10
11	"github.com/bnema/gordon/internal/boundaries/out"
12	"github.com/bnema/gordon/internal/domain"
13)
14
15// Domain validation errors.
16var (
17	ErrDomainEmpty         = errors.New("domain cannot be empty")
18	ErrDomainTooLong       = errors.New("domain exceeds maximum length of 253 characters")
19	ErrDomainPathTraversal = errors.New("domain contains path traversal sequence")
20	ErrDomainInvalidChars  = errors.New("domain contains invalid characters")
21	ErrServiceEmpty        = errors.New("service name cannot be empty")
22	ErrInvalidServiceName  = errors.New("invalid service name: must start with a letter, contain only lowercase letters, numbers, and hyphens, be at most 63 characters, and not end with a hyphen")
23)
24
25// Service implements the SecretService interface.
26type Service struct {
27	store out.DomainSecretStore
28	log   zerowrap.Logger
29}
30
31// NewService creates a new secrets service.
32func NewService(store out.DomainSecretStore, log zerowrap.Logger) *Service {
33	return &Service{
34		store: store,
35		log:   log,
36	}
37}
38
39// ListKeys returns the list of secret keys for a domain (not values).
40func (s *Service) ListKeys(ctx context.Context, domain string) ([]string, error) {
41	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
42		zerowrap.FieldLayer:   "usecase",
43		zerowrap.FieldUseCase: "ListKeys",
44		"domain":              domain,
45	})
46	log := zerowrap.FromCtx(ctx)
47
48	if err := ValidateDomain(domain); err != nil {
49		log.Warn().Err(err).Msg("domain validation failed")
50		return nil, err
51	}
52
53	keys, err := s.store.ListKeys(domain)
54	if err != nil {
55		log.Error().Err(err).Msg("failed to list secret keys")
56		return nil, err
57	}
58
59	log.Debug().Int("count", len(keys)).Msg("listed secret keys")
60	return keys, nil
61}
62
63// ListKeysWithAttachments returns the list of secret keys for a domain
64// along with any attachment secrets for containers associated with the domain.
65func (s *Service) ListKeysWithAttachments(ctx context.Context, domain string) ([]string, []out.AttachmentSecrets, error) {
66	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
67		zerowrap.FieldLayer:   "usecase",
68		zerowrap.FieldUseCase: "ListKeysWithAttachments",
69		"domain":              domain,
70	})
71	log := zerowrap.FromCtx(ctx)
72
73	if err := ValidateDomain(domain); err != nil {
74		log.Warn().Err(err).Msg("domain validation failed")
75		return nil, nil, err
76	}
77
78	// Get domain secrets
79	keys, err := s.store.ListKeys(domain)
80	if err != nil {
81		log.Error().Err(err).Msg("failed to list secret keys")
82		return nil, nil, err
83	}
84
85	// Get attachment secrets
86	attachments, err := s.store.ListAttachmentKeys(domain)
87	if err != nil {
88		log.Warn().Err(err).Msg("failed to list attachment secrets, continuing without them")
89		attachments = nil // Don't fail, just return empty attachments
90	}
91
92	log.Debug().
93		Int("domain_keys", len(keys)).
94		Int("attachments", len(attachments)).
95		Msg("listed secret keys with attachments")
96
97	return keys, attachments, nil
98}
99
100// GetAll returns all secrets for a domain as a key-value map.
101func (s *Service) GetAll(ctx context.Context, domain string) (map[string]string, error) {
102	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
103		zerowrap.FieldLayer:   "usecase",
104		zerowrap.FieldUseCase: "GetAll",
105		"domain":              domain,
106	})
107	log := zerowrap.FromCtx(ctx)
108
109	if err := ValidateDomain(domain); err != nil {
110		log.Warn().Err(err).Msg("domain validation failed")
111		return nil, err
112	}
113
114	secrets, err := s.store.GetAll(domain)
115	if err != nil {
116		log.Error().Err(err).Msg("failed to get secrets")
117		return nil, err
118	}
119
120	log.Debug().Int("count", len(secrets)).Msg("retrieved secrets")
121	return secrets, nil
122}
123
124// Set sets or updates multiple secrets for a domain, merging with existing.
125func (s *Service) Set(ctx context.Context, domain string, secrets map[string]string) error {
126	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
127		zerowrap.FieldLayer:   "usecase",
128		zerowrap.FieldUseCase: "Set",
129		"domain":              domain,
130	})
131	log := zerowrap.FromCtx(ctx)
132
133	if err := ValidateDomain(domain); err != nil {
134		log.Warn().Err(err).Msg("domain validation failed")
135		return err
136	}
137
138	if err := s.store.Set(domain, secrets); err != nil {
139		log.Error().Err(err).Msg("failed to set secrets")
140		return err
141	}
142
143	log.Info().Int("count", len(secrets)).Msg("secrets set")
144	return nil
145}
146
147// Delete removes a specific secret key from a domain.
148func (s *Service) Delete(ctx context.Context, domain, key string) error {
149	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
150		zerowrap.FieldLayer:   "usecase",
151		zerowrap.FieldUseCase: "Delete",
152		"domain":              domain,
153		"key":                 key,
154	})
155	log := zerowrap.FromCtx(ctx)
156
157	if err := ValidateDomain(domain); err != nil {
158		log.Warn().Err(err).Msg("domain validation failed")
159		return err
160	}
161
162	if err := s.store.Delete(domain, key); err != nil {
163		log.Error().Err(err).Msg("failed to delete secret")
164		return err
165	}
166
167	log.Info().Msg("secret deleted")
168	return nil
169}
170
171// SetAttachment sets or updates multiple secrets for an attachment container.
172func (s *Service) SetAttachment(ctx context.Context, domainName, service string, secrets map[string]string) error {
173	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
174		zerowrap.FieldLayer:   "usecase",
175		zerowrap.FieldUseCase: "SetAttachment",
176		"domain":              domainName,
177		"service":             service,
178	})
179	log := zerowrap.FromCtx(ctx)
180
181	if err := ValidateDomain(domainName); err != nil {
182		log.Warn().Err(err).Msg("domain validation failed")
183		return err
184	}
185
186	if err := validateServiceName(service); err != nil {
187		log.Warn().Err(err).Msg("service name validation failed")
188		return err
189	}
190
191	containerName := resolveContainerName(domainName, service)
192
193	if err := s.store.SetAttachment(containerName, secrets); err != nil {
194		log.Error().Err(err).Msg("failed to set attachment secrets")
195		return err
196	}
197
198	log.Info().Int("count", len(secrets)).Str("container", containerName).Msg("attachment secrets set")
199	return nil
200}
201
202// DeleteAttachment removes a specific secret key from an attachment container.
203func (s *Service) DeleteAttachment(ctx context.Context, domainName, service, key string) error {
204	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
205		zerowrap.FieldLayer:   "usecase",
206		zerowrap.FieldUseCase: "DeleteAttachment",
207		"domain":              domainName,
208		"service":             service,
209		"key":                 key,
210	})
211	log := zerowrap.FromCtx(ctx)
212
213	if err := ValidateDomain(domainName); err != nil {
214		log.Warn().Err(err).Msg("domain validation failed")
215		return err
216	}
217
218	if err := validateServiceName(service); err != nil {
219		log.Warn().Err(err).Msg("service name validation failed")
220		return err
221	}
222
223	containerName := resolveContainerName(domainName, service)
224
225	if err := s.store.DeleteAttachment(containerName, key); err != nil {
226		log.Error().Err(err).Msg("failed to delete attachment secret")
227		return err
228	}
229
230	log.Info().Str("container", containerName).Msg("attachment secret deleted")
231	return nil
232}
233
234// validateServiceName checks that a service name is safe and follows Docker-style conventions.
235func validateServiceName(service string) error {
236	if service == "" {
237		return ErrServiceEmpty
238	}
239	// DNS labels are limited to 63 characters (RFC 1035)
240	if len(service) > 63 {
241		return ErrInvalidServiceName
242	}
243	// Docker-style: lowercase letter start, then lowercase alphanumeric + hyphens
244	for i, c := range service {
245		if i == 0 {
246			if c < 'a' || c > 'z' {
247				return ErrInvalidServiceName
248			}
249			continue
250		}
251		if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' {
252			return ErrInvalidServiceName
253		}
254	}
255	// Trailing hyphen is not allowed in DNS labels
256	if service[len(service)-1] == '-' {
257		return ErrInvalidServiceName
258	}
259	return nil
260}
261
262// resolveContainerName builds the container name from a domain and service.
263// Ensures the total name does not exceed Docker's 255-character limit.
264func resolveContainerName(domainName, service string) string {
265	sanitized := domain.SanitizeDomainForContainer(domainName)
266	name := "gordon-" + sanitized + "-" + service
267
268	// Docker container names are limited to 255 characters
269	if len(name) > 255 {
270		// Truncate the domain part to fit, keeping room for "gordon-", "-", and service name
271		maxDomainLen := 255 - 7 - 1 - len(service) // 7 for "gordon-", 1 for "-"
272		if maxDomainLen > 0 {
273			name = "gordon-" + sanitized[:maxDomainLen] + "-" + service
274		}
275	}
276	return name
277}
278
279// ValidateDomain validates that a domain is safe to use for secret storage.
280// This prevents path traversal attacks and ensures the domain is valid.
281func ValidateDomain(domain string) error {
282	// Check for empty domain
283	if domain == "" {
284		return ErrDomainEmpty
285	}
286
287	// Check domain length (max DNS name length is 253)
288	if len(domain) > 253 {
289		return ErrDomainTooLong
290	}
291
292	// Check for path traversal attempts
293	if strings.Contains(domain, "..") {
294		return ErrDomainPathTraversal
295	}
296
297	// Check for null bytes (can cause issues in file paths)
298	if strings.ContainsRune(domain, '\x00') {
299		return ErrDomainInvalidChars
300	}
301
302	return nil
303}

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