Merge pull request #166 from bnema/review/pre-release-polish
refactor: resolve pre-release release polish findings
45 files · +431 -343
M internal/adapters/in/cli/auth.go +24 -15
| @@ -232,6 +232,15 @@ Examples: | |||
| 232 | 232 | 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 | 244 | func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer) error { | |
| 236 | 245 | token = strings.TrimSpace(token) | |
| 237 | 246 | if token == "" { | |
| @@ -239,9 +248,9 @@ func runAuthLoginWithToken(ctx context.Context, token string, out io.Writer) err | |||
| 239 | 248 | } | |
| 240 | 249 | ||
| 241 | 250 | // 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 | |
| 245 | 254 | } | |
| 246 | 255 | ||
| 247 | 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 | 282 | 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 | |
| 277 | 286 | } | |
| 278 | 287 | if resolved.Token == "" { | |
| 279 | 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 | 328 | 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 | |
| 323 | 332 | } | |
| 324 | 333 | if resolved.Token == "" { | |
| 325 | 334 | return fmt.Errorf("no token found for remote '%s'", resolved.DisplayName()) | |
| @@ -355,9 +364,9 @@ Examples: | |||
| 355 | 364 | } | |
| 356 | 365 | ||
| 357 | 366 | 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 | |
| 361 | 370 | } | |
| 362 | 371 | if resolved.Name == "" { | |
| 363 | 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 | 710 | // runAuthStatus checks authentication status for the active remote. | |
| 702 | 711 | 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 | |
| 706 | 715 | } | |
| 707 | 716 | ||
| 708 | 717 | client := remote.NewClient(resolved.URL, remoteClientOptions(resolved.Token, resolved.InsecureTLS)...) | |
| @@ -232,6 +232,15 @@ Examples: | |||||
| 232 | return cmd | 232 | 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 ( | |||
| 7 | 7 | "text/tabwriter" | |
| 8 | 8 | "time" | |
| 9 | 9 | ||
| 10 | + | "github.com/spf13/cobra" | |
| 11 | + | ||
| 10 | 12 | "github.com/bnema/gordon/internal/adapters/dto" | |
| 11 | 13 | "github.com/bnema/gordon/pkg/bytesize" | |
| 12 | - | "github.com/spf13/cobra" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 15 | 16 | // 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 ( | |||
| 13 | 13 | "github.com/spf13/cobra" | |
| 14 | 14 | ||
| 15 | 15 | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | |
| 16 | + | boundaryout "github.com/bnema/gordon/internal/boundaries/out" | |
| 16 | 17 | ) | |
| 17 | 18 | ||
| 18 | 19 | func newCACmd() *cobra.Command { | |
| @@ -108,7 +109,7 @@ func resolveCADataDir() (string, error) { | |||
| 108 | 109 | // loadCAFromDataDir creates a CA adapter directly (bypassing the usecase layer). | |
| 109 | 110 | // This is intentional: CLI commands like 'ca export' and 'ca info' are standalone | |
| 110 | 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 | 113 | ca, err := pkiadapter.NewCA(dataDir, cliLogger()) | |
| 113 | 114 | if err != nil { | |
| 114 | 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 | 158 | if uninstall { | |
| 158 | - | if err := pkiadapter.UninstallRoot(cert); err != nil { | |
| 159 | + | if err := ca.UninstallRoot(cert); err != nil { | |
| 159 | 160 | return fmt.Errorf("failed to uninstall root CA: %w", err) | |
| 160 | 161 | } | |
| 161 | 162 | if jsonOut { | |
| @@ -164,7 +165,7 @@ func runCAInstall(_ context.Context, out io.Writer, dataDir string, uninstall, j | |||
| 164 | 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 | 169 | return fmt.Errorf("failed to install root CA: %w", err) | |
| 169 | 170 | } | |
| 170 | 171 | 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 standalone | 110 | // 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 | |||
| 71 | 71 | messageDomain = result.Domain | |
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | - | containerID := result.ContainerID | |
| 75 | - | if containerID != "" && len(containerID) > 12 { | |
| 76 | - | containerID = containerID[:12] | |
| 77 | - | } | |
| 74 | + | containerID := shortContainerID(result.ContainerID) | |
| 78 | 75 | ||
| 79 | 76 | switch result.Status { | |
| 80 | 77 | case "deployed": | |
| @@ -71,10 +71,7 @@ func runDeploy(ctx context.Context, deployer deployer, isRemote bool, deployDoma | |||||
| 71 | messageDomain = result.Domain | 71 | messageDomain = result.Domain | ||
| 72 | } | 72 | } | ||
| 73 | 73 | ||||
| 74 | - | containerID := result.ContainerID | 74 | + | 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) { | |||
| 56 | 56 | ||
| 57 | 57 | func renderDeployFailure(summary, cause, hint string, logs []string) string { | |
| 58 | 58 | if summary == "" { | |
| 59 | - | summary = "failed to deploy" | |
| 59 | + | summary = domain.DefaultDeployFailureSummary | |
| 60 | 60 | } | |
| 61 | 61 | ||
| 62 | 62 | var b strings.Builder | |
| 63 | - | if summary == "failed to deploy" { | |
| 63 | + | if summary == domain.DefaultDeployFailureSummary { | |
| 64 | 64 | b.WriteString(summary) | |
| 65 | 65 | } else { | |
| 66 | - | b.WriteString("failed to deploy: ") | |
| 66 | + | b.WriteString(domain.DefaultDeployFailureSummary) | |
| 67 | + | b.WriteString(": ") | |
| 67 | 68 | b.WriteString(summary) | |
| 68 | 69 | } | |
| 69 | 70 | ||
| @@ -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.Builder | 62 | 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 { | |||
| 16 | 16 | } | |
| 17 | 17 | ||
| 18 | 18 | var uiAdoptionExpectations = []uiAdoptionExpectation{ | |
| 19 | - | { | |
| 20 | - | family: "server", | |
| 21 | - | file: "serve.go", | |
| 22 | - | functions: []string{"newStartCmd"}, | |
| 23 | - | }, | |
| 24 | 19 | { | |
| 25 | 20 | family: "root/server", | |
| 26 | 21 | 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 { | |||
| 63 | 63 | func cliRenderInfo(msg string) string { | |
| 64 | 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 | + | } | |
| @@ -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 | |||
| 681 | 681 | if err != nil { | |
| 682 | 682 | return formatDeployFailure(err) | |
| 683 | 683 | } | |
| 684 | - | containerID := result.ContainerID | |
| 685 | - | if len(containerID) > 12 { | |
| 686 | - | containerID = containerID[:12] | |
| 687 | - | } | |
| 684 | + | containerID := shortContainerID(result.ContainerID) | |
| 688 | 685 | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Deployed %s (container: %s)", pushDomain, containerID))) | |
| 689 | 686 | return nil | |
| 690 | 687 | } | |
| @@ -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.ContainerID | 684 | + | 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 nil | 686 | return nil | ||
| 690 | } | 687 | } | ||
M internal/adapters/in/cli/remote/config.go +8 -2
| @@ -97,7 +97,9 @@ func migrateClientConfig(config *ClientConfig) { | |||
| 97 | 97 | 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 | 104 | config.Remotes["default"] = RemoteEntry{ | |
| 103 | 105 | URL: legacy.Client.Remote, | |
| @@ -110,7 +112,11 @@ func migrateClientConfig(config *ClientConfig) { | |||
| 110 | 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 | 122 | func defaultGordonTomlPath() string { | |
| @@ -97,7 +97,9 @@ func migrateClientConfig(config *ClientConfig) { | |||||
| 97 | return | 97 | 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 | |||
| 244 | 244 | } | |
| 245 | 245 | return fmt.Errorf("failed to deploy (route reverted): %w", err) | |
| 246 | 246 | } | |
| 247 | - | containerID := result.ContainerID | |
| 248 | - | if len(containerID) > 12 { | |
| 249 | - | containerID = containerID[:12] | |
| 250 | - | } | |
| 247 | + | containerID := shortContainerID(result.ContainerID) | |
| 251 | 248 | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Rolled back %s to %s (container: %s)", rollbackDomain, selectedTag, containerID))) | |
| 252 | 249 | return nil | |
| 253 | 250 | } | |
| @@ -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.ContainerID | 247 | + | 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 nil | 249 | return nil | ||
| 253 | } | 250 | } | ||
M internal/adapters/in/cli/root.go +0 -4
| @@ -68,10 +68,6 @@ Commands are organized by where they run: | |||
| 68 | 68 | serveCmd.GroupID = groupServer | |
| 69 | 69 | rootCmd.AddCommand(serveCmd) | |
| 70 | 70 | ||
| 71 | - | startCmd := newStartCmd() // Deprecated alias for serve | |
| 72 | - | startCmd.GroupID = groupServer | |
| 73 | - | rootCmd.AddCommand(startCmd) | |
| 74 | - | ||
| 75 | 71 | authCmd := newAuthCmd() | |
| 76 | 72 | authCmd.GroupID = groupServer | |
| 77 | 73 | rootCmd.AddCommand(authCmd) | |
| @@ -68,10 +68,6 @@ Commands are organized by where they run: | |||||
| 68 | serveCmd.GroupID = groupServer | 68 | 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 = groupServer | 72 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "os" | |
| 6 | 5 | ||
| 7 | 6 | "github.com/bnema/gordon/internal/app" | |
| 8 | 7 | ||
| @@ -27,27 +26,3 @@ func newServeCmd() *cobra.Command { | |||
| 27 | 26 | ||
| 28 | 27 | 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 | - | } | |
| @@ -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 cmd | 27 | 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 ( | |||
| 10 | 10 | "github.com/stretchr/testify/assert" | |
| 11 | 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 | 14 | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | |
| 15 | 15 | outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 16 | 16 | "github.com/bnema/gordon/internal/domain" | |
| @@ -78,7 +78,7 @@ func TestAuthMiddleware_TrustedProxy(t *testing.T) { | |||
| 78 | 78 | ipLimiter := outmocks.NewMockRateLimiter(t) | |
| 79 | 79 | ||
| 80 | 80 | // 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 | 83 | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true) | |
| 84 | 84 | 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 IP | 80 | // 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 ( | |||
| 12 | 12 | "github.com/bnema/zerowrap" | |
| 13 | 13 | ||
| 14 | 14 | "github.com/bnema/gordon/internal/adapters/dto" | |
| 15 | - | "github.com/bnema/gordon/internal/adapters/in/http/httputil" | |
| 15 | + | "github.com/bnema/gordon/internal/adapters/in/http/httphelper" | |
| 16 | 16 | "github.com/bnema/gordon/internal/boundaries/in" | |
| 17 | 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 | 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 | 170 | log.Debug().Str("username", username).Msg("internal registry auth accepted") | |
| 171 | 171 | return true, &domain.TokenClaims{ | |
| 172 | 172 | 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 ( | |||
| 13 | 13 | "github.com/stretchr/testify/assert" | |
| 14 | 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 | 17 | "github.com/bnema/gordon/internal/boundaries/in/mocks" | |
| 18 | 18 | "github.com/bnema/gordon/internal/domain" | |
| 19 | 19 | ) | |
| @@ -358,7 +358,7 @@ func TestIsLocalhostRequest(t *testing.T) { | |||
| 358 | 358 | req := httptest.NewRequest(http.MethodGet, "/auth/token", nil) | |
| 359 | 359 | req.RemoteAddr = tt.remoteAddr | |
| 360 | 360 | ||
| 361 | - | result := httputil.IsLocalhostRequest(req) | |
| 361 | + | result := httphelper.IsLocalhostRequest(req) | |
| 362 | 362 | ||
| 363 | 363 | assert.Equal(t, tt.want, result) | |
| 364 | 364 | }) | |
| @@ -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.remoteAddr | 359 | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "net" | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package httputil | 1 | + | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "net/http" | |
| 5 | 5 | "net/http/httptest" | |
| 6 | 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 | 13 | func TestIsLocalhostRequest(t *testing.T) { | |
| @@ -25,9 +27,8 @@ func TestIsLocalhostRequest(t *testing.T) { | |||
| 25 | 27 | t.Run(tt.name, func(t *testing.T) { | |
| 26 | 28 | req := httptest.NewRequest(http.MethodGet, "/", nil) | |
| 27 | 29 | 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) | |
| 31 | 32 | }) | |
| 32 | 33 | } | |
| 33 | 34 | } | |
| @@ -1,11 +1,13 @@ | |||||
| 1 | - | package httputil_test | 1 | + | 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.remoteAddr | 29 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "net" | |
| 5 | 5 | "net/http" | |
| 6 | + | "net/url" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/zerowrap" | |
| 10 | 11 | ||
| 12 | + | "github.com/bnema/gordon/internal/adapters/in/http/httphelper" | |
| 11 | 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 | 42 | // Apply health-check exclusion after the response so the request | |
| 41 | 43 | // always completes — we just skip writing the log entry. | |
| 42 | 44 | // 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. | |
| 45 | 46 | if excludeHealthChecks { | |
| 46 | 47 | if strings.HasPrefix(r.UserAgent(), out.HealthCheckUserAgentPrefix) || | |
| 47 | - | IsTrustedProxy(clientIP, localhostNets) { | |
| 48 | + | httphelper.IsTrustedProxy(clientIP, httphelper.LocalhostNets) { | |
| 48 | 49 | return | |
| 49 | 50 | } | |
| 50 | 51 | } | |
| @@ -60,7 +61,7 @@ func AccessLogger(writer out.AccessLogWriter, excludeHealthChecks bool, log zero | |||
| 60 | 61 | Method: r.Method, | |
| 61 | 62 | Host: r.Host, | |
| 62 | 63 | Path: r.URL.Path, | |
| 63 | - | Query: r.URL.RawQuery, | |
| 64 | + | Query: sanitizeLoggedQuery(r.URL.RawQuery), | |
| 64 | 65 | Status: rw.StatusCode(), | |
| 65 | 66 | BytesSent: rw.BytesWritten(), | |
| 66 | 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 | + | } | |
| @@ -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 request | 42 | // 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 this | 44 | // UA check uses the shared domain constant so the prober and this | ||
| 43 | - | // filter can never drift. Loopback check reuses localhostNets from | 45 | + | // 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 | return | 49 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | + | "io" | |
| 5 | 6 | "net/http" | |
| 6 | 7 | "net/http/httptest" | |
| 7 | 8 | "testing" | |
| @@ -58,6 +59,28 @@ func TestAccessLogger_EmitsEntryPerRequest(t *testing.T) { | |||
| 58 | 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 | 84 | func TestAccessLogger_RequestIDReused(t *testing.T) { | |
| 62 | 85 | mock := &mockAccessLogWriter{} | |
| 63 | 86 | 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 ( | |||
| 12 | 12 | "github.com/bnema/zerowrap" | |
| 13 | 13 | ||
| 14 | 14 | "github.com/bnema/gordon/internal/adapters/dto" | |
| 15 | - | "github.com/bnema/gordon/internal/adapters/in/http/httputil" | |
| 15 | + | "github.com/bnema/gordon/internal/adapters/in/http/httphelper" | |
| 16 | 16 | "github.com/bnema/gordon/internal/boundaries/in" | |
| 17 | 17 | "github.com/bnema/gordon/internal/domain" | |
| 18 | 18 | ) | |
| @@ -59,7 +59,7 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, t | |||
| 59 | 59 | return func(next http.Handler) http.Handler { | |
| 60 | 60 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 61 | 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 | 63 | log.Debug(). | |
| 64 | 64 | Str(zerowrap.FieldLayer, "adapter"). | |
| 65 | 65 | Str(zerowrap.FieldAdapter, "http"). | |
| @@ -72,7 +72,7 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, t | |||
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | 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 | 76 | log.Warn(). | |
| 77 | 77 | Str(zerowrap.FieldLayer, "adapter"). | |
| 78 | 78 | 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 ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | "net" | |
| 7 | 7 | "net/http" | |
| 8 | - | "strconv" | |
| 9 | 8 | "strings" | |
| 10 | 9 | ||
| 11 | 10 | "github.com/bnema/zerowrap" | |
| 12 | 11 | ||
| 13 | 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 | 16 | // cidrAllowlist is the shared implementation for CIDR-based access control middleware. | |
| 20 | 17 | // ipExtractor determines how the client IP is obtained from the request. | |
| 21 | 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 | 24 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 28 | 25 | clientIP := ipExtractor(r) | |
| 29 | 26 | ||
| 30 | - | if IsTrustedProxy(clientIP, localhostNets) { | |
| 27 | + | if httphelper.IsTrustedProxy(clientIP, httphelper.LocalhostNets) { | |
| 31 | 28 | next.ServeHTTP(w, r) | |
| 32 | 29 | return | |
| 33 | 30 | } | |
| 34 | 31 | ||
| 35 | - | if IsTrustedProxy(clientIP, allowedNets) { | |
| 32 | + | if httphelper.IsTrustedProxy(clientIP, allowedNets) { | |
| 36 | 33 | next.ServeHTTP(w, r) | |
| 37 | 34 | 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 | 51 | // RegistryCIDRAllowlist returns middleware that restricts access to the given CIDR ranges. | |
| 92 | 52 | // Localhost is always allowed so Gordon can pull from its own registry. | |
| 93 | 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 | 82 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 123 | 83 | if !forceAll { | |
| 124 | - | remoteIP := ExtractRemoteIP(r.RemoteAddr) | |
| 125 | - | if IsTrustedOrLocal(remoteIP, proxyNets) { | |
| 84 | + | remoteIP := httphelper.ExtractRemoteIP(r.RemoteAddr) | |
| 85 | + | if httphelper.IsTrustedOrLocal(remoteIP, proxyNets) { | |
| 126 | 86 | next.ServeHTTP(w, r) | |
| 127 | 87 | return | |
| 128 | 88 | } | |
| @@ -146,7 +106,7 @@ func httpsRedirectTarget(host, requestURI string, httpPort, tlsPort int) string | |||
| 146 | 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 | 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 | 115 | // An empty allowedNets slice is a no-op (all traffic passes through). | |
| 156 | 116 | func ProxyCIDRAllowlist(allowedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 157 | 117 | return cidrAllowlist(allowedNets, func(r *http.Request) string { | |
| 158 | - | return ExtractRemoteIP(r.RemoteAddr) | |
| 118 | + | return httphelper.ExtractRemoteIP(r.RemoteAddr) | |
| 159 | 119 | }, "proxy origin", log) | |
| 160 | 120 | } | |
| @@ -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 | return | 29 | 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 | return | 34 | 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 | return | 87 | 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 ( | |||
| 9 | 9 | "testing" | |
| 10 | 10 | ||
| 11 | 11 | "github.com/bnema/gordon/internal/adapters/dto" | |
| 12 | + | "github.com/bnema/gordon/internal/adapters/in/http/httphelper" | |
| 12 | 13 | "github.com/stretchr/testify/assert" | |
| 13 | 14 | "github.com/stretchr/testify/require" | |
| 14 | 15 | ) | |
| @@ -41,46 +42,46 @@ func TestRegistryCIDRAllowlist(t *testing.T) { | |||
| 41 | 42 | }, | |
| 42 | 43 | { | |
| 43 | 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 | 46 | remoteAddr: "100.100.1.1:1234", | |
| 46 | 47 | wantStatus: http.StatusOK, | |
| 47 | 48 | }, | |
| 48 | 49 | { | |
| 49 | 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 | 52 | remoteAddr: "203.0.113.50:1234", | |
| 52 | 53 | wantStatus: http.StatusForbidden, | |
| 53 | 54 | }, | |
| 54 | 55 | { | |
| 55 | 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 | 58 | remoteAddr: "127.0.0.1:1234", | |
| 58 | 59 | wantStatus: http.StatusOK, | |
| 59 | 60 | }, | |
| 60 | 61 | { | |
| 61 | 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 | 64 | remoteAddr: "127.0.0.2:1234", | |
| 64 | 65 | wantStatus: http.StatusOK, | |
| 65 | 66 | }, | |
| 66 | 67 | { | |
| 67 | 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 | 70 | remoteAddr: "[::1]:1234", | |
| 70 | 71 | wantStatus: http.StatusOK, | |
| 71 | 72 | }, | |
| 72 | 73 | { | |
| 73 | 74 | 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"}), | |
| 76 | 77 | remoteAddr: "10.0.0.1:1234", | |
| 77 | 78 | xff: "100.100.1.1", | |
| 78 | 79 | wantStatus: http.StatusOK, | |
| 79 | 80 | }, | |
| 80 | 81 | { | |
| 81 | 82 | 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"}), | |
| 84 | 85 | remoteAddr: "10.0.0.1:1234", | |
| 85 | 86 | xff: "203.0.113.50", | |
| 86 | 87 | wantStatus: http.StatusForbidden, | |
| @@ -227,25 +228,25 @@ func TestProxyCIDRAllowlist(t *testing.T) { | |||
| 227 | 228 | }, | |
| 228 | 229 | { | |
| 229 | 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 | 232 | remoteAddr: "173.245.48.1:1234", | |
| 232 | 233 | wantStatus: http.StatusPermanentRedirect, | |
| 233 | 234 | }, | |
| 234 | 235 | { | |
| 235 | 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 | 238 | remoteAddr: "203.0.113.50:1234", | |
| 238 | 239 | wantStatus: http.StatusForbidden, | |
| 239 | 240 | }, | |
| 240 | 241 | { | |
| 241 | 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 | 244 | remoteAddr: "127.0.0.1:1234", | |
| 244 | 245 | wantStatus: http.StatusPermanentRedirect, | |
| 245 | 246 | }, | |
| 246 | 247 | { | |
| 247 | 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 | 250 | remoteAddr: "203.0.113.50:1234", | |
| 250 | 251 | wantStatus: http.StatusForbidden, | |
| 251 | 252 | }, | |
| @@ -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 ( | |||
| 4 | 4 | "net" | |
| 5 | 5 | "net/http" | |
| 6 | 6 | "strings" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/gordon/internal/adapters/in/http/httphelper" | |
| 7 | 9 | ) | |
| 8 | 10 | ||
| 9 | 11 | // cloudflareNets contains Cloudflare's published IPv4 and IPv6 ranges. | |
| 10 | 12 | // Cf-Connecting-Ip is only trusted when the immediate upstream matches these. | |
| 11 | 13 | // Source: https://www.cloudflare.com/ips/ | |
| 12 | - | var cloudflareNets = ParseTrustedProxies([]string{ | |
| 14 | + | var cloudflareNets = httphelper.ParseTrustedProxies([]string{ | |
| 13 | 15 | // IPv4 | |
| 14 | 16 | "173.245.48.0/20", | |
| 15 | 17 | "103.21.244.0/22", | |
| @@ -54,50 +56,6 @@ func normalizeIP(raw string) string { | |||
| 54 | 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 | 59 | // GetClientIP extracts the client IP address from the request. | |
| 102 | 60 | // It only honors X-Forwarded-For and X-Real-IP headers when the request | |
| 103 | 61 | // 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 | 73 | // Only honor proxy headers if the request comes from a trusted proxy | |
| 116 | - | if IsTrustedProxy(remoteIP, trustedNets) { | |
| 74 | + | if httphelper.IsTrustedProxy(remoteIP, trustedNets) { | |
| 117 | 75 | // Cf-Connecting-IP is set by Cloudflare to the true client IP. | |
| 118 | 76 | // Only trust it when the immediate upstream is a known Cloudflare IP, | |
| 119 | 77 | // not any generic trusted proxy (e.g. local LB/nginx), because a | |
| 120 | 78 | // non-Cloudflare proxy would blindly forward a spoofed header. | |
| 121 | - | if IsTrustedProxy(remoteIP, cloudflareNets) { | |
| 79 | + | if httphelper.IsTrustedProxy(remoteIP, cloudflareNets) { | |
| 122 | 80 | if cfIP := r.Header.Get("Cf-Connecting-Ip"); cfIP != "" { | |
| 123 | 81 | if ip := normalizeIP(cfIP); ip != "" { | |
| 124 | 82 | return ip | |
| @@ -140,7 +98,7 @@ func GetClientIP(r *http.Request, trustedNets []*net.IPNet) string { | |||
| 140 | 98 | } | |
| 141 | 99 | ||
| 142 | 100 | for i := len(validChain) - 1; i >= 0; i-- { | |
| 143 | - | if !IsTrustedProxy(validChain[i], trustedNets) { | |
| 101 | + | if !httphelper.IsTrustedProxy(validChain[i], trustedNets) { | |
| 144 | 102 | return validChain[i] | |
| 145 | 103 | } | |
| 146 | 104 | } | |
| @@ -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 | // IPv4 | 15 | // 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 request | 60 | // It only honors X-Forwarded-For and X-Real-IP headers when the request | ||
| 103 | // originates from a trusted proxy. This prevents attackers from spoofing | 61 | // 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 proxy | 73 | // 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 a | 77 | // 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 ip | 82 | 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 ( | |||
| 6 | 6 | "net/http/httptest" | |
| 7 | 7 | "testing" | |
| 8 | 8 | ||
| 9 | + | "github.com/bnema/gordon/internal/adapters/in/http/httphelper" | |
| 9 | 10 | "github.com/stretchr/testify/assert" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| 12 | 13 | func TestGetClientIP(t *testing.T) { | |
| 13 | 14 | // 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 | 16 | noTrustedNets := []*net.IPNet{} | |
| 16 | 17 | // 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 | 20 | tests := []struct { | |
| 20 | 21 | name string | |
| @@ -216,8 +217,8 @@ func TestParseTrustedProxies(t *testing.T) { | |||
| 216 | 217 | ||
| 217 | 218 | for _, tt := range tests { | |
| 218 | 219 | 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) | |
| 221 | 222 | assert.Equal(t, tt.want, got) | |
| 222 | 223 | }) | |
| 223 | 224 | } | |
| @@ -245,20 +246,20 @@ func TestIsTrustedProxy(t *testing.T) { | |||
| 245 | 246 | { | |
| 246 | 247 | name: "invalid IP", | |
| 247 | 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 | 250 | want: false, | |
| 250 | 251 | }, | |
| 251 | 252 | { | |
| 252 | 253 | name: "empty IP", | |
| 253 | 254 | ip: "", | |
| 254 | - | trustedNets: ParseTrustedProxies([]string{"192.168.1.0/24"}), | |
| 255 | + | trustedNets: httphelper.ParseTrustedProxies([]string{"192.168.1.0/24"}), | |
| 255 | 256 | want: false, | |
| 256 | 257 | }, | |
| 257 | 258 | } | |
| 258 | 259 | ||
| 259 | 260 | for _, tt := range tests { | |
| 260 | 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 | 263 | assert.Equal(t, tt.want, got) | |
| 263 | 264 | }) | |
| 264 | 265 | } | |
| @@ -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 tests | 14 | // 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-Ip | 17 | // 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 string | 21 | 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 | |||
| 130 | 130 | Str("request_id", requestID). | |
| 131 | 131 | Str(zerowrap.FieldMethod, r.Method). | |
| 132 | 132 | Str(zerowrap.FieldPath, r.URL.Path). | |
| 133 | - | Str("query", r.URL.RawQuery). | |
| 133 | + | Str("query", sanitizeLoggedQuery(r.URL.RawQuery)). | |
| 134 | 134 | Str(zerowrap.FieldHost, r.Host). | |
| 135 | 135 | Str("user_agent", r.UserAgent()). | |
| 136 | 136 | 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 ( | |||
| 6 | 6 | "net" | |
| 7 | 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 | 12 | // Handler serves CA onboarding endpoints for direct/Tailnet clients. | |
| @@ -49,7 +49,7 @@ func (h *Handler) ServeMobileconfig(w http.ResponseWriter, _ *http.Request) { | |||
| 49 | 49 | // publicHTTPSURL derives the public HTTPS URL from the request Host header. | |
| 50 | 50 | // The result is HTML-escaped to prevent XSS via a crafted Host header. | |
| 51 | 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 | 53 | return html.EscapeString("https://" + authority + "/") | |
| 54 | 54 | } | |
| 55 | 55 | ||
| @@ -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 ( | |||
| 15 | 15 | ||
| 16 | 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 | 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 | 202 | // SetXForwarded() unconditionally sets it based on the incoming scheme | |
| 203 | 203 | // (HTTP between proxies), but when a trusted proxy already sent the | |
| 204 | 204 | // real client proto, we must honor it. | |
| 205 | - | if existingProto != "" && isTrustedSource(incomingReq, trustedNets) { | |
| 205 | + | if existingProto != "" && gordonhttp.IsTrustedSource(incomingReq, trustedNets) { | |
| 206 | 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 | 215 | // modifyResponse returns a function that adds proxy headers and enforces response size limits. | |
| 230 | 216 | func modifyResponse(maxResponseSize int64) func(*http.Response) error { | |
| 231 | 217 | 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 scheme | 202 | // SetXForwarded() unconditionally sets it based on the incoming scheme | ||
| 203 | // (HTTP between proxies), but when a trusted proxy already sent the | 203 | // (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 ( | |||
| 14 | 14 | "github.com/stretchr/testify/mock" | |
| 15 | 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 | 18 | "github.com/bnema/gordon/internal/boundaries/in" | |
| 19 | 19 | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | |
| 20 | 20 | "github.com/bnema/gordon/internal/domain" | |
| @@ -201,7 +201,7 @@ func TestForwardToTarget_ProxyHeaderSet(t *testing.T) { | |||
| 201 | 201 | assert.Equal(t, http.StatusOK, w.Code) | |
| 202 | 202 | } | |
| 203 | 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 | 206 | tests := []struct { | |
| 207 | 207 | name string | |
| @@ -250,7 +250,7 @@ func TestNewReverseProxy_XForwardedProto(t *testing.T) { | |||
| 250 | 250 | remoteAddr: "[::1]", | |
| 251 | 251 | incomingProto: "https", | |
| 252 | 252 | wantProto: "https", | |
| 253 | - | nets: middleware.ParseTrustedProxies([]string{"::1/128"}), | |
| 253 | + | nets: gordonhttp.ParseTrustedProxies([]string{"::1/128"}), | |
| 254 | 254 | }, | |
| 255 | 255 | { | |
| 256 | 256 | 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 string | 207 | 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 ( | |||
| 8 | 8 | "github.com/bnema/zerowrap" | |
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/gordon/internal/adapters/dto" | |
| 11 | + | "github.com/bnema/gordon/internal/adapters/in/http/httphelper" | |
| 11 | 12 | "github.com/bnema/gordon/internal/adapters/in/http/middleware" | |
| 12 | 13 | "github.com/bnema/gordon/internal/boundaries/out" | |
| 13 | 14 | ) | |
| @@ -29,7 +30,7 @@ func RateLimitMiddleware( | |||
| 29 | 30 | } | |
| 30 | 31 | ||
| 31 | 32 | // Parse trusted proxy CIDRs once at middleware creation | |
| 32 | - | trustedNets := middleware.ParseTrustedProxies(trustedProxies) | |
| 33 | + | trustedNets := httphelper.ParseTrustedProxies(trustedProxies) | |
| 33 | 34 | ||
| 34 | 35 | return func(next http.Handler) http.Handler { | |
| 35 | 36 | 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 creation | 32 | // 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 ( | |||
| 9 | 9 | ||
| 10 | 10 | "github.com/stretchr/testify/assert" | |
| 11 | 11 | ||
| 12 | + | "github.com/bnema/gordon/internal/adapters/in/http/httphelper" | |
| 12 | 13 | "github.com/bnema/gordon/internal/adapters/in/http/middleware" | |
| 13 | 14 | "github.com/bnema/gordon/internal/adapters/out/ratelimit" | |
| 14 | 15 | outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| @@ -221,7 +222,7 @@ func TestRateLimitMiddleware_Integration(t *testing.T) { | |||
| 221 | 222 | ||
| 222 | 223 | func TestGetClientIP(t *testing.T) { | |
| 223 | 224 | // 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 | 226 | noTrustedNets := []*net.IPNet{} | |
| 226 | 227 | ||
| 227 | 228 | tests := []struct { | |
| @@ -373,8 +374,8 @@ func TestParseTrustedProxies(t *testing.T) { | |||
| 373 | 374 | ||
| 374 | 375 | for _, tt := range tests { | |
| 375 | 376 | 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) | |
| 378 | 379 | assert.Equal(t, tt.want, got) | |
| 379 | 380 | }) | |
| 380 | 381 | } | |
| @@ -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 tests | 224 | // 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) { | |||
| 72 | 72 | func TestWriter_Stdout_JSON(t *testing.T) { | |
| 73 | 73 | // Replace stdout to capture output. | |
| 74 | 74 | orig := os.Stdout | |
| 75 | - | r, w, _ := os.Pipe() | |
| 75 | + | r, w, err := os.Pipe() | |
| 76 | + | require.NoError(t, err) | |
| 76 | 77 | os.Stdout = w | |
| 77 | 78 | ||
| 78 | 79 | writer, err := New(Config{Format: "json", Output: "stdout"}) | |
| @@ -97,7 +98,8 @@ func TestWriter_Stdout_JSON(t *testing.T) { | |||
| 97 | 98 | ||
| 98 | 99 | func TestWriter_Stdout_CLF(t *testing.T) { | |
| 99 | 100 | orig := os.Stdout | |
| 100 | - | r, w, _ := os.Pipe() | |
| 101 | + | r, w, err := os.Pipe() | |
| 102 | + | require.NoError(t, err) | |
| 101 | 103 | os.Stdout = w | |
| 102 | 104 | ||
| 103 | 105 | writer, err := New(Config{Format: "clf", Output: "stdout"}) | |
| @@ -119,7 +121,8 @@ func TestWriter_Stdout_CLF(t *testing.T) { | |||
| 119 | 121 | ||
| 120 | 122 | func TestWriter_Stdout_Combined(t *testing.T) { | |
| 121 | 123 | orig := os.Stdout | |
| 122 | - | r, w, _ := os.Pipe() | |
| 124 | + | r, w, err := os.Pipe() | |
| 125 | + | require.NoError(t, err) | |
| 123 | 126 | os.Stdout = w | |
| 124 | 127 | ||
| 125 | 128 | 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.Stdout | 74 | orig := os.Stdout | ||
| 75 | - | r, w, _ := os.Pipe() | 75 | + | r, w, err := os.Pipe() |
| 76 | + | require.NoError(t, err) | |||
| 76 | os.Stdout = w | 77 | 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.Stdout | 100 | orig := os.Stdout | ||
| 100 | - | r, w, _ := os.Pipe() | 101 | + | r, w, err := os.Pipe() |
| 102 | + | require.NoError(t, err) | |||
| 101 | os.Stdout = w | 103 | 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.Stdout | 123 | orig := os.Stdout | ||
| 122 | - | r, w, _ := os.Pipe() | 124 | + | r, w, err := os.Pipe() |
| 125 | + | require.NoError(t, err) | |||
| 123 | os.Stdout = w | 126 | 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 { | |||
| 468 | 468 | ||
| 469 | 469 | reader, err := r.client.ImagePull(ctx, imageRef, image.PullOptions{}) | |
| 470 | 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 | 473 | defer reader.Close() | |
| 474 | 474 | ||
| @@ -528,7 +528,7 @@ func (r *Runtime) PullImageWithAuth(ctx context.Context, imageRef, username, pas | |||
| 528 | 528 | RegistryAuth: authStr, | |
| 529 | 529 | }) | |
| 530 | 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 | 533 | defer reader.Close() | |
| 534 | 534 | ||
| @@ -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 { | |||
| 449 | 449 | 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 | 462 | func randomSerial() (*big.Int, error) { | |
| 453 | 463 | serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) | |
| 454 | 464 | if err != nil { | |
| @@ -449,6 +449,16 @@ func (ca *CA) IntermediateLifetime() time.Duration { | |||||
| 449 | return intermediateLifetime | 449 | 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 ( | |||
| 4 | 4 | "encoding/base64" | |
| 5 | 5 | "fmt" | |
| 6 | 6 | "html" | |
| 7 | + | "strings" | |
| 8 | + | "unicode" | |
| 7 | 9 | ||
| 8 | 10 | "github.com/google/uuid" | |
| 9 | 11 | ) | |
| @@ -16,6 +18,7 @@ func GenerateMobileconfig(rootDER []byte, rootCN string) []byte { | |||
| 16 | 18 | certUUID := uuid.New().String() | |
| 17 | 19 | ||
| 18 | 20 | safeCN := html.EscapeString(rootCN) | |
| 21 | + | identifierCN := payloadIdentifierComponent(rootCN) | |
| 19 | 22 | profile := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?> | |
| 20 | 23 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| 21 | 24 | <plist version="1.0"> | |
| @@ -53,8 +56,39 @@ func GenerateMobileconfig(rootDER []byte, rootCN string) []byte { | |||
| 53 | 56 | <string>%s</string> | |
| 54 | 57 | <key>PayloadVersion</key> | |
| 55 | 58 | <integer>1</integer> | |
| 56 | - | </dict> | |
| 57 | - | </plist>`, b64Cert, safeCN, certUUID, certUUID, profileUUID) | |
| 59 | + | </dict> | |
| 60 | + | </plist>`, b64Cert, safeCN, identifierCN, certUUID, profileUUID) | |
| 58 | 61 | ||
| 59 | 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 | + | } | |
| @@ -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) { | |||
| 21 | 21 | assert.Contains(t, string(mc), "<key>PayloadType</key>") | |
| 22 | 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 <prod></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-<prod>") | |
| 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 <prod></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-<prod>") | |||
| 32 | + | } | |||
M internal/app/run.go +13 -19
| @@ -46,6 +46,7 @@ import ( | |||
| 46 | 46 | "github.com/bnema/gordon/internal/adapters/dto" | |
| 47 | 47 | "github.com/bnema/gordon/internal/adapters/in/http/admin" | |
| 48 | 48 | authhandler "github.com/bnema/gordon/internal/adapters/in/http/auth" | |
| 49 | + | "github.com/bnema/gordon/internal/adapters/in/http/httphelper" | |
| 49 | 50 | "github.com/bnema/gordon/internal/adapters/in/http/middleware" | |
| 50 | 51 | "github.com/bnema/gordon/internal/adapters/in/http/onboarding" | |
| 51 | 52 | proxyadapter "github.com/bnema/gordon/internal/adapters/in/http/proxy" | |
| @@ -88,7 +89,6 @@ type Config struct { | |||
| 88 | 89 | Port int `mapstructure:"port"` | |
| 89 | 90 | RegistryPort int `mapstructure:"registry_port"` | |
| 90 | 91 | GordonDomain string `mapstructure:"gordon_domain"` | |
| 91 | - | RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain | |
| 92 | 92 | TLSPort int `mapstructure:"tls_port"` | |
| 93 | 93 | TLSCertFile string `mapstructure:"tls_cert_file"` | |
| 94 | 94 | TLSKeyFile string `mapstructure:"tls_key_file"` | |
| @@ -314,11 +314,6 @@ func initConfig(configPath string) (*viper.Viper, Config, error) { | |||
| 314 | 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 | 317 | return v, cfg, nil | |
| 323 | 318 | } | |
| 324 | 319 | ||
| @@ -1385,7 +1380,7 @@ func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, erro | |||
| 1385 | 1380 | ||
| 1386 | 1381 | return &proxyConfigResult{ | |
| 1387 | 1382 | proxyConfig: proxy.Config{ | |
| 1388 | - | RegistryDomain: cfg.Server.RegistryDomain, | |
| 1383 | + | RegistryDomain: cfg.Server.GordonDomain, | |
| 1389 | 1384 | RegistryPort: cfg.Server.RegistryPort, | |
| 1390 | 1385 | MaxBodySize: maxProxyBodySize, | |
| 1391 | 1386 | MaxResponseSize: maxProxyResponseSize, | |
| @@ -1420,9 +1415,11 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc | |||
| 1420 | 1415 | defaultNanoCPUs = int64(cfg.Containers.CPULimit * 1e9) | |
| 1421 | 1416 | } | |
| 1422 | 1417 | ||
| 1418 | + | attachmentConfig := svc.configSvc.GetAttachmentConfig() | |
| 1419 | + | ||
| 1423 | 1420 | containerConfig := container.Config{ | |
| 1424 | 1421 | RegistryAuthEnabled: cfg.Auth.Enabled, | |
| 1425 | - | RegistryDomain: cfg.Server.RegistryDomain, | |
| 1422 | + | RegistryDomain: cfg.Server.GordonDomain, | |
| 1426 | 1423 | RegistryPort: cfg.Server.RegistryPort, | |
| 1427 | 1424 | InternalRegistryUsername: svc.internalRegUser, | |
| 1428 | 1425 | InternalRegistryPassword: svc.internalRegPass, | |
| @@ -1432,8 +1429,8 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc | |||
| 1432 | 1429 | VolumePreserve: v.GetBool("volumes.preserve"), | |
| 1433 | 1430 | NetworkIsolation: v.GetBool("network_isolation.enabled"), | |
| 1434 | 1431 | 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, | |
| 1437 | 1434 | ReadinessDelay: v.GetDuration("deploy.readiness_delay"), | |
| 1438 | 1435 | ReadinessMode: v.GetString("deploy.readiness_mode"), | |
| 1439 | 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 | 1545 | // 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 | 1547 | WithEnvExtractor(svc.runtime, svc.envDir) | |
| 1551 | 1548 | ||
| 1552 | 1549 | // 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 | 1613 | log.Error().Err(err).Msg("failed to unmarshal config on reload") | |
| 1617 | 1614 | return | |
| 1618 | 1615 | } | |
| 1619 | - | if reloadCfg.Server.GordonDomain != "" { | |
| 1620 | - | reloadCfg.Server.RegistryDomain = reloadCfg.Server.GordonDomain | |
| 1621 | - | } | |
| 1622 | 1616 | reloadedProxy, err := buildProxyConfig(reloadCfg, log) | |
| 1623 | 1617 | if err != nil { | |
| 1624 | 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 | 1674 | func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWriter out.AccessLogWriter) (http.Handler, http.Handler, http.Handler) { | |
| 1681 | 1675 | // Parse trusted proxies once for all middleware chains. | |
| 1682 | 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 | 1679 | // Registry handler (unchanged) | |
| 1686 | 1680 | registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize) | |
| @@ -1812,8 +1806,8 @@ func directHTTPOnboardingGate(ob *onboarding.Handler, proxyNets []*net.IPNet, pr | |||
| 1812 | 1806 | )(onboardingMux) | |
| 1813 | 1807 | ||
| 1814 | 1808 | 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) { | |
| 1817 | 1811 | proxyChain.ServeHTTP(w, r) | |
| 1818 | 1812 | return | |
| 1819 | 1813 | } | |
| @@ -1868,10 +1862,10 @@ func parseCIDRAllowlist(ips []string, label string, log zerowrap.Logger) ([]*net | |||
| 1868 | 1862 | return nil, false | |
| 1869 | 1863 | } | |
| 1870 | 1864 | ||
| 1871 | - | allowedNets := middleware.ParseTrustedProxies(ips) | |
| 1865 | + | allowedNets := httphelper.ParseTrustedProxies(ips) | |
| 1872 | 1866 | if len(allowedNets) != len(ips) { | |
| 1873 | 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 | 1869 | log.Warn().Str("entry", entry).Msgf("ignoring invalid %s entry", label) | |
| 1876 | 1870 | } | |
| 1877 | 1871 | } | |
| @@ -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, nil | 317 | 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 labels | 1545 | // 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 images | 1549 | // 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 | return | 1614 | 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 | return | 1812 | return | ||
| 1819 | } | 1813 | } | ||
| @@ -1868,10 +1862,10 @@ func parseCIDRAllowlist(ips []string, label string, log zerowrap.Logger) ([]*net | |||||
| 1868 | return nil, false | 1862 | 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 { | |||
| 20 | 20 | // network groups, read under a single lock to prevent cross-field races. | |
| 21 | 21 | 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 | 23 | // GetNetworkGroups returns a deep copy of the current network group config: group -> []domain. | |
| 27 | 24 | GetNetworkGroups() map[string][]string | |
| 28 | 25 | } | |
| @@ -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() AttachmentConfigSnapshot | 21 | 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][]string | 24 | 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 | |||
| 80 | 80 | 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 | 83 | // GetNetworkGroups provides a mock function for the type MockAttachmentConfigProvider | |
| 130 | 84 | func (_mock *MockAttachmentConfigProvider) GetNetworkGroups() map[string][]string { | |
| 131 | 85 | ret := _mock.Called() | |
| @@ -80,52 +80,6 @@ func (_c *MockAttachmentConfigProvider_GetAttachmentConfig_Call) RunAndReturn(ru | |||||
| 80 | return _c | 80 | 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 MockAttachmentConfigProvider | 83 | // 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "crypto/tls" | |
| 6 | + | "crypto/x509" | |
| 6 | 7 | "time" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/gordon/internal/domain" | |
| @@ -49,4 +50,10 @@ type CertificateAuthority interface { | |||
| 49 | 50 | ||
| 50 | 51 | // IntermediateLifetime returns the configured intermediate CA lifetime. | |
| 51 | 52 | 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 | } | |
| @@ -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.Duration | 52 | 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 @@ | |||
| 1 | 1 | package domain | |
| 2 | 2 | ||
| 3 | + | const DefaultDeployFailureSummary = "failed to deploy" | |
| 4 | + | ||
| 3 | 5 | // DeployFailureError describes a surfaced deployment failure. | |
| 4 | 6 | type DeployFailureError struct { | |
| 5 | 7 | Summary string | |
| @@ -14,7 +16,7 @@ type DeployFailureError struct { | |||
| 14 | 16 | // Error returns the deploy failure summary. | |
| 15 | 17 | func (e *DeployFailureError) Error() string { | |
| 16 | 18 | if e == nil || e.Summary == "" { | |
| 17 | - | return "failed to deploy" | |
| 19 | + | return DefaultDeployFailureSummary | |
| 18 | 20 | } | |
| 19 | 21 | ||
| 20 | 22 | return e.Summary | |
| @@ -1,5 +1,7 @@ | |||||
| 1 | package domain | 1 | 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 string | 7 | 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.Summary | 22 | return e.Summary | ||
M internal/usecase/config/service.go +0 -13
| @@ -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 | 936 | // GetAllAttachments returns all configured attachments. | |
| 950 | 937 | func (s *Service) GetAllAttachments(_ context.Context) map[string][]string { | |
| 951 | 938 | 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) { | |||
| 469 | 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 | 472 | func TestService_GetAttachmentConfig(t *testing.T) { | |
| 491 | 473 | v := viper.New() | |
| 492 | 474 | 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 { | |||
| 835 | 835 | return false | |
| 836 | 836 | } | |
| 837 | 837 | ||
| 838 | + | if errors.Is(err, domain.ErrImagePullFailed) { | |
| 839 | + | return true | |
| 840 | + | } | |
| 841 | + | ||
| 838 | 842 | msg := strings.ToLower(err.Error()) | |
| 839 | 843 | 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") | |
| 842 | 845 | } | |
| 843 | 846 | ||
| 844 | 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 | 1914 | if err := s.pullImageWithRetry(ctx, func(ctx context.Context) error { | |
| 1912 | 1915 | return s.runtime.PullImageWithAuth(ctx, pullRef, cfg.InternalRegistryUsername, cfg.InternalRegistryPassword) | |
| 1913 | 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 | 1919 | case isInternal: | |
| 1917 | 1920 | if err := s.pullImageWithRetry(ctx, func(ctx context.Context) error { | |
| 1918 | 1921 | return s.runtime.PullImage(ctx, pullRef) | |
| 1919 | 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 | 1925 | case cfg.RegistryAuthEnabled: | |
| 1923 | 1926 | if cfg.ServiceTokenUsername == "" || cfg.ServiceToken == "" { | |
| 1924 | 1927 | return log.WrapErr(fmt.Errorf("registry service token not configured"), "failed to pull image for registry auth") | |
| 1925 | 1928 | } | |
| 1926 | 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 | 1932 | default: | |
| 1930 | 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 | ||
| @@ -835,10 +835,13 @@ func isPullFailure(err error) bool { | |||||
| 835 | return false | 835 | 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) { | |||
| 499 | 499 | assert.Equal(t, "verify registry auth and confirm the image exists at the requested tag", deployErr.Hint) | |
| 500 | 500 | assert.Equal(t, "gordon-test.example.com", deployErr.ContainerName) | |
| 501 | 501 | assert.Empty(t, deployErr.ContainerID) | |
| 502 | + | assert.ErrorIs(t, deployErr.Err, domain.ErrImagePullFailed) | |
| 502 | 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 | 540 | func TestService_CaptureRecentContainerLogs_SkipsCanceledContext(t *testing.T) { | |
| 506 | 541 | runtime := mocks.NewMockContainerRuntime(t) | |
| 507 | 542 | 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) | ||