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