Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
738fea52f8 | ||
|
|
3c63a95446 | ||
|
|
7ca658b028 | ||
|
|
c98a03dfa5 | ||
|
|
00d16d6ae2 | ||
|
|
24ee15e524 |
@@ -1,183 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
// 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&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)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import "../wasm_exec"
|
||||
import wasmUrl from "./main.wasm"
|
||||
import { sessionStateStorage } from "../lib/js-state-store"
|
||||
import { renderApp } from "./app"
|
||||
import { startIPN } from "../lib/start-ipn"
|
||||
|
||||
async function main() {
|
||||
const app = await renderApp()
|
||||
@@ -13,23 +14,25 @@ async function main() {
|
||||
fetch(`./dist/${wasmUrl}`),
|
||||
go.importObject
|
||||
)
|
||||
// The Go process should never exit, if it does then it's an unhandled panic.
|
||||
go.run(wasmInstance.instance).then(() =>
|
||||
app.handleGoPanic("Unexpected shutdown")
|
||||
)
|
||||
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const authKey = params.get("authkey") ?? undefined
|
||||
|
||||
const ipn = newIPN({
|
||||
// Persist IPN state in sessionStorage in development, so that we don't need
|
||||
// to re-authorize every time we reload the page.
|
||||
stateStorage: DEBUG ? sessionStateStorage : undefined,
|
||||
// authKey allows for an auth key to be
|
||||
// specified as a url param which automatically
|
||||
// authorizes the client for use.
|
||||
authKey: DEBUG ? authKey : undefined,
|
||||
})
|
||||
// The Go process should never exit, if it does then it's an unhandled panic.
|
||||
const ipn = await startIPN(
|
||||
go,
|
||||
wasmInstance.instance,
|
||||
{
|
||||
// Persist IPN state in sessionStorage in development, so that we don't
|
||||
// need to re-authorize every time we reload the page.
|
||||
stateStorage: DEBUG ? sessionStateStorage : undefined,
|
||||
// authKey allows for an auth key to be
|
||||
// specified as a url param which automatically
|
||||
// authorizes the client for use.
|
||||
authKey: DEBUG ? authKey : undefined,
|
||||
},
|
||||
(reason) => app.handleGoPanic(reason)
|
||||
)
|
||||
app.runWithIPN(ipn)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
/**
|
||||
* Starts a Go runtime and returns the single IPN it owns.
|
||||
*
|
||||
* The runtime does not publish its bridge on a global. It reads the name of a
|
||||
* callback from its environment and invokes it once the bridge is ready, so
|
||||
* this resolves on an explicit signal rather than on the Go scheduler having
|
||||
* run far enough. The name is generated per runtime, so several runtimes can
|
||||
* start in one page without racing each other.
|
||||
*/
|
||||
export async function startIPN(
|
||||
go: Go,
|
||||
instance: WebAssembly.Instance,
|
||||
config: IPNConfig,
|
||||
onExit: (reason: string) => void
|
||||
): Promise<IPN> {
|
||||
const name = `__tsconnectInit_${Math.random().toString(36).slice(2)}`
|
||||
const globals = globalThis as Record<string, unknown>
|
||||
|
||||
const ready = new Promise<[NewIPN, Terminate]>((resolve) => {
|
||||
globals[name] = (newIPN: NewIPN, terminate: Terminate) => {
|
||||
delete globals[name]
|
||||
resolve([newIPN, terminate])
|
||||
}
|
||||
})
|
||||
|
||||
go.env[INIT_CALLBACK_ENV] = name
|
||||
|
||||
// An exit before the IPN reaches the caller is a startup failure, and throwing
|
||||
// hands it back as a rejection. Afterwards the caller holds the only shutdown
|
||||
// path, so an exit is either that shutdown or a panic; report it either way,
|
||||
// because the IPN is dead in both cases.
|
||||
let handedOver = false
|
||||
const exited: Promise<never> = go.run(instance).then(() => {
|
||||
delete globals[name]
|
||||
if (handedOver) onExit("Go runtime exited")
|
||||
// Always reject: before the handover this is what fails the race below,
|
||||
// and after it the race has settled, so nothing observes the rejection.
|
||||
throw new Error(
|
||||
handedOver
|
||||
? "Go runtime exited"
|
||||
: "Go runtime exited before the IPN was ready"
|
||||
)
|
||||
})
|
||||
|
||||
const [newIPN, terminate] = await Promise.race([ready, exited])
|
||||
try {
|
||||
const ipn = await newIPN(config)
|
||||
handedOver = true
|
||||
return ipn
|
||||
} catch (err) {
|
||||
// Nothing was built, so nothing can shut the runtime down. Exit it here and
|
||||
// wait for it, or the page keeps a blocked runtime for a failed startup.
|
||||
terminate()
|
||||
await exited.catch(() => {})
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
type NewIPN = (config: IPNConfig) => Promise<IPN>
|
||||
type Terminate = () => void
|
||||
|
||||
/** Must match initCallbackEnv in wasm_js.go. */
|
||||
const INIT_CALLBACK_ENV = "TSCONNECT_INIT_CALLBACK"
|
||||
@@ -7,6 +7,7 @@
|
||||
/// <reference path="../types/wasm_js.d.ts" />
|
||||
|
||||
import "../wasm_exec"
|
||||
import { startIPN } from "../lib/start-ipn"
|
||||
import wasmURL from "./main.wasm"
|
||||
|
||||
/**
|
||||
@@ -30,11 +31,7 @@ export async function createIPN(config: IPNPackageConfig): Promise<IPN> {
|
||||
go.importObject
|
||||
)
|
||||
// The Go process should never exit, if it does then it's an unhandled panic.
|
||||
go.run(wasmInstance.instance).then(() =>
|
||||
config.panicHandler("Unexpected shutdown")
|
||||
)
|
||||
|
||||
return newIPN(config)
|
||||
return startIPN(go, wasmInstance.instance, config, config.panicHandler)
|
||||
}
|
||||
|
||||
export { runSSHSession } from "../lib/ssh"
|
||||
|
||||
Vendored
-2
@@ -7,8 +7,6 @@
|
||||
*/
|
||||
|
||||
declare global {
|
||||
function newIPN(config: IPNConfig): IPN
|
||||
|
||||
interface IPN {
|
||||
run(callbacks: IPNCallbacks): void
|
||||
login(): void
|
||||
|
||||
@@ -6,17 +6,14 @@
|
||||
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"
|
||||
@@ -231,37 +228,11 @@ func wireDriveJS(i *jsIPN, driveFS *jsFileSystemForRemote, m map[string]any) {
|
||||
return nil
|
||||
})
|
||||
|
||||
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)
|
||||
m["listDrivePeers"] = js.FuncOf(func(_ js.Value, _ []js.Value) any {
|
||||
return i.listDrivePeers()
|
||||
})
|
||||
}
|
||||
|
||||
// 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"`
|
||||
@@ -277,12 +248,7 @@ 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.
|
||||
//
|
||||
// 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 {
|
||||
func (i *jsIPN) listDrivePeers() js.Value {
|
||||
return makePromise(func() (any, error) {
|
||||
if !i.lb.DriveAccessEnabled() {
|
||||
return "[]", nil
|
||||
@@ -335,10 +301,6 @@ func (i *jsIPN) listDrivePeers(hasShares bool) 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)
|
||||
|
||||
@@ -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(_ bool) js.Value {
|
||||
func (i *jsIPN) listDrivePeers() js.Value {
|
||||
return makePromise(func() (any, error) {
|
||||
return "[]", nil
|
||||
})
|
||||
|
||||
@@ -24,9 +24,11 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall/js"
|
||||
"time"
|
||||
|
||||
@@ -42,7 +44,6 @@ import (
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnauth"
|
||||
"tailscale.com/ipn/ipnlocal"
|
||||
"tailscale.com/ipn/ipnserver"
|
||||
"tailscale.com/ipn/localapi"
|
||||
"tailscale.com/ipn/store/mem"
|
||||
"tailscale.com/logpolicy"
|
||||
@@ -51,7 +52,6 @@ import (
|
||||
"tailscale.com/net/netns"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/net/tsdial"
|
||||
"tailscale.com/safesocket"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tsd"
|
||||
"tailscale.com/types/logid"
|
||||
@@ -64,21 +64,62 @@ import (
|
||||
// ControlURL defines the URL to be used for connection to Control.
|
||||
var ControlURL = ipn.DefaultControlURL
|
||||
|
||||
// initCallbackEnv names the JS global holding the callback that this runtime
|
||||
// hands its bridge to. The loader generates a name, installs the callback under
|
||||
// it, and passes the name in through go.env before starting the runtime.
|
||||
//
|
||||
// Publishing the bridge through a caller-supplied callback rather than a fixed
|
||||
// global means the loader never has to guess when the Go scheduler has run far
|
||||
// enough to expose it, and two runtimes in one JS realm cannot collide on the
|
||||
// name.
|
||||
const initCallbackEnv = "TSCONNECT_INIT_CALLBACK"
|
||||
|
||||
func main() {
|
||||
name := os.Getenv(initCallbackEnv)
|
||||
if name == "" {
|
||||
log.Fatalf("%s is not set; this module must be loaded by @webnet/tsconnect", initCallbackEnv)
|
||||
}
|
||||
callback := js.Global().Get(name)
|
||||
if callback.Type() != js.TypeFunction {
|
||||
log.Fatalf("globalThis[%q] is not a function", name)
|
||||
}
|
||||
|
||||
shutdownCh := make(chan struct{})
|
||||
js.Global().Set("newIPN", js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
if len(args) != 1 {
|
||||
log.Fatal("Usage: newIPN(config)")
|
||||
return nil
|
||||
}
|
||||
return newIPN(args[0], shutdownCh)
|
||||
}))
|
||||
var terminateOnce sync.Once
|
||||
terminate := func() { terminateOnce.Do(func() { close(shutdownCh) }) }
|
||||
|
||||
var claimed atomic.Bool
|
||||
newIPNFn := js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
return makePromise(func() (any, error) {
|
||||
if len(args) != 1 {
|
||||
return nil, errors.New("newIPN takes exactly one argument")
|
||||
}
|
||||
// One IPN per runtime: shutdown exits the shared Go runtime, so a
|
||||
// second IPN here would be torn down by the first one's shutdown.
|
||||
if !claimed.CompareAndSwap(false, true) {
|
||||
return nil, errors.New("this WASM runtime already has an IPN; start another runtime instead")
|
||||
}
|
||||
return newIPN(args[0], terminate)
|
||||
})
|
||||
})
|
||||
|
||||
// A failed newIPN leaves the runtime blocked below with nothing to shut it
|
||||
// down, so the loader gets a way to exit it. Leaving that to the loader
|
||||
// keeps the ordering right: closing shutdownCh here would let main return
|
||||
// before makePromise had delivered the rejection.
|
||||
terminateFn := js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
terminate()
|
||||
return nil
|
||||
})
|
||||
|
||||
callback.Invoke(newIPNFn, terminateFn)
|
||||
|
||||
// Block until shutdown() is called on the IPN, then let main return so the
|
||||
// Go runtime (and all its goroutines) can be collected by the JS engine.
|
||||
<-shutdownCh
|
||||
}
|
||||
|
||||
func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
|
||||
func newIPN(jsConfig js.Value, terminate func()) (map[string]any, error) {
|
||||
netns.SetEnabled(false)
|
||||
|
||||
var store ipn.StateStore
|
||||
@@ -133,13 +174,13 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
|
||||
EventBus: sys.Bus.Get(),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
return nil, fmt.Errorf("wgengine.NewUserspaceEngine: %w", err)
|
||||
}
|
||||
sys.Set(eng)
|
||||
|
||||
ns, err := netstack.Create(logf, sys.Tun.Get(), eng, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper())
|
||||
if err != nil {
|
||||
log.Fatalf("netstack.Create: %v", err)
|
||||
return nil, fmt.Errorf("netstack.Create: %w", err)
|
||||
}
|
||||
sys.Set(ns)
|
||||
ns.ProcessLocalIPs = true
|
||||
@@ -176,20 +217,17 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
|
||||
// initDriveForRemote must be called before NewLocalBackend (SubSystem is set-once).
|
||||
driveFS := initDriveForRemote(sys)
|
||||
|
||||
srv := ipnserver.New(logf, logid, sys.Bus.Get(), sys.NetMon.Get())
|
||||
lb, err := ipnlocal.NewLocalBackend(logf, logid, sys, controlclient.LoginEphemeral)
|
||||
if err != nil {
|
||||
log.Fatalf("ipnlocal.NewLocalBackend: %v", err)
|
||||
return nil, fmt.Errorf("ipnlocal.NewLocalBackend: %w", err)
|
||||
}
|
||||
if err := ns.Start(lb); err != nil {
|
||||
log.Fatalf("failed to start netstack: %v", err)
|
||||
return nil, fmt.Errorf("starting netstack: %w", err)
|
||||
}
|
||||
wireTaildropFileOps(lb, jsConfig.Get("fileOps"))
|
||||
srv.SetLocalBackend(lb)
|
||||
|
||||
jsIPN := &jsIPN{
|
||||
dialer: dialer,
|
||||
srv: srv,
|
||||
lb: lb,
|
||||
ns: ns,
|
||||
controlURL: controlURL,
|
||||
@@ -197,7 +235,7 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
|
||||
hostname: hostname,
|
||||
logID: logid,
|
||||
funnelPorts: make(map[uint16]*funnelListenerEntry),
|
||||
shutdownCh: shutdownCh,
|
||||
terminate: terminate,
|
||||
}
|
||||
lb.SetTCPHandlerForFunnelFlow(jsIPN.handleFunnelTCP)
|
||||
|
||||
@@ -377,12 +415,11 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
|
||||
}),
|
||||
}
|
||||
wireDriveJS(jsIPN, driveFS, m)
|
||||
return m
|
||||
return m, nil
|
||||
}
|
||||
|
||||
type jsIPN struct {
|
||||
dialer *tsdial.Dialer
|
||||
srv *ipnserver.Server
|
||||
lb *ipnlocal.LocalBackend
|
||||
ns *netstack.Impl
|
||||
controlURL string
|
||||
@@ -393,10 +430,7 @@ type jsIPN struct {
|
||||
funnelMu sync.Mutex
|
||||
funnelPorts map[uint16]*funnelListenerEntry
|
||||
|
||||
// ln is the safesocket listener created by run(); stored here so shutdown
|
||||
// can close it and unblock srv.Run.
|
||||
ln net.Listener
|
||||
shutdownCh chan struct{} // closed by shutdown() to unblock main()
|
||||
terminate func() // unblocks main() so the Go runtime can exit
|
||||
shutdownOnce sync.Once
|
||||
}
|
||||
|
||||
@@ -588,18 +622,6 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
|
||||
}
|
||||
}()
|
||||
|
||||
ln, err := safesocket.Listen("")
|
||||
if err != nil {
|
||||
log.Fatalf("safesocket.Listen: %v", err)
|
||||
}
|
||||
i.ln = ln
|
||||
|
||||
go func() {
|
||||
err := i.srv.Run(context.Background(), ln)
|
||||
if err != nil && !errors.Is(err, net.ErrClosed) {
|
||||
log.Fatalf("ipnserver.Run exited: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (i *jsIPN) login() {
|
||||
@@ -617,16 +639,17 @@ func (i *jsIPN) logout() {
|
||||
}()
|
||||
}
|
||||
|
||||
// shutdown tears down the backend and lets main return, which exits the whole
|
||||
// Go runtime. Callers should await the runtime's exit rather than the promise
|
||||
// returned here: terminating races with makePromise resolving, so the promise
|
||||
// may never settle.
|
||||
func (i *jsIPN) shutdown() js.Value {
|
||||
return makePromise(func() (any, error) {
|
||||
i.shutdownOnce.Do(func() {
|
||||
if i.lb != nil {
|
||||
i.lb.Shutdown()
|
||||
}
|
||||
if i.ln != nil {
|
||||
i.ln.Close()
|
||||
}
|
||||
close(i.shutdownCh)
|
||||
i.terminate()
|
||||
})
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
@@ -5,22 +5,15 @@ package safesocket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/akutz/memconn"
|
||||
)
|
||||
|
||||
const memName = "Tailscale-IPN"
|
||||
|
||||
// memSeq ensures each IPN instance in the same WASM process gets a distinct
|
||||
// memconn address, so concurrent instances do not conflict on the registry.
|
||||
var memSeq atomic.Int64
|
||||
|
||||
func listen(path string) (net.Listener, error) {
|
||||
name := fmt.Sprintf("%s-%d", memName, memSeq.Add(1))
|
||||
return memconn.Listen("memu", name)
|
||||
return memconn.Listen("memu", memName)
|
||||
}
|
||||
|
||||
func connect(ctx context.Context, _ string) (net.Conn, error) {
|
||||
|
||||
Reference in New Issue
Block a user