Compare commits

..

No commits in common. 'webnet' and 'main' have entirely different histories.
webnet ... main

  1. 2
      cmd/tsconnect/common.go
  2. 405
      cmd/tsconnect/wasm/taildrop.go
  3. 449
      cmd/tsconnect/wasm/wasm_js.go
  4. 18
      feature/taildrop/ext.go
  5. 2
      feature/taildrop/fileops_fs.go
  6. 16
      feature/taildrop/fileops_js.go
  7. 13
      feature/taildrop/paths.go
  8. 5
      feature/taildrop/send.go
  9. 5
      wgengine/netstack/netstack.go

@ -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,omitidna,omitpemdecrypt"}
args := []string{"build", "-tags", "tailscale_go,osusergo,netgo,nethttpomithttp2,omitidna,omitpemdecrypt"}
if !dev {
if *devControl != "" {
return nil, fmt.Errorf("Development control URL can only be used in dev mode.")

@ -1,405 +0,0 @@
// 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 }

@ -12,29 +12,19 @@ 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"
@ -43,9 +33,7 @@ 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"
@ -160,14 +148,12 @@ 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,
@ -222,79 +208,6 @@ 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())
}),
}
}
@ -302,7 +215,6 @@ type jsIPN struct {
dialer *tsdial.Dialer
srv *ipnserver.Server
lb *ipnlocal.LocalBackend
ns *netstack.Impl
controlURL string
authKey string
hostname string
@ -376,8 +288,6 @@ 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,
@ -388,52 +298,9 @@ 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() {
@ -664,293 +531,6 @@ 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
}
@ -961,29 +541,6 @@ 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"`
@ -1004,10 +561,8 @@ type jsNetMapSelfNode struct {
type jsNetMapPeerNode struct {
jsNetMapNode
Online *bool `json:"online,omitempty"`
TailscaleSSHEnabled bool `json:"tailscaleSSHEnabled"`
ExitNodeOption bool `json:"exitNodeOption"`
StableNodeID string `json:"stableNodeID"`
Online *bool `json:"online,omitempty"`
TailscaleSSHEnabled bool `json:"tailscaleSSHEnabled"`
}
type jsStateStore struct {

@ -76,12 +76,6 @@ 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
@ -161,10 +155,9 @@ 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] with directFileOps=true implies DirectFileMode (Android SAF).
// A non-nil [FileOps] with directFileOps=false uses staged mode (WASM JS bridge).
// A non-nil [FileOps] also implies that we are in DirectFileMode.
fops := e.fileOps
isDirectFileMode := fops != nil && e.directFileOps
isDirectFileMode := fops != nil
if fops == nil {
var fileRoot string
if fileRoot, isDirectFileMode = e.fileRoot(uid, activeLogin); fileRoot == "" {
@ -418,13 +411,6 @@ 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,6 +1,6 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !android && !js
//go:build !android
package taildrop

@ -1,16 +0,0 @@
// 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,21 +18,10 @@ 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() {

@ -134,9 +134,8 @@ func (m *manager) PutFile(id clientID, baseName string, r io.Reader, offset, len
}
}
// Copy via inFile (which wraps wc) so [incomingFile.Write] can track
// progress and fire periodic sendFileNotify callbacks.
copyLength, err := io.Copy(inFile, r)
// Copy the contents of the file to the writer.
copyLength, err := io.Copy(wc, r)
if err != nil {
return 0, m.redactAndLogError("Copy", err)
}

@ -280,11 +280,6 @@ 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

Loading…
Cancel
Save