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:
@@ -663,7 +663,11 @@ func printTCPStatusTree(ctx context.Context, sc *ipn.ServeConfig, st *ipnstate.S
|
|||||||
ipp := net.JoinHostPort(a.String(), strconv.Itoa(int(p)))
|
ipp := net.JoinHostPort(a.String(), strconv.Itoa(int(p)))
|
||||||
printf("|-- tcp://%s\n", ipp)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -702,19 +702,26 @@ func (e *serveEnv) runServeGetConfig(ctx context.Context, args []string) (err er
|
|||||||
} else {
|
} else {
|
||||||
proto = conffile.ProtoTCP
|
proto = conffile.ProtoTCP
|
||||||
}
|
}
|
||||||
destHost, destPortStr, err := net.SplitHostPort(config.TCPForward)
|
if strings.HasPrefix(config.TCPForward, "unix:") {
|
||||||
if err != nil {
|
mak.Set(&sdf.Endpoints, &ppr, &conffile.Target{
|
||||||
return nil, fmt.Errorf("parse TCPForward=%q: %w", config.TCPForward, err)
|
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 {
|
} else if config.HTTP || config.HTTPS {
|
||||||
webKey := ipn.HostPort(net.JoinHostPort(sniName, strconv.FormatUint(uint64(port), 10)))
|
webKey := ipn.HostPort(net.JoinHostPort(sniName, strconv.FormatUint(uint64(port), 10)))
|
||||||
handlers, ok := serviceConfig.Web[webKey]
|
handlers, ok := serviceConfig.Web[webKey]
|
||||||
@@ -732,25 +739,38 @@ func (e *serveEnv) runServeGetConfig(ctx context.Context, args []string) (err er
|
|||||||
DestinationPorts: tailcfg.PortRange{},
|
DestinationPorts: tailcfg.PortRange{},
|
||||||
})
|
})
|
||||||
} else if defaultHandler.Proxy != "" {
|
} else if defaultHandler.Proxy != "" {
|
||||||
proto, rest, ok := strings.Cut(defaultHandler.Proxy, "://")
|
if strings.HasPrefix(defaultHandler.Proxy, "unix:") {
|
||||||
if !ok {
|
// HTTP over unix socket: h.Proxy is "unix:/path" without "://".
|
||||||
return nil, fmt.Errorf("service %q: invalid proxy handler %q", svcName, defaultHandler.Proxy)
|
// The inbound protocol is HTTP(S); infer from useTLS.
|
||||||
}
|
httpProto := conffile.ProtoHTTP
|
||||||
host, portStr, err := net.SplitHostPort(rest)
|
if config.HTTPS {
|
||||||
if err != nil {
|
httpProto = conffile.ProtoHTTPS
|
||||||
return nil, fmt.Errorf("service %q: invalid proxy handler %q: %w", svcName, defaultHandler.Proxy, err)
|
}
|
||||||
}
|
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)
|
port, err := strconv.ParseUint(portStr, 10, 16)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("service %q: parse port %q: %w", svcName, portStr, err)
|
return nil, fmt.Errorf("service %q: parse port %q: %w", svcName, portStr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
mak.Set(&sdf.Endpoints, &ppr, &conffile.Target{
|
mak.Set(&sdf.Endpoints, &ppr, &conffile.Target{
|
||||||
Protocol: conffile.ServiceProtocol(proto),
|
Protocol: conffile.ServiceProtocol(proto),
|
||||||
Destination: host,
|
Destination: host,
|
||||||
DestinationPorts: tailcfg.PortRange{First: uint16(port), Last: uint16(port)},
|
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
|
var target string
|
||||||
if ep.Protocol == conffile.ProtoFile {
|
if ep.Protocol == conffile.ProtoFile {
|
||||||
target = ep.Destination
|
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 {
|
} else {
|
||||||
// map source port range 1-1 to destination port range
|
// map source port range 1-1 to destination port range
|
||||||
destPort := ep.DestinationPorts.First + (port - ppr.Ports.First)
|
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)))
|
ipp := net.JoinHostPort(a.String(), strconv.Itoa(int(srvPort)))
|
||||||
output.WriteString(fmt.Sprintf("|-- tcp://%s\n", ipp))
|
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 {
|
if !forService && !e.bg.Value {
|
||||||
@@ -1181,8 +1209,8 @@ func (e *serveEnv) shouldWarnRemoteDestCompatibility(ctx context.Context, target
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if filepath.IsAbs(target) || strings.HasPrefix(target, "text:") {
|
if filepath.IsAbs(target) || strings.HasPrefix(target, "text:") || strings.HasPrefix(target, "unix:") {
|
||||||
// local path or text target, nothing to check
|
// local path, text target, or unix socket, nothing to check
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1275,14 +1303,28 @@ func (e *serveEnv) applyTCPServe(sc *ipn.ServeConfig, dnsName string, srcType se
|
|||||||
|
|
||||||
svcName := tailcfg.AsServiceName(dnsName)
|
svcName := tailcfg.AsServiceName(dnsName)
|
||||||
|
|
||||||
targetURL, err := ipn.ExpandProxyTargetValue(target, []string{"tcp"}, "tcp")
|
targetURL, err := ipn.ExpandProxyTargetValue(target, []string{"tcp", "unix"}, "tcp")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("unable to expand target: %v", err)
|
return fmt.Errorf("unable to expand target: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
dstURL, err := url.Parse(targetURL)
|
// For unix: targets, store the full "unix:/path" string as the forward address.
|
||||||
if err != nil {
|
// For tcp: targets, extract the host:port from the parsed URL.
|
||||||
return fmt.Errorf("invalid TCP target %q: %v", target, err)
|
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) {
|
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
|
// TODO: needs to account for multiple configs from foreground mode
|
||||||
if svcName := tailcfg.AsServiceName(dnsName); svcName != "" {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: needs to account for multiple configs from foreground mode
|
// TODO: needs to account for multiple configs from foreground mode
|
||||||
if svcName != "" {
|
if svcName != "" {
|
||||||
sc.SetTCPForwardingForService(srcPort, dstURL.Host, terminateTLS, svcName, proxyProtocol, mds)
|
sc.SetTCPForwardingForService(srcPort, fwdAddr, terminateTLS, svcName, proxyProtocol, mds)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
sc.SetTCPForwarding(srcPort, dstURL.Host, terminateTLS, proxyProtocol, dnsName)
|
sc.SetTCPForwarding(srcPort, fwdAddr, terminateTLS, proxyProtocol, dnsName)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"runtime"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -40,6 +41,7 @@ func TestServeDevConfigMutations(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
steps []step
|
steps []step
|
||||||
initialState fakeLocalServeClient // use the zero value for empty config
|
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
|
// 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",
|
name: "tcp_off",
|
||||||
steps: []step{
|
steps: []step{
|
||||||
@@ -993,6 +1052,9 @@ func TestServeDevConfigMutations(t *testing.T) {
|
|||||||
|
|
||||||
for _, group := range groups {
|
for _, group := range groups {
|
||||||
t.Run(group.name, func(t *testing.T) {
|
t.Run(group.name, func(t *testing.T) {
|
||||||
|
if slices.Contains(group.skipOn, runtime.GOOS) {
|
||||||
|
t.Skip("skipping on", runtime.GOOS)
|
||||||
|
}
|
||||||
lc := group.initialState
|
lc := group.initialState
|
||||||
for i, st := range group.steps {
|
for i, st := range group.steps {
|
||||||
var stderr bytes.Buffer
|
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.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())
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,11 +92,16 @@ type Target struct {
|
|||||||
|
|
||||||
// If Protocol is ProtoFile, then Destination is a file path.
|
// If Protocol is ProtoFile, then Destination is a file path.
|
||||||
// If Protocol is ProtoTUN, then Destination is empty.
|
// 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.
|
// Otherwise, it is a host.
|
||||||
Destination string
|
Destination string
|
||||||
|
|
||||||
// If Protocol is not ProtoFile or ProtoTUN, then DestinationPorts is the
|
// 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.
|
// 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
|
DestinationPorts tailcfg.PortRange
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,13 +138,22 @@ func (t *Target) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
|
|||||||
t.Destination = target
|
t.Destination = target
|
||||||
t.DestinationPorts = tailcfg.PortRange{}
|
t.DestinationPorts = tailcfg.PortRange{}
|
||||||
case ProtoHTTP, ProtoHTTPS, ProtoHTTPSInsecure, ProtoTCP, ProtoTLSTerminatedTCP:
|
case ProtoHTTP, ProtoHTTPS, ProtoHTTPSInsecure, ProtoTCP, ProtoTLSTerminatedTCP:
|
||||||
host, portRange, err := tailcfg.ParseHostPortRange(rest)
|
if unixPath, ok := strings.CutPrefix(rest, "unix:"); ok {
|
||||||
if err != nil {
|
if unixPath == "" {
|
||||||
return err
|
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:
|
default:
|
||||||
return errors.New("unsupported protocol")
|
return errors.New("unsupported protocol")
|
||||||
}
|
}
|
||||||
@@ -155,7 +169,12 @@ func (t *Target) MarshalText() ([]byte, error) {
|
|||||||
case ProtoTUN:
|
case ProtoTUN:
|
||||||
out = "TUN"
|
out = "TUN"
|
||||||
case ProtoHTTP, ProtoHTTPS, ProtoHTTPSInsecure, ProtoTCP, ProtoTLSTerminatedTCP:
|
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:
|
default:
|
||||||
return nil, errors.New("unsupported protocol")
|
return nil, errors.New("unsupported protocol")
|
||||||
}
|
}
|
||||||
@@ -261,8 +280,19 @@ func loadConfigV0(json []byte, forService string) (*ServicesConfigFile, error) {
|
|||||||
}
|
}
|
||||||
foundTUN = true
|
foundTUN = true
|
||||||
} else {
|
} else {
|
||||||
if ppr.Ports.Last-ppr.Ports.First != target.DestinationPorts.Last-target.DestinationPorts.First {
|
// Unix socket targets (Destination starting with "unix:" on an
|
||||||
return nil, fmt.Errorf("service %q: source and destination port ranges must be of equal size", svcName.String())
|
// 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
|
foundNonTUN = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-1
@@ -823,7 +823,10 @@ func (v TCPPortHandlerView) HTTPS() bool { return v.ж.HTTPS }
|
|||||||
// It is mutually exclusive with TCPForward.
|
// It is mutually exclusive with TCPForward.
|
||||||
func (v TCPPortHandlerView) HTTP() bool { return v.ж.HTTP }
|
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
|
// Whether or not TLS is terminated by tailscaled depends on
|
||||||
// TerminateTLS.
|
// TerminateTLS.
|
||||||
//
|
//
|
||||||
|
|||||||
+15
-2
@@ -673,7 +673,14 @@ func (b *LocalBackend) tcpHandlerForServeTCP(tcph ipn.TCPPortHandlerView, dport
|
|||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
conn = b.meteredConnForService(conn, forVIPService)
|
conn = b.meteredConnForService(conn, forVIPService)
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
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()
|
cancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.logf("localbackend: failed to TCP proxy port %v (from %v) to %s: %v", dport, srcAddr, backDst, err)
|
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 {
|
func (b *LocalBackend) forwardTCPWithProxyProtocol(conn, backConn net.Conn, proxyProtoVer int, srcAddr netip.AddrPort, dport uint16, backDst string) error {
|
||||||
var proxyHeader []byte
|
var proxyHeader []byte
|
||||||
if proxyProtoVer > 0 {
|
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
|
// We always want to format the PROXY protocol header based on
|
||||||
// the IPv4 or IPv6-ness of the client. The SourceAddr and
|
// the IPv4 or IPv6-ness of the client. The SourceAddr and
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"tailscale.com/ipn"
|
||||||
"tailscale.com/tstest"
|
"tailscale.com/tstest"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -238,3 +240,83 @@ func TestServeBlocksTailscaledSocket(t *testing.T) {
|
|||||||
t.Error("expected valid handler for legitimate socket")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+13
-9
@@ -139,7 +139,10 @@ type TCPPortHandler struct {
|
|||||||
// It is mutually exclusive with TCPForward.
|
// It is mutually exclusive with TCPForward.
|
||||||
HTTP bool `json:",omitempty"`
|
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
|
// Whether or not TLS is terminated by tailscaled depends on
|
||||||
// TerminateTLS.
|
// 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
|
// SetTCPForwarding sets the fwdAddr to which to forward connections from the
|
||||||
// connections from the given port. If terminateTLS is true, TLS connections
|
// given port. fwdAddr is either an IP:port or "unix:/path/to/socket".
|
||||||
// are terminated with only the given host name permitted before passing them
|
// If terminateTLS is true, TLS connections are terminated with only the given
|
||||||
// to the fwdAddr.
|
// host name permitted before passing them to the fwdAddr.
|
||||||
//
|
//
|
||||||
// If proxyProtocol is non-zero, the corresponding PROXY protocol version
|
// If proxyProtocol is non-zero, the corresponding PROXY protocol version
|
||||||
// header is sent before forwarding the connection.
|
// 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
|
// SetTCPForwardingForService sets the fwdAddr to which to forward connections
|
||||||
// forward connections from the given port on the service. If terminateTLS
|
// from the given port on the service. fwdAddr is either a host:port or
|
||||||
// is true, TLS connections are terminated, with only the FQDN that corresponds
|
// "unix:/path" (absolute or relative). If terminateTLS is true, TLS connections
|
||||||
// to the given service being permitted, before passing them to the fwdAddr.
|
// 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) {
|
func (sc *ServeConfig) SetTCPForwardingForService(port uint16, fwdAddr string, terminateTLS bool, svcName tailcfg.ServiceName, proxyProtocol int, magicDNSSuffix string) {
|
||||||
if sc == nil {
|
if sc == nil {
|
||||||
sc = new(ServeConfig)
|
sc = new(ServeConfig)
|
||||||
|
|||||||
@@ -42,6 +42,14 @@ func TestExpandProxyTargetValueUnix(t *testing.T) {
|
|||||||
want: "unix:./myservice.sock",
|
want: "unix:./myservice.sock",
|
||||||
skipOnWindows: true,
|
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",
|
name: "unix-socket-empty-path",
|
||||||
target: "unix:",
|
target: "unix:",
|
||||||
@@ -56,6 +64,21 @@ func TestExpandProxyTargetValueUnix(t *testing.T) {
|
|||||||
defaultScheme: "http",
|
defaultScheme: "http",
|
||||||
wantErr: true,
|
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 {
|
for _, tt := range tests {
|
||||||
|
|||||||
Reference in New Issue
Block a user