From 7c5ecfe50fcf98fa2489ab1114e881023959d2e4 Mon Sep 17 00:00:00 2001 From: Codinget Date: Tue, 9 Jun 2026 20:57:01 +0000 Subject: [PATCH] feat(wasm): expose taildrive WebDAV server and listDrivePeers via JS bridge Add drive.go (build tag !ts_omit_drive): implements drive.FileSystemForRemote with a JS-backed handler. Streams request bodies chunk-by-chunk via readBodyChunk() and response bodies via write()/end() callbacks so no full-body buffering occurs regardless of file size. The handler is nil-safe: returns 404 until setDriveHandler() is called from JS. Add drive_stub.go (build tag ts_omit_drive): no-op stubs for stripped builds. Add peer.go: extract buildPeerAPIURL helper (previously inline in run()). Modify wasm_js.go: call initDriveForRemote before NewLocalBackend (SubSystem is set-once), expose setDriveHandler and listDrivePeers via wireDriveJS, and refactor the inline peerAPI URL logic to use buildPeerAPIURL. listDrivePeers mirrors native driveRemotesFromPeers: returns empty if DriveAccessEnabled() is false, then filters peers by PeerCapabilityTaildriveSharer using lb.PeerCaps(addr).HasCapability() (the live ACL-derived cap map). Co-Authored-By: Claude Sonnet 4.6 --- cmd/tsconnect/wasm/drive.go | 301 +++++++++++++++++++++++++++++++ cmd/tsconnect/wasm/drive_stub.go | 27 +++ cmd/tsconnect/wasm/peer.go | 41 +++++ cmd/tsconnect/wasm/wasm_js.go | 35 +--- 4 files changed, 377 insertions(+), 27 deletions(-) create mode 100644 cmd/tsconnect/wasm/drive.go create mode 100644 cmd/tsconnect/wasm/drive_stub.go create mode 100644 cmd/tsconnect/wasm/peer.go diff --git a/cmd/tsconnect/wasm/drive.go b/cmd/tsconnect/wasm/drive.go new file mode 100644 index 000000000..a0664ed55 --- /dev/null +++ b/cmd/tsconnect/wasm/drive.go @@ -0,0 +1,301 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !ts_omit_drive + +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "sync" + "syscall/js" + + "tailscale.com/drive" + "tailscale.com/tailcfg" + "tailscale.com/tsd" +) + +// Compile-time check that jsFileSystemForRemote implements drive.FileSystemForRemote. +var _ drive.FileSystemForRemote = (*jsFileSystemForRemote)(nil) + +// jsFileSystemForRemote implements drive.FileSystemForRemote by bridging +// incoming WebDAV requests to a JS handler function. Auth and permission +// parsing are handled upstream by handleServeDrive before this is called. +type jsFileSystemForRemote struct { + mu sync.RWMutex + fn js.Value +} + +func (fs *jsFileSystemForRemote) setHandler(fn js.Value) { + fs.mu.Lock() + fs.fn = fn + fs.mu.Unlock() +} + +// SetFileServerAddr is a no-op: the JS handler owns its own storage. +func (fs *jsFileSystemForRemote) SetFileServerAddr(_ string) {} + +// SetShares is a no-op: the JS handler controls which shares it exposes. +func (fs *jsFileSystemForRemote) SetShares(_ []*drive.Share) {} + +// Close is a no-op. +func (fs *jsFileSystemForRemote) Close() error { return nil } + +// ServeHTTPWithPerms handles a WebDAV request by bridging it to the JS handler. +// It streams the request body to JS via readBodyChunk() and streams the +// response body back via write()/end() callbacks, so no full-body buffering +// occurs regardless of file size. +// +// The call blocks until JS calls end() (or a write error occurs). +func (fs *jsFileSystemForRemote) ServeHTTPWithPerms( + perms drive.Permissions, w http.ResponseWriter, r *http.Request, +) { + fs.mu.RLock() + fn := fs.fn + fs.mu.RUnlock() + + if fn.IsUndefined() || fn.IsNull() { + http.NotFound(w, r) + return + } + + // readBodyChunk is exposed to JS as req.readBodyChunk(). + // Each call returns a Promise: null signals EOF. + readBodyChunk := js.FuncOf(func(_ js.Value, _ []js.Value) any { + return makePromise(func() (any, error) { + buf := make([]byte, 65536) + n, err := r.Body.Read(buf) + if n > 0 { + arr := js.Global().Get("Uint8Array").New(n) + js.CopyBytesToJS(arr, buf[:n]) + return arr, nil + } + if errors.Is(err, io.EOF) { + return js.Null(), nil + } + return nil, err + }) + }) + + // doneCh receives nil when JS calls end(), or a write error if Write fails. + doneCh := make(chan error, 1) + + // writeHead sets response headers and status code. Must be called before write(). + writeHead := js.FuncOf(func(_ js.Value, args []js.Value) any { + if len(args) < 1 { + return nil + } + status := args[0].Int() + if len(args) > 1 && !args[1].IsUndefined() && !args[1].IsNull() { + for k, vs := range jsHeadersToGo(args[1]) { + for _, v := range vs { + w.Header().Add(k, v) + } + } + } + w.WriteHeader(status) + return nil + }) + + // write streams a single response body chunk to the client. + write := js.FuncOf(func(_ js.Value, args []js.Value) any { + if len(args) < 1 { + return nil + } + data := args[0] + buf := make([]byte, data.Get("length").Int()) + js.CopyBytesToGo(buf, data) + if _, werr := w.Write(buf); werr != nil { + select { + case doneCh <- werr: + default: + } + return nil + } + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + return nil + }) + + // end signals that the response is complete. + end := js.FuncOf(func(_ js.Value, _ []js.Value) any { + select { + case doneCh <- nil: + default: + } + return nil + }) + + defer func() { + readBodyChunk.Release() + writeHead.Release() + write.Release() + end.Release() + }() + + jsReq := map[string]any{ + "method": r.Method, + "path": r.URL.Path, + "rawQuery": r.URL.RawQuery, + "headers": goHeadersToJS(r.Header), + "readBodyChunk": readBodyChunk, + } + jsRes := map[string]any{ + "writeHead": writeHead, + "write": write, + "end": end, + } + + fn.Invoke(jsReq, jsRes, drivePermsToJS(perms)) + + // Block this goroutine until JS calls end() or a write error occurs. + // The Go WASM scheduler yields back to JS while we wait. + <-doneCh +} + +// drivePermsToJS converts drive.Permissions to a plain JS-friendly object. +// Each share name maps to a numeric permission: 0=none, 1=read-only, 2=read-write. +// The wildcard share name "*" is included if present. +func drivePermsToJS(p drive.Permissions) map[string]any { + result := make(map[string]any, len(p)) + for name, perm := range p { + result[name] = int(perm) + } + return result +} + +// goHeadersToJS converts an http.Header to a map[string]any suitable for JS. +// Single-value headers become a string; multi-value headers become a []any. +func goHeadersToJS(h http.Header) map[string]any { + result := make(map[string]any, len(h)) + for k, vs := range h { + if len(vs) == 1 { + result[k] = vs[0] + } else { + arr := make([]any, len(vs)) + for i, v := range vs { + arr[i] = v + } + result[k] = arr + } + } + return result +} + +// jsHeadersToGo parses a JS headers object into an http.Header map. +// Values may be a string or an array of strings. +func jsHeadersToGo(jsHeaders js.Value) http.Header { + h := make(http.Header) + keys := js.Global().Get("Object").Call("keys", jsHeaders) + for i := 0; i < keys.Length(); i++ { + key := keys.Index(i).String() + val := jsHeaders.Get(key) + switch val.Type() { + case js.TypeString: + h.Set(key, val.String()) + case js.TypeObject: + if val.InstanceOf(js.Global().Get("Array")) { + for j := 0; j < val.Length(); j++ { + h.Add(key, val.Index(j).String()) + } + } + } + } + return h +} + +// initDriveForRemote creates the JS-backed FileSystemForRemote and registers +// it with sys. Must be called before NewLocalBackend (SubSystem is set-once). +func initDriveForRemote(sys *tsd.System) *jsFileSystemForRemote { + driveFS := &jsFileSystemForRemote{} + sys.Set(driveFS) + return driveFS +} + +// wireDriveJS adds drive-related methods to the IPN JS methods map. +// driveFS must be the value returned by initDriveForRemote. +func wireDriveJS(i *jsIPN, driveFS *jsFileSystemForRemote, m map[string]any) { + m["setDriveHandler"] = js.FuncOf(func(_ js.Value, args []js.Value) any { + if len(args) < 1 { + return nil + } + driveFS.setHandler(args[0]) + return nil + }) + + m["listDrivePeers"] = js.FuncOf(func(_ js.Value, _ []js.Value) any { + return i.listDrivePeers() + }) +} + +type jsDrivePeer struct { + Name string `json:"name"` + PeerAPIURL string `json:"peerAPIURL"` + StableNodeID string `json:"stableNodeID"` + Online *bool `json:"online,omitempty"` +} + +// listDrivePeers returns a JSON array of peers that carry +// PeerCapabilityTaildriveSharer. Returns an empty array if the local node +// does not have drive:access in its ACL (DriveAccessEnabled). This mirrors +// the filtering in LocalBackend.driveRemotesFromPeers. +func (i *jsIPN) listDrivePeers() js.Value { + return makePromise(func() (any, error) { + if !i.lb.DriveAccessEnabled() { + return "[]", nil + } + + nm := i.lb.NetMap() + if nm == nil { + return nil, errors.New("listDrivePeers: no network map available") + } + + var selfHave4, selfHave6 bool + for _, a := range nm.GetAddresses().All() { + if !a.IsSingleIP() { + continue + } + if a.Addr().Is4() { + selfHave4 = true + } else if a.Addr().Is6() { + selfHave6 = true + } + } + + peers := make([]jsDrivePeer, 0) + for _, p := range nm.Peers { + // Check PeerCapabilityTaildriveSharer via the live PeerCaps map + // (derived from ACL rules), mirroring driveRemotesFromPeers. + hasCap := false + for _, a := range p.Addresses().All() { + if a.IsSingleIP() && i.lb.PeerCaps(a.Addr()).HasCapability(tailcfg.PeerCapabilityTaildriveSharer) { + hasCap = true + break + } + } + if !hasCap { + continue + } + peerURL := buildPeerAPIURL(p, selfHave4, selfHave6) + online := p.Online().Clone() + peers = append(peers, jsDrivePeer{ + Name: p.DisplayName(false), + PeerAPIURL: peerURL, + StableNodeID: string(p.StableID()), + Online: online, + }) + } + + b, err := json.Marshal(peers) + if err != nil { + return nil, fmt.Errorf("listDrivePeers: marshal: %w", err) + } + return string(b), nil + }) +} + diff --git a/cmd/tsconnect/wasm/drive_stub.go b/cmd/tsconnect/wasm/drive_stub.go new file mode 100644 index 000000000..a9f3101d7 --- /dev/null +++ b/cmd/tsconnect/wasm/drive_stub.go @@ -0,0 +1,27 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build ts_omit_drive + +package main + +import ( + "syscall/js" + + "tailscale.com/tsd" +) + +type jsFileSystemForRemote struct{} + +// initDriveForRemote is a no-op when the drive feature is omitted. +func initDriveForRemote(_ *tsd.System) *jsFileSystemForRemote { return nil } + +// wireDriveJS is a no-op when the drive feature is omitted. +func wireDriveJS(_ *jsIPN, _ *jsFileSystemForRemote, _ map[string]any) {} + +// listDrivePeers returns an empty list when the drive feature is omitted. +func (i *jsIPN) listDrivePeers() js.Value { + return makePromise(func() (any, error) { + return "[]", nil + }) +} diff --git a/cmd/tsconnect/wasm/peer.go b/cmd/tsconnect/wasm/peer.go new file mode 100644 index 000000000..341404bfe --- /dev/null +++ b/cmd/tsconnect/wasm/peer.go @@ -0,0 +1,41 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package main + +import ( + "fmt" + "net/netip" + + "tailscale.com/tailcfg" +) + +// buildPeerAPIURL returns the HTTP base URL for a peer's peerAPI server, +// selecting IPv4 when available and falling back to IPv6. Returns an empty +// string if the peer advertises no reachable peerAPI port. +func buildPeerAPIURL(p tailcfg.NodeView, selfHave4, selfHave6 bool) string { + var pp4, pp6 uint16 + for _, s := range p.Hostinfo().Services().All() { + switch s.Proto { + case tailcfg.PeerAPI4: + pp4 = s.Port + case tailcfg.PeerAPI6: + pp6 = s.Port + } + } + if selfHave4 && pp4 != 0 { + for _, a := range p.Addresses().All() { + if a.IsSingleIP() && a.Addr().Is4() { + return fmt.Sprintf("http://%v", netip.AddrPortFrom(a.Addr(), pp4)) + } + } + } + if selfHave6 && pp6 != 0 { + for _, a := range p.Addresses().All() { + if a.IsSingleIP() && a.Addr().Is6() { + return fmt.Sprintf("http://%v", netip.AddrPortFrom(a.Addr(), pp6)) + } + } + } + return "" +} diff --git a/cmd/tsconnect/wasm/wasm_js.go b/cmd/tsconnect/wasm/wasm_js.go index d37921211..be507997b 100644 --- a/cmd/tsconnect/wasm/wasm_js.go +++ b/cmd/tsconnect/wasm/wasm_js.go @@ -158,6 +158,10 @@ func newIPN(jsConfig js.Value) map[string]any { sys.Tun.Get().Start() logid := lpc.PublicID + + // initDriveForRemote must be called before NewLocalBackend (SubSystem is set-once). + driveFS := initDriveForRemote(sys) + srv := ipnserver.New(logf, logid, sys.Bus.Get(), sys.NetMon.Get()) lb, err := ipnlocal.NewLocalBackend(logf, logid, sys, controlclient.LoginEphemeral) if err != nil { @@ -182,7 +186,7 @@ func newIPN(jsConfig js.Value) map[string]any { } lb.SetTCPHandlerForFunnelFlow(jsIPN.handleFunnelTCP) - return map[string]any{ + m := map[string]any{ "run": js.FuncOf(func(this js.Value, args []js.Value) any { if len(args) != 1 { log.Fatal(`Usage: run({ @@ -373,6 +377,8 @@ func newIPN(jsConfig js.Value) map[string]any { return jsIPN.localAPI(args[0].String(), args[1].String(), body) }), } + wireDriveJS(jsIPN, driveFS, m) + return m } type jsIPN struct { @@ -482,32 +488,7 @@ func (i *jsIPN) run(jsCallbacks js.Value) { } // 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 - } - } - } + peerURL := buildPeerAPIURL(p, selfHave4, selfHave6) return jsNetMapPeerNode{ jsNetMapNode: jsNetMapNode{ -- 2.52.0