From dd9c9f68444775be071ee7bd5d9d3b03a2164ee5 Mon Sep 17 00:00:00 2001 From: Codinget Date: Mon, 8 Jun 2026 22:57:27 +0000 Subject: [PATCH] feat(tsconnect): expose service advertisement to JS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/tsconnect/wasm/wasm_js.go | 59 ++++++++++++++++++++++++++++++++--- ipn/ipnlocal/local.go | 22 ++++++++++++- 2 files changed, 75 insertions(+), 6 deletions(-) diff --git a/cmd/tsconnect/wasm/wasm_js.go b/cmd/tsconnect/wasm/wasm_js.go index d37921211..652e651e3 100644 --- a/cmd/tsconnect/wasm/wasm_js.go +++ b/cmd/tsconnect/wasm/wasm_js.go @@ -361,6 +361,13 @@ func newIPN(jsConfig js.Value) map[string]any { "suggestExitNode": js.FuncOf(func(this js.Value, args []js.Value) any { 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 { if len(args) < 2 { log.Printf("Usage: localAPI(method, path[, body])") @@ -467,6 +474,7 @@ func (i *jsIPN) run(jsCallbacks js.Value) { NodeKey: nm.NodeKey.String(), MachineKey: nm.MachineKey.String(), PeerAPIURL: selfPeerAPIURL, + Services: userServicesFromView(nm.SelfNode.Hostinfo().Services()), }, MachineStatus: jsMachineStatus[nm.GetMachineStatus()], }, @@ -516,6 +524,7 @@ func (i *jsIPN) run(jsCallbacks js.Value) { MachineKey: p.Machine().String(), NodeKey: p.Key().String(), PeerAPIURL: peerURL, + Services: userServicesFromView(p.Hostinfo().Services()), }, Online: p.Online().Clone(), 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 { return makePromise(func() (any, error) { h := localapi.NewHandler(localapi.HandlerConfig{ @@ -1564,12 +1606,19 @@ type jsNetMap struct { LockedOut bool `json:"lockedOut"` } +type jsService struct { + Proto string `json:"proto"` + Port uint16 `json:"port"` + Description string `json:"description,omitempty"` +} + type jsNetMapNode struct { - Name string `json:"name"` - Addresses []string `json:"addresses"` - MachineKey string `json:"machineKey"` - NodeKey string `json:"nodeKey"` - PeerAPIURL string `json:"peerAPIURL,omitempty"` + Name string `json:"name"` + Addresses []string `json:"addresses"` + MachineKey string `json:"machineKey"` + NodeKey string `json:"nodeKey"` + PeerAPIURL string `json:"peerAPIURL,omitempty"` + Services []jsService `json:"services,omitempty"` } type jsNetMapSelfNode struct { diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index e8138aa50..4bffbea3b 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -294,6 +294,7 @@ type LocalBackend struct { capTailnetLock bool // whether netMap contains the tailnet lock capability // hostinfo is mutated in-place while mu is held. 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 activeLogin string // last logged LoginName from netMap; TODO(nickkhyl): move to nodeBackend (or remove? it's in [ipn.LoginProfile]). engineStatus ipn.EngineStatus @@ -4967,6 +4968,23 @@ func (b *LocalBackend) setPortlistServices(sl []tailcfg.Service) { 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, // 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 // at the Service field. 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