ipn/ipnlocal,cmd/tailscale/cli: support unix socket targets for TCP serve

Allow `tailscale serve --tcp <port> unix:/path/to/socket` and `tailscale serve --tls-terminated-tcp <port> 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 <ayanamist@gmail.com>
This commit is contained in:
ayanamist
2026-07-16 09:13:38 -06:00
committed by Harry Harpham
parent 38345dce3d
commit 50f1c285ba
10 changed files with 494 additions and 61 deletions
+39 -9
View File
@@ -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
}
+108
View File
@@ -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)
}
})
}
}