1 Commits

Author SHA1 Message Date
codinget dd9c9f6844 feat(tsconnect): expose service advertisement to JS
Add SetExplicitServices on LocalBackend so the browser WASM node can
declare TCP/UDP services that get uploaded to the control server and
distributed to all peers in the netmap — without the OS port-scanner
(portlist extension) that cannot run in a browser.

The ShouldUploadServices gate in hostInfoWithServicesLocked is bypassed
when services were set explicitly, leaving all other callers unaffected.

On the JS side, a new setServices(services) method accepts an array of
{proto, port, description?} objects.  The netmap JSON now includes a
services field on every node (self and peers), populated from
Hostinfo.Services with internal peerapi entries stripped (they are
already reflected in peerAPIURL).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:57:27 +00:00
2 changed files with 75 additions and 6 deletions
+54 -5
View File
@@ -361,6 +361,13 @@ 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()
}), }),
"setServices": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 1 {
log.Printf("Usage: setServices(services)")
return nil
}
return jsIPN.setServices(args[0])
}),
"localAPI": js.FuncOf(func(this js.Value, args []js.Value) any { "localAPI": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) < 2 { if len(args) < 2 {
log.Printf("Usage: localAPI(method, path[, body])") log.Printf("Usage: localAPI(method, path[, body])")
@@ -467,6 +474,7 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
NodeKey: nm.NodeKey.String(), NodeKey: nm.NodeKey.String(),
MachineKey: nm.MachineKey.String(), MachineKey: nm.MachineKey.String(),
PeerAPIURL: selfPeerAPIURL, PeerAPIURL: selfPeerAPIURL,
Services: userServicesFromView(nm.SelfNode.Hostinfo().Services()),
}, },
MachineStatus: jsMachineStatus[nm.GetMachineStatus()], MachineStatus: jsMachineStatus[nm.GetMachineStatus()],
}, },
@@ -516,6 +524,7 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
MachineKey: p.Machine().String(), MachineKey: p.Machine().String(),
NodeKey: p.Key().String(), NodeKey: p.Key().String(),
PeerAPIURL: peerURL, PeerAPIURL: peerURL,
Services: userServicesFromView(p.Hostinfo().Services()),
}, },
Online: p.Online().Clone(), Online: p.Online().Clone(),
TailscaleSSHEnabled: p.Hostinfo().TailscaleSSHEnabled(), TailscaleSSHEnabled: p.Hostinfo().TailscaleSSHEnabled(),
@@ -1328,6 +1337,39 @@ func (i *jsIPN) suggestExitNode() js.Value {
}) })
} }
func (i *jsIPN) setServices(jsServices js.Value) js.Value {
return makePromise(func() (any, error) {
n := jsServices.Length()
svcs := make([]tailcfg.Service, 0, n)
for idx := range n {
s := jsServices.Index(idx)
proto := tailcfg.ServiceProto(s.Get("proto").String())
port := uint16(s.Get("port").Int())
var desc string
if d := s.Get("description"); d.Type() == js.TypeString {
desc = d.String()
}
svcs = append(svcs, tailcfg.Service{Proto: proto, Port: port, Description: desc})
}
i.lb.SetExplicitServices(svcs)
return nil, nil
})
}
// userServicesFromView converts a hostinfo services slice to jsService entries,
// filtering out internal peerapi protocol entries (already reflected in peerAPIURL).
func userServicesFromView(svcs views.Slice[tailcfg.Service]) []jsService {
var out []jsService
for _, s := range svcs.All() {
switch s.Proto {
case tailcfg.PeerAPI4, tailcfg.PeerAPI6, tailcfg.PeerAPIDNS:
continue
}
out = append(out, jsService{Proto: string(s.Proto), Port: s.Port, Description: s.Description})
}
return out
}
func (i *jsIPN) localAPI(method, path, body string) js.Value { func (i *jsIPN) localAPI(method, path, body string) js.Value {
return makePromise(func() (any, error) { return makePromise(func() (any, error) {
h := localapi.NewHandler(localapi.HandlerConfig{ h := localapi.NewHandler(localapi.HandlerConfig{
@@ -1564,12 +1606,19 @@ type jsNetMap struct {
LockedOut bool `json:"lockedOut"` LockedOut bool `json:"lockedOut"`
} }
type jsService struct {
Proto string `json:"proto"`
Port uint16 `json:"port"`
Description string `json:"description,omitempty"`
}
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"` PeerAPIURL string `json:"peerAPIURL,omitempty"`
Services []jsService `json:"services,omitempty"`
} }
type jsNetMapSelfNode struct { type jsNetMapSelfNode struct {
+21 -1
View File
@@ -294,6 +294,7 @@ type LocalBackend struct {
capTailnetLock bool // whether netMap contains the tailnet lock capability capTailnetLock bool // whether netMap contains the tailnet lock capability
// hostinfo is mutated in-place while mu is held. // hostinfo is mutated in-place while mu is held.
hostinfo *tailcfg.Hostinfo // TODO(nickkhyl): move to nodeBackend hostinfo *tailcfg.Hostinfo // TODO(nickkhyl): move to nodeBackend
explicitServices []tailcfg.Service // services set explicitly via SetExplicitServices; always uploaded
nmExpiryTimer tstime.TimerController // for updating netMap on node expiry; can be nil; TODO(nickkhyl): move to nodeBackend nmExpiryTimer tstime.TimerController // for updating netMap on node expiry; can be nil; TODO(nickkhyl): move to nodeBackend
activeLogin string // last logged LoginName from netMap; TODO(nickkhyl): move to nodeBackend (or remove? it's in [ipn.LoginProfile]). activeLogin string // last logged LoginName from netMap; TODO(nickkhyl): move to nodeBackend (or remove? it's in [ipn.LoginProfile]).
engineStatus ipn.EngineStatus engineStatus ipn.EngineStatus
@@ -4967,6 +4968,23 @@ func (b *LocalBackend) setPortlistServices(sl []tailcfg.Service) {
b.doSetHostinfoFilterServices() b.doSetHostinfoFilterServices()
} }
// SetExplicitServices sets the services this node advertises on the netmap.
// Unlike the OS port-scan path (setPortlistServices), services set here are
// always uploaded to the control server regardless of the ShouldUploadServices
// hook — suitable for environments like browser WASM where OS port scanning is
// unavailable and services are declared programmatically.
func (b *LocalBackend) SetExplicitServices(sl []tailcfg.Service) {
b.mu.Lock()
if b.hostinfo == nil {
b.hostinfo = new(tailcfg.Hostinfo)
}
b.hostinfo.Services = sl
b.explicitServices = sl
b.mu.Unlock()
b.doSetHostinfoFilterServices()
}
// doSetHostinfoFilterServices calls SetHostinfo on the controlclient, // doSetHostinfoFilterServices calls SetHostinfo on the controlclient,
// possibly after mangling the given hostinfo. // possibly after mangling the given hostinfo.
// //
@@ -5011,7 +5029,9 @@ func (b *LocalBackend) hostInfoWithServicesLocked() *tailcfg.Hostinfo {
// Make a shallow copy of hostinfo so we can mutate // Make a shallow copy of hostinfo so we can mutate
// at the Service field. // at the Service field.
if f, ok := b.extHost.Hooks().ShouldUploadServices.GetOk(); !ok || !f() { if f, ok := b.extHost.Hooks().ShouldUploadServices.GetOk(); !ok || !f() {
hi.Services = []tailcfg.Service{} if len(b.explicitServices) == 0 {
hi.Services = []tailcfg.Service{}
}
} }
// Don't mutate hi.Service's underlying array. Append to // Don't mutate hi.Service's underlying array. Append to