Browse Source

cmd/tailscale,ipn: add Unix socket support for serve

Based on PR #16700 by @lox, adapted to current codebase.

Adds support for proxying HTTP requests to Unix domain sockets via
tailscale serve unix:/path/to/socket, enabling exposure of services
like Docker, containerd, PHP-FPM over Tailscale without TCP bridging.

The implementation includes reasonable protections against exposure of
tailscaled's own socket.

Adaptations from original PR:
- Use net.Dialer.DialContext instead of net.Dial for context propagation
- Use http.Transport with Protocols API (current h2c approach, not http2.Transport)
- Resolve conflicts with hasScheme variable in ExpandProxyTargetValue

Updates #9771

Signed-off-by: Peter A. <[email protected]>
Co-authored-by: Lachlan Donald <[email protected]>
Peter A. 3 months ago
parent
commit
f4d34f38be

+ 6 - 1
cmd/tailscale/cli/serve_v2.go

@@ -138,6 +138,7 @@ var serveHelpCommon = strings.TrimSpace(`
 <target> can be a file, directory, text, or most commonly the location to a service running on the
 local machine. The location to the location service can be expressed as a port number (e.g., 3000),
 a partial URL (e.g., localhost:3000), or a full URL including a path (e.g., http://localhost:3000/foo).
+On Unix-like systems, you can also specify a Unix domain socket (e.g., unix:/tmp/myservice.sock).
 
 EXAMPLES
   - Expose an HTTP server running at 127.0.0.1:3000 in the foreground:
@@ -149,6 +150,9 @@ EXAMPLES
   - Expose an HTTPS server with invalid or self-signed certificates at https://localhost:8443
     $ tailscale %[1]s https+insecure://localhost:8443
 
+  - Expose a service listening on a Unix socket (Linux/macOS/BSD only):
+    $ tailscale %[1]s unix:/var/run/myservice.sock
+
 For more examples and use cases visit our docs site https://tailscale.com/kb/1247/funnel-serve-use-cases
 `)
 
@@ -1172,7 +1176,8 @@ func (e *serveEnv) applyWebServe(sc *ipn.ServeConfig, dnsName string, srvPort ui
 		}
 		h.Path = target
 	default:
-		t, err := ipn.ExpandProxyTargetValue(target, []string{"http", "https", "https+insecure"}, "http")
+		// Include unix in supported schemes for HTTP(S) serve
+		t, err := ipn.ExpandProxyTargetValue(target, []string{"http", "https", "https+insecure", "unix"}, "http")
 		if err != nil {
 			return err
 		}

+ 86 - 0
cmd/tailscale/cli/serve_v2_unix_test.go

@@ -0,0 +1,86 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build unix
+
+package cli
+
+import (
+	"path/filepath"
+	"testing"
+
+	"tailscale.com/ipn"
+)
+
+func TestServeUnixSocketCLI(t *testing.T) {
+	// Create a temporary directory for our socket path
+	tmpDir := t.TempDir()
+	socketPath := filepath.Join(tmpDir, "test.sock")
+
+	// Test that Unix socket targets are accepted by ExpandProxyTargetValue
+	target := "unix:" + socketPath
+	result, err := ipn.ExpandProxyTargetValue(target, []string{"http", "https", "https+insecure", "unix"}, "http")
+	if err != nil {
+		t.Fatalf("ExpandProxyTargetValue failed: %v", err)
+	}
+
+	if result != target {
+		t.Errorf("ExpandProxyTargetValue(%q) = %q, want %q", target, result, target)
+	}
+}
+
+func TestServeUnixSocketConfigPreserved(t *testing.T) {
+	// Test that Unix socket URLs are preserved in ServeConfig
+	sc := &ipn.ServeConfig{
+		Web: map[ipn.HostPort]*ipn.WebServerConfig{
+			"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
+				"/": {Proxy: "unix:/tmp/test.sock"},
+			}},
+		},
+	}
+
+	// Verify the proxy value is preserved
+	handler := sc.Web["foo.test.ts.net:443"].Handlers["/"]
+	if handler.Proxy != "unix:/tmp/test.sock" {
+		t.Errorf("proxy = %q, want %q", handler.Proxy, "unix:/tmp/test.sock")
+	}
+}
+
+func TestServeUnixSocketVariousPaths(t *testing.T) {
+	tests := []struct {
+		name    string
+		target  string
+		wantErr bool
+	}{
+		{
+			name:   "absolute-path",
+			target: "unix:/var/run/docker.sock",
+		},
+		{
+			name:   "tmp-path",
+			target: "unix:/tmp/myservice.sock",
+		},
+		{
+			name:   "relative-path",
+			target: "unix:./local.sock",
+		},
+		{
+			name:   "home-path",
+			target: "unix:/home/user/.local/service.sock",
+		},
+		{
+			name:    "empty-path",
+			target:  "unix:",
+			wantErr: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			_, err := ipn.ExpandProxyTargetValue(tt.target, []string{"http", "https", "unix"}, "http")
+			if (err != nil) != tt.wantErr {
+				t.Errorf("ExpandProxyTargetValue(%q) error = %v, wantErr %v", tt.target, err, tt.wantErr)
+			}
+		})
+	}
+}

+ 1 - 0
cmd/tailscaled/tailscaled.go

@@ -401,6 +401,7 @@ func run() (err error) {
 	// Install an event bus as early as possible, so that it's
 	// available universally when setting up everything else.
 	sys := tsd.NewSystem()
+	sys.SocketPath = args.socketpath
 
 	// Parse config, if specified, to fail early if it's invalid.
 	var conf *conffile.Config

+ 69 - 2
ipn/ipnlocal/serve.go

@@ -76,6 +76,10 @@ const (
 // current etag of a resource.
 var ErrETagMismatch = errors.New("etag mismatch")
 
+// ErrProxyToTailscaledSocket is returned when attempting to proxy
+// to the tailscaled socket itself, which would create a loop.
+var ErrProxyToTailscaledSocket = errors.New("cannot proxy to tailscaled socket")
+
 var serveHTTPContextKey ctxkey.Key[*serveHTTPContext]
 
 type serveHTTPContext struct {
@@ -812,6 +816,27 @@ func (b *LocalBackend) getServeHandler(r *http.Request) (_ ipn.HTTPHandlerView,
 // we serve requests for. `backend` is a HTTPHandler.Proxy string (url, hostport or just port).
 func (b *LocalBackend) proxyHandlerForBackend(backend string) (http.Handler, error) {
 	targetURL, insecure := expandProxyArg(backend)
+
+	// Handle unix: scheme specially
+	if strings.HasPrefix(targetURL, "unix:") {
+		socketPath := strings.TrimPrefix(targetURL, "unix:")
+		if socketPath == "" {
+			return nil, fmt.Errorf("empty unix socket path")
+		}
+		if b.isTailscaledSocket(socketPath) {
+			return nil, ErrProxyToTailscaledSocket
+		}
+		u, _ := url.Parse("http://localhost")
+		return &reverseProxy{
+			logf:       b.logf,
+			url:        u,
+			insecure:   false,
+			backend:    backend,
+			lb:         b,
+			socketPath: socketPath,
+		}, nil
+	}
+
 	u, err := url.Parse(targetURL)
 	if err != nil {
 		return nil, fmt.Errorf("invalid url %s: %w", targetURL, err)
@@ -826,6 +851,22 @@ func (b *LocalBackend) proxyHandlerForBackend(backend string) (http.Handler, err
 	return p, nil
 }
 
+// isTailscaledSocket reports whether socketPath refers to the same file
+// as the tailscaled socket. It uses os.SameFile to handle symlinks,
+// bind mounts, and other path variations.
+func (b *LocalBackend) isTailscaledSocket(socketPath string) bool {
+	tailscaledSocket := b.sys.SocketPath
+	if tailscaledSocket == "" {
+		return false
+	}
+	fi1, err1 := os.Stat(socketPath)
+	fi2, err2 := os.Stat(tailscaledSocket)
+	if err1 != nil || err2 != nil {
+		return false
+	}
+	return os.SameFile(fi1, fi2)
+}
+
 // reverseProxy is a proxy that forwards a request to a backend host
 // (preconfigured via ipn.ServeConfig). If the host is configured with
 // http+insecure prefix, connection between proxy and backend will be over
@@ -840,6 +881,7 @@ type reverseProxy struct {
 	insecure      bool
 	backend       string
 	lb            *LocalBackend
+	socketPath    string                          // path to unix socket, empty for TCP
 	httpTransport lazy.SyncValue[*http.Transport] // transport for non-h2c backends
 	h2cTransport  lazy.SyncValue[*http.Transport] // transport for h2c backends
 	// closed tracks whether proxy is closed/currently closing.
@@ -880,7 +922,12 @@ func (rp *reverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 			r.Out.URL.RawPath = rp.url.RawPath
 		}
 
-		r.Out.Host = r.In.Host
+		// For Unix sockets, use the URL's host (localhost) instead of the incoming host
+		if rp.socketPath != "" {
+			r.Out.Host = rp.url.Host
+		} else {
+			r.Out.Host = r.In.Host
+		}
 		addProxyForwardedHeaders(r)
 		rp.lb.addTailscaleIdentityHeaders(r)
 		if err := rp.lb.addAppCapabilitiesHeader(r); err != nil {
@@ -905,8 +952,16 @@ func (rp *reverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 // to the backend. The Transport gets created lazily, at most once.
 func (rp *reverseProxy) getTransport() *http.Transport {
 	return rp.httpTransport.Get(func() *http.Transport {
+		dial := rp.lb.dialer.SystemDial
+		if rp.socketPath != "" {
+			dial = func(ctx context.Context, _, _ string) (net.Conn, error) {
+				var d net.Dialer
+				return d.DialContext(ctx, "unix", rp.socketPath)
+			}
+		}
+
 		return &http.Transport{
-			DialContext: rp.lb.dialer.SystemDial,
+			DialContext: dial,
 			TLSClientConfig: &tls.Config{
 				InsecureSkipVerify: rp.insecure,
 			},
@@ -929,6 +984,10 @@ func (rp *reverseProxy) getH2CTransport() http.RoundTripper {
 		tr := &http.Transport{
 			Protocols: &p,
 			DialTLSContext: func(ctx context.Context, network string, addr string) (net.Conn, error) {
+				if rp.socketPath != "" {
+					var d net.Dialer
+					return d.DialContext(ctx, "unix", rp.socketPath)
+				}
 				return rp.lb.dialer.SystemDial(ctx, "tcp", rp.url.Host)
 			},
 		}
@@ -940,6 +999,10 @@ func (rp *reverseProxy) getH2CTransport() http.RoundTripper {
 // for a h2c server, but sufficient for our particular use case.
 func (rp *reverseProxy) shouldProxyViaH2C(r *http.Request) bool {
 	contentType := r.Header.Get(contentTypeHeader)
+	// For unix sockets, check if it's gRPC content to determine h2c
+	if rp.socketPath != "" {
+		return r.ProtoMajor == 2 && isGRPCContentType(contentType)
+	}
 	return r.ProtoMajor == 2 && strings.HasPrefix(rp.backend, "http://") && isGRPCContentType(contentType)
 }
 
@@ -1184,6 +1247,10 @@ func expandProxyArg(s string) (targetURL string, insecureSkipVerify bool) {
 	if s == "" {
 		return "", false
 	}
+	// Unix sockets - return as-is
+	if strings.HasPrefix(s, "unix:") {
+		return s, false
+	}
 	if strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") {
 		return s, false
 	}

+ 218 - 0
ipn/ipnlocal/serve_unix_test.go

@@ -0,0 +1,218 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build unix
+
+package ipnlocal
+
+import (
+	"errors"
+	"fmt"
+	"net"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"os"
+	"path/filepath"
+	"testing"
+	"time"
+
+	"tailscale.com/tstest"
+)
+
+func TestExpandProxyArgUnix(t *testing.T) {
+	tests := []struct {
+		input        string
+		wantURL      string
+		wantInsecure bool
+	}{
+		{
+			input:   "unix:/tmp/test.sock",
+			wantURL: "unix:/tmp/test.sock",
+		},
+		{
+			input:   "unix:/var/run/docker.sock",
+			wantURL: "unix:/var/run/docker.sock",
+		},
+		{
+			input:   "unix:./relative.sock",
+			wantURL: "unix:./relative.sock",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.input, func(t *testing.T) {
+			gotURL, gotInsecure := expandProxyArg(tt.input)
+			if gotURL != tt.wantURL {
+				t.Errorf("expandProxyArg(%q) url = %q, want %q", tt.input, gotURL, tt.wantURL)
+			}
+			if gotInsecure != tt.wantInsecure {
+				t.Errorf("expandProxyArg(%q) insecure = %v, want %v", tt.input, gotInsecure, tt.wantInsecure)
+			}
+		})
+	}
+}
+
+func TestServeUnixSocket(t *testing.T) {
+	// Create a temporary directory for our socket
+	tmpDir := t.TempDir()
+	socketPath := filepath.Join(tmpDir, "test.sock")
+
+	// Create a test HTTP server on Unix socket
+	listener, err := net.Listen("unix", socketPath)
+	if err != nil {
+		t.Fatalf("failed to create unix socket listener: %v", err)
+	}
+	defer listener.Close()
+
+	testResponse := "Hello from Unix socket!"
+	testServer := &http.Server{
+		Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			w.Header().Set("Content-Type", "text/plain")
+			fmt.Fprint(w, testResponse)
+		}),
+	}
+
+	go testServer.Serve(listener)
+	defer testServer.Close()
+
+	// Wait for server to be ready
+	time.Sleep(50 * time.Millisecond)
+
+	// Create LocalBackend with test logger
+	logf := tstest.WhileTestRunningLogger(t)
+	b := newTestBackend(t)
+	b.logf = logf
+
+	// Test creating proxy handler for Unix socket
+	handler, err := b.proxyHandlerForBackend("unix:" + socketPath)
+	if err != nil {
+		t.Fatalf("proxyHandlerForBackend failed: %v", err)
+	}
+
+	// Verify it's a reverseProxy with correct socketPath
+	rp, ok := handler.(*reverseProxy)
+	if !ok {
+		t.Fatalf("expected *reverseProxy, got %T", handler)
+	}
+	if rp.socketPath != socketPath {
+		t.Errorf("socketPath = %q, want %q", rp.socketPath, socketPath)
+	}
+	if rp.url.Host != "localhost" {
+		t.Errorf("url.Host = %q, want %q", rp.url.Host, "localhost")
+	}
+}
+
+func TestServeUnixSocketErrors(t *testing.T) {
+	logf := tstest.WhileTestRunningLogger(t)
+	b := newTestBackend(t)
+	b.logf = logf
+
+	// Test empty socket path
+	_, err := b.proxyHandlerForBackend("unix:")
+	if err == nil {
+		t.Error("expected error for empty socket path")
+	}
+
+	// Test non-existent socket - should create handler but fail on request
+	nonExistentSocket := filepath.Join(t.TempDir(), "nonexistent.sock")
+	handler, err := b.proxyHandlerForBackend("unix:" + nonExistentSocket)
+	if err != nil {
+		t.Fatalf("proxyHandlerForBackend failed: %v", err)
+	}
+
+	req := httptest.NewRequest("GET", "http://foo.test.ts.net/", nil)
+	rec := httptest.NewRecorder()
+
+	handler.ServeHTTP(rec, req)
+
+	// Should get a 502 Bad Gateway when socket doesn't exist
+	if rec.Code != http.StatusBadGateway {
+		t.Errorf("got status %d, want %d for non-existent socket", rec.Code, http.StatusBadGateway)
+	}
+}
+
+func TestReverseProxyConfigurationUnix(t *testing.T) {
+	b := newTestBackend(t)
+
+	// Test that Unix socket backend creates proper reverseProxy
+	backend := "unix:/var/run/test.sock"
+	handler, err := b.proxyHandlerForBackend(backend)
+	if err != nil {
+		t.Fatalf("proxyHandlerForBackend failed: %v", err)
+	}
+
+	rp, ok := handler.(*reverseProxy)
+	if !ok {
+		t.Fatalf("expected *reverseProxy, got %T", handler)
+	}
+
+	// Verify configuration
+	if rp.socketPath != "/var/run/test.sock" {
+		t.Errorf("socketPath = %q, want %q", rp.socketPath, "/var/run/test.sock")
+	}
+	if rp.backend != backend {
+		t.Errorf("backend = %q, want %q", rp.backend, backend)
+	}
+	if rp.insecure {
+		t.Error("insecure should be false for unix sockets")
+	}
+	expectedURL := url.URL{Scheme: "http", Host: "localhost"}
+	if rp.url.Scheme != expectedURL.Scheme || rp.url.Host != expectedURL.Host {
+		t.Errorf("url = %v, want %v", rp.url, expectedURL)
+	}
+}
+
+func TestServeBlocksTailscaledSocket(t *testing.T) {
+	// Use /tmp to avoid macOS socket path length limits
+	tmpDir, err := os.MkdirTemp("/tmp", "ts-test-*")
+	if err != nil {
+		t.Fatalf("failed to create temp dir: %v", err)
+	}
+	defer os.RemoveAll(tmpDir)
+
+	tailscaledSocket := filepath.Join(tmpDir, "ts.sock")
+
+	// Create actual socket file
+	listener, err := net.Listen("unix", tailscaledSocket)
+	if err != nil {
+		t.Fatalf("failed to create tailscaled socket: %v", err)
+	}
+	defer listener.Close()
+
+	b := newTestBackend(t)
+	b.sys.SocketPath = tailscaledSocket
+
+	// Direct path to tailscaled socket should be blocked
+	_, err = b.proxyHandlerForBackend("unix:" + tailscaledSocket)
+	if !errors.Is(err, ErrProxyToTailscaledSocket) {
+		t.Errorf("direct path: got err=%v, want ErrProxyToTailscaledSocket", err)
+	}
+
+	// Symlink to tailscaled socket should be blocked
+	symlinkPath := filepath.Join(tmpDir, "link")
+	if err := os.Symlink(tailscaledSocket, symlinkPath); err != nil {
+		t.Fatalf("failed to create symlink: %v", err)
+	}
+
+	_, err = b.proxyHandlerForBackend("unix:" + symlinkPath)
+	if !errors.Is(err, ErrProxyToTailscaledSocket) {
+		t.Errorf("symlink: got err=%v, want ErrProxyToTailscaledSocket", err)
+	}
+
+	// Different socket should work
+	otherSocket := filepath.Join(tmpDir, "ok.sock")
+	listener2, err := net.Listen("unix", otherSocket)
+	if err != nil {
+		t.Fatalf("failed to create other socket: %v", err)
+	}
+	defer listener2.Close()
+
+	handler, err := b.proxyHandlerForBackend("unix:" + otherSocket)
+	if err != nil {
+		t.Errorf("legitimate socket should not be blocked: %v", err)
+	}
+	if handler == nil {
+		t.Error("expected valid handler for legitimate socket")
+	}
+}

+ 16 - 0
ipn/serve.go

@@ -10,6 +10,7 @@ import (
 	"net"
 	"net/netip"
 	"net/url"
+	"runtime"
 	"slices"
 	"strconv"
 	"strings"
@@ -713,6 +714,21 @@ func ExpandProxyTargetValue(target string, supportedSchemes []string, defaultSch
 		return fmt.Sprintf("%s://%s:%d", defaultScheme, host, port), nil
 	}
 
+	// handle unix: scheme specially - it doesn't use standard URL format
+	if strings.HasPrefix(target, "unix:") {
+		if !slices.Contains(supportedSchemes, "unix") {
+			return "", fmt.Errorf("unix sockets are not supported for this target type")
+		}
+		if runtime.GOOS == "windows" {
+			return "", fmt.Errorf("unix socket serve target is not supported on Windows")
+		}
+		path := strings.TrimPrefix(target, "unix:")
+		if path == "" {
+			return "", fmt.Errorf("unix socket path cannot be empty")
+		}
+		return target, nil
+	}
+
 	hasScheme := true
 	// prepend scheme if not present
 	if !strings.Contains(target, "://") {

+ 82 - 0
ipn/serve_expand_test.go

@@ -0,0 +1,82 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package ipn
+
+import (
+	"runtime"
+	"testing"
+)
+
+func TestExpandProxyTargetValueUnix(t *testing.T) {
+	tests := []struct {
+		name             string
+		target           string
+		supportedSchemes []string
+		defaultScheme    string
+		want             string
+		wantErr          bool
+		skipOnWindows    bool
+	}{
+		{
+			name:             "unix-socket-absolute-path",
+			target:           "unix:/tmp/myservice.sock",
+			supportedSchemes: []string{"http", "https", "unix"},
+			defaultScheme:    "http",
+			want:             "unix:/tmp/myservice.sock",
+			skipOnWindows:    true,
+		},
+		{
+			name:             "unix-socket-var-run",
+			target:           "unix:/var/run/docker.sock",
+			supportedSchemes: []string{"http", "https", "unix"},
+			defaultScheme:    "http",
+			want:             "unix:/var/run/docker.sock",
+			skipOnWindows:    true,
+		},
+		{
+			name:             "unix-socket-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:",
+			supportedSchemes: []string{"http", "https", "unix"},
+			defaultScheme:    "http",
+			wantErr:          true,
+		},
+		{
+			name:             "unix-socket-not-in-supported-schemes",
+			target:           "unix:/tmp/myservice.sock",
+			supportedSchemes: []string{"http", "https"},
+			defaultScheme:    "http",
+			wantErr:          true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if tt.skipOnWindows && runtime.GOOS == "windows" {
+				t.Skip("skipping unix socket test on Windows")
+			}
+
+			// On Windows, unix sockets should always error
+			if runtime.GOOS == "windows" && !tt.wantErr {
+				tt.wantErr = true
+			}
+
+			got, err := ExpandProxyTargetValue(tt.target, tt.supportedSchemes, tt.defaultScheme)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("ExpandProxyTargetValue() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if !tt.wantErr && got != tt.want {
+				t.Errorf("ExpandProxyTargetValue() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}

+ 4 - 0
tsd/tsd.go

@@ -68,6 +68,10 @@ type System struct {
 	// LocalBackend tracks the current config after any reloads.
 	InitialConfig *conffile.Config
 
+	// SocketPath is the path to the tailscaled Unix socket.
+	// It is used to prevent serve from proxying to our own socket.
+	SocketPath string
+
 	// onlyNetstack is whether the Tun value is a fake TUN device
 	// and we're using netstack for everything.
 	onlyNetstack bool