mirror of
https://github.com/tailscale/tailscale.git
synced 2025-12-01 17:49:02 +00:00
ipn: add support for HTTP Redirects
Adds a new Redirect field to HTTPHandler for serving HTTP redirects
from the Tailscale serve config. The redirect URL supports template
variables ${HOST} and ${REQUEST_URI} that are resolved per request.
By default, it redirects using HTTP Status 302 (Found). For another
redirect status, like 301 - Moved Permanently, pass the HTTP status
code followed by ':' on Redirect, like: "301:https://tailscale.com"
Updates #11252
Updates #11330
Signed-off-by: Fernando Serboncini <fserb@tailscale.com>
This commit is contained in:
@@ -242,6 +242,7 @@ var _HTTPHandlerCloneNeedsRegeneration = HTTPHandler(struct {
|
||||
Proxy string
|
||||
Text string
|
||||
AcceptAppCaps []tailcfg.PeerCapability
|
||||
Redirect string
|
||||
}{})
|
||||
|
||||
// Clone makes a deep copy of WebServerConfig.
|
||||
|
||||
@@ -896,12 +896,22 @@ func (v HTTPHandlerView) AcceptAppCaps() views.Slice[tailcfg.PeerCapability] {
|
||||
return views.SliceOf(v.ж.AcceptAppCaps)
|
||||
}
|
||||
|
||||
// Redirect, if not empty, is the target URL to redirect requests to.
|
||||
// By default, we redirect with HTTP 302 (Found) status.
|
||||
// If Redirect starts with '<httpcode>:', then we use that status instead.
|
||||
//
|
||||
// The target URL supports the following expansion variables:
|
||||
// - ${HOST}: replaced with the request's Host header value
|
||||
// - ${REQUEST_URI}: replaced with the request's full URI (path and query string)
|
||||
func (v HTTPHandlerView) Redirect() string { return v.ж.Redirect }
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
var _HTTPHandlerViewNeedsRegeneration = HTTPHandler(struct {
|
||||
Path string
|
||||
Proxy string
|
||||
Text string
|
||||
AcceptAppCaps []tailcfg.PeerCapability
|
||||
Redirect string
|
||||
}{})
|
||||
|
||||
// View returns a read-only view of WebServerConfig.
|
||||
|
||||
@@ -966,6 +966,19 @@ func (b *LocalBackend) addAppCapabilitiesHeader(r *httputil.ProxyRequest) error
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseRedirectWithCode parses a redirect string that may optionally start with
|
||||
// a HTTP redirect status code ("3xx:").
|
||||
// Returns the status code and the final redirect URL.
|
||||
// If no code prefix is found, returns http.StatusFound (302).
|
||||
func parseRedirectWithCode(redirect string) (code int, url string) {
|
||||
if len(redirect) >= 4 && redirect[3] == ':' {
|
||||
if statusCode, err := strconv.Atoi(redirect[:3]); err == nil && statusCode >= 300 && statusCode <= 399 {
|
||||
return statusCode, redirect[4:]
|
||||
}
|
||||
}
|
||||
return http.StatusFound, redirect
|
||||
}
|
||||
|
||||
// serveWebHandler is an http.HandlerFunc that maps incoming requests to the
|
||||
// correct *http.
|
||||
func (b *LocalBackend) serveWebHandler(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -979,6 +992,13 @@ func (b *LocalBackend) serveWebHandler(w http.ResponseWriter, r *http.Request) {
|
||||
io.WriteString(w, s)
|
||||
return
|
||||
}
|
||||
if v := h.Redirect(); v != "" {
|
||||
code, v := parseRedirectWithCode(v)
|
||||
v = strings.ReplaceAll(v, "${HOST}", r.Host)
|
||||
v = strings.ReplaceAll(v, "${REQUEST_URI}", r.RequestURI)
|
||||
http.Redirect(w, r, v, code)
|
||||
return
|
||||
}
|
||||
if v := h.Path(); v != "" {
|
||||
b.serveFileOrDirectory(w, r, v, mountPoint)
|
||||
return
|
||||
|
||||
@@ -72,6 +72,41 @@ func TestExpandProxyArg(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRedirectWithRedirectCode(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
wantCode int
|
||||
wantURL string
|
||||
}{
|
||||
{"301:https://example.com", 301, "https://example.com"},
|
||||
{"302:https://example.com", 302, "https://example.com"},
|
||||
{"303:/path", 303, "/path"},
|
||||
{"307:https://example.com/path?query=1", 307, "https://example.com/path?query=1"},
|
||||
{"308:https://example.com", 308, "https://example.com"},
|
||||
|
||||
{"https://example.com", 302, "https://example.com"},
|
||||
{"/path", 302, "/path"},
|
||||
{"http://example.com", 302, "http://example.com"},
|
||||
{"git://example.com", 302, "git://example.com"},
|
||||
|
||||
{"200:https://example.com", 302, "200:https://example.com"},
|
||||
{"404:https://example.com", 302, "404:https://example.com"},
|
||||
{"500:https://example.com", 302, "500:https://example.com"},
|
||||
{"30:https://example.com", 302, "30:https://example.com"},
|
||||
{"3:https://example.com", 302, "3:https://example.com"},
|
||||
{"3012:https://example.com", 302, "3012:https://example.com"},
|
||||
{"abc:https://example.com", 302, "abc:https://example.com"},
|
||||
{"301", 302, "301"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
gotCode, gotURL := parseRedirectWithCode(tt.in)
|
||||
if gotCode != tt.wantCode || gotURL != tt.wantURL {
|
||||
t.Errorf("parseRedirectWithCode(%q) = (%d, %q), want (%d, %q)",
|
||||
tt.in, gotCode, gotURL, tt.wantCode, tt.wantURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetServeHandler(t *testing.T) {
|
||||
const serverName = "example.ts.net"
|
||||
conf1 := &ipn.ServeConfig{
|
||||
@@ -1327,3 +1362,95 @@ func TestServeGRPCProxy(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHTTPRedirect(t *testing.T) {
|
||||
b := newTestBackend(t)
|
||||
|
||||
tests := []struct {
|
||||
host string
|
||||
path string
|
||||
redirect string
|
||||
reqURI string
|
||||
wantCode int
|
||||
wantLoc string
|
||||
}{
|
||||
{
|
||||
host: "hardcoded-root",
|
||||
path: "/",
|
||||
redirect: "https://example.com/",
|
||||
reqURI: "/old",
|
||||
wantCode: http.StatusFound, // 302 is the default
|
||||
wantLoc: "https://example.com/",
|
||||
},
|
||||
{
|
||||
host: "template-host-and-uri",
|
||||
path: "/",
|
||||
redirect: "https://${HOST}${REQUEST_URI}",
|
||||
reqURI: "/path?foo=bar",
|
||||
wantCode: http.StatusFound, // 302 is the default
|
||||
wantLoc: "https://template-host-and-uri/path?foo=bar",
|
||||
},
|
||||
{
|
||||
host: "custom-301",
|
||||
path: "/",
|
||||
redirect: "301:https://example.com/",
|
||||
reqURI: "/old",
|
||||
wantCode: http.StatusMovedPermanently, // 301
|
||||
wantLoc: "https://example.com/",
|
||||
},
|
||||
{
|
||||
host: "custom-307",
|
||||
path: "/",
|
||||
redirect: "307:https://example.com/new",
|
||||
reqURI: "/old",
|
||||
wantCode: http.StatusTemporaryRedirect, // 307
|
||||
wantLoc: "https://example.com/new",
|
||||
},
|
||||
{
|
||||
host: "custom-308",
|
||||
path: "/",
|
||||
redirect: "308:https://example.com/permanent",
|
||||
reqURI: "/old",
|
||||
wantCode: http.StatusPermanentRedirect, // 308
|
||||
wantLoc: "https://example.com/permanent",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.host, func(t *testing.T) {
|
||||
conf := &ipn.ServeConfig{
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
ipn.HostPort(tt.host + ":80"): {
|
||||
Handlers: map[string]*ipn.HTTPHandler{
|
||||
tt.path: {Redirect: tt.redirect},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := b.SetServeConfig(conf, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := &http.Request{
|
||||
Host: tt.host,
|
||||
URL: &url.URL{Path: tt.path},
|
||||
RequestURI: tt.reqURI,
|
||||
TLS: &tls.ConnectionState{ServerName: tt.host},
|
||||
}
|
||||
req = req.WithContext(serveHTTPContextKey.WithValue(req.Context(), &serveHTTPContext{
|
||||
DestPort: 80,
|
||||
SrcAddr: netip.MustParseAddrPort("1.2.3.4:1234"),
|
||||
}))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
b.serveWebHandler(w, req)
|
||||
|
||||
if w.Code != tt.wantCode {
|
||||
t.Errorf("got status %d, want %d", w.Code, tt.wantCode)
|
||||
}
|
||||
if got := w.Header().Get("Location"); got != tt.wantLoc {
|
||||
t.Errorf("got Location %q, want %q", got, tt.wantLoc)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
11
ipn/serve.go
11
ipn/serve.go
@@ -162,8 +162,17 @@ type HTTPHandler struct {
|
||||
|
||||
AcceptAppCaps []tailcfg.PeerCapability `json:",omitempty"` // peer capabilities to forward in grant header, e.g. example.com/cap/mon
|
||||
|
||||
// Redirect, if not empty, is the target URL to redirect requests to.
|
||||
// By default, we redirect with HTTP 302 (Found) status.
|
||||
// If Redirect starts with '<httpcode>:', then we use that status instead.
|
||||
//
|
||||
// The target URL supports the following expansion variables:
|
||||
// - ${HOST}: replaced with the request's Host header value
|
||||
// - ${REQUEST_URI}: replaced with the request's full URI (path and query string)
|
||||
Redirect string `json:",omitempty"`
|
||||
|
||||
// TODO(bradfitz): bool to not enumerate directories? TTL on mapping for
|
||||
// temporary ones? Error codes? Redirects?
|
||||
// temporary ones? Error codes?
|
||||
}
|
||||
|
||||
// WebHandlerExists reports whether if the ServeConfig Web handler exists for
|
||||
|
||||
Reference in New Issue
Block a user