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)))
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user