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:
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user