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>main
parent
557457f3c2
commit
f4d34f38be
@ -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) |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
@ -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") |
||||
} |
||||
} |
||||
@ -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…
Reference in new issue