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