mirror of
https://github.com/tailscale/tailscale.git
synced 2024-11-29 21:15:39 +00:00
491483d599
This abstraction provides a nicer way to work with maps of slices without having to write out three long type params. This also allows it to provide an AsMap implementation which copies the map and the slices at least. Updates tailscale/corp#20910 Signed-off-by: Maisem Ali <maisem@tailscale.com>
914 lines
27 KiB
Go
914 lines
27 KiB
Go
// Copyright (c) Tailscale Inc & AUTHORS
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package ipnlocal
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"crypto/tls"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httputil"
|
|
"net/netip"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"golang.org/x/net/http2"
|
|
"tailscale.com/ipn"
|
|
"tailscale.com/logtail/backoff"
|
|
"tailscale.com/net/netutil"
|
|
"tailscale.com/syncs"
|
|
"tailscale.com/tailcfg"
|
|
"tailscale.com/types/lazy"
|
|
"tailscale.com/types/logger"
|
|
"tailscale.com/util/ctxkey"
|
|
"tailscale.com/util/mak"
|
|
"tailscale.com/version"
|
|
)
|
|
|
|
const (
|
|
contentTypeHeader = "Content-Type"
|
|
grpcBaseContentType = "application/grpc"
|
|
)
|
|
|
|
// ErrETagMismatch signals that the given
|
|
// If-Match header does not match with the
|
|
// current etag of a resource.
|
|
var ErrETagMismatch = errors.New("etag mismatch")
|
|
|
|
var serveHTTPContextKey ctxkey.Key[*serveHTTPContext]
|
|
|
|
type serveHTTPContext struct {
|
|
SrcAddr netip.AddrPort
|
|
DestPort uint16
|
|
}
|
|
|
|
// localListener is the state of host-level net.Listen for a specific (Tailscale IP, port)
|
|
// combination. If there are two TailscaleIPs (v4 and v6) and three ports being served,
|
|
// then there will be six of these active and looping in their Run method.
|
|
//
|
|
// This is not used in userspace-networking mode.
|
|
//
|
|
// localListener is used by tailscale serve (TCP only), the built-in web client and Taildrive.
|
|
// Most serve traffic and peer traffic for the web client are intercepted by netstack.
|
|
// This listener exists purely for connections from the machine itself, as that goes via the kernel,
|
|
// so we need to be in the kernel's listening/routing tables.
|
|
type localListener struct {
|
|
b *LocalBackend
|
|
ap netip.AddrPort
|
|
ctx context.Context // valid while listener is desired
|
|
cancel context.CancelFunc // for ctx, to close listener
|
|
logf logger.Logf
|
|
bo *backoff.Backoff // for retrying failed Listen calls
|
|
|
|
handler func(net.Conn) error // handler for inbound connections
|
|
closeListener syncs.AtomicValue[func() error] // Listener's Close method, if any
|
|
}
|
|
|
|
func (b *LocalBackend) newServeListener(ctx context.Context, ap netip.AddrPort, logf logger.Logf) *localListener {
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
return &localListener{
|
|
b: b,
|
|
ap: ap,
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
logf: logf,
|
|
|
|
handler: func(conn net.Conn) error {
|
|
srcAddr := conn.RemoteAddr().(*net.TCPAddr).AddrPort()
|
|
handler := b.tcpHandlerForServe(ap.Port(), srcAddr)
|
|
if handler == nil {
|
|
b.logf("[unexpected] local-serve: no handler for %v to port %v", srcAddr, ap.Port())
|
|
conn.Close()
|
|
return nil
|
|
}
|
|
return handler(conn)
|
|
},
|
|
bo: backoff.NewBackoff("serve-listener", logf, 30*time.Second),
|
|
}
|
|
|
|
}
|
|
|
|
// Close cancels the context and closes the listener, if any.
|
|
func (s *localListener) Close() error {
|
|
s.cancel()
|
|
if close, ok := s.closeListener.LoadOk(); ok {
|
|
s.closeListener.Store(nil)
|
|
close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Run starts a net.Listen for the localListener's address and port.
|
|
// If unable to listen, it retries with exponential backoff.
|
|
// Listen is retried until the context is canceled.
|
|
func (s *localListener) Run() {
|
|
for {
|
|
ip := s.ap.Addr()
|
|
ipStr := ip.String()
|
|
|
|
var lc net.ListenConfig
|
|
if initListenConfig != nil {
|
|
// On macOS, this sets the lc.Control hook to
|
|
// setsockopt the interface index to bind to. This is
|
|
// required by the network sandbox to allow binding to
|
|
// a specific interface. Without this hook, the system
|
|
// chooses a default interface to bind to.
|
|
if err := initListenConfig(&lc, ip, s.b.prevIfState, s.b.dialer.TUNName()); err != nil {
|
|
s.logf("localListener failed to init listen config %v, backing off: %v", s.ap, err)
|
|
s.bo.BackOff(s.ctx, err)
|
|
continue
|
|
}
|
|
// On macOS (AppStore or macsys) and if we're binding to a privileged port,
|
|
if version.IsSandboxedMacOS() && s.ap.Port() < 1024 {
|
|
// On macOS, we need to bind to ""/all-interfaces due to
|
|
// the network sandbox. Ideally we would only bind to the
|
|
// Tailscale interface, but macOS errors out if we try to
|
|
// to listen on privileged ports binding only to a specific
|
|
// interface. (#6364)
|
|
ipStr = ""
|
|
}
|
|
}
|
|
|
|
tcp4or6 := "tcp4"
|
|
if ip.Is6() {
|
|
tcp4or6 = "tcp6"
|
|
}
|
|
|
|
ln, err := lc.Listen(s.ctx, tcp4or6, net.JoinHostPort(ipStr, fmt.Sprint(s.ap.Port())))
|
|
if err != nil {
|
|
if s.shouldWarnAboutListenError(err) {
|
|
s.logf("localListener failed to listen on %v, backing off: %v", s.ap, err)
|
|
}
|
|
s.bo.BackOff(s.ctx, err)
|
|
continue
|
|
}
|
|
s.closeListener.Store(ln.Close)
|
|
|
|
s.logf("listening on %v", s.ap)
|
|
err = s.handleListenersAccept(ln)
|
|
if s.ctx.Err() != nil {
|
|
// context canceled, we're done
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.logf("localListener accept error, retrying: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *localListener) shouldWarnAboutListenError(err error) bool {
|
|
if !s.b.sys.NetMon.Get().InterfaceState().HasIP(s.ap.Addr()) {
|
|
// Machine likely doesn't have IPv6 enabled (or the IP is still being
|
|
// assigned). No need to warn. Notably, WSL2 (Issue 6303).
|
|
return false
|
|
}
|
|
// TODO(bradfitz): check errors.Is(err, syscall.EADDRNOTAVAIL) etc? Let's
|
|
// see what happens in practice.
|
|
return true
|
|
}
|
|
|
|
// handleListenersAccept accepts connections for the Listener. It calls the
|
|
// handler in a new goroutine for each accepted connection. This is used to
|
|
// handle local "tailscale serve" and web client traffic originating from the
|
|
// machine itself.
|
|
func (s *localListener) handleListenersAccept(ln net.Listener) error {
|
|
for {
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
go s.handler(conn)
|
|
}
|
|
}
|
|
|
|
// updateServeTCPPortNetMapAddrListenersLocked starts a net.Listen for configured
|
|
// Serve ports on all the node's addresses.
|
|
// Existing Listeners are closed if port no longer in incoming ports list.
|
|
//
|
|
// b.mu must be held.
|
|
func (b *LocalBackend) updateServeTCPPortNetMapAddrListenersLocked(ports []uint16) {
|
|
// close existing listeners where port
|
|
// is no longer in incoming ports list
|
|
for ap, sl := range b.serveListeners {
|
|
if !slices.Contains(ports, ap.Port()) {
|
|
b.logf("closing listener %v", ap)
|
|
sl.Close()
|
|
delete(b.serveListeners, ap)
|
|
}
|
|
}
|
|
|
|
nm := b.netMap
|
|
if nm == nil {
|
|
b.logf("netMap is nil")
|
|
return
|
|
}
|
|
if !nm.SelfNode.Valid() {
|
|
b.logf("netMap SelfNode is nil")
|
|
return
|
|
}
|
|
|
|
addrs := nm.GetAddresses()
|
|
for i := range addrs.Len() {
|
|
a := addrs.At(i)
|
|
for _, p := range ports {
|
|
addrPort := netip.AddrPortFrom(a.Addr(), p)
|
|
if _, ok := b.serveListeners[addrPort]; ok {
|
|
continue // already listening
|
|
}
|
|
|
|
sl := b.newServeListener(context.Background(), addrPort, b.logf)
|
|
mak.Set(&b.serveListeners, addrPort, sl)
|
|
|
|
go sl.Run()
|
|
}
|
|
}
|
|
}
|
|
|
|
// SetServeConfig establishes or replaces the current serve config.
|
|
// ETag is an optional parameter to enforce Optimistic Concurrency Control.
|
|
// If it is an empty string, then the config will be overwritten.
|
|
func (b *LocalBackend) SetServeConfig(config *ipn.ServeConfig, etag string) error {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.setServeConfigLocked(config, etag)
|
|
}
|
|
|
|
func (b *LocalBackend) setServeConfigLocked(config *ipn.ServeConfig, etag string) error {
|
|
prefs := b.pm.CurrentPrefs()
|
|
if config.IsFunnelOn() && prefs.ShieldsUp() {
|
|
return errors.New("Unable to turn on Funnel while shields-up is enabled")
|
|
}
|
|
if b.isConfigLocked_Locked() {
|
|
return errors.New("can't reconfigure tailscaled when using a config file; config file is locked")
|
|
}
|
|
|
|
nm := b.netMap
|
|
if nm == nil {
|
|
return errors.New("netMap is nil")
|
|
}
|
|
if !nm.SelfNode.Valid() {
|
|
return errors.New("netMap SelfNode is nil")
|
|
}
|
|
|
|
// If etag is present, check that it has
|
|
// not changed from the last config.
|
|
prevConfig := b.serveConfig
|
|
if etag != "" {
|
|
// Note that we marshal b.serveConfig
|
|
// and not use b.lastServeConfJSON as that might
|
|
// be a Go nil value, which produces a different
|
|
// checksum from a JSON "null" value.
|
|
prevBytes, err := json.Marshal(prevConfig)
|
|
if err != nil {
|
|
return fmt.Errorf("error encoding previous config: %w", err)
|
|
}
|
|
sum := sha256.Sum256(prevBytes)
|
|
previousEtag := hex.EncodeToString(sum[:])
|
|
if etag != previousEtag {
|
|
return ErrETagMismatch
|
|
}
|
|
}
|
|
|
|
var bs []byte
|
|
if config != nil {
|
|
j, err := json.Marshal(config)
|
|
if err != nil {
|
|
return fmt.Errorf("encoding serve config: %w", err)
|
|
}
|
|
bs = j
|
|
}
|
|
|
|
profileID := b.pm.CurrentProfile().ID
|
|
confKey := ipn.ServeConfigKey(profileID)
|
|
if err := b.store.WriteState(confKey, bs); err != nil {
|
|
return fmt.Errorf("writing ServeConfig to StateStore: %w", err)
|
|
}
|
|
|
|
b.setTCPPortsInterceptedFromNetmapAndPrefsLocked(b.pm.CurrentPrefs())
|
|
|
|
// clean up and close all previously open foreground sessions
|
|
// if the current ServeConfig has overwritten them.
|
|
if prevConfig.Valid() {
|
|
has := func(string) bool { return false }
|
|
if b.serveConfig.Valid() {
|
|
has = b.serveConfig.Foreground().Contains
|
|
}
|
|
prevConfig.Foreground().Range(func(k string, v ipn.ServeConfigView) (cont bool) {
|
|
if !has(k) {
|
|
for _, sess := range b.notifyWatchers {
|
|
if sess.sessionID == k {
|
|
close(sess.ch)
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ServeConfig provides a view of the current serve mappings.
|
|
// If serving is not configured, the returned view is not Valid.
|
|
func (b *LocalBackend) ServeConfig() ipn.ServeConfigView {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.serveConfig
|
|
}
|
|
|
|
// DeleteForegroundSession deletes a ServeConfig's foreground session
|
|
// in the LocalBackend if it exists. It also ensures check, delete, and
|
|
// set operations happen within the same mutex lock to avoid any races.
|
|
func (b *LocalBackend) DeleteForegroundSession(sessionID string) error {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if !b.serveConfig.Valid() || !b.serveConfig.Foreground().Contains(sessionID) {
|
|
return nil
|
|
}
|
|
sc := b.serveConfig.AsStruct()
|
|
delete(sc.Foreground, sessionID)
|
|
return b.setServeConfigLocked(sc, "")
|
|
}
|
|
|
|
// HandleIngressTCPConn handles a TCP connection initiated by the ingressPeer
|
|
// proxied to the local node over the PeerAPI.
|
|
// Target represents the destination HostPort of the conn.
|
|
// srcAddr represents the source AddrPort and not that of the ingressPeer.
|
|
// getConnOrReset is a callback to get the connection, or reset if the connection
|
|
// is no longer available.
|
|
// sendRST is a callback to send a TCP RST to the ingressPeer indicating that
|
|
// the connection was not accepted.
|
|
func (b *LocalBackend) HandleIngressTCPConn(ingressPeer tailcfg.NodeView, target ipn.HostPort, srcAddr netip.AddrPort, getConnOrReset func() (net.Conn, bool), sendRST func()) {
|
|
b.mu.Lock()
|
|
sc := b.serveConfig
|
|
b.mu.Unlock()
|
|
|
|
// TODO(maisem,bradfitz): make this not alloc for every conn.
|
|
logf := logger.WithPrefix(b.logf, "handleIngress: ")
|
|
|
|
if !sc.Valid() {
|
|
logf("got ingress conn w/o serveConfig; rejecting")
|
|
sendRST()
|
|
return
|
|
}
|
|
|
|
if !sc.HasFunnelForTarget(target) {
|
|
logf("got ingress conn for unconfigured %q; rejecting", target)
|
|
sendRST()
|
|
return
|
|
}
|
|
|
|
_, port, err := net.SplitHostPort(string(target))
|
|
if err != nil {
|
|
logf("got ingress conn for bad target %q; rejecting", target)
|
|
sendRST()
|
|
return
|
|
}
|
|
port16, err := strconv.ParseUint(port, 10, 16)
|
|
if err != nil {
|
|
logf("got ingress conn for bad target %q; rejecting", target)
|
|
sendRST()
|
|
return
|
|
}
|
|
dport := uint16(port16)
|
|
if b.getTCPHandlerForFunnelFlow != nil {
|
|
handler := b.getTCPHandlerForFunnelFlow(srcAddr, dport)
|
|
if handler != nil {
|
|
c, ok := getConnOrReset()
|
|
if !ok {
|
|
logf("getConn didn't complete from %v to port %v", srcAddr, dport)
|
|
return
|
|
}
|
|
handler(c)
|
|
return
|
|
}
|
|
}
|
|
// TODO(bradfitz): pass ingressPeer etc in context to tcpHandlerForServe,
|
|
// extend serveHTTPContext or similar.
|
|
handler := b.tcpHandlerForServe(dport, srcAddr)
|
|
if handler == nil {
|
|
logf("[unexpected] no matching ingress serve handler for %v to port %v", srcAddr, dport)
|
|
sendRST()
|
|
return
|
|
}
|
|
c, ok := getConnOrReset()
|
|
if !ok {
|
|
logf("getConn didn't complete from %v to port %v", srcAddr, dport)
|
|
return
|
|
}
|
|
handler(c)
|
|
}
|
|
|
|
// tcpHandlerForServe returns a handler for a TCP connection to be served via
|
|
// the ipn.ServeConfig.
|
|
func (b *LocalBackend) tcpHandlerForServe(dport uint16, srcAddr netip.AddrPort) (handler func(net.Conn) error) {
|
|
b.mu.Lock()
|
|
sc := b.serveConfig
|
|
b.mu.Unlock()
|
|
|
|
if !sc.Valid() {
|
|
return nil
|
|
}
|
|
|
|
tcph, ok := sc.FindTCP(dport)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
if tcph.HTTPS() || tcph.HTTP() {
|
|
hs := &http.Server{
|
|
Handler: http.HandlerFunc(b.serveWebHandler),
|
|
BaseContext: func(_ net.Listener) context.Context {
|
|
return serveHTTPContextKey.WithValue(context.Background(), &serveHTTPContext{
|
|
SrcAddr: srcAddr,
|
|
DestPort: dport,
|
|
})
|
|
},
|
|
}
|
|
if tcph.HTTPS() {
|
|
hs.TLSConfig = &tls.Config{
|
|
GetCertificate: b.getTLSServeCertForPort(dport),
|
|
}
|
|
return func(c net.Conn) error {
|
|
return hs.ServeTLS(netutil.NewOneConnListener(c, nil), "", "")
|
|
}
|
|
}
|
|
|
|
return func(c net.Conn) error {
|
|
return hs.Serve(netutil.NewOneConnListener(c, nil))
|
|
}
|
|
}
|
|
|
|
if backDst := tcph.TCPForward(); backDst != "" {
|
|
return func(conn net.Conn) error {
|
|
defer conn.Close()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
backConn, err := b.dialer.SystemDial(ctx, "tcp", backDst)
|
|
cancel()
|
|
if err != nil {
|
|
b.logf("localbackend: failed to TCP proxy port %v (from %v) to %s: %v", dport, srcAddr, backDst, err)
|
|
return nil
|
|
}
|
|
defer backConn.Close()
|
|
if sni := tcph.TerminateTLS(); sni != "" {
|
|
conn = tls.Server(conn, &tls.Config{
|
|
GetCertificate: func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
defer cancel()
|
|
pair, err := b.GetCertPEM(ctx, sni)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cert, err := tls.X509KeyPair(pair.CertPEM, pair.KeyPEM)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &cert, nil
|
|
},
|
|
})
|
|
}
|
|
|
|
// TODO(bradfitz): do the RegisterIPPortIdentity and
|
|
// UnregisterIPPortIdentity stuff that netstack does
|
|
errc := make(chan error, 1)
|
|
go func() {
|
|
_, err := io.Copy(backConn, conn)
|
|
errc <- err
|
|
}()
|
|
go func() {
|
|
_, err := io.Copy(conn, backConn)
|
|
errc <- err
|
|
}()
|
|
return <-errc
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (b *LocalBackend) getServeHandler(r *http.Request) (_ ipn.HTTPHandlerView, at string, ok bool) {
|
|
var z ipn.HTTPHandlerView // zero value
|
|
|
|
hostname := r.Host
|
|
if r.TLS == nil {
|
|
tcd := "." + b.Status().CurrentTailnet.MagicDNSSuffix
|
|
if host, _, err := net.SplitHostPort(hostname); err == nil {
|
|
hostname = host
|
|
}
|
|
if !strings.HasSuffix(hostname, tcd) {
|
|
hostname += tcd
|
|
}
|
|
} else {
|
|
hostname = r.TLS.ServerName
|
|
}
|
|
|
|
sctx, ok := serveHTTPContextKey.ValueOk(r.Context())
|
|
if !ok {
|
|
b.logf("[unexpected] localbackend: no serveHTTPContext in request")
|
|
return z, "", false
|
|
}
|
|
wsc, ok := b.webServerConfig(hostname, sctx.DestPort)
|
|
if !ok {
|
|
return z, "", false
|
|
}
|
|
|
|
if h, ok := wsc.Handlers().GetOk(r.URL.Path); ok {
|
|
return h, r.URL.Path, true
|
|
}
|
|
pth := path.Clean(r.URL.Path)
|
|
for {
|
|
withSlash := pth + "/"
|
|
if h, ok := wsc.Handlers().GetOk(withSlash); ok {
|
|
return h, withSlash, true
|
|
}
|
|
if h, ok := wsc.Handlers().GetOk(pth); ok {
|
|
return h, pth, true
|
|
}
|
|
if pth == "/" {
|
|
return z, "", false
|
|
}
|
|
pth = path.Dir(pth)
|
|
}
|
|
}
|
|
|
|
// proxyHandlerForBackend creates a new HTTP reverse proxy for a particular backend that
|
|
// we serve requests for. `backend` is a HTTPHandler.Proxy string (url, hostport or just port).
|
|
func (b *LocalBackend) proxyHandlerForBackend(backend string) (http.Handler, error) {
|
|
targetURL, insecure := expandProxyArg(backend)
|
|
u, err := url.Parse(targetURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid url %s: %w", targetURL, err)
|
|
}
|
|
p := &reverseProxy{
|
|
logf: b.logf,
|
|
url: u,
|
|
insecure: insecure,
|
|
backend: backend,
|
|
lb: b,
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// reverseProxy is a proxy that forwards a request to a backend host
|
|
// (preconfigured via ipn.ServeConfig). If the host is configured with
|
|
// http+insecure prefix, connection between proxy and backend will be over
|
|
// insecure TLS. If the backend host has a http prefix and the incoming request
|
|
// has application/grpc content type header, the connection will be over h2c.
|
|
// Otherwise standard Go http transport will be used.
|
|
type reverseProxy struct {
|
|
logf logger.Logf
|
|
url *url.URL
|
|
// insecure tracks whether the connection to an https backend should be
|
|
// insecure (i.e because we cannot verify its CA).
|
|
insecure bool
|
|
backend string
|
|
lb *LocalBackend
|
|
httpTransport lazy.SyncValue[*http.Transport] // transport for non-h2c backends
|
|
h2cTransport lazy.SyncValue[*http2.Transport] // transport for h2c backends
|
|
// closed tracks whether proxy is closed/currently closing.
|
|
closed atomic.Bool
|
|
}
|
|
|
|
// close ensures that any open backend connections get closed.
|
|
func (rp *reverseProxy) close() {
|
|
rp.closed.Store(true)
|
|
if h2cT := rp.h2cTransport.Get(func() *http2.Transport {
|
|
return nil
|
|
}); h2cT != nil {
|
|
h2cT.CloseIdleConnections()
|
|
}
|
|
if httpTransport := rp.httpTransport.Get(func() *http.Transport {
|
|
return nil
|
|
}); httpTransport != nil {
|
|
httpTransport.CloseIdleConnections()
|
|
}
|
|
}
|
|
|
|
func (rp *reverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if closed := rp.closed.Load(); closed {
|
|
rp.logf("received a request for a proxy that's being closed or has been closed")
|
|
http.Error(w, "proxy is closed", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
p := &httputil.ReverseProxy{Rewrite: func(r *httputil.ProxyRequest) {
|
|
oldOutPath := r.Out.URL.Path
|
|
r.SetURL(rp.url)
|
|
|
|
// If mount point matches the request path exactly, the outbound
|
|
// request URL was set to empty string in serveWebHandler which
|
|
// would have resulted in the outbound path set to <proxy path>
|
|
// + '/' in SetURL. In that case, if the proxy path was set, we
|
|
// want to send the request to the <proxy path> (without the
|
|
// '/') .
|
|
if oldOutPath == "" && rp.url.Path != "" {
|
|
r.Out.URL.Path = rp.url.Path
|
|
r.Out.URL.RawPath = rp.url.RawPath
|
|
}
|
|
|
|
r.Out.Host = r.In.Host
|
|
addProxyForwardedHeaders(r)
|
|
rp.lb.addTailscaleIdentityHeaders(r)
|
|
}}
|
|
|
|
// There is no way to autodetect h2c as per RFC 9113
|
|
// https://datatracker.ietf.org/doc/html/rfc9113#name-starting-http-2.
|
|
// However, we assume that http:// proxy prefix in combination with the
|
|
// protoccol being HTTP/2 is sufficient to detect h2c for our needs. Only use this for
|
|
// gRPC to fix a known problem of plaintext gRPC backends
|
|
if rp.shouldProxyViaH2C(r) {
|
|
rp.logf("received a proxy request for plaintext gRPC")
|
|
p.Transport = rp.getH2CTransport()
|
|
} else {
|
|
p.Transport = rp.getTransport()
|
|
}
|
|
p.ServeHTTP(w, r)
|
|
}
|
|
|
|
// getTransport returns the Transport used for regular (non-GRPC) requests
|
|
// to the backend. The Transport gets created lazily, at most once.
|
|
func (rp *reverseProxy) getTransport() *http.Transport {
|
|
return rp.httpTransport.Get(func() *http.Transport {
|
|
return &http.Transport{
|
|
DialContext: rp.lb.dialer.SystemDial,
|
|
TLSClientConfig: &tls.Config{
|
|
InsecureSkipVerify: rp.insecure,
|
|
},
|
|
// Values for the following parameters have been copied from http.DefaultTransport.
|
|
ForceAttemptHTTP2: true,
|
|
MaxIdleConns: 100,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
TLSHandshakeTimeout: 10 * time.Second,
|
|
ExpectContinueTimeout: 1 * time.Second,
|
|
}
|
|
})
|
|
}
|
|
|
|
// getH2CTransport returns the Transport used for GRPC requests to the backend.
|
|
// The Transport gets created lazily, at most once.
|
|
func (rp *reverseProxy) getH2CTransport() *http2.Transport {
|
|
return rp.h2cTransport.Get(func() *http2.Transport {
|
|
return &http2.Transport{
|
|
AllowHTTP: true,
|
|
DialTLSContext: func(ctx context.Context, network string, addr string, _ *tls.Config) (net.Conn, error) {
|
|
return rp.lb.dialer.SystemDial(ctx, "tcp", rp.url.Host)
|
|
},
|
|
}
|
|
})
|
|
}
|
|
|
|
// This is not a generally reliable way how to determine whether a request is
|
|
// for a h2c server, but sufficient for our particular use case.
|
|
func (rp *reverseProxy) shouldProxyViaH2C(r *http.Request) bool {
|
|
contentType := r.Header.Get(contentTypeHeader)
|
|
return r.ProtoMajor == 2 && strings.HasPrefix(rp.backend, "http://") && isGRPCContentType(contentType)
|
|
}
|
|
|
|
// isGRPC accepts an HTTP request's content type header value and determines
|
|
// 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
|
|
// valid grpc content type header.
|
|
// https://github.com/grpc/grpc-go/blob/v1.60.0-dev/internal/grpcutil/method.go#L41-L78
|
|
func isGRPCContentType(contentType string) bool {
|
|
s, ok := strings.CutPrefix(contentType, grpcBaseContentType)
|
|
return ok && (len(s) == 0 || s[0] == '+' || s[0] == ';')
|
|
}
|
|
|
|
func addProxyForwardedHeaders(r *httputil.ProxyRequest) {
|
|
r.Out.Header.Set("X-Forwarded-Host", r.In.Host)
|
|
if r.In.TLS != nil {
|
|
r.Out.Header.Set("X-Forwarded-Proto", "https")
|
|
}
|
|
if c, ok := serveHTTPContextKey.ValueOk(r.Out.Context()); ok {
|
|
r.Out.Header.Set("X-Forwarded-For", c.SrcAddr.Addr().String())
|
|
}
|
|
}
|
|
|
|
func (b *LocalBackend) addTailscaleIdentityHeaders(r *httputil.ProxyRequest) {
|
|
// Clear any incoming values squatting in the headers.
|
|
r.Out.Header.Del("Tailscale-User-Login")
|
|
r.Out.Header.Del("Tailscale-User-Name")
|
|
r.Out.Header.Del("Tailscale-User-Profile-Pic")
|
|
r.Out.Header.Del("Tailscale-Headers-Info")
|
|
|
|
c, ok := serveHTTPContextKey.ValueOk(r.Out.Context())
|
|
if !ok {
|
|
return
|
|
}
|
|
node, user, ok := b.WhoIs(c.SrcAddr)
|
|
if !ok {
|
|
return // traffic from outside of Tailnet (funneled)
|
|
}
|
|
if node.IsTagged() {
|
|
// 2023-06-14: Not setting identity headers for tagged nodes.
|
|
// Only currently set for nodes with user identities.
|
|
return
|
|
}
|
|
r.Out.Header.Set("Tailscale-User-Login", encTailscaleHeaderValue(user.LoginName))
|
|
r.Out.Header.Set("Tailscale-User-Name", encTailscaleHeaderValue(user.DisplayName))
|
|
r.Out.Header.Set("Tailscale-User-Profile-Pic", user.ProfilePicURL)
|
|
r.Out.Header.Set("Tailscale-Headers-Info", "https://tailscale.com/s/serve-headers")
|
|
}
|
|
|
|
// encTailscaleHeaderValue cleans or encodes as necessary v, to be suitable in
|
|
// an HTTP header value. See
|
|
// https://github.com/tailscale/tailscale/issues/11603.
|
|
//
|
|
// If v is not a valid UTF-8 string, it returns an empty string.
|
|
// If v is a valid ASCII string, it returns v unmodified.
|
|
// If v is a valid UTF-8 string with non-ASCII characters, it returns a
|
|
// RFC 2047 Q-encoded string.
|
|
func encTailscaleHeaderValue(v string) string {
|
|
if !utf8.ValidString(v) {
|
|
return ""
|
|
}
|
|
return mime.QEncoding.Encode("utf-8", v)
|
|
}
|
|
|
|
// serveWebHandler is an http.HandlerFunc that maps incoming requests to the
|
|
// correct *http.
|
|
func (b *LocalBackend) serveWebHandler(w http.ResponseWriter, r *http.Request) {
|
|
h, mountPoint, ok := b.getServeHandler(r)
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if s := h.Text(); s != "" {
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
io.WriteString(w, s)
|
|
return
|
|
}
|
|
if v := h.Path(); v != "" {
|
|
b.serveFileOrDirectory(w, r, v, mountPoint)
|
|
return
|
|
}
|
|
if v := h.Proxy(); v != "" {
|
|
p, ok := b.serveProxyHandlers.Load(v)
|
|
if !ok {
|
|
http.Error(w, "unknown proxy destination", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
h := p.(http.Handler)
|
|
// Trim the mount point from the URL path before proxying. (#6571)
|
|
if r.URL.Path != "/" {
|
|
h = http.StripPrefix(strings.TrimSuffix(mountPoint, "/"), h)
|
|
}
|
|
h.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
http.Error(w, "empty handler", 500)
|
|
}
|
|
|
|
func (b *LocalBackend) serveFileOrDirectory(w http.ResponseWriter, r *http.Request, fileOrDir, mountPoint string) {
|
|
fi, err := os.Stat(fileOrDir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
b.logf("error calling stat on %s: %v", fileOrDir, err)
|
|
http.Error(w, "an error occurred reading the file or directory", 500)
|
|
return
|
|
}
|
|
if fi.Mode().IsRegular() {
|
|
if mountPoint != r.URL.Path {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
f, err := os.Open(fileOrDir)
|
|
if err != nil {
|
|
b.logf("error opening %s: %v", fileOrDir, err)
|
|
http.Error(w, "an error occurred reading the file or directory", 500)
|
|
return
|
|
}
|
|
defer f.Close()
|
|
http.ServeContent(w, r, path.Base(mountPoint), fi.ModTime(), f)
|
|
return
|
|
}
|
|
if !fi.IsDir() {
|
|
http.Error(w, "not a file or directory", 500)
|
|
return
|
|
}
|
|
if len(r.URL.Path) < len(mountPoint) && r.URL.Path+"/" == mountPoint {
|
|
http.Redirect(w, r, mountPoint, http.StatusFound)
|
|
return
|
|
}
|
|
|
|
var fs http.Handler = http.FileServer(http.Dir(fileOrDir))
|
|
if mountPoint != "/" {
|
|
fs = http.StripPrefix(strings.TrimSuffix(mountPoint, "/"), fs)
|
|
}
|
|
fs.ServeHTTP(&fixLocationHeaderResponseWriter{
|
|
ResponseWriter: w,
|
|
mountPoint: mountPoint,
|
|
}, r)
|
|
}
|
|
|
|
// fixLocationHeaderResponseWriter is an http.ResponseWriter wrapper that, upon
|
|
// flushing HTTP headers, prefixes any Location header with the mount point.
|
|
type fixLocationHeaderResponseWriter struct {
|
|
http.ResponseWriter
|
|
mountPoint string
|
|
fixOnce sync.Once // guards call to fix
|
|
}
|
|
|
|
func (w *fixLocationHeaderResponseWriter) fix() {
|
|
h := w.ResponseWriter.Header()
|
|
if v := h.Get("Location"); v != "" {
|
|
h.Set("Location", w.mountPoint+v)
|
|
}
|
|
}
|
|
|
|
func (w *fixLocationHeaderResponseWriter) WriteHeader(code int) {
|
|
w.fixOnce.Do(w.fix)
|
|
w.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
func (w *fixLocationHeaderResponseWriter) Write(p []byte) (int, error) {
|
|
w.fixOnce.Do(w.fix)
|
|
return w.ResponseWriter.Write(p)
|
|
}
|
|
|
|
// expandProxyArg returns a URL from s, where s can be of form:
|
|
//
|
|
// * port number ("8080")
|
|
// * host:port ("localhost:8080")
|
|
// * full URL ("http://localhost:8080", in which case it's returned unchanged)
|
|
// * insecure TLS ("https+insecure://127.0.0.1:4430")
|
|
func expandProxyArg(s string) (targetURL string, insecureSkipVerify bool) {
|
|
if s == "" {
|
|
return "", false
|
|
}
|
|
if strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") {
|
|
return s, false
|
|
}
|
|
if rest, ok := strings.CutPrefix(s, "https+insecure://"); ok {
|
|
return "https://" + rest, true
|
|
}
|
|
if allNumeric(s) {
|
|
return "http://127.0.0.1:" + s, false
|
|
}
|
|
return "http://" + s, false
|
|
}
|
|
|
|
func allNumeric(s string) bool {
|
|
for i := range len(s) {
|
|
if s[i] < '0' || s[i] > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return s != ""
|
|
}
|
|
|
|
func (b *LocalBackend) webServerConfig(hostname string, port uint16) (c ipn.WebServerConfigView, ok bool) {
|
|
key := ipn.HostPort(fmt.Sprintf("%s:%v", hostname, port))
|
|
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
if !b.serveConfig.Valid() {
|
|
return c, false
|
|
}
|
|
return b.serveConfig.FindWeb(key)
|
|
}
|
|
|
|
func (b *LocalBackend) getTLSServeCertForPort(port uint16) func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
|
return func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
|
if hi == nil || hi.ServerName == "" {
|
|
return nil, errors.New("no SNI ServerName")
|
|
}
|
|
_, ok := b.webServerConfig(hi.ServerName, port)
|
|
if !ok {
|
|
return nil, errors.New("no webserver configured for name/port")
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
defer cancel()
|
|
pair, err := b.GetCertPEM(ctx, hi.ServerName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cert, err := tls.X509KeyPair(pair.CertPEM, pair.KeyPEM)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &cert, nil
|
|
}
|
|
}
|