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:
+228
-83
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user