Author SHA1 Message Date
codingetandClaude f1904a0f7d 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-07-28 17:40:39 +00:00
codingetandClaude c0acbfc399 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-07-28 17:40:39 +00:00
codingetandClaude 75965f86e3 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-07-28 17:40:39 +00:00
codingetandClaude 308312cbc6 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-07-28 17:40:06 +00:00
codingetandClaude 62368af810 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-07-28 17:40:06 +00:00
codingetandClaude a1aa13da24 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-07-28 17:40:06 +00:00
codingetandClaude f1708334a3 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-07-28 17:39:10 +00:00
codingetandClaude 90a32450ee 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-07-28 17:39:09 +00:00
codingetandClaude 4e00cfff66 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-07-28 17:35:39 +00:00
codingetandClaude 6c630c6abf 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-07-28 17:35:39 +00:00
codingetandClaude d36f64bea2 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-07-28 17:35:39 +00:00
codingetandClaude 10caf83326 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-07-28 17:35:39 +00:00
codingetandClaude d73f4278c7 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-07-28 17:35:39 +00:00
codingetandClaude 33289574ef 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-07-28 17:35:39 +00:00
codingetandClaude 0c6b23f427 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-07-28 17:31:43 +00:00
codingetandClaude 22e68fe5d9 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-07-28 17:31:43 +00:00
codingetandClaude 343b3fe583 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-07-28 17:31:43 +00:00
codinget 1a13f1cdb3 feat(tsconnect): add TCP listening to ipn.listen
Extend ipn.listen to also accept "tcp"/"tcp4"/"tcp6" and return a
TCPListener bound to a netstack gonet.TCPListener. The listener
exposes accept/close/addr like a Go net.Listener and additionally
implements Symbol.asyncIterator so JS callers can write:

  for await (const conn of listener) { ... }

The async iterator returns done when the listener is closed (via
errors.Is(net.ErrClosed)) and rejects on any other accept error.
Symbol-keyed properties are set via Reflect.set since syscall/js
only exposes string-keyed Set.
2026-07-28 17:31:13 +00:00
codinget dec5041157 feat(tsconnect): expose dialTLS to JS
Add ipn.dialTLS(addr, opts?) which dials a TCP connection through
the Tailscale dialer and performs a TLS handshake on top, returning
a JS Conn just like ipn.dial.

WASM has no system root pool, so verification defaults to the
baked-in LetsEncrypt ISRG roots already linked via net/bakedroots.
That covers any tailnet HTTPS endpoint provisioned via
`tailscale cert`. Callers can override with opts.caCerts (PEM) or
bypass entirely with opts.insecureSkipVerify, and override SNI with
opts.serverName.

Marginal binary cost is ~10 KiB on top of the existing ~31.6 MiB
wasm: crypto/tls and the x509 verification path are already pulled
in by control/controlclient and net/tlsdial.
2026-07-28 17:31:13 +00:00
codinget 434acfdf03 feat(tsconnect): expose dial, listen and listenICMP to JS
Wire up the userspace networking primitives to the JS bridge so
browser callers can initiate outbound and receive inbound traffic
over the Tailscale network:

- ipn.dial(network, addr) wraps a tsdial UserDial into a JS Conn
  with read/write/close/localAddr/remoteAddr.
- ipn.listen(network, addr) wraps a netstack ListenPacket into a
  JS PacketConn with readFrom/writeTo/close/localAddr.
- ipn.listenICMP("icmp4"|"icmp6"|"icmp") creates a raw ICMP
  endpoint on the underlying gVisor stack and wraps it as a
  PacketConn for sending/receiving ping traffic.

To support listenICMP, netstack.Impl gains a Stack() accessor that
returns the underlying *stack.Stack so jsIPN can call NewEndpoint
with icmp.ProtocolNumber4/6.

Binary I/O uses js.CopyBytesToGo / js.CopyBytesToJS to move bytes
across the syscall/js boundary without base64 round-trips.
2026-07-28 17:31:13 +00:00
16 changed files with 482 additions and 1602 deletions
+13 -16
View File
@@ -5,7 +5,6 @@ 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()
@@ -14,25 +13,23 @@ 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
// 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)
)
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,
})
app.runWithIPN(ipn)
}
-87
View File
@@ -1,87 +0,0 @@
// 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<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
// 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<void> = 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<never> = 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<IPN>
type Terminate = () => void
/** Must match initCallbackEnv in wasm_js.go. */
const INIT_CALLBACK_ENV = "TSCONNECT_INIT_CALLBACK"
+5 -2
View File
@@ -7,7 +7,6 @@
/// <reference path="../types/wasm_js.d.ts" />
import "../wasm_exec"
import { startIPN } from "../lib/start-ipn"
import wasmURL from "./main.wasm"
/**
@@ -31,7 +30,11 @@ export async function createIPN(config: IPNPackageConfig): Promise<IPN> {
go.importObject
)
// The Go process should never exit, if it does then it's an unhandled panic.
return startIPN(go, wasmInstance.instance, config, config.panicHandler)
go.run(wasmInstance.instance).then(() =>
config.panicHandler("Unexpected shutdown")
)
return newIPN(config)
}
export { runSSHSession } from "../lib/ssh"
+2 -8
View File
@@ -7,18 +7,12 @@
*/
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<void>
ssh(
host: string,
username: string,
-334
View File
@@ -1,334 +0,0 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build js && wasm
package main
import (
"context"
"errors"
"io"
"net/http/httptest"
"strings"
"syscall/js"
"testing"
"time"
"tailscale.com/ipn"
"tailscale.com/tailcfg"
)
func TestMakePromiseRejectsJSError(t *testing.T) {
errValue := js.Global().Get("Error").New("boom")
promise := makePromise(func() (any, error) {
panic(js.Error{Value: errValue})
})
rejection := awaitPromise(t, promise)
if rejection.Type() != js.TypeObject || rejection.Get("message").String() != "boom" {
t.Fatalf("rejection = %v, want Error(boom)", rejection)
}
}
func TestDriveHandlerThrowReturns500(t *testing.T) {
fn := evalJS(t, `(function() { throw new Error("boom") })`)
fs := new(jsFileSystemForRemote)
fs.setHandler(fn)
recorder := httptest.NewRecorder()
fs.ServeHTTPWithPerms(nil, recorder, httptest.NewRequest("GET", "/", nil))
if recorder.Code != 500 {
t.Fatalf("status = %d, want 500", recorder.Code)
}
}
func TestDriveCancellationDisablesCallbacks(t *testing.T) {
type handlerArgs struct {
req js.Value
res js.Value
}
started := make(chan handlerArgs, 1)
deferred := evalJS(t, `(() => {
let resolve
const promise = new Promise(r => { resolve = r })
return {promise, resolve}
})()`)
handler := js.FuncOf(func(this js.Value, args []js.Value) any {
started <- handlerArgs{req: args[0], res: args[1]}
return deferred.Get("promise")
})
defer handler.Release()
fs := new(jsFileSystemForRemote)
fs.setHandler(handler.Value)
ctx, cancel := context.WithCancel(context.Background())
recorder := httptest.NewRecorder()
done := make(chan struct{})
go func() {
fs.ServeHTTPWithPerms(nil, recorder, httptest.NewRequest("GET", "/", nil).WithContext(ctx))
close(done)
}()
var jsArgs handlerArgs
select {
case jsArgs = <-started:
case <-time.After(time.Second):
t.Fatal("drive handler did not start")
}
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("drive handler did not stop after cancellation")
}
readResult := jsArgs.req.Call("readBodyChunk")
if readResult.Type() != js.TypeObject {
t.Fatalf("readBodyChunk after cancellation returned %s, want Promise", readResult.Type())
}
if result := awaitFulfilledPromise(t, readResult); !result.IsNull() {
t.Fatalf("readBodyChunk after cancellation = %v, want null", result)
}
lateChunk := js.Global().Get("Uint8Array").New(1)
js.CopyBytesToJS(lateChunk, []byte("x"))
jsArgs.res.Call("write", lateChunk)
if recorder.Body.Len() != 0 {
t.Fatalf("late JS write changed response body to %q", recorder.Body.String())
}
deferred.Call("resolve")
writeStarted := make(chan struct{})
allowWrite := make(chan struct{})
response := &driveResponse{
w: recorder,
doneCh: make(chan error, 1),
live: true,
testBeforeWriteCheck: func() {
close(writeStarted)
<-allowWrite
},
}
chunk := js.Global().Get("Uint8Array").New(1)
js.CopyBytesToJS(chunk, []byte("x"))
writeReturned := make(chan struct{})
go func() {
response.write([]js.Value{chunk})
close(writeReturned)
}()
select {
case <-writeStarted:
case <-time.After(time.Second):
t.Fatal("response write did not start")
}
response.finish(context.Canceled, context.Canceled)
close(allowWrite)
select {
case <-writeReturned:
case <-time.After(time.Second):
t.Fatal("response callback did not finish")
}
if recorder.Body.Len() != 0 {
t.Fatalf("late write changed response body to %q", recorder.Body.String())
}
}
func TestDriveHandlerFulfillmentCompletesRequest(t *testing.T) {
handler := evalJS(t, `(function() { return Promise.resolve() })`)
fs := new(jsFileSystemForRemote)
fs.setHandler(handler)
recorder := httptest.NewRecorder()
done := make(chan struct{})
go func() {
fs.ServeHTTPWithPerms(nil, recorder, httptest.NewRequest("GET", "/", nil))
close(done)
}()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("fulfilled drive handler did not complete request")
}
}
func TestJSStreamReaderErrorsAreSticky(t *testing.T) {
for _, test := range []struct {
name string
reader string
}{
{"rejected", `({read() { return Promise.reject("boom") }, cancel() { return Promise.resolve() }})`},
{"throwing", `({read() { throw new Error("boom") }, cancel() { return Promise.resolve() }})`},
} {
t.Run(test.name, func(t *testing.T) {
r := &jsStreamReader{reader: evalJS(t, test.reader)}
_, first := r.Read(make([]byte, 1))
_, second := r.Read(make([]byte, 1))
if first == nil || second == nil || first.Error() != second.Error() || !strings.Contains(first.Error(), "boom") {
t.Fatalf("read errors = %v, %v; want matching boom errors", first, second)
}
})
}
}
func TestJSStreamReaderCloseErrors(t *testing.T) {
for _, test := range []struct {
name string
reader string
}{
{"rejected", `({cancel() { return Promise.reject("boom") }})`},
{"throwing", `({cancel() { throw new Error("boom") }})`},
} {
t.Run(test.name, func(t *testing.T) {
r := &jsStreamReader{reader: evalJS(t, test.reader)}
if err := r.Close(); err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("Close error = %v, want boom", err)
}
})
}
}
func TestJSStreamReaderCloseTimesOut(t *testing.T) {
deferred := evalJS(t, `(() => {
let timer
return {
reader: {cancel() { return {then(resolve) { timer = setTimeout(resolve, 200) }} }},
stop() { clearTimeout(timer) },
}
})()`)
defer deferred.Call("stop")
r := &jsStreamReader{
reader: deferred.Get("reader"),
testCancelTimeout: 10 * time.Millisecond,
}
started := time.Now()
if err := r.Close(); err == nil || !strings.Contains(err.Error(), "timed out") {
t.Fatalf("Close error = %v, want timeout", err)
}
if elapsed := time.Since(started); elapsed >= 250*time.Millisecond {
t.Fatalf("Close took %v, want less than 250ms", elapsed)
}
if _, err := r.Read(make([]byte, 1)); err == nil || !strings.Contains(err.Error(), "timed out") {
t.Fatalf("Read error after Close timeout = %v, want sticky timeout", err)
}
}
func TestJSStreamReaderIgnoresDuplicateSettlement(t *testing.T) {
reader := evalJS(t, `({
read() {
return {then(resolve, reject) { resolve({done: true}); reject("late") }}
},
cancel() {
return {then(resolve, reject) { resolve(); reject("late") }}
},
})`)
r := &jsStreamReader{reader: reader}
if _, err := r.Read(make([]byte, 1)); !errors.Is(err, io.EOF) {
t.Fatalf("Read error = %v, want EOF", err)
}
if err := r.Close(); err != nil {
t.Fatalf("Close error = %v, want nil", err)
}
}
func TestNotifyRefreshesNetMap(t *testing.T) {
online := true
lastSeen := time.Now()
for _, test := range []struct {
name string
n ipn.Notify
want bool
}{
{"self", ipn.Notify{SelfChange: new(tailcfg.Node)}, true},
{"peer changed", ipn.Notify{PeersChanged: []*tailcfg.Node{{}}}, true},
{"peer removed", ipn.Notify{PeersRemoved: []tailcfg.NodeID{1}}, true},
{"online patch", ipn.Notify{PeerChangedPatch: []*tailcfg.PeerChange{{Online: &online}}}, true},
{"last seen patch", ipn.Notify{PeerChangedPatch: []*tailcfg.PeerChange{{LastSeen: &lastSeen}}}, false},
{"state only", ipn.Notify{State: new(ipn.State)}, false},
} {
t.Run(test.name, func(t *testing.T) {
if got := notifyRefreshesNetMap(test.n); got != test.want {
t.Fatalf("notifyRefreshesNetMap() = %v, want %v", got, test.want)
}
})
}
}
func TestNotifyWatchMask(t *testing.T) {
want := ipn.NotifyInitialState | ipn.NotifyInitialPrefs | ipn.NotifyPeerChanges | ipn.NotifyPeerPatches
if jsIPNNotifyWatchMask != want {
t.Fatalf("jsIPNNotifyWatchMask = %v, want %v", jsIPNNotifyWatchMask, want)
}
if jsIPNNotifyWatchMask&ipn.NotifyInProcessNoDisconnect != 0 {
t.Fatal("jsIPNNotifyWatchMask must allow a lagging watcher to disconnect")
}
}
func evalJS(t *testing.T, source string) js.Value {
t.Helper()
value, err := callJSFunction(js.Global().Get("eval"), source)
if err != nil {
t.Fatal(err)
}
return value
}
func awaitPromise(t *testing.T, promise js.Value) js.Value {
t.Helper()
type result struct {
value js.Value
rejected bool
}
ch := make(chan result, 1)
resolve := js.FuncOf(func(this js.Value, args []js.Value) any {
ch <- result{value: args[0]}
return nil
})
reject := js.FuncOf(func(this js.Value, args []js.Value) any {
ch <- result{value: args[0], rejected: true}
return nil
})
defer resolve.Release()
defer reject.Release()
if _, err := callJSMethod(promise, "then", resolve, reject); err != nil {
t.Fatal(err)
}
select {
case result := <-ch:
if !result.rejected {
t.Fatal(errors.New("promise resolved; want rejection"))
}
return result.value
case <-time.After(time.Second):
t.Fatal("promise did not settle")
return js.Undefined()
}
}
func awaitFulfilledPromise(t *testing.T, promise js.Value) js.Value {
t.Helper()
type result struct {
value js.Value
rejected bool
}
ch := make(chan result, 1)
resolve := js.FuncOf(func(this js.Value, args []js.Value) any {
ch <- result{value: args[0]}
return nil
})
reject := js.FuncOf(func(this js.Value, args []js.Value) any {
ch <- result{value: args[0], rejected: true}
return nil
})
defer resolve.Release()
defer reject.Release()
if _, err := callJSMethod(promise, "then", resolve, reject); err != nil {
t.Fatal(err)
}
select {
case result := <-ch:
if result.rejected {
t.Fatalf("promise rejected with %v; want fulfillment", result.value)
}
return result.value
case <-time.After(time.Second):
t.Fatal("promise did not settle")
return js.Undefined()
}
}
-394
View File
@@ -1,394 +0,0 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_drive
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"sync"
"syscall/js"
"tailscale.com/drive"
"tailscale.com/tailcfg"
"tailscale.com/tsd"
)
// Compile-time check that jsFileSystemForRemote implements drive.FileSystemForRemote.
var _ drive.FileSystemForRemote = (*jsFileSystemForRemote)(nil)
// jsFileSystemForRemote implements drive.FileSystemForRemote by bridging
// incoming WebDAV requests to a JS handler function. Auth and permission
// parsing are handled upstream by handleServeDrive before this is called.
type jsFileSystemForRemote struct {
mu sync.RWMutex
fn js.Value
}
type driveResponse struct {
mu sync.Mutex
w http.ResponseWriter
doneCh chan error
live bool
responseStarted bool
testBeforeWriteCheck func()
}
func (r *driveResponse) isLive() bool {
r.mu.Lock()
defer r.mu.Unlock()
return r.live
}
func (r *driveResponse) writeHead(args []js.Value) {
r.mu.Lock()
defer r.mu.Unlock()
if !r.live || len(args) < 1 {
return
}
status := args[0].Int()
if len(args) > 1 && !args[1].IsUndefined() && !args[1].IsNull() {
for k, vs := range jsHeadersToGo(args[1]) {
for _, v := range vs {
r.w.Header().Add(k, v)
}
}
}
r.w.WriteHeader(status)
r.responseStarted = true
}
func (r *driveResponse) write(args []js.Value) {
if r.testBeforeWriteCheck != nil {
r.testBeforeWriteCheck()
}
r.mu.Lock()
defer r.mu.Unlock()
if !r.live || len(args) < 1 {
return
}
data := args[0]
buf := make([]byte, data.Get("length").Int())
js.CopyBytesToGo(buf, data)
r.responseStarted = true
if _, err := r.w.Write(buf); err != nil {
select {
case r.doneCh <- err:
default:
}
return
}
if f, ok := r.w.(http.Flusher); ok {
f.Flush()
}
}
func (r *driveResponse) finish(resultErr, contextErr error) {
r.mu.Lock()
defer r.mu.Unlock()
if resultErr != nil && contextErr == nil && !r.responseStarted {
http.Error(r.w, "drive handler failed", http.StatusInternalServerError)
}
r.live = false
}
func (fs *jsFileSystemForRemote) setHandler(fn js.Value) {
fs.mu.Lock()
fs.fn = fn
fs.mu.Unlock()
}
// SetFileServerAddr is a no-op: the JS handler owns its own storage.
func (fs *jsFileSystemForRemote) SetFileServerAddr(_ string) {}
// SetShares is a no-op: the JS handler controls which shares it exposes.
func (fs *jsFileSystemForRemote) SetShares(_ []*drive.Share) {}
// Close is a no-op.
func (fs *jsFileSystemForRemote) Close() error { return nil }
// ServeHTTPWithPerms handles a WebDAV request by bridging it to the JS handler.
// It streams the request body to JS via readBodyChunk() and streams the
// response body back via write()/end() callbacks, so no full-body buffering
// occurs regardless of file size.
//
// The call blocks until JS calls end(), a write or handler error occurs, or
// the request context is cancelled.
func (fs *jsFileSystemForRemote) ServeHTTPWithPerms(
perms drive.Permissions, w http.ResponseWriter, r *http.Request,
) {
fs.mu.RLock()
fn := fs.fn
fs.mu.RUnlock()
if fn.Type() != js.TypeFunction {
http.NotFound(w, r)
return
}
response := &driveResponse{w: w, doneCh: make(chan error, 1), live: true}
// readBodyChunk is exposed to JS as req.readBodyChunk().
// Each call returns a Promise<Uint8Array|null>: null signals EOF.
readBodyChunk := js.FuncOf(func(_ js.Value, _ []js.Value) any {
if !response.isLive() {
return makePromise(func() (any, error) {
return js.Null(), nil
})
}
return makePromise(func() (any, error) {
buf := make([]byte, 65536)
n, err := r.Body.Read(buf)
if n > 0 {
arr := js.Global().Get("Uint8Array").New(n)
js.CopyBytesToJS(arr, buf[:n])
return arr, nil
}
if errors.Is(err, io.EOF) {
return js.Null(), nil
}
return nil, err
})
})
// writeHead sets response headers and status code. Must be called before write().
writeHead := js.FuncOf(func(_ js.Value, args []js.Value) any {
response.writeHead(args)
return nil
})
// write streams a single response body chunk to the client.
write := js.FuncOf(func(_ js.Value, args []js.Value) any {
response.write(args)
return nil
})
// end signals that the response is complete.
end := js.FuncOf(func(_ js.Value, _ []js.Value) any {
select {
case response.doneCh <- nil:
default:
}
return nil
})
var fulfilled, rejected js.Func
var handlerSettled sync.Once
settleHandler := func(err error) {
handlerSettled.Do(func() {
select {
case response.doneCh <- err:
default:
}
readBodyChunk.Release()
writeHead.Release()
write.Release()
end.Release()
fulfilled.Release()
rejected.Release()
})
}
fulfilled = js.FuncOf(func(_ js.Value, _ []js.Value) any {
settleHandler(nil)
return nil
})
rejected = js.FuncOf(func(_ js.Value, args []js.Value) any {
err := errors.New("JavaScript drive handler rejected")
if len(args) > 0 && args[0].Type() == js.TypeString {
err = fmt.Errorf("JavaScript drive handler rejected: %s", args[0].String())
}
settleHandler(err)
return nil
})
jsReq := map[string]any{
"method": r.Method,
"path": r.URL.Path,
"rawQuery": r.URL.RawQuery,
"headers": goHeadersToJS(r.Header),
"readBodyChunk": readBodyChunk,
}
jsRes := map[string]any{
"writeHead": writeHead,
"write": write,
"end": end,
}
result, handlerErr := callJSFunction(fn, jsReq, jsRes, drivePermsToJS(perms))
if handlerErr == nil {
if hasThen, err := hasJSFunctionProperty(result, "then"); err != nil {
handlerErr = err
} else if hasThen {
_, handlerErr = callJSMethod(result, "then", fulfilled, rejected)
} else {
settleHandler(nil)
}
}
if handlerErr != nil {
settleHandler(handlerErr)
}
var resultErr error
select {
case resultErr = <-response.doneCh:
case <-r.Context().Done():
resultErr = r.Context().Err()
}
response.finish(resultErr, r.Context().Err())
}
// drivePermsToJS converts drive.Permissions to a plain JS-friendly object.
// Each share name maps to a numeric permission: 0=none, 1=read-only, 2=read-write.
// The wildcard share name "*" is included if present.
func drivePermsToJS(p drive.Permissions) map[string]any {
result := make(map[string]any, len(p))
for name, perm := range p {
result[name] = int(perm)
}
return result
}
// goHeadersToJS converts an http.Header to a map[string]any suitable for JS.
// Single-value headers become a string; multi-value headers become a []any.
func goHeadersToJS(h http.Header) map[string]any {
result := make(map[string]any, len(h))
for k, vs := range h {
if len(vs) == 1 {
result[k] = vs[0]
} else {
arr := make([]any, len(vs))
for i, v := range vs {
arr[i] = v
}
result[k] = arr
}
}
return result
}
// jsHeadersToGo parses a JS headers object into an http.Header map.
// Values may be a string or an array of strings.
func jsHeadersToGo(jsHeaders js.Value) http.Header {
h := make(http.Header)
keys := js.Global().Get("Object").Call("keys", jsHeaders)
for i := 0; i < keys.Length(); i++ {
key := keys.Index(i).String()
val := jsHeaders.Get(key)
switch val.Type() {
case js.TypeString:
h.Set(key, val.String())
case js.TypeObject:
if val.InstanceOf(js.Global().Get("Array")) {
for j := 0; j < val.Length(); j++ {
h.Add(key, val.Index(j).String())
}
}
}
}
return h
}
// initDriveForRemote creates the JS-backed FileSystemForRemote and registers
// it with sys. Must be called before NewLocalBackend (SubSystem is set-once).
func initDriveForRemote(sys *tsd.System) *jsFileSystemForRemote {
driveFS := &jsFileSystemForRemote{}
sys.Set(driveFS)
return driveFS
}
// wireDriveJS adds drive-related methods to the IPN JS methods map.
// driveFS must be the value returned by initDriveForRemote.
func wireDriveJS(i *jsIPN, driveFS *jsFileSystemForRemote, m map[string]any) {
m["setDriveHandler"] = js.FuncOf(func(_ js.Value, args []js.Value) any {
if len(args) < 1 {
return nil
}
driveFS.setHandler(args[0])
return nil
})
m["listDrivePeers"] = js.FuncOf(func(_ js.Value, _ []js.Value) any {
return i.listDrivePeers()
})
}
type jsDrivePeer struct {
Name string `json:"name"`
PeerAPIURL string `json:"peerAPIURL"`
StableNodeID string `json:"stableNodeID"`
Online *bool `json:"online,omitempty"`
}
// listDrivePeers returns a JSON array of peers that are online, have a
// reachable peerAPI and carry PeerCapabilityTaildriveSharer. Returns an empty
// array if the local node does not have drive:access in its ACL
// (DriveAccessEnabled). This mirrors the filtering in
// LocalBackend.driveRemotesFromPeers.
//
// The cap means a peer is allowed to share with us, not that it currently
// exposes any share, so the result is a superset of the peers with shares.
func (i *jsIPN) listDrivePeers() js.Value {
return makePromise(func() (any, error) {
if !i.lb.DriveAccessEnabled() {
return "[]", nil
}
nm := i.lb.NetMap()
if nm == nil {
return nil, errors.New("listDrivePeers: no network map available")
}
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
}
}
peers := make([]jsDrivePeer, 0)
for _, p := range nm.Peers {
if !p.Online().Get() {
continue
}
peerURL := buildPeerAPIURL(p, selfHave4, selfHave6)
if peerURL == "" {
continue
}
// Check PeerCapabilityTaildriveSharer via the live PeerCaps map
// (derived from ACL rules), mirroring driveRemotesFromPeers.
hasCap := false
for _, a := range p.Addresses().All() {
if a.IsSingleIP() && i.lb.PeerCaps(a.Addr()).HasCapability(tailcfg.PeerCapabilityTaildriveSharer) {
hasCap = true
break
}
}
if !hasCap {
continue
}
online := p.Online().Clone()
peers = append(peers, jsDrivePeer{
Name: p.DisplayName(false),
PeerAPIURL: peerURL,
StableNodeID: string(p.StableID()),
Online: online,
})
}
b, err := json.Marshal(peers)
if err != nil {
return nil, fmt.Errorf("listDrivePeers: marshal: %w", err)
}
return string(b), nil
})
}
-27
View File
@@ -1,27 +0,0 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build ts_omit_drive
package main
import (
"syscall/js"
"tailscale.com/tsd"
)
type jsFileSystemForRemote struct{}
// initDriveForRemote is a no-op when the drive feature is omitted.
func initDriveForRemote(_ *tsd.System) *jsFileSystemForRemote { return nil }
// wireDriveJS is a no-op when the drive feature is omitted.
func wireDriveJS(_ *jsIPN, _ *jsFileSystemForRemote, _ map[string]any) {}
// listDrivePeers returns an empty list when the drive feature is omitted.
func (i *jsIPN) listDrivePeers() js.Value {
return makePromise(func() (any, error) {
return "[]", nil
})
}
-41
View File
@@ -1,41 +0,0 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"fmt"
"net/netip"
"tailscale.com/tailcfg"
)
// buildPeerAPIURL returns the HTTP base URL for a peer's peerAPI server,
// selecting IPv4 when available and falling back to IPv6. Returns an empty
// string if the peer advertises no reachable peerAPI port.
func buildPeerAPIURL(p tailcfg.NodeView, selfHave4, selfHave6 bool) string {
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() {
return fmt.Sprintf("http://%v", netip.AddrPortFrom(a.Addr(), pp4))
}
}
}
if selfHave6 && pp6 != 0 {
for _, a := range p.Addresses().All() {
if a.IsSingleIP() && a.Addr().Is6() {
return fmt.Sprintf("http://%v", netip.AddrPortFrom(a.Addr(), pp6))
}
}
}
return ""
}
+10 -138
View File
@@ -17,7 +17,6 @@ import (
"net/http"
"net/url"
"os"
"sync"
"syscall/js"
"time"
@@ -110,10 +109,7 @@ func (i *jsIPN) sendFile(stableNodeID, filename string, stream js.Value, declare
return nil, fmt.Errorf("bogus peer URL: %w", err)
}
reader, err := callJSMethod(stream, "getReader")
if err != nil {
return nil, err
}
reader := stream.Call("getReader")
body := &jsStreamReader{reader: reader}
outgoing := ipn.OutgoingFile{
@@ -246,100 +242,40 @@ func wireTaildropFileOps(lb *ipnlocal.LocalBackend, jsObj js.Value) {
// 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 {
mu sync.Mutex
reader js.Value
buf []byte
done bool
err error
testCancelTimeout time.Duration
reader js.Value
buf []byte
done bool
}
const jsStreamCancelTimeout = time.Second
func (r *jsStreamReader) Read(p []byte) (int, error) {
r.mu.Lock()
if r.err != nil {
err := r.err
r.mu.Unlock()
return 0, err
}
if r.done {
r.mu.Unlock()
return 0, io.EOF
}
if len(r.buf) > 0 {
n := copy(p, r.buf)
r.buf = r.buf[n:]
r.mu.Unlock()
return n, nil
}
r.mu.Unlock()
type chunkResult struct {
data []byte
done bool
err error
}
ch := make(chan chunkResult, 1)
settle := func(result chunkResult) {
select {
case ch <- result:
default:
}
}
thenFn := js.FuncOf(func(this js.Value, args []js.Value) any {
defer func() {
if recovered := recover(); recovered != nil {
settle(chunkResult{err: recoveredJSError(recovered)})
}
}()
if len(args) == 0 || args[0].Type() != js.TypeObject {
settle(chunkResult{err: errors.New("JavaScript stream read returned an invalid result")})
return nil
}
result := args[0]
done := result.Get("done")
if done.Type() != js.TypeBoolean {
settle(chunkResult{err: errors.New("JavaScript stream read result has no boolean done property")})
return nil
}
if done.Bool() {
settle(chunkResult{done: true})
if result.Get("done").Bool() {
ch <- chunkResult{done: true}
} else {
value := result.Get("value")
uint8Array := js.Global().Get("Uint8Array")
if value.Type() != js.TypeObject || uint8Array.Type() != js.TypeFunction || !value.InstanceOf(uint8Array) {
settle(chunkResult{err: errors.New("JavaScript stream read result is not a Uint8Array")})
return nil
}
b := make([]byte, value.Get("byteLength").Int())
js.CopyBytesToGo(b, value)
settle(chunkResult{data: b})
ch <- chunkResult{data: b}
}
return nil
})
rejectFn := js.FuncOf(func(this js.Value, args []js.Value) any {
err := errors.New("JavaScript stream read rejected")
if len(args) > 0 && args[0].Type() == js.TypeString {
err = fmt.Errorf("JavaScript stream read rejected: %s", args[0].String())
}
settle(chunkResult{err: err})
return nil
})
defer thenFn.Release()
defer rejectFn.Release()
promise, err := callJSMethod(r.reader, "read")
if err != nil {
return 0, r.setError(err)
}
if _, err := callJSMethod(promise, "then", thenFn, rejectFn); err != nil {
return 0, r.setError(err)
}
r.reader.Call("read").Call("then", thenFn)
result := <-ch
if result.err != nil {
return 0, r.setError(result.err)
}
r.mu.Lock()
defer r.mu.Unlock()
if result.done {
r.done = true
return 0, io.EOF
@@ -350,72 +286,8 @@ func (r *jsStreamReader) Read(p []byte) (int, error) {
}
func (r *jsStreamReader) Close() error {
r.mu.Lock()
priorErr := r.err
r.mu.Unlock()
promise, err := callJSMethod(r.reader, "cancel")
if err != nil {
return r.setError(err)
}
if hasThen, err := hasJSFunctionProperty(promise, "then"); err != nil {
return r.setError(err)
} else if !hasThen {
r.mu.Lock()
r.done = true
r.mu.Unlock()
return priorErr
}
resultCh := make(chan error, 1)
settle := func(err error) {
select {
case resultCh <- err:
default:
}
}
resolveFn := js.FuncOf(func(this js.Value, args []js.Value) any {
settle(nil)
return nil
})
rejectFn := js.FuncOf(func(this js.Value, args []js.Value) any {
err := errors.New("JavaScript stream cancel rejected")
if len(args) > 0 && args[0].Type() == js.TypeString {
err = fmt.Errorf("JavaScript stream cancel rejected: %s", args[0].String())
}
settle(err)
return nil
})
defer resolveFn.Release()
defer rejectFn.Release()
if _, err := callJSMethod(promise, "then", resolveFn, rejectFn); err != nil {
return r.setError(err)
}
timeout := jsStreamCancelTimeout
if r.testCancelTimeout > 0 {
timeout = r.testCancelTimeout
}
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case err := <-resultCh:
if err != nil {
return r.setError(err)
}
case <-timer.C:
return r.setError(errors.New("JavaScript stream cancel timed out"))
}
r.mu.Lock()
r.done = true
r.mu.Unlock()
return priorErr
}
func (r *jsStreamReader) setError(err error) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.err == nil {
r.err = err
}
return r.err
r.reader.Call("cancel")
return nil
}
// jsReadableStream wraps rc in a pull-based JS ReadableStream. Each pull call
File diff suppressed because it is too large Load Diff
-14
View File
@@ -1,14 +0,0 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"fmt"
"tailscale.com/cmd/tsconnect/wasmbuild"
)
func main() {
fmt.Print(wasmbuild.Tags())
}
+43 -27
View File
@@ -36,36 +36,49 @@ var baseTags = []string{
"omitpemdecrypt",
}
// Omit is the set of feature/featuretags tags excluded from the
// cmd/tsconnect/wasm build via their ts_omit_ build tag (computed by
// [Tags]). Everything else in [featuretags.Features] stays linked.
//
// Upstream uses the opposite polarity here — a small allow-list — because
// its wasm client is only an SSH/fetch-in-browser tool. This fork's JS
// bridge exposes Taildrop, Taildrive, Funnel/serve, ACME certs, exit node
// selection, service advertisement and the peerAPI, so an allow-list is
// the wrong default: a missing entry is not a compile error, it is a
// feature that silently stops working at runtime (an omitted extension
// simply never registers its hooks). Linking everything also matches how
// this build behaved before upstream introduced featuretags.
// Keep is the set of feature/featuretags tags the cmd/tsconnect/wasm
// build needs LINKED. Every other feature in [featuretags.Features] is
// excluded via its ts_omit_ build tag (computed by [Tags]).
// Transitive dependencies of entries in Keep are pulled in
// automatically via [featuretags.Requires].
//
// Adding an entry here grows the wasm bundle. Removing one strips it.
// The init() below panics if any entry is unknown to feature/featuretags,
// so a rename / removal in that registry fails loudly here.
//
// Trimming the bundle by omitting more features is worthwhile but should
// be done with measurements and per-feature runtime verification, not by
// assuming a feature is unreachable from the browser.
var Omit = []featuretags.FeatureTag{
// feature/ace does not compile for GOOS=js: control/controlhttp only
// installs HookMakeACEDialer on non-js platforms, so feature/ace's
// reference to it is undefined here.
"ace",
// Notably absent (server-only or otherwise meaningless in a browser):
// - "ssh": controls the SSH *server* (feature/ssh registers
// ssh/tailssh). The wasm acts as an SSH *client* using
// golang.org/x/crypto/ssh directly; no featuretag gates that.
// - "portmapper", "debugportmapper": js/wasm has no UDP sockets,
// can't speak NAT-PMP / PCP / UPnP.
// - "captiveportal": the browser handles captive portal detection
// in front of us.
// - "syspolicy": no MDM in a browser.
// - "drive", "taildrop", "peerapi*": no local filesystem.
// - "clientupdate": no binary self-update.
// - "dbus", "resolved", "networkmanager", "iptables", "linkspeed",
// "linuxdnsfight", "listenrawdisco", "osrouter", "synology",
// "systray", "tundevstats", "wakeonlan": OS integrations not
// applicable to a browser-hosted client.
// - "aws", "cloud", "kube", "bird", "appconnectors", "conn25",
// "relayserver", "serve", "acme", "tap", "tpm", "doctor",
// "advertiseroutes", "advertiseexitnode", "useroutes",
// "useexitnode": server-side or otherwise out of scope for the
// SSH-in-browser / fetch-in-browser use case.
var Keep = []featuretags.FeatureTag{
"c2n", // control-to-node mechanism the control client invokes
"dns", // MagicDNS resolution in-process
"health", // ipnstate/ipnlocal reference health warnables pervasively
"ipnbus", // notification bus for state/netmap callbacks
"logtail", // log upload (browser console + remote)
"netstack", // userspace networking; wasm has no kernel TUN
}
func init() {
for _, ft := range Omit {
for _, ft := range Keep {
if _, ok := featuretags.Features[ft]; !ok {
panic(fmt.Sprintf("wasmbuild.Omit references unknown feature tag %q; "+
panic(fmt.Sprintf("wasmbuild.Keep references unknown feature tag %q; "+
"did feature/featuretags rename or remove it?", ft))
}
}
@@ -90,22 +103,25 @@ type BuildInfo struct {
}
// Tags returns the joined -tags value for the wasm build: [baseTags]
// plus a ts_omit_<feature> for every entry in [Omit].
// plus a ts_omit_<feature> for every entry in [featuretags.Features]
// that is not transitively required by [Keep].
//
// The result is sorted so that the same source tree always produces
// the same string (and therefore the same wasm bytes, given identical
// inputs to `go build`).
func Tags() string {
omit := map[featuretags.FeatureTag]bool{}
for _, ft := range Omit {
omit[ft] = true
keep := map[featuretags.FeatureTag]bool{}
for _, ft := range Keep {
for dep := range featuretags.Requires(ft) {
keep[dep] = true
}
}
tags := slices.Clone(baseTags)
for ft := range featuretags.Features {
if ft == "" || !ft.IsOmittable() {
continue
}
if omit[ft] {
if !keep[ft] {
tags = append(tags, ft.OmitTag())
}
}
-6
View File
@@ -304,12 +304,6 @@ func (c *Auto) restartMap() {
c.updateControl()
}
// RestartMap cancels the existing map poll and starts a fresh streaming one,
// forcing the control server to send a new full netmap response.
func (c *Auto) RestartMap() {
c.restartMap()
}
func (c *Auto) authRoutine() {
defer close(c.authDone)
bo := backoff.NewBackoff("authRoutine", c.logf, 30*time.Second)
+1 -35
View File
@@ -337,7 +337,6 @@ type LocalBackend struct {
capTailnetLock bool // whether netMap contains the tailnet lock capability
// hostinfo is mutated in-place while mu is held.
hostinfo *tailcfg.Hostinfo // TODO(nickkhyl): move to nodeBackend
explicitServices []tailcfg.Service // services set explicitly via SetExplicitServices; always uploaded
nmExpiryTimer tstime.TimerController // for updating netMap on node expiry; can be nil; TODO(nickkhyl): move to nodeBackend
activeLogin string // last logged LoginName from netMap; TODO(nickkhyl): move to nodeBackend (or remove? it's in [ipn.LoginProfile]).
engineStatus ipn.EngineStatus
@@ -1763,13 +1762,6 @@ func (b *LocalBackend) PeerCaps(src netip.Addr) tailcfg.PeerCapMap {
return b.currentNode().PeerCaps(src)
}
// PeerCapsIncludingUnsigned is like [LocalBackend.PeerCaps] but does not deny
// capabilities to peers with UnsignedPeerAPIOnly set. It exists only for the
// Funnel ingress path; see [nodeBackend.PeerCapsIncludingUnsigned].
func (b *LocalBackend) PeerCapsIncludingUnsigned(src netip.Addr) tailcfg.PeerCapMap {
return b.currentNode().PeerCapsIncludingUnsigned(src)
}
// PeerCapsForIP returns the capabilities that remote src IP has when
// talking to the given destination IP on this node.
func (b *LocalBackend) PeerCapsForIP(src, dst netip.Addr) tailcfg.PeerCapMap {
@@ -5689,30 +5681,6 @@ func (b *LocalBackend) setPortlistServices(sl []tailcfg.Service) {
b.doSetHostinfoFilterServices()
}
// SetExplicitServices sets the services this node advertises on the netmap.
// Unlike the OS port-scan path (setPortlistServices), services set here are
// always uploaded to the control server regardless of the ShouldUploadServices
// hook — suitable for environments like browser WASM where OS port scanning is
// unavailable and services are declared programmatically.
func (b *LocalBackend) SetExplicitServices(sl []tailcfg.Service) {
b.mu.Lock()
if b.hostinfo == nil {
b.hostinfo = new(tailcfg.Hostinfo)
}
b.hostinfo.Services = sl
b.explicitServices = sl
ccAuto := b.ccAuto
b.mu.Unlock()
b.doSetHostinfoFilterServices()
// Restart the streaming map poll so the control server sends back a fresh
// netmap that includes our updated services in SelfNode, and so peers
// receive the update promptly via the control server's push.
if ccAuto != nil {
ccAuto.RestartMap()
}
}
// doSetHostinfoFilterServices calls SetHostinfo on the controlclient,
// possibly after mangling the given hostinfo.
//
@@ -5757,9 +5725,7 @@ func (b *LocalBackend) hostInfoWithServicesLocked() *tailcfg.Hostinfo {
// Make a shallow copy of hostinfo so we can mutate
// at the Service field.
if f, ok := b.extHost.Hooks().ShouldUploadServices.GetOk(); !ok || !f() {
if len(b.explicitServices) == 0 {
hi.Services = []tailcfg.Service{}
}
hi.Services = []tailcfg.Service{}
}
// Don't mutate hi.Service's underlying array. Append to
-22
View File
@@ -432,32 +432,10 @@ func (nb *nodeBackend) srcIsUnsignedPeerLocked(src netip.Addr) bool {
return ok && n.UnsignedPeerAPIOnly()
}
// PeerCapsIncludingUnsigned is like [nodeBackend.PeerCaps] but does not deny
// capabilities to peers with UnsignedPeerAPIOnly set.
//
// Funnel ingress relays are delivered as UnsignedPeerAPIOnly nodes: per the
// docs on [tailcfg.Node.UnsignedPeerAPIOnly] they get no network access at all
// and exist solely to reach this node's peerapi. The ingress endpoint they need
// is gated on [tailcfg.PeerCapabilityIngress], so denying them capabilities
// wholesale — as peerCapsLocked does upstream as of 0eb38dc2e — makes Funnel
// impossible. Callers must therefore be limited to the ingress path.
//
// This is a fork-local patch; drop it once upstream restores Funnel.
// See webnet/tailscale#16.
func (nb *nodeBackend) PeerCapsIncludingUnsigned(src netip.Addr) tailcfg.PeerCapMap {
nb.mu.Lock()
defer nb.mu.Unlock()
return nb.peerCapsIgnoringSignatureLocked(src)
}
func (nb *nodeBackend) peerCapsLocked(src netip.Addr) tailcfg.PeerCapMap {
if nb.srcIsUnsignedPeerLocked(src) {
return nil
}
return nb.peerCapsIgnoringSignatureLocked(src)
}
func (nb *nodeBackend) peerCapsIgnoringSignatureLocked(src netip.Addr) tailcfg.PeerCapMap {
if nb.netMap == nil {
return nil
}
+1 -7
View File
@@ -592,14 +592,8 @@ func (h *peerAPIHandler) canDebug() bool {
var allowSelfIngress = envknob.RegisterBool("TS_ALLOW_SELF_INGRESS")
// canIngress reports whether h can send ingress requests to this node.
//
// The ingress cap is resolved without the unsigned-peer denial that
// [nodeBackend.PeerCaps] applies, because Funnel ingress relays are by design
// UnsignedPeerAPIOnly nodes whose only permitted action is this endpoint.
// See [nodeBackend.PeerCapsIncludingUnsigned].
func (h *peerAPIHandler) canIngress() bool {
caps := h.ps.b.PeerCapsIncludingUnsigned(h.remoteAddr.Addr())
return caps.HasCapability(tailcfg.PeerCapabilityIngress) || (allowSelfIngress() && h.isSelf)
return h.peerHasCap(tailcfg.PeerCapabilityIngress) || (allowSelfIngress() && h.isSelf)
}
func (h *peerAPIHandler) peerHasCap(wantCap tailcfg.PeerCapability) bool {