ipn/ipnlocal: add PROXY protocol support to Funnel/Serve

This adds the --proxy-protocol flag to 'tailscale serve' and
'tailscale funnel', which tells the Tailscale client to prepend a PROXY
protocol[1] header when making connections to the proxied-to backend.

I've verified that this works with our existing funnel servers without
additional work, since they pass along source address information via
PeerAPI already.

Updates #7747

[1]: https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt

Change-Id: I647c24d319375c1b33e995555a541b7615d2d203
Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
pull/17920/head
Andrew Dunham 1 month ago
parent 653d0738f9
commit 3a41c0c585

@ -85,6 +85,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
💣 github.com/modern-go/reflect2 from github.com/json-iterator/go
github.com/munnerz/goautoneg from k8s.io/kube-openapi/pkg/handler3+
github.com/opencontainers/go-digest from github.com/distribution/reference
github.com/pires/go-proxyproto from tailscale.com/ipn/ipnlocal
github.com/pkg/errors from github.com/evanphx/json-patch/v5+
D github.com/prometheus-community/pro-bing from tailscale.com/wgengine/netstack
github.com/prometheus/client_golang/internal/github.com/golang/gddo/httputil from github.com/prometheus/client_golang/prometheus/promhttp

@ -168,6 +168,7 @@ type serveEnv struct {
http uint // HTTP port
tcp uint // TCP port
tlsTerminatedTCP uint // a TLS terminated TCP port
proxyProtocol uint // PROXY protocol version (1 or 2)
subcmd serveMode // subcommand
yes bool // update without prompt
service tailcfg.ServiceName // service name
@ -570,7 +571,7 @@ func (e *serveEnv) handleTCPServe(ctx context.Context, srcType string, srcPort u
return fmt.Errorf("cannot serve TCP; already serving web on %d", srcPort)
}
sc.SetTCPForwarding(srcPort, fwdAddr, terminateTLS, dnsName)
sc.SetTCPForwarding(srcPort, fwdAddr, terminateTLS, 0 /* proxy proto */, dnsName)
if !reflect.DeepEqual(cursc, sc) {
if err := e.lc.SetServeConfig(ctx, sc); err != nil {

@ -240,6 +240,7 @@ func newServeV2Command(e *serveEnv, subcmd serveMode) *ffcli.Command {
}
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.proxyProtocol, "proxy-protocol", 0, "PROXY protocol version (1 or 2) for TCP forwarding")
fs.BoolVar(&e.yes, "yes", false, "Update without interactive prompts (default false)")
fs.BoolVar(&e.tun, "tun", false, "Forward all traffic to the local machine (default false), only supported for services. Refer to docs for more information.")
}),
@ -413,6 +414,14 @@ func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc {
return errHelpFunc(subcmd)
}
if (srvType == serveTypeHTTP || srvType == serveTypeHTTPS) && e.proxyProtocol != 0 {
return fmt.Errorf("PROXY protocol is only supported for TCP forwarding, not HTTP/HTTPS")
}
// Validate PROXY protocol version
if e.proxyProtocol != 0 && e.proxyProtocol != 1 && e.proxyProtocol != 2 {
return fmt.Errorf("invalid PROXY protocol version %d; must be 1 or 2", e.proxyProtocol)
}
sc, err := e.lc.GetServeConfig(ctx)
if err != nil {
return fmt.Errorf("error getting serve config: %w", err)
@ -507,7 +516,7 @@ func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc {
if len(args) > 0 {
target = args[0]
}
err = e.setServe(sc, dnsName, srvType, srvPort, mount, target, funnel, magicDNSSuffix, e.acceptAppCaps)
err = e.setServe(sc, dnsName, srvType, srvPort, mount, target, funnel, magicDNSSuffix, e.acceptAppCaps, int(e.proxyProtocol))
msg = e.messageForPort(sc, st, dnsName, srvType, srvPort)
}
if err != nil {
@ -828,7 +837,7 @@ func (e *serveEnv) runServeSetConfig(ctx context.Context, args []string) (err er
for name, details := range scf.Services {
for ppr, ep := range details.Endpoints {
if ep.Protocol == conffile.ProtoTUN {
err := e.setServe(sc, name.String(), serveTypeTUN, 0, "", "", false, magicDNSSuffix, nil)
err := e.setServe(sc, name.String(), serveTypeTUN, 0, "", "", false, magicDNSSuffix, nil, 0 /* proxy protocol */)
if err != nil {
return err
}
@ -850,7 +859,7 @@ func (e *serveEnv) runServeSetConfig(ctx context.Context, args []string) (err er
portStr := fmt.Sprint(destPort)
target = fmt.Sprintf("%s://%s", ep.Protocol, net.JoinHostPort(ep.Destination, portStr))
}
err := e.setServe(sc, name.String(), serveType, port, "/", target, false, magicDNSSuffix, nil)
err := e.setServe(sc, name.String(), serveType, port, "/", target, false, magicDNSSuffix, nil, 0 /* proxy protocol */)
if err != nil {
return fmt.Errorf("service %q: %w", name, err)
}
@ -953,7 +962,7 @@ func serveFromPortHandler(tcp *ipn.TCPPortHandler) serveType {
}
}
func (e *serveEnv) setServe(sc *ipn.ServeConfig, dnsName string, srvType serveType, srvPort uint16, mount string, target string, allowFunnel bool, mds string, caps []tailcfg.PeerCapability) error {
func (e *serveEnv) setServe(sc *ipn.ServeConfig, dnsName string, srvType serveType, srvPort uint16, mount string, target string, allowFunnel bool, mds string, caps []tailcfg.PeerCapability, proxyProtocol int) error {
// update serve config based on the type
switch srvType {
case serveTypeHTTPS, serveTypeHTTP:
@ -966,7 +975,7 @@ func (e *serveEnv) setServe(sc *ipn.ServeConfig, dnsName string, srvType serveTy
if e.setPath != "" {
return fmt.Errorf("cannot mount a path for TCP serve")
}
err := e.applyTCPServe(sc, dnsName, srvType, srvPort, target)
err := e.applyTCPServe(sc, dnsName, srvType, srvPort, target, proxyProtocol)
if err != nil {
return fmt.Errorf("failed to apply TCP serve: %w", err)
}
@ -1092,6 +1101,9 @@ func (e *serveEnv) messageForPort(sc *ipn.ServeConfig, st *ipnstate.Status, dnsN
if tcpHandler.TerminateTLS != "" {
tlsStatus = "TLS terminated"
}
if ver := tcpHandler.ProxyProtocol; ver != 0 {
tlsStatus = fmt.Sprintf("%s, PROXY protocol v%d", tlsStatus, ver)
}
output.WriteString(fmt.Sprintf("|-- tcp://%s:%d (%s)\n", host, srvPort, tlsStatus))
for _, a := range ips {
@ -1170,7 +1182,7 @@ func (e *serveEnv) applyWebServe(sc *ipn.ServeConfig, dnsName string, srvPort ui
return nil
}
func (e *serveEnv) applyTCPServe(sc *ipn.ServeConfig, dnsName string, srcType serveType, srcPort uint16, target string) error {
func (e *serveEnv) applyTCPServe(sc *ipn.ServeConfig, dnsName string, srcType serveType, srcPort uint16, target string, proxyProtocol int) error {
var terminateTLS bool
switch srcType {
case serveTypeTCP:
@ -1197,8 +1209,7 @@ func (e *serveEnv) applyTCPServe(sc *ipn.ServeConfig, dnsName string, srcType se
return fmt.Errorf("cannot serve TCP; already serving web on %d for %s", srcPort, dnsName)
}
sc.SetTCPForwarding(srcPort, dstURL.Host, terminateTLS, dnsName)
sc.SetTCPForwarding(srcPort, dstURL.Host, terminateTLS, proxyProtocol, dnsName)
return nil
}

@ -919,6 +919,73 @@ func TestServeDevConfigMutations(t *testing.T) {
},
},
},
{
name: "tcp_with_proxy_protocol_v1",
steps: []step{{
command: cmd("serve --tcp=8000 --proxy-protocol=1 --bg tcp://localhost:5432"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
8000: {
TCPForward: "localhost:5432",
ProxyProtocol: 1,
},
},
},
}},
},
{
name: "tls_terminated_tcp_with_proxy_protocol_v2",
steps: []step{{
command: cmd("serve --tls-terminated-tcp=443 --proxy-protocol=2 --bg tcp://localhost:5432"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {
TCPForward: "localhost:5432",
TerminateTLS: "foo.test.ts.net",
ProxyProtocol: 2,
},
},
},
}},
},
{
name: "tcp_update_to_add_proxy_protocol",
steps: []step{
{
command: cmd("serve --tcp=8000 --bg tcp://localhost:5432"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
8000: {TCPForward: "localhost:5432"},
},
},
},
{
command: cmd("serve --tcp=8000 --proxy-protocol=1 --bg tcp://localhost:5432"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
8000: {
TCPForward: "localhost:5432",
ProxyProtocol: 1,
},
},
},
},
},
},
{
name: "tcp_proxy_protocol_invalid_version",
steps: []step{{
command: cmd("serve --tcp=8000 --proxy-protocol=3 --bg tcp://localhost:5432"),
wantErr: anyErr(),
}},
},
{
name: "proxy_protocol_without_tcp",
steps: []step{{
command: cmd("serve --https=443 --proxy-protocol=1 --bg http://localhost:3000"),
wantErr: anyErr(),
}},
},
}
for _, group := range groups {
@ -1889,18 +1956,19 @@ func TestSetServe(t *testing.T) {
e := &serveEnv{}
magicDNSSuffix := "test.ts.net"
tests := []struct {
name string
desc string
cfg *ipn.ServeConfig
st *ipnstate.Status
dnsName string
srvType serveType
srvPort uint16
mountPath string
target string
allowFunnel bool
expected *ipn.ServeConfig
expectErr bool
name string
desc string
cfg *ipn.ServeConfig
st *ipnstate.Status
dnsName string
srvType serveType
srvPort uint16
mountPath string
target string
allowFunnel bool
proxyProtocol int
expected *ipn.ServeConfig
expectErr bool
}{
{
name: "add new handler",
@ -2183,7 +2251,7 @@ func TestSetServe(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := e.setServe(tt.cfg, tt.dnsName, tt.srvType, tt.srvPort, tt.mountPath, tt.target, tt.allowFunnel, magicDNSSuffix, nil)
err := e.setServe(tt.cfg, tt.dnsName, tt.srvType, tt.srvPort, tt.mountPath, tt.target, tt.allowFunnel, magicDNSSuffix, nil, tt.proxyProtocol)
if err != nil && !tt.expectErr {
t.Fatalf("got error: %v; did not expect error.", err)
}

@ -156,6 +156,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
L github.com/pierrec/lz4/v4/internal/lz4errors from github.com/pierrec/lz4/v4+
L github.com/pierrec/lz4/v4/internal/lz4stream from github.com/pierrec/lz4/v4
L github.com/pierrec/lz4/v4/internal/xxh32 from github.com/pierrec/lz4/v4/internal/lz4stream
github.com/pires/go-proxyproto from tailscale.com/ipn/ipnlocal
LD github.com/pkg/sftp from tailscale.com/ssh/tailssh
LD github.com/pkg/sftp/internal/encoding/ssh/filexfer from github.com/pkg/sftp
D github.com/prometheus-community/pro-bing from tailscale.com/wgengine/netstack

@ -43,6 +43,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
L 💣 github.com/mdlayher/netlink/nlenc from github.com/jsimonetti/rtnetlink+
L 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink+
💣 github.com/mitchellh/go-ps from tailscale.com/safesocket
github.com/pires/go-proxyproto from tailscale.com/ipn/ipnlocal
D github.com/prometheus-community/pro-bing from tailscale.com/wgengine/netstack
L 💣 github.com/safchain/ethtool from tailscale.com/net/netkernelconf
W 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient

@ -151,5 +151,5 @@
});
};
}
# nix-direnv cache busting line: sha256-p8dVVZm2bLL4J/d4TtnUOp3bfMqUkBGE+53RUhamF+A=
# nix-direnv cache busting line: sha256-ADuIW4GAiskY0nRFWFk50rzihRIXIdm1Wk7IxfYKMPg=

@ -70,6 +70,7 @@ require (
github.com/miekg/dns v1.1.58
github.com/mitchellh/go-ps v1.0.0
github.com/peterbourgon/ff/v3 v3.4.0
github.com/pires/go-proxyproto v0.8.1
github.com/pkg/errors v0.9.1
github.com/pkg/sftp v1.13.6
github.com/prometheus-community/pro-bing v0.4.0

@ -1 +1 @@
sha256-p8dVVZm2bLL4J/d4TtnUOp3bfMqUkBGE+53RUhamF+A=
sha256-ADuIW4GAiskY0nRFWFk50rzihRIXIdm1Wk7IxfYKMPg=

@ -809,6 +809,8 @@ github.com/peterbourgon/ff/v3 v3.4.0 h1:QBvM/rizZM1cB0p0lGMdmR7HxZeI/ZrBWB4DqLkM
github.com/peterbourgon/ff/v3 v3.4.0/go.mod h1:zjJVUhx+twciwfDl0zBcFzl4dW8axCRyXE/eKY9RztQ=
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0=
github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
github.com/pkg/diff v0.0.0-20200914180035-5b29258ca4f7/go.mod h1:zO8QMzTeZd5cpnIkz/Gn6iK0jDfGicM1nynOkkPIl28=

@ -218,10 +218,11 @@ func (src *TCPPortHandler) Clone() *TCPPortHandler {
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _TCPPortHandlerCloneNeedsRegeneration = TCPPortHandler(struct {
HTTPS bool
HTTP bool
TCPForward string
TerminateTLS string
HTTPS bool
HTTP bool
TCPForward string
TerminateTLS string
ProxyProtocol int
}{})
// Clone makes a deep copy of HTTPHandler.

@ -807,12 +807,19 @@ func (v TCPPortHandlerView) TCPForward() string { return v.ж.TCPForward }
// (the HTTPS mode uses ServeConfig.Web)
func (v TCPPortHandlerView) TerminateTLS() string { return v.ж.TerminateTLS }
// ProxyProtocol indicates whether to send a PROXY protocol header
// before forwarding the connection to TCPForward.
//
// This is only valid if TCPForward is non-empty.
func (v TCPPortHandlerView) ProxyProtocol() int { return v.ж.ProxyProtocol }
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _TCPPortHandlerViewNeedsRegeneration = TCPPortHandler(struct {
HTTPS bool
HTTP bool
TCPForward string
TerminateTLS string
HTTPS bool
HTTP bool
TCPForward string
TerminateTLS string
ProxyProtocol int
}{})
// View returns a read-only view of HTTPHandler.

@ -33,6 +33,7 @@ import (
"time"
"unicode/utf8"
"github.com/pires/go-proxyproto"
"go4.org/mem"
"tailscale.com/ipn"
"tailscale.com/net/netutil"
@ -671,10 +672,81 @@ func (b *LocalBackend) tcpHandlerForServe(dport uint16, srcAddr netip.AddrPort,
})
}
var proxyHeader []byte
if ver := tcph.ProxyProtocol(); ver > 0 {
// backAddr is the final "destination" of the connection,
// which is the connection to the proxied-to backend.
backAddr := backConn.RemoteAddr().(*net.TCPAddr)
// We always want to format the PROXY protocol
// header based on the IPv4 or IPv6-ness of
// the client. The SourceAddr and
// DestinationAddr need to match in type, so we
// need to be careful to not e.g. set a
// SourceAddr of type IPv6 and DestinationAddr
// of type IPv4.
//
// If this is an IPv6-mapped IPv4 address,
// though, unmap it.
proxySrcAddr := srcAddr
if proxySrcAddr.Addr().Is4In6() {
proxySrcAddr = netip.AddrPortFrom(
proxySrcAddr.Addr().Unmap(),
proxySrcAddr.Port(),
)
}
is4 := proxySrcAddr.Addr().Is4()
var destAddr netip.Addr
if self := b.currentNode().Self(); self.Valid() {
if is4 {
destAddr = nodeIP(self, netip.Addr.Is4)
} else {
destAddr = nodeIP(self, netip.Addr.Is6)
}
}
if !destAddr.IsValid() {
// Pick a best-effort destination address of localhost.
if is4 {
destAddr = netip.AddrFrom4([4]byte{127, 0, 0, 1})
} else {
destAddr = netip.IPv6Loopback()
}
}
header := &proxyproto.Header{
Version: byte(ver),
Command: proxyproto.PROXY,
SourceAddr: net.TCPAddrFromAddrPort(proxySrcAddr),
DestinationAddr: &net.TCPAddr{
IP: destAddr.AsSlice(),
Port: backAddr.Port,
},
}
if is4 {
header.TransportProtocol = proxyproto.TCPv4
} else {
header.TransportProtocol = proxyproto.TCPv6
}
var err error
proxyHeader, err = header.Format()
if err != nil {
b.logf("localbackend: failed to format proxy protocol header for port %v (from %v) to %s: %v", dport, srcAddr, backDst, err)
}
}
// TODO(bradfitz): do the RegisterIPPortIdentity and
// UnregisterIPPortIdentity stuff that netstack does
errc := make(chan error, 1)
go func() {
if len(proxyHeader) > 0 {
if _, err := backConn.Write(proxyHeader); err != nil {
errc <- err
backConn.Close() // to ensure that the other side gets EOF
return
}
}
_, err := io.Copy(backConn, conn)
errc <- err
}()

@ -149,6 +149,12 @@ type TCPPortHandler struct {
// SNI name with this value. It is only used if TCPForward is non-empty.
// (the HTTPS mode uses ServeConfig.Web)
TerminateTLS string `json:",omitempty"`
// ProxyProtocol indicates whether to send a PROXY protocol header
// before forwarding the connection to TCPForward.
//
// This is only valid if TCPForward is non-empty.
ProxyProtocol int `json:",omitzero"`
}
// HTTPHandler is either a path or a proxy to serve.
@ -404,7 +410,10 @@ func (sc *ServeConfig) SetWebHandler(handler *HTTPHandler, host string, port uin
// connections from the given port. If terminateTLS is true, TLS connections
// are terminated with only the given host name permitted before passing them
// to the fwdAddr.
func (sc *ServeConfig) SetTCPForwarding(port uint16, fwdAddr string, terminateTLS bool, host string) {
//
// If proxyProtocol is non-zero, the corresponding PROXY protocol version
// header is sent before forwarding the connection.
func (sc *ServeConfig) SetTCPForwarding(port uint16, fwdAddr string, terminateTLS bool, proxyProtocol int, host string) {
if sc == nil {
sc = new(ServeConfig)
}
@ -417,11 +426,15 @@ func (sc *ServeConfig) SetTCPForwarding(port uint16, fwdAddr string, terminateTL
}
tcpPortHandler = &svcConfig.TCP
}
mak.Set(tcpPortHandler, port, &TCPPortHandler{TCPForward: fwdAddr})
handler := &TCPPortHandler{
TCPForward: fwdAddr,
ProxyProtocol: proxyProtocol, // can be 0
}
if terminateTLS {
(*tcpPortHandler)[port].TerminateTLS = host
handler.TerminateTLS = host
}
mak.Set(tcpPortHandler, port, handler)
}
// SetFunnel sets the sc.AllowFunnel value for the given host and port.

@ -16,4 +16,4 @@
) {
src = ./.;
}).shellNix
# nix-direnv cache busting line: sha256-p8dVVZm2bLL4J/d4TtnUOp3bfMqUkBGE+53RUhamF+A=
# nix-direnv cache busting line: sha256-ADuIW4GAiskY0nRFWFk50rzihRIXIdm1Wk7IxfYKMPg=

@ -43,6 +43,7 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware)
L 💣 github.com/mdlayher/netlink/nlenc from github.com/jsimonetti/rtnetlink+
LA 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink+
LDW 💣 github.com/mitchellh/go-ps from tailscale.com/safesocket
github.com/pires/go-proxyproto from tailscale.com/ipn/ipnlocal
DI github.com/prometheus-community/pro-bing from tailscale.com/wgengine/netstack
L 💣 github.com/safchain/ethtool from tailscale.com/net/netkernelconf
W 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient

Loading…
Cancel
Save