Compare commits

13 Commits

Author SHA1 Message Date
codinget d9efc3bae2 fix(tsconnect): pin types to avoid monorepo @types pollution
Replace skipLibCheck with an explicit types list so TypeScript and
dts-bundle-generator only auto-include @types/golang-wasm-exec and
@types/qrcode, preventing @types/eslint-scope and @types/ws from
leaking in from a parent node_modules when built inside a monorepo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 20:04:20 +00:00
codinget 9e36a7f27f fix(tsconnect): skipLibCheck to avoid monorepo @types conflicts
When tsconnect is built inside a JS monorepo, TypeScript walks up the
directory tree and auto-discovers @types/eslint-scope and @types/ws
from the root node_modules, causing spurious type errors unrelated to
tsconnect itself. skipLibCheck suppresses these.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 19:52:29 +00:00
codinget 8277fc0f1d fix(tsconnect): lowercase name/size in waitingFiles JSON
apitype.WaitingFile has no json tags so it serialised as {Name, Size}.
Introduce a local jsWaitingFile struct with json:"name" / json:"size"
so the JS side receives idiomatic camelCase property names.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 19:39:52 +00:00
codinget e32520659d fix(taildrop): restore incoming file progress notifications
The io.Copy in PutFile was writing directly to wc, bypassing the
incomingFile wrapper whose Write method increments f.copied and fires
a throttled sendFileNotify on progress. As a result, notifyIncomingFiles
on the JS side only ever fired once (on completion) with received=0,
making progress UI impossible. The original inFile wrapping was lost
during the Android SAF refactor.

Also surface the PartialFile.Done flag through jsIncomingFile so JS can
distinguish the final "transfer complete" notification from in-progress
updates.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-16 19:04:02 +00:00
codinget e8eb9d71c2 fix(tsconnect): guard nil n.Prefs in notify callback
n.Prefs is *PrefsView (a pointer), so calling n.Prefs.Valid() on a
Notify where Prefs is nil auto-dereferenced nil and panicked. The
callback's defer recover() swallowed the panic, which meant every
Notify without Prefs (Health-only, FilesWaiting, IncomingFiles,
OutgoingFiles, etc.) never reached the file-related JS calls.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-16 18:43:58 +00:00
codinget c4ff4c4835 feat(tsconnect): add outgoing file transfer progress notifications
- Export UpdateOutgoingFiles on taildrop.Extension so it can be called
  from outside the package (wasm bridge, package main).
- Wrap sendFile's PUT body with progresstracking.NewReader so bytes-sent
  is sampled roughly once per second during transfer.
- Create an OutgoingFile entry (with UUID, peer ID, name, declared size)
  before the PUT and call UpdateOutgoingFiles on each progress tick and
  on completion (setting Finished/Succeeded). This flows into the IPN
  notify stream as OutgoingFiles notifications.
- Add jsOutgoingFile struct and wire n.OutgoingFiles into a new
  notifyOutgoingFiles callback in run(), mirroring notifyIncomingFiles.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 23:01:30 +00:00
codinget 68ecc4b033 feat(tsconnect): add notifyFilesWaiting and notifyIncomingFiles callbacks
Wire two new callbacks into the IPN notify stream:

- notifyFilesWaiting: fires when a completed inbound transfer is staged
  and ready to retrieve via waitingFiles(). Triggered by n.FilesWaiting
  in the notify stream.
- notifyIncomingFiles: fires with a JSON snapshot of in-progress inbound
  transfers whenever progress changes (roughly once per second while
  active, plus once at completion). The jsIncomingFile struct carries
  name, started (Unix ms), declaredSize, and received bytes. An empty
  array indicates all active transfers have finished.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 22:58:13 +00:00
codinget 9f96b7434c feat(taildrop): fix DirectFileMode, void callbacks, and empty WaitingFiles
- Add SetStagedFileOps to Extension: sets fileOps without enabling
  DirectFileMode, so WASM clients use staged retrieval (WaitingFiles,
  OpenFile, DeleteFile) instead of direct-write mode.
- Add directFileOps bool field: SetFileOps (Android SAF) sets it true;
  SetStagedFileOps (WASM JS) leaves it false. onChangeProfile now uses
  `fops != nil && e.directFileOps` to determine DirectFileMode.
- Add jsCallVoid to jsFileOps: void ops (openWriter, write, closeWriter,
  remove) now use cb(err?: string) instead of cb(null, err: string).
- Fix waitingFiles() returning JSON null when no files are waiting:
  normalise nil slice to empty slice before marshalling.
- Update wireTaildropFileOps to call SetStagedFileOps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 22:48:11 +00:00
codinget b04b4f7751 feat(tsconnect): expose exit node selection to JS
Add exit node support to the wasm JS bridge:

- Include `exitNodeOption` and `stableNodeID` on each peer in the
  notifyNetMap payload so callers can identify which peers are exit
  nodes and reference them by stable ID.
- Call `notifyExitNode(stableNodeID)` whenever prefs change, so
  callers can track which exit node (if any) is currently active.
- Expose `setExitNode(stableNodeID)` — sets ExitNodeID via EditPrefs.
- Expose `setExitNodeEnabled(enabled)` — toggles the last-used exit
  node on/off via SetUseExitNodeEnabled.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:43:01 +00:00
codinget f961db8925 feat(tsconnect): add TCP listening to ipn.listen
Extend ipn.listen to also accept "tcp"/"tcp4"/"tcp6" and return a
TCPListener bound to a netstack gonet.TCPListener. The listener
exposes accept/close/addr like a Go net.Listener and additionally
implements Symbol.asyncIterator so JS callers can write:

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

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

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

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

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

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

Binary I/O uses js.CopyBytesToGo / js.CopyBytesToJS to move bytes
across the syscall/js boundary without base64 round-trips.
2026-04-10 13:57:15 +00:00
codinget 68670f938b fix(tsconnect): drop nethttpomithttp2 build tag
After 1d93bdce2 ("control/controlclient: remove x/net/http2, use
net/http"), the noise control client uses net/http's Transport with
Protocols.SetUnencryptedHTTP2(true). The nethttpomithttp2 build tag
strips the bundled HTTP/2 implementation from net/http, so at runtime
the control client fails the first register request with "http:
Transport does not support unencrypted HTTP/2" and the wasm never
connects.

Drop the tag so the bundled HTTP/2 ships in the wasm binary.
2026-04-10 13:56:59 +00:00
10 changed files with 908 additions and 10 deletions
+1 -1
View File
@@ -228,7 +228,7 @@ func buildWasm(dev bool) ([]byte, error) {
// to fail for unclosed files.
defer outputFile.Close()
args := []string{"build", "-tags", "tailscale_go,osusergo,netgo,nethttpomithttp2,omitidna,omitpemdecrypt"}
args := []string{"build", "-tags", "tailscale_go,osusergo,netgo,omitidna,omitpemdecrypt"}
if !dev {
if *devControl != "" {
return nil, fmt.Errorf("Development control URL can only be used in dev mode.")
+2 -1
View File
@@ -8,7 +8,8 @@
"forceConsistentCasingInFileNames": true,
"sourceMap": true,
"jsx": "react-jsx",
"jsxImportSource": "preact"
"jsxImportSource": "preact",
"types": ["golang-wasm-exec", "qrcode"]
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
+405
View File
@@ -0,0 +1,405 @@
// 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 (
"bytes"
"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 data as filename to the peer identified by stableNodeID,
// reporting progress via notifyOutgoingFiles callbacks roughly once per second.
func (i *jsIPN) sendFile(stableNodeID, filename string, data js.Value) 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)
}
b := make([]byte, data.Get("byteLength").Int())
js.CopyBytesToGo(b, data)
outgoing := &ipn.OutgoingFile{
ID: rands.HexString(30),
PeerID: tailcfg.StableNodeID(stableNodeID),
Name: filename,
DeclaredSize: int64(len(b)),
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)
}()
body := progresstracking.NewReader(bytes.NewReader(b), 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), body)
if err != nil {
sendErr = err
return nil, err
}
req.ContentLength = int64(len(b))
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)
sendErr = fmt.Errorf("send file: %s: %s", resp.Status, bytes.TrimSpace(respBody))
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 Uint8Array.
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
}
defer rc.Close()
data, err := io.ReadAll(rc)
if err != nil {
return nil, err
}
buf := js.Global().Get("Uint8Array").New(len(data))
js.CopyBytesToJS(buf, data)
return buf, 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})
}
// 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
}
b := make([]byte, val.Get("byteLength").Int())
js.CopyBytesToGo(b, val)
return io.NopCloser(bytes.NewReader(b)), 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 }
+447 -2
View File
@@ -12,19 +12,29 @@ package main
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"math/rand/v2"
"net"
"net/http"
"net/netip"
"strconv"
"strings"
"syscall/js"
"time"
"golang.org/x/crypto/ssh"
"gvisor.dev/gvisor/pkg/tcpip"
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
"gvisor.dev/gvisor/pkg/tcpip/network/ipv4"
"gvisor.dev/gvisor/pkg/tcpip/network/ipv6"
"gvisor.dev/gvisor/pkg/tcpip/transport/icmp"
"gvisor.dev/gvisor/pkg/waiter"
"tailscale.com/control/controlclient"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnauth"
@@ -33,7 +43,9 @@ import (
"tailscale.com/ipn/store/mem"
"tailscale.com/logpolicy"
"tailscale.com/logtail"
"tailscale.com/net/bakedroots"
"tailscale.com/net/netns"
"tailscale.com/net/tsaddr"
"tailscale.com/net/tsdial"
"tailscale.com/safesocket"
"tailscale.com/tailcfg"
@@ -148,12 +160,14 @@ func newIPN(jsConfig js.Value) map[string]any {
if err := ns.Start(lb); err != nil {
log.Fatalf("failed to start netstack: %v", err)
}
wireTaildropFileOps(lb, jsConfig.Get("fileOps"))
srv.SetLocalBackend(lb)
jsIPN := &jsIPN{
dialer: dialer,
srv: srv,
lb: lb,
ns: ns,
controlURL: controlURL,
authKey: authKey,
hostname: hostname,
@@ -208,6 +222,79 @@ func newIPN(jsConfig js.Value) map[string]any {
url := args[0].String()
return jsIPN.fetch(url)
}),
"dial": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 2 {
log.Printf("Usage: dial(network, addr)")
return nil
}
return jsIPN.dial(args[0].String(), args[1].String())
}),
"listen": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 2 {
log.Printf("Usage: listen(network, addr)")
return nil
}
return jsIPN.listen(args[0].String(), args[1].String())
}),
"listenICMP": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 1 {
log.Printf("Usage: listenICMP(network)")
return nil
}
return jsIPN.listenICMP(args[0].String())
}),
"dialTLS": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) < 1 || len(args) > 2 {
log.Printf("Usage: dialTLS(addr, opts?)")
return nil
}
var opts js.Value
if len(args) == 2 {
opts = args[1]
}
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) != 3 {
log.Printf("Usage: sendFile(stableNodeID, filename, data)")
return nil
}
return jsIPN.sendFile(args[0].String(), args[1].String(), args[2])
}),
"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())
}),
}
}
@@ -215,6 +302,7 @@ type jsIPN struct {
dialer *tsdial.Dialer
srv *ipnserver.Server
lb *ipnlocal.LocalBackend
ns *netstack.Impl
controlURL string
authKey string
hostname string
@@ -288,6 +376,8 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
},
Online: p.Online().Clone(),
TailscaleSSHEnabled: p.Hostinfo().TailscaleSSHEnabled(),
ExitNodeOption: tsaddr.ContainsExitRoutes(p.AllowedIPs()),
StableNodeID: string(p.StableID()),
}
}),
LockedOut: nm.TKAEnabled && nm.SelfNode.KeySignature().Len() == 0,
@@ -298,9 +388,52 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
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 {
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() {
@@ -531,6 +664,293 @@ 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 {
return makePromise(func() (any, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
conn, err := i.dialer.UserDial(ctx, network, addr)
if err != nil {
return nil, err
}
return wrapConn(conn), nil
})
}
func (i *jsIPN) listen(network, addr string) js.Value {
return makePromise(func() (any, error) {
switch network {
case "tcp", "tcp4", "tcp6":
// netstack.ListenTCP only accepts tcp4/tcp6; bare "tcp"
// defaults to IPv4 to match net.Listen's typical behavior
// when given an unspecified address.
n := network
if n == "tcp" {
n = "tcp4"
}
ln, err := i.ns.ListenTCP(n, addr)
if err != nil {
return nil, err
}
return wrapTCPListener(ln), nil
case "udp", "udp4", "udp6":
pc, err := i.ns.ListenPacket(network, addr)
if err != nil {
return nil, err
}
return wrapPacketConn(pc), nil
default:
return nil, fmt.Errorf("unsupported network %q", network)
}
})
}
func (i *jsIPN) dialTLS(addr string, opts js.Value) js.Value {
return makePromise(func() (any, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
host, _, err := net.SplitHostPort(addr)
if err != nil {
return nil, fmt.Errorf("invalid address %q: %w", addr, err)
}
// On wasm there's no system root pool, so default to the
// baked-in LetsEncrypt roots (which is what `tailscale cert`
// uses for tailnet HTTPS endpoints). Callers can override with
// caCerts (PEM) or bypass entirely with insecureSkipVerify.
cfg := &tls.Config{
ServerName: host,
RootCAs: bakedroots.Get(),
}
if !opts.IsUndefined() && !opts.IsNull() {
if sn := opts.Get("serverName"); sn.Type() == js.TypeString {
cfg.ServerName = sn.String()
}
if iv := opts.Get("insecureSkipVerify"); iv.Type() == js.TypeBoolean {
cfg.InsecureSkipVerify = iv.Bool()
}
if ca := opts.Get("caCerts"); ca.Type() == js.TypeString {
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM([]byte(ca.String())) {
return nil, fmt.Errorf("caCerts: no valid PEM certificates found")
}
cfg.RootCAs = pool
}
}
rawConn, err := i.dialer.UserDial(ctx, "tcp", addr)
if err != nil {
return nil, err
}
tlsConn := tls.Client(rawConn, cfg)
if err := tlsConn.HandshakeContext(ctx); err != nil {
rawConn.Close()
return nil, err
}
return wrapConn(tlsConn), nil
})
}
func (i *jsIPN) listenICMP(network string) js.Value {
return makePromise(func() (any, error) {
var transportProto tcpip.TransportProtocolNumber
var networkProto tcpip.NetworkProtocolNumber
switch network {
case "icmp4", "icmp":
transportProto = icmp.ProtocolNumber4
networkProto = ipv4.ProtocolNumber
case "icmp6":
transportProto = icmp.ProtocolNumber6
networkProto = ipv6.ProtocolNumber
default:
return nil, fmt.Errorf("unsupported network %q (use \"icmp4\" or \"icmp6\")", network)
}
st := i.ns.Stack()
var wq waiter.Queue
ep, nserr := st.NewEndpoint(transportProto, networkProto, &wq)
if nserr != nil {
return nil, fmt.Errorf("creating ICMP endpoint: %v", nserr)
}
pc := gonet.NewUDPConn(&wq, ep)
return wrapPacketConn(pc), nil
})
}
// wrapConn exposes a net.Conn to JavaScript with binary (Uint8Array) I/O.
func wrapConn(conn net.Conn) map[string]any {
return map[string]any{
"read": js.FuncOf(func(this js.Value, args []js.Value) any {
return makePromise(func() (any, error) {
buf := make([]byte, 65536)
n, err := conn.Read(buf)
if err != nil {
return nil, err
}
arr := js.Global().Get("Uint8Array").New(n)
js.CopyBytesToJS(arr, buf[:n])
return arr, nil
})
}),
"write": js.FuncOf(func(this js.Value, args []js.Value) any {
return makePromise(func() (any, error) {
data := args[0]
buf := make([]byte, data.Get("length").Int())
js.CopyBytesToGo(buf, data)
n, err := conn.Write(buf)
if err != nil {
return nil, err
}
return n, nil
})
}),
"close": js.FuncOf(func(this js.Value, args []js.Value) any {
return conn.Close() != nil
}),
"localAddr": js.FuncOf(func(this js.Value, args []js.Value) any {
return conn.LocalAddr().String()
}),
"remoteAddr": js.FuncOf(func(this js.Value, args []js.Value) any {
return conn.RemoteAddr().String()
}),
}
}
// wrapTCPListener exposes a net.Listener to JavaScript as an object with
// accept/close/addr methods plus a Symbol.asyncIterator implementation, so
// callers can write `for await (const conn of listener)`.
func wrapTCPListener(ln net.Listener) js.Value {
obj := js.Global().Get("Object").New()
obj.Set("accept", js.FuncOf(func(this js.Value, args []js.Value) any {
return makePromise(func() (any, error) {
conn, err := ln.Accept()
if err != nil {
return nil, err
}
return wrapConn(conn), nil
})
}))
obj.Set("close", js.FuncOf(func(this js.Value, args []js.Value) any {
return ln.Close() != nil
}))
obj.Set("addr", js.FuncOf(func(this js.Value, args []js.Value) any {
return ln.Addr().String()
}))
asyncIterSym := js.Global().Get("Symbol").Get("asyncIterator")
iterFactory := js.FuncOf(func(this js.Value, args []js.Value) any {
iter := js.Global().Get("Object").New()
iter.Set("next", js.FuncOf(func(this js.Value, args []js.Value) any {
return makePromise(func() (any, error) {
conn, err := ln.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) {
return map[string]any{
"value": js.Undefined(),
"done": true,
}, nil
}
return nil, err
}
return map[string]any{
"value": wrapConn(conn),
"done": false,
}, nil
})
}))
return iter
})
js.Global().Get("Reflect").Call("set", obj, asyncIterSym, iterFactory)
return obj
}
// wrapPacketConn exposes a net.PacketConn to JavaScript with binary (Uint8Array) I/O.
func wrapPacketConn(pc net.PacketConn) map[string]any {
return map[string]any{
"readFrom": js.FuncOf(func(this js.Value, args []js.Value) any {
return makePromise(func() (any, error) {
buf := make([]byte, 65536)
n, addr, err := pc.ReadFrom(buf)
if err != nil {
return nil, err
}
arr := js.Global().Get("Uint8Array").New(n)
js.CopyBytesToJS(arr, buf[:n])
return map[string]any{
"data": arr,
"addr": addr.String(),
}, nil
})
}),
"writeTo": js.FuncOf(func(this js.Value, args []js.Value) any {
return makePromise(func() (any, error) {
data := args[0]
addrStr := args[1].String()
buf := make([]byte, data.Get("length").Int())
js.CopyBytesToGo(buf, data)
addr, err := resolveUDPAddr(addrStr)
if err != nil {
return nil, err
}
n, err := pc.WriteTo(buf, addr)
if err != nil {
return nil, err
}
return n, nil
})
}),
"close": js.FuncOf(func(this js.Value, args []js.Value) any {
return pc.Close() != nil
}),
"localAddr": js.FuncOf(func(this js.Value, args []js.Value) any {
return pc.LocalAddr().String()
}),
}
}
// resolveUDPAddr parses an address string that is either "host:port" or just
// an IP (for ICMP, where port defaults to 0).
func resolveUDPAddr(s string) (*net.UDPAddr, error) {
host, portStr, err := net.SplitHostPort(s)
if err != nil {
// Bare IP address without port (used for ICMP).
ip := net.ParseIP(s)
if ip == nil {
return nil, fmt.Errorf("invalid address: %s", s)
}
return &net.UDPAddr{IP: ip}, nil
}
ip := net.ParseIP(host)
if ip == nil {
return nil, fmt.Errorf("invalid IP: %s", host)
}
port, err := strconv.Atoi(portStr)
if err != nil {
return nil, fmt.Errorf("invalid port: %s", portStr)
}
return &net.UDPAddr{IP: ip, Port: port}, nil
}
type termWriter struct {
f js.Value
}
@@ -541,6 +961,29 @@ func (w termWriter) Write(p []byte) (n int, err error) {
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 {
Self jsNetMapSelfNode `json:"self"`
Peers []jsNetMapPeerNode `json:"peers"`
@@ -561,8 +1004,10 @@ type jsNetMapSelfNode struct {
type jsNetMapPeerNode struct {
jsNetMapNode
Online *bool `json:"online,omitempty"`
TailscaleSSHEnabled bool `json:"tailscaleSSHEnabled"`
Online *bool `json:"online,omitempty"`
TailscaleSSHEnabled bool `json:"tailscaleSSHEnabled"`
ExitNodeOption bool `json:"exitNodeOption"`
StableNodeID string `json:"stableNodeID"`
}
type jsStateStore struct {
+16 -2
View File
@@ -76,6 +76,12 @@ type Extension struct {
// This is currently being used for Android to use the Storage Access Framework.
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
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),
// 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
isDirectFileMode := fops != nil
isDirectFileMode := fops != nil && e.directFileOps
if fops == nil {
var fileRoot string
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
}
// 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
// sends an ipn.Notify with the full list of outgoingFiles.
func (e *Extension) updateOutgoingFiles(updates map[string]*ipn.OutgoingFile) {
+1 -1
View File
@@ -1,6 +1,6 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !android
//go:build !android && !js
package taildrop
+16
View File
@@ -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")
}
}
+12 -1
View File
@@ -18,10 +18,21 @@ func (e *Extension) SetDirectFileRoot(root string) {
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.
// 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) {
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() {
+3 -2
View File
@@ -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.
copyLength, err := io.Copy(wc, r)
// Copy via inFile (which wraps wc) so [incomingFile.Write] can track
// progress and fire periodic sendFileNotify callbacks.
copyLength, err := io.Copy(inFile, r)
if err != nil {
return 0, m.redactAndLogError("Copy", err)
}
+5
View File
@@ -280,6 +280,11 @@ type Impl struct {
packetsInFlight map[stack.TransportEndpointID]struct{}
}
// Stack returns the underlying gVisor network stack.
func (ns *Impl) Stack() *stack.Stack {
return ns.ipstack
}
const nicID = 1
// maxUDPPacketSize is the maximum size of a UDP packet we copy in