diff --git a/ipn/ipn_clone.go b/ipn/ipn_clone.go index 3d67efc6f..a5e1d3d97 100644 --- a/ipn/ipn_clone.go +++ b/ipn/ipn_clone.go @@ -237,9 +237,10 @@ func (src *HTTPHandler) Clone() *HTTPHandler { // A compilation failure here means this code must be regenerated, with the command at the top of this file. var _HTTPHandlerCloneNeedsRegeneration = HTTPHandler(struct { - Path string - Proxy string - Text string + Path string + Proxy string + Text string + Redirect string }{}) // Clone makes a deep copy of WebServerConfig. diff --git a/ipn/ipn_view.go b/ipn/ipn_view.go index 1c7639f6f..38f709516 100644 --- a/ipn/ipn_view.go +++ b/ipn/ipn_view.go @@ -891,11 +891,20 @@ func (v HTTPHandlerView) Proxy() string { return v.ж.Proxy } // plaintext to serve (primarily for testing) func (v HTTPHandlerView) Text() string { return v.ж.Text } +// Redirect is the target URL for an HTTP redirect. +// Redirects are always sent with HTTP 301 (Moved Permanently) status. +// +// 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 + Path string + Proxy string + Text string + Redirect string }{}) // View returns a read-only view of WebServerConfig. diff --git a/ipn/ipnlocal/serve.go b/ipn/ipnlocal/serve.go index 3c967fd1e..eb310c7b4 100644 --- a/ipn/ipnlocal/serve.go +++ b/ipn/ipnlocal/serve.go @@ -940,6 +940,12 @@ func (b *LocalBackend) serveWebHandler(w http.ResponseWriter, r *http.Request) { io.WriteString(w, s) return } + if v := h.Redirect(); v != "" { + v = strings.ReplaceAll(v, "${HOST}", r.Host) + v = strings.ReplaceAll(v, "${REQUEST_URI}", r.RequestURI) + http.Redirect(w, r, v, http.StatusMovedPermanently) + return + } if v := h.Path(); v != "" { b.serveFileOrDirectory(w, r, v, mountPoint) return diff --git a/ipn/ipnlocal/serve_test.go b/ipn/ipnlocal/serve_test.go index b4461d12f..fb9c8d39c 100644 --- a/ipn/ipnlocal/serve_test.go +++ b/ipn/ipnlocal/serve_test.go @@ -1171,3 +1171,68 @@ func TestServeGRPCProxy(t *testing.T) { }) } } + +func TestServeHTTPRedirect(t *testing.T) { + b := newTestBackend(t) + + tests := []struct { + host string + path string + redirect string + reqURI string + wantLoc string + }{ + { + host: "hardcoded-root", + path: "/", + redirect: "https://example.com/", + reqURI: "/old", + wantLoc: "https://example.com/", + }, + { + host: "template-host-and-uri", + path: "/", + redirect: "https://${HOST}${REQUEST_URI}", + reqURI: "/path?foo=bar", + wantLoc: "https://template-host-and-uri/path?foo=bar", + }, + } + + 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 != http.StatusMovedPermanently { + t.Errorf("got status %d, want %d", w.Code, http.StatusMovedPermanently) + } + if got := w.Header().Get("Location"); got != tt.wantLoc { + t.Errorf("got Location %q, want %q", got, tt.wantLoc) + } + }) + } +} diff --git a/ipn/serve.go b/ipn/serve.go index a0f1334d7..7e153d527 100644 --- a/ipn/serve.go +++ b/ipn/serve.go @@ -160,8 +160,16 @@ type HTTPHandler struct { Text string `json:",omitempty"` // plaintext to serve (primarily for testing) + // Redirect is the target URL for an HTTP redirect. + // Redirects are always sent with HTTP 301 (Moved Permanently) status. + // + // 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