Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c5ecfe50f | |||
| 0df765eb60 | |||
| 52cae45f81 | |||
| 7fd2507611 | |||
| 8514045909 | |||
| 7f5983eaab | |||
| 143581c955 | |||
| d9efc3bae2 | |||
| 9e36a7f27f | |||
| 8277fc0f1d | |||
| e32520659d | |||
| e8eb9d71c2 | |||
| c4ff4c4835 | |||
| 68ecc4b033 | |||
| 9f96b7434c | |||
| b04b4f7751 | |||
| f961db8925 | |||
| fde5f11895 | |||
| 756ba1d5ec | |||
| 68670f938b |
@@ -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 {
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ func buildWasm(dev bool) ([]byte, error) {
|
|||||||
// to fail for unclosed files.
|
// to fail for unclosed files.
|
||||||
defer outputFile.Close()
|
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 !dev {
|
||||||
if *devControl != "" {
|
if *devControl != "" {
|
||||||
return nil, fmt.Errorf("Development control URL can only be used in dev mode.")
|
return nil, fmt.Errorf("Development control URL can only be used in dev mode.")
|
||||||
|
|||||||
@@ -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,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<Uint8Array|null>: 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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
+1004
-3
File diff suppressed because it is too large
Load Diff
+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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -280,6 +280,11 @@ type Impl struct {
|
|||||||
packetsInFlight map[stack.TransportEndpointID]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
|
const nicID = 1
|
||||||
|
|
||||||
// maxUDPPacketSize is the maximum size of a UDP packet we copy in
|
// maxUDPPacketSize is the maximum size of a UDP packet we copy in
|
||||||
|
|||||||
Reference in New Issue
Block a user