Compare commits

..

1 Commits

Author SHA1 Message Date
codinget dd9c9f6844 feat(tsconnect): expose service advertisement to JS
Add SetExplicitServices on LocalBackend so the browser WASM node can
declare TCP/UDP services that get uploaded to the control server and
distributed to all peers in the netmap — without the OS port-scanner
(portlist extension) that cannot run in a browser.

The ShouldUploadServices gate in hostInfoWithServicesLocked is bypassed
when services were set explicitly, leaving all other callers unaffected.

On the JS side, a new setServices(services) method accepts an array of
{proto, port, description?} objects.  The netmap JSON now includes a
services field on every node (self and peers), populated from
Hostinfo.Services with internal peerapi entries stripped (they are
already reflected in peerAPIURL).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:57:27 +00:00
3 changed files with 37 additions and 183 deletions
+22 -127
View File
@@ -9,6 +9,7 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
@@ -80,11 +81,9 @@ func (i *jsIPN) listFileTargets() js.Value {
})
}
// sendFile sends stream as filename to the peer identified by stableNodeID,
// sendFile sends data as filename to the peer identified by stableNodeID,
// reporting progress via notifyOutgoingFiles callbacks roughly once per second.
// 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 {
func (i *jsIPN) sendFile(stableNodeID, filename string, data js.Value) js.Value {
return makePromise(func() (any, error) {
ext, err := i.taildropExt()
if err != nil {
@@ -108,15 +107,14 @@ func (i *jsIPN) sendFile(stableNodeID, filename string, stream js.Value, declare
if err != nil {
return nil, fmt.Errorf("bogus peer URL: %w", err)
}
reader := stream.Call("getReader")
body := &jsStreamReader{reader: reader}
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(declaredSize),
DeclaredSize: int64(len(b)),
Started: time.Now(),
}
updates := map[string]*ipn.OutgoingFile{outgoing.ID: outgoing}
@@ -129,17 +127,17 @@ func (i *jsIPN) sendFile(stableNodeID, filename string, stream js.Value, declare
ext.UpdateOutgoingFiles(updates)
}()
progressBody := progresstracking.NewReader(body, time.Second, func(n int, _ error) {
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), progressBody)
req, err := http.NewRequest("PUT", dstURL.String()+"/v0/put/"+url.PathEscape(filename), body)
if err != nil {
sendErr = err
return nil, err
}
req.ContentLength = int64(declaredSize)
req.ContentLength = int64(len(b))
client := &http.Client{Transport: i.lb.Dialer().PeerAPITransport()}
resp, err := client.Do(req)
if err != nil {
@@ -149,13 +147,7 @@ func (i *jsIPN) sendFile(stableNodeID, filename string, stream js.Value, declare
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
respBody, _ := io.ReadAll(resp.Body)
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)
sendErr = fmt.Errorf("send file: %s: %s", resp.Status, bytes.TrimSpace(respBody))
return nil, sendErr
}
return nil, nil
@@ -190,8 +182,7 @@ func (i *jsIPN) waitingFiles() js.Value {
})
}
// openWaitingFile returns the contents of a received file as a ReadableStream.
// The stream emits Uint8Array chunks and closes when the file is fully read.
// 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()
@@ -202,7 +193,14 @@ func (i *jsIPN) openWaitingFile(name string) js.Value {
if err != nil {
return nil, err
}
return jsReadableStream(rc), nil
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
})
}
@@ -236,109 +234,6 @@ 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:
//
@@ -490,9 +385,9 @@ func (j jsFileOps) OpenReader(name string) (io.ReadCloser, error) {
if err != nil {
return nil, err
}
// val is a ReadableStream; wrap its reader for streaming delivery to Go.
reader := val.Call("getReader")
return &jsStreamReader{reader: reader}, nil
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.
+14 -48
View File
@@ -66,20 +66,19 @@ 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], shutdownCh)
return newIPN(args[0])
}))
// 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
// Keep Go runtime alive, otherwise it will be shut down before newIPN gets
// called.
<-make(chan bool)
}
func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
func newIPN(jsConfig js.Value) map[string]any {
netns.SetEnabled(false)
var store ipn.StateStore
@@ -180,7 +179,6 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
hostname: hostname,
logID: logid,
funnelPorts: make(map[uint16]*funnelListenerEntry),
shutdownCh: shutdownCh,
}
lb.SetTCPHandlerForFunnelFlow(jsIPN.handleFunnelTCP)
@@ -283,11 +281,11 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
return jsIPN.listFileTargets()
}),
"sendFile": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 4 {
log.Printf("Usage: sendFile(stableNodeID, filename, stream, declaredSize)")
if len(args) != 3 {
log.Printf("Usage: sendFile(stableNodeID, filename, data)")
return nil
}
return jsIPN.sendFile(args[0].String(), args[1].String(), args[2], args[3].Int())
return jsIPN.sendFile(args[0].String(), args[1].String(), args[2])
}),
"waitingFiles": js.FuncOf(func(this js.Value, args []js.Value) any {
return jsIPN.waitingFiles()
@@ -363,9 +361,6 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
"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)")
@@ -399,12 +394,6 @@ type jsIPN struct {
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.
@@ -614,17 +603,14 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
}
}()
ln, err := safesocket.Listen("")
if err != nil {
log.Fatalf("safesocket.Listen: %v", err)
}
i.ln = ln
go func() {
err := i.srv.Run(context.Background(), ln)
if err != nil && !errors.Is(err, net.ErrClosed) {
log.Fatalf("ipnserver.Run exited: %v", err)
ln, err := safesocket.Listen("")
if err != nil {
log.Fatalf("safesocket.Listen: %v", err)
}
err = i.srv.Run(context.Background(), ln)
log.Fatalf("ipnserver.Run exited: %v", err)
}()
}
@@ -643,21 +629,6 @@ 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,
@@ -886,11 +857,6 @@ 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
+1 -8
View File
@@ -5,22 +5,15 @@ 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) {
name := fmt.Sprintf("%s-%d", memName, memSeq.Add(1))
return memconn.Listen("memu", name)
return memconn.Listen("memu", memName)
}
func connect(ctx context.Context, _ string) (net.Conn, error) {