20 Commits

Author SHA1 Message Date
codinget e7270026f7 fix(tsconnect/wasm): nil-check lb and ln in shutdown() before use
lb and ln are only initialised during run(); calling shutdown() before
run() panics on nil. Guard both fields before dereferencing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 21:54:55 +00:00
codinget 4618ee1496 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:35:17 +00:00
codinget 915dca44fe 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:35:17 +00:00
codinget 6e83d5291b feat(tsconnect/wasm): add shutdown() to jsIPN
Expose a shutdown() method on the JS-side IPN object that stops the
LocalBackend, closes the safesocket listener (which unblocks srv.Run),
and signals main() to return so the Go runtime exits cleanly.

This allows the host environment (Node.js process or browser service
worker) to terminate normally once the Tailscale WASM module is no
longer needed, instead of being kept alive indefinitely by open handles,
goroutines, or the Go runtime's blocking main goroutine.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 00:22:56 +00:00
codinget 21d0f11d85 feat(taildrop): stream files via ReadableStream on send and receive
Send: accept a ReadableStreamDefaultReader instead of a Uint8Array.
jsStreamReader (new io.ReadCloser) awaits reader.read() Promises via the
channel+FuncOf pattern, feeding chunks directly to the HTTP PUT body.
No js.CopyBytesToGo of the full file.

Receive: openWaitingFile now returns a pull-based ReadableStream backed by
the Go io.ReadCloser (jsReadableStream helper). Each pull call reads up
to 64 KiB and enqueues a Uint8Array chunk; no io.ReadAll.

jsFileOps.OpenReader: JS now returns a ReadableStream instead of a
Uint8Array; Go wraps it in jsStreamReader for streaming delivery.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 21:13:04 +00:00
codinget 0df765eb60 chore(tsconnect): drop wasm pre-compression from build-pkg
Consumers are now responsible for compressing assets; the package ships
only the raw main.wasm binary.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 23:01:45 +00:00
codinget 52cae45f81 fix(wasm): correct ICMP case in ping type error message
The constant tailcfg.PingICMP is "ICMP" not "icmp"; the error message
was listing the wrong string, causing user confusion about valid values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 15:28:50 +00:00
codinget 7fd2507611 fix(wasm): validate ping type early; fallback DNS resolver for exit node
Add a switch guard before the 30-second context in ping() so that invalid
ping type strings (e.g. "disco" vs "Disco") reject immediately with a clear
error rather than silently timing out because userspaceEngine.Ping has no
default case.

For queryDNS(), detect SERVFAIL responses returned with an empty resolver
list (the typical state when an exit node is active but the DNS manager
forwarder has no configured upstreams) and fall back to querying 8.8.8.8
via the dialer — which honours exit-node routing — for A/AAAA record types.
Fall further back to the browser's native resolver if UserDial fails.

Also accept bare IP addresses in whoIs() (in addition to ip:port) so
callers don't need to fabricate a port when they only have a peer IP.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 15:20:40 +00:00
codinget 8514045909 feat(tsconnect): add peerAPIURL to netmap and localAPI in-process bridge
Include the PeerAPI base URL (http://ip:port) in every node entry of the
notifyNetMap payload — for self via LocalBackend.GetPeerAPIPort, for peers
by reading the PeerAPI4/PeerAPI6 Services entries in their Hostinfo. The URL
mirrors the address-family preference used by peerAPIBase (prefer IPv4).

Add a localAPI(method, path, body?) WASM binding that dispatches in-process
HTTP requests directly to a LocalAPI handler with full read/write/cert
permissions, returning {status, body}. Enables TypeScript callers to access
any LocalAPI endpoint (ACL policy, Taildrive shares, etc.) without network
setup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 01:19:37 +00:00
codinget 7f5983eaab feat(tsconnect): add whoIs, queryDNS, ping, suggestExitNode WASM bindings
Expose four LocalBackend capabilities to JavaScript:
- whoIs(addrPort, proto?): resolves a connecting ip:port to a tailnet node
  and user profile; returns null for unknown peers
- queryDNS(name, type?): queries the tailnet DNS resolver (MagicDNS +
  upstream); parses A/AAAA/CNAME/TXT answers into strings
- ping(ip, type?, size?): pings a tailnet peer (TSMP, disco, ICMP, peerapi)
  with a 30 s context timeout; returns latency and path details
- suggestExitNode(): asks the coordination server for the best exit node

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 21:55:58 +00:00
codinget 143581c955 feat(tsconnect): add getCert, listenTLS, setFunnel + fix TLS cert for WASM
Enable ACME TLS certificates on js/wasm by dropping the !js build tag from
cert.go and routing storage through the state store. Add getCert, listenTLS,
and setFunnel WASM bindings with a combinedTLSListener that merges Funnel
ingress and direct tailnet connections. Notify the control plane immediately
after serve config changes to accelerate Funnel DNS provisioning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 11:19:25 +00:00
codinget d9efc3bae2 fix(tsconnect): pin types to avoid monorepo @types pollution
Replace skipLibCheck with an explicit types list so TypeScript and
dts-bundle-generator only auto-include @types/golang-wasm-exec and
@types/qrcode, preventing @types/eslint-scope and @types/ws from
leaking in from a parent node_modules when built inside a monorepo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 20:04:20 +00:00
codinget 9e36a7f27f fix(tsconnect): skipLibCheck to avoid monorepo @types conflicts
When tsconnect is built inside a JS monorepo, TypeScript walks up the
directory tree and auto-discovers @types/eslint-scope and @types/ws
from the root node_modules, causing spurious type errors unrelated to
tsconnect itself. skipLibCheck suppresses these.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 19:52:29 +00:00
codinget 8277fc0f1d fix(tsconnect): lowercase name/size in waitingFiles JSON
apitype.WaitingFile has no json tags so it serialised as {Name, Size}.
Introduce a local jsWaitingFile struct with json:"name" / json:"size"
so the JS side receives idiomatic camelCase property names.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 19:39:52 +00:00
codinget e32520659d fix(taildrop): restore incoming file progress notifications
The io.Copy in PutFile was writing directly to wc, bypassing the
incomingFile wrapper whose Write method increments f.copied and fires
a throttled sendFileNotify on progress. As a result, notifyIncomingFiles
on the JS side only ever fired once (on completion) with received=0,
making progress UI impossible. The original inFile wrapping was lost
during the Android SAF refactor.

Also surface the PartialFile.Done flag through jsIncomingFile so JS can
distinguish the final "transfer complete" notification from in-progress
updates.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-16 19:04:02 +00:00
codinget e8eb9d71c2 fix(tsconnect): guard nil n.Prefs in notify callback
n.Prefs is *PrefsView (a pointer), so calling n.Prefs.Valid() on a
Notify where Prefs is nil auto-dereferenced nil and panicked. The
callback's defer recover() swallowed the panic, which meant every
Notify without Prefs (Health-only, FilesWaiting, IncomingFiles,
OutgoingFiles, etc.) never reached the file-related JS calls.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-16 18:43:58 +00:00
codinget c4ff4c4835 feat(tsconnect): add outgoing file transfer progress notifications
- Export UpdateOutgoingFiles on taildrop.Extension so it can be called
  from outside the package (wasm bridge, package main).
- Wrap sendFile's PUT body with progresstracking.NewReader so bytes-sent
  is sampled roughly once per second during transfer.
- Create an OutgoingFile entry (with UUID, peer ID, name, declared size)
  before the PUT and call UpdateOutgoingFiles on each progress tick and
  on completion (setting Finished/Succeeded). This flows into the IPN
  notify stream as OutgoingFiles notifications.
- Add jsOutgoingFile struct and wire n.OutgoingFiles into a new
  notifyOutgoingFiles callback in run(), mirroring notifyIncomingFiles.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 23:01:30 +00:00
codinget 68ecc4b033 feat(tsconnect): add notifyFilesWaiting and notifyIncomingFiles callbacks
Wire two new callbacks into the IPN notify stream:

- notifyFilesWaiting: fires when a completed inbound transfer is staged
  and ready to retrieve via waitingFiles(). Triggered by n.FilesWaiting
  in the notify stream.
- notifyIncomingFiles: fires with a JSON snapshot of in-progress inbound
  transfers whenever progress changes (roughly once per second while
  active, plus once at completion). The jsIncomingFile struct carries
  name, started (Unix ms), declaredSize, and received bytes. An empty
  array indicates all active transfers have finished.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 22:58:13 +00:00
codinget 9f96b7434c feat(taildrop): fix DirectFileMode, void callbacks, and empty WaitingFiles
- Add SetStagedFileOps to Extension: sets fileOps without enabling
  DirectFileMode, so WASM clients use staged retrieval (WaitingFiles,
  OpenFile, DeleteFile) instead of direct-write mode.
- Add directFileOps bool field: SetFileOps (Android SAF) sets it true;
  SetStagedFileOps (WASM JS) leaves it false. onChangeProfile now uses
  `fops != nil && e.directFileOps` to determine DirectFileMode.
- Add jsCallVoid to jsFileOps: void ops (openWriter, write, closeWriter,
  remove) now use cb(err?: string) instead of cb(null, err: string).
- Fix waitingFiles() returning JSON null when no files are waiting:
  normalise nil slice to empty slice before marshalling.
- Update wireTaildropFileOps to call SetStagedFileOps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 22:48:11 +00:00
codinget b04b4f7751 feat(tsconnect): expose exit node selection to JS
Add exit node support to the wasm JS bridge:

- Include `exitNodeOption` and `stableNodeID` on each peer in the
  notifyNetMap payload so callers can identify which peers are exit
  nodes and reference them by stable ID.
- Call `notifyExitNode(stableNodeID)` whenever prefs change, so
  callers can track which exit node (if any) is currently active.
- Expose `setExitNode(stableNodeID)` — sets ExitNodeID via EditPrefs.
- Expose `setExitNodeEnabled(enabled)` — toggles the last-used exit
  node on/off via SetUseExitNodeEnabled.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:43:01 +00:00
14 changed files with 1362 additions and 49 deletions
-12
View File
@@ -13,7 +13,6 @@ import (
"path" "path"
"github.com/tailscale/hujson" "github.com/tailscale/hujson"
"tailscale.com/util/precompress"
"tailscale.com/version" "tailscale.com/version"
) )
@@ -39,10 +38,6 @@ func runBuildPkg() {
runEsbuild(*buildOptions) runEsbuild(*buildOptions)
if err := precompressWasm(); err != nil {
log.Fatalf("Could not pre-recompress wasm: %v", err)
}
log.Printf("Generating types...\n") log.Printf("Generating types...\n")
if err := runYarn("pkg-types"); err != nil { if err := runYarn("pkg-types"); err != nil {
log.Fatalf("Type generation failed: %v", err) log.Fatalf("Type generation failed: %v", err)
@@ -59,13 +54,6 @@ func runBuildPkg() {
log.Printf("Built package version %s", version.Long()) log.Printf("Built package version %s", version.Long())
} }
func precompressWasm() error {
log.Printf("Pre-compressing main.wasm...\n")
return precompress.Precompress(path.Join(*pkgDir, "main.wasm"), precompress.Options{
FastCompression: *fastCompression,
})
}
func updateVersion() error { func updateVersion() error {
packageJSONBytes, err := os.ReadFile("package.json.tmpl") packageJSONBytes, err := os.ReadFile("package.json.tmpl")
if err != nil { if err != nil {
+2 -1
View File
@@ -8,7 +8,8 @@
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"sourceMap": true, "sourceMap": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"jsxImportSource": "preact" "jsxImportSource": "preact",
"types": ["golang-wasm-exec", "qrcode"]
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules"] "exclude": ["node_modules"]
+510
View File
@@ -0,0 +1,510 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// This file bridges the Taildrop FileOps interface to JS callbacks,
// using the same channel+FuncOf pattern as the Go stdlib's WASM HTTP
// transport (src/net/http/roundtrip_js.go): Go passes a js.FuncOf to JS,
// then blocks on a channel until JS calls it back — which may be async.
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"net/url"
"os"
"syscall/js"
"time"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/feature/taildrop"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/tailcfg"
"tailscale.com/util/progresstracking"
"tailscale.com/util/rands"
)
// Compile-time check that jsFileOps implements taildrop.FileOps.
var _ taildrop.FileOps = (*jsFileOps)(nil)
// taildropExt returns the taildrop extension, or an error if unavailable.
func (i *jsIPN) taildropExt() (*taildrop.Extension, error) {
ext, ok := ipnlocal.GetExt[*taildrop.Extension](i.lb)
if !ok {
return nil, errors.New("taildrop extension not available")
}
return ext, nil
}
// listFileTargets returns the peers that can receive Taildrop files as a JSON
// array of {stableNodeID, name, addresses, os} objects.
func (i *jsIPN) listFileTargets() js.Value {
return makePromise(func() (any, error) {
ext, err := i.taildropExt()
if err != nil {
return nil, err
}
fts, err := ext.FileTargets()
if err != nil {
return nil, err
}
type jsTarget struct {
StableNodeID string `json:"stableNodeID"`
Name string `json:"name"`
Addresses []string `json:"addresses"`
OS string `json:"os"`
}
out := make([]jsTarget, 0, len(fts))
for _, ft := range fts {
addrs := make([]string, 0, len(ft.Node.Addresses))
for _, a := range ft.Node.Addresses {
addrs = append(addrs, a.Addr().String())
}
out = append(out, jsTarget{
StableNodeID: string(ft.Node.StableID),
Name: ft.Node.Name,
Addresses: addrs,
OS: ft.Node.Hostinfo.OS(),
})
}
b, err := json.Marshal(out)
if err != nil {
return nil, err
}
return string(b), nil
})
}
// sendFile sends stream as filename to the peer identified by stableNodeID,
// reporting progress via notifyOutgoingFiles callbacks roughly once per second.
// declaredSize is the total byte count (-1 if unknown); it is used for progress
// reporting and sets Content-Length on the PUT request (chunked TE when -1).
func (i *jsIPN) sendFile(stableNodeID, filename string, stream js.Value, declaredSize int) js.Value {
return makePromise(func() (any, error) {
ext, err := i.taildropExt()
if err != nil {
return nil, err
}
fts, err := ext.FileTargets()
if err != nil {
return nil, err
}
var ft *apitype.FileTarget
for _, x := range fts {
if x.Node.StableID == tailcfg.StableNodeID(stableNodeID) {
ft = x
break
}
}
if ft == nil {
return nil, fmt.Errorf("node %q not found or not a file target", stableNodeID)
}
dstURL, err := url.Parse(ft.PeerAPIURL)
if err != nil {
return nil, fmt.Errorf("bogus peer URL: %w", err)
}
reader := stream.Call("getReader")
body := &jsStreamReader{reader: reader}
outgoing := &ipn.OutgoingFile{
ID: rands.HexString(30),
PeerID: tailcfg.StableNodeID(stableNodeID),
Name: filename,
DeclaredSize: int64(declaredSize),
Started: time.Now(),
}
updates := map[string]*ipn.OutgoingFile{outgoing.ID: outgoing}
// Report final state (success or failure) when the function returns.
var sendErr error
defer func() {
outgoing.Finished = true
outgoing.Succeeded = sendErr == nil
ext.UpdateOutgoingFiles(updates)
}()
progressBody := progresstracking.NewReader(body, time.Second, func(n int, _ error) {
outgoing.Sent = int64(n)
ext.UpdateOutgoingFiles(updates)
})
req, err := http.NewRequest("PUT", dstURL.String()+"/v0/put/"+url.PathEscape(filename), progressBody)
if err != nil {
sendErr = err
return nil, err
}
req.ContentLength = int64(declaredSize)
client := &http.Client{Transport: i.lb.Dialer().PeerAPITransport()}
resp, err := client.Do(req)
if err != nil {
sendErr = err
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
respBody, _ := io.ReadAll(resp.Body)
b := make([]byte, len(respBody))
copy(b, respBody)
// trim trailing whitespace
for len(b) > 0 && (b[len(b)-1] == '\n' || b[len(b)-1] == '\r' || b[len(b)-1] == ' ') {
b = b[:len(b)-1]
}
sendErr = fmt.Errorf("send file: %s: %s", resp.Status, b)
return nil, sendErr
}
return nil, nil
})
}
// waitingFiles returns received files waiting for pickup as a JSON array of
// {name, size} objects. Always returns an array (never null).
func (i *jsIPN) waitingFiles() js.Value {
return makePromise(func() (any, error) {
ext, err := i.taildropExt()
if err != nil {
return nil, err
}
wfs, err := ext.WaitingFiles()
if err != nil {
return nil, err
}
type jsWaitingFile struct {
Name string `json:"name"`
Size int64 `json:"size"`
}
out := make([]jsWaitingFile, len(wfs))
for i, wf := range wfs {
out[i] = jsWaitingFile{Name: wf.Name, Size: wf.Size}
}
b, err := json.Marshal(out)
if err != nil {
return nil, err
}
return string(b), nil
})
}
// openWaitingFile returns the contents of a received file as a ReadableStream.
// The stream emits Uint8Array chunks and closes when the file is fully read.
func (i *jsIPN) openWaitingFile(name string) js.Value {
return makePromise(func() (any, error) {
ext, err := i.taildropExt()
if err != nil {
return nil, err
}
rc, _, err := ext.OpenFile(name)
if err != nil {
return nil, err
}
return jsReadableStream(rc), nil
})
}
// deleteWaitingFile deletes a received file by name.
func (i *jsIPN) deleteWaitingFile(name string) js.Value {
return makePromise(func() (any, error) {
ext, err := i.taildropExt()
if err != nil {
return nil, err
}
return nil, ext.DeleteFile(name)
})
}
// wireTaildropFileOps installs a JS-backed FileOps on the taildrop extension
// if jsObj is a non-null JS object. It must be called after NewLocalBackend
// and before lb.Start (i.e. before run() is called by the user), so that the
// FileOps is in place when the extension's onChangeProfile hook fires on init.
//
// SetStagedFileOps is used instead of SetFileOps so that files are staged for
// explicit retrieval via WaitingFiles/OpenFile rather than delivered directly
// (DirectFileMode=false). The JS caller fetches them via waitingFiles() et al.
func wireTaildropFileOps(lb *ipnlocal.LocalBackend, jsObj js.Value) {
if jsObj.IsUndefined() || jsObj.IsNull() {
return
}
ext, ok := ipnlocal.GetExt[*taildrop.Extension](lb)
if !ok {
return
}
ext.SetStagedFileOps(&jsFileOps{v: jsObj})
}
// jsStreamReader implements io.ReadCloser by pulling chunks from a JS
// ReadableStreamDefaultReader. Each Read call awaits one reader.read() Promise,
// using the channel+FuncOf pattern so Go blocks until JS delivers the chunk.
type jsStreamReader struct {
reader js.Value
buf []byte
done bool
}
func (r *jsStreamReader) Read(p []byte) (int, error) {
if r.done {
return 0, io.EOF
}
if len(r.buf) > 0 {
n := copy(p, r.buf)
r.buf = r.buf[n:]
return n, nil
}
type chunkResult struct {
data []byte
done bool
}
ch := make(chan chunkResult, 1)
thenFn := js.FuncOf(func(this js.Value, args []js.Value) any {
result := args[0]
if result.Get("done").Bool() {
ch <- chunkResult{done: true}
} else {
value := result.Get("value")
b := make([]byte, value.Get("byteLength").Int())
js.CopyBytesToGo(b, value)
ch <- chunkResult{data: b}
}
return nil
})
defer thenFn.Release()
r.reader.Call("read").Call("then", thenFn)
result := <-ch
if result.done {
r.done = true
return 0, io.EOF
}
n := copy(p, result.data)
r.buf = result.data[n:]
return n, nil
}
func (r *jsStreamReader) Close() error {
r.reader.Call("cancel")
return nil
}
// jsReadableStream wraps rc in a pull-based JS ReadableStream. Each pull call
// reads up to 64 KiB from rc and enqueues a Uint8Array chunk; the stream
// closes on EOF or signals an error on any other read failure.
func jsReadableStream(rc io.ReadCloser) js.Value {
var pullFn, cancelFn js.Func
cancelFn = js.FuncOf(func(this js.Value, args []js.Value) any {
rc.Close()
pullFn.Release()
cancelFn.Release()
return nil
})
pullFn = js.FuncOf(func(this js.Value, args []js.Value) any {
controller := args[0]
var execFn js.Func
execFn = js.FuncOf(func(this js.Value, rr []js.Value) any {
resolve := rr[0]
go func() {
defer execFn.Release()
buf := make([]byte, 65536)
n, err := rc.Read(buf)
if n > 0 {
chunk := js.Global().Get("Uint8Array").New(n)
js.CopyBytesToJS(chunk, buf[:n])
controller.Call("enqueue", chunk)
}
if err == io.EOF {
rc.Close()
pullFn.Release()
cancelFn.Release()
controller.Call("close")
} else if err != nil {
rc.Close()
pullFn.Release()
cancelFn.Release()
controller.Call("error", err.Error())
}
resolve.Invoke()
}()
return nil
})
return js.Global().Get("Promise").New(execFn)
})
return js.Global().Get("ReadableStream").New(map[string]any{
"pull": pullFn,
"cancel": cancelFn,
})
}
// jsFileOps implements [taildrop.FileOps] by delegating to JS callbacks.
// JS methods use one of two callback conventions:
//
// Void ops (openWriter, write, closeWriter, remove): cb(err?: string)
//
// on success: cb() or cb("")
// on error: cb("error message")
// not found: cb("ENOENT")
//
// Result ops (rename, listFiles, stat, openReader): cb(result: T | null, err?: string)
//
// on success: cb(result)
// on error: cb(null, "error message")
// not found: cb(null, "ENOENT")
type jsFileOps struct {
v js.Value
}
// jsCallResult invokes method on j.v, appending a Go-owned js.FuncOf as the
// final argument. It blocks until JS calls back with (result, errStr?), then
// returns (result, error). An absent or empty errStr means success.
//
// JS convention for result ops: cb(result: T | null, err?: string)
func (j jsFileOps) jsCallResult(method string, args ...any) (js.Value, error) {
type result struct {
val js.Value
err error
}
ch := make(chan result, 1)
cb := js.FuncOf(func(this js.Value, cbArgs []js.Value) any {
var r result
if len(cbArgs) > 0 {
if t := cbArgs[0].Type(); t != js.TypeNull && t != js.TypeUndefined {
r.val = cbArgs[0]
}
}
if len(cbArgs) > 1 && cbArgs[1].Type() == js.TypeString {
if s := cbArgs[1].String(); s != "" {
r.err = errors.New(s)
}
}
ch <- r
return nil
})
defer cb.Release()
j.v.Call(method, append(args, cb)...)
r := <-ch
return r.val, r.err
}
// jsCallVoid invokes method on j.v for operations that return no result,
// appending a Go-owned js.FuncOf as the final argument. It blocks until JS
// calls back with an optional error string, then returns the error or nil.
//
// JS convention for void ops: cb(err?: string)
func (j jsFileOps) jsCallVoid(method string, args ...any) error {
ch := make(chan error, 1)
cb := js.FuncOf(func(this js.Value, cbArgs []js.Value) any {
var err error
if len(cbArgs) > 0 && cbArgs[0].Type() == js.TypeString {
if s := cbArgs[0].String(); s != "" {
err = errors.New(s)
}
}
ch <- err
return nil
})
defer cb.Release()
j.v.Call(method, append(args, cb)...)
return <-ch
}
// isJSNotExist reports whether err is the sentinel "ENOENT" from JS.
func isJSNotExist(err error) bool {
return err != nil && err.Error() == "ENOENT"
}
func (j jsFileOps) OpenWriter(name string, offset int64, _ os.FileMode) (io.WriteCloser, string, error) {
if err := j.jsCallVoid("openWriter", name, offset); err != nil {
return nil, "", err
}
return &jsWriteCloser{ops: j, name: name}, name, nil
}
type jsWriteCloser struct {
ops jsFileOps
name string
}
func (w *jsWriteCloser) Write(p []byte) (int, error) {
buf := js.Global().Get("Uint8Array").New(len(p))
js.CopyBytesToJS(buf, p)
if err := w.ops.jsCallVoid("write", w.name, buf); err != nil {
return 0, err
}
return len(p), nil
}
func (w *jsWriteCloser) Close() error {
return w.ops.jsCallVoid("closeWriter", w.name)
}
func (j jsFileOps) Remove(name string) error {
err := j.jsCallVoid("remove", name)
if isJSNotExist(err) {
return &fs.PathError{Op: "remove", Path: name, Err: fs.ErrNotExist}
}
return err
}
func (j jsFileOps) Rename(oldPath, newName string) (string, error) {
val, err := j.jsCallResult("rename", oldPath, newName)
if err != nil {
return "", err
}
return val.String(), nil
}
func (j jsFileOps) ListFiles() ([]string, error) {
val, err := j.jsCallResult("listFiles")
if err != nil {
return nil, err
}
n := val.Length()
names := make([]string, n)
for i := 0; i < n; i++ {
names[i] = val.Index(i).String()
}
return names, nil
}
func (j jsFileOps) Stat(name string) (fs.FileInfo, error) {
val, err := j.jsCallResult("stat", name)
if isJSNotExist(err) {
return nil, &fs.PathError{Op: "stat", Path: name, Err: fs.ErrNotExist}
}
if err != nil {
return nil, err
}
// Use Float to correctly handle files larger than 2 GiB (int is 32-bit on wasm).
return &jsFileInfo{name: name, size: int64(val.Float())}, nil
}
func (j jsFileOps) OpenReader(name string) (io.ReadCloser, error) {
val, err := j.jsCallResult("openReader", name)
if isJSNotExist(err) {
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
}
if err != nil {
return nil, err
}
// val is a ReadableStream; wrap its reader for streaming delivery to Go.
reader := val.Call("getReader")
return &jsStreamReader{reader: reader}, nil
}
// jsFileInfo is a minimal [fs.FileInfo] backed by a name and a size.
// Only Size() is used by the taildrop manager; the other fields are stubs.
type jsFileInfo struct {
name string
size int64
}
func (i *jsFileInfo) Name() string { return i.name }
func (i *jsFileInfo) Size() int64 { return i.size }
func (i *jsFileInfo) Mode() fs.FileMode { return 0o444 }
func (i *jsFileInfo) ModTime() time.Time { return time.Time{} }
func (i *jsFileInfo) IsDir() bool { return false }
func (i *jsFileInfo) Sys() any { return nil }
+767 -27
View File
@@ -18,17 +18,21 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"log" "log"
"math/rand/v2" "math/rand/v2"
"net" "net"
"net/http" "net/http"
"net/http/httptest"
"net/netip" "net/netip"
"strconv" "strconv"
"strings" "strings"
"sync"
"syscall/js" "syscall/js"
"time" "time"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
"golang.org/x/net/dns/dnsmessage"
"gvisor.dev/gvisor/pkg/tcpip" "gvisor.dev/gvisor/pkg/tcpip"
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet" "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
"gvisor.dev/gvisor/pkg/tcpip/network/ipv4" "gvisor.dev/gvisor/pkg/tcpip/network/ipv4"
@@ -40,15 +44,18 @@ import (
"tailscale.com/ipn/ipnauth" "tailscale.com/ipn/ipnauth"
"tailscale.com/ipn/ipnlocal" "tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/ipnserver" "tailscale.com/ipn/ipnserver"
"tailscale.com/ipn/localapi"
"tailscale.com/ipn/store/mem" "tailscale.com/ipn/store/mem"
"tailscale.com/logpolicy" "tailscale.com/logpolicy"
"tailscale.com/logtail" "tailscale.com/logtail"
"tailscale.com/net/bakedroots" "tailscale.com/net/bakedroots"
"tailscale.com/net/netns" "tailscale.com/net/netns"
"tailscale.com/net/tsaddr"
"tailscale.com/net/tsdial" "tailscale.com/net/tsdial"
"tailscale.com/safesocket" "tailscale.com/safesocket"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
"tailscale.com/tsd" "tailscale.com/tsd"
"tailscale.com/types/logid"
"tailscale.com/types/views" "tailscale.com/types/views"
"tailscale.com/wgengine" "tailscale.com/wgengine"
"tailscale.com/wgengine/netstack" "tailscale.com/wgengine/netstack"
@@ -59,19 +66,20 @@ 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]) return newIPN(args[0], shutdownCh)
})) }))
// Keep Go runtime alive, otherwise it will be shut down before newIPN gets // Block until shutdown() is called on the IPN, then let main return so the
// called. // Go runtime (and all its goroutines) can be collected by the JS engine.
<-make(chan bool) <-shutdownCh
} }
func newIPN(jsConfig js.Value) map[string]any { func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
netns.SetEnabled(false) netns.SetEnabled(false)
var store ipn.StateStore var store ipn.StateStore
@@ -159,17 +167,22 @@ func newIPN(jsConfig js.Value) map[string]any {
if err := ns.Start(lb); err != nil { if err := ns.Start(lb); err != nil {
log.Fatalf("failed to start netstack: %v", err) log.Fatalf("failed to start netstack: %v", err)
} }
wireTaildropFileOps(lb, jsConfig.Get("fileOps"))
srv.SetLocalBackend(lb) srv.SetLocalBackend(lb)
jsIPN := &jsIPN{ jsIPN := &jsIPN{
dialer: dialer, dialer: dialer,
srv: srv, srv: srv,
lb: lb, lb: lb,
ns: ns, ns: ns,
controlURL: controlURL, controlURL: controlURL,
authKey: authKey, authKey: authKey,
hostname: hostname, hostname: hostname,
logID: logid,
funnelPorts: make(map[uint16]*funnelListenerEntry),
shutdownCh: shutdownCh,
} }
lb.SetTCPHandlerForFunnelFlow(jsIPN.handleFunnelTCP)
return map[string]any{ return map[string]any{
"run": js.FuncOf(func(this js.Value, args []js.Value) any { "run": js.FuncOf(func(this js.Value, args []js.Value) any {
@@ -252,6 +265,118 @@ func newIPN(jsConfig js.Value) map[string]any {
} }
return jsIPN.dialTLS(args[0].String(), opts) return jsIPN.dialTLS(args[0].String(), opts)
}), }),
"setExitNode": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 1 {
log.Printf("Usage: setExitNode(stableNodeID)")
return nil
}
return jsIPN.setExitNode(args[0].String())
}),
"setExitNodeEnabled": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 1 {
log.Printf("Usage: setExitNodeEnabled(enabled)")
return nil
}
return jsIPN.setExitNodeEnabled(args[0].Bool())
}),
"listFileTargets": js.FuncOf(func(this js.Value, args []js.Value) any {
return jsIPN.listFileTargets()
}),
"sendFile": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 4 {
log.Printf("Usage: sendFile(stableNodeID, filename, stream, declaredSize)")
return nil
}
return jsIPN.sendFile(args[0].String(), args[1].String(), args[2], args[3].Int())
}),
"waitingFiles": js.FuncOf(func(this js.Value, args []js.Value) any {
return jsIPN.waitingFiles()
}),
"openWaitingFile": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 1 {
log.Printf("Usage: openWaitingFile(name)")
return nil
}
return jsIPN.openWaitingFile(args[0].String())
}),
"deleteWaitingFile": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 1 {
log.Printf("Usage: deleteWaitingFile(name)")
return nil
}
return jsIPN.deleteWaitingFile(args[0].String())
}),
"getCert": js.FuncOf(func(this js.Value, args []js.Value) any {
return jsIPN.getCert()
}),
"listenTLS": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 3 {
log.Printf("Usage: listenTLS(addr, certPEM, keyPEM)")
return nil
}
return jsIPN.listenTLS(args[0].String(), args[1].String(), args[2].String())
}),
"setFunnel": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 3 {
log.Printf("Usage: setFunnel(hostname, port, enabled)")
return nil
}
return jsIPN.setFunnel(args[0].String(), uint16(args[1].Int()), args[2].Bool())
}),
"whoIs": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) < 1 {
log.Printf("Usage: whoIs(addrPort[, proto])")
return nil
}
proto := ""
if len(args) >= 2 {
proto = args[1].String()
}
return jsIPN.whoIs(args[0].String(), proto)
}),
"queryDNS": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) < 1 {
log.Printf("Usage: queryDNS(name[, type])")
return nil
}
qtype := 1 // TypeA
if len(args) >= 2 {
qtype = args[1].Int()
}
return jsIPN.queryDNS(args[0].String(), qtype)
}),
"ping": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) < 1 {
log.Printf("Usage: ping(ip[, type[, size]])")
return nil
}
pingType := "TSMP"
if len(args) >= 2 {
pingType = args[1].String()
}
size := 0
if len(args) >= 3 {
size = args[2].Int()
}
return jsIPN.ping(args[0].String(), pingType, size)
}),
"suggestExitNode": js.FuncOf(func(this js.Value, args []js.Value) any {
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 {
if len(args) < 2 {
log.Printf("Usage: localAPI(method, path[, body])")
return nil
}
body := ""
if len(args) >= 3 {
body = args[2].String()
}
return jsIPN.localAPI(args[0].String(), args[1].String(), body)
}),
} }
} }
@@ -263,6 +388,22 @@ type jsIPN struct {
controlURL string controlURL string
authKey string authKey string
hostname string hostname string
logID logid.PublicID
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()
shutdownOnce sync.Once
}
// funnelListenerEntry is the per-port state for routing Funnel connections to a listenTLS listener.
type funnelListenerEntry struct {
ch chan net.Conn
tlsCfg *tls.Config
} }
var jsIPNState = map[ipn.State]string{ var jsIPNState = map[ipn.State]string{
@@ -304,6 +445,31 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
notifyState(*n.State) notifyState(*n.State)
} }
if nm := n.NetMap; nm != nil { if nm := n.NetMap; 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{ jsNetMap := jsNetMap{
Self: jsNetMapSelfNode{ Self: jsNetMapSelfNode{
jsNetMapNode: jsNetMapNode{ jsNetMapNode: jsNetMapNode{
@@ -311,6 +477,7 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
Addresses: mapSliceView(nm.GetAddresses(), func(a netip.Prefix) string { return a.Addr().String() }), Addresses: mapSliceView(nm.GetAddresses(), func(a netip.Prefix) string { return a.Addr().String() }),
NodeKey: nm.NodeKey.String(), NodeKey: nm.NodeKey.String(),
MachineKey: nm.MachineKey.String(), MachineKey: nm.MachineKey.String(),
PeerAPIURL: selfPeerAPIURL,
}, },
MachineStatus: jsMachineStatus[nm.GetMachineStatus()], MachineStatus: jsMachineStatus[nm.GetMachineStatus()],
}, },
@@ -321,18 +488,50 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
name = p.Hostinfo().Hostname() name = p.Hostinfo().Hostname()
} }
addrs := make([]string, p.Addresses().Len()) addrs := make([]string, p.Addresses().Len())
for i, ap := range p.Addresses().All() { for idx, ap := range p.Addresses().All() {
addrs[i] = ap.Addr().String() addrs[idx] = ap.Addr().String()
} }
// Peer peerAPI URL from the peer's advertised Services.
peerURL := ""
var pp4, pp6 uint16
for _, s := range p.Hostinfo().Services().All() {
switch s.Proto {
case tailcfg.PeerAPI4:
pp4 = s.Port
case tailcfg.PeerAPI6:
pp6 = s.Port
}
}
if selfHave4 && pp4 != 0 {
for _, a := range p.Addresses().All() {
if a.IsSingleIP() && a.Addr().Is4() {
peerURL = fmt.Sprintf("http://%v", netip.AddrPortFrom(a.Addr(), pp4))
break
}
}
}
if peerURL == "" && selfHave6 && pp6 != 0 {
for _, a := range p.Addresses().All() {
if a.IsSingleIP() && a.Addr().Is6() {
peerURL = fmt.Sprintf("http://%v", netip.AddrPortFrom(a.Addr(), pp6))
break
}
}
}
return jsNetMapPeerNode{ return jsNetMapPeerNode{
jsNetMapNode: jsNetMapNode{ jsNetMapNode: jsNetMapNode{
Name: name, Name: name,
Addresses: addrs, Addresses: addrs,
MachineKey: p.Machine().String(), MachineKey: p.Machine().String(),
NodeKey: p.Key().String(), NodeKey: p.Key().String(),
PeerAPIURL: peerURL,
}, },
Online: p.Online().Clone(), Online: p.Online().Clone(),
TailscaleSSHEnabled: p.Hostinfo().TailscaleSSHEnabled(), TailscaleSSHEnabled: p.Hostinfo().TailscaleSSHEnabled(),
ExitNodeOption: tsaddr.ContainsExitRoutes(p.AllowedIPs()),
StableNodeID: string(p.StableID()),
} }
}), }),
LockedOut: nm.TKAEnabled && nm.SelfNode.KeySignature().Len() == 0, LockedOut: nm.TKAEnabled && nm.SelfNode.KeySignature().Len() == 0,
@@ -343,9 +542,52 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
log.Printf("Could not generate JSON netmap: %v", err) log.Printf("Could not generate JSON netmap: %v", err)
} }
} }
if n.Prefs != nil && n.Prefs.Valid() {
jsCallbacks.Call("notifyExitNode", string(n.Prefs.ExitNodeID()))
}
if n.BrowseToURL != nil { if n.BrowseToURL != nil {
jsCallbacks.Call("notifyBrowseToURL", *n.BrowseToURL) jsCallbacks.Call("notifyBrowseToURL", *n.BrowseToURL)
} }
if n.FilesWaiting != nil {
jsCallbacks.Call("notifyFilesWaiting")
}
if n.IncomingFiles != nil {
files := make([]jsIncomingFile, len(n.IncomingFiles))
for i, f := range n.IncomingFiles {
files[i] = jsIncomingFile{
Name: f.Name,
Started: f.Started.UnixMilli(),
DeclaredSize: f.DeclaredSize,
Received: f.Received,
Done: f.Done,
}
}
if b, err := json.Marshal(files); err == nil {
jsCallbacks.Call("notifyIncomingFiles", string(b))
} else {
log.Printf("could not marshal IncomingFiles: %v", err)
}
}
if n.OutgoingFiles != nil {
files := make([]jsOutgoingFile, len(n.OutgoingFiles))
for i, f := range n.OutgoingFiles {
files[i] = jsOutgoingFile{
ID: f.ID,
PeerID: string(f.PeerID),
Name: f.Name,
Started: f.Started.UnixMilli(),
DeclaredSize: f.DeclaredSize,
Sent: f.Sent,
Finished: f.Finished,
Succeeded: f.Succeeded,
}
}
if b, err := json.Marshal(files); err == nil {
jsCallbacks.Call("notifyOutgoingFiles", string(b))
} else {
log.Printf("could not marshal OutgoingFiles: %v", err)
}
}
}) })
go func() { go func() {
@@ -363,14 +605,17 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
} }
}() }()
go func() { ln, err := safesocket.Listen("")
ln, err := safesocket.Listen("") if err != nil {
if err != nil { log.Fatalf("safesocket.Listen: %v", err)
log.Fatalf("safesocket.Listen: %v", err) }
} i.ln = ln
err = i.srv.Run(context.Background(), ln) go func() {
log.Fatalf("ipnserver.Run exited: %v", err) err := i.srv.Run(context.Background(), ln)
if err != nil && !errors.Is(err, net.ErrClosed) {
log.Fatalf("ipnserver.Run exited: %v", err)
}
}() }()
} }
@@ -389,6 +634,21 @@ func (i *jsIPN) logout() {
}() }()
} }
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)
})
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,
@@ -576,6 +836,24 @@ func (i *jsIPN) fetch(url string) js.Value {
}) })
} }
func (i *jsIPN) setExitNode(stableNodeID string) js.Value {
return makePromise(func() (any, error) {
mp := &ipn.MaskedPrefs{
ExitNodeIDSet: true,
Prefs: ipn.Prefs{ExitNodeID: tailcfg.StableNodeID(stableNodeID)},
}
_, err := i.lb.EditPrefs(mp)
return nil, err
})
}
func (i *jsIPN) setExitNodeEnabled(enabled bool) js.Value {
return makePromise(func() (any, error) {
_, err := i.lb.SetUseExitNodeEnabled(ipnauth.Self, enabled)
return nil, err
})
}
func (i *jsIPN) dial(network, addr string) js.Value { func (i *jsIPN) dial(network, addr string) js.Value {
return makePromise(func() (any, error) { return makePromise(func() (any, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
@@ -599,6 +877,11 @@ func (i *jsIPN) listen(network, addr string) js.Value {
if n == "tcp" { if n == "tcp" {
n = "tcp4" n = "tcp4"
} }
// netstack.ListenTCP requires a full host:port; normalise the
// standard net.Listen form ":port" that omits the host.
if strings.HasPrefix(addr, ":") {
addr = "0.0.0.0" + addr
}
ln, err := i.ns.ListenTCP(n, addr) ln, err := i.ns.ListenTCP(n, addr)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -691,6 +974,437 @@ func (i *jsIPN) listenICMP(network string) js.Value {
}) })
} }
func (i *jsIPN) getCert() js.Value {
return makePromise(func() (any, error) {
nm := i.lb.NetMap()
if nm == nil {
return nil, errors.New("getCert: no network map available")
}
certDomains := nm.DNS.CertDomains
if len(certDomains) == 0 {
return nil, errors.New("getCert: this tailnet does not support TLS certificates")
}
pair, err := i.lb.GetCertPEM(context.Background(), certDomains[0])
if err != nil {
return nil, err
}
return map[string]any{
"certPEM": string(pair.CertPEM),
"keyPEM": string(pair.KeyPEM),
}, nil
})
}
func (i *jsIPN) listenTLS(addr, certPEM, keyPEM string) js.Value {
return makePromise(func() (any, error) {
cert, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM))
if err != nil {
return nil, fmt.Errorf("listenTLS: parsing cert/key: %w", err)
}
tlsCfg := &tls.Config{Certificates: []tls.Certificate{cert}}
tcpLn, err := i.ns.ListenTCP("tcp4", addr)
if err != nil {
return nil, err
}
// Determine the actual port (handles ":0" ephemeral assignment).
// Use SplitHostPort rather than netip.ParseAddrPort because gVisor
// may return ":443" (empty host) which ParseAddrPort rejects.
_, portStr, err := net.SplitHostPort(tcpLn.Addr().String())
if err != nil {
tcpLn.Close()
return nil, fmt.Errorf("listenTLS: getting port from listener addr: %w", err)
}
portNum, err := strconv.ParseUint(portStr, 10, 16)
if err != nil {
tcpLn.Close()
return nil, fmt.Errorf("listenTLS: parsing port %q: %w", portStr, err)
}
port := uint16(portNum)
// Register a Funnel entry so handleFunnelTCP can route to this listener.
entry := &funnelListenerEntry{
ch: make(chan net.Conn, 8),
tlsCfg: tlsCfg,
}
i.funnelMu.Lock()
i.funnelPorts[port] = entry
i.funnelMu.Unlock()
ln := newCombinedTLSListener(tcpLn, tlsCfg, entry.ch, port, i)
return wrapTCPListener(ln), nil
})
}
// handleFunnelTCP is registered with LocalBackend.SetTCPHandlerForFunnelFlow.
// It routes incoming Funnel connections to the matching listenTLS listener.
func (i *jsIPN) handleFunnelTCP(src netip.AddrPort, dstPort uint16) func(net.Conn) {
i.funnelMu.Lock()
entry := i.funnelPorts[dstPort]
i.funnelMu.Unlock()
if entry == nil {
return nil
}
return func(conn net.Conn) {
tlsConn := tls.Server(conn, entry.tlsCfg)
select {
case entry.ch <- tlsConn:
default:
// Channel full; drop the connection rather than block.
conn.Close()
}
}
}
// combinedTLSListener merges TLS connections from the local netstack (direct
// tailnet access) and from Funnel ingress into a single net.Listener.
type combinedTLSListener struct {
tcpLn net.Listener
tlsCfg *tls.Config
funnelCh <-chan net.Conn
port uint16
ipn *jsIPN
netstackCh chan net.Conn
errCh chan error
done chan struct{}
closeOnce sync.Once
}
func newCombinedTLSListener(tcpLn net.Listener, tlsCfg *tls.Config, funnelCh <-chan net.Conn, port uint16, ipn *jsIPN) *combinedTLSListener {
l := &combinedTLSListener{
tcpLn: tcpLn,
tlsCfg: tlsCfg,
funnelCh: funnelCh,
port: port,
ipn: ipn,
netstackCh: make(chan net.Conn, 8),
errCh: make(chan error, 1),
done: make(chan struct{}),
}
go l.drainNetstack()
return l
}
func (l *combinedTLSListener) drainNetstack() {
for {
conn, err := l.tcpLn.Accept()
if err != nil {
select {
case l.errCh <- err:
default:
}
return
}
tlsConn := tls.Server(conn, l.tlsCfg)
select {
case l.netstackCh <- tlsConn:
case <-l.done:
conn.Close()
return
}
}
}
func (l *combinedTLSListener) Accept() (net.Conn, error) {
select {
case conn := <-l.funnelCh:
return conn, nil
case conn := <-l.netstackCh:
return conn, nil
case err := <-l.errCh:
return nil, err
case <-l.done:
return nil, net.ErrClosed
}
}
func (l *combinedTLSListener) Close() error {
l.closeOnce.Do(func() {
close(l.done)
l.tcpLn.Close()
l.ipn.funnelMu.Lock()
delete(l.ipn.funnelPorts, l.port)
l.ipn.funnelMu.Unlock()
})
return nil
}
func (l *combinedTLSListener) Addr() net.Addr {
return l.tcpLn.Addr()
}
func (i *jsIPN) setFunnel(hostname string, port uint16, enabled bool) js.Value {
return makePromise(func() (any, error) {
hp := ipn.HostPort(fmt.Sprintf("%s:%d", hostname, port))
var cfg *ipn.ServeConfig
if enabled {
cfg = &ipn.ServeConfig{
AllowFunnel: map[ipn.HostPort]bool{hp: true},
}
} else {
cfg = &ipn.ServeConfig{}
}
return nil, i.lb.SetServeConfig(cfg, "")
})
}
func (i *jsIPN) whoIs(addr string, proto string) js.Value {
return makePromise(func() (any, error) {
// Accept both "ip:port" and bare "ip" (port 0 still resolves by IP).
var ipp netip.AddrPort
if ap, err := netip.ParseAddrPort(addr); err == nil {
ipp = ap
} else if ip, err := netip.ParseAddr(addr); err == nil {
ipp = netip.AddrPortFrom(ip, 0)
} else {
return nil, fmt.Errorf("whoIs: invalid address %q (want ip:port or ip)", addr)
}
n, u, ok := i.lb.WhoIs(proto, ipp)
if !ok {
return nil, nil
}
addrs := make([]any, n.Addresses().Len())
for idx, ap := range n.Addresses().All() {
addrs[idx] = ap.Addr().String()
}
return map[string]any{
"node": map[string]any{
"id": string(n.StableID()),
"name": n.Name(),
"addresses": addrs,
},
"user": map[string]any{
"id": int64(u.ID),
"loginName": u.LoginName,
"displayName": u.DisplayName,
"profilePicURL": u.ProfilePicURL,
},
}, nil
})
}
func (i *jsIPN) queryDNS(name string, queryType int) js.Value {
return makePromise(func() (any, error) {
res, resolvers, err := i.lb.QueryDNS(name, dnsmessage.Type(queryType))
// Detect SERVFAIL with no upstream resolvers (common when an exit node is
// active but the DNS manager forwarder has no configured upstreams). Fall
// back to querying 8.8.8.8 via the dialer (which routes through the exit
// node), then as a last resort use the browser's default name resolver.
needsFallback := err != nil
if !needsFallback && len(resolvers) == 0 && len(res) > 0 {
var hdrParser dnsmessage.Parser
if hdr, hdrErr := hdrParser.Start(res); hdrErr == nil && hdr.RCode == dnsmessage.RCodeServerFailure {
needsFallback = true
}
}
if needsFallback {
qt := dnsmessage.Type(queryType)
if qt != dnsmessage.TypeA && qt != dnsmessage.TypeAAAA {
if err != nil {
return nil, fmt.Errorf("queryDNS: %w (no upstream resolver; only A/AAAA queries support fallback)", err)
}
return nil, fmt.Errorf("queryDNS: no upstream resolver available; only A/AAAA queries support fallback lookup")
}
ctx := context.Background()
d := i.dialer
r := &net.Resolver{
PreferGo: true,
Dial: func(rctx context.Context, network, address string) (net.Conn, error) {
return d.UserDial(rctx, "tcp", "8.8.8.8:53")
},
}
ips, rerr := r.LookupIPAddr(ctx, name)
if rerr != nil {
// Last resort: browser-native resolution (no exit-node routing).
ips, rerr = (&net.Resolver{PreferGo: false}).LookupIPAddr(ctx, name)
if rerr != nil {
return nil, fmt.Errorf("queryDNS: fallback resolution failed: %w", rerr)
}
}
var answers []any
for _, ia := range ips {
ip, ok := netip.AddrFromSlice(ia.IP)
if !ok {
continue
}
ip = ip.Unmap()
if qt == dnsmessage.TypeA && ip.Is4() {
answers = append(answers, ip.String())
} else if qt == dnsmessage.TypeAAAA && ip.Is6() {
answers = append(answers, ip.String())
}
}
return map[string]any{
"answers": answers,
"resolvers": []any{},
}, nil
}
var p dnsmessage.Parser
if _, err := p.Start(res); err != nil {
return nil, fmt.Errorf("queryDNS: parsing response: %w", err)
}
if err := p.SkipAllQuestions(); err != nil {
return nil, fmt.Errorf("queryDNS: skipping questions: %w", err)
}
var answers []any
for {
h, err := p.AnswerHeader()
if err == dnsmessage.ErrSectionDone {
break
}
if err != nil {
return nil, fmt.Errorf("queryDNS: reading answer: %w", err)
}
switch h.Type {
case dnsmessage.TypeA:
r, err := p.AResource()
if err != nil {
return nil, fmt.Errorf("queryDNS: reading A record: %w", err)
}
answers = append(answers, netip.AddrFrom4(r.A).String())
case dnsmessage.TypeAAAA:
r, err := p.AAAAResource()
if err != nil {
return nil, fmt.Errorf("queryDNS: reading AAAA record: %w", err)
}
answers = append(answers, netip.AddrFrom16(r.AAAA).String())
case dnsmessage.TypeCNAME:
r, err := p.CNAMEResource()
if err != nil {
return nil, fmt.Errorf("queryDNS: reading CNAME record: %w", err)
}
answers = append(answers, r.CNAME.String())
case dnsmessage.TypeTXT:
r, err := p.TXTResource()
if err != nil {
return nil, fmt.Errorf("queryDNS: reading TXT record: %w", err)
}
for _, s := range r.TXT {
answers = append(answers, s)
}
default:
if err := p.SkipAnswer(); err != nil {
return nil, fmt.Errorf("queryDNS: skipping unknown answer: %w", err)
}
}
}
resolverAddrs := make([]any, len(resolvers))
for idx, r := range resolvers {
resolverAddrs[idx] = r.Addr
}
return map[string]any{
"answers": answers,
"resolvers": resolverAddrs,
}, nil
})
}
func (i *jsIPN) ping(ip string, pingType string, size int) js.Value {
return makePromise(func() (any, error) {
addr, err := netip.ParseAddr(ip)
if err != nil {
return nil, fmt.Errorf("ping: invalid IP %q: %w", ip, err)
}
switch tailcfg.PingType(pingType) {
case tailcfg.PingDisco, tailcfg.PingTSMP, tailcfg.PingICMP, tailcfg.PingPeerAPI:
// valid
default:
return nil, fmt.Errorf("ping: unknown type %q, must be one of: disco, TSMP, ICMP, peerapi", pingType)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
pr, err := i.lb.Ping(ctx, addr, tailcfg.PingType(pingType), size)
if err != nil {
return nil, err
}
result := map[string]any{
"ip": pr.IP,
"nodeIP": pr.NodeIP,
"nodeName": pr.NodeName,
"latencySeconds": pr.LatencySeconds,
"endpoint": pr.Endpoint,
"derpRegionID": pr.DERPRegionID,
"derpRegionCode": pr.DERPRegionCode,
"peerAPIURL": pr.PeerAPIURL,
"isLocalIP": pr.IsLocalIP,
}
if pr.Err != "" {
result["err"] = pr.Err
}
return result, nil
})
}
func (i *jsIPN) suggestExitNode() js.Value {
return makePromise(func() (any, error) {
resp, err := i.lb.SuggestExitNode()
if err != nil {
return nil, err
}
result := map[string]any{
"id": string(resp.ID),
"name": resp.Name,
}
if l := resp.Location; l.Valid() {
result["location"] = map[string]any{
"country": l.Country(),
"countryCode": l.CountryCode(),
"city": l.City(),
"cityCode": l.CityCode(),
"latitude": l.Latitude(),
"longitude": l.Longitude(),
}
}
return result, nil
})
}
func (i *jsIPN) localAPI(method, path, body string) js.Value {
return makePromise(func() (any, error) {
h := localapi.NewHandler(localapi.HandlerConfig{
Actor: &ipnauth.TestActor{
Name: "wasm",
LocalAdmin: true,
},
Backend: i.lb,
Logf: log.Printf,
LogID: i.logID,
EventBus: i.lb.Sys().Bus.Get(),
})
h.PermitRead = true
h.PermitWrite = true
h.PermitCert = true
var bodyReader io.Reader
if body != "" {
bodyReader = strings.NewReader(body)
}
req, err := http.NewRequest(method, "http://local-tailscaled.sock"+path, bodyReader)
if err != nil {
return nil, fmt.Errorf("localAPI: %w", err)
}
// Empty Host passes the validHost check in the LocalAPI handler.
req.Host = ""
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
resp := w.Result()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("localAPI: reading response: %w", err)
}
return map[string]any{
"status": resp.StatusCode,
"body": string(respBody),
}, nil
})
}
// wrapConn exposes a net.Conn to JavaScript with binary (Uint8Array) I/O. // wrapConn exposes a net.Conn to JavaScript with binary (Uint8Array) I/O.
func wrapConn(conn net.Conn) map[string]any { func wrapConn(conn net.Conn) map[string]any {
return map[string]any{ return map[string]any{
@@ -855,6 +1569,29 @@ func (w termWriter) Write(p []byte) (n int, err error) {
return len(p), nil return len(p), nil
} }
// jsIncomingFile is the JSON representation of an in-progress inbound file
// transfer sent to the notifyIncomingFiles callback.
type jsIncomingFile struct {
Name string `json:"name"`
Started int64 `json:"started"` // Unix milliseconds; use new Date(started) in JS
DeclaredSize int64 `json:"declaredSize"` // -1 if unknown
Received int64 `json:"received"` // bytes received so far
Done bool `json:"done"` // true once the file has been fully received
}
// jsOutgoingFile is the JSON representation of an outgoing file transfer
// sent to the notifyOutgoingFiles callback.
type jsOutgoingFile struct {
ID string `json:"id"`
PeerID string `json:"peerID"`
Name string `json:"name"`
Started int64 `json:"started"` // Unix milliseconds
DeclaredSize int64 `json:"declaredSize"` // -1 if unknown
Sent int64 `json:"sent"` // bytes sent so far
Finished bool `json:"finished"`
Succeeded bool `json:"succeeded"` // only meaningful when finished
}
type jsNetMap struct { type jsNetMap struct {
Self jsNetMapSelfNode `json:"self"` Self jsNetMapSelfNode `json:"self"`
Peers []jsNetMapPeerNode `json:"peers"` Peers []jsNetMapPeerNode `json:"peers"`
@@ -862,10 +1599,11 @@ type jsNetMap struct {
} }
type jsNetMapNode struct { type jsNetMapNode struct {
Name string `json:"name"` Name string `json:"name"`
Addresses []string `json:"addresses"` Addresses []string `json:"addresses"`
MachineKey string `json:"machineKey"` MachineKey string `json:"machineKey"`
NodeKey string `json:"nodeKey"` NodeKey string `json:"nodeKey"`
PeerAPIURL string `json:"peerAPIURL,omitempty"`
} }
type jsNetMapSelfNode struct { type jsNetMapSelfNode struct {
@@ -875,8 +1613,10 @@ type jsNetMapSelfNode struct {
type jsNetMapPeerNode struct { type jsNetMapPeerNode struct {
jsNetMapNode jsNetMapNode
Online *bool `json:"online,omitempty"` Online *bool `json:"online,omitempty"`
TailscaleSSHEnabled bool `json:"tailscaleSSHEnabled"` TailscaleSSHEnabled bool `json:"tailscaleSSHEnabled"`
ExitNodeOption bool `json:"exitNodeOption"`
StableNodeID string `json:"stableNodeID"`
} }
type jsStateStore struct { type jsStateStore struct {
+16 -2
View File
@@ -76,6 +76,12 @@ type Extension struct {
// This is currently being used for Android to use the Storage Access Framework. // This is currently being used for Android to use the Storage Access Framework.
fileOps FileOps fileOps FileOps
// directFileOps, when true, means that files received via fileOps should be
// delivered directly to the caller (DirectFileMode=true). Set by SetFileOps.
// SetStagedFileOps leaves this false so that received files are staged for
// explicit retrieval via WaitingFiles/OpenFile (used by the WASM JS bridge).
directFileOps bool
nodeBackendForTest ipnext.NodeBackend // if non-nil, pretend we're this node state for tests nodeBackendForTest ipnext.NodeBackend // if non-nil, pretend we're this node state for tests
mu sync.Mutex // Lock order: lb.mu > e.mu mu sync.Mutex // Lock order: lb.mu > e.mu
@@ -155,9 +161,10 @@ func (e *Extension) onChangeProfile(profile ipn.LoginProfileView, _ ipn.PrefsVie
// Use the provided [FileOps] implementation (typically for SAF access on Android), // Use the provided [FileOps] implementation (typically for SAF access on Android),
// or create an [fsFileOps] instance rooted at fileRoot. // or create an [fsFileOps] instance rooted at fileRoot.
// //
// A non-nil [FileOps] also implies that we are in DirectFileMode. // A non-nil [FileOps] with directFileOps=true implies DirectFileMode (Android SAF).
// A non-nil [FileOps] with directFileOps=false uses staged mode (WASM JS bridge).
fops := e.fileOps fops := e.fileOps
isDirectFileMode := fops != nil isDirectFileMode := fops != nil && e.directFileOps
if fops == nil { if fops == nil {
var fileRoot string var fileRoot string
if fileRoot, isDirectFileMode = e.fileRoot(uid, activeLogin); fileRoot == "" { if fileRoot, isDirectFileMode = e.fileRoot(uid, activeLogin); fileRoot == "" {
@@ -411,6 +418,13 @@ func (e *Extension) taildropTargetStatus(p tailcfg.NodeView, nb ipnext.NodeBacke
return ipnstate.TaildropTargetAvailable return ipnstate.TaildropTargetAvailable
} }
// UpdateOutgoingFiles updates the tracked set of outgoing file transfers and
// sends an ipn.Notify with the full merged list. The updates map is keyed by
// OutgoingFile.ID; existing entries not present in updates are preserved.
func (e *Extension) UpdateOutgoingFiles(updates map[string]*ipn.OutgoingFile) {
e.updateOutgoingFiles(updates)
}
// updateOutgoingFiles updates b.outgoingFiles to reflect the given updates and // updateOutgoingFiles updates b.outgoingFiles to reflect the given updates and
// sends an ipn.Notify with the full list of outgoingFiles. // sends an ipn.Notify with the full list of outgoingFiles.
func (e *Extension) updateOutgoingFiles(updates map[string]*ipn.OutgoingFile) { func (e *Extension) updateOutgoingFiles(updates map[string]*ipn.OutgoingFile) {
+1 -1
View File
@@ -1,6 +1,6 @@
// Copyright (c) Tailscale Inc & contributors // Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
//go:build !android //go:build !android && !js
package taildrop package taildrop
+16
View File
@@ -0,0 +1,16 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build js
package taildrop
import "errors"
func init() {
// On WASM there is no real filesystem. newFileOps is only reached when
// SetFileOps was not called; return a clear error rather than panicking.
newFileOps = func(dir string) (FileOps, error) {
return nil, errors.New("taildrop: no filesystem on WASM; provide fileOps in the IPN config")
}
}
+12 -1
View File
@@ -18,10 +18,21 @@ func (e *Extension) SetDirectFileRoot(root string) {
e.directFileRoot = root e.directFileRoot = root
} }
// SetFileOps sets the platform specific file operations. This is used // SetFileOps sets the platform-specific file operations. This is used
// to call Android's Storage Access Framework APIs. // to call Android's Storage Access Framework APIs.
// It implies DirectFileMode, so received files are delivered directly to the
// caller rather than staged for retrieval via WaitingFiles/OpenFile.
func (e *Extension) SetFileOps(fileOps FileOps) { func (e *Extension) SetFileOps(fileOps FileOps) {
e.fileOps = fileOps e.fileOps = fileOps
e.directFileOps = true
}
// SetStagedFileOps sets the platform-specific file operations without enabling
// DirectFileMode. Received files are staged for explicit retrieval via
// WaitingFiles, OpenFile, and DeleteFile. Used by the WASM JS bridge.
func (e *Extension) SetStagedFileOps(fileOps FileOps) {
e.fileOps = fileOps
e.directFileOps = false
} }
func (e *Extension) setPlatformDefaultDirectFileRoot() { func (e *Extension) setPlatformDefaultDirectFileRoot() {
+3 -2
View File
@@ -134,8 +134,9 @@ func (m *manager) PutFile(id clientID, baseName string, r io.Reader, offset, len
} }
} }
// Copy the contents of the file to the writer. // Copy via inFile (which wraps wc) so [incomingFile.Write] can track
copyLength, err := io.Copy(wc, r) // progress and fire periodic sendFileNotify callbacks.
copyLength, err := io.Copy(inFile, r)
if err != nil { if err != nil {
return 0, m.redactAndLogError("Copy", err) return 0, m.redactAndLogError("Copy", err)
} }
+17 -1
View File
@@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & contributors // Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
//go:build !js && !ts_omit_acme //go:build !ts_omit_acme
package ipnlocal package ipnlocal
@@ -302,6 +302,9 @@ var errCertExpired = errors.New("cert expired")
var testX509Roots *x509.CertPool // set non-nil by tests var testX509Roots *x509.CertPool // set non-nil by tests
func (b *LocalBackend) getCertStore() (certStore, error) { func (b *LocalBackend) getCertStore() (certStore, error) {
if runtime.GOOS == "js" {
return certStateStore{StateStore: b.store}, nil
}
switch b.store.(type) { switch b.store.(type) {
case *store.FileStore: case *store.FileStore:
case *mem.Store: case *mem.Store:
@@ -333,6 +336,16 @@ func (b *LocalBackend) ConfigureCertsForTest(getCert func(hostname string) (*TLS
b.mu.Unlock() b.mu.Unlock()
} }
// SetACMEHTTPClient sets a custom HTTP client for ACME certificate operations.
// On js/wasm, this can be used to route requests through the Tailscale network
// stack to bypass browser CORS if Let's Encrypt endpoints fail preflight.
// A nil value (the default) uses the standard http.DefaultClient.
func (b *LocalBackend) SetACMEHTTPClient(c *http.Client) {
b.mu.Lock()
defer b.mu.Unlock()
b.acmeHTTPClient = c
}
// certFileStore implements certStore by storing the cert & key files in the named directory. // certFileStore implements certStore by storing the cert & key files in the named directory.
type certFileStore struct { type certFileStore struct {
dir string dir string
@@ -550,6 +563,9 @@ var getCertPEM = func(ctx context.Context, b *LocalBackend, cs certStore, logf l
if err != nil { if err != nil {
return nil, err return nil, err
} }
b.mu.Lock()
ac.HTTPClient = b.acmeHTTPClient
b.mu.Unlock()
if !isDefaultDirectoryURL(ac.DirectoryURL) { if !isDefaultDirectoryURL(ac.DirectoryURL) {
logf("acme: using Directory URL %q", ac.DirectoryURL) logf("acme: using Directory URL %q", ac.DirectoryURL)
+1 -1
View File
@@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & contributors // Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
//go:build js || ts_omit_acme //go:build ts_omit_acme
package ipnlocal package ipnlocal
+4
View File
@@ -412,6 +412,10 @@ type LocalBackend struct {
// See [LocalBackend.ConfigureCertsForTest]. // See [LocalBackend.ConfigureCertsForTest].
getCertForTest func(hostname string) (*TLSCertKeyPair, error) getCertForTest func(hostname string) (*TLSCertKeyPair, error)
// acmeHTTPClient, if non-nil, is used for all ACME HTTP requests instead
// of http.DefaultClient. Set via SetACMEHTTPClient before first cert use.
acmeHTTPClient *http.Client
// existsPendingAuthReconfig tracks if a goroutine is waiting to // existsPendingAuthReconfig tracks if a goroutine is waiting to
// acquire [LocalBackend]'s mutex inside of [LocalBackend.AuthReconfig]. // acquire [LocalBackend]'s mutex inside of [LocalBackend.AuthReconfig].
// It is used to prevent goroutines from piling up to do the same // It is used to prevent goroutines from piling up to do the same
+5
View File
@@ -393,6 +393,11 @@ func (b *LocalBackend) setServeConfigLocked(config *ipn.ServeConfig, etag string
} }
} }
// Notify the control plane immediately so that changes to IngressEnabled /
// WireIngress (required for Funnel DNS provisioning) are not delayed until
// the next periodic heartbeat.
b.authReconfigLocked()
return nil return nil
} }
+8 -1
View File
@@ -5,15 +5,22 @@ 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) {
return memconn.Listen("memu", memName) name := fmt.Sprintf("%s-%d", memName, memSeq.Add(1))
return memconn.Listen("memu", name)
} }
func connect(ctx context.Context, _ string) (net.Conn, error) { func connect(ctx context.Context, _ string) (net.Conn, error) {