detached 8056b57

Merge pull request #166 from bnema/review/pre-release-polish

refactor: resolve pre-release release polish findings
commit
8056b57bfd168d5c07aaf5787ef58199b4179ea1
author
bnema · · 2026-04-08 19:14 UTC
committer
GitHub · · 2026-04-08 19:14 UTC
merge of
4162c86 + f8d5e7c · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v2.31.0-dev6
45 files · +431 -343
M internal/adapters/in/cli/auth.go +24 -15
@@ -232,6 +232,15 @@ Examples:
232232 return cmd
233233 }
234234
235+func resolveRequiredRemote(flagToken string) (*remote.ResolvedRemote, error) {
236+ resolved, isRemote := remote.Resolve(remoteFlag, flagToken, insecureTLSFlag)
237+ if !isRemote {
238+ return nil, fmt.Errorf("no remote specified. Use --remote or 'gordon remotes use <name>'")
239+ }
240+
241+ return resolved, nil
242+}
243+
235244 func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer) error {
236245 token = strings.TrimSpace(token)
237246 if token == "" {
@@ -239,9 +248,9 @@ func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer) err
239248 }
240249
241250 // Resolve remote — but use the new token (not the stored one) for verification.
242- resolved, isRemote := remote.Resolve(remoteFlag, "", insecureTLSFlag)
243- if !isRemote {
244- return fmt.Errorf("no remote specified. Use --remote or 'gordon remotes use <name>'")
251+ resolved, err := resolveRequiredRemote("")
252+ if err != nil {
253+ return err
245254 }
246255
247256 client := remote.NewClient(resolved.URL, remoteClientOptions(token, resolved.InsecureTLS)...)
@@ -271,9 +280,9 @@ func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer) err
271280 }
272281
273282 func runAuthLoginVerify(ctx context.Context, out io.Writer) error {
274- resolved, isRemote := remote.Resolve(remoteFlag, tokenFlag, insecureTLSFlag)
275- if !isRemote {
276- return fmt.Errorf("no remote specified. Use --remote or 'gordon remotes use <name>'")
283+ resolved, err := resolveRequiredRemote(tokenFlag)
284+ if err != nil {
285+ return err
277286 }
278287 if resolved.Token == "" {
279288 return fmt.Errorf("no token stored for remote '%s'; use: gordon auth login --token <token>", resolved.DisplayName())
@@ -317,9 +326,9 @@ Examples:
317326 }
318327
319328 func runAuthShowToken(out io.Writer) error {
320- resolved, isRemote := remote.Resolve(remoteFlag, tokenFlag, insecureTLSFlag)
321- if !isRemote {
322- return fmt.Errorf("no remote specified. Use --remote or 'gordon remotes use <name>'")
329+ resolved, err := resolveRequiredRemote(tokenFlag)
330+ if err != nil {
331+ return err
323332 }
324333 if resolved.Token == "" {
325334 return fmt.Errorf("no token found for remote '%s'", resolved.DisplayName())
@@ -355,9 +364,9 @@ Examples:
355364 }
356365
357366 func runAuthLogout(out io.Writer) error {
358- resolved, isRemote := remote.Resolve(remoteFlag, tokenFlag, insecureTLSFlag)
359- if !isRemote {
360- return fmt.Errorf("no remote specified. Use --remote or 'gordon remotes use <name>'")
367+ resolved, err := resolveRequiredRemote(tokenFlag)
368+ if err != nil {
369+ return err
361370 }
362371 if resolved.Name == "" {
363372 return fmt.Errorf("cannot logout from ad-hoc URL. Use 'gordon remotes add' to save this remote first")
@@ -700,9 +709,9 @@ Examples:
700709
701710 // runAuthStatus checks authentication status for the active remote.
702711 func runAuthStatus(ctx context.Context) error {
703- resolved, isRemote := remote.Resolve(remoteFlag, tokenFlag, insecureTLSFlag)
704- if !isRemote {
705- return fmt.Errorf("no remote specified. Use --remote or 'gordon remotes use <name>'")
712+ resolved, err := resolveRequiredRemote(tokenFlag)
713+ if err != nil {
714+ return err
706715 }
707716
708717 client := remote.NewClient(resolved.URL, remoteClientOptions(resolved.Token, resolved.InsecureTLS)...)
@@ -232,6 +232,15 @@ Examples:
232 return cmd232 return cmd
233 }233 }
234 234
235+func resolveRequiredRemote(flagToken string) (*remote.ResolvedRemote, error) {
236+ resolved, isRemote := remote.Resolve(remoteFlag, flagToken, insecureTLSFlag)
237+ if !isRemote {
238+ return nil, fmt.Errorf("no remote specified. Use --remote or 'gordon remotes use <name>'")
239+ }
240+
241+ return resolved, nil
242+}
243+
235 func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer) error {244 func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer) error {
236 token = strings.TrimSpace(token)245 token = strings.TrimSpace(token)
237 if token == "" {246 if token == "" {
@@ -239,9 +248,9 @@ func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer) err
239 }248 }
240 249
241 // Resolve remote — but use the new token (not the stored one) for verification.250 // Resolve remote — but use the new token (not the stored one) for verification.
242- resolved, isRemote := remote.Resolve(remoteFlag, "", insecureTLSFlag)251+ resolved, err := resolveRequiredRemote("")
243- if !isRemote {252+ if err != nil {
244- return fmt.Errorf("no remote specified. Use --remote or 'gordon remotes use <name>'")253+ return err
245 }254 }
246 255
247 client := remote.NewClient(resolved.URL, remoteClientOptions(token, resolved.InsecureTLS)...)256 client := remote.NewClient(resolved.URL, remoteClientOptions(token, resolved.InsecureTLS)...)
@@ -271,9 +280,9 @@ func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer) err
271 }280 }
272 281
273 func runAuthLoginVerify(ctx context.Context, out io.Writer) error {282 func runAuthLoginVerify(ctx context.Context, out io.Writer) error {
274- resolved, isRemote := remote.Resolve(remoteFlag, tokenFlag, insecureTLSFlag)283+ resolved, err := resolveRequiredRemote(tokenFlag)
275- if !isRemote {284+ if err != nil {
276- return fmt.Errorf("no remote specified. Use --remote or 'gordon remotes use <name>'")285+ return err
277 }286 }
278 if resolved.Token == "" {287 if resolved.Token == "" {
279 return fmt.Errorf("no token stored for remote '%s'; use: gordon auth login --token <token>", resolved.DisplayName())288 return fmt.Errorf("no token stored for remote '%s'; use: gordon auth login --token <token>", resolved.DisplayName())
@@ -317,9 +326,9 @@ Examples:
317 }326 }
318 327
319 func runAuthShowToken(out io.Writer) error {328 func runAuthShowToken(out io.Writer) error {
320- resolved, isRemote := remote.Resolve(remoteFlag, tokenFlag, insecureTLSFlag)329+ resolved, err := resolveRequiredRemote(tokenFlag)
321- if !isRemote {330+ if err != nil {
322- return fmt.Errorf("no remote specified. Use --remote or 'gordon remotes use <name>'")331+ return err
323 }332 }
324 if resolved.Token == "" {333 if resolved.Token == "" {
325 return fmt.Errorf("no token found for remote '%s'", resolved.DisplayName())334 return fmt.Errorf("no token found for remote '%s'", resolved.DisplayName())
@@ -355,9 +364,9 @@ Examples:
355 }364 }
356 365
357 func runAuthLogout(out io.Writer) error {366 func runAuthLogout(out io.Writer) error {
358- resolved, isRemote := remote.Resolve(remoteFlag, tokenFlag, insecureTLSFlag)367+ resolved, err := resolveRequiredRemote(tokenFlag)
359- if !isRemote {368+ if err != nil {
360- return fmt.Errorf("no remote specified. Use --remote or 'gordon remotes use <name>'")369+ return err
361 }370 }
362 if resolved.Name == "" {371 if resolved.Name == "" {
363 return fmt.Errorf("cannot logout from ad-hoc URL. Use 'gordon remotes add' to save this remote first")372 return fmt.Errorf("cannot logout from ad-hoc URL. Use 'gordon remotes add' to save this remote first")
@@ -700,9 +709,9 @@ Examples:
700 709
701 // runAuthStatus checks authentication status for the active remote.710 // runAuthStatus checks authentication status for the active remote.
702 func runAuthStatus(ctx context.Context) error {711 func runAuthStatus(ctx context.Context) error {
703- resolved, isRemote := remote.Resolve(remoteFlag, tokenFlag, insecureTLSFlag)712+ resolved, err := resolveRequiredRemote(tokenFlag)
704- if !isRemote {713+ if err != nil {
705- return fmt.Errorf("no remote specified. Use --remote or 'gordon remotes use <name>'")714+ return err
706 }715 }
707 716
708 client := remote.NewClient(resolved.URL, remoteClientOptions(resolved.Token, resolved.InsecureTLS)...)717 client := remote.NewClient(resolved.URL, remoteClientOptions(resolved.Token, resolved.InsecureTLS)...)
M internal/adapters/in/cli/backup.go +2 -1
@@ -7,9 +7,10 @@ import (
77 "text/tabwriter"
88 "time"
99
10+ "github.com/spf13/cobra"
11+
1012 "github.com/bnema/gordon/internal/adapters/dto"
1113 "github.com/bnema/gordon/pkg/bytesize"
12- "github.com/spf13/cobra"
1314 )
1415
1516 // newBackupCmd creates the backup command group.
@@ -7,9 +7,10 @@ import (
7 "text/tabwriter"7 "text/tabwriter"
8 "time"8 "time"
9 9
10+ "github.com/spf13/cobra"
11+
10 "github.com/bnema/gordon/internal/adapters/dto"12 "github.com/bnema/gordon/internal/adapters/dto"
11 "github.com/bnema/gordon/pkg/bytesize"13 "github.com/bnema/gordon/pkg/bytesize"
12- "github.com/spf13/cobra"
13 )14 )
14 15
15 // newBackupCmd creates the backup command group.16 // newBackupCmd creates the backup command group.
M internal/adapters/in/cli/ca.go +4 -3
@@ -13,6 +13,7 @@ import (
1313 "github.com/spf13/cobra"
1414
1515 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
16+ boundaryout "github.com/bnema/gordon/internal/boundaries/out"
1617 )
1718
1819 func newCACmd() *cobra.Command {
@@ -108,7 +109,7 @@ func resolveCADataDir() (string, error) {
108109 // loadCAFromDataDir creates a CA adapter directly (bypassing the usecase layer).
109110 // This is intentional: CLI commands like 'ca export' and 'ca info' are standalone
110111 // admin utilities that run outside the server lifecycle and don't need business logic.
111-func loadCAFromDataDir(dataDir string) (*pkiadapter.CA, error) {
112+func loadCAFromDataDir(dataDir string) (boundaryout.CertificateAuthority, error) {
112113 ca, err := pkiadapter.NewCA(dataDir, cliLogger())
113114 if err != nil {
114115 return nil, fmt.Errorf("failed to load CA from %s: %w", dataDir, err)
@@ -155,7 +156,7 @@ func runCAInstall(_ context.Context, out io.Writer, dataDir string, uninstall, j
155156 }
156157
157158 if uninstall {
158- if err := pkiadapter.UninstallRoot(cert); err != nil {
159+ if err := ca.UninstallRoot(cert); err != nil {
159160 return fmt.Errorf("failed to uninstall root CA: %w", err)
160161 }
161162 if jsonOut {
@@ -164,7 +165,7 @@ func runCAInstall(_ context.Context, out io.Writer, dataDir string, uninstall, j
164165 return cliWriteLine(out, cliRenderSuccess("Root CA removed from system trust stores"))
165166 }
166167
167- if err := pkiadapter.InstallRoot(cert); err != nil {
168+ if err := ca.InstallRoot(cert); err != nil {
168169 return fmt.Errorf("failed to install root CA: %w", err)
169170 }
170171 if jsonOut {
@@ -13,6 +13,7 @@ import (
13 "github.com/spf13/cobra"13 "github.com/spf13/cobra"
14 14
15 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"15 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
16+ boundaryout "github.com/bnema/gordon/internal/boundaries/out"
16 )17 )
17 18
18 func newCACmd() *cobra.Command {19 func newCACmd() *cobra.Command {
@@ -108,7 +109,7 @@ func resolveCADataDir() (string, error) {
108 // loadCAFromDataDir creates a CA adapter directly (bypassing the usecase layer).109 // loadCAFromDataDir creates a CA adapter directly (bypassing the usecase layer).
109 // This is intentional: CLI commands like 'ca export' and 'ca info' are standalone110 // This is intentional: CLI commands like 'ca export' and 'ca info' are standalone
110 // admin utilities that run outside the server lifecycle and don't need business logic.111 // admin utilities that run outside the server lifecycle and don't need business logic.
111-func loadCAFromDataDir(dataDir string) (*pkiadapter.CA, error) {112+func loadCAFromDataDir(dataDir string) (boundaryout.CertificateAuthority, error) {
112 ca, err := pkiadapter.NewCA(dataDir, cliLogger())113 ca, err := pkiadapter.NewCA(dataDir, cliLogger())
113 if err != nil {114 if err != nil {
114 return nil, fmt.Errorf("failed to load CA from %s: %w", dataDir, err)115 return nil, fmt.Errorf("failed to load CA from %s: %w", dataDir, err)
@@ -155,7 +156,7 @@ func runCAInstall(_ context.Context, out io.Writer, dataDir string, uninstall, j
155 }156 }
156 157
157 if uninstall {158 if uninstall {
158- if err := pkiadapter.UninstallRoot(cert); err != nil {159+ if err := ca.UninstallRoot(cert); err != nil {
159 return fmt.Errorf("failed to uninstall root CA: %w", err)160 return fmt.Errorf("failed to uninstall root CA: %w", err)
160 }161 }
161 if jsonOut {162 if jsonOut {
@@ -164,7 +165,7 @@ func runCAInstall(_ context.Context, out io.Writer, dataDir string, uninstall, j
164 return cliWriteLine(out, cliRenderSuccess("Root CA removed from system trust stores"))165 return cliWriteLine(out, cliRenderSuccess("Root CA removed from system trust stores"))
165 }166 }
166 167
167- if err := pkiadapter.InstallRoot(cert); err != nil {168+ if err := ca.InstallRoot(cert); err != nil {
168 return fmt.Errorf("failed to install root CA: %w", err)169 return fmt.Errorf("failed to install root CA: %w", err)
169 }170 }
170 if jsonOut {171 if jsonOut {
M internal/adapters/in/cli/deploy.go +1 -4
@@ -71,10 +71,7 @@ func runDeploy(ctx context.Context, deployer deployer, isRemote bool, deployDoma
7171 messageDomain = result.Domain
7272 }
7373
74- containerID := result.ContainerID
75- if containerID != "" && len(containerID) > 12 {
76- containerID = containerID[:12]
77- }
74+ containerID := shortContainerID(result.ContainerID)
7875
7976 switch result.Status {
8077 case "deployed":
@@ -71,10 +71,7 @@ func runDeploy(ctx context.Context, deployer deployer, isRemote bool, deployDoma
71 messageDomain = result.Domain71 messageDomain = result.Domain
72 }72 }
73 73
74- containerID := result.ContainerID74+ containerID := shortContainerID(result.ContainerID)
75- if containerID != "" && len(containerID) > 12 {
76- containerID = containerID[:12]
77- }
78 75
79 switch result.Status {76 switch result.Status {
80 case "deployed":77 case "deployed":
M internal/adapters/in/cli/deploy_error.go +4 -3
@@ -56,14 +56,15 @@ func structuredDeployFailure(err error) (error, bool) {
5656
5757 func renderDeployFailure(summary, cause, hint string, logs []string) string {
5858 if summary == "" {
59- summary = "failed to deploy"
59+ summary = domain.DefaultDeployFailureSummary
6060 }
6161
6262 var b strings.Builder
63- if summary == "failed to deploy" {
63+ if summary == domain.DefaultDeployFailureSummary {
6464 b.WriteString(summary)
6565 } else {
66- b.WriteString("failed to deploy: ")
66+ b.WriteString(domain.DefaultDeployFailureSummary)
67+ b.WriteString(": ")
6768 b.WriteString(summary)
6869 }
6970
@@ -56,14 +56,15 @@ func structuredDeployFailure(err error) (error, bool) {
56 56
57 func renderDeployFailure(summary, cause, hint string, logs []string) string {57 func renderDeployFailure(summary, cause, hint string, logs []string) string {
58 if summary == "" {58 if summary == "" {
59- summary = "failed to deploy"59+ summary = domain.DefaultDeployFailureSummary
60 }60 }
61 61
62 var b strings.Builder62 var b strings.Builder
63- if summary == "failed to deploy" {63+ if summary == domain.DefaultDeployFailureSummary {
64 b.WriteString(summary)64 b.WriteString(summary)
65 } else {65 } else {
66- b.WriteString("failed to deploy: ")66+ b.WriteString(domain.DefaultDeployFailureSummary)
67+ b.WriteString(": ")
67 b.WriteString(summary)68 b.WriteString(summary)
68 }69 }
69 70
M internal/adapters/in/cli/parity_matrix_test.go +0 -5
@@ -16,11 +16,6 @@ type uiAdoptionExpectation struct {
1616 }
1717
1818 var uiAdoptionExpectations = []uiAdoptionExpectation{
19- {
20- family: "server",
21- file: "serve.go",
22- functions: []string{"newStartCmd"},
23- },
2419 {
2520 family: "root/server",
2621 file: "root.go",
@@ -16,11 +16,6 @@ type uiAdoptionExpectation struct {
16 }16 }
17 17
18 var uiAdoptionExpectations = []uiAdoptionExpectation{18 var uiAdoptionExpectations = []uiAdoptionExpectation{
19- {
20- family: "server",
21- file: "serve.go",
22- functions: []string{"newStartCmd"},
23- },
24 {19 {
25 family: "root/server",20 family: "root/server",
26 file: "root.go",21 file: "root.go",
M internal/adapters/in/cli/presentation.go +8 -0
@@ -63,3 +63,11 @@ func cliRenderWarning(msg string) string {
6363 func cliRenderInfo(msg string) string {
6464 return styles.RenderInfo(msg)
6565 }
66+
67+func shortContainerID(id string) string {
68+ if len(id) > 12 {
69+ return id[:12]
70+ }
71+
72+ return id
73+}
@@ -63,3 +63,11 @@ func cliRenderWarning(msg string) string {
63 func cliRenderInfo(msg string) string {63 func cliRenderInfo(msg string) string {
64 return styles.RenderInfo(msg)64 return styles.RenderInfo(msg)
65 }65 }
66+
67+func shortContainerID(id string) string {
68+ if len(id) > 12 {
69+ return id[:12]
70+ }
71+
72+ return id
73+}
M internal/adapters/in/cli/push.go +1 -4
@@ -681,10 +681,7 @@ func deployAfterPush(ctx context.Context, cp ControlPlane, pushDomain string, no
681681 if err != nil {
682682 return formatDeployFailure(err)
683683 }
684- containerID := result.ContainerID
685- if len(containerID) > 12 {
686- containerID = containerID[:12]
687- }
684+ containerID := shortContainerID(result.ContainerID)
688685 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Deployed %s (container: %s)", pushDomain, containerID)))
689686 return nil
690687 }
@@ -681,10 +681,7 @@ func deployAfterPush(ctx context.Context, cp ControlPlane, pushDomain string, no
681 if err != nil {681 if err != nil {
682 return formatDeployFailure(err)682 return formatDeployFailure(err)
683 }683 }
684- containerID := result.ContainerID684+ containerID := shortContainerID(result.ContainerID)
685- if len(containerID) > 12 {
686- containerID = containerID[:12]
687- }
688 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Deployed %s (container: %s)", pushDomain, containerID)))685 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Deployed %s (container: %s)", pushDomain, containerID)))
689 return nil686 return nil
690 }687 }
M internal/adapters/in/cli/remote/config.go +8 -2
@@ -97,7 +97,9 @@ func migrateClientConfig(config *ClientConfig) {
9797 return
9898 }
9999
100- fmt.Fprintf(os.Stderr, "Notice: migrated [client] config from gordon.toml to 'default' remote. The [client] section is deprecated; use 'gordon remotes' instead.\n")
100+ if _, err := fmt.Fprintf(os.Stderr, "Notice: migrated [client] config from gordon.toml to 'default' remote. The [client] section is deprecated; use 'gordon remotes' instead.\n"); err != nil {
101+ _, _ = os.Stderr.WriteString("Warning: failed to write remote migration notice\n")
102+ }
101103
102104 config.Remotes["default"] = RemoteEntry{
103105 URL: legacy.Client.Remote,
@@ -110,7 +112,11 @@ func migrateClientConfig(config *ClientConfig) {
110112 config.Active = "default"
111113 }
112114
113- _ = SaveRemotes("", config)
115+ if err := SaveRemotes("", config); err != nil {
116+ if _, writeErr := fmt.Fprintf(os.Stderr, "Warning: failed to save migrated remotes: %v\n", err); writeErr != nil {
117+ _, _ = os.Stderr.WriteString("Warning: failed to write remote migration warning\n")
118+ }
119+ }
114120 }
115121
116122 func defaultGordonTomlPath() string {
@@ -97,7 +97,9 @@ func migrateClientConfig(config *ClientConfig) {
97 return97 return
98 }98 }
99 99
100- fmt.Fprintf(os.Stderr, "Notice: migrated [client] config from gordon.toml to 'default' remote. The [client] section is deprecated; use 'gordon remotes' instead.\n")100+ if _, err := fmt.Fprintf(os.Stderr, "Notice: migrated [client] config from gordon.toml to 'default' remote. The [client] section is deprecated; use 'gordon remotes' instead.\n"); err != nil {
101+ _, _ = os.Stderr.WriteString("Warning: failed to write remote migration notice\n")
102+ }
101 103
102 config.Remotes["default"] = RemoteEntry{104 config.Remotes["default"] = RemoteEntry{
103 URL: legacy.Client.Remote,105 URL: legacy.Client.Remote,
@@ -110,7 +112,11 @@ func migrateClientConfig(config *ClientConfig) {
110 config.Active = "default"112 config.Active = "default"
111 }113 }
112 114
113- _ = SaveRemotes("", config)115+ if err := SaveRemotes("", config); err != nil {
116+ if _, writeErr := fmt.Fprintf(os.Stderr, "Warning: failed to save migrated remotes: %v\n", err); writeErr != nil {
117+ _, _ = os.Stderr.WriteString("Warning: failed to write remote migration warning\n")
118+ }
119+ }
114 }120 }
115 121
116 func defaultGordonTomlPath() string {122 func defaultGordonTomlPath() string {
M internal/adapters/in/cli/rollback.go +1 -4
@@ -244,10 +244,7 @@ func deploySelectedTag(ctx context.Context, cp ControlPlane, route *domain.Route
244244 }
245245 return fmt.Errorf("failed to deploy (route reverted): %w", err)
246246 }
247- containerID := result.ContainerID
248- if len(containerID) > 12 {
249- containerID = containerID[:12]
250- }
247+ containerID := shortContainerID(result.ContainerID)
251248 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Rolled back %s to %s (container: %s)", rollbackDomain, selectedTag, containerID)))
252249 return nil
253250 }
@@ -244,10 +244,7 @@ func deploySelectedTag(ctx context.Context, cp ControlPlane, route *domain.Route
244 }244 }
245 return fmt.Errorf("failed to deploy (route reverted): %w", err)245 return fmt.Errorf("failed to deploy (route reverted): %w", err)
246 }246 }
247- containerID := result.ContainerID247+ containerID := shortContainerID(result.ContainerID)
248- if len(containerID) > 12 {
249- containerID = containerID[:12]
250- }
251 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Rolled back %s to %s (container: %s)", rollbackDomain, selectedTag, containerID)))248 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Rolled back %s to %s (container: %s)", rollbackDomain, selectedTag, containerID)))
252 return nil249 return nil
253 }250 }
M internal/adapters/in/cli/root.go +0 -4
@@ -68,10 +68,6 @@ Commands are organized by where they run:
6868 serveCmd.GroupID = groupServer
6969 rootCmd.AddCommand(serveCmd)
7070
71- startCmd := newStartCmd() // Deprecated alias for serve
72- startCmd.GroupID = groupServer
73- rootCmd.AddCommand(startCmd)
74-
7571 authCmd := newAuthCmd()
7672 authCmd.GroupID = groupServer
7773 rootCmd.AddCommand(authCmd)
@@ -68,10 +68,6 @@ Commands are organized by where they run:
68 serveCmd.GroupID = groupServer68 serveCmd.GroupID = groupServer
69 rootCmd.AddCommand(serveCmd)69 rootCmd.AddCommand(serveCmd)
70 70
71- startCmd := newStartCmd() // Deprecated alias for serve
72- startCmd.GroupID = groupServer
73- rootCmd.AddCommand(startCmd)
74-
75 authCmd := newAuthCmd()71 authCmd := newAuthCmd()
76 authCmd.GroupID = groupServer72 authCmd.GroupID = groupServer
77 rootCmd.AddCommand(authCmd)73 rootCmd.AddCommand(authCmd)
M internal/adapters/in/cli/serve.go +0 -25
@@ -2,7 +2,6 @@ package cli
22
33 import (
44 "context"
5- "os"
65
76 "github.com/bnema/gordon/internal/app"
87
@@ -27,27 +26,3 @@ func newServeCmd() *cobra.Command {
2726
2827 return cmd
2928 }
30-
31-// newStartCmd creates a deprecated alias for serve.
32-func newStartCmd() *cobra.Command {
33- var configPath string
34-
35- cmd := &cobra.Command{
36- Use: "start",
37- Short: "Start the Gordon server (deprecated: use 'serve')",
38- Long: `Start the Gordon server. This command is deprecated, please use 'gordon serve' instead.`,
39- Deprecated: "use 'gordon serve' instead",
40- Hidden: true, // Hide from help but still works
41- RunE: func(cmd *cobra.Command, args []string) error {
42- if err := cliWriteLine(os.Stderr, cliRenderWarning("Warning: 'gordon start' is deprecated, use 'gordon serve' instead")); err != nil {
43- return err
44- }
45- return app.Run(context.Background(), configPath)
46- },
47- }
48-
49- // Add flags (same as serve)
50- cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
51-
52- return cmd
53-}
@@ -2,7 +2,6 @@ package cli
2 2
3 import (3 import (
4 "context"4 "context"
5- "os"
6 5
7 "github.com/bnema/gordon/internal/app"6 "github.com/bnema/gordon/internal/app"
8 7
@@ -27,27 +26,3 @@ func newServeCmd() *cobra.Command {
27 26
28 return cmd27 return cmd
29 }28 }
30-
31-// newStartCmd creates a deprecated alias for serve.
32-func newStartCmd() *cobra.Command {
33- var configPath string
34-
35- cmd := &cobra.Command{
36- Use: "start",
37- Short: "Start the Gordon server (deprecated: use 'serve')",
38- Long: `Start the Gordon server. This command is deprecated, please use 'gordon serve' instead.`,
39- Deprecated: "use 'gordon serve' instead",
40- Hidden: true, // Hide from help but still works
41- RunE: func(cmd *cobra.Command, args []string) error {
42- if err := cliWriteLine(os.Stderr, cliRenderWarning("Warning: 'gordon start' is deprecated, use 'gordon serve' instead")); err != nil {
43- return err
44- }
45- return app.Run(context.Background(), configPath)
46- },
47- }
48-
49- // Add flags (same as serve)
50- cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
51-
52- return cmd
53-}
M internal/adapters/in/http/admin/middleware_test.go +2 -2
@@ -10,7 +10,7 @@ import (
1010 "github.com/stretchr/testify/assert"
1111 "github.com/stretchr/testify/mock"
1212
13- "github.com/bnema/gordon/internal/adapters/in/http/middleware"
13+ "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
1414 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
1515 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
1616 "github.com/bnema/gordon/internal/domain"
@@ -78,7 +78,7 @@ func TestAuthMiddleware_TrustedProxy(t *testing.T) {
7878 ipLimiter := outmocks.NewMockRateLimiter(t)
7979
8080 // Request from trusted proxy, should use X-Forwarded-For IP
81- trustedNets := middleware.ParseTrustedProxies([]string{"127.0.0.1"})
81+ trustedNets := httphelper.ParseTrustedProxies([]string{"127.0.0.1"})
8282
8383 globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
8484 ipLimiter.EXPECT().Allow(context.Background(), "ip:203.0.113.50").Return(true)
@@ -10,7 +10,7 @@ import (
10 "github.com/stretchr/testify/assert"10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/mock"11 "github.com/stretchr/testify/mock"
12 12
13- "github.com/bnema/gordon/internal/adapters/in/http/middleware"13+ "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
14 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"14 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
15 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"15 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
16 "github.com/bnema/gordon/internal/domain"16 "github.com/bnema/gordon/internal/domain"
@@ -78,7 +78,7 @@ func TestAuthMiddleware_TrustedProxy(t *testing.T) {
78 ipLimiter := outmocks.NewMockRateLimiter(t)78 ipLimiter := outmocks.NewMockRateLimiter(t)
79 79
80 // Request from trusted proxy, should use X-Forwarded-For IP80 // Request from trusted proxy, should use X-Forwarded-For IP
81- trustedNets := middleware.ParseTrustedProxies([]string{"127.0.0.1"})81+ trustedNets := httphelper.ParseTrustedProxies([]string{"127.0.0.1"})
82 82
83 globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)83 globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
84 ipLimiter.EXPECT().Allow(context.Background(), "ip:203.0.113.50").Return(true)84 ipLimiter.EXPECT().Allow(context.Background(), "ip:203.0.113.50").Return(true)
M internal/adapters/in/http/auth/handler.go +2 -2
@@ -12,7 +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"
15+ "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
1616 "github.com/bnema/gordon/internal/boundaries/in"
1717 "github.com/bnema/gordon/internal/domain"
1818 )
@@ -166,7 +166,7 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
166166 }
167167
168168 func (h *Handler) authenticateTokenCredentials(ctx context.Context, r *http.Request, username, password string, log zerowrap.Logger) (bool, *domain.TokenClaims) {
169- if httputil.IsLocalhostRequest(r) && h.isInternalAuth(username, password) {
169+ if httphelper.IsLocalhostRequest(r) && h.isInternalAuth(username, password) {
170170 log.Debug().Str("username", username).Msg("internal registry auth accepted")
171171 return true, &domain.TokenClaims{
172172 Subject: username,
@@ -12,7 +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/adapters/in/http/httphelper"
16 "github.com/bnema/gordon/internal/boundaries/in"16 "github.com/bnema/gordon/internal/boundaries/in"
17 "github.com/bnema/gordon/internal/domain"17 "github.com/bnema/gordon/internal/domain"
18 )18 )
@@ -166,7 +166,7 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
166 }166 }
167 167
168 func (h *Handler) authenticateTokenCredentials(ctx context.Context, r *http.Request, username, password string, log zerowrap.Logger) (bool, *domain.TokenClaims) {168 func (h *Handler) authenticateTokenCredentials(ctx context.Context, r *http.Request, username, password string, log zerowrap.Logger) (bool, *domain.TokenClaims) {
169- if httputil.IsLocalhostRequest(r) && h.isInternalAuth(username, password) {169+ if httphelper.IsLocalhostRequest(r) && h.isInternalAuth(username, password) {
170 log.Debug().Str("username", username).Msg("internal registry auth accepted")170 log.Debug().Str("username", username).Msg("internal registry auth accepted")
171 return true, &domain.TokenClaims{171 return true, &domain.TokenClaims{
172 Subject: username,172 Subject: username,
M internal/adapters/in/http/auth/handler_test.go +2 -2
@@ -13,7 +13,7 @@ import (
1313 "github.com/stretchr/testify/assert"
1414 "github.com/stretchr/testify/mock"
1515
16- "github.com/bnema/gordon/internal/adapters/in/http/httputil"
16+ "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
1717 "github.com/bnema/gordon/internal/boundaries/in/mocks"
1818 "github.com/bnema/gordon/internal/domain"
1919 )
@@ -358,7 +358,7 @@ func TestIsLocalhostRequest(t *testing.T) {
358358 req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
359359 req.RemoteAddr = tt.remoteAddr
360360
361- result := httputil.IsLocalhostRequest(req)
361+ result := httphelper.IsLocalhostRequest(req)
362362
363363 assert.Equal(t, tt.want, result)
364364 })
@@ -13,7 +13,7 @@ import (
13 "github.com/stretchr/testify/assert"13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/mock"14 "github.com/stretchr/testify/mock"
15 15
16- "github.com/bnema/gordon/internal/adapters/in/http/httputil"16+ "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
17 "github.com/bnema/gordon/internal/boundaries/in/mocks"17 "github.com/bnema/gordon/internal/boundaries/in/mocks"
18 "github.com/bnema/gordon/internal/domain"18 "github.com/bnema/gordon/internal/domain"
19 )19 )
@@ -358,7 +358,7 @@ func TestIsLocalhostRequest(t *testing.T) {
358 req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)358 req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
359 req.RemoteAddr = tt.remoteAddr359 req.RemoteAddr = tt.remoteAddr
360 360
361- result := httputil.IsLocalhostRequest(req)361+ result := httphelper.IsLocalhostRequest(req)
362 362
363 assert.Equal(t, tt.want, result)363 assert.Equal(t, tt.want, result)
364 })364 })
R internal/adapters/in/http/httputil/localhost.go → internal/adapters/in/http/httphelper/localhost.go +1 -1 renamed, 96% similar
@@ -1,4 +1,4 @@
1-package httputil
1+package httphelper
22
33 import (
44 "net"
@@ -1,4 +1,4 @@
1-package httputil1+package httphelper
2 2
3 import (3 import (
4 "net"4 "net"
R internal/adapters/in/http/httputil/localhost_test.go → internal/adapters/in/http/httphelper/localhost_test.go +6 -5 renamed, 71% similar
@@ -1,11 +1,13 @@
1-package httputil_test
1+package httphelper_test
22
33 import (
44 "net/http"
55 "net/http/httptest"
66 "testing"
77
8- "github.com/bnema/gordon/internal/adapters/in/http/httputil"
8+ "github.com/stretchr/testify/assert"
9+
10+ "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
911 )
1012
1113 func TestIsLocalhostRequest(t *testing.T) {
@@ -25,9 +27,8 @@ func TestIsLocalhostRequest(t *testing.T) {
2527 t.Run(tt.name, func(t *testing.T) {
2628 req := httptest.NewRequest(http.MethodGet, "/", nil)
2729 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- }
30+ got := httphelper.IsLocalhostRequest(req)
31+ assert.Equal(t, tt.want, got, "IsLocalhostRequest(%q)", tt.remoteAddr)
3132 })
3233 }
3334 }
@@ -1,11 +1,13 @@
1-package httputil_test1+package httphelper_test
2 2
3 import (3 import (
4 "net/http"4 "net/http"
5 "net/http/httptest"5 "net/http/httptest"
6 "testing"6 "testing"
7 7
8- "github.com/bnema/gordon/internal/adapters/in/http/httputil"8+ "github.com/stretchr/testify/assert"
9+
10+ "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
9 )11 )
10 12
11 func TestIsLocalhostRequest(t *testing.T) {13 func TestIsLocalhostRequest(t *testing.T) {
@@ -25,9 +27,8 @@ func TestIsLocalhostRequest(t *testing.T) {
25 t.Run(tt.name, func(t *testing.T) {27 t.Run(tt.name, func(t *testing.T) {
26 req := httptest.NewRequest(http.MethodGet, "/", nil)28 req := httptest.NewRequest(http.MethodGet, "/", nil)
27 req.RemoteAddr = tt.remoteAddr29 req.RemoteAddr = tt.remoteAddr
28- if got := httputil.IsLocalhostRequest(req); got != tt.want {30+ got := httphelper.IsLocalhostRequest(req)
29- t.Errorf("IsLocalhostRequest(%q) = %v, want %v", tt.remoteAddr, got, tt.want)31+ assert.Equal(t, tt.want, got, "IsLocalhostRequest(%q)", tt.remoteAddr)
30- }
31 })32 })
32 }33 }
33 }34 }
A internal/adapters/in/http/httphelper/trust.go +115 -0
@@ -0,0 +1,115 @@
1+package httphelper
2+
3+import (
4+ "net"
5+ "net/http"
6+ "strconv"
7+ "strings"
8+)
9+
10+// LocalhostNets contains IPv4 and IPv6 loopback ranges.
11+var LocalhostNets = ParseTrustedProxies([]string{"127.0.0.0/8", "::1"})
12+
13+// ParseTrustedProxies converts a list of IP addresses and CIDR ranges to [net.IPNet].
14+// It accepts both single IPs (e.g., "10.0.0.1") and CIDR notation (e.g., "10.0.0.0/8").
15+// Single IPs are converted to /32 (IPv4) or /128 (IPv6) CIDR blocks.
16+func ParseTrustedProxies(proxies []string) []*net.IPNet {
17+ var nets []*net.IPNet
18+ for _, proxy := range proxies {
19+ proxy = strings.TrimSpace(proxy)
20+ if proxy == "" {
21+ continue
22+ }
23+
24+ // Try parsing as CIDR
25+ _, ipNet, err := net.ParseCIDR(proxy)
26+ if err == nil {
27+ nets = append(nets, ipNet)
28+ continue
29+ }
30+ // Try parsing as single IP
31+ ip := net.ParseIP(proxy)
32+ if ip != nil {
33+ // Convert single IP to /32 or /128 CIDR
34+ bits := 32
35+ if ip.To4() == nil {
36+ bits = 128
37+ }
38+ nets = append(nets, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)})
39+ }
40+ }
41+ return nets
42+}
43+
44+// IsTrustedProxy reports whether the given IP address falls within any of the
45+// trusted networks. Returns false if trustedNets is empty or the IP cannot be parsed.
46+func IsTrustedProxy(ip string, trustedNets []*net.IPNet) bool {
47+ if len(trustedNets) == 0 {
48+ return false
49+ }
50+ parsedIP := net.ParseIP(ip)
51+ if parsedIP == nil {
52+ return false
53+ }
54+ for _, ipNet := range trustedNets {
55+ if ipNet.Contains(parsedIP) {
56+ return true
57+ }
58+ }
59+ return false
60+}
61+
62+// IsTrustedOrLocal reports whether an IP belongs to localhost or the given
63+// trusted proxy networks.
64+func IsTrustedOrLocal(ip string, proxyNets []*net.IPNet) bool {
65+ return IsTrustedProxy(ip, LocalhostNets) || IsTrustedProxy(ip, proxyNets)
66+}
67+
68+// ExtractRemoteIP returns the IP portion of an [http.Request.RemoteAddr],
69+// stripping the port. When RemoteAddr lacks a port (e.g. Unix sockets or
70+// certain proxy setups), it is returned as-is.
71+func ExtractRemoteIP(remoteAddr string) string {
72+ host, _, err := net.SplitHostPort(remoteAddr)
73+ if err != nil {
74+ return remoteAddr
75+ }
76+ return host
77+}
78+
79+// IsTrustedSource reports whether the request's remote address is in trustedNets.
80+// When RemoteAddr lacks a port, IPv6 brackets are stripped before checking.
81+func IsTrustedSource(r *http.Request, trustedNets []*net.IPNet) bool {
82+ if len(trustedNets) == 0 {
83+ return false
84+ }
85+ host, _, err := net.SplitHostPort(r.RemoteAddr)
86+ if err != nil {
87+ // RemoteAddr may be a bare IP without a port (e.g. from Unix sockets
88+ // or certain proxy setups). Fall back to using it directly.
89+ host = strings.TrimSuffix(strings.TrimPrefix(r.RemoteAddr, "["), "]")
90+ }
91+ return IsTrustedProxy(host, trustedNets)
92+}
93+
94+// HTTPSAuthority converts an incoming Host header plus httpPort/tlsPort into
95+// the correct HTTPS authority (host or host:port).
96+//
97+// Rules:
98+// - No port in Host: omit TLS port (public reverse-proxy assumed on :443).
99+// - Host port == httpPort: map to tlsPort.
100+// - Any other explicit port: preserve it.
101+func HTTPSAuthority(host string, httpPort, tlsPort int) string {
102+ hostname, portStr, err := net.SplitHostPort(host)
103+ if err != nil {
104+ // No port in Host header — omit TLS port from URL.
105+ return host
106+ }
107+
108+ port, err := strconv.Atoi(portStr)
109+ if err == nil && port == httpPort {
110+ return net.JoinHostPort(hostname, strconv.Itoa(tlsPort))
111+ }
112+
113+ // Unknown explicit port — preserve it.
114+ return net.JoinHostPort(hostname, portStr)
115+}
@@ -0,0 +1,115 @@
1+package httphelper
2+
3+import (
4+ "net"
5+ "net/http"
6+ "strconv"
7+ "strings"
8+)
9+
10+// LocalhostNets contains IPv4 and IPv6 loopback ranges.
11+var LocalhostNets = ParseTrustedProxies([]string{"127.0.0.0/8", "::1"})
12+
13+// ParseTrustedProxies converts a list of IP addresses and CIDR ranges to [net.IPNet].
14+// It accepts both single IPs (e.g., "10.0.0.1") and CIDR notation (e.g., "10.0.0.0/8").
15+// Single IPs are converted to /32 (IPv4) or /128 (IPv6) CIDR blocks.
16+func ParseTrustedProxies(proxies []string) []*net.IPNet {
17+ var nets []*net.IPNet
18+ for _, proxy := range proxies {
19+ proxy = strings.TrimSpace(proxy)
20+ if proxy == "" {
21+ continue
22+ }
23+
24+ // Try parsing as CIDR
25+ _, ipNet, err := net.ParseCIDR(proxy)
26+ if err == nil {
27+ nets = append(nets, ipNet)
28+ continue
29+ }
30+ // Try parsing as single IP
31+ ip := net.ParseIP(proxy)
32+ if ip != nil {
33+ // Convert single IP to /32 or /128 CIDR
34+ bits := 32
35+ if ip.To4() == nil {
36+ bits = 128
37+ }
38+ nets = append(nets, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)})
39+ }
40+ }
41+ return nets
42+}
43+
44+// IsTrustedProxy reports whether the given IP address falls within any of the
45+// trusted networks. Returns false if trustedNets is empty or the IP cannot be parsed.
46+func IsTrustedProxy(ip string, trustedNets []*net.IPNet) bool {
47+ if len(trustedNets) == 0 {
48+ return false
49+ }
50+ parsedIP := net.ParseIP(ip)
51+ if parsedIP == nil {
52+ return false
53+ }
54+ for _, ipNet := range trustedNets {
55+ if ipNet.Contains(parsedIP) {
56+ return true
57+ }
58+ }
59+ return false
60+}
61+
62+// IsTrustedOrLocal reports whether an IP belongs to localhost or the given
63+// trusted proxy networks.
64+func IsTrustedOrLocal(ip string, proxyNets []*net.IPNet) bool {
65+ return IsTrustedProxy(ip, LocalhostNets) || IsTrustedProxy(ip, proxyNets)
66+}
67+
68+// ExtractRemoteIP returns the IP portion of an [http.Request.RemoteAddr],
69+// stripping the port. When RemoteAddr lacks a port (e.g. Unix sockets or
70+// certain proxy setups), it is returned as-is.
71+func ExtractRemoteIP(remoteAddr string) string {
72+ host, _, err := net.SplitHostPort(remoteAddr)
73+ if err != nil {
74+ return remoteAddr
75+ }
76+ return host
77+}
78+
79+// IsTrustedSource reports whether the request's remote address is in trustedNets.
80+// When RemoteAddr lacks a port, IPv6 brackets are stripped before checking.
81+func IsTrustedSource(r *http.Request, trustedNets []*net.IPNet) bool {
82+ if len(trustedNets) == 0 {
83+ return false
84+ }
85+ host, _, err := net.SplitHostPort(r.RemoteAddr)
86+ if err != nil {
87+ // RemoteAddr may be a bare IP without a port (e.g. from Unix sockets
88+ // or certain proxy setups). Fall back to using it directly.
89+ host = strings.TrimSuffix(strings.TrimPrefix(r.RemoteAddr, "["), "]")
90+ }
91+ return IsTrustedProxy(host, trustedNets)
92+}
93+
94+// HTTPSAuthority converts an incoming Host header plus httpPort/tlsPort into
95+// the correct HTTPS authority (host or host:port).
96+//
97+// Rules:
98+// - No port in Host: omit TLS port (public reverse-proxy assumed on :443).
99+// - Host port == httpPort: map to tlsPort.
100+// - Any other explicit port: preserve it.
101+func HTTPSAuthority(host string, httpPort, tlsPort int) string {
102+ hostname, portStr, err := net.SplitHostPort(host)
103+ if err != nil {
104+ // No port in Host header — omit TLS port from URL.
105+ return host
106+ }
107+
108+ port, err := strconv.Atoi(portStr)
109+ if err == nil && port == httpPort {
110+ return net.JoinHostPort(hostname, strconv.Itoa(tlsPort))
111+ }
112+
113+ // Unknown explicit port — preserve it.
114+ return net.JoinHostPort(hostname, portStr)
115+}
M internal/adapters/in/http/middleware/accesslog.go +45 -4
@@ -3,11 +3,13 @@ package middleware
33 import (
44 "net"
55 "net/http"
6+ "net/url"
67 "strings"
78 "time"
89
910 "github.com/bnema/zerowrap"
1011
12+ "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
1113 out "github.com/bnema/gordon/internal/boundaries/out"
1214 )
1315
@@ -40,11 +42,10 @@ func AccessLogger(writer out.AccessLogWriter, excludeHealthChecks bool, log zero
4042 // Apply health-check exclusion after the response so the request
4143 // always completes — we just skip writing the log entry.
4244 // UA check uses the shared domain constant so the prober and this
43- // filter can never drift. Loopback check reuses localhostNets from
44- // cidr.go (same package) — no separate loopback definition needed.
45+ // filter can never drift. Loopback check uses the shared helper list.
4546 if excludeHealthChecks {
4647 if strings.HasPrefix(r.UserAgent(), out.HealthCheckUserAgentPrefix) ||
47- IsTrustedProxy(clientIP, localhostNets) {
48+ httphelper.IsTrustedProxy(clientIP, httphelper.LocalhostNets) {
4849 return
4950 }
5051 }
@@ -60,7 +61,7 @@ func AccessLogger(writer out.AccessLogWriter, excludeHealthChecks bool, log zero
6061 Method: r.Method,
6162 Host: r.Host,
6263 Path: r.URL.Path,
63- Query: r.URL.RawQuery,
64+ Query: sanitizeLoggedQuery(r.URL.RawQuery),
6465 Status: rw.StatusCode(),
6566 BytesSent: rw.BytesWritten(),
6667 DurationMS: durationMS,
@@ -85,3 +86,43 @@ func AccessLogger(writer out.AccessLogWriter, excludeHealthChecks bool, log zero
8586 })
8687 }
8788 }
89+
90+func sanitizeLoggedQuery(rawQuery string) string {
91+ if rawQuery == "" {
92+ return ""
93+ }
94+
95+ parts := strings.Split(rawQuery, "&")
96+ for i, part := range parts {
97+ if part == "" {
98+ continue
99+ }
100+
101+ key, _, found := strings.Cut(part, "=")
102+ decodedKey, err := url.QueryUnescape(key)
103+ if err != nil {
104+ decodedKey = key
105+ }
106+
107+ if !isSensitiveQueryKey(decodedKey) {
108+ continue
109+ }
110+
111+ if found {
112+ parts[i] = key + "=[REDACTED]"
113+ } else {
114+ parts[i] = key
115+ }
116+ }
117+
118+ return strings.Join(parts, "&")
119+}
120+
121+func isSensitiveQueryKey(key string) bool {
122+ switch strings.ToLower(key) {
123+ case "access_token", "api_key", "apikey", "auth", "bearer", "code", "credential", "credentials", "key", "password", "private_key", "refresh_token", "reset", "secret", "session", "session_id", "sig", "signature", "token":
124+ return true
125+ default:
126+ return false
127+ }
128+}
@@ -3,11 +3,13 @@ package middleware
3 import (3 import (
4 "net"4 "net"
5 "net/http"5 "net/http"
6+ "net/url"
6 "strings"7 "strings"
7 "time"8 "time"
8 9
9 "github.com/bnema/zerowrap"10 "github.com/bnema/zerowrap"
10 11
12+ "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
11 out "github.com/bnema/gordon/internal/boundaries/out"13 out "github.com/bnema/gordon/internal/boundaries/out"
12 )14 )
13 15
@@ -40,11 +42,10 @@ func AccessLogger(writer out.AccessLogWriter, excludeHealthChecks bool, log zero
40 // Apply health-check exclusion after the response so the request42 // Apply health-check exclusion after the response so the request
41 // always completes — we just skip writing the log entry.43 // always completes — we just skip writing the log entry.
42 // UA check uses the shared domain constant so the prober and this44 // UA check uses the shared domain constant so the prober and this
43- // filter can never drift. Loopback check reuses localhostNets from45+ // filter can never drift. Loopback check uses the shared helper list.
44- // cidr.go (same package) — no separate loopback definition needed.
45 if excludeHealthChecks {46 if excludeHealthChecks {
46 if strings.HasPrefix(r.UserAgent(), out.HealthCheckUserAgentPrefix) ||47 if strings.HasPrefix(r.UserAgent(), out.HealthCheckUserAgentPrefix) ||
47- IsTrustedProxy(clientIP, localhostNets) {48+ httphelper.IsTrustedProxy(clientIP, httphelper.LocalhostNets) {
48 return49 return
49 }50 }
50 }51 }
@@ -60,7 +61,7 @@ func AccessLogger(writer out.AccessLogWriter, excludeHealthChecks bool, log zero
60 Method: r.Method,61 Method: r.Method,
61 Host: r.Host,62 Host: r.Host,
62 Path: r.URL.Path,63 Path: r.URL.Path,
63- Query: r.URL.RawQuery,64+ Query: sanitizeLoggedQuery(r.URL.RawQuery),
64 Status: rw.StatusCode(),65 Status: rw.StatusCode(),
65 BytesSent: rw.BytesWritten(),66 BytesSent: rw.BytesWritten(),
66 DurationMS: durationMS,67 DurationMS: durationMS,
@@ -85,3 +86,43 @@ func AccessLogger(writer out.AccessLogWriter, excludeHealthChecks bool, log zero
85 })86 })
86 }87 }
87 }88 }
89+
90+func sanitizeLoggedQuery(rawQuery string) string {
91+ if rawQuery == "" {
92+ return ""
93+ }
94+
95+ parts := strings.Split(rawQuery, "&")
96+ for i, part := range parts {
97+ if part == "" {
98+ continue
99+ }
100+
101+ key, _, found := strings.Cut(part, "=")
102+ decodedKey, err := url.QueryUnescape(key)
103+ if err != nil {
104+ decodedKey = key
105+ }
106+
107+ if !isSensitiveQueryKey(decodedKey) {
108+ continue
109+ }
110+
111+ if found {
112+ parts[i] = key + "=[REDACTED]"
113+ } else {
114+ parts[i] = key
115+ }
116+ }
117+
118+ return strings.Join(parts, "&")
119+}
120+
121+func isSensitiveQueryKey(key string) bool {
122+ switch strings.ToLower(key) {
123+ case "access_token", "api_key", "apikey", "auth", "bearer", "code", "credential", "credentials", "key", "password", "private_key", "refresh_token", "reset", "secret", "session", "session_id", "sig", "signature", "token":
124+ return true
125+ default:
126+ return false
127+ }
128+}
M internal/adapters/in/http/middleware/accesslog_test.go +23 -0
@@ -2,6 +2,7 @@ package middleware
22
33 import (
44 "fmt"
5+ "io"
56 "net/http"
67 "net/http/httptest"
78 "testing"
@@ -58,6 +59,28 @@ func TestAccessLogger_EmitsEntryPerRequest(t *testing.T) {
5859 assert.Equal(t, "192.168.1.100", e.ClientIP)
5960 }
6061
62+func TestAccessLogger_RedactsSensitiveQueryParams(t *testing.T) {
63+ mock := &mockAccessLogWriter{}
64+ log := testLogger()
65+
66+ handler := AccessLogger(mock, false, log, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
67+ w.WriteHeader(http.StatusOK)
68+ }))
69+ server := httptest.NewServer(handler)
70+ defer server.Close()
71+
72+ resp, err := http.Get(server.URL + "/oauth/callback?code=secret-code&state=ok&token=secret-token&api_key=top-secret&session_id=session-secret")
73+ require.NoError(t, err)
74+ defer func() {
75+ assert.NoError(t, resp.Body.Close())
76+ }()
77+ _, err = io.Copy(io.Discard, resp.Body)
78+ require.NoError(t, err)
79+
80+ require.Len(t, mock.entries, 1)
81+ assert.Equal(t, "code=[REDACTED]&state=ok&token=[REDACTED]&api_key=[REDACTED]&session_id=[REDACTED]", mock.entries[0].Query)
82+}
83+
6184 func TestAccessLogger_RequestIDReused(t *testing.T) {
6285 mock := &mockAccessLogWriter{}
6386 log := testLogger()
@@ -2,6 +2,7 @@ package middleware
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5+ "io"
5 "net/http"6 "net/http"
6 "net/http/httptest"7 "net/http/httptest"
7 "testing"8 "testing"
@@ -58,6 +59,28 @@ func TestAccessLogger_EmitsEntryPerRequest(t *testing.T) {
58 assert.Equal(t, "192.168.1.100", e.ClientIP)59 assert.Equal(t, "192.168.1.100", e.ClientIP)
59 }60 }
60 61
62+func TestAccessLogger_RedactsSensitiveQueryParams(t *testing.T) {
63+ mock := &mockAccessLogWriter{}
64+ log := testLogger()
65+
66+ handler := AccessLogger(mock, false, log, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
67+ w.WriteHeader(http.StatusOK)
68+ }))
69+ server := httptest.NewServer(handler)
70+ defer server.Close()
71+
72+ resp, err := http.Get(server.URL + "/oauth/callback?code=secret-code&state=ok&token=secret-token&api_key=top-secret&session_id=session-secret")
73+ require.NoError(t, err)
74+ defer func() {
75+ assert.NoError(t, resp.Body.Close())
76+ }()
77+ _, err = io.Copy(io.Discard, resp.Body)
78+ require.NoError(t, err)
79+
80+ require.Len(t, mock.entries, 1)
81+ assert.Equal(t, "code=[REDACTED]&state=ok&token=[REDACTED]&api_key=[REDACTED]&session_id=[REDACTED]", mock.entries[0].Query)
82+}
83+
61 func TestAccessLogger_RequestIDReused(t *testing.T) {84 func TestAccessLogger_RequestIDReused(t *testing.T) {
62 mock := &mockAccessLogWriter{}85 mock := &mockAccessLogWriter{}
63 log := testLogger()86 log := testLogger()
M internal/adapters/in/http/middleware/auth.go +3 -3
@@ -12,7 +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"
15+ "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
1616 "github.com/bnema/gordon/internal/boundaries/in"
1717 "github.com/bnema/gordon/internal/domain"
1818 )
@@ -59,7 +59,7 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, t
5959 return func(next http.Handler) http.Handler {
6060 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
6161 // Allow localhost requests only with internal instance credentials.
62- if httputil.IsLocalhostRequest(r) && isInternalRegistryAuth(r, internalAuth) {
62+ if httphelper.IsLocalhostRequest(r) && isInternalRegistryAuth(r, internalAuth) {
6363 log.Debug().
6464 Str(zerowrap.FieldLayer, "adapter").
6565 Str(zerowrap.FieldAdapter, "http").
@@ -72,7 +72,7 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, t
7272 }
7373
7474 // Warn if not using TLS (skip for localhost — internal proxy traffic)
75- if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" && !httputil.IsLocalhostRequest(r) {
75+ if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" && !httphelper.IsLocalhostRequest(r) {
7676 log.Warn().
7777 Str(zerowrap.FieldLayer, "adapter").
7878 Str(zerowrap.FieldAdapter, "http").
@@ -12,7 +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/adapters/in/http/httphelper"
16 "github.com/bnema/gordon/internal/boundaries/in"16 "github.com/bnema/gordon/internal/boundaries/in"
17 "github.com/bnema/gordon/internal/domain"17 "github.com/bnema/gordon/internal/domain"
18 )18 )
@@ -59,7 +59,7 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, t
59 return func(next http.Handler) http.Handler {59 return func(next http.Handler) http.Handler {
60 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {60 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
61 // Allow localhost requests only with internal instance credentials.61 // Allow localhost requests only with internal instance credentials.
62- if httputil.IsLocalhostRequest(r) && isInternalRegistryAuth(r, internalAuth) {62+ if httphelper.IsLocalhostRequest(r) && isInternalRegistryAuth(r, internalAuth) {
63 log.Debug().63 log.Debug().
64 Str(zerowrap.FieldLayer, "adapter").64 Str(zerowrap.FieldLayer, "adapter").
65 Str(zerowrap.FieldAdapter, "http").65 Str(zerowrap.FieldAdapter, "http").
@@ -72,7 +72,7 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, t
72 }72 }
73 73
74 // Warn if not using TLS (skip for localhost — internal proxy traffic)74 // Warn if not using TLS (skip for localhost — internal proxy traffic)
75- if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" && !httputil.IsLocalhostRequest(r) {75+ if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" && !httphelper.IsLocalhostRequest(r) {
76 log.Warn().76 log.Warn().
77 Str(zerowrap.FieldLayer, "adapter").77 Str(zerowrap.FieldLayer, "adapter").
78 Str(zerowrap.FieldAdapter, "http").78 Str(zerowrap.FieldAdapter, "http").
M internal/adapters/in/http/middleware/cidr.go +7 -47
@@ -5,17 +5,14 @@ import (
55 "fmt"
66 "net"
77 "net/http"
8- "strconv"
98 "strings"
109
1110 "github.com/bnema/zerowrap"
1211
1312 "github.com/bnema/gordon/internal/adapters/dto"
13+ "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
1414 )
1515
16-// localhostNets contains IPv4 and IPv6 loopback ranges that are always allowed.
17-var localhostNets = ParseTrustedProxies([]string{"127.0.0.0/8", "::1"})
18-
1916 // cidrAllowlist is the shared implementation for CIDR-based access control middleware.
2017 // ipExtractor determines how the client IP is obtained from the request.
2118 // logLabel is used in the deny log message (e.g. "registry", "proxy origin").
@@ -27,12 +24,12 @@ func cidrAllowlist(allowedNets []*net.IPNet, ipExtractor func(*http.Request) str
2724 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2825 clientIP := ipExtractor(r)
2926
30- if IsTrustedProxy(clientIP, localhostNets) {
27+ if httphelper.IsTrustedProxy(clientIP, httphelper.LocalhostNets) {
3128 next.ServeHTTP(w, r)
3229 return
3330 }
3431
35- if IsTrustedProxy(clientIP, allowedNets) {
32+ if httphelper.IsTrustedProxy(clientIP, allowedNets) {
3633 next.ServeHTTP(w, r)
3734 return
3835 }
@@ -51,43 +48,6 @@ func cidrAllowlist(allowedNets []*net.IPNet, ipExtractor func(*http.Request) str
5148 }
5249 }
5350
54-// ExtractRemoteIP returns the IP portion of a RemoteAddr, stripping the port.
55-func ExtractRemoteIP(remoteAddr string) string {
56- host, _, err := net.SplitHostPort(remoteAddr)
57- if err != nil {
58- return remoteAddr
59- }
60- return host
61-}
62-
63-// IsTrustedOrLocal reports whether an IP belongs to localhost or trusted proxy nets.
64-func IsTrustedOrLocal(ip string, proxyNets []*net.IPNet) bool {
65- return IsTrustedProxy(ip, localhostNets) || IsTrustedProxy(ip, proxyNets)
66-}
67-
68-// HTTPSAuthority converts an incoming Host header plus httpPort/tlsPort into the
69-// correct HTTPS authority (host or host:port).
70-//
71-// Rules:
72-// - No port in Host → omit TLS port (public reverse-proxy assumed on :443).
73-// - Host port == httpPort → map to tlsPort.
74-// - Any other explicit port → preserve it.
75-func HTTPSAuthority(host string, httpPort, tlsPort int) string {
76- hostname, portStr, err := net.SplitHostPort(host)
77- if err != nil {
78- // No port in Host header — omit TLS port from URL.
79- return host
80- }
81-
82- port, err := strconv.Atoi(portStr)
83- if err == nil && port == httpPort {
84- return net.JoinHostPort(hostname, strconv.Itoa(tlsPort))
85- }
86-
87- // Unknown explicit port — preserve it.
88- return net.JoinHostPort(hostname, portStr)
89-}
90-
9151 // RegistryCIDRAllowlist returns middleware that restricts access to the given CIDR ranges.
9252 // Localhost is always allowed so Gordon can pull from its own registry.
9353 // An empty allowedNets slice is a no-op (all traffic passes through).
@@ -121,8 +81,8 @@ func HTTPSRedirect(proxyNets []*net.IPNet, httpPort, tlsPort int, forceAll bool,
12181 }
12282 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
12383 if !forceAll {
124- remoteIP := ExtractRemoteIP(r.RemoteAddr)
125- if IsTrustedOrLocal(remoteIP, proxyNets) {
84+ remoteIP := httphelper.ExtractRemoteIP(r.RemoteAddr)
85+ if httphelper.IsTrustedOrLocal(remoteIP, proxyNets) {
12686 next.ServeHTTP(w, r)
12787 return
12888 }
@@ -146,7 +106,7 @@ func httpsRedirectTarget(host, requestURI string, httpPort, tlsPort int) string
146106 path = "/"
147107 }
148108
149- return fmt.Sprintf("https://%s%s", HTTPSAuthority(host, httpPort, tlsPort), path)
109+ return fmt.Sprintf("https://%s%s", httphelper.HTTPSAuthority(host, httpPort, tlsPort), path)
150110 }
151111
152112 // ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges.
@@ -155,6 +115,6 @@ func httpsRedirectTarget(host, requestURI string, httpPort, tlsPort int) string
155115 // An empty allowedNets slice is a no-op (all traffic passes through).
156116 func ProxyCIDRAllowlist(allowedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler {
157117 return cidrAllowlist(allowedNets, func(r *http.Request) string {
158- return ExtractRemoteIP(r.RemoteAddr)
118+ return httphelper.ExtractRemoteIP(r.RemoteAddr)
159119 }, "proxy origin", log)
160120 }
@@ -5,17 +5,14 @@ import (
5 "fmt"5 "fmt"
6 "net"6 "net"
7 "net/http"7 "net/http"
8- "strconv"
9 "strings"8 "strings"
10 9
11 "github.com/bnema/zerowrap"10 "github.com/bnema/zerowrap"
12 11
13 "github.com/bnema/gordon/internal/adapters/dto"12 "github.com/bnema/gordon/internal/adapters/dto"
13+ "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
14 )14 )
15 15
16-// localhostNets contains IPv4 and IPv6 loopback ranges that are always allowed.
17-var localhostNets = ParseTrustedProxies([]string{"127.0.0.0/8", "::1"})
18-
19 // cidrAllowlist is the shared implementation for CIDR-based access control middleware.16 // cidrAllowlist is the shared implementation for CIDR-based access control middleware.
20 // ipExtractor determines how the client IP is obtained from the request.17 // ipExtractor determines how the client IP is obtained from the request.
21 // logLabel is used in the deny log message (e.g. "registry", "proxy origin").18 // logLabel is used in the deny log message (e.g. "registry", "proxy origin").
@@ -27,12 +24,12 @@ func cidrAllowlist(allowedNets []*net.IPNet, ipExtractor func(*http.Request) str
27 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {24 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
28 clientIP := ipExtractor(r)25 clientIP := ipExtractor(r)
29 26
30- if IsTrustedProxy(clientIP, localhostNets) {27+ if httphelper.IsTrustedProxy(clientIP, httphelper.LocalhostNets) {
31 next.ServeHTTP(w, r)28 next.ServeHTTP(w, r)
32 return29 return
33 }30 }
34 31
35- if IsTrustedProxy(clientIP, allowedNets) {32+ if httphelper.IsTrustedProxy(clientIP, allowedNets) {
36 next.ServeHTTP(w, r)33 next.ServeHTTP(w, r)
37 return34 return
38 }35 }
@@ -51,43 +48,6 @@ func cidrAllowlist(allowedNets []*net.IPNet, ipExtractor func(*http.Request) str
51 }48 }
52 }49 }
53 50
54-// ExtractRemoteIP returns the IP portion of a RemoteAddr, stripping the port.
55-func ExtractRemoteIP(remoteAddr string) string {
56- host, _, err := net.SplitHostPort(remoteAddr)
57- if err != nil {
58- return remoteAddr
59- }
60- return host
61-}
62-
63-// IsTrustedOrLocal reports whether an IP belongs to localhost or trusted proxy nets.
64-func IsTrustedOrLocal(ip string, proxyNets []*net.IPNet) bool {
65- return IsTrustedProxy(ip, localhostNets) || IsTrustedProxy(ip, proxyNets)
66-}
67-
68-// HTTPSAuthority converts an incoming Host header plus httpPort/tlsPort into the
69-// correct HTTPS authority (host or host:port).
70-//
71-// Rules:
72-// - No port in Host → omit TLS port (public reverse-proxy assumed on :443).
73-// - Host port == httpPort → map to tlsPort.
74-// - Any other explicit port → preserve it.
75-func HTTPSAuthority(host string, httpPort, tlsPort int) string {
76- hostname, portStr, err := net.SplitHostPort(host)
77- if err != nil {
78- // No port in Host header — omit TLS port from URL.
79- return host
80- }
81-
82- port, err := strconv.Atoi(portStr)
83- if err == nil && port == httpPort {
84- return net.JoinHostPort(hostname, strconv.Itoa(tlsPort))
85- }
86-
87- // Unknown explicit port — preserve it.
88- return net.JoinHostPort(hostname, portStr)
89-}
90-
91 // RegistryCIDRAllowlist returns middleware that restricts access to the given CIDR ranges.51 // RegistryCIDRAllowlist returns middleware that restricts access to the given CIDR ranges.
92 // Localhost is always allowed so Gordon can pull from its own registry.52 // Localhost is always allowed so Gordon can pull from its own registry.
93 // An empty allowedNets slice is a no-op (all traffic passes through).53 // An empty allowedNets slice is a no-op (all traffic passes through).
@@ -121,8 +81,8 @@ func HTTPSRedirect(proxyNets []*net.IPNet, httpPort, tlsPort int, forceAll bool,
121 }81 }
122 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {82 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
123 if !forceAll {83 if !forceAll {
124- remoteIP := ExtractRemoteIP(r.RemoteAddr)84+ remoteIP := httphelper.ExtractRemoteIP(r.RemoteAddr)
125- if IsTrustedOrLocal(remoteIP, proxyNets) {85+ if httphelper.IsTrustedOrLocal(remoteIP, proxyNets) {
126 next.ServeHTTP(w, r)86 next.ServeHTTP(w, r)
127 return87 return
128 }88 }
@@ -146,7 +106,7 @@ func httpsRedirectTarget(host, requestURI string, httpPort, tlsPort int) string
146 path = "/"106 path = "/"
147 }107 }
148 108
149- return fmt.Sprintf("https://%s%s", HTTPSAuthority(host, httpPort, tlsPort), path)109+ return fmt.Sprintf("https://%s%s", httphelper.HTTPSAuthority(host, httpPort, tlsPort), path)
150 }110 }
151 111
152 // ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges.112 // ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges.
@@ -155,6 +115,6 @@ func httpsRedirectTarget(host, requestURI string, httpPort, tlsPort int) string
155 // An empty allowedNets slice is a no-op (all traffic passes through).115 // An empty allowedNets slice is a no-op (all traffic passes through).
156 func ProxyCIDRAllowlist(allowedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler {116 func ProxyCIDRAllowlist(allowedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler {
157 return cidrAllowlist(allowedNets, func(r *http.Request) string {117 return cidrAllowlist(allowedNets, func(r *http.Request) string {
158- return ExtractRemoteIP(r.RemoteAddr)118+ return httphelper.ExtractRemoteIP(r.RemoteAddr)
159 }, "proxy origin", log)119 }, "proxy origin", log)
160 }120 }
M internal/adapters/in/http/middleware/cidr_test.go +14 -13
@@ -9,6 +9,7 @@ import (
99 "testing"
1010
1111 "github.com/bnema/gordon/internal/adapters/dto"
12+ "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
1213 "github.com/stretchr/testify/assert"
1314 "github.com/stretchr/testify/require"
1415 )
@@ -41,46 +42,46 @@ func TestRegistryCIDRAllowlist(t *testing.T) {
4142 },
4243 {
4344 name: "allowed IP returns 200",
44- allowedNets: ParseTrustedProxies([]string{"100.64.0.0/10"}),
45+ allowedNets: httphelper.ParseTrustedProxies([]string{"100.64.0.0/10"}),
4546 remoteAddr: "100.100.1.1:1234",
4647 wantStatus: http.StatusOK,
4748 },
4849 {
4950 name: "denied IP returns 403",
50- allowedNets: ParseTrustedProxies([]string{"100.64.0.0/10"}),
51+ allowedNets: httphelper.ParseTrustedProxies([]string{"100.64.0.0/10"}),
5152 remoteAddr: "203.0.113.50:1234",
5253 wantStatus: http.StatusForbidden,
5354 },
5455 {
5556 name: "localhost always allowed even when not in allowlist",
56- allowedNets: ParseTrustedProxies([]string{"100.64.0.0/10"}),
57+ allowedNets: httphelper.ParseTrustedProxies([]string{"100.64.0.0/10"}),
5758 remoteAddr: "127.0.0.1:1234",
5859 wantStatus: http.StatusOK,
5960 },
6061 {
6162 name: "127.x.x.x always allowed",
62- allowedNets: ParseTrustedProxies([]string{"100.64.0.0/10"}),
63+ allowedNets: httphelper.ParseTrustedProxies([]string{"100.64.0.0/10"}),
6364 remoteAddr: "127.0.0.2:1234",
6465 wantStatus: http.StatusOK,
6566 },
6667 {
6768 name: "IPv6 localhost always allowed",
68- allowedNets: ParseTrustedProxies([]string{"100.64.0.0/10"}),
69+ allowedNets: httphelper.ParseTrustedProxies([]string{"100.64.0.0/10"}),
6970 remoteAddr: "[::1]:1234",
7071 wantStatus: http.StatusOK,
7172 },
7273 {
7374 name: "trusted proxy forwards allowed client IP",
74- allowedNets: ParseTrustedProxies([]string{"100.64.0.0/10"}),
75- trustedNets: ParseTrustedProxies([]string{"10.0.0.1"}),
75+ allowedNets: httphelper.ParseTrustedProxies([]string{"100.64.0.0/10"}),
76+ trustedNets: httphelper.ParseTrustedProxies([]string{"10.0.0.1"}),
7677 remoteAddr: "10.0.0.1:1234",
7778 xff: "100.100.1.1",
7879 wantStatus: http.StatusOK,
7980 },
8081 {
8182 name: "trusted proxy forwards denied client IP",
82- allowedNets: ParseTrustedProxies([]string{"100.64.0.0/10"}),
83- trustedNets: ParseTrustedProxies([]string{"10.0.0.1"}),
83+ allowedNets: httphelper.ParseTrustedProxies([]string{"100.64.0.0/10"}),
84+ trustedNets: httphelper.ParseTrustedProxies([]string{"10.0.0.1"}),
8485 remoteAddr: "10.0.0.1:1234",
8586 xff: "203.0.113.50",
8687 wantStatus: http.StatusForbidden,
@@ -227,25 +228,25 @@ func TestProxyCIDRAllowlist(t *testing.T) {
227228 },
228229 {
229230 name: "allowed IP gets redirect",
230- allowedNets: ParseTrustedProxies([]string{"173.245.48.0/20"}),
231+ allowedNets: httphelper.ParseTrustedProxies([]string{"173.245.48.0/20"}),
231232 remoteAddr: "173.245.48.1:1234",
232233 wantStatus: http.StatusPermanentRedirect,
233234 },
234235 {
235236 name: "blocked IP gets 403",
236- allowedNets: ParseTrustedProxies([]string{"173.245.48.0/20"}),
237+ allowedNets: httphelper.ParseTrustedProxies([]string{"173.245.48.0/20"}),
237238 remoteAddr: "203.0.113.50:1234",
238239 wantStatus: http.StatusForbidden,
239240 },
240241 {
241242 name: "localhost always allowed",
242- allowedNets: ParseTrustedProxies([]string{"173.245.48.0/20"}),
243+ allowedNets: httphelper.ParseTrustedProxies([]string{"173.245.48.0/20"}),
243244 remoteAddr: "127.0.0.1:1234",
244245 wantStatus: http.StatusPermanentRedirect,
245246 },
246247 {
247248 name: "ignores XFF (uses RemoteAddr only)",
248- allowedNets: ParseTrustedProxies([]string{"173.245.48.0/20"}),
249+ allowedNets: httphelper.ParseTrustedProxies([]string{"173.245.48.0/20"}),
249250 remoteAddr: "203.0.113.50:1234",
250251 wantStatus: http.StatusForbidden,
251252 },
@@ -9,6 +9,7 @@ import (
9 "testing"9 "testing"
10 10
11 "github.com/bnema/gordon/internal/adapters/dto"11 "github.com/bnema/gordon/internal/adapters/dto"
12+ "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
12 "github.com/stretchr/testify/assert"13 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
14 )15 )
@@ -41,46 +42,46 @@ func TestRegistryCIDRAllowlist(t *testing.T) {
41 },42 },
42 {43 {
43 name: "allowed IP returns 200",44 name: "allowed IP returns 200",
44- allowedNets: ParseTrustedProxies([]string{"100.64.0.0/10"}),45+ allowedNets: httphelper.ParseTrustedProxies([]string{"100.64.0.0/10"}),
45 remoteAddr: "100.100.1.1:1234",46 remoteAddr: "100.100.1.1:1234",
46 wantStatus: http.StatusOK,47 wantStatus: http.StatusOK,
47 },48 },
48 {49 {
49 name: "denied IP returns 403",50 name: "denied IP returns 403",
50- allowedNets: ParseTrustedProxies([]string{"100.64.0.0/10"}),51+ allowedNets: httphelper.ParseTrustedProxies([]string{"100.64.0.0/10"}),
51 remoteAddr: "203.0.113.50:1234",52 remoteAddr: "203.0.113.50:1234",
52 wantStatus: http.StatusForbidden,53 wantStatus: http.StatusForbidden,
53 },54 },
54 {55 {
55 name: "localhost always allowed even when not in allowlist",56 name: "localhost always allowed even when not in allowlist",
56- allowedNets: ParseTrustedProxies([]string{"100.64.0.0/10"}),57+ allowedNets: httphelper.ParseTrustedProxies([]string{"100.64.0.0/10"}),
57 remoteAddr: "127.0.0.1:1234",58 remoteAddr: "127.0.0.1:1234",
58 wantStatus: http.StatusOK,59 wantStatus: http.StatusOK,
59 },60 },
60 {61 {
61 name: "127.x.x.x always allowed",62 name: "127.x.x.x always allowed",
62- allowedNets: ParseTrustedProxies([]string{"100.64.0.0/10"}),63+ allowedNets: httphelper.ParseTrustedProxies([]string{"100.64.0.0/10"}),
63 remoteAddr: "127.0.0.2:1234",64 remoteAddr: "127.0.0.2:1234",
64 wantStatus: http.StatusOK,65 wantStatus: http.StatusOK,
65 },66 },
66 {67 {
67 name: "IPv6 localhost always allowed",68 name: "IPv6 localhost always allowed",
68- allowedNets: ParseTrustedProxies([]string{"100.64.0.0/10"}),69+ allowedNets: httphelper.ParseTrustedProxies([]string{"100.64.0.0/10"}),
69 remoteAddr: "[::1]:1234",70 remoteAddr: "[::1]:1234",
70 wantStatus: http.StatusOK,71 wantStatus: http.StatusOK,
71 },72 },
72 {73 {
73 name: "trusted proxy forwards allowed client IP",74 name: "trusted proxy forwards allowed client IP",
74- allowedNets: ParseTrustedProxies([]string{"100.64.0.0/10"}),75+ allowedNets: httphelper.ParseTrustedProxies([]string{"100.64.0.0/10"}),
75- trustedNets: ParseTrustedProxies([]string{"10.0.0.1"}),76+ trustedNets: httphelper.ParseTrustedProxies([]string{"10.0.0.1"}),
76 remoteAddr: "10.0.0.1:1234",77 remoteAddr: "10.0.0.1:1234",
77 xff: "100.100.1.1",78 xff: "100.100.1.1",
78 wantStatus: http.StatusOK,79 wantStatus: http.StatusOK,
79 },80 },
80 {81 {
81 name: "trusted proxy forwards denied client IP",82 name: "trusted proxy forwards denied client IP",
82- allowedNets: ParseTrustedProxies([]string{"100.64.0.0/10"}),83+ allowedNets: httphelper.ParseTrustedProxies([]string{"100.64.0.0/10"}),
83- trustedNets: ParseTrustedProxies([]string{"10.0.0.1"}),84+ trustedNets: httphelper.ParseTrustedProxies([]string{"10.0.0.1"}),
84 remoteAddr: "10.0.0.1:1234",85 remoteAddr: "10.0.0.1:1234",
85 xff: "203.0.113.50",86 xff: "203.0.113.50",
86 wantStatus: http.StatusForbidden,87 wantStatus: http.StatusForbidden,
@@ -227,25 +228,25 @@ func TestProxyCIDRAllowlist(t *testing.T) {
227 },228 },
228 {229 {
229 name: "allowed IP gets redirect",230 name: "allowed IP gets redirect",
230- allowedNets: ParseTrustedProxies([]string{"173.245.48.0/20"}),231+ allowedNets: httphelper.ParseTrustedProxies([]string{"173.245.48.0/20"}),
231 remoteAddr: "173.245.48.1:1234",232 remoteAddr: "173.245.48.1:1234",
232 wantStatus: http.StatusPermanentRedirect,233 wantStatus: http.StatusPermanentRedirect,
233 },234 },
234 {235 {
235 name: "blocked IP gets 403",236 name: "blocked IP gets 403",
236- allowedNets: ParseTrustedProxies([]string{"173.245.48.0/20"}),237+ allowedNets: httphelper.ParseTrustedProxies([]string{"173.245.48.0/20"}),
237 remoteAddr: "203.0.113.50:1234",238 remoteAddr: "203.0.113.50:1234",
238 wantStatus: http.StatusForbidden,239 wantStatus: http.StatusForbidden,
239 },240 },
240 {241 {
241 name: "localhost always allowed",242 name: "localhost always allowed",
242- allowedNets: ParseTrustedProxies([]string{"173.245.48.0/20"}),243+ allowedNets: httphelper.ParseTrustedProxies([]string{"173.245.48.0/20"}),
243 remoteAddr: "127.0.0.1:1234",244 remoteAddr: "127.0.0.1:1234",
244 wantStatus: http.StatusPermanentRedirect,245 wantStatus: http.StatusPermanentRedirect,
245 },246 },
246 {247 {
247 name: "ignores XFF (uses RemoteAddr only)",248 name: "ignores XFF (uses RemoteAddr only)",
248- allowedNets: ParseTrustedProxies([]string{"173.245.48.0/20"}),249+ allowedNets: httphelper.ParseTrustedProxies([]string{"173.245.48.0/20"}),
249 remoteAddr: "203.0.113.50:1234",250 remoteAddr: "203.0.113.50:1234",
250 wantStatus: http.StatusForbidden,251 wantStatus: http.StatusForbidden,
251 },252 },
M internal/adapters/in/http/middleware/clientip.go +6 -48
@@ -4,12 +4,14 @@ import (
44 "net"
55 "net/http"
66 "strings"
7+
8+ "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
79 )
810
911 // cloudflareNets contains Cloudflare's published IPv4 and IPv6 ranges.
1012 // Cf-Connecting-Ip is only trusted when the immediate upstream matches these.
1113 // Source: https://www.cloudflare.com/ips/
12-var cloudflareNets = ParseTrustedProxies([]string{
14+var cloudflareNets = httphelper.ParseTrustedProxies([]string{
1315 // IPv4
1416 "173.245.48.0/20",
1517 "103.21.244.0/22",
@@ -54,50 +56,6 @@ func normalizeIP(raw string) string {
5456 return parsed.String()
5557 }
5658
57-// ParseTrustedProxies converts a list of IP addresses and CIDR ranges to net.IPNet.
58-// It accepts both single IPs (e.g., "10.0.0.1") and CIDR notation (e.g., "10.0.0.0/8").
59-// Single IPs are converted to /32 (IPv4) or /128 (IPv6) CIDR blocks.
60-func ParseTrustedProxies(proxies []string) []*net.IPNet {
61- var nets []*net.IPNet
62- for _, proxy := range proxies {
63- // Try parsing as CIDR
64- _, ipNet, err := net.ParseCIDR(proxy)
65- if err == nil {
66- nets = append(nets, ipNet)
67- continue
68- }
69- // Try parsing as single IP
70- ip := net.ParseIP(proxy)
71- if ip != nil {
72- // Convert single IP to /32 or /128 CIDR
73- bits := 32
74- if ip.To4() == nil {
75- bits = 128
76- }
77- nets = append(nets, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)})
78- }
79- }
80- return nets
81-}
82-
83-// IsTrustedProxy checks if the given IP address is from a trusted proxy.
84-// Returns false if trustedNets is empty or the IP cannot be parsed.
85-func IsTrustedProxy(ip string, trustedNets []*net.IPNet) bool {
86- if len(trustedNets) == 0 {
87- return false
88- }
89- parsedIP := net.ParseIP(ip)
90- if parsedIP == nil {
91- return false
92- }
93- for _, ipNet := range trustedNets {
94- if ipNet.Contains(parsedIP) {
95- return true
96- }
97- }
98- return false
99-}
100-
10159 // GetClientIP extracts the client IP address from the request.
10260 // It only honors X-Forwarded-For and X-Real-IP headers when the request
10361 // originates from a trusted proxy. This prevents attackers from spoofing
@@ -113,12 +71,12 @@ func GetClientIP(r *http.Request, trustedNets []*net.IPNet) string {
11371 }
11472
11573 // Only honor proxy headers if the request comes from a trusted proxy
116- if IsTrustedProxy(remoteIP, trustedNets) {
74+ if httphelper.IsTrustedProxy(remoteIP, trustedNets) {
11775 // Cf-Connecting-IP is set by Cloudflare to the true client IP.
11876 // Only trust it when the immediate upstream is a known Cloudflare IP,
11977 // not any generic trusted proxy (e.g. local LB/nginx), because a
12078 // non-Cloudflare proxy would blindly forward a spoofed header.
121- if IsTrustedProxy(remoteIP, cloudflareNets) {
79+ if httphelper.IsTrustedProxy(remoteIP, cloudflareNets) {
12280 if cfIP := r.Header.Get("Cf-Connecting-Ip"); cfIP != "" {
12381 if ip := normalizeIP(cfIP); ip != "" {
12482 return ip
@@ -140,7 +98,7 @@ func GetClientIP(r *http.Request, trustedNets []*net.IPNet) string {
14098 }
14199
142100 for i := len(validChain) - 1; i >= 0; i-- {
143- if !IsTrustedProxy(validChain[i], trustedNets) {
101+ if !httphelper.IsTrustedProxy(validChain[i], trustedNets) {
144102 return validChain[i]
145103 }
146104 }
@@ -4,12 +4,14 @@ import (
4 "net"4 "net"
5 "net/http"5 "net/http"
6 "strings"6 "strings"
7+
8+ "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
7 )9 )
8 10
9 // cloudflareNets contains Cloudflare's published IPv4 and IPv6 ranges.11 // cloudflareNets contains Cloudflare's published IPv4 and IPv6 ranges.
10 // Cf-Connecting-Ip is only trusted when the immediate upstream matches these.12 // Cf-Connecting-Ip is only trusted when the immediate upstream matches these.
11 // Source: https://www.cloudflare.com/ips/13 // Source: https://www.cloudflare.com/ips/
12-var cloudflareNets = ParseTrustedProxies([]string{14+var cloudflareNets = httphelper.ParseTrustedProxies([]string{
13 // IPv415 // IPv4
14 "173.245.48.0/20",16 "173.245.48.0/20",
15 "103.21.244.0/22",17 "103.21.244.0/22",
@@ -54,50 +56,6 @@ func normalizeIP(raw string) string {
54 return parsed.String()56 return parsed.String()
55 }57 }
56 58
57-// ParseTrustedProxies converts a list of IP addresses and CIDR ranges to net.IPNet.
58-// It accepts both single IPs (e.g., "10.0.0.1") and CIDR notation (e.g., "10.0.0.0/8").
59-// Single IPs are converted to /32 (IPv4) or /128 (IPv6) CIDR blocks.
60-func ParseTrustedProxies(proxies []string) []*net.IPNet {
61- var nets []*net.IPNet
62- for _, proxy := range proxies {
63- // Try parsing as CIDR
64- _, ipNet, err := net.ParseCIDR(proxy)
65- if err == nil {
66- nets = append(nets, ipNet)
67- continue
68- }
69- // Try parsing as single IP
70- ip := net.ParseIP(proxy)
71- if ip != nil {
72- // Convert single IP to /32 or /128 CIDR
73- bits := 32
74- if ip.To4() == nil {
75- bits = 128
76- }
77- nets = append(nets, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)})
78- }
79- }
80- return nets
81-}
82-
83-// IsTrustedProxy checks if the given IP address is from a trusted proxy.
84-// Returns false if trustedNets is empty or the IP cannot be parsed.
85-func IsTrustedProxy(ip string, trustedNets []*net.IPNet) bool {
86- if len(trustedNets) == 0 {
87- return false
88- }
89- parsedIP := net.ParseIP(ip)
90- if parsedIP == nil {
91- return false
92- }
93- for _, ipNet := range trustedNets {
94- if ipNet.Contains(parsedIP) {
95- return true
96- }
97- }
98- return false
99-}
100-
101 // GetClientIP extracts the client IP address from the request.59 // GetClientIP extracts the client IP address from the request.
102 // It only honors X-Forwarded-For and X-Real-IP headers when the request60 // It only honors X-Forwarded-For and X-Real-IP headers when the request
103 // originates from a trusted proxy. This prevents attackers from spoofing61 // originates from a trusted proxy. This prevents attackers from spoofing
@@ -113,12 +71,12 @@ func GetClientIP(r *http.Request, trustedNets []*net.IPNet) string {
113 }71 }
114 72
115 // Only honor proxy headers if the request comes from a trusted proxy73 // Only honor proxy headers if the request comes from a trusted proxy
116- if IsTrustedProxy(remoteIP, trustedNets) {74+ if httphelper.IsTrustedProxy(remoteIP, trustedNets) {
117 // Cf-Connecting-IP is set by Cloudflare to the true client IP.75 // Cf-Connecting-IP is set by Cloudflare to the true client IP.
118 // Only trust it when the immediate upstream is a known Cloudflare IP,76 // Only trust it when the immediate upstream is a known Cloudflare IP,
119 // not any generic trusted proxy (e.g. local LB/nginx), because a77 // not any generic trusted proxy (e.g. local LB/nginx), because a
120 // non-Cloudflare proxy would blindly forward a spoofed header.78 // non-Cloudflare proxy would blindly forward a spoofed header.
121- if IsTrustedProxy(remoteIP, cloudflareNets) {79+ if httphelper.IsTrustedProxy(remoteIP, cloudflareNets) {
122 if cfIP := r.Header.Get("Cf-Connecting-Ip"); cfIP != "" {80 if cfIP := r.Header.Get("Cf-Connecting-Ip"); cfIP != "" {
123 if ip := normalizeIP(cfIP); ip != "" {81 if ip := normalizeIP(cfIP); ip != "" {
124 return ip82 return ip
@@ -140,7 +98,7 @@ func GetClientIP(r *http.Request, trustedNets []*net.IPNet) string {
140 }98 }
141 99
142 for i := len(validChain) - 1; i >= 0; i-- {100 for i := len(validChain) - 1; i >= 0; i-- {
143- if !IsTrustedProxy(validChain[i], trustedNets) {101+ if !httphelper.IsTrustedProxy(validChain[i], trustedNets) {
144 return validChain[i]102 return validChain[i]
145 }103 }
146 }104 }
M internal/adapters/in/http/middleware/clientip_test.go +8 -7
@@ -6,15 +6,16 @@ import (
66 "net/http/httptest"
77 "testing"
88
9+ "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
910 "github.com/stretchr/testify/assert"
1011 )
1112
1213 func TestGetClientIP(t *testing.T) {
1314 // Parse trusted proxies for tests
14- trustedNets := ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8"})
15+ trustedNets := httphelper.ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8"})
1516 noTrustedNets := []*net.IPNet{}
1617 // Include a Cloudflare IP in trusted proxies to test Cf-Connecting-Ip
17- trustedWithCF := ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8", "173.245.48.0/20"})
18+ trustedWithCF := httphelper.ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8", "173.245.48.0/20"})
1819
1920 tests := []struct {
2021 name string
@@ -216,8 +217,8 @@ func TestParseTrustedProxies(t *testing.T) {
216217
217218 for _, tt := range tests {
218219 t.Run(tt.name, func(t *testing.T) {
219- nets := ParseTrustedProxies(tt.proxies)
220- got := IsTrustedProxy(tt.testIP, nets)
220+ nets := httphelper.ParseTrustedProxies(tt.proxies)
221+ got := httphelper.IsTrustedProxy(tt.testIP, nets)
221222 assert.Equal(t, tt.want, got)
222223 })
223224 }
@@ -245,20 +246,20 @@ func TestIsTrustedProxy(t *testing.T) {
245246 {
246247 name: "invalid IP",
247248 ip: "not-an-ip",
248- trustedNets: ParseTrustedProxies([]string{"192.168.1.0/24"}),
249+ trustedNets: httphelper.ParseTrustedProxies([]string{"192.168.1.0/24"}),
249250 want: false,
250251 },
251252 {
252253 name: "empty IP",
253254 ip: "",
254- trustedNets: ParseTrustedProxies([]string{"192.168.1.0/24"}),
255+ trustedNets: httphelper.ParseTrustedProxies([]string{"192.168.1.0/24"}),
255256 want: false,
256257 },
257258 }
258259
259260 for _, tt := range tests {
260261 t.Run(tt.name, func(t *testing.T) {
261- got := IsTrustedProxy(tt.ip, tt.trustedNets)
262+ got := httphelper.IsTrustedProxy(tt.ip, tt.trustedNets)
262263 assert.Equal(t, tt.want, got)
263264 })
264265 }
@@ -6,15 +6,16 @@ import (
6 "net/http/httptest"6 "net/http/httptest"
7 "testing"7 "testing"
8 8
9+ "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
9 "github.com/stretchr/testify/assert"10 "github.com/stretchr/testify/assert"
10 )11 )
11 12
12 func TestGetClientIP(t *testing.T) {13 func TestGetClientIP(t *testing.T) {
13 // Parse trusted proxies for tests14 // Parse trusted proxies for tests
14- trustedNets := ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8"})15+ trustedNets := httphelper.ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8"})
15 noTrustedNets := []*net.IPNet{}16 noTrustedNets := []*net.IPNet{}
16 // Include a Cloudflare IP in trusted proxies to test Cf-Connecting-Ip17 // Include a Cloudflare IP in trusted proxies to test Cf-Connecting-Ip
17- trustedWithCF := ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8", "173.245.48.0/20"})18+ trustedWithCF := httphelper.ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8", "173.245.48.0/20"})
18 19
19 tests := []struct {20 tests := []struct {
20 name string21 name string
@@ -216,8 +217,8 @@ func TestParseTrustedProxies(t *testing.T) {
216 217
217 for _, tt := range tests {218 for _, tt := range tests {
218 t.Run(tt.name, func(t *testing.T) {219 t.Run(tt.name, func(t *testing.T) {
219- nets := ParseTrustedProxies(tt.proxies)220+ nets := httphelper.ParseTrustedProxies(tt.proxies)
220- got := IsTrustedProxy(tt.testIP, nets)221+ got := httphelper.IsTrustedProxy(tt.testIP, nets)
221 assert.Equal(t, tt.want, got)222 assert.Equal(t, tt.want, got)
222 })223 })
223 }224 }
@@ -245,20 +246,20 @@ func TestIsTrustedProxy(t *testing.T) {
245 {246 {
246 name: "invalid IP",247 name: "invalid IP",
247 ip: "not-an-ip",248 ip: "not-an-ip",
248- trustedNets: ParseTrustedProxies([]string{"192.168.1.0/24"}),249+ trustedNets: httphelper.ParseTrustedProxies([]string{"192.168.1.0/24"}),
249 want: false,250 want: false,
250 },251 },
251 {252 {
252 name: "empty IP",253 name: "empty IP",
253 ip: "",254 ip: "",
254- trustedNets: ParseTrustedProxies([]string{"192.168.1.0/24"}),255+ trustedNets: httphelper.ParseTrustedProxies([]string{"192.168.1.0/24"}),
255 want: false,256 want: false,
256 },257 },
257 }258 }
258 259
259 for _, tt := range tests {260 for _, tt := range tests {
260 t.Run(tt.name, func(t *testing.T) {261 t.Run(tt.name, func(t *testing.T) {
261- got := IsTrustedProxy(tt.ip, tt.trustedNets)262+ got := httphelper.IsTrustedProxy(tt.ip, tt.trustedNets)
262 assert.Equal(t, tt.want, got)263 assert.Equal(t, tt.want, got)
263 })264 })
264 }265 }
M internal/adapters/in/http/middleware/logging.go +1 -1
@@ -130,7 +130,7 @@ func RequestLogger(log zerowrap.Logger, trustedNets ...[]*net.IPNet) func(http.H
130130 Str("request_id", requestID).
131131 Str(zerowrap.FieldMethod, r.Method).
132132 Str(zerowrap.FieldPath, r.URL.Path).
133- Str("query", r.URL.RawQuery).
133+ Str("query", sanitizeLoggedQuery(r.URL.RawQuery)).
134134 Str(zerowrap.FieldHost, r.Host).
135135 Str("user_agent", r.UserAgent()).
136136 Str("referer", r.Referer()).
@@ -130,7 +130,7 @@ func RequestLogger(log zerowrap.Logger, trustedNets ...[]*net.IPNet) func(http.H
130 Str("request_id", requestID).130 Str("request_id", requestID).
131 Str(zerowrap.FieldMethod, r.Method).131 Str(zerowrap.FieldMethod, r.Method).
132 Str(zerowrap.FieldPath, r.URL.Path).132 Str(zerowrap.FieldPath, r.URL.Path).
133- Str("query", r.URL.RawQuery).133+ Str("query", sanitizeLoggedQuery(r.URL.RawQuery)).
134 Str(zerowrap.FieldHost, r.Host).134 Str(zerowrap.FieldHost, r.Host).
135 Str("user_agent", r.UserAgent()).135 Str("user_agent", r.UserAgent()).
136 Str("referer", r.Referer()).136 Str("referer", r.Referer()).
M internal/adapters/in/http/onboarding/handler.go +2 -2
@@ -6,7 +6,7 @@ import (
66 "net"
77 "net/http"
88
9- "github.com/bnema/gordon/internal/adapters/in/http/middleware"
9+ "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
1010 )
1111
1212 // Handler serves CA onboarding endpoints for direct/Tailnet clients.
@@ -49,7 +49,7 @@ func (h *Handler) ServeMobileconfig(w http.ResponseWriter, _ *http.Request) {
4949 // publicHTTPSURL derives the public HTTPS URL from the request Host header.
5050 // The result is HTML-escaped to prevent XSS via a crafted Host header.
5151 func (h *Handler) publicHTTPSURL(r *http.Request) string {
52- authority := middleware.HTTPSAuthority(r.Host, h.httpPort, h.tlsPort)
52+ authority := httphelper.HTTPSAuthority(r.Host, h.httpPort, h.tlsPort)
5353 return html.EscapeString("https://" + authority + "/")
5454 }
5555
@@ -6,7 +6,7 @@ import (
6 "net"6 "net"
7 "net/http"7 "net/http"
8 8
9- "github.com/bnema/gordon/internal/adapters/in/http/middleware"9+ "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
10 )10 )
11 11
12 // Handler serves CA onboarding endpoints for direct/Tailnet clients.12 // Handler serves CA onboarding endpoints for direct/Tailnet clients.
@@ -49,7 +49,7 @@ func (h *Handler) ServeMobileconfig(w http.ResponseWriter, _ *http.Request) {
49 // publicHTTPSURL derives the public HTTPS URL from the request Host header.49 // publicHTTPSURL derives the public HTTPS URL from the request Host header.
50 // The result is HTML-escaped to prevent XSS via a crafted Host header.50 // The result is HTML-escaped to prevent XSS via a crafted Host header.
51 func (h *Handler) publicHTTPSURL(r *http.Request) string {51 func (h *Handler) publicHTTPSURL(r *http.Request) string {
52- authority := middleware.HTTPSAuthority(r.Host, h.httpPort, h.tlsPort)52+ authority := httphelper.HTTPSAuthority(r.Host, h.httpPort, h.tlsPort)
53 return html.EscapeString("https://" + authority + "/")53 return html.EscapeString("https://" + authority + "/")
54 }54 }
55 55
M internal/adapters/in/http/proxy/forwarder.go +2 -16
@@ -15,7 +15,7 @@ import (
1515
1616 "github.com/bnema/zerowrap"
1717
18- "github.com/bnema/gordon/internal/adapters/in/http/middleware"
18+ gordonhttp "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
1919 "github.com/bnema/gordon/internal/domain"
2020 )
2121
@@ -202,7 +202,7 @@ func newReverseProxy(targetURL *url.URL, originalHost string, transport http.Rou
202202 // SetXForwarded() unconditionally sets it based on the incoming scheme
203203 // (HTTP between proxies), but when a trusted proxy already sent the
204204 // real client proto, we must honor it.
205- if existingProto != "" && isTrustedSource(incomingReq, trustedNets) {
205+ if existingProto != "" && gordonhttp.IsTrustedSource(incomingReq, trustedNets) {
206206 pr.Out.Header.Set("X-Forwarded-Proto", existingProto)
207207 }
208208 },
@@ -212,20 +212,6 @@ func newReverseProxy(targetURL *url.URL, originalHost string, transport http.Rou
212212 }
213213 }
214214
215-// isTrustedSource reports whether the request's remote address is in trustedNets.
216-func isTrustedSource(r *http.Request, trustedNets []*net.IPNet) bool {
217- if len(trustedNets) == 0 {
218- return false
219- }
220- host, _, err := net.SplitHostPort(r.RemoteAddr)
221- if err != nil {
222- // RemoteAddr may be a bare IP without a port (e.g. from Unix sockets
223- // or certain proxy setups). Fall back to using it directly.
224- host = strings.TrimSuffix(strings.TrimPrefix(r.RemoteAddr, "["), "]")
225- }
226- return middleware.IsTrustedProxy(host, trustedNets)
227-}
228-
229215 // modifyResponse returns a function that adds proxy headers and enforces response size limits.
230216 func modifyResponse(maxResponseSize int64) func(*http.Response) error {
231217 return func(resp *http.Response) error {
@@ -15,7 +15,7 @@ import (
15 15
16 "github.com/bnema/zerowrap"16 "github.com/bnema/zerowrap"
17 17
18- "github.com/bnema/gordon/internal/adapters/in/http/middleware"18+ gordonhttp "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
19 "github.com/bnema/gordon/internal/domain"19 "github.com/bnema/gordon/internal/domain"
20 )20 )
21 21
@@ -202,7 +202,7 @@ func newReverseProxy(targetURL *url.URL, originalHost string, transport http.Rou
202 // SetXForwarded() unconditionally sets it based on the incoming scheme202 // SetXForwarded() unconditionally sets it based on the incoming scheme
203 // (HTTP between proxies), but when a trusted proxy already sent the203 // (HTTP between proxies), but when a trusted proxy already sent the
204 // real client proto, we must honor it.204 // real client proto, we must honor it.
205- if existingProto != "" && isTrustedSource(incomingReq, trustedNets) {205+ if existingProto != "" && gordonhttp.IsTrustedSource(incomingReq, trustedNets) {
206 pr.Out.Header.Set("X-Forwarded-Proto", existingProto)206 pr.Out.Header.Set("X-Forwarded-Proto", existingProto)
207 }207 }
208 },208 },
@@ -212,20 +212,6 @@ func newReverseProxy(targetURL *url.URL, originalHost string, transport http.Rou
212 }212 }
213 }213 }
214 214
215-// isTrustedSource reports whether the request's remote address is in trustedNets.
216-func isTrustedSource(r *http.Request, trustedNets []*net.IPNet) bool {
217- if len(trustedNets) == 0 {
218- return false
219- }
220- host, _, err := net.SplitHostPort(r.RemoteAddr)
221- if err != nil {
222- // RemoteAddr may be a bare IP without a port (e.g. from Unix sockets
223- // or certain proxy setups). Fall back to using it directly.
224- host = strings.TrimSuffix(strings.TrimPrefix(r.RemoteAddr, "["), "]")
225- }
226- return middleware.IsTrustedProxy(host, trustedNets)
227-}
228-
229 // modifyResponse returns a function that adds proxy headers and enforces response size limits.215 // modifyResponse returns a function that adds proxy headers and enforces response size limits.
230 func modifyResponse(maxResponseSize int64) func(*http.Response) error {216 func modifyResponse(maxResponseSize int64) func(*http.Response) error {
231 return func(resp *http.Response) error {217 return func(resp *http.Response) error {
M internal/adapters/in/http/proxy/forwarder_test.go +3 -3
@@ -14,7 +14,7 @@ import (
1414 "github.com/stretchr/testify/mock"
1515 "github.com/stretchr/testify/require"
1616
17- "github.com/bnema/gordon/internal/adapters/in/http/middleware"
17+ gordonhttp "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
1818 "github.com/bnema/gordon/internal/boundaries/in"
1919 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
2020 "github.com/bnema/gordon/internal/domain"
@@ -201,7 +201,7 @@ func TestForwardToTarget_ProxyHeaderSet(t *testing.T) {
201201 assert.Equal(t, http.StatusOK, w.Code)
202202 }
203203 func TestNewReverseProxy_XForwardedProto(t *testing.T) {
204- trustedNets := middleware.ParseTrustedProxies([]string{"10.0.0.0/8"})
204+ trustedNets := gordonhttp.ParseTrustedProxies([]string{"10.0.0.0/8"})
205205
206206 tests := []struct {
207207 name string
@@ -250,7 +250,7 @@ func TestNewReverseProxy_XForwardedProto(t *testing.T) {
250250 remoteAddr: "[::1]",
251251 incomingProto: "https",
252252 wantProto: "https",
253- nets: middleware.ParseTrustedProxies([]string{"::1/128"}),
253+ nets: gordonhttp.ParseTrustedProxies([]string{"::1/128"}),
254254 },
255255 {
256256 name: "bare IPv4 no port untrusted source strips proto",
@@ -14,7 +14,7 @@ import (
14 "github.com/stretchr/testify/mock"14 "github.com/stretchr/testify/mock"
15 "github.com/stretchr/testify/require"15 "github.com/stretchr/testify/require"
16 16
17- "github.com/bnema/gordon/internal/adapters/in/http/middleware"17+ gordonhttp "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
18 "github.com/bnema/gordon/internal/boundaries/in"18 "github.com/bnema/gordon/internal/boundaries/in"
19 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"19 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
20 "github.com/bnema/gordon/internal/domain"20 "github.com/bnema/gordon/internal/domain"
@@ -201,7 +201,7 @@ func TestForwardToTarget_ProxyHeaderSet(t *testing.T) {
201 assert.Equal(t, http.StatusOK, w.Code)201 assert.Equal(t, http.StatusOK, w.Code)
202 }202 }
203 func TestNewReverseProxy_XForwardedProto(t *testing.T) {203 func TestNewReverseProxy_XForwardedProto(t *testing.T) {
204- trustedNets := middleware.ParseTrustedProxies([]string{"10.0.0.0/8"})204+ trustedNets := gordonhttp.ParseTrustedProxies([]string{"10.0.0.0/8"})
205 205
206 tests := []struct {206 tests := []struct {
207 name string207 name string
@@ -250,7 +250,7 @@ func TestNewReverseProxy_XForwardedProto(t *testing.T) {
250 remoteAddr: "[::1]",250 remoteAddr: "[::1]",
251 incomingProto: "https",251 incomingProto: "https",
252 wantProto: "https",252 wantProto: "https",
253- nets: middleware.ParseTrustedProxies([]string{"::1/128"}),253+ nets: gordonhttp.ParseTrustedProxies([]string{"::1/128"}),
254 },254 },
255 {255 {
256 name: "bare IPv4 no port untrusted source strips proto",256 name: "bare IPv4 no port untrusted source strips proto",
M internal/adapters/in/http/registry/ratelimit.go +2 -1
@@ -8,6 +8,7 @@ import (
88 "github.com/bnema/zerowrap"
99
1010 "github.com/bnema/gordon/internal/adapters/dto"
11+ "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
1112 "github.com/bnema/gordon/internal/adapters/in/http/middleware"
1213 "github.com/bnema/gordon/internal/boundaries/out"
1314 )
@@ -29,7 +30,7 @@ func RateLimitMiddleware(
2930 }
3031
3132 // Parse trusted proxy CIDRs once at middleware creation
32- trustedNets := middleware.ParseTrustedProxies(trustedProxies)
33+ trustedNets := httphelper.ParseTrustedProxies(trustedProxies)
3334
3435 return func(next http.Handler) http.Handler {
3536 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -8,6 +8,7 @@ import (
8 "github.com/bnema/zerowrap"8 "github.com/bnema/zerowrap"
9 9
10 "github.com/bnema/gordon/internal/adapters/dto"10 "github.com/bnema/gordon/internal/adapters/dto"
11+ "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
11 "github.com/bnema/gordon/internal/adapters/in/http/middleware"12 "github.com/bnema/gordon/internal/adapters/in/http/middleware"
12 "github.com/bnema/gordon/internal/boundaries/out"13 "github.com/bnema/gordon/internal/boundaries/out"
13 )14 )
@@ -29,7 +30,7 @@ func RateLimitMiddleware(
29 }30 }
30 31
31 // Parse trusted proxy CIDRs once at middleware creation32 // Parse trusted proxy CIDRs once at middleware creation
32- trustedNets := middleware.ParseTrustedProxies(trustedProxies)33+ trustedNets := httphelper.ParseTrustedProxies(trustedProxies)
33 34
34 return func(next http.Handler) http.Handler {35 return func(next http.Handler) http.Handler {
35 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {36 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
M internal/adapters/in/http/registry/ratelimit_test.go +4 -3
@@ -9,6 +9,7 @@ import (
99
1010 "github.com/stretchr/testify/assert"
1111
12+ "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
1213 "github.com/bnema/gordon/internal/adapters/in/http/middleware"
1314 "github.com/bnema/gordon/internal/adapters/out/ratelimit"
1415 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
@@ -221,7 +222,7 @@ func TestRateLimitMiddleware_Integration(t *testing.T) {
221222
222223 func TestGetClientIP(t *testing.T) {
223224 // Parse trusted proxies for tests
224- trustedNets := middleware.ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8"})
225+ trustedNets := httphelper.ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8"})
225226 noTrustedNets := []*net.IPNet{}
226227
227228 tests := []struct {
@@ -373,8 +374,8 @@ func TestParseTrustedProxies(t *testing.T) {
373374
374375 for _, tt := range tests {
375376 t.Run(tt.name, func(t *testing.T) {
376- nets := middleware.ParseTrustedProxies(tt.proxies)
377- got := middleware.IsTrustedProxy(tt.testIP, nets)
377+ nets := httphelper.ParseTrustedProxies(tt.proxies)
378+ got := httphelper.IsTrustedProxy(tt.testIP, nets)
378379 assert.Equal(t, tt.want, got)
379380 })
380381 }
@@ -9,6 +9,7 @@ import (
9 9
10 "github.com/stretchr/testify/assert"10 "github.com/stretchr/testify/assert"
11 11
12+ "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
12 "github.com/bnema/gordon/internal/adapters/in/http/middleware"13 "github.com/bnema/gordon/internal/adapters/in/http/middleware"
13 "github.com/bnema/gordon/internal/adapters/out/ratelimit"14 "github.com/bnema/gordon/internal/adapters/out/ratelimit"
14 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"15 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
@@ -221,7 +222,7 @@ func TestRateLimitMiddleware_Integration(t *testing.T) {
221 222
222 func TestGetClientIP(t *testing.T) {223 func TestGetClientIP(t *testing.T) {
223 // Parse trusted proxies for tests224 // Parse trusted proxies for tests
224- trustedNets := middleware.ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8"})225+ trustedNets := httphelper.ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8"})
225 noTrustedNets := []*net.IPNet{}226 noTrustedNets := []*net.IPNet{}
226 227
227 tests := []struct {228 tests := []struct {
@@ -373,8 +374,8 @@ func TestParseTrustedProxies(t *testing.T) {
373 374
374 for _, tt := range tests {375 for _, tt := range tests {
375 t.Run(tt.name, func(t *testing.T) {376 t.Run(tt.name, func(t *testing.T) {
376- nets := middleware.ParseTrustedProxies(tt.proxies)377+ nets := httphelper.ParseTrustedProxies(tt.proxies)
377- got := middleware.IsTrustedProxy(tt.testIP, nets)378+ got := httphelper.IsTrustedProxy(tt.testIP, nets)
378 assert.Equal(t, tt.want, got)379 assert.Equal(t, tt.want, got)
379 })380 })
380 }381 }
M internal/adapters/out/accesslog/writer_test.go +6 -3
@@ -72,7 +72,8 @@ func TestNew_FileOutput_CreatesDirectory(t *testing.T) {
7272 func TestWriter_Stdout_JSON(t *testing.T) {
7373 // Replace stdout to capture output.
7474 orig := os.Stdout
75- r, w, _ := os.Pipe()
75+ r, w, err := os.Pipe()
76+ require.NoError(t, err)
7677 os.Stdout = w
7778
7879 writer, err := New(Config{Format: "json", Output: "stdout"})
@@ -97,7 +98,8 @@ func TestWriter_Stdout_JSON(t *testing.T) {
9798
9899 func TestWriter_Stdout_CLF(t *testing.T) {
99100 orig := os.Stdout
100- r, w, _ := os.Pipe()
101+ r, w, err := os.Pipe()
102+ require.NoError(t, err)
101103 os.Stdout = w
102104
103105 writer, err := New(Config{Format: "clf", Output: "stdout"})
@@ -119,7 +121,8 @@ func TestWriter_Stdout_CLF(t *testing.T) {
119121
120122 func TestWriter_Stdout_Combined(t *testing.T) {
121123 orig := os.Stdout
122- r, w, _ := os.Pipe()
124+ r, w, err := os.Pipe()
125+ require.NoError(t, err)
123126 os.Stdout = w
124127
125128 writer, err := New(Config{Format: "combined", Output: "stdout"})
@@ -72,7 +72,8 @@ func TestNew_FileOutput_CreatesDirectory(t *testing.T) {
72 func TestWriter_Stdout_JSON(t *testing.T) {72 func TestWriter_Stdout_JSON(t *testing.T) {
73 // Replace stdout to capture output.73 // Replace stdout to capture output.
74 orig := os.Stdout74 orig := os.Stdout
75- r, w, _ := os.Pipe()75+ r, w, err := os.Pipe()
76+ require.NoError(t, err)
76 os.Stdout = w77 os.Stdout = w
77 78
78 writer, err := New(Config{Format: "json", Output: "stdout"})79 writer, err := New(Config{Format: "json", Output: "stdout"})
@@ -97,7 +98,8 @@ func TestWriter_Stdout_JSON(t *testing.T) {
97 98
98 func TestWriter_Stdout_CLF(t *testing.T) {99 func TestWriter_Stdout_CLF(t *testing.T) {
99 orig := os.Stdout100 orig := os.Stdout
100- r, w, _ := os.Pipe()101+ r, w, err := os.Pipe()
102+ require.NoError(t, err)
101 os.Stdout = w103 os.Stdout = w
102 104
103 writer, err := New(Config{Format: "clf", Output: "stdout"})105 writer, err := New(Config{Format: "clf", Output: "stdout"})
@@ -119,7 +121,8 @@ func TestWriter_Stdout_CLF(t *testing.T) {
119 121
120 func TestWriter_Stdout_Combined(t *testing.T) {122 func TestWriter_Stdout_Combined(t *testing.T) {
121 orig := os.Stdout123 orig := os.Stdout
122- r, w, _ := os.Pipe()124+ r, w, err := os.Pipe()
125+ require.NoError(t, err)
123 os.Stdout = w126 os.Stdout = w
124 127
125 writer, err := New(Config{Format: "combined", Output: "stdout"})128 writer, err := New(Config{Format: "combined", Output: "stdout"})
M internal/adapters/out/docker/runtime.go +2 -2
@@ -468,7 +468,7 @@ func (r *Runtime) PullImage(ctx context.Context, imageRef string) error {
468468
469469 reader, err := r.client.ImagePull(ctx, imageRef, image.PullOptions{})
470470 if err != nil {
471- return log.WrapErr(err, "failed to pull image")
471+ return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image")
472472 }
473473 defer reader.Close()
474474
@@ -528,7 +528,7 @@ func (r *Runtime) PullImageWithAuth(ctx context.Context, imageRef, username, pas
528528 RegistryAuth: authStr,
529529 })
530530 if err != nil {
531- return log.WrapErr(err, "failed to pull image with auth")
531+ return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image with auth")
532532 }
533533 defer reader.Close()
534534
@@ -468,7 +468,7 @@ func (r *Runtime) PullImage(ctx context.Context, imageRef string) error {
468 468
469 reader, err := r.client.ImagePull(ctx, imageRef, image.PullOptions{})469 reader, err := r.client.ImagePull(ctx, imageRef, image.PullOptions{})
470 if err != nil {470 if err != nil {
471- return log.WrapErr(err, "failed to pull image")471+ return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image")
472 }472 }
473 defer reader.Close()473 defer reader.Close()
474 474
@@ -528,7 +528,7 @@ func (r *Runtime) PullImageWithAuth(ctx context.Context, imageRef, username, pas
528 RegistryAuth: authStr,528 RegistryAuth: authStr,
529 })529 })
530 if err != nil {530 if err != nil {
531- return log.WrapErr(err, "failed to pull image with auth")531+ return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image with auth")
532 }532 }
533 defer reader.Close()533 defer reader.Close()
534 534
M internal/adapters/out/pki/ca.go +10 -0
@@ -449,6 +449,16 @@ func (ca *CA) IntermediateLifetime() time.Duration {
449449 return intermediateLifetime
450450 }
451451
452+// InstallRoot installs the root CA certificate into the system trust store.
453+func (ca *CA) InstallRoot(cert *x509.Certificate) error {
454+ return InstallRoot(cert)
455+}
456+
457+// UninstallRoot removes the root CA certificate from the system trust store.
458+func (ca *CA) UninstallRoot(cert *x509.Certificate) error {
459+ return UninstallRoot(cert)
460+}
461+
452462 func randomSerial() (*big.Int, error) {
453463 serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
454464 if err != nil {
@@ -449,6 +449,16 @@ func (ca *CA) IntermediateLifetime() time.Duration {
449 return intermediateLifetime449 return intermediateLifetime
450 }450 }
451 451
452+// InstallRoot installs the root CA certificate into the system trust store.
453+func (ca *CA) InstallRoot(cert *x509.Certificate) error {
454+ return InstallRoot(cert)
455+}
456+
457+// UninstallRoot removes the root CA certificate from the system trust store.
458+func (ca *CA) UninstallRoot(cert *x509.Certificate) error {
459+ return UninstallRoot(cert)
460+}
461+
452 func randomSerial() (*big.Int, error) {462 func randomSerial() (*big.Int, error) {
453 serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))463 serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
454 if err != nil {464 if err != nil {
M internal/adapters/out/pki/mobileconfig.go +36 -2
@@ -4,6 +4,8 @@ import (
44 "encoding/base64"
55 "fmt"
66 "html"
7+ "strings"
8+ "unicode"
79
810 "github.com/google/uuid"
911 )
@@ -16,6 +18,7 @@ func GenerateMobileconfig(rootDER []byte, rootCN string) []byte {
1618 certUUID := uuid.New().String()
1719
1820 safeCN := html.EscapeString(rootCN)
21+ identifierCN := payloadIdentifierComponent(rootCN)
1922 profile := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
2023 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2124 <plist version="1.0">
@@ -53,8 +56,39 @@ func GenerateMobileconfig(rootDER []byte, rootCN string) []byte {
5356 <string>%s</string>
5457 <key>PayloadVersion</key>
5558 <integer>1</integer>
56-</dict>
57-</plist>`, b64Cert, safeCN, certUUID, certUUID, profileUUID)
59+ </dict>
60+</plist>`, b64Cert, safeCN, identifierCN, certUUID, profileUUID)
5861
5962 return []byte(profile)
6063 }
64+
65+func payloadIdentifierComponent(rootCN string) string {
66+ var b strings.Builder
67+ lastHyphen := false
68+
69+ for _, r := range rootCN {
70+ r = unicode.ToLower(r)
71+ switch {
72+ case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '.':
73+ b.WriteRune(r)
74+ lastHyphen = false
75+ case r == '-':
76+ if !lastHyphen {
77+ b.WriteRune(r)
78+ lastHyphen = true
79+ }
80+ default:
81+ if !lastHyphen {
82+ b.WriteRune('-')
83+ lastHyphen = true
84+ }
85+ }
86+ }
87+
88+ identifier := strings.Trim(b.String(), "-.")
89+ if identifier == "" {
90+ return "gordon-ca"
91+ }
92+
93+ return identifier
94+}
@@ -4,6 +4,8 @@ import (
4 "encoding/base64"4 "encoding/base64"
5 "fmt"5 "fmt"
6 "html"6 "html"
7+ "strings"
8+ "unicode"
7 9
8 "github.com/google/uuid"10 "github.com/google/uuid"
9 )11 )
@@ -16,6 +18,7 @@ func GenerateMobileconfig(rootDER []byte, rootCN string) []byte {
16 certUUID := uuid.New().String()18 certUUID := uuid.New().String()
17 19
18 safeCN := html.EscapeString(rootCN)20 safeCN := html.EscapeString(rootCN)
21+ identifierCN := payloadIdentifierComponent(rootCN)
19 profile := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>22 profile := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
20 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">23 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
21 <plist version="1.0">24 <plist version="1.0">
@@ -53,8 +56,39 @@ func GenerateMobileconfig(rootDER []byte, rootCN string) []byte {
53 <string>%s</string>56 <string>%s</string>
54 <key>PayloadVersion</key>57 <key>PayloadVersion</key>
55 <integer>1</integer>58 <integer>1</integer>
56-</dict>59+ </dict>
57-</plist>`, b64Cert, safeCN, certUUID, certUUID, profileUUID)60+</plist>`, b64Cert, safeCN, identifierCN, certUUID, profileUUID)
58 61
59 return []byte(profile)62 return []byte(profile)
60 }63 }
64+
65+func payloadIdentifierComponent(rootCN string) string {
66+ var b strings.Builder
67+ lastHyphen := false
68+
69+ for _, r := range rootCN {
70+ r = unicode.ToLower(r)
71+ switch {
72+ case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '.':
73+ b.WriteRune(r)
74+ lastHyphen = false
75+ case r == '-':
76+ if !lastHyphen {
77+ b.WriteRune(r)
78+ lastHyphen = true
79+ }
80+ default:
81+ if !lastHyphen {
82+ b.WriteRune('-')
83+ lastHyphen = true
84+ }
85+ }
86+ }
87+
88+ identifier := strings.Trim(b.String(), "-.")
89+ if identifier == "" {
90+ return "gordon-ca"
91+ }
92+
93+ return identifier
94+}
M internal/adapters/out/pki/mobileconfig_test.go +9 -0
@@ -21,3 +21,12 @@ func TestGenerateMobileconfig(t *testing.T) {
2121 assert.Contains(t, string(mc), "<key>PayloadType</key>")
2222 assert.Contains(t, string(mc), "com.apple.security.root")
2323 }
24+
25+func TestGenerateMobileconfig_UsesIdentifierSafePayloadIdentifier(t *testing.T) {
26+ mc := pki.GenerateMobileconfig([]byte("cert"), `Gordon Internal CA <prod>`)
27+
28+ assert.Contains(t, string(mc), "<string>Gordon Internal CA &lt;prod&gt;</string>")
29+ assert.Contains(t, string(mc), "<string>dev.gordon.ca.gordon-internal-ca-prod</string>")
30+ assert.NotContains(t, string(mc), "dev.gordon.ca.Gordon Internal CA")
31+ assert.NotContains(t, string(mc), "dev.gordon.ca.gordon-internal-ca-&lt;prod&gt;")
32+}
@@ -21,3 +21,12 @@ func TestGenerateMobileconfig(t *testing.T) {
21 assert.Contains(t, string(mc), "<key>PayloadType</key>")21 assert.Contains(t, string(mc), "<key>PayloadType</key>")
22 assert.Contains(t, string(mc), "com.apple.security.root")22 assert.Contains(t, string(mc), "com.apple.security.root")
23 }23 }
24+
25+func TestGenerateMobileconfig_UsesIdentifierSafePayloadIdentifier(t *testing.T) {
26+ mc := pki.GenerateMobileconfig([]byte("cert"), `Gordon Internal CA <prod>`)
27+
28+ assert.Contains(t, string(mc), "<string>Gordon Internal CA &lt;prod&gt;</string>")
29+ assert.Contains(t, string(mc), "<string>dev.gordon.ca.gordon-internal-ca-prod</string>")
30+ assert.NotContains(t, string(mc), "dev.gordon.ca.Gordon Internal CA")
31+ assert.NotContains(t, string(mc), "dev.gordon.ca.gordon-internal-ca-&lt;prod&gt;")
32+}
M internal/app/run.go +13 -19
@@ -46,6 +46,7 @@ import (
4646 "github.com/bnema/gordon/internal/adapters/dto"
4747 "github.com/bnema/gordon/internal/adapters/in/http/admin"
4848 authhandler "github.com/bnema/gordon/internal/adapters/in/http/auth"
49+ "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
4950 "github.com/bnema/gordon/internal/adapters/in/http/middleware"
5051 "github.com/bnema/gordon/internal/adapters/in/http/onboarding"
5152 proxyadapter "github.com/bnema/gordon/internal/adapters/in/http/proxy"
@@ -88,7 +89,6 @@ type Config struct {
8889 Port int `mapstructure:"port"`
8990 RegistryPort int `mapstructure:"registry_port"`
9091 GordonDomain string `mapstructure:"gordon_domain"`
91- RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain
9292 TLSPort int `mapstructure:"tls_port"`
9393 TLSCertFile string `mapstructure:"tls_cert_file"`
9494 TLSKeyFile string `mapstructure:"tls_key_file"`
@@ -314,11 +314,6 @@ func initConfig(configPath string) (*viper.Viper, Config, error) {
314314 return nil, Config{}, fmt.Errorf("failed to unmarshal config: %w", err)
315315 }
316316
317- // Normalize domain config: prefer gordon_domain over registry_domain
318- if cfg.Server.GordonDomain != "" {
319- cfg.Server.RegistryDomain = cfg.Server.GordonDomain
320- }
321-
322317 return v, cfg, nil
323318 }
324319
@@ -1385,7 +1380,7 @@ func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, erro
13851380
13861381 return &proxyConfigResult{
13871382 proxyConfig: proxy.Config{
1388- RegistryDomain: cfg.Server.RegistryDomain,
1383+ RegistryDomain: cfg.Server.GordonDomain,
13891384 RegistryPort: cfg.Server.RegistryPort,
13901385 MaxBodySize: maxProxyBodySize,
13911386 MaxResponseSize: maxProxyResponseSize,
@@ -1420,9 +1415,11 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
14201415 defaultNanoCPUs = int64(cfg.Containers.CPULimit * 1e9)
14211416 }
14221417
1418+ attachmentConfig := svc.configSvc.GetAttachmentConfig()
1419+
14231420 containerConfig := container.Config{
14241421 RegistryAuthEnabled: cfg.Auth.Enabled,
1425- RegistryDomain: cfg.Server.RegistryDomain,
1422+ RegistryDomain: cfg.Server.GordonDomain,
14261423 RegistryPort: cfg.Server.RegistryPort,
14271424 InternalRegistryUsername: svc.internalRegUser,
14281425 InternalRegistryPassword: svc.internalRegPass,
@@ -1432,8 +1429,8 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
14321429 VolumePreserve: v.GetBool("volumes.preserve"),
14331430 NetworkIsolation: v.GetBool("network_isolation.enabled"),
14341431 NetworkPrefix: v.GetString("network_isolation.network_prefix"),
1435- NetworkGroups: svc.configSvc.GetNetworkGroups(),
1436- Attachments: svc.configSvc.GetAttachments(),
1432+ NetworkGroups: attachmentConfig.NetworkGroups,
1433+ Attachments: attachmentConfig.Attachments,
14371434 ReadinessDelay: v.GetDuration("deploy.readiness_delay"),
14381435 ReadinessMode: v.GetString("deploy.readiness_mode"),
14391436 HealthTimeout: v.GetDuration("deploy.health_timeout"),
@@ -1546,7 +1543,7 @@ func registerEventHandlers(ctx context.Context, svc *services, cfg Config) (func
15461543 }
15471544
15481545 // Auto-route handler for creating routes from image labels
1549- autoRouteHandler := container.NewAutoRouteHandler(ctx, svc.configSvc, svc.containerSvc, svc.blobStorage, cfg.Server.RegistryDomain).
1546+ autoRouteHandler := container.NewAutoRouteHandler(ctx, svc.configSvc, svc.containerSvc, svc.blobStorage, cfg.Server.GordonDomain).
15501547 WithEnvExtractor(svc.runtime, svc.envDir)
15511548
15521549 // Preview handler for creating preview environments from tagged images
@@ -1616,9 +1613,6 @@ func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, lo
16161613 log.Error().Err(err).Msg("failed to unmarshal config on reload")
16171614 return
16181615 }
1619- if reloadCfg.Server.GordonDomain != "" {
1620- reloadCfg.Server.RegistryDomain = reloadCfg.Server.GordonDomain
1621- }
16221616 reloadedProxy, err := buildProxyConfig(reloadCfg, log)
16231617 if err != nil {
16241618 log.Error().Err(err).Msg("failed to parse proxy config on reload")
@@ -1680,7 +1674,7 @@ func loopbackOnly(next http.Handler, log zerowrap.Logger) http.Handler {
16801674 func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWriter out.AccessLogWriter) (http.Handler, http.Handler, http.Handler) {
16811675 // Parse trusted proxies once for all middleware chains.
16821676 // This ensures consistent IP extraction across logging, rate limiting, and auth.
1683- trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies)
1677+ trustedNets := httphelper.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies)
16841678
16851679 // Registry handler (unchanged)
16861680 registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize)
@@ -1812,8 +1806,8 @@ func directHTTPOnboardingGate(ob *onboarding.Handler, proxyNets []*net.IPNet, pr
18121806 )(onboardingMux)
18131807
18141808 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1815- remoteIP := middleware.ExtractRemoteIP(r.RemoteAddr)
1816- if middleware.IsTrustedOrLocal(remoteIP, proxyNets) {
1809+ remoteIP := httphelper.ExtractRemoteIP(r.RemoteAddr)
1810+ if httphelper.IsTrustedOrLocal(remoteIP, proxyNets) {
18171811 proxyChain.ServeHTTP(w, r)
18181812 return
18191813 }
@@ -1868,10 +1862,10 @@ func parseCIDRAllowlist(ips []string, label string, log zerowrap.Logger) ([]*net
18681862 return nil, false
18691863 }
18701864
1871- allowedNets := middleware.ParseTrustedProxies(ips)
1865+ allowedNets := httphelper.ParseTrustedProxies(ips)
18721866 if len(allowedNets) != len(ips) {
18731867 for _, entry := range ips {
1874- if nets := middleware.ParseTrustedProxies([]string{entry}); len(nets) == 0 {
1868+ if nets := httphelper.ParseTrustedProxies([]string{entry}); len(nets) == 0 {
18751869 log.Warn().Str("entry", entry).Msgf("ignoring invalid %s entry", label)
18761870 }
18771871 }
@@ -46,6 +46,7 @@ import (
46 "github.com/bnema/gordon/internal/adapters/dto"46 "github.com/bnema/gordon/internal/adapters/dto"
47 "github.com/bnema/gordon/internal/adapters/in/http/admin"47 "github.com/bnema/gordon/internal/adapters/in/http/admin"
48 authhandler "github.com/bnema/gordon/internal/adapters/in/http/auth"48 authhandler "github.com/bnema/gordon/internal/adapters/in/http/auth"
49+ "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
49 "github.com/bnema/gordon/internal/adapters/in/http/middleware"50 "github.com/bnema/gordon/internal/adapters/in/http/middleware"
50 "github.com/bnema/gordon/internal/adapters/in/http/onboarding"51 "github.com/bnema/gordon/internal/adapters/in/http/onboarding"
51 proxyadapter "github.com/bnema/gordon/internal/adapters/in/http/proxy"52 proxyadapter "github.com/bnema/gordon/internal/adapters/in/http/proxy"
@@ -88,7 +89,6 @@ type Config struct {
88 Port int `mapstructure:"port"`89 Port int `mapstructure:"port"`
89 RegistryPort int `mapstructure:"registry_port"`90 RegistryPort int `mapstructure:"registry_port"`
90 GordonDomain string `mapstructure:"gordon_domain"`91 GordonDomain string `mapstructure:"gordon_domain"`
91- RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain
92 TLSPort int `mapstructure:"tls_port"`92 TLSPort int `mapstructure:"tls_port"`
93 TLSCertFile string `mapstructure:"tls_cert_file"`93 TLSCertFile string `mapstructure:"tls_cert_file"`
94 TLSKeyFile string `mapstructure:"tls_key_file"`94 TLSKeyFile string `mapstructure:"tls_key_file"`
@@ -314,11 +314,6 @@ func initConfig(configPath string) (*viper.Viper, Config, error) {
314 return nil, Config{}, fmt.Errorf("failed to unmarshal config: %w", err)314 return nil, Config{}, fmt.Errorf("failed to unmarshal config: %w", err)
315 }315 }
316 316
317- // Normalize domain config: prefer gordon_domain over registry_domain
318- if cfg.Server.GordonDomain != "" {
319- cfg.Server.RegistryDomain = cfg.Server.GordonDomain
320- }
321-
322 return v, cfg, nil317 return v, cfg, nil
323 }318 }
324 319
@@ -1385,7 +1380,7 @@ func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, erro
1385 1380
1386 return &proxyConfigResult{1381 return &proxyConfigResult{
1387 proxyConfig: proxy.Config{1382 proxyConfig: proxy.Config{
1388- RegistryDomain: cfg.Server.RegistryDomain,1383+ RegistryDomain: cfg.Server.GordonDomain,
1389 RegistryPort: cfg.Server.RegistryPort,1384 RegistryPort: cfg.Server.RegistryPort,
1390 MaxBodySize: maxProxyBodySize,1385 MaxBodySize: maxProxyBodySize,
1391 MaxResponseSize: maxProxyResponseSize,1386 MaxResponseSize: maxProxyResponseSize,
@@ -1420,9 +1415,11 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
1420 defaultNanoCPUs = int64(cfg.Containers.CPULimit * 1e9)1415 defaultNanoCPUs = int64(cfg.Containers.CPULimit * 1e9)
1421 }1416 }
1422 1417
1418+ attachmentConfig := svc.configSvc.GetAttachmentConfig()
1419+
1423 containerConfig := container.Config{1420 containerConfig := container.Config{
1424 RegistryAuthEnabled: cfg.Auth.Enabled,1421 RegistryAuthEnabled: cfg.Auth.Enabled,
1425- RegistryDomain: cfg.Server.RegistryDomain,1422+ RegistryDomain: cfg.Server.GordonDomain,
1426 RegistryPort: cfg.Server.RegistryPort,1423 RegistryPort: cfg.Server.RegistryPort,
1427 InternalRegistryUsername: svc.internalRegUser,1424 InternalRegistryUsername: svc.internalRegUser,
1428 InternalRegistryPassword: svc.internalRegPass,1425 InternalRegistryPassword: svc.internalRegPass,
@@ -1432,8 +1429,8 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
1432 VolumePreserve: v.GetBool("volumes.preserve"),1429 VolumePreserve: v.GetBool("volumes.preserve"),
1433 NetworkIsolation: v.GetBool("network_isolation.enabled"),1430 NetworkIsolation: v.GetBool("network_isolation.enabled"),
1434 NetworkPrefix: v.GetString("network_isolation.network_prefix"),1431 NetworkPrefix: v.GetString("network_isolation.network_prefix"),
1435- NetworkGroups: svc.configSvc.GetNetworkGroups(),1432+ NetworkGroups: attachmentConfig.NetworkGroups,
1436- Attachments: svc.configSvc.GetAttachments(),1433+ Attachments: attachmentConfig.Attachments,
1437 ReadinessDelay: v.GetDuration("deploy.readiness_delay"),1434 ReadinessDelay: v.GetDuration("deploy.readiness_delay"),
1438 ReadinessMode: v.GetString("deploy.readiness_mode"),1435 ReadinessMode: v.GetString("deploy.readiness_mode"),
1439 HealthTimeout: v.GetDuration("deploy.health_timeout"),1436 HealthTimeout: v.GetDuration("deploy.health_timeout"),
@@ -1546,7 +1543,7 @@ func registerEventHandlers(ctx context.Context, svc *services, cfg Config) (func
1546 }1543 }
1547 1544
1548 // Auto-route handler for creating routes from image labels1545 // Auto-route handler for creating routes from image labels
1549- autoRouteHandler := container.NewAutoRouteHandler(ctx, svc.configSvc, svc.containerSvc, svc.blobStorage, cfg.Server.RegistryDomain).1546+ autoRouteHandler := container.NewAutoRouteHandler(ctx, svc.configSvc, svc.containerSvc, svc.blobStorage, cfg.Server.GordonDomain).
1550 WithEnvExtractor(svc.runtime, svc.envDir)1547 WithEnvExtractor(svc.runtime, svc.envDir)
1551 1548
1552 // Preview handler for creating preview environments from tagged images1549 // Preview handler for creating preview environments from tagged images
@@ -1616,9 +1613,6 @@ func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, lo
1616 log.Error().Err(err).Msg("failed to unmarshal config on reload")1613 log.Error().Err(err).Msg("failed to unmarshal config on reload")
1617 return1614 return
1618 }1615 }
1619- if reloadCfg.Server.GordonDomain != "" {
1620- reloadCfg.Server.RegistryDomain = reloadCfg.Server.GordonDomain
1621- }
1622 reloadedProxy, err := buildProxyConfig(reloadCfg, log)1616 reloadedProxy, err := buildProxyConfig(reloadCfg, log)
1623 if err != nil {1617 if err != nil {
1624 log.Error().Err(err).Msg("failed to parse proxy config on reload")1618 log.Error().Err(err).Msg("failed to parse proxy config on reload")
@@ -1680,7 +1674,7 @@ func loopbackOnly(next http.Handler, log zerowrap.Logger) http.Handler {
1680 func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWriter out.AccessLogWriter) (http.Handler, http.Handler, http.Handler) {1674 func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWriter out.AccessLogWriter) (http.Handler, http.Handler, http.Handler) {
1681 // Parse trusted proxies once for all middleware chains.1675 // Parse trusted proxies once for all middleware chains.
1682 // This ensures consistent IP extraction across logging, rate limiting, and auth.1676 // This ensures consistent IP extraction across logging, rate limiting, and auth.
1683- trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies)1677+ trustedNets := httphelper.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies)
1684 1678
1685 // Registry handler (unchanged)1679 // Registry handler (unchanged)
1686 registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize)1680 registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize)
@@ -1812,8 +1806,8 @@ func directHTTPOnboardingGate(ob *onboarding.Handler, proxyNets []*net.IPNet, pr
1812 )(onboardingMux)1806 )(onboardingMux)
1813 1807
1814 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {1808 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1815- remoteIP := middleware.ExtractRemoteIP(r.RemoteAddr)1809+ remoteIP := httphelper.ExtractRemoteIP(r.RemoteAddr)
1816- if middleware.IsTrustedOrLocal(remoteIP, proxyNets) {1810+ if httphelper.IsTrustedOrLocal(remoteIP, proxyNets) {
1817 proxyChain.ServeHTTP(w, r)1811 proxyChain.ServeHTTP(w, r)
1818 return1812 return
1819 }1813 }
@@ -1868,10 +1862,10 @@ func parseCIDRAllowlist(ips []string, label string, log zerowrap.Logger) ([]*net
1868 return nil, false1862 return nil, false
1869 }1863 }
1870 1864
1871- allowedNets := middleware.ParseTrustedProxies(ips)1865+ allowedNets := httphelper.ParseTrustedProxies(ips)
1872 if len(allowedNets) != len(ips) {1866 if len(allowedNets) != len(ips) {
1873 for _, entry := range ips {1867 for _, entry := range ips {
1874- if nets := middleware.ParseTrustedProxies([]string{entry}); len(nets) == 0 {1868+ if nets := httphelper.ParseTrustedProxies([]string{entry}); len(nets) == 0 {
1875 log.Warn().Str("entry", entry).Msgf("ignoring invalid %s entry", label)1869 log.Warn().Str("entry", entry).Msgf("ignoring invalid %s entry", label)
1876 }1870 }
1877 }1871 }
M internal/boundaries/out/attachment_config.go +0 -3
@@ -20,9 +20,6 @@ type AttachmentConfigProvider interface {
2020 // network groups, read under a single lock to prevent cross-field races.
2121 GetAttachmentConfig() AttachmentConfigSnapshot
2222
23- // GetAttachments returns a deep copy of the current attachment config: domain/group -> []image.
24- GetAttachments() map[string][]string
25-
2623 // GetNetworkGroups returns a deep copy of the current network group config: group -> []domain.
2724 GetNetworkGroups() map[string][]string
2825 }
@@ -20,9 +20,6 @@ type AttachmentConfigProvider interface {
20 // network groups, read under a single lock to prevent cross-field races.20 // network groups, read under a single lock to prevent cross-field races.
21 GetAttachmentConfig() AttachmentConfigSnapshot21 GetAttachmentConfig() AttachmentConfigSnapshot
22 22
23- // GetAttachments returns a deep copy of the current attachment config: domain/group -> []image.
24- GetAttachments() map[string][]string
25-
26 // GetNetworkGroups returns a deep copy of the current network group config: group -> []domain.23 // GetNetworkGroups returns a deep copy of the current network group config: group -> []domain.
27 GetNetworkGroups() map[string][]string24 GetNetworkGroups() map[string][]string
28 }25 }
M internal/boundaries/out/mocks/mock_attachment_config_provider.go +0 -46
@@ -80,52 +80,6 @@ func (_c *MockAttachmentConfigProvider_GetAttachmentConfig_Call) RunAndReturn(ru
8080 return _c
8181 }
8282
83-// GetAttachments provides a mock function for the type MockAttachmentConfigProvider
84-func (_mock *MockAttachmentConfigProvider) GetAttachments() map[string][]string {
85- ret := _mock.Called()
86-
87- if len(ret) == 0 {
88- panic("no return value specified for GetAttachments")
89- }
90-
91- var r0 map[string][]string
92- if returnFunc, ok := ret.Get(0).(func() map[string][]string); ok {
93- r0 = returnFunc()
94- } else {
95- if ret.Get(0) != nil {
96- r0 = ret.Get(0).(map[string][]string)
97- }
98- }
99- return r0
100-}
101-
102-// MockAttachmentConfigProvider_GetAttachments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAttachments'
103-type MockAttachmentConfigProvider_GetAttachments_Call struct {
104- *mock.Call
105-}
106-
107-// GetAttachments is a helper method to define mock.On call
108-func (_e *MockAttachmentConfigProvider_Expecter) GetAttachments() *MockAttachmentConfigProvider_GetAttachments_Call {
109- return &MockAttachmentConfigProvider_GetAttachments_Call{Call: _e.mock.On("GetAttachments")}
110-}
111-
112-func (_c *MockAttachmentConfigProvider_GetAttachments_Call) Run(run func()) *MockAttachmentConfigProvider_GetAttachments_Call {
113- _c.Call.Run(func(args mock.Arguments) {
114- run()
115- })
116- return _c
117-}
118-
119-func (_c *MockAttachmentConfigProvider_GetAttachments_Call) Return(stringToStrings map[string][]string) *MockAttachmentConfigProvider_GetAttachments_Call {
120- _c.Call.Return(stringToStrings)
121- return _c
122-}
123-
124-func (_c *MockAttachmentConfigProvider_GetAttachments_Call) RunAndReturn(run func() map[string][]string) *MockAttachmentConfigProvider_GetAttachments_Call {
125- _c.Call.Return(run)
126- return _c
127-}
128-
12983 // GetNetworkGroups provides a mock function for the type MockAttachmentConfigProvider
13084 func (_mock *MockAttachmentConfigProvider) GetNetworkGroups() map[string][]string {
13185 ret := _mock.Called()
@@ -80,52 +80,6 @@ func (_c *MockAttachmentConfigProvider_GetAttachmentConfig_Call) RunAndReturn(ru
80 return _c80 return _c
81 }81 }
82 82
83-// GetAttachments provides a mock function for the type MockAttachmentConfigProvider
84-func (_mock *MockAttachmentConfigProvider) GetAttachments() map[string][]string {
85- ret := _mock.Called()
86-
87- if len(ret) == 0 {
88- panic("no return value specified for GetAttachments")
89- }
90-
91- var r0 map[string][]string
92- if returnFunc, ok := ret.Get(0).(func() map[string][]string); ok {
93- r0 = returnFunc()
94- } else {
95- if ret.Get(0) != nil {
96- r0 = ret.Get(0).(map[string][]string)
97- }
98- }
99- return r0
100-}
101-
102-// MockAttachmentConfigProvider_GetAttachments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAttachments'
103-type MockAttachmentConfigProvider_GetAttachments_Call struct {
104- *mock.Call
105-}
106-
107-// GetAttachments is a helper method to define mock.On call
108-func (_e *MockAttachmentConfigProvider_Expecter) GetAttachments() *MockAttachmentConfigProvider_GetAttachments_Call {
109- return &MockAttachmentConfigProvider_GetAttachments_Call{Call: _e.mock.On("GetAttachments")}
110-}
111-
112-func (_c *MockAttachmentConfigProvider_GetAttachments_Call) Run(run func()) *MockAttachmentConfigProvider_GetAttachments_Call {
113- _c.Call.Run(func(args mock.Arguments) {
114- run()
115- })
116- return _c
117-}
118-
119-func (_c *MockAttachmentConfigProvider_GetAttachments_Call) Return(stringToStrings map[string][]string) *MockAttachmentConfigProvider_GetAttachments_Call {
120- _c.Call.Return(stringToStrings)
121- return _c
122-}
123-
124-func (_c *MockAttachmentConfigProvider_GetAttachments_Call) RunAndReturn(run func() map[string][]string) *MockAttachmentConfigProvider_GetAttachments_Call {
125- _c.Call.Return(run)
126- return _c
127-}
128-
129 // GetNetworkGroups provides a mock function for the type MockAttachmentConfigProvider83 // GetNetworkGroups provides a mock function for the type MockAttachmentConfigProvider
130 func (_mock *MockAttachmentConfigProvider) GetNetworkGroups() map[string][]string {84 func (_mock *MockAttachmentConfigProvider) GetNetworkGroups() map[string][]string {
131 ret := _mock.Called()85 ret := _mock.Called()
M internal/boundaries/out/pki.go +7 -0
@@ -3,6 +3,7 @@ package out
33 import (
44 "context"
55 "crypto/tls"
6+ "crypto/x509"
67 "time"
78
89 "github.com/bnema/gordon/internal/domain"
@@ -49,4 +50,10 @@ type CertificateAuthority interface {
4950
5051 // IntermediateLifetime returns the configured intermediate CA lifetime.
5152 IntermediateLifetime() time.Duration
53+
54+ // InstallRoot installs the root CA certificate into the system trust store.
55+ InstallRoot(cert *x509.Certificate) error
56+
57+ // UninstallRoot removes the root CA certificate from the system trust store.
58+ UninstallRoot(cert *x509.Certificate) error
5259 }
@@ -3,6 +3,7 @@ package out
3 import (3 import (
4 "context"4 "context"
5 "crypto/tls"5 "crypto/tls"
6+ "crypto/x509"
6 "time"7 "time"
7 8
8 "github.com/bnema/gordon/internal/domain"9 "github.com/bnema/gordon/internal/domain"
@@ -49,4 +50,10 @@ type CertificateAuthority interface {
49 50
50 // IntermediateLifetime returns the configured intermediate CA lifetime.51 // IntermediateLifetime returns the configured intermediate CA lifetime.
51 IntermediateLifetime() time.Duration52 IntermediateLifetime() time.Duration
53+
54+ // InstallRoot installs the root CA certificate into the system trust store.
55+ InstallRoot(cert *x509.Certificate) error
56+
57+ // UninstallRoot removes the root CA certificate from the system trust store.
58+ UninstallRoot(cert *x509.Certificate) error
52 }59 }
M internal/domain/deploy_failure.go +3 -1
@@ -1,5 +1,7 @@
11 package domain
22
3+const DefaultDeployFailureSummary = "failed to deploy"
4+
35 // DeployFailureError describes a surfaced deployment failure.
46 type DeployFailureError struct {
57 Summary string
@@ -14,7 +16,7 @@ type DeployFailureError struct {
1416 // Error returns the deploy failure summary.
1517 func (e *DeployFailureError) Error() string {
1618 if e == nil || e.Summary == "" {
17- return "failed to deploy"
19+ return DefaultDeployFailureSummary
1820 }
1921
2022 return e.Summary
@@ -1,5 +1,7 @@
1 package domain1 package domain
2 2
3+const DefaultDeployFailureSummary = "failed to deploy"
4+
3 // DeployFailureError describes a surfaced deployment failure.5 // DeployFailureError describes a surfaced deployment failure.
4 type DeployFailureError struct {6 type DeployFailureError struct {
5 Summary string7 Summary string
@@ -14,7 +16,7 @@ type DeployFailureError struct {
14 // Error returns the deploy failure summary.16 // Error returns the deploy failure summary.
15 func (e *DeployFailureError) Error() string {17 func (e *DeployFailureError) Error() string {
16 if e == nil || e.Summary == "" {18 if e == nil || e.Summary == "" {
17- return "failed to deploy"19+ return DefaultDeployFailureSummary
18 }20 }
19 21
20 return e.Summary22 return e.Summary
M internal/usecase/config/service.go +0 -13
@@ -933,19 +933,6 @@ func (s *Service) GetAttachmentConfig() out.AttachmentConfigSnapshot {
933933 }
934934 }
935935
936-// GetAttachments returns attachment configuration.
937-// Deprecated: Use GetAllAttachments instead.
938-func (s *Service) GetAttachments() map[string][]string {
939- s.mu.RLock()
940- defer s.mu.RUnlock()
941-
942- result := make(map[string][]string)
943- for k, v := range s.config.Attachments {
944- result[k] = append([]string{}, v...)
945- }
946- return result
947-}
948-
949936 // GetAllAttachments returns all configured attachments.
950937 func (s *Service) GetAllAttachments(_ context.Context) map[string][]string {
951938 s.mu.RLock()
@@ -933,19 +933,6 @@ func (s *Service) GetAttachmentConfig() out.AttachmentConfigSnapshot {
933 }933 }
934 }934 }
935 935
936-// GetAttachments returns attachment configuration.
937-// Deprecated: Use GetAllAttachments instead.
938-func (s *Service) GetAttachments() map[string][]string {
939- s.mu.RLock()
940- defer s.mu.RUnlock()
941-
942- result := make(map[string][]string)
943- for k, v := range s.config.Attachments {
944- result[k] = append([]string{}, v...)
945- }
946- return result
947-}
948-
949 // GetAllAttachments returns all configured attachments.936 // GetAllAttachments returns all configured attachments.
950 func (s *Service) GetAllAttachments(_ context.Context) map[string][]string {937 func (s *Service) GetAllAttachments(_ context.Context) map[string][]string {
951 s.mu.RLock()938 s.mu.RLock()
M internal/usecase/config/service_test.go +0 -18
@@ -469,24 +469,6 @@ func TestService_GetNetworkGroups(t *testing.T) {
469469 assert.ElementsMatch(t, []string{"api.example.com", "db.example.com"}, groups["backend"])
470470 }
471471
472-func TestService_GetAttachments(t *testing.T) {
473- v := viper.New()
474- v.Set("attachments", map[string]interface{}{
475- "app.example.com": []interface{}{"redis:latest", "postgres:18"},
476- })
477-
478- eventBus := mocks.NewMockEventPublisher(t)
479- svc := NewService(v, eventBus)
480- ctx := testContext()
481-
482- _ = svc.Load(ctx)
483-
484- attachments := svc.GetAttachments()
485-
486- assert.Len(t, attachments, 1)
487- assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, attachments["app.example.com"])
488-}
489-
490472 func TestService_GetAttachmentConfig(t *testing.T) {
491473 v := viper.New()
492474 v.Set("attachments", map[string]interface{}{
@@ -469,24 +469,6 @@ func TestService_GetNetworkGroups(t *testing.T) {
469 assert.ElementsMatch(t, []string{"api.example.com", "db.example.com"}, groups["backend"])469 assert.ElementsMatch(t, []string{"api.example.com", "db.example.com"}, groups["backend"])
470 }470 }
471 471
472-func TestService_GetAttachments(t *testing.T) {
473- v := viper.New()
474- v.Set("attachments", map[string]interface{}{
475- "app.example.com": []interface{}{"redis:latest", "postgres:18"},
476- })
477-
478- eventBus := mocks.NewMockEventPublisher(t)
479- svc := NewService(v, eventBus)
480- ctx := testContext()
481-
482- _ = svc.Load(ctx)
483-
484- attachments := svc.GetAttachments()
485-
486- assert.Len(t, attachments, 1)
487- assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, attachments["app.example.com"])
488-}
489-
490 func TestService_GetAttachmentConfig(t *testing.T) {472 func TestService_GetAttachmentConfig(t *testing.T) {
491 v := viper.New()473 v := viper.New()
492 v.Set("attachments", map[string]interface{}{474 v.Set("attachments", map[string]interface{}{
M internal/usecase/container/service.go +9 -6
@@ -835,10 +835,13 @@ func isPullFailure(err error) bool {
835835 return false
836836 }
837837
838+ if errors.Is(err, domain.ErrImagePullFailed) {
839+ return true
840+ }
841+
838842 msg := strings.ToLower(err.Error())
839843 return strings.Contains(msg, "failed to pull image") ||
840- strings.Contains(msg, "pull access denied") ||
841- strings.Contains(msg, "unauthorized")
844+ strings.Contains(msg, "pull access denied")
842845 }
843846
844847 func (s *Service) activateDeployedContainer(ctx context.Context, domainName string, container *domain.Container) bool {
@@ -1911,24 +1914,24 @@ func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool
19111914 if err := s.pullImageWithRetry(ctx, func(ctx context.Context) error {
19121915 return s.runtime.PullImageWithAuth(ctx, pullRef, cfg.InternalRegistryUsername, cfg.InternalRegistryPassword)
19131916 }, isInternal); err != nil {
1914- return log.WrapErr(err, "failed to pull image with internal auth")
1917+ return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image with internal auth")
19151918 }
19161919 case isInternal:
19171920 if err := s.pullImageWithRetry(ctx, func(ctx context.Context) error {
19181921 return s.runtime.PullImage(ctx, pullRef)
19191922 }, isInternal); err != nil {
1920- return log.WrapErr(err, "failed to pull image")
1923+ return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image")
19211924 }
19221925 case cfg.RegistryAuthEnabled:
19231926 if cfg.ServiceTokenUsername == "" || cfg.ServiceToken == "" {
19241927 return log.WrapErr(fmt.Errorf("registry service token not configured"), "failed to pull image for registry auth")
19251928 }
19261929 if err := s.runtime.PullImageWithAuth(ctx, pullRef, cfg.ServiceTokenUsername, cfg.ServiceToken); err != nil {
1927- return log.WrapErr(err, "failed to pull image with auth")
1930+ return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image with auth")
19281931 }
19291932 default:
19301933 if err := s.runtime.PullImage(ctx, pullRef); err != nil {
1931- return log.WrapErr(err, "failed to pull image")
1934+ return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image")
19321935 }
19331936 }
19341937
@@ -835,10 +835,13 @@ func isPullFailure(err error) bool {
835 return false835 return false
836 }836 }
837 837
838+ if errors.Is(err, domain.ErrImagePullFailed) {
839+ return true
840+ }
841+
838 msg := strings.ToLower(err.Error())842 msg := strings.ToLower(err.Error())
839 return strings.Contains(msg, "failed to pull image") ||843 return strings.Contains(msg, "failed to pull image") ||
840- strings.Contains(msg, "pull access denied") ||844+ strings.Contains(msg, "pull access denied")
841- strings.Contains(msg, "unauthorized")
842 }845 }
843 846
844 func (s *Service) activateDeployedContainer(ctx context.Context, domainName string, container *domain.Container) bool {847 func (s *Service) activateDeployedContainer(ctx context.Context, domainName string, container *domain.Container) bool {
@@ -1911,24 +1914,24 @@ func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool
1911 if err := s.pullImageWithRetry(ctx, func(ctx context.Context) error {1914 if err := s.pullImageWithRetry(ctx, func(ctx context.Context) error {
1912 return s.runtime.PullImageWithAuth(ctx, pullRef, cfg.InternalRegistryUsername, cfg.InternalRegistryPassword)1915 return s.runtime.PullImageWithAuth(ctx, pullRef, cfg.InternalRegistryUsername, cfg.InternalRegistryPassword)
1913 }, isInternal); err != nil {1916 }, isInternal); err != nil {
1914- return log.WrapErr(err, "failed to pull image with internal auth")1917+ return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image with internal auth")
1915 }1918 }
1916 case isInternal:1919 case isInternal:
1917 if err := s.pullImageWithRetry(ctx, func(ctx context.Context) error {1920 if err := s.pullImageWithRetry(ctx, func(ctx context.Context) error {
1918 return s.runtime.PullImage(ctx, pullRef)1921 return s.runtime.PullImage(ctx, pullRef)
1919 }, isInternal); err != nil {1922 }, isInternal); err != nil {
1920- return log.WrapErr(err, "failed to pull image")1923+ return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image")
1921 }1924 }
1922 case cfg.RegistryAuthEnabled:1925 case cfg.RegistryAuthEnabled:
1923 if cfg.ServiceTokenUsername == "" || cfg.ServiceToken == "" {1926 if cfg.ServiceTokenUsername == "" || cfg.ServiceToken == "" {
1924 return log.WrapErr(fmt.Errorf("registry service token not configured"), "failed to pull image for registry auth")1927 return log.WrapErr(fmt.Errorf("registry service token not configured"), "failed to pull image for registry auth")
1925 }1928 }
1926 if err := s.runtime.PullImageWithAuth(ctx, pullRef, cfg.ServiceTokenUsername, cfg.ServiceToken); err != nil {1929 if err := s.runtime.PullImageWithAuth(ctx, pullRef, cfg.ServiceTokenUsername, cfg.ServiceToken); err != nil {
1927- return log.WrapErr(err, "failed to pull image with auth")1930+ return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image with auth")
1928 }1931 }
1929 default:1932 default:
1930 if err := s.runtime.PullImage(ctx, pullRef); err != nil {1933 if err := s.runtime.PullImage(ctx, pullRef); err != nil {
1931- return log.WrapErr(err, "failed to pull image")1934+ return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image")
1932 }1935 }
1933 }1936 }
1934 1937
M internal/usecase/container/service_test.go +35 -0
@@ -499,9 +499,44 @@ func TestService_Deploy_ImagePullFailure(t *testing.T) {
499499 assert.Equal(t, "verify registry auth and confirm the image exists at the requested tag", deployErr.Hint)
500500 assert.Equal(t, "gordon-test.example.com", deployErr.ContainerName)
501501 assert.Empty(t, deployErr.ContainerID)
502+ assert.ErrorIs(t, deployErr.Err, domain.ErrImagePullFailed)
502503 assert.ErrorContains(t, deployErr.Err, "failed to pull image")
503504 }
504505
506+func TestIsPullFailure_ErrImagePullFailed(t *testing.T) {
507+ err := fmt.Errorf("wrapped: %w", domain.ErrImagePullFailed)
508+
509+ assert.True(t, isPullFailure(err))
510+}
511+
512+func TestService_Deploy_DoesNotMisclassifyUnauthorizedEnvLoadFailure(t *testing.T) {
513+ runtime := mocks.NewMockContainerRuntime(t)
514+ envLoader := mocks.NewMockEnvLoader(t)
515+ eventBus := mocks.NewMockEventPublisher(t)
516+
517+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
518+ ctx := testContext()
519+
520+ route := domain.Route{
521+ Domain: "test.example.com",
522+ Image: "myapp:latest",
523+ }
524+
525+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
526+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
527+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
528+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
529+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
530+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return(nil, errors.New("unauthorized"))
531+
532+ result, err := svc.Deploy(ctx, route)
533+
534+ assert.Nil(t, result)
535+ assert.ErrorContains(t, err, "failed to load environment variables: unauthorized")
536+ var deployErr *domain.DeployFailureError
537+ assert.False(t, errors.As(err, &deployErr))
538+}
539+
505540 func TestService_CaptureRecentContainerLogs_SkipsCanceledContext(t *testing.T) {
506541 runtime := mocks.NewMockContainerRuntime(t)
507542 svc := NewService(runtime, nil, nil, nil, Config{}, nil)
@@ -499,9 +499,44 @@ func TestService_Deploy_ImagePullFailure(t *testing.T) {
499 assert.Equal(t, "verify registry auth and confirm the image exists at the requested tag", deployErr.Hint)499 assert.Equal(t, "verify registry auth and confirm the image exists at the requested tag", deployErr.Hint)
500 assert.Equal(t, "gordon-test.example.com", deployErr.ContainerName)500 assert.Equal(t, "gordon-test.example.com", deployErr.ContainerName)
501 assert.Empty(t, deployErr.ContainerID)501 assert.Empty(t, deployErr.ContainerID)
502+ assert.ErrorIs(t, deployErr.Err, domain.ErrImagePullFailed)
502 assert.ErrorContains(t, deployErr.Err, "failed to pull image")503 assert.ErrorContains(t, deployErr.Err, "failed to pull image")
503 }504 }
504 505
506+func TestIsPullFailure_ErrImagePullFailed(t *testing.T) {
507+ err := fmt.Errorf("wrapped: %w", domain.ErrImagePullFailed)
508+
509+ assert.True(t, isPullFailure(err))
510+}
511+
512+func TestService_Deploy_DoesNotMisclassifyUnauthorizedEnvLoadFailure(t *testing.T) {
513+ runtime := mocks.NewMockContainerRuntime(t)
514+ envLoader := mocks.NewMockEnvLoader(t)
515+ eventBus := mocks.NewMockEventPublisher(t)
516+
517+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
518+ ctx := testContext()
519+
520+ route := domain.Route{
521+ Domain: "test.example.com",
522+ Image: "myapp:latest",
523+ }
524+
525+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
526+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
527+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
528+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
529+ runtime.EXPECT().GetImageLabels(mock.Anything, "myapp:latest").Return(nil, nil)
530+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return(nil, errors.New("unauthorized"))
531+
532+ result, err := svc.Deploy(ctx, route)
533+
534+ assert.Nil(t, result)
535+ assert.ErrorContains(t, err, "failed to load environment variables: unauthorized")
536+ var deployErr *domain.DeployFailureError
537+ assert.False(t, errors.As(err, &deployErr))
538+}
539+
505 func TestService_CaptureRecentContainerLogs_SkipsCanceledContext(t *testing.T) {540 func TestService_CaptureRecentContainerLogs_SkipsCanceledContext(t *testing.T) {
506 runtime := mocks.NewMockContainerRuntime(t)541 runtime := mocks.NewMockContainerRuntime(t)
507 svc := NewService(runtime, nil, nil, nil, Config{}, nil)542 svc := NewService(runtime, nil, nil, nil, Config{}, nil)

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