From d88e2b2cda987fac0badc0e36abba766291fac88 Mon Sep 17 00:00:00 2001 From: Harry Harpham Date: Mon, 17 Nov 2025 16:20:46 -0700 Subject: [PATCH] ipn: allow serve to forward TCP connections to Unix sockets Incidentally, this also allows serve to forward TCP connections to UDP listeners, though this is still prohibited by the CLI. Updates tailscale/corp#27200 Signed-off-by: Harry Harpham --- cmd/tailscale/cli/serve_v2.go | 6 +- cmd/tailscale/cli/serve_v2_test.go | 127 +++++++++++++++++++++++++---- ipn/ipnlocal/serve.go | 22 ++++- ipn/serve.go | 4 + 4 files changed, 137 insertions(+), 22 deletions(-) diff --git a/cmd/tailscale/cli/serve_v2.go b/cmd/tailscale/cli/serve_v2.go index 89d247be9..e6d7b7153 100644 --- a/cmd/tailscale/cli/serve_v2.go +++ b/cmd/tailscale/cli/serve_v2.go @@ -1048,7 +1048,7 @@ func (e *serveEnv) messageForPort(sc *ipn.ServeConfig, st *ipnstate.Status, dnsN ipp := net.JoinHostPort(a.String(), strconv.Itoa(int(srvPort))) output.WriteString(fmt.Sprintf("|-- tcp://%s\n", ipp)) } - output.WriteString(fmt.Sprintf("|--> tcp://%s\n\n", tcpHandler.TCPForward)) + output.WriteString(fmt.Sprintf("|--> %s\n\n", tcpHandler.TCPForward)) } if !forService && !e.bg.Value { @@ -1204,7 +1204,7 @@ func (e *serveEnv) applyTCPServe(sc *ipn.ServeConfig, dnsName string, srcType se svcName := tailcfg.AsServiceName(dnsName) - targetURL, err := ipn.ExpandProxyTargetValue(target, []string{"tcp"}, "tcp") + targetURL, err := ipn.ExpandProxyTargetValue(target, []string{"tcp", "unix"}, "tcp") if err != nil { return fmt.Errorf("unable to expand target: %v", err) } @@ -1219,7 +1219,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, proxyProtocol, dnsName) + sc.SetTCPForwarding(srcPort, dstURL.String(), terminateTLS, proxyProtocol, dnsName) return nil } diff --git a/cmd/tailscale/cli/serve_v2_test.go b/cmd/tailscale/cli/serve_v2_test.go index b3ebb32a2..57fd8d424 100644 --- a/cmd/tailscale/cli/serve_v2_test.go +++ b/cmd/tailscale/cli/serve_v2_test.go @@ -449,7 +449,7 @@ func TestServeDevConfigMutations(t *testing.T) { want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{ 443: { - TCPForward: "127.0.0.1:5432", + TCPForward: "tcp://127.0.0.1:5432", TerminateTLS: "foo.test.ts.net", }, }, @@ -464,7 +464,7 @@ func TestServeDevConfigMutations(t *testing.T) { want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{ 443: { - TCPForward: "localhost:5432", + TCPForward: "tcp://localhost:5432", TerminateTLS: "foo.test.ts.net", }, }, @@ -475,7 +475,7 @@ func TestServeDevConfigMutations(t *testing.T) { want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{ 443: { - TCPForward: "127.0.0.1:8443", + TCPForward: "tcp://127.0.0.1:8443", TerminateTLS: "foo.test.ts.net", }, }, @@ -491,7 +491,7 @@ func TestServeDevConfigMutations(t *testing.T) { want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{ 443: { - TCPForward: "localhost:123", + TCPForward: "tcp://localhost:123", TerminateTLS: "foo.test.ts.net", }, }, @@ -549,6 +549,35 @@ func TestServeDevConfigMutations(t *testing.T) { }, }, }, + { + name: "unix", + steps: []step{ + { + command: cmd("serve --bg --tcp=80 unix://my-socket.sock"), + want: &ipn.ServeConfig{ + TCP: map[uint16]*ipn.TCPPortHandler{ + 80: {TCPForward: "unix://my-socket.sock"}, + }, + }, + }, + }, + }, + { + name: "unix over TLS", + steps: []step{ + { + command: cmd("serve --bg --tls-terminated-tcp=443 unix://my-socket.sock"), + want: &ipn.ServeConfig{ + TCP: map[uint16]*ipn.TCPPortHandler{ + 443: { + TCPForward: "unix://my-socket.sock", + TerminateTLS: "foo.test.ts.net", + }, + }, + }, + }, + }, + }, { name: "bad_path", initialState: fakeLocalServeClient{ @@ -664,7 +693,9 @@ func TestServeDevConfigMutations(t *testing.T) { { // start a tcp forwarder on 8443 command: cmd("serve --bg --tcp=8443 tcp://localhost:5432"), want: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {TCPForward: "localhost:5432"}}, + TCP: map[uint16]*ipn.TCPPortHandler{ + 443: {HTTPS: true}, + 8443: {TCPForward: "tcp://localhost:5432"}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ "/": {Proxy: "http://localhost:3000"}, @@ -675,7 +706,7 @@ func TestServeDevConfigMutations(t *testing.T) { { // remove primary port http handler command: cmd("serve off"), want: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{8443: {TCPForward: "localhost:5432"}}, + TCP: map[uint16]*ipn.TCPPortHandler{8443: {TCPForward: "tcp://localhost:5432"}}, }, }, { // remove tcp forwarder @@ -745,7 +776,7 @@ func TestServeDevConfigMutations(t *testing.T) { want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{ 443: { - TCPForward: "localhost:5432", + TCPForward: "tcp://localhost:5432", TerminateTLS: "foo.test.ts.net", }, }, @@ -929,7 +960,7 @@ func TestServeDevConfigMutations(t *testing.T) { want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{ 8000: { - TCPForward: "localhost:5432", + TCPForward: "tcp://localhost:5432", ProxyProtocol: 1, }, }, @@ -943,7 +974,7 @@ func TestServeDevConfigMutations(t *testing.T) { want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{ 443: { - TCPForward: "localhost:5432", + TCPForward: "tcp://localhost:5432", TerminateTLS: "foo.test.ts.net", ProxyProtocol: 2, }, @@ -958,7 +989,7 @@ func TestServeDevConfigMutations(t *testing.T) { command: cmd("serve --tcp=8000 --bg tcp://localhost:5432"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{ - 8000: {TCPForward: "localhost:5432"}, + 8000: {TCPForward: "tcp://localhost:5432"}, }, }, }, @@ -967,7 +998,7 @@ func TestServeDevConfigMutations(t *testing.T) { want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{ 8000: { - TCPForward: "localhost:5432", + TCPForward: "tcp://localhost:5432", ProxyProtocol: 1, }, }, @@ -1445,6 +1476,30 @@ func TestMessageForPort(t *testing.T) { fmt.Sprintf(msgDisableProxy, "serve", "http", 80), }, "\n"), }, + { + name: "serve unix", + subcmd: serve, + serveConfig: &ipn.ServeConfig{ + TCP: map[uint16]*ipn.TCPPortHandler{ + 80: { + TCPForward: "unix://my-socket.sock", + }, + }, + }, + status: &ipnstate.Status{CurrentTailnet: &ipnstate.TailnetStatus{MagicDNSSuffix: "test.ts.net"}}, + dnsName: "foo.test.ts.net", + srvType: serveTypeTCP, + srvPort: 80, + expected: strings.Join([]string{ + msgServeAvailable, + "", + "|-- tcp://foo.test.ts.net:80 (TLS over TCP)", + "|--> unix://my-socket.sock", + "", + fmt.Sprintf(msgRunningInBackground, "Serve"), + fmt.Sprintf(msgDisableProxy, "serve", "tcp", 80), + }, "\n"), + }, { name: "serve service http", subcmd: serve, @@ -1582,7 +1637,7 @@ func TestMessageForPort(t *testing.T) { Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{ "svc:foo": { TCP: map[uint16]*ipn.TCPPortHandler{ - 2200: {TCPForward: "localhost:3000"}, + 2200: {TCPForward: "tcp://localhost:3000"}, }, }, }, @@ -1612,6 +1667,43 @@ func TestMessageForPort(t *testing.T) { fmt.Sprintf(msgDisableService, "svc:foo"), }, "\n"), }, + { + name: "serve service unix", + subcmd: serve, + serveConfig: &ipn.ServeConfig{ + Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{ + "svc:foo": { + TCP: map[uint16]*ipn.TCPPortHandler{ + 2200: {TCPForward: "unix://my-socket.sock"}, + }, + }, + }, + }, + status: &ipnstate.Status{ + CurrentTailnet: &ipnstate.TailnetStatus{MagicDNSSuffix: "test.ts.net"}, + Self: &ipnstate.PeerStatus{ + CapMap: tailcfg.NodeCapMap{ + tailcfg.NodeAttrServiceHost: []tailcfg.RawMessage{svcIPMapJSONRawMSG}, + }, + }, + }, + prefs: &ipn.Prefs{AdvertiseServices: []string{"svc:foo"}}, + dnsName: "svc:foo", + srvType: serveTypeTCP, + srvPort: 2200, + expected: strings.Join([]string{ + msgServeAvailable, + "", + "|-- tcp://foo.test.ts.net:2200 (TLS over TCP)", + "|-- tcp://100.101.101.101:2200", + "|-- tcp://[fd7a:115c:a1e0:ab12:4843:cd96:6565:6565]:2200", + "|--> unix://my-socket.sock", + "", + fmt.Sprintf(msgRunningInBackground, "Serve"), + fmt.Sprintf(msgDisableServiceProxy, "svc:foo", "tcp", 2200), + fmt.Sprintf(msgDisableService, "svc:foo"), + }, "\n"), + }, { name: "serve service Tun", subcmd: serve, @@ -1654,7 +1746,7 @@ func TestMessageForPort(t *testing.T) { } if actual != tt.expected { - t.Errorf("\nGot: %q\nExpected: %q", actual, tt.expected) + t.Errorf("\nGot:\n%s\n\nExpected:\n%s", actual, tt.expected) } }) } @@ -1863,7 +1955,7 @@ func TestSetServe(t *testing.T) { expected: &ipn.ServeConfig{ Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{ "svc:bar": { - TCP: map[uint16]*ipn.TCPPortHandler{80: {TCPForward: "127.0.0.1:3000"}}, + TCP: map[uint16]*ipn.TCPPortHandler{80: {TCPForward: "tcp://127.0.0.1:3000"}}, }, }, }, @@ -1885,7 +1977,7 @@ func TestSetServe(t *testing.T) { expected: &ipn.ServeConfig{ Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{ "svc:bar": { - TCP: map[uint16]*ipn.TCPPortHandler{80: {TCPForward: "127.0.0.1:3001"}}, + TCP: map[uint16]*ipn.TCPPortHandler{80: {TCPForward: "tcp://127.0.0.1:3001"}}, }, }, }, @@ -2078,8 +2170,9 @@ func TestSetServe(t *testing.T) { t.Fatalf("got no error; expected error.") } if !tt.expectErr && !reflect.DeepEqual(tt.cfg, tt.expected) { - svcName := tailcfg.ServiceName(tt.dnsName) - t.Fatalf("got: %v; expected: %v", tt.cfg.Services[svcName], tt.expected.Services[svcName]) + gotbts, _ := json.MarshalIndent(tt.cfg, "", "\t") + wantbts, _ := json.MarshalIndent(tt.expected, "", "\t") + t.Fatalf("diff:\n%s", cmp.Diff(string(gotbts), string(wantbts))) } }) } diff --git a/ipn/ipnlocal/serve.go b/ipn/ipnlocal/serve.go index ef4e91545..fd6507887 100644 --- a/ipn/ipnlocal/serve.go +++ b/ipn/ipnlocal/serve.go @@ -561,8 +561,17 @@ func (b *LocalBackend) tcpHandlerForVIPService(dstAddr, srcAddr netip.AddrPort) if backDst := tcph.TCPForward(); backDst != "" { return func(conn net.Conn) error { defer conn.Close() + + // Support optional schemes like 'unix://socket-file'. + // For backwards compatibility with existing serve config, this + // needs to support schemeless destinations and assume TCP. + backNet, backAddr := "tcp", backDst + if i := strings.Index(backDst, "://"); i >= 0 { + backNet, backAddr = backDst[:i], backDst[i+len("://"):] + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - backConn, err := b.dialer.SystemDial(ctx, "tcp", backDst) + backConn, err := b.dialer.SystemDial(ctx, backNet, backAddr) cancel() if err != nil { b.logf("localbackend: failed to TCP proxy port %v (from %v) to %s: %v", dport, srcAddr, backDst, err) @@ -648,8 +657,17 @@ func (b *LocalBackend) tcpHandlerForServe(dport uint16, srcAddr netip.AddrPort, if backDst := tcph.TCPForward(); backDst != "" { return func(conn net.Conn) error { defer conn.Close() + + // Support optional schemes like 'unix://socket-file'. + // For backwards compatibility with existing serve config, this + // needs to support schemeless destinations and assume TCP. + backNet, backAddr := "tcp", backDst + if i := strings.Index(backDst, "://"); i >= 0 { + backNet, backAddr = backDst[:i], backDst[i+len("://"):] + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - backConn, err := b.dialer.SystemDial(ctx, "tcp", backDst) + backConn, err := b.dialer.SystemDial(ctx, backNet, backAddr) cancel() if err != nil { b.logf("localbackend: failed to TCP proxy port %v (from %v) to %s: %v", dport, srcAddr, backDst, err) diff --git a/ipn/serve.go b/ipn/serve.go index 1f1557889..7477c3ba2 100644 --- a/ipn/serve.go +++ b/ipn/serve.go @@ -731,6 +731,10 @@ func ExpandProxyTargetValue(target string, supportedSchemes []string, defaultSch return "", fmt.Errorf("must be a URL starting with one of the supported schemes: %v", supportedSchemes) } + if u.Scheme == "unix" { + return u.String(), nil + } + // validate port according to host. if u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1" || u.Hostname() == "::1" { // require port for localhost targets