Merge pull request #89 from bnema/fix/gh-issues-83-84-85-86-87-88
fix: address GitHub issues #83–#88 + security & reliability bugs
45 files · +1857 -602
M .github/workflows/ci.yml +1 -1
| @@ -47,7 +47,7 @@ jobs: | |||
| 47 | 47 | - name: Run golangci-lint | |
| 48 | 48 | uses: golangci/golangci-lint-action@v9 | |
| 49 | 49 | with: | |
| 50 | - | version: latest | |
| 50 | + | version: v2.10.1 | |
| 51 | 51 | ||
| 52 | 52 | test: | |
| 53 | 53 | name: Test | |
| @@ -47,7 +47,7 @@ jobs: | |||||
| 47 | - name: Run golangci-lint | 47 | - name: Run golangci-lint | ||
| 48 | uses: golangci/golangci-lint-action@v9 | 48 | uses: golangci/golangci-lint-action@v9 | ||
| 49 | with: | 49 | with: | ||
| 50 | - | version: latest | 50 | + | version: v2.10.1 |
| 51 | 51 | ||||
| 52 | test: | 52 | test: | ||
| 53 | name: Test | 53 | name: Test | ||
M .golangci.yml +20 -0
| @@ -58,6 +58,26 @@ linters: | |||
| 58 | 58 | - text: "G104:" | |
| 59 | 59 | linters: | |
| 60 | 60 | - gosec | |
| 61 | + | # gosec G117: exported struct fields matching secret-name patterns are intentional DTOs | |
| 62 | + | # (OCI registry tokens, CLI credential request bodies, telemetry config, JWT response frames). | |
| 63 | + | # These fields must be exported and carry credentials by design — they are not accidental leaks. | |
| 64 | + | - text: "G117:" | |
| 65 | + | linters: | |
| 66 | + | - gosec | |
| 67 | + | # gosec G703/G704/G705: taint-analysis false positives for a CLI tool and reverse proxy. | |
| 68 | + | # G704 (SSRF): target URLs come from operator-configured routing tables or explicit CLI flags, | |
| 69 | + | # not from untrusted user input. G703 (path traversal): XDG_RUNTIME_DIR is a trusted env var | |
| 70 | + | # set by the login session, not user-supplied. G705 (XSS): registry manifest data is binary | |
| 71 | + | # OCI content served with an explicit Content-Type, not rendered as HTML. | |
| 72 | + | - text: "G703:" | |
| 73 | + | linters: | |
| 74 | + | - gosec | |
| 75 | + | - text: "G704:" | |
| 76 | + | linters: | |
| 77 | + | - gosec | |
| 78 | + | - text: "G705:" | |
| 79 | + | linters: | |
| 80 | + | - gosec | |
| 61 | 81 | ||
| 62 | 82 | formatters: | |
| 63 | 83 | enable: | |
| @@ -58,6 +58,26 @@ linters: | |||||
| 58 | - text: "G104:" | 58 | - text: "G104:" | ||
| 59 | linters: | 59 | linters: | ||
| 60 | - gosec | 60 | - gosec | ||
| 61 | + | # gosec G117: exported struct fields matching secret-name patterns are intentional DTOs | |||
| 62 | + | # (OCI registry tokens, CLI credential request bodies, telemetry config, JWT response frames). | |||
| 63 | + | # These fields must be exported and carry credentials by design — they are not accidental leaks. | |||
| 64 | + | - text: "G117:" | |||
| 65 | + | linters: | |||
| 66 | + | - gosec | |||
| 67 | + | # gosec G703/G704/G705: taint-analysis false positives for a CLI tool and reverse proxy. | |||
| 68 | + | # G704 (SSRF): target URLs come from operator-configured routing tables or explicit CLI flags, | |||
| 69 | + | # not from untrusted user input. G703 (path traversal): XDG_RUNTIME_DIR is a trusted env var | |||
| 70 | + | # set by the login session, not user-supplied. G705 (XSS): registry manifest data is binary | |||
| 71 | + | # OCI content served with an explicit Content-Type, not rendered as HTML. | |||
| 72 | + | - text: "G703:" | |||
| 73 | + | linters: | |||
| 74 | + | - gosec | |||
| 75 | + | - text: "G704:" | |||
| 76 | + | linters: | |||
| 77 | + | - gosec | |||
| 78 | + | - text: "G705:" | |||
| 79 | + | linters: | |||
| 80 | + | - gosec | |||
| 61 | 81 | ||||
| 62 | formatters: | 82 | formatters: | ||
| 63 | enable: | 83 | enable: | ||
M internal/adapters/dto/registry_token.go +1 -1
| @@ -3,7 +3,7 @@ package dto | |||
| 3 | 3 | // TokenResponse represents the response from the token server. | |
| 4 | 4 | type TokenResponse struct { | |
| 5 | 5 | Token string `json:"token"` | |
| 6 | - | AccessToken string `json:"access_token,omitempty"` | |
| 6 | + | AccessToken string `json:"access_token,omitempty"` //nolint:gosec // OCI token response field, required by Docker Registry API v2 spec | |
| 7 | 7 | ExpiresIn int `json:"expires_in,omitempty"` | |
| 8 | 8 | IssuedAt string `json:"issued_at,omitempty"` | |
| 9 | 9 | } | |
| @@ -3,7 +3,7 @@ package dto | |||||
| 3 | // TokenResponse represents the response from the token server. | 3 | // TokenResponse represents the response from the token server. | ||
| 4 | type TokenResponse struct { | 4 | type TokenResponse struct { | ||
| 5 | Token string `json:"token"` | 5 | Token string `json:"token"` | ||
| 6 | - | AccessToken string `json:"access_token,omitempty"` | 6 | + | AccessToken string `json:"access_token,omitempty"` //nolint:gosec // OCI token response field, required by Docker Registry API v2 spec |
| 7 | ExpiresIn int `json:"expires_in,omitempty"` | 7 | ExpiresIn int `json:"expires_in,omitempty"` | ||
| 8 | IssuedAt string `json:"issued_at,omitempty"` | 8 | IssuedAt string `json:"issued_at,omitempty"` | ||
| 9 | } | 9 | } | ||
M internal/adapters/in/cli/auth.go +20 -2
| @@ -24,6 +24,16 @@ import ( | |||
| 24 | 24 | "github.com/bnema/gordon/pkg/duration" | |
| 25 | 25 | ) | |
| 26 | 26 | ||
| 27 | + | // stdinFD returns os.Stdin's file descriptor as an int, guarded against | |
| 28 | + | // uintptr overflow on platforms where uintptr > int. | |
| 29 | + | func stdinFD() (int, error) { | |
| 30 | + | fd := os.Stdin.Fd() | |
| 31 | + | if fd > uintptr(^uint(0)>>1) { | |
| 32 | + | return 0, fmt.Errorf("stdin fd value %d overflows int", fd) | |
| 33 | + | } | |
| 34 | + | return int(fd), nil | |
| 35 | + | } | |
| 36 | + | ||
| 27 | 37 | // newAuthCmd creates the auth command group. | |
| 28 | 38 | func newAuthCmd() *cobra.Command { | |
| 29 | 39 | cmd := &cobra.Command{ | |
| @@ -274,7 +284,11 @@ func runAuthLogin(remoteName, username, password string) error { | |||
| 274 | 284 | if password == "" { | |
| 275 | 285 | // Prompt for password (hidden input) | |
| 276 | 286 | fmt.Print("Password: ") | |
| 277 | - | passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd())) | |
| 287 | + | fd, err := stdinFD() | |
| 288 | + | if err != nil { | |
| 289 | + | return fmt.Errorf("failed to get stdin fd: %w", err) | |
| 290 | + | } | |
| 291 | + | passwordBytes, err := term.ReadPassword(fd) | |
| 278 | 292 | if err != nil { | |
| 279 | 293 | // Fallback for non-terminal input | |
| 280 | 294 | reader := bufio.NewReader(os.Stdin) | |
| @@ -595,7 +609,11 @@ func runPasswordHash() error { | |||
| 595 | 609 | fmt.Print("Enter password: ") | |
| 596 | 610 | ||
| 597 | 611 | // Read password without echo (use os.Stdin.Fd() for better compatibility) | |
| 598 | - | passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd())) | |
| 612 | + | fd, err := stdinFD() | |
| 613 | + | if err != nil { | |
| 614 | + | return fmt.Errorf("failed to get stdin fd: %w", err) | |
| 615 | + | } | |
| 616 | + | passwordBytes, err := term.ReadPassword(fd) | |
| 599 | 617 | if err != nil { | |
| 600 | 618 | // Fallback for non-terminal input | |
| 601 | 619 | reader := bufio.NewReader(os.Stdin) | |
| @@ -24,6 +24,16 @@ import ( | |||||
| 24 | "github.com/bnema/gordon/pkg/duration" | 24 | "github.com/bnema/gordon/pkg/duration" | ||
| 25 | ) | 25 | ) | ||
| 26 | 26 | ||||
| 27 | + | // stdinFD returns os.Stdin's file descriptor as an int, guarded against | |||
| 28 | + | // uintptr overflow on platforms where uintptr > int. | |||
| 29 | + | func stdinFD() (int, error) { | |||
| 30 | + | fd := os.Stdin.Fd() | |||
| 31 | + | if fd > uintptr(^uint(0)>>1) { | |||
| 32 | + | return 0, fmt.Errorf("stdin fd value %d overflows int", fd) | |||
| 33 | + | } | |||
| 34 | + | return int(fd), nil | |||
| 35 | + | } | |||
| 36 | + | ||||
| 27 | // newAuthCmd creates the auth command group. | 37 | // newAuthCmd creates the auth command group. | ||
| 28 | func newAuthCmd() *cobra.Command { | 38 | func newAuthCmd() *cobra.Command { | ||
| 29 | cmd := &cobra.Command{ | 39 | cmd := &cobra.Command{ | ||
| @@ -274,7 +284,11 @@ func runAuthLogin(remoteName, username, password string) error { | |||||
| 274 | if password == "" { | 284 | if password == "" { | ||
| 275 | // Prompt for password (hidden input) | 285 | // Prompt for password (hidden input) | ||
| 276 | fmt.Print("Password: ") | 286 | fmt.Print("Password: ") | ||
| 277 | - | passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd())) | 287 | + | fd, err := stdinFD() |
| 288 | + | if err != nil { | |||
| 289 | + | return fmt.Errorf("failed to get stdin fd: %w", err) | |||
| 290 | + | } | |||
| 291 | + | passwordBytes, err := term.ReadPassword(fd) | |||
| 278 | if err != nil { | 292 | if err != nil { | ||
| 279 | // Fallback for non-terminal input | 293 | // Fallback for non-terminal input | ||
| 280 | reader := bufio.NewReader(os.Stdin) | 294 | reader := bufio.NewReader(os.Stdin) | ||
| @@ -595,7 +609,11 @@ func runPasswordHash() error { | |||||
| 595 | fmt.Print("Enter password: ") | 609 | fmt.Print("Enter password: ") | ||
| 596 | 610 | ||||
| 597 | // Read password without echo (use os.Stdin.Fd() for better compatibility) | 611 | // Read password without echo (use os.Stdin.Fd() for better compatibility) | ||
| 598 | - | passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd())) | 612 | + | fd, err := stdinFD() |
| 613 | + | if err != nil { | |||
| 614 | + | return fmt.Errorf("failed to get stdin fd: %w", err) | |||
| 615 | + | } | |||
| 616 | + | passwordBytes, err := term.ReadPassword(fd) | |||
| 599 | if err != nil { | 617 | if err != nil { | ||
| 600 | // Fallback for non-terminal input | 618 | // Fallback for non-terminal input | ||
| 601 | reader := bufio.NewReader(os.Stdin) | 619 | reader := bufio.NewReader(os.Stdin) | ||
M internal/adapters/in/cli/push.go +40 -7
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "path/filepath" | |
| 10 | 10 | "regexp" | |
| 11 | 11 | "strings" | |
| 12 | + | "time" | |
| 12 | 13 | ||
| 13 | 14 | tea "github.com/charmbracelet/bubbletea" | |
| 14 | 15 | "github.com/spf13/cobra" | |
| @@ -463,8 +464,8 @@ func buildAndPush(ctx context.Context, version, platform, dockerfile string, bui | |||
| 463 | 464 | // Cloudflare's 100MB per-request limit. Loading locally then | |
| 464 | 465 | // using docker push gives us chunked uploads (~5MB per request). | |
| 465 | 466 | fmt.Println("\nBuilding image...") | |
| 466 | - | buildCmd := exec.CommandContext(ctx, "docker", buildImageArgs(version, platform, dockerfile, buildArgs, versionRef, latestRef)...) // #nosec G204 | |
| 467 | - | buildCmd.Env = append(os.Environ(), "VERSION="+version) | |
| 467 | + | buildCmd := exec.CommandContext(ctx, "docker", buildImageArgs(ctx, version, platform, dockerfile, buildArgs, versionRef, latestRef)...) // #nosec G204 | |
| 468 | + | buildCmd.Env = os.Environ() // VERSION is now passed as --build-arg VERSION=<value> | |
| 468 | 469 | buildCmd.Stdout = os.Stdout | |
| 469 | 470 | buildCmd.Stderr = os.Stderr | |
| 470 | 471 | if err := buildCmd.Run(); err != nil { | |
| @@ -484,23 +485,52 @@ func buildAndPush(ctx context.Context, version, platform, dockerfile string, bui | |||
| 484 | 485 | return nil | |
| 485 | 486 | } | |
| 486 | 487 | ||
| 488 | + | // standardBuildArgs returns the standard set of git-related build args as | |
| 489 | + | // explicit KEY=VALUE pairs. User-supplied args are appended after and take | |
| 490 | + | // precedence (Docker uses the last occurrence of a duplicate key). | |
| 491 | + | func standardBuildArgs(ctx context.Context, version string) []string { | |
| 492 | + | gitSHA := resolveGitSHA(ctx) | |
| 493 | + | buildTime := time.Now().UTC().Format(time.RFC3339) | |
| 494 | + | return []string{ | |
| 495 | + | "VERSION=" + version, | |
| 496 | + | "GIT_TAG=" + version, | |
| 497 | + | "GIT_SHA=" + gitSHA, | |
| 498 | + | "BUILD_TIME=" + buildTime, | |
| 499 | + | } | |
| 500 | + | } | |
| 501 | + | ||
| 502 | + | // resolveGitSHA returns the short git SHA of HEAD, or "unknown" if unavailable. | |
| 503 | + | func resolveGitSHA(ctx context.Context) string { | |
| 504 | + | out, err := exec.CommandContext(ctx, "git", "rev-parse", "--short", "HEAD").Output() // #nosec G204 | |
| 505 | + | if err != nil { | |
| 506 | + | return "unknown" | |
| 507 | + | } | |
| 508 | + | return strings.TrimSpace(string(out)) | |
| 509 | + | } | |
| 510 | + | ||
| 487 | 511 | // buildImageArgs constructs the docker buildx build arguments. | |
| 488 | 512 | // Uses --load instead of --push so the image is loaded into the local | |
| 489 | 513 | // daemon, allowing docker push to handle the upload with chunked requests. | |
| 490 | - | func buildImageArgs(version, platform, dockerfile string, buildArgs []string, versionRef, latestRef string) []string { | |
| 514 | + | func buildImageArgs(ctx context.Context, version, platform, dockerfile string, buildArgs []string, versionRef, latestRef string) []string { | |
| 491 | 515 | args := []string{ | |
| 492 | 516 | "buildx", "build", | |
| 493 | 517 | "--platform", platform, | |
| 494 | 518 | "-f", dockerfile, | |
| 495 | 519 | "-t", latestRef, | |
| 496 | - | "--build-arg", "VERSION", | |
| 497 | 520 | } | |
| 498 | 521 | if version != "latest" { | |
| 499 | 522 | args = append(args, "-t", versionRef) | |
| 500 | 523 | } | |
| 524 | + | ||
| 525 | + | // Inject standard git build args as explicit KEY=VALUE pairs. | |
| 526 | + | // User-supplied --build-arg flags are appended AFTER so they override defaults. | |
| 527 | + | for _, ba := range standardBuildArgs(ctx, version) { | |
| 528 | + | args = append(args, "--build-arg", ba) | |
| 529 | + | } | |
| 501 | 530 | for _, ba := range buildArgs { | |
| 502 | 531 | args = append(args, "--build-arg", ba) | |
| 503 | 532 | } | |
| 533 | + | ||
| 504 | 534 | args = append(args, "--load", ".") | |
| 505 | 535 | return args | |
| 506 | 536 | } | |
| @@ -682,23 +712,26 @@ func parseImageRef(image string) (registry, name, tag string) { | |||
| 682 | 712 | } | |
| 683 | 713 | ||
| 684 | 714 | // getGitVersion returns git describe output, or empty string if unavailable. | |
| 715 | + | // When it falls back it prints a warning to stderr so the user knows the | |
| 716 | + | // image will be tagged "latest" rather than a real version. | |
| 685 | 717 | func getGitVersion(ctx context.Context) string { | |
| 686 | - | out, err := exec.CommandContext(ctx, "git", "describe", "--tags", "--dirty").Output() | |
| 718 | + | out, err := exec.CommandContext(ctx, "git", "describe", "--tags", "--dirty").Output() // #nosec G204 | |
| 687 | 719 | if err != nil { | |
| 720 | + | fmt.Fprintf(os.Stderr, "Warning: unable to determine git tag (%v) — image version will be 'latest'. Tag your repo to get versioned images.\n", err) | |
| 688 | 721 | return "" | |
| 689 | 722 | } | |
| 690 | 723 | return strings.TrimSpace(string(out)) | |
| 691 | 724 | } | |
| 692 | 725 | ||
| 693 | 726 | func dockerTag(ctx context.Context, src, dst string) error { | |
| 694 | - | cmd := exec.CommandContext(ctx, "docker", "tag", src, dst) | |
| 727 | + | cmd := exec.CommandContext(ctx, "docker", "tag", src, dst) //nolint:gosec // binary is constant; image refs validated by OCI ref parser | |
| 695 | 728 | cmd.Stdout = os.Stdout | |
| 696 | 729 | cmd.Stderr = os.Stderr | |
| 697 | 730 | return cmd.Run() | |
| 698 | 731 | } | |
| 699 | 732 | ||
| 700 | 733 | func dockerPush(ctx context.Context, ref string) error { | |
| 701 | - | cmd := exec.CommandContext(ctx, "docker", "push", ref) | |
| 734 | + | cmd := exec.CommandContext(ctx, "docker", "push", ref) //nolint:gosec // binary is constant; image ref validated by OCI ref parser | |
| 702 | 735 | cmd.Stdout = os.Stdout | |
| 703 | 736 | cmd.Stderr = os.Stderr | |
| 704 | 737 | return cmd.Run() | |
| @@ -9,6 +9,7 @@ import ( | |||||
| 9 | "path/filepath" | 9 | "path/filepath" | ||
| 10 | "regexp" | 10 | "regexp" | ||
| 11 | "strings" | 11 | "strings" | ||
| 12 | + | "time" | |||
| 12 | 13 | ||||
| 13 | tea "github.com/charmbracelet/bubbletea" | 14 | tea "github.com/charmbracelet/bubbletea" | ||
| 14 | "github.com/spf13/cobra" | 15 | "github.com/spf13/cobra" | ||
| @@ -463,8 +464,8 @@ func buildAndPush(ctx context.Context, version, platform, dockerfile string, bui | |||||
| 463 | // Cloudflare's 100MB per-request limit. Loading locally then | 464 | // Cloudflare's 100MB per-request limit. Loading locally then | ||
| 464 | // using docker push gives us chunked uploads (~5MB per request). | 465 | // using docker push gives us chunked uploads (~5MB per request). | ||
| 465 | fmt.Println("\nBuilding image...") | 466 | fmt.Println("\nBuilding image...") | ||
| 466 | - | buildCmd := exec.CommandContext(ctx, "docker", buildImageArgs(version, platform, dockerfile, buildArgs, versionRef, latestRef)...) // #nosec G204 | 467 | + | buildCmd := exec.CommandContext(ctx, "docker", buildImageArgs(ctx, version, platform, dockerfile, buildArgs, versionRef, latestRef)...) // #nosec G204 |
| 467 | - | buildCmd.Env = append(os.Environ(), "VERSION="+version) | 468 | + | buildCmd.Env = os.Environ() // VERSION is now passed as --build-arg VERSION=<value> |
| 468 | buildCmd.Stdout = os.Stdout | 469 | buildCmd.Stdout = os.Stdout | ||
| 469 | buildCmd.Stderr = os.Stderr | 470 | buildCmd.Stderr = os.Stderr | ||
| 470 | if err := buildCmd.Run(); err != nil { | 471 | if err := buildCmd.Run(); err != nil { | ||
| @@ -484,23 +485,52 @@ func buildAndPush(ctx context.Context, version, platform, dockerfile string, bui | |||||
| 484 | return nil | 485 | return nil | ||
| 485 | } | 486 | } | ||
| 486 | 487 | ||||
| 488 | + | // standardBuildArgs returns the standard set of git-related build args as | |||
| 489 | + | // explicit KEY=VALUE pairs. User-supplied args are appended after and take | |||
| 490 | + | // precedence (Docker uses the last occurrence of a duplicate key). | |||
| 491 | + | func standardBuildArgs(ctx context.Context, version string) []string { | |||
| 492 | + | gitSHA := resolveGitSHA(ctx) | |||
| 493 | + | buildTime := time.Now().UTC().Format(time.RFC3339) | |||
| 494 | + | return []string{ | |||
| 495 | + | "VERSION=" + version, | |||
| 496 | + | "GIT_TAG=" + version, | |||
| 497 | + | "GIT_SHA=" + gitSHA, | |||
| 498 | + | "BUILD_TIME=" + buildTime, | |||
| 499 | + | } | |||
| 500 | + | } | |||
| 501 | + | ||||
| 502 | + | // resolveGitSHA returns the short git SHA of HEAD, or "unknown" if unavailable. | |||
| 503 | + | func resolveGitSHA(ctx context.Context) string { | |||
| 504 | + | out, err := exec.CommandContext(ctx, "git", "rev-parse", "--short", "HEAD").Output() // #nosec G204 | |||
| 505 | + | if err != nil { | |||
| 506 | + | return "unknown" | |||
| 507 | + | } | |||
| 508 | + | return strings.TrimSpace(string(out)) | |||
| 509 | + | } | |||
| 510 | + | ||||
| 487 | // buildImageArgs constructs the docker buildx build arguments. | 511 | // buildImageArgs constructs the docker buildx build arguments. | ||
| 488 | // Uses --load instead of --push so the image is loaded into the local | 512 | // Uses --load instead of --push so the image is loaded into the local | ||
| 489 | // daemon, allowing docker push to handle the upload with chunked requests. | 513 | // daemon, allowing docker push to handle the upload with chunked requests. | ||
| 490 | - | func buildImageArgs(version, platform, dockerfile string, buildArgs []string, versionRef, latestRef string) []string { | 514 | + | func buildImageArgs(ctx context.Context, version, platform, dockerfile string, buildArgs []string, versionRef, latestRef string) []string { |
| 491 | args := []string{ | 515 | args := []string{ | ||
| 492 | "buildx", "build", | 516 | "buildx", "build", | ||
| 493 | "--platform", platform, | 517 | "--platform", platform, | ||
| 494 | "-f", dockerfile, | 518 | "-f", dockerfile, | ||
| 495 | "-t", latestRef, | 519 | "-t", latestRef, | ||
| 496 | - | "--build-arg", "VERSION", | |||
| 497 | } | 520 | } | ||
| 498 | if version != "latest" { | 521 | if version != "latest" { | ||
| 499 | args = append(args, "-t", versionRef) | 522 | args = append(args, "-t", versionRef) | ||
| 500 | } | 523 | } | ||
| 524 | + | ||||
| 525 | + | // Inject standard git build args as explicit KEY=VALUE pairs. | |||
| 526 | + | // User-supplied --build-arg flags are appended AFTER so they override defaults. | |||
| 527 | + | for _, ba := range standardBuildArgs(ctx, version) { | |||
| 528 | + | args = append(args, "--build-arg", ba) | |||
| 529 | + | } | |||
| 501 | for _, ba := range buildArgs { | 530 | for _, ba := range buildArgs { | ||
| 502 | args = append(args, "--build-arg", ba) | 531 | args = append(args, "--build-arg", ba) | ||
| 503 | } | 532 | } | ||
| 533 | + | ||||
| 504 | args = append(args, "--load", ".") | 534 | args = append(args, "--load", ".") | ||
| 505 | return args | 535 | return args | ||
| 506 | } | 536 | } | ||
| @@ -682,23 +712,26 @@ func parseImageRef(image string) (registry, name, tag string) { | |||||
| 682 | } | 712 | } | ||
| 683 | 713 | ||||
| 684 | // getGitVersion returns git describe output, or empty string if unavailable. | 714 | // getGitVersion returns git describe output, or empty string if unavailable. | ||
| 715 | + | // When it falls back it prints a warning to stderr so the user knows the | |||
| 716 | + | // image will be tagged "latest" rather than a real version. | |||
| 685 | func getGitVersion(ctx context.Context) string { | 717 | func getGitVersion(ctx context.Context) string { | ||
| 686 | - | out, err := exec.CommandContext(ctx, "git", "describe", "--tags", "--dirty").Output() | 718 | + | out, err := exec.CommandContext(ctx, "git", "describe", "--tags", "--dirty").Output() // #nosec G204 |
| 687 | if err != nil { | 719 | if err != nil { | ||
| 720 | + | fmt.Fprintf(os.Stderr, "Warning: unable to determine git tag (%v) — image version will be 'latest'. Tag your repo to get versioned images.\n", err) | |||
| 688 | return "" | 721 | return "" | ||
| 689 | } | 722 | } | ||
| 690 | return strings.TrimSpace(string(out)) | 723 | return strings.TrimSpace(string(out)) | ||
| 691 | } | 724 | } | ||
| 692 | 725 | ||||
| 693 | func dockerTag(ctx context.Context, src, dst string) error { | 726 | func dockerTag(ctx context.Context, src, dst string) error { | ||
| 694 | - | cmd := exec.CommandContext(ctx, "docker", "tag", src, dst) | 727 | + | cmd := exec.CommandContext(ctx, "docker", "tag", src, dst) //nolint:gosec // binary is constant; image refs validated by OCI ref parser |
| 695 | cmd.Stdout = os.Stdout | 728 | cmd.Stdout = os.Stdout | ||
| 696 | cmd.Stderr = os.Stderr | 729 | cmd.Stderr = os.Stderr | ||
| 697 | return cmd.Run() | 730 | return cmd.Run() | ||
| 698 | } | 731 | } | ||
| 699 | 732 | ||||
| 700 | func dockerPush(ctx context.Context, ref string) error { | 733 | func dockerPush(ctx context.Context, ref string) error { | ||
| 701 | - | cmd := exec.CommandContext(ctx, "docker", "push", ref) | 734 | + | cmd := exec.CommandContext(ctx, "docker", "push", ref) //nolint:gosec // binary is constant; image ref validated by OCI ref parser |
| 702 | cmd.Stdout = os.Stdout | 735 | cmd.Stdout = os.Stdout | ||
| 703 | cmd.Stderr = os.Stderr | 736 | cmd.Stderr = os.Stderr | ||
| 704 | return cmd.Run() | 737 | return cmd.Run() | ||
M internal/adapters/in/cli/push_test.go +98 -4
| @@ -1,8 +1,12 @@ | |||
| 1 | 1 | package cli | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 5 | + | "io" | |
| 4 | 6 | "os" | |
| 7 | + | "os/exec" | |
| 5 | 8 | "path/filepath" | |
| 9 | + | "strings" | |
| 6 | 10 | "testing" | |
| 7 | 11 | ||
| 8 | 12 | "github.com/stretchr/testify/assert" | |
| @@ -65,15 +69,56 @@ func TestParseImageRef(t *testing.T) { | |||
| 65 | 69 | ||
| 66 | 70 | func TestBuildAndPush_BuildArgs(t *testing.T) { | |
| 67 | 71 | // Verify buildImageArgs produces --load instead of --push | |
| 68 | - | args := buildImageArgs("v1.0.0", "linux/amd64", "Dockerfile", []string{"CGO_ENABLED=0"}, "reg.example.com/app:v1.0.0", "reg.example.com/app:latest") | |
| 72 | + | args := buildImageArgs(context.Background(), "v1.0.0", "linux/amd64", "Dockerfile", []string{"CGO_ENABLED=0"}, "reg.example.com/app:v1.0.0", "reg.example.com/app:latest") | |
| 69 | 73 | ||
| 70 | 74 | assert.Contains(t, args, "--load") | |
| 71 | 75 | assert.NotContains(t, args, "--push") | |
| 72 | 76 | assert.Contains(t, args, "--platform") | |
| 73 | 77 | assert.Contains(t, args, "-f") | |
| 74 | 78 | assert.Contains(t, args, "Dockerfile") | |
| 75 | - | assert.Contains(t, args, "VERSION") | |
| 76 | - | assert.NotContains(t, args, "VERSION=v1.0.0") | |
| 79 | + | assert.Contains(t, args, "VERSION=v1.0.0") | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func TestBuildImageArgsInjectsGitBuildArgs(t *testing.T) { | |
| 83 | + | args := buildImageArgs(context.Background(), "v1.2.3", "linux/amd64", "Dockerfile", nil, "registry/img:v1.2.3", "registry/img:latest") | |
| 84 | + | ||
| 85 | + | // Must contain explicit KEY=VALUE for all standard git build args | |
| 86 | + | argStr := strings.Join(args, " ") | |
| 87 | + | for _, key := range []string{"VERSION=v1.2.3", "GIT_TAG=v1.2.3", "GIT_SHA=", "BUILD_TIME="} { | |
| 88 | + | if !strings.Contains(argStr, key) { | |
| 89 | + | t.Errorf("expected args to contain %q, got: %s", key, argStr) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // Must NOT contain bare "--build-arg VERSION" (without =value) | |
| 94 | + | for i, a := range args { | |
| 95 | + | if a == "--build-arg" && i+1 < len(args) && args[i+1] == "VERSION" { | |
| 96 | + | t.Error("found bare '--build-arg VERSION' (without =value); should be '--build-arg VERSION=v1.2.3'") | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func TestBuildImageArgsUserArgsOverrideDefaults(t *testing.T) { | |
| 102 | + | userArgs := []string{"GIT_TAG=custom-override"} | |
| 103 | + | args := buildImageArgs(context.Background(), "v1.2.3", "linux/amd64", "Dockerfile", userArgs, "r/i:v1.2.3", "r/i:latest") | |
| 104 | + | ||
| 105 | + | // Count how many times GIT_TAG appears and track the last occurrence index. | |
| 106 | + | // Docker uses the last occurrence of a duplicate --build-arg key, so the user | |
| 107 | + | // override must come after the default injected value. | |
| 108 | + | count := 0 | |
| 109 | + | lastIdx := -1 | |
| 110 | + | for i, a := range args { | |
| 111 | + | if a == "--build-arg" && i+1 < len(args) && strings.HasPrefix(args[i+1], "GIT_TAG=") { | |
| 112 | + | count++ | |
| 113 | + | lastIdx = i + 1 | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | if count < 2 { | |
| 117 | + | t.Errorf("expected GIT_TAG to appear twice (default + override), got %d", count) | |
| 118 | + | } | |
| 119 | + | if lastIdx < 0 || args[lastIdx] != "GIT_TAG="+userArgs[0][len("GIT_TAG="):] { | |
| 120 | + | t.Errorf("expected last GIT_TAG= arg to be the user override %q, got %q", "GIT_TAG=custom-override", args[lastIdx]) | |
| 121 | + | } | |
| 77 | 122 | } | |
| 78 | 123 | ||
| 79 | 124 | func TestParseTagRef(t *testing.T) { | |
| @@ -142,7 +187,7 @@ func TestVersionFromTagRefs(t *testing.T) { | |||
| 142 | 187 | } | |
| 143 | 188 | ||
| 144 | 189 | func TestBuildImageArgs_CustomDockerfile(t *testing.T) { | |
| 145 | - | args := buildImageArgs("v1.0.0", "linux/amd64", "docker/app/Dockerfile", nil, "reg.example.com/app:v1.0.0", "reg.example.com/app:latest") | |
| 190 | + | args := buildImageArgs(context.Background(), "v1.0.0", "linux/amd64", "docker/app/Dockerfile", nil, "reg.example.com/app:v1.0.0", "reg.example.com/app:latest") | |
| 146 | 191 | ||
| 147 | 192 | assert.Contains(t, args, "-f") | |
| 148 | 193 | assert.Contains(t, args, "docker/app/Dockerfile") | |
| @@ -301,6 +346,55 @@ func TestSplitLabelPairs(t *testing.T) { | |||
| 301 | 346 | } | |
| 302 | 347 | } | |
| 303 | 348 | ||
| 349 | + | func TestGetGitVersionNoTagsReturnsFallbackAndWarns(t *testing.T) { | |
| 350 | + | ctx := context.Background() | |
| 351 | + | ||
| 352 | + | // Create a temp dir with a git repo that has no tags | |
| 353 | + | tmpDir := t.TempDir() | |
| 354 | + | if err := exec.Command("git", "-C", tmpDir, "init").Run(); err != nil { // #nosec G204 | |
| 355 | + | t.Skipf("git init failed: %v", err) | |
| 356 | + | } | |
| 357 | + | // Need at least one commit so git describe has something to describe | |
| 358 | + | if err := exec.Command("git", "-C", tmpDir, "-c", "user.email=test@test.com", "-c", "user.name=Test", "commit", "--allow-empty", "-m", "init").Run(); err != nil { // #nosec G204 | |
| 359 | + | t.Skipf("git commit failed: %v", err) | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | // Change to the tmpDir for this test so getGitVersion uses the tag-less repo | |
| 363 | + | origDir, err := os.Getwd() | |
| 364 | + | if err != nil { | |
| 365 | + | t.Fatal(err) | |
| 366 | + | } | |
| 367 | + | if err := os.Chdir(tmpDir); err != nil { // #nosec G204 | |
| 368 | + | t.Fatal(err) | |
| 369 | + | } | |
| 370 | + | defer os.Chdir(origDir) // #nosec G204 | |
| 371 | + | ||
| 372 | + | // Redirect stderr to capture the warning | |
| 373 | + | origStderr := os.Stderr | |
| 374 | + | r, w, err := os.Pipe() | |
| 375 | + | if err != nil { | |
| 376 | + | t.Fatal(err) | |
| 377 | + | } | |
| 378 | + | os.Stderr = w | |
| 379 | + | defer func() { | |
| 380 | + | w.Close() | |
| 381 | + | os.Stderr = origStderr | |
| 382 | + | }() | |
| 383 | + | ||
| 384 | + | v := getGitVersion(ctx) | |
| 385 | + | ||
| 386 | + | w.Close() | |
| 387 | + | os.Stderr = origStderr | |
| 388 | + | stderrOutput, _ := io.ReadAll(r) | |
| 389 | + | ||
| 390 | + | // Should return "" (fallback to "latest" handled by determineVersion) | |
| 391 | + | assert.Equal(t, "", v, "expected empty string fallback when no git tags exist") | |
| 392 | + | ||
| 393 | + | // Should have printed a warning to stderr | |
| 394 | + | assert.Contains(t, string(stderrOutput), "latest", | |
| 395 | + | "expected a warning about 'latest' fallback on stderr") | |
| 396 | + | } | |
| 397 | + | ||
| 304 | 398 | func TestParseLabelPair(t *testing.T) { | |
| 305 | 399 | tests := []struct { | |
| 306 | 400 | name string | |
| @@ -1,8 +1,12 @@ | |||||
| 1 | package cli | 1 | package cli | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 5 | + | "io" | |||
| 4 | "os" | 6 | "os" | ||
| 7 | + | "os/exec" | |||
| 5 | "path/filepath" | 8 | "path/filepath" | ||
| 9 | + | "strings" | |||
| 6 | "testing" | 10 | "testing" | ||
| 7 | 11 | ||||
| 8 | "github.com/stretchr/testify/assert" | 12 | "github.com/stretchr/testify/assert" | ||
| @@ -65,15 +69,56 @@ func TestParseImageRef(t *testing.T) { | |||||
| 65 | 69 | ||||
| 66 | func TestBuildAndPush_BuildArgs(t *testing.T) { | 70 | func TestBuildAndPush_BuildArgs(t *testing.T) { | ||
| 67 | // Verify buildImageArgs produces --load instead of --push | 71 | // Verify buildImageArgs produces --load instead of --push | ||
| 68 | - | args := buildImageArgs("v1.0.0", "linux/amd64", "Dockerfile", []string{"CGO_ENABLED=0"}, "reg.example.com/app:v1.0.0", "reg.example.com/app:latest") | 72 | + | args := buildImageArgs(context.Background(), "v1.0.0", "linux/amd64", "Dockerfile", []string{"CGO_ENABLED=0"}, "reg.example.com/app:v1.0.0", "reg.example.com/app:latest") |
| 69 | 73 | ||||
| 70 | assert.Contains(t, args, "--load") | 74 | assert.Contains(t, args, "--load") | ||
| 71 | assert.NotContains(t, args, "--push") | 75 | assert.NotContains(t, args, "--push") | ||
| 72 | assert.Contains(t, args, "--platform") | 76 | assert.Contains(t, args, "--platform") | ||
| 73 | assert.Contains(t, args, "-f") | 77 | assert.Contains(t, args, "-f") | ||
| 74 | assert.Contains(t, args, "Dockerfile") | 78 | assert.Contains(t, args, "Dockerfile") | ||
| 75 | - | assert.Contains(t, args, "VERSION") | 79 | + | assert.Contains(t, args, "VERSION=v1.0.0") |
| 76 | - | assert.NotContains(t, args, "VERSION=v1.0.0") | 80 | + | } |
| 81 | + | ||||
| 82 | + | func TestBuildImageArgsInjectsGitBuildArgs(t *testing.T) { | |||
| 83 | + | args := buildImageArgs(context.Background(), "v1.2.3", "linux/amd64", "Dockerfile", nil, "registry/img:v1.2.3", "registry/img:latest") | |||
| 84 | + | ||||
| 85 | + | // Must contain explicit KEY=VALUE for all standard git build args | |||
| 86 | + | argStr := strings.Join(args, " ") | |||
| 87 | + | for _, key := range []string{"VERSION=v1.2.3", "GIT_TAG=v1.2.3", "GIT_SHA=", "BUILD_TIME="} { | |||
| 88 | + | if !strings.Contains(argStr, key) { | |||
| 89 | + | t.Errorf("expected args to contain %q, got: %s", key, argStr) | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // Must NOT contain bare "--build-arg VERSION" (without =value) | |||
| 94 | + | for i, a := range args { | |||
| 95 | + | if a == "--build-arg" && i+1 < len(args) && args[i+1] == "VERSION" { | |||
| 96 | + | t.Error("found bare '--build-arg VERSION' (without =value); should be '--build-arg VERSION=v1.2.3'") | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func TestBuildImageArgsUserArgsOverrideDefaults(t *testing.T) { | |||
| 102 | + | userArgs := []string{"GIT_TAG=custom-override"} | |||
| 103 | + | args := buildImageArgs(context.Background(), "v1.2.3", "linux/amd64", "Dockerfile", userArgs, "r/i:v1.2.3", "r/i:latest") | |||
| 104 | + | ||||
| 105 | + | // Count how many times GIT_TAG appears and track the last occurrence index. | |||
| 106 | + | // Docker uses the last occurrence of a duplicate --build-arg key, so the user | |||
| 107 | + | // override must come after the default injected value. | |||
| 108 | + | count := 0 | |||
| 109 | + | lastIdx := -1 | |||
| 110 | + | for i, a := range args { | |||
| 111 | + | if a == "--build-arg" && i+1 < len(args) && strings.HasPrefix(args[i+1], "GIT_TAG=") { | |||
| 112 | + | count++ | |||
| 113 | + | lastIdx = i + 1 | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | if count < 2 { | |||
| 117 | + | t.Errorf("expected GIT_TAG to appear twice (default + override), got %d", count) | |||
| 118 | + | } | |||
| 119 | + | if lastIdx < 0 || args[lastIdx] != "GIT_TAG="+userArgs[0][len("GIT_TAG="):] { | |||
| 120 | + | t.Errorf("expected last GIT_TAG= arg to be the user override %q, got %q", "GIT_TAG=custom-override", args[lastIdx]) | |||
| 121 | + | } | |||
| 77 | } | 122 | } | ||
| 78 | 123 | ||||
| 79 | func TestParseTagRef(t *testing.T) { | 124 | func TestParseTagRef(t *testing.T) { | ||
| @@ -142,7 +187,7 @@ func TestVersionFromTagRefs(t *testing.T) { | |||||
| 142 | } | 187 | } | ||
| 143 | 188 | ||||
| 144 | func TestBuildImageArgs_CustomDockerfile(t *testing.T) { | 189 | func TestBuildImageArgs_CustomDockerfile(t *testing.T) { | ||
| 145 | - | args := buildImageArgs("v1.0.0", "linux/amd64", "docker/app/Dockerfile", nil, "reg.example.com/app:v1.0.0", "reg.example.com/app:latest") | 190 | + | args := buildImageArgs(context.Background(), "v1.0.0", "linux/amd64", "docker/app/Dockerfile", nil, "reg.example.com/app:v1.0.0", "reg.example.com/app:latest") |
| 146 | 191 | ||||
| 147 | assert.Contains(t, args, "-f") | 192 | assert.Contains(t, args, "-f") | ||
| 148 | assert.Contains(t, args, "docker/app/Dockerfile") | 193 | assert.Contains(t, args, "docker/app/Dockerfile") | ||
| @@ -301,6 +346,55 @@ func TestSplitLabelPairs(t *testing.T) { | |||||
| 301 | } | 346 | } | ||
| 302 | } | 347 | } | ||
| 303 | 348 | ||||
| 349 | + | func TestGetGitVersionNoTagsReturnsFallbackAndWarns(t *testing.T) { | |||
| 350 | + | ctx := context.Background() | |||
| 351 | + | ||||
| 352 | + | // Create a temp dir with a git repo that has no tags | |||
| 353 | + | tmpDir := t.TempDir() | |||
| 354 | + | if err := exec.Command("git", "-C", tmpDir, "init").Run(); err != nil { // #nosec G204 | |||
| 355 | + | t.Skipf("git init failed: %v", err) | |||
| 356 | + | } | |||
| 357 | + | // Need at least one commit so git describe has something to describe | |||
| 358 | + | if err := exec.Command("git", "-C", tmpDir, "-c", "user.email=test@test.com", "-c", "user.name=Test", "commit", "--allow-empty", "-m", "init").Run(); err != nil { // #nosec G204 | |||
| 359 | + | t.Skipf("git commit failed: %v", err) | |||
| 360 | + | } | |||
| 361 | + | ||||
| 362 | + | // Change to the tmpDir for this test so getGitVersion uses the tag-less repo | |||
| 363 | + | origDir, err := os.Getwd() | |||
| 364 | + | if err != nil { | |||
| 365 | + | t.Fatal(err) | |||
| 366 | + | } | |||
| 367 | + | if err := os.Chdir(tmpDir); err != nil { // #nosec G204 | |||
| 368 | + | t.Fatal(err) | |||
| 369 | + | } | |||
| 370 | + | defer os.Chdir(origDir) // #nosec G204 | |||
| 371 | + | ||||
| 372 | + | // Redirect stderr to capture the warning | |||
| 373 | + | origStderr := os.Stderr | |||
| 374 | + | r, w, err := os.Pipe() | |||
| 375 | + | if err != nil { | |||
| 376 | + | t.Fatal(err) | |||
| 377 | + | } | |||
| 378 | + | os.Stderr = w | |||
| 379 | + | defer func() { | |||
| 380 | + | w.Close() | |||
| 381 | + | os.Stderr = origStderr | |||
| 382 | + | }() | |||
| 383 | + | ||||
| 384 | + | v := getGitVersion(ctx) | |||
| 385 | + | ||||
| 386 | + | w.Close() | |||
| 387 | + | os.Stderr = origStderr | |||
| 388 | + | stderrOutput, _ := io.ReadAll(r) | |||
| 389 | + | ||||
| 390 | + | // Should return "" (fallback to "latest" handled by determineVersion) | |||
| 391 | + | assert.Equal(t, "", v, "expected empty string fallback when no git tags exist") | |||
| 392 | + | ||||
| 393 | + | // Should have printed a warning to stderr | |||
| 394 | + | assert.Contains(t, string(stderrOutput), "latest", | |||
| 395 | + | "expected a warning about 'latest' fallback on stderr") | |||
| 396 | + | } | |||
| 397 | + | ||||
| 304 | func TestParseLabelPair(t *testing.T) { | 398 | func TestParseLabelPair(t *testing.T) { | ||
| 305 | tests := []struct { | 399 | tests := []struct { | ||
| 306 | name string | 400 | name string | ||
M internal/adapters/in/cli/remote/auth.go +1 -1
| @@ -12,7 +12,7 @@ import ( | |||
| 12 | 12 | // PasswordRequest represents the request body for POST /auth/password. | |
| 13 | 13 | type PasswordRequest struct { | |
| 14 | 14 | Username string `json:"username"` | |
| 15 | - | Password string `json:"password"` | |
| 15 | + | Password string `json:"password"` //nolint:gosec // intentional: CLI credential DTO for auth endpoint | |
| 16 | 16 | } | |
| 17 | 17 | ||
| 18 | 18 | // PasswordResponse represents the response from POST /auth/password. | |
| @@ -12,7 +12,7 @@ import ( | |||||
| 12 | // PasswordRequest represents the request body for POST /auth/password. | 12 | // PasswordRequest represents the request body for POST /auth/password. | ||
| 13 | type PasswordRequest struct { | 13 | type PasswordRequest struct { | ||
| 14 | Username string `json:"username"` | 14 | Username string `json:"username"` | ||
| 15 | - | Password string `json:"password"` | 15 | + | Password string `json:"password"` //nolint:gosec // intentional: CLI credential DTO for auth endpoint |
| 16 | } | 16 | } | ||
| 17 | 17 | ||||
| 18 | // PasswordResponse represents the response from POST /auth/password. | 18 | // PasswordResponse represents the response from POST /auth/password. | ||
M internal/adapters/in/cli/remote/client.go +48 -5
| @@ -21,10 +21,11 @@ import ( | |||
| 21 | 21 | ||
| 22 | 22 | // Client is an HTTP client for the Gordon admin API. | |
| 23 | 23 | type Client struct { | |
| 24 | - | baseURL string | |
| 25 | - | token string | |
| 26 | - | httpClient *http.Client | |
| 27 | - | insecureTLS bool | |
| 24 | + | baseURL string | |
| 25 | + | token string | |
| 26 | + | httpClient *http.Client | |
| 27 | + | insecureTLS bool | |
| 28 | + | onTokenRefreshed func(newToken string) // optional callback to persist a refreshed token | |
| 28 | 29 | } | |
| 29 | 30 | ||
| 30 | 31 | var ( | |
| @@ -84,6 +85,37 @@ func WithInsecureTLS(insecure bool) ClientOption { | |||
| 84 | 85 | } | |
| 85 | 86 | } | |
| 86 | 87 | ||
| 88 | + | // WithTokenRefreshCallback sets a callback that is invoked when the server | |
| 89 | + | // returns a refreshed token in the X-Gordon-Token response header. | |
| 90 | + | // The CLI uses this to persist the new token to the remotes config atomically. | |
| 91 | + | func WithTokenRefreshCallback(fn func(newToken string)) ClientOption { | |
| 92 | + | return func(c *Client) { | |
| 93 | + | c.onTokenRefreshed = fn | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // observeTokenRotation checks the X-Gordon-Token header on a response and, if | |
| 98 | + | // the server issued a refreshed token, updates the client's in-memory token and | |
| 99 | + | // invokes the optional persistence callback. Panics in the callback are caught | |
| 100 | + | // so they never propagate to the caller. | |
| 101 | + | func (c *Client) observeTokenRotation(resp *http.Response) { | |
| 102 | + | newToken := resp.Header.Get("X-Gordon-Token") | |
| 103 | + | if newToken == "" || newToken == c.token { | |
| 104 | + | return | |
| 105 | + | } | |
| 106 | + | c.token = newToken | |
| 107 | + | if c.onTokenRefreshed != nil { | |
| 108 | + | func() { | |
| 109 | + | defer func() { | |
| 110 | + | if r := recover(); r != nil { | |
| 111 | + | fmt.Fprintf(os.Stderr, "warning: token refresh callback panicked: %v\n", r) | |
| 112 | + | } | |
| 113 | + | }() | |
| 114 | + | c.onTokenRefreshed(newToken) | |
| 115 | + | }() | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 87 | 119 | func (c *Client) applyTLSConfig() { | |
| 88 | 120 | if !c.insecureTLS { | |
| 89 | 121 | return | |
| @@ -140,7 +172,15 @@ func (c *Client) request(ctx context.Context, method, path string, body any) (*h | |||
| 140 | 172 | req.Header.Set("Authorization", "Bearer "+c.token) | |
| 141 | 173 | } | |
| 142 | 174 | ||
| 143 | - | return c.httpClient.Do(req) | |
| 175 | + | resp, err := c.httpClient.Do(req) | |
| 176 | + | if err != nil { | |
| 177 | + | return nil, err | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // Observe token rotation: the server re-signs and returns the token via X-Gordon-Token. | |
| 181 | + | c.observeTokenRotation(resp) | |
| 182 | + | ||
| 183 | + | return resp, nil | |
| 144 | 184 | } | |
| 145 | 185 | ||
| 146 | 186 | // parseResponse parses a JSON response into the given target. | |
| @@ -887,6 +927,9 @@ func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, er | |||
| 887 | 927 | return nil, fmt.Errorf("failed to connect: %w", err) | |
| 888 | 928 | } | |
| 889 | 929 | ||
| 930 | + | // Observe token rotation on streaming responses, same as request(). | |
| 931 | + | c.observeTokenRotation(resp) | |
| 932 | + | ||
| 890 | 933 | if resp.StatusCode >= 400 { | |
| 891 | 934 | body, _ := io.ReadAll(resp.Body) | |
| 892 | 935 | resp.Body.Close() | |
| @@ -21,10 +21,11 @@ import ( | |||||
| 21 | 21 | ||||
| 22 | // Client is an HTTP client for the Gordon admin API. | 22 | // Client is an HTTP client for the Gordon admin API. | ||
| 23 | type Client struct { | 23 | type Client struct { | ||
| 24 | - | baseURL string | 24 | + | baseURL string |
| 25 | - | token string | 25 | + | token string |
| 26 | - | httpClient *http.Client | 26 | + | httpClient *http.Client |
| 27 | - | insecureTLS bool | 27 | + | insecureTLS bool |
| 28 | + | onTokenRefreshed func(newToken string) // optional callback to persist a refreshed token | |||
| 28 | } | 29 | } | ||
| 29 | 30 | ||||
| 30 | var ( | 31 | var ( | ||
| @@ -84,6 +85,37 @@ func WithInsecureTLS(insecure bool) ClientOption { | |||||
| 84 | } | 85 | } | ||
| 85 | } | 86 | } | ||
| 86 | 87 | ||||
| 88 | + | // WithTokenRefreshCallback sets a callback that is invoked when the server | |||
| 89 | + | // returns a refreshed token in the X-Gordon-Token response header. | |||
| 90 | + | // The CLI uses this to persist the new token to the remotes config atomically. | |||
| 91 | + | func WithTokenRefreshCallback(fn func(newToken string)) ClientOption { | |||
| 92 | + | return func(c *Client) { | |||
| 93 | + | c.onTokenRefreshed = fn | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | // observeTokenRotation checks the X-Gordon-Token header on a response and, if | |||
| 98 | + | // the server issued a refreshed token, updates the client's in-memory token and | |||
| 99 | + | // invokes the optional persistence callback. Panics in the callback are caught | |||
| 100 | + | // so they never propagate to the caller. | |||
| 101 | + | func (c *Client) observeTokenRotation(resp *http.Response) { | |||
| 102 | + | newToken := resp.Header.Get("X-Gordon-Token") | |||
| 103 | + | if newToken == "" || newToken == c.token { | |||
| 104 | + | return | |||
| 105 | + | } | |||
| 106 | + | c.token = newToken | |||
| 107 | + | if c.onTokenRefreshed != nil { | |||
| 108 | + | func() { | |||
| 109 | + | defer func() { | |||
| 110 | + | if r := recover(); r != nil { | |||
| 111 | + | fmt.Fprintf(os.Stderr, "warning: token refresh callback panicked: %v\n", r) | |||
| 112 | + | } | |||
| 113 | + | }() | |||
| 114 | + | c.onTokenRefreshed(newToken) | |||
| 115 | + | }() | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | ||||
| 87 | func (c *Client) applyTLSConfig() { | 119 | func (c *Client) applyTLSConfig() { | ||
| 88 | if !c.insecureTLS { | 120 | if !c.insecureTLS { | ||
| 89 | return | 121 | return | ||
| @@ -140,7 +172,15 @@ func (c *Client) request(ctx context.Context, method, path string, body any) (*h | |||||
| 140 | req.Header.Set("Authorization", "Bearer "+c.token) | 172 | req.Header.Set("Authorization", "Bearer "+c.token) | ||
| 141 | } | 173 | } | ||
| 142 | 174 | ||||
| 143 | - | return c.httpClient.Do(req) | 175 | + | resp, err := c.httpClient.Do(req) |
| 176 | + | if err != nil { | |||
| 177 | + | return nil, err | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | // Observe token rotation: the server re-signs and returns the token via X-Gordon-Token. | |||
| 181 | + | c.observeTokenRotation(resp) | |||
| 182 | + | ||||
| 183 | + | return resp, nil | |||
| 144 | } | 184 | } | ||
| 145 | 185 | ||||
| 146 | // parseResponse parses a JSON response into the given target. | 186 | // parseResponse parses a JSON response into the given target. | ||
| @@ -887,6 +927,9 @@ func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, er | |||||
| 887 | return nil, fmt.Errorf("failed to connect: %w", err) | 927 | return nil, fmt.Errorf("failed to connect: %w", err) | ||
| 888 | } | 928 | } | ||
| 889 | 929 | ||||
| 930 | + | // Observe token rotation on streaming responses, same as request(). | |||
| 931 | + | c.observeTokenRotation(resp) | |||
| 932 | + | ||||
| 890 | if resp.StatusCode >= 400 { | 933 | if resp.StatusCode >= 400 { | ||
| 891 | body, _ := io.ReadAll(resp.Body) | 934 | body, _ := io.ReadAll(resp.Body) | ||
| 892 | resp.Body.Close() | 935 | resp.Body.Close() | ||
M internal/adapters/in/cli/remote/config.go +15 -3
| @@ -138,16 +138,28 @@ func SaveRemotes(path string, config *ClientConfig) error { | |||
| 138 | 138 | // ResolveRemote resolves the remote URL and token from configuration. | |
| 139 | 139 | // Precedence: flag > env > config > active remote | |
| 140 | 140 | func ResolveRemote(flagRemote, flagToken string, flagInsecure bool) (url, token string, insecureTLS, isRemote bool) { | |
| 141 | + | url, token, insecureTLS, _, isRemote = ResolveRemoteFull(flagRemote, flagToken, flagInsecure) | |
| 142 | + | return url, token, insecureTLS, isRemote | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // ResolveRemoteFull resolves the remote URL, token, and named remote from configuration. | |
| 146 | + | // It also returns the remote name for use in token persistence callbacks. | |
| 147 | + | // Precedence: flag > env > config > active remote | |
| 148 | + | func ResolveRemoteFull(flagRemote, flagToken string, flagInsecure bool) (url, token string, insecureTLS bool, remoteName string, isRemote bool) { | |
| 141 | 149 | config, _ := LoadClientConfig("") | |
| 142 | 150 | remotes, _ := LoadRemotes("") | |
| 143 | 151 | ||
| 144 | - | url, remoteName, isRemote := resolveRemoteURL(flagRemote, config, remotes) | |
| 152 | + | var name string | |
| 153 | + | url, name, isRemote = resolveRemoteURL(flagRemote, config, remotes) | |
| 145 | 154 | if isRemote { | |
| 155 | + | // resolveToken looks up remotes.Active internally; name == remotes.Active here | |
| 156 | + | // because resolveRemoteURL only sets name when it finds the active remote entry. | |
| 146 | 157 | token = resolveToken(flagToken, config, remotes) | |
| 147 | - | insecureTLS = resolveInsecureTLS(flagInsecure, config, remotes, remoteName) | |
| 158 | + | insecureTLS = resolveInsecureTLS(flagInsecure, config, remotes, name) | |
| 159 | + | remoteName = name | |
| 148 | 160 | } | |
| 149 | 161 | ||
| 150 | - | return url, token, insecureTLS, isRemote | |
| 162 | + | return url, token, insecureTLS, remoteName, isRemote | |
| 151 | 163 | } | |
| 152 | 164 | ||
| 153 | 165 | // resolveRemoteURL resolves the remote URL from various sources. | |
| @@ -138,16 +138,28 @@ func SaveRemotes(path string, config *ClientConfig) error { | |||||
| 138 | // ResolveRemote resolves the remote URL and token from configuration. | 138 | // ResolveRemote resolves the remote URL and token from configuration. | ||
| 139 | // Precedence: flag > env > config > active remote | 139 | // Precedence: flag > env > config > active remote | ||
| 140 | func ResolveRemote(flagRemote, flagToken string, flagInsecure bool) (url, token string, insecureTLS, isRemote bool) { | 140 | func ResolveRemote(flagRemote, flagToken string, flagInsecure bool) (url, token string, insecureTLS, isRemote bool) { | ||
| 141 | + | url, token, insecureTLS, _, isRemote = ResolveRemoteFull(flagRemote, flagToken, flagInsecure) | |||
| 142 | + | return url, token, insecureTLS, isRemote | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // ResolveRemoteFull resolves the remote URL, token, and named remote from configuration. | |||
| 146 | + | // It also returns the remote name for use in token persistence callbacks. | |||
| 147 | + | // Precedence: flag > env > config > active remote | |||
| 148 | + | func ResolveRemoteFull(flagRemote, flagToken string, flagInsecure bool) (url, token string, insecureTLS bool, remoteName string, isRemote bool) { | |||
| 141 | config, _ := LoadClientConfig("") | 149 | config, _ := LoadClientConfig("") | ||
| 142 | remotes, _ := LoadRemotes("") | 150 | remotes, _ := LoadRemotes("") | ||
| 143 | 151 | ||||
| 144 | - | url, remoteName, isRemote := resolveRemoteURL(flagRemote, config, remotes) | 152 | + | var name string |
| 153 | + | url, name, isRemote = resolveRemoteURL(flagRemote, config, remotes) | |||
| 145 | if isRemote { | 154 | if isRemote { | ||
| 155 | + | // resolveToken looks up remotes.Active internally; name == remotes.Active here | |||
| 156 | + | // because resolveRemoteURL only sets name when it finds the active remote entry. | |||
| 146 | token = resolveToken(flagToken, config, remotes) | 157 | token = resolveToken(flagToken, config, remotes) | ||
| 147 | - | insecureTLS = resolveInsecureTLS(flagInsecure, config, remotes, remoteName) | 158 | + | insecureTLS = resolveInsecureTLS(flagInsecure, config, remotes, name) |
| 159 | + | remoteName = name | |||
| 148 | } | 160 | } | ||
| 149 | 161 | ||||
| 150 | - | return url, token, insecureTLS, isRemote | 162 | + | return url, token, insecureTLS, remoteName, isRemote |
| 151 | } | 163 | } | ||
| 152 | 164 | ||||
| 153 | // resolveRemoteURL resolves the remote URL from various sources. | 165 | // resolveRemoteURL resolves the remote URL from various sources. | ||
M internal/adapters/in/cli/root.go +13 -2
| @@ -144,12 +144,23 @@ Commands are organized by where they run: | |||
| 144 | 144 | // GetRemoteClient returns a remote client if targeting a remote instance, | |
| 145 | 145 | // or nil if running locally. | |
| 146 | 146 | func GetRemoteClient() (*remote.Client, bool) { | |
| 147 | - | url, token, insecureTLS, isRemote := remote.ResolveRemote(remoteFlag, tokenFlag, insecureTLSFlag) | |
| 147 | + | url, token, insecureTLS, remoteName, isRemote := remote.ResolveRemoteFull(remoteFlag, tokenFlag, insecureTLSFlag) | |
| 148 | 148 | if !isRemote { | |
| 149 | 149 | return nil, false | |
| 150 | 150 | } | |
| 151 | 151 | ||
| 152 | - | client := remote.NewClient(url, remoteClientOptions(token, insecureTLS)...) | |
| 152 | + | opts := remoteClientOptions(token, insecureTLS) | |
| 153 | + | ||
| 154 | + | // When using a named remote (from remotes.toml), register a callback to persist | |
| 155 | + | // refreshed tokens returned by the server in the X-Gordon-Token header. | |
| 156 | + | if remoteName != "" { | |
| 157 | + | name := remoteName // capture for closure | |
| 158 | + | opts = append(opts, remote.WithTokenRefreshCallback(func(newToken string) { | |
| 159 | + | _ = remote.UpdateRemoteToken(name, newToken) // best-effort; errors are non-fatal | |
| 160 | + | })) | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | client := remote.NewClient(url, opts...) | |
| 153 | 164 | return client, true | |
| 154 | 165 | } | |
| 155 | 166 | ||
| @@ -144,12 +144,23 @@ Commands are organized by where they run: | |||||
| 144 | // GetRemoteClient returns a remote client if targeting a remote instance, | 144 | // GetRemoteClient returns a remote client if targeting a remote instance, | ||
| 145 | // or nil if running locally. | 145 | // or nil if running locally. | ||
| 146 | func GetRemoteClient() (*remote.Client, bool) { | 146 | func GetRemoteClient() (*remote.Client, bool) { | ||
| 147 | - | url, token, insecureTLS, isRemote := remote.ResolveRemote(remoteFlag, tokenFlag, insecureTLSFlag) | 147 | + | url, token, insecureTLS, remoteName, isRemote := remote.ResolveRemoteFull(remoteFlag, tokenFlag, insecureTLSFlag) |
| 148 | if !isRemote { | 148 | if !isRemote { | ||
| 149 | return nil, false | 149 | return nil, false | ||
| 150 | } | 150 | } | ||
| 151 | 151 | ||||
| 152 | - | client := remote.NewClient(url, remoteClientOptions(token, insecureTLS)...) | 152 | + | opts := remoteClientOptions(token, insecureTLS) |
| 153 | + | ||||
| 154 | + | // When using a named remote (from remotes.toml), register a callback to persist | |||
| 155 | + | // refreshed tokens returned by the server in the X-Gordon-Token header. | |||
| 156 | + | if remoteName != "" { | |||
| 157 | + | name := remoteName // capture for closure | |||
| 158 | + | opts = append(opts, remote.WithTokenRefreshCallback(func(newToken string) { | |||
| 159 | + | _ = remote.UpdateRemoteToken(name, newToken) // best-effort; errors are non-fatal | |||
| 160 | + | })) | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | client := remote.NewClient(url, opts...) | |||
| 153 | return client, true | 164 | return client, true | ||
| 154 | } | 165 | } | ||
| 155 | 166 | ||||
M internal/adapters/in/cli/ui/components/selector.go +2 -1
| @@ -82,7 +82,8 @@ func (m SelectorModel) View() string { | |||
| 82 | 82 | if i == m.cursor { | |
| 83 | 83 | b.WriteString(styles.Theme.Highlight.Render(cursor + line)) | |
| 84 | 84 | } else { | |
| 85 | - | b.WriteString(fmt.Sprintf("%s%s", cursor, line)) | |
| 85 | + | b.WriteString(cursor) | |
| 86 | + | b.WriteString(line) | |
| 86 | 87 | } | |
| 87 | 88 | b.WriteString("\n") | |
| 88 | 89 | } | |
| @@ -82,7 +82,8 @@ func (m SelectorModel) View() string { | |||||
| 82 | if i == m.cursor { | 82 | if i == m.cursor { | ||
| 83 | b.WriteString(styles.Theme.Highlight.Render(cursor + line)) | 83 | b.WriteString(styles.Theme.Highlight.Render(cursor + line)) | ||
| 84 | } else { | 84 | } else { | ||
| 85 | - | b.WriteString(fmt.Sprintf("%s%s", cursor, line)) | 85 | + | b.WriteString(cursor) |
| 86 | + | b.WriteString(line) | |||
| 86 | } | 87 | } | ||
| 87 | b.WriteString("\n") | 88 | b.WriteString("\n") | ||
| 88 | } | 89 | } | ||
M internal/adapters/in/http/admin/middleware.go +8 -0
| @@ -106,6 +106,14 @@ func AuthMiddleware( | |||
| 106 | 106 | ctx = context.WithValue(ctx, domain.ContextKeySubject, claims.Subject) | |
| 107 | 107 | ctx = context.WithValue(ctx, domain.TokenClaimsKey, claims) | |
| 108 | 108 | ||
| 109 | + | // Attempt to slide token expiry. Non-fatal if it fails. | |
| 110 | + | // The new token is returned in X-Gordon-Token so the CLI can persist it atomically. | |
| 111 | + | if newToken, extErr := authSvc.ExtendToken(ctx, token); extErr != nil { | |
| 112 | + | log.Warn().Err(extErr).Str("subject", claims.Subject).Msg("token extension failed") | |
| 113 | + | } else if newToken != token { | |
| 114 | + | w.Header().Set("X-Gordon-Token", newToken) | |
| 115 | + | } | |
| 116 | + | ||
| 109 | 117 | next.ServeHTTP(w, r.WithContext(ctx)) | |
| 110 | 118 | }) | |
| 111 | 119 | } | |
| @@ -106,6 +106,14 @@ func AuthMiddleware( | |||||
| 106 | ctx = context.WithValue(ctx, domain.ContextKeySubject, claims.Subject) | 106 | ctx = context.WithValue(ctx, domain.ContextKeySubject, claims.Subject) | ||
| 107 | ctx = context.WithValue(ctx, domain.TokenClaimsKey, claims) | 107 | ctx = context.WithValue(ctx, domain.TokenClaimsKey, claims) | ||
| 108 | 108 | ||||
| 109 | + | // Attempt to slide token expiry. Non-fatal if it fails. | |||
| 110 | + | // The new token is returned in X-Gordon-Token so the CLI can persist it atomically. | |||
| 111 | + | if newToken, extErr := authSvc.ExtendToken(ctx, token); extErr != nil { | |||
| 112 | + | log.Warn().Err(extErr).Str("subject", claims.Subject).Msg("token extension failed") | |||
| 113 | + | } else if newToken != token { | |||
| 114 | + | w.Header().Set("X-Gordon-Token", newToken) | |||
| 115 | + | } | |||
| 116 | + | ||||
| 109 | next.ServeHTTP(w, r.WithContext(ctx)) | 117 | next.ServeHTTP(w, r.WithContext(ctx)) | ||
| 110 | }) | 118 | }) | ||
| 111 | } | 119 | } | ||
M internal/adapters/in/http/admin/middleware_test.go +61 -0
| @@ -2,6 +2,7 @@ package admin | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "net/http" | |
| 6 | 7 | "net/http/httptest" | |
| 7 | 8 | "testing" | |
| @@ -83,6 +84,8 @@ func TestAuthMiddleware_TrustedProxy(t *testing.T) { | |||
| 83 | 84 | Subject: "admin", | |
| 84 | 85 | Scopes: []string{"admin:*:*"}, | |
| 85 | 86 | }, nil) | |
| 87 | + | // ExtendToken is called after successful validation; non-fatal if it fails | |
| 88 | + | authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil) | |
| 86 | 89 | ||
| 87 | 90 | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 88 | 91 | w.WriteHeader(http.StatusOK) | |
| @@ -110,6 +113,8 @@ func TestAuthMiddleware_NoRateLimiting(t *testing.T) { | |||
| 110 | 113 | Subject: "admin", | |
| 111 | 114 | Scopes: []string{"admin:*:*"}, | |
| 112 | 115 | }, nil) | |
| 116 | + | // ExtendToken is called after successful validation; non-fatal if it fails | |
| 117 | + | authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil) | |
| 113 | 118 | ||
| 114 | 119 | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 115 | 120 | w.WriteHeader(http.StatusOK) | |
| @@ -247,6 +252,8 @@ func TestAuthMiddleware_Success(t *testing.T) { | |||
| 247 | 252 | Subject: "admin", | |
| 248 | 253 | Scopes: []string{"admin:*:*"}, | |
| 249 | 254 | }, nil) | |
| 255 | + | // ExtendToken is called after successful validation; non-fatal if it fails | |
| 256 | + | authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil) | |
| 250 | 257 | ||
| 251 | 258 | var capturedCtx context.Context | |
| 252 | 259 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| @@ -358,6 +365,60 @@ func TestRequireScope(t *testing.T) { | |||
| 358 | 365 | } | |
| 359 | 366 | } | |
| 360 | 367 | ||
| 368 | + | func TestAuthMiddleware_ExtendTokenFailureIsNonFatal(t *testing.T) { | |
| 369 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 370 | + | ||
| 371 | + | authSvc.EXPECT().IsEnabled().Return(true) | |
| 372 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{ | |
| 373 | + | Subject: "admin", | |
| 374 | + | Scopes: []string{"admin:*:*"}, | |
| 375 | + | }, nil) | |
| 376 | + | // ExtendToken fails — response must still be 200 (non-fatal) | |
| 377 | + | authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("", errors.New("store unavailable")) | |
| 378 | + | ||
| 379 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 380 | + | w.WriteHeader(http.StatusOK) | |
| 381 | + | }) | |
| 382 | + | ||
| 383 | + | mw := AuthMiddleware(authSvc, nil, nil, nil, adminTestLogger()) | |
| 384 | + | wrappedHandler := mw(handler) | |
| 385 | + | ||
| 386 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |
| 387 | + | req.Header.Set("Authorization", "Bearer valid-admin-token") | |
| 388 | + | rec := httptest.NewRecorder() | |
| 389 | + | wrappedHandler.ServeHTTP(rec, req) | |
| 390 | + | ||
| 391 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 392 | + | assert.Empty(t, rec.Header().Get("X-Gordon-Token")) | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | func TestAuthMiddleware_ExtendTokenRotationHeader(t *testing.T) { | |
| 396 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 397 | + | ||
| 398 | + | authSvc.EXPECT().IsEnabled().Return(true) | |
| 399 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "old-token").Return(&domain.TokenClaims{ | |
| 400 | + | Subject: "admin", | |
| 401 | + | Scopes: []string{"admin:*:*"}, | |
| 402 | + | }, nil) | |
| 403 | + | // ExtendToken returns a rotated token — must appear in response header | |
| 404 | + | authSvc.EXPECT().ExtendToken(mock.Anything, "old-token").Return("rotated-token", nil) | |
| 405 | + | ||
| 406 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 407 | + | w.WriteHeader(http.StatusOK) | |
| 408 | + | }) | |
| 409 | + | ||
| 410 | + | mw := AuthMiddleware(authSvc, nil, nil, nil, adminTestLogger()) | |
| 411 | + | wrappedHandler := mw(handler) | |
| 412 | + | ||
| 413 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |
| 414 | + | req.Header.Set("Authorization", "Bearer old-token") | |
| 415 | + | rec := httptest.NewRecorder() | |
| 416 | + | wrappedHandler.ServeHTTP(rec, req) | |
| 417 | + | ||
| 418 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 419 | + | assert.Equal(t, "rotated-token", rec.Header().Get("X-Gordon-Token")) | |
| 420 | + | } | |
| 421 | + | ||
| 361 | 422 | func TestHasAccess(t *testing.T) { | |
| 362 | 423 | ctx := context.WithValue(context.Background(), domain.ContextKeyScopes, []string{"admin:routes:read", "admin:secrets:*"}) | |
| 363 | 424 | ||
| @@ -2,6 +2,7 @@ package admin | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 5 | "net/http" | 6 | "net/http" | ||
| 6 | "net/http/httptest" | 7 | "net/http/httptest" | ||
| 7 | "testing" | 8 | "testing" | ||
| @@ -83,6 +84,8 @@ func TestAuthMiddleware_TrustedProxy(t *testing.T) { | |||||
| 83 | Subject: "admin", | 84 | Subject: "admin", | ||
| 84 | Scopes: []string{"admin:*:*"}, | 85 | Scopes: []string{"admin:*:*"}, | ||
| 85 | }, nil) | 86 | }, nil) | ||
| 87 | + | // ExtendToken is called after successful validation; non-fatal if it fails | |||
| 88 | + | authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil) | |||
| 86 | 89 | ||||
| 87 | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | 90 | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | ||
| 88 | w.WriteHeader(http.StatusOK) | 91 | w.WriteHeader(http.StatusOK) | ||
| @@ -110,6 +113,8 @@ func TestAuthMiddleware_NoRateLimiting(t *testing.T) { | |||||
| 110 | Subject: "admin", | 113 | Subject: "admin", | ||
| 111 | Scopes: []string{"admin:*:*"}, | 114 | Scopes: []string{"admin:*:*"}, | ||
| 112 | }, nil) | 115 | }, nil) | ||
| 116 | + | // ExtendToken is called after successful validation; non-fatal if it fails | |||
| 117 | + | authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil) | |||
| 113 | 118 | ||||
| 114 | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | 119 | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | ||
| 115 | w.WriteHeader(http.StatusOK) | 120 | w.WriteHeader(http.StatusOK) | ||
| @@ -247,6 +252,8 @@ func TestAuthMiddleware_Success(t *testing.T) { | |||||
| 247 | Subject: "admin", | 252 | Subject: "admin", | ||
| 248 | Scopes: []string{"admin:*:*"}, | 253 | Scopes: []string{"admin:*:*"}, | ||
| 249 | }, nil) | 254 | }, nil) | ||
| 255 | + | // ExtendToken is called after successful validation; non-fatal if it fails | |||
| 256 | + | authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil) | |||
| 250 | 257 | ||||
| 251 | var capturedCtx context.Context | 258 | var capturedCtx context.Context | ||
| 252 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 259 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| @@ -358,6 +365,60 @@ func TestRequireScope(t *testing.T) { | |||||
| 358 | } | 365 | } | ||
| 359 | } | 366 | } | ||
| 360 | 367 | ||||
| 368 | + | func TestAuthMiddleware_ExtendTokenFailureIsNonFatal(t *testing.T) { | |||
| 369 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 370 | + | ||||
| 371 | + | authSvc.EXPECT().IsEnabled().Return(true) | |||
| 372 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{ | |||
| 373 | + | Subject: "admin", | |||
| 374 | + | Scopes: []string{"admin:*:*"}, | |||
| 375 | + | }, nil) | |||
| 376 | + | // ExtendToken fails — response must still be 200 (non-fatal) | |||
| 377 | + | authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("", errors.New("store unavailable")) | |||
| 378 | + | ||||
| 379 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 380 | + | w.WriteHeader(http.StatusOK) | |||
| 381 | + | }) | |||
| 382 | + | ||||
| 383 | + | mw := AuthMiddleware(authSvc, nil, nil, nil, adminTestLogger()) | |||
| 384 | + | wrappedHandler := mw(handler) | |||
| 385 | + | ||||
| 386 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |||
| 387 | + | req.Header.Set("Authorization", "Bearer valid-admin-token") | |||
| 388 | + | rec := httptest.NewRecorder() | |||
| 389 | + | wrappedHandler.ServeHTTP(rec, req) | |||
| 390 | + | ||||
| 391 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 392 | + | assert.Empty(t, rec.Header().Get("X-Gordon-Token")) | |||
| 393 | + | } | |||
| 394 | + | ||||
| 395 | + | func TestAuthMiddleware_ExtendTokenRotationHeader(t *testing.T) { | |||
| 396 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 397 | + | ||||
| 398 | + | authSvc.EXPECT().IsEnabled().Return(true) | |||
| 399 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "old-token").Return(&domain.TokenClaims{ | |||
| 400 | + | Subject: "admin", | |||
| 401 | + | Scopes: []string{"admin:*:*"}, | |||
| 402 | + | }, nil) | |||
| 403 | + | // ExtendToken returns a rotated token — must appear in response header | |||
| 404 | + | authSvc.EXPECT().ExtendToken(mock.Anything, "old-token").Return("rotated-token", nil) | |||
| 405 | + | ||||
| 406 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 407 | + | w.WriteHeader(http.StatusOK) | |||
| 408 | + | }) | |||
| 409 | + | ||||
| 410 | + | mw := AuthMiddleware(authSvc, nil, nil, nil, adminTestLogger()) | |||
| 411 | + | wrappedHandler := mw(handler) | |||
| 412 | + | ||||
| 413 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |||
| 414 | + | req.Header.Set("Authorization", "Bearer old-token") | |||
| 415 | + | rec := httptest.NewRecorder() | |||
| 416 | + | wrappedHandler.ServeHTTP(rec, req) | |||
| 417 | + | ||||
| 418 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 419 | + | assert.Equal(t, "rotated-token", rec.Header().Get("X-Gordon-Token")) | |||
| 420 | + | } | |||
| 421 | + | ||||
| 361 | func TestHasAccess(t *testing.T) { | 422 | func TestHasAccess(t *testing.T) { | ||
| 362 | ctx := context.WithValue(context.Background(), domain.ContextKeyScopes, []string{"admin:routes:read", "admin:secrets:*"}) | 423 | ctx := context.WithValue(context.Background(), domain.ContextKeyScopes, []string{"admin:routes:read", "admin:secrets:*"}) | ||
| 363 | 424 | ||||
M internal/adapters/in/http/auth/handler.go +2 -11
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | "github.com/bnema/zerowrap" | |
| 13 | 13 | ||
| 14 | 14 | "github.com/bnema/gordon/internal/adapters/dto" | |
| 15 | + | "github.com/bnema/gordon/internal/adapters/in/http/httputil" | |
| 15 | 16 | "github.com/bnema/gordon/internal/boundaries/in" | |
| 16 | 17 | "github.com/bnema/gordon/internal/domain" | |
| 17 | 18 | ) | |
| @@ -269,7 +270,7 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | |||
| 269 | 270 | } | |
| 270 | 271 | ||
| 271 | 272 | func (h *Handler) authenticateTokenCredentials(ctx context.Context, r *http.Request, username, password string, log zerowrap.Logger) (bool, *domain.TokenClaims) { | |
| 272 | - | if isLocalhostRequest(r) && h.isInternalAuth(username, password) { | |
| 273 | + | if httputil.IsLocalhostRequest(r) && h.isInternalAuth(username, password) { | |
| 273 | 274 | log.Debug().Str("username", username).Msg("internal registry auth accepted") | |
| 274 | 275 | return true, nil | |
| 275 | 276 | } | |
| @@ -350,16 +351,6 @@ func hasGrantedRegistryAccess(grantedScopes []string, repoName, action string) b | |||
| 350 | 351 | return false | |
| 351 | 352 | } | |
| 352 | 353 | ||
| 353 | - | // isLocalhostRequest checks if the request originates from localhost. | |
| 354 | - | // SECURITY: Uses RemoteAddr (server-set) instead of Host header (client-spoofable). | |
| 355 | - | func isLocalhostRequest(r *http.Request) bool { | |
| 356 | - | host := r.RemoteAddr | |
| 357 | - | // RemoteAddr includes port, e.g., "127.0.0.1:12345" or "[::1]:12345" | |
| 358 | - | return strings.HasPrefix(host, "127.") || | |
| 359 | - | strings.HasPrefix(host, "[::1]") || | |
| 360 | - | strings.HasPrefix(host, "::1") | |
| 361 | - | } | |
| 362 | - | ||
| 363 | 354 | // parseRequestedScopes extracts and validates scope parameters from the request. | |
| 364 | 355 | // Per Docker Registry v2 auth spec, scope format is: repository:name:actions | |
| 365 | 356 | // Example: GET /auth/token?scope=repository:myrepo:push,pull&scope=repository:other:pull | |
| @@ -12,6 +12,7 @@ import ( | |||||
| 12 | "github.com/bnema/zerowrap" | 12 | "github.com/bnema/zerowrap" | ||
| 13 | 13 | ||||
| 14 | "github.com/bnema/gordon/internal/adapters/dto" | 14 | "github.com/bnema/gordon/internal/adapters/dto" | ||
| 15 | + | "github.com/bnema/gordon/internal/adapters/in/http/httputil" | |||
| 15 | "github.com/bnema/gordon/internal/boundaries/in" | 16 | "github.com/bnema/gordon/internal/boundaries/in" | ||
| 16 | "github.com/bnema/gordon/internal/domain" | 17 | "github.com/bnema/gordon/internal/domain" | ||
| 17 | ) | 18 | ) | ||
| @@ -269,7 +270,7 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | |||||
| 269 | } | 270 | } | ||
| 270 | 271 | ||||
| 271 | func (h *Handler) authenticateTokenCredentials(ctx context.Context, r *http.Request, username, password string, log zerowrap.Logger) (bool, *domain.TokenClaims) { | 272 | func (h *Handler) authenticateTokenCredentials(ctx context.Context, r *http.Request, username, password string, log zerowrap.Logger) (bool, *domain.TokenClaims) { | ||
| 272 | - | if isLocalhostRequest(r) && h.isInternalAuth(username, password) { | 273 | + | if httputil.IsLocalhostRequest(r) && h.isInternalAuth(username, password) { |
| 273 | log.Debug().Str("username", username).Msg("internal registry auth accepted") | 274 | log.Debug().Str("username", username).Msg("internal registry auth accepted") | ||
| 274 | return true, nil | 275 | return true, nil | ||
| 275 | } | 276 | } | ||
| @@ -350,16 +351,6 @@ func hasGrantedRegistryAccess(grantedScopes []string, repoName, action string) b | |||||
| 350 | return false | 351 | return false | ||
| 351 | } | 352 | } | ||
| 352 | 353 | ||||
| 353 | - | // isLocalhostRequest checks if the request originates from localhost. | |||
| 354 | - | // SECURITY: Uses RemoteAddr (server-set) instead of Host header (client-spoofable). | |||
| 355 | - | func isLocalhostRequest(r *http.Request) bool { | |||
| 356 | - | host := r.RemoteAddr | |||
| 357 | - | // RemoteAddr includes port, e.g., "127.0.0.1:12345" or "[::1]:12345" | |||
| 358 | - | return strings.HasPrefix(host, "127.") || | |||
| 359 | - | strings.HasPrefix(host, "[::1]") || | |||
| 360 | - | strings.HasPrefix(host, "::1") | |||
| 361 | - | } | |||
| 362 | - | ||||
| 363 | // parseRequestedScopes extracts and validates scope parameters from the request. | 354 | // parseRequestedScopes extracts and validates scope parameters from the request. | ||
| 364 | // Per Docker Registry v2 auth spec, scope format is: repository:name:actions | 355 | // Per Docker Registry v2 auth spec, scope format is: repository:name:actions | ||
| 365 | // Example: GET /auth/token?scope=repository:myrepo:push,pull&scope=repository:other:pull | 356 | // Example: GET /auth/token?scope=repository:myrepo:push,pull&scope=repository:other:pull | ||
M internal/adapters/in/http/auth/handler_test.go +2 -2
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | "github.com/stretchr/testify/assert" | |
| 13 | 13 | "github.com/stretchr/testify/mock" | |
| 14 | 14 | ||
| 15 | + | "github.com/bnema/gordon/internal/adapters/in/http/httputil" | |
| 15 | 16 | "github.com/bnema/gordon/internal/boundaries/in/mocks" | |
| 16 | 17 | "github.com/bnema/gordon/internal/domain" | |
| 17 | 18 | ) | |
| @@ -435,7 +436,6 @@ func TestIsLocalhostRequest(t *testing.T) { | |||
| 435 | 436 | {"127.0.0.1 is localhost", "127.0.0.1:12345", true}, | |
| 436 | 437 | {"127.0.0.2 is localhost", "127.0.0.2:12345", true}, | |
| 437 | 438 | {"::1 is localhost", "[::1]:12345", true}, | |
| 438 | - | {"::1 without brackets", "::1:12345", true}, | |
| 439 | 439 | {"192.168.1.1 is not localhost", "192.168.1.1:12345", false}, | |
| 440 | 440 | {"10.0.0.1 is not localhost", "10.0.0.1:12345", false}, | |
| 441 | 441 | {"public IP is not localhost", "8.8.8.8:12345", false}, | |
| @@ -446,7 +446,7 @@ func TestIsLocalhostRequest(t *testing.T) { | |||
| 446 | 446 | req := httptest.NewRequest(http.MethodGet, "/auth/token", nil) | |
| 447 | 447 | req.RemoteAddr = tt.remoteAddr | |
| 448 | 448 | ||
| 449 | - | result := isLocalhostRequest(req) | |
| 449 | + | result := httputil.IsLocalhostRequest(req) | |
| 450 | 450 | ||
| 451 | 451 | assert.Equal(t, tt.want, result) | |
| 452 | 452 | }) | |
| @@ -12,6 +12,7 @@ import ( | |||||
| 12 | "github.com/stretchr/testify/assert" | 12 | "github.com/stretchr/testify/assert" | ||
| 13 | "github.com/stretchr/testify/mock" | 13 | "github.com/stretchr/testify/mock" | ||
| 14 | 14 | ||||
| 15 | + | "github.com/bnema/gordon/internal/adapters/in/http/httputil" | |||
| 15 | "github.com/bnema/gordon/internal/boundaries/in/mocks" | 16 | "github.com/bnema/gordon/internal/boundaries/in/mocks" | ||
| 16 | "github.com/bnema/gordon/internal/domain" | 17 | "github.com/bnema/gordon/internal/domain" | ||
| 17 | ) | 18 | ) | ||
| @@ -435,7 +436,6 @@ func TestIsLocalhostRequest(t *testing.T) { | |||||
| 435 | {"127.0.0.1 is localhost", "127.0.0.1:12345", true}, | 436 | {"127.0.0.1 is localhost", "127.0.0.1:12345", true}, | ||
| 436 | {"127.0.0.2 is localhost", "127.0.0.2:12345", true}, | 437 | {"127.0.0.2 is localhost", "127.0.0.2:12345", true}, | ||
| 437 | {"::1 is localhost", "[::1]:12345", true}, | 438 | {"::1 is localhost", "[::1]:12345", true}, | ||
| 438 | - | {"::1 without brackets", "::1:12345", true}, | |||
| 439 | {"192.168.1.1 is not localhost", "192.168.1.1:12345", false}, | 439 | {"192.168.1.1 is not localhost", "192.168.1.1:12345", false}, | ||
| 440 | {"10.0.0.1 is not localhost", "10.0.0.1:12345", false}, | 440 | {"10.0.0.1 is not localhost", "10.0.0.1:12345", false}, | ||
| 441 | {"public IP is not localhost", "8.8.8.8:12345", false}, | 441 | {"public IP is not localhost", "8.8.8.8:12345", false}, | ||
| @@ -446,7 +446,7 @@ func TestIsLocalhostRequest(t *testing.T) { | |||||
| 446 | req := httptest.NewRequest(http.MethodGet, "/auth/token", nil) | 446 | req := httptest.NewRequest(http.MethodGet, "/auth/token", nil) | ||
| 447 | req.RemoteAddr = tt.remoteAddr | 447 | req.RemoteAddr = tt.remoteAddr | ||
| 448 | 448 | ||||
| 449 | - | result := isLocalhostRequest(req) | 449 | + | result := httputil.IsLocalhostRequest(req) |
| 450 | 450 | ||||
| 451 | assert.Equal(t, tt.want, result) | 451 | assert.Equal(t, tt.want, result) | ||
| 452 | }) | 452 | }) | ||
A internal/adapters/in/http/httputil/localhost.go +22 -0
| @@ -0,0 +1,22 @@ | |||
| 1 | + | package httputil | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "net" | |
| 5 | + | "net/http" | |
| 6 | + | "net/netip" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // IsLocalhostRequest reports whether the request originates from localhost. | |
| 10 | + | // SECURITY: Uses RemoteAddr (server-set) instead of Host header (client-spoofable). | |
| 11 | + | func IsLocalhostRequest(r *http.Request) bool { | |
| 12 | + | host, _, err := net.SplitHostPort(r.RemoteAddr) | |
| 13 | + | if err != nil { | |
| 14 | + | // RemoteAddr has no port (unusual but possible in tests/Unix sockets). | |
| 15 | + | host = r.RemoteAddr | |
| 16 | + | } | |
| 17 | + | addr, err := netip.ParseAddr(host) | |
| 18 | + | if err != nil { | |
| 19 | + | return false | |
| 20 | + | } | |
| 21 | + | return addr.IsLoopback() | |
| 22 | + | } | |
| @@ -0,0 +1,22 @@ | |||||
| 1 | + | package httputil | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net" | |||
| 5 | + | "net/http" | |||
| 6 | + | "net/netip" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // IsLocalhostRequest reports whether the request originates from localhost. | |||
| 10 | + | // SECURITY: Uses RemoteAddr (server-set) instead of Host header (client-spoofable). | |||
| 11 | + | func IsLocalhostRequest(r *http.Request) bool { | |||
| 12 | + | host, _, err := net.SplitHostPort(r.RemoteAddr) | |||
| 13 | + | if err != nil { | |||
| 14 | + | // RemoteAddr has no port (unusual but possible in tests/Unix sockets). | |||
| 15 | + | host = r.RemoteAddr | |||
| 16 | + | } | |||
| 17 | + | addr, err := netip.ParseAddr(host) | |||
| 18 | + | if err != nil { | |||
| 19 | + | return false | |||
| 20 | + | } | |||
| 21 | + | return addr.IsLoopback() | |||
| 22 | + | } | |||
A internal/adapters/in/http/httputil/localhost_test.go +33 -0
| @@ -0,0 +1,33 @@ | |||
| 1 | + | package httputil_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "net/http" | |
| 5 | + | "net/http/httptest" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/gordon/internal/adapters/in/http/httputil" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestIsLocalhostRequest(t *testing.T) { | |
| 12 | + | tests := []struct { | |
| 13 | + | name string | |
| 14 | + | remoteAddr string | |
| 15 | + | want bool | |
| 16 | + | }{ | |
| 17 | + | {"ipv4 loopback", "127.0.0.1:12345", true}, | |
| 18 | + | {"ipv4 loopback other", "127.0.0.2:9000", true}, | |
| 19 | + | {"ipv6 loopback bracketed", "[::1]:12345", true}, | |
| 20 | + | {"external ipv4", "192.168.1.1:12345", false}, | |
| 21 | + | {"external ipv6", "[2001:db8::1]:12345", false}, | |
| 22 | + | {"empty", "", false}, | |
| 23 | + | } | |
| 24 | + | for _, tt := range tests { | |
| 25 | + | t.Run(tt.name, func(t *testing.T) { | |
| 26 | + | req := httptest.NewRequest(http.MethodGet, "/", nil) | |
| 27 | + | req.RemoteAddr = tt.remoteAddr | |
| 28 | + | if got := httputil.IsLocalhostRequest(req); got != tt.want { | |
| 29 | + | t.Errorf("IsLocalhostRequest(%q) = %v, want %v", tt.remoteAddr, got, tt.want) | |
| 30 | + | } | |
| 31 | + | }) | |
| 32 | + | } | |
| 33 | + | } | |
| @@ -0,0 +1,33 @@ | |||||
| 1 | + | package httputil_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net/http" | |||
| 5 | + | "net/http/httptest" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/gordon/internal/adapters/in/http/httputil" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestIsLocalhostRequest(t *testing.T) { | |||
| 12 | + | tests := []struct { | |||
| 13 | + | name string | |||
| 14 | + | remoteAddr string | |||
| 15 | + | want bool | |||
| 16 | + | }{ | |||
| 17 | + | {"ipv4 loopback", "127.0.0.1:12345", true}, | |||
| 18 | + | {"ipv4 loopback other", "127.0.0.2:9000", true}, | |||
| 19 | + | {"ipv6 loopback bracketed", "[::1]:12345", true}, | |||
| 20 | + | {"external ipv4", "192.168.1.1:12345", false}, | |||
| 21 | + | {"external ipv6", "[2001:db8::1]:12345", false}, | |||
| 22 | + | {"empty", "", false}, | |||
| 23 | + | } | |||
| 24 | + | for _, tt := range tests { | |||
| 25 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 26 | + | req := httptest.NewRequest(http.MethodGet, "/", nil) | |||
| 27 | + | req.RemoteAddr = tt.remoteAddr | |||
| 28 | + | if got := httputil.IsLocalhostRequest(req); got != tt.want { | |||
| 29 | + | t.Errorf("IsLocalhostRequest(%q) = %v, want %v", tt.remoteAddr, got, tt.want) | |||
| 30 | + | } | |||
| 31 | + | }) | |||
| 32 | + | } | |||
| 33 | + | } | |||
M internal/adapters/in/http/middleware/auth.go +2 -83
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "github.com/bnema/zerowrap" | |
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/gordon/internal/adapters/dto" | |
| 13 | + | "github.com/bnema/gordon/internal/adapters/in/http/httputil" | |
| 13 | 14 | "github.com/bnema/gordon/internal/boundaries/in" | |
| 14 | 15 | "github.com/bnema/gordon/internal/domain" | |
| 15 | 16 | ) | |
| @@ -18,75 +19,6 @@ import ( | |||
| 18 | 19 | // Using domain key for consistency across all auth flows. | |
| 19 | 20 | const TokenClaimsKey = domain.TokenClaimsKey | |
| 20 | 21 | ||
| 21 | - | // RegistryAuth middleware provides Docker Registry authentication. | |
| 22 | - | func RegistryAuth(username, password string, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 23 | - | return func(next http.Handler) http.Handler { | |
| 24 | - | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 25 | - | // Require auth for all registry operations | |
| 26 | - | if !isAuthenticated(r, username, password, log) { | |
| 27 | - | w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`) | |
| 28 | - | w.Header().Set("Docker-Distribution-API-Version", "registry/2.0") | |
| 29 | - | w.Header().Set("Content-Type", "application/json") | |
| 30 | - | w.WriteHeader(http.StatusUnauthorized) | |
| 31 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Unauthorized"}) | |
| 32 | - | ||
| 33 | - | log.Warn(). | |
| 34 | - | Str(zerowrap.FieldLayer, "adapter"). | |
| 35 | - | Str(zerowrap.FieldAdapter, "http"). | |
| 36 | - | Str(zerowrap.FieldMethod, r.Method). | |
| 37 | - | Str(zerowrap.FieldPath, r.URL.Path). | |
| 38 | - | Str(zerowrap.FieldClientIP, r.RemoteAddr). | |
| 39 | - | Msg("unauthorized registry access attempt") | |
| 40 | - | return | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | next.ServeHTTP(w, r) | |
| 44 | - | }) | |
| 45 | - | } | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | // isAuthenticated checks basic auth credentials. | |
| 49 | - | func isAuthenticated(r *http.Request, expectedUsername, expectedPassword string, log zerowrap.Logger) bool { | |
| 50 | - | authHeader := r.Header.Get("Authorization") | |
| 51 | - | log.Debug(). | |
| 52 | - | Str(zerowrap.FieldLayer, "adapter"). | |
| 53 | - | Str(zerowrap.FieldAdapter, "http"). | |
| 54 | - | Str(zerowrap.FieldMethod, r.Method). | |
| 55 | - | Str(zerowrap.FieldPath, r.URL.Path). | |
| 56 | - | Bool("has_auth_header", authHeader != ""). | |
| 57 | - | Msg("processing authentication request") | |
| 58 | - | ||
| 59 | - | username, password, ok := r.BasicAuth() | |
| 60 | - | if !ok { | |
| 61 | - | log.Debug(). | |
| 62 | - | Str(zerowrap.FieldMethod, r.Method). | |
| 63 | - | Str(zerowrap.FieldPath, r.URL.Path). | |
| 64 | - | Msg("no basic auth provided") | |
| 65 | - | return false | |
| 66 | - | } | |
| 67 | - | ||
| 68 | - | // Use constant-time comparison to prevent timing attacks | |
| 69 | - | usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(expectedUsername)) == 1 | |
| 70 | - | passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(expectedPassword)) == 1 | |
| 71 | - | ||
| 72 | - | authenticated := usernameMatch && passwordMatch | |
| 73 | - | if !authenticated { | |
| 74 | - | log.Debug(). | |
| 75 | - | Str("provided_username", redactUsername(username)). | |
| 76 | - | Str(zerowrap.FieldMethod, r.Method). | |
| 77 | - | Str(zerowrap.FieldPath, r.URL.Path). | |
| 78 | - | Msg("authentication failed") | |
| 79 | - | } else { | |
| 80 | - | log.Debug(). | |
| 81 | - | Str("username", redactUsername(username)). | |
| 82 | - | Str(zerowrap.FieldMethod, r.Method). | |
| 83 | - | Str(zerowrap.FieldPath, r.URL.Path). | |
| 84 | - | Msg("authentication successful") | |
| 85 | - | } | |
| 86 | - | ||
| 87 | - | return authenticated | |
| 88 | - | } | |
| 89 | - | ||
| 90 | 22 | // sanitizeHeaderValue removes characters that could enable header injection. | |
| 91 | 23 | // Only allows alphanumeric, dots, hyphens, colons, and square brackets | |
| 92 | 24 | // (sufficient for host:port and IPv6 addresses). | |
| @@ -125,7 +57,7 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l | |||
| 125 | 57 | return func(next http.Handler) http.Handler { | |
| 126 | 58 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 127 | 59 | // Allow localhost requests only with internal instance credentials. | |
| 128 | - | if isLocalhostRequest(r) && isInternalRegistryAuth(r, internalAuth) { | |
| 60 | + | if httputil.IsLocalhostRequest(r) && isInternalRegistryAuth(r, internalAuth) { | |
| 129 | 61 | log.Debug(). | |
| 130 | 62 | Str(zerowrap.FieldLayer, "adapter"). | |
| 131 | 63 | Str(zerowrap.FieldAdapter, "http"). | |
| @@ -296,19 +228,6 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ | |||
| 296 | 228 | return true, claims | |
| 297 | 229 | } | |
| 298 | 230 | ||
| 299 | - | // isLocalhostRequest checks if the request originates from localhost. | |
| 300 | - | // This is used to allow Gordon to pull from its own registry with internal auth. | |
| 301 | - | func isLocalhostRequest(r *http.Request) bool { | |
| 302 | - | host := r.RemoteAddr | |
| 303 | - | // RemoteAddr includes port, e.g., "127.0.0.1:12345" or "[::1]:12345" | |
| 304 | - | if strings.HasPrefix(host, "127.") || | |
| 305 | - | strings.HasPrefix(host, "[::1]") || | |
| 306 | - | strings.HasPrefix(host, "::1") { | |
| 307 | - | return true | |
| 308 | - | } | |
| 309 | - | return false | |
| 310 | - | } | |
| 311 | - | ||
| 312 | 231 | func isInternalRegistryAuth(r *http.Request, internalAuth InternalRegistryAuth) bool { | |
| 313 | 232 | if internalAuth.Username == "" || internalAuth.Password == "" { | |
| 314 | 233 | return false | |
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | "github.com/bnema/zerowrap" | 10 | "github.com/bnema/zerowrap" | ||
| 11 | 11 | ||||
| 12 | "github.com/bnema/gordon/internal/adapters/dto" | 12 | "github.com/bnema/gordon/internal/adapters/dto" | ||
| 13 | + | "github.com/bnema/gordon/internal/adapters/in/http/httputil" | |||
| 13 | "github.com/bnema/gordon/internal/boundaries/in" | 14 | "github.com/bnema/gordon/internal/boundaries/in" | ||
| 14 | "github.com/bnema/gordon/internal/domain" | 15 | "github.com/bnema/gordon/internal/domain" | ||
| 15 | ) | 16 | ) | ||
| @@ -18,75 +19,6 @@ import ( | |||||
| 18 | // Using domain key for consistency across all auth flows. | 19 | // Using domain key for consistency across all auth flows. | ||
| 19 | const TokenClaimsKey = domain.TokenClaimsKey | 20 | const TokenClaimsKey = domain.TokenClaimsKey | ||
| 20 | 21 | ||||
| 21 | - | // RegistryAuth middleware provides Docker Registry authentication. | |||
| 22 | - | func RegistryAuth(username, password string, log zerowrap.Logger) func(http.Handler) http.Handler { | |||
| 23 | - | return func(next http.Handler) http.Handler { | |||
| 24 | - | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 25 | - | // Require auth for all registry operations | |||
| 26 | - | if !isAuthenticated(r, username, password, log) { | |||
| 27 | - | w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`) | |||
| 28 | - | w.Header().Set("Docker-Distribution-API-Version", "registry/2.0") | |||
| 29 | - | w.Header().Set("Content-Type", "application/json") | |||
| 30 | - | w.WriteHeader(http.StatusUnauthorized) | |||
| 31 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Unauthorized"}) | |||
| 32 | - | ||||
| 33 | - | log.Warn(). | |||
| 34 | - | Str(zerowrap.FieldLayer, "adapter"). | |||
| 35 | - | Str(zerowrap.FieldAdapter, "http"). | |||
| 36 | - | Str(zerowrap.FieldMethod, r.Method). | |||
| 37 | - | Str(zerowrap.FieldPath, r.URL.Path). | |||
| 38 | - | Str(zerowrap.FieldClientIP, r.RemoteAddr). | |||
| 39 | - | Msg("unauthorized registry access attempt") | |||
| 40 | - | return | |||
| 41 | - | } | |||
| 42 | - | ||||
| 43 | - | next.ServeHTTP(w, r) | |||
| 44 | - | }) | |||
| 45 | - | } | |||
| 46 | - | } | |||
| 47 | - | ||||
| 48 | - | // isAuthenticated checks basic auth credentials. | |||
| 49 | - | func isAuthenticated(r *http.Request, expectedUsername, expectedPassword string, log zerowrap.Logger) bool { | |||
| 50 | - | authHeader := r.Header.Get("Authorization") | |||
| 51 | - | log.Debug(). | |||
| 52 | - | Str(zerowrap.FieldLayer, "adapter"). | |||
| 53 | - | Str(zerowrap.FieldAdapter, "http"). | |||
| 54 | - | Str(zerowrap.FieldMethod, r.Method). | |||
| 55 | - | Str(zerowrap.FieldPath, r.URL.Path). | |||
| 56 | - | Bool("has_auth_header", authHeader != ""). | |||
| 57 | - | Msg("processing authentication request") | |||
| 58 | - | ||||
| 59 | - | username, password, ok := r.BasicAuth() | |||
| 60 | - | if !ok { | |||
| 61 | - | log.Debug(). | |||
| 62 | - | Str(zerowrap.FieldMethod, r.Method). | |||
| 63 | - | Str(zerowrap.FieldPath, r.URL.Path). | |||
| 64 | - | Msg("no basic auth provided") | |||
| 65 | - | return false | |||
| 66 | - | } | |||
| 67 | - | ||||
| 68 | - | // Use constant-time comparison to prevent timing attacks | |||
| 69 | - | usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(expectedUsername)) == 1 | |||
| 70 | - | passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(expectedPassword)) == 1 | |||
| 71 | - | ||||
| 72 | - | authenticated := usernameMatch && passwordMatch | |||
| 73 | - | if !authenticated { | |||
| 74 | - | log.Debug(). | |||
| 75 | - | Str("provided_username", redactUsername(username)). | |||
| 76 | - | Str(zerowrap.FieldMethod, r.Method). | |||
| 77 | - | Str(zerowrap.FieldPath, r.URL.Path). | |||
| 78 | - | Msg("authentication failed") | |||
| 79 | - | } else { | |||
| 80 | - | log.Debug(). | |||
| 81 | - | Str("username", redactUsername(username)). | |||
| 82 | - | Str(zerowrap.FieldMethod, r.Method). | |||
| 83 | - | Str(zerowrap.FieldPath, r.URL.Path). | |||
| 84 | - | Msg("authentication successful") | |||
| 85 | - | } | |||
| 86 | - | ||||
| 87 | - | return authenticated | |||
| 88 | - | } | |||
| 89 | - | ||||
| 90 | // sanitizeHeaderValue removes characters that could enable header injection. | 22 | // sanitizeHeaderValue removes characters that could enable header injection. | ||
| 91 | // Only allows alphanumeric, dots, hyphens, colons, and square brackets | 23 | // Only allows alphanumeric, dots, hyphens, colons, and square brackets | ||
| 92 | // (sufficient for host:port and IPv6 addresses). | 24 | // (sufficient for host:port and IPv6 addresses). | ||
| @@ -125,7 +57,7 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l | |||||
| 125 | return func(next http.Handler) http.Handler { | 57 | return func(next http.Handler) http.Handler { | ||
| 126 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 58 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 127 | // Allow localhost requests only with internal instance credentials. | 59 | // Allow localhost requests only with internal instance credentials. | ||
| 128 | - | if isLocalhostRequest(r) && isInternalRegistryAuth(r, internalAuth) { | 60 | + | if httputil.IsLocalhostRequest(r) && isInternalRegistryAuth(r, internalAuth) { |
| 129 | log.Debug(). | 61 | log.Debug(). | ||
| 130 | Str(zerowrap.FieldLayer, "adapter"). | 62 | Str(zerowrap.FieldLayer, "adapter"). | ||
| 131 | Str(zerowrap.FieldAdapter, "http"). | 63 | Str(zerowrap.FieldAdapter, "http"). | ||
| @@ -296,19 +228,6 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ | |||||
| 296 | return true, claims | 228 | return true, claims | ||
| 297 | } | 229 | } | ||
| 298 | 230 | ||||
| 299 | - | // isLocalhostRequest checks if the request originates from localhost. | |||
| 300 | - | // This is used to allow Gordon to pull from its own registry with internal auth. | |||
| 301 | - | func isLocalhostRequest(r *http.Request) bool { | |||
| 302 | - | host := r.RemoteAddr | |||
| 303 | - | // RemoteAddr includes port, e.g., "127.0.0.1:12345" or "[::1]:12345" | |||
| 304 | - | if strings.HasPrefix(host, "127.") || | |||
| 305 | - | strings.HasPrefix(host, "[::1]") || | |||
| 306 | - | strings.HasPrefix(host, "::1") { | |||
| 307 | - | return true | |||
| 308 | - | } | |||
| 309 | - | return false | |||
| 310 | - | } | |||
| 311 | - | ||||
| 312 | func isInternalRegistryAuth(r *http.Request, internalAuth InternalRegistryAuth) bool { | 231 | func isInternalRegistryAuth(r *http.Request, internalAuth InternalRegistryAuth) bool { | ||
| 313 | if internalAuth.Username == "" || internalAuth.Password == "" { | 232 | if internalAuth.Username == "" || internalAuth.Password == "" { | ||
| 314 | return false | 233 | return false | ||
M internal/adapters/in/http/middleware/auth_test.go +4 -335
| @@ -2,7 +2,6 @@ package middleware | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "encoding/base64" | |
| 6 | 5 | "errors" | |
| 7 | 6 | "net/http" | |
| 8 | 7 | "net/http/httptest" | |
| @@ -19,340 +18,6 @@ func testLogger() zerowrap.Logger { | |||
| 19 | 18 | return zerowrap.Default() | |
| 20 | 19 | } | |
| 21 | 20 | ||
| 22 | - | func TestRegistryAuth_ValidCredentials(t *testing.T) { | |
| 23 | - | log := testLogger() | |
| 24 | - | middleware := RegistryAuth("admin", "secret", log) | |
| 25 | - | ||
| 26 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 27 | - | w.WriteHeader(http.StatusOK) | |
| 28 | - | w.Write([]byte("authenticated")) | |
| 29 | - | })) | |
| 30 | - | ||
| 31 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |
| 32 | - | req.SetBasicAuth("admin", "secret") | |
| 33 | - | rec := httptest.NewRecorder() | |
| 34 | - | ||
| 35 | - | handler.ServeHTTP(rec, req) | |
| 36 | - | ||
| 37 | - | assert.Equal(t, http.StatusOK, rec.Code) | |
| 38 | - | assert.Equal(t, "authenticated", rec.Body.String()) | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | func TestRegistryAuth_InvalidUsername(t *testing.T) { | |
| 42 | - | log := testLogger() | |
| 43 | - | middleware := RegistryAuth("admin", "secret", log) | |
| 44 | - | ||
| 45 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 46 | - | w.WriteHeader(http.StatusOK) | |
| 47 | - | })) | |
| 48 | - | ||
| 49 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |
| 50 | - | req.SetBasicAuth("wronguser", "secret") | |
| 51 | - | rec := httptest.NewRecorder() | |
| 52 | - | ||
| 53 | - | handler.ServeHTTP(rec, req) | |
| 54 | - | ||
| 55 | - | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |
| 56 | - | assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate")) | |
| 57 | - | assert.Equal(t, "registry/2.0", rec.Header().Get("Docker-Distribution-API-Version")) | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | func TestRegistryAuth_InvalidPassword(t *testing.T) { | |
| 61 | - | log := testLogger() | |
| 62 | - | middleware := RegistryAuth("admin", "secret", log) | |
| 63 | - | ||
| 64 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 65 | - | w.WriteHeader(http.StatusOK) | |
| 66 | - | })) | |
| 67 | - | ||
| 68 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |
| 69 | - | req.SetBasicAuth("admin", "wrongpassword") | |
| 70 | - | rec := httptest.NewRecorder() | |
| 71 | - | ||
| 72 | - | handler.ServeHTTP(rec, req) | |
| 73 | - | ||
| 74 | - | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | func TestRegistryAuth_NoCredentials(t *testing.T) { | |
| 78 | - | log := testLogger() | |
| 79 | - | middleware := RegistryAuth("admin", "secret", log) | |
| 80 | - | ||
| 81 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 82 | - | w.WriteHeader(http.StatusOK) | |
| 83 | - | })) | |
| 84 | - | ||
| 85 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |
| 86 | - | // No auth header | |
| 87 | - | rec := httptest.NewRecorder() | |
| 88 | - | ||
| 89 | - | handler.ServeHTTP(rec, req) | |
| 90 | - | ||
| 91 | - | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |
| 92 | - | assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate")) | |
| 93 | - | } | |
| 94 | - | ||
| 95 | - | func TestRegistryAuth_MalformedAuthHeader(t *testing.T) { | |
| 96 | - | log := testLogger() | |
| 97 | - | middleware := RegistryAuth("admin", "secret", log) | |
| 98 | - | ||
| 99 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 100 | - | w.WriteHeader(http.StatusOK) | |
| 101 | - | })) | |
| 102 | - | ||
| 103 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |
| 104 | - | req.Header.Set("Authorization", "Basic notbase64!!!") // Invalid base64 | |
| 105 | - | rec := httptest.NewRecorder() | |
| 106 | - | ||
| 107 | - | handler.ServeHTTP(rec, req) | |
| 108 | - | ||
| 109 | - | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |
| 110 | - | } | |
| 111 | - | ||
| 112 | - | func TestRegistryAuth_WrongAuthScheme(t *testing.T) { | |
| 113 | - | log := testLogger() | |
| 114 | - | middleware := RegistryAuth("admin", "secret", log) | |
| 115 | - | ||
| 116 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 117 | - | w.WriteHeader(http.StatusOK) | |
| 118 | - | })) | |
| 119 | - | ||
| 120 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |
| 121 | - | req.Header.Set("Authorization", "Bearer some-token") | |
| 122 | - | rec := httptest.NewRecorder() | |
| 123 | - | ||
| 124 | - | handler.ServeHTTP(rec, req) | |
| 125 | - | ||
| 126 | - | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |
| 127 | - | } | |
| 128 | - | ||
| 129 | - | func TestRegistryAuth_EmptyCredentials(t *testing.T) { | |
| 130 | - | log := testLogger() | |
| 131 | - | middleware := RegistryAuth("admin", "secret", log) | |
| 132 | - | ||
| 133 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 134 | - | w.WriteHeader(http.StatusOK) | |
| 135 | - | })) | |
| 136 | - | ||
| 137 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |
| 138 | - | req.SetBasicAuth("", "") | |
| 139 | - | rec := httptest.NewRecorder() | |
| 140 | - | ||
| 141 | - | handler.ServeHTTP(rec, req) | |
| 142 | - | ||
| 143 | - | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |
| 144 | - | } | |
| 145 | - | ||
| 146 | - | func TestRegistryAuth_PartialCredentials(t *testing.T) { | |
| 147 | - | log := testLogger() | |
| 148 | - | middleware := RegistryAuth("admin", "secret", log) | |
| 149 | - | ||
| 150 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 151 | - | w.WriteHeader(http.StatusOK) | |
| 152 | - | })) | |
| 153 | - | ||
| 154 | - | // Only username, empty password | |
| 155 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |
| 156 | - | req.SetBasicAuth("admin", "") | |
| 157 | - | rec := httptest.NewRecorder() | |
| 158 | - | ||
| 159 | - | handler.ServeHTTP(rec, req) | |
| 160 | - | ||
| 161 | - | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |
| 162 | - | } | |
| 163 | - | ||
| 164 | - | func TestIsAuthenticated_ValidCredentials(t *testing.T) { | |
| 165 | - | log := testLogger() | |
| 166 | - | ||
| 167 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |
| 168 | - | req.SetBasicAuth("admin", "secret") | |
| 169 | - | ||
| 170 | - | result := isAuthenticated(req, "admin", "secret", log) | |
| 171 | - | ||
| 172 | - | assert.True(t, result) | |
| 173 | - | } | |
| 174 | - | ||
| 175 | - | func TestIsAuthenticated_InvalidCredentials(t *testing.T) { | |
| 176 | - | log := testLogger() | |
| 177 | - | ||
| 178 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |
| 179 | - | req.SetBasicAuth("admin", "wrong") | |
| 180 | - | ||
| 181 | - | result := isAuthenticated(req, "admin", "secret", log) | |
| 182 | - | ||
| 183 | - | assert.False(t, result) | |
| 184 | - | } | |
| 185 | - | ||
| 186 | - | func TestIsAuthenticated_NoAuthHeader(t *testing.T) { | |
| 187 | - | log := testLogger() | |
| 188 | - | ||
| 189 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |
| 190 | - | ||
| 191 | - | result := isAuthenticated(req, "admin", "secret", log) | |
| 192 | - | ||
| 193 | - | assert.False(t, result) | |
| 194 | - | } | |
| 195 | - | ||
| 196 | - | func TestIsAuthenticated_TimingAttackPrevention(t *testing.T) { | |
| 197 | - | // This test verifies that constant-time comparison is used | |
| 198 | - | // by checking both username and password are validated | |
| 199 | - | log := testLogger() | |
| 200 | - | ||
| 201 | - | tests := []struct { | |
| 202 | - | name string | |
| 203 | - | username string | |
| 204 | - | password string | |
| 205 | - | expectedUser string | |
| 206 | - | expectedPass string | |
| 207 | - | shouldAuth bool | |
| 208 | - | }{ | |
| 209 | - | { | |
| 210 | - | name: "both correct", | |
| 211 | - | username: "admin", | |
| 212 | - | password: "secret", | |
| 213 | - | expectedUser: "admin", | |
| 214 | - | expectedPass: "secret", | |
| 215 | - | shouldAuth: true, | |
| 216 | - | }, | |
| 217 | - | { | |
| 218 | - | name: "username wrong first char", | |
| 219 | - | username: "bdmin", | |
| 220 | - | password: "secret", | |
| 221 | - | expectedUser: "admin", | |
| 222 | - | expectedPass: "secret", | |
| 223 | - | shouldAuth: false, | |
| 224 | - | }, | |
| 225 | - | { | |
| 226 | - | name: "username wrong last char", | |
| 227 | - | username: "admio", | |
| 228 | - | password: "secret", | |
| 229 | - | expectedUser: "admin", | |
| 230 | - | expectedPass: "secret", | |
| 231 | - | shouldAuth: false, | |
| 232 | - | }, | |
| 233 | - | { | |
| 234 | - | name: "password wrong first char", | |
| 235 | - | username: "admin", | |
| 236 | - | password: "aecret", | |
| 237 | - | expectedUser: "admin", | |
| 238 | - | expectedPass: "secret", | |
| 239 | - | shouldAuth: false, | |
| 240 | - | }, | |
| 241 | - | { | |
| 242 | - | name: "password wrong last char", | |
| 243 | - | username: "admin", | |
| 244 | - | password: "secreo", | |
| 245 | - | expectedUser: "admin", | |
| 246 | - | expectedPass: "secret", | |
| 247 | - | shouldAuth: false, | |
| 248 | - | }, | |
| 249 | - | { | |
| 250 | - | name: "different length username", | |
| 251 | - | username: "adm", | |
| 252 | - | password: "secret", | |
| 253 | - | expectedUser: "admin", | |
| 254 | - | expectedPass: "secret", | |
| 255 | - | shouldAuth: false, | |
| 256 | - | }, | |
| 257 | - | { | |
| 258 | - | name: "different length password", | |
| 259 | - | username: "admin", | |
| 260 | - | password: "sec", | |
| 261 | - | expectedUser: "admin", | |
| 262 | - | expectedPass: "secret", | |
| 263 | - | shouldAuth: false, | |
| 264 | - | }, | |
| 265 | - | } | |
| 266 | - | ||
| 267 | - | for _, tt := range tests { | |
| 268 | - | t.Run(tt.name, func(t *testing.T) { | |
| 269 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |
| 270 | - | req.SetBasicAuth(tt.username, tt.password) | |
| 271 | - | ||
| 272 | - | result := isAuthenticated(req, tt.expectedUser, tt.expectedPass, log) | |
| 273 | - | ||
| 274 | - | assert.Equal(t, tt.shouldAuth, result) | |
| 275 | - | }) | |
| 276 | - | } | |
| 277 | - | } | |
| 278 | - | ||
| 279 | - | func TestRegistryAuth_SpecialCharactersInCredentials(t *testing.T) { | |
| 280 | - | log := testLogger() | |
| 281 | - | ||
| 282 | - | tests := []struct { | |
| 283 | - | name string | |
| 284 | - | username string | |
| 285 | - | password string | |
| 286 | - | }{ | |
| 287 | - | {"unicode username", "用户", "password"}, | |
| 288 | - | {"unicode password", "admin", "密码"}, | |
| 289 | - | {"special chars", "admin@example.com", "p@ss:w0rd!"}, | |
| 290 | - | {"spaces", "admin user", "pass word"}, | |
| 291 | - | {"colon in password", "admin", "pass:word"}, | |
| 292 | - | } | |
| 293 | - | ||
| 294 | - | for _, tt := range tests { | |
| 295 | - | t.Run(tt.name, func(t *testing.T) { | |
| 296 | - | middleware := RegistryAuth(tt.username, tt.password, log) | |
| 297 | - | ||
| 298 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 299 | - | w.WriteHeader(http.StatusOK) | |
| 300 | - | })) | |
| 301 | - | ||
| 302 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |
| 303 | - | req.SetBasicAuth(tt.username, tt.password) | |
| 304 | - | rec := httptest.NewRecorder() | |
| 305 | - | ||
| 306 | - | handler.ServeHTTP(rec, req) | |
| 307 | - | ||
| 308 | - | assert.Equal(t, http.StatusOK, rec.Code) | |
| 309 | - | }) | |
| 310 | - | } | |
| 311 | - | } | |
| 312 | - | ||
| 313 | - | func TestRegistryAuth_Base64EdgeCases(t *testing.T) { | |
| 314 | - | log := testLogger() | |
| 315 | - | middleware := RegistryAuth("admin", "secret", log) | |
| 316 | - | ||
| 317 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 318 | - | w.WriteHeader(http.StatusOK) | |
| 319 | - | })) | |
| 320 | - | ||
| 321 | - | tests := []struct { | |
| 322 | - | name string | |
| 323 | - | authHeader string | |
| 324 | - | wantStatus int | |
| 325 | - | }{ | |
| 326 | - | { | |
| 327 | - | name: "empty basic auth", | |
| 328 | - | authHeader: "Basic ", | |
| 329 | - | wantStatus: http.StatusUnauthorized, | |
| 330 | - | }, | |
| 331 | - | { | |
| 332 | - | name: "basic auth with only colon", | |
| 333 | - | authHeader: "Basic " + base64.StdEncoding.EncodeToString([]byte(":")), | |
| 334 | - | wantStatus: http.StatusUnauthorized, | |
| 335 | - | }, | |
| 336 | - | { | |
| 337 | - | name: "missing colon in decoded value", | |
| 338 | - | authHeader: "Basic " + base64.StdEncoding.EncodeToString([]byte("adminpassword")), | |
| 339 | - | wantStatus: http.StatusUnauthorized, | |
| 340 | - | }, | |
| 341 | - | } | |
| 342 | - | ||
| 343 | - | for _, tt := range tests { | |
| 344 | - | t.Run(tt.name, func(t *testing.T) { | |
| 345 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |
| 346 | - | req.Header.Set("Authorization", tt.authHeader) | |
| 347 | - | rec := httptest.NewRecorder() | |
| 348 | - | ||
| 349 | - | handler.ServeHTTP(rec, req) | |
| 350 | - | ||
| 351 | - | assert.Equal(t, tt.wantStatus, rec.Code) | |
| 352 | - | }) | |
| 353 | - | } | |
| 354 | - | } | |
| 355 | - | ||
| 356 | 21 | func TestRegistryAuthV2_LocalhostBypassRequiresInternalAuth(t *testing.T) { | |
| 357 | 22 | log := testLogger() | |
| 358 | 23 | called := false | |
| @@ -505,6 +170,10 @@ func (s stubAuthService) GetAuthStatus(context.Context) (*domain.AuthStatus, err | |||
| 505 | 170 | return nil, errors.New("not implemented") | |
| 506 | 171 | } | |
| 507 | 172 | ||
| 173 | + | func (s stubAuthService) ExtendToken(context.Context, string) (string, error) { | |
| 174 | + | return "", errors.New("not implemented") | |
| 175 | + | } | |
| 176 | + | ||
| 508 | 177 | // Tests for checkScopeAccess function | |
| 509 | 178 | ||
| 510 | 179 | func TestCheckScopeAccess_ActionMapping(t *testing.T) { | |
| @@ -2,7 +2,6 @@ package middleware | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | - | "encoding/base64" | |||
| 6 | "errors" | 5 | "errors" | ||
| 7 | "net/http" | 6 | "net/http" | ||
| 8 | "net/http/httptest" | 7 | "net/http/httptest" | ||
| @@ -19,340 +18,6 @@ func testLogger() zerowrap.Logger { | |||||
| 19 | return zerowrap.Default() | 18 | return zerowrap.Default() | ||
| 20 | } | 19 | } | ||
| 21 | 20 | ||||
| 22 | - | func TestRegistryAuth_ValidCredentials(t *testing.T) { | |||
| 23 | - | log := testLogger() | |||
| 24 | - | middleware := RegistryAuth("admin", "secret", log) | |||
| 25 | - | ||||
| 26 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 27 | - | w.WriteHeader(http.StatusOK) | |||
| 28 | - | w.Write([]byte("authenticated")) | |||
| 29 | - | })) | |||
| 30 | - | ||||
| 31 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |||
| 32 | - | req.SetBasicAuth("admin", "secret") | |||
| 33 | - | rec := httptest.NewRecorder() | |||
| 34 | - | ||||
| 35 | - | handler.ServeHTTP(rec, req) | |||
| 36 | - | ||||
| 37 | - | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 38 | - | assert.Equal(t, "authenticated", rec.Body.String()) | |||
| 39 | - | } | |||
| 40 | - | ||||
| 41 | - | func TestRegistryAuth_InvalidUsername(t *testing.T) { | |||
| 42 | - | log := testLogger() | |||
| 43 | - | middleware := RegistryAuth("admin", "secret", log) | |||
| 44 | - | ||||
| 45 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 46 | - | w.WriteHeader(http.StatusOK) | |||
| 47 | - | })) | |||
| 48 | - | ||||
| 49 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |||
| 50 | - | req.SetBasicAuth("wronguser", "secret") | |||
| 51 | - | rec := httptest.NewRecorder() | |||
| 52 | - | ||||
| 53 | - | handler.ServeHTTP(rec, req) | |||
| 54 | - | ||||
| 55 | - | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |||
| 56 | - | assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate")) | |||
| 57 | - | assert.Equal(t, "registry/2.0", rec.Header().Get("Docker-Distribution-API-Version")) | |||
| 58 | - | } | |||
| 59 | - | ||||
| 60 | - | func TestRegistryAuth_InvalidPassword(t *testing.T) { | |||
| 61 | - | log := testLogger() | |||
| 62 | - | middleware := RegistryAuth("admin", "secret", log) | |||
| 63 | - | ||||
| 64 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 65 | - | w.WriteHeader(http.StatusOK) | |||
| 66 | - | })) | |||
| 67 | - | ||||
| 68 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |||
| 69 | - | req.SetBasicAuth("admin", "wrongpassword") | |||
| 70 | - | rec := httptest.NewRecorder() | |||
| 71 | - | ||||
| 72 | - | handler.ServeHTTP(rec, req) | |||
| 73 | - | ||||
| 74 | - | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |||
| 75 | - | } | |||
| 76 | - | ||||
| 77 | - | func TestRegistryAuth_NoCredentials(t *testing.T) { | |||
| 78 | - | log := testLogger() | |||
| 79 | - | middleware := RegistryAuth("admin", "secret", log) | |||
| 80 | - | ||||
| 81 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 82 | - | w.WriteHeader(http.StatusOK) | |||
| 83 | - | })) | |||
| 84 | - | ||||
| 85 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |||
| 86 | - | // No auth header | |||
| 87 | - | rec := httptest.NewRecorder() | |||
| 88 | - | ||||
| 89 | - | handler.ServeHTTP(rec, req) | |||
| 90 | - | ||||
| 91 | - | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |||
| 92 | - | assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate")) | |||
| 93 | - | } | |||
| 94 | - | ||||
| 95 | - | func TestRegistryAuth_MalformedAuthHeader(t *testing.T) { | |||
| 96 | - | log := testLogger() | |||
| 97 | - | middleware := RegistryAuth("admin", "secret", log) | |||
| 98 | - | ||||
| 99 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 100 | - | w.WriteHeader(http.StatusOK) | |||
| 101 | - | })) | |||
| 102 | - | ||||
| 103 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |||
| 104 | - | req.Header.Set("Authorization", "Basic notbase64!!!") // Invalid base64 | |||
| 105 | - | rec := httptest.NewRecorder() | |||
| 106 | - | ||||
| 107 | - | handler.ServeHTTP(rec, req) | |||
| 108 | - | ||||
| 109 | - | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |||
| 110 | - | } | |||
| 111 | - | ||||
| 112 | - | func TestRegistryAuth_WrongAuthScheme(t *testing.T) { | |||
| 113 | - | log := testLogger() | |||
| 114 | - | middleware := RegistryAuth("admin", "secret", log) | |||
| 115 | - | ||||
| 116 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 117 | - | w.WriteHeader(http.StatusOK) | |||
| 118 | - | })) | |||
| 119 | - | ||||
| 120 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |||
| 121 | - | req.Header.Set("Authorization", "Bearer some-token") | |||
| 122 | - | rec := httptest.NewRecorder() | |||
| 123 | - | ||||
| 124 | - | handler.ServeHTTP(rec, req) | |||
| 125 | - | ||||
| 126 | - | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |||
| 127 | - | } | |||
| 128 | - | ||||
| 129 | - | func TestRegistryAuth_EmptyCredentials(t *testing.T) { | |||
| 130 | - | log := testLogger() | |||
| 131 | - | middleware := RegistryAuth("admin", "secret", log) | |||
| 132 | - | ||||
| 133 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 134 | - | w.WriteHeader(http.StatusOK) | |||
| 135 | - | })) | |||
| 136 | - | ||||
| 137 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |||
| 138 | - | req.SetBasicAuth("", "") | |||
| 139 | - | rec := httptest.NewRecorder() | |||
| 140 | - | ||||
| 141 | - | handler.ServeHTTP(rec, req) | |||
| 142 | - | ||||
| 143 | - | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |||
| 144 | - | } | |||
| 145 | - | ||||
| 146 | - | func TestRegistryAuth_PartialCredentials(t *testing.T) { | |||
| 147 | - | log := testLogger() | |||
| 148 | - | middleware := RegistryAuth("admin", "secret", log) | |||
| 149 | - | ||||
| 150 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 151 | - | w.WriteHeader(http.StatusOK) | |||
| 152 | - | })) | |||
| 153 | - | ||||
| 154 | - | // Only username, empty password | |||
| 155 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |||
| 156 | - | req.SetBasicAuth("admin", "") | |||
| 157 | - | rec := httptest.NewRecorder() | |||
| 158 | - | ||||
| 159 | - | handler.ServeHTTP(rec, req) | |||
| 160 | - | ||||
| 161 | - | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |||
| 162 | - | } | |||
| 163 | - | ||||
| 164 | - | func TestIsAuthenticated_ValidCredentials(t *testing.T) { | |||
| 165 | - | log := testLogger() | |||
| 166 | - | ||||
| 167 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |||
| 168 | - | req.SetBasicAuth("admin", "secret") | |||
| 169 | - | ||||
| 170 | - | result := isAuthenticated(req, "admin", "secret", log) | |||
| 171 | - | ||||
| 172 | - | assert.True(t, result) | |||
| 173 | - | } | |||
| 174 | - | ||||
| 175 | - | func TestIsAuthenticated_InvalidCredentials(t *testing.T) { | |||
| 176 | - | log := testLogger() | |||
| 177 | - | ||||
| 178 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |||
| 179 | - | req.SetBasicAuth("admin", "wrong") | |||
| 180 | - | ||||
| 181 | - | result := isAuthenticated(req, "admin", "secret", log) | |||
| 182 | - | ||||
| 183 | - | assert.False(t, result) | |||
| 184 | - | } | |||
| 185 | - | ||||
| 186 | - | func TestIsAuthenticated_NoAuthHeader(t *testing.T) { | |||
| 187 | - | log := testLogger() | |||
| 188 | - | ||||
| 189 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |||
| 190 | - | ||||
| 191 | - | result := isAuthenticated(req, "admin", "secret", log) | |||
| 192 | - | ||||
| 193 | - | assert.False(t, result) | |||
| 194 | - | } | |||
| 195 | - | ||||
| 196 | - | func TestIsAuthenticated_TimingAttackPrevention(t *testing.T) { | |||
| 197 | - | // This test verifies that constant-time comparison is used | |||
| 198 | - | // by checking both username and password are validated | |||
| 199 | - | log := testLogger() | |||
| 200 | - | ||||
| 201 | - | tests := []struct { | |||
| 202 | - | name string | |||
| 203 | - | username string | |||
| 204 | - | password string | |||
| 205 | - | expectedUser string | |||
| 206 | - | expectedPass string | |||
| 207 | - | shouldAuth bool | |||
| 208 | - | }{ | |||
| 209 | - | { | |||
| 210 | - | name: "both correct", | |||
| 211 | - | username: "admin", | |||
| 212 | - | password: "secret", | |||
| 213 | - | expectedUser: "admin", | |||
| 214 | - | expectedPass: "secret", | |||
| 215 | - | shouldAuth: true, | |||
| 216 | - | }, | |||
| 217 | - | { | |||
| 218 | - | name: "username wrong first char", | |||
| 219 | - | username: "bdmin", | |||
| 220 | - | password: "secret", | |||
| 221 | - | expectedUser: "admin", | |||
| 222 | - | expectedPass: "secret", | |||
| 223 | - | shouldAuth: false, | |||
| 224 | - | }, | |||
| 225 | - | { | |||
| 226 | - | name: "username wrong last char", | |||
| 227 | - | username: "admio", | |||
| 228 | - | password: "secret", | |||
| 229 | - | expectedUser: "admin", | |||
| 230 | - | expectedPass: "secret", | |||
| 231 | - | shouldAuth: false, | |||
| 232 | - | }, | |||
| 233 | - | { | |||
| 234 | - | name: "password wrong first char", | |||
| 235 | - | username: "admin", | |||
| 236 | - | password: "aecret", | |||
| 237 | - | expectedUser: "admin", | |||
| 238 | - | expectedPass: "secret", | |||
| 239 | - | shouldAuth: false, | |||
| 240 | - | }, | |||
| 241 | - | { | |||
| 242 | - | name: "password wrong last char", | |||
| 243 | - | username: "admin", | |||
| 244 | - | password: "secreo", | |||
| 245 | - | expectedUser: "admin", | |||
| 246 | - | expectedPass: "secret", | |||
| 247 | - | shouldAuth: false, | |||
| 248 | - | }, | |||
| 249 | - | { | |||
| 250 | - | name: "different length username", | |||
| 251 | - | username: "adm", | |||
| 252 | - | password: "secret", | |||
| 253 | - | expectedUser: "admin", | |||
| 254 | - | expectedPass: "secret", | |||
| 255 | - | shouldAuth: false, | |||
| 256 | - | }, | |||
| 257 | - | { | |||
| 258 | - | name: "different length password", | |||
| 259 | - | username: "admin", | |||
| 260 | - | password: "sec", | |||
| 261 | - | expectedUser: "admin", | |||
| 262 | - | expectedPass: "secret", | |||
| 263 | - | shouldAuth: false, | |||
| 264 | - | }, | |||
| 265 | - | } | |||
| 266 | - | ||||
| 267 | - | for _, tt := range tests { | |||
| 268 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 269 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |||
| 270 | - | req.SetBasicAuth(tt.username, tt.password) | |||
| 271 | - | ||||
| 272 | - | result := isAuthenticated(req, tt.expectedUser, tt.expectedPass, log) | |||
| 273 | - | ||||
| 274 | - | assert.Equal(t, tt.shouldAuth, result) | |||
| 275 | - | }) | |||
| 276 | - | } | |||
| 277 | - | } | |||
| 278 | - | ||||
| 279 | - | func TestRegistryAuth_SpecialCharactersInCredentials(t *testing.T) { | |||
| 280 | - | log := testLogger() | |||
| 281 | - | ||||
| 282 | - | tests := []struct { | |||
| 283 | - | name string | |||
| 284 | - | username string | |||
| 285 | - | password string | |||
| 286 | - | }{ | |||
| 287 | - | {"unicode username", "用户", "password"}, | |||
| 288 | - | {"unicode password", "admin", "密码"}, | |||
| 289 | - | {"special chars", "admin@example.com", "p@ss:w0rd!"}, | |||
| 290 | - | {"spaces", "admin user", "pass word"}, | |||
| 291 | - | {"colon in password", "admin", "pass:word"}, | |||
| 292 | - | } | |||
| 293 | - | ||||
| 294 | - | for _, tt := range tests { | |||
| 295 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 296 | - | middleware := RegistryAuth(tt.username, tt.password, log) | |||
| 297 | - | ||||
| 298 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 299 | - | w.WriteHeader(http.StatusOK) | |||
| 300 | - | })) | |||
| 301 | - | ||||
| 302 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |||
| 303 | - | req.SetBasicAuth(tt.username, tt.password) | |||
| 304 | - | rec := httptest.NewRecorder() | |||
| 305 | - | ||||
| 306 | - | handler.ServeHTTP(rec, req) | |||
| 307 | - | ||||
| 308 | - | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 309 | - | }) | |||
| 310 | - | } | |||
| 311 | - | } | |||
| 312 | - | ||||
| 313 | - | func TestRegistryAuth_Base64EdgeCases(t *testing.T) { | |||
| 314 | - | log := testLogger() | |||
| 315 | - | middleware := RegistryAuth("admin", "secret", log) | |||
| 316 | - | ||||
| 317 | - | handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 318 | - | w.WriteHeader(http.StatusOK) | |||
| 319 | - | })) | |||
| 320 | - | ||||
| 321 | - | tests := []struct { | |||
| 322 | - | name string | |||
| 323 | - | authHeader string | |||
| 324 | - | wantStatus int | |||
| 325 | - | }{ | |||
| 326 | - | { | |||
| 327 | - | name: "empty basic auth", | |||
| 328 | - | authHeader: "Basic ", | |||
| 329 | - | wantStatus: http.StatusUnauthorized, | |||
| 330 | - | }, | |||
| 331 | - | { | |||
| 332 | - | name: "basic auth with only colon", | |||
| 333 | - | authHeader: "Basic " + base64.StdEncoding.EncodeToString([]byte(":")), | |||
| 334 | - | wantStatus: http.StatusUnauthorized, | |||
| 335 | - | }, | |||
| 336 | - | { | |||
| 337 | - | name: "missing colon in decoded value", | |||
| 338 | - | authHeader: "Basic " + base64.StdEncoding.EncodeToString([]byte("adminpassword")), | |||
| 339 | - | wantStatus: http.StatusUnauthorized, | |||
| 340 | - | }, | |||
| 341 | - | } | |||
| 342 | - | ||||
| 343 | - | for _, tt := range tests { | |||
| 344 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 345 | - | req := httptest.NewRequest("GET", "/v2/", nil) | |||
| 346 | - | req.Header.Set("Authorization", tt.authHeader) | |||
| 347 | - | rec := httptest.NewRecorder() | |||
| 348 | - | ||||
| 349 | - | handler.ServeHTTP(rec, req) | |||
| 350 | - | ||||
| 351 | - | assert.Equal(t, tt.wantStatus, rec.Code) | |||
| 352 | - | }) | |||
| 353 | - | } | |||
| 354 | - | } | |||
| 355 | - | ||||
| 356 | func TestRegistryAuthV2_LocalhostBypassRequiresInternalAuth(t *testing.T) { | 21 | func TestRegistryAuthV2_LocalhostBypassRequiresInternalAuth(t *testing.T) { | ||
| 357 | log := testLogger() | 22 | log := testLogger() | ||
| 358 | called := false | 23 | called := false | ||
| @@ -505,6 +170,10 @@ func (s stubAuthService) GetAuthStatus(context.Context) (*domain.AuthStatus, err | |||||
| 505 | return nil, errors.New("not implemented") | 170 | return nil, errors.New("not implemented") | ||
| 506 | } | 171 | } | ||
| 507 | 172 | ||||
| 173 | + | func (s stubAuthService) ExtendToken(context.Context, string) (string, error) { | |||
| 174 | + | return "", errors.New("not implemented") | |||
| 175 | + | } | |||
| 176 | + | ||||
| 508 | // Tests for checkScopeAccess function | 177 | // Tests for checkScopeAccess function | ||
| 509 | 178 | ||||
| 510 | func TestCheckScopeAccess_ActionMapping(t *testing.T) { | 179 | func TestCheckScopeAccess_ActionMapping(t *testing.T) { | ||
M internal/adapters/out/docker/runtime.go +3 -3
| @@ -1016,8 +1016,8 @@ func (r *Runtime) CreateVolume(ctx context.Context, volumeName string) error { | |||
| 1016 | 1016 | _, err := r.client.VolumeCreate(ctx, volume.CreateOptions{ | |
| 1017 | 1017 | Name: volumeName, | |
| 1018 | 1018 | Labels: map[string]string{ | |
| 1019 | - | "gordon.managed": "true", | |
| 1020 | - | "gordon.created": "auto", | |
| 1019 | + | domain.LabelManaged: "true", | |
| 1020 | + | domain.LabelCreated: time.Now().UTC().Format(time.RFC3339), | |
| 1021 | 1021 | }, | |
| 1022 | 1022 | }) | |
| 1023 | 1023 | if err != nil { | |
| @@ -1142,7 +1142,7 @@ func (r *Runtime) CreateNetwork(ctx context.Context, name string, options map[st | |||
| 1142 | 1142 | createOptions := network.CreateOptions{ | |
| 1143 | 1143 | Driver: driver, | |
| 1144 | 1144 | Labels: map[string]string{ | |
| 1145 | - | "gordon.managed": "true", | |
| 1145 | + | domain.LabelManaged: "true", | |
| 1146 | 1146 | }, | |
| 1147 | 1147 | } | |
| 1148 | 1148 | ||
| @@ -1016,8 +1016,8 @@ func (r *Runtime) CreateVolume(ctx context.Context, volumeName string) error { | |||||
| 1016 | _, err := r.client.VolumeCreate(ctx, volume.CreateOptions{ | 1016 | _, err := r.client.VolumeCreate(ctx, volume.CreateOptions{ | ||
| 1017 | Name: volumeName, | 1017 | Name: volumeName, | ||
| 1018 | Labels: map[string]string{ | 1018 | Labels: map[string]string{ | ||
| 1019 | - | "gordon.managed": "true", | 1019 | + | domain.LabelManaged: "true", |
| 1020 | - | "gordon.created": "auto", | 1020 | + | domain.LabelCreated: time.Now().UTC().Format(time.RFC3339), |
| 1021 | }, | 1021 | }, | ||
| 1022 | }) | 1022 | }) | ||
| 1023 | if err != nil { | 1023 | if err != nil { | ||
| @@ -1142,7 +1142,7 @@ func (r *Runtime) CreateNetwork(ctx context.Context, name string, options map[st | |||||
| 1142 | createOptions := network.CreateOptions{ | 1142 | createOptions := network.CreateOptions{ | ||
| 1143 | Driver: driver, | 1143 | Driver: driver, | ||
| 1144 | Labels: map[string]string{ | 1144 | Labels: map[string]string{ | ||
| 1145 | - | "gordon.managed": "true", | 1145 | + | domain.LabelManaged: "true", |
| 1146 | }, | 1146 | }, | ||
| 1147 | } | 1147 | } | ||
| 1148 | 1148 | ||||
M internal/adapters/out/domainsecrets/pass_store.go +15 -8
| @@ -33,7 +33,10 @@ type PassStore struct { | |||
| 33 | 33 | ||
| 34 | 34 | // NewPassStore creates a new pass-based domain secret store. | |
| 35 | 35 | func NewPassStore(log zerowrap.Logger) (*PassStore, error) { | |
| 36 | - | if err := exec.Command("pass", "version").Run(); err != nil { | |
| 36 | + | // Use a timeout so a stalled GPG agent or keyring does not hang startup. | |
| 37 | + | probeCtx, probeCancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| 38 | + | defer probeCancel() | |
| 39 | + | if err := exec.CommandContext(probeCtx, "pass", "version").Run(); err != nil { //nolint:gosec // binary is a constant ("pass"), no user input | |
| 37 | 40 | return nil, fmt.Errorf("pass is not available: %w", err) | |
| 38 | 41 | } | |
| 39 | 42 | ||
| @@ -646,7 +649,7 @@ func (s *PassStore) ManifestExists(domainName string) (bool, error) { | |||
| 646 | 649 | } | |
| 647 | 650 | ||
| 648 | 651 | func (s *PassStore) passInsert(ctx context.Context, path, value string) error { | |
| 649 | - | cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path) | |
| 652 | + | cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path) //nolint:gosec // binary is constant ("pass"); path arguments validated by secrets path validator | |
| 650 | 653 | cmd.Stdin = strings.NewReader(value) | |
| 651 | 654 | output, err := cmd.CombinedOutput() | |
| 652 | 655 | if err != nil { | |
| @@ -656,7 +659,7 @@ func (s *PassStore) passInsert(ctx context.Context, path, value string) error { | |||
| 656 | 659 | } | |
| 657 | 660 | ||
| 658 | 661 | func (s *PassStore) passRemove(ctx context.Context, path string) error { | |
| 659 | - | cmd := exec.CommandContext(ctx, "pass", "rm", "-f", path) | |
| 662 | + | cmd := exec.CommandContext(ctx, "pass", "rm", "-f", path) //nolint:gosec // binary is constant ("pass"); path arguments validated by secrets path validator | |
| 660 | 663 | output, err := cmd.CombinedOutput() | |
| 661 | 664 | if err != nil { | |
| 662 | 665 | if passEntryMissing(string(output)) { | |
| @@ -668,7 +671,7 @@ func (s *PassStore) passRemove(ctx context.Context, path string) error { | |||
| 668 | 671 | } | |
| 669 | 672 | ||
| 670 | 673 | func (s *PassStore) passShow(ctx context.Context, path string) (string, bool, error) { | |
| 671 | - | cmd := exec.CommandContext(ctx, "pass", "show", path) | |
| 674 | + | cmd := exec.CommandContext(ctx, "pass", "show", path) //nolint:gosec // binary is constant ("pass"); path arguments validated by secrets path validator | |
| 672 | 675 | output, err := cmd.CombinedOutput() | |
| 673 | 676 | if err != nil { | |
| 674 | 677 | if passEntryMissing(string(output)) { | |
| @@ -687,7 +690,7 @@ func (s *PassStore) listTopLevelEntries(ctx context.Context, basePath string) ([ | |||
| 687 | 690 | return nil, err | |
| 688 | 691 | } | |
| 689 | 692 | ||
| 690 | - | cmd := exec.CommandContext(ctx, "pass", "ls", basePath) | |
| 693 | + | cmd := exec.CommandContext(ctx, "pass", "ls", basePath) //nolint:gosec // binary is constant ("pass"); path arguments validated by secrets path validator | |
| 691 | 694 | output, err := cmd.CombinedOutput() | |
| 692 | 695 | if err != nil { | |
| 693 | 696 | if passEntryMissing(string(output)) { | |
| @@ -809,8 +812,12 @@ func (s *PassStore) cleanupInsertedPaths(paths []string) { | |||
| 809 | 812 | } | |
| 810 | 813 | ||
| 811 | 814 | for _, path := range paths { | |
| 812 | - | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 813 | - | _ = s.passRemove(ctx, path) | |
| 814 | - | cancel() | |
| 815 | + | // Wrap in a closure so defer cancel() runs at the end of each iteration, | |
| 816 | + | // not at the end of cleanupInsertedPaths (which would delay all cancels). | |
| 817 | + | func(p string) { | |
| 818 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 819 | + | defer cancel() | |
| 820 | + | _ = s.passRemove(ctx, p) | |
| 821 | + | }(path) | |
| 815 | 822 | } | |
| 816 | 823 | } | |
| @@ -33,7 +33,10 @@ type PassStore struct { | |||||
| 33 | 33 | ||||
| 34 | // NewPassStore creates a new pass-based domain secret store. | 34 | // NewPassStore creates a new pass-based domain secret store. | ||
| 35 | func NewPassStore(log zerowrap.Logger) (*PassStore, error) { | 35 | func NewPassStore(log zerowrap.Logger) (*PassStore, error) { | ||
| 36 | - | if err := exec.Command("pass", "version").Run(); err != nil { | 36 | + | // Use a timeout so a stalled GPG agent or keyring does not hang startup. |
| 37 | + | probeCtx, probeCancel := context.WithTimeout(context.Background(), 10*time.Second) | |||
| 38 | + | defer probeCancel() | |||
| 39 | + | if err := exec.CommandContext(probeCtx, "pass", "version").Run(); err != nil { //nolint:gosec // binary is a constant ("pass"), no user input | |||
| 37 | return nil, fmt.Errorf("pass is not available: %w", err) | 40 | return nil, fmt.Errorf("pass is not available: %w", err) | ||
| 38 | } | 41 | } | ||
| 39 | 42 | ||||
| @@ -646,7 +649,7 @@ func (s *PassStore) ManifestExists(domainName string) (bool, error) { | |||||
| 646 | } | 649 | } | ||
| 647 | 650 | ||||
| 648 | func (s *PassStore) passInsert(ctx context.Context, path, value string) error { | 651 | func (s *PassStore) passInsert(ctx context.Context, path, value string) error { | ||
| 649 | - | cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path) | 652 | + | cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path) //nolint:gosec // binary is constant ("pass"); path arguments validated by secrets path validator |
| 650 | cmd.Stdin = strings.NewReader(value) | 653 | cmd.Stdin = strings.NewReader(value) | ||
| 651 | output, err := cmd.CombinedOutput() | 654 | output, err := cmd.CombinedOutput() | ||
| 652 | if err != nil { | 655 | if err != nil { | ||
| @@ -656,7 +659,7 @@ func (s *PassStore) passInsert(ctx context.Context, path, value string) error { | |||||
| 656 | } | 659 | } | ||
| 657 | 660 | ||||
| 658 | func (s *PassStore) passRemove(ctx context.Context, path string) error { | 661 | func (s *PassStore) passRemove(ctx context.Context, path string) error { | ||
| 659 | - | cmd := exec.CommandContext(ctx, "pass", "rm", "-f", path) | 662 | + | cmd := exec.CommandContext(ctx, "pass", "rm", "-f", path) //nolint:gosec // binary is constant ("pass"); path arguments validated by secrets path validator |
| 660 | output, err := cmd.CombinedOutput() | 663 | output, err := cmd.CombinedOutput() | ||
| 661 | if err != nil { | 664 | if err != nil { | ||
| 662 | if passEntryMissing(string(output)) { | 665 | if passEntryMissing(string(output)) { | ||
| @@ -668,7 +671,7 @@ func (s *PassStore) passRemove(ctx context.Context, path string) error { | |||||
| 668 | } | 671 | } | ||
| 669 | 672 | ||||
| 670 | func (s *PassStore) passShow(ctx context.Context, path string) (string, bool, error) { | 673 | func (s *PassStore) passShow(ctx context.Context, path string) (string, bool, error) { | ||
| 671 | - | cmd := exec.CommandContext(ctx, "pass", "show", path) | 674 | + | cmd := exec.CommandContext(ctx, "pass", "show", path) //nolint:gosec // binary is constant ("pass"); path arguments validated by secrets path validator |
| 672 | output, err := cmd.CombinedOutput() | 675 | output, err := cmd.CombinedOutput() | ||
| 673 | if err != nil { | 676 | if err != nil { | ||
| 674 | if passEntryMissing(string(output)) { | 677 | if passEntryMissing(string(output)) { | ||
| @@ -687,7 +690,7 @@ func (s *PassStore) listTopLevelEntries(ctx context.Context, basePath string) ([ | |||||
| 687 | return nil, err | 690 | return nil, err | ||
| 688 | } | 691 | } | ||
| 689 | 692 | ||||
| 690 | - | cmd := exec.CommandContext(ctx, "pass", "ls", basePath) | 693 | + | cmd := exec.CommandContext(ctx, "pass", "ls", basePath) //nolint:gosec // binary is constant ("pass"); path arguments validated by secrets path validator |
| 691 | output, err := cmd.CombinedOutput() | 694 | output, err := cmd.CombinedOutput() | ||
| 692 | if err != nil { | 695 | if err != nil { | ||
| 693 | if passEntryMissing(string(output)) { | 696 | if passEntryMissing(string(output)) { | ||
| @@ -809,8 +812,12 @@ func (s *PassStore) cleanupInsertedPaths(paths []string) { | |||||
| 809 | } | 812 | } | ||
| 810 | 813 | ||||
| 811 | for _, path := range paths { | 814 | for _, path := range paths { | ||
| 812 | - | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | 815 | + | // Wrap in a closure so defer cancel() runs at the end of each iteration, |
| 813 | - | _ = s.passRemove(ctx, path) | 816 | + | // not at the end of cleanupInsertedPaths (which would delay all cancels). |
| 814 | - | cancel() | 817 | + | func(p string) { |
| 818 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 819 | + | defer cancel() | |||
| 820 | + | _ = s.passRemove(ctx, p) | |||
| 821 | + | }(path) | |||
| 815 | } | 822 | } | ||
| 816 | } | 823 | } | ||
M internal/adapters/out/secrets/pass.go +6 -4
| @@ -80,7 +80,7 @@ func (p *PassProvider) GetSecret(ctx context.Context, path string) (string, erro | |||
| 80 | 80 | ctx, cancel := context.WithTimeout(ctx, p.timeout) | |
| 81 | 81 | defer cancel() | |
| 82 | 82 | ||
| 83 | - | cmd := exec.CommandContext(ctx, "pass", "show", path) | |
| 83 | + | cmd := exec.CommandContext(ctx, "pass", "show", path) //nolint:gosec // binary is constant ("pass"); arguments are validated secret paths | |
| 84 | 84 | output, err := cmd.Output() | |
| 85 | 85 | if err != nil { | |
| 86 | 86 | if exitError, ok := err.(*exec.ExitError); ok { | |
| @@ -105,8 +105,10 @@ func (p *PassProvider) GetSecret(ctx context.Context, path string) (string, erro | |||
| 105 | 105 | } | |
| 106 | 106 | ||
| 107 | 107 | // IsAvailable checks if pass is available in the system. | |
| 108 | + | // Uses a short timeout so a stalled GPG agent does not hang the caller. | |
| 108 | 109 | func (p *PassProvider) IsAvailable() bool { | |
| 109 | - | cmd := exec.Command("pass", "version") | |
| 110 | - | err := cmd.Run() | |
| 111 | - | return err == nil | |
| 110 | + | ctx, cancel := context.WithTimeout(context.Background(), p.timeout) | |
| 111 | + | defer cancel() | |
| 112 | + | cmd := exec.CommandContext(ctx, "pass", "version") //nolint:gosec // binary is a constant ("pass"), no user input | |
| 113 | + | return cmd.Run() == nil | |
| 112 | 114 | } | |
| @@ -80,7 +80,7 @@ func (p *PassProvider) GetSecret(ctx context.Context, path string) (string, erro | |||||
| 80 | ctx, cancel := context.WithTimeout(ctx, p.timeout) | 80 | ctx, cancel := context.WithTimeout(ctx, p.timeout) | ||
| 81 | defer cancel() | 81 | defer cancel() | ||
| 82 | 82 | ||||
| 83 | - | cmd := exec.CommandContext(ctx, "pass", "show", path) | 83 | + | cmd := exec.CommandContext(ctx, "pass", "show", path) //nolint:gosec // binary is constant ("pass"); arguments are validated secret paths |
| 84 | output, err := cmd.Output() | 84 | output, err := cmd.Output() | ||
| 85 | if err != nil { | 85 | if err != nil { | ||
| 86 | if exitError, ok := err.(*exec.ExitError); ok { | 86 | if exitError, ok := err.(*exec.ExitError); ok { | ||
| @@ -105,8 +105,10 @@ func (p *PassProvider) GetSecret(ctx context.Context, path string) (string, erro | |||||
| 105 | } | 105 | } | ||
| 106 | 106 | ||||
| 107 | // IsAvailable checks if pass is available in the system. | 107 | // IsAvailable checks if pass is available in the system. | ||
| 108 | + | // Uses a short timeout so a stalled GPG agent does not hang the caller. | |||
| 108 | func (p *PassProvider) IsAvailable() bool { | 109 | func (p *PassProvider) IsAvailable() bool { | ||
| 109 | - | cmd := exec.Command("pass", "version") | 110 | + | ctx, cancel := context.WithTimeout(context.Background(), p.timeout) |
| 110 | - | err := cmd.Run() | 111 | + | defer cancel() |
| 111 | - | return err == nil | 112 | + | cmd := exec.CommandContext(ctx, "pass", "version") //nolint:gosec // binary is a constant ("pass"), no user input |
| 113 | + | return cmd.Run() == nil | |||
| 112 | } | 114 | } | ||
M internal/adapters/out/tokenstore/pass.go +194 -46
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "strings" | |
| 11 | 11 | "sync" | |
| 12 | 12 | "time" | |
| 13 | + | "unicode/utf8" | |
| 13 | 14 | ||
| 14 | 15 | "github.com/bnema/zerowrap" | |
| 15 | 16 | ||
| @@ -52,6 +53,10 @@ func validateSubject(subject string) error { | |||
| 52 | 53 | return fmt.Errorf("invalid subject: cannot contain '..' to prevent path traversal") | |
| 53 | 54 | } | |
| 54 | 55 | ||
| 56 | + | if strings.HasSuffix(subject, ".meta") { | |
| 57 | + | return fmt.Errorf("invalid subject: cannot end with '.meta' (reserved for token metadata files)") | |
| 58 | + | } | |
| 59 | + | ||
| 55 | 60 | return nil | |
| 56 | 61 | } | |
| 57 | 62 | ||
| @@ -79,12 +84,13 @@ func NewPassStore(log zerowrap.Logger) *PassStore { | |||
| 79 | 84 | ||
| 80 | 85 | // tokenMetadata holds the non-sensitive token information. | |
| 81 | 86 | type tokenMetadata struct { | |
| 82 | - | ID string `json:"id"` | |
| 83 | - | Subject string `json:"subject"` | |
| 84 | - | Scopes []string `json:"scopes"` | |
| 85 | - | IssuedAt time.Time `json:"issued_at"` | |
| 86 | - | ExpiresAt time.Time `json:"expires_at,omitempty"` | |
| 87 | - | Revoked bool `json:"revoked"` | |
| 87 | + | ID string `json:"id"` | |
| 88 | + | Subject string `json:"subject"` | |
| 89 | + | Scopes []string `json:"scopes"` | |
| 90 | + | IssuedAt time.Time `json:"issued_at"` | |
| 91 | + | ExpiresAt time.Time `json:"expires_at,omitempty"` | |
| 92 | + | Revoked bool `json:"revoked"` | |
| 93 | + | LastExtendedAt time.Time `json:"last_extended_at"` | |
| 88 | 94 | } | |
| 89 | 95 | ||
| 90 | 96 | // SaveToken stores a token JWT and metadata in pass. | |
| @@ -104,12 +110,13 @@ func (s *PassStore) SaveToken(ctx context.Context, token *domain.Token, jwt stri | |||
| 104 | 110 | ||
| 105 | 111 | // Store metadata | |
| 106 | 112 | meta := tokenMetadata{ | |
| 107 | - | ID: token.ID, | |
| 108 | - | Subject: token.Subject, | |
| 109 | - | Scopes: token.Scopes, | |
| 110 | - | IssuedAt: token.IssuedAt, | |
| 111 | - | ExpiresAt: token.ExpiresAt, | |
| 112 | - | Revoked: token.Revoked, | |
| 113 | + | ID: token.ID, | |
| 114 | + | Subject: token.Subject, | |
| 115 | + | Scopes: token.Scopes, | |
| 116 | + | IssuedAt: token.IssuedAt, | |
| 117 | + | ExpiresAt: token.ExpiresAt, | |
| 118 | + | Revoked: token.Revoked, | |
| 119 | + | LastExtendedAt: token.LastExtendedAt, | |
| 113 | 120 | } | |
| 114 | 121 | metaJSON, err := json.Marshal(meta) | |
| 115 | 122 | if err != nil { | |
| @@ -173,12 +180,13 @@ func (s *PassStore) GetToken(ctx context.Context, subject string) (string, *doma | |||
| 173 | 180 | } | |
| 174 | 181 | ||
| 175 | 182 | token := &domain.Token{ | |
| 176 | - | ID: meta.ID, | |
| 177 | - | Subject: meta.Subject, | |
| 178 | - | Scopes: meta.Scopes, | |
| 179 | - | IssuedAt: meta.IssuedAt, | |
| 180 | - | ExpiresAt: meta.ExpiresAt, | |
| 181 | - | Revoked: meta.Revoked, | |
| 183 | + | ID: meta.ID, | |
| 184 | + | Subject: meta.Subject, | |
| 185 | + | Scopes: meta.Scopes, | |
| 186 | + | IssuedAt: meta.IssuedAt, | |
| 187 | + | ExpiresAt: meta.ExpiresAt, | |
| 188 | + | Revoked: meta.Revoked, | |
| 189 | + | LastExtendedAt: meta.LastExtendedAt, | |
| 182 | 190 | } | |
| 183 | 191 | ||
| 184 | 192 | // Cache the token | |
| @@ -189,49 +197,170 @@ func (s *PassStore) GetToken(ctx context.Context, subject string) (string, *doma | |||
| 189 | 197 | return jwt, token, nil | |
| 190 | 198 | } | |
| 191 | 199 | ||
| 200 | + | // treeEntry holds a single parsed entry from pass ls tree output. | |
| 201 | + | type treeEntry struct { | |
| 202 | + | depth int | |
| 203 | + | name string | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // parsePassLsEntries parses raw pass ls output into a slice of tree entries. | |
| 207 | + | // Each entry carries its nesting depth (0 = direct child of the listed root) | |
| 208 | + | // and the bare file/directory name on that line. | |
| 209 | + | func parsePassLsEntries(output string) []treeEntry { | |
| 210 | + | var entries []treeEntry | |
| 211 | + | ||
| 212 | + | lines := strings.Split(output, "\n") | |
| 213 | + | // Skip the first line (the directory header, e.g. "gordon/registry/tokens"). | |
| 214 | + | for _, line := range lines[1:] { | |
| 215 | + | // Strip ANSI escape sequences (pass may output coloured text on a TTY). | |
| 216 | + | line = ansiRegex.ReplaceAllString(line, "") | |
| 217 | + | ||
| 218 | + | if strings.TrimSpace(line) == "" { | |
| 219 | + | continue | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | // Locate the branch marker. pass uses Unicode box-drawing characters: | |
| 223 | + | // ├── or └── (U+251C / U+2514, then two U+2500 dashes, then a space) | |
| 224 | + | // Some terminals may fall back to ASCII equivalents: | |
| 225 | + | // |-- or `-- (1-byte ASCII chars) | |
| 226 | + | // | |
| 227 | + | // Regardless of Unicode vs ASCII, each indent level is exactly 4 characters | |
| 228 | + | // wide (e.g. "│ " = pipe + 3 spaces, " " = 4 spaces, "| " = pipe + 3). | |
| 229 | + | // We count runes before the branch character and divide by 4 to get depth. | |
| 230 | + | ||
| 231 | + | var nameOffset int // byte offset where the entry name begins | |
| 232 | + | var branchRune int // rune count before the branch character (├ └ | `) | |
| 233 | + | ||
| 234 | + | if idx := strings.Index(line, "── "); idx != -1 { | |
| 235 | + | // Unicode variant: the two '─' dashes (U+2500) start at byte idx. | |
| 236 | + | // The branch character (├ or └, 3 bytes each) immediately precedes them. | |
| 237 | + | branchByteIdx := idx - 3 | |
| 238 | + | if branchByteIdx < 0 { | |
| 239 | + | // Marker is too close to the start — malformed line, skip. | |
| 240 | + | continue | |
| 241 | + | } | |
| 242 | + | // Count runes in the indentation prefix before the branch char. | |
| 243 | + | branchRune = utf8.RuneCountInString(line[:branchByteIdx]) | |
| 244 | + | // Name starts after: branchChar(3B) + ─(3B) + ─(3B) + SP(1B) = 10B from branch. | |
| 245 | + | nameOffset = branchByteIdx + 10 | |
| 246 | + | } else if idx := strings.Index(line, "-- "); idx != -1 { | |
| 247 | + | // ASCII variant: branch char (| or `, 1 byte) is immediately before the dashes. | |
| 248 | + | branchByteIdx := idx - 1 | |
| 249 | + | if branchByteIdx < 0 { | |
| 250 | + | // Marker is too close to the start — malformed line, skip. | |
| 251 | + | continue | |
| 252 | + | } | |
| 253 | + | branchRune = utf8.RuneCountInString(line[:branchByteIdx]) | |
| 254 | + | // Name starts after: branchChar(1B) + -(1B) + -(1B) + SP(1B) = 4B from branch. | |
| 255 | + | nameOffset = branchByteIdx + 4 | |
| 256 | + | } else { | |
| 257 | + | // No recognisable tree marker on this line — skip it. | |
| 258 | + | continue | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | if nameOffset > len(line) { | |
| 262 | + | continue | |
| 263 | + | } | |
| 264 | + | name := strings.TrimSpace(line[nameOffset:]) | |
| 265 | + | if name == "" { | |
| 266 | + | continue | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | // Each indent level is 4 characters wide. | |
| 270 | + | depth := branchRune / 4 | |
| 271 | + | entries = append(entries, treeEntry{depth: depth, name: name}) | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | return entries | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | // parsePassLsOutput parses the tree-formatted output of `pass ls` and returns | |
| 278 | + | // a flat list of full subject paths. It reconstructs slash-separated paths for | |
| 279 | + | // nested entries (e.g. "team/alice") by tracking the indentation depth. | |
| 280 | + | // Only leaf nodes (actual pass entries, not intermediate directories) are returned. | |
| 281 | + | // | |
| 282 | + | // pass ls outputs a tree like: | |
| 283 | + | // | |
| 284 | + | // gordon/registry/tokens | |
| 285 | + | // ├── admin | |
| 286 | + | // └── team | |
| 287 | + | // ├── alice | |
| 288 | + | // └── bob | |
| 289 | + | // | |
| 290 | + | // This function returns ["admin", "team/alice", "team/bob"]. | |
| 291 | + | func parsePassLsOutput(output string) []string { | |
| 292 | + | type frame struct { | |
| 293 | + | depth int | |
| 294 | + | name string | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | entries := parsePassLsEntries(output) | |
| 298 | + | ||
| 299 | + | var subjects []string | |
| 300 | + | var stack []frame | |
| 301 | + | ||
| 302 | + | for i, entry := range entries { | |
| 303 | + | // Pop ancestors that are at the same depth or deeper than the current entry. | |
| 304 | + | for len(stack) > 0 && stack[len(stack)-1].depth >= entry.depth { | |
| 305 | + | stack = stack[:len(stack)-1] | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | // Build the full path for this entry by joining ancestors with this name. | |
| 309 | + | parts := make([]string, 0, len(stack)+1) | |
| 310 | + | for _, f := range stack { | |
| 311 | + | parts = append(parts, f.name) | |
| 312 | + | } | |
| 313 | + | parts = append(parts, entry.name) | |
| 314 | + | subject := strings.Join(parts, "/") | |
| 315 | + | ||
| 316 | + | // Determine whether this entry is an intermediate directory node. | |
| 317 | + | // A node is a directory if the immediately following entry is deeper. | |
| 318 | + | isDir := i+1 < len(entries) && entries[i+1].depth > entry.depth | |
| 319 | + | ||
| 320 | + | // Always push so deeper entries can use this as ancestor context. | |
| 321 | + | stack = append(stack, frame(entry)) | |
| 322 | + | ||
| 323 | + | if !isDir { | |
| 324 | + | subjects = append(subjects, subject) | |
| 325 | + | } | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | if subjects == nil { | |
| 329 | + | return []string{} | |
| 330 | + | } | |
| 331 | + | return subjects | |
| 332 | + | } | |
| 333 | + | ||
| 192 | 334 | // ListTokens returns all stored tokens from pass. | |
| 193 | 335 | func (s *PassStore) ListTokens(ctx context.Context) ([]domain.Token, error) { | |
| 194 | 336 | ctx, cancel := context.WithTimeout(ctx, s.timeout) | |
| 195 | 337 | defer cancel() | |
| 196 | 338 | ||
| 197 | 339 | // List all entries under the token path | |
| 198 | - | cmd := exec.CommandContext(ctx, "pass", "ls", passTokenPath) | |
| 340 | + | cmd := exec.CommandContext(ctx, "pass", "ls", passTokenPath) //nolint:gosec // passTokenPath is a fixed constant | |
| 199 | 341 | output, err := cmd.Output() | |
| 200 | 342 | if err != nil { | |
| 201 | 343 | // If the path doesn't exist, return empty list | |
| 202 | 344 | return []domain.Token{}, nil | |
| 203 | 345 | } | |
| 204 | 346 | ||
| 205 | - | var tokens []domain.Token | |
| 206 | - | lines := strings.Split(string(output), "\n") | |
| 207 | - | ||
| 208 | - | for _, line := range lines { | |
| 209 | - | line = strings.TrimSpace(line) | |
| 347 | + | subjects := parsePassLsOutput(string(output)) | |
| 210 | 348 | ||
| 211 | - | // Strip ANSI escape sequences (pass outputs colored text on TTY) | |
| 212 | - | line = ansiRegex.ReplaceAllString(line, "") | |
| 213 | - | ||
| 214 | - | // Skip tree formatting characters (both Unicode and ASCII variants) | |
| 215 | - | // Unicode: ├── └── │ | |
| 216 | - | line = strings.TrimPrefix(line, "├── ") | |
| 217 | - | line = strings.TrimPrefix(line, "└── ") | |
| 218 | - | line = strings.TrimPrefix(line, "│ ") | |
| 219 | - | // ASCII: |-- `-- | | |
| 220 | - | line = strings.TrimPrefix(line, "|-- ") | |
| 221 | - | line = strings.TrimPrefix(line, "`-- ") | |
| 222 | - | line = strings.TrimPrefix(line, "| ") | |
| 223 | - | ||
| 224 | - | line = strings.TrimSpace(line) | |
| 225 | - | ||
| 226 | - | // Skip .meta files, empty lines, and the header line | |
| 227 | - | if line == "" || strings.HasSuffix(line, ".meta") || line == passTokenPath { | |
| 349 | + | var tokens []domain.Token | |
| 350 | + | for _, subject := range subjects { | |
| 351 | + | // Skip .meta files — they are metadata companions, not tokens themselves. | |
| 352 | + | if strings.HasSuffix(subject, ".meta") { | |
| 228 | 353 | continue | |
| 229 | 354 | } | |
| 230 | 355 | ||
| 231 | - | // Try to get the token metadata | |
| 232 | - | _, token, err := s.GetToken(ctx, line) | |
| 356 | + | // Try to get the token metadata. | |
| 357 | + | // Use a fresh per-call context so each GetToken gets the full timeout, | |
| 358 | + | // not the shared (potentially exhausted) deadline from the outer ctx. | |
| 359 | + | callCtx, callCancel := context.WithTimeout(context.Background(), s.timeout) | |
| 360 | + | _, token, err := s.GetToken(callCtx, subject) | |
| 361 | + | callCancel() | |
| 233 | 362 | if err != nil { | |
| 234 | - | s.log.Warn().Err(err).Str("subject", line).Msg("failed to get token") | |
| 363 | + | s.log.Warn().Err(err).Str("subject", subject).Msg("failed to get token") | |
| 235 | 364 | continue | |
| 236 | 365 | } | |
| 237 | 366 | ||
| @@ -321,6 +450,25 @@ func (s *PassStore) IsRevoked(ctx context.Context, tokenID string) (bool, error) | |||
| 321 | 450 | return revoked, nil | |
| 322 | 451 | } | |
| 323 | 452 | ||
| 453 | + | // UpdateTokenExpiry updates the JWT and expiry metadata for an existing token. | |
| 454 | + | // LastExtendedAt is also updated to track debounce timing. | |
| 455 | + | // UpdateTokenExpiry enforces update-only semantics: it returns an error if | |
| 456 | + | // token is nil or if no existing record is found for token.Subject. | |
| 457 | + | func (s *PassStore) UpdateTokenExpiry(ctx context.Context, token *domain.Token, newJWT string) error { | |
| 458 | + | if token == nil { | |
| 459 | + | return fmt.Errorf("UpdateTokenExpiry: token must not be nil") | |
| 460 | + | } | |
| 461 | + | // Confirm the token exists before overwriting to avoid silent creation. | |
| 462 | + | _, existing, err := s.GetToken(ctx, token.Subject) | |
| 463 | + | if err != nil { | |
| 464 | + | return fmt.Errorf("UpdateTokenExpiry: token not found for subject %q: %w", token.Subject, err) | |
| 465 | + | } | |
| 466 | + | if existing == nil { | |
| 467 | + | return fmt.Errorf("UpdateTokenExpiry: no existing token for subject %q", token.Subject) | |
| 468 | + | } | |
| 469 | + | return s.SaveToken(ctx, token, newJWT) | |
| 470 | + | } | |
| 471 | + | ||
| 324 | 472 | // DeleteToken removes token from pass. | |
| 325 | 473 | func (s *PassStore) DeleteToken(ctx context.Context, subject string) error { | |
| 326 | 474 | if err := validateSubject(subject); err != nil { | |
| @@ -363,7 +511,7 @@ func (s *PassStore) DeleteToken(ctx context.Context, subject string) error { | |||
| 363 | 511 | ||
| 364 | 512 | // passInsert inserts a value into pass. | |
| 365 | 513 | func (s *PassStore) passInsert(ctx context.Context, path, value string) error { | |
| 366 | - | cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path) | |
| 514 | + | cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path) //nolint:gosec // binary is constant ("pass"); path arguments are sanitized token subjects | |
| 367 | 515 | cmd.Stdin = strings.NewReader(value) | |
| 368 | 516 | output, err := cmd.CombinedOutput() | |
| 369 | 517 | if err != nil { | |
| @@ -374,7 +522,7 @@ func (s *PassStore) passInsert(ctx context.Context, path, value string) error { | |||
| 374 | 522 | ||
| 375 | 523 | // passShow retrieves a value from pass. | |
| 376 | 524 | func (s *PassStore) passShow(ctx context.Context, path string) (string, error) { | |
| 377 | - | cmd := exec.CommandContext(ctx, "pass", "show", path) | |
| 525 | + | cmd := exec.CommandContext(ctx, "pass", "show", path) //nolint:gosec // binary is constant ("pass"); path arguments are sanitized token subjects | |
| 378 | 526 | output, err := cmd.Output() | |
| 379 | 527 | if err != nil { | |
| 380 | 528 | return "", fmt.Errorf("pass show failed: %w", err) | |
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | "strings" | 10 | "strings" | ||
| 11 | "sync" | 11 | "sync" | ||
| 12 | "time" | 12 | "time" | ||
| 13 | + | "unicode/utf8" | |||
| 13 | 14 | ||||
| 14 | "github.com/bnema/zerowrap" | 15 | "github.com/bnema/zerowrap" | ||
| 15 | 16 | ||||
| @@ -52,6 +53,10 @@ func validateSubject(subject string) error { | |||||
| 52 | return fmt.Errorf("invalid subject: cannot contain '..' to prevent path traversal") | 53 | return fmt.Errorf("invalid subject: cannot contain '..' to prevent path traversal") | ||
| 53 | } | 54 | } | ||
| 54 | 55 | ||||
| 56 | + | if strings.HasSuffix(subject, ".meta") { | |||
| 57 | + | return fmt.Errorf("invalid subject: cannot end with '.meta' (reserved for token metadata files)") | |||
| 58 | + | } | |||
| 59 | + | ||||
| 55 | return nil | 60 | return nil | ||
| 56 | } | 61 | } | ||
| 57 | 62 | ||||
| @@ -79,12 +84,13 @@ func NewPassStore(log zerowrap.Logger) *PassStore { | |||||
| 79 | 84 | ||||
| 80 | // tokenMetadata holds the non-sensitive token information. | 85 | // tokenMetadata holds the non-sensitive token information. | ||
| 81 | type tokenMetadata struct { | 86 | type tokenMetadata struct { | ||
| 82 | - | ID string `json:"id"` | 87 | + | ID string `json:"id"` |
| 83 | - | Subject string `json:"subject"` | 88 | + | Subject string `json:"subject"` |
| 84 | - | Scopes []string `json:"scopes"` | 89 | + | Scopes []string `json:"scopes"` |
| 85 | - | IssuedAt time.Time `json:"issued_at"` | 90 | + | IssuedAt time.Time `json:"issued_at"` |
| 86 | - | ExpiresAt time.Time `json:"expires_at,omitempty"` | 91 | + | ExpiresAt time.Time `json:"expires_at,omitempty"` |
| 87 | - | Revoked bool `json:"revoked"` | 92 | + | Revoked bool `json:"revoked"` |
| 93 | + | LastExtendedAt time.Time `json:"last_extended_at"` | |||
| 88 | } | 94 | } | ||
| 89 | 95 | ||||
| 90 | // SaveToken stores a token JWT and metadata in pass. | 96 | // SaveToken stores a token JWT and metadata in pass. | ||
| @@ -104,12 +110,13 @@ func (s *PassStore) SaveToken(ctx context.Context, token *domain.Token, jwt stri | |||||
| 104 | 110 | ||||
| 105 | // Store metadata | 111 | // Store metadata | ||
| 106 | meta := tokenMetadata{ | 112 | meta := tokenMetadata{ | ||
| 107 | - | ID: token.ID, | 113 | + | ID: token.ID, |
| 108 | - | Subject: token.Subject, | 114 | + | Subject: token.Subject, |
| 109 | - | Scopes: token.Scopes, | 115 | + | Scopes: token.Scopes, |
| 110 | - | IssuedAt: token.IssuedAt, | 116 | + | IssuedAt: token.IssuedAt, |
| 111 | - | ExpiresAt: token.ExpiresAt, | 117 | + | ExpiresAt: token.ExpiresAt, |
| 112 | - | Revoked: token.Revoked, | 118 | + | Revoked: token.Revoked, |
| 119 | + | LastExtendedAt: token.LastExtendedAt, | |||
| 113 | } | 120 | } | ||
| 114 | metaJSON, err := json.Marshal(meta) | 121 | metaJSON, err := json.Marshal(meta) | ||
| 115 | if err != nil { | 122 | if err != nil { | ||
| @@ -173,12 +180,13 @@ func (s *PassStore) GetToken(ctx context.Context, subject string) (string, *doma | |||||
| 173 | } | 180 | } | ||
| 174 | 181 | ||||
| 175 | token := &domain.Token{ | 182 | token := &domain.Token{ | ||
| 176 | - | ID: meta.ID, | 183 | + | ID: meta.ID, |
| 177 | - | Subject: meta.Subject, | 184 | + | Subject: meta.Subject, |
| 178 | - | Scopes: meta.Scopes, | 185 | + | Scopes: meta.Scopes, |
| 179 | - | IssuedAt: meta.IssuedAt, | 186 | + | IssuedAt: meta.IssuedAt, |
| 180 | - | ExpiresAt: meta.ExpiresAt, | 187 | + | ExpiresAt: meta.ExpiresAt, |
| 181 | - | Revoked: meta.Revoked, | 188 | + | Revoked: meta.Revoked, |
| 189 | + | LastExtendedAt: meta.LastExtendedAt, | |||
| 182 | } | 190 | } | ||
| 183 | 191 | ||||
| 184 | // Cache the token | 192 | // Cache the token | ||
| @@ -189,49 +197,170 @@ func (s *PassStore) GetToken(ctx context.Context, subject string) (string, *doma | |||||
| 189 | return jwt, token, nil | 197 | return jwt, token, nil | ||
| 190 | } | 198 | } | ||
| 191 | 199 | ||||
| 200 | + | // treeEntry holds a single parsed entry from pass ls tree output. | |||
| 201 | + | type treeEntry struct { | |||
| 202 | + | depth int | |||
| 203 | + | name string | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // parsePassLsEntries parses raw pass ls output into a slice of tree entries. | |||
| 207 | + | // Each entry carries its nesting depth (0 = direct child of the listed root) | |||
| 208 | + | // and the bare file/directory name on that line. | |||
| 209 | + | func parsePassLsEntries(output string) []treeEntry { | |||
| 210 | + | var entries []treeEntry | |||
| 211 | + | ||||
| 212 | + | lines := strings.Split(output, "\n") | |||
| 213 | + | // Skip the first line (the directory header, e.g. "gordon/registry/tokens"). | |||
| 214 | + | for _, line := range lines[1:] { | |||
| 215 | + | // Strip ANSI escape sequences (pass may output coloured text on a TTY). | |||
| 216 | + | line = ansiRegex.ReplaceAllString(line, "") | |||
| 217 | + | ||||
| 218 | + | if strings.TrimSpace(line) == "" { | |||
| 219 | + | continue | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | // Locate the branch marker. pass uses Unicode box-drawing characters: | |||
| 223 | + | // ├── or └── (U+251C / U+2514, then two U+2500 dashes, then a space) | |||
| 224 | + | // Some terminals may fall back to ASCII equivalents: | |||
| 225 | + | // |-- or `-- (1-byte ASCII chars) | |||
| 226 | + | // | |||
| 227 | + | // Regardless of Unicode vs ASCII, each indent level is exactly 4 characters | |||
| 228 | + | // wide (e.g. "│ " = pipe + 3 spaces, " " = 4 spaces, "| " = pipe + 3). | |||
| 229 | + | // We count runes before the branch character and divide by 4 to get depth. | |||
| 230 | + | ||||
| 231 | + | var nameOffset int // byte offset where the entry name begins | |||
| 232 | + | var branchRune int // rune count before the branch character (├ └ | `) | |||
| 233 | + | ||||
| 234 | + | if idx := strings.Index(line, "── "); idx != -1 { | |||
| 235 | + | // Unicode variant: the two '─' dashes (U+2500) start at byte idx. | |||
| 236 | + | // The branch character (├ or └, 3 bytes each) immediately precedes them. | |||
| 237 | + | branchByteIdx := idx - 3 | |||
| 238 | + | if branchByteIdx < 0 { | |||
| 239 | + | // Marker is too close to the start — malformed line, skip. | |||
| 240 | + | continue | |||
| 241 | + | } | |||
| 242 | + | // Count runes in the indentation prefix before the branch char. | |||
| 243 | + | branchRune = utf8.RuneCountInString(line[:branchByteIdx]) | |||
| 244 | + | // Name starts after: branchChar(3B) + ─(3B) + ─(3B) + SP(1B) = 10B from branch. | |||
| 245 | + | nameOffset = branchByteIdx + 10 | |||
| 246 | + | } else if idx := strings.Index(line, "-- "); idx != -1 { | |||
| 247 | + | // ASCII variant: branch char (| or `, 1 byte) is immediately before the dashes. | |||
| 248 | + | branchByteIdx := idx - 1 | |||
| 249 | + | if branchByteIdx < 0 { | |||
| 250 | + | // Marker is too close to the start — malformed line, skip. | |||
| 251 | + | continue | |||
| 252 | + | } | |||
| 253 | + | branchRune = utf8.RuneCountInString(line[:branchByteIdx]) | |||
| 254 | + | // Name starts after: branchChar(1B) + -(1B) + -(1B) + SP(1B) = 4B from branch. | |||
| 255 | + | nameOffset = branchByteIdx + 4 | |||
| 256 | + | } else { | |||
| 257 | + | // No recognisable tree marker on this line — skip it. | |||
| 258 | + | continue | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | if nameOffset > len(line) { | |||
| 262 | + | continue | |||
| 263 | + | } | |||
| 264 | + | name := strings.TrimSpace(line[nameOffset:]) | |||
| 265 | + | if name == "" { | |||
| 266 | + | continue | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | // Each indent level is 4 characters wide. | |||
| 270 | + | depth := branchRune / 4 | |||
| 271 | + | entries = append(entries, treeEntry{depth: depth, name: name}) | |||
| 272 | + | } | |||
| 273 | + | ||||
| 274 | + | return entries | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | // parsePassLsOutput parses the tree-formatted output of `pass ls` and returns | |||
| 278 | + | // a flat list of full subject paths. It reconstructs slash-separated paths for | |||
| 279 | + | // nested entries (e.g. "team/alice") by tracking the indentation depth. | |||
| 280 | + | // Only leaf nodes (actual pass entries, not intermediate directories) are returned. | |||
| 281 | + | // | |||
| 282 | + | // pass ls outputs a tree like: | |||
| 283 | + | // | |||
| 284 | + | // gordon/registry/tokens | |||
| 285 | + | // ├── admin | |||
| 286 | + | // └── team | |||
| 287 | + | // ├── alice | |||
| 288 | + | // └── bob | |||
| 289 | + | // | |||
| 290 | + | // This function returns ["admin", "team/alice", "team/bob"]. | |||
| 291 | + | func parsePassLsOutput(output string) []string { | |||
| 292 | + | type frame struct { | |||
| 293 | + | depth int | |||
| 294 | + | name string | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | entries := parsePassLsEntries(output) | |||
| 298 | + | ||||
| 299 | + | var subjects []string | |||
| 300 | + | var stack []frame | |||
| 301 | + | ||||
| 302 | + | for i, entry := range entries { | |||
| 303 | + | // Pop ancestors that are at the same depth or deeper than the current entry. | |||
| 304 | + | for len(stack) > 0 && stack[len(stack)-1].depth >= entry.depth { | |||
| 305 | + | stack = stack[:len(stack)-1] | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | // Build the full path for this entry by joining ancestors with this name. | |||
| 309 | + | parts := make([]string, 0, len(stack)+1) | |||
| 310 | + | for _, f := range stack { | |||
| 311 | + | parts = append(parts, f.name) | |||
| 312 | + | } | |||
| 313 | + | parts = append(parts, entry.name) | |||
| 314 | + | subject := strings.Join(parts, "/") | |||
| 315 | + | ||||
| 316 | + | // Determine whether this entry is an intermediate directory node. | |||
| 317 | + | // A node is a directory if the immediately following entry is deeper. | |||
| 318 | + | isDir := i+1 < len(entries) && entries[i+1].depth > entry.depth | |||
| 319 | + | ||||
| 320 | + | // Always push so deeper entries can use this as ancestor context. | |||
| 321 | + | stack = append(stack, frame(entry)) | |||
| 322 | + | ||||
| 323 | + | if !isDir { | |||
| 324 | + | subjects = append(subjects, subject) | |||
| 325 | + | } | |||
| 326 | + | } | |||
| 327 | + | ||||
| 328 | + | if subjects == nil { | |||
| 329 | + | return []string{} | |||
| 330 | + | } | |||
| 331 | + | return subjects | |||
| 332 | + | } | |||
| 333 | + | ||||
| 192 | // ListTokens returns all stored tokens from pass. | 334 | // ListTokens returns all stored tokens from pass. | ||
| 193 | func (s *PassStore) ListTokens(ctx context.Context) ([]domain.Token, error) { | 335 | func (s *PassStore) ListTokens(ctx context.Context) ([]domain.Token, error) { | ||
| 194 | ctx, cancel := context.WithTimeout(ctx, s.timeout) | 336 | ctx, cancel := context.WithTimeout(ctx, s.timeout) | ||
| 195 | defer cancel() | 337 | defer cancel() | ||
| 196 | 338 | ||||
| 197 | // List all entries under the token path | 339 | // List all entries under the token path | ||
| 198 | - | cmd := exec.CommandContext(ctx, "pass", "ls", passTokenPath) | 340 | + | cmd := exec.CommandContext(ctx, "pass", "ls", passTokenPath) //nolint:gosec // passTokenPath is a fixed constant |
| 199 | output, err := cmd.Output() | 341 | output, err := cmd.Output() | ||
| 200 | if err != nil { | 342 | if err != nil { | ||
| 201 | // If the path doesn't exist, return empty list | 343 | // If the path doesn't exist, return empty list | ||
| 202 | return []domain.Token{}, nil | 344 | return []domain.Token{}, nil | ||
| 203 | } | 345 | } | ||
| 204 | 346 | ||||
| 205 | - | var tokens []domain.Token | 347 | + | subjects := parsePassLsOutput(string(output)) |
| 206 | - | lines := strings.Split(string(output), "\n") | |||
| 207 | - | ||||
| 208 | - | for _, line := range lines { | |||
| 209 | - | line = strings.TrimSpace(line) | |||
| 210 | 348 | ||||
| 211 | - | // Strip ANSI escape sequences (pass outputs colored text on TTY) | 349 | + | var tokens []domain.Token |
| 212 | - | line = ansiRegex.ReplaceAllString(line, "") | 350 | + | for _, subject := range subjects { |
| 213 | - | 351 | + | // Skip .meta files — they are metadata companions, not tokens themselves. | |
| 214 | - | // Skip tree formatting characters (both Unicode and ASCII variants) | 352 | + | if strings.HasSuffix(subject, ".meta") { |
| 215 | - | // Unicode: ├── └── │ | |||
| 216 | - | line = strings.TrimPrefix(line, "├── ") | |||
| 217 | - | line = strings.TrimPrefix(line, "└── ") | |||
| 218 | - | line = strings.TrimPrefix(line, "│ ") | |||
| 219 | - | // ASCII: |-- `-- | | |||
| 220 | - | line = strings.TrimPrefix(line, "|-- ") | |||
| 221 | - | line = strings.TrimPrefix(line, "`-- ") | |||
| 222 | - | line = strings.TrimPrefix(line, "| ") | |||
| 223 | - | ||||
| 224 | - | line = strings.TrimSpace(line) | |||
| 225 | - | ||||
| 226 | - | // Skip .meta files, empty lines, and the header line | |||
| 227 | - | if line == "" || strings.HasSuffix(line, ".meta") || line == passTokenPath { | |||
| 228 | continue | 353 | continue | ||
| 229 | } | 354 | } | ||
| 230 | 355 | ||||
| 231 | - | // Try to get the token metadata | 356 | + | // Try to get the token metadata. |
| 232 | - | _, token, err := s.GetToken(ctx, line) | 357 | + | // Use a fresh per-call context so each GetToken gets the full timeout, |
| 358 | + | // not the shared (potentially exhausted) deadline from the outer ctx. | |||
| 359 | + | callCtx, callCancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 360 | + | _, token, err := s.GetToken(callCtx, subject) | |||
| 361 | + | callCancel() | |||
| 233 | if err != nil { | 362 | if err != nil { | ||
| 234 | - | s.log.Warn().Err(err).Str("subject", line).Msg("failed to get token") | 363 | + | s.log.Warn().Err(err).Str("subject", subject).Msg("failed to get token") |
| 235 | continue | 364 | continue | ||
| 236 | } | 365 | } | ||
| 237 | 366 | ||||
| @@ -321,6 +450,25 @@ func (s *PassStore) IsRevoked(ctx context.Context, tokenID string) (bool, error) | |||||
| 321 | return revoked, nil | 450 | return revoked, nil | ||
| 322 | } | 451 | } | ||
| 323 | 452 | ||||
| 453 | + | // UpdateTokenExpiry updates the JWT and expiry metadata for an existing token. | |||
| 454 | + | // LastExtendedAt is also updated to track debounce timing. | |||
| 455 | + | // UpdateTokenExpiry enforces update-only semantics: it returns an error if | |||
| 456 | + | // token is nil or if no existing record is found for token.Subject. | |||
| 457 | + | func (s *PassStore) UpdateTokenExpiry(ctx context.Context, token *domain.Token, newJWT string) error { | |||
| 458 | + | if token == nil { | |||
| 459 | + | return fmt.Errorf("UpdateTokenExpiry: token must not be nil") | |||
| 460 | + | } | |||
| 461 | + | // Confirm the token exists before overwriting to avoid silent creation. | |||
| 462 | + | _, existing, err := s.GetToken(ctx, token.Subject) | |||
| 463 | + | if err != nil { | |||
| 464 | + | return fmt.Errorf("UpdateTokenExpiry: token not found for subject %q: %w", token.Subject, err) | |||
| 465 | + | } | |||
| 466 | + | if existing == nil { | |||
| 467 | + | return fmt.Errorf("UpdateTokenExpiry: no existing token for subject %q", token.Subject) | |||
| 468 | + | } | |||
| 469 | + | return s.SaveToken(ctx, token, newJWT) | |||
| 470 | + | } | |||
| 471 | + | ||||
| 324 | // DeleteToken removes token from pass. | 472 | // DeleteToken removes token from pass. | ||
| 325 | func (s *PassStore) DeleteToken(ctx context.Context, subject string) error { | 473 | func (s *PassStore) DeleteToken(ctx context.Context, subject string) error { | ||
| 326 | if err := validateSubject(subject); err != nil { | 474 | if err := validateSubject(subject); err != nil { | ||
| @@ -363,7 +511,7 @@ func (s *PassStore) DeleteToken(ctx context.Context, subject string) error { | |||||
| 363 | 511 | ||||
| 364 | // passInsert inserts a value into pass. | 512 | // passInsert inserts a value into pass. | ||
| 365 | func (s *PassStore) passInsert(ctx context.Context, path, value string) error { | 513 | func (s *PassStore) passInsert(ctx context.Context, path, value string) error { | ||
| 366 | - | cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path) | 514 | + | cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path) //nolint:gosec // binary is constant ("pass"); path arguments are sanitized token subjects |
| 367 | cmd.Stdin = strings.NewReader(value) | 515 | cmd.Stdin = strings.NewReader(value) | ||
| 368 | output, err := cmd.CombinedOutput() | 516 | output, err := cmd.CombinedOutput() | ||
| 369 | if err != nil { | 517 | if err != nil { | ||
| @@ -374,7 +522,7 @@ func (s *PassStore) passInsert(ctx context.Context, path, value string) error { | |||||
| 374 | 522 | ||||
| 375 | // passShow retrieves a value from pass. | 523 | // passShow retrieves a value from pass. | ||
| 376 | func (s *PassStore) passShow(ctx context.Context, path string) (string, error) { | 524 | func (s *PassStore) passShow(ctx context.Context, path string) (string, error) { | ||
| 377 | - | cmd := exec.CommandContext(ctx, "pass", "show", path) | 525 | + | cmd := exec.CommandContext(ctx, "pass", "show", path) //nolint:gosec // binary is constant ("pass"); path arguments are sanitized token subjects |
| 378 | output, err := cmd.Output() | 526 | output, err := cmd.Output() | ||
| 379 | if err != nil { | 527 | if err != nil { | ||
| 380 | return "", fmt.Errorf("pass show failed: %w", err) | 528 | return "", fmt.Errorf("pass show failed: %w", err) | ||
M internal/adapters/out/tokenstore/pass_test.go +68 -0
| @@ -12,6 +12,74 @@ import ( | |||
| 12 | 12 | "github.com/bnema/gordon/internal/domain" | |
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | + | // TestParsePassLsOutput verifies that pass ls tree output is parsed correctly, | |
| 16 | + | // including subjects that contain "/" (multi-level paths). | |
| 17 | + | func TestParsePassLsOutput(t *testing.T) { | |
| 18 | + | tests := []struct { | |
| 19 | + | name string | |
| 20 | + | input string | |
| 21 | + | want []string | |
| 22 | + | }{ | |
| 23 | + | { | |
| 24 | + | name: "flat entries only", | |
| 25 | + | input: `gordon/registry/tokens | |
| 26 | + | ├── admin | |
| 27 | + | └── bob | |
| 28 | + | `, | |
| 29 | + | want: []string{"admin", "bob"}, | |
| 30 | + | }, | |
| 31 | + | { | |
| 32 | + | name: "nested entries with slash in subject", | |
| 33 | + | input: `gordon/registry/tokens | |
| 34 | + | ├── admin | |
| 35 | + | └── team | |
| 36 | + | ├── alice | |
| 37 | + | └── bob | |
| 38 | + | `, | |
| 39 | + | want: []string{"admin", "team/alice", "team/bob"}, | |
| 40 | + | }, | |
| 41 | + | { | |
| 42 | + | name: "deeply nested entries", | |
| 43 | + | input: `gordon/registry/tokens | |
| 44 | + | └── org | |
| 45 | + | └── team | |
| 46 | + | └── alice | |
| 47 | + | `, | |
| 48 | + | want: []string{"org/team/alice"}, | |
| 49 | + | }, | |
| 50 | + | { | |
| 51 | + | name: "mixed flat and nested", | |
| 52 | + | input: `gordon/registry/tokens | |
| 53 | + | ├── admin | |
| 54 | + | ├── team | |
| 55 | + | │ ├── alice | |
| 56 | + | │ └── bob | |
| 57 | + | └── standalone | |
| 58 | + | `, | |
| 59 | + | want: []string{"admin", "team/alice", "team/bob", "standalone"}, | |
| 60 | + | }, | |
| 61 | + | { | |
| 62 | + | name: "empty output (no entries)", | |
| 63 | + | input: "gordon/registry/tokens\n", | |
| 64 | + | want: []string{}, | |
| 65 | + | }, | |
| 66 | + | { | |
| 67 | + | name: "ascii tree decoration variant", | |
| 68 | + | input: `gordon/registry/tokens | |
| 69 | + | |-- admin | |
| 70 | + | ` + "`-- team\n `-- alice\n", | |
| 71 | + | want: []string{"admin", "team/alice"}, | |
| 72 | + | }, | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | for _, tt := range tests { | |
| 76 | + | t.Run(tt.name, func(t *testing.T) { | |
| 77 | + | got := parsePassLsOutput(tt.input) | |
| 78 | + | assert.Equal(t, tt.want, got) | |
| 79 | + | }) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 15 | 83 | // These tests verify the in-memory caching behavior of PassStore. | |
| 16 | 84 | // They directly manipulate the cache to test cache hit/miss logic | |
| 17 | 85 | // without requiring the actual pass binary. | |
| @@ -12,6 +12,74 @@ import ( | |||||
| 12 | "github.com/bnema/gordon/internal/domain" | 12 | "github.com/bnema/gordon/internal/domain" | ||
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
| 15 | + | // TestParsePassLsOutput verifies that pass ls tree output is parsed correctly, | |||
| 16 | + | // including subjects that contain "/" (multi-level paths). | |||
| 17 | + | func TestParsePassLsOutput(t *testing.T) { | |||
| 18 | + | tests := []struct { | |||
| 19 | + | name string | |||
| 20 | + | input string | |||
| 21 | + | want []string | |||
| 22 | + | }{ | |||
| 23 | + | { | |||
| 24 | + | name: "flat entries only", | |||
| 25 | + | input: `gordon/registry/tokens | |||
| 26 | + | ├── admin | |||
| 27 | + | └── bob | |||
| 28 | + | `, | |||
| 29 | + | want: []string{"admin", "bob"}, | |||
| 30 | + | }, | |||
| 31 | + | { | |||
| 32 | + | name: "nested entries with slash in subject", | |||
| 33 | + | input: `gordon/registry/tokens | |||
| 34 | + | ├── admin | |||
| 35 | + | └── team | |||
| 36 | + | ├── alice | |||
| 37 | + | └── bob | |||
| 38 | + | `, | |||
| 39 | + | want: []string{"admin", "team/alice", "team/bob"}, | |||
| 40 | + | }, | |||
| 41 | + | { | |||
| 42 | + | name: "deeply nested entries", | |||
| 43 | + | input: `gordon/registry/tokens | |||
| 44 | + | └── org | |||
| 45 | + | └── team | |||
| 46 | + | └── alice | |||
| 47 | + | `, | |||
| 48 | + | want: []string{"org/team/alice"}, | |||
| 49 | + | }, | |||
| 50 | + | { | |||
| 51 | + | name: "mixed flat and nested", | |||
| 52 | + | input: `gordon/registry/tokens | |||
| 53 | + | ├── admin | |||
| 54 | + | ├── team | |||
| 55 | + | │ ├── alice | |||
| 56 | + | │ └── bob | |||
| 57 | + | └── standalone | |||
| 58 | + | `, | |||
| 59 | + | want: []string{"admin", "team/alice", "team/bob", "standalone"}, | |||
| 60 | + | }, | |||
| 61 | + | { | |||
| 62 | + | name: "empty output (no entries)", | |||
| 63 | + | input: "gordon/registry/tokens\n", | |||
| 64 | + | want: []string{}, | |||
| 65 | + | }, | |||
| 66 | + | { | |||
| 67 | + | name: "ascii tree decoration variant", | |||
| 68 | + | input: `gordon/registry/tokens | |||
| 69 | + | |-- admin | |||
| 70 | + | ` + "`-- team\n `-- alice\n", | |||
| 71 | + | want: []string{"admin", "team/alice"}, | |||
| 72 | + | }, | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | for _, tt := range tests { | |||
| 76 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 77 | + | got := parsePassLsOutput(tt.input) | |||
| 78 | + | assert.Equal(t, tt.want, got) | |||
| 79 | + | }) | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | ||||
| 15 | // These tests verify the in-memory caching behavior of PassStore. | 83 | // These tests verify the in-memory caching behavior of PassStore. | ||
| 16 | // They directly manipulate the cache to test cache hit/miss logic | 84 | // They directly manipulate the cache to test cache hit/miss logic | ||
| 17 | // without requiring the actual pass binary. | 85 | // without requiring the actual pass binary. | ||
M internal/adapters/out/tokenstore/unsafe.go +35 -18
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "os" | |
| 10 | 10 | "path/filepath" | |
| 11 | 11 | "strings" | |
| 12 | + | "sync" | |
| 12 | 13 | ||
| 13 | 14 | "github.com/bnema/zerowrap" | |
| 14 | 15 | ||
| @@ -32,6 +33,7 @@ const ( | |||
| 32 | 33 | // UnsafeStore implements TokenStore using plain text files. | |
| 33 | 34 | // WARNING: This store does not encrypt secrets. Only use when pass/sops are unavailable. | |
| 34 | 35 | type UnsafeStore struct { | |
| 36 | + | mu sync.RWMutex | |
| 35 | 37 | dataDir string | |
| 36 | 38 | log zerowrap.Logger | |
| 37 | 39 | } | |
| @@ -73,12 +75,13 @@ func (s *UnsafeStore) SaveToken(_ context.Context, token *domain.Token, jwt stri | |||
| 73 | 75 | data := unsafeTokenData{ | |
| 74 | 76 | JWT: jwt, | |
| 75 | 77 | Metadata: tokenMetadata{ | |
| 76 | - | ID: token.ID, | |
| 77 | - | Subject: token.Subject, // Store original subject in metadata | |
| 78 | - | Scopes: token.Scopes, | |
| 79 | - | IssuedAt: token.IssuedAt, | |
| 80 | - | ExpiresAt: token.ExpiresAt, | |
| 81 | - | Revoked: token.Revoked, | |
| 78 | + | ID: token.ID, | |
| 79 | + | Subject: token.Subject, // Store original subject in metadata | |
| 80 | + | Scopes: token.Scopes, | |
| 81 | + | IssuedAt: token.IssuedAt, | |
| 82 | + | ExpiresAt: token.ExpiresAt, | |
| 83 | + | Revoked: token.Revoked, | |
| 84 | + | LastExtendedAt: token.LastExtendedAt, | |
| 82 | 85 | }, | |
| 83 | 86 | } | |
| 84 | 87 | ||
| @@ -124,12 +127,13 @@ func (s *UnsafeStore) GetToken(_ context.Context, subject string) (string, *doma | |||
| 124 | 127 | } | |
| 125 | 128 | ||
| 126 | 129 | token := &domain.Token{ | |
| 127 | - | ID: data.Metadata.ID, | |
| 128 | - | Subject: data.Metadata.Subject, | |
| 129 | - | Scopes: data.Metadata.Scopes, | |
| 130 | - | IssuedAt: data.Metadata.IssuedAt, | |
| 131 | - | ExpiresAt: data.Metadata.ExpiresAt, | |
| 132 | - | Revoked: data.Metadata.Revoked, | |
| 130 | + | ID: data.Metadata.ID, | |
| 131 | + | Subject: data.Metadata.Subject, | |
| 132 | + | Scopes: data.Metadata.Scopes, | |
| 133 | + | IssuedAt: data.Metadata.IssuedAt, | |
| 134 | + | ExpiresAt: data.Metadata.ExpiresAt, | |
| 135 | + | Revoked: data.Metadata.Revoked, | |
| 136 | + | LastExtendedAt: data.Metadata.LastExtendedAt, | |
| 133 | 137 | } | |
| 134 | 138 | ||
| 135 | 139 | return data.JWT, token, nil | |
| @@ -168,12 +172,13 @@ func (s *UnsafeStore) ListTokens(_ context.Context) ([]domain.Token, error) { | |||
| 168 | 172 | } | |
| 169 | 173 | ||
| 170 | 174 | token := domain.Token{ | |
| 171 | - | ID: data.Metadata.ID, | |
| 172 | - | Subject: data.Metadata.Subject, | |
| 173 | - | Scopes: data.Metadata.Scopes, | |
| 174 | - | IssuedAt: data.Metadata.IssuedAt, | |
| 175 | - | ExpiresAt: data.Metadata.ExpiresAt, | |
| 176 | - | Revoked: data.Metadata.Revoked, | |
| 175 | + | ID: data.Metadata.ID, | |
| 176 | + | Subject: data.Metadata.Subject, | |
| 177 | + | Scopes: data.Metadata.Scopes, | |
| 178 | + | IssuedAt: data.Metadata.IssuedAt, | |
| 179 | + | ExpiresAt: data.Metadata.ExpiresAt, | |
| 180 | + | Revoked: data.Metadata.Revoked, | |
| 181 | + | LastExtendedAt: data.Metadata.LastExtendedAt, | |
| 177 | 182 | } | |
| 178 | 183 | ||
| 179 | 184 | tokens = append(tokens, token) | |
| @@ -184,6 +189,9 @@ func (s *UnsafeStore) ListTokens(_ context.Context) ([]domain.Token, error) { | |||
| 184 | 189 | ||
| 185 | 190 | // Revoke adds token ID to revocation list file. | |
| 186 | 191 | func (s *UnsafeStore) Revoke(_ context.Context, tokenID string) error { | |
| 192 | + | s.mu.Lock() | |
| 193 | + | defer s.mu.Unlock() | |
| 194 | + | ||
| 187 | 195 | revokedFile := filepath.Join(s.dataDir, unsafeRevokedFile) | |
| 188 | 196 | ||
| 189 | 197 | // Ensure parent directory exists | |
| @@ -226,6 +234,9 @@ func (s *UnsafeStore) Revoke(_ context.Context, tokenID string) error { | |||
| 226 | 234 | ||
| 227 | 235 | // IsRevoked checks if token ID is in revocation list. | |
| 228 | 236 | func (s *UnsafeStore) IsRevoked(_ context.Context, tokenID string) (bool, error) { | |
| 237 | + | s.mu.RLock() | |
| 238 | + | defer s.mu.RUnlock() | |
| 239 | + | ||
| 229 | 240 | revokedList, err := s.getRevokedList() | |
| 230 | 241 | if err != nil { | |
| 231 | 242 | return false, err | |
| @@ -240,6 +251,12 @@ func (s *UnsafeStore) IsRevoked(_ context.Context, tokenID string) (bool, error) | |||
| 240 | 251 | return false, nil | |
| 241 | 252 | } | |
| 242 | 253 | ||
| 254 | + | // UpdateTokenExpiry updates the JWT and expiry metadata for an existing token. | |
| 255 | + | // LastExtendedAt is also updated to track debounce timing. | |
| 256 | + | func (s *UnsafeStore) UpdateTokenExpiry(ctx context.Context, token *domain.Token, newJWT string) error { | |
| 257 | + | return s.SaveToken(ctx, token, newJWT) | |
| 258 | + | } | |
| 259 | + | ||
| 243 | 260 | // DeleteToken removes token file. | |
| 244 | 261 | func (s *UnsafeStore) DeleteToken(_ context.Context, subject string) error { | |
| 245 | 262 | // SECURITY: Use sanitized filename to prevent path traversal | |
| @@ -9,6 +9,7 @@ import ( | |||||
| 9 | "os" | 9 | "os" | ||
| 10 | "path/filepath" | 10 | "path/filepath" | ||
| 11 | "strings" | 11 | "strings" | ||
| 12 | + | "sync" | |||
| 12 | 13 | ||||
| 13 | "github.com/bnema/zerowrap" | 14 | "github.com/bnema/zerowrap" | ||
| 14 | 15 | ||||
| @@ -32,6 +33,7 @@ const ( | |||||
| 32 | // UnsafeStore implements TokenStore using plain text files. | 33 | // UnsafeStore implements TokenStore using plain text files. | ||
| 33 | // WARNING: This store does not encrypt secrets. Only use when pass/sops are unavailable. | 34 | // WARNING: This store does not encrypt secrets. Only use when pass/sops are unavailable. | ||
| 34 | type UnsafeStore struct { | 35 | type UnsafeStore struct { | ||
| 36 | + | mu sync.RWMutex | |||
| 35 | dataDir string | 37 | dataDir string | ||
| 36 | log zerowrap.Logger | 38 | log zerowrap.Logger | ||
| 37 | } | 39 | } | ||
| @@ -73,12 +75,13 @@ func (s *UnsafeStore) SaveToken(_ context.Context, token *domain.Token, jwt stri | |||||
| 73 | data := unsafeTokenData{ | 75 | data := unsafeTokenData{ | ||
| 74 | JWT: jwt, | 76 | JWT: jwt, | ||
| 75 | Metadata: tokenMetadata{ | 77 | Metadata: tokenMetadata{ | ||
| 76 | - | ID: token.ID, | 78 | + | ID: token.ID, |
| 77 | - | Subject: token.Subject, // Store original subject in metadata | 79 | + | Subject: token.Subject, // Store original subject in metadata |
| 78 | - | Scopes: token.Scopes, | 80 | + | Scopes: token.Scopes, |
| 79 | - | IssuedAt: token.IssuedAt, | 81 | + | IssuedAt: token.IssuedAt, |
| 80 | - | ExpiresAt: token.ExpiresAt, | 82 | + | ExpiresAt: token.ExpiresAt, |
| 81 | - | Revoked: token.Revoked, | 83 | + | Revoked: token.Revoked, |
| 84 | + | LastExtendedAt: token.LastExtendedAt, | |||
| 82 | }, | 85 | }, | ||
| 83 | } | 86 | } | ||
| 84 | 87 | ||||
| @@ -124,12 +127,13 @@ func (s *UnsafeStore) GetToken(_ context.Context, subject string) (string, *doma | |||||
| 124 | } | 127 | } | ||
| 125 | 128 | ||||
| 126 | token := &domain.Token{ | 129 | token := &domain.Token{ | ||
| 127 | - | ID: data.Metadata.ID, | 130 | + | ID: data.Metadata.ID, |
| 128 | - | Subject: data.Metadata.Subject, | 131 | + | Subject: data.Metadata.Subject, |
| 129 | - | Scopes: data.Metadata.Scopes, | 132 | + | Scopes: data.Metadata.Scopes, |
| 130 | - | IssuedAt: data.Metadata.IssuedAt, | 133 | + | IssuedAt: data.Metadata.IssuedAt, |
| 131 | - | ExpiresAt: data.Metadata.ExpiresAt, | 134 | + | ExpiresAt: data.Metadata.ExpiresAt, |
| 132 | - | Revoked: data.Metadata.Revoked, | 135 | + | Revoked: data.Metadata.Revoked, |
| 136 | + | LastExtendedAt: data.Metadata.LastExtendedAt, | |||
| 133 | } | 137 | } | ||
| 134 | 138 | ||||
| 135 | return data.JWT, token, nil | 139 | return data.JWT, token, nil | ||
| @@ -168,12 +172,13 @@ func (s *UnsafeStore) ListTokens(_ context.Context) ([]domain.Token, error) { | |||||
| 168 | } | 172 | } | ||
| 169 | 173 | ||||
| 170 | token := domain.Token{ | 174 | token := domain.Token{ | ||
| 171 | - | ID: data.Metadata.ID, | 175 | + | ID: data.Metadata.ID, |
| 172 | - | Subject: data.Metadata.Subject, | 176 | + | Subject: data.Metadata.Subject, |
| 173 | - | Scopes: data.Metadata.Scopes, | 177 | + | Scopes: data.Metadata.Scopes, |
| 174 | - | IssuedAt: data.Metadata.IssuedAt, | 178 | + | IssuedAt: data.Metadata.IssuedAt, |
| 175 | - | ExpiresAt: data.Metadata.ExpiresAt, | 179 | + | ExpiresAt: data.Metadata.ExpiresAt, |
| 176 | - | Revoked: data.Metadata.Revoked, | 180 | + | Revoked: data.Metadata.Revoked, |
| 181 | + | LastExtendedAt: data.Metadata.LastExtendedAt, | |||
| 177 | } | 182 | } | ||
| 178 | 183 | ||||
| 179 | tokens = append(tokens, token) | 184 | tokens = append(tokens, token) | ||
| @@ -184,6 +189,9 @@ func (s *UnsafeStore) ListTokens(_ context.Context) ([]domain.Token, error) { | |||||
| 184 | 189 | ||||
| 185 | // Revoke adds token ID to revocation list file. | 190 | // Revoke adds token ID to revocation list file. | ||
| 186 | func (s *UnsafeStore) Revoke(_ context.Context, tokenID string) error { | 191 | func (s *UnsafeStore) Revoke(_ context.Context, tokenID string) error { | ||
| 192 | + | s.mu.Lock() | |||
| 193 | + | defer s.mu.Unlock() | |||
| 194 | + | ||||
| 187 | revokedFile := filepath.Join(s.dataDir, unsafeRevokedFile) | 195 | revokedFile := filepath.Join(s.dataDir, unsafeRevokedFile) | ||
| 188 | 196 | ||||
| 189 | // Ensure parent directory exists | 197 | // Ensure parent directory exists | ||
| @@ -226,6 +234,9 @@ func (s *UnsafeStore) Revoke(_ context.Context, tokenID string) error { | |||||
| 226 | 234 | ||||
| 227 | // IsRevoked checks if token ID is in revocation list. | 235 | // IsRevoked checks if token ID is in revocation list. | ||
| 228 | func (s *UnsafeStore) IsRevoked(_ context.Context, tokenID string) (bool, error) { | 236 | func (s *UnsafeStore) IsRevoked(_ context.Context, tokenID string) (bool, error) { | ||
| 237 | + | s.mu.RLock() | |||
| 238 | + | defer s.mu.RUnlock() | |||
| 239 | + | ||||
| 229 | revokedList, err := s.getRevokedList() | 240 | revokedList, err := s.getRevokedList() | ||
| 230 | if err != nil { | 241 | if err != nil { | ||
| 231 | return false, err | 242 | return false, err | ||
| @@ -240,6 +251,12 @@ func (s *UnsafeStore) IsRevoked(_ context.Context, tokenID string) (bool, error) | |||||
| 240 | return false, nil | 251 | return false, nil | ||
| 241 | } | 252 | } | ||
| 242 | 253 | ||||
| 254 | + | // UpdateTokenExpiry updates the JWT and expiry metadata for an existing token. | |||
| 255 | + | // LastExtendedAt is also updated to track debounce timing. | |||
| 256 | + | func (s *UnsafeStore) UpdateTokenExpiry(ctx context.Context, token *domain.Token, newJWT string) error { | |||
| 257 | + | return s.SaveToken(ctx, token, newJWT) | |||
| 258 | + | } | |||
| 259 | + | ||||
| 243 | // DeleteToken removes token file. | 260 | // DeleteToken removes token file. | ||
| 244 | func (s *UnsafeStore) DeleteToken(_ context.Context, subject string) error { | 261 | func (s *UnsafeStore) DeleteToken(_ context.Context, subject string) error { | ||
| 245 | // SECURITY: Use sanitized filename to prevent path traversal | 262 | // SECURITY: Use sanitized filename to prevent path traversal | ||
A internal/adapters/out/tokenstore/unsafe_test.go +78 -0
| @@ -0,0 +1,78 @@ | |||
| 1 | + | package tokenstore | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "io" | |
| 7 | + | "sync" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/zerowrap" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/gordon/internal/domain" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // newTestUnsafeStore creates a UnsafeStore backed by a temp directory. | |
| 16 | + | func newTestUnsafeStore(t *testing.T) *UnsafeStore { | |
| 17 | + | t.Helper() | |
| 18 | + | dir := t.TempDir() | |
| 19 | + | log := zerowrap.New(zerowrap.Config{Level: "disabled", Output: io.Discard}) | |
| 20 | + | store, err := NewUnsafeStore(dir, log) | |
| 21 | + | if err != nil { | |
| 22 | + | t.Fatalf("NewUnsafeStore: %v", err) | |
| 23 | + | } | |
| 24 | + | return store | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func TestUnsafeStoreRevokeConcurrentNoLoss(t *testing.T) { | |
| 28 | + | // Run with: go test -race ./internal/adapters/out/tokenstore/... -run TestUnsafeStoreRevokeConcurrentNoLoss -v | |
| 29 | + | store := newTestUnsafeStore(t) | |
| 30 | + | ||
| 31 | + | ctx := context.Background() | |
| 32 | + | const n = 10 | |
| 33 | + | ||
| 34 | + | // Save n tokens | |
| 35 | + | tokenIDs := make([]string, n) | |
| 36 | + | for i := 0; i < n; i++ { | |
| 37 | + | id := fmt.Sprintf("token-%d", i) | |
| 38 | + | tokenIDs[i] = id | |
| 39 | + | tok := &domain.Token{ | |
| 40 | + | ID: id, | |
| 41 | + | Subject: fmt.Sprintf("user-%d", i), | |
| 42 | + | Scopes: []string{"admin"}, | |
| 43 | + | } | |
| 44 | + | if err := store.SaveToken(ctx, tok, "jwt-"+id); err != nil { | |
| 45 | + | t.Fatal(err) | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // Revoke all concurrently; collect errors via a buffered channel. | |
| 50 | + | var wg sync.WaitGroup | |
| 51 | + | errs := make(chan error, len(tokenIDs)) | |
| 52 | + | for _, id := range tokenIDs { | |
| 53 | + | wg.Add(1) | |
| 54 | + | go func(id string) { | |
| 55 | + | defer wg.Done() | |
| 56 | + | if err := store.Revoke(ctx, id); err != nil { | |
| 57 | + | errs <- fmt.Errorf("Revoke(%q): %w", id, err) | |
| 58 | + | } | |
| 59 | + | }(id) | |
| 60 | + | } | |
| 61 | + | wg.Wait() | |
| 62 | + | close(errs) | |
| 63 | + | for err := range errs { | |
| 64 | + | t.Error(err) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // Verify all n token IDs are in the revoked list | |
| 68 | + | for _, id := range tokenIDs { | |
| 69 | + | revoked, err := store.IsRevoked(ctx, id) | |
| 70 | + | if err != nil { | |
| 71 | + | t.Errorf("IsRevoked(%q): %v", id, err) | |
| 72 | + | continue | |
| 73 | + | } | |
| 74 | + | if !revoked { | |
| 75 | + | t.Errorf("token %q should be revoked but IsRevoked returned false", id) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | } | |
| @@ -0,0 +1,78 @@ | |||||
| 1 | + | package tokenstore | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "io" | |||
| 7 | + | "sync" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/zerowrap" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/gordon/internal/domain" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // newTestUnsafeStore creates a UnsafeStore backed by a temp directory. | |||
| 16 | + | func newTestUnsafeStore(t *testing.T) *UnsafeStore { | |||
| 17 | + | t.Helper() | |||
| 18 | + | dir := t.TempDir() | |||
| 19 | + | log := zerowrap.New(zerowrap.Config{Level: "disabled", Output: io.Discard}) | |||
| 20 | + | store, err := NewUnsafeStore(dir, log) | |||
| 21 | + | if err != nil { | |||
| 22 | + | t.Fatalf("NewUnsafeStore: %v", err) | |||
| 23 | + | } | |||
| 24 | + | return store | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func TestUnsafeStoreRevokeConcurrentNoLoss(t *testing.T) { | |||
| 28 | + | // Run with: go test -race ./internal/adapters/out/tokenstore/... -run TestUnsafeStoreRevokeConcurrentNoLoss -v | |||
| 29 | + | store := newTestUnsafeStore(t) | |||
| 30 | + | ||||
| 31 | + | ctx := context.Background() | |||
| 32 | + | const n = 10 | |||
| 33 | + | ||||
| 34 | + | // Save n tokens | |||
| 35 | + | tokenIDs := make([]string, n) | |||
| 36 | + | for i := 0; i < n; i++ { | |||
| 37 | + | id := fmt.Sprintf("token-%d", i) | |||
| 38 | + | tokenIDs[i] = id | |||
| 39 | + | tok := &domain.Token{ | |||
| 40 | + | ID: id, | |||
| 41 | + | Subject: fmt.Sprintf("user-%d", i), | |||
| 42 | + | Scopes: []string{"admin"}, | |||
| 43 | + | } | |||
| 44 | + | if err := store.SaveToken(ctx, tok, "jwt-"+id); err != nil { | |||
| 45 | + | t.Fatal(err) | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // Revoke all concurrently; collect errors via a buffered channel. | |||
| 50 | + | var wg sync.WaitGroup | |||
| 51 | + | errs := make(chan error, len(tokenIDs)) | |||
| 52 | + | for _, id := range tokenIDs { | |||
| 53 | + | wg.Add(1) | |||
| 54 | + | go func(id string) { | |||
| 55 | + | defer wg.Done() | |||
| 56 | + | if err := store.Revoke(ctx, id); err != nil { | |||
| 57 | + | errs <- fmt.Errorf("Revoke(%q): %w", id, err) | |||
| 58 | + | } | |||
| 59 | + | }(id) | |||
| 60 | + | } | |||
| 61 | + | wg.Wait() | |||
| 62 | + | close(errs) | |||
| 63 | + | for err := range errs { | |||
| 64 | + | t.Error(err) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // Verify all n token IDs are in the revoked list | |||
| 68 | + | for _, id := range tokenIDs { | |||
| 69 | + | revoked, err := store.IsRevoked(ctx, id) | |||
| 70 | + | if err != nil { | |||
| 71 | + | t.Errorf("IsRevoked(%q): %v", id, err) | |||
| 72 | + | continue | |||
| 73 | + | } | |||
| 74 | + | if !revoked { | |||
| 75 | + | t.Errorf("token %q should be revoked but IsRevoked returned false", id) | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | } | |||
M internal/app/run.go +89 -31
| @@ -819,19 +819,73 @@ func cleanupInternalCredentials() { | |||
| 819 | 819 | _ = os.Remove(getInternalCredentialsFile()) | |
| 820 | 820 | } | |
| 821 | 821 | ||
| 822 | - | // GetInternalCredentials reads the internal registry credentials from file. | |
| 823 | - | // This is used by the CLI to display credentials for manual recovery. | |
| 824 | - | func GetInternalCredentials() (*InternalCredentials, error) { | |
| 825 | - | credFile := getInternalCredentialsFile() | |
| 826 | - | data, err := os.ReadFile(credFile) | |
| 827 | - | if err != nil { | |
| 828 | - | return nil, fmt.Errorf("failed to read credentials file (is Gordon running?): %w", err) | |
| 822 | + | // getInternalCredentialsCandidates returns candidate file paths in priority order: | |
| 823 | + | // 1. XDG_RUNTIME_DIR/gordon/ (set by systemd for the daemon) | |
| 824 | + | // 2. /run/user/<uid>/gordon/ (well-known systemd default, for CLI in shells without XDG_RUNTIME_DIR) | |
| 825 | + | // 3. ~/.gordon/run/ (fallback for non-systemd environments) | |
| 826 | + | // 4. os.TempDir() (last resort, matches getInternalCredentialsFile fallback path) | |
| 827 | + | func getInternalCredentialsCandidates() []string { | |
| 828 | + | var candidates []string | |
| 829 | + | ||
| 830 | + | // 1. XDG_RUNTIME_DIR (set in daemon's environment) | |
| 831 | + | if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" { | |
| 832 | + | candidates = append(candidates, filepath.Join(runtimeDir, "gordon", "internal-creds.json")) | |
| 833 | + | } | |
| 834 | + | ||
| 835 | + | // 2. /run/user/<uid>/gordon/ (systemd default, may not be in CLI's env) | |
| 836 | + | uid := os.Getuid() | |
| 837 | + | sysRuntime := filepath.Join("/run/user", fmt.Sprintf("%d", uid), "gordon", "internal-creds.json") | |
| 838 | + | // Avoid duplicate if XDG_RUNTIME_DIR already points here | |
| 839 | + | if len(candidates) == 0 || candidates[0] != sysRuntime { | |
| 840 | + | candidates = append(candidates, sysRuntime) | |
| 841 | + | } | |
| 842 | + | ||
| 843 | + | // 3. ~/.gordon/run/ fallback | |
| 844 | + | if homeDir, err := os.UserHomeDir(); err == nil { | |
| 845 | + | candidates = append(candidates, filepath.Join(homeDir, ".gordon", "run", "internal-creds.json")) | |
| 846 | + | } | |
| 847 | + | ||
| 848 | + | // 4. os.TempDir() last resort — matches the fallback path in getInternalCredentialsFile, | |
| 849 | + | // ensuring GetInternalCredentials can find credentials even when getSecureRuntimeDir fails. | |
| 850 | + | candidates = append(candidates, filepath.Join(os.TempDir(), "gordon-internal-creds.json")) | |
| 851 | + | ||
| 852 | + | return candidates | |
| 853 | + | } | |
| 854 | + | ||
| 855 | + | // GetInternalCredentialsFromCandidates reads credentials from the first candidate file that exists. | |
| 856 | + | // Exported for testing. | |
| 857 | + | func GetInternalCredentialsFromCandidates(candidates []string) (*InternalCredentials, error) { | |
| 858 | + | var lastErr error | |
| 859 | + | for _, path := range candidates { | |
| 860 | + | data, err := os.ReadFile(path) | |
| 861 | + | if os.IsNotExist(err) { | |
| 862 | + | continue | |
| 863 | + | } | |
| 864 | + | if err != nil { | |
| 865 | + | // Non-permission errors (e.g. EACCES) may be transient or path-specific; | |
| 866 | + | // record and try the next candidate rather than failing immediately. | |
| 867 | + | lastErr = fmt.Errorf("failed to read credentials file %s: %w", path, err) | |
| 868 | + | continue | |
| 869 | + | } | |
| 870 | + | var creds InternalCredentials | |
| 871 | + | if err := json.Unmarshal(data, &creds); err != nil { | |
| 872 | + | // Corrupt file — record and fall through to lower-priority candidates. | |
| 873 | + | lastErr = fmt.Errorf("failed to parse credentials at %s: %w", path, err) | |
| 874 | + | continue | |
| 875 | + | } | |
| 876 | + | return &creds, nil | |
| 829 | 877 | } | |
| 830 | - | var creds InternalCredentials | |
| 831 | - | if err := json.Unmarshal(data, &creds); err != nil { | |
| 832 | - | return nil, fmt.Errorf("failed to parse credentials: %w", err) | |
| 878 | + | if lastErr != nil { | |
| 879 | + | return nil, lastErr | |
| 833 | 880 | } | |
| 834 | - | return &creds, nil | |
| 881 | + | return nil, fmt.Errorf("no credentials file found (is Gordon running?): checked %v", candidates) | |
| 882 | + | } | |
| 883 | + | ||
| 884 | + | // GetInternalCredentials reads the internal registry credentials from file. | |
| 885 | + | // Probes all candidate runtime directories so CLI works regardless of whether | |
| 886 | + | // XDG_RUNTIME_DIR is set in the current shell environment. | |
| 887 | + | func GetInternalCredentials() (*InternalCredentials, error) { | |
| 888 | + | return GetInternalCredentialsFromCandidates(getInternalCredentialsCandidates()) | |
| 835 | 889 | } | |
| 836 | 890 | ||
| 837 | 891 | // createAuthService creates the authentication service and token store. | |
| @@ -1645,12 +1699,13 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler | |||
| 1645 | 1699 | ||
| 1646 | 1700 | registrySrv, registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log) | |
| 1647 | 1701 | proxySrv, proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", errChan, log) | |
| 1702 | + | var tlsSrv *http.Server | |
| 1648 | 1703 | var tlsReady <-chan struct{} | |
| 1649 | 1704 | if cfg.Server.TLSEnabled { | |
| 1650 | 1705 | if cfg.Server.TLSCertFile == "" || cfg.Server.TLSKeyFile == "" { | |
| 1651 | 1706 | return fmt.Errorf("server.tls_enabled=true requires both server.tls_cert_file and server.tls_key_file") | |
| 1652 | 1707 | } | |
| 1653 | - | tlsReady = startTLSServer( | |
| 1708 | + | tlsSrv, tlsReady = startTLSServer( | |
| 1654 | 1709 | fmt.Sprintf(":%d", cfg.Server.TLSPort), | |
| 1655 | 1710 | proxyHandler, | |
| 1656 | 1711 | "proxy-tls", | |
| @@ -1695,7 +1750,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler | |||
| 1695 | 1750 | syncAndAutoStart(ctx, svc, log) | |
| 1696 | 1751 | ||
| 1697 | 1752 | waitForShutdown(ctx, errChan, reloadChan, deployChan, eventBus, log) | |
| 1698 | - | gracefulShutdown(registrySrv, proxySrv, containerSvc, log) | |
| 1753 | + | gracefulShutdown(registrySrv, proxySrv, tlsSrv, containerSvc, log) | |
| 1699 | 1754 | return nil | |
| 1700 | 1755 | } | |
| 1701 | 1756 | ||
| @@ -1895,17 +1950,19 @@ func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, depl | |||
| 1895 | 1950 | ||
| 1896 | 1951 | // gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down | |
| 1897 | 1952 | // the container service and cleans up runtime files. | |
| 1898 | - | func gracefulShutdown(registrySrv, proxySrv *http.Server, containerSvc *container.Service, log zerowrap.Logger) { | |
| 1953 | + | func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *container.Service, log zerowrap.Logger) { | |
| 1899 | 1954 | log.Info().Msg("shutting down Gordon...") | |
| 1900 | 1955 | ||
| 1901 | 1956 | shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) | |
| 1902 | 1957 | defer shutdownCancel() | |
| 1903 | 1958 | ||
| 1904 | - | if err := registrySrv.Shutdown(shutdownCtx); err != nil { | |
| 1905 | - | log.Warn().Err(err).Msg("registry server shutdown error") | |
| 1906 | - | } | |
| 1907 | - | if err := proxySrv.Shutdown(shutdownCtx); err != nil { | |
| 1908 | - | log.Warn().Err(err).Msg("proxy server shutdown error") | |
| 1959 | + | for _, srv := range []*http.Server{registrySrv, proxySrv, tlsSrv} { | |
| 1960 | + | if srv == nil { | |
| 1961 | + | continue | |
| 1962 | + | } | |
| 1963 | + | if err := srv.Shutdown(shutdownCtx); err != nil { | |
| 1964 | + | log.Warn().Err(err).Str("addr", srv.Addr).Msg("server shutdown error") | |
| 1965 | + | } | |
| 1909 | 1966 | } | |
| 1910 | 1967 | ||
| 1911 | 1968 | containerSvc.StopMonitor() | |
| @@ -1955,9 +2012,20 @@ func startServer(addr string, handler http.Handler, name string, errChan chan<- | |||
| 1955 | 2012 | } | |
| 1956 | 2013 | ||
| 1957 | 2014 | // startTLSServer starts an HTTPS server with the provided certificate and key. | |
| 1958 | - | func startTLSServer(addr string, handler http.Handler, name, certFile, keyFile string, errChan chan<- error, log zerowrap.Logger) <-chan struct{} { | |
| 2015 | + | // It returns the server instance (for graceful shutdown) and a channel that closes once the port is bound. | |
| 2016 | + | func startTLSServer(addr string, handler http.Handler, name, certFile, keyFile string, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}) { | |
| 1959 | 2017 | ready := make(chan struct{}) | |
| 1960 | 2018 | ||
| 2019 | + | server := &http.Server{ | |
| 2020 | + | Addr: addr, | |
| 2021 | + | Handler: handler, | |
| 2022 | + | ReadHeaderTimeout: 10 * time.Second, | |
| 2023 | + | ReadTimeout: 5 * time.Minute, | |
| 2024 | + | WriteTimeout: 5 * time.Minute, | |
| 2025 | + | IdleTimeout: 120 * time.Second, | |
| 2026 | + | MaxHeaderBytes: 1 << 20, | |
| 2027 | + | } | |
| 2028 | + | ||
| 1961 | 2029 | go func() { | |
| 1962 | 2030 | log.Info(). | |
| 1963 | 2031 | Str("address", addr). | |
| @@ -1965,16 +2033,6 @@ func startTLSServer(addr string, handler http.Handler, name, certFile, keyFile s | |||
| 1965 | 2033 | Str("key_file", keyFile). | |
| 1966 | 2034 | Msgf("%s server starting", name) | |
| 1967 | 2035 | ||
| 1968 | - | server := &http.Server{ | |
| 1969 | - | Addr: addr, | |
| 1970 | - | Handler: handler, | |
| 1971 | - | ReadHeaderTimeout: 10 * time.Second, | |
| 1972 | - | ReadTimeout: 5 * time.Minute, | |
| 1973 | - | WriteTimeout: 5 * time.Minute, | |
| 1974 | - | IdleTimeout: 120 * time.Second, | |
| 1975 | - | MaxHeaderBytes: 1 << 20, | |
| 1976 | - | } | |
| 1977 | - | ||
| 1978 | 2036 | ln, err := net.Listen("tcp", addr) | |
| 1979 | 2037 | if err != nil { | |
| 1980 | 2038 | errChan <- fmt.Errorf("%s server error: %w", name, err) | |
| @@ -1987,7 +2045,7 @@ func startTLSServer(addr string, handler http.Handler, name, certFile, keyFile s | |||
| 1987 | 2045 | } | |
| 1988 | 2046 | }() | |
| 1989 | 2047 | ||
| 1990 | - | return ready | |
| 2048 | + | return server, ready | |
| 1991 | 2049 | } | |
| 1992 | 2050 | ||
| 1993 | 2051 | // SendReloadSignal sends SIGUSR1 to the running Gordon process. | |
| @@ -819,19 +819,73 @@ func cleanupInternalCredentials() { | |||||
| 819 | _ = os.Remove(getInternalCredentialsFile()) | 819 | _ = os.Remove(getInternalCredentialsFile()) | ||
| 820 | } | 820 | } | ||
| 821 | 821 | ||||
| 822 | - | // GetInternalCredentials reads the internal registry credentials from file. | 822 | + | // getInternalCredentialsCandidates returns candidate file paths in priority order: |
| 823 | - | // This is used by the CLI to display credentials for manual recovery. | 823 | + | // 1. XDG_RUNTIME_DIR/gordon/ (set by systemd for the daemon) |
| 824 | - | func GetInternalCredentials() (*InternalCredentials, error) { | 824 | + | // 2. /run/user/<uid>/gordon/ (well-known systemd default, for CLI in shells without XDG_RUNTIME_DIR) |
| 825 | - | credFile := getInternalCredentialsFile() | 825 | + | // 3. ~/.gordon/run/ (fallback for non-systemd environments) |
| 826 | - | data, err := os.ReadFile(credFile) | 826 | + | // 4. os.TempDir() (last resort, matches getInternalCredentialsFile fallback path) |
| 827 | - | if err != nil { | 827 | + | func getInternalCredentialsCandidates() []string { |
| 828 | - | return nil, fmt.Errorf("failed to read credentials file (is Gordon running?): %w", err) | 828 | + | var candidates []string |
| 829 | + | ||||
| 830 | + | // 1. XDG_RUNTIME_DIR (set in daemon's environment) | |||
| 831 | + | if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" { | |||
| 832 | + | candidates = append(candidates, filepath.Join(runtimeDir, "gordon", "internal-creds.json")) | |||
| 833 | + | } | |||
| 834 | + | ||||
| 835 | + | // 2. /run/user/<uid>/gordon/ (systemd default, may not be in CLI's env) | |||
| 836 | + | uid := os.Getuid() | |||
| 837 | + | sysRuntime := filepath.Join("/run/user", fmt.Sprintf("%d", uid), "gordon", "internal-creds.json") | |||
| 838 | + | // Avoid duplicate if XDG_RUNTIME_DIR already points here | |||
| 839 | + | if len(candidates) == 0 || candidates[0] != sysRuntime { | |||
| 840 | + | candidates = append(candidates, sysRuntime) | |||
| 841 | + | } | |||
| 842 | + | ||||
| 843 | + | // 3. ~/.gordon/run/ fallback | |||
| 844 | + | if homeDir, err := os.UserHomeDir(); err == nil { | |||
| 845 | + | candidates = append(candidates, filepath.Join(homeDir, ".gordon", "run", "internal-creds.json")) | |||
| 846 | + | } | |||
| 847 | + | ||||
| 848 | + | // 4. os.TempDir() last resort — matches the fallback path in getInternalCredentialsFile, | |||
| 849 | + | // ensuring GetInternalCredentials can find credentials even when getSecureRuntimeDir fails. | |||
| 850 | + | candidates = append(candidates, filepath.Join(os.TempDir(), "gordon-internal-creds.json")) | |||
| 851 | + | ||||
| 852 | + | return candidates | |||
| 853 | + | } | |||
| 854 | + | ||||
| 855 | + | // GetInternalCredentialsFromCandidates reads credentials from the first candidate file that exists. | |||
| 856 | + | // Exported for testing. | |||
| 857 | + | func GetInternalCredentialsFromCandidates(candidates []string) (*InternalCredentials, error) { | |||
| 858 | + | var lastErr error | |||
| 859 | + | for _, path := range candidates { | |||
| 860 | + | data, err := os.ReadFile(path) | |||
| 861 | + | if os.IsNotExist(err) { | |||
| 862 | + | continue | |||
| 863 | + | } | |||
| 864 | + | if err != nil { | |||
| 865 | + | // Non-permission errors (e.g. EACCES) may be transient or path-specific; | |||
| 866 | + | // record and try the next candidate rather than failing immediately. | |||
| 867 | + | lastErr = fmt.Errorf("failed to read credentials file %s: %w", path, err) | |||
| 868 | + | continue | |||
| 869 | + | } | |||
| 870 | + | var creds InternalCredentials | |||
| 871 | + | if err := json.Unmarshal(data, &creds); err != nil { | |||
| 872 | + | // Corrupt file — record and fall through to lower-priority candidates. | |||
| 873 | + | lastErr = fmt.Errorf("failed to parse credentials at %s: %w", path, err) | |||
| 874 | + | continue | |||
| 875 | + | } | |||
| 876 | + | return &creds, nil | |||
| 829 | } | 877 | } | ||
| 830 | - | var creds InternalCredentials | 878 | + | if lastErr != nil { |
| 831 | - | if err := json.Unmarshal(data, &creds); err != nil { | 879 | + | return nil, lastErr |
| 832 | - | return nil, fmt.Errorf("failed to parse credentials: %w", err) | |||
| 833 | } | 880 | } | ||
| 834 | - | return &creds, nil | 881 | + | return nil, fmt.Errorf("no credentials file found (is Gordon running?): checked %v", candidates) |
| 882 | + | } | |||
| 883 | + | ||||
| 884 | + | // GetInternalCredentials reads the internal registry credentials from file. | |||
| 885 | + | // Probes all candidate runtime directories so CLI works regardless of whether | |||
| 886 | + | // XDG_RUNTIME_DIR is set in the current shell environment. | |||
| 887 | + | func GetInternalCredentials() (*InternalCredentials, error) { | |||
| 888 | + | return GetInternalCredentialsFromCandidates(getInternalCredentialsCandidates()) | |||
| 835 | } | 889 | } | ||
| 836 | 890 | ||||
| 837 | // createAuthService creates the authentication service and token store. | 891 | // createAuthService creates the authentication service and token store. | ||
| @@ -1645,12 +1699,13 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler | |||||
| 1645 | 1699 | ||||
| 1646 | registrySrv, registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log) | 1700 | registrySrv, registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log) | ||
| 1647 | proxySrv, proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", errChan, log) | 1701 | proxySrv, proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", errChan, log) | ||
| 1702 | + | var tlsSrv *http.Server | |||
| 1648 | var tlsReady <-chan struct{} | 1703 | var tlsReady <-chan struct{} | ||
| 1649 | if cfg.Server.TLSEnabled { | 1704 | if cfg.Server.TLSEnabled { | ||
| 1650 | if cfg.Server.TLSCertFile == "" || cfg.Server.TLSKeyFile == "" { | 1705 | if cfg.Server.TLSCertFile == "" || cfg.Server.TLSKeyFile == "" { | ||
| 1651 | return fmt.Errorf("server.tls_enabled=true requires both server.tls_cert_file and server.tls_key_file") | 1706 | return fmt.Errorf("server.tls_enabled=true requires both server.tls_cert_file and server.tls_key_file") | ||
| 1652 | } | 1707 | } | ||
| 1653 | - | tlsReady = startTLSServer( | 1708 | + | tlsSrv, tlsReady = startTLSServer( |
| 1654 | fmt.Sprintf(":%d", cfg.Server.TLSPort), | 1709 | fmt.Sprintf(":%d", cfg.Server.TLSPort), | ||
| 1655 | proxyHandler, | 1710 | proxyHandler, | ||
| 1656 | "proxy-tls", | 1711 | "proxy-tls", | ||
| @@ -1695,7 +1750,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler | |||||
| 1695 | syncAndAutoStart(ctx, svc, log) | 1750 | syncAndAutoStart(ctx, svc, log) | ||
| 1696 | 1751 | ||||
| 1697 | waitForShutdown(ctx, errChan, reloadChan, deployChan, eventBus, log) | 1752 | waitForShutdown(ctx, errChan, reloadChan, deployChan, eventBus, log) | ||
| 1698 | - | gracefulShutdown(registrySrv, proxySrv, containerSvc, log) | 1753 | + | gracefulShutdown(registrySrv, proxySrv, tlsSrv, containerSvc, log) |
| 1699 | return nil | 1754 | return nil | ||
| 1700 | } | 1755 | } | ||
| 1701 | 1756 | ||||
| @@ -1895,17 +1950,19 @@ func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, depl | |||||
| 1895 | 1950 | ||||
| 1896 | // gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down | 1951 | // gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down | ||
| 1897 | // the container service and cleans up runtime files. | 1952 | // the container service and cleans up runtime files. | ||
| 1898 | - | func gracefulShutdown(registrySrv, proxySrv *http.Server, containerSvc *container.Service, log zerowrap.Logger) { | 1953 | + | func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *container.Service, log zerowrap.Logger) { |
| 1899 | log.Info().Msg("shutting down Gordon...") | 1954 | log.Info().Msg("shutting down Gordon...") | ||
| 1900 | 1955 | ||||
| 1901 | shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) | 1956 | shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) | ||
| 1902 | defer shutdownCancel() | 1957 | defer shutdownCancel() | ||
| 1903 | 1958 | ||||
| 1904 | - | if err := registrySrv.Shutdown(shutdownCtx); err != nil { | 1959 | + | for _, srv := range []*http.Server{registrySrv, proxySrv, tlsSrv} { |
| 1905 | - | log.Warn().Err(err).Msg("registry server shutdown error") | 1960 | + | if srv == nil { |
| 1906 | - | } | 1961 | + | continue |
| 1907 | - | if err := proxySrv.Shutdown(shutdownCtx); err != nil { | 1962 | + | } |
| 1908 | - | log.Warn().Err(err).Msg("proxy server shutdown error") | 1963 | + | if err := srv.Shutdown(shutdownCtx); err != nil { |
| 1964 | + | log.Warn().Err(err).Str("addr", srv.Addr).Msg("server shutdown error") | |||
| 1965 | + | } | |||
| 1909 | } | 1966 | } | ||
| 1910 | 1967 | ||||
| 1911 | containerSvc.StopMonitor() | 1968 | containerSvc.StopMonitor() | ||
| @@ -1955,9 +2012,20 @@ func startServer(addr string, handler http.Handler, name string, errChan chan<- | |||||
| 1955 | } | 2012 | } | ||
| 1956 | 2013 | ||||
| 1957 | // startTLSServer starts an HTTPS server with the provided certificate and key. | 2014 | // startTLSServer starts an HTTPS server with the provided certificate and key. | ||
| 1958 | - | func startTLSServer(addr string, handler http.Handler, name, certFile, keyFile string, errChan chan<- error, log zerowrap.Logger) <-chan struct{} { | 2015 | + | // It returns the server instance (for graceful shutdown) and a channel that closes once the port is bound. |
| 2016 | + | func startTLSServer(addr string, handler http.Handler, name, certFile, keyFile string, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}) { | |||
| 1959 | ready := make(chan struct{}) | 2017 | ready := make(chan struct{}) | ||
| 1960 | 2018 | ||||
| 2019 | + | server := &http.Server{ | |||
| 2020 | + | Addr: addr, | |||
| 2021 | + | Handler: handler, | |||
| 2022 | + | ReadHeaderTimeout: 10 * time.Second, | |||
| 2023 | + | ReadTimeout: 5 * time.Minute, | |||
| 2024 | + | WriteTimeout: 5 * time.Minute, | |||
| 2025 | + | IdleTimeout: 120 * time.Second, | |||
| 2026 | + | MaxHeaderBytes: 1 << 20, | |||
| 2027 | + | } | |||
| 2028 | + | ||||
| 1961 | go func() { | 2029 | go func() { | ||
| 1962 | log.Info(). | 2030 | log.Info(). | ||
| 1963 | Str("address", addr). | 2031 | Str("address", addr). | ||
| @@ -1965,16 +2033,6 @@ func startTLSServer(addr string, handler http.Handler, name, certFile, keyFile s | |||||
| 1965 | Str("key_file", keyFile). | 2033 | Str("key_file", keyFile). | ||
| 1966 | Msgf("%s server starting", name) | 2034 | Msgf("%s server starting", name) | ||
| 1967 | 2035 | ||||
| 1968 | - | server := &http.Server{ | |||
| 1969 | - | Addr: addr, | |||
| 1970 | - | Handler: handler, | |||
| 1971 | - | ReadHeaderTimeout: 10 * time.Second, | |||
| 1972 | - | ReadTimeout: 5 * time.Minute, | |||
| 1973 | - | WriteTimeout: 5 * time.Minute, | |||
| 1974 | - | IdleTimeout: 120 * time.Second, | |||
| 1975 | - | MaxHeaderBytes: 1 << 20, | |||
| 1976 | - | } | |||
| 1977 | - | ||||
| 1978 | ln, err := net.Listen("tcp", addr) | 2036 | ln, err := net.Listen("tcp", addr) | ||
| 1979 | if err != nil { | 2037 | if err != nil { | ||
| 1980 | errChan <- fmt.Errorf("%s server error: %w", name, err) | 2038 | errChan <- fmt.Errorf("%s server error: %w", name, err) | ||
| @@ -1987,7 +2045,7 @@ func startTLSServer(addr string, handler http.Handler, name, certFile, keyFile s | |||||
| 1987 | } | 2045 | } | ||
| 1988 | }() | 2046 | }() | ||
| 1989 | 2047 | ||||
| 1990 | - | return ready | 2048 | + | return server, ready |
| 1991 | } | 2049 | } | ||
| 1992 | 2050 | ||||
| 1993 | // SendReloadSignal sends SIGUSR1 to the running Gordon process. | 2051 | // SendReloadSignal sends SIGUSR1 to the running Gordon process. | ||
A internal/app/run_internal_creds_test.go +78 -0
| @@ -0,0 +1,78 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestGetInternalCredentialsFindsRuntimeFile(t *testing.T) { | |
| 10 | + | // Simulate daemon writing to XDG_RUNTIME_DIR/gordon/ while | |
| 11 | + | // CLI has no XDG_RUNTIME_DIR set. | |
| 12 | + | tmpRuntime := t.TempDir() | |
| 13 | + | gordonRuntime := filepath.Join(tmpRuntime, "gordon") | |
| 14 | + | if err := os.MkdirAll(gordonRuntime, 0700); err != nil { | |
| 15 | + | t.Fatal(err) | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | // Write "live" creds to the runtime dir (as daemon would) | |
| 19 | + | liveCreds := `{"username":"gordon-internal","password":"livepassword"}` | |
| 20 | + | if err := os.WriteFile(filepath.Join(gordonRuntime, "internal-creds.json"), []byte(liveCreds), 0600); err != nil { | |
| 21 | + | t.Fatal(err) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // Also write a stale creds file to home fallback | |
| 25 | + | tmpHome := t.TempDir() | |
| 26 | + | gordonHome := filepath.Join(tmpHome, ".gordon", "run") | |
| 27 | + | if err := os.MkdirAll(gordonHome, 0700); err != nil { | |
| 28 | + | t.Fatal(err) | |
| 29 | + | } | |
| 30 | + | staleCreds := `{"username":"gordon-internal","password":"stalepassword"}` | |
| 31 | + | if err := os.WriteFile(filepath.Join(gordonHome, "internal-creds.json"), []byte(staleCreds), 0600); err != nil { | |
| 32 | + | t.Fatal(err) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | creds, err := GetInternalCredentialsFromCandidates([]string{ | |
| 36 | + | filepath.Join(gordonRuntime, "internal-creds.json"), | |
| 37 | + | filepath.Join(gordonHome, "internal-creds.json"), | |
| 38 | + | }) | |
| 39 | + | if err != nil { | |
| 40 | + | t.Fatalf("unexpected error: %v", err) | |
| 41 | + | } | |
| 42 | + | if creds.Password != "livepassword" { | |
| 43 | + | t.Errorf("got password %q, want %q", creds.Password, "livepassword") | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestGetInternalCredentialsFallsBackToStale(t *testing.T) { | |
| 48 | + | // If only the stale/home file exists, it should still work | |
| 49 | + | tmpHome := t.TempDir() | |
| 50 | + | gordonHome := filepath.Join(tmpHome, ".gordon", "run") | |
| 51 | + | if err := os.MkdirAll(gordonHome, 0700); err != nil { | |
| 52 | + | t.Fatal(err) | |
| 53 | + | } | |
| 54 | + | staleCreds := `{"username":"gordon-internal","password":"stalepassword"}` | |
| 55 | + | if err := os.WriteFile(filepath.Join(gordonHome, "internal-creds.json"), []byte(staleCreds), 0600); err != nil { | |
| 56 | + | t.Fatal(err) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | creds, err := GetInternalCredentialsFromCandidates([]string{ | |
| 60 | + | filepath.Join(t.TempDir(), "gordon", "internal-creds.json"), // doesn't exist | |
| 61 | + | filepath.Join(gordonHome, "internal-creds.json"), | |
| 62 | + | }) | |
| 63 | + | if err != nil { | |
| 64 | + | t.Fatalf("unexpected error: %v", err) | |
| 65 | + | } | |
| 66 | + | if creds.Password != "stalepassword" { | |
| 67 | + | t.Errorf("got password %q, want %q", creds.Password, "stalepassword") | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func TestGetInternalCredentialsNoCandidatesReturnsError(t *testing.T) { | |
| 72 | + | _, err := GetInternalCredentialsFromCandidates([]string{ | |
| 73 | + | filepath.Join(t.TempDir(), "nonexistent", "internal-creds.json"), | |
| 74 | + | }) | |
| 75 | + | if err == nil { | |
| 76 | + | t.Fatal("expected error when no credentials found, got nil") | |
| 77 | + | } | |
| 78 | + | } | |
| @@ -0,0 +1,78 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestGetInternalCredentialsFindsRuntimeFile(t *testing.T) { | |||
| 10 | + | // Simulate daemon writing to XDG_RUNTIME_DIR/gordon/ while | |||
| 11 | + | // CLI has no XDG_RUNTIME_DIR set. | |||
| 12 | + | tmpRuntime := t.TempDir() | |||
| 13 | + | gordonRuntime := filepath.Join(tmpRuntime, "gordon") | |||
| 14 | + | if err := os.MkdirAll(gordonRuntime, 0700); err != nil { | |||
| 15 | + | t.Fatal(err) | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | // Write "live" creds to the runtime dir (as daemon would) | |||
| 19 | + | liveCreds := `{"username":"gordon-internal","password":"livepassword"}` | |||
| 20 | + | if err := os.WriteFile(filepath.Join(gordonRuntime, "internal-creds.json"), []byte(liveCreds), 0600); err != nil { | |||
| 21 | + | t.Fatal(err) | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // Also write a stale creds file to home fallback | |||
| 25 | + | tmpHome := t.TempDir() | |||
| 26 | + | gordonHome := filepath.Join(tmpHome, ".gordon", "run") | |||
| 27 | + | if err := os.MkdirAll(gordonHome, 0700); err != nil { | |||
| 28 | + | t.Fatal(err) | |||
| 29 | + | } | |||
| 30 | + | staleCreds := `{"username":"gordon-internal","password":"stalepassword"}` | |||
| 31 | + | if err := os.WriteFile(filepath.Join(gordonHome, "internal-creds.json"), []byte(staleCreds), 0600); err != nil { | |||
| 32 | + | t.Fatal(err) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | creds, err := GetInternalCredentialsFromCandidates([]string{ | |||
| 36 | + | filepath.Join(gordonRuntime, "internal-creds.json"), | |||
| 37 | + | filepath.Join(gordonHome, "internal-creds.json"), | |||
| 38 | + | }) | |||
| 39 | + | if err != nil { | |||
| 40 | + | t.Fatalf("unexpected error: %v", err) | |||
| 41 | + | } | |||
| 42 | + | if creds.Password != "livepassword" { | |||
| 43 | + | t.Errorf("got password %q, want %q", creds.Password, "livepassword") | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func TestGetInternalCredentialsFallsBackToStale(t *testing.T) { | |||
| 48 | + | // If only the stale/home file exists, it should still work | |||
| 49 | + | tmpHome := t.TempDir() | |||
| 50 | + | gordonHome := filepath.Join(tmpHome, ".gordon", "run") | |||
| 51 | + | if err := os.MkdirAll(gordonHome, 0700); err != nil { | |||
| 52 | + | t.Fatal(err) | |||
| 53 | + | } | |||
| 54 | + | staleCreds := `{"username":"gordon-internal","password":"stalepassword"}` | |||
| 55 | + | if err := os.WriteFile(filepath.Join(gordonHome, "internal-creds.json"), []byte(staleCreds), 0600); err != nil { | |||
| 56 | + | t.Fatal(err) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | creds, err := GetInternalCredentialsFromCandidates([]string{ | |||
| 60 | + | filepath.Join(t.TempDir(), "gordon", "internal-creds.json"), // doesn't exist | |||
| 61 | + | filepath.Join(gordonHome, "internal-creds.json"), | |||
| 62 | + | }) | |||
| 63 | + | if err != nil { | |||
| 64 | + | t.Fatalf("unexpected error: %v", err) | |||
| 65 | + | } | |||
| 66 | + | if creds.Password != "stalepassword" { | |||
| 67 | + | t.Errorf("got password %q, want %q", creds.Password, "stalepassword") | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func TestGetInternalCredentialsNoCandidatesReturnsError(t *testing.T) { | |||
| 72 | + | _, err := GetInternalCredentialsFromCandidates([]string{ | |||
| 73 | + | filepath.Join(t.TempDir(), "nonexistent", "internal-creds.json"), | |||
| 74 | + | }) | |||
| 75 | + | if err == nil { | |||
| 76 | + | t.Fatal("expected error when no credentials found, got nil") | |||
| 77 | + | } | |||
| 78 | + | } | |||
M internal/boundaries/in/auth.go +5 -0
| @@ -46,4 +46,9 @@ type AuthService interface { | |||
| 46 | 46 | // GetAuthStatus returns authentication status from context. | |
| 47 | 47 | // Extracts and validates token claims that were set by auth middleware. | |
| 48 | 48 | GetAuthStatus(ctx context.Context) (*domain.AuthStatus, error) | |
| 49 | + | ||
| 50 | + | // ExtendToken re-issues the token with expiry slid forward by 24h. | |
| 51 | + | // Returns the same token string if it was already extended within the debounce window (1h). | |
| 52 | + | // Skips extension for ephemeral access tokens (≤5min) and service tokens. | |
| 53 | + | ExtendToken(ctx context.Context, tokenString string) (string, error) | |
| 49 | 54 | } | |
| @@ -46,4 +46,9 @@ type AuthService interface { | |||||
| 46 | // GetAuthStatus returns authentication status from context. | 46 | // GetAuthStatus returns authentication status from context. | ||
| 47 | // Extracts and validates token claims that were set by auth middleware. | 47 | // Extracts and validates token claims that were set by auth middleware. | ||
| 48 | GetAuthStatus(ctx context.Context) (*domain.AuthStatus, error) | 48 | GetAuthStatus(ctx context.Context) (*domain.AuthStatus, error) | ||
| 49 | + | ||||
| 50 | + | // ExtendToken re-issues the token with expiry slid forward by 24h. | |||
| 51 | + | // Returns the same token string if it was already extended within the debounce window (1h). | |||
| 52 | + | // Skips extension for ephemeral access tokens (≤5min) and service tokens. | |||
| 53 | + | ExtendToken(ctx context.Context, tokenString string) (string, error) | |||
| 49 | } | 54 | } | ||
M internal/boundaries/in/container.go +4 -0
| @@ -45,6 +45,10 @@ type ContainerService interface { | |||
| 45 | 45 | // SyncContainers synchronizes containers with configured routes. | |
| 46 | 46 | SyncContainers(ctx context.Context) error | |
| 47 | 47 | ||
| 48 | + | // UpdateAttachments updates the attachment configuration in the container service. | |
| 49 | + | // This is called after a config reload to propagate attachment changes without restart. | |
| 50 | + | UpdateAttachments(attachments map[string][]string) | |
| 51 | + | ||
| 48 | 52 | // AutoStart starts containers for the provided routes that aren't running. | |
| 49 | 53 | AutoStart(ctx context.Context, routes []domain.Route) error | |
| 50 | 54 | ||
| @@ -45,6 +45,10 @@ type ContainerService interface { | |||||
| 45 | // SyncContainers synchronizes containers with configured routes. | 45 | // SyncContainers synchronizes containers with configured routes. | ||
| 46 | SyncContainers(ctx context.Context) error | 46 | SyncContainers(ctx context.Context) error | ||
| 47 | 47 | ||||
| 48 | + | // UpdateAttachments updates the attachment configuration in the container service. | |||
| 49 | + | // This is called after a config reload to propagate attachment changes without restart. | |||
| 50 | + | UpdateAttachments(attachments map[string][]string) | |||
| 51 | + | ||||
| 48 | // AutoStart starts containers for the provided routes that aren't running. | 52 | // AutoStart starts containers for the provided routes that aren't running. | ||
| 49 | AutoStart(ctx context.Context, routes []domain.Route) error | 53 | AutoStart(ctx context.Context, routes []domain.Route) error | ||
| 50 | 54 | ||||
M internal/boundaries/in/mocks/mock_auth_service.go +66 -0
| @@ -39,6 +39,72 @@ func (_m *MockAuthService) EXPECT() *MockAuthService_Expecter { | |||
| 39 | 39 | return &MockAuthService_Expecter{mock: &_m.Mock} | |
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | + | // ExtendToken provides a mock function for the type MockAuthService | |
| 43 | + | func (_mock *MockAuthService) ExtendToken(ctx context.Context, tokenString string) (string, error) { | |
| 44 | + | ret := _mock.Called(ctx, tokenString) | |
| 45 | + | ||
| 46 | + | if len(ret) == 0 { | |
| 47 | + | panic("no return value specified for ExtendToken") | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | var r0 string | |
| 51 | + | var r1 error | |
| 52 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, error)); ok { | |
| 53 | + | return returnFunc(ctx, tokenString) | |
| 54 | + | } | |
| 55 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok { | |
| 56 | + | r0 = returnFunc(ctx, tokenString) | |
| 57 | + | } else { | |
| 58 | + | r0 = ret.Get(0).(string) | |
| 59 | + | } | |
| 60 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 61 | + | r1 = returnFunc(ctx, tokenString) | |
| 62 | + | } else { | |
| 63 | + | r1 = ret.Error(1) | |
| 64 | + | } | |
| 65 | + | return r0, r1 | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // MockAuthService_ExtendToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ExtendToken' | |
| 69 | + | type MockAuthService_ExtendToken_Call struct { | |
| 70 | + | *mock.Call | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // ExtendToken is a helper method to define mock.On call | |
| 74 | + | // - ctx context.Context | |
| 75 | + | // - tokenString string | |
| 76 | + | func (_e *MockAuthService_Expecter) ExtendToken(ctx interface{}, tokenString interface{}) *MockAuthService_ExtendToken_Call { | |
| 77 | + | return &MockAuthService_ExtendToken_Call{Call: _e.mock.On("ExtendToken", ctx, tokenString)} | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func (_c *MockAuthService_ExtendToken_Call) Run(run func(ctx context.Context, tokenString string)) *MockAuthService_ExtendToken_Call { | |
| 81 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 82 | + | var arg0 context.Context | |
| 83 | + | if args[0] != nil { | |
| 84 | + | arg0 = args[0].(context.Context) | |
| 85 | + | } | |
| 86 | + | var arg1 string | |
| 87 | + | if args[1] != nil { | |
| 88 | + | arg1 = args[1].(string) | |
| 89 | + | } | |
| 90 | + | run( | |
| 91 | + | arg0, | |
| 92 | + | arg1, | |
| 93 | + | ) | |
| 94 | + | }) | |
| 95 | + | return _c | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func (_c *MockAuthService_ExtendToken_Call) Return(s string, err error) *MockAuthService_ExtendToken_Call { | |
| 99 | + | _c.Call.Return(s, err) | |
| 100 | + | return _c | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func (_c *MockAuthService_ExtendToken_Call) RunAndReturn(run func(ctx context.Context, tokenString string) (string, error)) *MockAuthService_ExtendToken_Call { | |
| 104 | + | _c.Call.Return(run) | |
| 105 | + | return _c | |
| 106 | + | } | |
| 107 | + | ||
| 42 | 108 | // GenerateAccessToken provides a mock function for the type MockAuthService | |
| 43 | 109 | func (_mock *MockAuthService) GenerateAccessToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) { | |
| 44 | 110 | ret := _mock.Called(ctx, subject, scopes, expiry) | |
| @@ -39,6 +39,72 @@ func (_m *MockAuthService) EXPECT() *MockAuthService_Expecter { | |||||
| 39 | return &MockAuthService_Expecter{mock: &_m.Mock} | 39 | return &MockAuthService_Expecter{mock: &_m.Mock} | ||
| 40 | } | 40 | } | ||
| 41 | 41 | ||||
| 42 | + | // ExtendToken provides a mock function for the type MockAuthService | |||
| 43 | + | func (_mock *MockAuthService) ExtendToken(ctx context.Context, tokenString string) (string, error) { | |||
| 44 | + | ret := _mock.Called(ctx, tokenString) | |||
| 45 | + | ||||
| 46 | + | if len(ret) == 0 { | |||
| 47 | + | panic("no return value specified for ExtendToken") | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | var r0 string | |||
| 51 | + | var r1 error | |||
| 52 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, error)); ok { | |||
| 53 | + | return returnFunc(ctx, tokenString) | |||
| 54 | + | } | |||
| 55 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok { | |||
| 56 | + | r0 = returnFunc(ctx, tokenString) | |||
| 57 | + | } else { | |||
| 58 | + | r0 = ret.Get(0).(string) | |||
| 59 | + | } | |||
| 60 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |||
| 61 | + | r1 = returnFunc(ctx, tokenString) | |||
| 62 | + | } else { | |||
| 63 | + | r1 = ret.Error(1) | |||
| 64 | + | } | |||
| 65 | + | return r0, r1 | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // MockAuthService_ExtendToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ExtendToken' | |||
| 69 | + | type MockAuthService_ExtendToken_Call struct { | |||
| 70 | + | *mock.Call | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // ExtendToken is a helper method to define mock.On call | |||
| 74 | + | // - ctx context.Context | |||
| 75 | + | // - tokenString string | |||
| 76 | + | func (_e *MockAuthService_Expecter) ExtendToken(ctx interface{}, tokenString interface{}) *MockAuthService_ExtendToken_Call { | |||
| 77 | + | return &MockAuthService_ExtendToken_Call{Call: _e.mock.On("ExtendToken", ctx, tokenString)} | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func (_c *MockAuthService_ExtendToken_Call) Run(run func(ctx context.Context, tokenString string)) *MockAuthService_ExtendToken_Call { | |||
| 81 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 82 | + | var arg0 context.Context | |||
| 83 | + | if args[0] != nil { | |||
| 84 | + | arg0 = args[0].(context.Context) | |||
| 85 | + | } | |||
| 86 | + | var arg1 string | |||
| 87 | + | if args[1] != nil { | |||
| 88 | + | arg1 = args[1].(string) | |||
| 89 | + | } | |||
| 90 | + | run( | |||
| 91 | + | arg0, | |||
| 92 | + | arg1, | |||
| 93 | + | ) | |||
| 94 | + | }) | |||
| 95 | + | return _c | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func (_c *MockAuthService_ExtendToken_Call) Return(s string, err error) *MockAuthService_ExtendToken_Call { | |||
| 99 | + | _c.Call.Return(s, err) | |||
| 100 | + | return _c | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func (_c *MockAuthService_ExtendToken_Call) RunAndReturn(run func(ctx context.Context, tokenString string) (string, error)) *MockAuthService_ExtendToken_Call { | |||
| 104 | + | _c.Call.Return(run) | |||
| 105 | + | return _c | |||
| 106 | + | } | |||
| 107 | + | ||||
| 42 | // GenerateAccessToken provides a mock function for the type MockAuthService | 108 | // GenerateAccessToken provides a mock function for the type MockAuthService | ||
| 43 | func (_mock *MockAuthService) GenerateAccessToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) { | 109 | func (_mock *MockAuthService) GenerateAccessToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) { | ||
| 44 | ret := _mock.Called(ctx, subject, scopes, expiry) | 110 | ret := _mock.Called(ctx, subject, scopes, expiry) | ||
M internal/boundaries/in/mocks/mock_container_service.go +40 -0
| @@ -795,3 +795,43 @@ func (_c *MockContainerService_SyncContainers_Call) RunAndReturn(run func(ctx co | |||
| 795 | 795 | _c.Call.Return(run) | |
| 796 | 796 | return _c | |
| 797 | 797 | } | |
| 798 | + | ||
| 799 | + | // UpdateAttachments provides a mock function for the type MockContainerService | |
| 800 | + | func (_mock *MockContainerService) UpdateAttachments(attachments map[string][]string) { | |
| 801 | + | _mock.Called(attachments) | |
| 802 | + | return | |
| 803 | + | } | |
| 804 | + | ||
| 805 | + | // MockContainerService_UpdateAttachments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateAttachments' | |
| 806 | + | type MockContainerService_UpdateAttachments_Call struct { | |
| 807 | + | *mock.Call | |
| 808 | + | } | |
| 809 | + | ||
| 810 | + | // UpdateAttachments is a helper method to define mock.On call | |
| 811 | + | // - attachments map[string][]string | |
| 812 | + | func (_e *MockContainerService_Expecter) UpdateAttachments(attachments interface{}) *MockContainerService_UpdateAttachments_Call { | |
| 813 | + | return &MockContainerService_UpdateAttachments_Call{Call: _e.mock.On("UpdateAttachments", attachments)} | |
| 814 | + | } | |
| 815 | + | ||
| 816 | + | func (_c *MockContainerService_UpdateAttachments_Call) Run(run func(attachments map[string][]string)) *MockContainerService_UpdateAttachments_Call { | |
| 817 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 818 | + | var arg0 map[string][]string | |
| 819 | + | if args[0] != nil { | |
| 820 | + | arg0 = args[0].(map[string][]string) | |
| 821 | + | } | |
| 822 | + | run( | |
| 823 | + | arg0, | |
| 824 | + | ) | |
| 825 | + | }) | |
| 826 | + | return _c | |
| 827 | + | } | |
| 828 | + | ||
| 829 | + | func (_c *MockContainerService_UpdateAttachments_Call) Return() *MockContainerService_UpdateAttachments_Call { | |
| 830 | + | _c.Call.Return() | |
| 831 | + | return _c | |
| 832 | + | } | |
| 833 | + | ||
| 834 | + | func (_c *MockContainerService_UpdateAttachments_Call) RunAndReturn(run func(attachments map[string][]string)) *MockContainerService_UpdateAttachments_Call { | |
| 835 | + | _c.Run(run) | |
| 836 | + | return _c | |
| 837 | + | } | |
| @@ -795,3 +795,43 @@ func (_c *MockContainerService_SyncContainers_Call) RunAndReturn(run func(ctx co | |||||
| 795 | _c.Call.Return(run) | 795 | _c.Call.Return(run) | ||
| 796 | return _c | 796 | return _c | ||
| 797 | } | 797 | } | ||
| 798 | + | ||||
| 799 | + | // UpdateAttachments provides a mock function for the type MockContainerService | |||
| 800 | + | func (_mock *MockContainerService) UpdateAttachments(attachments map[string][]string) { | |||
| 801 | + | _mock.Called(attachments) | |||
| 802 | + | return | |||
| 803 | + | } | |||
| 804 | + | ||||
| 805 | + | // MockContainerService_UpdateAttachments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateAttachments' | |||
| 806 | + | type MockContainerService_UpdateAttachments_Call struct { | |||
| 807 | + | *mock.Call | |||
| 808 | + | } | |||
| 809 | + | ||||
| 810 | + | // UpdateAttachments is a helper method to define mock.On call | |||
| 811 | + | // - attachments map[string][]string | |||
| 812 | + | func (_e *MockContainerService_Expecter) UpdateAttachments(attachments interface{}) *MockContainerService_UpdateAttachments_Call { | |||
| 813 | + | return &MockContainerService_UpdateAttachments_Call{Call: _e.mock.On("UpdateAttachments", attachments)} | |||
| 814 | + | } | |||
| 815 | + | ||||
| 816 | + | func (_c *MockContainerService_UpdateAttachments_Call) Run(run func(attachments map[string][]string)) *MockContainerService_UpdateAttachments_Call { | |||
| 817 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 818 | + | var arg0 map[string][]string | |||
| 819 | + | if args[0] != nil { | |||
| 820 | + | arg0 = args[0].(map[string][]string) | |||
| 821 | + | } | |||
| 822 | + | run( | |||
| 823 | + | arg0, | |||
| 824 | + | ) | |||
| 825 | + | }) | |||
| 826 | + | return _c | |||
| 827 | + | } | |||
| 828 | + | ||||
| 829 | + | func (_c *MockContainerService_UpdateAttachments_Call) Return() *MockContainerService_UpdateAttachments_Call { | |||
| 830 | + | _c.Call.Return() | |||
| 831 | + | return _c | |||
| 832 | + | } | |||
| 833 | + | ||||
| 834 | + | func (_c *MockContainerService_UpdateAttachments_Call) RunAndReturn(run func(attachments map[string][]string)) *MockContainerService_UpdateAttachments_Call { | |||
| 835 | + | _c.Run(run) | |||
| 836 | + | return _c | |||
| 837 | + | } | |||
M internal/boundaries/out/mocks/mock_token_store.go +63 -0
| @@ -416,3 +416,66 @@ func (_c *MockTokenStore_SaveToken_Call) RunAndReturn(run func(ctx context.Conte | |||
| 416 | 416 | _c.Call.Return(run) | |
| 417 | 417 | return _c | |
| 418 | 418 | } | |
| 419 | + | ||
| 420 | + | // UpdateTokenExpiry provides a mock function for the type MockTokenStore | |
| 421 | + | func (_mock *MockTokenStore) UpdateTokenExpiry(ctx context.Context, token *domain.Token, newJWT string) error { | |
| 422 | + | ret := _mock.Called(ctx, token, newJWT) | |
| 423 | + | ||
| 424 | + | if len(ret) == 0 { | |
| 425 | + | panic("no return value specified for UpdateTokenExpiry") | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | var r0 error | |
| 429 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, *domain.Token, string) error); ok { | |
| 430 | + | r0 = returnFunc(ctx, token, newJWT) | |
| 431 | + | } else { | |
| 432 | + | r0 = ret.Error(0) | |
| 433 | + | } | |
| 434 | + | return r0 | |
| 435 | + | } | |
| 436 | + | ||
| 437 | + | // MockTokenStore_UpdateTokenExpiry_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateTokenExpiry' | |
| 438 | + | type MockTokenStore_UpdateTokenExpiry_Call struct { | |
| 439 | + | *mock.Call | |
| 440 | + | } | |
| 441 | + | ||
| 442 | + | // UpdateTokenExpiry is a helper method to define mock.On call | |
| 443 | + | // - ctx context.Context | |
| 444 | + | // - token *domain.Token | |
| 445 | + | // - newJWT string | |
| 446 | + | func (_e *MockTokenStore_Expecter) UpdateTokenExpiry(ctx interface{}, token interface{}, newJWT interface{}) *MockTokenStore_UpdateTokenExpiry_Call { | |
| 447 | + | return &MockTokenStore_UpdateTokenExpiry_Call{Call: _e.mock.On("UpdateTokenExpiry", ctx, token, newJWT)} | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | func (_c *MockTokenStore_UpdateTokenExpiry_Call) Run(run func(ctx context.Context, token *domain.Token, newJWT string)) *MockTokenStore_UpdateTokenExpiry_Call { | |
| 451 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 452 | + | var arg0 context.Context | |
| 453 | + | if args[0] != nil { | |
| 454 | + | arg0 = args[0].(context.Context) | |
| 455 | + | } | |
| 456 | + | var arg1 *domain.Token | |
| 457 | + | if args[1] != nil { | |
| 458 | + | arg1 = args[1].(*domain.Token) | |
| 459 | + | } | |
| 460 | + | var arg2 string | |
| 461 | + | if args[2] != nil { | |
| 462 | + | arg2 = args[2].(string) | |
| 463 | + | } | |
| 464 | + | run( | |
| 465 | + | arg0, | |
| 466 | + | arg1, | |
| 467 | + | arg2, | |
| 468 | + | ) | |
| 469 | + | }) | |
| 470 | + | return _c | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | func (_c *MockTokenStore_UpdateTokenExpiry_Call) Return(err error) *MockTokenStore_UpdateTokenExpiry_Call { | |
| 474 | + | _c.Call.Return(err) | |
| 475 | + | return _c | |
| 476 | + | } | |
| 477 | + | ||
| 478 | + | func (_c *MockTokenStore_UpdateTokenExpiry_Call) RunAndReturn(run func(ctx context.Context, token *domain.Token, newJWT string) error) *MockTokenStore_UpdateTokenExpiry_Call { | |
| 479 | + | _c.Call.Return(run) | |
| 480 | + | return _c | |
| 481 | + | } | |
| @@ -416,3 +416,66 @@ func (_c *MockTokenStore_SaveToken_Call) RunAndReturn(run func(ctx context.Conte | |||||
| 416 | _c.Call.Return(run) | 416 | _c.Call.Return(run) | ||
| 417 | return _c | 417 | return _c | ||
| 418 | } | 418 | } | ||
| 419 | + | ||||
| 420 | + | // UpdateTokenExpiry provides a mock function for the type MockTokenStore | |||
| 421 | + | func (_mock *MockTokenStore) UpdateTokenExpiry(ctx context.Context, token *domain.Token, newJWT string) error { | |||
| 422 | + | ret := _mock.Called(ctx, token, newJWT) | |||
| 423 | + | ||||
| 424 | + | if len(ret) == 0 { | |||
| 425 | + | panic("no return value specified for UpdateTokenExpiry") | |||
| 426 | + | } | |||
| 427 | + | ||||
| 428 | + | var r0 error | |||
| 429 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, *domain.Token, string) error); ok { | |||
| 430 | + | r0 = returnFunc(ctx, token, newJWT) | |||
| 431 | + | } else { | |||
| 432 | + | r0 = ret.Error(0) | |||
| 433 | + | } | |||
| 434 | + | return r0 | |||
| 435 | + | } | |||
| 436 | + | ||||
| 437 | + | // MockTokenStore_UpdateTokenExpiry_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateTokenExpiry' | |||
| 438 | + | type MockTokenStore_UpdateTokenExpiry_Call struct { | |||
| 439 | + | *mock.Call | |||
| 440 | + | } | |||
| 441 | + | ||||
| 442 | + | // UpdateTokenExpiry is a helper method to define mock.On call | |||
| 443 | + | // - ctx context.Context | |||
| 444 | + | // - token *domain.Token | |||
| 445 | + | // - newJWT string | |||
| 446 | + | func (_e *MockTokenStore_Expecter) UpdateTokenExpiry(ctx interface{}, token interface{}, newJWT interface{}) *MockTokenStore_UpdateTokenExpiry_Call { | |||
| 447 | + | return &MockTokenStore_UpdateTokenExpiry_Call{Call: _e.mock.On("UpdateTokenExpiry", ctx, token, newJWT)} | |||
| 448 | + | } | |||
| 449 | + | ||||
| 450 | + | func (_c *MockTokenStore_UpdateTokenExpiry_Call) Run(run func(ctx context.Context, token *domain.Token, newJWT string)) *MockTokenStore_UpdateTokenExpiry_Call { | |||
| 451 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 452 | + | var arg0 context.Context | |||
| 453 | + | if args[0] != nil { | |||
| 454 | + | arg0 = args[0].(context.Context) | |||
| 455 | + | } | |||
| 456 | + | var arg1 *domain.Token | |||
| 457 | + | if args[1] != nil { | |||
| 458 | + | arg1 = args[1].(*domain.Token) | |||
| 459 | + | } | |||
| 460 | + | var arg2 string | |||
| 461 | + | if args[2] != nil { | |||
| 462 | + | arg2 = args[2].(string) | |||
| 463 | + | } | |||
| 464 | + | run( | |||
| 465 | + | arg0, | |||
| 466 | + | arg1, | |||
| 467 | + | arg2, | |||
| 468 | + | ) | |||
| 469 | + | }) | |||
| 470 | + | return _c | |||
| 471 | + | } | |||
| 472 | + | ||||
| 473 | + | func (_c *MockTokenStore_UpdateTokenExpiry_Call) Return(err error) *MockTokenStore_UpdateTokenExpiry_Call { | |||
| 474 | + | _c.Call.Return(err) | |||
| 475 | + | return _c | |||
| 476 | + | } | |||
| 477 | + | ||||
| 478 | + | func (_c *MockTokenStore_UpdateTokenExpiry_Call) RunAndReturn(run func(ctx context.Context, token *domain.Token, newJWT string) error) *MockTokenStore_UpdateTokenExpiry_Call { | |||
| 479 | + | _c.Call.Return(run) | |||
| 480 | + | return _c | |||
| 481 | + | } | |||
M internal/boundaries/out/token_store.go +4 -0
| @@ -26,4 +26,8 @@ type TokenStore interface { | |||
| 26 | 26 | ||
| 27 | 27 | // DeleteToken removes token from store. | |
| 28 | 28 | DeleteToken(ctx context.Context, subject string) error | |
| 29 | + | ||
| 30 | + | // UpdateTokenExpiry updates the JWT and expiry/LastExtendedAt metadata for an existing token. | |
| 31 | + | // Used by token sliding expiry to re-sign tokens without changing the JTI. | |
| 32 | + | UpdateTokenExpiry(ctx context.Context, token *domain.Token, newJWT string) error | |
| 29 | 33 | } | |
| @@ -26,4 +26,8 @@ type TokenStore interface { | |||||
| 26 | 26 | ||||
| 27 | // DeleteToken removes token from store. | 27 | // DeleteToken removes token from store. | ||
| 28 | DeleteToken(ctx context.Context, subject string) error | 28 | DeleteToken(ctx context.Context, subject string) error | ||
| 29 | + | ||||
| 30 | + | // UpdateTokenExpiry updates the JWT and expiry/LastExtendedAt metadata for an existing token. | |||
| 31 | + | // Used by token sliding expiry to re-sign tokens without changing the JTI. | |||
| 32 | + | UpdateTokenExpiry(ctx context.Context, token *domain.Token, newJWT string) error | |||
| 29 | } | 33 | } | ||
M internal/domain/auth.go +7 -6
| @@ -20,12 +20,13 @@ const ( | |||
| 20 | 20 | ||
| 21 | 21 | // Token represents a generated authentication token stored in the secrets backend. | |
| 22 | 22 | type Token struct { | |
| 23 | - | ID string | |
| 24 | - | Subject string | |
| 25 | - | Scopes []string | |
| 26 | - | IssuedAt time.Time | |
| 27 | - | ExpiresAt time.Time // Zero value means never expires | |
| 28 | - | Revoked bool | |
| 23 | + | ID string | |
| 24 | + | Subject string | |
| 25 | + | Scopes []string | |
| 26 | + | IssuedAt time.Time | |
| 27 | + | ExpiresAt time.Time // Zero value means never expires | |
| 28 | + | Revoked bool | |
| 29 | + | LastExtendedAt time.Time // Zero value means never extended | |
| 29 | 30 | } | |
| 30 | 31 | ||
| 31 | 32 | // TokenClaims represents the JWT claims for a token. | |
| @@ -20,12 +20,13 @@ const ( | |||||
| 20 | 20 | ||||
| 21 | // Token represents a generated authentication token stored in the secrets backend. | 21 | // Token represents a generated authentication token stored in the secrets backend. | ||
| 22 | type Token struct { | 22 | type Token struct { | ||
| 23 | - | ID string | 23 | + | ID string |
| 24 | - | Subject string | 24 | + | Subject string |
| 25 | - | Scopes []string | 25 | + | Scopes []string |
| 26 | - | IssuedAt time.Time | 26 | + | IssuedAt time.Time |
| 27 | - | ExpiresAt time.Time // Zero value means never expires | 27 | + | ExpiresAt time.Time // Zero value means never expires |
| 28 | - | Revoked bool | 28 | + | Revoked bool |
| 29 | + | LastExtendedAt time.Time // Zero value means never extended | |||
| 29 | } | 30 | } | ||
| 30 | 31 | ||||
| 31 | // TokenClaims represents the JWT claims for a token. | 32 | // TokenClaims represents the JWT claims for a token. | ||
A internal/domain/labels_test.go +31 -0
| @@ -0,0 +1,31 @@ | |||
| 1 | + | package domain_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/gordon/internal/domain" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // TestLabelConstantsValues guards against accidental value changes that | |
| 10 | + | // would silently break container discovery. | |
| 11 | + | func TestLabelConstantsValues(t *testing.T) { | |
| 12 | + | tests := []struct { | |
| 13 | + | constant string | |
| 14 | + | expected string | |
| 15 | + | }{ | |
| 16 | + | {domain.LabelDomain, "gordon.domain"}, | |
| 17 | + | {domain.LabelImage, "gordon.image"}, | |
| 18 | + | {domain.LabelManaged, "gordon.managed"}, | |
| 19 | + | {domain.LabelRoute, "gordon.route"}, | |
| 20 | + | {domain.LabelAttachment, "gordon.attachment"}, | |
| 21 | + | {domain.LabelAttachedTo, "gordon.attached-to"}, | |
| 22 | + | {domain.LabelCreated, "gordon.created"}, | |
| 23 | + | } | |
| 24 | + | for _, tt := range tests { | |
| 25 | + | t.Run(tt.expected, func(t *testing.T) { | |
| 26 | + | if tt.constant != tt.expected { | |
| 27 | + | t.Errorf("constant value changed: got %q, want %q", tt.constant, tt.expected) | |
| 28 | + | } | |
| 29 | + | }) | |
| 30 | + | } | |
| 31 | + | } | |
| @@ -0,0 +1,31 @@ | |||||
| 1 | + | package domain_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/gordon/internal/domain" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // TestLabelConstantsValues guards against accidental value changes that | |||
| 10 | + | // would silently break container discovery. | |||
| 11 | + | func TestLabelConstantsValues(t *testing.T) { | |||
| 12 | + | tests := []struct { | |||
| 13 | + | constant string | |||
| 14 | + | expected string | |||
| 15 | + | }{ | |||
| 16 | + | {domain.LabelDomain, "gordon.domain"}, | |||
| 17 | + | {domain.LabelImage, "gordon.image"}, | |||
| 18 | + | {domain.LabelManaged, "gordon.managed"}, | |||
| 19 | + | {domain.LabelRoute, "gordon.route"}, | |||
| 20 | + | {domain.LabelAttachment, "gordon.attachment"}, | |||
| 21 | + | {domain.LabelAttachedTo, "gordon.attached-to"}, | |||
| 22 | + | {domain.LabelCreated, "gordon.created"}, | |||
| 23 | + | } | |||
| 24 | + | for _, tt := range tests { | |||
| 25 | + | t.Run(tt.expected, func(t *testing.T) { | |||
| 26 | + | if tt.constant != tt.expected { | |||
| 27 | + | t.Errorf("constant value changed: got %q, want %q", tt.constant, tt.expected) | |||
| 28 | + | } | |||
| 29 | + | }) | |||
| 30 | + | } | |||
| 31 | + | } | |||
M internal/usecase/auth/service.go +128 -1
| @@ -27,6 +27,13 @@ const ( | |||
| 27 | 27 | MaxAccessTokenLifetime = 5 * time.Minute | |
| 28 | 28 | // maxAccessTokenLifetimeSecs is MaxAccessTokenLifetime in seconds for JWT comparisons. | |
| 29 | 29 | maxAccessTokenLifetimeSecs = int64(MaxAccessTokenLifetime / time.Second) | |
| 30 | + | // tokenExtensionTTL is the amount of time added to a token's expiry when extended. | |
| 31 | + | tokenExtensionTTL = 24 * time.Hour | |
| 32 | + | // tokenExtensionDebounce is the minimum time between token extensions. | |
| 33 | + | tokenExtensionDebounce = time.Hour | |
| 34 | + | // serviceTokenSubject is the subject used for internal service tokens. | |
| 35 | + | // Service tokens are not extended to avoid churn on the token store. | |
| 36 | + | serviceTokenSubject = "gordon-service" | |
| 30 | 37 | ) | |
| 31 | 38 | ||
| 32 | 39 | // Config holds the authentication configuration. | |
| @@ -161,7 +168,10 @@ func (s *Service) parseTokenClaims(tokenString string) (jwt.MapClaims, error) { | |||
| 161 | 168 | return nil, domain.ErrInvalidToken | |
| 162 | 169 | } | |
| 163 | 170 | return s.config.TokenSecret, nil | |
| 164 | - | }, jwt.WithIssuer(TokenIssuer)) // SECURITY: Enforce issuer validation | |
| 171 | + | }, | |
| 172 | + | jwt.WithIssuer(TokenIssuer), // SECURITY: Enforce issuer validation | |
| 173 | + | jwt.WithIssuedAt(), // SECURITY: Reject tokens with iat in the future | |
| 174 | + | ) | |
| 165 | 175 | if err != nil { | |
| 166 | 176 | return nil, domain.ErrInvalidToken | |
| 167 | 177 | } | |
| @@ -258,6 +268,10 @@ func (s *Service) isEphemeralAccessToken(claims *domain.TokenClaims) bool { | |||
| 258 | 268 | // stolen secrets from creating arbitrary short-lived tokens | |
| 259 | 269 | now := time.Now().UTC().Unix() | |
| 260 | 270 | tokenAge := now - claims.IssuedAt | |
| 271 | + | if tokenAge < 0 { | |
| 272 | + | // iat is in the future — do not classify as ephemeral (defense-in-depth: prevents bypass) | |
| 273 | + | return false | |
| 274 | + | } | |
| 261 | 275 | if tokenAge > maxAccessTokenLifetimeSecs { | |
| 262 | 276 | return false // Old tokens require store validation | |
| 263 | 277 | } | |
| @@ -433,6 +447,119 @@ func (s *Service) ListTokens(ctx context.Context) ([]domain.Token, error) { | |||
| 433 | 447 | return tokens, nil | |
| 434 | 448 | } | |
| 435 | 449 | ||
| 450 | + | // ExtendToken re-issues the token with expiry slid forward by 24h. | |
| 451 | + | // Returns the same token string if it was already extended within the debounce window (1h). | |
| 452 | + | // Skips extension for ephemeral access tokens (≤5min) and service tokens. | |
| 453 | + | func (s *Service) ExtendToken(ctx context.Context, tokenString string) (string, error) { | |
| 454 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 455 | + | zerowrap.FieldLayer: "usecase", | |
| 456 | + | zerowrap.FieldUseCase: "ExtendToken", | |
| 457 | + | }) | |
| 458 | + | log := zerowrap.FromCtx(ctx) | |
| 459 | + | ||
| 460 | + | // Parse claims without store validation first (to check skip conditions cheaply) | |
| 461 | + | rawClaims, err := s.parseTokenClaims(tokenString) | |
| 462 | + | if err != nil { | |
| 463 | + | return "", domain.ErrInvalidToken | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | tokenClaims := buildTokenClaims(rawClaims) | |
| 467 | + | ||
| 468 | + | // Skip ephemeral access tokens (≤5min lifetime) | |
| 469 | + | if s.isEphemeralAccessToken(tokenClaims) { | |
| 470 | + | log.Debug().Msg("skipping extension for ephemeral access token") | |
| 471 | + | return tokenString, nil | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | // Skip service tokens | |
| 475 | + | if tokenClaims.Subject == serviceTokenSubject { | |
| 476 | + | log.Debug().Msg("skipping extension for service token") | |
| 477 | + | return tokenString, nil | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | // Skip tokens with no expiry (permanent tokens must not be silently converted to 24h tokens) | |
| 481 | + | if tokenClaims.ExpiresAt <= 0 { | |
| 482 | + | log.Debug().Str("subject", tokenClaims.Subject).Msg("skipping extension for non-expiring token") | |
| 483 | + | return tokenString, nil | |
| 484 | + | } | |
| 485 | + | ||
| 486 | + | // Full validation including store checks (not expired, not revoked, exists in store) | |
| 487 | + | if _, err := s.ValidateToken(ctx, tokenString); err != nil { | |
| 488 | + | return "", fmt.Errorf("token validation failed: %w", err) | |
| 489 | + | } | |
| 490 | + | ||
| 491 | + | // Fetch stored token to check debounce and get metadata | |
| 492 | + | _, storedToken, err := s.tokenStore.GetToken(ctx, tokenClaims.Subject) | |
| 493 | + | if err != nil { | |
| 494 | + | return "", fmt.Errorf("failed to fetch stored token: %w", err) | |
| 495 | + | } | |
| 496 | + | ||
| 497 | + | // TOCTOU guard: confirm the store's current JTI matches the JWT we validated. | |
| 498 | + | // A mismatch means the token was rotated between ValidateToken and GetToken; | |
| 499 | + | // in that case return the original (still-valid) token without re-signing. | |
| 500 | + | if storedToken.ID != tokenClaims.ID { | |
| 501 | + | log.Debug(). | |
| 502 | + | Str("subject", tokenClaims.Subject). | |
| 503 | + | Str("jwt_jti", tokenClaims.ID). | |
| 504 | + | Str("store_id", storedToken.ID). | |
| 505 | + | Msg("token ID mismatch after rotation, skipping extension") | |
| 506 | + | return tokenString, nil | |
| 507 | + | } | |
| 508 | + | ||
| 509 | + | // Debounce: skip if already extended within the last hour | |
| 510 | + | if !storedToken.LastExtendedAt.IsZero() && time.Since(storedToken.LastExtendedAt) < tokenExtensionDebounce { | |
| 511 | + | log.Debug(). | |
| 512 | + | Str("subject", tokenClaims.Subject). | |
| 513 | + | Time("last_extended_at", storedToken.LastExtendedAt). | |
| 514 | + | Msg("token extension debounced") | |
| 515 | + | return tokenString, nil | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | // Re-sign with same JTI but new expiry | |
| 519 | + | now := time.Now().UTC() | |
| 520 | + | newExpiresAt := now.Add(tokenExtensionTTL) | |
| 521 | + | ||
| 522 | + | newClaims := jwt.MapClaims{ | |
| 523 | + | "jti": tokenClaims.ID, // Reuse existing JTI to avoid invalidating concurrent requests | |
| 524 | + | "sub": tokenClaims.Subject, | |
| 525 | + | "iss": TokenIssuer, | |
| 526 | + | "iat": now.Unix(), // Update to current time (token is being re-issued) | |
| 527 | + | "nbf": now.Unix(), // SECURITY: not-before matches issuance time | |
| 528 | + | "scopes": tokenClaims.Scopes, | |
| 529 | + | "exp": newExpiresAt.Unix(), | |
| 530 | + | } | |
| 531 | + | ||
| 532 | + | jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, newClaims) | |
| 533 | + | newTokenString, err := jwtToken.SignedString(s.config.TokenSecret) | |
| 534 | + | if err != nil { | |
| 535 | + | return "", log.WrapErr(err, "failed to re-sign token") | |
| 536 | + | } | |
| 537 | + | ||
| 538 | + | // Update stored token with new JWT, expiry, and LastExtendedAt | |
| 539 | + | updatedToken := &domain.Token{ | |
| 540 | + | ID: storedToken.ID, | |
| 541 | + | Subject: storedToken.Subject, | |
| 542 | + | Scopes: storedToken.Scopes, | |
| 543 | + | IssuedAt: storedToken.IssuedAt, | |
| 544 | + | ExpiresAt: newExpiresAt, | |
| 545 | + | Revoked: storedToken.Revoked, | |
| 546 | + | LastExtendedAt: now, | |
| 547 | + | } | |
| 548 | + | ||
| 549 | + | if err := s.tokenStore.UpdateTokenExpiry(ctx, updatedToken, newTokenString); err != nil { | |
| 550 | + | // Non-fatal: log and return original token rather than failing the request | |
| 551 | + | log.Warn().Err(err).Str("subject", tokenClaims.Subject).Msg("failed to persist extended token, returning original") | |
| 552 | + | return tokenString, nil | |
| 553 | + | } | |
| 554 | + | ||
| 555 | + | log.Debug(). | |
| 556 | + | Str("subject", tokenClaims.Subject). | |
| 557 | + | Time("new_expires_at", newExpiresAt). | |
| 558 | + | Msg("token expiry extended") | |
| 559 | + | ||
| 560 | + | return newTokenString, nil | |
| 561 | + | } | |
| 562 | + | ||
| 436 | 563 | // GeneratePasswordHash generates a bcrypt hash for a password. | |
| 437 | 564 | func (s *Service) GeneratePasswordHash(password string) (string, error) { | |
| 438 | 565 | hash, err := bcrypt.GenerateFromPassword([]byte(password), DefaultBcryptCost) | |
| @@ -27,6 +27,13 @@ const ( | |||||
| 27 | MaxAccessTokenLifetime = 5 * time.Minute | 27 | MaxAccessTokenLifetime = 5 * time.Minute | ||
| 28 | // maxAccessTokenLifetimeSecs is MaxAccessTokenLifetime in seconds for JWT comparisons. | 28 | // maxAccessTokenLifetimeSecs is MaxAccessTokenLifetime in seconds for JWT comparisons. | ||
| 29 | maxAccessTokenLifetimeSecs = int64(MaxAccessTokenLifetime / time.Second) | 29 | maxAccessTokenLifetimeSecs = int64(MaxAccessTokenLifetime / time.Second) | ||
| 30 | + | // tokenExtensionTTL is the amount of time added to a token's expiry when extended. | |||
| 31 | + | tokenExtensionTTL = 24 * time.Hour | |||
| 32 | + | // tokenExtensionDebounce is the minimum time between token extensions. | |||
| 33 | + | tokenExtensionDebounce = time.Hour | |||
| 34 | + | // serviceTokenSubject is the subject used for internal service tokens. | |||
| 35 | + | // Service tokens are not extended to avoid churn on the token store. | |||
| 36 | + | serviceTokenSubject = "gordon-service" | |||
| 30 | ) | 37 | ) | ||
| 31 | 38 | ||||
| 32 | // Config holds the authentication configuration. | 39 | // Config holds the authentication configuration. | ||
| @@ -161,7 +168,10 @@ func (s *Service) parseTokenClaims(tokenString string) (jwt.MapClaims, error) { | |||||
| 161 | return nil, domain.ErrInvalidToken | 168 | return nil, domain.ErrInvalidToken | ||
| 162 | } | 169 | } | ||
| 163 | return s.config.TokenSecret, nil | 170 | return s.config.TokenSecret, nil | ||
| 164 | - | }, jwt.WithIssuer(TokenIssuer)) // SECURITY: Enforce issuer validation | 171 | + | }, |
| 172 | + | jwt.WithIssuer(TokenIssuer), // SECURITY: Enforce issuer validation | |||
| 173 | + | jwt.WithIssuedAt(), // SECURITY: Reject tokens with iat in the future | |||
| 174 | + | ) | |||
| 165 | if err != nil { | 175 | if err != nil { | ||
| 166 | return nil, domain.ErrInvalidToken | 176 | return nil, domain.ErrInvalidToken | ||
| 167 | } | 177 | } | ||
| @@ -258,6 +268,10 @@ func (s *Service) isEphemeralAccessToken(claims *domain.TokenClaims) bool { | |||||
| 258 | // stolen secrets from creating arbitrary short-lived tokens | 268 | // stolen secrets from creating arbitrary short-lived tokens | ||
| 259 | now := time.Now().UTC().Unix() | 269 | now := time.Now().UTC().Unix() | ||
| 260 | tokenAge := now - claims.IssuedAt | 270 | tokenAge := now - claims.IssuedAt | ||
| 271 | + | if tokenAge < 0 { | |||
| 272 | + | // iat is in the future — do not classify as ephemeral (defense-in-depth: prevents bypass) | |||
| 273 | + | return false | |||
| 274 | + | } | |||
| 261 | if tokenAge > maxAccessTokenLifetimeSecs { | 275 | if tokenAge > maxAccessTokenLifetimeSecs { | ||
| 262 | return false // Old tokens require store validation | 276 | return false // Old tokens require store validation | ||
| 263 | } | 277 | } | ||
| @@ -433,6 +447,119 @@ func (s *Service) ListTokens(ctx context.Context) ([]domain.Token, error) { | |||||
| 433 | return tokens, nil | 447 | return tokens, nil | ||
| 434 | } | 448 | } | ||
| 435 | 449 | ||||
| 450 | + | // ExtendToken re-issues the token with expiry slid forward by 24h. | |||
| 451 | + | // Returns the same token string if it was already extended within the debounce window (1h). | |||
| 452 | + | // Skips extension for ephemeral access tokens (≤5min) and service tokens. | |||
| 453 | + | func (s *Service) ExtendToken(ctx context.Context, tokenString string) (string, error) { | |||
| 454 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 455 | + | zerowrap.FieldLayer: "usecase", | |||
| 456 | + | zerowrap.FieldUseCase: "ExtendToken", | |||
| 457 | + | }) | |||
| 458 | + | log := zerowrap.FromCtx(ctx) | |||
| 459 | + | ||||
| 460 | + | // Parse claims without store validation first (to check skip conditions cheaply) | |||
| 461 | + | rawClaims, err := s.parseTokenClaims(tokenString) | |||
| 462 | + | if err != nil { | |||
| 463 | + | return "", domain.ErrInvalidToken | |||
| 464 | + | } | |||
| 465 | + | ||||
| 466 | + | tokenClaims := buildTokenClaims(rawClaims) | |||
| 467 | + | ||||
| 468 | + | // Skip ephemeral access tokens (≤5min lifetime) | |||
| 469 | + | if s.isEphemeralAccessToken(tokenClaims) { | |||
| 470 | + | log.Debug().Msg("skipping extension for ephemeral access token") | |||
| 471 | + | return tokenString, nil | |||
| 472 | + | } | |||
| 473 | + | ||||
| 474 | + | // Skip service tokens | |||
| 475 | + | if tokenClaims.Subject == serviceTokenSubject { | |||
| 476 | + | log.Debug().Msg("skipping extension for service token") | |||
| 477 | + | return tokenString, nil | |||
| 478 | + | } | |||
| 479 | + | ||||
| 480 | + | // Skip tokens with no expiry (permanent tokens must not be silently converted to 24h tokens) | |||
| 481 | + | if tokenClaims.ExpiresAt <= 0 { | |||
| 482 | + | log.Debug().Str("subject", tokenClaims.Subject).Msg("skipping extension for non-expiring token") | |||
| 483 | + | return tokenString, nil | |||
| 484 | + | } | |||
| 485 | + | ||||
| 486 | + | // Full validation including store checks (not expired, not revoked, exists in store) | |||
| 487 | + | if _, err := s.ValidateToken(ctx, tokenString); err != nil { | |||
| 488 | + | return "", fmt.Errorf("token validation failed: %w", err) | |||
| 489 | + | } | |||
| 490 | + | ||||
| 491 | + | // Fetch stored token to check debounce and get metadata | |||
| 492 | + | _, storedToken, err := s.tokenStore.GetToken(ctx, tokenClaims.Subject) | |||
| 493 | + | if err != nil { | |||
| 494 | + | return "", fmt.Errorf("failed to fetch stored token: %w", err) | |||
| 495 | + | } | |||
| 496 | + | ||||
| 497 | + | // TOCTOU guard: confirm the store's current JTI matches the JWT we validated. | |||
| 498 | + | // A mismatch means the token was rotated between ValidateToken and GetToken; | |||
| 499 | + | // in that case return the original (still-valid) token without re-signing. | |||
| 500 | + | if storedToken.ID != tokenClaims.ID { | |||
| 501 | + | log.Debug(). | |||
| 502 | + | Str("subject", tokenClaims.Subject). | |||
| 503 | + | Str("jwt_jti", tokenClaims.ID). | |||
| 504 | + | Str("store_id", storedToken.ID). | |||
| 505 | + | Msg("token ID mismatch after rotation, skipping extension") | |||
| 506 | + | return tokenString, nil | |||
| 507 | + | } | |||
| 508 | + | ||||
| 509 | + | // Debounce: skip if already extended within the last hour | |||
| 510 | + | if !storedToken.LastExtendedAt.IsZero() && time.Since(storedToken.LastExtendedAt) < tokenExtensionDebounce { | |||
| 511 | + | log.Debug(). | |||
| 512 | + | Str("subject", tokenClaims.Subject). | |||
| 513 | + | Time("last_extended_at", storedToken.LastExtendedAt). | |||
| 514 | + | Msg("token extension debounced") | |||
| 515 | + | return tokenString, nil | |||
| 516 | + | } | |||
| 517 | + | ||||
| 518 | + | // Re-sign with same JTI but new expiry | |||
| 519 | + | now := time.Now().UTC() | |||
| 520 | + | newExpiresAt := now.Add(tokenExtensionTTL) | |||
| 521 | + | ||||
| 522 | + | newClaims := jwt.MapClaims{ | |||
| 523 | + | "jti": tokenClaims.ID, // Reuse existing JTI to avoid invalidating concurrent requests | |||
| 524 | + | "sub": tokenClaims.Subject, | |||
| 525 | + | "iss": TokenIssuer, | |||
| 526 | + | "iat": now.Unix(), // Update to current time (token is being re-issued) | |||
| 527 | + | "nbf": now.Unix(), // SECURITY: not-before matches issuance time | |||
| 528 | + | "scopes": tokenClaims.Scopes, | |||
| 529 | + | "exp": newExpiresAt.Unix(), | |||
| 530 | + | } | |||
| 531 | + | ||||
| 532 | + | jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, newClaims) | |||
| 533 | + | newTokenString, err := jwtToken.SignedString(s.config.TokenSecret) | |||
| 534 | + | if err != nil { | |||
| 535 | + | return "", log.WrapErr(err, "failed to re-sign token") | |||
| 536 | + | } | |||
| 537 | + | ||||
| 538 | + | // Update stored token with new JWT, expiry, and LastExtendedAt | |||
| 539 | + | updatedToken := &domain.Token{ | |||
| 540 | + | ID: storedToken.ID, | |||
| 541 | + | Subject: storedToken.Subject, | |||
| 542 | + | Scopes: storedToken.Scopes, | |||
| 543 | + | IssuedAt: storedToken.IssuedAt, | |||
| 544 | + | ExpiresAt: newExpiresAt, | |||
| 545 | + | Revoked: storedToken.Revoked, | |||
| 546 | + | LastExtendedAt: now, | |||
| 547 | + | } | |||
| 548 | + | ||||
| 549 | + | if err := s.tokenStore.UpdateTokenExpiry(ctx, updatedToken, newTokenString); err != nil { | |||
| 550 | + | // Non-fatal: log and return original token rather than failing the request | |||
| 551 | + | log.Warn().Err(err).Str("subject", tokenClaims.Subject).Msg("failed to persist extended token, returning original") | |||
| 552 | + | return tokenString, nil | |||
| 553 | + | } | |||
| 554 | + | ||||
| 555 | + | log.Debug(). | |||
| 556 | + | Str("subject", tokenClaims.Subject). | |||
| 557 | + | Time("new_expires_at", newExpiresAt). | |||
| 558 | + | Msg("token expiry extended") | |||
| 559 | + | ||||
| 560 | + | return newTokenString, nil | |||
| 561 | + | } | |||
| 562 | + | ||||
| 436 | // GeneratePasswordHash generates a bcrypt hash for a password. | 563 | // GeneratePasswordHash generates a bcrypt hash for a password. | ||
| 437 | func (s *Service) GeneratePasswordHash(password string) (string, error) { | 564 | func (s *Service) GeneratePasswordHash(password string) (string, error) { | ||
| 438 | hash, err := bcrypt.GenerateFromPassword([]byte(password), DefaultBcryptCost) | 565 | hash, err := bcrypt.GenerateFromPassword([]byte(password), DefaultBcryptCost) | ||
M internal/usecase/auth/service_test.go +234 -0
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "time" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/zerowrap" | |
| 10 | + | "github.com/golang-jwt/jwt/v5" | |
| 10 | 11 | "github.com/stretchr/testify/assert" | |
| 11 | 12 | "github.com/stretchr/testify/mock" | |
| 12 | 13 | "github.com/stretchr/testify/require" | |
| @@ -816,3 +817,236 @@ func TestService_GenerateAccessToken_HasNbfClaim(t *testing.T) { | |||
| 816 | 817 | ||
| 817 | 818 | assert.Equal(t, iat, nbf, "nbf should equal iat for access tokens") | |
| 818 | 819 | } | |
| 820 | + | ||
| 821 | + | func TestExtendTokenSlidesExpiry(t *testing.T) { | |
| 822 | + | tokenStore := mocks.NewMockTokenStore(t) | |
| 823 | + | ||
| 824 | + | svc := NewService(Config{ | |
| 825 | + | Enabled: true, | |
| 826 | + | AuthType: domain.AuthTypeToken, | |
| 827 | + | TokenSecret: []byte("test-secret-key-for-jwt-signing"), | |
| 828 | + | }, tokenStore, zerowrap.Default()) | |
| 829 | + | ||
| 830 | + | ctx := testContext() | |
| 831 | + | ||
| 832 | + | // Generate a token with a short remaining life (1h) so ExtendToken produces a different exp | |
| 833 | + | var capturedToken *domain.Token | |
| 834 | + | tokenStore.EXPECT(). | |
| 835 | + | SaveToken(mock.Anything, mock.Anything, mock.Anything). | |
| 836 | + | Run(func(_ context.Context, t *domain.Token, _ string) { | |
| 837 | + | capturedToken = t | |
| 838 | + | }). | |
| 839 | + | Return(nil) | |
| 840 | + | ||
| 841 | + | tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, time.Hour) | |
| 842 | + | require.NoError(t, err) | |
| 843 | + | ||
| 844 | + | // ExtendToken flow: | |
| 845 | + | // 1. ValidateToken → GetToken (ensureTokenExists) + IsRevoked | |
| 846 | + | tokenStore.EXPECT(). | |
| 847 | + | GetToken(mock.Anything, "testuser"). | |
| 848 | + | Return(tokenStr, capturedToken, nil).Once() | |
| 849 | + | tokenStore.EXPECT(). | |
| 850 | + | IsRevoked(mock.Anything, capturedToken.ID). | |
| 851 | + | Return(false, nil).Once() | |
| 852 | + | ||
| 853 | + | // 2. GetToken again for debounce check | |
| 854 | + | tokenStore.EXPECT(). | |
| 855 | + | GetToken(mock.Anything, "testuser"). | |
| 856 | + | Return(tokenStr, capturedToken, nil).Once() | |
| 857 | + | ||
| 858 | + | // 3. UpdateTokenExpiry | |
| 859 | + | var updatedToken *domain.Token | |
| 860 | + | tokenStore.EXPECT(). | |
| 861 | + | UpdateTokenExpiry(mock.Anything, mock.Anything, mock.Anything). | |
| 862 | + | Run(func(_ context.Context, t *domain.Token, _ string) { | |
| 863 | + | updatedToken = t | |
| 864 | + | }). | |
| 865 | + | Return(nil) | |
| 866 | + | ||
| 867 | + | // Extend it | |
| 868 | + | newTokenStr, err := svc.ExtendToken(ctx, tokenStr) | |
| 869 | + | require.NoError(t, err, "ExtendToken should not fail") | |
| 870 | + | ||
| 871 | + | // Since the original token expires in 1h and the new one expires in 24h, they must differ | |
| 872 | + | assert.NotEqual(t, tokenStr, newTokenStr, "expected a new token string after extension") | |
| 873 | + | assert.NotNil(t, updatedToken) | |
| 874 | + | assert.False(t, updatedToken.LastExtendedAt.IsZero(), "LastExtendedAt should be set") | |
| 875 | + | ||
| 876 | + | // New token must be valid — set up mock expectations for ValidateToken | |
| 877 | + | tokenStore.EXPECT(). | |
| 878 | + | GetToken(mock.Anything, "testuser"). | |
| 879 | + | Return(newTokenStr, updatedToken, nil) | |
| 880 | + | tokenStore.EXPECT(). | |
| 881 | + | IsRevoked(mock.Anything, capturedToken.ID). | |
| 882 | + | Return(false, nil) | |
| 883 | + | ||
| 884 | + | claims, err := svc.ValidateToken(ctx, newTokenStr) | |
| 885 | + | require.NoError(t, err, "new token must be valid") | |
| 886 | + | ||
| 887 | + | // New token should have ~24h expiry from now | |
| 888 | + | expectedExpiry := time.Now().Add(24 * time.Hour) | |
| 889 | + | actualExpiry := time.Unix(claims.ExpiresAt, 0) | |
| 890 | + | assert.WithinDuration(t, expectedExpiry, actualExpiry, 5*time.Minute, "expiry should be ~24h from now") | |
| 891 | + | } | |
| 892 | + | ||
| 893 | + | func TestExtendTokenDebounce(t *testing.T) { | |
| 894 | + | tokenStore := mocks.NewMockTokenStore(t) | |
| 895 | + | ||
| 896 | + | svc := NewService(Config{ | |
| 897 | + | Enabled: true, | |
| 898 | + | AuthType: domain.AuthTypeToken, | |
| 899 | + | TokenSecret: []byte("test-secret-key-for-jwt-signing"), | |
| 900 | + | }, tokenStore, zerowrap.Default()) | |
| 901 | + | ||
| 902 | + | ctx := testContext() | |
| 903 | + | ||
| 904 | + | // Generate a short-lived token (1h) so the first extension produces a different JWT | |
| 905 | + | var capturedToken *domain.Token | |
| 906 | + | tokenStore.EXPECT(). | |
| 907 | + | SaveToken(mock.Anything, mock.Anything, mock.Anything). | |
| 908 | + | Run(func(_ context.Context, t *domain.Token, _ string) { | |
| 909 | + | capturedToken = t | |
| 910 | + | }). | |
| 911 | + | Return(nil) | |
| 912 | + | ||
| 913 | + | tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, time.Hour) | |
| 914 | + | require.NoError(t, err) | |
| 915 | + | ||
| 916 | + | // First extend: flow is ValidateToken(GetToken+IsRevoked) + GetToken(debounce) + UpdateTokenExpiry | |
| 917 | + | tokenStore.EXPECT(). | |
| 918 | + | GetToken(mock.Anything, "testuser"). | |
| 919 | + | Return(tokenStr, capturedToken, nil).Once() | |
| 920 | + | tokenStore.EXPECT(). | |
| 921 | + | IsRevoked(mock.Anything, capturedToken.ID). | |
| 922 | + | Return(false, nil).Once() | |
| 923 | + | tokenStore.EXPECT(). | |
| 924 | + | GetToken(mock.Anything, "testuser"). | |
| 925 | + | Return(tokenStr, capturedToken, nil).Once() | |
| 926 | + | ||
| 927 | + | var extendedToken *domain.Token | |
| 928 | + | var extendedJWT string | |
| 929 | + | tokenStore.EXPECT(). | |
| 930 | + | UpdateTokenExpiry(mock.Anything, mock.Anything, mock.Anything). | |
| 931 | + | Run(func(_ context.Context, t *domain.Token, jwt string) { | |
| 932 | + | extendedToken = t | |
| 933 | + | extendedJWT = jwt | |
| 934 | + | }). | |
| 935 | + | Return(nil).Once() | |
| 936 | + | ||
| 937 | + | newToken1, err := svc.ExtendToken(ctx, tokenStr) | |
| 938 | + | require.NoError(t, err) | |
| 939 | + | assert.NotEqual(t, tokenStr, newToken1, "first extend should produce a new token") | |
| 940 | + | assert.NotNil(t, extendedToken) | |
| 941 | + | ||
| 942 | + | // Second extend immediately — debounce should kick in (LastExtendedAt just set to now) | |
| 943 | + | // Flow: ValidateToken(GetToken+IsRevoked) + GetToken(debounce) → debounced, no UpdateTokenExpiry | |
| 944 | + | tokenStore.EXPECT(). | |
| 945 | + | GetToken(mock.Anything, "testuser"). | |
| 946 | + | Return(extendedJWT, extendedToken, nil).Once() | |
| 947 | + | tokenStore.EXPECT(). | |
| 948 | + | IsRevoked(mock.Anything, capturedToken.ID). | |
| 949 | + | Return(false, nil).Once() | |
| 950 | + | tokenStore.EXPECT(). | |
| 951 | + | GetToken(mock.Anything, "testuser"). | |
| 952 | + | Return(extendedJWT, extendedToken, nil).Once() | |
| 953 | + | ||
| 954 | + | // No UpdateTokenExpiry should be called — debounced | |
| 955 | + | newToken2, err := svc.ExtendToken(ctx, newToken1) | |
| 956 | + | require.NoError(t, err) | |
| 957 | + | ||
| 958 | + | assert.Equal(t, newToken1, newToken2, "expected debounce to return same token within 1h window") | |
| 959 | + | } | |
| 960 | + | ||
| 961 | + | func TestExtendTokenSkipsEphemeral(t *testing.T) { | |
| 962 | + | svc := NewService(Config{ | |
| 963 | + | Enabled: true, | |
| 964 | + | AuthType: domain.AuthTypeToken, | |
| 965 | + | TokenSecret: []byte("test-secret-key-for-jwt-signing"), | |
| 966 | + | }, nil, zerowrap.Default()) | |
| 967 | + | ||
| 968 | + | ctx := testContext() | |
| 969 | + | ||
| 970 | + | // Generate an ephemeral access token (≤5min) | |
| 971 | + | tokenStr, err := svc.GenerateAccessToken(ctx, "testuser", []string{"pull"}, 5*time.Minute) | |
| 972 | + | require.NoError(t, err) | |
| 973 | + | ||
| 974 | + | // ExtendToken should skip ephemeral tokens and return the same string | |
| 975 | + | result, err := svc.ExtendToken(ctx, tokenStr) | |
| 976 | + | require.NoError(t, err) | |
| 977 | + | assert.Equal(t, tokenStr, result, "ephemeral tokens should not be extended") | |
| 978 | + | } | |
| 979 | + | ||
| 980 | + | func newTestAuthService(t *testing.T) (*Service, *mocks.MockTokenStore) { | |
| 981 | + | t.Helper() | |
| 982 | + | tokenStore := mocks.NewMockTokenStore(t) | |
| 983 | + | svc := NewService(Config{ | |
| 984 | + | Enabled: true, | |
| 985 | + | AuthType: domain.AuthTypeToken, | |
| 986 | + | TokenSecret: []byte("test-secret-key-for-jwt-signing"), | |
| 987 | + | }, tokenStore, zerowrap.Default()) | |
| 988 | + | return svc, tokenStore | |
| 989 | + | } | |
| 990 | + | ||
| 991 | + | func TestIsEphemeralAccessTokenRejectsFutureIat(t *testing.T) { | |
| 992 | + | // A token with iat in the future should NOT be treated as ephemeral | |
| 993 | + | // (otherwise it bypasses revocation checks) | |
| 994 | + | svc, _ := newTestAuthService(t) | |
| 995 | + | ||
| 996 | + | // Craft a token with iat = now+10min, exp = now+15min (age appears as -10min → negative → < 5min). | |
| 997 | + | // nbf is set to current time (not future) so the JWT library's nbf check does not reject it — | |
| 998 | + | // we need to isolate the iat bypass specifically. | |
| 999 | + | now := time.Now().UTC() | |
| 1000 | + | claims := jwt.MapClaims{ | |
| 1001 | + | "jti": "test-jti", | |
| 1002 | + | "sub": "testuser", | |
| 1003 | + | "iss": TokenIssuer, | |
| 1004 | + | "iat": now.Add(10 * time.Minute).Unix(), // future iat — the attack vector | |
| 1005 | + | "nbf": now.Add(-1 * time.Second).Unix(), // current nbf so nbf check passes | |
| 1006 | + | "exp": now.Add(15 * time.Minute).Unix(), // exp - iat = 5min → looks ephemeral | |
| 1007 | + | "scopes": []string{"admin:*:*"}, | |
| 1008 | + | } | |
| 1009 | + | token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) | |
| 1010 | + | tokenStr, err := token.SignedString(svc.config.TokenSecret) | |
| 1011 | + | if err != nil { | |
| 1012 | + | t.Fatal(err) | |
| 1013 | + | } | |
| 1014 | + | ||
| 1015 | + | // Parsing should either reject the token outright (future iat) or not classify it as ephemeral | |
| 1016 | + | rawClaims, err := svc.parseTokenClaims(tokenStr) | |
| 1017 | + | if err != nil { | |
| 1018 | + | // Acceptable: future iat rejected at parse time | |
| 1019 | + | t.Logf("token with future iat rejected at parse: %v", err) | |
| 1020 | + | return | |
| 1021 | + | } | |
| 1022 | + | tokenClaims := buildTokenClaims(rawClaims) | |
| 1023 | + | if svc.isEphemeralAccessToken(tokenClaims) { | |
| 1024 | + | t.Error("token with future iat must NOT be classified as ephemeral — it bypasses revocation") | |
| 1025 | + | } | |
| 1026 | + | } | |
| 1027 | + | ||
| 1028 | + | func TestExtendTokenSkipsServiceToken(t *testing.T) { | |
| 1029 | + | tokenStore := mocks.NewMockTokenStore(t) | |
| 1030 | + | ||
| 1031 | + | svc := NewService(Config{ | |
| 1032 | + | Enabled: true, | |
| 1033 | + | AuthType: domain.AuthTypeToken, | |
| 1034 | + | TokenSecret: []byte("test-secret-key-for-jwt-signing"), | |
| 1035 | + | }, tokenStore, zerowrap.Default()) | |
| 1036 | + | ||
| 1037 | + | ctx := testContext() | |
| 1038 | + | ||
| 1039 | + | // Generate a service token — service tokens skip extension before store checks | |
| 1040 | + | tokenStore.EXPECT(). | |
| 1041 | + | SaveToken(mock.Anything, mock.Anything, mock.Anything). | |
| 1042 | + | Return(nil) | |
| 1043 | + | ||
| 1044 | + | const svcSubject = "gordon-service" | |
| 1045 | + | tokenStr, err := svc.GenerateToken(ctx, svcSubject, []string{"pull"}, 24*time.Hour) | |
| 1046 | + | require.NoError(t, err) | |
| 1047 | + | ||
| 1048 | + | // ExtendToken should skip service tokens WITHOUT touching the store | |
| 1049 | + | result, err := svc.ExtendToken(ctx, tokenStr) | |
| 1050 | + | require.NoError(t, err) | |
| 1051 | + | assert.Equal(t, tokenStr, result, "service tokens should not be extended") | |
| 1052 | + | } | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "time" | 7 | "time" | ||
| 8 | 8 | ||||
| 9 | "github.com/bnema/zerowrap" | 9 | "github.com/bnema/zerowrap" | ||
| 10 | + | "github.com/golang-jwt/jwt/v5" | |||
| 10 | "github.com/stretchr/testify/assert" | 11 | "github.com/stretchr/testify/assert" | ||
| 11 | "github.com/stretchr/testify/mock" | 12 | "github.com/stretchr/testify/mock" | ||
| 12 | "github.com/stretchr/testify/require" | 13 | "github.com/stretchr/testify/require" | ||
| @@ -816,3 +817,236 @@ func TestService_GenerateAccessToken_HasNbfClaim(t *testing.T) { | |||||
| 816 | 817 | ||||
| 817 | assert.Equal(t, iat, nbf, "nbf should equal iat for access tokens") | 818 | assert.Equal(t, iat, nbf, "nbf should equal iat for access tokens") | ||
| 818 | } | 819 | } | ||
| 820 | + | ||||
| 821 | + | func TestExtendTokenSlidesExpiry(t *testing.T) { | |||
| 822 | + | tokenStore := mocks.NewMockTokenStore(t) | |||
| 823 | + | ||||
| 824 | + | svc := NewService(Config{ | |||
| 825 | + | Enabled: true, | |||
| 826 | + | AuthType: domain.AuthTypeToken, | |||
| 827 | + | TokenSecret: []byte("test-secret-key-for-jwt-signing"), | |||
| 828 | + | }, tokenStore, zerowrap.Default()) | |||
| 829 | + | ||||
| 830 | + | ctx := testContext() | |||
| 831 | + | ||||
| 832 | + | // Generate a token with a short remaining life (1h) so ExtendToken produces a different exp | |||
| 833 | + | var capturedToken *domain.Token | |||
| 834 | + | tokenStore.EXPECT(). | |||
| 835 | + | SaveToken(mock.Anything, mock.Anything, mock.Anything). | |||
| 836 | + | Run(func(_ context.Context, t *domain.Token, _ string) { | |||
| 837 | + | capturedToken = t | |||
| 838 | + | }). | |||
| 839 | + | Return(nil) | |||
| 840 | + | ||||
| 841 | + | tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, time.Hour) | |||
| 842 | + | require.NoError(t, err) | |||
| 843 | + | ||||
| 844 | + | // ExtendToken flow: | |||
| 845 | + | // 1. ValidateToken → GetToken (ensureTokenExists) + IsRevoked | |||
| 846 | + | tokenStore.EXPECT(). | |||
| 847 | + | GetToken(mock.Anything, "testuser"). | |||
| 848 | + | Return(tokenStr, capturedToken, nil).Once() | |||
| 849 | + | tokenStore.EXPECT(). | |||
| 850 | + | IsRevoked(mock.Anything, capturedToken.ID). | |||
| 851 | + | Return(false, nil).Once() | |||
| 852 | + | ||||
| 853 | + | // 2. GetToken again for debounce check | |||
| 854 | + | tokenStore.EXPECT(). | |||
| 855 | + | GetToken(mock.Anything, "testuser"). | |||
| 856 | + | Return(tokenStr, capturedToken, nil).Once() | |||
| 857 | + | ||||
| 858 | + | // 3. UpdateTokenExpiry | |||
| 859 | + | var updatedToken *domain.Token | |||
| 860 | + | tokenStore.EXPECT(). | |||
| 861 | + | UpdateTokenExpiry(mock.Anything, mock.Anything, mock.Anything). | |||
| 862 | + | Run(func(_ context.Context, t *domain.Token, _ string) { | |||
| 863 | + | updatedToken = t | |||
| 864 | + | }). | |||
| 865 | + | Return(nil) | |||
| 866 | + | ||||
| 867 | + | // Extend it | |||
| 868 | + | newTokenStr, err := svc.ExtendToken(ctx, tokenStr) | |||
| 869 | + | require.NoError(t, err, "ExtendToken should not fail") | |||
| 870 | + | ||||
| 871 | + | // Since the original token expires in 1h and the new one expires in 24h, they must differ | |||
| 872 | + | assert.NotEqual(t, tokenStr, newTokenStr, "expected a new token string after extension") | |||
| 873 | + | assert.NotNil(t, updatedToken) | |||
| 874 | + | assert.False(t, updatedToken.LastExtendedAt.IsZero(), "LastExtendedAt should be set") | |||
| 875 | + | ||||
| 876 | + | // New token must be valid — set up mock expectations for ValidateToken | |||
| 877 | + | tokenStore.EXPECT(). | |||
| 878 | + | GetToken(mock.Anything, "testuser"). | |||
| 879 | + | Return(newTokenStr, updatedToken, nil) | |||
| 880 | + | tokenStore.EXPECT(). | |||
| 881 | + | IsRevoked(mock.Anything, capturedToken.ID). | |||
| 882 | + | Return(false, nil) | |||
| 883 | + | ||||
| 884 | + | claims, err := svc.ValidateToken(ctx, newTokenStr) | |||
| 885 | + | require.NoError(t, err, "new token must be valid") | |||
| 886 | + | ||||
| 887 | + | // New token should have ~24h expiry from now | |||
| 888 | + | expectedExpiry := time.Now().Add(24 * time.Hour) | |||
| 889 | + | actualExpiry := time.Unix(claims.ExpiresAt, 0) | |||
| 890 | + | assert.WithinDuration(t, expectedExpiry, actualExpiry, 5*time.Minute, "expiry should be ~24h from now") | |||
| 891 | + | } | |||
| 892 | + | ||||
| 893 | + | func TestExtendTokenDebounce(t *testing.T) { | |||
| 894 | + | tokenStore := mocks.NewMockTokenStore(t) | |||
| 895 | + | ||||
| 896 | + | svc := NewService(Config{ | |||
| 897 | + | Enabled: true, | |||
| 898 | + | AuthType: domain.AuthTypeToken, | |||
| 899 | + | TokenSecret: []byte("test-secret-key-for-jwt-signing"), | |||
| 900 | + | }, tokenStore, zerowrap.Default()) | |||
| 901 | + | ||||
| 902 | + | ctx := testContext() | |||
| 903 | + | ||||
| 904 | + | // Generate a short-lived token (1h) so the first extension produces a different JWT | |||
| 905 | + | var capturedToken *domain.Token | |||
| 906 | + | tokenStore.EXPECT(). | |||
| 907 | + | SaveToken(mock.Anything, mock.Anything, mock.Anything). | |||
| 908 | + | Run(func(_ context.Context, t *domain.Token, _ string) { | |||
| 909 | + | capturedToken = t | |||
| 910 | + | }). | |||
| 911 | + | Return(nil) | |||
| 912 | + | ||||
| 913 | + | tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, time.Hour) | |||
| 914 | + | require.NoError(t, err) | |||
| 915 | + | ||||
| 916 | + | // First extend: flow is ValidateToken(GetToken+IsRevoked) + GetToken(debounce) + UpdateTokenExpiry | |||
| 917 | + | tokenStore.EXPECT(). | |||
| 918 | + | GetToken(mock.Anything, "testuser"). | |||
| 919 | + | Return(tokenStr, capturedToken, nil).Once() | |||
| 920 | + | tokenStore.EXPECT(). | |||
| 921 | + | IsRevoked(mock.Anything, capturedToken.ID). | |||
| 922 | + | Return(false, nil).Once() | |||
| 923 | + | tokenStore.EXPECT(). | |||
| 924 | + | GetToken(mock.Anything, "testuser"). | |||
| 925 | + | Return(tokenStr, capturedToken, nil).Once() | |||
| 926 | + | ||||
| 927 | + | var extendedToken *domain.Token | |||
| 928 | + | var extendedJWT string | |||
| 929 | + | tokenStore.EXPECT(). | |||
| 930 | + | UpdateTokenExpiry(mock.Anything, mock.Anything, mock.Anything). | |||
| 931 | + | Run(func(_ context.Context, t *domain.Token, jwt string) { | |||
| 932 | + | extendedToken = t | |||
| 933 | + | extendedJWT = jwt | |||
| 934 | + | }). | |||
| 935 | + | Return(nil).Once() | |||
| 936 | + | ||||
| 937 | + | newToken1, err := svc.ExtendToken(ctx, tokenStr) | |||
| 938 | + | require.NoError(t, err) | |||
| 939 | + | assert.NotEqual(t, tokenStr, newToken1, "first extend should produce a new token") | |||
| 940 | + | assert.NotNil(t, extendedToken) | |||
| 941 | + | ||||
| 942 | + | // Second extend immediately — debounce should kick in (LastExtendedAt just set to now) | |||
| 943 | + | // Flow: ValidateToken(GetToken+IsRevoked) + GetToken(debounce) → debounced, no UpdateTokenExpiry | |||
| 944 | + | tokenStore.EXPECT(). | |||
| 945 | + | GetToken(mock.Anything, "testuser"). | |||
| 946 | + | Return(extendedJWT, extendedToken, nil).Once() | |||
| 947 | + | tokenStore.EXPECT(). | |||
| 948 | + | IsRevoked(mock.Anything, capturedToken.ID). | |||
| 949 | + | Return(false, nil).Once() | |||
| 950 | + | tokenStore.EXPECT(). | |||
| 951 | + | GetToken(mock.Anything, "testuser"). | |||
| 952 | + | Return(extendedJWT, extendedToken, nil).Once() | |||
| 953 | + | ||||
| 954 | + | // No UpdateTokenExpiry should be called — debounced | |||
| 955 | + | newToken2, err := svc.ExtendToken(ctx, newToken1) | |||
| 956 | + | require.NoError(t, err) | |||
| 957 | + | ||||
| 958 | + | assert.Equal(t, newToken1, newToken2, "expected debounce to return same token within 1h window") | |||
| 959 | + | } | |||
| 960 | + | ||||
| 961 | + | func TestExtendTokenSkipsEphemeral(t *testing.T) { | |||
| 962 | + | svc := NewService(Config{ | |||
| 963 | + | Enabled: true, | |||
| 964 | + | AuthType: domain.AuthTypeToken, | |||
| 965 | + | TokenSecret: []byte("test-secret-key-for-jwt-signing"), | |||
| 966 | + | }, nil, zerowrap.Default()) | |||
| 967 | + | ||||
| 968 | + | ctx := testContext() | |||
| 969 | + | ||||
| 970 | + | // Generate an ephemeral access token (≤5min) | |||
| 971 | + | tokenStr, err := svc.GenerateAccessToken(ctx, "testuser", []string{"pull"}, 5*time.Minute) | |||
| 972 | + | require.NoError(t, err) | |||
| 973 | + | ||||
| 974 | + | // ExtendToken should skip ephemeral tokens and return the same string | |||
| 975 | + | result, err := svc.ExtendToken(ctx, tokenStr) | |||
| 976 | + | require.NoError(t, err) | |||
| 977 | + | assert.Equal(t, tokenStr, result, "ephemeral tokens should not be extended") | |||
| 978 | + | } | |||
| 979 | + | ||||
| 980 | + | func newTestAuthService(t *testing.T) (*Service, *mocks.MockTokenStore) { | |||
| 981 | + | t.Helper() | |||
| 982 | + | tokenStore := mocks.NewMockTokenStore(t) | |||
| 983 | + | svc := NewService(Config{ | |||
| 984 | + | Enabled: true, | |||
| 985 | + | AuthType: domain.AuthTypeToken, | |||
| 986 | + | TokenSecret: []byte("test-secret-key-for-jwt-signing"), | |||
| 987 | + | }, tokenStore, zerowrap.Default()) | |||
| 988 | + | return svc, tokenStore | |||
| 989 | + | } | |||
| 990 | + | ||||
| 991 | + | func TestIsEphemeralAccessTokenRejectsFutureIat(t *testing.T) { | |||
| 992 | + | // A token with iat in the future should NOT be treated as ephemeral | |||
| 993 | + | // (otherwise it bypasses revocation checks) | |||
| 994 | + | svc, _ := newTestAuthService(t) | |||
| 995 | + | ||||
| 996 | + | // Craft a token with iat = now+10min, exp = now+15min (age appears as -10min → negative → < 5min). | |||
| 997 | + | // nbf is set to current time (not future) so the JWT library's nbf check does not reject it — | |||
| 998 | + | // we need to isolate the iat bypass specifically. | |||
| 999 | + | now := time.Now().UTC() | |||
| 1000 | + | claims := jwt.MapClaims{ | |||
| 1001 | + | "jti": "test-jti", | |||
| 1002 | + | "sub": "testuser", | |||
| 1003 | + | "iss": TokenIssuer, | |||
| 1004 | + | "iat": now.Add(10 * time.Minute).Unix(), // future iat — the attack vector | |||
| 1005 | + | "nbf": now.Add(-1 * time.Second).Unix(), // current nbf so nbf check passes | |||
| 1006 | + | "exp": now.Add(15 * time.Minute).Unix(), // exp - iat = 5min → looks ephemeral | |||
| 1007 | + | "scopes": []string{"admin:*:*"}, | |||
| 1008 | + | } | |||
| 1009 | + | token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) | |||
| 1010 | + | tokenStr, err := token.SignedString(svc.config.TokenSecret) | |||
| 1011 | + | if err != nil { | |||
| 1012 | + | t.Fatal(err) | |||
| 1013 | + | } | |||
| 1014 | + | ||||
| 1015 | + | // Parsing should either reject the token outright (future iat) or not classify it as ephemeral | |||
| 1016 | + | rawClaims, err := svc.parseTokenClaims(tokenStr) | |||
| 1017 | + | if err != nil { | |||
| 1018 | + | // Acceptable: future iat rejected at parse time | |||
| 1019 | + | t.Logf("token with future iat rejected at parse: %v", err) | |||
| 1020 | + | return | |||
| 1021 | + | } | |||
| 1022 | + | tokenClaims := buildTokenClaims(rawClaims) | |||
| 1023 | + | if svc.isEphemeralAccessToken(tokenClaims) { | |||
| 1024 | + | t.Error("token with future iat must NOT be classified as ephemeral — it bypasses revocation") | |||
| 1025 | + | } | |||
| 1026 | + | } | |||
| 1027 | + | ||||
| 1028 | + | func TestExtendTokenSkipsServiceToken(t *testing.T) { | |||
| 1029 | + | tokenStore := mocks.NewMockTokenStore(t) | |||
| 1030 | + | ||||
| 1031 | + | svc := NewService(Config{ | |||
| 1032 | + | Enabled: true, | |||
| 1033 | + | AuthType: domain.AuthTypeToken, | |||
| 1034 | + | TokenSecret: []byte("test-secret-key-for-jwt-signing"), | |||
| 1035 | + | }, tokenStore, zerowrap.Default()) | |||
| 1036 | + | ||||
| 1037 | + | ctx := testContext() | |||
| 1038 | + | ||||
| 1039 | + | // Generate a service token — service tokens skip extension before store checks | |||
| 1040 | + | tokenStore.EXPECT(). | |||
| 1041 | + | SaveToken(mock.Anything, mock.Anything, mock.Anything). | |||
| 1042 | + | Return(nil) | |||
| 1043 | + | ||||
| 1044 | + | const svcSubject = "gordon-service" | |||
| 1045 | + | tokenStr, err := svc.GenerateToken(ctx, svcSubject, []string{"pull"}, 24*time.Hour) | |||
| 1046 | + | require.NoError(t, err) | |||
| 1047 | + | ||||
| 1048 | + | // ExtendToken should skip service tokens WITHOUT touching the store | |||
| 1049 | + | result, err := svc.ExtendToken(ctx, tokenStr) | |||
| 1050 | + | require.NoError(t, err) | |||
| 1051 | + | assert.Equal(t, tokenStr, result, "service tokens should not be extended") | |||
| 1052 | + | } | |||
M internal/usecase/config/service.go +6 -8
| @@ -418,9 +418,14 @@ func (s *Service) Save(ctx context.Context) error { | |||
| 418 | 418 | s.mu.Lock() | |
| 419 | 419 | defer s.mu.Unlock() | |
| 420 | 420 | ||
| 421 | - | // Update viper with current config | |
| 421 | + | // Sync ALL mutable config sections back to Viper before writing. | |
| 422 | + | // Using explicit Set() ensures the values are stored as proper flat maps, | |
| 423 | + | // preventing viper from splitting dotted keys (e.g. "reg.example.com") into | |
| 424 | + | // nested TOML subtrees which corrupts the data on re-read. | |
| 422 | 425 | s.viper.Set("routes", s.config.Routes) | |
| 426 | + | s.viper.Set("external_routes", s.config.ExternalRoutes) | |
| 423 | 427 | s.viper.Set("attachments", s.config.Attachments) | |
| 428 | + | s.viper.Set("network_groups", s.config.NetworkGroups) | |
| 424 | 429 | ||
| 425 | 430 | // Record save time to debounce file watcher events | |
| 426 | 431 | atomic.StoreInt64(&s.lastSaveTime, time.Now().UnixNano()) | |
| @@ -534,13 +539,6 @@ func (s *Service) GetConfig() Config { | |||
| 534 | 539 | return s.config | |
| 535 | 540 | } | |
| 536 | 541 | ||
| 537 | - | // GetRegistryAuthConfig returns registry authentication configuration. | |
| 538 | - | func (s *Service) GetRegistryAuthConfig() (enabled bool, username, password string) { | |
| 539 | - | s.mu.RLock() | |
| 540 | - | defer s.mu.RUnlock() | |
| 541 | - | return s.config.RegistryAuthEnabled, s.config.RegistryAuthUsername, s.config.RegistryAuthPassword | |
| 542 | - | } | |
| 543 | - | ||
| 544 | 542 | // GetVolumeConfig returns volume configuration. | |
| 545 | 543 | func (s *Service) GetVolumeConfig() (autoCreate bool, prefix string, preserve bool) { | |
| 546 | 544 | s.mu.RLock() | |
| @@ -418,9 +418,14 @@ func (s *Service) Save(ctx context.Context) error { | |||||
| 418 | s.mu.Lock() | 418 | s.mu.Lock() | ||
| 419 | defer s.mu.Unlock() | 419 | defer s.mu.Unlock() | ||
| 420 | 420 | ||||
| 421 | - | // Update viper with current config | 421 | + | // Sync ALL mutable config sections back to Viper before writing. |
| 422 | + | // Using explicit Set() ensures the values are stored as proper flat maps, | |||
| 423 | + | // preventing viper from splitting dotted keys (e.g. "reg.example.com") into | |||
| 424 | + | // nested TOML subtrees which corrupts the data on re-read. | |||
| 422 | s.viper.Set("routes", s.config.Routes) | 425 | s.viper.Set("routes", s.config.Routes) | ||
| 426 | + | s.viper.Set("external_routes", s.config.ExternalRoutes) | |||
| 423 | s.viper.Set("attachments", s.config.Attachments) | 427 | s.viper.Set("attachments", s.config.Attachments) | ||
| 428 | + | s.viper.Set("network_groups", s.config.NetworkGroups) | |||
| 424 | 429 | ||||
| 425 | // Record save time to debounce file watcher events | 430 | // Record save time to debounce file watcher events | ||
| 426 | atomic.StoreInt64(&s.lastSaveTime, time.Now().UnixNano()) | 431 | atomic.StoreInt64(&s.lastSaveTime, time.Now().UnixNano()) | ||
| @@ -534,13 +539,6 @@ func (s *Service) GetConfig() Config { | |||||
| 534 | return s.config | 539 | return s.config | ||
| 535 | } | 540 | } | ||
| 536 | 541 | ||||
| 537 | - | // GetRegistryAuthConfig returns registry authentication configuration. | |||
| 538 | - | func (s *Service) GetRegistryAuthConfig() (enabled bool, username, password string) { | |||
| 539 | - | s.mu.RLock() | |||
| 540 | - | defer s.mu.RUnlock() | |||
| 541 | - | return s.config.RegistryAuthEnabled, s.config.RegistryAuthUsername, s.config.RegistryAuthPassword | |||
| 542 | - | } | |||
| 543 | - | ||||
| 544 | // GetVolumeConfig returns volume configuration. | 542 | // GetVolumeConfig returns volume configuration. | ||
| 545 | func (s *Service) GetVolumeConfig() (autoCreate bool, prefix string, preserve bool) { | 543 | func (s *Service) GetVolumeConfig() (autoCreate bool, prefix string, preserve bool) { | ||
| 546 | s.mu.RLock() | 544 | s.mu.RLock() | ||
M internal/usecase/config/service_test.go +168 -5
| @@ -53,11 +53,6 @@ func TestService_Load(t *testing.T) { | |||
| 53 | 53 | assert.True(t, svc.IsNetworkIsolationEnabled()) | |
| 54 | 54 | assert.Equal(t, "gordon", svc.GetNetworkPrefix()) | |
| 55 | 55 | ||
| 56 | - | enabled, username, password := svc.GetRegistryAuthConfig() | |
| 57 | - | assert.True(t, enabled) | |
| 58 | - | assert.Equal(t, "admin", username) | |
| 59 | - | assert.Equal(t, "secret", password) | |
| 60 | - | ||
| 61 | 56 | autoCreate, prefix, preserve := svc.GetVolumeConfig() | |
| 62 | 57 | assert.True(t, autoCreate) | |
| 63 | 58 | assert.Equal(t, "gordon", prefix) | |
| @@ -1091,6 +1086,174 @@ func TestService_FindRoutesByImage(t *testing.T) { | |||
| 1091 | 1086 | }) | |
| 1092 | 1087 | } | |
| 1093 | 1088 | ||
| 1089 | + | func TestSavePreservesAllConfigFields(t *testing.T) { | |
| 1090 | + | t.Run("network_groups are persisted to disk", func(t *testing.T) { | |
| 1091 | + | // Setup: config file with auth, server, and network_groups | |
| 1092 | + | tmpDir := t.TempDir() | |
| 1093 | + | configFile := filepath.Join(tmpDir, "gordon.toml") | |
| 1094 | + | initialConfig := ` | |
| 1095 | + | [server] | |
| 1096 | + | port = 9999 | |
| 1097 | + | ||
| 1098 | + | [auth] | |
| 1099 | + | username = "admin" | |
| 1100 | + | token_secret = "supersecretvalue" | |
| 1101 | + | ||
| 1102 | + | [routes] | |
| 1103 | + | "app.example.com" = "myapp:latest" | |
| 1104 | + | ||
| 1105 | + | [network_groups] | |
| 1106 | + | frontend = ["app1.example.com", "app2.example.com"] | |
| 1107 | + | ` | |
| 1108 | + | err := os.WriteFile(configFile, []byte(initialConfig), 0600) | |
| 1109 | + | require.NoError(t, err) | |
| 1110 | + | ||
| 1111 | + | v := viper.New() | |
| 1112 | + | v.SetConfigFile(configFile) | |
| 1113 | + | err = v.ReadInConfig() | |
| 1114 | + | require.NoError(t, err) | |
| 1115 | + | ||
| 1116 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 1117 | + | svc := NewService(v, eventBus) | |
| 1118 | + | ctx := testContext() | |
| 1119 | + | ||
| 1120 | + | err = svc.Load(ctx) | |
| 1121 | + | require.NoError(t, err) | |
| 1122 | + | ||
| 1123 | + | // Verify network_groups loaded correctly in memory | |
| 1124 | + | groups := svc.GetNetworkGroups() | |
| 1125 | + | require.Len(t, groups, 1) | |
| 1126 | + | require.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, groups["frontend"]) | |
| 1127 | + | ||
| 1128 | + | // Trigger a Save via AddRoute (which calls Save internally) | |
| 1129 | + | err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"}) | |
| 1130 | + | require.NoError(t, err) | |
| 1131 | + | ||
| 1132 | + | // Re-read the file to verify network_groups was persisted | |
| 1133 | + | v2 := viper.New() | |
| 1134 | + | v2.SetConfigFile(configFile) | |
| 1135 | + | err = v2.ReadInConfig() | |
| 1136 | + | require.NoError(t, err) | |
| 1137 | + | ||
| 1138 | + | // Verify network_groups is still intact and correct after save | |
| 1139 | + | savedGroups := loadStringArrayMap(v2.Get("network_groups")) | |
| 1140 | + | assert.Len(t, savedGroups, 1, "network_groups should still be present in saved config") | |
| 1141 | + | assert.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, savedGroups["frontend"]) | |
| 1142 | + | ||
| 1143 | + | // Verify auth fields are preserved | |
| 1144 | + | assert.Equal(t, "admin", v2.GetString("auth.username")) | |
| 1145 | + | assert.Equal(t, "supersecretvalue", v2.GetString("auth.token_secret")) | |
| 1146 | + | ||
| 1147 | + | // Verify server fields are preserved | |
| 1148 | + | assert.Equal(t, 9999, v2.GetInt("server.port")) | |
| 1149 | + | }) | |
| 1150 | + | ||
| 1151 | + | // Regression test: external_routes keys contain dots (e.g. "reg.example.com"). | |
| 1152 | + | // Without explicitly calling viper.Set("external_routes", ...) before WriteConfig, | |
| 1153 | + | // viper splits dotted keys into nested subtrees, corrupting the data on re-read. | |
| 1154 | + | t.Run("external_routes with dotted domain keys are not corrupted on Save", func(t *testing.T) { | |
| 1155 | + | tmpDir := t.TempDir() | |
| 1156 | + | configFile := filepath.Join(tmpDir, "gordon.toml") | |
| 1157 | + | initialConfig := ` | |
| 1158 | + | [server] | |
| 1159 | + | port = 8080 | |
| 1160 | + | ||
| 1161 | + | [routes] | |
| 1162 | + | "app.example.com" = "myapp:latest" | |
| 1163 | + | ||
| 1164 | + | [external_routes] | |
| 1165 | + | "reg.example.com" = "localhost:5000" | |
| 1166 | + | ` | |
| 1167 | + | err := os.WriteFile(configFile, []byte(initialConfig), 0600) | |
| 1168 | + | require.NoError(t, err) | |
| 1169 | + | ||
| 1170 | + | v := viper.New() | |
| 1171 | + | v.SetConfigFile(configFile) | |
| 1172 | + | err = v.ReadInConfig() | |
| 1173 | + | require.NoError(t, err) | |
| 1174 | + | ||
| 1175 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 1176 | + | svc := NewService(v, eventBus) | |
| 1177 | + | ctx := testContext() | |
| 1178 | + | ||
| 1179 | + | err = svc.Load(ctx) | |
| 1180 | + | require.NoError(t, err) | |
| 1181 | + | ||
| 1182 | + | // Verify external_routes loaded correctly in memory | |
| 1183 | + | extRoutes := svc.GetExternalRoutes() | |
| 1184 | + | require.Equal(t, "localhost:5000", extRoutes["reg.example.com"]) | |
| 1185 | + | ||
| 1186 | + | // Trigger Save | |
| 1187 | + | err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"}) | |
| 1188 | + | require.NoError(t, err) | |
| 1189 | + | ||
| 1190 | + | // Re-read the file with a fresh viper instance | |
| 1191 | + | v2 := viper.New() | |
| 1192 | + | v2.SetConfigFile(configFile) | |
| 1193 | + | err = v2.ReadInConfig() | |
| 1194 | + | require.NoError(t, err) | |
| 1195 | + | ||
| 1196 | + | // external_routes must survive Save with correct flat map structure. | |
| 1197 | + | // Without the fix, viper splits "reg.example.com" into nested TOML subtrees | |
| 1198 | + | // and GetString("external_routes.reg\\.example\\.com") returns empty string. | |
| 1199 | + | savedExtRoutes := loadStringMap(v2.Get("external_routes")) | |
| 1200 | + | assert.Equal(t, "localhost:5000", savedExtRoutes["reg.example.com"], | |
| 1201 | + | "external_routes must not be corrupted by viper's dot-path splitting on WriteConfig") | |
| 1202 | + | }) | |
| 1203 | + | ||
| 1204 | + | t.Run("network_groups added in memory are written to disk on Save", func(t *testing.T) { | |
| 1205 | + | // Setup: config file WITHOUT network_groups | |
| 1206 | + | tmpDir := t.TempDir() | |
| 1207 | + | configFile := filepath.Join(tmpDir, "gordon.toml") | |
| 1208 | + | initialConfig := ` | |
| 1209 | + | [server] | |
| 1210 | + | port = 8080 | |
| 1211 | + | ||
| 1212 | + | [routes] | |
| 1213 | + | "app.example.com" = "myapp:latest" | |
| 1214 | + | ` | |
| 1215 | + | err := os.WriteFile(configFile, []byte(initialConfig), 0600) | |
| 1216 | + | require.NoError(t, err) | |
| 1217 | + | ||
| 1218 | + | v := viper.New() | |
| 1219 | + | v.SetConfigFile(configFile) | |
| 1220 | + | err = v.ReadInConfig() | |
| 1221 | + | require.NoError(t, err) | |
| 1222 | + | ||
| 1223 | + | // Inject network_groups into viper as if they were loaded from some other source | |
| 1224 | + | // This simulates a case where network_groups exist in memory but not on disk | |
| 1225 | + | v.Set("network_groups", map[string]interface{}{ | |
| 1226 | + | "backend": []interface{}{"api.example.com"}, | |
| 1227 | + | }) | |
| 1228 | + | ||
| 1229 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 1230 | + | svc := NewService(v, eventBus) | |
| 1231 | + | ctx := testContext() | |
| 1232 | + | ||
| 1233 | + | err = svc.Load(ctx) | |
| 1234 | + | require.NoError(t, err) | |
| 1235 | + | ||
| 1236 | + | // Verify network_groups is loaded in memory | |
| 1237 | + | groups := svc.GetNetworkGroups() | |
| 1238 | + | require.Len(t, groups, 1) | |
| 1239 | + | ||
| 1240 | + | // Trigger Save | |
| 1241 | + | err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"}) | |
| 1242 | + | require.NoError(t, err) | |
| 1243 | + | ||
| 1244 | + | // Re-read the file | |
| 1245 | + | v2 := viper.New() | |
| 1246 | + | v2.SetConfigFile(configFile) | |
| 1247 | + | err = v2.ReadInConfig() | |
| 1248 | + | require.NoError(t, err) | |
| 1249 | + | ||
| 1250 | + | // network_groups must be present in the saved file | |
| 1251 | + | savedGroups := loadStringArrayMap(v2.Get("network_groups")) | |
| 1252 | + | assert.Len(t, savedGroups, 1, "network_groups added in memory must be written on Save") | |
| 1253 | + | assert.ElementsMatch(t, []string{"api.example.com"}, savedGroups["backend"]) | |
| 1254 | + | }) | |
| 1255 | + | } | |
| 1256 | + | ||
| 1094 | 1257 | func TestExtractDomainFromImageName(t *testing.T) { | |
| 1095 | 1258 | tests := []struct { | |
| 1096 | 1259 | name string | |
| @@ -53,11 +53,6 @@ func TestService_Load(t *testing.T) { | |||||
| 53 | assert.True(t, svc.IsNetworkIsolationEnabled()) | 53 | assert.True(t, svc.IsNetworkIsolationEnabled()) | ||
| 54 | assert.Equal(t, "gordon", svc.GetNetworkPrefix()) | 54 | assert.Equal(t, "gordon", svc.GetNetworkPrefix()) | ||
| 55 | 55 | ||||
| 56 | - | enabled, username, password := svc.GetRegistryAuthConfig() | |||
| 57 | - | assert.True(t, enabled) | |||
| 58 | - | assert.Equal(t, "admin", username) | |||
| 59 | - | assert.Equal(t, "secret", password) | |||
| 60 | - | ||||
| 61 | autoCreate, prefix, preserve := svc.GetVolumeConfig() | 56 | autoCreate, prefix, preserve := svc.GetVolumeConfig() | ||
| 62 | assert.True(t, autoCreate) | 57 | assert.True(t, autoCreate) | ||
| 63 | assert.Equal(t, "gordon", prefix) | 58 | assert.Equal(t, "gordon", prefix) | ||
| @@ -1091,6 +1086,174 @@ func TestService_FindRoutesByImage(t *testing.T) { | |||||
| 1091 | }) | 1086 | }) | ||
| 1092 | } | 1087 | } | ||
| 1093 | 1088 | ||||
| 1089 | + | func TestSavePreservesAllConfigFields(t *testing.T) { | |||
| 1090 | + | t.Run("network_groups are persisted to disk", func(t *testing.T) { | |||
| 1091 | + | // Setup: config file with auth, server, and network_groups | |||
| 1092 | + | tmpDir := t.TempDir() | |||
| 1093 | + | configFile := filepath.Join(tmpDir, "gordon.toml") | |||
| 1094 | + | initialConfig := ` | |||
| 1095 | + | [server] | |||
| 1096 | + | port = 9999 | |||
| 1097 | + | ||||
| 1098 | + | [auth] | |||
| 1099 | + | username = "admin" | |||
| 1100 | + | token_secret = "supersecretvalue" | |||
| 1101 | + | ||||
| 1102 | + | [routes] | |||
| 1103 | + | "app.example.com" = "myapp:latest" | |||
| 1104 | + | ||||
| 1105 | + | [network_groups] | |||
| 1106 | + | frontend = ["app1.example.com", "app2.example.com"] | |||
| 1107 | + | ` | |||
| 1108 | + | err := os.WriteFile(configFile, []byte(initialConfig), 0600) | |||
| 1109 | + | require.NoError(t, err) | |||
| 1110 | + | ||||
| 1111 | + | v := viper.New() | |||
| 1112 | + | v.SetConfigFile(configFile) | |||
| 1113 | + | err = v.ReadInConfig() | |||
| 1114 | + | require.NoError(t, err) | |||
| 1115 | + | ||||
| 1116 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 1117 | + | svc := NewService(v, eventBus) | |||
| 1118 | + | ctx := testContext() | |||
| 1119 | + | ||||
| 1120 | + | err = svc.Load(ctx) | |||
| 1121 | + | require.NoError(t, err) | |||
| 1122 | + | ||||
| 1123 | + | // Verify network_groups loaded correctly in memory | |||
| 1124 | + | groups := svc.GetNetworkGroups() | |||
| 1125 | + | require.Len(t, groups, 1) | |||
| 1126 | + | require.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, groups["frontend"]) | |||
| 1127 | + | ||||
| 1128 | + | // Trigger a Save via AddRoute (which calls Save internally) | |||
| 1129 | + | err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"}) | |||
| 1130 | + | require.NoError(t, err) | |||
| 1131 | + | ||||
| 1132 | + | // Re-read the file to verify network_groups was persisted | |||
| 1133 | + | v2 := viper.New() | |||
| 1134 | + | v2.SetConfigFile(configFile) | |||
| 1135 | + | err = v2.ReadInConfig() | |||
| 1136 | + | require.NoError(t, err) | |||
| 1137 | + | ||||
| 1138 | + | // Verify network_groups is still intact and correct after save | |||
| 1139 | + | savedGroups := loadStringArrayMap(v2.Get("network_groups")) | |||
| 1140 | + | assert.Len(t, savedGroups, 1, "network_groups should still be present in saved config") | |||
| 1141 | + | assert.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, savedGroups["frontend"]) | |||
| 1142 | + | ||||
| 1143 | + | // Verify auth fields are preserved | |||
| 1144 | + | assert.Equal(t, "admin", v2.GetString("auth.username")) | |||
| 1145 | + | assert.Equal(t, "supersecretvalue", v2.GetString("auth.token_secret")) | |||
| 1146 | + | ||||
| 1147 | + | // Verify server fields are preserved | |||
| 1148 | + | assert.Equal(t, 9999, v2.GetInt("server.port")) | |||
| 1149 | + | }) | |||
| 1150 | + | ||||
| 1151 | + | // Regression test: external_routes keys contain dots (e.g. "reg.example.com"). | |||
| 1152 | + | // Without explicitly calling viper.Set("external_routes", ...) before WriteConfig, | |||
| 1153 | + | // viper splits dotted keys into nested subtrees, corrupting the data on re-read. | |||
| 1154 | + | t.Run("external_routes with dotted domain keys are not corrupted on Save", func(t *testing.T) { | |||
| 1155 | + | tmpDir := t.TempDir() | |||
| 1156 | + | configFile := filepath.Join(tmpDir, "gordon.toml") | |||
| 1157 | + | initialConfig := ` | |||
| 1158 | + | [server] | |||
| 1159 | + | port = 8080 | |||
| 1160 | + | ||||
| 1161 | + | [routes] | |||
| 1162 | + | "app.example.com" = "myapp:latest" | |||
| 1163 | + | ||||
| 1164 | + | [external_routes] | |||
| 1165 | + | "reg.example.com" = "localhost:5000" | |||
| 1166 | + | ` | |||
| 1167 | + | err := os.WriteFile(configFile, []byte(initialConfig), 0600) | |||
| 1168 | + | require.NoError(t, err) | |||
| 1169 | + | ||||
| 1170 | + | v := viper.New() | |||
| 1171 | + | v.SetConfigFile(configFile) | |||
| 1172 | + | err = v.ReadInConfig() | |||
| 1173 | + | require.NoError(t, err) | |||
| 1174 | + | ||||
| 1175 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 1176 | + | svc := NewService(v, eventBus) | |||
| 1177 | + | ctx := testContext() | |||
| 1178 | + | ||||
| 1179 | + | err = svc.Load(ctx) | |||
| 1180 | + | require.NoError(t, err) | |||
| 1181 | + | ||||
| 1182 | + | // Verify external_routes loaded correctly in memory | |||
| 1183 | + | extRoutes := svc.GetExternalRoutes() | |||
| 1184 | + | require.Equal(t, "localhost:5000", extRoutes["reg.example.com"]) | |||
| 1185 | + | ||||
| 1186 | + | // Trigger Save | |||
| 1187 | + | err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"}) | |||
| 1188 | + | require.NoError(t, err) | |||
| 1189 | + | ||||
| 1190 | + | // Re-read the file with a fresh viper instance | |||
| 1191 | + | v2 := viper.New() | |||
| 1192 | + | v2.SetConfigFile(configFile) | |||
| 1193 | + | err = v2.ReadInConfig() | |||
| 1194 | + | require.NoError(t, err) | |||
| 1195 | + | ||||
| 1196 | + | // external_routes must survive Save with correct flat map structure. | |||
| 1197 | + | // Without the fix, viper splits "reg.example.com" into nested TOML subtrees | |||
| 1198 | + | // and GetString("external_routes.reg\\.example\\.com") returns empty string. | |||
| 1199 | + | savedExtRoutes := loadStringMap(v2.Get("external_routes")) | |||
| 1200 | + | assert.Equal(t, "localhost:5000", savedExtRoutes["reg.example.com"], | |||
| 1201 | + | "external_routes must not be corrupted by viper's dot-path splitting on WriteConfig") | |||
| 1202 | + | }) | |||
| 1203 | + | ||||
| 1204 | + | t.Run("network_groups added in memory are written to disk on Save", func(t *testing.T) { | |||
| 1205 | + | // Setup: config file WITHOUT network_groups | |||
| 1206 | + | tmpDir := t.TempDir() | |||
| 1207 | + | configFile := filepath.Join(tmpDir, "gordon.toml") | |||
| 1208 | + | initialConfig := ` | |||
| 1209 | + | [server] | |||
| 1210 | + | port = 8080 | |||
| 1211 | + | ||||
| 1212 | + | [routes] | |||
| 1213 | + | "app.example.com" = "myapp:latest" | |||
| 1214 | + | ` | |||
| 1215 | + | err := os.WriteFile(configFile, []byte(initialConfig), 0600) | |||
| 1216 | + | require.NoError(t, err) | |||
| 1217 | + | ||||
| 1218 | + | v := viper.New() | |||
| 1219 | + | v.SetConfigFile(configFile) | |||
| 1220 | + | err = v.ReadInConfig() | |||
| 1221 | + | require.NoError(t, err) | |||
| 1222 | + | ||||
| 1223 | + | // Inject network_groups into viper as if they were loaded from some other source | |||
| 1224 | + | // This simulates a case where network_groups exist in memory but not on disk | |||
| 1225 | + | v.Set("network_groups", map[string]interface{}{ | |||
| 1226 | + | "backend": []interface{}{"api.example.com"}, | |||
| 1227 | + | }) | |||
| 1228 | + | ||||
| 1229 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 1230 | + | svc := NewService(v, eventBus) | |||
| 1231 | + | ctx := testContext() | |||
| 1232 | + | ||||
| 1233 | + | err = svc.Load(ctx) | |||
| 1234 | + | require.NoError(t, err) | |||
| 1235 | + | ||||
| 1236 | + | // Verify network_groups is loaded in memory | |||
| 1237 | + | groups := svc.GetNetworkGroups() | |||
| 1238 | + | require.Len(t, groups, 1) | |||
| 1239 | + | ||||
| 1240 | + | // Trigger Save | |||
| 1241 | + | err = svc.AddRoute(ctx, domain.Route{Domain: "new.example.com", Image: "newapp:latest"}) | |||
| 1242 | + | require.NoError(t, err) | |||
| 1243 | + | ||||
| 1244 | + | // Re-read the file | |||
| 1245 | + | v2 := viper.New() | |||
| 1246 | + | v2.SetConfigFile(configFile) | |||
| 1247 | + | err = v2.ReadInConfig() | |||
| 1248 | + | require.NoError(t, err) | |||
| 1249 | + | ||||
| 1250 | + | // network_groups must be present in the saved file | |||
| 1251 | + | savedGroups := loadStringArrayMap(v2.Get("network_groups")) | |||
| 1252 | + | assert.Len(t, savedGroups, 1, "network_groups added in memory must be written on Save") | |||
| 1253 | + | assert.ElementsMatch(t, []string{"api.example.com"}, savedGroups["backend"]) | |||
| 1254 | + | }) | |||
| 1255 | + | } | |||
| 1256 | + | ||||
| 1094 | func TestExtractDomainFromImageName(t *testing.T) { | 1257 | func TestExtractDomainFromImageName(t *testing.T) { | ||
| 1095 | tests := []struct { | 1258 | tests := []struct { | ||
| 1096 | name string | 1259 | name string | ||
M internal/usecase/container/autoroute.go +1 -1
| @@ -493,7 +493,7 @@ func writeEnvFile(path string, env map[string]string) error { | |||
| 493 | 493 | if strings.ContainsAny(v, " \t\n\"'$\\") { | |
| 494 | 494 | v = fmt.Sprintf("\"%s\"", strings.ReplaceAll(v, "\"", "\\\"")) | |
| 495 | 495 | } | |
| 496 | - | buf.WriteString(fmt.Sprintf("%s=%s\n", k, v)) | |
| 496 | + | fmt.Fprintf(&buf, "%s=%s\n", k, v) | |
| 497 | 497 | } | |
| 498 | 498 | ||
| 499 | 499 | return os.WriteFile(path, buf.Bytes(), 0600) | |
| @@ -493,7 +493,7 @@ func writeEnvFile(path string, env map[string]string) error { | |||||
| 493 | if strings.ContainsAny(v, " \t\n\"'$\\") { | 493 | if strings.ContainsAny(v, " \t\n\"'$\\") { | ||
| 494 | v = fmt.Sprintf("\"%s\"", strings.ReplaceAll(v, "\"", "\\\"")) | 494 | v = fmt.Sprintf("\"%s\"", strings.ReplaceAll(v, "\"", "\\\"")) | ||
| 495 | } | 495 | } | ||
| 496 | - | buf.WriteString(fmt.Sprintf("%s=%s\n", k, v)) | 496 | + | fmt.Fprintf(&buf, "%s=%s\n", k, v) |
| 497 | } | 497 | } | ||
| 498 | 498 | ||||
| 499 | return os.WriteFile(path, buf.Bytes(), 0600) | 499 | return os.WriteFile(path, buf.Bytes(), 0600) | ||
M internal/usecase/container/events.go +9 -2
| @@ -107,11 +107,18 @@ func (h *ConfigReloadHandler) Handle(ctx context.Context, event domain.Event) er | |||
| 107 | 107 | log.Warn().Err(err).Msg("failed to sync containers before reload, proceeding with current state") | |
| 108 | 108 | } | |
| 109 | 109 | ||
| 110 | + | // Propagate updated attachment configuration so new attachments take effect | |
| 111 | + | // without requiring a Gordon restart (fixes issue #87 part 2). | |
| 112 | + | attachments := h.configSvc.GetAllAttachments(ctx) | |
| 113 | + | log.Debug().Int("attachment_groups", len(attachments)).Msg("propagating updated attachment configuration") | |
| 114 | + | h.containerSvc.UpdateAttachments(attachments) | |
| 115 | + | log.Debug().Int("attachment_groups", len(attachments)).Msg("attachment configuration propagated") | |
| 116 | + | ||
| 110 | 117 | currentContainers := h.containerSvc.List(ctx) | |
| 111 | 118 | ||
| 112 | 119 | activeRoutes := make(map[string]*domain.Container) | |
| 113 | 120 | for _, container := range currentContainers { | |
| 114 | - | if route, exists := container.Labels["gordon.route"]; exists { | |
| 121 | + | if route, exists := container.Labels[domain.LabelRoute]; exists { | |
| 115 | 122 | activeRoutes[route] = container | |
| 116 | 123 | } | |
| 117 | 124 | } | |
| @@ -119,7 +126,7 @@ func (h *ConfigReloadHandler) Handle(ctx context.Context, event domain.Event) er | |||
| 119 | 126 | routes := h.configSvc.GetRoutes(ctx) | |
| 120 | 127 | for _, route := range routes { | |
| 121 | 128 | if container, exists := activeRoutes[route.Domain]; exists { | |
| 122 | - | currentImage := container.Labels["gordon.image"] | |
| 129 | + | currentImage := container.Labels[domain.LabelImage] | |
| 123 | 130 | if currentImage != route.Image { | |
| 124 | 131 | log.Info(). | |
| 125 | 132 | Str("domain", route.Domain). | |
| @@ -107,11 +107,18 @@ func (h *ConfigReloadHandler) Handle(ctx context.Context, event domain.Event) er | |||||
| 107 | log.Warn().Err(err).Msg("failed to sync containers before reload, proceeding with current state") | 107 | log.Warn().Err(err).Msg("failed to sync containers before reload, proceeding with current state") | ||
| 108 | } | 108 | } | ||
| 109 | 109 | ||||
| 110 | + | // Propagate updated attachment configuration so new attachments take effect | |||
| 111 | + | // without requiring a Gordon restart (fixes issue #87 part 2). | |||
| 112 | + | attachments := h.configSvc.GetAllAttachments(ctx) | |||
| 113 | + | log.Debug().Int("attachment_groups", len(attachments)).Msg("propagating updated attachment configuration") | |||
| 114 | + | h.containerSvc.UpdateAttachments(attachments) | |||
| 115 | + | log.Debug().Int("attachment_groups", len(attachments)).Msg("attachment configuration propagated") | |||
| 116 | + | ||||
| 110 | currentContainers := h.containerSvc.List(ctx) | 117 | currentContainers := h.containerSvc.List(ctx) | ||
| 111 | 118 | ||||
| 112 | activeRoutes := make(map[string]*domain.Container) | 119 | activeRoutes := make(map[string]*domain.Container) | ||
| 113 | for _, container := range currentContainers { | 120 | for _, container := range currentContainers { | ||
| 114 | - | if route, exists := container.Labels["gordon.route"]; exists { | 121 | + | if route, exists := container.Labels[domain.LabelRoute]; exists { |
| 115 | activeRoutes[route] = container | 122 | activeRoutes[route] = container | ||
| 116 | } | 123 | } | ||
| 117 | } | 124 | } | ||
| @@ -119,7 +126,7 @@ func (h *ConfigReloadHandler) Handle(ctx context.Context, event domain.Event) er | |||||
| 119 | routes := h.configSvc.GetRoutes(ctx) | 126 | routes := h.configSvc.GetRoutes(ctx) | ||
| 120 | for _, route := range routes { | 127 | for _, route := range routes { | ||
| 121 | if container, exists := activeRoutes[route.Domain]; exists { | 128 | if container, exists := activeRoutes[route.Domain]; exists { | ||
| 122 | - | currentImage := container.Labels["gordon.image"] | 129 | + | currentImage := container.Labels[domain.LabelImage] |
| 123 | if currentImage != route.Image { | 130 | if currentImage != route.Image { | ||
| 124 | log.Info(). | 131 | log.Info(). | ||
| 125 | Str("domain", route.Domain). | 132 | Str("domain", route.Domain). | ||
M internal/usecase/container/events_test.go +50 -0
| @@ -209,6 +209,10 @@ func TestConfigReloadHandler_Handle_DeploysNewRoutes(t *testing.T) { | |||
| 209 | 209 | // SyncContainers is called first | |
| 210 | 210 | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil) | |
| 211 | 211 | ||
| 212 | + | // Attachment config is propagated after sync | |
| 213 | + | configSvc.EXPECT().GetAllAttachments(mock.Anything).Return(map[string][]string{}) | |
| 214 | + | containerSvc.EXPECT().UpdateAttachments(map[string][]string{}).Return() | |
| 215 | + | ||
| 212 | 216 | // No existing containers | |
| 213 | 217 | containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{}) | |
| 214 | 218 | ||
| @@ -241,6 +245,10 @@ func TestConfigReloadHandler_Handle_StopsRemovedRoutes(t *testing.T) { | |||
| 241 | 245 | // SyncContainers is called first | |
| 242 | 246 | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil) | |
| 243 | 247 | ||
| 248 | + | // Attachment config is propagated after sync | |
| 249 | + | configSvc.EXPECT().GetAllAttachments(mock.Anything).Return(map[string][]string{}) | |
| 250 | + | containerSvc.EXPECT().UpdateAttachments(map[string][]string{}).Return() | |
| 251 | + | ||
| 244 | 252 | // Existing container for a route that's no longer configured | |
| 245 | 253 | containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{ | |
| 246 | 254 | "removed.example.com": { | |
| @@ -277,6 +285,10 @@ func TestConfigReloadHandler_Handle_RedeploysChangedImage(t *testing.T) { | |||
| 277 | 285 | // SyncContainers is called first | |
| 278 | 286 | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil) | |
| 279 | 287 | ||
| 288 | + | // Attachment config is propagated after sync | |
| 289 | + | configSvc.EXPECT().GetAllAttachments(mock.Anything).Return(map[string][]string{}) | |
| 290 | + | containerSvc.EXPECT().UpdateAttachments(map[string][]string{}).Return() | |
| 291 | + | ||
| 280 | 292 | // Existing container with old image | |
| 281 | 293 | containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{ | |
| 282 | 294 | "app.example.com": { | |
| @@ -317,6 +329,10 @@ func TestConfigReloadHandler_Handle_NoChanges(t *testing.T) { | |||
| 317 | 329 | // SyncContainers is called first | |
| 318 | 330 | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil) | |
| 319 | 331 | ||
| 332 | + | // Attachment config is propagated after sync | |
| 333 | + | configSvc.EXPECT().GetAllAttachments(mock.Anything).Return(map[string][]string{}) | |
| 334 | + | containerSvc.EXPECT().UpdateAttachments(map[string][]string{}).Return() | |
| 335 | + | ||
| 320 | 336 | // Existing container matches config | |
| 321 | 337 | containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{ | |
| 322 | 338 | "app.example.com": { | |
| @@ -494,6 +510,40 @@ func TestManualReloadHandler_Handle_DoesNotRestartRunningContainers(t *testing.T | |||
| 494 | 510 | ||
| 495 | 511 | // ManualDeployHandler tests | |
| 496 | 512 | ||
| 513 | + | func TestConfigReloadHandlerUpdatesContainerConfig(t *testing.T) { | |
| 514 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 515 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 516 | + | ||
| 517 | + | handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc) | |
| 518 | + | ||
| 519 | + | // SyncContainers is called first | |
| 520 | + | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil) | |
| 521 | + | ||
| 522 | + | // No existing containers | |
| 523 | + | containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{}) | |
| 524 | + | ||
| 525 | + | // New attachments from config | |
| 526 | + | newAttachments := map[string][]string{ | |
| 527 | + | "app.example.com": {"postgres:15", "redis:7"}, | |
| 528 | + | } | |
| 529 | + | configSvc.EXPECT().GetAllAttachments(mock.Anything).Return(newAttachments) | |
| 530 | + | ||
| 531 | + | // Expect UpdateAttachments to be called with new attachment config | |
| 532 | + | containerSvc.EXPECT().UpdateAttachments(newAttachments).Return() | |
| 533 | + | ||
| 534 | + | // No routes configured | |
| 535 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}) | |
| 536 | + | ||
| 537 | + | event := domain.Event{ | |
| 538 | + | ID: "event-123", | |
| 539 | + | Type: domain.EventConfigReload, | |
| 540 | + | } | |
| 541 | + | ||
| 542 | + | err := handler.Handle(context.Background(), event) | |
| 543 | + | ||
| 544 | + | assert.NoError(t, err) | |
| 545 | + | } | |
| 546 | + | ||
| 497 | 547 | func TestManualDeployHandler_CanHandle(t *testing.T) { | |
| 498 | 548 | containerSvc := inmocks.NewMockContainerService(t) | |
| 499 | 549 | configSvc := inmocks.NewMockConfigService(t) | |
| @@ -209,6 +209,10 @@ func TestConfigReloadHandler_Handle_DeploysNewRoutes(t *testing.T) { | |||||
| 209 | // SyncContainers is called first | 209 | // SyncContainers is called first | ||
| 210 | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil) | 210 | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil) | ||
| 211 | 211 | ||||
| 212 | + | // Attachment config is propagated after sync | |||
| 213 | + | configSvc.EXPECT().GetAllAttachments(mock.Anything).Return(map[string][]string{}) | |||
| 214 | + | containerSvc.EXPECT().UpdateAttachments(map[string][]string{}).Return() | |||
| 215 | + | ||||
| 212 | // No existing containers | 216 | // No existing containers | ||
| 213 | containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{}) | 217 | containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{}) | ||
| 214 | 218 | ||||
| @@ -241,6 +245,10 @@ func TestConfigReloadHandler_Handle_StopsRemovedRoutes(t *testing.T) { | |||||
| 241 | // SyncContainers is called first | 245 | // SyncContainers is called first | ||
| 242 | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil) | 246 | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil) | ||
| 243 | 247 | ||||
| 248 | + | // Attachment config is propagated after sync | |||
| 249 | + | configSvc.EXPECT().GetAllAttachments(mock.Anything).Return(map[string][]string{}) | |||
| 250 | + | containerSvc.EXPECT().UpdateAttachments(map[string][]string{}).Return() | |||
| 251 | + | ||||
| 244 | // Existing container for a route that's no longer configured | 252 | // Existing container for a route that's no longer configured | ||
| 245 | containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{ | 253 | containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{ | ||
| 246 | "removed.example.com": { | 254 | "removed.example.com": { | ||
| @@ -277,6 +285,10 @@ func TestConfigReloadHandler_Handle_RedeploysChangedImage(t *testing.T) { | |||||
| 277 | // SyncContainers is called first | 285 | // SyncContainers is called first | ||
| 278 | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil) | 286 | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil) | ||
| 279 | 287 | ||||
| 288 | + | // Attachment config is propagated after sync | |||
| 289 | + | configSvc.EXPECT().GetAllAttachments(mock.Anything).Return(map[string][]string{}) | |||
| 290 | + | containerSvc.EXPECT().UpdateAttachments(map[string][]string{}).Return() | |||
| 291 | + | ||||
| 280 | // Existing container with old image | 292 | // Existing container with old image | ||
| 281 | containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{ | 293 | containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{ | ||
| 282 | "app.example.com": { | 294 | "app.example.com": { | ||
| @@ -317,6 +329,10 @@ func TestConfigReloadHandler_Handle_NoChanges(t *testing.T) { | |||||
| 317 | // SyncContainers is called first | 329 | // SyncContainers is called first | ||
| 318 | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil) | 330 | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil) | ||
| 319 | 331 | ||||
| 332 | + | // Attachment config is propagated after sync | |||
| 333 | + | configSvc.EXPECT().GetAllAttachments(mock.Anything).Return(map[string][]string{}) | |||
| 334 | + | containerSvc.EXPECT().UpdateAttachments(map[string][]string{}).Return() | |||
| 335 | + | ||||
| 320 | // Existing container matches config | 336 | // Existing container matches config | ||
| 321 | containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{ | 337 | containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{ | ||
| 322 | "app.example.com": { | 338 | "app.example.com": { | ||
| @@ -494,6 +510,40 @@ func TestManualReloadHandler_Handle_DoesNotRestartRunningContainers(t *testing.T | |||||
| 494 | 510 | ||||
| 495 | // ManualDeployHandler tests | 511 | // ManualDeployHandler tests | ||
| 496 | 512 | ||||
| 513 | + | func TestConfigReloadHandlerUpdatesContainerConfig(t *testing.T) { | |||
| 514 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 515 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 516 | + | ||||
| 517 | + | handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc) | |||
| 518 | + | ||||
| 519 | + | // SyncContainers is called first | |||
| 520 | + | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil) | |||
| 521 | + | ||||
| 522 | + | // No existing containers | |||
| 523 | + | containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{}) | |||
| 524 | + | ||||
| 525 | + | // New attachments from config | |||
| 526 | + | newAttachments := map[string][]string{ | |||
| 527 | + | "app.example.com": {"postgres:15", "redis:7"}, | |||
| 528 | + | } | |||
| 529 | + | configSvc.EXPECT().GetAllAttachments(mock.Anything).Return(newAttachments) | |||
| 530 | + | ||||
| 531 | + | // Expect UpdateAttachments to be called with new attachment config | |||
| 532 | + | containerSvc.EXPECT().UpdateAttachments(newAttachments).Return() | |||
| 533 | + | ||||
| 534 | + | // No routes configured | |||
| 535 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}) | |||
| 536 | + | ||||
| 537 | + | event := domain.Event{ | |||
| 538 | + | ID: "event-123", | |||
| 539 | + | Type: domain.EventConfigReload, | |||
| 540 | + | } | |||
| 541 | + | ||||
| 542 | + | err := handler.Handle(context.Background(), event) | |||
| 543 | + | ||||
| 544 | + | assert.NoError(t, err) | |||
| 545 | + | } | |||
| 546 | + | ||||
| 497 | func TestManualDeployHandler_CanHandle(t *testing.T) { | 547 | func TestManualDeployHandler_CanHandle(t *testing.T) { | ||
| 498 | containerSvc := inmocks.NewMockContainerService(t) | 548 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 499 | configSvc := inmocks.NewMockConfigService(t) | 549 | configSvc := inmocks.NewMockConfigService(t) | ||
M internal/usecase/container/service.go +39 -11
| @@ -219,10 +219,10 @@ func (s *Service) buildContainerConfig(containerDomain, image, actualImageRef st | |||
| 219 | 219 | NetworkMode: networkName, | |
| 220 | 220 | Hostname: containerDomain, | |
| 221 | 221 | Labels: map[string]string{ | |
| 222 | - | "gordon.domain": containerDomain, | |
| 223 | - | "gordon.image": image, | |
| 224 | - | "gordon.managed": "true", | |
| 225 | - | "gordon.route": containerDomain, | |
| 222 | + | domain.LabelDomain: containerDomain, | |
| 223 | + | domain.LabelImage: image, | |
| 224 | + | domain.LabelManaged: "true", | |
| 225 | + | domain.LabelRoute: containerDomain, | |
| 226 | 226 | }, | |
| 227 | 227 | AutoRemove: false, | |
| 228 | 228 | } | |
| @@ -903,16 +903,26 @@ func (s *Service) SyncContainers(ctx context.Context) error { | |||
| 903 | 903 | } | |
| 904 | 904 | ||
| 905 | 905 | managed := make(map[string]*domain.Container) | |
| 906 | + | attachments := make(map[string][]string) | |
| 906 | 907 | for _, c := range allContainers { | |
| 907 | - | if c.Labels != nil { | |
| 908 | - | if d, ok := c.Labels["gordon.domain"]; ok && c.Labels["gordon.managed"] == "true" { | |
| 909 | - | managed[d] = c | |
| 908 | + | if c.Labels == nil || c.Labels[domain.LabelManaged] != "true" { | |
| 909 | + | continue | |
| 910 | + | } | |
| 911 | + | if c.Labels[domain.LabelAttachment] == "true" { | |
| 912 | + | owner := c.Labels[domain.LabelAttachedTo] | |
| 913 | + | if owner != "" { | |
| 914 | + | attachments[owner] = append(attachments[owner], c.ID) | |
| 910 | 915 | } | |
| 916 | + | continue | |
| 917 | + | } | |
| 918 | + | if d, ok := c.Labels[domain.LabelDomain]; ok { | |
| 919 | + | managed[d] = c | |
| 911 | 920 | } | |
| 912 | 921 | } | |
| 913 | 922 | ||
| 914 | 923 | s.mu.Lock() | |
| 915 | 924 | s.containers = managed | |
| 925 | + | s.attachments = attachments | |
| 916 | 926 | newCount := int64(len(managed)) | |
| 917 | 927 | delta := newCount - s.managedCount | |
| 918 | 928 | s.managedCount = newCount | |
| @@ -1061,6 +1071,24 @@ func (s *Service) UpdateConfig(config Config) { | |||
| 1061 | 1071 | s.mu.Unlock() | |
| 1062 | 1072 | } | |
| 1063 | 1073 | ||
| 1074 | + | // UpdateAttachments updates only the attachment configuration in the service. | |
| 1075 | + | // This is called after a config reload to propagate attachment changes without restart. | |
| 1076 | + | // The incoming map is deep-copied so external callers cannot mutate service state. | |
| 1077 | + | func (s *Service) UpdateAttachments(attachments map[string][]string) { | |
| 1078 | + | var copied map[string][]string | |
| 1079 | + | if attachments != nil { | |
| 1080 | + | copied = make(map[string][]string, len(attachments)) | |
| 1081 | + | for k, v := range attachments { | |
| 1082 | + | sl := make([]string, len(v)) | |
| 1083 | + | copy(sl, v) | |
| 1084 | + | copied[k] = sl | |
| 1085 | + | } | |
| 1086 | + | } | |
| 1087 | + | s.mu.Lock() | |
| 1088 | + | s.config.Attachments = copied | |
| 1089 | + | s.mu.Unlock() | |
| 1090 | + | } | |
| 1091 | + | ||
| 1064 | 1092 | // Helper methods | |
| 1065 | 1093 | ||
| 1066 | 1094 | // cleanupFailedContainer stops and removes a container that failed to start properly. | |
| @@ -1875,10 +1903,10 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||
| 1875 | 1903 | Volumes: volumes, | |
| 1876 | 1904 | NetworkMode: networkName, // Same network as main app | |
| 1877 | 1905 | Labels: map[string]string{ | |
| 1878 | - | "gordon.managed": "true", | |
| 1879 | - | "gordon.attachment": "true", | |
| 1880 | - | "gordon.attached-to": ownerDomain, | |
| 1881 | - | "gordon.image": serviceImage, | |
| 1906 | + | domain.LabelManaged: "true", | |
| 1907 | + | domain.LabelAttachment: "true", | |
| 1908 | + | domain.LabelAttachedTo: ownerDomain, | |
| 1909 | + | domain.LabelImage: serviceImage, | |
| 1882 | 1910 | }, | |
| 1883 | 1911 | } | |
| 1884 | 1912 | ||
| @@ -219,10 +219,10 @@ func (s *Service) buildContainerConfig(containerDomain, image, actualImageRef st | |||||
| 219 | NetworkMode: networkName, | 219 | NetworkMode: networkName, | ||
| 220 | Hostname: containerDomain, | 220 | Hostname: containerDomain, | ||
| 221 | Labels: map[string]string{ | 221 | Labels: map[string]string{ | ||
| 222 | - | "gordon.domain": containerDomain, | 222 | + | domain.LabelDomain: containerDomain, |
| 223 | - | "gordon.image": image, | 223 | + | domain.LabelImage: image, |
| 224 | - | "gordon.managed": "true", | 224 | + | domain.LabelManaged: "true", |
| 225 | - | "gordon.route": containerDomain, | 225 | + | domain.LabelRoute: containerDomain, |
| 226 | }, | 226 | }, | ||
| 227 | AutoRemove: false, | 227 | AutoRemove: false, | ||
| 228 | } | 228 | } | ||
| @@ -903,16 +903,26 @@ func (s *Service) SyncContainers(ctx context.Context) error { | |||||
| 903 | } | 903 | } | ||
| 904 | 904 | ||||
| 905 | managed := make(map[string]*domain.Container) | 905 | managed := make(map[string]*domain.Container) | ||
| 906 | + | attachments := make(map[string][]string) | |||
| 906 | for _, c := range allContainers { | 907 | for _, c := range allContainers { | ||
| 907 | - | if c.Labels != nil { | 908 | + | if c.Labels == nil || c.Labels[domain.LabelManaged] != "true" { |
| 908 | - | if d, ok := c.Labels["gordon.domain"]; ok && c.Labels["gordon.managed"] == "true" { | 909 | + | continue |
| 909 | - | managed[d] = c | 910 | + | } |
| 911 | + | if c.Labels[domain.LabelAttachment] == "true" { | |||
| 912 | + | owner := c.Labels[domain.LabelAttachedTo] | |||
| 913 | + | if owner != "" { | |||
| 914 | + | attachments[owner] = append(attachments[owner], c.ID) | |||
| 910 | } | 915 | } | ||
| 916 | + | continue | |||
| 917 | + | } | |||
| 918 | + | if d, ok := c.Labels[domain.LabelDomain]; ok { | |||
| 919 | + | managed[d] = c | |||
| 911 | } | 920 | } | ||
| 912 | } | 921 | } | ||
| 913 | 922 | ||||
| 914 | s.mu.Lock() | 923 | s.mu.Lock() | ||
| 915 | s.containers = managed | 924 | s.containers = managed | ||
| 925 | + | s.attachments = attachments | |||
| 916 | newCount := int64(len(managed)) | 926 | newCount := int64(len(managed)) | ||
| 917 | delta := newCount - s.managedCount | 927 | delta := newCount - s.managedCount | ||
| 918 | s.managedCount = newCount | 928 | s.managedCount = newCount | ||
| @@ -1061,6 +1071,24 @@ func (s *Service) UpdateConfig(config Config) { | |||||
| 1061 | s.mu.Unlock() | 1071 | s.mu.Unlock() | ||
| 1062 | } | 1072 | } | ||
| 1063 | 1073 | ||||
| 1074 | + | // UpdateAttachments updates only the attachment configuration in the service. | |||
| 1075 | + | // This is called after a config reload to propagate attachment changes without restart. | |||
| 1076 | + | // The incoming map is deep-copied so external callers cannot mutate service state. | |||
| 1077 | + | func (s *Service) UpdateAttachments(attachments map[string][]string) { | |||
| 1078 | + | var copied map[string][]string | |||
| 1079 | + | if attachments != nil { | |||
| 1080 | + | copied = make(map[string][]string, len(attachments)) | |||
| 1081 | + | for k, v := range attachments { | |||
| 1082 | + | sl := make([]string, len(v)) | |||
| 1083 | + | copy(sl, v) | |||
| 1084 | + | copied[k] = sl | |||
| 1085 | + | } | |||
| 1086 | + | } | |||
| 1087 | + | s.mu.Lock() | |||
| 1088 | + | s.config.Attachments = copied | |||
| 1089 | + | s.mu.Unlock() | |||
| 1090 | + | } | |||
| 1091 | + | ||||
| 1064 | // Helper methods | 1092 | // Helper methods | ||
| 1065 | 1093 | ||||
| 1066 | // cleanupFailedContainer stops and removes a container that failed to start properly. | 1094 | // cleanupFailedContainer stops and removes a container that failed to start properly. | ||
| @@ -1875,10 +1903,10 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||||
| 1875 | Volumes: volumes, | 1903 | Volumes: volumes, | ||
| 1876 | NetworkMode: networkName, // Same network as main app | 1904 | NetworkMode: networkName, // Same network as main app | ||
| 1877 | Labels: map[string]string{ | 1905 | Labels: map[string]string{ | ||
| 1878 | - | "gordon.managed": "true", | 1906 | + | domain.LabelManaged: "true", |
| 1879 | - | "gordon.attachment": "true", | 1907 | + | domain.LabelAttachment: "true", |
| 1880 | - | "gordon.attached-to": ownerDomain, | 1908 | + | domain.LabelAttachedTo: ownerDomain, |
| 1881 | - | "gordon.image": serviceImage, | 1909 | + | domain.LabelImage: serviceImage, |
| 1882 | }, | 1910 | }, | ||
| 1883 | } | 1911 | } | ||
| 1884 | 1912 | ||||
M internal/usecase/container/service_test.go +43 -0
| @@ -2332,6 +2332,49 @@ func TestService_Deploy_DoesNotSkipWhenImageIDDiffers(t *testing.T) { | |||
| 2332 | 2332 | assert.Equal(t, "new-container", result.ID) | |
| 2333 | 2333 | } | |
| 2334 | 2334 | ||
| 2335 | + | func TestSyncContainersRebuildsAttachmentMap(t *testing.T) { | |
| 2336 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 2337 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 2338 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 2339 | + | ||
| 2340 | + | svc := NewService(runtime, envLoader, eventBus, nil, Config{}) | |
| 2341 | + | ctx := testContext() | |
| 2342 | + | ||
| 2343 | + | // ListContainers returns one main container and one attachment container | |
| 2344 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{ | |
| 2345 | + | { | |
| 2346 | + | ID: "main-container-1", | |
| 2347 | + | Labels: map[string]string{ | |
| 2348 | + | domain.LabelDomain: "app.example.com", | |
| 2349 | + | domain.LabelManaged: "true", | |
| 2350 | + | }, | |
| 2351 | + | }, | |
| 2352 | + | { | |
| 2353 | + | ID: "attachment-container-1", | |
| 2354 | + | Labels: map[string]string{ | |
| 2355 | + | domain.LabelManaged: "true", | |
| 2356 | + | domain.LabelAttachment: "true", | |
| 2357 | + | domain.LabelAttachedTo: "app.example.com", | |
| 2358 | + | }, | |
| 2359 | + | }, | |
| 2360 | + | }, nil).Once() | |
| 2361 | + | ||
| 2362 | + | require.NoError(t, svc.SyncContainers(ctx)) | |
| 2363 | + | ||
| 2364 | + | // Main container should be in s.containers | |
| 2365 | + | tracked, exists := svc.Get(ctx, "app.example.com") | |
| 2366 | + | require.True(t, exists, "main container should be tracked after SyncContainers") | |
| 2367 | + | assert.Equal(t, "main-container-1", tracked.ID) | |
| 2368 | + | ||
| 2369 | + | // Attachment container should be in s.attachments under the owner domain | |
| 2370 | + | svc.mu.RLock() | |
| 2371 | + | attachIDs := svc.attachments["app.example.com"] | |
| 2372 | + | svc.mu.RUnlock() | |
| 2373 | + | ||
| 2374 | + | require.Len(t, attachIDs, 1, "attachment map should contain one entry for app.example.com after SyncContainers") | |
| 2375 | + | assert.Equal(t, "attachment-container-1", attachIDs[0]) | |
| 2376 | + | } | |
| 2377 | + | ||
| 2335 | 2378 | func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) { | |
| 2336 | 2379 | runtime := mocks.NewMockContainerRuntime(t) | |
| 2337 | 2380 | envLoader := mocks.NewMockEnvLoader(t) | |
| @@ -2332,6 +2332,49 @@ func TestService_Deploy_DoesNotSkipWhenImageIDDiffers(t *testing.T) { | |||||
| 2332 | assert.Equal(t, "new-container", result.ID) | 2332 | assert.Equal(t, "new-container", result.ID) | ||
| 2333 | } | 2333 | } | ||
| 2334 | 2334 | ||||
| 2335 | + | func TestSyncContainersRebuildsAttachmentMap(t *testing.T) { | |||
| 2336 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 2337 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 2338 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 2339 | + | ||||
| 2340 | + | svc := NewService(runtime, envLoader, eventBus, nil, Config{}) | |||
| 2341 | + | ctx := testContext() | |||
| 2342 | + | ||||
| 2343 | + | // ListContainers returns one main container and one attachment container | |||
| 2344 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{ | |||
| 2345 | + | { | |||
| 2346 | + | ID: "main-container-1", | |||
| 2347 | + | Labels: map[string]string{ | |||
| 2348 | + | domain.LabelDomain: "app.example.com", | |||
| 2349 | + | domain.LabelManaged: "true", | |||
| 2350 | + | }, | |||
| 2351 | + | }, | |||
| 2352 | + | { | |||
| 2353 | + | ID: "attachment-container-1", | |||
| 2354 | + | Labels: map[string]string{ | |||
| 2355 | + | domain.LabelManaged: "true", | |||
| 2356 | + | domain.LabelAttachment: "true", | |||
| 2357 | + | domain.LabelAttachedTo: "app.example.com", | |||
| 2358 | + | }, | |||
| 2359 | + | }, | |||
| 2360 | + | }, nil).Once() | |||
| 2361 | + | ||||
| 2362 | + | require.NoError(t, svc.SyncContainers(ctx)) | |||
| 2363 | + | ||||
| 2364 | + | // Main container should be in s.containers | |||
| 2365 | + | tracked, exists := svc.Get(ctx, "app.example.com") | |||
| 2366 | + | require.True(t, exists, "main container should be tracked after SyncContainers") | |||
| 2367 | + | assert.Equal(t, "main-container-1", tracked.ID) | |||
| 2368 | + | ||||
| 2369 | + | // Attachment container should be in s.attachments under the owner domain | |||
| 2370 | + | svc.mu.RLock() | |||
| 2371 | + | attachIDs := svc.attachments["app.example.com"] | |||
| 2372 | + | svc.mu.RUnlock() | |||
| 2373 | + | ||||
| 2374 | + | require.Len(t, attachIDs, 1, "attachment map should contain one entry for app.example.com after SyncContainers") | |||
| 2375 | + | assert.Equal(t, "attachment-container-1", attachIDs[0]) | |||
| 2376 | + | } | |||
| 2377 | + | ||||
| 2335 | func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) { | 2378 | func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) { | ||
| 2336 | runtime := mocks.NewMockContainerRuntime(t) | 2379 | runtime := mocks.NewMockContainerRuntime(t) | ||
| 2337 | envLoader := mocks.NewMockEnvLoader(t) | 2380 | envLoader := mocks.NewMockEnvLoader(t) | ||