Author SHA1 Message Date
codingetandClaude 4ae5083960 fix(driveprobe): only count listing entries below the collection
hasChild treated any href that was not the collection as a share, so a
peer answering about an unrelated collection looked like it had shares.
Follow RFC 4918 §9.1 instead: the collection comes first and anything
after it is a member, with the first href counted only if it is itself
below the root.

Also accumulate href text across tokens; the XML decoder may split
character data, which the previous token-at-a-time check miscounted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 23:51:57 +00:00
codingetandClaude 3b239fe9e2 feat(tsconnect/wasm): add hasShares filter to listDrivePeers
PeerCapabilityTaildriveSharer says a peer may share with us, not that it
does, so listDrivePeers is a superset of the peers actually exposing
shares. listDrivePeers now takes an options object; with
{hasShares: true} each candidate's taildrive root is probed with a
Depth-1 PROPFIND and only peers listing at least one share are kept.

The probe and its multistatus parsing live in cmd/tsconnect/driveprobe
so they can be tested without syscall/js. Probes run in parallel with a
bounded worker count, a per-probe timeout and a bounded response read;
a probe that fails drops that peer and is logged rather than failing
the call, so the filter is positive-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 23:32:03 +00:00
14 changed files with 694 additions and 1016 deletions
+183
View File
@@ -0,0 +1,183 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package driveprobe reports whether a peer currently exposes Taildrive
// shares, by asking its peerAPI rather than by inspecting ACL capabilities.
//
// PeerCapabilityTaildriveSharer only says a peer is allowed to share with us.
// The share list itself is only visible over WebDAV, so this package issues a
// Depth-1 PROPFIND against the peer's Taildrive root and looks for children.
// The peer applies our permissions before listing, so a child is a share we
// can actually reach.
//
// This lives outside the wasm package so it can be tested without syscall/js.
package driveprobe
import (
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"path"
"strings"
"sync"
"time"
"golang.org/x/sync/errgroup"
"tailscale.com/types/logger"
)
const (
// drivePath is the peerAPI prefix taildrive is served under.
drivePath = "/v0/drive/"
// maxProbes bounds how many probes are in flight at once. Go under wasm
// runs on a single thread, so a high limit buys little and costs memory.
maxProbes = 8
// maxResponseBytes bounds the listing we are willing to read. A peer with
// a plausible number of shares is far below this.
maxResponseBytes = 1 << 20
// probeTimeout bounds a single probe in HasSharesMulti, so one peer that
// accepts the connection and then stalls cannot hold up the listing.
probeTimeout = 5 * time.Second
)
// propfindBody asks only for resourcetype: we care whether children exist,
// not what they are.
const propfindBody = `<?xml version="1.0" encoding="utf-8"?>` +
`<D:propfind xmlns:D="DAV:"><D:prop><D:resourcetype/></D:prop></D:propfind>`
// HasShares reports whether the peer at peerAPIURL exposes at least one
// Taildrive share to us.
//
// A false result means the peer answered and listed nothing. An error means we
// could not find out — callers must not read it as "no shares".
func HasShares(ctx context.Context, c *http.Client, peerAPIURL string) (bool, error) {
u := strings.TrimSuffix(peerAPIURL, "/") + drivePath
req, err := http.NewRequestWithContext(ctx, "PROPFIND", u, strings.NewReader(propfindBody))
if err != nil {
return false, err
}
req.Header.Set("Depth", "1")
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
resp, err := c.Do(req)
if err != nil {
return false, err
}
defer func() {
io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseBytes))
resp.Body.Close()
}()
// Anything other than 207 is the peer declining to list: taildrive off
// (404), no cap for us (403), or a handler that does not speak WebDAV.
if resp.StatusCode != http.StatusMultiStatus {
return false, fmt.Errorf("PROPFIND %s: %s", u, resp.Status)
}
return hasChild(io.LimitReader(resp.Body, maxResponseBytes), drivePath)
}
// hasChild reports whether a multistatus body lists anything besides the
// collection we asked about. It decodes as a stream and stops at the first
// child, so a peer with many shares costs no more than a peer with one.
//
// RFC 4918 §9.1 says a Depth-1 PROPFIND answers with the collection itself
// followed by its members, so anything after the first href is a share. The
// first href counts only if it is itself below root, which catches a peer that
// answers about a subtree rather than the collection we asked for.
//
// A peer that omits the collection entirely is not understood: ipnlocal strips
// the taildrive prefix before handing the request to the share server, so a
// real peer's members are named "/docs" rather than "/v0/drive/docs" and a lone
// member is indistinguishable from the collection. Such a peer is reported as
// having no shares, which is the safe direction for a positive filter.
func hasChild(body io.Reader, root string) (bool, error) {
dec := xml.NewDecoder(body)
var href string
inHref, first := false, true
for {
tok, err := dec.Token()
if err == io.EOF {
return false, nil
}
if err != nil {
return false, fmt.Errorf("parse multistatus: %w", err)
}
switch t := tok.(type) {
case xml.StartElement:
if t.Name.Space == "DAV:" && t.Name.Local == "href" {
inHref, href = true, ""
}
case xml.CharData:
// Character data can arrive in several tokens for one element.
if inHref {
href += string(t)
}
case xml.EndElement:
if !inHref {
continue
}
inHref = false
if !first {
return true, nil
}
first = false
if isBelow(href, root) {
return true, nil
}
}
}
}
// isBelow reports whether href points below root. Peers may answer with an
// absolute URL or a path, percent-encoded and with or without a trailing
// slash, so compare cleaned paths rather than strings.
func isBelow(href, root string) bool {
u, err := url.Parse(strings.TrimSpace(href))
if err != nil {
return false
}
p := path.Clean("/" + strings.Trim(u.Path, "/"))
r := path.Clean("/" + strings.Trim(root, "/"))
if p == r || p == "/" {
return false
}
return r == "/" || strings.HasPrefix(p, r+"/")
}
// HasSharesMulti probes every URL and returns one result per input, in input
// order. A probe that fails is reported as false and logged: the caller is
// filtering to peers we positively confirmed, and one unreachable peer must
// not sink the rest.
func HasSharesMulti(ctx context.Context, c *http.Client, urls []string, logf logger.Logf) []bool {
out := make([]bool, len(urls))
var mu sync.Mutex
// Deliberately not errgroup.WithContext: a failing probe must not cancel
// its siblings.
var g errgroup.Group
g.SetLimit(maxProbes)
for i, u := range urls {
g.Go(func() error {
ctx, cancel := context.WithTimeout(ctx, probeTimeout)
defer cancel()
ok, err := HasShares(ctx, c, u)
if err != nil {
logf("driveprobe: %s: %v", u, err)
return nil
}
mu.Lock()
out[i] = ok
mu.Unlock()
return nil
})
}
g.Wait()
return out
}
+263
View File
@@ -0,0 +1,263 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package driveprobe
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
func multistatus(hrefs ...string) string {
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?><D:multistatus xmlns:D="DAV:">`)
for _, h := range hrefs {
fmt.Fprintf(&b, `<D:response><D:href>%s</D:href><D:propstat><D:prop><D:resourcetype><D:collection/></D:resourcetype></D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>`, h)
}
b.WriteString(`</D:multistatus>`)
return b.String()
}
func TestHasChild(t *testing.T) {
tests := []struct {
name string
body string
want bool
}{
{"root only, prefix stripped", multistatus("/"), false},
{"root only, prefix kept", multistatus("/v0/drive/"), false},
{"root only, no trailing slash", multistatus("/v0/drive"), false},
{"one share, prefix stripped", multistatus("/", "/docs"), true},
{"one share, prefix kept", multistatus("/v0/drive/", "/v0/drive/docs"), true},
{"absolute urls", multistatus("http://100.1.2.3:1234/v0/drive/", "http://100.1.2.3:1234/v0/drive/docs"), true},
{"percent-encoded share name", multistatus("/v0/drive/", "/v0/drive/my%20share"), true},
{"unicode share name", multistatus("/v0/drive/", "/v0/drive/%E6%97%A5%E6%9C%AC"), true},
{"empty multistatus", multistatus(), false},
// The collection comes first per RFC 4918 §9.1, so anything after it
// is a share whatever the peer names it.
{"unrelated collection href, no members", multistatus("/somewhere/else/"), false},
{"unrelated collection href with a member", multistatus("/somewhere/else/", "/somewhere/else/docs"), true},
// A peer that omits the collection from a Depth-1 listing violates
// RFC 4918 §9.1, and once the taildrive prefix is stripped there is
// nothing left to tell its lone member apart from the collection. It
// loses the benefit of the doubt: hasShares excludes what it cannot
// confirm.
{"single member, collection omitted", multistatus("/docs"), false},
{"href split by an entity reference", multistatus("/v0/drive/", "/v0/drive/a&amp;b"), true},
{"empty href", multistatus("/v0/drive/", ""), true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := hasChild(strings.NewReader(tt.body), drivePath)
if err != nil {
t.Fatalf("hasChild: %v", err)
}
if got != tt.want {
t.Errorf("hasChild = %v, want %v", got, tt.want)
}
})
}
}
func TestHasChildMalformed(t *testing.T) {
body := strings.TrimSuffix(multistatus("/v0/drive/", "/v0/drive/docs"), "</D:multistatus>")
// Truncation after a child href still answers the question.
got, err := hasChild(strings.NewReader(body), drivePath)
if err != nil {
t.Fatalf("hasChild: %v", err)
}
if !got {
t.Error("hasChild = false on a truncated body that already listed a share")
}
if _, err := hasChild(strings.NewReader("<D:multistatus"), drivePath); err == nil {
t.Error("hasChild on malformed XML: want error, got nil")
}
}
// serveDrive returns a server answering PROPFIND at the taildrive root with the
// given body and status, and records the requests it saw.
func serveDrive(t *testing.T, status int, body string) (*httptest.Server, *[]*http.Request) {
t.Helper()
var mu sync.Mutex
var reqs []*http.Request
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
reqs = append(reqs, r)
mu.Unlock()
w.WriteHeader(status)
io := []byte(body)
w.Write(io)
}))
t.Cleanup(srv.Close)
return srv, &reqs
}
func TestHasShares(t *testing.T) {
srv, reqs := serveDrive(t, http.StatusMultiStatus, multistatus("/v0/drive/", "/v0/drive/docs"))
got, err := HasShares(context.Background(), srv.Client(), srv.URL)
if err != nil {
t.Fatalf("HasShares: %v", err)
}
if !got {
t.Error("HasShares = false, want true")
}
if len(*reqs) != 1 {
t.Fatalf("got %d requests, want 1", len(*reqs))
}
r := (*reqs)[0]
if r.Method != "PROPFIND" {
t.Errorf("method = %q, want PROPFIND", r.Method)
}
if r.URL.Path != drivePath {
t.Errorf("path = %q, want %q", r.URL.Path, drivePath)
}
if d := r.Header.Get("Depth"); d != "1" {
t.Errorf("Depth = %q, want 1", d)
}
}
func TestHasSharesTrailingSlashInPeerURL(t *testing.T) {
srv, reqs := serveDrive(t, http.StatusMultiStatus, multistatus("/"))
if _, err := HasShares(context.Background(), srv.Client(), srv.URL+"/"); err != nil {
t.Fatalf("HasShares: %v", err)
}
if p := (*reqs)[0].URL.Path; p != drivePath {
t.Errorf("path = %q, want %q", p, drivePath)
}
}
func TestHasSharesNonMultiStatus(t *testing.T) {
// Taildrive disabled, or we hold no cap on that peer.
for _, status := range []int{http.StatusNotFound, http.StatusForbidden, http.StatusOK} {
srv, _ := serveDrive(t, status, "")
got, err := HasShares(context.Background(), srv.Client(), srv.URL)
if err == nil {
t.Errorf("status %d: want error, got nil", status)
}
if got {
t.Errorf("status %d: HasShares = true", status)
}
}
}
func TestHasSharesCancelled(t *testing.T) {
// The handler must also unblock on release: a client-side cancel does not
// reliably reach the server's request context, and Close waits for it.
release := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-r.Context().Done():
case <-release:
}
}))
defer srv.Close()
defer close(release)
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
if _, err := HasShares(ctx, srv.Client(), srv.URL); err == nil {
t.Error("want error on cancelled probe, got nil")
}
}
func TestHasSharesMultiOrderAndFailures(t *testing.T) {
withShares, _ := serveDrive(t, http.StatusMultiStatus, multistatus("/v0/drive/", "/v0/drive/docs"))
noShares, _ := serveDrive(t, http.StatusMultiStatus, multistatus("/v0/drive/"))
refused, _ := serveDrive(t, http.StatusNotFound, "")
urls := []string{noShares.URL, withShares.URL, refused.URL, "http://127.0.0.1:1/dead", withShares.URL}
want := []bool{false, true, false, false, true}
var logs atomic.Int32
got := HasSharesMulti(context.Background(), withShares.Client(), urls, func(string, ...any) {
logs.Add(1)
})
for i := range want {
if got[i] != want[i] {
t.Errorf("result[%d] = %v, want %v (%s)", i, got[i], want[i], urls[i])
}
}
// The 404 peer and the dead address are both reported, not swallowed.
if n := logs.Load(); n != 2 {
t.Errorf("logged %d probe failures, want 2", n)
}
}
func TestHasSharesMultiRunsInParallel(t *testing.T) {
const delay = 100 * time.Millisecond
var inFlight, peak atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := inFlight.Add(1)
for {
old := peak.Load()
if n <= old || peak.CompareAndSwap(old, n) {
break
}
}
time.Sleep(delay)
inFlight.Add(-1)
w.WriteHeader(http.StatusMultiStatus)
w.Write([]byte(multistatus("/v0/drive/", "/v0/drive/docs")))
}))
defer srv.Close()
urls := make([]string, maxProbes)
for i := range urls {
urls[i] = srv.URL
}
start := time.Now()
got := HasSharesMulti(context.Background(), srv.Client(), urls, func(string, ...any) {})
elapsed := time.Since(start)
for i, ok := range got {
if !ok {
t.Errorf("result[%d] = false, want true", i)
}
}
if elapsed >= delay*time.Duration(len(urls)) {
t.Errorf("probes serialized: %v for %d probes of %v each", elapsed, len(urls), delay)
}
if peak.Load() < 2 {
t.Errorf("peak concurrency = %d, want >= 2", peak.Load())
}
}
func TestHasSharesMultiLimitsConcurrency(t *testing.T) {
var inFlight, peak atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := inFlight.Add(1)
for {
old := peak.Load()
if n <= old || peak.CompareAndSwap(old, n) {
break
}
}
time.Sleep(20 * time.Millisecond)
inFlight.Add(-1)
w.WriteHeader(http.StatusMultiStatus)
w.Write([]byte(multistatus("/v0/drive/")))
}))
defer srv.Close()
urls := make([]string, maxProbes*3)
for i := range urls {
urls[i] = srv.URL
}
client := &http.Client{Transport: &http.Transport{MaxConnsPerHost: 0}}
HasSharesMulti(context.Background(), client, urls, func(string, ...any) {})
if peak.Load() > maxProbes {
t.Errorf("peak concurrency = %d, want <= %d", peak.Load(), maxProbes)
}
}
+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,
-326
View File
@@ -1,326 +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) {
r := &jsStreamReader{
reader: evalJS(t, `({cancel() { return new Promise(() => {}) }})`),
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()
}
}
+86 -132
View File
@@ -6,14 +6,17 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"sync"
"syscall/js"
"tailscale.com/cmd/tsconnect/driveprobe"
"tailscale.com/drive"
"tailscale.com/tailcfg"
"tailscale.com/tsd"
@@ -30,73 +33,6 @@ type jsFileSystemForRemote struct {
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
@@ -117,8 +53,7 @@ func (fs *jsFileSystemForRemote) Close() error { return nil }
// 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.
// The call blocks until JS calls end() (or a write error occurs).
func (fs *jsFileSystemForRemote) ServeHTTPWithPerms(
perms drive.Permissions, w http.ResponseWriter, r *http.Request,
) {
@@ -126,21 +61,14 @@ func (fs *jsFileSystemForRemote) ServeHTTPWithPerms(
fn := fs.fn
fs.mu.RUnlock()
if fn.Type() != js.TypeFunction {
if fn.IsUndefined() || fn.IsNull() {
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)
@@ -156,54 +84,62 @@ func (fs *jsFileSystemForRemote) ServeHTTPWithPerms(
})
})
// doneCh receives nil when JS calls end(), or a write error if Write fails.
doneCh := make(chan error, 1)
// 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)
if len(args) < 1 {
return nil
}
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 {
w.Header().Add(k, v)
}
}
}
w.WriteHeader(status)
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)
if len(args) < 1 {
return nil
}
data := args[0]
buf := make([]byte, data.Get("length").Int())
js.CopyBytesToGo(buf, data)
if _, werr := w.Write(buf); werr != nil {
select {
case doneCh <- werr:
default:
}
return nil
}
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
return nil
})
// end signals that the response is complete.
end := js.FuncOf(func(_ js.Value, _ []js.Value) any {
select {
case response.doneCh <- nil:
case 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
})
defer func() {
readBodyChunk.Release()
writeHead.Release()
write.Release()
end.Release()
}()
jsReq := map[string]any{
"method": r.Method,
@@ -218,28 +154,11 @@ func (fs *jsFileSystemForRemote) ServeHTTPWithPerms(
"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)
}
fn.Invoke(jsReq, jsRes, drivePermsToJS(perms))
var resultErr error
select {
case resultErr = <-response.doneCh:
case <-r.Context().Done():
resultErr = r.Context().Err()
}
response.finish(resultErr, r.Context().Err())
// Block this goroutine until JS calls end() or a write error occurs.
// The Go WASM scheduler yields back to JS while we wait.
<-doneCh
}
// drivePermsToJS converts drive.Permissions to a plain JS-friendly object.
@@ -312,11 +231,37 @@ func wireDriveJS(i *jsIPN, driveFS *jsFileSystemForRemote, m map[string]any) {
return nil
})
m["listDrivePeers"] = js.FuncOf(func(_ js.Value, _ []js.Value) any {
return i.listDrivePeers()
m["listDrivePeers"] = js.FuncOf(func(_ js.Value, args []js.Value) any {
var hasShares bool
if len(args) > 0 && !args[0].IsUndefined() && !args[0].IsNull() {
if v := args[0].Get("hasShares"); v.Type() == js.TypeBoolean {
hasShares = v.Bool()
}
}
return i.listDrivePeers(hasShares)
})
}
// filterPeersWithShares keeps only the peers whose Taildrive endpoint lists at
// least one share for us, preserving the order of the input.
func (i *jsIPN) filterPeersWithShares(peers []jsDrivePeer) []jsDrivePeer {
urls := make([]string, len(peers))
for n, p := range peers {
urls[n] = p.PeerAPIURL
}
client := &http.Client{Transport: i.lb.Dialer().PeerAPITransport()}
results := driveprobe.HasSharesMulti(context.Background(), client, urls, log.Printf)
kept := make([]jsDrivePeer, 0, len(peers))
for n, p := range peers {
if results[n] {
kept = append(kept, p)
}
}
return kept
}
type jsDrivePeer struct {
Name string `json:"name"`
PeerAPIURL string `json:"peerAPIURL"`
@@ -332,7 +277,12 @@ type jsDrivePeer struct {
//
// 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 {
//
// hasShares narrows it to peers we confirmed are exposing at least one share,
// at the cost of one peerAPI round-trip per candidate (run in parallel). It is
// a positive filter: a peer we could not reach is left out, which is not the
// same as knowing it has no shares.
func (i *jsIPN) listDrivePeers(hasShares bool) js.Value {
return makePromise(func() (any, error) {
if !i.lb.DriveAccessEnabled() {
return "[]", nil
@@ -385,6 +335,10 @@ func (i *jsIPN) listDrivePeers() js.Value {
})
}
if hasShares && len(peers) > 0 {
peers = i.filterPeersWithShares(peers)
}
b, err := json.Marshal(peers)
if err != nil {
return nil, fmt.Errorf("listDrivePeers: marshal: %w", err)
+1 -1
View File
@@ -20,7 +20,7 @@ func initDriveForRemote(_ *tsd.System) *jsFileSystemForRemote { return nil }
func wireDriveJS(_ *jsIPN, _ *jsFileSystemForRemote, _ map[string]any) {}
// listDrivePeers returns an empty list when the drive feature is omitted.
func (i *jsIPN) listDrivePeers() js.Value {
func (i *jsIPN) listDrivePeers(_ bool) js.Value {
return makePromise(func() (any, error) {
return "[]", nil
})
+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
+123 -291
View File
@@ -24,11 +24,9 @@ import (
"net/http"
"net/http/httptest"
"net/netip"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall/js"
"time"
@@ -44,6 +42,7 @@ import (
"tailscale.com/ipn"
"tailscale.com/ipn/ipnauth"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/ipnserver"
"tailscale.com/ipn/localapi"
"tailscale.com/ipn/store/mem"
"tailscale.com/logpolicy"
@@ -52,6 +51,7 @@ import (
"tailscale.com/net/netns"
"tailscale.com/net/tsaddr"
"tailscale.com/net/tsdial"
"tailscale.com/safesocket"
"tailscale.com/tailcfg"
"tailscale.com/tsd"
"tailscale.com/types/logid"
@@ -64,62 +64,21 @@ import (
// ControlURL defines the URL to be used for connection to Control.
var ControlURL = ipn.DefaultControlURL
// initCallbackEnv names the JS global holding the callback that this runtime
// hands its bridge to. The loader generates a name, installs the callback under
// it, and passes the name in through go.env before starting the runtime.
//
// Publishing the bridge through a caller-supplied callback rather than a fixed
// global means the loader never has to guess when the Go scheduler has run far
// enough to expose it, and two runtimes in one JS realm cannot collide on the
// name.
const initCallbackEnv = "TSCONNECT_INIT_CALLBACK"
func main() {
name := os.Getenv(initCallbackEnv)
if name == "" {
log.Fatalf("%s is not set; this module must be loaded by @webnet/tsconnect", initCallbackEnv)
}
callback := js.Global().Get(name)
if callback.Type() != js.TypeFunction {
log.Fatalf("globalThis[%q] is not a function", name)
}
shutdownCh := make(chan struct{})
var terminateOnce sync.Once
terminate := func() { terminateOnce.Do(func() { close(shutdownCh) }) }
var claimed atomic.Bool
newIPNFn := js.FuncOf(func(this js.Value, args []js.Value) any {
return makePromise(func() (any, error) {
if len(args) != 1 {
return nil, errors.New("newIPN takes exactly one argument")
}
// One IPN per runtime: shutdown exits the shared Go runtime, so a
// second IPN here would be torn down by the first one's shutdown.
if !claimed.CompareAndSwap(false, true) {
return nil, errors.New("this WASM runtime already has an IPN; start another runtime instead")
}
return newIPN(args[0], terminate)
})
})
// A failed newIPN leaves the runtime blocked below with nothing to shut it
// down, so the loader gets a way to exit it. Leaving that to the loader
// keeps the ordering right: closing shutdownCh here would let main return
// before makePromise had delivered the rejection.
terminateFn := js.FuncOf(func(this js.Value, args []js.Value) any {
terminate()
return nil
})
callback.Invoke(newIPNFn, terminateFn)
js.Global().Set("newIPN", js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 1 {
log.Fatal("Usage: newIPN(config)")
return nil
}
return newIPN(args[0], shutdownCh)
}))
// Block until shutdown() is called on the IPN, then let main return so the
// Go runtime (and all its goroutines) can be collected by the JS engine.
<-shutdownCh
}
func newIPN(jsConfig js.Value, terminate func()) (map[string]any, error) {
func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
netns.SetEnabled(false)
var store ipn.StateStore
@@ -174,13 +133,13 @@ func newIPN(jsConfig js.Value, terminate func()) (map[string]any, error) {
EventBus: sys.Bus.Get(),
})
if err != nil {
return nil, fmt.Errorf("wgengine.NewUserspaceEngine: %w", err)
log.Fatal(err)
}
sys.Set(eng)
ns, err := netstack.Create(logf, sys.Tun.Get(), eng, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper())
if err != nil {
return nil, fmt.Errorf("netstack.Create: %w", err)
log.Fatalf("netstack.Create: %v", err)
}
sys.Set(ns)
ns.ProcessLocalIPs = true
@@ -217,17 +176,20 @@ func newIPN(jsConfig js.Value, terminate func()) (map[string]any, error) {
// initDriveForRemote must be called before NewLocalBackend (SubSystem is set-once).
driveFS := initDriveForRemote(sys)
srv := ipnserver.New(logf, logid, sys.Bus.Get(), sys.NetMon.Get())
lb, err := ipnlocal.NewLocalBackend(logf, logid, sys, controlclient.LoginEphemeral)
if err != nil {
return nil, fmt.Errorf("ipnlocal.NewLocalBackend: %w", err)
log.Fatalf("ipnlocal.NewLocalBackend: %v", err)
}
if err := ns.Start(lb); err != nil {
return nil, fmt.Errorf("starting netstack: %w", err)
log.Fatalf("failed to start netstack: %v", err)
}
wireTaildropFileOps(lb, jsConfig.Get("fileOps"))
srv.SetLocalBackend(lb)
jsIPN := &jsIPN{
dialer: dialer,
srv: srv,
lb: lb,
ns: ns,
controlURL: controlURL,
@@ -235,7 +197,7 @@ func newIPN(jsConfig js.Value, terminate func()) (map[string]any, error) {
hostname: hostname,
logID: logid,
funnelPorts: make(map[uint16]*funnelListenerEntry),
terminate: terminate,
shutdownCh: shutdownCh,
}
lb.SetTCPHandlerForFunnelFlow(jsIPN.handleFunnelTCP)
@@ -415,11 +377,12 @@ func newIPN(jsConfig js.Value, terminate func()) (map[string]any, error) {
}),
}
wireDriveJS(jsIPN, driveFS, m)
return m, nil
return m
}
type jsIPN struct {
dialer *tsdial.Dialer
srv *ipnserver.Server
lb *ipnlocal.LocalBackend
ns *netstack.Impl
controlURL string
@@ -430,11 +393,11 @@ type jsIPN struct {
funnelMu sync.Mutex
funnelPorts map[uint16]*funnelListenerEntry
terminate func() // unblocks main() so the Go runtime can exit
// 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
notifyMu sync.Mutex
notifyCancel context.CancelFunc
notifyDone <-chan struct{}
}
// funnelListenerEntry is the per-port state for routing Funnel connections to a listenTLS listener.
@@ -460,137 +423,13 @@ var jsMachineStatus = map[tailcfg.MachineStatus]string{
tailcfg.MachineInvalid: "MachineInvalid",
}
const jsIPNNotifyWatchMask = ipn.NotifyInitialState | ipn.NotifyInitialPrefs | ipn.NotifyPeerChanges | ipn.NotifyPeerPatches
func notifyRefreshesNetMap(n ipn.Notify) bool {
if n.SelfChange != nil || len(n.PeersChanged) > 0 || len(n.PeersRemoved) > 0 {
return true
}
for _, patch := range n.PeerChangedPatch {
if patch.Online != nil {
return true
}
}
return false
}
func (i *jsIPN) watchNotifications(ctx context.Context, registered, done chan struct{}, notify func(ipn.Notify), refresh func()) {
defer close(done)
var registeredOnce sync.Once
for {
watchAdded := false
i.lb.WatchNotifications(ctx, jsIPNNotifyWatchMask, func() {
watchAdded = true
refresh()
registeredOnce.Do(func() { close(registered) })
}, func(n *ipn.Notify) bool {
if n.ErrMessage != nil {
log.Printf("IPN notification error: %s", *n.ErrMessage)
}
notify(*n)
return true
})
if ctx.Err() != nil {
return
}
if !watchAdded {
log.Printf("IPN notification watcher stopped before registration")
return
}
log.Printf("IPN notification watcher stopped; reconnecting")
timer := time.NewTimer(100 * time.Millisecond)
select {
case <-ctx.Done():
if !timer.Stop() {
<-timer.C
}
return
case <-timer.C:
}
}
}
func (i *jsIPN) refreshNetMap(jsCallbacks js.Value) {
nm := i.lb.NetMapWithPeers()
if nm == nil {
return
}
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
}
}
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{
Self: jsNetMapSelfNode{
jsNetMapNode: jsNetMapNode{
Name: nm.SelfName(),
Addresses: mapSliceView(nm.GetAddresses(), func(a netip.Prefix) string { return a.Addr().String() }),
NodeKey: nm.NodeKey.String(),
MachineKey: nm.MachineKey.String(),
PeerAPIURL: selfPeerAPIURL,
Services: userServicesFromView(nm.SelfNode.Hostinfo().Services()),
},
MachineStatus: jsMachineStatus[nm.GetMachineStatus()],
},
Peers: mapSlice(nm.Peers, func(p tailcfg.NodeView) jsNetMapPeerNode {
name := p.Name()
if name == "" {
name = p.Hostinfo().Hostname()
}
addrs := make([]string, p.Addresses().Len())
for idx, ap := range p.Addresses().All() {
addrs[idx] = ap.Addr().String()
}
return jsNetMapPeerNode{
jsNetMapNode: jsNetMapNode{
Name: name,
Addresses: addrs,
MachineKey: p.Machine().String(),
NodeKey: p.Key().String(),
PeerAPIURL: buildPeerAPIURL(p, selfHave4, selfHave6),
Services: userServicesFromView(p.Hostinfo().Services()),
},
Online: p.Online().Clone(),
TailscaleSSHEnabled: p.Hostinfo().TailscaleSSHEnabled(),
ExitNodeOption: tsaddr.ContainsExitRoutes(p.AllowedIPs()),
StableNodeID: string(p.StableID()),
}
}),
LockedOut: nm.TKAEnabled && nm.SelfNode.KeySignature().Len() == 0,
}
if jsonNetMap, err := json.Marshal(jsNetMap); err == nil {
jsCallbacks.Call("notifyNetMap", string(jsonNetMap))
} else {
log.Printf("Could not generate JSON netmap: %v", err)
}
}
func (i *jsIPN) run(jsCallbacks js.Value) {
notifyState := func(state ipn.State) {
jsCallbacks.Call("notifyState", jsIPNState[state])
}
notifyState(ipn.NoState)
notify := func(n ipn.Notify) {
i.lb.SetNotifyCallback(func(n ipn.Notify) {
// Panics in the notify callback are likely due to be due to bugs in
// this bridging module (as opposed to actual bugs in Tailscale) and
// thus may be recoverable. Let the UI know, and allow the user to
@@ -605,8 +444,86 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
if n.State != nil {
notifyState(*n.State)
}
if notifyRefreshesNetMap(n) {
i.refreshNetMap(jsCallbacks)
if n.SelfChange != nil {
// Self changed: rebuild the JS-side NetMap snapshot. Peers
// don't ride on the bus anymore, so fetch them on demand
// from LocalBackend.
nm := i.lb.NetMapWithPeers()
if 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{
Self: jsNetMapSelfNode{
jsNetMapNode: jsNetMapNode{
Name: nm.SelfName(),
Addresses: mapSliceView(nm.GetAddresses(), func(a netip.Prefix) string { return a.Addr().String() }),
NodeKey: nm.NodeKey.String(),
MachineKey: nm.MachineKey.String(),
PeerAPIURL: selfPeerAPIURL,
Services: userServicesFromView(nm.SelfNode.Hostinfo().Services()),
},
MachineStatus: jsMachineStatus[nm.GetMachineStatus()],
},
Peers: mapSlice(nm.Peers, func(p tailcfg.NodeView) jsNetMapPeerNode {
name := p.Name()
if name == "" {
// In practice this should only happen for Hello.
name = p.Hostinfo().Hostname()
}
addrs := make([]string, p.Addresses().Len())
for idx, ap := range p.Addresses().All() {
addrs[idx] = ap.Addr().String()
}
// Peer peerAPI URL from the peer's advertised Services.
peerURL := buildPeerAPIURL(p, selfHave4, selfHave6)
return jsNetMapPeerNode{
jsNetMapNode: jsNetMapNode{
Name: name,
Addresses: addrs,
MachineKey: p.Machine().String(),
NodeKey: p.Key().String(),
PeerAPIURL: peerURL,
Services: userServicesFromView(p.Hostinfo().Services()),
},
Online: p.Online().Clone(),
TailscaleSSHEnabled: p.Hostinfo().TailscaleSSHEnabled(),
ExitNodeOption: tsaddr.ContainsExitRoutes(p.AllowedIPs()),
StableNodeID: string(p.StableID()),
}
}),
LockedOut: nm.TKAEnabled && nm.SelfNode.KeySignature().Len() == 0,
}
if jsonNetMap, err := json.Marshal(jsNetMap); err == nil {
jsCallbacks.Call("notifyNetMap", string(jsonNetMap))
} else {
log.Printf("Could not generate JSON netmap: %v", err)
}
}
}
if n.Prefs != nil && n.Prefs.Valid() {
jsCallbacks.Call("notifyExitNode", string(n.Prefs.ExitNodeID()))
@@ -654,32 +571,9 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
log.Printf("could not marshal OutgoingFiles: %v", err)
}
}
}
refresh := func() {
defer func() {
if r := recover(); r != nil {
fmt.Println("Panic recovered:", r)
jsCallbacks.Call("notifyPanicRecover", fmt.Sprint(r))
}
}()
i.refreshNetMap(jsCallbacks)
}
registered := make(chan struct{})
notifyCtx, notifyCancel := context.WithCancel(context.Background())
notifyDone := make(chan struct{})
i.notifyMu.Lock()
i.notifyCancel = notifyCancel
i.notifyDone = notifyDone
i.notifyMu.Unlock()
go i.watchNotifications(notifyCtx, registered, notifyDone, notify, refresh)
})
go func() {
select {
case <-registered:
case <-notifyDone:
return
}
err := i.lb.Start(ipn.Options{
UpdatePrefs: &ipn.Prefs{
ControlURL: i.controlURL,
@@ -694,6 +588,18 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
}
}()
ln, err := safesocket.Listen("")
if err != nil {
log.Fatalf("safesocket.Listen: %v", err)
}
i.ln = ln
go func() {
err := i.srv.Run(context.Background(), ln)
if err != nil && !errors.Is(err, net.ErrClosed) {
log.Fatalf("ipnserver.Run exited: %v", err)
}
}()
}
func (i *jsIPN) login() {
@@ -711,26 +617,16 @@ func (i *jsIPN) logout() {
}()
}
// shutdown tears down the backend and lets main return, which exits the whole
// Go runtime. Callers should await the runtime's exit rather than the promise
// returned here: terminating races with makePromise resolving, so the promise
// may never settle.
func (i *jsIPN) shutdown() js.Value {
return makePromise(func() (any, error) {
i.shutdownOnce.Do(func() {
i.notifyMu.Lock()
notifyCancel, notifyDone := i.notifyCancel, i.notifyDone
i.notifyMu.Unlock()
if notifyCancel != nil {
notifyCancel()
}
if notifyDone != nil {
<-notifyDone
}
if i.lb != nil {
i.lb.Shutdown()
}
i.terminate()
if i.ln != nil {
i.ln.Close()
}
close(i.shutdownCh)
})
return nil, nil
})
@@ -1672,83 +1568,19 @@ func makePromise(f func() (any, error)) js.Value {
resolve := args[0]
reject := args[1]
go func() {
defer func() {
if recovered := recover(); recovered != nil {
rejectJSError(reject, recoveredJSError(recovered))
}
}()
if res, err := f(); err == nil {
resolve.Invoke(res)
} else {
rejectJSError(reject, err)
reject.Invoke(err.Error())
}
}()
return nil
})
defer handler.Release()
promiseConstructor := js.Global().Get("Promise")
return promiseConstructor.New(handler)
}
func callJSFunction(fn js.Value, args ...any) (ret js.Value, err error) {
if fn.Type() != js.TypeFunction {
return js.Undefined(), fmt.Errorf("expected JavaScript function, got %s", fn.Type())
}
defer func() {
if recovered := recover(); recovered != nil {
err = recoveredJSError(recovered)
}
}()
return fn.Invoke(args...), nil
}
func callJSMethod(receiver js.Value, method string, args ...any) (ret js.Value, err error) {
defer func() {
if recovered := recover(); recovered != nil {
err = recoveredJSError(recovered)
}
}()
if receiver.Type() != js.TypeObject && receiver.Type() != js.TypeFunction {
return js.Undefined(), fmt.Errorf("cannot call JavaScript method %q on %s", method, receiver.Type())
}
if receiver.Get(method).Type() != js.TypeFunction {
return js.Undefined(), fmt.Errorf("JavaScript property %q is not a function", method)
}
return receiver.Call(method, args...), nil
}
func hasJSFunctionProperty(receiver js.Value, property string) (ok bool, err error) {
defer func() {
if recovered := recover(); recovered != nil {
err = recoveredJSError(recovered)
}
}()
if receiver.Type() != js.TypeObject && receiver.Type() != js.TypeFunction {
return false, nil
}
return receiver.Get(property).Type() == js.TypeFunction, nil
}
func recoveredJSError(recovered any) error {
switch recovered := recovered.(type) {
case js.Error:
return recovered
case *js.ValueError:
return recovered
default:
panic(recovered)
}
}
func rejectJSError(reject js.Value, err error) {
if jsErr, ok := err.(js.Error); ok {
reject.Invoke(jsErr.Value)
return
}
reject.Invoke(err.Error())
}
const logPolicyStateKey = "log-policy"
func getOrCreateLogPolicyConfig(state ipn.StateStore) *logpolicy.Config {
-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())
}
+8 -1
View File
@@ -5,15 +5,22 @@ package safesocket
import (
"context"
"fmt"
"net"
"sync/atomic"
"github.com/akutz/memconn"
)
const memName = "Tailscale-IPN"
// memSeq ensures each IPN instance in the same WASM process gets a distinct
// memconn address, so concurrent instances do not conflict on the registry.
var memSeq atomic.Int64
func listen(path string) (net.Listener, error) {
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) {
Executable
BIN
View File
Binary file not shown.