detached d786451

Merge pull request #89 from bnema/fix/gh-issues-83-84-85-86-87-88

fix: address GitHub issues #83–#88 + security & reliability bugs
commit
d7864516fc4222b53b0dc56066a183c4f089dc00
author
bnema · · 2026-02-25 02:29 UTC
committer
GitHub · · 2026-02-25 02:29 UTC
merge of
b86c85a + 32c6f5d · diff against the first parent
signature
unsigned
45 files · +1857 -602
M .github/workflows/ci.yml +1 -1
@@ -47,7 +47,7 @@ jobs:
4747 - name: Run golangci-lint
4848 uses: golangci/golangci-lint-action@v9
4949 with:
50- version: latest
50+ version: v2.10.1
5151
5252 test:
5353 name: Test
@@ -47,7 +47,7 @@ jobs:
47 - name: Run golangci-lint47 - name: Run golangci-lint
48 uses: golangci/golangci-lint-action@v948 uses: golangci/golangci-lint-action@v9
49 with:49 with:
50- version: latest50+ version: v2.10.1
51 51
52 test:52 test:
53 name: Test53 name: Test
M .golangci.yml +20 -0
@@ -58,6 +58,26 @@ linters:
5858 - text: "G104:"
5959 linters:
6060 - 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
6181
6282 formatters:
6383 enable:
@@ -58,6 +58,26 @@ linters:
58 - text: "G104:"58 - text: "G104:"
59 linters:59 linters:
60 - gosec60 - 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
33 // TokenResponse represents the response from the token server.
44 type TokenResponse struct {
55 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
77 ExpiresIn int `json:"expires_in,omitempty"`
88 IssuedAt string `json:"issued_at,omitempty"`
99 }
@@ -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 (
2424 "github.com/bnema/gordon/pkg/duration"
2525 )
2626
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+
2737 // newAuthCmd creates the auth command group.
2838 func newAuthCmd() *cobra.Command {
2939 cmd := &cobra.Command{
@@ -274,7 +284,11 @@ func runAuthLogin(remoteName, username, password string) error {
274284 if password == "" {
275285 // Prompt for password (hidden input)
276286 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)
278292 if err != nil {
279293 // Fallback for non-terminal input
280294 reader := bufio.NewReader(os.Stdin)
@@ -595,7 +609,11 @@ func runPasswordHash() error {
595609 fmt.Print("Enter password: ")
596610
597611 // 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)
599617 if err != nil {
600618 // Fallback for non-terminal input
601619 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 input293 // 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 input618 // 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 (
99 "path/filepath"
1010 "regexp"
1111 "strings"
12+ "time"
1213
1314 tea "github.com/charmbracelet/bubbletea"
1415 "github.com/spf13/cobra"
@@ -463,8 +464,8 @@ func buildAndPush(ctx context.Context, version, platform, dockerfile string, bui
463464 // Cloudflare's 100MB per-request limit. Loading locally then
464465 // using docker push gives us chunked uploads (~5MB per request).
465466 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>
468469 buildCmd.Stdout = os.Stdout
469470 buildCmd.Stderr = os.Stderr
470471 if err := buildCmd.Run(); err != nil {
@@ -484,23 +485,52 @@ func buildAndPush(ctx context.Context, version, platform, dockerfile string, bui
484485 return nil
485486 }
486487
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+
487511 // buildImageArgs constructs the docker buildx build arguments.
488512 // Uses --load instead of --push so the image is loaded into the local
489513 // 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 {
491515 args := []string{
492516 "buildx", "build",
493517 "--platform", platform,
494518 "-f", dockerfile,
495519 "-t", latestRef,
496- "--build-arg", "VERSION",
497520 }
498521 if version != "latest" {
499522 args = append(args, "-t", versionRef)
500523 }
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+ }
501530 for _, ba := range buildArgs {
502531 args = append(args, "--build-arg", ba)
503532 }
533+
504534 args = append(args, "--load", ".")
505535 return args
506536 }
@@ -682,23 +712,26 @@ func parseImageRef(image string) (registry, name, tag string) {
682712 }
683713
684714 // 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.
685717 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
687719 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)
688721 return ""
689722 }
690723 return strings.TrimSpace(string(out))
691724 }
692725
693726 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
695728 cmd.Stdout = os.Stdout
696729 cmd.Stderr = os.Stderr
697730 return cmd.Run()
698731 }
699732
700733 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
702735 cmd.Stdout = os.Stdout
703736 cmd.Stderr = os.Stderr
704737 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 then464 // 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 G204467+ 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.Stdout469 buildCmd.Stdout = os.Stdout
469 buildCmd.Stderr = os.Stderr470 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 nil485 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 local512 // 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 args535 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.Stdout728 cmd.Stdout = os.Stdout
696 cmd.Stderr = os.Stderr729 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.Stdout735 cmd.Stdout = os.Stdout
703 cmd.Stderr = os.Stderr736 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 @@
11 package cli
22
33 import (
4+ "context"
5+ "io"
46 "os"
7+ "os/exec"
58 "path/filepath"
9+ "strings"
610 "testing"
711
812 "github.com/stretchr/testify/assert"
@@ -65,15 +69,56 @@ func TestParseImageRef(t *testing.T) {
6569
6670 func TestBuildAndPush_BuildArgs(t *testing.T) {
6771 // 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")
6973
7074 assert.Contains(t, args, "--load")
7175 assert.NotContains(t, args, "--push")
7276 assert.Contains(t, args, "--platform")
7377 assert.Contains(t, args, "-f")
7478 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+ }
77122 }
78123
79124 func TestParseTagRef(t *testing.T) {
@@ -142,7 +187,7 @@ func TestVersionFromTagRefs(t *testing.T) {
142187 }
143188
144189 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")
146191
147192 assert.Contains(t, args, "-f")
148193 assert.Contains(t, args, "docker/app/Dockerfile")
@@ -301,6 +346,55 @@ func TestSplitLabelPairs(t *testing.T) {
301346 }
302347 }
303348
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+
304398 func TestParseLabelPair(t *testing.T) {
305399 tests := []struct {
306400 name string
@@ -1,8 +1,12 @@
1 package cli1 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 --push71 // 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 string400 name string
M internal/adapters/in/cli/remote/auth.go +1 -1
@@ -12,7 +12,7 @@ import (
1212 // PasswordRequest represents the request body for POST /auth/password.
1313 type PasswordRequest struct {
1414 Username string `json:"username"`
15- Password string `json:"password"`
15+ Password string `json:"password"` //nolint:gosec // intentional: CLI credential DTO for auth endpoint
1616 }
1717
1818 // 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 (
2121
2222 // Client is an HTTP client for the Gordon admin API.
2323 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
2829 }
2930
3031 var (
@@ -84,6 +85,37 @@ func WithInsecureTLS(insecure bool) ClientOption {
8485 }
8586 }
8687
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+
87119 func (c *Client) applyTLSConfig() {
88120 if !c.insecureTLS {
89121 return
@@ -140,7 +172,15 @@ func (c *Client) request(ctx context.Context, method, path string, body any) (*h
140172 req.Header.Set("Authorization", "Bearer "+c.token)
141173 }
142174
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
144184 }
145185
146186 // 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
887927 return nil, fmt.Errorf("failed to connect: %w", err)
888928 }
889929
930+ // Observe token rotation on streaming responses, same as request().
931+ c.observeTokenRotation(resp)
932+
890933 if resp.StatusCode >= 400 {
891934 body, _ := io.ReadAll(resp.Body)
892935 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 string24+ baseURL string
25- token string25+ token string
26- httpClient *http.Client26+ httpClient *http.Client
27- insecureTLS bool27+ 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 return121 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 {
138138 // ResolveRemote resolves the remote URL and token from configuration.
139139 // Precedence: flag > env > config > active remote
140140 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) {
141149 config, _ := LoadClientConfig("")
142150 remotes, _ := LoadRemotes("")
143151
144- url, remoteName, isRemote := resolveRemoteURL(flagRemote, config, remotes)
152+ var name string
153+ url, name, isRemote = resolveRemoteURL(flagRemote, config, remotes)
145154 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.
146157 token = resolveToken(flagToken, config, remotes)
147- insecureTLS = resolveInsecureTLS(flagInsecure, config, remotes, remoteName)
158+ insecureTLS = resolveInsecureTLS(flagInsecure, config, remotes, name)
159+ remoteName = name
148160 }
149161
150- return url, token, insecureTLS, isRemote
162+ return url, token, insecureTLS, remoteName, isRemote
151163 }
152164
153165 // 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 remote139 // 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, isRemote162+ 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:
144144 // GetRemoteClient returns a remote client if targeting a remote instance,
145145 // or nil if running locally.
146146 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)
148148 if !isRemote {
149149 return nil, false
150150 }
151151
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...)
153164 return client, true
154165 }
155166
@@ -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, false149 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, true164 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 {
8282 if i == m.cursor {
8383 b.WriteString(styles.Theme.Highlight.Render(cursor + line))
8484 } else {
85- b.WriteString(fmt.Sprintf("%s%s", cursor, line))
85+ b.WriteString(cursor)
86+ b.WriteString(line)
8687 }
8788 b.WriteString("\n")
8889 }
@@ -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(
106106 ctx = context.WithValue(ctx, domain.ContextKeySubject, claims.Subject)
107107 ctx = context.WithValue(ctx, domain.TokenClaimsKey, claims)
108108
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+
109117 next.ServeHTTP(w, r.WithContext(ctx))
110118 })
111119 }
@@ -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
22
33 import (
44 "context"
5+ "errors"
56 "net/http"
67 "net/http/httptest"
78 "testing"
@@ -83,6 +84,8 @@ func TestAuthMiddleware_TrustedProxy(t *testing.T) {
8384 Subject: "admin",
8485 Scopes: []string{"admin:*:*"},
8586 }, 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)
8689
8790 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
8891 w.WriteHeader(http.StatusOK)
@@ -110,6 +113,8 @@ func TestAuthMiddleware_NoRateLimiting(t *testing.T) {
110113 Subject: "admin",
111114 Scopes: []string{"admin:*:*"},
112115 }, 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)
113118
114119 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
115120 w.WriteHeader(http.StatusOK)
@@ -247,6 +252,8 @@ func TestAuthMiddleware_Success(t *testing.T) {
247252 Subject: "admin",
248253 Scopes: []string{"admin:*:*"},
249254 }, 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)
250257
251258 var capturedCtx context.Context
252259 handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -358,6 +365,60 @@ func TestRequireScope(t *testing.T) {
358365 }
359366 }
360367
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+
361422 func TestHasAccess(t *testing.T) {
362423 ctx := context.WithValue(context.Background(), domain.ContextKeyScopes, []string{"admin:routes:read", "admin:secrets:*"})
363424
@@ -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.Context258 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 (
1212 "github.com/bnema/zerowrap"
1313
1414 "github.com/bnema/gordon/internal/adapters/dto"
15+ "github.com/bnema/gordon/internal/adapters/in/http/httputil"
1516 "github.com/bnema/gordon/internal/boundaries/in"
1617 "github.com/bnema/gordon/internal/domain"
1718 )
@@ -269,7 +270,7 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
269270 }
270271
271272 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) {
273274 log.Debug().Str("username", username).Msg("internal registry auth accepted")
274275 return true, nil
275276 }
@@ -350,16 +351,6 @@ func hasGrantedRegistryAccess(grantedScopes []string, repoName, action string) b
350351 return false
351352 }
352353
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-
363354 // parseRequestedScopes extracts and validates scope parameters from the request.
364355 // Per Docker Registry v2 auth spec, scope format is: repository:name:actions
365356 // 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, nil275 return true, nil
275 }276 }
@@ -350,16 +351,6 @@ func hasGrantedRegistryAccess(grantedScopes []string, repoName, action string) b
350 return false351 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:actions355 // 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:pull356 // 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 (
1212 "github.com/stretchr/testify/assert"
1313 "github.com/stretchr/testify/mock"
1414
15+ "github.com/bnema/gordon/internal/adapters/in/http/httputil"
1516 "github.com/bnema/gordon/internal/boundaries/in/mocks"
1617 "github.com/bnema/gordon/internal/domain"
1718 )
@@ -435,7 +436,6 @@ func TestIsLocalhostRequest(t *testing.T) {
435436 {"127.0.0.1 is localhost", "127.0.0.1:12345", true},
436437 {"127.0.0.2 is localhost", "127.0.0.2:12345", true},
437438 {"::1 is localhost", "[::1]:12345", true},
438- {"::1 without brackets", "::1:12345", true},
439439 {"192.168.1.1 is not localhost", "192.168.1.1:12345", false},
440440 {"10.0.0.1 is not localhost", "10.0.0.1:12345", false},
441441 {"public IP is not localhost", "8.8.8.8:12345", false},
@@ -446,7 +446,7 @@ func TestIsLocalhostRequest(t *testing.T) {
446446 req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
447447 req.RemoteAddr = tt.remoteAddr
448448
449- result := isLocalhostRequest(req)
449+ result := httputil.IsLocalhostRequest(req)
450450
451451 assert.Equal(t, tt.want, result)
452452 })
@@ -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.remoteAddr447 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 (
1010 "github.com/bnema/zerowrap"
1111
1212 "github.com/bnema/gordon/internal/adapters/dto"
13+ "github.com/bnema/gordon/internal/adapters/in/http/httputil"
1314 "github.com/bnema/gordon/internal/boundaries/in"
1415 "github.com/bnema/gordon/internal/domain"
1516 )
@@ -18,75 +19,6 @@ import (
1819 // Using domain key for consistency across all auth flows.
1920 const TokenClaimsKey = domain.TokenClaimsKey
2021
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-
9022 // sanitizeHeaderValue removes characters that could enable header injection.
9123 // Only allows alphanumeric, dots, hyphens, colons, and square brackets
9224 // (sufficient for host:port and IPv6 addresses).
@@ -125,7 +57,7 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l
12557 return func(next http.Handler) http.Handler {
12658 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
12759 // Allow localhost requests only with internal instance credentials.
128- if isLocalhostRequest(r) && isInternalRegistryAuth(r, internalAuth) {
60+ if httputil.IsLocalhostRequest(r) && isInternalRegistryAuth(r, internalAuth) {
12961 log.Debug().
13062 Str(zerowrap.FieldLayer, "adapter").
13163 Str(zerowrap.FieldAdapter, "http").
@@ -296,19 +228,6 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ
296228 return true, claims
297229 }
298230
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-
312231 func isInternalRegistryAuth(r *http.Request, internalAuth InternalRegistryAuth) bool {
313232 if internalAuth.Username == "" || internalAuth.Password == "" {
314233 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.TokenClaimsKey20 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 brackets23 // 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, claims228 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 false233 return false
M internal/adapters/in/http/middleware/auth_test.go +4 -335
@@ -2,7 +2,6 @@ package middleware
22
33 import (
44 "context"
5- "encoding/base64"
65 "errors"
76 "net/http"
87 "net/http/httptest"
@@ -19,340 +18,6 @@ func testLogger() zerowrap.Logger {
1918 return zerowrap.Default()
2019 }
2120
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-
35621 func TestRegistryAuthV2_LocalhostBypassRequiresInternalAuth(t *testing.T) {
35722 log := testLogger()
35823 called := false
@@ -505,6 +170,10 @@ func (s stubAuthService) GetAuthStatus(context.Context) (*domain.AuthStatus, err
505170 return nil, errors.New("not implemented")
506171 }
507172
173+func (s stubAuthService) ExtendToken(context.Context, string) (string, error) {
174+ return "", errors.New("not implemented")
175+}
176+
508177 // Tests for checkScopeAccess function
509178
510179 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 := false23 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 function177 // 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 {
10161016 _, err := r.client.VolumeCreate(ctx, volume.CreateOptions{
10171017 Name: volumeName,
10181018 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),
10211021 },
10221022 })
10231023 if err != nil {
@@ -1142,7 +1142,7 @@ func (r *Runtime) CreateNetwork(ctx context.Context, name string, options map[st
11421142 createOptions := network.CreateOptions{
11431143 Driver: driver,
11441144 Labels: map[string]string{
1145- "gordon.managed": "true",
1145+ domain.LabelManaged: "true",
11461146 },
11471147 }
11481148
@@ -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 {
3333
3434 // NewPassStore creates a new pass-based domain secret store.
3535 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
3740 return nil, fmt.Errorf("pass is not available: %w", err)
3841 }
3942
@@ -646,7 +649,7 @@ func (s *PassStore) ManifestExists(domainName string) (bool, error) {
646649 }
647650
648651 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
650653 cmd.Stdin = strings.NewReader(value)
651654 output, err := cmd.CombinedOutput()
652655 if err != nil {
@@ -656,7 +659,7 @@ func (s *PassStore) passInsert(ctx context.Context, path, value string) error {
656659 }
657660
658661 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
660663 output, err := cmd.CombinedOutput()
661664 if err != nil {
662665 if passEntryMissing(string(output)) {
@@ -668,7 +671,7 @@ func (s *PassStore) passRemove(ctx context.Context, path string) error {
668671 }
669672
670673 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
672675 output, err := cmd.CombinedOutput()
673676 if err != nil {
674677 if passEntryMissing(string(output)) {
@@ -687,7 +690,7 @@ func (s *PassStore) listTopLevelEntries(ctx context.Context, basePath string) ([
687690 return nil, err
688691 }
689692
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
691694 output, err := cmd.CombinedOutput()
692695 if err != nil {
693696 if passEntryMissing(string(output)) {
@@ -809,8 +812,12 @@ func (s *PassStore) cleanupInsertedPaths(paths []string) {
809812 }
810813
811814 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)
815822 }
816823 }
@@ -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, err690 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
8080 ctx, cancel := context.WithTimeout(ctx, p.timeout)
8181 defer cancel()
8282
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
8484 output, err := cmd.Output()
8585 if err != nil {
8686 if exitError, ok := err.(*exec.ExitError); ok {
@@ -105,8 +105,10 @@ func (p *PassProvider) GetSecret(ctx context.Context, path string) (string, erro
105105 }
106106
107107 // IsAvailable checks if pass is available in the system.
108+// Uses a short timeout so a stalled GPG agent does not hang the caller.
108109 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
112114 }
@@ -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 == nil112+ 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 (
1010 "strings"
1111 "sync"
1212 "time"
13+ "unicode/utf8"
1314
1415 "github.com/bnema/zerowrap"
1516
@@ -52,6 +53,10 @@ func validateSubject(subject string) error {
5253 return fmt.Errorf("invalid subject: cannot contain '..' to prevent path traversal")
5354 }
5455
56+ if strings.HasSuffix(subject, ".meta") {
57+ return fmt.Errorf("invalid subject: cannot end with '.meta' (reserved for token metadata files)")
58+ }
59+
5560 return nil
5661 }
5762
@@ -79,12 +84,13 @@ func NewPassStore(log zerowrap.Logger) *PassStore {
7984
8085 // tokenMetadata holds the non-sensitive token information.
8186 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"`
8894 }
8995
9096 // 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
104110
105111 // Store metadata
106112 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,
113120 }
114121 metaJSON, err := json.Marshal(meta)
115122 if err != nil {
@@ -173,12 +180,13 @@ func (s *PassStore) GetToken(ctx context.Context, subject string) (string, *doma
173180 }
174181
175182 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,
182190 }
183191
184192 // Cache the token
@@ -189,49 +197,170 @@ func (s *PassStore) GetToken(ctx context.Context, subject string) (string, *doma
189197 return jwt, token, nil
190198 }
191199
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+
192334 // ListTokens returns all stored tokens from pass.
193335 func (s *PassStore) ListTokens(ctx context.Context) ([]domain.Token, error) {
194336 ctx, cancel := context.WithTimeout(ctx, s.timeout)
195337 defer cancel()
196338
197339 // 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
199341 output, err := cmd.Output()
200342 if err != nil {
201343 // If the path doesn't exist, return empty list
202344 return []domain.Token{}, nil
203345 }
204346
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))
210348
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") {
228353 continue
229354 }
230355
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()
233362 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")
235364 continue
236365 }
237366
@@ -321,6 +450,25 @@ func (s *PassStore) IsRevoked(ctx context.Context, tokenID string) (bool, error)
321450 return revoked, nil
322451 }
323452
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+
324472 // DeleteToken removes token from pass.
325473 func (s *PassStore) DeleteToken(ctx context.Context, subject string) error {
326474 if err := validateSubject(subject); err != nil {
@@ -363,7 +511,7 @@ func (s *PassStore) DeleteToken(ctx context.Context, subject string) error {
363511
364512 // passInsert inserts a value into pass.
365513 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
367515 cmd.Stdin = strings.NewReader(value)
368516 output, err := cmd.CombinedOutput()
369517 if err != nil {
@@ -374,7 +522,7 @@ func (s *PassStore) passInsert(ctx context.Context, path, value string) error {
374522
375523 // passShow retrieves a value from pass.
376524 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
378526 output, err := cmd.Output()
379527 if err != nil {
380528 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 nil60 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 metadata111 // 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 token192 // Cache the token
@@ -189,49 +197,170 @@ func (s *PassStore) GetToken(ctx context.Context, subject string) (string, *doma
189 return jwt, token, nil197 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 path339 // 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 list343 // If the path doesn't exist, return empty list
202 return []domain.Token{}, nil344 return []domain.Token{}, nil
203 }345 }
204 346
205- var tokens []domain.Token347+ 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 continue353 continue
229 }354 }
230 355
231- // Try to get the token metadata356+ // 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 continue364 continue
236 }365 }
237 366
@@ -321,6 +450,25 @@ func (s *PassStore) IsRevoked(ctx context.Context, tokenID string) (bool, error)
321 return revoked, nil450 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 (
1212 "github.com/bnema/gordon/internal/domain"
1313 )
1414
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+
1583 // These tests verify the in-memory caching behavior of PassStore.
1684 // They directly manipulate the cache to test cache hit/miss logic
1785 // 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 logic84 // 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 (
99 "os"
1010 "path/filepath"
1111 "strings"
12+ "sync"
1213
1314 "github.com/bnema/zerowrap"
1415
@@ -32,6 +33,7 @@ const (
3233 // UnsafeStore implements TokenStore using plain text files.
3334 // WARNING: This store does not encrypt secrets. Only use when pass/sops are unavailable.
3435 type UnsafeStore struct {
36+ mu sync.RWMutex
3537 dataDir string
3638 log zerowrap.Logger
3739 }
@@ -73,12 +75,13 @@ func (s *UnsafeStore) SaveToken(_ context.Context, token *domain.Token, jwt stri
7375 data := unsafeTokenData{
7476 JWT: jwt,
7577 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,
8285 },
8386 }
8487
@@ -124,12 +127,13 @@ func (s *UnsafeStore) GetToken(_ context.Context, subject string) (string, *doma
124127 }
125128
126129 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,
133137 }
134138
135139 return data.JWT, token, nil
@@ -168,12 +172,13 @@ func (s *UnsafeStore) ListTokens(_ context.Context) ([]domain.Token, error) {
168172 }
169173
170174 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,
177182 }
178183
179184 tokens = append(tokens, token)
@@ -184,6 +189,9 @@ func (s *UnsafeStore) ListTokens(_ context.Context) ([]domain.Token, error) {
184189
185190 // Revoke adds token ID to revocation list file.
186191 func (s *UnsafeStore) Revoke(_ context.Context, tokenID string) error {
192+ s.mu.Lock()
193+ defer s.mu.Unlock()
194+
187195 revokedFile := filepath.Join(s.dataDir, unsafeRevokedFile)
188196
189197 // Ensure parent directory exists
@@ -226,6 +234,9 @@ func (s *UnsafeStore) Revoke(_ context.Context, tokenID string) error {
226234
227235 // IsRevoked checks if token ID is in revocation list.
228236 func (s *UnsafeStore) IsRevoked(_ context.Context, tokenID string) (bool, error) {
237+ s.mu.RLock()
238+ defer s.mu.RUnlock()
239+
229240 revokedList, err := s.getRevokedList()
230241 if err != nil {
231242 return false, err
@@ -240,6 +251,12 @@ func (s *UnsafeStore) IsRevoked(_ context.Context, tokenID string) (bool, error)
240251 return false, nil
241252 }
242253
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+
243260 // DeleteToken removes token file.
244261 func (s *UnsafeStore) DeleteToken(_ context.Context, subject string) error {
245262 // 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 string37 dataDir string
36 log zerowrap.Logger38 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 metadata79+ 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, nil139 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 exists197 // 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, err242 return false, err
@@ -240,6 +251,12 @@ func (s *UnsafeStore) IsRevoked(_ context.Context, tokenID string) (bool, error)
240 return false, nil251 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 traversal262 // 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() {
819819 _ = os.Remove(getInternalCredentialsFile())
820820 }
821821
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
829877 }
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
833880 }
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())
835889 }
836890
837891 // createAuthService creates the authentication service and token store.
@@ -1645,12 +1699,13 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler
16451699
16461700 registrySrv, registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log)
16471701 proxySrv, proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", errChan, log)
1702+ var tlsSrv *http.Server
16481703 var tlsReady <-chan struct{}
16491704 if cfg.Server.TLSEnabled {
16501705 if cfg.Server.TLSCertFile == "" || cfg.Server.TLSKeyFile == "" {
16511706 return fmt.Errorf("server.tls_enabled=true requires both server.tls_cert_file and server.tls_key_file")
16521707 }
1653- tlsReady = startTLSServer(
1708+ tlsSrv, tlsReady = startTLSServer(
16541709 fmt.Sprintf(":%d", cfg.Server.TLSPort),
16551710 proxyHandler,
16561711 "proxy-tls",
@@ -1695,7 +1750,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler
16951750 syncAndAutoStart(ctx, svc, log)
16961751
16971752 waitForShutdown(ctx, errChan, reloadChan, deployChan, eventBus, log)
1698- gracefulShutdown(registrySrv, proxySrv, containerSvc, log)
1753+ gracefulShutdown(registrySrv, proxySrv, tlsSrv, containerSvc, log)
16991754 return nil
17001755 }
17011756
@@ -1895,17 +1950,19 @@ func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, depl
18951950
18961951 // gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down
18971952 // 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) {
18991954 log.Info().Msg("shutting down Gordon...")
19001955
19011956 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
19021957 defer shutdownCancel()
19031958
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+ }
19091966 }
19101967
19111968 containerSvc.StopMonitor()
@@ -1955,9 +2012,20 @@ func startServer(addr string, handler http.Handler, name string, errChan chan<-
19552012 }
19562013
19572014 // 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{}) {
19592017 ready := make(chan struct{})
19602018
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+
19612029 go func() {
19622030 log.Info().
19632031 Str("address", addr).
@@ -1965,16 +2033,6 @@ func startTLSServer(addr string, handler http.Handler, name, certFile, keyFile s
19652033 Str("key_file", keyFile).
19662034 Msgf("%s server starting", name)
19672035
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-
19782036 ln, err := net.Listen("tcp", addr)
19792037 if err != nil {
19802038 errChan <- fmt.Errorf("%s server error: %w", name, err)
@@ -1987,7 +2045,7 @@ func startTLSServer(addr string, handler http.Handler, name, certFile, keyFile s
19872045 }
19882046 }()
19892047
1990- return ready
2048+ return server, ready
19912049 }
19922050
19932051 // 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 InternalCredentials878+ 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, nil881+ 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 nil1754 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 down1951 // 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 ready2048+ 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 {
4646 // GetAuthStatus returns authentication status from context.
4747 // Extracts and validates token claims that were set by auth middleware.
4848 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)
4954 }
@@ -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 {
4545 // SyncContainers synchronizes containers with configured routes.
4646 SyncContainers(ctx context.Context) error
4747
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+
4852 // AutoStart starts containers for the provided routes that aren't running.
4953 AutoStart(ctx context.Context, routes []domain.Route) error
5054
@@ -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) error46 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) error53 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 {
3939 return &MockAuthService_Expecter{mock: &_m.Mock}
4040 }
4141
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+
42108 // GenerateAccessToken provides a mock function for the type MockAuthService
43109 func (_mock *MockAuthService) GenerateAccessToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) {
44110 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 MockAuthService108 // 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
795795 _c.Call.Return(run)
796796 return _c
797797 }
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 _c796 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
416416 _c.Call.Return(run)
417417 return _c
418418 }
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 _c417 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 {
2626
2727 // DeleteToken removes token from store.
2828 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
2933 }
@@ -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) error28 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 (
2020
2121 // Token represents a generated authentication token stored in the secrets backend.
2222 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
2930 }
3031
3132 // 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 string23+ ID string
24- Subject string24+ Subject string
25- Scopes []string25+ Scopes []string
26- IssuedAt time.Time26+ IssuedAt time.Time
27- ExpiresAt time.Time // Zero value means never expires27+ ExpiresAt time.Time // Zero value means never expires
28- Revoked bool28+ 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 (
2727 MaxAccessTokenLifetime = 5 * time.Minute
2828 // maxAccessTokenLifetimeSecs is MaxAccessTokenLifetime in seconds for JWT comparisons.
2929 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"
3037 )
3138
3239 // Config holds the authentication configuration.
@@ -161,7 +168,10 @@ func (s *Service) parseTokenClaims(tokenString string) (jwt.MapClaims, error) {
161168 return nil, domain.ErrInvalidToken
162169 }
163170 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+ )
165175 if err != nil {
166176 return nil, domain.ErrInvalidToken
167177 }
@@ -258,6 +268,10 @@ func (s *Service) isEphemeralAccessToken(claims *domain.TokenClaims) bool {
258268 // stolen secrets from creating arbitrary short-lived tokens
259269 now := time.Now().UTC().Unix()
260270 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+ }
261275 if tokenAge > maxAccessTokenLifetimeSecs {
262276 return false // Old tokens require store validation
263277 }
@@ -433,6 +447,119 @@ func (s *Service) ListTokens(ctx context.Context) ([]domain.Token, error) {
433447 return tokens, nil
434448 }
435449
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+
436563 // GeneratePasswordHash generates a bcrypt hash for a password.
437564 func (s *Service) GeneratePasswordHash(password string) (string, error) {
438565 hash, err := bcrypt.GenerateFromPassword([]byte(password), DefaultBcryptCost)
@@ -27,6 +27,13 @@ const (
27 MaxAccessTokenLifetime = 5 * time.Minute27 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.ErrInvalidToken168 return nil, domain.ErrInvalidToken
162 }169 }
163 return s.config.TokenSecret, nil170 return s.config.TokenSecret, nil
164- }, jwt.WithIssuer(TokenIssuer)) // SECURITY: Enforce issuer validation171+ },
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.ErrInvalidToken176 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 tokens268 // stolen secrets from creating arbitrary short-lived tokens
259 now := time.Now().UTC().Unix()269 now := time.Now().UTC().Unix()
260 tokenAge := now - claims.IssuedAt270 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 validation276 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, nil447 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 (
77 "time"
88
99 "github.com/bnema/zerowrap"
10+ "github.com/golang-jwt/jwt/v5"
1011 "github.com/stretchr/testify/assert"
1112 "github.com/stretchr/testify/mock"
1213 "github.com/stretchr/testify/require"
@@ -816,3 +817,236 @@ func TestService_GenerateAccessToken_HasNbfClaim(t *testing.T) {
816817
817818 assert.Equal(t, iat, nbf, "nbf should equal iat for access tokens")
818819 }
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 {
418418 s.mu.Lock()
419419 defer s.mu.Unlock()
420420
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.
422425 s.viper.Set("routes", s.config.Routes)
426+ s.viper.Set("external_routes", s.config.ExternalRoutes)
423427 s.viper.Set("attachments", s.config.Attachments)
428+ s.viper.Set("network_groups", s.config.NetworkGroups)
424429
425430 // Record save time to debounce file watcher events
426431 atomic.StoreInt64(&s.lastSaveTime, time.Now().UnixNano())
@@ -534,13 +539,6 @@ func (s *Service) GetConfig() Config {
534539 return s.config
535540 }
536541
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-
544542 // GetVolumeConfig returns volume configuration.
545543 func (s *Service) GetVolumeConfig() (autoCreate bool, prefix string, preserve bool) {
546544 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 config421+ // 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 events430 // 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.config539 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) {
5353 assert.True(t, svc.IsNetworkIsolationEnabled())
5454 assert.Equal(t, "gordon", svc.GetNetworkPrefix())
5555
56- enabled, username, password := svc.GetRegistryAuthConfig()
57- assert.True(t, enabled)
58- assert.Equal(t, "admin", username)
59- assert.Equal(t, "secret", password)
60-
6156 autoCreate, prefix, preserve := svc.GetVolumeConfig()
6257 assert.True(t, autoCreate)
6358 assert.Equal(t, "gordon", prefix)
@@ -1091,6 +1086,174 @@ func TestService_FindRoutesByImage(t *testing.T) {
10911086 })
10921087 }
10931088
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+
10941257 func TestExtractDomainFromImageName(t *testing.T) {
10951258 tests := []struct {
10961259 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 string1259 name string
M internal/usecase/container/autoroute.go +1 -1
@@ -493,7 +493,7 @@ func writeEnvFile(path string, env map[string]string) error {
493493 if strings.ContainsAny(v, " \t\n\"'$\\") {
494494 v = fmt.Sprintf("\"%s\"", strings.ReplaceAll(v, "\"", "\\\""))
495495 }
496- buf.WriteString(fmt.Sprintf("%s=%s\n", k, v))
496+ fmt.Fprintf(&buf, "%s=%s\n", k, v)
497497 }
498498
499499 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
107107 log.Warn().Err(err).Msg("failed to sync containers before reload, proceeding with current state")
108108 }
109109
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+
110117 currentContainers := h.containerSvc.List(ctx)
111118
112119 activeRoutes := make(map[string]*domain.Container)
113120 for _, container := range currentContainers {
114- if route, exists := container.Labels["gordon.route"]; exists {
121+ if route, exists := container.Labels[domain.LabelRoute]; exists {
115122 activeRoutes[route] = container
116123 }
117124 }
@@ -119,7 +126,7 @@ func (h *ConfigReloadHandler) Handle(ctx context.Context, event domain.Event) er
119126 routes := h.configSvc.GetRoutes(ctx)
120127 for _, route := range routes {
121128 if container, exists := activeRoutes[route.Domain]; exists {
122- currentImage := container.Labels["gordon.image"]
129+ currentImage := container.Labels[domain.LabelImage]
123130 if currentImage != route.Image {
124131 log.Info().
125132 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] = container122 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) {
209209 // SyncContainers is called first
210210 containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
211211
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+
212216 // No existing containers
213217 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{})
214218
@@ -241,6 +245,10 @@ func TestConfigReloadHandler_Handle_StopsRemovedRoutes(t *testing.T) {
241245 // SyncContainers is called first
242246 containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
243247
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+
244252 // Existing container for a route that's no longer configured
245253 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
246254 "removed.example.com": {
@@ -277,6 +285,10 @@ func TestConfigReloadHandler_Handle_RedeploysChangedImage(t *testing.T) {
277285 // SyncContainers is called first
278286 containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
279287
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+
280292 // Existing container with old image
281293 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
282294 "app.example.com": {
@@ -317,6 +329,10 @@ func TestConfigReloadHandler_Handle_NoChanges(t *testing.T) {
317329 // SyncContainers is called first
318330 containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
319331
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+
320336 // Existing container matches config
321337 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
322338 "app.example.com": {
@@ -494,6 +510,40 @@ func TestManualReloadHandler_Handle_DoesNotRestartRunningContainers(t *testing.T
494510
495511 // ManualDeployHandler tests
496512
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+
497547 func TestManualDeployHandler_CanHandle(t *testing.T) {
498548 containerSvc := inmocks.NewMockContainerService(t)
499549 configSvc := inmocks.NewMockConfigService(t)
@@ -209,6 +209,10 @@ func TestConfigReloadHandler_Handle_DeploysNewRoutes(t *testing.T) {
209 // SyncContainers is called first209 // 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 containers216 // 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 first245 // 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 configured252 // 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 first285 // 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 image292 // 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 first329 // 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 config336 // 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 tests511 // 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
219219 NetworkMode: networkName,
220220 Hostname: containerDomain,
221221 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,
226226 },
227227 AutoRemove: false,
228228 }
@@ -903,16 +903,26 @@ func (s *Service) SyncContainers(ctx context.Context) error {
903903 }
904904
905905 managed := make(map[string]*domain.Container)
906+ attachments := make(map[string][]string)
906907 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)
910915 }
916+ continue
917+ }
918+ if d, ok := c.Labels[domain.LabelDomain]; ok {
919+ managed[d] = c
911920 }
912921 }
913922
914923 s.mu.Lock()
915924 s.containers = managed
925+ s.attachments = attachments
916926 newCount := int64(len(managed))
917927 delta := newCount - s.managedCount
918928 s.managedCount = newCount
@@ -1061,6 +1071,24 @@ func (s *Service) UpdateConfig(config Config) {
10611071 s.mu.Unlock()
10621072 }
10631073
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+
10641092 // Helper methods
10651093
10661094 // cleanupFailedContainer stops and removes a container that failed to start properly.
@@ -1875,10 +1903,10 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
18751903 Volumes: volumes,
18761904 NetworkMode: networkName, // Same network as main app
18771905 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,
18821910 },
18831911 }
18841912
@@ -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] = c910+ }
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 = managed924 s.containers = managed
925+ s.attachments = attachments
916 newCount := int64(len(managed))926 newCount := int64(len(managed))
917 delta := newCount - s.managedCount927 delta := newCount - s.managedCount
918 s.managedCount = newCount928 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 methods1092 // 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 app1904 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) {
23322332 assert.Equal(t, "new-container", result.ID)
23332333 }
23342334
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+
23352378 func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) {
23362379 runtime := mocks.NewMockContainerRuntime(t)
23372380 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)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close