Fix HTTPRoute Redirect Filter with port and scheme

Co-authored-by: Kevin Pollet <pollet.kevin@gmail.com>
This commit is contained in:
Romain 2024-06-06 10:56:03 +02:00 committed by GitHub
parent 7eac92f49c
commit 28d40e7f3c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 431 additions and 93 deletions

View file

@ -48,6 +48,8 @@ spec:
- --api.insecure
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --entrypoints.web8080.address=:8080
- --entrypoints.traefik.address=:9000
- --experimental.kubernetesgateway
- --providers.kubernetesgateway.experimentalChannel
- --providers.kubernetesgateway.statusaddress.service.namespace=traefik
@ -55,10 +57,12 @@ spec:
ports:
- name: web
containerPort: 80
- name: admin
containerPort: 8080
- name: websecure
containerPort: 443
- name: web8080
containerPort: 8080
- name: traefik
containerPort: 9000
---
apiVersion: v1
@ -78,5 +82,8 @@ spec:
name: websecure
targetPort: websecure
- port: 8080
name: admin
targetPort: admin
name: web8080
targetPort: web8080
- port: 9000
name: traefik
targetPort: traefik

View file

@ -166,7 +166,7 @@ func (s *K8sConformanceSuite) TestK8sGatewayAPIConformance() {
k3sContainerIP, err := s.k3sContainer.ContainerIP(context.Background())
require.NoError(s.T(), err)
err = try.GetRequest("http://"+k3sContainerIP+":8080/api/entrypoints", 10*time.Second, try.BodyContains(`"name":"web"`))
err = try.GetRequest("http://"+k3sContainerIP+":9000/api/entrypoints", 10*time.Second, try.BodyContains(`"name":"web"`))
require.NoError(s.T(), err)
opts := ksuite.Options{
@ -195,23 +195,32 @@ func (s *K8sConformanceSuite) TestK8sGatewayAPIConformance() {
LatestObservedGenerationSet: 5 * time.Second,
RequiredConsecutiveSuccesses: 0,
},
SupportedFeatures: sets.New(ksuite.SupportGateway, ksuite.SupportHTTPRoute).
Union(ksuite.HTTPRouteExtendedFeatures),
SupportedFeatures: sets.New(ksuite.SupportGateway,
ksuite.SupportGatewayPort8080,
ksuite.SupportHTTPRoute,
ksuite.SupportHTTPRouteQueryParamMatching,
ksuite.SupportHTTPRouteMethodMatching,
ksuite.SupportHTTPRoutePortRedirect,
ksuite.SupportHTTPRouteSchemeRedirect,
ksuite.SupportHTTPRouteHostRewrite,
ksuite.SupportHTTPRoutePathRewrite,
),
ExemptFeatures: sets.New(
ksuite.SupportHTTPRouteRequestTimeout,
ksuite.SupportHTTPRouteBackendTimeout,
ksuite.SupportHTTPRouteResponseHeaderModification,
ksuite.SupportHTTPRoutePathRedirect,
ksuite.SupportHTTPRouteRequestMirror,
ksuite.SupportHTTPRouteRequestMultipleMirrors,
),
EnableAllSupportedFeatures: false,
RunTest: *k8sConformanceRunTest,
// Until the feature are all supported, following tests are skipped.
SkipTests: []string{
tests.HTTPRouteMethodMatching.ShortName,
tests.HTTPRouteQueryParamMatching.ShortName,
tests.HTTPRouteRedirectPath.ShortName,
tests.HTTPRouteRedirectPortAndScheme.ShortName,
tests.HTTPRouteRequestMirror.ShortName,
tests.HTTPRouteRequestMultipleMirrors.ShortName,
tests.HTTPRouteResponseHeaderModifier.ShortName,
tests.HTTPRouteRewriteHost.ShortName,
tests.HTTPRouteRewritePath.ShortName,
tests.HTTPRouteTimeoutBackendRequest.ShortName,
tests.HTTPRouteTimeoutRequest.ShortName,
},
}

View file

@ -42,6 +42,7 @@ type Middleware struct {
// Gateway API HTTPRoute filters middlewares.
RequestHeaderModifier *RequestHeaderModifier `json:"requestHeaderModifier,omitempty" toml:"-" yaml:"-" label:"-" file:"-" kv:"-" export:"true"`
RequestRedirect *RequestRedirect `json:"requestRedirect,omitempty" toml:"-" yaml:"-" label:"-" file:"-" kv:"-" export:"true"`
}
// +k8s:deepcopy-gen=true
@ -685,3 +686,12 @@ type RequestHeaderModifier struct {
Add map[string]string `json:"add,omitempty"`
Remove []string `json:"remove,omitempty"`
}
// +k8s:deepcopy-gen=true
// RequestRedirect holds the request redirect middleware configuration.
type RequestRedirect struct {
Regex string `json:"regex,omitempty"`
Replacement string `json:"replacement,omitempty"`
Permanent bool `json:"permanent,omitempty"`
}

View file

@ -864,6 +864,11 @@ func (in *Middleware) DeepCopyInto(out *Middleware) {
*out = new(RequestHeaderModifier)
(*in).DeepCopyInto(*out)
}
if in.RequestRedirect != nil {
in, out := &in.RequestRedirect, &out.RequestRedirect
*out = new(RequestRedirect)
**out = **in
}
return
}
@ -1107,6 +1112,22 @@ func (in *RequestHeaderModifier) DeepCopy() *RequestHeaderModifier {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *RequestRedirect) DeepCopyInto(out *RequestRedirect) {
*out = *in
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RequestRedirect.
func (in *RequestRedirect) DeepCopy() *RequestRedirect {
if in == nil {
return nil
}
out := new(RequestRedirect)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ResponseForwarding) DeepCopyInto(out *ResponseForwarding) {
*out = *in

View file

@ -0,0 +1,134 @@
package redirect
import (
"context"
"net/http"
"net/url"
"regexp"
"strings"
"github.com/traefik/traefik/v3/pkg/config/dynamic"
"github.com/traefik/traefik/v3/pkg/middlewares"
"github.com/vulcand/oxy/v2/utils"
"go.opentelemetry.io/otel/trace"
)
const (
schemeHTTP = "http"
schemeHTTPS = "https"
typeName = "RequestRedirect"
)
var uriRegexp = regexp.MustCompile(`^(https?):\/\/(\[[\w:.]+\]|[\w\._-]+)?(:\d+)?(.*)$`)
// NewRequestRedirect creates a redirect middleware.
func NewRequestRedirect(ctx context.Context, next http.Handler, conf dynamic.RequestRedirect, name string) (http.Handler, error) {
logger := middlewares.GetLogger(ctx, name, typeName)
logger.Debug().Msg("Creating middleware")
logger.Debug().Msgf("Setting up redirection from %s to %s", conf.Regex, conf.Replacement)
re, err := regexp.Compile(conf.Regex)
if err != nil {
return nil, err
}
return &redirect{
regex: re,
replacement: conf.Replacement,
permanent: conf.Permanent,
errHandler: utils.DefaultHandler,
next: next,
name: name,
rawURL: rawURL,
}, nil
}
type redirect struct {
next http.Handler
regex *regexp.Regexp
replacement string
permanent bool
errHandler utils.ErrorHandler
name string
rawURL func(*http.Request) string
}
func rawURL(req *http.Request) string {
scheme := schemeHTTP
host := req.Host
port := ""
uri := req.RequestURI
if match := uriRegexp.FindStringSubmatch(req.RequestURI); len(match) > 0 {
scheme = match[1]
if len(match[2]) > 0 {
host = match[2]
}
if len(match[3]) > 0 {
port = match[3]
}
uri = match[4]
}
if req.TLS != nil {
scheme = schemeHTTPS
}
return strings.Join([]string{scheme, "://", host, port, uri}, "")
}
func (r *redirect) GetTracingInformation() (string, string, trace.SpanKind) {
return r.name, typeName, trace.SpanKindInternal
}
func (r *redirect) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
oldURL := r.rawURL(req)
// If the Regexp doesn't match, skip to the next handler.
if !r.regex.MatchString(oldURL) {
r.next.ServeHTTP(rw, req)
return
}
// Apply a rewrite regexp to the URL.
newURL := r.regex.ReplaceAllString(oldURL, r.replacement)
// Parse the rewritten URL and replace request URL with it.
parsedURL, err := url.Parse(newURL)
if err != nil {
r.errHandler.ServeHTTP(rw, req, err)
return
}
handler := &moveHandler{location: parsedURL, permanent: r.permanent}
handler.ServeHTTP(rw, req)
}
type moveHandler struct {
location *url.URL
permanent bool
}
func (m *moveHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
rw.Header().Set("Location", m.location.String())
status := http.StatusFound
if req.Method != http.MethodGet {
status = http.StatusTemporaryRedirect
}
if m.permanent {
status = http.StatusMovedPermanently
if req.Method != http.MethodGet {
status = http.StatusPermanentRedirect
}
}
rw.WriteHeader(status)
_, err := rw.Write([]byte(http.StatusText(status)))
if err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError)
}
}

View file

@ -0,0 +1,203 @@
package redirect
import (
"context"
"crypto/tls"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/traefik/traefik/v3/pkg/config/dynamic"
)
func TestRequestRedirectHandler(t *testing.T) {
testCases := []struct {
desc string
config dynamic.RequestRedirect
method string
url string
headers map[string]string
secured bool
expectedURL string
expectedStatus int
errorExpected bool
}{
{
desc: "simple redirection",
config: dynamic.RequestRedirect{
Regex: `^(?:http?:\/\/)(foo)(\.com)(:\d+)(.*)$`,
Replacement: "https://${1}bar$2:443$4",
},
url: "http://foo.com:80",
expectedURL: "https://foobar.com:443",
expectedStatus: http.StatusFound,
},
{
desc: "URL doesn't match regex",
config: dynamic.RequestRedirect{
Regex: `^(?:http?:\/\/)(foo)(\.com)(:\d+)(.*)$`,
Replacement: "https://${1}bar$2:443$4",
},
url: "http://bar.com:80",
expectedStatus: http.StatusOK,
},
{
desc: "invalid rewritten URL",
config: dynamic.RequestRedirect{
Regex: `^(.*)$`,
Replacement: "http://192.168.0.%31/",
},
url: "http://foo.com:80",
expectedStatus: http.StatusBadGateway,
},
{
desc: "invalid regex",
config: dynamic.RequestRedirect{
Regex: `^(.*`,
Replacement: "$1",
},
url: "http://foo.com:80",
errorExpected: true,
},
{
desc: "HTTP to HTTPS permanent",
config: dynamic.RequestRedirect{
Regex: `^http://`,
Replacement: "https://$1",
Permanent: true,
},
url: "http://foo",
expectedURL: "https://foo",
expectedStatus: http.StatusMovedPermanently,
},
{
desc: "HTTPS to HTTP permanent",
config: dynamic.RequestRedirect{
Regex: `https://foo`,
Replacement: "http://foo",
Permanent: true,
},
secured: true,
url: "https://foo",
expectedURL: "http://foo",
expectedStatus: http.StatusMovedPermanently,
},
{
desc: "HTTP to HTTPS",
config: dynamic.RequestRedirect{
Regex: `http://foo:80`,
Replacement: "https://foo:443",
},
url: "http://foo:80",
expectedURL: "https://foo:443",
expectedStatus: http.StatusFound,
},
{
desc: "HTTP to HTTPS, with X-Forwarded-Proto",
config: dynamic.RequestRedirect{
Regex: `http://foo:80`,
Replacement: "https://foo:443",
},
url: "http://foo:80",
headers: map[string]string{
"X-Forwarded-Proto": "https",
},
expectedURL: "https://foo:443",
expectedStatus: http.StatusFound,
},
{
desc: "HTTPS to HTTP",
config: dynamic.RequestRedirect{
Regex: `https://foo:443`,
Replacement: "http://foo:80",
},
secured: true,
url: "https://foo:443",
expectedURL: "http://foo:80",
expectedStatus: http.StatusFound,
},
{
desc: "HTTP to HTTP",
config: dynamic.RequestRedirect{
Regex: `http://foo:80`,
Replacement: "http://foo:88",
},
url: "http://foo:80",
expectedURL: "http://foo:88",
expectedStatus: http.StatusFound,
},
{
desc: "HTTP to HTTP POST",
config: dynamic.RequestRedirect{
Regex: `^http://`,
Replacement: "https://$1",
},
url: "http://foo",
method: http.MethodPost,
expectedURL: "https://foo",
expectedStatus: http.StatusTemporaryRedirect,
},
{
desc: "HTTP to HTTP POST permanent",
config: dynamic.RequestRedirect{
Regex: `^http://`,
Replacement: "https://$1",
Permanent: true,
},
url: "http://foo",
method: http.MethodPost,
expectedURL: "https://foo",
expectedStatus: http.StatusPermanentRedirect,
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
handler, err := NewRequestRedirect(context.Background(), next, test.config, "traefikTest")
if test.errorExpected {
require.Error(t, err)
require.Nil(t, handler)
} else {
require.NoError(t, err)
require.NotNil(t, handler)
recorder := httptest.NewRecorder()
method := http.MethodGet
if test.method != "" {
method = test.method
}
req := httptest.NewRequest(method, test.url, nil)
if test.secured {
req.TLS = &tls.ConnectionState{}
}
for k, v := range test.headers {
req.Header.Set(k, v)
}
req.Header.Set("X-Foo", "bar")
handler.ServeHTTP(recorder, req)
assert.Equal(t, test.expectedStatus, recorder.Code)
switch test.expectedStatus {
case http.StatusMovedPermanently, http.StatusFound, http.StatusTemporaryRedirect, http.StatusPermanentRedirect:
location, err := recorder.Result().Location()
require.NoError(t, err)
assert.Equal(t, test.expectedURL, location.String())
default:
location, err := recorder.Result().Location()
require.Errorf(t, err, "Location %v", location)
}
}
})
}
}

View file

@ -39,13 +39,7 @@ spec:
hostnames:
- "example.org"
rules:
- backendRefs:
- name: whoami
port: 80
weight: 1
kind: Service
group: ""
filters:
- filters:
- type: RequestRedirect
requestRedirect:
scheme: https

View file

@ -39,13 +39,7 @@ spec:
hostnames:
- "example.org"
rules:
- backendRefs:
- name: whoami
port: 80
weight: 1
kind: Service
group: ""
filters:
- filters:
- type: RequestRedirect
requestRedirect:
hostname: example.com

View file

@ -135,7 +135,7 @@ func (p *Provider) loadHTTPRoute(ctx context.Context, client Client, listener ga
var wrr dynamic.WeightedRoundRobin
wrrName := provider.Normalize(routerKey + "-wrr")
middlewares, err := p.loadMiddlewares(listener.Protocol, route.Namespace, routerKey, routeRule.Filters)
middlewares, err := p.loadMiddlewares(route.Namespace, routerKey, routeRule.Filters)
if err != nil {
log.Ctx(ctx).Error().
Err(err).
@ -294,14 +294,14 @@ func (p *Provider) loadHTTPBackendRef(namespace string, backendRef gatev1.HTTPBa
return backendFunc(string(backendRef.Name), namespace)
}
func (p *Provider) loadMiddlewares(listenerProtocol gatev1.ProtocolType, namespace, prefix string, filters []gatev1.HTTPRouteFilter) (map[string]*dynamic.Middleware, error) {
func (p *Provider) loadMiddlewares(namespace, prefix string, filters []gatev1.HTTPRouteFilter) (map[string]*dynamic.Middleware, error) {
middlewares := make(map[string]*dynamic.Middleware)
for i, filter := range filters {
switch filter.Type {
case gatev1.HTTPRouteFilterRequestRedirect:
middlewareName := provider.Normalize(fmt.Sprintf("%s-%s-%d", prefix, strings.ToLower(string(filter.Type)), i))
middlewares[middlewareName] = createRedirectRegexMiddleware(listenerProtocol, filter.RequestRedirect)
middlewares[middlewareName] = createRedirectMiddleware(filter.RequestRedirect)
case gatev1.HTTPRouteFilterRequestHeaderModifier:
middlewareName := provider.Normalize(fmt.Sprintf("%s-%s-%d", prefix, strings.ToLower(string(filter.Type)), i))
@ -573,25 +573,27 @@ func createRequestHeaderModifier(filter *gatev1.HTTPHeaderFilter) *dynamic.Middl
}
}
func createRedirectRegexMiddleware(listenerProtocol gatev1.ProtocolType, filter *gatev1.HTTPRequestRedirectFilter) *dynamic.Middleware {
// The spec allows for an empty string in which case we should use the
// scheme of the request which in this case is the listener scheme.
filterScheme := ptr.Deref(filter.Scheme, strings.ToLower(string(listenerProtocol)))
statusCode := ptr.Deref(filter.StatusCode, http.StatusFound)
func createRedirectMiddleware(filter *gatev1.HTTPRequestRedirectFilter) *dynamic.Middleware {
filterScheme := ptr.Deref(filter.Scheme, "${scheme}")
port := "${port}"
if filterScheme == "http" || filterScheme == "https" {
port = ""
}
if filter.Port != nil {
port = fmt.Sprintf(":%d", *filter.Port)
}
statusCode := ptr.Deref(filter.StatusCode, http.StatusFound)
hostname := "${hostname}"
if filter.Hostname != nil && *filter.Hostname != "" {
hostname = string(*filter.Hostname)
}
return &dynamic.Middleware{
RedirectRegex: &dynamic.RedirectRegex{
Regex: `^[a-z]+:\/\/(?P<userInfo>.+@)?(?P<hostname>\[[\w:\.]+\]|[\w\._-]+)(?P<port>:\d+)?\/(?P<path>.*)`,
RequestRedirect: &dynamic.RequestRedirect{
Regex: `^(?P<scheme>[a-z]+):\/\/(?P<userinfo>.+@)?(?P<hostname>\[[\w:\.]+\]|[\w\._-]+)(?P<port>:\d+)?\/(?P<path>.*)`,
Replacement: fmt.Sprintf("%s://${userinfo}%s%s/${path}", filterScheme, hostname, port),
Permanent: statusCode == http.StatusMovedPermanently,
},

View file

@ -1669,39 +1669,16 @@ func TestLoadHTTPRoutes(t *testing.T) {
},
Middlewares: map[string]*dynamic.Middleware{
"default-http-app-1-my-gateway-web-fa136e10345bd0e7248d-requestredirect-0": {
RedirectRegex: &dynamic.RedirectRegex{
Regex: "^[a-z]+:\\/\\/(?P<userInfo>.+@)?(?P<hostname>\\[[\\w:\\.]+\\]|[\\w\\._-]+)(?P<port>:\\d+)?\\/(?P<path>.*)",
Replacement: "https://${userinfo}${hostname}${port}/${path}",
RequestRedirect: &dynamic.RequestRedirect{
Regex: "^(?P<scheme>[a-z]+):\\/\\/(?P<userinfo>.+@)?(?P<hostname>\\[[\\w:\\.]+\\]|[\\w\\._-]+)(?P<port>:\\d+)?\\/(?P<path>.*)",
Replacement: "https://${userinfo}${hostname}/${path}",
Permanent: true,
},
},
},
Services: map[string]*dynamic.Service{
"default-http-app-1-my-gateway-web-fa136e10345bd0e7248d-wrr": {
Weighted: &dynamic.WeightedRoundRobin{
Services: []dynamic.WRRService{
{
Name: "default-whoami-80",
Weight: ptr.To(1),
},
},
},
},
"default-whoami-80": {
LoadBalancer: &dynamic.ServersLoadBalancer{
Servers: []dynamic.Server{
{
URL: "http://10.10.0.1:80",
},
{
URL: "http://10.10.0.2:80",
},
},
PassHostHeader: ptr.To(true),
ResponseForwarding: &dynamic.ResponseForwarding{
FlushInterval: ptypes.Duration(100 * time.Millisecond),
},
},
Weighted: &dynamic.WeightedRoundRobin{},
},
},
ServersTransports: map[string]*dynamic.ServersTransport{},
@ -1739,38 +1716,15 @@ func TestLoadHTTPRoutes(t *testing.T) {
},
Middlewares: map[string]*dynamic.Middleware{
"default-http-app-1-my-gateway-web-fa136e10345bd0e7248d-requestredirect-0": {
RedirectRegex: &dynamic.RedirectRegex{
Regex: "^[a-z]+:\\/\\/(?P<userInfo>.+@)?(?P<hostname>\\[[\\w:\\.]+\\]|[\\w\\._-]+)(?P<port>:\\d+)?\\/(?P<path>.*)",
Replacement: "http://${userinfo}example.com:443/${path}",
RequestRedirect: &dynamic.RequestRedirect{
Regex: "^(?P<scheme>[a-z]+):\\/\\/(?P<userinfo>.+@)?(?P<hostname>\\[[\\w:\\.]+\\]|[\\w\\._-]+)(?P<port>:\\d+)?\\/(?P<path>.*)",
Replacement: "${scheme}://${userinfo}example.com:443/${path}",
},
},
},
Services: map[string]*dynamic.Service{
"default-http-app-1-my-gateway-web-fa136e10345bd0e7248d-wrr": {
Weighted: &dynamic.WeightedRoundRobin{
Services: []dynamic.WRRService{
{
Name: "default-whoami-80",
Weight: ptr.To(1),
},
},
},
},
"default-whoami-80": {
LoadBalancer: &dynamic.ServersLoadBalancer{
Servers: []dynamic.Server{
{
URL: "http://10.10.0.1:80",
},
{
URL: "http://10.10.0.2:80",
},
},
PassHostHeader: ptr.To(true),
ResponseForwarding: &dynamic.ResponseForwarding{
FlushInterval: ptypes.Duration(100 * time.Millisecond),
},
},
Weighted: &dynamic.WeightedRoundRobin{},
},
},
ServersTransports: map[string]*dynamic.ServersTransport{},

View file

@ -20,8 +20,9 @@ import (
"github.com/traefik/traefik/v3/pkg/middlewares/compress"
"github.com/traefik/traefik/v3/pkg/middlewares/contenttype"
"github.com/traefik/traefik/v3/pkg/middlewares/customerrors"
"github.com/traefik/traefik/v3/pkg/middlewares/gatewayapi/headermodifier"
gapiredirect "github.com/traefik/traefik/v3/pkg/middlewares/gatewayapi/redirect"
"github.com/traefik/traefik/v3/pkg/middlewares/grpcweb"
"github.com/traefik/traefik/v3/pkg/middlewares/headermodifier"
"github.com/traefik/traefik/v3/pkg/middlewares/headers"
"github.com/traefik/traefik/v3/pkg/middlewares/inflightreq"
"github.com/traefik/traefik/v3/pkg/middlewares/ipallowlist"
@ -395,6 +396,15 @@ func (b *Builder) buildConstructor(ctx context.Context, middlewareName string) (
}
}
if config.RequestRedirect != nil {
if middleware != nil {
return nil, badConf
}
middleware = func(next http.Handler) (http.Handler, error) {
return gapiredirect.NewRequestRedirect(ctx, next, *config.RequestRedirect, middlewareName)
}
}
if middleware == nil {
return nil, fmt.Errorf("invalid middleware %q configuration: invalid middleware type or middleware does not exist", middlewareName)
}