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. <ink.splatters@pm.me>
Co-authored-by: Lachlan Donald <lachlan@ljd.cc>
This commit is contained in:
Peter A.
2025-11-28 23:39:41 +01:00
committed by Brad Fitzpatrick
parent 557457f3c2
commit f4d34f38be
8 changed files with 482 additions and 3 deletions

View File

@@ -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
}

View File

@@ -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)
}
})
}
}

View File

@@ -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