Skip to content

Commit d66c24d

Browse files
committed
Add support for redirecting to 'primary' domain
If a route has multiple domains, adding the 'redirect-to-primary' directive will redirect any requests to the later domains to the first listed one. This allows you to force users onto a canonical domain, without the upstream being aware of aliases. Closes #205
1 parent eb4600a commit d66c24d

10 files changed

Lines changed: 453 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
## Unreleased
44

5+
## 2.2.0 - 2025-09-21
6+
7+
### New features
8+
9+
- Routes with multiple domains can now have a `redirect-to-primary` directive,
10+
which will redirect all requests to the primary (first listed) domain.
11+
([issue #205](https://github.com/csmith/centauri/issues/205))
12+
513
## 2.1.1 - 2025-09-17
614

715
### New features

cmd/centauri/frontend.go

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -38,23 +38,25 @@ type frontendContext struct {
3838

3939
// createProxy creates a reverse proxy backed by the context's rewriter.
4040
func (fc *frontendContext) createProxy() http.Handler {
41-
return &httputil.ReverseProxy{
42-
Rewrite: fc.rewriter.RewriteRequest,
43-
ModifyResponse: fc.recorder.TrackResponse(fc.rewriter.RewriteResponse),
44-
ErrorHandler: fc.recorder.TrackBadGateway(fc.rewriter.RewriteError(handleError)),
45-
BufferPool: newBufferPool(),
46-
Transport: &http.Transport{
47-
ForceAttemptHTTP2: false,
48-
DisableCompression: true,
49-
MaxIdleConnsPerHost: 100,
50-
IdleConnTimeout: 90 * time.Second,
51-
},
52-
}
41+
return proxy.NewDomainRedirector(
42+
fc.manager,
43+
&httputil.ReverseProxy{
44+
Rewrite: fc.rewriter.RewriteRequest,
45+
ModifyResponse: fc.recorder.TrackResponse(fc.rewriter.RewriteResponse),
46+
ErrorHandler: fc.recorder.TrackBadGateway(fc.rewriter.RewriteError(handleError)),
47+
BufferPool: newBufferPool(),
48+
Transport: &http.Transport{
49+
ForceAttemptHTTP2: false,
50+
DisableCompression: true,
51+
MaxIdleConnsPerHost: 100,
52+
IdleConnTimeout: 90 * time.Second,
53+
},
54+
})
5355
}
5456

5557
// createRedirector creates a http.Handler that redirects all requests to HTTPS.
5658
func (fc *frontendContext) createRedirector() http.Handler {
57-
return &proxy.Redirector{}
59+
return &proxy.HttpRedirector{}
5860
}
5961

6062
// createTLSConfig creates a new tls.Config following the Mozilla intermediate configuration, and using

cmd/centauri/main_test.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,9 @@ func Test_Run_ObtainsCertificatesUsingAcme(t *testing.T) {
408408
assert.NoError(t, err)
409409
assert.Equal(t, http.StatusOK, res.StatusCode)
410410
assert.True(t, strings.Contains(res.TLS.PeerCertificates[0].Issuer.CommonName, "Pebble Intermediate CA"))
411+
412+
signalChan <- os.Interrupt
413+
<-doneChan
411414
return
412415
}
413416

@@ -493,6 +496,94 @@ func Test_Run_ValidateFlag_WorksWithDifferentConfigPaths(t *testing.T) {
493496
assert.NoError(t, err)
494497
}
495498

499+
func Test_Run_RedirectsToPrimaryDomain(t *testing.T) {
500+
upstream := startStaticServer(8701)
501+
defer upstream.stop(context.Background())
502+
503+
signalChan := make(chan os.Signal, 1)
504+
doneChan := make(chan struct{}, 1)
505+
506+
go func() {
507+
err := runTest(
508+
signalChan,
509+
"CONFIG", testdata.Path("domain-redirect.conf"),
510+
"PROVIDER", "selfsigned",
511+
"FRONTEND", "tcp",
512+
"HTTP_PORT", "8702",
513+
"HTTPS_PORT", "8703",
514+
)
515+
assert.NoError(t, err)
516+
doneChan <- struct{}{}
517+
}()
518+
519+
time.Sleep(2 * time.Second)
520+
521+
// Test HTTPS redirect from www.example.com to example.com
522+
res, err := proxyGet(8703, "https://www.example.com/test?param=value")
523+
assert.NoError(t, err)
524+
assert.Equal(t, http.StatusPermanentRedirect, res.StatusCode)
525+
assert.Equal(t, "https://example.com/test?param=value", res.Header.Get("Location"))
526+
527+
// Test that requests to primary domain (example.com) are not redirected
528+
res, err = proxyGet(8703, "https://example.com/test")
529+
assert.NoError(t, err)
530+
assert.Equal(t, http.StatusOK, res.StatusCode)
531+
532+
b, _ := io.ReadAll(res.Body)
533+
defer res.Body.Close()
534+
assert.Contains(t, string(b), "This is the upstream on port 8701")
535+
536+
signalChan <- os.Interrupt
537+
<-doneChan
538+
}
539+
540+
func Test_Run_RedirectsHttpToHttps(t *testing.T) {
541+
upstream := startStaticServer(8701)
542+
defer upstream.stop(context.Background())
543+
544+
signalChan := make(chan os.Signal, 1)
545+
doneChan := make(chan struct{}, 1)
546+
547+
go func() {
548+
err := runTest(
549+
signalChan,
550+
"CONFIG", testdata.Path("simple-proxy.conf"),
551+
"PROVIDER", "selfsigned",
552+
"FRONTEND", "tcp",
553+
"HTTP_PORT", "8702",
554+
"HTTPS_PORT", "8703",
555+
)
556+
assert.NoError(t, err)
557+
doneChan <- struct{}{}
558+
}()
559+
560+
time.Sleep(2 * time.Second)
561+
562+
// Test HTTP to HTTPS redirect
563+
res, err := getClientProxy(8702).Get("http://example.com/test?param=value")
564+
assert.NoError(t, err)
565+
assert.Equal(t, http.StatusPermanentRedirect, res.StatusCode)
566+
assert.Equal(t, "https://example.com/test?param=value", res.Header.Get("Location"))
567+
568+
// Test HTTP to HTTPS redirect strips port
569+
res, err = getClientProxy(8702).Get("http://example.com:80/test")
570+
assert.NoError(t, err)
571+
assert.Equal(t, http.StatusPermanentRedirect, res.StatusCode)
572+
assert.Equal(t, "https://example.com/test", res.Header.Get("Location"))
573+
574+
// Test that invalid host header returns 400
575+
req, err := http.NewRequest(http.MethodGet, "http://example.com/test", nil)
576+
assert.NoError(t, err)
577+
req.Host = "invalid..domain"
578+
579+
res, err = getClientProxy(8702).Do(req)
580+
assert.NoError(t, err)
581+
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
582+
583+
signalChan <- os.Interrupt
584+
<-doneChan
585+
}
586+
496587
func runTest(signalChan <-chan os.Signal, cfg ...string) error {
497588
flag.CommandLine.VisitAll(func(f *flag.Flag) {
498589
if !strings.HasPrefix(f.Name, "test") {
@@ -560,6 +651,9 @@ func getClientProxy(realPort int) *http.Client {
560651
InsecureSkipVerify: true,
561652
},
562653
},
654+
CheckRedirect: func(req *http.Request, via []*http.Request) error {
655+
return http.ErrUseLastResponse
656+
},
563657
}
564658
}
565659

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
route example.com www.example.com
2+
upstream 127.0.0.1:8701
3+
redirect-to-primary

config/parser.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,17 @@ func Parse(reader io.Reader) (routes []*proxy.Route, fallback *proxy.Route, err
6161
return nil, nil, fmt.Errorf("multiple fallback routes specified: %s and %s", route.Domains, fallback.Domains)
6262
}
6363
fallback = route
64+
case "redirect-to-primary":
65+
if route == nil {
66+
return nil, nil, fmt.Errorf("redirect-to-primary without route: %s", line)
67+
}
68+
if route.RedirectToPrimary {
69+
return nil, nil, fmt.Errorf("multiple redirect-to-primary options specified in route %s", route.Domains)
70+
}
71+
if len(route.Domains) < 2 {
72+
return nil, nil, fmt.Errorf("redirect-to-primary specified with only a single domain in route %s", route.Domains)
73+
}
74+
route.RedirectToPrimary = true
6475
case "#":
6576
// Ignore comments
6677
default:

config/parser_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,3 +287,43 @@ route example.net
287287

288288
assert.ErrorContains(t, err, "multiple fallback routes specified")
289289
}
290+
291+
func Test_Parse_RedirectToPrimary_OutsideRoute(t *testing.T) {
292+
_, _, err := Parse(bytes.NewBuffer([]byte(`redirect-to-primary`)))
293+
294+
assert.ErrorContains(t, err, "redirect-to-primary without route")
295+
}
296+
297+
func Test_Parse_RedirectToPrimary_MultipleDomains(t *testing.T) {
298+
routes, _, err := Parse(bytes.NewBuffer([]byte(`
299+
route example.com www.example.com
300+
upstream localhost:8080
301+
redirect-to-primary
302+
`)))
303+
304+
assert.NoError(t, err)
305+
assert.NotNil(t, routes)
306+
307+
assert.True(t, routes[0].RedirectToPrimary)
308+
}
309+
310+
func Test_Parse_RedirectToPrimary_SingleDomain(t *testing.T) {
311+
_, _, err := Parse(bytes.NewBuffer([]byte(`
312+
route example.com
313+
upstream localhost:8080
314+
redirect-to-primary
315+
`)))
316+
317+
assert.ErrorContains(t, err, "redirect-to-primary specified with only a single domain")
318+
}
319+
320+
func Test_Parse_RedirectToPrimary_Repeated(t *testing.T) {
321+
_, _, err := Parse(bytes.NewBuffer([]byte(`
322+
route example.com example.net
323+
redirect-to-primary
324+
upstream localhost:8080
325+
redirect-to-primary
326+
`)))
327+
328+
assert.ErrorContains(t, err, "multiple redirect-to-primary options specified")
329+
}

docs/routes.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,15 @@ This may only be specified on one route. Centauri's normal behaviour is
9696
to close connections for non-matching requests, as it won't be able to
9797
provide a valid certificate for that connection.
9898

99+
### `redirect-to-primary`
100+
101+
```
102+
redirect-to-primary
103+
```
104+
105+
When applied to routes with multiple domains, redirects any requests from
106+
the secondary domains to the primary. The primary domain is the first listed.
107+
99108
## Comments and whitespace
100109

101110
Lines that are empty or start with a `#` character are ignored, as is
@@ -134,4 +143,12 @@ route placeholder.example.com
134143
upstream server1:8082
135144
upstream server1:8083
136145
fallback
146+
147+
# This route will answer requests made to `example.org`, `www.example.org` and
148+
# `www1.example.org`. Requests to `example.org` will be proxied to
149+
# `server1:8084`. Requests to the other domains will be redirected to
150+
# `example.org`
151+
route example.org www.example.org www1.example.org
152+
upstream server1:8084
153+
redirect-to-primary
137154
```

proxy/redirector.go

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ import (
77
"net/url"
88
)
99

10-
// Redirector is a http.Handler that redirects all requests to HTTPS.
11-
type Redirector struct {
10+
// HttpRedirector is a http.Handler that redirects all requests to HTTPS.
11+
type HttpRedirector struct {
1212
}
1313

14-
func (r *Redirector) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
14+
func (h *HttpRedirector) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
1515
// Remove any port that may be along for the ride
1616
host, _, err := net.SplitHostPort(request.Host)
1717
if err != nil {
@@ -28,3 +28,52 @@ func (r *Redirector) ServeHTTP(writer http.ResponseWriter, request *http.Request
2828
targetUrl := url.URL{Scheme: "https", Host: host, Path: request.URL.Path, RawQuery: request.URL.RawQuery}
2929
http.Redirect(writer, request, targetUrl.String(), http.StatusPermanentRedirect)
3030
}
31+
32+
// RouteProvider is the surface used by DomainRedirector to obtain routes for
33+
// a given domain name.
34+
type RouteProvider interface {
35+
RouteForDomain(domain string) *Route
36+
}
37+
38+
// DomainRedirector is a http.Handler that redirects requests to a primary
39+
// domain name.
40+
type DomainRedirector struct {
41+
routeProvider RouteProvider
42+
next http.Handler
43+
}
44+
45+
// NewDomainRedirector creates a new DomainRedirector which will obtain routes
46+
// from the given provider. If the request does not need to be redirected, it is
47+
// passed to the `next` handler.
48+
func NewDomainRedirector(provider RouteProvider, next http.Handler) *DomainRedirector {
49+
return &DomainRedirector{
50+
routeProvider: provider,
51+
next: next,
52+
}
53+
}
54+
55+
func (d *DomainRedirector) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
56+
host := d.hostForRequest(request)
57+
route := d.routeProvider.RouteForDomain(host)
58+
if route != nil && route.RedirectToPrimary && route.Domains[0] != host {
59+
newAddress := request.URL
60+
newAddress.Host = route.Domains[0]
61+
if request.TLS != nil {
62+
newAddress.Scheme = "https"
63+
} else {
64+
newAddress.Scheme = "http"
65+
}
66+
http.Redirect(writer, request, newAddress.String(), http.StatusPermanentRedirect)
67+
} else {
68+
d.next.ServeHTTP(writer, request)
69+
}
70+
}
71+
72+
// hostForRequest returns the hostname the given request was for, without any port information.
73+
func (d *DomainRedirector) hostForRequest(req *http.Request) string {
74+
host, _, err := net.SplitHostPort(req.Host)
75+
if err != nil {
76+
return req.Host
77+
}
78+
return host
79+
}

0 commit comments

Comments
 (0)