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}