From 91d05773f7af728397946a231326c1d68108da18 Mon Sep 17 00:00:00 2001 From: "Peter A." Date: Fri, 28 Nov 2025 23:39:41 +0100 Subject: [PATCH] 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. 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. --- cmd/tailscale/cli/serve_v2.go | 7 +- cmd/tailscale/cli/serve_v2_unix_test.go | 86 +++++++++++++ ipn/ipnlocal/serve.go | 50 +++++++- ipn/ipnlocal/serve_unix_test.go | 162 ++++++++++++++++++++++++ ipn/serve.go | 16 +++ ipn/serve_expand_test.go | 82 ++++++++++++ 6 files changed, 400 insertions(+), 3 deletions(-) create mode 100644 cmd/tailscale/cli/serve_v2_unix_test.go create mode 100644 ipn/ipnlocal/serve_unix_test.go create mode 100644 ipn/serve_expand_test.go diff --git a/cmd/tailscale/cli/serve_v2.go b/cmd/tailscale/cli/serve_v2.go index 89d247be9..d474696b3 100644 --- a/cmd/tailscale/cli/serve_v2.go +++ b/cmd/tailscale/cli/serve_v2.go @@ -138,6 +138,7 @@ var serveHelpCommon = strings.TrimSpace(` 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 } diff --git a/cmd/tailscale/cli/serve_v2_unix_test.go b/cmd/tailscale/cli/serve_v2_unix_test.go new file mode 100644 index 000000000..fa4ff8b4f --- /dev/null +++ b/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 !windows + +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) + } + }) + } +} diff --git a/ipn/ipnlocal/serve.go b/ipn/ipnlocal/serve.go index ef4e91545..039407412 100644 --- a/ipn/ipnlocal/serve.go +++ b/ipn/ipnlocal/serve.go @@ -812,6 +812,24 @@ 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") + } + 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) @@ -840,6 +858,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 +899,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 +929,18 @@ 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 { + var dialContext func(ctx context.Context, network, addr string) (net.Conn, error) + if rp.socketPath != "" { + dialContext = func(ctx context.Context, _, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "unix", rp.socketPath) + } + } else { + dialContext = rp.lb.dialer.SystemDial + } + return &http.Transport{ - DialContext: rp.lb.dialer.SystemDial, + DialContext: dialContext, TLSClientConfig: &tls.Config{ InsecureSkipVerify: rp.insecure, }, @@ -929,6 +963,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 +978,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 +1226,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 } diff --git a/ipn/ipnlocal/serve_unix_test.go b/ipn/ipnlocal/serve_unix_test.go new file mode 100644 index 000000000..5216c8adf --- /dev/null +++ b/ipn/ipnlocal/serve_unix_test.go @@ -0,0 +1,162 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !windows + +package ipnlocal + +import ( + "fmt" + "net" + "net/http" + "net/http/httptest" + "net/url" + "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) + } +} diff --git a/ipn/serve.go b/ipn/serve.go index 1f1557889..76823a846 100644 --- a/ipn/serve.go +++ b/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, "://") { diff --git a/ipn/serve_expand_test.go b/ipn/serve_expand_test.go new file mode 100644 index 000000000..b977238fe --- /dev/null +++ b/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) + } + }) + } +}