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>
This commit is contained in:
Andrew Dunham
2025-10-20 11:40:30 -04:00
parent 653d0738f9
commit 3a41c0c585
16 changed files with 217 additions and 37 deletions
+1
View File
@@ -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
+2 -1
View File
@@ -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 {
+19 -8
View File
@@ -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
}
+81 -13
View File
@@ -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)
}
+1
View File
@@ -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
+1
View File
@@ -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