Compare commits

..

2 Commits

Author SHA1 Message Date
codinget cef1031b8e fix(tsconnect/wasm): normalise ":port" listen addr to "0.0.0.0:port"
netstack.ListenTCP requires a full host:port address; callers passing
the standard net.Listen form (":0" for any-interface ephemeral port)
would get ParseAddrPort error. Prepend "0.0.0.0" when the address
starts with ":" so the API matches Go's net.Listen behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 20:11:32 +00:00
codinget 13e9529084 fix(safesocket/js): use unique memconn name per IPN instance
Each call to newIPN() starts an independent Go backend that calls
safesocket.Listen() to serve the ipnserver IPC channel. Because
memName was a global constant, the second instance would fail with
"addr unavailable" and log.Fatal the whole WASM process.

Use an atomic counter to give each listener a distinct name
(Tailscale-IPN-1, Tailscale-IPN-2, …). The connect() path is
unchanged: in the wasm/tsconnect build all LocalAPI calls go through
the in-process httptest handler, so connect() is never called.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 20:02:24 +00:00
+11 -36
View File
@@ -66,20 +66,19 @@ import (
var ControlURL = ipn.DefaultControlURL var ControlURL = ipn.DefaultControlURL
func main() { func main() {
shutdownCh := make(chan struct{})
js.Global().Set("newIPN", js.FuncOf(func(this js.Value, args []js.Value) any { js.Global().Set("newIPN", js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 1 { if len(args) != 1 {
log.Fatal("Usage: newIPN(config)") log.Fatal("Usage: newIPN(config)")
return nil return nil
} }
return newIPN(args[0], shutdownCh) return newIPN(args[0])
})) }))
// Block until shutdown() is called on the IPN, then let main return so the // Keep Go runtime alive, otherwise it will be shut down before newIPN gets
// Go runtime (and all its goroutines) can be collected by the JS engine. // called.
<-shutdownCh <-make(chan bool)
} }
func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any { func newIPN(jsConfig js.Value) map[string]any {
netns.SetEnabled(false) netns.SetEnabled(false)
var store ipn.StateStore var store ipn.StateStore
@@ -180,7 +179,6 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
hostname: hostname, hostname: hostname,
logID: logid, logID: logid,
funnelPorts: make(map[uint16]*funnelListenerEntry), funnelPorts: make(map[uint16]*funnelListenerEntry),
shutdownCh: shutdownCh,
} }
lb.SetTCPHandlerForFunnelFlow(jsIPN.handleFunnelTCP) lb.SetTCPHandlerForFunnelFlow(jsIPN.handleFunnelTCP)
@@ -363,9 +361,6 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
"suggestExitNode": js.FuncOf(func(this js.Value, args []js.Value) any { "suggestExitNode": js.FuncOf(func(this js.Value, args []js.Value) any {
return jsIPN.suggestExitNode() return jsIPN.suggestExitNode()
}), }),
"shutdown": js.FuncOf(func(this js.Value, args []js.Value) any {
return jsIPN.shutdown()
}),
"localAPI": js.FuncOf(func(this js.Value, args []js.Value) any { "localAPI": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) < 2 { if len(args) < 2 {
log.Printf("Usage: localAPI(method, path[, body])") log.Printf("Usage: localAPI(method, path[, body])")
@@ -392,12 +387,6 @@ type jsIPN struct {
funnelMu sync.Mutex funnelMu sync.Mutex
funnelPorts map[uint16]*funnelListenerEntry funnelPorts map[uint16]*funnelListenerEntry
// ln is the safesocket listener created by run(); stored here so shutdown
// can close it and unblock srv.Run.
ln net.Listener
shutdownCh chan struct{} // closed by shutdown() to unblock main()
shutdownOnce sync.Once
} }
// funnelListenerEntry is the per-port state for routing Funnel connections to a listenTLS listener. // funnelListenerEntry is the per-port state for routing Funnel connections to a listenTLS listener.
@@ -605,17 +594,14 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
} }
}() }()
ln, err := safesocket.Listen("")
if err != nil {
log.Fatalf("safesocket.Listen: %v", err)
}
i.ln = ln
go func() { go func() {
err := i.srv.Run(context.Background(), ln) ln, err := safesocket.Listen("")
if err != nil && !errors.Is(err, net.ErrClosed) { if err != nil {
log.Fatalf("ipnserver.Run exited: %v", err) log.Fatalf("safesocket.Listen: %v", err)
} }
err = i.srv.Run(context.Background(), ln)
log.Fatalf("ipnserver.Run exited: %v", err)
}() }()
} }
@@ -634,17 +620,6 @@ func (i *jsIPN) logout() {
}() }()
} }
func (i *jsIPN) shutdown() js.Value {
return makePromise(func() (any, error) {
i.shutdownOnce.Do(func() {
i.lb.Shutdown()
i.ln.Close()
close(i.shutdownCh)
})
return nil, nil
})
}
func (i *jsIPN) ssh(host, username string, termConfig js.Value) map[string]any { func (i *jsIPN) ssh(host, username string, termConfig js.Value) map[string]any {
jsSSHSession := &jsSSHSession{ jsSSHSession := &jsSSHSession{
jsIPN: i, jsIPN: i,