pull/18089/merge
Peter A. 23 hours ago committed by GitHub
commit c3a83790a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

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

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

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

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

@ -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, "://") {

@ -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)
}
})
}
}
Loading…
Cancel
Save