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:
Fernando Serboncini
2025-10-20 14:09:23 -04:00
parent 05d2dcaf49
commit cd80e49dbe
5 changed files with 168 additions and 1 deletions

View File

@@ -242,6 +242,7 @@ var _HTTPHandlerCloneNeedsRegeneration = HTTPHandler(struct {
Proxy string
Text string
AcceptAppCaps []tailcfg.PeerCapability
Redirect string
}{})
// Clone makes a deep copy of WebServerConfig.

View File

@@ -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.

View File

@@ -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

View File

@@ -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)
}
})
}
}

View File

@@ -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