net/tsdial, ipn/localapi, client/local: let clients dial non-Tailscale addresses directly

Add a tsdial.Dialer.UserDialPlan method that resolves an address and
reports whether the dialer would route it via Tailscale. The LocalAPI
/dial handler now uses this to skip proxying for addresses that aren't
Tailscale routes (e.g. localhost), returning a Dial-Self response with
the resolved address so the client can dial it directly. This avoids
an unnecessary round-trip through the daemon for local connections.

The client's UserDial handles the new response by dialing the resolved
address itself, and the server passes the pre-resolved IP:port for
Tailscale dials to avoid redundant DNS lookups.

Thanks to giacomo and Moyao for pointing this out!

Updates tailscale/corp#39702

Change-Id: I78d640f11ccd92f43ddd505cbb0db8fee19f43a6
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick
2026-04-06 23:45:17 +00:00
committed by Brad Fitzpatrick
parent 649781df84
commit 0e10a3f580
6 changed files with 276 additions and 4 deletions
+66
View File
@@ -500,3 +500,69 @@ func TestServeWithUnhealthyState(t *testing.T) {
})
}
}
func TestServeDialSelf(t *testing.T) {
h := handlerForTest(t, &Handler{
PermitRead: true,
PermitWrite: true,
b: newTestLocalBackend(t),
})
tests := []struct {
name string
host string
port string
wantSelf bool
wantAddr string
wantStatus int
}{
{
name: "loopback_v4",
host: "127.0.0.1",
port: "8080",
wantSelf: true,
wantAddr: "127.0.0.1:8080",
wantStatus: http.StatusOK,
},
{
name: "loopback_v6",
host: "::1",
port: "8080",
wantSelf: true,
wantAddr: "[::1]:8080",
wantStatus: http.StatusOK,
},
{
name: "localhost",
host: "localhost",
port: "3000",
wantSelf: true,
wantStatus: http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("POST", "http://local-tailscaled.sock/localapi/v0/dial", nil)
req.Header.Set("Connection", "upgrade")
req.Header.Set("Upgrade", "ts-dial")
req.Header.Set("Dial-Host", tt.host)
req.Header.Set("Dial-Port", tt.port)
resp := httptest.NewRecorder()
h.serveDial(resp, req)
if resp.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d; body: %s", resp.Code, tt.wantStatus, resp.Body.String())
}
gotSelf := resp.Header().Get("Dial-Self") == "true"
if gotSelf != tt.wantSelf {
t.Errorf("Dial-Self = %v, want %v", gotSelf, tt.wantSelf)
}
if tt.wantAddr != "" {
if got := resp.Header().Get("Dial-Addr"); got != tt.wantAddr {
t.Errorf("Dial-Addr = %q, want %q", got, tt.wantAddr)
}
}
})
}
}