refactor(secrets): extract shared sanitization logic to domain package
- Add SanitizeDomainForContainer and SanitizeDomainForContainerLegacy to domain package - Update container service and secret stores to use shared sanitization functions - Eliminate duplicate sanitization code across adapters - Improve collision resistance with distinct separator replacements
- commit
- 854173518eddad59f0c9f1e19a8580e7e665a596
- author
- bnema · · 2026-01-31 22:20 UTC
- parent
- 0a85e4e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +474 -53
M internal/adapters/out/domainsecrets/pass_store.go +195 -3
| @@ -229,7 +229,113 @@ func (s *PassStore) Delete(domainName, key string) error { | |||
| 229 | 229 | return nil | |
| 230 | 230 | } | |
| 231 | 231 | ||
| 232 | + | // SetAttachment sets or updates multiple secrets for an attachment container. | |
| 233 | + | func (s *PassStore) SetAttachment(containerName string, secretsMap map[string]string) error { | |
| 234 | + | if _, err := s.attachmentPath(containerName); err != nil { | |
| 235 | + | return err | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | existingKeys, err := s.listAttachmentKeys(containerName) | |
| 239 | + | if err != nil { | |
| 240 | + | return err | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | existingSet := make(map[string]struct{}, len(existingKeys)) | |
| 244 | + | for _, key := range existingKeys { | |
| 245 | + | existingSet[key] = struct{}{} | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | insertedNewPaths := make([]string, 0, len(secretsMap)) | |
| 249 | + | for key, value := range secretsMap { | |
| 250 | + | if err := domain.ValidateEnvKey(key); err != nil { | |
| 251 | + | return err | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | keyPath, err := s.attachmentKeyPath(containerName, key) | |
| 255 | + | if err != nil { | |
| 256 | + | return err | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 260 | + | err = s.passInsert(ctx, keyPath, value) | |
| 261 | + | cancel() | |
| 262 | + | if err != nil { | |
| 263 | + | s.cleanupInsertedPaths(insertedNewPaths) | |
| 264 | + | return fmt.Errorf("failed to store attachment secret %s: %w", key, err) | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | if _, exists := existingSet[key]; !exists { | |
| 268 | + | insertedNewPaths = append(insertedNewPaths, keyPath) | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | keySet := make(map[string]struct{}, len(existingKeys)+len(secretsMap)) | |
| 273 | + | for _, key := range existingKeys { | |
| 274 | + | keySet[key] = struct{}{} | |
| 275 | + | } | |
| 276 | + | for key := range secretsMap { | |
| 277 | + | keySet[key] = struct{}{} | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | keys := make([]string, 0, len(keySet)) | |
| 281 | + | for key := range keySet { | |
| 282 | + | keys = append(keys, key) | |
| 283 | + | } | |
| 284 | + | sort.Strings(keys) | |
| 285 | + | ||
| 286 | + | manifestPath, err := s.attachmentManifestPath(containerName) | |
| 287 | + | if err != nil { | |
| 288 | + | return err | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 292 | + | if err := s.passInsert(ctx, manifestPath, strings.Join(keys, "\n")); err != nil { | |
| 293 | + | cancel() | |
| 294 | + | s.cleanupInsertedPaths(insertedNewPaths) | |
| 295 | + | return fmt.Errorf("failed to update attachment manifest: %w", err) | |
| 296 | + | } | |
| 297 | + | cancel() | |
| 298 | + | ||
| 299 | + | return nil | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | // GetAllAttachment returns all secrets for an attachment container as a key-value map. | |
| 303 | + | func (s *PassStore) GetAllAttachment(containerName string) (map[string]string, error) { | |
| 304 | + | keys, err := s.listAttachmentKeys(containerName) | |
| 305 | + | if err != nil { | |
| 306 | + | return nil, err | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | secretsMap := make(map[string]string) | |
| 310 | + | for _, key := range keys { | |
| 311 | + | keyPath, err := s.attachmentKeyPath(containerName, key) | |
| 312 | + | if err != nil { | |
| 313 | + | return nil, err | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 317 | + | value, exists, err := s.passShow(ctx, keyPath) | |
| 318 | + | cancel() | |
| 319 | + | if err != nil { | |
| 320 | + | return nil, err | |
| 321 | + | } | |
| 322 | + | if !exists { | |
| 323 | + | s.log.Warn(). | |
| 324 | + | Str(zerowrap.FieldLayer, "adapter"). | |
| 325 | + | Str(zerowrap.FieldAdapter, "domainsecrets"). | |
| 326 | + | Str("container", containerName). | |
| 327 | + | Str("key", key). | |
| 328 | + | Msg("attachment secret listed in manifest but missing in pass") | |
| 329 | + | continue | |
| 330 | + | } | |
| 331 | + | secretsMap[key] = value | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | return secretsMap, nil | |
| 335 | + | } | |
| 336 | + | ||
| 232 | 337 | // ListAttachmentKeys finds attachment secrets for a domain from pass. | |
| 338 | + | // Supports both new (collision-resistant) and legacy container naming for backwards compatibility. | |
| 233 | 339 | func (s *PassStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecrets, error) { | |
| 234 | 340 | if _, err := s.domainPath(domainName); err != nil { | |
| 235 | 341 | return nil, err | |
| @@ -243,14 +349,33 @@ func (s *PassStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecre | |||
| 243 | 349 | return nil, err | |
| 244 | 350 | } | |
| 245 | 351 | ||
| 246 | - | sanitizedDomain := sanitizeDomainForContainer(domainName) | |
| 247 | - | prefix := "gordon-" + sanitizedDomain + "-" | |
| 352 | + | // Try both new and legacy sanitization for backwards compatibility | |
| 353 | + | sanitizedDomain := domain.SanitizeDomainForContainer(domainName) | |
| 354 | + | sanitizedDomainLegacy := domain.SanitizeDomainForContainerLegacy(domainName) | |
| 355 | + | prefixes := []string{ | |
| 356 | + | "gordon-" + sanitizedDomain + "-", // New format (collision-resistant) | |
| 357 | + | "gordon-" + sanitizedDomainLegacy + "-", // Old format (buggy but backwards compatible) | |
| 358 | + | } | |
| 248 | 359 | ||
| 360 | + | seen := make(map[string]bool) | |
| 249 | 361 | var results []out.AttachmentSecrets | |
| 250 | 362 | for _, containerName := range containers { | |
| 251 | - | if !strings.HasPrefix(containerName, prefix) { | |
| 363 | + | matches := false | |
| 364 | + | for _, prefix := range prefixes { | |
| 365 | + | if strings.HasPrefix(containerName, prefix) { | |
| 366 | + | matches = true | |
| 367 | + | break | |
| 368 | + | } | |
| 369 | + | } | |
| 370 | + | if !matches { | |
| 371 | + | continue | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | // Deduplicate results | |
| 375 | + | if seen[containerName] { | |
| 252 | 376 | continue | |
| 253 | 377 | } | |
| 378 | + | seen[containerName] = true | |
| 254 | 379 | ||
| 255 | 380 | manifestPath := fmt.Sprintf("%s/%s/.keys", PassAttachmentPath, containerName) | |
| 256 | 381 | if err := secrets.ValidatePath(manifestPath); err != nil { | |
| @@ -326,6 +451,73 @@ func (s *PassStore) manifestPath(domainName string) (string, error) { | |||
| 326 | 451 | return path, nil | |
| 327 | 452 | } | |
| 328 | 453 | ||
| 454 | + | func (s *PassStore) attachmentPath(containerName string) (string, error) { | |
| 455 | + | if err := domain.ValidateContainerName(containerName); err != nil { | |
| 456 | + | return "", err | |
| 457 | + | } | |
| 458 | + | path := fmt.Sprintf("%s/%s", PassAttachmentPath, containerName) | |
| 459 | + | if err := secrets.ValidatePath(path); err != nil { | |
| 460 | + | return "", err | |
| 461 | + | } | |
| 462 | + | return path, nil | |
| 463 | + | } | |
| 464 | + | ||
| 465 | + | func (s *PassStore) attachmentKeyPath(containerName, key string) (string, error) { | |
| 466 | + | attachmentPath, err := s.attachmentPath(containerName) | |
| 467 | + | if err != nil { | |
| 468 | + | return "", err | |
| 469 | + | } | |
| 470 | + | if err := domain.ValidateEnvKey(key); err != nil { | |
| 471 | + | return "", err | |
| 472 | + | } | |
| 473 | + | path := fmt.Sprintf("%s/%s", attachmentPath, key) | |
| 474 | + | if err := secrets.ValidatePath(path); err != nil { | |
| 475 | + | return "", err | |
| 476 | + | } | |
| 477 | + | return path, nil | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | func (s *PassStore) attachmentManifestPath(containerName string) (string, error) { | |
| 481 | + | attachmentPath, err := s.attachmentPath(containerName) | |
| 482 | + | if err != nil { | |
| 483 | + | return "", err | |
| 484 | + | } | |
| 485 | + | path := fmt.Sprintf("%s/.keys", attachmentPath) | |
| 486 | + | if err := secrets.ValidatePath(path); err != nil { | |
| 487 | + | return "", err | |
| 488 | + | } | |
| 489 | + | return path, nil | |
| 490 | + | } | |
| 491 | + | ||
| 492 | + | func (s *PassStore) listAttachmentKeys(containerName string) ([]string, error) { | |
| 493 | + | manifestPath, err := s.attachmentManifestPath(containerName) | |
| 494 | + | if err != nil { | |
| 495 | + | return nil, err | |
| 496 | + | } | |
| 497 | + | ||
| 498 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 499 | + | defer cancel() | |
| 500 | + | ||
| 501 | + | content, exists, err := s.passShow(ctx, manifestPath) | |
| 502 | + | if err != nil { | |
| 503 | + | return nil, err | |
| 504 | + | } | |
| 505 | + | if !exists { | |
| 506 | + | return []string{}, nil | |
| 507 | + | } | |
| 508 | + | ||
| 509 | + | keys := []string{} | |
| 510 | + | for _, line := range strings.Split(content, "\n") { | |
| 511 | + | key := strings.TrimSpace(line) | |
| 512 | + | if key == "" { | |
| 513 | + | continue | |
| 514 | + | } | |
| 515 | + | keys = append(keys, key) | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | return keys, nil | |
| 519 | + | } | |
| 520 | + | ||
| 329 | 521 | func (s *PassStore) sanitizeDomain(domainName string) (string, error) { | |
| 330 | 522 | safeDomain, err := domain.SanitizeDomainForEnvFile(domainName) | |
| 331 | 523 | if err != nil { | |
| @@ -229,7 +229,113 @@ func (s *PassStore) Delete(domainName, key string) error { | |||||
| 229 | return nil | 229 | return nil | ||
| 230 | } | 230 | } | ||
| 231 | 231 | ||||
| 232 | + | // SetAttachment sets or updates multiple secrets for an attachment container. | |||
| 233 | + | func (s *PassStore) SetAttachment(containerName string, secretsMap map[string]string) error { | |||
| 234 | + | if _, err := s.attachmentPath(containerName); err != nil { | |||
| 235 | + | return err | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | existingKeys, err := s.listAttachmentKeys(containerName) | |||
| 239 | + | if err != nil { | |||
| 240 | + | return err | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | existingSet := make(map[string]struct{}, len(existingKeys)) | |||
| 244 | + | for _, key := range existingKeys { | |||
| 245 | + | existingSet[key] = struct{}{} | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | insertedNewPaths := make([]string, 0, len(secretsMap)) | |||
| 249 | + | for key, value := range secretsMap { | |||
| 250 | + | if err := domain.ValidateEnvKey(key); err != nil { | |||
| 251 | + | return err | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | keyPath, err := s.attachmentKeyPath(containerName, key) | |||
| 255 | + | if err != nil { | |||
| 256 | + | return err | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 260 | + | err = s.passInsert(ctx, keyPath, value) | |||
| 261 | + | cancel() | |||
| 262 | + | if err != nil { | |||
| 263 | + | s.cleanupInsertedPaths(insertedNewPaths) | |||
| 264 | + | return fmt.Errorf("failed to store attachment secret %s: %w", key, err) | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | if _, exists := existingSet[key]; !exists { | |||
| 268 | + | insertedNewPaths = append(insertedNewPaths, keyPath) | |||
| 269 | + | } | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | keySet := make(map[string]struct{}, len(existingKeys)+len(secretsMap)) | |||
| 273 | + | for _, key := range existingKeys { | |||
| 274 | + | keySet[key] = struct{}{} | |||
| 275 | + | } | |||
| 276 | + | for key := range secretsMap { | |||
| 277 | + | keySet[key] = struct{}{} | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | keys := make([]string, 0, len(keySet)) | |||
| 281 | + | for key := range keySet { | |||
| 282 | + | keys = append(keys, key) | |||
| 283 | + | } | |||
| 284 | + | sort.Strings(keys) | |||
| 285 | + | ||||
| 286 | + | manifestPath, err := s.attachmentManifestPath(containerName) | |||
| 287 | + | if err != nil { | |||
| 288 | + | return err | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 292 | + | if err := s.passInsert(ctx, manifestPath, strings.Join(keys, "\n")); err != nil { | |||
| 293 | + | cancel() | |||
| 294 | + | s.cleanupInsertedPaths(insertedNewPaths) | |||
| 295 | + | return fmt.Errorf("failed to update attachment manifest: %w", err) | |||
| 296 | + | } | |||
| 297 | + | cancel() | |||
| 298 | + | ||||
| 299 | + | return nil | |||
| 300 | + | } | |||
| 301 | + | ||||
| 302 | + | // GetAllAttachment returns all secrets for an attachment container as a key-value map. | |||
| 303 | + | func (s *PassStore) GetAllAttachment(containerName string) (map[string]string, error) { | |||
| 304 | + | keys, err := s.listAttachmentKeys(containerName) | |||
| 305 | + | if err != nil { | |||
| 306 | + | return nil, err | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | secretsMap := make(map[string]string) | |||
| 310 | + | for _, key := range keys { | |||
| 311 | + | keyPath, err := s.attachmentKeyPath(containerName, key) | |||
| 312 | + | if err != nil { | |||
| 313 | + | return nil, err | |||
| 314 | + | } | |||
| 315 | + | ||||
| 316 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 317 | + | value, exists, err := s.passShow(ctx, keyPath) | |||
| 318 | + | cancel() | |||
| 319 | + | if err != nil { | |||
| 320 | + | return nil, err | |||
| 321 | + | } | |||
| 322 | + | if !exists { | |||
| 323 | + | s.log.Warn(). | |||
| 324 | + | Str(zerowrap.FieldLayer, "adapter"). | |||
| 325 | + | Str(zerowrap.FieldAdapter, "domainsecrets"). | |||
| 326 | + | Str("container", containerName). | |||
| 327 | + | Str("key", key). | |||
| 328 | + | Msg("attachment secret listed in manifest but missing in pass") | |||
| 329 | + | continue | |||
| 330 | + | } | |||
| 331 | + | secretsMap[key] = value | |||
| 332 | + | } | |||
| 333 | + | ||||
| 334 | + | return secretsMap, nil | |||
| 335 | + | } | |||
| 336 | + | ||||
| 232 | // ListAttachmentKeys finds attachment secrets for a domain from pass. | 337 | // ListAttachmentKeys finds attachment secrets for a domain from pass. | ||
| 338 | + | // Supports both new (collision-resistant) and legacy container naming for backwards compatibility. | |||
| 233 | func (s *PassStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecrets, error) { | 339 | func (s *PassStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecrets, error) { | ||
| 234 | if _, err := s.domainPath(domainName); err != nil { | 340 | if _, err := s.domainPath(domainName); err != nil { | ||
| 235 | return nil, err | 341 | return nil, err | ||
| @@ -243,14 +349,33 @@ func (s *PassStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecre | |||||
| 243 | return nil, err | 349 | return nil, err | ||
| 244 | } | 350 | } | ||
| 245 | 351 | ||||
| 246 | - | sanitizedDomain := sanitizeDomainForContainer(domainName) | 352 | + | // Try both new and legacy sanitization for backwards compatibility |
| 247 | - | prefix := "gordon-" + sanitizedDomain + "-" | 353 | + | sanitizedDomain := domain.SanitizeDomainForContainer(domainName) |
| 354 | + | sanitizedDomainLegacy := domain.SanitizeDomainForContainerLegacy(domainName) | |||
| 355 | + | prefixes := []string{ | |||
| 356 | + | "gordon-" + sanitizedDomain + "-", // New format (collision-resistant) | |||
| 357 | + | "gordon-" + sanitizedDomainLegacy + "-", // Old format (buggy but backwards compatible) | |||
| 358 | + | } | |||
| 248 | 359 | ||||
| 360 | + | seen := make(map[string]bool) | |||
| 249 | var results []out.AttachmentSecrets | 361 | var results []out.AttachmentSecrets | ||
| 250 | for _, containerName := range containers { | 362 | for _, containerName := range containers { | ||
| 251 | - | if !strings.HasPrefix(containerName, prefix) { | 363 | + | matches := false |
| 364 | + | for _, prefix := range prefixes { | |||
| 365 | + | if strings.HasPrefix(containerName, prefix) { | |||
| 366 | + | matches = true | |||
| 367 | + | break | |||
| 368 | + | } | |||
| 369 | + | } | |||
| 370 | + | if !matches { | |||
| 371 | + | continue | |||
| 372 | + | } | |||
| 373 | + | ||||
| 374 | + | // Deduplicate results | |||
| 375 | + | if seen[containerName] { | |||
| 252 | continue | 376 | continue | ||
| 253 | } | 377 | } | ||
| 378 | + | seen[containerName] = true | |||
| 254 | 379 | ||||
| 255 | manifestPath := fmt.Sprintf("%s/%s/.keys", PassAttachmentPath, containerName) | 380 | manifestPath := fmt.Sprintf("%s/%s/.keys", PassAttachmentPath, containerName) | ||
| 256 | if err := secrets.ValidatePath(manifestPath); err != nil { | 381 | if err := secrets.ValidatePath(manifestPath); err != nil { | ||
| @@ -326,6 +451,73 @@ func (s *PassStore) manifestPath(domainName string) (string, error) { | |||||
| 326 | return path, nil | 451 | return path, nil | ||
| 327 | } | 452 | } | ||
| 328 | 453 | ||||
| 454 | + | func (s *PassStore) attachmentPath(containerName string) (string, error) { | |||
| 455 | + | if err := domain.ValidateContainerName(containerName); err != nil { | |||
| 456 | + | return "", err | |||
| 457 | + | } | |||
| 458 | + | path := fmt.Sprintf("%s/%s", PassAttachmentPath, containerName) | |||
| 459 | + | if err := secrets.ValidatePath(path); err != nil { | |||
| 460 | + | return "", err | |||
| 461 | + | } | |||
| 462 | + | return path, nil | |||
| 463 | + | } | |||
| 464 | + | ||||
| 465 | + | func (s *PassStore) attachmentKeyPath(containerName, key string) (string, error) { | |||
| 466 | + | attachmentPath, err := s.attachmentPath(containerName) | |||
| 467 | + | if err != nil { | |||
| 468 | + | return "", err | |||
| 469 | + | } | |||
| 470 | + | if err := domain.ValidateEnvKey(key); err != nil { | |||
| 471 | + | return "", err | |||
| 472 | + | } | |||
| 473 | + | path := fmt.Sprintf("%s/%s", attachmentPath, key) | |||
| 474 | + | if err := secrets.ValidatePath(path); err != nil { | |||
| 475 | + | return "", err | |||
| 476 | + | } | |||
| 477 | + | return path, nil | |||
| 478 | + | } | |||
| 479 | + | ||||
| 480 | + | func (s *PassStore) attachmentManifestPath(containerName string) (string, error) { | |||
| 481 | + | attachmentPath, err := s.attachmentPath(containerName) | |||
| 482 | + | if err != nil { | |||
| 483 | + | return "", err | |||
| 484 | + | } | |||
| 485 | + | path := fmt.Sprintf("%s/.keys", attachmentPath) | |||
| 486 | + | if err := secrets.ValidatePath(path); err != nil { | |||
| 487 | + | return "", err | |||
| 488 | + | } | |||
| 489 | + | return path, nil | |||
| 490 | + | } | |||
| 491 | + | ||||
| 492 | + | func (s *PassStore) listAttachmentKeys(containerName string) ([]string, error) { | |||
| 493 | + | manifestPath, err := s.attachmentManifestPath(containerName) | |||
| 494 | + | if err != nil { | |||
| 495 | + | return nil, err | |||
| 496 | + | } | |||
| 497 | + | ||||
| 498 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 499 | + | defer cancel() | |||
| 500 | + | ||||
| 501 | + | content, exists, err := s.passShow(ctx, manifestPath) | |||
| 502 | + | if err != nil { | |||
| 503 | + | return nil, err | |||
| 504 | + | } | |||
| 505 | + | if !exists { | |||
| 506 | + | return []string{}, nil | |||
| 507 | + | } | |||
| 508 | + | ||||
| 509 | + | keys := []string{} | |||
| 510 | + | for _, line := range strings.Split(content, "\n") { | |||
| 511 | + | key := strings.TrimSpace(line) | |||
| 512 | + | if key == "" { | |||
| 513 | + | continue | |||
| 514 | + | } | |||
| 515 | + | keys = append(keys, key) | |||
| 516 | + | } | |||
| 517 | + | ||||
| 518 | + | return keys, nil | |||
| 519 | + | } | |||
| 520 | + | ||||
| 329 | func (s *PassStore) sanitizeDomain(domainName string) (string, error) { | 521 | func (s *PassStore) sanitizeDomain(domainName string) (string, error) { | ||
| 330 | safeDomain, err := domain.SanitizeDomainForEnvFile(domainName) | 522 | safeDomain, err := domain.SanitizeDomainForEnvFile(domainName) | ||
| 331 | if err != nil { | 523 | if err != nil { | ||
M internal/adapters/out/domainsecrets/store.go +214 -42
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "fmt" | |
| 7 | 7 | "os" | |
| 8 | 8 | "path/filepath" | |
| 9 | + | "sort" | |
| 9 | 10 | "strings" | |
| 10 | 11 | ||
| 11 | 12 | "github.com/bnema/zerowrap" | |
| @@ -148,6 +149,56 @@ func (s *FileStore) Delete(domainName, key string) error { | |||
| 148 | 149 | return s.writeSecretsAtomic(domainName, existing) | |
| 149 | 150 | } | |
| 150 | 151 | ||
| 152 | + | // SetAttachment sets or updates multiple secrets for an attachment container. | |
| 153 | + | func (s *FileStore) SetAttachment(containerName string, secrets map[string]string) error { | |
| 154 | + | // Validate container name first | |
| 155 | + | if _, err := s.validateAttachmentEnvFilePath(containerName); err != nil { | |
| 156 | + | return err | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // Ensure env directory exists | |
| 160 | + | if err := os.MkdirAll(s.envDir, 0700); err != nil { | |
| 161 | + | return fmt.Errorf("failed to create env directory: %w", err) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // Read existing secrets | |
| 165 | + | existing, err := s.GetAllAttachment(containerName) | |
| 166 | + | if err != nil { | |
| 167 | + | return err | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // Merge new secrets with existing | |
| 171 | + | for key, value := range secrets { | |
| 172 | + | existing[key] = value | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // Write back atomically | |
| 176 | + | return s.writeAttachmentSecretsAtomic(containerName, existing) | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | // GetAllAttachment returns all secrets for an attachment container as a key-value map. | |
| 180 | + | func (s *FileStore) GetAllAttachment(containerName string) (map[string]string, error) { | |
| 181 | + | envFile, err := s.validateAttachmentEnvFilePath(containerName) | |
| 182 | + | if err != nil { | |
| 183 | + | return nil, err | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | data, err := os.ReadFile(envFile) | |
| 187 | + | if err != nil { | |
| 188 | + | if os.IsNotExist(err) { | |
| 189 | + | return map[string]string{}, nil | |
| 190 | + | } | |
| 191 | + | return nil, fmt.Errorf("failed to read attachment env file: %w", err) | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | secrets, err := domain.ParseEnvData(data) | |
| 195 | + | if err != nil { | |
| 196 | + | return nil, fmt.Errorf("failed to parse attachment env file: %w", err) | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | return secrets, nil | |
| 200 | + | } | |
| 201 | + | ||
| 151 | 202 | // writeSecretsAtomic writes all secrets to the domain's env file atomically. | |
| 152 | 203 | // It writes to a temporary file first, syncs it, then renames to the final path. | |
| 153 | 204 | func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]string) error { | |
| @@ -174,9 +225,15 @@ func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]str | |||
| 174 | 225 | return fmt.Errorf("failed to write header: %w", err) | |
| 175 | 226 | } | |
| 176 | 227 | ||
| 177 | - | // Write each secret | |
| 178 | - | for key, value := range secrets { | |
| 179 | - | if _, err := fmt.Fprintf(file, "%s=%s\n", key, value); err != nil { | |
| 228 | + | // Write each secret in sorted order for deterministic output | |
| 229 | + | keys := make([]string, 0, len(secrets)) | |
| 230 | + | for key := range secrets { | |
| 231 | + | keys = append(keys, key) | |
| 232 | + | } | |
| 233 | + | sort.Strings(keys) | |
| 234 | + | ||
| 235 | + | for _, key := range keys { | |
| 236 | + | if _, err := fmt.Fprintf(file, "%s=%s\n", key, secrets[key]); err != nil { | |
| 180 | 237 | file.Close() | |
| 181 | 238 | os.Remove(tmpFile) | |
| 182 | 239 | return fmt.Errorf("failed to write secret %s: %w", key, err) | |
| @@ -248,12 +305,118 @@ func (s *FileStore) validateEnvFilePath(domainName string) (string, error) { | |||
| 248 | 305 | return path, nil | |
| 249 | 306 | } | |
| 250 | 307 | ||
| 308 | + | // getAttachmentEnvFilePath converts a container name to its attachment env file path. | |
| 309 | + | // Attachment env files follow the pattern: gordon-<containerName>.env | |
| 310 | + | // | |
| 311 | + | // SECURITY: Validates container name and ensures the resulting path stays within envDir. | |
| 312 | + | func (s *FileStore) getAttachmentEnvFilePath(containerName string) (string, error) { | |
| 313 | + | if err := domain.ValidateContainerName(containerName); err != nil { | |
| 314 | + | s.log.Warn(). | |
| 315 | + | Str(zerowrap.FieldLayer, "adapter"). | |
| 316 | + | Str(zerowrap.FieldAdapter, "domainsecrets"). | |
| 317 | + | Str("container", containerName). | |
| 318 | + | Err(err). | |
| 319 | + | Msg("rejected invalid container name") | |
| 320 | + | return "", err | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | fullPath := filepath.Join(s.envDir, "gordon-"+containerName+".env") | |
| 324 | + | ||
| 325 | + | // SECURITY: Final validation - ensure path stays within envDir | |
| 326 | + | cleanPath := filepath.Clean(fullPath) | |
| 327 | + | cleanEnvDir := filepath.Clean(s.envDir) | |
| 328 | + | if !strings.HasPrefix(cleanPath, cleanEnvDir+string(filepath.Separator)) { | |
| 329 | + | s.log.Error(). | |
| 330 | + | Str(zerowrap.FieldLayer, "adapter"). | |
| 331 | + | Str(zerowrap.FieldAdapter, "domainsecrets"). | |
| 332 | + | Str("container", containerName). | |
| 333 | + | Str("attempted_path", fullPath). | |
| 334 | + | Msg("path traversal attempt blocked - path escapes env directory") | |
| 335 | + | return "", domain.ErrPathTraversal | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | return fullPath, nil | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | // validateAttachmentEnvFilePath validates that a container name produces a valid attachment env file path. | |
| 342 | + | // Returns an error if the container name is invalid or would result in path traversal. | |
| 343 | + | func (s *FileStore) validateAttachmentEnvFilePath(containerName string) (string, error) { | |
| 344 | + | return s.getAttachmentEnvFilePath(containerName) | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | // writeAttachmentSecretsAtomic writes all secrets to the attachment's env file atomically. | |
| 348 | + | // It writes to a temporary file first, syncs it, then renames to the final path. | |
| 349 | + | func (s *FileStore) writeAttachmentSecretsAtomic(containerName string, secrets map[string]string) error { | |
| 350 | + | envFile, err := s.validateAttachmentEnvFilePath(containerName) | |
| 351 | + | if err != nil { | |
| 352 | + | return err | |
| 353 | + | } | |
| 354 | + | tmpFile := envFile + ".tmp" | |
| 355 | + | ||
| 356 | + | file, err := os.OpenFile(tmpFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) | |
| 357 | + | if err != nil { | |
| 358 | + | return fmt.Errorf("failed to create temp attachment env file: %w", err) | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | // Write header comment | |
| 362 | + | if _, err := fmt.Fprintf(file, "# Environment variables for attachment %s\n", containerName); err != nil { | |
| 363 | + | file.Close() | |
| 364 | + | os.Remove(tmpFile) | |
| 365 | + | return fmt.Errorf("failed to write header: %w", err) | |
| 366 | + | } | |
| 367 | + | if _, err := fmt.Fprintf(file, "# Managed by Gordon admin API\n\n"); err != nil { | |
| 368 | + | file.Close() | |
| 369 | + | os.Remove(tmpFile) | |
| 370 | + | return fmt.Errorf("failed to write header: %w", err) | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | // Write each secret in sorted order for deterministic output | |
| 374 | + | keys := make([]string, 0, len(secrets)) | |
| 375 | + | for key := range secrets { | |
| 376 | + | keys = append(keys, key) | |
| 377 | + | } | |
| 378 | + | sort.Strings(keys) | |
| 379 | + | ||
| 380 | + | for _, key := range keys { | |
| 381 | + | if _, err := fmt.Fprintf(file, "%s=%s\n", key, secrets[key]); err != nil { | |
| 382 | + | file.Close() | |
| 383 | + | os.Remove(tmpFile) | |
| 384 | + | return fmt.Errorf("failed to write secret %s: %w", key, err) | |
| 385 | + | } | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | // Sync to ensure data is on disk before rename | |
| 389 | + | if err := file.Sync(); err != nil { | |
| 390 | + | file.Close() | |
| 391 | + | os.Remove(tmpFile) | |
| 392 | + | return fmt.Errorf("failed to sync attachment env file: %w", err) | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | if err := file.Close(); err != nil { | |
| 396 | + | os.Remove(tmpFile) | |
| 397 | + | return fmt.Errorf("failed to close attachment env file: %w", err) | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | // Atomic rename | |
| 401 | + | if err := os.Rename(tmpFile, envFile); err != nil { | |
| 402 | + | os.Remove(tmpFile) | |
| 403 | + | return fmt.Errorf("failed to rename attachment env file: %w", err) | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | return nil | |
| 407 | + | } | |
| 408 | + | ||
| 251 | 409 | // ListAttachmentKeys finds attachment env files for a domain and returns their keys. | |
| 410 | + | // Supports both new (collision-resistant) and legacy container naming for backwards compatibility. | |
| 252 | 411 | // Attachment env files follow the naming pattern: gordon-{sanitized-domain}-{service}.env | |
| 253 | 412 | func (s *FileStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecrets, error) { | |
| 254 | - | // Sanitize domain the same way container service does | |
| 255 | - | sanitized := sanitizeDomainForContainer(domainName) | |
| 256 | - | prefix := "gordon-" + sanitized + "-" | |
| 413 | + | // Try both new and legacy sanitization for backwards compatibility | |
| 414 | + | sanitized := domain.SanitizeDomainForContainer(domainName) | |
| 415 | + | sanitizedLegacy := domain.SanitizeDomainForContainerLegacy(domainName) | |
| 416 | + | prefixes := []string{ | |
| 417 | + | "gordon-" + sanitized + "-", // New format (collision-resistant) | |
| 418 | + | "gordon-" + sanitizedLegacy + "-", // Old format (buggy but backwards compatible) | |
| 419 | + | } | |
| 257 | 420 | ||
| 258 | 421 | // List all files in env directory | |
| 259 | 422 | entries, err := os.ReadDir(s.envDir) | |
| @@ -264,57 +427,66 @@ func (s *FileStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecre | |||
| 264 | 427 | return nil, fmt.Errorf("failed to read env directory: %w", err) | |
| 265 | 428 | } | |
| 266 | 429 | ||
| 430 | + | seen := make(map[string]bool) | |
| 267 | 431 | var results []out.AttachmentSecrets | |
| 268 | 432 | for _, entry := range entries { | |
| 269 | 433 | if entry.IsDir() { | |
| 270 | 434 | continue | |
| 271 | 435 | } | |
| 272 | 436 | name := entry.Name() | |
| 273 | - | // Check if this is an attachment file for the domain | |
| 274 | - | if strings.HasPrefix(name, prefix) && strings.HasSuffix(name, ".env") { | |
| 275 | - | // Extract service name from filename | |
| 276 | - | // e.g., "gordon-git-bnema-dev-gitea-postgres.env" → "gitea-postgres" | |
| 277 | - | serviceName := strings.TrimPrefix(name, prefix) | |
| 278 | - | serviceName = strings.TrimSuffix(serviceName, ".env") | |
| 279 | - | if serviceName == "" { | |
| 280 | - | continue | |
| 281 | - | } | |
| 282 | 437 | ||
| 283 | - | // The full container name is the filename without .env | |
| 284 | - | containerName := strings.TrimSuffix(name, ".env") | |
| 285 | - | ||
| 286 | - | // Read keys from this file using existing method | |
| 287 | - | // Note: We use the container name directly since it matches the env file naming | |
| 288 | - | keys, err := s.listKeysFromFile(filepath.Join(s.envDir, name)) | |
| 289 | - | if err != nil { | |
| 290 | - | s.log.Warn(). | |
| 291 | - | Err(err). | |
| 292 | - | Str("file", name). | |
| 293 | - | Str("domain", domainName). | |
| 294 | - | Msg("failed to read attachment secrets file") | |
| 295 | - | continue | |
| 438 | + | // Check if this matches any of our prefixes | |
| 439 | + | var matchedPrefix string | |
| 440 | + | for _, prefix := range prefixes { | |
| 441 | + | if strings.HasPrefix(name, prefix) && strings.HasSuffix(name, ".env") { | |
| 442 | + | matchedPrefix = prefix | |
| 443 | + | break | |
| 296 | 444 | } | |
| 445 | + | } | |
| 446 | + | if matchedPrefix == "" { | |
| 447 | + | continue | |
| 448 | + | } | |
| 297 | 449 | ||
| 298 | - | if len(keys) > 0 { | |
| 299 | - | results = append(results, out.AttachmentSecrets{ | |
| 300 | - | Service: containerName, | |
| 301 | - | Keys: keys, | |
| 302 | - | }) | |
| 303 | - | } | |
| 450 | + | // Extract container name from filename | |
| 451 | + | containerName := strings.TrimSuffix(name, ".env") | |
| 452 | + | ||
| 453 | + | // Deduplicate results | |
| 454 | + | if seen[containerName] { | |
| 455 | + | continue | |
| 456 | + | } | |
| 457 | + | seen[containerName] = true | |
| 458 | + | ||
| 459 | + | // Extract service name from filename | |
| 460 | + | // e.g., "gordon-git-bnema-dev-gitea-postgres.env" → "gitea-postgres" | |
| 461 | + | serviceName := strings.TrimPrefix(name, matchedPrefix) | |
| 462 | + | serviceName = strings.TrimSuffix(serviceName, ".env") | |
| 463 | + | if serviceName == "" { | |
| 464 | + | continue | |
| 465 | + | } | |
| 466 | + | ||
| 467 | + | // Read keys from this file using existing method | |
| 468 | + | // Note: We use the container name directly since it matches the env file naming | |
| 469 | + | keys, err := s.listKeysFromFile(filepath.Join(s.envDir, name)) | |
| 470 | + | if err != nil { | |
| 471 | + | s.log.Warn(). | |
| 472 | + | Err(err). | |
| 473 | + | Str("file", name). | |
| 474 | + | Str("domain", domainName). | |
| 475 | + | Msg("failed to read attachment secrets file") | |
| 476 | + | continue | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | if len(keys) > 0 { | |
| 480 | + | results = append(results, out.AttachmentSecrets{ | |
| 481 | + | Service: containerName, | |
| 482 | + | Keys: keys, | |
| 483 | + | }) | |
| 304 | 484 | } | |
| 305 | 485 | } | |
| 306 | 486 | ||
| 307 | 487 | return results, nil | |
| 308 | 488 | } | |
| 309 | 489 | ||
| 310 | - | // sanitizeDomainForContainer matches the sanitizeName function in container service. | |
| 311 | - | func sanitizeDomainForContainer(domain string) string { | |
| 312 | - | result := strings.ReplaceAll(domain, ".", "-") | |
| 313 | - | result = strings.ReplaceAll(result, ":", "-") | |
| 314 | - | result = strings.ReplaceAll(result, "/", "-") | |
| 315 | - | return result | |
| 316 | - | } | |
| 317 | - | ||
| 318 | 490 | // listKeysFromFile reads secret keys from a specific env file path. | |
| 319 | 491 | func (s *FileStore) listKeysFromFile(filePath string) ([]string, error) { | |
| 320 | 492 | file, err := os.Open(filePath) | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "os" | 7 | "os" | ||
| 8 | "path/filepath" | 8 | "path/filepath" | ||
| 9 | + | "sort" | |||
| 9 | "strings" | 10 | "strings" | ||
| 10 | 11 | ||||
| 11 | "github.com/bnema/zerowrap" | 12 | "github.com/bnema/zerowrap" | ||
| @@ -148,6 +149,56 @@ func (s *FileStore) Delete(domainName, key string) error { | |||||
| 148 | return s.writeSecretsAtomic(domainName, existing) | 149 | return s.writeSecretsAtomic(domainName, existing) | ||
| 149 | } | 150 | } | ||
| 150 | 151 | ||||
| 152 | + | // SetAttachment sets or updates multiple secrets for an attachment container. | |||
| 153 | + | func (s *FileStore) SetAttachment(containerName string, secrets map[string]string) error { | |||
| 154 | + | // Validate container name first | |||
| 155 | + | if _, err := s.validateAttachmentEnvFilePath(containerName); err != nil { | |||
| 156 | + | return err | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | // Ensure env directory exists | |||
| 160 | + | if err := os.MkdirAll(s.envDir, 0700); err != nil { | |||
| 161 | + | return fmt.Errorf("failed to create env directory: %w", err) | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | // Read existing secrets | |||
| 165 | + | existing, err := s.GetAllAttachment(containerName) | |||
| 166 | + | if err != nil { | |||
| 167 | + | return err | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | // Merge new secrets with existing | |||
| 171 | + | for key, value := range secrets { | |||
| 172 | + | existing[key] = value | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | // Write back atomically | |||
| 176 | + | return s.writeAttachmentSecretsAtomic(containerName, existing) | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | // GetAllAttachment returns all secrets for an attachment container as a key-value map. | |||
| 180 | + | func (s *FileStore) GetAllAttachment(containerName string) (map[string]string, error) { | |||
| 181 | + | envFile, err := s.validateAttachmentEnvFilePath(containerName) | |||
| 182 | + | if err != nil { | |||
| 183 | + | return nil, err | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | data, err := os.ReadFile(envFile) | |||
| 187 | + | if err != nil { | |||
| 188 | + | if os.IsNotExist(err) { | |||
| 189 | + | return map[string]string{}, nil | |||
| 190 | + | } | |||
| 191 | + | return nil, fmt.Errorf("failed to read attachment env file: %w", err) | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | secrets, err := domain.ParseEnvData(data) | |||
| 195 | + | if err != nil { | |||
| 196 | + | return nil, fmt.Errorf("failed to parse attachment env file: %w", err) | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | return secrets, nil | |||
| 200 | + | } | |||
| 201 | + | ||||
| 151 | // writeSecretsAtomic writes all secrets to the domain's env file atomically. | 202 | // writeSecretsAtomic writes all secrets to the domain's env file atomically. | ||
| 152 | // It writes to a temporary file first, syncs it, then renames to the final path. | 203 | // It writes to a temporary file first, syncs it, then renames to the final path. | ||
| 153 | func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]string) error { | 204 | func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]string) error { | ||
| @@ -174,9 +225,15 @@ func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]str | |||||
| 174 | return fmt.Errorf("failed to write header: %w", err) | 225 | return fmt.Errorf("failed to write header: %w", err) | ||
| 175 | } | 226 | } | ||
| 176 | 227 | ||||
| 177 | - | // Write each secret | 228 | + | // Write each secret in sorted order for deterministic output |
| 178 | - | for key, value := range secrets { | 229 | + | keys := make([]string, 0, len(secrets)) |
| 179 | - | if _, err := fmt.Fprintf(file, "%s=%s\n", key, value); err != nil { | 230 | + | for key := range secrets { |
| 231 | + | keys = append(keys, key) | |||
| 232 | + | } | |||
| 233 | + | sort.Strings(keys) | |||
| 234 | + | ||||
| 235 | + | for _, key := range keys { | |||
| 236 | + | if _, err := fmt.Fprintf(file, "%s=%s\n", key, secrets[key]); err != nil { | |||
| 180 | file.Close() | 237 | file.Close() | ||
| 181 | os.Remove(tmpFile) | 238 | os.Remove(tmpFile) | ||
| 182 | return fmt.Errorf("failed to write secret %s: %w", key, err) | 239 | return fmt.Errorf("failed to write secret %s: %w", key, err) | ||
| @@ -248,12 +305,118 @@ func (s *FileStore) validateEnvFilePath(domainName string) (string, error) { | |||||
| 248 | return path, nil | 305 | return path, nil | ||
| 249 | } | 306 | } | ||
| 250 | 307 | ||||
| 308 | + | // getAttachmentEnvFilePath converts a container name to its attachment env file path. | |||
| 309 | + | // Attachment env files follow the pattern: gordon-<containerName>.env | |||
| 310 | + | // | |||
| 311 | + | // SECURITY: Validates container name and ensures the resulting path stays within envDir. | |||
| 312 | + | func (s *FileStore) getAttachmentEnvFilePath(containerName string) (string, error) { | |||
| 313 | + | if err := domain.ValidateContainerName(containerName); err != nil { | |||
| 314 | + | s.log.Warn(). | |||
| 315 | + | Str(zerowrap.FieldLayer, "adapter"). | |||
| 316 | + | Str(zerowrap.FieldAdapter, "domainsecrets"). | |||
| 317 | + | Str("container", containerName). | |||
| 318 | + | Err(err). | |||
| 319 | + | Msg("rejected invalid container name") | |||
| 320 | + | return "", err | |||
| 321 | + | } | |||
| 322 | + | ||||
| 323 | + | fullPath := filepath.Join(s.envDir, "gordon-"+containerName+".env") | |||
| 324 | + | ||||
| 325 | + | // SECURITY: Final validation - ensure path stays within envDir | |||
| 326 | + | cleanPath := filepath.Clean(fullPath) | |||
| 327 | + | cleanEnvDir := filepath.Clean(s.envDir) | |||
| 328 | + | if !strings.HasPrefix(cleanPath, cleanEnvDir+string(filepath.Separator)) { | |||
| 329 | + | s.log.Error(). | |||
| 330 | + | Str(zerowrap.FieldLayer, "adapter"). | |||
| 331 | + | Str(zerowrap.FieldAdapter, "domainsecrets"). | |||
| 332 | + | Str("container", containerName). | |||
| 333 | + | Str("attempted_path", fullPath). | |||
| 334 | + | Msg("path traversal attempt blocked - path escapes env directory") | |||
| 335 | + | return "", domain.ErrPathTraversal | |||
| 336 | + | } | |||
| 337 | + | ||||
| 338 | + | return fullPath, nil | |||
| 339 | + | } | |||
| 340 | + | ||||
| 341 | + | // validateAttachmentEnvFilePath validates that a container name produces a valid attachment env file path. | |||
| 342 | + | // Returns an error if the container name is invalid or would result in path traversal. | |||
| 343 | + | func (s *FileStore) validateAttachmentEnvFilePath(containerName string) (string, error) { | |||
| 344 | + | return s.getAttachmentEnvFilePath(containerName) | |||
| 345 | + | } | |||
| 346 | + | ||||
| 347 | + | // writeAttachmentSecretsAtomic writes all secrets to the attachment's env file atomically. | |||
| 348 | + | // It writes to a temporary file first, syncs it, then renames to the final path. | |||
| 349 | + | func (s *FileStore) writeAttachmentSecretsAtomic(containerName string, secrets map[string]string) error { | |||
| 350 | + | envFile, err := s.validateAttachmentEnvFilePath(containerName) | |||
| 351 | + | if err != nil { | |||
| 352 | + | return err | |||
| 353 | + | } | |||
| 354 | + | tmpFile := envFile + ".tmp" | |||
| 355 | + | ||||
| 356 | + | file, err := os.OpenFile(tmpFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) | |||
| 357 | + | if err != nil { | |||
| 358 | + | return fmt.Errorf("failed to create temp attachment env file: %w", err) | |||
| 359 | + | } | |||
| 360 | + | ||||
| 361 | + | // Write header comment | |||
| 362 | + | if _, err := fmt.Fprintf(file, "# Environment variables for attachment %s\n", containerName); err != nil { | |||
| 363 | + | file.Close() | |||
| 364 | + | os.Remove(tmpFile) | |||
| 365 | + | return fmt.Errorf("failed to write header: %w", err) | |||
| 366 | + | } | |||
| 367 | + | if _, err := fmt.Fprintf(file, "# Managed by Gordon admin API\n\n"); err != nil { | |||
| 368 | + | file.Close() | |||
| 369 | + | os.Remove(tmpFile) | |||
| 370 | + | return fmt.Errorf("failed to write header: %w", err) | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | // Write each secret in sorted order for deterministic output | |||
| 374 | + | keys := make([]string, 0, len(secrets)) | |||
| 375 | + | for key := range secrets { | |||
| 376 | + | keys = append(keys, key) | |||
| 377 | + | } | |||
| 378 | + | sort.Strings(keys) | |||
| 379 | + | ||||
| 380 | + | for _, key := range keys { | |||
| 381 | + | if _, err := fmt.Fprintf(file, "%s=%s\n", key, secrets[key]); err != nil { | |||
| 382 | + | file.Close() | |||
| 383 | + | os.Remove(tmpFile) | |||
| 384 | + | return fmt.Errorf("failed to write secret %s: %w", key, err) | |||
| 385 | + | } | |||
| 386 | + | } | |||
| 387 | + | ||||
| 388 | + | // Sync to ensure data is on disk before rename | |||
| 389 | + | if err := file.Sync(); err != nil { | |||
| 390 | + | file.Close() | |||
| 391 | + | os.Remove(tmpFile) | |||
| 392 | + | return fmt.Errorf("failed to sync attachment env file: %w", err) | |||
| 393 | + | } | |||
| 394 | + | ||||
| 395 | + | if err := file.Close(); err != nil { | |||
| 396 | + | os.Remove(tmpFile) | |||
| 397 | + | return fmt.Errorf("failed to close attachment env file: %w", err) | |||
| 398 | + | } | |||
| 399 | + | ||||
| 400 | + | // Atomic rename | |||
| 401 | + | if err := os.Rename(tmpFile, envFile); err != nil { | |||
| 402 | + | os.Remove(tmpFile) | |||
| 403 | + | return fmt.Errorf("failed to rename attachment env file: %w", err) | |||
| 404 | + | } | |||
| 405 | + | ||||
| 406 | + | return nil | |||
| 407 | + | } | |||
| 408 | + | ||||
| 251 | // ListAttachmentKeys finds attachment env files for a domain and returns their keys. | 409 | // ListAttachmentKeys finds attachment env files for a domain and returns their keys. | ||
| 410 | + | // Supports both new (collision-resistant) and legacy container naming for backwards compatibility. | |||
| 252 | // Attachment env files follow the naming pattern: gordon-{sanitized-domain}-{service}.env | 411 | // Attachment env files follow the naming pattern: gordon-{sanitized-domain}-{service}.env | ||
| 253 | func (s *FileStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecrets, error) { | 412 | func (s *FileStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecrets, error) { | ||
| 254 | - | // Sanitize domain the same way container service does | 413 | + | // Try both new and legacy sanitization for backwards compatibility |
| 255 | - | sanitized := sanitizeDomainForContainer(domainName) | 414 | + | sanitized := domain.SanitizeDomainForContainer(domainName) |
| 256 | - | prefix := "gordon-" + sanitized + "-" | 415 | + | sanitizedLegacy := domain.SanitizeDomainForContainerLegacy(domainName) |
| 416 | + | prefixes := []string{ | |||
| 417 | + | "gordon-" + sanitized + "-", // New format (collision-resistant) | |||
| 418 | + | "gordon-" + sanitizedLegacy + "-", // Old format (buggy but backwards compatible) | |||
| 419 | + | } | |||
| 257 | 420 | ||||
| 258 | // List all files in env directory | 421 | // List all files in env directory | ||
| 259 | entries, err := os.ReadDir(s.envDir) | 422 | entries, err := os.ReadDir(s.envDir) | ||
| @@ -264,57 +427,66 @@ func (s *FileStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecre | |||||
| 264 | return nil, fmt.Errorf("failed to read env directory: %w", err) | 427 | return nil, fmt.Errorf("failed to read env directory: %w", err) | ||
| 265 | } | 428 | } | ||
| 266 | 429 | ||||
| 430 | + | seen := make(map[string]bool) | |||
| 267 | var results []out.AttachmentSecrets | 431 | var results []out.AttachmentSecrets | ||
| 268 | for _, entry := range entries { | 432 | for _, entry := range entries { | ||
| 269 | if entry.IsDir() { | 433 | if entry.IsDir() { | ||
| 270 | continue | 434 | continue | ||
| 271 | } | 435 | } | ||
| 272 | name := entry.Name() | 436 | name := entry.Name() | ||
| 273 | - | // Check if this is an attachment file for the domain | |||
| 274 | - | if strings.HasPrefix(name, prefix) && strings.HasSuffix(name, ".env") { | |||
| 275 | - | // Extract service name from filename | |||
| 276 | - | // e.g., "gordon-git-bnema-dev-gitea-postgres.env" → "gitea-postgres" | |||
| 277 | - | serviceName := strings.TrimPrefix(name, prefix) | |||
| 278 | - | serviceName = strings.TrimSuffix(serviceName, ".env") | |||
| 279 | - | if serviceName == "" { | |||
| 280 | - | continue | |||
| 281 | - | } | |||
| 282 | 437 | ||||
| 283 | - | // The full container name is the filename without .env | 438 | + | // Check if this matches any of our prefixes |
| 284 | - | containerName := strings.TrimSuffix(name, ".env") | 439 | + | var matchedPrefix string |
| 285 | - | 440 | + | for _, prefix := range prefixes { | |
| 286 | - | // Read keys from this file using existing method | 441 | + | if strings.HasPrefix(name, prefix) && strings.HasSuffix(name, ".env") { |
| 287 | - | // Note: We use the container name directly since it matches the env file naming | 442 | + | matchedPrefix = prefix |
| 288 | - | keys, err := s.listKeysFromFile(filepath.Join(s.envDir, name)) | 443 | + | break |
| 289 | - | if err != nil { | |||
| 290 | - | s.log.Warn(). | |||
| 291 | - | Err(err). | |||
| 292 | - | Str("file", name). | |||
| 293 | - | Str("domain", domainName). | |||
| 294 | - | Msg("failed to read attachment secrets file") | |||
| 295 | - | continue | |||
| 296 | } | 444 | } | ||
| 445 | + | } | |||
| 446 | + | if matchedPrefix == "" { | |||
| 447 | + | continue | |||
| 448 | + | } | |||
| 297 | 449 | ||||
| 298 | - | if len(keys) > 0 { | 450 | + | // Extract container name from filename |
| 299 | - | results = append(results, out.AttachmentSecrets{ | 451 | + | containerName := strings.TrimSuffix(name, ".env") |
| 300 | - | Service: containerName, | 452 | + | |
| 301 | - | Keys: keys, | 453 | + | // Deduplicate results |
| 302 | - | }) | 454 | + | if seen[containerName] { |
| 303 | - | } | 455 | + | continue |
| 456 | + | } | |||
| 457 | + | seen[containerName] = true | |||
| 458 | + | ||||
| 459 | + | // Extract service name from filename | |||
| 460 | + | // e.g., "gordon-git-bnema-dev-gitea-postgres.env" → "gitea-postgres" | |||
| 461 | + | serviceName := strings.TrimPrefix(name, matchedPrefix) | |||
| 462 | + | serviceName = strings.TrimSuffix(serviceName, ".env") | |||
| 463 | + | if serviceName == "" { | |||
| 464 | + | continue | |||
| 465 | + | } | |||
| 466 | + | ||||
| 467 | + | // Read keys from this file using existing method | |||
| 468 | + | // Note: We use the container name directly since it matches the env file naming | |||
| 469 | + | keys, err := s.listKeysFromFile(filepath.Join(s.envDir, name)) | |||
| 470 | + | if err != nil { | |||
| 471 | + | s.log.Warn(). | |||
| 472 | + | Err(err). | |||
| 473 | + | Str("file", name). | |||
| 474 | + | Str("domain", domainName). | |||
| 475 | + | Msg("failed to read attachment secrets file") | |||
| 476 | + | continue | |||
| 477 | + | } | |||
| 478 | + | ||||
| 479 | + | if len(keys) > 0 { | |||
| 480 | + | results = append(results, out.AttachmentSecrets{ | |||
| 481 | + | Service: containerName, | |||
| 482 | + | Keys: keys, | |||
| 483 | + | }) | |||
| 304 | } | 484 | } | ||
| 305 | } | 485 | } | ||
| 306 | 486 | ||||
| 307 | return results, nil | 487 | return results, nil | ||
| 308 | } | 488 | } | ||
| 309 | 489 | ||||
| 310 | - | // sanitizeDomainForContainer matches the sanitizeName function in container service. | |||
| 311 | - | func sanitizeDomainForContainer(domain string) string { | |||
| 312 | - | result := strings.ReplaceAll(domain, ".", "-") | |||
| 313 | - | result = strings.ReplaceAll(result, ":", "-") | |||
| 314 | - | result = strings.ReplaceAll(result, "/", "-") | |||
| 315 | - | return result | |||
| 316 | - | } | |||
| 317 | - | ||||
| 318 | // listKeysFromFile reads secret keys from a specific env file path. | 490 | // listKeysFromFile reads secret keys from a specific env file path. | ||
| 319 | func (s *FileStore) listKeysFromFile(filePath string) ([]string, error) { | 491 | func (s *FileStore) listKeysFromFile(filePath string) ([]string, error) { | ||
| 320 | file, err := os.Open(filePath) | 492 | file, err := os.Open(filePath) | ||
A internal/domain/sanitize.go +38 -0
| @@ -0,0 +1,38 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | import "strings" | |
| 4 | + | ||
| 5 | + | // SanitizeDomainForContainer makes a domain safe for container naming. | |
| 6 | + | // It uses distinct replacements to avoid collisions between domains like | |
| 7 | + | // "git.example.com" and "git-example.com" (which would both become "git-example-com" | |
| 8 | + | // if we naively replaced all separators with hyphens). | |
| 9 | + | // | |
| 10 | + | // Replacements: | |
| 11 | + | // | |
| 12 | + | // . → __ (double underscore, distinct from single _ in some conventions) | |
| 13 | + | // : → -_ (hyphen-underscore combo, used for ports) | |
| 14 | + | // / → -- (double hyphen, distinct from colon to avoid :/ collisions) | |
| 15 | + | func SanitizeDomainForContainer(domain string) string { | |
| 16 | + | result := strings.ReplaceAll(domain, ".", "__") | |
| 17 | + | result = strings.ReplaceAll(result, ":", "-_") | |
| 18 | + | result = strings.ReplaceAll(result, "/", "--") | |
| 19 | + | return result | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // SanitizeDomainForContainerLegacy uses the OLD (buggy) sanitization that replaces | |
| 23 | + | // all separators with hyphens, for backwards compatibility. | |
| 24 | + | // | |
| 25 | + | // IMPORTANT: This is maintained ONLY for finding old containers during upgrades. | |
| 26 | + | // New containers always use the collision-resistant SanitizeDomainForContainer(). | |
| 27 | + | // | |
| 28 | + | // Migration strategy: | |
| 29 | + | // - During binary upgrades, legacy containers are found using this function | |
| 30 | + | // - Old containers are stopped and removed, then replaced with new containers | |
| 31 | + | // - New containers use the collision-resistant SanitizeDomainForContainer() naming | |
| 32 | + | // - After all deployments are upgraded, this function can be removed in a major version | |
| 33 | + | func SanitizeDomainForContainerLegacy(domain string) string { | |
| 34 | + | result := strings.ReplaceAll(domain, ".", "-") | |
| 35 | + | result = strings.ReplaceAll(result, ":", "-") | |
| 36 | + | result = strings.ReplaceAll(result, "/", "-") | |
| 37 | + | return result | |
| 38 | + | } | |
| @@ -0,0 +1,38 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import "strings" | |||
| 4 | + | ||||
| 5 | + | // SanitizeDomainForContainer makes a domain safe for container naming. | |||
| 6 | + | // It uses distinct replacements to avoid collisions between domains like | |||
| 7 | + | // "git.example.com" and "git-example.com" (which would both become "git-example-com" | |||
| 8 | + | // if we naively replaced all separators with hyphens). | |||
| 9 | + | // | |||
| 10 | + | // Replacements: | |||
| 11 | + | // | |||
| 12 | + | // . → __ (double underscore, distinct from single _ in some conventions) | |||
| 13 | + | // : → -_ (hyphen-underscore combo, used for ports) | |||
| 14 | + | // / → -- (double hyphen, distinct from colon to avoid :/ collisions) | |||
| 15 | + | func SanitizeDomainForContainer(domain string) string { | |||
| 16 | + | result := strings.ReplaceAll(domain, ".", "__") | |||
| 17 | + | result = strings.ReplaceAll(result, ":", "-_") | |||
| 18 | + | result = strings.ReplaceAll(result, "/", "--") | |||
| 19 | + | return result | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // SanitizeDomainForContainerLegacy uses the OLD (buggy) sanitization that replaces | |||
| 23 | + | // all separators with hyphens, for backwards compatibility. | |||
| 24 | + | // | |||
| 25 | + | // IMPORTANT: This is maintained ONLY for finding old containers during upgrades. | |||
| 26 | + | // New containers always use the collision-resistant SanitizeDomainForContainer(). | |||
| 27 | + | // | |||
| 28 | + | // Migration strategy: | |||
| 29 | + | // - During binary upgrades, legacy containers are found using this function | |||
| 30 | + | // - Old containers are stopped and removed, then replaced with new containers | |||
| 31 | + | // - New containers use the collision-resistant SanitizeDomainForContainer() naming | |||
| 32 | + | // - After all deployments are upgraded, this function can be removed in a major version | |||
| 33 | + | func SanitizeDomainForContainerLegacy(domain string) string { | |||
| 34 | + | result := strings.ReplaceAll(domain, ".", "-") | |||
| 35 | + | result = strings.ReplaceAll(result, ":", "-") | |||
| 36 | + | result = strings.ReplaceAll(result, "/", "-") | |||
| 37 | + | return result | |||
| 38 | + | } | |||
M internal/usecase/container/service.go +27 -8
| @@ -1095,7 +1095,25 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||
| 1095 | 1095 | }) | |
| 1096 | 1096 | ||
| 1097 | 1097 | // Check if already running (idempotent) | |
| 1098 | + | // Try new name first, then fall back to legacy name for backwards compatibility | |
| 1098 | 1099 | existingContainer := s.findContainerByName(ctx, containerName) | |
| 1100 | + | if existingContainer == nil { | |
| 1101 | + | // Try legacy naming scheme for backwards compatibility during upgrades | |
| 1102 | + | containerNameLegacy := fmt.Sprintf("gordon-%s-%s", sanitizeNameLegacy(ownerDomain), serviceName) | |
| 1103 | + | existingContainer = s.findContainerByName(ctx, containerNameLegacy) | |
| 1104 | + | if existingContainer != nil { | |
| 1105 | + | log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced") | |
| 1106 | + | // Remove the old container and recreate with new name | |
| 1107 | + | if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil { | |
| 1108 | + | return log.WrapErr(err, "failed to stop legacy attachment container") | |
| 1109 | + | } | |
| 1110 | + | if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil { | |
| 1111 | + | return log.WrapErr(err, "failed to remove legacy attachment container") | |
| 1112 | + | } | |
| 1113 | + | existingContainer = nil // Clear so we create new one below | |
| 1114 | + | } | |
| 1115 | + | } | |
| 1116 | + | ||
| 1099 | 1117 | if existingContainer != nil && existingContainer.Status == string(domain.ContainerStatusRunning) { | |
| 1100 | 1118 | log.Debug().Str("container_name", containerName).Msg("attachment already running, skipping") | |
| 1101 | 1119 | return nil | |
| @@ -1105,7 +1123,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||
| 1105 | 1123 | if existingContainer != nil { | |
| 1106 | 1124 | log.Info().Str("container_name", containerName).Msg("removing stopped attachment container") | |
| 1107 | 1125 | if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil { | |
| 1108 | - | log.WrapErr(err, "failed to remove existing attachment container") | |
| 1126 | + | return log.WrapErr(err, "failed to remove existing attachment container") | |
| 1109 | 1127 | } | |
| 1110 | 1128 | } | |
| 1111 | 1129 | ||
| @@ -1306,13 +1324,14 @@ func extractServiceName(image string) string { | |||
| 1306 | 1324 | return name | |
| 1307 | 1325 | } | |
| 1308 | 1326 | ||
| 1309 | - | // sanitizeName makes a domain safe for container naming. | |
| 1310 | - | func sanitizeName(domain string) string { | |
| 1311 | - | // Replace dots and other special chars with dashes | |
| 1312 | - | result := strings.ReplaceAll(domain, ".", "-") | |
| 1313 | - | result = strings.ReplaceAll(result, ":", "-") | |
| 1314 | - | result = strings.ReplaceAll(result, "/", "-") | |
| 1315 | - | return result | |
| 1327 | + | // sanitizeName is a convenience wrapper around domain.SanitizeDomainForContainer. | |
| 1328 | + | func sanitizeName(d string) string { | |
| 1329 | + | return domain.SanitizeDomainForContainer(d) | |
| 1330 | + | } | |
| 1331 | + | ||
| 1332 | + | // sanitizeNameLegacy is a convenience wrapper around domain.SanitizeDomainForContainerLegacy. | |
| 1333 | + | func sanitizeNameLegacy(d string) string { | |
| 1334 | + | return domain.SanitizeDomainForContainerLegacy(d) | |
| 1316 | 1335 | } | |
| 1317 | 1336 | ||
| 1318 | 1337 | // findContainerByName finds a container by its name. | |
| @@ -1095,7 +1095,25 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||||
| 1095 | }) | 1095 | }) | ||
| 1096 | 1096 | ||||
| 1097 | // Check if already running (idempotent) | 1097 | // Check if already running (idempotent) | ||
| 1098 | + | // Try new name first, then fall back to legacy name for backwards compatibility | |||
| 1098 | existingContainer := s.findContainerByName(ctx, containerName) | 1099 | existingContainer := s.findContainerByName(ctx, containerName) | ||
| 1100 | + | if existingContainer == nil { | |||
| 1101 | + | // Try legacy naming scheme for backwards compatibility during upgrades | |||
| 1102 | + | containerNameLegacy := fmt.Sprintf("gordon-%s-%s", sanitizeNameLegacy(ownerDomain), serviceName) | |||
| 1103 | + | existingContainer = s.findContainerByName(ctx, containerNameLegacy) | |||
| 1104 | + | if existingContainer != nil { | |||
| 1105 | + | log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced") | |||
| 1106 | + | // Remove the old container and recreate with new name | |||
| 1107 | + | if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil { | |||
| 1108 | + | return log.WrapErr(err, "failed to stop legacy attachment container") | |||
| 1109 | + | } | |||
| 1110 | + | if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil { | |||
| 1111 | + | return log.WrapErr(err, "failed to remove legacy attachment container") | |||
| 1112 | + | } | |||
| 1113 | + | existingContainer = nil // Clear so we create new one below | |||
| 1114 | + | } | |||
| 1115 | + | } | |||
| 1116 | + | ||||
| 1099 | if existingContainer != nil && existingContainer.Status == string(domain.ContainerStatusRunning) { | 1117 | if existingContainer != nil && existingContainer.Status == string(domain.ContainerStatusRunning) { | ||
| 1100 | log.Debug().Str("container_name", containerName).Msg("attachment already running, skipping") | 1118 | log.Debug().Str("container_name", containerName).Msg("attachment already running, skipping") | ||
| 1101 | return nil | 1119 | return nil | ||
| @@ -1105,7 +1123,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||||
| 1105 | if existingContainer != nil { | 1123 | if existingContainer != nil { | ||
| 1106 | log.Info().Str("container_name", containerName).Msg("removing stopped attachment container") | 1124 | log.Info().Str("container_name", containerName).Msg("removing stopped attachment container") | ||
| 1107 | if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil { | 1125 | if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil { | ||
| 1108 | - | log.WrapErr(err, "failed to remove existing attachment container") | 1126 | + | return log.WrapErr(err, "failed to remove existing attachment container") |
| 1109 | } | 1127 | } | ||
| 1110 | } | 1128 | } | ||
| 1111 | 1129 | ||||
| @@ -1306,13 +1324,14 @@ func extractServiceName(image string) string { | |||||
| 1306 | return name | 1324 | return name | ||
| 1307 | } | 1325 | } | ||
| 1308 | 1326 | ||||
| 1309 | - | // sanitizeName makes a domain safe for container naming. | 1327 | + | // sanitizeName is a convenience wrapper around domain.SanitizeDomainForContainer. |
| 1310 | - | func sanitizeName(domain string) string { | 1328 | + | func sanitizeName(d string) string { |
| 1311 | - | // Replace dots and other special chars with dashes | 1329 | + | return domain.SanitizeDomainForContainer(d) |
| 1312 | - | result := strings.ReplaceAll(domain, ".", "-") | 1330 | + | } |
| 1313 | - | result = strings.ReplaceAll(result, ":", "-") | 1331 | + | |
| 1314 | - | result = strings.ReplaceAll(result, "/", "-") | 1332 | + | // sanitizeNameLegacy is a convenience wrapper around domain.SanitizeDomainForContainerLegacy. |
| 1315 | - | return result | 1333 | + | func sanitizeNameLegacy(d string) string { |
| 1334 | + | return domain.SanitizeDomainForContainerLegacy(d) | |||
| 1316 | } | 1335 | } | ||
| 1317 | 1336 | ||||
| 1318 | // findContainerByName finds a container by its name. | 1337 | // findContainerByName finds a container by its name. | ||