From 50f1c285bae11966697dcc0f682feb2527b099a0 Mon Sep 17 00:00:00 2001 From: ayanamist Date: Sun, 10 May 2026 21:13:11 +0800 Subject: [PATCH] ipn/ipnlocal,cmd/tailscale/cli: support unix socket targets for TCP serve Allow `tailscale serve --tcp unix:/path/to/socket` and `tailscale serve --tls-terminated-tcp unix:/path/to/socket` to forward TCP connections to a Unix domain socket. Previously only host:port targets were supported for TCP serve mode. Updates #20161 Signed-off-by: ayanamist --- cmd/tailscale/cli/serve_legacy.go | 6 +- cmd/tailscale/cli/serve_v2.go | 120 +++++++++++++++++++--------- cmd/tailscale/cli/serve_v2_test.go | 124 +++++++++++++++++++++++++++++ ipn/conffile/serveconf.go | 48 ++++++++--- ipn/conffile/serveconf_test.go | 108 +++++++++++++++++++++++++ ipn/ipn_view.go | 5 +- ipn/ipnlocal/serve.go | 17 +++- ipn/ipnlocal/serve_unix_test.go | 82 +++++++++++++++++++ ipn/serve.go | 22 ++--- ipn/serve_expand_test.go | 23 ++++++ 10 files changed, 494 insertions(+), 61 deletions(-) create mode 100644 ipn/conffile/serveconf_test.go diff --git a/cmd/tailscale/cli/serve_legacy.go b/cmd/tailscale/cli/serve_legacy.go index a8a248e1e..b8d9e2766 100644 --- a/cmd/tailscale/cli/serve_legacy.go +++ b/cmd/tailscale/cli/serve_legacy.go @@ -663,7 +663,11 @@ func printTCPStatusTree(ctx context.Context, sc *ipn.ServeConfig, st *ipnstate.S ipp := net.JoinHostPort(a.String(), strconv.Itoa(int(p))) printf("|-- tcp://%s\n", ipp) } - printf("|--> tcp://%s\n", h.TCPForward) + if strings.HasPrefix(h.TCPForward, "unix:") { + printf("|--> %s\n", h.TCPForward) + } else { + printf("|--> tcp://%s\n", h.TCPForward) + } } return nil } diff --git a/cmd/tailscale/cli/serve_v2.go b/cmd/tailscale/cli/serve_v2.go index fe7c5730f..6a41f7156 100644 --- a/cmd/tailscale/cli/serve_v2.go +++ b/cmd/tailscale/cli/serve_v2.go @@ -702,19 +702,26 @@ func (e *serveEnv) runServeGetConfig(ctx context.Context, args []string) (err er } else { proto = conffile.ProtoTCP } - destHost, destPortStr, err := net.SplitHostPort(config.TCPForward) - if err != nil { - return nil, fmt.Errorf("parse TCPForward=%q: %w", config.TCPForward, err) + if strings.HasPrefix(config.TCPForward, "unix:") { + mak.Set(&sdf.Endpoints, &ppr, &conffile.Target{ + Protocol: proto, + Destination: config.TCPForward, + }) + } else { + destHost, destPortStr, err := net.SplitHostPort(config.TCPForward) + if err != nil { + return nil, fmt.Errorf("parse TCPForward=%q: %w", config.TCPForward, err) + } + destPort, err := strconv.ParseUint(destPortStr, 10, 16) + if err != nil { + return nil, fmt.Errorf("parse port %q: %w", destPortStr, err) + } + mak.Set(&sdf.Endpoints, &ppr, &conffile.Target{ + Protocol: proto, + Destination: destHost, + DestinationPorts: tailcfg.PortRange{First: uint16(destPort), Last: uint16(destPort)}, + }) } - destPort, err := strconv.ParseUint(destPortStr, 10, 16) - if err != nil { - return nil, fmt.Errorf("parse port %q: %w", destPortStr, err) - } - mak.Set(&sdf.Endpoints, &ppr, &conffile.Target{ - Protocol: proto, - Destination: destHost, - DestinationPorts: tailcfg.PortRange{First: uint16(destPort), Last: uint16(destPort)}, - }) } else if config.HTTP || config.HTTPS { webKey := ipn.HostPort(net.JoinHostPort(sniName, strconv.FormatUint(uint64(port), 10))) handlers, ok := serviceConfig.Web[webKey] @@ -732,25 +739,38 @@ func (e *serveEnv) runServeGetConfig(ctx context.Context, args []string) (err er DestinationPorts: tailcfg.PortRange{}, }) } else if defaultHandler.Proxy != "" { - proto, rest, ok := strings.Cut(defaultHandler.Proxy, "://") - if !ok { - return nil, fmt.Errorf("service %q: invalid proxy handler %q", svcName, defaultHandler.Proxy) - } - host, portStr, err := net.SplitHostPort(rest) - if err != nil { - return nil, fmt.Errorf("service %q: invalid proxy handler %q: %w", svcName, defaultHandler.Proxy, err) - } + if strings.HasPrefix(defaultHandler.Proxy, "unix:") { + // HTTP over unix socket: h.Proxy is "unix:/path" without "://". + // The inbound protocol is HTTP(S); infer from useTLS. + httpProto := conffile.ProtoHTTP + if config.HTTPS { + httpProto = conffile.ProtoHTTPS + } + mak.Set(&sdf.Endpoints, &ppr, &conffile.Target{ + Protocol: httpProto, + Destination: defaultHandler.Proxy, + }) + } else { + proto, rest, ok := strings.Cut(defaultHandler.Proxy, "://") + if !ok { + return nil, fmt.Errorf("service %q: invalid proxy handler %q", svcName, defaultHandler.Proxy) + } + host, portStr, err := net.SplitHostPort(rest) + if err != nil { + return nil, fmt.Errorf("service %q: invalid proxy handler %q: %w", svcName, defaultHandler.Proxy, err) + } - port, err := strconv.ParseUint(portStr, 10, 16) - if err != nil { - return nil, fmt.Errorf("service %q: parse port %q: %w", svcName, portStr, err) - } + port, err := strconv.ParseUint(portStr, 10, 16) + if err != nil { + return nil, fmt.Errorf("service %q: parse port %q: %w", svcName, portStr, err) + } - mak.Set(&sdf.Endpoints, &ppr, &conffile.Target{ - Protocol: conffile.ServiceProtocol(proto), - Destination: host, - DestinationPorts: tailcfg.PortRange{First: uint16(port), Last: uint16(port)}, - }) + mak.Set(&sdf.Endpoints, &ppr, &conffile.Target{ + Protocol: conffile.ServiceProtocol(proto), + Destination: host, + DestinationPorts: tailcfg.PortRange{First: uint16(port), Last: uint16(port)}, + }) + } } } } @@ -917,6 +937,10 @@ func (e *serveEnv) runServeSetConfig(ctx context.Context, args []string) (err er var target string if ep.Protocol == conffile.ProtoFile { target = ep.Destination + } else if strings.HasPrefix(ep.Destination, "unix:") { + // Unix socket target: pass "unix:/path" through to setServe. + // Supported for HTTP(S), TCP, and TLS-terminated-TCP inbound. + target = ep.Destination } else { // map source port range 1-1 to destination port range destPort := ep.DestinationPorts.First + (port - ppr.Ports.First) @@ -1118,7 +1142,11 @@ 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)) + if strings.HasPrefix(tcpHandler.TCPForward, "unix:") { + output.WriteString(fmt.Sprintf("|--> %s\n\n", tcpHandler.TCPForward)) + } else { + output.WriteString(fmt.Sprintf("|--> tcp://%s\n\n", tcpHandler.TCPForward)) + } } if !forService && !e.bg.Value { @@ -1181,8 +1209,8 @@ func (e *serveEnv) shouldWarnRemoteDestCompatibility(ctx context.Context, target return nil } - if filepath.IsAbs(target) || strings.HasPrefix(target, "text:") { - // local path or text target, nothing to check + if filepath.IsAbs(target) || strings.HasPrefix(target, "text:") || strings.HasPrefix(target, "unix:") { + // local path, text target, or unix socket, nothing to check return nil } @@ -1275,14 +1303,28 @@ 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) } - dstURL, err := url.Parse(targetURL) - if err != nil { - return fmt.Errorf("invalid TCP target %q: %v", target, err) + // For unix: targets, store the full "unix:/path" string as the forward address. + // For tcp: targets, extract the host:port from the parsed URL. + var fwdAddr string + if strings.HasPrefix(targetURL, "unix:") { + if proxyProtocol != 0 { + return fmt.Errorf("PROXY protocol is not supported with unix socket targets") + } + fwdAddr = targetURL + } else { + dstURL, err := url.Parse(targetURL) + if err != nil { + return fmt.Errorf("invalid TCP target %q: %v", target, err) + } + if dstURL.Port() == "" { + return fmt.Errorf("TCP target %q must include a port", target) + } + fwdAddr = dstURL.Host } if sc.IsServingWeb(srcPort, svcName) { @@ -1291,17 +1333,17 @@ func (e *serveEnv) applyTCPServe(sc *ipn.ServeConfig, dnsName string, srcType se // TODO: needs to account for multiple configs from foreground mode if svcName := tailcfg.AsServiceName(dnsName); svcName != "" { - sc.SetTCPForwardingForService(srcPort, dstURL.Host, terminateTLS, svcName, proxyProtocol, mds) + sc.SetTCPForwardingForService(srcPort, fwdAddr, terminateTLS, svcName, proxyProtocol, mds) return nil } // TODO: needs to account for multiple configs from foreground mode if svcName != "" { - sc.SetTCPForwardingForService(srcPort, dstURL.Host, terminateTLS, svcName, proxyProtocol, mds) + sc.SetTCPForwardingForService(srcPort, fwdAddr, terminateTLS, svcName, proxyProtocol, mds) return nil } - sc.SetTCPForwarding(srcPort, dstURL.Host, terminateTLS, proxyProtocol, dnsName) + sc.SetTCPForwarding(srcPort, fwdAddr, terminateTLS, proxyProtocol, dnsName) return nil } diff --git a/cmd/tailscale/cli/serve_v2_test.go b/cmd/tailscale/cli/serve_v2_test.go index 531f15f67..b86c40139 100644 --- a/cmd/tailscale/cli/serve_v2_test.go +++ b/cmd/tailscale/cli/serve_v2_test.go @@ -13,6 +13,7 @@ import ( "path/filepath" "reflect" "regexp" + "runtime" "slices" "strconv" "strings" @@ -40,6 +41,7 @@ func TestServeDevConfigMutations(t *testing.T) { name string steps []step initialState fakeLocalServeClient // use the zero value for empty config + skipOn []string // platforms on which to skip; GOOS values } // creaet a temporary directory for path-based destinations @@ -483,6 +485,63 @@ func TestServeDevConfigMutations(t *testing.T) { }, }, }, + { + name: "tcp_unix_socket", + steps: []step{{ + command: cmd("serve --tcp=3128 --bg unix:/var/run/app.sock"), + want: &ipn.ServeConfig{ + TCP: map[uint16]*ipn.TCPPortHandler{ + 3128: { + TCPForward: "unix:/var/run/app.sock", + }, + }, + }, + }}, + skipOn: []string{"windows"}, + }, + { + name: "tls_terminated_tcp_unix_socket", + steps: []step{{ + command: cmd("serve --tls-terminated-tcp=443 --bg unix:/var/run/app.sock"), + want: &ipn.ServeConfig{ + TCP: map[uint16]*ipn.TCPPortHandler{ + 443: { + TCPForward: "unix:/var/run/app.sock", + TerminateTLS: "foo.test.ts.net", + }, + }, + }, + }}, + skipOn: []string{"windows"}, + }, + { + name: "tcp_unix_socket_off", + steps: []step{ + { + command: cmd("serve --tcp=3128 --bg unix:/var/run/app.sock"), + want: &ipn.ServeConfig{ + TCP: map[uint16]*ipn.TCPPortHandler{ + 3128: { + TCPForward: "unix:/var/run/app.sock", + }, + }, + }, + }, + { + command: cmd("serve --tcp=3128 off"), + want: &ipn.ServeConfig{}, + }, + }, + skipOn: []string{"windows"}, + }, + { + name: "tcp_unix_socket_proxy_protocol_rejected", + steps: []step{{ + command: cmd("serve --tcp=3128 --proxy-protocol=1 --bg unix:/var/run/app.sock"), + wantErr: anyErr(), + }}, + skipOn: []string{"windows"}, + }, { name: "tcp_off", steps: []step{ @@ -993,6 +1052,9 @@ func TestServeDevConfigMutations(t *testing.T) { for _, group := range groups { t.Run(group.name, func(t *testing.T) { + if slices.Contains(group.skipOn, runtime.GOOS) { + t.Skip("skipping on", runtime.GOOS) + } lc := group.initialState for i, st := range group.steps { var stderr bytes.Buffer @@ -2573,4 +2635,66 @@ func TestRunServeSetConfig(t *testing.T) { t.Errorf("new format must not warn; stderr:\n%s", stderr.String()) } }) + + t.Run("http_over_unix_roundtrip", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skipping on windows") + } + + // set-config: apply HTTP-over-unix declarative config; then get-config + // should reproduce a target of "http://unix:/var/run/app.sock" without + // mangling it through host:port parsing. + lc := &fakeLocalServeClient{config: &ipn.ServeConfig{}} + var stdout, stderr bytes.Buffer + e := &serveEnv{lc: lc, service: fooSvc, testStdout: &stdout, testStderr: &stderr} + path := writeTmpServeConfig(t, `{"version":"0.0.1","endpoints":{"tcp:443":"http://unix:/var/run/app.sock"}}`) + + if err := e.runServeSetConfig(context.Background(), []string{path}); err != nil { + t.Fatalf("set-config: %v", err) + } + svc := lc.config.Services[fooSvc] + if svc == nil { + t.Fatalf("svc:foo not applied; got %+v", lc.config.Services) + } + if got := svc.Web["foo.test.ts.net:443"].Handlers["/"].Proxy; got != "unix:/var/run/app.sock" { + t.Errorf("Handler Proxy = %q, want %q", got, "unix:/var/run/app.sock") + } + if stderr.Len() != 0 { + t.Errorf("stderr must be empty; got:\n%s", stderr.String()) + } + + // Round-trip through get-config. + var gotStdout, gotStderr bytes.Buffer + g := &serveEnv{lc: lc, service: fooSvc, testStdout: &gotStdout, testStderr: &gotStderr} + if err := g.runServeGetConfig(context.Background(), nil); err != nil { + t.Fatalf("get-config: %v", err) + } + if !strings.Contains(gotStdout.String(), `"tcp:443": "http://unix:/var/run/app.sock"`) { + t.Errorf("get-config output missing http-over-unix target:\n%s", gotStdout.String()) + } + }) + + t.Run("https_over_unix_roundtrip", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skipping on windows") + } + + lc := &fakeLocalServeClient{config: &ipn.ServeConfig{}} + var stdout, stderr bytes.Buffer + e := &serveEnv{lc: lc, service: fooSvc, testStdout: &stdout, testStderr: &stderr} + path := writeTmpServeConfig(t, `{"version":"0.0.1","endpoints":{"tcp:443":"https://unix:/var/run/app.sock"}}`) + + if err := e.runServeSetConfig(context.Background(), []string{path}); err != nil { + t.Fatalf("set-config: %v", err) + } + + var gotStdout, gotStderr bytes.Buffer + g := &serveEnv{lc: lc, service: fooSvc, testStdout: &gotStdout, testStderr: &gotStderr} + if err := g.runServeGetConfig(context.Background(), nil); err != nil { + t.Fatalf("get-config: %v", err) + } + if !strings.Contains(gotStdout.String(), `"tcp:443": "https://unix:/var/run/app.sock"`) { + t.Errorf("get-config output missing https-over-unix target:\n%s", gotStdout.String()) + } + }) } diff --git a/ipn/conffile/serveconf.go b/ipn/conffile/serveconf.go index b58badc53..cff47d303 100644 --- a/ipn/conffile/serveconf.go +++ b/ipn/conffile/serveconf.go @@ -92,11 +92,16 @@ type Target struct { // If Protocol is ProtoFile, then Destination is a file path. // If Protocol is ProtoTUN, then Destination is empty. + // If Protocol is ProtoHTTP, ProtoHTTPS, ProtoHTTPSInsecure, ProtoTCP, or + // ProtoTLSTerminatedTCP and Destination starts with "unix:", it is a Unix + // socket path (e.g. "unix:/var/run/app.sock" or "unix:relative.sock"). // Otherwise, it is a host. Destination string // If Protocol is not ProtoFile or ProtoTUN, then DestinationPorts is the // set of ports on which to connect to the host referred to by Destination. + // For unix socket targets (Destination starting with "unix:"), + // DestinationPorts is unused and left at the zero value. DestinationPorts tailcfg.PortRange } @@ -133,13 +138,22 @@ func (t *Target) UnmarshalJSONFrom(dec *jsontext.Decoder) error { t.Destination = target t.DestinationPorts = tailcfg.PortRange{} case ProtoHTTP, ProtoHTTPS, ProtoHTTPSInsecure, ProtoTCP, ProtoTLSTerminatedTCP: - host, portRange, err := tailcfg.ParseHostPortRange(rest) - if err != nil { - return err + if unixPath, ok := strings.CutPrefix(rest, "unix:"); ok { + if unixPath == "" { + return errors.New("unix socket path cannot be empty") + } + t.Protocol = ServiceProtocol(proto) + t.Destination = rest + t.DestinationPorts = tailcfg.PortRange{} + } else { + host, portRange, err := tailcfg.ParseHostPortRange(rest) + if err != nil { + return err + } + t.Protocol = ServiceProtocol(proto) + t.Destination = host + t.DestinationPorts = portRange } - t.Protocol = ServiceProtocol(proto) - t.Destination = host - t.DestinationPorts = portRange default: return errors.New("unsupported protocol") } @@ -155,7 +169,12 @@ func (t *Target) MarshalText() ([]byte, error) { case ProtoTUN: out = "TUN" case ProtoHTTP, ProtoHTTPS, ProtoHTTPSInsecure, ProtoTCP, ProtoTLSTerminatedTCP: - out = fmt.Sprintf("%s://%s", t.Protocol, net.JoinHostPort(t.Destination, t.DestinationPorts.String())) + if strings.HasPrefix(t.Destination, "unix:") { + // Unix socket: serialize as e.g. "tcp://unix:/path/to/sock" + out = fmt.Sprintf("%s://%s", t.Protocol, t.Destination) + } else { + out = fmt.Sprintf("%s://%s", t.Protocol, net.JoinHostPort(t.Destination, t.DestinationPorts.String())) + } default: return nil, errors.New("unsupported protocol") } @@ -261,8 +280,19 @@ func loadConfigV0(json []byte, forService string) (*ServicesConfigFile, error) { } foundTUN = true } else { - if ppr.Ports.Last-ppr.Ports.First != target.DestinationPorts.Last-target.DestinationPorts.First { - return nil, fmt.Errorf("service %q: source and destination port ranges must be of equal size", svcName.String()) + // Unix socket targets (Destination starting with "unix:" on an + // HTTP/HTTPS/HTTPSInsecure/TCP/TLSTerminatedTCP inbound protocol) + // don't have a destination port range; skip the equality check. + isUnixSocket := strings.HasPrefix(target.Destination, "unix:") && + (target.Protocol == ProtoHTTP || + target.Protocol == ProtoHTTPS || + target.Protocol == ProtoHTTPSInsecure || + target.Protocol == ProtoTCP || + target.Protocol == ProtoTLSTerminatedTCP) + if !isUnixSocket { + if ppr.Ports.Last-ppr.Ports.First != target.DestinationPorts.Last-target.DestinationPorts.First { + return nil, fmt.Errorf("service %q: source and destination port ranges must be of equal size", svcName.String()) + } } foundNonTUN = true } diff --git a/ipn/conffile/serveconf_test.go b/ipn/conffile/serveconf_test.go new file mode 100644 index 000000000..6b1cc2b77 --- /dev/null +++ b/ipn/conffile/serveconf_test.go @@ -0,0 +1,108 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !ts_omit_serve + +package conffile + +import ( + "testing" + + "tailscale.com/tailcfg" +) + +func TestTargetUnixSocketRoundtrip(t *testing.T) { + tests := []struct { + name string + serialized string + want Target + }{ + { + name: "tcp_unix_socket", + serialized: "tcp://unix:/var/run/app.sock", + want: Target{ + Protocol: ProtoTCP, + Destination: "unix:/var/run/app.sock", + }, + }, + { + name: "tls_terminated_tcp_unix_socket", + serialized: "tls-terminated-tcp://unix:/var/run/app.sock", + want: Target{ + Protocol: ProtoTLSTerminatedTCP, + Destination: "unix:/var/run/app.sock", + }, + }, + { + name: "tcp_unix_socket_relative", + serialized: "tcp://unix:relative.sock", + want: Target{ + Protocol: ProtoTCP, + Destination: "unix:relative.sock", + }, + }, + { + name: "http_unix_socket", + serialized: "http://unix:/var/run/app.sock", + want: Target{ + Protocol: ProtoHTTP, + Destination: "unix:/var/run/app.sock", + }, + }, + { + name: "https_unix_socket", + serialized: "https://unix:/var/run/app.sock", + want: Target{ + Protocol: ProtoHTTPS, + Destination: "unix:/var/run/app.sock", + }, + }, + { + name: "https_insecure_unix_socket", + serialized: "https+insecure://unix:/var/run/app.sock", + want: Target{ + Protocol: ProtoHTTPSInsecure, + Destination: "unix:/var/run/app.sock", + }, + }, + { + name: "http_unix_socket_relative", + serialized: "http://unix:relative.sock", + want: Target{ + Protocol: ProtoHTTP, + Destination: "unix:relative.sock", + }, + }, + { + name: "tcp_host_port", + serialized: "tcp://localhost:5432", + want: Target{ + Protocol: ProtoTCP, + Destination: "localhost", + DestinationPorts: tailcfg.PortRange{First: 5432, Last: 5432}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test unmarshal + var got Target + if err := got.UnmarshalJSON([]byte(`"` + tt.serialized + `"`)); err != nil { + t.Fatalf("UnmarshalJSON(%q) failed: %v", tt.serialized, err) + } + if got != tt.want { + t.Errorf("UnmarshalJSON(%q) = %+v, want %+v", tt.serialized, got, tt.want) + } + + // Test marshal roundtrip + marshaled, err := tt.want.MarshalText() + if err != nil { + t.Fatalf("MarshalText() failed: %v", err) + } + if string(marshaled) != tt.serialized { + t.Errorf("MarshalText() = %q, want %q", marshaled, tt.serialized) + } + }) + } +} diff --git a/ipn/ipn_view.go b/ipn/ipn_view.go index 8d30fd5b9..2b25a6aac 100644 --- a/ipn/ipn_view.go +++ b/ipn/ipn_view.go @@ -823,7 +823,10 @@ func (v TCPPortHandlerView) HTTPS() bool { return v.ж.HTTPS } // It is mutually exclusive with TCPForward. func (v TCPPortHandlerView) HTTP() bool { return v.ж.HTTP } -// TCPForward is the IP:port to forward TCP connections to. +// TCPForward is the address to forward TCP connections to. +// It is either a host:port (e.g. "127.0.0.1:3128", "localhost:5432") +// or a Unix socket path prefixed with "unix:" +// (e.g. "unix:/var/run/app.sock" or "unix:relative.sock"). // Whether or not TLS is terminated by tailscaled depends on // TerminateTLS. // diff --git a/ipn/ipnlocal/serve.go b/ipn/ipnlocal/serve.go index 8fdf66f4d..cee274028 100644 --- a/ipn/ipnlocal/serve.go +++ b/ipn/ipnlocal/serve.go @@ -673,7 +673,14 @@ func (b *LocalBackend) tcpHandlerForServeTCP(tcph ipn.TCPPortHandlerView, dport defer conn.Close() conn = b.meteredConnForService(conn, forVIPService) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - backConn, err := b.dialer.SystemDial(ctx, "tcp", backDst) + var backConn net.Conn + var err error + if socketPath, ok := strings.CutPrefix(backDst, "unix:"); ok { + var d net.Dialer + backConn, err = d.DialContext(ctx, "unix", socketPath) + } else { + 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) @@ -714,7 +721,13 @@ func (b *LocalBackend) tcpHandlerForServeTCP(tcph ipn.TCPPortHandlerView, dport func (b *LocalBackend) forwardTCPWithProxyProtocol(conn, backConn net.Conn, proxyProtoVer int, srcAddr netip.AddrPort, dport uint16, backDst string) error { var proxyHeader []byte if proxyProtoVer > 0 { - backAddr := backConn.RemoteAddr().(*net.TCPAddr) + // PROXY protocol requires a valid TCP destination address. + // For Unix socket backends, RemoteAddr is *net.UnixAddr; + // the CLI rejects this combination, but guard here as well. + backAddr, ok := backConn.RemoteAddr().(*net.TCPAddr) + if !ok { + return fmt.Errorf("PROXY protocol is not supported with non-TCP backend %s", backDst) + } // We always want to format the PROXY protocol header based on // the IPv4 or IPv6-ness of the client. The SourceAddr and diff --git a/ipn/ipnlocal/serve_unix_test.go b/ipn/ipnlocal/serve_unix_test.go index 9e641e0e5..d2db63ff0 100644 --- a/ipn/ipnlocal/serve_unix_test.go +++ b/ipn/ipnlocal/serve_unix_test.go @@ -12,12 +12,14 @@ import ( "net" "net/http" "net/http/httptest" + "net/netip" "net/url" "os" "path/filepath" "testing" "time" + "tailscale.com/ipn" "tailscale.com/tstest" ) @@ -238,3 +240,83 @@ func TestServeBlocksTailscaledSocket(t *testing.T) { t.Error("expected valid handler for legitimate socket") } } + +func TestTCPForwardUnixSocket(t *testing.T) { + tmpDir := t.TempDir() + + socketPath := filepath.Join(tmpDir, "backend.sock") + + // Create a Unix socket echo server + listener, err := net.Listen("unix", socketPath) + if err != nil { + t.Fatalf("failed to create unix socket listener: %v", err) + } + defer listener.Close() + + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + go func() { + defer conn.Close() + io.Copy(conn, conn) // echo + }() + } + }() + + // Set up a LocalBackend with a ServeConfig that forwards TCP port 3128 to the unix socket + b := newTestBackend(t) + b.logf = tstest.WhileTestRunningLogger(t) + + conf := &ipn.ServeConfig{ + TCP: map[uint16]*ipn.TCPPortHandler{ + 3128: {TCPForward: "unix:" + socketPath}, + }, + } + if err := b.SetServeConfig(conf, ""); err != nil { + t.Fatal("setting serve config:", err) + } + + // Get the handler from tcpHandlerForServe + srcAddr := netip.MustParseAddrPort("100.100.100.1:12345") + handler := b.tcpHandlerForServe(3128, srcAddr, nil) + if handler == nil { + t.Fatal("tcpHandlerForServe returned nil handler") + } + + // Create a pipe to simulate an incoming connection + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + + // Run the handler in a goroutine + handlerDone := make(chan error, 1) + go func() { + handlerDone <- handler(serverConn) + }() + + // Write data through the "client" side and read the echo back + testData := []byte("hello via tcpHandlerForServe") + if _, err := clientConn.Write(testData); err != nil { + t.Fatalf("write failed: %v", err) + } + buf := make([]byte, len(testData)) + if _, err := io.ReadFull(clientConn, buf); err != nil { + t.Fatalf("read failed: %v", err) + } + if string(buf) != string(testData) { + t.Fatalf("echo mismatch: got %q, want %q", buf, testData) + } + + // Close client side, handler should finish + clientConn.Close() + select { + case err := <-handlerDone: + if err != nil { + t.Fatalf("handler returned unexpected error: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("handler did not finish in time") + } +} diff --git a/ipn/serve.go b/ipn/serve.go index 40315f545..5c1fa2975 100644 --- a/ipn/serve.go +++ b/ipn/serve.go @@ -139,7 +139,10 @@ type TCPPortHandler struct { // It is mutually exclusive with TCPForward. HTTP bool `json:",omitempty"` - // TCPForward is the IP:port to forward TCP connections to. + // TCPForward is the address to forward TCP connections to. + // It is either a host:port (e.g. "127.0.0.1:3128", "localhost:5432") + // or a Unix socket path prefixed with "unix:" + // (e.g. "unix:/var/run/app.sock" or "unix:relative.sock"). // Whether or not TLS is terminated by tailscaled depends on // TerminateTLS. // @@ -462,10 +465,10 @@ func (sc *ServeConfig) SetWebHandler(handler *HTTPHandler, host string, port uin } } -// SetTCPForwarding sets the fwdAddr (IP:port form) to which to forward -// 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. +// SetTCPForwarding sets the fwdAddr to which to forward connections from the +// given port. fwdAddr is either an IP:port or "unix:/path/to/socket". +// If terminateTLS is true, TLS connections are terminated with only the given +// host name permitted before passing them to the fwdAddr. // // If proxyProtocol is non-zero, the corresponding PROXY protocol version // header is sent before forwarding the connection. @@ -483,10 +486,11 @@ func (sc *ServeConfig) SetTCPForwarding(port uint16, fwdAddr string, terminateTL } } -// SetTCPForwardingForService sets the fwdAddr (IP:port form) to which to -// forward connections from the given port on the service. If terminateTLS -// is true, TLS connections are terminated, with only the FQDN that corresponds -// to the given service being permitted, before passing them to the fwdAddr. +// SetTCPForwardingForService sets the fwdAddr to which to forward connections +// from the given port on the service. fwdAddr is either a host:port or +// "unix:/path" (absolute or relative). If terminateTLS is true, TLS connections +// are terminated, with only the FQDN that corresponds to the given service +// being permitted, before passing them to the fwdAddr. func (sc *ServeConfig) SetTCPForwardingForService(port uint16, fwdAddr string, terminateTLS bool, svcName tailcfg.ServiceName, proxyProtocol int, magicDNSSuffix string) { if sc == nil { sc = new(ServeConfig) diff --git a/ipn/serve_expand_test.go b/ipn/serve_expand_test.go index 33f808d62..225e38799 100644 --- a/ipn/serve_expand_test.go +++ b/ipn/serve_expand_test.go @@ -42,6 +42,14 @@ func TestExpandProxyTargetValueUnix(t *testing.T) { want: "unix:./myservice.sock", skipOnWindows: true, }, + { + name: "unix-socket-bare-relative-path", + target: "unix:myservice.sock", + supportedSchemes: []string{"http", "https", "unix"}, + defaultScheme: "http", + want: "unix:myservice.sock", + skipOnWindows: true, + }, { name: "unix-socket-empty-path", target: "unix:", @@ -56,6 +64,21 @@ func TestExpandProxyTargetValueUnix(t *testing.T) { defaultScheme: "http", wantErr: true, }, + { + name: "unix-socket-tcp-supported", + target: "unix:/var/run/app.sock", + supportedSchemes: []string{"tcp", "unix"}, + defaultScheme: "tcp", + want: "unix:/var/run/app.sock", + skipOnWindows: true, + }, + { + name: "unix-socket-tcp-not-supported", + target: "unix:/var/run/app.sock", + supportedSchemes: []string{"tcp"}, + defaultScheme: "tcp", + wantErr: true, + }, } for _, tt := range tests {