4 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
6 changed files with 470 additions and 51 deletions
+301
View File
@@ -0,0 +1,301 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_drive
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"sync"
"syscall/js"
"tailscale.com/drive"
"tailscale.com/tailcfg"
"tailscale.com/tsd"
)
// Compile-time check that jsFileSystemForRemote implements drive.FileSystemForRemote.
var _ drive.FileSystemForRemote = (*jsFileSystemForRemote)(nil)
// jsFileSystemForRemote implements drive.FileSystemForRemote by bridging
// incoming WebDAV requests to a JS handler function. Auth and permission
// parsing are handled upstream by handleServeDrive before this is called.
type jsFileSystemForRemote struct {
mu sync.RWMutex
fn js.Value
}
func (fs *jsFileSystemForRemote) setHandler(fn js.Value) {
fs.mu.Lock()
fs.fn = fn
fs.mu.Unlock()
}
// SetFileServerAddr is a no-op: the JS handler owns its own storage.
func (fs *jsFileSystemForRemote) SetFileServerAddr(_ string) {}
// SetShares is a no-op: the JS handler controls which shares it exposes.
func (fs *jsFileSystemForRemote) SetShares(_ []*drive.Share) {}
// Close is a no-op.
func (fs *jsFileSystemForRemote) Close() error { return nil }
// ServeHTTPWithPerms handles a WebDAV request by bridging it to the JS handler.
// It streams the request body to JS via readBodyChunk() and streams the
// response body back via write()/end() callbacks, so no full-body buffering
// occurs regardless of file size.
//
// The call blocks until JS calls end() (or a write error occurs).
func (fs *jsFileSystemForRemote) ServeHTTPWithPerms(
perms drive.Permissions, w http.ResponseWriter, r *http.Request,
) {
fs.mu.RLock()
fn := fs.fn
fs.mu.RUnlock()
if fn.IsUndefined() || fn.IsNull() {
http.NotFound(w, r)
return
}
// readBodyChunk is exposed to JS as req.readBodyChunk().
// Each call returns a Promise<Uint8Array|null>: null signals EOF.
readBodyChunk := js.FuncOf(func(_ js.Value, _ []js.Value) any {
return makePromise(func() (any, error) {
buf := make([]byte, 65536)
n, err := r.Body.Read(buf)
if n > 0 {
arr := js.Global().Get("Uint8Array").New(n)
js.CopyBytesToJS(arr, buf[:n])
return arr, nil
}
if errors.Is(err, io.EOF) {
return js.Null(), nil
}
return nil, err
})
})
// doneCh receives nil when JS calls end(), or a write error if Write fails.
doneCh := make(chan error, 1)
// writeHead sets response headers and status code. Must be called before write().
writeHead := js.FuncOf(func(_ js.Value, args []js.Value) any {
if len(args) < 1 {
return nil
}
status := args[0].Int()
if len(args) > 1 && !args[1].IsUndefined() && !args[1].IsNull() {
for k, vs := range jsHeadersToGo(args[1]) {
for _, v := range vs {
w.Header().Add(k, v)
}
}
}
w.WriteHeader(status)
return nil
})
// write streams a single response body chunk to the client.
write := js.FuncOf(func(_ js.Value, args []js.Value) any {
if len(args) < 1 {
return nil
}
data := args[0]
buf := make([]byte, data.Get("length").Int())
js.CopyBytesToGo(buf, data)
if _, werr := w.Write(buf); werr != nil {
select {
case doneCh <- werr:
default:
}
return nil
}
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
return nil
})
// end signals that the response is complete.
end := js.FuncOf(func(_ js.Value, _ []js.Value) any {
select {
case doneCh <- nil:
default:
}
return nil
})
defer func() {
readBodyChunk.Release()
writeHead.Release()
write.Release()
end.Release()
}()
jsReq := map[string]any{
"method": r.Method,
"path": r.URL.Path,
"rawQuery": r.URL.RawQuery,
"headers": goHeadersToJS(r.Header),
"readBodyChunk": readBodyChunk,
}
jsRes := map[string]any{
"writeHead": writeHead,
"write": write,
"end": end,
}
fn.Invoke(jsReq, jsRes, drivePermsToJS(perms))
// Block this goroutine until JS calls end() or a write error occurs.
// The Go WASM scheduler yields back to JS while we wait.
<-doneCh
}
// drivePermsToJS converts drive.Permissions to a plain JS-friendly object.
// Each share name maps to a numeric permission: 0=none, 1=read-only, 2=read-write.
// The wildcard share name "*" is included if present.
func drivePermsToJS(p drive.Permissions) map[string]any {
result := make(map[string]any, len(p))
for name, perm := range p {
result[name] = int(perm)
}
return result
}
// goHeadersToJS converts an http.Header to a map[string]any suitable for JS.
// Single-value headers become a string; multi-value headers become a []any.
func goHeadersToJS(h http.Header) map[string]any {
result := make(map[string]any, len(h))
for k, vs := range h {
if len(vs) == 1 {
result[k] = vs[0]
} else {
arr := make([]any, len(vs))
for i, v := range vs {
arr[i] = v
}
result[k] = arr
}
}
return result
}
// jsHeadersToGo parses a JS headers object into an http.Header map.
// Values may be a string or an array of strings.
func jsHeadersToGo(jsHeaders js.Value) http.Header {
h := make(http.Header)
keys := js.Global().Get("Object").Call("keys", jsHeaders)
for i := 0; i < keys.Length(); i++ {
key := keys.Index(i).String()
val := jsHeaders.Get(key)
switch val.Type() {
case js.TypeString:
h.Set(key, val.String())
case js.TypeObject:
if val.InstanceOf(js.Global().Get("Array")) {
for j := 0; j < val.Length(); j++ {
h.Add(key, val.Index(j).String())
}
}
}
}
return h
}
// initDriveForRemote creates the JS-backed FileSystemForRemote and registers
// it with sys. Must be called before NewLocalBackend (SubSystem is set-once).
func initDriveForRemote(sys *tsd.System) *jsFileSystemForRemote {
driveFS := &jsFileSystemForRemote{}
sys.Set(driveFS)
return driveFS
}
// wireDriveJS adds drive-related methods to the IPN JS methods map.
// driveFS must be the value returned by initDriveForRemote.
func wireDriveJS(i *jsIPN, driveFS *jsFileSystemForRemote, m map[string]any) {
m["setDriveHandler"] = js.FuncOf(func(_ js.Value, args []js.Value) any {
if len(args) < 1 {
return nil
}
driveFS.setHandler(args[0])
return nil
})
m["listDrivePeers"] = js.FuncOf(func(_ js.Value, _ []js.Value) any {
return i.listDrivePeers()
})
}
type jsDrivePeer struct {
Name string `json:"name"`
PeerAPIURL string `json:"peerAPIURL"`
StableNodeID string `json:"stableNodeID"`
Online *bool `json:"online,omitempty"`
}
// listDrivePeers returns a JSON array of peers that carry
// PeerCapabilityTaildriveSharer. Returns an empty array if the local node
// does not have drive:access in its ACL (DriveAccessEnabled). This mirrors
// the filtering in LocalBackend.driveRemotesFromPeers.
func (i *jsIPN) listDrivePeers() js.Value {
return makePromise(func() (any, error) {
if !i.lb.DriveAccessEnabled() {
return "[]", nil
}
nm := i.lb.NetMap()
if nm == nil {
return nil, errors.New("listDrivePeers: no network map available")
}
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
}
}
peers := make([]jsDrivePeer, 0)
for _, p := range nm.Peers {
// Check PeerCapabilityTaildriveSharer via the live PeerCaps map
// (derived from ACL rules), mirroring driveRemotesFromPeers.
hasCap := false
for _, a := range p.Addresses().All() {
if a.IsSingleIP() && i.lb.PeerCaps(a.Addr()).HasCapability(tailcfg.PeerCapabilityTaildriveSharer) {
hasCap = true
break
}
}
if !hasCap {
continue
}
peerURL := buildPeerAPIURL(p, selfHave4, selfHave6)
online := p.Online().Clone()
peers = append(peers, jsDrivePeer{
Name: p.DisplayName(false),
PeerAPIURL: peerURL,
StableNodeID: string(p.StableID()),
Online: online,
})
}
b, err := json.Marshal(peers)
if err != nil {
return nil, fmt.Errorf("listDrivePeers: marshal: %w", err)
}
return string(b), nil
})
}
+27
View File
@@ -0,0 +1,27 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build ts_omit_drive
package main
import (
"syscall/js"
"tailscale.com/tsd"
)
type jsFileSystemForRemote struct{}
// initDriveForRemote is a no-op when the drive feature is omitted.
func initDriveForRemote(_ *tsd.System) *jsFileSystemForRemote { return nil }
// wireDriveJS is a no-op when the drive feature is omitted.
func wireDriveJS(_ *jsIPN, _ *jsFileSystemForRemote, _ map[string]any) {}
// listDrivePeers returns an empty list when the drive feature is omitted.
func (i *jsIPN) listDrivePeers() js.Value {
return makePromise(func() (any, error) {
return "[]", nil
})
}
+41
View File
@@ -0,0 +1,41 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"fmt"
"net/netip"
"tailscale.com/tailcfg"
)
// buildPeerAPIURL returns the HTTP base URL for a peer's peerAPI server,
// selecting IPv4 when available and falling back to IPv6. Returns an empty
// string if the peer advertises no reachable peerAPI port.
func buildPeerAPIURL(p tailcfg.NodeView, selfHave4, selfHave6 bool) string {
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() {
return fmt.Sprintf("http://%v", netip.AddrPortFrom(a.Addr(), pp4))
}
}
}
if selfHave6 && pp6 != 0 {
for _, a := range p.Addresses().All() {
if a.IsSingleIP() && a.Addr().Is6() {
return fmt.Sprintf("http://%v", netip.AddrPortFrom(a.Addr(), pp6))
}
}
}
return ""
}
+88 -51
View File
@@ -159,6 +159,10 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
sys.Tun.Get().Start() sys.Tun.Get().Start()
logid := lpc.PublicID logid := lpc.PublicID
// initDriveForRemote must be called before NewLocalBackend (SubSystem is set-once).
driveFS := initDriveForRemote(sys)
srv := ipnserver.New(logf, logid, sys.Bus.Get(), sys.NetMon.Get()) srv := ipnserver.New(logf, logid, sys.Bus.Get(), sys.NetMon.Get())
lb, err := ipnlocal.NewLocalBackend(logf, logid, sys, controlclient.LoginEphemeral) lb, err := ipnlocal.NewLocalBackend(logf, logid, sys, controlclient.LoginEphemeral)
if err != nil { if err != nil {
@@ -184,7 +188,7 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
} }
lb.SetTCPHandlerForFunnelFlow(jsIPN.handleFunnelTCP) lb.SetTCPHandlerForFunnelFlow(jsIPN.handleFunnelTCP)
return map[string]any{ m := map[string]any{
"run": js.FuncOf(func(this js.Value, args []js.Value) any { "run": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 1 { if len(args) != 1 {
log.Fatal(`Usage: run({ log.Fatal(`Usage: run({
@@ -385,6 +389,8 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
return jsIPN.localAPI(args[0].String(), args[1].String(), body) return jsIPN.localAPI(args[0].String(), args[1].String(), body)
}), }),
} }
wireDriveJS(jsIPN, driveFS, m)
return m
} }
type jsIPN struct { type jsIPN struct {
@@ -501,32 +507,7 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
} }
// Peer peerAPI URL from the peer's advertised Services. // Peer peerAPI URL from the peer's advertised Services.
peerURL := "" peerURL := buildPeerAPIURL(p, selfHave4, selfHave6)
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{
@@ -908,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 { func (i *jsIPN) dialTLS(addr string, opts js.Value) js.Value {
return makePromise(func() (any, error) { return makePromise(func() (any, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
@@ -918,28 +934,9 @@ func (i *jsIPN) dialTLS(addr string, opts js.Value) js.Value {
return nil, fmt.Errorf("invalid address %q: %w", addr, err) return nil, fmt.Errorf("invalid address %q: %w", addr, err)
} }
// On wasm there's no system root pool, so default to the cfg, err := tlsClientConfigFromJS(host, opts)
// baked-in LetsEncrypt roots (which is what `tailscale cert` if err != nil {
// uses for tailnet HTTPS endpoints). Callers can override with return nil, err
// 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
}
} }
rawConn, err := i.dialer.UserDial(ctx, "tcp", addr) rawConn, err := i.dialer.UserDial(ctx, "tcp", addr)
@@ -1393,7 +1390,7 @@ func (i *jsIPN) setServices(jsServices js.Value) js.Value {
// userServicesFromView converts a hostinfo services slice to jsService entries, // userServicesFromView converts a hostinfo services slice to jsService entries,
// filtering out internal peerapi protocol entries (already reflected in peerAPIURL). // filtering out internal peerapi protocol entries (already reflected in peerAPIURL).
func userServicesFromView(svcs views.Slice[tailcfg.Service]) []jsService { func userServicesFromView(svcs views.Slice[tailcfg.Service]) []jsService {
var out []jsService out := make([]jsService, 0, svcs.Len())
for _, s := range svcs.All() { for _, s := range svcs.All() {
switch s.Proto { switch s.Proto {
case tailcfg.PeerAPI4, tailcfg.PeerAPI6, tailcfg.PeerAPIDNS: case tailcfg.PeerAPI4, tailcfg.PeerAPI6, tailcfg.PeerAPIDNS:
@@ -1483,6 +1480,46 @@ func wrapConn(conn net.Conn) map[string]any {
"remoteAddr": js.FuncOf(func(this js.Value, args []js.Value) any { "remoteAddr": js.FuncOf(func(this js.Value, args []js.Value) any {
return conn.RemoteAddr().String() 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
})
}),
} }
} }
@@ -1652,7 +1689,7 @@ type jsNetMapNode struct {
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"` Services []jsService `json:"services"`
} }
type jsNetMapSelfNode struct { type jsNetMapSelfNode struct {
+6
View File
@@ -302,6 +302,12 @@ func (c *Auto) restartMap() {
c.updateControl() 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() { func (c *Auto) authRoutine() {
defer close(c.authDone) defer close(c.authDone)
bo := backoff.NewBackoff("authRoutine", c.logf, 30*time.Second) bo := backoff.NewBackoff("authRoutine", c.logf, 30*time.Second)
+7
View File
@@ -4980,9 +4980,16 @@ func (b *LocalBackend) SetExplicitServices(sl []tailcfg.Service) {
} }
b.hostinfo.Services = sl b.hostinfo.Services = sl
b.explicitServices = sl b.explicitServices = sl
ccAuto := b.ccAuto
b.mu.Unlock() b.mu.Unlock()
b.doSetHostinfoFilterServices() 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, // doSetHostinfoFilterServices calls SetHostinfo on the controlclient,