tsconnect: harden JS bridge callbacks

Contain synchronous and asynchronous JS failures, make Drive cancellation fence response callbacks, and keep netmap snapshots current across peer changes and watcher restarts.

Co-Authored-By: gpt-5.6-sol <noreply@openai.com>
This commit is contained in:
2026-08-30 22:23:06 +00:00
co-authored by Codex
parent 0a6e85834a
commit d3e8c23686
5 changed files with 712 additions and 128 deletions
+228 -83
View File
@@ -432,6 +432,9 @@ type jsIPN struct {
terminate func() // unblocks main() so the Go runtime can exit
shutdownOnce sync.Once
notifyMu sync.Mutex
notifyCancel context.CancelFunc
notifyDone <-chan struct{}
}
// funnelListenerEntry is the per-port state for routing Funnel connections to a listenTLS listener.
@@ -457,13 +460,137 @@ var jsMachineStatus = map[tailcfg.MachineStatus]string{
tailcfg.MachineInvalid: "MachineInvalid",
}
const jsIPNNotifyWatchMask = ipn.NotifyInitialState | ipn.NotifyInitialPrefs | ipn.NotifyPeerChanges | ipn.NotifyPeerPatches
func notifyRefreshesNetMap(n ipn.Notify) bool {
if n.SelfChange != nil || len(n.PeersChanged) > 0 || len(n.PeersRemoved) > 0 {
return true
}
for _, patch := range n.PeerChangedPatch {
if patch.Online != nil {
return true
}
}
return false
}
func (i *jsIPN) watchNotifications(ctx context.Context, registered, done chan struct{}, notify func(ipn.Notify), refresh func()) {
defer close(done)
var registeredOnce sync.Once
for {
watchAdded := false
i.lb.WatchNotifications(ctx, jsIPNNotifyWatchMask, func() {
watchAdded = true
refresh()
registeredOnce.Do(func() { close(registered) })
}, func(n *ipn.Notify) bool {
if n.ErrMessage != nil {
log.Printf("IPN notification error: %s", *n.ErrMessage)
}
notify(*n)
return true
})
if ctx.Err() != nil {
return
}
if !watchAdded {
log.Printf("IPN notification watcher stopped before registration")
return
}
log.Printf("IPN notification watcher stopped; reconnecting")
timer := time.NewTimer(100 * time.Millisecond)
select {
case <-ctx.Done():
if !timer.Stop() {
<-timer.C
}
return
case <-timer.C:
}
}
}
func (i *jsIPN) refreshNetMap(jsCallbacks js.Value) {
nm := i.lb.NetMapWithPeers()
if nm == nil {
return
}
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
}
}
selfPeerAPIURL := ""
for _, a := range nm.GetAddresses().All() {
if !a.IsSingleIP() {
continue
}
if port, ok := i.lb.GetPeerAPIPort(a.Addr()); ok && port != 0 {
selfPeerAPIURL = fmt.Sprintf("http://%v", netip.AddrPortFrom(a.Addr(), port))
break
}
}
jsNetMap := jsNetMap{
Self: jsNetMapSelfNode{
jsNetMapNode: jsNetMapNode{
Name: nm.SelfName(),
Addresses: mapSliceView(nm.GetAddresses(), func(a netip.Prefix) string { return a.Addr().String() }),
NodeKey: nm.NodeKey.String(),
MachineKey: nm.MachineKey.String(),
PeerAPIURL: selfPeerAPIURL,
Services: userServicesFromView(nm.SelfNode.Hostinfo().Services()),
},
MachineStatus: jsMachineStatus[nm.GetMachineStatus()],
},
Peers: mapSlice(nm.Peers, func(p tailcfg.NodeView) jsNetMapPeerNode {
name := p.Name()
if name == "" {
name = p.Hostinfo().Hostname()
}
addrs := make([]string, p.Addresses().Len())
for idx, ap := range p.Addresses().All() {
addrs[idx] = ap.Addr().String()
}
return jsNetMapPeerNode{
jsNetMapNode: jsNetMapNode{
Name: name,
Addresses: addrs,
MachineKey: p.Machine().String(),
NodeKey: p.Key().String(),
PeerAPIURL: buildPeerAPIURL(p, selfHave4, selfHave6),
Services: userServicesFromView(p.Hostinfo().Services()),
},
Online: p.Online().Clone(),
TailscaleSSHEnabled: p.Hostinfo().TailscaleSSHEnabled(),
ExitNodeOption: tsaddr.ContainsExitRoutes(p.AllowedIPs()),
StableNodeID: string(p.StableID()),
}
}),
LockedOut: nm.TKAEnabled && nm.SelfNode.KeySignature().Len() == 0,
}
if jsonNetMap, err := json.Marshal(jsNetMap); err == nil {
jsCallbacks.Call("notifyNetMap", string(jsonNetMap))
} else {
log.Printf("Could not generate JSON netmap: %v", err)
}
}
func (i *jsIPN) run(jsCallbacks js.Value) {
notifyState := func(state ipn.State) {
jsCallbacks.Call("notifyState", jsIPNState[state])
}
notifyState(ipn.NoState)
i.lb.SetNotifyCallback(func(n ipn.Notify) {
notify := func(n ipn.Notify) {
// Panics in the notify callback are likely due to be due to bugs in
// this bridging module (as opposed to actual bugs in Tailscale) and
// thus may be recoverable. Let the UI know, and allow the user to
@@ -478,86 +605,8 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
if n.State != nil {
notifyState(*n.State)
}
if n.SelfChange != nil {
// Self changed: rebuild the JS-side NetMap snapshot. Peers
// don't ride on the bus anymore, so fetch them on demand
// from LocalBackend.
nm := i.lb.NetMapWithPeers()
if nm != nil {
// Determine which address families we have, for peer peerAPI URL selection.
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
}
}
// Self peerAPI URL: own port as reported by LocalBackend.
selfPeerAPIURL := ""
for _, a := range nm.GetAddresses().All() {
if !a.IsSingleIP() {
continue
}
if port, ok := i.lb.GetPeerAPIPort(a.Addr()); ok && port != 0 {
selfPeerAPIURL = fmt.Sprintf("http://%v", netip.AddrPortFrom(a.Addr(), port))
break
}
}
jsNetMap := jsNetMap{
Self: jsNetMapSelfNode{
jsNetMapNode: jsNetMapNode{
Name: nm.SelfName(),
Addresses: mapSliceView(nm.GetAddresses(), func(a netip.Prefix) string { return a.Addr().String() }),
NodeKey: nm.NodeKey.String(),
MachineKey: nm.MachineKey.String(),
PeerAPIURL: selfPeerAPIURL,
Services: userServicesFromView(nm.SelfNode.Hostinfo().Services()),
},
MachineStatus: jsMachineStatus[nm.GetMachineStatus()],
},
Peers: mapSlice(nm.Peers, func(p tailcfg.NodeView) jsNetMapPeerNode {
name := p.Name()
if name == "" {
// In practice this should only happen for Hello.
name = p.Hostinfo().Hostname()
}
addrs := make([]string, p.Addresses().Len())
for idx, ap := range p.Addresses().All() {
addrs[idx] = ap.Addr().String()
}
// Peer peerAPI URL from the peer's advertised Services.
peerURL := buildPeerAPIURL(p, selfHave4, selfHave6)
return jsNetMapPeerNode{
jsNetMapNode: jsNetMapNode{
Name: name,
Addresses: addrs,
MachineKey: p.Machine().String(),
NodeKey: p.Key().String(),
PeerAPIURL: peerURL,
Services: userServicesFromView(p.Hostinfo().Services()),
},
Online: p.Online().Clone(),
TailscaleSSHEnabled: p.Hostinfo().TailscaleSSHEnabled(),
ExitNodeOption: tsaddr.ContainsExitRoutes(p.AllowedIPs()),
StableNodeID: string(p.StableID()),
}
}),
LockedOut: nm.TKAEnabled && nm.SelfNode.KeySignature().Len() == 0,
}
if jsonNetMap, err := json.Marshal(jsNetMap); err == nil {
jsCallbacks.Call("notifyNetMap", string(jsonNetMap))
} else {
log.Printf("Could not generate JSON netmap: %v", err)
}
}
if notifyRefreshesNetMap(n) {
i.refreshNetMap(jsCallbacks)
}
if n.Prefs != nil && n.Prefs.Valid() {
jsCallbacks.Call("notifyExitNode", string(n.Prefs.ExitNodeID()))
@@ -605,9 +654,32 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
log.Printf("could not marshal OutgoingFiles: %v", err)
}
}
})
}
refresh := func() {
defer func() {
if r := recover(); r != nil {
fmt.Println("Panic recovered:", r)
jsCallbacks.Call("notifyPanicRecover", fmt.Sprint(r))
}
}()
i.refreshNetMap(jsCallbacks)
}
registered := make(chan struct{})
notifyCtx, notifyCancel := context.WithCancel(context.Background())
notifyDone := make(chan struct{})
i.notifyMu.Lock()
i.notifyCancel = notifyCancel
i.notifyDone = notifyDone
i.notifyMu.Unlock()
go i.watchNotifications(notifyCtx, registered, notifyDone, notify, refresh)
go func() {
select {
case <-registered:
case <-notifyDone:
return
}
err := i.lb.Start(ipn.Options{
UpdatePrefs: &ipn.Prefs{
ControlURL: i.controlURL,
@@ -646,6 +718,15 @@ func (i *jsIPN) logout() {
func (i *jsIPN) shutdown() js.Value {
return makePromise(func() (any, error) {
i.shutdownOnce.Do(func() {
i.notifyMu.Lock()
notifyCancel, notifyDone := i.notifyCancel, i.notifyDone
i.notifyMu.Unlock()
if notifyCancel != nil {
notifyCancel()
}
if notifyDone != nil {
<-notifyDone
}
if i.lb != nil {
i.lb.Shutdown()
}
@@ -1591,19 +1672,83 @@ func makePromise(f func() (any, error)) js.Value {
resolve := args[0]
reject := args[1]
go func() {
defer func() {
if recovered := recover(); recovered != nil {
rejectJSError(reject, recoveredJSError(recovered))
}
}()
if res, err := f(); err == nil {
resolve.Invoke(res)
} else {
reject.Invoke(err.Error())
rejectJSError(reject, err)
}
}()
return nil
})
defer handler.Release()
promiseConstructor := js.Global().Get("Promise")
return promiseConstructor.New(handler)
}
func callJSFunction(fn js.Value, args ...any) (ret js.Value, err error) {
if fn.Type() != js.TypeFunction {
return js.Undefined(), fmt.Errorf("expected JavaScript function, got %s", fn.Type())
}
defer func() {
if recovered := recover(); recovered != nil {
err = recoveredJSError(recovered)
}
}()
return fn.Invoke(args...), nil
}
func callJSMethod(receiver js.Value, method string, args ...any) (ret js.Value, err error) {
defer func() {
if recovered := recover(); recovered != nil {
err = recoveredJSError(recovered)
}
}()
if receiver.Type() != js.TypeObject && receiver.Type() != js.TypeFunction {
return js.Undefined(), fmt.Errorf("cannot call JavaScript method %q on %s", method, receiver.Type())
}
if receiver.Get(method).Type() != js.TypeFunction {
return js.Undefined(), fmt.Errorf("JavaScript property %q is not a function", method)
}
return receiver.Call(method, args...), nil
}
func hasJSFunctionProperty(receiver js.Value, property string) (ok bool, err error) {
defer func() {
if recovered := recover(); recovered != nil {
err = recoveredJSError(recovered)
}
}()
if receiver.Type() != js.TypeObject && receiver.Type() != js.TypeFunction {
return false, nil
}
return receiver.Get(property).Type() == js.TypeFunction, nil
}
func recoveredJSError(recovered any) error {
switch recovered := recovered.(type) {
case js.Error:
return recovered
case *js.ValueError:
return recovered
default:
panic(recovered)
}
}
func rejectJSError(reject js.Value, err error) {
if jsErr, ok := err.(js.Error); ok {
reject.Invoke(jsErr.Value)
return
}
reject.Invoke(err.Error())
}
const logPolicyStateKey = "log-policy"
func getOrCreateLogPolicyConfig(state ipn.StateStore) *logpolicy.Config {