Compare commits
16 Commits
9e36a7f27f
...
webnet
| Author | SHA1 | Date | |
|---|---|---|---|
| cada6936b9 | |||
| 78c4511a3d | |||
| 3a9f6f463a | |||
| 7bfc64c379 | |||
| e7270026f7 | |||
| 4618ee1496 | |||
| 915dca44fe | |||
| 6e83d5291b | |||
| 21d0f11d85 | |||
| 0df765eb60 | |||
| 52cae45f81 | |||
| 7fd2507611 | |||
| 8514045909 | |||
| 7f5983eaab | |||
| 143581c955 | |||
| d9efc3bae2 |
@@ -13,7 +13,6 @@ import (
|
||||
"path"
|
||||
|
||||
"github.com/tailscale/hujson"
|
||||
"tailscale.com/util/precompress"
|
||||
"tailscale.com/version"
|
||||
)
|
||||
|
||||
@@ -39,10 +38,6 @@ func runBuildPkg() {
|
||||
|
||||
runEsbuild(*buildOptions)
|
||||
|
||||
if err := precompressWasm(); err != nil {
|
||||
log.Fatalf("Could not pre-recompress wasm: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("Generating types...\n")
|
||||
if err := runYarn("pkg-types"); err != nil {
|
||||
log.Fatalf("Type generation failed: %v", err)
|
||||
@@ -59,13 +54,6 @@ func runBuildPkg() {
|
||||
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 {
|
||||
packageJSONBytes, err := os.ReadFile("package.json.tmpl")
|
||||
if err != nil {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"sourceMap": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"skipLibCheck": true
|
||||
"types": ["golang-wasm-exec", "qrcode"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"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 ""
|
||||
}
|
||||
+127
-22
@@ -9,7 +9,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -81,9 +80,11 @@ func (i *jsIPN) listFileTargets() js.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// sendFile sends data as filename to the peer identified by stableNodeID,
|
||||
// sendFile sends stream 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 {
|
||||
// declaredSize is the total byte count (-1 if unknown); it is used for progress
|
||||
// reporting and sets Content-Length on the PUT request (chunked TE when -1).
|
||||
func (i *jsIPN) sendFile(stableNodeID, filename string, stream js.Value, declaredSize int) js.Value {
|
||||
return makePromise(func() (any, error) {
|
||||
ext, err := i.taildropExt()
|
||||
if err != nil {
|
||||
@@ -107,14 +108,15 @@ func (i *jsIPN) sendFile(stableNodeID, filename string, data js.Value) js.Value
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bogus peer URL: %w", err)
|
||||
}
|
||||
b := make([]byte, data.Get("byteLength").Int())
|
||||
js.CopyBytesToGo(b, data)
|
||||
|
||||
reader := stream.Call("getReader")
|
||||
body := &jsStreamReader{reader: reader}
|
||||
|
||||
outgoing := &ipn.OutgoingFile{
|
||||
ID: rands.HexString(30),
|
||||
PeerID: tailcfg.StableNodeID(stableNodeID),
|
||||
Name: filename,
|
||||
DeclaredSize: int64(len(b)),
|
||||
DeclaredSize: int64(declaredSize),
|
||||
Started: time.Now(),
|
||||
}
|
||||
updates := map[string]*ipn.OutgoingFile{outgoing.ID: outgoing}
|
||||
@@ -127,17 +129,17 @@ func (i *jsIPN) sendFile(stableNodeID, filename string, data js.Value) js.Value
|
||||
ext.UpdateOutgoingFiles(updates)
|
||||
}()
|
||||
|
||||
body := progresstracking.NewReader(bytes.NewReader(b), time.Second, func(n int, _ error) {
|
||||
progressBody := progresstracking.NewReader(body, time.Second, func(n int, _ error) {
|
||||
outgoing.Sent = int64(n)
|
||||
ext.UpdateOutgoingFiles(updates)
|
||||
})
|
||||
|
||||
req, err := http.NewRequest("PUT", dstURL.String()+"/v0/put/"+url.PathEscape(filename), body)
|
||||
req, err := http.NewRequest("PUT", dstURL.String()+"/v0/put/"+url.PathEscape(filename), progressBody)
|
||||
if err != nil {
|
||||
sendErr = err
|
||||
return nil, err
|
||||
}
|
||||
req.ContentLength = int64(len(b))
|
||||
req.ContentLength = int64(declaredSize)
|
||||
client := &http.Client{Transport: i.lb.Dialer().PeerAPITransport()}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
@@ -147,7 +149,13 @@ func (i *jsIPN) sendFile(stableNodeID, filename string, data js.Value) js.Value
|
||||
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))
|
||||
b := make([]byte, len(respBody))
|
||||
copy(b, respBody)
|
||||
// trim trailing whitespace
|
||||
for len(b) > 0 && (b[len(b)-1] == '\n' || b[len(b)-1] == '\r' || b[len(b)-1] == ' ') {
|
||||
b = b[:len(b)-1]
|
||||
}
|
||||
sendErr = fmt.Errorf("send file: %s: %s", resp.Status, b)
|
||||
return nil, sendErr
|
||||
}
|
||||
return nil, nil
|
||||
@@ -182,7 +190,8 @@ func (i *jsIPN) waitingFiles() js.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// openWaitingFile returns the contents of a received file as a Uint8Array.
|
||||
// openWaitingFile returns the contents of a received file as a ReadableStream.
|
||||
// The stream emits Uint8Array chunks and closes when the file is fully read.
|
||||
func (i *jsIPN) openWaitingFile(name string) js.Value {
|
||||
return makePromise(func() (any, error) {
|
||||
ext, err := i.taildropExt()
|
||||
@@ -193,14 +202,7 @@ func (i *jsIPN) openWaitingFile(name string) js.Value {
|
||||
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
|
||||
return jsReadableStream(rc), nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -234,6 +236,109 @@ func wireTaildropFileOps(lb *ipnlocal.LocalBackend, jsObj js.Value) {
|
||||
ext.SetStagedFileOps(&jsFileOps{v: jsObj})
|
||||
}
|
||||
|
||||
// jsStreamReader implements io.ReadCloser by pulling chunks from a JS
|
||||
// ReadableStreamDefaultReader. Each Read call awaits one reader.read() Promise,
|
||||
// using the channel+FuncOf pattern so Go blocks until JS delivers the chunk.
|
||||
type jsStreamReader struct {
|
||||
reader js.Value
|
||||
buf []byte
|
||||
done bool
|
||||
}
|
||||
|
||||
func (r *jsStreamReader) Read(p []byte) (int, error) {
|
||||
if r.done {
|
||||
return 0, io.EOF
|
||||
}
|
||||
if len(r.buf) > 0 {
|
||||
n := copy(p, r.buf)
|
||||
r.buf = r.buf[n:]
|
||||
return n, nil
|
||||
}
|
||||
type chunkResult struct {
|
||||
data []byte
|
||||
done bool
|
||||
}
|
||||
ch := make(chan chunkResult, 1)
|
||||
thenFn := js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
result := args[0]
|
||||
if result.Get("done").Bool() {
|
||||
ch <- chunkResult{done: true}
|
||||
} else {
|
||||
value := result.Get("value")
|
||||
b := make([]byte, value.Get("byteLength").Int())
|
||||
js.CopyBytesToGo(b, value)
|
||||
ch <- chunkResult{data: b}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
defer thenFn.Release()
|
||||
r.reader.Call("read").Call("then", thenFn)
|
||||
result := <-ch
|
||||
if result.done {
|
||||
r.done = true
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := copy(p, result.data)
|
||||
r.buf = result.data[n:]
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (r *jsStreamReader) Close() error {
|
||||
r.reader.Call("cancel")
|
||||
return nil
|
||||
}
|
||||
|
||||
// jsReadableStream wraps rc in a pull-based JS ReadableStream. Each pull call
|
||||
// reads up to 64 KiB from rc and enqueues a Uint8Array chunk; the stream
|
||||
// closes on EOF or signals an error on any other read failure.
|
||||
func jsReadableStream(rc io.ReadCloser) js.Value {
|
||||
var pullFn, cancelFn js.Func
|
||||
|
||||
cancelFn = js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
rc.Close()
|
||||
pullFn.Release()
|
||||
cancelFn.Release()
|
||||
return nil
|
||||
})
|
||||
|
||||
pullFn = js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
controller := args[0]
|
||||
var execFn js.Func
|
||||
execFn = js.FuncOf(func(this js.Value, rr []js.Value) any {
|
||||
resolve := rr[0]
|
||||
go func() {
|
||||
defer execFn.Release()
|
||||
buf := make([]byte, 65536)
|
||||
n, err := rc.Read(buf)
|
||||
if n > 0 {
|
||||
chunk := js.Global().Get("Uint8Array").New(n)
|
||||
js.CopyBytesToJS(chunk, buf[:n])
|
||||
controller.Call("enqueue", chunk)
|
||||
}
|
||||
if err == io.EOF {
|
||||
rc.Close()
|
||||
pullFn.Release()
|
||||
cancelFn.Release()
|
||||
controller.Call("close")
|
||||
} else if err != nil {
|
||||
rc.Close()
|
||||
pullFn.Release()
|
||||
cancelFn.Release()
|
||||
controller.Call("error", err.Error())
|
||||
}
|
||||
resolve.Invoke()
|
||||
}()
|
||||
return nil
|
||||
})
|
||||
return js.Global().Get("Promise").New(execFn)
|
||||
})
|
||||
|
||||
return js.Global().Get("ReadableStream").New(map[string]any{
|
||||
"pull": pullFn,
|
||||
"cancel": cancelFn,
|
||||
})
|
||||
}
|
||||
|
||||
// jsFileOps implements [taildrop.FileOps] by delegating to JS callbacks.
|
||||
// JS methods use one of two callback conventions:
|
||||
//
|
||||
@@ -385,9 +490,9 @@ func (j jsFileOps) OpenReader(name string) (io.ReadCloser, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b := make([]byte, val.Get("byteLength").Int())
|
||||
js.CopyBytesToGo(b, val)
|
||||
return io.NopCloser(bytes.NewReader(b)), nil
|
||||
// val is a ReadableStream; wrap its reader for streaming delivery to Go.
|
||||
reader := val.Call("getReader")
|
||||
return &jsStreamReader{reader: reader}, nil
|
||||
}
|
||||
|
||||
// jsFileInfo is a minimal [fs.FileInfo] backed by a name and a size.
|
||||
|
||||
+668
-29
@@ -18,17 +18,21 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall/js"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
"gvisor.dev/gvisor/pkg/tcpip"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/network/ipv4"
|
||||
@@ -40,6 +44,7 @@ import (
|
||||
"tailscale.com/ipn/ipnauth"
|
||||
"tailscale.com/ipn/ipnlocal"
|
||||
"tailscale.com/ipn/ipnserver"
|
||||
"tailscale.com/ipn/localapi"
|
||||
"tailscale.com/ipn/store/mem"
|
||||
"tailscale.com/logpolicy"
|
||||
"tailscale.com/logtail"
|
||||
@@ -50,6 +55,7 @@ import (
|
||||
"tailscale.com/safesocket"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tsd"
|
||||
"tailscale.com/types/logid"
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/wgengine"
|
||||
"tailscale.com/wgengine/netstack"
|
||||
@@ -60,19 +66,20 @@ import (
|
||||
var ControlURL = ipn.DefaultControlURL
|
||||
|
||||
func main() {
|
||||
shutdownCh := make(chan struct{})
|
||||
js.Global().Set("newIPN", js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
if len(args) != 1 {
|
||||
log.Fatal("Usage: newIPN(config)")
|
||||
return nil
|
||||
}
|
||||
return newIPN(args[0])
|
||||
return newIPN(args[0], shutdownCh)
|
||||
}))
|
||||
// Keep Go runtime alive, otherwise it will be shut down before newIPN gets
|
||||
// called.
|
||||
<-make(chan bool)
|
||||
// Block until shutdown() is called on the IPN, then let main return so the
|
||||
// Go runtime (and all its goroutines) can be collected by the JS engine.
|
||||
<-shutdownCh
|
||||
}
|
||||
|
||||
func newIPN(jsConfig js.Value) map[string]any {
|
||||
func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
|
||||
netns.SetEnabled(false)
|
||||
|
||||
var store ipn.StateStore
|
||||
@@ -152,6 +159,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 {
|
||||
@@ -164,16 +175,20 @@ func newIPN(jsConfig js.Value) map[string]any {
|
||||
srv.SetLocalBackend(lb)
|
||||
|
||||
jsIPN := &jsIPN{
|
||||
dialer: dialer,
|
||||
srv: srv,
|
||||
lb: lb,
|
||||
ns: ns,
|
||||
controlURL: controlURL,
|
||||
authKey: authKey,
|
||||
hostname: hostname,
|
||||
dialer: dialer,
|
||||
srv: srv,
|
||||
lb: lb,
|
||||
ns: ns,
|
||||
controlURL: controlURL,
|
||||
authKey: authKey,
|
||||
hostname: hostname,
|
||||
logID: logid,
|
||||
funnelPorts: make(map[uint16]*funnelListenerEntry),
|
||||
shutdownCh: shutdownCh,
|
||||
}
|
||||
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({
|
||||
@@ -272,11 +287,11 @@ func newIPN(jsConfig js.Value) map[string]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)")
|
||||
if len(args) != 4 {
|
||||
log.Printf("Usage: sendFile(stableNodeID, filename, stream, declaredSize)")
|
||||
return nil
|
||||
}
|
||||
return jsIPN.sendFile(args[0].String(), args[1].String(), args[2])
|
||||
return jsIPN.sendFile(args[0].String(), args[1].String(), args[2], args[3].Int())
|
||||
}),
|
||||
"waitingFiles": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
return jsIPN.waitingFiles()
|
||||
@@ -295,7 +310,87 @@ func newIPN(jsConfig js.Value) map[string]any {
|
||||
}
|
||||
return jsIPN.deleteWaitingFile(args[0].String())
|
||||
}),
|
||||
"getCert": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
return jsIPN.getCert()
|
||||
}),
|
||||
"listenTLS": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
if len(args) != 3 {
|
||||
log.Printf("Usage: listenTLS(addr, certPEM, keyPEM)")
|
||||
return nil
|
||||
}
|
||||
return jsIPN.listenTLS(args[0].String(), args[1].String(), args[2].String())
|
||||
}),
|
||||
"setFunnel": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
if len(args) != 3 {
|
||||
log.Printf("Usage: setFunnel(hostname, port, enabled)")
|
||||
return nil
|
||||
}
|
||||
return jsIPN.setFunnel(args[0].String(), uint16(args[1].Int()), args[2].Bool())
|
||||
}),
|
||||
"whoIs": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
if len(args) < 1 {
|
||||
log.Printf("Usage: whoIs(addrPort[, proto])")
|
||||
return nil
|
||||
}
|
||||
proto := ""
|
||||
if len(args) >= 2 {
|
||||
proto = args[1].String()
|
||||
}
|
||||
return jsIPN.whoIs(args[0].String(), proto)
|
||||
}),
|
||||
"queryDNS": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
if len(args) < 1 {
|
||||
log.Printf("Usage: queryDNS(name[, type])")
|
||||
return nil
|
||||
}
|
||||
qtype := 1 // TypeA
|
||||
if len(args) >= 2 {
|
||||
qtype = args[1].Int()
|
||||
}
|
||||
return jsIPN.queryDNS(args[0].String(), qtype)
|
||||
}),
|
||||
"ping": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
if len(args) < 1 {
|
||||
log.Printf("Usage: ping(ip[, type[, size]])")
|
||||
return nil
|
||||
}
|
||||
pingType := "TSMP"
|
||||
if len(args) >= 2 {
|
||||
pingType = args[1].String()
|
||||
}
|
||||
size := 0
|
||||
if len(args) >= 3 {
|
||||
size = args[2].Int()
|
||||
}
|
||||
return jsIPN.ping(args[0].String(), pingType, size)
|
||||
}),
|
||||
"suggestExitNode": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
return jsIPN.suggestExitNode()
|
||||
}),
|
||||
"shutdown": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
return jsIPN.shutdown()
|
||||
}),
|
||||
"setServices": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
if len(args) != 1 {
|
||||
log.Printf("Usage: setServices(services)")
|
||||
return nil
|
||||
}
|
||||
return jsIPN.setServices(args[0])
|
||||
}),
|
||||
"localAPI": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
if len(args) < 2 {
|
||||
log.Printf("Usage: localAPI(method, path[, body])")
|
||||
return nil
|
||||
}
|
||||
body := ""
|
||||
if len(args) >= 3 {
|
||||
body = args[2].String()
|
||||
}
|
||||
return jsIPN.localAPI(args[0].String(), args[1].String(), body)
|
||||
}),
|
||||
}
|
||||
wireDriveJS(jsIPN, driveFS, m)
|
||||
return m
|
||||
}
|
||||
|
||||
type jsIPN struct {
|
||||
@@ -306,6 +401,22 @@ type jsIPN struct {
|
||||
controlURL string
|
||||
authKey string
|
||||
hostname string
|
||||
logID logid.PublicID
|
||||
|
||||
funnelMu sync.Mutex
|
||||
funnelPorts map[uint16]*funnelListenerEntry
|
||||
|
||||
// ln is the safesocket listener created by run(); stored here so shutdown
|
||||
// can close it and unblock srv.Run.
|
||||
ln net.Listener
|
||||
shutdownCh chan struct{} // closed by shutdown() to unblock main()
|
||||
shutdownOnce sync.Once
|
||||
}
|
||||
|
||||
// funnelListenerEntry is the per-port state for routing Funnel connections to a listenTLS listener.
|
||||
type funnelListenerEntry struct {
|
||||
ch chan net.Conn
|
||||
tlsCfg *tls.Config
|
||||
}
|
||||
|
||||
var jsIPNState = map[ipn.State]string{
|
||||
@@ -347,6 +458,31 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
|
||||
notifyState(*n.State)
|
||||
}
|
||||
if nm := n.NetMap; nm != nil {
|
||||
// Determine which address families we have, for peer peerAPI URL selection.
|
||||
var selfHave4, selfHave6 bool
|
||||
for _, a := range nm.GetAddresses().All() {
|
||||
if !a.IsSingleIP() {
|
||||
continue
|
||||
}
|
||||
if a.Addr().Is4() {
|
||||
selfHave4 = true
|
||||
} else if a.Addr().Is6() {
|
||||
selfHave6 = true
|
||||
}
|
||||
}
|
||||
|
||||
// Self peerAPI URL: own port as reported by LocalBackend.
|
||||
selfPeerAPIURL := ""
|
||||
for _, a := range nm.GetAddresses().All() {
|
||||
if !a.IsSingleIP() {
|
||||
continue
|
||||
}
|
||||
if port, ok := i.lb.GetPeerAPIPort(a.Addr()); ok && port != 0 {
|
||||
selfPeerAPIURL = fmt.Sprintf("http://%v", netip.AddrPortFrom(a.Addr(), port))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
jsNetMap := jsNetMap{
|
||||
Self: jsNetMapSelfNode{
|
||||
jsNetMapNode: jsNetMapNode{
|
||||
@@ -354,6 +490,8 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
|
||||
Addresses: mapSliceView(nm.GetAddresses(), func(a netip.Prefix) string { return a.Addr().String() }),
|
||||
NodeKey: nm.NodeKey.String(),
|
||||
MachineKey: nm.MachineKey.String(),
|
||||
PeerAPIURL: selfPeerAPIURL,
|
||||
Services: userServicesFromView(nm.SelfNode.Hostinfo().Services()),
|
||||
},
|
||||
MachineStatus: jsMachineStatus[nm.GetMachineStatus()],
|
||||
},
|
||||
@@ -364,15 +502,21 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
|
||||
name = p.Hostinfo().Hostname()
|
||||
}
|
||||
addrs := make([]string, p.Addresses().Len())
|
||||
for i, ap := range p.Addresses().All() {
|
||||
addrs[i] = ap.Addr().String()
|
||||
for idx, ap := range p.Addresses().All() {
|
||||
addrs[idx] = ap.Addr().String()
|
||||
}
|
||||
|
||||
// Peer peerAPI URL from the peer's advertised Services.
|
||||
peerURL := buildPeerAPIURL(p, selfHave4, selfHave6)
|
||||
|
||||
return jsNetMapPeerNode{
|
||||
jsNetMapNode: jsNetMapNode{
|
||||
Name: name,
|
||||
Addresses: addrs,
|
||||
MachineKey: p.Machine().String(),
|
||||
NodeKey: p.Key().String(),
|
||||
PeerAPIURL: peerURL,
|
||||
Services: userServicesFromView(p.Hostinfo().Services()),
|
||||
},
|
||||
Online: p.Online().Clone(),
|
||||
TailscaleSSHEnabled: p.Hostinfo().TailscaleSSHEnabled(),
|
||||
@@ -451,14 +595,17 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
ln, err := safesocket.Listen("")
|
||||
if err != nil {
|
||||
log.Fatalf("safesocket.Listen: %v", err)
|
||||
}
|
||||
ln, err := safesocket.Listen("")
|
||||
if err != nil {
|
||||
log.Fatalf("safesocket.Listen: %v", err)
|
||||
}
|
||||
i.ln = ln
|
||||
|
||||
err = i.srv.Run(context.Background(), ln)
|
||||
log.Fatalf("ipnserver.Run exited: %v", err)
|
||||
go func() {
|
||||
err := i.srv.Run(context.Background(), ln)
|
||||
if err != nil && !errors.Is(err, net.ErrClosed) {
|
||||
log.Fatalf("ipnserver.Run exited: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -477,6 +624,21 @@ func (i *jsIPN) logout() {
|
||||
}()
|
||||
}
|
||||
|
||||
func (i *jsIPN) shutdown() js.Value {
|
||||
return makePromise(func() (any, error) {
|
||||
i.shutdownOnce.Do(func() {
|
||||
if i.lb != nil {
|
||||
i.lb.Shutdown()
|
||||
}
|
||||
if i.ln != nil {
|
||||
i.ln.Close()
|
||||
}
|
||||
close(i.shutdownCh)
|
||||
})
|
||||
return nil, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (i *jsIPN) ssh(host, username string, termConfig js.Value) map[string]any {
|
||||
jsSSHSession := &jsSSHSession{
|
||||
jsIPN: i,
|
||||
@@ -705,6 +867,11 @@ func (i *jsIPN) listen(network, addr string) js.Value {
|
||||
if n == "tcp" {
|
||||
n = "tcp4"
|
||||
}
|
||||
// netstack.ListenTCP requires a full host:port; normalise the
|
||||
// standard net.Listen form ":port" that omits the host.
|
||||
if strings.HasPrefix(addr, ":") {
|
||||
addr = "0.0.0.0" + addr
|
||||
}
|
||||
ln, err := i.ns.ListenTCP(n, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -797,6 +964,470 @@ func (i *jsIPN) listenICMP(network string) js.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (i *jsIPN) getCert() js.Value {
|
||||
return makePromise(func() (any, error) {
|
||||
nm := i.lb.NetMap()
|
||||
if nm == nil {
|
||||
return nil, errors.New("getCert: no network map available")
|
||||
}
|
||||
certDomains := nm.DNS.CertDomains
|
||||
if len(certDomains) == 0 {
|
||||
return nil, errors.New("getCert: this tailnet does not support TLS certificates")
|
||||
}
|
||||
pair, err := i.lb.GetCertPEM(context.Background(), certDomains[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
"certPEM": string(pair.CertPEM),
|
||||
"keyPEM": string(pair.KeyPEM),
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (i *jsIPN) listenTLS(addr, certPEM, keyPEM string) js.Value {
|
||||
return makePromise(func() (any, error) {
|
||||
cert, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listenTLS: parsing cert/key: %w", err)
|
||||
}
|
||||
tlsCfg := &tls.Config{Certificates: []tls.Certificate{cert}}
|
||||
|
||||
tcpLn, err := i.ns.ListenTCP("tcp4", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Determine the actual port (handles ":0" ephemeral assignment).
|
||||
// Use SplitHostPort rather than netip.ParseAddrPort because gVisor
|
||||
// may return ":443" (empty host) which ParseAddrPort rejects.
|
||||
_, portStr, err := net.SplitHostPort(tcpLn.Addr().String())
|
||||
if err != nil {
|
||||
tcpLn.Close()
|
||||
return nil, fmt.Errorf("listenTLS: getting port from listener addr: %w", err)
|
||||
}
|
||||
portNum, err := strconv.ParseUint(portStr, 10, 16)
|
||||
if err != nil {
|
||||
tcpLn.Close()
|
||||
return nil, fmt.Errorf("listenTLS: parsing port %q: %w", portStr, err)
|
||||
}
|
||||
port := uint16(portNum)
|
||||
|
||||
// Register a Funnel entry so handleFunnelTCP can route to this listener.
|
||||
entry := &funnelListenerEntry{
|
||||
ch: make(chan net.Conn, 8),
|
||||
tlsCfg: tlsCfg,
|
||||
}
|
||||
i.funnelMu.Lock()
|
||||
i.funnelPorts[port] = entry
|
||||
i.funnelMu.Unlock()
|
||||
|
||||
ln := newCombinedTLSListener(tcpLn, tlsCfg, entry.ch, port, i)
|
||||
return wrapTCPListener(ln), nil
|
||||
})
|
||||
}
|
||||
|
||||
// handleFunnelTCP is registered with LocalBackend.SetTCPHandlerForFunnelFlow.
|
||||
// It routes incoming Funnel connections to the matching listenTLS listener.
|
||||
func (i *jsIPN) handleFunnelTCP(src netip.AddrPort, dstPort uint16) func(net.Conn) {
|
||||
i.funnelMu.Lock()
|
||||
entry := i.funnelPorts[dstPort]
|
||||
i.funnelMu.Unlock()
|
||||
if entry == nil {
|
||||
return nil
|
||||
}
|
||||
return func(conn net.Conn) {
|
||||
tlsConn := tls.Server(conn, entry.tlsCfg)
|
||||
select {
|
||||
case entry.ch <- tlsConn:
|
||||
default:
|
||||
// Channel full; drop the connection rather than block.
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// combinedTLSListener merges TLS connections from the local netstack (direct
|
||||
// tailnet access) and from Funnel ingress into a single net.Listener.
|
||||
type combinedTLSListener struct {
|
||||
tcpLn net.Listener
|
||||
tlsCfg *tls.Config
|
||||
funnelCh <-chan net.Conn
|
||||
port uint16
|
||||
ipn *jsIPN
|
||||
netstackCh chan net.Conn
|
||||
errCh chan error
|
||||
done chan struct{}
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func newCombinedTLSListener(tcpLn net.Listener, tlsCfg *tls.Config, funnelCh <-chan net.Conn, port uint16, ipn *jsIPN) *combinedTLSListener {
|
||||
l := &combinedTLSListener{
|
||||
tcpLn: tcpLn,
|
||||
tlsCfg: tlsCfg,
|
||||
funnelCh: funnelCh,
|
||||
port: port,
|
||||
ipn: ipn,
|
||||
netstackCh: make(chan net.Conn, 8),
|
||||
errCh: make(chan error, 1),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go l.drainNetstack()
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *combinedTLSListener) drainNetstack() {
|
||||
for {
|
||||
conn, err := l.tcpLn.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case l.errCh <- err:
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
tlsConn := tls.Server(conn, l.tlsCfg)
|
||||
select {
|
||||
case l.netstackCh <- tlsConn:
|
||||
case <-l.done:
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *combinedTLSListener) Accept() (net.Conn, error) {
|
||||
select {
|
||||
case conn := <-l.funnelCh:
|
||||
return conn, nil
|
||||
case conn := <-l.netstackCh:
|
||||
return conn, nil
|
||||
case err := <-l.errCh:
|
||||
return nil, err
|
||||
case <-l.done:
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
}
|
||||
|
||||
func (l *combinedTLSListener) Close() error {
|
||||
l.closeOnce.Do(func() {
|
||||
close(l.done)
|
||||
l.tcpLn.Close()
|
||||
l.ipn.funnelMu.Lock()
|
||||
delete(l.ipn.funnelPorts, l.port)
|
||||
l.ipn.funnelMu.Unlock()
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *combinedTLSListener) Addr() net.Addr {
|
||||
return l.tcpLn.Addr()
|
||||
}
|
||||
|
||||
func (i *jsIPN) setFunnel(hostname string, port uint16, enabled bool) js.Value {
|
||||
return makePromise(func() (any, error) {
|
||||
hp := ipn.HostPort(fmt.Sprintf("%s:%d", hostname, port))
|
||||
var cfg *ipn.ServeConfig
|
||||
if enabled {
|
||||
cfg = &ipn.ServeConfig{
|
||||
AllowFunnel: map[ipn.HostPort]bool{hp: true},
|
||||
}
|
||||
} else {
|
||||
cfg = &ipn.ServeConfig{}
|
||||
}
|
||||
return nil, i.lb.SetServeConfig(cfg, "")
|
||||
})
|
||||
}
|
||||
|
||||
func (i *jsIPN) whoIs(addr string, proto string) js.Value {
|
||||
return makePromise(func() (any, error) {
|
||||
// Accept both "ip:port" and bare "ip" (port 0 still resolves by IP).
|
||||
var ipp netip.AddrPort
|
||||
if ap, err := netip.ParseAddrPort(addr); err == nil {
|
||||
ipp = ap
|
||||
} else if ip, err := netip.ParseAddr(addr); err == nil {
|
||||
ipp = netip.AddrPortFrom(ip, 0)
|
||||
} else {
|
||||
return nil, fmt.Errorf("whoIs: invalid address %q (want ip:port or ip)", addr)
|
||||
}
|
||||
n, u, ok := i.lb.WhoIs(proto, ipp)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
addrs := make([]any, n.Addresses().Len())
|
||||
for idx, ap := range n.Addresses().All() {
|
||||
addrs[idx] = ap.Addr().String()
|
||||
}
|
||||
return map[string]any{
|
||||
"node": map[string]any{
|
||||
"id": string(n.StableID()),
|
||||
"name": n.Name(),
|
||||
"addresses": addrs,
|
||||
},
|
||||
"user": map[string]any{
|
||||
"id": int64(u.ID),
|
||||
"loginName": u.LoginName,
|
||||
"displayName": u.DisplayName,
|
||||
"profilePicURL": u.ProfilePicURL,
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (i *jsIPN) queryDNS(name string, queryType int) js.Value {
|
||||
return makePromise(func() (any, error) {
|
||||
res, resolvers, err := i.lb.QueryDNS(name, dnsmessage.Type(queryType))
|
||||
|
||||
// Detect SERVFAIL with no upstream resolvers (common when an exit node is
|
||||
// active but the DNS manager forwarder has no configured upstreams). Fall
|
||||
// back to querying 8.8.8.8 via the dialer (which routes through the exit
|
||||
// node), then as a last resort use the browser's default name resolver.
|
||||
needsFallback := err != nil
|
||||
if !needsFallback && len(resolvers) == 0 && len(res) > 0 {
|
||||
var hdrParser dnsmessage.Parser
|
||||
if hdr, hdrErr := hdrParser.Start(res); hdrErr == nil && hdr.RCode == dnsmessage.RCodeServerFailure {
|
||||
needsFallback = true
|
||||
}
|
||||
}
|
||||
if needsFallback {
|
||||
qt := dnsmessage.Type(queryType)
|
||||
if qt != dnsmessage.TypeA && qt != dnsmessage.TypeAAAA {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("queryDNS: %w (no upstream resolver; only A/AAAA queries support fallback)", err)
|
||||
}
|
||||
return nil, fmt.Errorf("queryDNS: no upstream resolver available; only A/AAAA queries support fallback lookup")
|
||||
}
|
||||
ctx := context.Background()
|
||||
d := i.dialer
|
||||
r := &net.Resolver{
|
||||
PreferGo: true,
|
||||
Dial: func(rctx context.Context, network, address string) (net.Conn, error) {
|
||||
return d.UserDial(rctx, "tcp", "8.8.8.8:53")
|
||||
},
|
||||
}
|
||||
ips, rerr := r.LookupIPAddr(ctx, name)
|
||||
if rerr != nil {
|
||||
// Last resort: browser-native resolution (no exit-node routing).
|
||||
ips, rerr = (&net.Resolver{PreferGo: false}).LookupIPAddr(ctx, name)
|
||||
if rerr != nil {
|
||||
return nil, fmt.Errorf("queryDNS: fallback resolution failed: %w", rerr)
|
||||
}
|
||||
}
|
||||
var answers []any
|
||||
for _, ia := range ips {
|
||||
ip, ok := netip.AddrFromSlice(ia.IP)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ip = ip.Unmap()
|
||||
if qt == dnsmessage.TypeA && ip.Is4() {
|
||||
answers = append(answers, ip.String())
|
||||
} else if qt == dnsmessage.TypeAAAA && ip.Is6() {
|
||||
answers = append(answers, ip.String())
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"answers": answers,
|
||||
"resolvers": []any{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
var p dnsmessage.Parser
|
||||
if _, err := p.Start(res); err != nil {
|
||||
return nil, fmt.Errorf("queryDNS: parsing response: %w", err)
|
||||
}
|
||||
if err := p.SkipAllQuestions(); err != nil {
|
||||
return nil, fmt.Errorf("queryDNS: skipping questions: %w", err)
|
||||
}
|
||||
var answers []any
|
||||
for {
|
||||
h, err := p.AnswerHeader()
|
||||
if err == dnsmessage.ErrSectionDone {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("queryDNS: reading answer: %w", err)
|
||||
}
|
||||
switch h.Type {
|
||||
case dnsmessage.TypeA:
|
||||
r, err := p.AResource()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("queryDNS: reading A record: %w", err)
|
||||
}
|
||||
answers = append(answers, netip.AddrFrom4(r.A).String())
|
||||
case dnsmessage.TypeAAAA:
|
||||
r, err := p.AAAAResource()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("queryDNS: reading AAAA record: %w", err)
|
||||
}
|
||||
answers = append(answers, netip.AddrFrom16(r.AAAA).String())
|
||||
case dnsmessage.TypeCNAME:
|
||||
r, err := p.CNAMEResource()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("queryDNS: reading CNAME record: %w", err)
|
||||
}
|
||||
answers = append(answers, r.CNAME.String())
|
||||
case dnsmessage.TypeTXT:
|
||||
r, err := p.TXTResource()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("queryDNS: reading TXT record: %w", err)
|
||||
}
|
||||
for _, s := range r.TXT {
|
||||
answers = append(answers, s)
|
||||
}
|
||||
default:
|
||||
if err := p.SkipAnswer(); err != nil {
|
||||
return nil, fmt.Errorf("queryDNS: skipping unknown answer: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
resolverAddrs := make([]any, len(resolvers))
|
||||
for idx, r := range resolvers {
|
||||
resolverAddrs[idx] = r.Addr
|
||||
}
|
||||
return map[string]any{
|
||||
"answers": answers,
|
||||
"resolvers": resolverAddrs,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (i *jsIPN) ping(ip string, pingType string, size int) js.Value {
|
||||
return makePromise(func() (any, error) {
|
||||
addr, err := netip.ParseAddr(ip)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ping: invalid IP %q: %w", ip, err)
|
||||
}
|
||||
switch tailcfg.PingType(pingType) {
|
||||
case tailcfg.PingDisco, tailcfg.PingTSMP, tailcfg.PingICMP, tailcfg.PingPeerAPI:
|
||||
// valid
|
||||
default:
|
||||
return nil, fmt.Errorf("ping: unknown type %q, must be one of: disco, TSMP, ICMP, peerapi", pingType)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
pr, err := i.lb.Ping(ctx, addr, tailcfg.PingType(pingType), size)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := map[string]any{
|
||||
"ip": pr.IP,
|
||||
"nodeIP": pr.NodeIP,
|
||||
"nodeName": pr.NodeName,
|
||||
"latencySeconds": pr.LatencySeconds,
|
||||
"endpoint": pr.Endpoint,
|
||||
"derpRegionID": pr.DERPRegionID,
|
||||
"derpRegionCode": pr.DERPRegionCode,
|
||||
"peerAPIURL": pr.PeerAPIURL,
|
||||
"isLocalIP": pr.IsLocalIP,
|
||||
}
|
||||
if pr.Err != "" {
|
||||
result["err"] = pr.Err
|
||||
}
|
||||
return result, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (i *jsIPN) suggestExitNode() js.Value {
|
||||
return makePromise(func() (any, error) {
|
||||
resp, err := i.lb.SuggestExitNode()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := map[string]any{
|
||||
"id": string(resp.ID),
|
||||
"name": resp.Name,
|
||||
}
|
||||
if l := resp.Location; l.Valid() {
|
||||
result["location"] = map[string]any{
|
||||
"country": l.Country(),
|
||||
"countryCode": l.CountryCode(),
|
||||
"city": l.City(),
|
||||
"cityCode": l.CityCode(),
|
||||
"latitude": l.Latitude(),
|
||||
"longitude": l.Longitude(),
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (i *jsIPN) setServices(jsServices js.Value) js.Value {
|
||||
return makePromise(func() (any, error) {
|
||||
n := jsServices.Length()
|
||||
svcs := make([]tailcfg.Service, 0, n)
|
||||
for idx := range n {
|
||||
s := jsServices.Index(idx)
|
||||
proto := tailcfg.ServiceProto(s.Get("proto").String())
|
||||
port := uint16(s.Get("port").Int())
|
||||
var desc string
|
||||
if d := s.Get("description"); d.Type() == js.TypeString {
|
||||
desc = d.String()
|
||||
}
|
||||
svcs = append(svcs, tailcfg.Service{Proto: proto, Port: port, Description: desc})
|
||||
}
|
||||
i.lb.SetExplicitServices(svcs)
|
||||
return nil, nil
|
||||
})
|
||||
}
|
||||
|
||||
// userServicesFromView converts a hostinfo services slice to jsService entries,
|
||||
// filtering out internal peerapi protocol entries (already reflected in peerAPIURL).
|
||||
func userServicesFromView(svcs views.Slice[tailcfg.Service]) []jsService {
|
||||
out := make([]jsService, 0, svcs.Len())
|
||||
for _, s := range svcs.All() {
|
||||
switch s.Proto {
|
||||
case tailcfg.PeerAPI4, tailcfg.PeerAPI6, tailcfg.PeerAPIDNS:
|
||||
continue
|
||||
}
|
||||
out = append(out, jsService{Proto: string(s.Proto), Port: s.Port, Description: s.Description})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (i *jsIPN) localAPI(method, path, body string) js.Value {
|
||||
return makePromise(func() (any, error) {
|
||||
h := localapi.NewHandler(localapi.HandlerConfig{
|
||||
Actor: &ipnauth.TestActor{
|
||||
Name: "wasm",
|
||||
LocalAdmin: true,
|
||||
},
|
||||
Backend: i.lb,
|
||||
Logf: log.Printf,
|
||||
LogID: i.logID,
|
||||
EventBus: i.lb.Sys().Bus.Get(),
|
||||
})
|
||||
h.PermitRead = true
|
||||
h.PermitWrite = true
|
||||
h.PermitCert = true
|
||||
|
||||
var bodyReader io.Reader
|
||||
if body != "" {
|
||||
bodyReader = strings.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequest(method, "http://local-tailscaled.sock"+path, bodyReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("localAPI: %w", err)
|
||||
}
|
||||
// Empty Host passes the validHost check in the LocalAPI handler.
|
||||
req.Host = ""
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
resp := w.Result()
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("localAPI: reading response: %w", err)
|
||||
}
|
||||
return map[string]any{
|
||||
"status": resp.StatusCode,
|
||||
"body": string(respBody),
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// wrapConn exposes a net.Conn to JavaScript with binary (Uint8Array) I/O.
|
||||
func wrapConn(conn net.Conn) map[string]any {
|
||||
return map[string]any{
|
||||
@@ -990,11 +1621,19 @@ type jsNetMap struct {
|
||||
LockedOut bool `json:"lockedOut"`
|
||||
}
|
||||
|
||||
type jsService struct {
|
||||
Proto string `json:"proto"`
|
||||
Port uint16 `json:"port"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type jsNetMapNode struct {
|
||||
Name string `json:"name"`
|
||||
Addresses []string `json:"addresses"`
|
||||
MachineKey string `json:"machineKey"`
|
||||
NodeKey string `json:"nodeKey"`
|
||||
Name string `json:"name"`
|
||||
Addresses []string `json:"addresses"`
|
||||
MachineKey string `json:"machineKey"`
|
||||
NodeKey string `json:"nodeKey"`
|
||||
PeerAPIURL string `json:"peerAPIURL,omitempty"`
|
||||
Services []jsService `json:"services"`
|
||||
}
|
||||
|
||||
type jsNetMapSelfNode struct {
|
||||
|
||||
@@ -302,6 +302,12 @@ func (c *Auto) restartMap() {
|
||||
c.updateControl()
|
||||
}
|
||||
|
||||
// RestartMap cancels the existing map poll and starts a fresh streaming one,
|
||||
// forcing the control server to send a new full netmap response.
|
||||
func (c *Auto) RestartMap() {
|
||||
c.restartMap()
|
||||
}
|
||||
|
||||
func (c *Auto) authRoutine() {
|
||||
defer close(c.authDone)
|
||||
bo := backoff.NewBackoff("authRoutine", c.logf, 30*time.Second)
|
||||
|
||||
+17
-1
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !js && !ts_omit_acme
|
||||
//go:build !ts_omit_acme
|
||||
|
||||
package ipnlocal
|
||||
|
||||
@@ -302,6 +302,9 @@ var errCertExpired = errors.New("cert expired")
|
||||
var testX509Roots *x509.CertPool // set non-nil by tests
|
||||
|
||||
func (b *LocalBackend) getCertStore() (certStore, error) {
|
||||
if runtime.GOOS == "js" {
|
||||
return certStateStore{StateStore: b.store}, nil
|
||||
}
|
||||
switch b.store.(type) {
|
||||
case *store.FileStore:
|
||||
case *mem.Store:
|
||||
@@ -333,6 +336,16 @@ func (b *LocalBackend) ConfigureCertsForTest(getCert func(hostname string) (*TLS
|
||||
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.
|
||||
type certFileStore struct {
|
||||
dir string
|
||||
@@ -550,6 +563,9 @@ var getCertPEM = func(ctx context.Context, b *LocalBackend, cs certStore, logf l
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.mu.Lock()
|
||||
ac.HTTPClient = b.acmeHTTPClient
|
||||
b.mu.Unlock()
|
||||
|
||||
if !isDefaultDirectoryURL(ac.DirectoryURL) {
|
||||
logf("acme: using Directory URL %q", ac.DirectoryURL)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build js || ts_omit_acme
|
||||
//go:build ts_omit_acme
|
||||
|
||||
package ipnlocal
|
||||
|
||||
|
||||
+32
-1
@@ -294,6 +294,7 @@ type LocalBackend struct {
|
||||
capTailnetLock bool // whether netMap contains the tailnet lock capability
|
||||
// hostinfo is mutated in-place while mu is held.
|
||||
hostinfo *tailcfg.Hostinfo // TODO(nickkhyl): move to nodeBackend
|
||||
explicitServices []tailcfg.Service // services set explicitly via SetExplicitServices; always uploaded
|
||||
nmExpiryTimer tstime.TimerController // for updating netMap on node expiry; can be nil; TODO(nickkhyl): move to nodeBackend
|
||||
activeLogin string // last logged LoginName from netMap; TODO(nickkhyl): move to nodeBackend (or remove? it's in [ipn.LoginProfile]).
|
||||
engineStatus ipn.EngineStatus
|
||||
@@ -412,6 +413,10 @@ type LocalBackend struct {
|
||||
// See [LocalBackend.ConfigureCertsForTest].
|
||||
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
|
||||
// acquire [LocalBackend]'s mutex inside of [LocalBackend.AuthReconfig].
|
||||
// It is used to prevent goroutines from piling up to do the same
|
||||
@@ -4963,6 +4968,30 @@ func (b *LocalBackend) setPortlistServices(sl []tailcfg.Service) {
|
||||
b.doSetHostinfoFilterServices()
|
||||
}
|
||||
|
||||
// SetExplicitServices sets the services this node advertises on the netmap.
|
||||
// Unlike the OS port-scan path (setPortlistServices), services set here are
|
||||
// always uploaded to the control server regardless of the ShouldUploadServices
|
||||
// hook — suitable for environments like browser WASM where OS port scanning is
|
||||
// unavailable and services are declared programmatically.
|
||||
func (b *LocalBackend) SetExplicitServices(sl []tailcfg.Service) {
|
||||
b.mu.Lock()
|
||||
if b.hostinfo == nil {
|
||||
b.hostinfo = new(tailcfg.Hostinfo)
|
||||
}
|
||||
b.hostinfo.Services = sl
|
||||
b.explicitServices = sl
|
||||
ccAuto := b.ccAuto
|
||||
b.mu.Unlock()
|
||||
|
||||
b.doSetHostinfoFilterServices()
|
||||
// Restart the streaming map poll so the control server sends back a fresh
|
||||
// netmap that includes our updated services in SelfNode, and so peers
|
||||
// receive the update promptly via the control server's push.
|
||||
if ccAuto != nil {
|
||||
ccAuto.RestartMap()
|
||||
}
|
||||
}
|
||||
|
||||
// doSetHostinfoFilterServices calls SetHostinfo on the controlclient,
|
||||
// possibly after mangling the given hostinfo.
|
||||
//
|
||||
@@ -5007,7 +5036,9 @@ func (b *LocalBackend) hostInfoWithServicesLocked() *tailcfg.Hostinfo {
|
||||
// Make a shallow copy of hostinfo so we can mutate
|
||||
// at the Service field.
|
||||
if f, ok := b.extHost.Hooks().ShouldUploadServices.GetOk(); !ok || !f() {
|
||||
hi.Services = []tailcfg.Service{}
|
||||
if len(b.explicitServices) == 0 {
|
||||
hi.Services = []tailcfg.Service{}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't mutate hi.Service's underlying array. Append to
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -5,15 +5,22 @@ package safesocket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/akutz/memconn"
|
||||
)
|
||||
|
||||
const memName = "Tailscale-IPN"
|
||||
|
||||
// memSeq ensures each IPN instance in the same WASM process gets a distinct
|
||||
// memconn address, so concurrent instances do not conflict on the registry.
|
||||
var memSeq atomic.Int64
|
||||
|
||||
func listen(path string) (net.Listener, error) {
|
||||
return memconn.Listen("memu", memName)
|
||||
name := fmt.Sprintf("%s-%d", memName, memSeq.Add(1))
|
||||
return memconn.Listen("memu", name)
|
||||
}
|
||||
|
||||
func connect(ctx context.Context, _ string) (net.Conn, error) {
|
||||
|
||||
Reference in New Issue
Block a user