Merge branch 'main' into fix/podman-rootless-friction
4 files · +206 -95
M go.mod +26 -27
| @@ -13,18 +13,18 @@ require ( | |||
| 13 | 13 | github.com/golang-jwt/jwt/v5 v5.3.1 | |
| 14 | 14 | github.com/google/go-containerregistry v0.21.3 | |
| 15 | 15 | github.com/google/uuid v1.6.0 | |
| 16 | - | github.com/mattn/go-runewidth v0.0.21 | |
| 16 | + | github.com/mattn/go-runewidth v0.0.22 | |
| 17 | 17 | github.com/muesli/termenv v0.16.0 | |
| 18 | 18 | github.com/rivo/uniseg v0.4.7 | |
| 19 | 19 | github.com/smallstep/truststore v0.13.0 | |
| 20 | 20 | github.com/spf13/cobra v1.10.2 | |
| 21 | 21 | github.com/spf13/viper v1.21.0 | |
| 22 | 22 | github.com/stretchr/testify v1.11.1 | |
| 23 | - | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 | |
| 24 | - | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 | |
| 25 | - | go.opentelemetry.io/otel/sdk v1.42.0 | |
| 26 | - | go.opentelemetry.io/otel/sdk/log v0.18.0 | |
| 27 | - | go.opentelemetry.io/otel/sdk/metric v1.42.0 | |
| 23 | + | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 | |
| 24 | + | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 | |
| 25 | + | go.opentelemetry.io/otel/sdk v1.43.0 | |
| 26 | + | go.opentelemetry.io/otel/sdk/log v0.19.0 | |
| 27 | + | go.opentelemetry.io/otel/sdk/metric v1.43.0 | |
| 28 | 28 | golang.org/x/mod v0.34.0 | |
| 29 | 29 | golang.org/x/sync v0.20.0 | |
| 30 | 30 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 | |
| @@ -35,47 +35,46 @@ require ( | |||
| 35 | 35 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | |
| 36 | 36 | github.com/cenkalti/backoff/v5 v5.0.3 // indirect | |
| 37 | 37 | github.com/cespare/xxhash/v2 v2.3.0 // indirect | |
| 38 | - | github.com/charmbracelet/colorprofile v0.4.1 // indirect | |
| 38 | + | github.com/charmbracelet/colorprofile v0.4.3 // indirect | |
| 39 | 39 | github.com/charmbracelet/x/ansi v0.11.6 // indirect | |
| 40 | 40 | github.com/charmbracelet/x/cellbuf v0.0.15 // indirect | |
| 41 | 41 | github.com/charmbracelet/x/term v0.2.2 // indirect | |
| 42 | - | github.com/clipperhouse/displaywidth v0.9.0 // indirect | |
| 43 | - | github.com/clipperhouse/stringish v0.1.1 // indirect | |
| 44 | - | github.com/clipperhouse/uax29/v2 v2.5.0 // indirect | |
| 42 | + | github.com/clipperhouse/displaywidth v0.11.0 // indirect | |
| 43 | + | github.com/clipperhouse/uax29/v2 v2.7.0 // indirect | |
| 45 | 44 | github.com/containerd/log v0.1.0 // indirect | |
| 46 | 45 | github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect | |
| 47 | - | github.com/docker/cli v29.3.0+incompatible // indirect | |
| 48 | - | github.com/docker/docker-credential-helpers v0.9.3 // indirect | |
| 46 | + | github.com/docker/cli v29.3.1+incompatible // indirect | |
| 47 | + | github.com/docker/docker-credential-helpers v0.9.5 // indirect | |
| 49 | 48 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | |
| 50 | 49 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect | |
| 51 | - | github.com/klauspost/compress v1.18.4 // indirect | |
| 52 | - | github.com/lucasb-eyer/go-colorful v1.3.0 // indirect | |
| 50 | + | github.com/klauspost/compress v1.18.5 // indirect | |
| 51 | + | github.com/lucasb-eyer/go-colorful v1.4.0 // indirect | |
| 53 | 52 | github.com/mattn/go-localereader v0.0.1 // indirect | |
| 54 | 53 | github.com/mitchellh/go-homedir v1.1.0 // indirect | |
| 55 | 54 | github.com/moby/sys/atomicwriter v0.1.0 // indirect | |
| 56 | 55 | github.com/morikuni/aec v1.1.0 // indirect | |
| 57 | 56 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect | |
| 58 | 57 | github.com/muesli/cancelreader v0.2.2 // indirect | |
| 59 | - | github.com/rs/zerolog v1.34.0 // indirect | |
| 58 | + | github.com/rs/zerolog v1.35.0 // indirect | |
| 60 | 59 | github.com/sirupsen/logrus v1.9.4 // indirect | |
| 61 | 60 | github.com/vbatts/tar-split v0.12.2 // indirect | |
| 62 | 61 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect | |
| 63 | - | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect | |
| 64 | - | go.opentelemetry.io/otel/log v0.18.0 // indirect | |
| 65 | - | go.opentelemetry.io/proto/otlp v1.9.0 // indirect | |
| 62 | + | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect | |
| 63 | + | go.opentelemetry.io/otel/log v0.19.0 // indirect | |
| 64 | + | go.opentelemetry.io/proto/otlp v1.10.0 // indirect | |
| 66 | 65 | go.yaml.in/yaml/v3 v3.0.4 // indirect | |
| 67 | 66 | golang.org/x/net v0.52.0 // indirect | |
| 68 | - | google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect | |
| 69 | - | google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect | |
| 70 | - | google.golang.org/grpc v1.79.3 // indirect | |
| 67 | + | google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect | |
| 68 | + | google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect | |
| 69 | + | google.golang.org/grpc v1.80.0 // indirect | |
| 71 | 70 | google.golang.org/protobuf v1.36.11 // indirect | |
| 72 | - | howett.net/plist v1.0.0 // indirect | |
| 71 | + | howett.net/plist v1.0.1 // indirect | |
| 73 | 72 | ) | |
| 74 | 73 | ||
| 75 | 74 | require ( | |
| 76 | 75 | github.com/davecgh/go-spew v1.1.1 // indirect | |
| 77 | 76 | github.com/pmezard/go-difflib v1.0.0 // indirect | |
| 78 | - | github.com/stretchr/objx v0.5.2 // indirect | |
| 77 | + | github.com/stretchr/objx v0.5.3 // indirect | |
| 79 | 78 | go.uber.org/mock v0.6.0 // direct | |
| 80 | 79 | golang.org/x/time v0.15.0 | |
| 81 | 80 | ) | |
| @@ -107,10 +106,10 @@ require ( | |||
| 107 | 106 | github.com/subosito/gotenv v1.6.0 // indirect | |
| 108 | 107 | go.opentelemetry.io/auto/sdk v1.2.1 // indirect | |
| 109 | 108 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 | |
| 110 | - | go.opentelemetry.io/otel v1.42.0 | |
| 111 | - | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 | |
| 112 | - | go.opentelemetry.io/otel/metric v1.42.0 | |
| 113 | - | go.opentelemetry.io/otel/trace v1.42.0 | |
| 109 | + | go.opentelemetry.io/otel v1.43.0 | |
| 110 | + | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 | |
| 111 | + | go.opentelemetry.io/otel/metric v1.43.0 | |
| 112 | + | go.opentelemetry.io/otel/trace v1.43.0 | |
| 114 | 113 | golang.org/x/sys v0.42.0 // indirect | |
| 115 | 114 | golang.org/x/text v0.35.0 // indirect | |
| 116 | 115 | gopkg.in/yaml.v3 v3.0.1 // indirect | |
| @@ -13,18 +13,18 @@ require ( | |||||
| 13 | github.com/golang-jwt/jwt/v5 v5.3.1 | 13 | github.com/golang-jwt/jwt/v5 v5.3.1 | ||
| 14 | github.com/google/go-containerregistry v0.21.3 | 14 | github.com/google/go-containerregistry v0.21.3 | ||
| 15 | github.com/google/uuid v1.6.0 | 15 | github.com/google/uuid v1.6.0 | ||
| 16 | - | github.com/mattn/go-runewidth v0.0.21 | 16 | + | github.com/mattn/go-runewidth v0.0.22 |
| 17 | github.com/muesli/termenv v0.16.0 | 17 | github.com/muesli/termenv v0.16.0 | ||
| 18 | github.com/rivo/uniseg v0.4.7 | 18 | github.com/rivo/uniseg v0.4.7 | ||
| 19 | github.com/smallstep/truststore v0.13.0 | 19 | github.com/smallstep/truststore v0.13.0 | ||
| 20 | github.com/spf13/cobra v1.10.2 | 20 | github.com/spf13/cobra v1.10.2 | ||
| 21 | github.com/spf13/viper v1.21.0 | 21 | github.com/spf13/viper v1.21.0 | ||
| 22 | github.com/stretchr/testify v1.11.1 | 22 | github.com/stretchr/testify v1.11.1 | ||
| 23 | - | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 | 23 | + | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 |
| 24 | - | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 | 24 | + | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 |
| 25 | - | go.opentelemetry.io/otel/sdk v1.42.0 | 25 | + | go.opentelemetry.io/otel/sdk v1.43.0 |
| 26 | - | go.opentelemetry.io/otel/sdk/log v0.18.0 | 26 | + | go.opentelemetry.io/otel/sdk/log v0.19.0 |
| 27 | - | go.opentelemetry.io/otel/sdk/metric v1.42.0 | 27 | + | go.opentelemetry.io/otel/sdk/metric v1.43.0 |
| 28 | golang.org/x/mod v0.34.0 | 28 | golang.org/x/mod v0.34.0 | ||
| 29 | golang.org/x/sync v0.20.0 | 29 | golang.org/x/sync v0.20.0 | ||
| 30 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 | 30 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 | ||
| @@ -35,47 +35,46 @@ require ( | |||||
| 35 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | 35 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | ||
| 36 | github.com/cenkalti/backoff/v5 v5.0.3 // indirect | 36 | github.com/cenkalti/backoff/v5 v5.0.3 // indirect | ||
| 37 | github.com/cespare/xxhash/v2 v2.3.0 // indirect | 37 | github.com/cespare/xxhash/v2 v2.3.0 // indirect | ||
| 38 | - | github.com/charmbracelet/colorprofile v0.4.1 // indirect | 38 | + | github.com/charmbracelet/colorprofile v0.4.3 // indirect |
| 39 | github.com/charmbracelet/x/ansi v0.11.6 // indirect | 39 | github.com/charmbracelet/x/ansi v0.11.6 // indirect | ||
| 40 | github.com/charmbracelet/x/cellbuf v0.0.15 // indirect | 40 | github.com/charmbracelet/x/cellbuf v0.0.15 // indirect | ||
| 41 | github.com/charmbracelet/x/term v0.2.2 // indirect | 41 | github.com/charmbracelet/x/term v0.2.2 // indirect | ||
| 42 | - | github.com/clipperhouse/displaywidth v0.9.0 // indirect | 42 | + | github.com/clipperhouse/displaywidth v0.11.0 // indirect |
| 43 | - | github.com/clipperhouse/stringish v0.1.1 // indirect | 43 | + | github.com/clipperhouse/uax29/v2 v2.7.0 // indirect |
| 44 | - | github.com/clipperhouse/uax29/v2 v2.5.0 // indirect | |||
| 45 | github.com/containerd/log v0.1.0 // indirect | 44 | github.com/containerd/log v0.1.0 // indirect | ||
| 46 | github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect | 45 | github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect | ||
| 47 | - | github.com/docker/cli v29.3.0+incompatible // indirect | 46 | + | github.com/docker/cli v29.3.1+incompatible // indirect |
| 48 | - | github.com/docker/docker-credential-helpers v0.9.3 // indirect | 47 | + | github.com/docker/docker-credential-helpers v0.9.5 // indirect |
| 49 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | 48 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | ||
| 50 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect | 49 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect | ||
| 51 | - | github.com/klauspost/compress v1.18.4 // indirect | 50 | + | github.com/klauspost/compress v1.18.5 // indirect |
| 52 | - | github.com/lucasb-eyer/go-colorful v1.3.0 // indirect | 51 | + | github.com/lucasb-eyer/go-colorful v1.4.0 // indirect |
| 53 | github.com/mattn/go-localereader v0.0.1 // indirect | 52 | github.com/mattn/go-localereader v0.0.1 // indirect | ||
| 54 | github.com/mitchellh/go-homedir v1.1.0 // indirect | 53 | github.com/mitchellh/go-homedir v1.1.0 // indirect | ||
| 55 | github.com/moby/sys/atomicwriter v0.1.0 // indirect | 54 | github.com/moby/sys/atomicwriter v0.1.0 // indirect | ||
| 56 | github.com/morikuni/aec v1.1.0 // indirect | 55 | github.com/morikuni/aec v1.1.0 // indirect | ||
| 57 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect | 56 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect | ||
| 58 | github.com/muesli/cancelreader v0.2.2 // indirect | 57 | github.com/muesli/cancelreader v0.2.2 // indirect | ||
| 59 | - | github.com/rs/zerolog v1.34.0 // indirect | 58 | + | github.com/rs/zerolog v1.35.0 // indirect |
| 60 | github.com/sirupsen/logrus v1.9.4 // indirect | 59 | github.com/sirupsen/logrus v1.9.4 // indirect | ||
| 61 | github.com/vbatts/tar-split v0.12.2 // indirect | 60 | github.com/vbatts/tar-split v0.12.2 // indirect | ||
| 62 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect | 61 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect | ||
| 63 | - | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect | 62 | + | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect |
| 64 | - | go.opentelemetry.io/otel/log v0.18.0 // indirect | 63 | + | go.opentelemetry.io/otel/log v0.19.0 // indirect |
| 65 | - | go.opentelemetry.io/proto/otlp v1.9.0 // indirect | 64 | + | go.opentelemetry.io/proto/otlp v1.10.0 // indirect |
| 66 | go.yaml.in/yaml/v3 v3.0.4 // indirect | 65 | go.yaml.in/yaml/v3 v3.0.4 // indirect | ||
| 67 | golang.org/x/net v0.52.0 // indirect | 66 | golang.org/x/net v0.52.0 // indirect | ||
| 68 | - | google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect | 67 | + | google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect |
| 69 | - | google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect | 68 | + | google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect |
| 70 | - | google.golang.org/grpc v1.79.3 // indirect | 69 | + | google.golang.org/grpc v1.80.0 // indirect |
| 71 | google.golang.org/protobuf v1.36.11 // indirect | 70 | google.golang.org/protobuf v1.36.11 // indirect | ||
| 72 | - | howett.net/plist v1.0.0 // indirect | 71 | + | howett.net/plist v1.0.1 // indirect |
| 73 | ) | 72 | ) | ||
| 74 | 73 | ||||
| 75 | require ( | 74 | require ( | ||
| 76 | github.com/davecgh/go-spew v1.1.1 // indirect | 75 | github.com/davecgh/go-spew v1.1.1 // indirect | ||
| 77 | github.com/pmezard/go-difflib v1.0.0 // indirect | 76 | github.com/pmezard/go-difflib v1.0.0 // indirect | ||
| 78 | - | github.com/stretchr/objx v0.5.2 // indirect | 77 | + | github.com/stretchr/objx v0.5.3 // indirect |
| 79 | go.uber.org/mock v0.6.0 // direct | 78 | go.uber.org/mock v0.6.0 // direct | ||
| 80 | golang.org/x/time v0.15.0 | 79 | golang.org/x/time v0.15.0 | ||
| 81 | ) | 80 | ) | ||
| @@ -107,10 +106,10 @@ require ( | |||||
| 107 | github.com/subosito/gotenv v1.6.0 // indirect | 106 | github.com/subosito/gotenv v1.6.0 // indirect | ||
| 108 | go.opentelemetry.io/auto/sdk v1.2.1 // indirect | 107 | go.opentelemetry.io/auto/sdk v1.2.1 // indirect | ||
| 109 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 | 108 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 | ||
| 110 | - | go.opentelemetry.io/otel v1.42.0 | 109 | + | go.opentelemetry.io/otel v1.43.0 |
| 111 | - | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 | 110 | + | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 |
| 112 | - | go.opentelemetry.io/otel/metric v1.42.0 | 111 | + | go.opentelemetry.io/otel/metric v1.43.0 |
| 113 | - | go.opentelemetry.io/otel/trace v1.42.0 | 112 | + | go.opentelemetry.io/otel/trace v1.43.0 |
| 114 | golang.org/x/sys v0.42.0 // indirect | 113 | golang.org/x/sys v0.42.0 // indirect | ||
| 115 | golang.org/x/text v0.35.0 // indirect | 114 | golang.org/x/text v0.35.0 // indirect | ||
| 116 | gopkg.in/yaml.v3 v3.0.1 // indirect | 115 | gopkg.in/yaml.v3 v3.0.1 // indirect | ||
M go.sum +56 -66 generated file: collapsed
M internal/adapters/in/http/proxy/forwarder.go +28 -2
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | 15 | ||
| 16 | 16 | "github.com/bnema/zerowrap" | |
| 17 | 17 | ||
| 18 | + | "github.com/bnema/gordon/internal/adapters/in/http/middleware" | |
| 18 | 19 | "github.com/bnema/gordon/internal/domain" | |
| 19 | 20 | ) | |
| 20 | 21 | ||
| @@ -95,7 +96,7 @@ func (h *Handler) forwardToTarget(w http.ResponseWriter, r *http.Request, target | |||
| 95 | 96 | ||
| 96 | 97 | transport := h.transportForTarget(target) | |
| 97 | 98 | ||
| 98 | - | proxy := newReverseProxy(targetURL, originalHost, transport, | |
| 99 | + | proxy := newReverseProxy(targetURL, originalHost, transport, r, h.trustedNets, | |
| 99 | 100 | func(w http.ResponseWriter, _ *http.Request, proxyErr error) { | |
| 100 | 101 | var maxBytesErr *http.MaxBytesError | |
| 101 | 102 | if errors.As(proxyErr, &maxBytesErr) { | |
| @@ -183,9 +184,13 @@ func (h *Handler) forwardToRegistry(w http.ResponseWriter, r *http.Request, regi | |||
| 183 | 184 | // originalHost, when non-empty, overrides the Host header sent to the backend. | |
| 184 | 185 | // This is used for DNS-pinned targets where the proxy dials an IP but needs to | |
| 185 | 186 | // send the original hostname for virtual-hosted upstreams. | |
| 186 | - | func newReverseProxy(targetURL *url.URL, originalHost string, transport http.RoundTripper, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResp func(*http.Response) error) *httputil.ReverseProxy { | |
| 187 | + | // | |
| 188 | + | // incomingReq and trustedNets are used to preserve X-Forwarded-Proto when the | |
| 189 | + | // request originates from a trusted upstream proxy. | |
| 190 | + | func newReverseProxy(targetURL *url.URL, originalHost string, transport http.RoundTripper, incomingReq *http.Request, trustedNets []*net.IPNet, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResp func(*http.Response) error) *httputil.ReverseProxy { | |
| 187 | 191 | return &httputil.ReverseProxy{ | |
| 188 | 192 | Rewrite: func(pr *httputil.ProxyRequest) { | |
| 193 | + | existingProto := pr.In.Header.Get("X-Forwarded-Proto") | |
| 189 | 194 | pr.SetURL(targetURL) | |
| 190 | 195 | pr.SetXForwarded() | |
| 191 | 196 | if originalHost != "" { | |
| @@ -193,6 +198,13 @@ func newReverseProxy(targetURL *url.URL, originalHost string, transport http.Rou | |||
| 193 | 198 | } else { | |
| 194 | 199 | pr.Out.Host = targetURL.Host | |
| 195 | 200 | } | |
| 201 | + | // Preserve X-Forwarded-Proto from trusted upstream proxies. | |
| 202 | + | // SetXForwarded() unconditionally sets it based on the incoming scheme | |
| 203 | + | // (HTTP between proxies), but when a trusted proxy already sent the | |
| 204 | + | // real client proto, we must honor it. | |
| 205 | + | if existingProto != "" && isTrustedSource(incomingReq, trustedNets) { | |
| 206 | + | pr.Out.Header.Set("X-Forwarded-Proto", existingProto) | |
| 207 | + | } | |
| 196 | 208 | }, | |
| 197 | 209 | Transport: transport, | |
| 198 | 210 | ErrorHandler: errorHandler, | |
| @@ -200,6 +212,20 @@ func newReverseProxy(targetURL *url.URL, originalHost string, transport http.Rou | |||
| 200 | 212 | } | |
| 201 | 213 | } | |
| 202 | 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 | + | ||
| 203 | 229 | // modifyResponse returns a function that adds proxy headers and enforces response size limits. | |
| 204 | 230 | func modifyResponse(maxResponseSize int64) func(*http.Response) error { | |
| 205 | 231 | return func(resp *http.Response) error { | |
| @@ -15,6 +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 | "github.com/bnema/gordon/internal/domain" | 19 | "github.com/bnema/gordon/internal/domain" | ||
| 19 | ) | 20 | ) | ||
| 20 | 21 | ||||
| @@ -95,7 +96,7 @@ func (h *Handler) forwardToTarget(w http.ResponseWriter, r *http.Request, target | |||||
| 95 | 96 | ||||
| 96 | transport := h.transportForTarget(target) | 97 | transport := h.transportForTarget(target) | ||
| 97 | 98 | ||||
| 98 | - | proxy := newReverseProxy(targetURL, originalHost, transport, | 99 | + | proxy := newReverseProxy(targetURL, originalHost, transport, r, h.trustedNets, |
| 99 | func(w http.ResponseWriter, _ *http.Request, proxyErr error) { | 100 | func(w http.ResponseWriter, _ *http.Request, proxyErr error) { | ||
| 100 | var maxBytesErr *http.MaxBytesError | 101 | var maxBytesErr *http.MaxBytesError | ||
| 101 | if errors.As(proxyErr, &maxBytesErr) { | 102 | if errors.As(proxyErr, &maxBytesErr) { | ||
| @@ -183,9 +184,13 @@ func (h *Handler) forwardToRegistry(w http.ResponseWriter, r *http.Request, regi | |||||
| 183 | // originalHost, when non-empty, overrides the Host header sent to the backend. | 184 | // originalHost, when non-empty, overrides the Host header sent to the backend. | ||
| 184 | // This is used for DNS-pinned targets where the proxy dials an IP but needs to | 185 | // This is used for DNS-pinned targets where the proxy dials an IP but needs to | ||
| 185 | // send the original hostname for virtual-hosted upstreams. | 186 | // send the original hostname for virtual-hosted upstreams. | ||
| 186 | - | func newReverseProxy(targetURL *url.URL, originalHost string, transport http.RoundTripper, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResp func(*http.Response) error) *httputil.ReverseProxy { | 187 | + | // |
| 188 | + | // incomingReq and trustedNets are used to preserve X-Forwarded-Proto when the | |||
| 189 | + | // request originates from a trusted upstream proxy. | |||
| 190 | + | func newReverseProxy(targetURL *url.URL, originalHost string, transport http.RoundTripper, incomingReq *http.Request, trustedNets []*net.IPNet, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResp func(*http.Response) error) *httputil.ReverseProxy { | |||
| 187 | return &httputil.ReverseProxy{ | 191 | return &httputil.ReverseProxy{ | ||
| 188 | Rewrite: func(pr *httputil.ProxyRequest) { | 192 | Rewrite: func(pr *httputil.ProxyRequest) { | ||
| 193 | + | existingProto := pr.In.Header.Get("X-Forwarded-Proto") | |||
| 189 | pr.SetURL(targetURL) | 194 | pr.SetURL(targetURL) | ||
| 190 | pr.SetXForwarded() | 195 | pr.SetXForwarded() | ||
| 191 | if originalHost != "" { | 196 | if originalHost != "" { | ||
| @@ -193,6 +198,13 @@ func newReverseProxy(targetURL *url.URL, originalHost string, transport http.Rou | |||||
| 193 | } else { | 198 | } else { | ||
| 194 | pr.Out.Host = targetURL.Host | 199 | pr.Out.Host = targetURL.Host | ||
| 195 | } | 200 | } | ||
| 201 | + | // Preserve X-Forwarded-Proto from trusted upstream proxies. | |||
| 202 | + | // SetXForwarded() unconditionally sets it based on the incoming scheme | |||
| 203 | + | // (HTTP between proxies), but when a trusted proxy already sent the | |||
| 204 | + | // real client proto, we must honor it. | |||
| 205 | + | if existingProto != "" && isTrustedSource(incomingReq, trustedNets) { | |||
| 206 | + | pr.Out.Header.Set("X-Forwarded-Proto", existingProto) | |||
| 207 | + | } | |||
| 196 | }, | 208 | }, | ||
| 197 | Transport: transport, | 209 | Transport: transport, | ||
| 198 | ErrorHandler: errorHandler, | 210 | ErrorHandler: errorHandler, | ||
| @@ -200,6 +212,20 @@ func newReverseProxy(targetURL *url.URL, originalHost string, transport http.Rou | |||||
| 200 | } | 212 | } | ||
| 201 | } | 213 | } | ||
| 202 | 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 | + | ||||
| 203 | // modifyResponse returns a function that adds proxy headers and enforces response size limits. | 229 | // modifyResponse returns a function that adds proxy headers and enforces response size limits. | ||
| 204 | func modifyResponse(maxResponseSize int64) func(*http.Response) error { | 230 | func modifyResponse(maxResponseSize int64) func(*http.Response) error { | ||
| 205 | return func(resp *http.Response) error { | 231 | return func(resp *http.Response) error { | ||
M internal/adapters/in/http/proxy/forwarder_test.go +96 -0
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "net" | |
| 6 | 6 | "net/http" | |
| 7 | 7 | "net/http/httptest" | |
| 8 | + | "net/url" | |
| 8 | 9 | "strconv" | |
| 9 | 10 | "testing" | |
| 10 | 11 | ||
| @@ -13,6 +14,7 @@ import ( | |||
| 13 | 14 | "github.com/stretchr/testify/mock" | |
| 14 | 15 | "github.com/stretchr/testify/require" | |
| 15 | 16 | ||
| 17 | + | "github.com/bnema/gordon/internal/adapters/in/http/middleware" | |
| 16 | 18 | "github.com/bnema/gordon/internal/boundaries/in" | |
| 17 | 19 | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | |
| 18 | 20 | "github.com/bnema/gordon/internal/domain" | |
| @@ -198,3 +200,97 @@ func TestForwardToTarget_ProxyHeaderSet(t *testing.T) { | |||
| 198 | 200 | ||
| 199 | 201 | assert.Equal(t, http.StatusOK, w.Code) | |
| 200 | 202 | } | |
| 203 | + | func TestNewReverseProxy_XForwardedProto(t *testing.T) { | |
| 204 | + | trustedNets := middleware.ParseTrustedProxies([]string{"10.0.0.0/8"}) | |
| 205 | + | ||
| 206 | + | tests := []struct { | |
| 207 | + | name string | |
| 208 | + | remoteAddr string | |
| 209 | + | incomingProto string // X-Forwarded-Proto on incoming request, empty = not set | |
| 210 | + | wantProto string // expected X-Forwarded-Proto at backend | |
| 211 | + | nets []*net.IPNet | |
| 212 | + | }{ | |
| 213 | + | { | |
| 214 | + | name: "trusted proxy with https preserved", | |
| 215 | + | remoteAddr: "10.0.0.1:12345", | |
| 216 | + | incomingProto: "https", | |
| 217 | + | wantProto: "https", | |
| 218 | + | nets: trustedNets, | |
| 219 | + | }, | |
| 220 | + | { | |
| 221 | + | name: "untrusted source proto overwritten", | |
| 222 | + | remoteAddr: "1.2.3.4:12345", | |
| 223 | + | incomingProto: "https", | |
| 224 | + | wantProto: "http", | |
| 225 | + | nets: trustedNets, | |
| 226 | + | }, | |
| 227 | + | { | |
| 228 | + | name: "trusted proxy without proto set normally", | |
| 229 | + | remoteAddr: "10.0.0.1:12345", | |
| 230 | + | incomingProto: "", | |
| 231 | + | wantProto: "http", | |
| 232 | + | nets: trustedNets, | |
| 233 | + | }, | |
| 234 | + | { | |
| 235 | + | name: "no trusted nets proto not preserved", | |
| 236 | + | remoteAddr: "10.0.0.1:12345", | |
| 237 | + | incomingProto: "https", | |
| 238 | + | wantProto: "http", | |
| 239 | + | nets: nil, | |
| 240 | + | }, | |
| 241 | + | { | |
| 242 | + | name: "bare IPv4 no port trusted source preserves proto", | |
| 243 | + | remoteAddr: "10.0.0.1", | |
| 244 | + | incomingProto: "https", | |
| 245 | + | wantProto: "https", | |
| 246 | + | nets: trustedNets, | |
| 247 | + | }, | |
| 248 | + | { | |
| 249 | + | name: "bare IPv6 with brackets trusted source preserves proto", | |
| 250 | + | remoteAddr: "[::1]", | |
| 251 | + | incomingProto: "https", | |
| 252 | + | wantProto: "https", | |
| 253 | + | nets: middleware.ParseTrustedProxies([]string{"::1/128"}), | |
| 254 | + | }, | |
| 255 | + | { | |
| 256 | + | name: "bare IPv4 no port untrusted source strips proto", | |
| 257 | + | remoteAddr: "1.2.3.4", | |
| 258 | + | incomingProto: "https", | |
| 259 | + | wantProto: "http", | |
| 260 | + | nets: trustedNets, | |
| 261 | + | }, | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | for _, tt := range tests { | |
| 265 | + | t.Run(tt.name, func(t *testing.T) { | |
| 266 | + | var gotProto string | |
| 267 | + | backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 268 | + | gotProto = r.Header.Get("X-Forwarded-Proto") | |
| 269 | + | w.WriteHeader(http.StatusOK) | |
| 270 | + | })) | |
| 271 | + | defer backend.Close() | |
| 272 | + | ||
| 273 | + | backendURL, err := url.Parse(backend.URL) | |
| 274 | + | require.NoError(t, err) | |
| 275 | + | ||
| 276 | + | // Build incoming request (http scheme, no TLS) | |
| 277 | + | incomingReq := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) | |
| 278 | + | incomingReq.RemoteAddr = tt.remoteAddr | |
| 279 | + | if tt.incomingProto != "" { | |
| 280 | + | incomingReq.Header.Set("X-Forwarded-Proto", tt.incomingProto) | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | proxy := newReverseProxy(backendURL, "", http.DefaultTransport, incomingReq, tt.nets, | |
| 284 | + | func(w http.ResponseWriter, _ *http.Request, err error) { | |
| 285 | + | http.Error(w, err.Error(), http.StatusBadGateway) | |
| 286 | + | }, | |
| 287 | + | nil, | |
| 288 | + | ) | |
| 289 | + | ||
| 290 | + | rec := httptest.NewRecorder() | |
| 291 | + | proxy.ServeHTTP(rec, incomingReq) | |
| 292 | + | ||
| 293 | + | assert.Equal(t, tt.wantProto, gotProto) | |
| 294 | + | }) | |
| 295 | + | } | |
| 296 | + | } | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "net" | 5 | "net" | ||
| 6 | "net/http" | 6 | "net/http" | ||
| 7 | "net/http/httptest" | 7 | "net/http/httptest" | ||
| 8 | + | "net/url" | |||
| 8 | "strconv" | 9 | "strconv" | ||
| 9 | "testing" | 10 | "testing" | ||
| 10 | 11 | ||||
| @@ -13,6 +14,7 @@ import ( | |||||
| 13 | "github.com/stretchr/testify/mock" | 14 | "github.com/stretchr/testify/mock" | ||
| 14 | "github.com/stretchr/testify/require" | 15 | "github.com/stretchr/testify/require" | ||
| 15 | 16 | ||||
| 17 | + | "github.com/bnema/gordon/internal/adapters/in/http/middleware" | |||
| 16 | "github.com/bnema/gordon/internal/boundaries/in" | 18 | "github.com/bnema/gordon/internal/boundaries/in" | ||
| 17 | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | 19 | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | ||
| 18 | "github.com/bnema/gordon/internal/domain" | 20 | "github.com/bnema/gordon/internal/domain" | ||
| @@ -198,3 +200,97 @@ func TestForwardToTarget_ProxyHeaderSet(t *testing.T) { | |||||
| 198 | 200 | ||||
| 199 | assert.Equal(t, http.StatusOK, w.Code) | 201 | assert.Equal(t, http.StatusOK, w.Code) | ||
| 200 | } | 202 | } | ||
| 203 | + | func TestNewReverseProxy_XForwardedProto(t *testing.T) { | |||
| 204 | + | trustedNets := middleware.ParseTrustedProxies([]string{"10.0.0.0/8"}) | |||
| 205 | + | ||||
| 206 | + | tests := []struct { | |||
| 207 | + | name string | |||
| 208 | + | remoteAddr string | |||
| 209 | + | incomingProto string // X-Forwarded-Proto on incoming request, empty = not set | |||
| 210 | + | wantProto string // expected X-Forwarded-Proto at backend | |||
| 211 | + | nets []*net.IPNet | |||
| 212 | + | }{ | |||
| 213 | + | { | |||
| 214 | + | name: "trusted proxy with https preserved", | |||
| 215 | + | remoteAddr: "10.0.0.1:12345", | |||
| 216 | + | incomingProto: "https", | |||
| 217 | + | wantProto: "https", | |||
| 218 | + | nets: trustedNets, | |||
| 219 | + | }, | |||
| 220 | + | { | |||
| 221 | + | name: "untrusted source proto overwritten", | |||
| 222 | + | remoteAddr: "1.2.3.4:12345", | |||
| 223 | + | incomingProto: "https", | |||
| 224 | + | wantProto: "http", | |||
| 225 | + | nets: trustedNets, | |||
| 226 | + | }, | |||
| 227 | + | { | |||
| 228 | + | name: "trusted proxy without proto set normally", | |||
| 229 | + | remoteAddr: "10.0.0.1:12345", | |||
| 230 | + | incomingProto: "", | |||
| 231 | + | wantProto: "http", | |||
| 232 | + | nets: trustedNets, | |||
| 233 | + | }, | |||
| 234 | + | { | |||
| 235 | + | name: "no trusted nets proto not preserved", | |||
| 236 | + | remoteAddr: "10.0.0.1:12345", | |||
| 237 | + | incomingProto: "https", | |||
| 238 | + | wantProto: "http", | |||
| 239 | + | nets: nil, | |||
| 240 | + | }, | |||
| 241 | + | { | |||
| 242 | + | name: "bare IPv4 no port trusted source preserves proto", | |||
| 243 | + | remoteAddr: "10.0.0.1", | |||
| 244 | + | incomingProto: "https", | |||
| 245 | + | wantProto: "https", | |||
| 246 | + | nets: trustedNets, | |||
| 247 | + | }, | |||
| 248 | + | { | |||
| 249 | + | name: "bare IPv6 with brackets trusted source preserves proto", | |||
| 250 | + | remoteAddr: "[::1]", | |||
| 251 | + | incomingProto: "https", | |||
| 252 | + | wantProto: "https", | |||
| 253 | + | nets: middleware.ParseTrustedProxies([]string{"::1/128"}), | |||
| 254 | + | }, | |||
| 255 | + | { | |||
| 256 | + | name: "bare IPv4 no port untrusted source strips proto", | |||
| 257 | + | remoteAddr: "1.2.3.4", | |||
| 258 | + | incomingProto: "https", | |||
| 259 | + | wantProto: "http", | |||
| 260 | + | nets: trustedNets, | |||
| 261 | + | }, | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | for _, tt := range tests { | |||
| 265 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 266 | + | var gotProto string | |||
| 267 | + | backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 268 | + | gotProto = r.Header.Get("X-Forwarded-Proto") | |||
| 269 | + | w.WriteHeader(http.StatusOK) | |||
| 270 | + | })) | |||
| 271 | + | defer backend.Close() | |||
| 272 | + | ||||
| 273 | + | backendURL, err := url.Parse(backend.URL) | |||
| 274 | + | require.NoError(t, err) | |||
| 275 | + | ||||
| 276 | + | // Build incoming request (http scheme, no TLS) | |||
| 277 | + | incomingReq := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) | |||
| 278 | + | incomingReq.RemoteAddr = tt.remoteAddr | |||
| 279 | + | if tt.incomingProto != "" { | |||
| 280 | + | incomingReq.Header.Set("X-Forwarded-Proto", tt.incomingProto) | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | proxy := newReverseProxy(backendURL, "", http.DefaultTransport, incomingReq, tt.nets, | |||
| 284 | + | func(w http.ResponseWriter, _ *http.Request, err error) { | |||
| 285 | + | http.Error(w, err.Error(), http.StatusBadGateway) | |||
| 286 | + | }, | |||
| 287 | + | nil, | |||
| 288 | + | ) | |||
| 289 | + | ||||
| 290 | + | rec := httptest.NewRecorder() | |||
| 291 | + | proxy.ServeHTTP(rec, incomingReq) | |||
| 292 | + | ||||
| 293 | + | assert.Equal(t, tt.wantProto, gotProto) | |||
| 294 | + | }) | |||
| 295 | + | } | |||
| 296 | + | } | |||