4 Commits

Author SHA1 Message Date
codinget cada6936b9 feat(wasm): expose taildrive WebDAV server and listDrivePeers via JS bridge
Add drive.go (build tag !ts_omit_drive): implements drive.FileSystemForRemote
with a JS-backed handler. Streams request bodies chunk-by-chunk via
readBodyChunk() and response bodies via write()/end() callbacks so no
full-body buffering occurs regardless of file size. The handler is nil-safe:
returns 404 until setDriveHandler() is called from JS.

Add drive_stub.go (build tag ts_omit_drive): no-op stubs for stripped builds.

Add peer.go: extract buildPeerAPIURL helper (previously inline in run()).

Modify wasm_js.go: call initDriveForRemote before NewLocalBackend (SubSystem
is set-once), expose setDriveHandler and listDrivePeers via wireDriveJS,
and refactor the inline peerAPI URL logic to use buildPeerAPIURL.

listDrivePeers mirrors native driveRemotesFromPeers: returns empty if
DriveAccessEnabled() is false, then filters peers by PeerCapabilityTaildriveSharer
using lb.PeerCaps(addr).HasCapability() (the live ACL-derived cap map).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 19:37:00 +00:00
codinget 78c4511a3d fix(tsconnect): avoid nil services slice in netmap JSON
userServicesFromView returned a nil slice when a node advertised no
services, which (combined with the omitempty tag) caused the
services field to be dropped or serialize as null instead of [].
TypeScript declares services as a non-optional array, so JS callers
calling .find()/.some() on it would throw intermittently depending on
which netmap snapshot they observed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 08:08:32 +00:00
codinget 3a9f6f463a fix(tsconnect): restart map poll after SetExplicitServices
The previous implementation only triggered a lite map update (non-streaming,
OmitPeers=true), whose response is discarded. This meant notifyNetMap was
never called after setServices, so self.services was never visible to the
local node and peers received the update only on their next periodic poll.

Add RestartMap() to controlclient.Auto and call it from SetExplicitServices
after the lite update. This cancels the current streaming poll and starts a
fresh one, causing the control server to send back a full netmap that
includes the updated SelfNode.Hostinfo.Services.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 01:09:37 +00:00
codinget 7bfc64c379 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-15 22:18:58 +00:00
3 changed files with 88 additions and 6 deletions
+54 -5
View File
@@ -370,6 +370,13 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
"shutdown": js.FuncOf(func(this js.Value, args []js.Value) any {
return jsIPN.shutdown()
}),
"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])")
@@ -484,6 +491,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()],
},
@@ -508,6 +516,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(),
@@ -1343,6 +1352,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 {
out := make([]jsService, 0, svcs.Len())
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{
@@ -1579,12 +1621,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"`
}
type jsNetMapSelfNode struct {
+6
View File
@@ -302,6 +302,12 @@ func (c *Auto) restartMap() {
c.updateControl()
}
// RestartMap cancels the existing map poll and starts a fresh streaming one,
// forcing the control server to send a new full netmap response.
func (c *Auto) RestartMap() {
c.restartMap()
}
func (c *Auto) authRoutine() {
defer close(c.authDone)
bo := backoff.NewBackoff("authRoutine", c.logf, 30*time.Second)
+28 -1
View File
@@ -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,30 @@ 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
ccAuto := b.ccAuto
b.mu.Unlock()
b.doSetHostinfoFilterServices()
// Restart the streaming map poll so the control server sends back a fresh
// netmap that includes our updated services in SelfNode, and so peers
// receive the update promptly via the control server's push.
if ccAuto != nil {
ccAuto.RestartMap()
}
}
// doSetHostinfoFilterServices calls SetHostinfo on the controlclient,
// possibly after mangling the given hostinfo.
//
@@ -5011,7 +5036,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