{cmd/tailscale/cli,ipn/ipnlocal}: add --promote-https flag to serve

Added a new `--promote-https` flag to `tailscale
serve`, allowing HTTP requests to be redirected to
HTTPS. This feature helps in scenarios where users
access `http://mynode`, which will now
automatically redirect to
`https://mynode.my-tailnet.ts.net`. The flag can
be toggled to enable or disable this redirect
behavior. Also included necessary changes to
manage redirect handlers for HTTP-to-HTTPS
promotion.

Closes #13400

Signed-off-by: Shayne Sweeney <shayne@tailscale.com>
pull/13397/head
Shayne Sweeney 2 months ago
parent e7b5e8c8cd
commit 9e5e4de2bc
No known key found for this signature in database
GPG Key ID: 69DA13E86BF403B0

@ -159,6 +159,7 @@ type serveEnv struct {
http uint // HTTP port http uint // HTTP port
tcp uint // TCP port tcp uint // TCP port
tlsTerminatedTCP uint // a TLS terminated TCP port tlsTerminatedTCP uint // a TLS terminated TCP port
promoteHTTPS bool // promote HTTP to HTTPS
subcmd serveMode // subcommand subcmd serveMode // subcommand
yes bool // update without prompt yes bool // update without prompt

@ -123,6 +123,7 @@ func newServeV2Command(e *serveEnv, subcmd serveMode) *ffcli.Command {
fs.UintVar(&e.https, "https", 0, "Expose an HTTPS server at the specified port (default mode)") fs.UintVar(&e.https, "https", 0, "Expose an HTTPS server at the specified port (default mode)")
if subcmd == serve { if subcmd == serve {
fs.UintVar(&e.http, "http", 0, "Expose an HTTP server at the specified port") fs.UintVar(&e.http, "http", 0, "Expose an HTTP server at the specified port")
fs.BoolVar(&e.promoteHTTPS, "promote-https", false, "Promote HTTP to HTTPS (default false)")
} }
fs.UintVar(&e.tcp, "tcp", 0, "Expose a TCP forwarder to forward raw TCP packets at the specified port") fs.UintVar(&e.tcp, "tcp", 0, "Expose a TCP forwarder to forward raw TCP packets at the specified port")
fs.UintVar(&e.tlsTerminatedTCP, "tls-terminated-tcp", 0, "Expose a TCP forwarder to forward TLS-terminated TCP packets at the specified port") fs.UintVar(&e.tlsTerminatedTCP, "tls-terminated-tcp", 0, "Expose a TCP forwarder to forward TLS-terminated TCP packets at the specified port")
@ -223,6 +224,11 @@ func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc {
return errHelpFunc(subcmd) return errHelpFunc(subcmd)
} }
if srvType == serveTypeHTTP && e.promoteHTTPS {
fmt.Fprintf(e.stderr(), "error: --promote-https is only valid for HTTPS\n\n")
return errHelpFunc(subcmd)
}
sc, err := e.lc.GetServeConfig(ctx) sc, err := e.lc.GetServeConfig(ctx)
if err != nil { if err != nil {
return fmt.Errorf("error getting serve config: %w", err) return fmt.Errorf("error getting serve config: %w", err)
@ -295,7 +301,7 @@ func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc {
if err := e.validateConfig(parentSC, srvPort, srvType); err != nil { if err := e.validateConfig(parentSC, srvPort, srvType); err != nil {
return err return err
} }
err = e.setServe(sc, st, dnsName, srvType, srvPort, mount, args[0], funnel) err = e.setServe(sc, st, dnsName, srvType, srvPort, mount, args[0], funnel, e.promoteHTTPS)
msg = e.messageForPort(sc, st, dnsName, srvType, srvPort) msg = e.messageForPort(sc, st, dnsName, srvType, srvPort)
} }
if err != nil { if err != nil {
@ -365,7 +371,7 @@ func serveFromPortHandler(tcp *ipn.TCPPortHandler) serveType {
} }
} }
func (e *serveEnv) setServe(sc *ipn.ServeConfig, st *ipnstate.Status, dnsName string, srvType serveType, srvPort uint16, mount string, target string, allowFunnel bool) error { func (e *serveEnv) setServe(sc *ipn.ServeConfig, st *ipnstate.Status, dnsName string, srvType serveType, srvPort uint16, mount string, target string, allowFunnel bool, promoteHTTPS bool) error {
// update serve config based on the type // update serve config based on the type
switch srvType { switch srvType {
case serveTypeHTTPS, serveTypeHTTP: case serveTypeHTTPS, serveTypeHTTP:
@ -390,6 +396,8 @@ func (e *serveEnv) setServe(sc *ipn.ServeConfig, st *ipnstate.Status, dnsName st
// update the serve config based on if funnel is enabled // update the serve config based on if funnel is enabled
e.applyFunnel(sc, dnsName, srvPort, allowFunnel) e.applyFunnel(sc, dnsName, srvPort, allowFunnel)
sc.SetRedirectToHTTPS(dnsName, srvPort, promoteHTTPS)
return nil return nil
} }

@ -161,6 +161,7 @@ var _HTTPHandlerCloneNeedsRegeneration = HTTPHandler(struct {
Path string Path string
Proxy string Proxy string
Text string Text string
Redirect string
}{}) }{})
// Clone makes a deep copy of WebServerConfig. // Clone makes a deep copy of WebServerConfig.

@ -317,12 +317,14 @@ func (v *HTTPHandlerView) UnmarshalJSON(b []byte) error {
func (v HTTPHandlerView) Path() string { return v.ж.Path } func (v HTTPHandlerView) Path() string { return v.ж.Path }
func (v HTTPHandlerView) Proxy() string { return v.ж.Proxy } func (v HTTPHandlerView) Proxy() string { return v.ж.Proxy }
func (v HTTPHandlerView) Text() string { return v.ж.Text } func (v HTTPHandlerView) Text() string { return v.ж.Text }
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
Redirect string
}{}) }{})
// View returns a readonly view of WebServerConfig. // View returns a readonly view of WebServerConfig.

@ -316,7 +316,7 @@ type LocalBackend struct {
serveListeners map[netip.AddrPort]*localListener // listeners for local serve traffic serveListeners map[netip.AddrPort]*localListener // listeners for local serve traffic
serveProxyHandlers sync.Map // string (HTTPHandler.Proxy) => *reverseProxy serveProxyHandlers sync.Map // string (HTTPHandler.Proxy) => *reverseProxy
serveRedirectHandlers sync.Map // string (HTTPHandler.Redirect) => http.Handler
// statusLock must be held before calling statusChanged.Wait() or // statusLock must be held before calling statusChanged.Wait() or
// statusChanged.Broadcast(). // statusChanged.Broadcast().
statusLock sync.Mutex statusLock sync.Mutex
@ -5614,6 +5614,7 @@ func (b *LocalBackend) setTCPPortsInterceptedFromNetmapAndPrefsLocked(prefs ipn.
handlePorts = append(handlePorts, servePorts...) handlePorts = append(handlePorts, servePorts...)
b.setServeProxyHandlersLocked() b.setServeProxyHandlersLocked()
b.setServeRedirectHandlersLocked()
// don't listen on netmap addresses if we're in userspace mode // don't listen on netmap addresses if we're in userspace mode
if !b.sys.IsNetstack() { if !b.sys.IsNetstack() {
@ -5677,6 +5678,51 @@ func (b *LocalBackend) setServeProxyHandlersLocked() {
}) })
} }
// setServeRedirectHandlersLocked ensures there is an http redirect handler for
// each redirect specified in serveConfig. It expects serveConfig to be valid
// and up-to-date, so should be called after reloadServeConfigLocked.
func (b *LocalBackend) setServeRedirectHandlersLocked() {
if !b.serveConfig.Valid() {
return
}
var redirects map[string]bool
b.serveConfig.RangeOverWebs(func(_ ipn.HostPort, conf ipn.WebServerConfigView) (cont bool) {
conf.Handlers().Range(func(_ string, h ipn.HTTPHandlerView) (cont bool) {
redirect := h.Redirect()
if redirect == "" {
// Only create redirect handlers for servers with a redirect target.
return true
}
mak.Set(&redirects, redirect, true)
if _, ok := b.serveRedirectHandlers.Load(redirect); ok {
return true
}
b.logf("serve: creating a new redirect handler for %s", redirect)
rh, err := b.redirectHandlerForRedirect(redirect, 301)
if err != nil {
b.logf("[unexpected] could not create redirect handler for %v: %s", redirect, err)
return true
}
b.serveRedirectHandlers.Store(redirect, rh)
return true
})
return true
})
// Clean up redirect handlers that are no longer present in configuration.
b.serveRedirectHandlers.Range(func(key, value any) bool {
redirect := key.(string)
if !redirects[redirect] {
b.logf("serve: closing idle connections to %s", redirect)
b.serveRedirectHandlers.Delete(redirect)
}
return true
})
}
// operatorUserName returns the current pref's OperatorUser's name, or the // operatorUserName returns the current pref's OperatorUser's name, or the
// empty string if none. // empty string if none.
func (b *LocalBackend) operatorUserName() string { func (b *LocalBackend) operatorUserName() string {

@ -700,6 +700,21 @@ func (rp *reverseProxy) shouldProxyViaH2C(r *http.Request) bool {
return r.ProtoMajor == 2 && strings.HasPrefix(rp.backend, "http://") && isGRPCContentType(contentType) return r.ProtoMajor == 2 && strings.HasPrefix(rp.backend, "http://") && isGRPCContentType(contentType)
} }
// redirectHandlerForRedirect creates a new HTTP redirect handler for a particular url.
// `targetURL` is a HTTPHandler.Redirect string (url).
func (b *LocalBackend) redirectHandlerForRedirect(targetURL string, code int) (http.Handler, error) {
u, err := url.Parse(targetURL)
if err != nil {
return nil, fmt.Errorf("invalid url %s: %w", targetURL, err)
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u.Path = r.URL.Path
u.RawPath = r.URL.RawPath
http.Redirect(w, r, u.String(), code)
}), nil
}
// isGRPC accepts an HTTP request's content type header value and determines // isGRPC accepts an HTTP request's content type header value and determines
// whether this is gRPC content. grpc-go considers a value that equals // whether this is gRPC content. grpc-go considers a value that equals
// application/grpc or has a prefix of application/grpc+ or application/grpc; a // application/grpc or has a prefix of application/grpc+ or application/grpc; a
@ -797,6 +812,15 @@ func (b *LocalBackend) serveWebHandler(w http.ResponseWriter, r *http.Request) {
h.ServeHTTP(w, r) h.ServeHTTP(w, r)
return return
} }
if v := h.Redirect(); v != "" {
h, ok := b.serveRedirectHandlers.Load(v)
if !ok {
http.Error(w, "unknown redirect destination", http.StatusInternalServerError)
return
}
h.(http.Handler).ServeHTTP(w, r)
return
}
http.Error(w, "empty handler", 500) http.Error(w, "empty handler", 500)
} }

@ -461,6 +461,63 @@ func TestServeHTTPProxyPath(t *testing.T) {
}) })
} }
} }
func TestServeHTTPRedirect(t *testing.T) {
b := newTestBackend(t)
tests := []struct {
name string
requestPath string
wantPath string
}{
{
name: "http / -> https /",
requestPath: "/",
wantPath: "/",
},
{
name: "http /foo -> https /foo",
requestPath: "/foo",
wantPath: "/foo",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
conf := &ipn.ServeConfig{
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"example.ts.net:80": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Redirect: "https://example.ts.net"},
}},
"example.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Text: "foo bar"},
}},
},
}
if err := b.SetServeConfig(conf, ""); err != nil {
t.Fatal(err)
}
req := &http.Request{
URL: &url.URL{Path: tt.requestPath},
TLS: &tls.ConnectionState{ServerName: "example.ts.net"},
}
req = req.WithContext(serveHTTPContextKey.WithValue(req.Context(),
&serveHTTPContext{
DestPort: 80,
SrcAddr: netip.MustParseAddrPort("1.2.3.4:1234"), // random src
}))
w := httptest.NewRecorder()
b.serveWebHandler(w, req)
// Verify what path was requested
p := w.Result().Header.Get("Location")
wantLoc := fmt.Sprintf("https://example.ts.net%s", tt.wantPath)
if p != wantLoc {
t.Errorf("wanted request path %s got %s", wantLoc, p)
}
})
}
}
func TestServeHTTPProxyHeaders(t *testing.T) { func TestServeHTTPProxyHeaders(t *testing.T) {
b := newTestBackend(t) b := newTestBackend(t)

@ -136,6 +136,8 @@ type HTTPHandler struct {
Text string `json:",omitempty"` // plaintext to serve (primarily for testing) Text string `json:",omitempty"` // plaintext to serve (primarily for testing)
Redirect string `json:",omitempty"` // redirect requests to this URL
// 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? Redirects?
} }
@ -320,6 +322,43 @@ func (sc *ServeConfig) SetFunnel(host string, port uint16, setOn bool) {
} }
} }
// SetRedirectToHTTPS configures a TCPPortHandler and HTTPHandler to redirect all
// traffic from port 80 to HTTPS, using the provided `host` and `port` for the
// redirect URL. If setOn is false, it removes any active redirect handlers on
// port 80.
func (sc *ServeConfig) SetRedirectToHTTPS(host string, port uint16, setOn bool) {
if sc == nil {
sc = new(ServeConfig)
}
hp := HostPort(net.JoinHostPort(host, "80"))
if setOn {
mak.Set(&sc.TCP, 80, &TCPPortHandler{HTTP: true})
var url string
if port == 443 {
url = fmt.Sprintf("https://%s", host)
} else {
url = fmt.Sprintf("https://%s:%d", host, port)
}
handler := &HTTPHandler{Redirect: url}
if _, ok := sc.Web[hp]; !ok {
mak.Set(&sc.Web, hp, new(WebServerConfig))
}
// Overwrite any existing handlers as we're handling all HTTP traffic.
sc.Web[hp].Handlers = map[string]*HTTPHandler{"/": handler}
} else {
// If we're running with HTTP to HTTPS promotion, we need to remove any
// existing Redirect handlers.
if tcph, exists := sc.TCP[80]; exists && tcph.HTTP {
if wh, exists := sc.Web[hp]; exists {
if wh.Handlers["/"].Redirect != "" {
delete(wh.Handlers, "/")
}
}
}
}
}
// RemoveWebHandler deletes the web handlers at all of the given mount points // RemoveWebHandler deletes the web handlers at all of the given mount points
// for the provided host and port in the serve config. If cleanupFunnel is // for the provided host and port in the serve config. If cleanupFunnel is
// true, this also removes the funnel value for this port if no handlers remain. // true, this also removes the funnel value for this port if no handlers remain.

Loading…
Cancel
Save