feat(tsconnect): add peerAPIURL to netmap and localAPI in-process bridge

Include the PeerAPI base URL (http://ip:port) in every node entry of the
notifyNetMap payload — for self via LocalBackend.GetPeerAPIPort, for peers
by reading the PeerAPI4/PeerAPI6 Services entries in their Hostinfo. The URL
mirrors the address-family preference used by peerAPIBase (prefer IPv4).

Add a localAPI(method, path, body?) WASM binding that dispatches in-process
HTTP requests directly to a LocalAPI handler with full read/write/cert
permissions, returning {status, body}. Enables TypeScript callers to access
any LocalAPI endpoint (ACL policy, Taildrive shares, etc.) without network
setup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 01:19:37 +00:00
parent 7f5983eaab
commit 8514045909
+123 -6
View File
@@ -18,10 +18,12 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"log" "log"
"math/rand/v2" "math/rand/v2"
"net" "net"
"net/http" "net/http"
"net/http/httptest"
"net/netip" "net/netip"
"strconv" "strconv"
"strings" "strings"
@@ -42,6 +44,7 @@ import (
"tailscale.com/ipn/ipnauth" "tailscale.com/ipn/ipnauth"
"tailscale.com/ipn/ipnlocal" "tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/ipnserver" "tailscale.com/ipn/ipnserver"
"tailscale.com/ipn/localapi"
"tailscale.com/ipn/store/mem" "tailscale.com/ipn/store/mem"
"tailscale.com/logpolicy" "tailscale.com/logpolicy"
"tailscale.com/logtail" "tailscale.com/logtail"
@@ -52,6 +55,7 @@ import (
"tailscale.com/safesocket" "tailscale.com/safesocket"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
"tailscale.com/tsd" "tailscale.com/tsd"
"tailscale.com/types/logid"
"tailscale.com/types/views" "tailscale.com/types/views"
"tailscale.com/wgengine" "tailscale.com/wgengine"
"tailscale.com/wgengine/netstack" "tailscale.com/wgengine/netstack"
@@ -173,6 +177,7 @@ func newIPN(jsConfig js.Value) map[string]any {
controlURL: controlURL, controlURL: controlURL,
authKey: authKey, authKey: authKey,
hostname: hostname, hostname: hostname,
logID: logid,
funnelPorts: make(map[uint16]*funnelListenerEntry), funnelPorts: make(map[uint16]*funnelListenerEntry),
} }
lb.SetTCPHandlerForFunnelFlow(jsIPN.handleFunnelTCP) lb.SetTCPHandlerForFunnelFlow(jsIPN.handleFunnelTCP)
@@ -356,6 +361,17 @@ func newIPN(jsConfig js.Value) map[string]any {
"suggestExitNode": js.FuncOf(func(this js.Value, args []js.Value) any { "suggestExitNode": js.FuncOf(func(this js.Value, args []js.Value) any {
return jsIPN.suggestExitNode() return jsIPN.suggestExitNode()
}), }),
"localAPI": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) < 2 {
log.Printf("Usage: localAPI(method, path[, body])")
return nil
}
body := ""
if len(args) >= 3 {
body = args[2].String()
}
return jsIPN.localAPI(args[0].String(), args[1].String(), body)
}),
} }
} }
@@ -367,6 +383,7 @@ type jsIPN struct {
controlURL string controlURL string
authKey string authKey string
hostname string hostname string
logID logid.PublicID
funnelMu sync.Mutex funnelMu sync.Mutex
funnelPorts map[uint16]*funnelListenerEntry funnelPorts map[uint16]*funnelListenerEntry
@@ -417,6 +434,31 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
notifyState(*n.State) notifyState(*n.State)
} }
if nm := n.NetMap; nm != nil { if nm := n.NetMap; nm != nil {
// Determine which address families we have, for peer peerAPI URL selection.
var selfHave4, selfHave6 bool
for _, a := range nm.GetAddresses().All() {
if !a.IsSingleIP() {
continue
}
if a.Addr().Is4() {
selfHave4 = true
} else if a.Addr().Is6() {
selfHave6 = true
}
}
// Self peerAPI URL: own port as reported by LocalBackend.
selfPeerAPIURL := ""
for _, a := range nm.GetAddresses().All() {
if !a.IsSingleIP() {
continue
}
if port, ok := i.lb.GetPeerAPIPort(a.Addr()); ok && port != 0 {
selfPeerAPIURL = fmt.Sprintf("http://%v", netip.AddrPortFrom(a.Addr(), port))
break
}
}
jsNetMap := jsNetMap{ jsNetMap := jsNetMap{
Self: jsNetMapSelfNode{ Self: jsNetMapSelfNode{
jsNetMapNode: jsNetMapNode{ jsNetMapNode: jsNetMapNode{
@@ -424,6 +466,7 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
Addresses: mapSliceView(nm.GetAddresses(), func(a netip.Prefix) string { return a.Addr().String() }), Addresses: mapSliceView(nm.GetAddresses(), func(a netip.Prefix) string { return a.Addr().String() }),
NodeKey: nm.NodeKey.String(), NodeKey: nm.NodeKey.String(),
MachineKey: nm.MachineKey.String(), MachineKey: nm.MachineKey.String(),
PeerAPIURL: selfPeerAPIURL,
}, },
MachineStatus: jsMachineStatus[nm.GetMachineStatus()], MachineStatus: jsMachineStatus[nm.GetMachineStatus()],
}, },
@@ -434,15 +477,45 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
name = p.Hostinfo().Hostname() name = p.Hostinfo().Hostname()
} }
addrs := make([]string, p.Addresses().Len()) addrs := make([]string, p.Addresses().Len())
for i, ap := range p.Addresses().All() { for idx, ap := range p.Addresses().All() {
addrs[i] = ap.Addr().String() addrs[idx] = ap.Addr().String()
} }
// Peer peerAPI URL from the peer's advertised Services.
peerURL := ""
var pp4, pp6 uint16
for _, s := range p.Hostinfo().Services().All() {
switch s.Proto {
case tailcfg.PeerAPI4:
pp4 = s.Port
case tailcfg.PeerAPI6:
pp6 = s.Port
}
}
if selfHave4 && pp4 != 0 {
for _, a := range p.Addresses().All() {
if a.IsSingleIP() && a.Addr().Is4() {
peerURL = fmt.Sprintf("http://%v", netip.AddrPortFrom(a.Addr(), pp4))
break
}
}
}
if peerURL == "" && selfHave6 && pp6 != 0 {
for _, a := range p.Addresses().All() {
if a.IsSingleIP() && a.Addr().Is6() {
peerURL = fmt.Sprintf("http://%v", netip.AddrPortFrom(a.Addr(), pp6))
break
}
}
}
return jsNetMapPeerNode{ return jsNetMapPeerNode{
jsNetMapNode: jsNetMapNode{ jsNetMapNode: jsNetMapNode{
Name: name, Name: name,
Addresses: addrs, Addresses: addrs,
MachineKey: p.Machine().String(), MachineKey: p.Machine().String(),
NodeKey: p.Key().String(), NodeKey: p.Key().String(),
PeerAPIURL: peerURL,
}, },
Online: p.Online().Clone(), Online: p.Online().Clone(),
TailscaleSSHEnabled: p.Hostinfo().TailscaleSSHEnabled(), TailscaleSSHEnabled: p.Hostinfo().TailscaleSSHEnabled(),
@@ -1192,6 +1265,49 @@ func (i *jsIPN) suggestExitNode() js.Value {
}) })
} }
func (i *jsIPN) localAPI(method, path, body string) js.Value {
return makePromise(func() (any, error) {
h := localapi.NewHandler(localapi.HandlerConfig{
Actor: &ipnauth.TestActor{
Name: "wasm",
LocalAdmin: true,
},
Backend: i.lb,
Logf: log.Printf,
LogID: i.logID,
EventBus: i.lb.Sys().Bus.Get(),
})
h.PermitRead = true
h.PermitWrite = true
h.PermitCert = true
var bodyReader io.Reader
if body != "" {
bodyReader = strings.NewReader(body)
}
req, err := http.NewRequest(method, "http://local-tailscaled.sock"+path, bodyReader)
if err != nil {
return nil, fmt.Errorf("localAPI: %w", err)
}
// Empty Host passes the validHost check in the LocalAPI handler.
req.Host = ""
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
resp := w.Result()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("localAPI: reading response: %w", err)
}
return map[string]any{
"status": resp.StatusCode,
"body": string(respBody),
}, nil
})
}
// wrapConn exposes a net.Conn to JavaScript with binary (Uint8Array) I/O. // wrapConn exposes a net.Conn to JavaScript with binary (Uint8Array) I/O.
func wrapConn(conn net.Conn) map[string]any { func wrapConn(conn net.Conn) map[string]any {
return map[string]any{ return map[string]any{
@@ -1386,10 +1502,11 @@ type jsNetMap struct {
} }
type jsNetMapNode struct { type jsNetMapNode struct {
Name string `json:"name"` Name string `json:"name"`
Addresses []string `json:"addresses"` Addresses []string `json:"addresses"`
MachineKey string `json:"machineKey"` MachineKey string `json:"machineKey"`
NodeKey string `json:"nodeKey"` NodeKey string `json:"nodeKey"`
PeerAPIURL string `json:"peerAPIURL,omitempty"`
} }
type jsNetMapSelfNode struct { type jsNetMapSelfNode struct {