mirror of
https://github.com/tailscale/tailscale.git
synced 2025-12-03 00:11:35 +01:00
ipn: add support for HTTP Redirects (#17594)
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:
parent
05d2dcaf49
commit
d68513b0db
@ -242,6 +242,7 @@ var _HTTPHandlerCloneNeedsRegeneration = HTTPHandler(struct {
|
|||||||
Proxy string
|
Proxy string
|
||||||
Text string
|
Text string
|
||||||
AcceptAppCaps []tailcfg.PeerCapability
|
AcceptAppCaps []tailcfg.PeerCapability
|
||||||
|
Redirect string
|
||||||
}{})
|
}{})
|
||||||
|
|
||||||
// Clone makes a deep copy of WebServerConfig.
|
// Clone makes a deep copy of WebServerConfig.
|
||||||
|
|||||||
@ -896,12 +896,22 @@ func (v HTTPHandlerView) AcceptAppCaps() views.Slice[tailcfg.PeerCapability] {
|
|||||||
return views.SliceOf(v.ж.AcceptAppCaps)
|
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.
|
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||||
var _HTTPHandlerViewNeedsRegeneration = HTTPHandler(struct {
|
var _HTTPHandlerViewNeedsRegeneration = HTTPHandler(struct {
|
||||||
Path string
|
Path string
|
||||||
Proxy string
|
Proxy string
|
||||||
Text string
|
Text string
|
||||||
AcceptAppCaps []tailcfg.PeerCapability
|
AcceptAppCaps []tailcfg.PeerCapability
|
||||||
|
Redirect string
|
||||||
}{})
|
}{})
|
||||||
|
|
||||||
// View returns a read-only view of WebServerConfig.
|
// View returns a read-only view of WebServerConfig.
|
||||||
|
|||||||
@ -966,6 +966,19 @@ func (b *LocalBackend) addAppCapabilitiesHeader(r *httputil.ProxyRequest) error
|
|||||||
return nil
|
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
|
// serveWebHandler is an http.HandlerFunc that maps incoming requests to the
|
||||||
// correct *http.
|
// correct *http.
|
||||||
func (b *LocalBackend) serveWebHandler(w http.ResponseWriter, r *http.Request) {
|
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)
|
io.WriteString(w, s)
|
||||||
return
|
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 != "" {
|
if v := h.Path(); v != "" {
|
||||||
b.serveFileOrDirectory(w, r, v, mountPoint)
|
b.serveFileOrDirectory(w, r, v, mountPoint)
|
||||||
return
|
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) {
|
func TestGetServeHandler(t *testing.T) {
|
||||||
const serverName = "example.ts.net"
|
const serverName = "example.ts.net"
|
||||||
conf1 := &ipn.ServeConfig{
|
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
|
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
|
// 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
|
// WebHandlerExists reports whether if the ServeConfig Web handler exists for
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user