Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
738fea52f8 | ||
|
|
3c63a95446 | ||
|
|
7ca658b028 | ||
|
|
c98a03dfa5 | ||
|
|
00d16d6ae2 | ||
|
|
24ee15e524 |
@@ -5,6 +5,7 @@ import "../wasm_exec"
|
|||||||
import wasmUrl from "./main.wasm"
|
import wasmUrl from "./main.wasm"
|
||||||
import { sessionStateStorage } from "../lib/js-state-store"
|
import { sessionStateStorage } from "../lib/js-state-store"
|
||||||
import { renderApp } from "./app"
|
import { renderApp } from "./app"
|
||||||
|
import { startIPN } from "../lib/start-ipn"
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const app = await renderApp()
|
const app = await renderApp()
|
||||||
@@ -13,23 +14,25 @@ async function main() {
|
|||||||
fetch(`./dist/${wasmUrl}`),
|
fetch(`./dist/${wasmUrl}`),
|
||||||
go.importObject
|
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 params = new URLSearchParams(window.location.search)
|
||||||
const authKey = params.get("authkey") ?? undefined
|
const authKey = params.get("authkey") ?? undefined
|
||||||
|
|
||||||
const ipn = newIPN({
|
// The Go process should never exit, if it does then it's an unhandled panic.
|
||||||
// Persist IPN state in sessionStorage in development, so that we don't need
|
const ipn = await startIPN(
|
||||||
// to re-authorize every time we reload the page.
|
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,
|
stateStorage: DEBUG ? sessionStateStorage : undefined,
|
||||||
// authKey allows for an auth key to be
|
// authKey allows for an auth key to be
|
||||||
// specified as a url param which automatically
|
// specified as a url param which automatically
|
||||||
// authorizes the client for use.
|
// authorizes the client for use.
|
||||||
authKey: DEBUG ? authKey : undefined,
|
authKey: DEBUG ? authKey : undefined,
|
||||||
})
|
},
|
||||||
|
(reason) => app.handleGoPanic(reason)
|
||||||
|
)
|
||||||
app.runWithIPN(ipn)
|
app.runWithIPN(ipn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// 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.
|
||||||
|
*/
|
||||||
|
export async function startIPN(
|
||||||
|
go: Go,
|
||||||
|
instance: WebAssembly.Instance,
|
||||||
|
config: IPNConfig,
|
||||||
|
onExit: (reason: string) => void
|
||||||
|
): Promise<IPN> {
|
||||||
|
const name = `__tsconnectInit_${Math.random().toString(36).slice(2)}`
|
||||||
|
const globals = globalThis as Record<string, unknown>
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
// An exit before the IPN reaches the caller is a startup failure, and throwing
|
||||||
|
// hands it back as a rejection. Afterwards the caller holds the only shutdown
|
||||||
|
// path, so an exit is either that shutdown or a panic; report it either way,
|
||||||
|
// because the IPN is dead in both cases.
|
||||||
|
let handedOver = false
|
||||||
|
const exited: Promise<never> = go.run(instance).then(() => {
|
||||||
|
delete globals[name]
|
||||||
|
if (handedOver) onExit("Go runtime exited")
|
||||||
|
// Always reject: before the handover this is what fails the race below,
|
||||||
|
// and after it the race has settled, so nothing observes the rejection.
|
||||||
|
throw new Error(
|
||||||
|
handedOver
|
||||||
|
? "Go runtime exited"
|
||||||
|
: "Go runtime exited before the IPN was ready"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const [newIPN, terminate] = await Promise.race([ready, exited])
|
||||||
|
try {
|
||||||
|
const ipn = await newIPN(config)
|
||||||
|
handedOver = true
|
||||||
|
return ipn
|
||||||
|
} 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.
|
||||||
|
terminate()
|
||||||
|
await exited.catch(() => {})
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type NewIPN = (config: IPNConfig) => Promise<IPN>
|
||||||
|
type Terminate = () => void
|
||||||
|
|
||||||
|
/** Must match initCallbackEnv in wasm_js.go. */
|
||||||
|
const INIT_CALLBACK_ENV = "TSCONNECT_INIT_CALLBACK"
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
/// <reference path="../types/wasm_js.d.ts" />
|
/// <reference path="../types/wasm_js.d.ts" />
|
||||||
|
|
||||||
import "../wasm_exec"
|
import "../wasm_exec"
|
||||||
|
import { startIPN } from "../lib/start-ipn"
|
||||||
import wasmURL from "./main.wasm"
|
import wasmURL from "./main.wasm"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,11 +31,7 @@ export async function createIPN(config: IPNPackageConfig): Promise<IPN> {
|
|||||||
go.importObject
|
go.importObject
|
||||||
)
|
)
|
||||||
// The Go process should never exit, if it does then it's an unhandled panic.
|
// The Go process should never exit, if it does then it's an unhandled panic.
|
||||||
go.run(wasmInstance.instance).then(() =>
|
return startIPN(go, wasmInstance.instance, config, config.panicHandler)
|
||||||
config.panicHandler("Unexpected shutdown")
|
|
||||||
)
|
|
||||||
|
|
||||||
return newIPN(config)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { runSSHSession } from "../lib/ssh"
|
export { runSSHSession } from "../lib/ssh"
|
||||||
|
|||||||
Vendored
-2
@@ -7,8 +7,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
function newIPN(config: IPNConfig): IPN
|
|
||||||
|
|
||||||
interface IPN {
|
interface IPN {
|
||||||
run(callbacks: IPNCallbacks): void
|
run(callbacks: IPNCallbacks): void
|
||||||
login(): void
|
login(): void
|
||||||
|
|||||||
@@ -24,9 +24,11 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"syscall/js"
|
"syscall/js"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -42,7 +44,6 @@ import (
|
|||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
"tailscale.com/ipn/ipnauth"
|
"tailscale.com/ipn/ipnauth"
|
||||||
"tailscale.com/ipn/ipnlocal"
|
"tailscale.com/ipn/ipnlocal"
|
||||||
"tailscale.com/ipn/ipnserver"
|
|
||||||
"tailscale.com/ipn/localapi"
|
"tailscale.com/ipn/localapi"
|
||||||
"tailscale.com/ipn/store/mem"
|
"tailscale.com/ipn/store/mem"
|
||||||
"tailscale.com/logpolicy"
|
"tailscale.com/logpolicy"
|
||||||
@@ -51,7 +52,6 @@ import (
|
|||||||
"tailscale.com/net/netns"
|
"tailscale.com/net/netns"
|
||||||
"tailscale.com/net/tsaddr"
|
"tailscale.com/net/tsaddr"
|
||||||
"tailscale.com/net/tsdial"
|
"tailscale.com/net/tsdial"
|
||||||
"tailscale.com/safesocket"
|
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
"tailscale.com/tsd"
|
"tailscale.com/tsd"
|
||||||
"tailscale.com/types/logid"
|
"tailscale.com/types/logid"
|
||||||
@@ -64,21 +64,62 @@ import (
|
|||||||
// ControlURL defines the URL to be used for connection to Control.
|
// ControlURL defines the URL to be used for connection to Control.
|
||||||
var ControlURL = ipn.DefaultControlURL
|
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() {
|
func main() {
|
||||||
shutdownCh := make(chan struct{})
|
name := os.Getenv(initCallbackEnv)
|
||||||
js.Global().Set("newIPN", js.FuncOf(func(this js.Value, args []js.Value) any {
|
if name == "" {
|
||||||
if len(args) != 1 {
|
log.Fatalf("%s is not set; this module must be loaded by @webnet/tsconnect", initCallbackEnv)
|
||||||
log.Fatal("Usage: newIPN(config)")
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
return newIPN(args[0], shutdownCh)
|
callback := js.Global().Get(name)
|
||||||
}))
|
if callback.Type() != js.TypeFunction {
|
||||||
|
log.Fatalf("globalThis[%q] is not a function", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
shutdownCh := make(chan struct{})
|
||||||
|
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
|
// 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.
|
// Go runtime (and all its goroutines) can be collected by the JS engine.
|
||||||
<-shutdownCh
|
<-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)
|
netns.SetEnabled(false)
|
||||||
|
|
||||||
var store ipn.StateStore
|
var store ipn.StateStore
|
||||||
@@ -133,13 +174,13 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
|
|||||||
EventBus: sys.Bus.Get(),
|
EventBus: sys.Bus.Get(),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
return nil, fmt.Errorf("wgengine.NewUserspaceEngine: %w", err)
|
||||||
}
|
}
|
||||||
sys.Set(eng)
|
sys.Set(eng)
|
||||||
|
|
||||||
ns, err := netstack.Create(logf, sys.Tun.Get(), eng, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper())
|
ns, err := netstack.Create(logf, sys.Tun.Get(), eng, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("netstack.Create: %v", err)
|
return nil, fmt.Errorf("netstack.Create: %w", err)
|
||||||
}
|
}
|
||||||
sys.Set(ns)
|
sys.Set(ns)
|
||||||
ns.ProcessLocalIPs = true
|
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).
|
// initDriveForRemote must be called before NewLocalBackend (SubSystem is set-once).
|
||||||
driveFS := initDriveForRemote(sys)
|
driveFS := initDriveForRemote(sys)
|
||||||
|
|
||||||
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 {
|
||||||
log.Fatalf("ipnlocal.NewLocalBackend: %v", err)
|
return nil, fmt.Errorf("ipnlocal.NewLocalBackend: %w", err)
|
||||||
}
|
}
|
||||||
if err := ns.Start(lb); err != nil {
|
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"))
|
wireTaildropFileOps(lb, jsConfig.Get("fileOps"))
|
||||||
srv.SetLocalBackend(lb)
|
|
||||||
|
|
||||||
jsIPN := &jsIPN{
|
jsIPN := &jsIPN{
|
||||||
dialer: dialer,
|
dialer: dialer,
|
||||||
srv: srv,
|
|
||||||
lb: lb,
|
lb: lb,
|
||||||
ns: ns,
|
ns: ns,
|
||||||
controlURL: controlURL,
|
controlURL: controlURL,
|
||||||
@@ -197,7 +235,7 @@ 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,
|
terminate: terminate,
|
||||||
}
|
}
|
||||||
lb.SetTCPHandlerForFunnelFlow(jsIPN.handleFunnelTCP)
|
lb.SetTCPHandlerForFunnelFlow(jsIPN.handleFunnelTCP)
|
||||||
|
|
||||||
@@ -377,12 +415,11 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
wireDriveJS(jsIPN, driveFS, m)
|
wireDriveJS(jsIPN, driveFS, m)
|
||||||
return m
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type jsIPN struct {
|
type jsIPN struct {
|
||||||
dialer *tsdial.Dialer
|
dialer *tsdial.Dialer
|
||||||
srv *ipnserver.Server
|
|
||||||
lb *ipnlocal.LocalBackend
|
lb *ipnlocal.LocalBackend
|
||||||
ns *netstack.Impl
|
ns *netstack.Impl
|
||||||
controlURL string
|
controlURL string
|
||||||
@@ -393,10 +430,7 @@ 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
|
terminate func() // unblocks main() so the Go runtime can exit
|
||||||
// can close it and unblock srv.Run.
|
|
||||||
ln net.Listener
|
|
||||||
shutdownCh chan struct{} // closed by shutdown() to unblock main()
|
|
||||||
shutdownOnce sync.Once
|
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() {
|
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 {
|
func (i *jsIPN) shutdown() js.Value {
|
||||||
return makePromise(func() (any, error) {
|
return makePromise(func() (any, error) {
|
||||||
i.shutdownOnce.Do(func() {
|
i.shutdownOnce.Do(func() {
|
||||||
if i.lb != nil {
|
if i.lb != nil {
|
||||||
i.lb.Shutdown()
|
i.lb.Shutdown()
|
||||||
}
|
}
|
||||||
if i.ln != nil {
|
i.terminate()
|
||||||
i.ln.Close()
|
|
||||||
}
|
|
||||||
close(i.shutdownCh)
|
|
||||||
})
|
})
|
||||||
return nil, nil
|
return nil, nil
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,22 +5,15 @@ package safesocket
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"net"
|
"net"
|
||||||
"sync/atomic"
|
|
||||||
|
|
||||||
"github.com/akutz/memconn"
|
"github.com/akutz/memconn"
|
||||||
)
|
)
|
||||||
|
|
||||||
const memName = "Tailscale-IPN"
|
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) {
|
func listen(path string) (net.Listener, error) {
|
||||||
name := fmt.Sprintf("%s-%d", memName, memSeq.Add(1))
|
return memconn.Listen("memu", memName)
|
||||||
return memconn.Listen("memu", name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func connect(ctx context.Context, _ string) (net.Conn, error) {
|
func connect(ctx context.Context, _ string) (net.Conn, error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user