5 Commits

Author SHA1 Message Date
codinget 81e37f8812 feat(tsconnect/wasm): add upgradeTLS to wrapped conns
Adds an upgradeTLS method to the conn objects returned by dial/accept,
wrapping the existing net.Conn with crypto/tls in place (STARTTLS /
FTPS AUTH TLS style). Client mode reuses the dialTLS options
(serverName, insecureSkipVerify, caCerts), now factored into
tlsClientConfigFromJS; server mode takes isServer with certPem/keyPem.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfRgMke8p7QDxD5QM5thxH
2026-07-06 09:00:19 +00:00
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 166 additions and 28 deletions
+132 -27
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(),
@@ -880,6 +889,41 @@ func (i *jsIPN) listen(network, addr string) js.Value {
})
}
// tlsClientConfigFromJS builds a client tls.Config from optional JS options
// (serverName, insecureSkipVerify, caCerts). defaultServerName may be empty
// (STARTTLS upgrade case), in which case serverName must be provided unless
// insecureSkipVerify is set.
//
// On wasm there's no system root pool, so default to the baked-in
// LetsEncrypt roots (which is what `tailscale cert` uses for tailnet
// HTTPS endpoints). Callers can override with caCerts (PEM) or bypass
// entirely with insecureSkipVerify.
func tlsClientConfigFromJS(defaultServerName string, opts js.Value) (*tls.Config, error) {
cfg := &tls.Config{
ServerName: defaultServerName,
RootCAs: bakedroots.Get(),
}
if !opts.IsUndefined() && !opts.IsNull() {
if sn := opts.Get("serverName"); sn.Type() == js.TypeString {
cfg.ServerName = sn.String()
}
if iv := opts.Get("insecureSkipVerify"); iv.Type() == js.TypeBoolean {
cfg.InsecureSkipVerify = iv.Bool()
}
if ca := opts.Get("caCerts"); ca.Type() == js.TypeString {
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM([]byte(ca.String())) {
return nil, fmt.Errorf("caCerts: no valid PEM certificates found")
}
cfg.RootCAs = pool
}
}
if cfg.ServerName == "" && !cfg.InsecureSkipVerify {
return nil, fmt.Errorf("serverName is required unless insecureSkipVerify is set")
}
return cfg, nil
}
func (i *jsIPN) dialTLS(addr string, opts js.Value) js.Value {
return makePromise(func() (any, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
@@ -890,28 +934,9 @@ func (i *jsIPN) dialTLS(addr string, opts js.Value) js.Value {
return nil, fmt.Errorf("invalid address %q: %w", addr, err)
}
// On wasm there's no system root pool, so default to the
// baked-in LetsEncrypt roots (which is what `tailscale cert`
// uses for tailnet HTTPS endpoints). Callers can override with
// caCerts (PEM) or bypass entirely with insecureSkipVerify.
cfg := &tls.Config{
ServerName: host,
RootCAs: bakedroots.Get(),
}
if !opts.IsUndefined() && !opts.IsNull() {
if sn := opts.Get("serverName"); sn.Type() == js.TypeString {
cfg.ServerName = sn.String()
}
if iv := opts.Get("insecureSkipVerify"); iv.Type() == js.TypeBoolean {
cfg.InsecureSkipVerify = iv.Bool()
}
if ca := opts.Get("caCerts"); ca.Type() == js.TypeString {
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM([]byte(ca.String())) {
return nil, fmt.Errorf("caCerts: no valid PEM certificates found")
}
cfg.RootCAs = pool
}
cfg, err := tlsClientConfigFromJS(host, opts)
if err != nil {
return nil, err
}
rawConn, err := i.dialer.UserDial(ctx, "tcp", addr)
@@ -1343,6 +1368,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{
@@ -1422,6 +1480,46 @@ func wrapConn(conn net.Conn) map[string]any {
"remoteAddr": js.FuncOf(func(this js.Value, args []js.Value) any {
return conn.RemoteAddr().String()
}),
// upgradeTLS wraps the conn in TLS in place (STARTTLS-style) and
// returns a new wrapped conn sharing the same underlying net.Conn;
// the old handle must not be used afterward. On handshake failure
// the underlying conn is closed. With isServer, certPem and keyPem
// are required; otherwise client options as in dialTLS apply.
"upgradeTLS": js.FuncOf(func(this js.Value, args []js.Value) any {
opts := js.Undefined()
if len(args) > 0 {
opts = args[0]
}
return makePromise(func() (any, error) {
var tlsConn *tls.Conn
hasOpts := !opts.IsUndefined() && !opts.IsNull()
if hasOpts && opts.Get("isServer").Type() == js.TypeBoolean && opts.Get("isServer").Bool() {
certPem := opts.Get("certPem")
keyPem := opts.Get("keyPem")
if certPem.Type() != js.TypeString || keyPem.Type() != js.TypeString {
return nil, fmt.Errorf("upgradeTLS: certPem and keyPem are required when isServer is set")
}
cert, err := tls.X509KeyPair([]byte(certPem.String()), []byte(keyPem.String()))
if err != nil {
return nil, fmt.Errorf("upgradeTLS: parsing cert/key: %w", err)
}
tlsConn = tls.Server(conn, &tls.Config{Certificates: []tls.Certificate{cert}})
} else {
cfg, err := tlsClientConfigFromJS("", opts)
if err != nil {
return nil, err
}
tlsConn = tls.Client(conn, cfg)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := tlsConn.HandshakeContext(ctx); err != nil {
conn.Close()
return nil, err
}
return wrapConn(tlsConn), nil
})
}),
}
}
@@ -1579,12 +1677,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