diff --git a/cmd/tsconnect/src/app/index.ts b/cmd/tsconnect/src/app/index.ts index bdbcaf3e5..aec47dbea 100644 --- a/cmd/tsconnect/src/app/index.ts +++ b/cmd/tsconnect/src/app/index.ts @@ -5,6 +5,7 @@ import "../wasm_exec" import wasmUrl from "./main.wasm" import { sessionStateStorage } from "../lib/js-state-store" import { renderApp } from "./app" +import { startIPN } from "../lib/start-ipn" async function main() { const app = await renderApp() @@ -13,23 +14,25 @@ async function main() { fetch(`./dist/${wasmUrl}`), go.importObject ) - // The Go process should never exit, if it does then it's an unhandled panic. - go.run(wasmInstance.instance).then(() => - app.handleGoPanic("Unexpected shutdown") - ) const params = new URLSearchParams(window.location.search) const authKey = params.get("authkey") ?? undefined - const ipn = newIPN({ - // Persist IPN state in sessionStorage in development, so that we don't need - // to re-authorize every time we reload the page. - stateStorage: DEBUG ? sessionStateStorage : undefined, - // authKey allows for an auth key to be - // specified as a url param which automatically - // authorizes the client for use. - authKey: DEBUG ? authKey : undefined, - }) + // The Go process should never exit, if it does then it's an unhandled panic. + const ipn = await startIPN( + go, + wasmInstance.instance, + { + // Persist IPN state in sessionStorage in development, so that we don't + // need to re-authorize every time we reload the page. + stateStorage: DEBUG ? sessionStateStorage : undefined, + // authKey allows for an auth key to be + // specified as a url param which automatically + // authorizes the client for use. + authKey: DEBUG ? authKey : undefined, + }, + (reason) => app.handleGoPanic(reason) + ) app.runWithIPN(ipn) } diff --git a/cmd/tsconnect/src/lib/start-ipn.ts b/cmd/tsconnect/src/lib/start-ipn.ts new file mode 100644 index 000000000..efa474ec1 --- /dev/null +++ b/cmd/tsconnect/src/lib/start-ipn.ts @@ -0,0 +1,87 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +/** + * Starts a Go runtime and returns the single IPN it owns. + * + * The runtime does not publish its bridge on a global. It reads the name of a + * callback from its environment and invokes it once the bridge is ready, so + * this resolves on an explicit signal rather than on the Go scheduler having + * run far enough. The name is generated per runtime, so several runtimes can + * start in one page without racing each other. + * + * The returned IPN replaces the bridge's shutdown() with one that resolves when + * the runtime has actually exited. The raw promise cannot be awaited: it races + * with the runtime tearing itself down, so it may never settle. + */ +export async function startIPN( + go: Go, + instance: WebAssembly.Instance, + config: IPNConfig, + onExit: (reason: string) => void +): Promise { + const name = `__tsconnectInit_${Math.random().toString(36).slice(2)}` + const globals = globalThis as Record + + const ready = new Promise<[NewIPN, Terminate]>((resolve) => { + globals[name] = (newIPN: NewIPN, terminate: Terminate) => { + delete globals[name] + resolve([newIPN, terminate]) + } + }) + + go.env[INIT_CALLBACK_ENV] = name + + // Only an exit the caller did not ask for is worth reporting. Before the + // handover every exit is a startup failure and rejecting hands it back as an + // error; afterwards, only an exit that shutdown() did not cause is a panic. + let stopping = false + const exited: Promise = go.run(instance).then(() => { + delete globals[name] + if (!stopping) onExit("Unexpected shutdown") + }) + // Reject alongside it, so an exit during startup fails the awaits below + // instead of leaving them pending forever. + const failed: Promise = exited.then(() => { + throw new Error("Go runtime exited before the IPN was ready") + }) + + const [newIPN, terminate] = await Promise.race([ready, failed]) + let ipn: IPN + try { + // Keep racing the runtime: building the backend runs in a Go goroutine, and + // if the runtime dies partway that goroutine dies with it and its promise + // never settles. + ipn = await Promise.race([newIPN(config), failed]) + } catch (err) { + // Nothing was built, so nothing can shut the runtime down. Exit it here and + // wait for it, or the page keeps a blocked runtime for a failed startup. + // Calling terminate on an already-exited runtime does nothing. + stopping = true + terminate() + await exited + throw err + } + + // Replace shutdown in place rather than wrapping the object: the bridge hands + // back a plain map of Go-backed functions, and copying it would leave the + // caller with something that only looks like the IPN. + const rawShutdown = ipn.shutdown.bind(ipn) + ipn.shutdown = async () => { + stopping = true + try { + void rawShutdown() + } catch { + // The runtime may already be gone, in which case there is nothing to ask + // and the await below returns immediately. + } + await exited + } + return ipn +} + +type NewIPN = (config: IPNConfig) => Promise +type Terminate = () => void + +/** Must match initCallbackEnv in wasm_js.go. */ +const INIT_CALLBACK_ENV = "TSCONNECT_INIT_CALLBACK" diff --git a/cmd/tsconnect/src/pkg/pkg.ts b/cmd/tsconnect/src/pkg/pkg.ts index a44c57150..b28bb2bc3 100644 --- a/cmd/tsconnect/src/pkg/pkg.ts +++ b/cmd/tsconnect/src/pkg/pkg.ts @@ -7,6 +7,7 @@ /// import "../wasm_exec" +import { startIPN } from "../lib/start-ipn" import wasmURL from "./main.wasm" /** @@ -30,11 +31,7 @@ export async function createIPN(config: IPNPackageConfig): Promise { go.importObject ) // The Go process should never exit, if it does then it's an unhandled panic. - go.run(wasmInstance.instance).then(() => - config.panicHandler("Unexpected shutdown") - ) - - return newIPN(config) + return startIPN(go, wasmInstance.instance, config, config.panicHandler) } export { runSSHSession } from "../lib/ssh" diff --git a/cmd/tsconnect/src/types/wasm_js.d.ts b/cmd/tsconnect/src/types/wasm_js.d.ts index 938ec759c..dd08be1be 100644 --- a/cmd/tsconnect/src/types/wasm_js.d.ts +++ b/cmd/tsconnect/src/types/wasm_js.d.ts @@ -7,12 +7,18 @@ */ declare global { - function newIPN(config: IPNConfig): IPN - interface IPN { run(callbacks: IPNCallbacks): void login(): void logout(): void + /** + * Tears down the backend and exits the Go runtime that owns this IPN. + * + * The promise the bridge returns races with the runtime exiting and may + * never settle; startIPN replaces it with one that resolves when the + * runtime has actually gone. + */ + shutdown(): Promise ssh( host: string, username: string, diff --git a/cmd/tsconnect/wasm/wasm_js.go b/cmd/tsconnect/wasm/wasm_js.go index bc5ed3e80..27729797c 100644 --- a/cmd/tsconnect/wasm/wasm_js.go +++ b/cmd/tsconnect/wasm/wasm_js.go @@ -24,9 +24,11 @@ import ( "net/http" "net/http/httptest" "net/netip" + "os" "strconv" "strings" "sync" + "sync/atomic" "syscall/js" "time" @@ -42,7 +44,6 @@ import ( "tailscale.com/ipn" "tailscale.com/ipn/ipnauth" "tailscale.com/ipn/ipnlocal" - "tailscale.com/ipn/ipnserver" "tailscale.com/ipn/localapi" "tailscale.com/ipn/store/mem" "tailscale.com/logpolicy" @@ -51,7 +52,6 @@ import ( "tailscale.com/net/netns" "tailscale.com/net/tsaddr" "tailscale.com/net/tsdial" - "tailscale.com/safesocket" "tailscale.com/tailcfg" "tailscale.com/tsd" "tailscale.com/types/logid" @@ -64,21 +64,62 @@ import ( // ControlURL defines the URL to be used for connection to Control. var ControlURL = ipn.DefaultControlURL +// initCallbackEnv names the JS global holding the callback that this runtime +// hands its bridge to. The loader generates a name, installs the callback under +// it, and passes the name in through go.env before starting the runtime. +// +// Publishing the bridge through a caller-supplied callback rather than a fixed +// global means the loader never has to guess when the Go scheduler has run far +// enough to expose it, and two runtimes in one JS realm cannot collide on the +// name. +const initCallbackEnv = "TSCONNECT_INIT_CALLBACK" + func main() { + name := os.Getenv(initCallbackEnv) + if name == "" { + log.Fatalf("%s is not set; this module must be loaded by @webnet/tsconnect", initCallbackEnv) + } + callback := js.Global().Get(name) + if callback.Type() != js.TypeFunction { + log.Fatalf("globalThis[%q] is not a function", name) + } + shutdownCh := make(chan struct{}) - js.Global().Set("newIPN", js.FuncOf(func(this js.Value, args []js.Value) any { - if len(args) != 1 { - log.Fatal("Usage: newIPN(config)") - return nil - } - return newIPN(args[0], shutdownCh) - })) + var terminateOnce sync.Once + terminate := func() { terminateOnce.Do(func() { close(shutdownCh) }) } + + var claimed atomic.Bool + newIPNFn := js.FuncOf(func(this js.Value, args []js.Value) any { + return makePromise(func() (any, error) { + if len(args) != 1 { + return nil, errors.New("newIPN takes exactly one argument") + } + // One IPN per runtime: shutdown exits the shared Go runtime, so a + // second IPN here would be torn down by the first one's shutdown. + if !claimed.CompareAndSwap(false, true) { + return nil, errors.New("this WASM runtime already has an IPN; start another runtime instead") + } + return newIPN(args[0], terminate) + }) + }) + + // A failed newIPN leaves the runtime blocked below with nothing to shut it + // down, so the loader gets a way to exit it. Leaving that to the loader + // keeps the ordering right: closing shutdownCh here would let main return + // before makePromise had delivered the rejection. + terminateFn := js.FuncOf(func(this js.Value, args []js.Value) any { + terminate() + return nil + }) + + callback.Invoke(newIPNFn, terminateFn) + // Block until shutdown() is called on the IPN, then let main return so the // Go runtime (and all its goroutines) can be collected by the JS engine. <-shutdownCh } -func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any { +func newIPN(jsConfig js.Value, terminate func()) (map[string]any, error) { netns.SetEnabled(false) var store ipn.StateStore @@ -133,13 +174,13 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any { EventBus: sys.Bus.Get(), }) if err != nil { - log.Fatal(err) + return nil, fmt.Errorf("wgengine.NewUserspaceEngine: %w", err) } sys.Set(eng) ns, err := netstack.Create(logf, sys.Tun.Get(), eng, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper()) if err != nil { - log.Fatalf("netstack.Create: %v", err) + return nil, fmt.Errorf("netstack.Create: %w", err) } sys.Set(ns) ns.ProcessLocalIPs = true @@ -176,20 +217,17 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any { // initDriveForRemote must be called before NewLocalBackend (SubSystem is set-once). driveFS := initDriveForRemote(sys) - srv := ipnserver.New(logf, logid, sys.Bus.Get(), sys.NetMon.Get()) lb, err := ipnlocal.NewLocalBackend(logf, logid, sys, controlclient.LoginEphemeral) if err != nil { - log.Fatalf("ipnlocal.NewLocalBackend: %v", err) + return nil, fmt.Errorf("ipnlocal.NewLocalBackend: %w", err) } if err := ns.Start(lb); err != nil { - log.Fatalf("failed to start netstack: %v", err) + return nil, fmt.Errorf("starting netstack: %w", err) } wireTaildropFileOps(lb, jsConfig.Get("fileOps")) - srv.SetLocalBackend(lb) jsIPN := &jsIPN{ dialer: dialer, - srv: srv, lb: lb, ns: ns, controlURL: controlURL, @@ -197,7 +235,7 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any { hostname: hostname, logID: logid, funnelPorts: make(map[uint16]*funnelListenerEntry), - shutdownCh: shutdownCh, + terminate: terminate, } lb.SetTCPHandlerForFunnelFlow(jsIPN.handleFunnelTCP) @@ -377,12 +415,11 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any { }), } wireDriveJS(jsIPN, driveFS, m) - return m + return m, nil } type jsIPN struct { dialer *tsdial.Dialer - srv *ipnserver.Server lb *ipnlocal.LocalBackend ns *netstack.Impl controlURL string @@ -393,10 +430,7 @@ type jsIPN struct { funnelMu sync.Mutex 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() + terminate func() // unblocks main() so the Go runtime can exit shutdownOnce sync.Once } @@ -588,18 +622,6 @@ 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() { - err := i.srv.Run(context.Background(), ln) - if err != nil && !errors.Is(err, net.ErrClosed) { - log.Fatalf("ipnserver.Run exited: %v", err) - } - }() } func (i *jsIPN) login() { @@ -617,16 +639,17 @@ func (i *jsIPN) logout() { }() } +// shutdown tears down the backend and lets main return, which exits the whole +// Go runtime. Callers should await the runtime's exit rather than the promise +// returned here: terminating races with makePromise resolving, so the promise +// may never settle. func (i *jsIPN) shutdown() js.Value { return makePromise(func() (any, error) { i.shutdownOnce.Do(func() { if i.lb != nil { i.lb.Shutdown() } - if i.ln != nil { - i.ln.Close() - } - close(i.shutdownCh) + i.terminate() }) return nil, nil }) diff --git a/safesocket/safesocket_js.go b/safesocket/safesocket_js.go index 0807885a8..746fea511 100644 --- a/safesocket/safesocket_js.go +++ b/safesocket/safesocket_js.go @@ -5,22 +5,15 @@ package safesocket import ( "context" - "fmt" "net" - "sync/atomic" "github.com/akutz/memconn" ) const memName = "Tailscale-IPN" -// memSeq ensures each IPN instance in the same WASM process gets a distinct -// memconn address, so concurrent instances do not conflict on the registry. -var memSeq atomic.Int64 - func listen(path string) (net.Listener, error) { - name := fmt.Sprintf("%s-%d", memName, memSeq.Add(1)) - return memconn.Listen("memu", name) + return memconn.Listen("memu", memName) } func connect(ctx context.Context, _ string) (net.Conn, error) {