Author SHA1 Message Date
codingetandClaude 4ae5083960 fix(driveprobe): only count listing entries below the collection
hasChild treated any href that was not the collection as a share, so a
peer answering about an unrelated collection looked like it had shares.
Follow RFC 4918 §9.1 instead: the collection comes first and anything
after it is a member, with the first href counted only if it is itself
below the root.

Also accumulate href text across tokens; the XML decoder may split
character data, which the previous token-at-a-time check miscounted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 23:51:57 +00:00
codingetandClaude 3b239fe9e2 feat(tsconnect/wasm): add hasShares filter to listDrivePeers
PeerCapabilityTaildriveSharer says a peer may share with us, not that it
does, so listDrivePeers is a superset of the peers actually exposing
shares. listDrivePeers now takes an options object; with
{hasShares: true} each candidate's taildrive root is probed with a
Depth-1 PROPFIND and only peers listing at least one share are kept.

The probe and its multistatus parsing live in cmd/tsconnect/driveprobe
so they can be tested without syscall/js. Probes run in parallel with a
bounded worker count, a per-probe timeout and a bounded response read;
a probe that fails drops that peer and is logged rather than failing
the call, so the filter is positive-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 23:32:03 +00:00
codingetandClaude 7e9868f50e fix(tsconnect/wasm): filter offline and unreachable drive peers
listDrivePeers only checked PeerCapabilityTaildriveSharer, which is
usually granted to a whole group or tag, so it returned most of the
tailnet including offline peers and peers with no reachable peerAPI.
Apply the same conjunction as LocalBackend.driveRemotesFromPeers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 01:32:15 +00:00
codingetandCodex d94244830b cmd/tsconnect: remove obsolete wasm bridge APIs
Co-Authored-By: gpt-5.6-sol <noreply@openai.com>
2026-07-30 22:34:09 +00:00
codingetandClaude 15a70243ed fix(ipnlocal): allow Funnel ingress from unsigned peer relays
Upstream 0eb38dc2e denies peer capabilities to any peer with
UnsignedPeerAPIOnly set. Funnel ingress relays are precisely that: per the
docs on tailcfg.Node.UnsignedPeerAPIOnly they get no network access and exist
only to reach this node's peerapi. Since canIngress() resolves
PeerCapabilityIngress through the capability map, the relay can no longer
reach the one endpoint it is allowed to use, and Funnel connections are
refused after the client's ClientHello.

Split peerCapsLocked so the unsigned-peer denial can be skipped for the
ingress path alone. Every other caller keeps upstream's stricter behaviour.

Fork-local patch, tracked in webnet/tailscale#16 for reverting once upstream
restores Funnel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 20:10:02 +00:00
codingetandClaude cf52316095 fix(tsconnect/wasm): close conn on upgradeTLS configuration errors too
Previously only a handshake failure closed the underlying conn;
configuration errors (malformed caCerts PEM, bad cert/key pair)
returned with the conn still open, while the JS wrapper treats every
upgradeTLS rejection as fatal and marks the conn closed — leaking a
live Go conn that could no longer be closed from JS. Close on every
error path so the JS contract (any rejection after validation is
fatal) holds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfRgMke8p7QDxD5QM5thxH
2026-07-28 20:10:02 +00:00
codingetandClaude 375fad6adb feat(tsconnect/wasm): add upgradeTLS to wrapped conns
Adds an upgradeTLS method to the conn objects returned by dial/accept,
wrapping the existing net.Conn with crypto/tls in place (STARTTLS /
FTPS AUTH TLS style). Client mode reuses the dialTLS options
(serverName, insecureSkipVerify, caCerts), now factored into
tlsClientConfigFromJS; server mode takes isServer with certPem/keyPem.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfRgMke8p7QDxD5QM5thxH
2026-07-28 20:10:02 +00:00
codingetandClaude 487ac2cf17 feat(wasm): expose taildrive WebDAV server and listDrivePeers via JS bridge
Add drive.go (build tag !ts_omit_drive): implements drive.FileSystemForRemote
with a JS-backed handler. Streams request bodies chunk-by-chunk via
readBodyChunk() and response bodies via write()/end() callbacks so no
full-body buffering occurs regardless of file size. The handler is nil-safe:
returns 404 until setDriveHandler() is called from JS.

Add drive_stub.go (build tag ts_omit_drive): no-op stubs for stripped builds.

Add peer.go: extract buildPeerAPIURL helper (previously inline in run()).

Modify wasm_js.go: call initDriveForRemote before NewLocalBackend (SubSystem
is set-once), expose setDriveHandler and listDrivePeers via wireDriveJS,
and refactor the inline peerAPI URL logic to use buildPeerAPIURL.

listDrivePeers mirrors native driveRemotesFromPeers: returns empty if
DriveAccessEnabled() is false, then filters peers by PeerCapabilityTaildriveSharer
using lb.PeerCaps(addr).HasCapability() (the live ACL-derived cap map).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:02 +00:00
codingetandClaude 8cbf31ca49 fix(tsconnect): avoid nil services slice in netmap JSON
userServicesFromView returned a nil slice when a node advertised no
services, which (combined with the omitempty tag) caused the
services field to be dropped or serialize as null instead of [].
TypeScript declares services as a non-optional array, so JS callers
calling .find()/.some() on it would throw intermittently depending on
which netmap snapshot they observed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:02 +00:00
codingetandClaude 4a0b942852 fix(tsconnect): restart map poll after SetExplicitServices
The previous implementation only triggered a lite map update (non-streaming,
OmitPeers=true), whose response is discarded. This meant notifyNetMap was
never called after setServices, so self.services was never visible to the
local node and peers received the update only on their next periodic poll.

Add RestartMap() to controlclient.Auto and call it from SetExplicitServices
after the lite update. This cancels the current streaming poll and starts a
fresh one, causing the control server to send back a full netmap that
includes the updated SelfNode.Hostinfo.Services.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:02 +00:00
codingetandClaude 9a44000533 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-07-28 20:10:02 +00:00
codingetandClaude 6fa024a8af fix(tsconnect/wasm): nil-check lb and ln in shutdown() before use
lb and ln are only initialised during run(); calling shutdown() before
run() panics on nil. Guard both fields before dereferencing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:02 +00:00
codingetandClaude d789fa3e85 fix(tsconnect/wasm): normalise ":port" listen addr to "0.0.0.0:port"
netstack.ListenTCP requires a full host:port address; callers passing
the standard net.Listen form (":0" for any-interface ephemeral port)
would get ParseAddrPort error. Prepend "0.0.0.0" when the address
starts with ":" so the API matches Go's net.Listen behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:02 +00:00
codingetandClaude 862b569e8c fix(safesocket/js): use unique memconn name per IPN instance
Each call to newIPN() starts an independent Go backend that calls
safesocket.Listen() to serve the ipnserver IPC channel. Because
memName was a global constant, the second instance would fail with
"addr unavailable" and log.Fatal the whole WASM process.

Use an atomic counter to give each listener a distinct name
(Tailscale-IPN-1, Tailscale-IPN-2, …). The connect() path is
unchanged: in the wasm/tsconnect build all LocalAPI calls go through
the in-process httptest handler, so connect() is never called.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:02 +00:00
codingetandClaude 7b631aa83e feat(tsconnect/wasm): add shutdown() to jsIPN
Expose a shutdown() method on the JS-side IPN object that stops the
LocalBackend, closes the safesocket listener (which unblocks srv.Run),
and signals main() to return so the Go runtime exits cleanly.

This allows the host environment (Node.js process or browser service
worker) to terminate normally once the Tailscale WASM module is no
longer needed, instead of being kept alive indefinitely by open handles,
goroutines, or the Go runtime's blocking main goroutine.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:02 +00:00
codingetandClaude 34841c4801 feat(taildrop): stream files via ReadableStream on send and receive
Send: accept a ReadableStreamDefaultReader instead of a Uint8Array.
jsStreamReader (new io.ReadCloser) awaits reader.read() Promises via the
channel+FuncOf pattern, feeding chunks directly to the HTTP PUT body.
No js.CopyBytesToGo of the full file.

Receive: openWaitingFile now returns a pull-based ReadableStream backed by
the Go io.ReadCloser (jsReadableStream helper). Each pull call reads up
to 64 KiB and enqueues a Uint8Array chunk; no io.ReadAll.

jsFileOps.OpenReader: JS now returns a ReadableStream instead of a
Uint8Array; Go wraps it in jsStreamReader for streaming delivery.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:02 +00:00
codingetandClaude efdb8c56be chore(tsconnect): drop wasm pre-compression from build-pkg
Consumers are now responsible for compressing assets; the package ships
only the raw main.wasm binary.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:02 +00:00
codingetandClaude 37df6f9853 fix(wasm): correct ICMP case in ping type error message
The constant tailcfg.PingICMP is "ICMP" not "icmp"; the error message
was listing the wrong string, causing user confusion about valid values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:02 +00:00
codingetandClaude 24338efd08 fix(wasm): validate ping type early; fallback DNS resolver for exit node
Add a switch guard before the 30-second context in ping() so that invalid
ping type strings (e.g. "disco" vs "Disco") reject immediately with a clear
error rather than silently timing out because userspaceEngine.Ping has no
default case.

For queryDNS(), detect SERVFAIL responses returned with an empty resolver
list (the typical state when an exit node is active but the DNS manager
forwarder has no configured upstreams) and fall back to querying 8.8.8.8
via the dialer — which honours exit-node routing — for A/AAAA record types.
Fall further back to the browser's native resolver if UserDial fails.

Also accept bare IP addresses in whoIs() (in addition to ip:port) so
callers don't need to fabricate a port when they only have a peer IP.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:02 +00:00
codingetandClaude aab02cbf00 feat(tsconnect): add peerAPIURL to netmap and localAPI in-process bridge
Include the PeerAPI base URL (http://ip:port) in every node entry of the
notifyNetMap payload — for self via LocalBackend.GetPeerAPIPort, for peers
by reading the PeerAPI4/PeerAPI6 Services entries in their Hostinfo. The URL
mirrors the address-family preference used by peerAPIBase (prefer IPv4).

Add a localAPI(method, path, body?) WASM binding that dispatches in-process
HTTP requests directly to a LocalAPI handler with full read/write/cert
permissions, returning {status, body}. Enables TypeScript callers to access
any LocalAPI endpoint (ACL policy, Taildrive shares, etc.) without network
setup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:02 +00:00
codingetandClaude 962cee914d feat(tsconnect): add whoIs, queryDNS, ping, suggestExitNode WASM bindings
Expose four LocalBackend capabilities to JavaScript:
- whoIs(addrPort, proto?): resolves a connecting ip:port to a tailnet node
  and user profile; returns null for unknown peers
- queryDNS(name, type?): queries the tailnet DNS resolver (MagicDNS +
  upstream); parses A/AAAA/CNAME/TXT answers into strings
- ping(ip, type?, size?): pings a tailnet peer (TSMP, disco, ICMP, peerapi)
  with a 30 s context timeout; returns latency and path details
- suggestExitNode(): asks the coordination server for the best exit node

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:02 +00:00
codingetandClaude 07bbd6901b feat(tsconnect): add getCert, listenTLS, setFunnel + fix TLS cert for WASM
Enable ACME TLS certificates on js/wasm by dropping the !js build tag from
cert.go and routing storage through the state store. Add getCert, listenTLS,
and setFunnel WASM bindings with a combinedTLSListener that merges Funnel
ingress and direct tailnet connections. Notify the control plane immediately
after serve config changes to accelerate Funnel DNS provisioning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:02 +00:00
codingetandClaude c1c1f26c90 fix(tsconnect): pin types to avoid monorepo @types pollution
Replace skipLibCheck with an explicit types list so TypeScript and
dts-bundle-generator only auto-include @types/golang-wasm-exec and
@types/qrcode, preventing @types/eslint-scope and @types/ws from
leaking in from a parent node_modules when built inside a monorepo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:02 +00:00
codingetandClaude 101a52e75c fix(tsconnect): skipLibCheck to avoid monorepo @types conflicts
When tsconnect is built inside a JS monorepo, TypeScript walks up the
directory tree and auto-discovers @types/eslint-scope and @types/ws
from the root node_modules, causing spurious type errors unrelated to
tsconnect itself. skipLibCheck suppresses these.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:02 +00:00
codingetandClaude 23ef28b4ae fix(tsconnect): lowercase name/size in waitingFiles JSON
apitype.WaitingFile has no json tags so it serialised as {Name, Size}.
Introduce a local jsWaitingFile struct with json:"name" / json:"size"
so the JS side receives idiomatic camelCase property names.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:02 +00:00
codingetandClaude 18db7a0f94 fix(taildrop): restore incoming file progress notifications
The io.Copy in PutFile was writing directly to wc, bypassing the
incomingFile wrapper whose Write method increments f.copied and fires
a throttled sendFileNotify on progress. As a result, notifyIncomingFiles
on the JS side only ever fired once (on completion) with received=0,
making progress UI impossible. The original inFile wrapping was lost
during the Android SAF refactor.

Also surface the PartialFile.Done flag through jsIncomingFile so JS can
distinguish the final "transfer complete" notification from in-progress
updates.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-28 20:10:01 +00:00
codingetandClaude 0b277058d3 fix(tsconnect): guard nil n.Prefs in notify callback
n.Prefs is *PrefsView (a pointer), so calling n.Prefs.Valid() on a
Notify where Prefs is nil auto-dereferenced nil and panicked. The
callback's defer recover() swallowed the panic, which meant every
Notify without Prefs (Health-only, FilesWaiting, IncomingFiles,
OutgoingFiles, etc.) never reached the file-related JS calls.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-28 20:10:01 +00:00
codingetandClaude 038aa47b83 feat(tsconnect): add outgoing file transfer progress notifications
- Export UpdateOutgoingFiles on taildrop.Extension so it can be called
  from outside the package (wasm bridge, package main).
- Wrap sendFile's PUT body with progresstracking.NewReader so bytes-sent
  is sampled roughly once per second during transfer.
- Create an OutgoingFile entry (with UUID, peer ID, name, declared size)
  before the PUT and call UpdateOutgoingFiles on each progress tick and
  on completion (setting Finished/Succeeded). This flows into the IPN
  notify stream as OutgoingFiles notifications.
- Add jsOutgoingFile struct and wire n.OutgoingFiles into a new
  notifyOutgoingFiles callback in run(), mirroring notifyIncomingFiles.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:01 +00:00
codingetandClaude 06258280de feat(tsconnect): add notifyFilesWaiting and notifyIncomingFiles callbacks
Wire two new callbacks into the IPN notify stream:

- notifyFilesWaiting: fires when a completed inbound transfer is staged
  and ready to retrieve via waitingFiles(). Triggered by n.FilesWaiting
  in the notify stream.
- notifyIncomingFiles: fires with a JSON snapshot of in-progress inbound
  transfers whenever progress changes (roughly once per second while
  active, plus once at completion). The jsIncomingFile struct carries
  name, started (Unix ms), declaredSize, and received bytes. An empty
  array indicates all active transfers have finished.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:01 +00:00
codingetandClaude b9555a463b feat(taildrop): fix DirectFileMode, void callbacks, and empty WaitingFiles
- Add SetStagedFileOps to Extension: sets fileOps without enabling
  DirectFileMode, so WASM clients use staged retrieval (WaitingFiles,
  OpenFile, DeleteFile) instead of direct-write mode.
- Add directFileOps bool field: SetFileOps (Android SAF) sets it true;
  SetStagedFileOps (WASM JS) leaves it false. onChangeProfile now uses
  `fops != nil && e.directFileOps` to determine DirectFileMode.
- Add jsCallVoid to jsFileOps: void ops (openWriter, write, closeWriter,
  remove) now use cb(err?: string) instead of cb(null, err: string).
- Fix waitingFiles() returning JSON null when no files are waiting:
  normalise nil slice to empty slice before marshalling.
- Update wireTaildropFileOps to call SetStagedFileOps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:01 +00:00
codingetandClaude b2547cc664 feat(tsconnect): expose exit node selection to JS
Add exit node support to the wasm JS bridge:

- Include `exitNodeOption` and `stableNodeID` on each peer in the
  notifyNetMap payload so callers can identify which peers are exit
  nodes and reference them by stable ID.
- Call `notifyExitNode(stableNodeID)` whenever prefs change, so
  callers can track which exit node (if any) is currently active.
- Expose `setExitNode(stableNodeID)` — sets ExitNodeID via EditPrefs.
- Expose `setExitNodeEnabled(enabled)` — toggles the last-used exit
  node on/off via SetUseExitNodeEnabled.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:10:01 +00:00
codinget 358f47bc79 feat(tsconnect): add TCP listening to ipn.listen
Extend ipn.listen to also accept "tcp"/"tcp4"/"tcp6" and return a
TCPListener bound to a netstack gonet.TCPListener. The listener
exposes accept/close/addr like a Go net.Listener and additionally
implements Symbol.asyncIterator so JS callers can write:

  for await (const conn of listener) { ... }

The async iterator returns done when the listener is closed (via
errors.Is(net.ErrClosed)) and rejects on any other accept error.
Symbol-keyed properties are set via Reflect.set since syscall/js
only exposes string-keyed Set.
2026-07-28 20:10:01 +00:00
codinget 58095f829c feat(tsconnect): expose dialTLS to JS
Add ipn.dialTLS(addr, opts?) which dials a TCP connection through
the Tailscale dialer and performs a TLS handshake on top, returning
a JS Conn just like ipn.dial.

WASM has no system root pool, so verification defaults to the
baked-in LetsEncrypt ISRG roots already linked via net/bakedroots.
That covers any tailnet HTTPS endpoint provisioned via
`tailscale cert`. Callers can override with opts.caCerts (PEM) or
bypass entirely with opts.insecureSkipVerify, and override SNI with
opts.serverName.

Marginal binary cost is ~10 KiB on top of the existing ~31.6 MiB
wasm: crypto/tls and the x509 verification path are already pulled
in by control/controlclient and net/tlsdial.
2026-07-28 20:10:01 +00:00
codinget d42da2fbd7 feat(tsconnect): expose dial, listen and listenICMP to JS
Wire up the userspace networking primitives to the JS bridge so
browser callers can initiate outbound and receive inbound traffic
over the Tailscale network:

- ipn.dial(network, addr) wraps a tsdial UserDial into a JS Conn
  with read/write/close/localAddr/remoteAddr.
- ipn.listen(network, addr) wraps a netstack ListenPacket into a
  JS PacketConn with readFrom/writeTo/close/localAddr.
- ipn.listenICMP("icmp4"|"icmp6"|"icmp") creates a raw ICMP
  endpoint on the underlying gVisor stack and wraps it as a
  PacketConn for sending/receiving ping traffic.

To support listenICMP, netstack.Impl gains a Stack() accessor that
returns the underlying *stack.Stack so jsIPN can call NewEndpoint
with icmp.ProtocolNumber4/6.

Binary I/O uses js.CopyBytesToGo / js.CopyBytesToJS to move bytes
across the syscall/js boundary without base64 round-trips.
2026-07-28 20:10:01 +00:00
codingetandClaude c695e579fa fix(tsconnect): link fork features into the wasm build
Upstream's featuretags work turned the wasm build into an allow-list scoped
to its SSH-in-browser client, which strips most of what this fork's JS bridge
exposes. Two problems, both silent at build time:

- cmd/tsconnect/wasm never imported feature/condregister, so extensions only
  registered if the wasm happened to import them directly (taildrop did, ACME
  did not). Without it getCert/listenTLS/setFunnel fail with "cert support not
  compiled in this build".
- The Keep allow-list omitted acme, serve, taildrop, drive, tailnetlock,
  bakedroots and the exit node features. bakedroots matters especially: a
  browser has no system roots, so net/tlsdial's LetsEncrypt fallback is the
  only verification path there.

Invert the polarity to an explicit Omit list, matching how this build behaved
before featuretags existed. Only feature/ace is omitted, because it does not
compile for GOOS=js. Trimming the bundle is worth doing later with
measurements; an allow-list turns each mistake into a runtime failure rather
than a build error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 20:09:08 +00:00
13 changed files with 1127 additions and 337 deletions
+183
View File
@@ -0,0 +1,183 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package driveprobe reports whether a peer currently exposes Taildrive
// shares, by asking its peerAPI rather than by inspecting ACL capabilities.
//
// PeerCapabilityTaildriveSharer only says a peer is allowed to share with us.
// The share list itself is only visible over WebDAV, so this package issues a
// Depth-1 PROPFIND against the peer's Taildrive root and looks for children.
// The peer applies our permissions before listing, so a child is a share we
// can actually reach.
//
// This lives outside the wasm package so it can be tested without syscall/js.
package driveprobe
import (
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"path"
"strings"
"sync"
"time"
"golang.org/x/sync/errgroup"
"tailscale.com/types/logger"
)
const (
// drivePath is the peerAPI prefix taildrive is served under.
drivePath = "/v0/drive/"
// maxProbes bounds how many probes are in flight at once. Go under wasm
// runs on a single thread, so a high limit buys little and costs memory.
maxProbes = 8
// maxResponseBytes bounds the listing we are willing to read. A peer with
// a plausible number of shares is far below this.
maxResponseBytes = 1 << 20
// probeTimeout bounds a single probe in HasSharesMulti, so one peer that
// accepts the connection and then stalls cannot hold up the listing.
probeTimeout = 5 * time.Second
)
// propfindBody asks only for resourcetype: we care whether children exist,
// not what they are.
const propfindBody = `<?xml version="1.0" encoding="utf-8"?>` +
`<D:propfind xmlns:D="DAV:"><D:prop><D:resourcetype/></D:prop></D:propfind>`
// HasShares reports whether the peer at peerAPIURL exposes at least one
// Taildrive share to us.
//
// A false result means the peer answered and listed nothing. An error means we
// could not find out — callers must not read it as "no shares".
func HasShares(ctx context.Context, c *http.Client, peerAPIURL string) (bool, error) {
u := strings.TrimSuffix(peerAPIURL, "/") + drivePath
req, err := http.NewRequestWithContext(ctx, "PROPFIND", u, strings.NewReader(propfindBody))
if err != nil {
return false, err
}
req.Header.Set("Depth", "1")
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
resp, err := c.Do(req)
if err != nil {
return false, err
}
defer func() {
io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseBytes))
resp.Body.Close()
}()
// Anything other than 207 is the peer declining to list: taildrive off
// (404), no cap for us (403), or a handler that does not speak WebDAV.
if resp.StatusCode != http.StatusMultiStatus {
return false, fmt.Errorf("PROPFIND %s: %s", u, resp.Status)
}
return hasChild(io.LimitReader(resp.Body, maxResponseBytes), drivePath)
}
// hasChild reports whether a multistatus body lists anything besides the
// collection we asked about. It decodes as a stream and stops at the first
// child, so a peer with many shares costs no more than a peer with one.
//
// RFC 4918 §9.1 says a Depth-1 PROPFIND answers with the collection itself
// followed by its members, so anything after the first href is a share. The
// first href counts only if it is itself below root, which catches a peer that
// answers about a subtree rather than the collection we asked for.
//
// A peer that omits the collection entirely is not understood: ipnlocal strips
// the taildrive prefix before handing the request to the share server, so a
// real peer's members are named "/docs" rather than "/v0/drive/docs" and a lone
// member is indistinguishable from the collection. Such a peer is reported as
// having no shares, which is the safe direction for a positive filter.
func hasChild(body io.Reader, root string) (bool, error) {
dec := xml.NewDecoder(body)
var href string
inHref, first := false, true
for {
tok, err := dec.Token()
if err == io.EOF {
return false, nil
}
if err != nil {
return false, fmt.Errorf("parse multistatus: %w", err)
}
switch t := tok.(type) {
case xml.StartElement:
if t.Name.Space == "DAV:" && t.Name.Local == "href" {
inHref, href = true, ""
}
case xml.CharData:
// Character data can arrive in several tokens for one element.
if inHref {
href += string(t)
}
case xml.EndElement:
if !inHref {
continue
}
inHref = false
if !first {
return true, nil
}
first = false
if isBelow(href, root) {
return true, nil
}
}
}
}
// isBelow reports whether href points below root. Peers may answer with an
// absolute URL or a path, percent-encoded and with or without a trailing
// slash, so compare cleaned paths rather than strings.
func isBelow(href, root string) bool {
u, err := url.Parse(strings.TrimSpace(href))
if err != nil {
return false
}
p := path.Clean("/" + strings.Trim(u.Path, "/"))
r := path.Clean("/" + strings.Trim(root, "/"))
if p == r || p == "/" {
return false
}
return r == "/" || strings.HasPrefix(p, r+"/")
}
// HasSharesMulti probes every URL and returns one result per input, in input
// order. A probe that fails is reported as false and logged: the caller is
// filtering to peers we positively confirmed, and one unreachable peer must
// not sink the rest.
func HasSharesMulti(ctx context.Context, c *http.Client, urls []string, logf logger.Logf) []bool {
out := make([]bool, len(urls))
var mu sync.Mutex
// Deliberately not errgroup.WithContext: a failing probe must not cancel
// its siblings.
var g errgroup.Group
g.SetLimit(maxProbes)
for i, u := range urls {
g.Go(func() error {
ctx, cancel := context.WithTimeout(ctx, probeTimeout)
defer cancel()
ok, err := HasShares(ctx, c, u)
if err != nil {
logf("driveprobe: %s: %v", u, err)
return nil
}
mu.Lock()
out[i] = ok
mu.Unlock()
return nil
})
}
g.Wait()
return out
}
+263
View File
@@ -0,0 +1,263 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package driveprobe
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
func multistatus(hrefs ...string) string {
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?><D:multistatus xmlns:D="DAV:">`)
for _, h := range hrefs {
fmt.Fprintf(&b, `<D:response><D:href>%s</D:href><D:propstat><D:prop><D:resourcetype><D:collection/></D:resourcetype></D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>`, h)
}
b.WriteString(`</D:multistatus>`)
return b.String()
}
func TestHasChild(t *testing.T) {
tests := []struct {
name string
body string
want bool
}{
{"root only, prefix stripped", multistatus("/"), false},
{"root only, prefix kept", multistatus("/v0/drive/"), false},
{"root only, no trailing slash", multistatus("/v0/drive"), false},
{"one share, prefix stripped", multistatus("/", "/docs"), true},
{"one share, prefix kept", multistatus("/v0/drive/", "/v0/drive/docs"), true},
{"absolute urls", multistatus("http://100.1.2.3:1234/v0/drive/", "http://100.1.2.3:1234/v0/drive/docs"), true},
{"percent-encoded share name", multistatus("/v0/drive/", "/v0/drive/my%20share"), true},
{"unicode share name", multistatus("/v0/drive/", "/v0/drive/%E6%97%A5%E6%9C%AC"), true},
{"empty multistatus", multistatus(), false},
// The collection comes first per RFC 4918 §9.1, so anything after it
// is a share whatever the peer names it.
{"unrelated collection href, no members", multistatus("/somewhere/else/"), false},
{"unrelated collection href with a member", multistatus("/somewhere/else/", "/somewhere/else/docs"), true},
// A peer that omits the collection from a Depth-1 listing violates
// RFC 4918 §9.1, and once the taildrive prefix is stripped there is
// nothing left to tell its lone member apart from the collection. It
// loses the benefit of the doubt: hasShares excludes what it cannot
// confirm.
{"single member, collection omitted", multistatus("/docs"), false},
{"href split by an entity reference", multistatus("/v0/drive/", "/v0/drive/a&amp;b"), true},
{"empty href", multistatus("/v0/drive/", ""), true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := hasChild(strings.NewReader(tt.body), drivePath)
if err != nil {
t.Fatalf("hasChild: %v", err)
}
if got != tt.want {
t.Errorf("hasChild = %v, want %v", got, tt.want)
}
})
}
}
func TestHasChildMalformed(t *testing.T) {
body := strings.TrimSuffix(multistatus("/v0/drive/", "/v0/drive/docs"), "</D:multistatus>")
// Truncation after a child href still answers the question.
got, err := hasChild(strings.NewReader(body), drivePath)
if err != nil {
t.Fatalf("hasChild: %v", err)
}
if !got {
t.Error("hasChild = false on a truncated body that already listed a share")
}
if _, err := hasChild(strings.NewReader("<D:multistatus"), drivePath); err == nil {
t.Error("hasChild on malformed XML: want error, got nil")
}
}
// serveDrive returns a server answering PROPFIND at the taildrive root with the
// given body and status, and records the requests it saw.
func serveDrive(t *testing.T, status int, body string) (*httptest.Server, *[]*http.Request) {
t.Helper()
var mu sync.Mutex
var reqs []*http.Request
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
reqs = append(reqs, r)
mu.Unlock()
w.WriteHeader(status)
io := []byte(body)
w.Write(io)
}))
t.Cleanup(srv.Close)
return srv, &reqs
}
func TestHasShares(t *testing.T) {
srv, reqs := serveDrive(t, http.StatusMultiStatus, multistatus("/v0/drive/", "/v0/drive/docs"))
got, err := HasShares(context.Background(), srv.Client(), srv.URL)
if err != nil {
t.Fatalf("HasShares: %v", err)
}
if !got {
t.Error("HasShares = false, want true")
}
if len(*reqs) != 1 {
t.Fatalf("got %d requests, want 1", len(*reqs))
}
r := (*reqs)[0]
if r.Method != "PROPFIND" {
t.Errorf("method = %q, want PROPFIND", r.Method)
}
if r.URL.Path != drivePath {
t.Errorf("path = %q, want %q", r.URL.Path, drivePath)
}
if d := r.Header.Get("Depth"); d != "1" {
t.Errorf("Depth = %q, want 1", d)
}
}
func TestHasSharesTrailingSlashInPeerURL(t *testing.T) {
srv, reqs := serveDrive(t, http.StatusMultiStatus, multistatus("/"))
if _, err := HasShares(context.Background(), srv.Client(), srv.URL+"/"); err != nil {
t.Fatalf("HasShares: %v", err)
}
if p := (*reqs)[0].URL.Path; p != drivePath {
t.Errorf("path = %q, want %q", p, drivePath)
}
}
func TestHasSharesNonMultiStatus(t *testing.T) {
// Taildrive disabled, or we hold no cap on that peer.
for _, status := range []int{http.StatusNotFound, http.StatusForbidden, http.StatusOK} {
srv, _ := serveDrive(t, status, "")
got, err := HasShares(context.Background(), srv.Client(), srv.URL)
if err == nil {
t.Errorf("status %d: want error, got nil", status)
}
if got {
t.Errorf("status %d: HasShares = true", status)
}
}
}
func TestHasSharesCancelled(t *testing.T) {
// The handler must also unblock on release: a client-side cancel does not
// reliably reach the server's request context, and Close waits for it.
release := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-r.Context().Done():
case <-release:
}
}))
defer srv.Close()
defer close(release)
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
if _, err := HasShares(ctx, srv.Client(), srv.URL); err == nil {
t.Error("want error on cancelled probe, got nil")
}
}
func TestHasSharesMultiOrderAndFailures(t *testing.T) {
withShares, _ := serveDrive(t, http.StatusMultiStatus, multistatus("/v0/drive/", "/v0/drive/docs"))
noShares, _ := serveDrive(t, http.StatusMultiStatus, multistatus("/v0/drive/"))
refused, _ := serveDrive(t, http.StatusNotFound, "")
urls := []string{noShares.URL, withShares.URL, refused.URL, "http://127.0.0.1:1/dead", withShares.URL}
want := []bool{false, true, false, false, true}
var logs atomic.Int32
got := HasSharesMulti(context.Background(), withShares.Client(), urls, func(string, ...any) {
logs.Add(1)
})
for i := range want {
if got[i] != want[i] {
t.Errorf("result[%d] = %v, want %v (%s)", i, got[i], want[i], urls[i])
}
}
// The 404 peer and the dead address are both reported, not swallowed.
if n := logs.Load(); n != 2 {
t.Errorf("logged %d probe failures, want 2", n)
}
}
func TestHasSharesMultiRunsInParallel(t *testing.T) {
const delay = 100 * time.Millisecond
var inFlight, peak atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := inFlight.Add(1)
for {
old := peak.Load()
if n <= old || peak.CompareAndSwap(old, n) {
break
}
}
time.Sleep(delay)
inFlight.Add(-1)
w.WriteHeader(http.StatusMultiStatus)
w.Write([]byte(multistatus("/v0/drive/", "/v0/drive/docs")))
}))
defer srv.Close()
urls := make([]string, maxProbes)
for i := range urls {
urls[i] = srv.URL
}
start := time.Now()
got := HasSharesMulti(context.Background(), srv.Client(), urls, func(string, ...any) {})
elapsed := time.Since(start)
for i, ok := range got {
if !ok {
t.Errorf("result[%d] = false, want true", i)
}
}
if elapsed >= delay*time.Duration(len(urls)) {
t.Errorf("probes serialized: %v for %d probes of %v each", elapsed, len(urls), delay)
}
if peak.Load() < 2 {
t.Errorf("peak concurrency = %d, want >= 2", peak.Load())
}
}
func TestHasSharesMultiLimitsConcurrency(t *testing.T) {
var inFlight, peak atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := inFlight.Add(1)
for {
old := peak.Load()
if n <= old || peak.CompareAndSwap(old, n) {
break
}
}
time.Sleep(20 * time.Millisecond)
inFlight.Add(-1)
w.WriteHeader(http.StatusMultiStatus)
w.Write([]byte(multistatus("/v0/drive/")))
}))
defer srv.Close()
urls := make([]string, maxProbes*3)
for i := range urls {
urls[i] = srv.URL
}
client := &http.Client{Transport: &http.Transport{MaxConnsPerHost: 0}}
HasSharesMulti(context.Background(), client, urls, func(string, ...any) {})
if peak.Load() > maxProbes {
t.Errorf("peak concurrency = %d, want <= %d", peak.Load(), maxProbes)
}
}
+348
View File
@@ -0,0 +1,348 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_drive
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"sync"
"syscall/js"
"tailscale.com/cmd/tsconnect/driveprobe"
"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, args []js.Value) any {
var hasShares bool
if len(args) > 0 && !args[0].IsUndefined() && !args[0].IsNull() {
if v := args[0].Get("hasShares"); v.Type() == js.TypeBoolean {
hasShares = v.Bool()
}
}
return i.listDrivePeers(hasShares)
})
}
// filterPeersWithShares keeps only the peers whose Taildrive endpoint lists at
// least one share for us, preserving the order of the input.
func (i *jsIPN) filterPeersWithShares(peers []jsDrivePeer) []jsDrivePeer {
urls := make([]string, len(peers))
for n, p := range peers {
urls[n] = p.PeerAPIURL
}
client := &http.Client{Transport: i.lb.Dialer().PeerAPITransport()}
results := driveprobe.HasSharesMulti(context.Background(), client, urls, log.Printf)
kept := make([]jsDrivePeer, 0, len(peers))
for n, p := range peers {
if results[n] {
kept = append(kept, p)
}
}
return kept
}
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 are online, have a
// reachable peerAPI and 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.
//
// The cap means a peer is allowed to share with us, not that it currently
// exposes any share, so the result is a superset of the peers with shares.
//
// hasShares narrows it to peers we confirmed are exposing at least one share,
// at the cost of one peerAPI round-trip per candidate (run in parallel). It is
// a positive filter: a peer we could not reach is left out, which is not the
// same as knowing it has no shares.
func (i *jsIPN) listDrivePeers(hasShares bool) 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 {
if !p.Online().Get() {
continue
}
peerURL := buildPeerAPIURL(p, selfHave4, selfHave6)
if peerURL == "" {
continue
}
// 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
}
online := p.Online().Clone()
peers = append(peers, jsDrivePeer{
Name: p.DisplayName(false),
PeerAPIURL: peerURL,
StableNodeID: string(p.StableID()),
Online: online,
})
}
if hasShares && len(peers) > 0 {
peers = i.filterPeersWithShares(peers)
}
b, err := json.Marshal(peers)
if err != nil {
return nil, fmt.Errorf("listDrivePeers: marshal: %w", err)
}
return string(b), nil
})
}
+27
View File
@@ -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(_ bool) js.Value {
return makePromise(func() (any, error) {
return "[]", nil
})
}
+41
View File
@@ -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 ""
}
+160 -291
View File
@@ -6,11 +6,10 @@
//
// When run in the browser, a newIPN(config) function is added to the global JS
// namespace. When called it returns an ipn object with the methods
// run(callbacks), login(), logout(), and ssh(...).
// run(callbacks), login(), and logout().
package main
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
@@ -31,7 +30,6 @@ import (
"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"
@@ -40,6 +38,7 @@ import (
"gvisor.dev/gvisor/pkg/tcpip/transport/icmp"
"gvisor.dev/gvisor/pkg/waiter"
"tailscale.com/control/controlclient"
_ "tailscale.com/feature/condregister"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnauth"
"tailscale.com/ipn/ipnlocal"
@@ -173,6 +172,10 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) 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 {
@@ -198,7 +201,7 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
}
lb.SetTCPHandlerForFunnelFlow(jsIPN.handleFunnelTCP)
return map[string]any{
m := map[string]any{
"run": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 1 {
log.Fatal(`Usage: run({
@@ -228,25 +231,6 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
jsIPN.logout()
return nil
}),
"ssh": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 3 {
log.Printf("Usage: ssh(hostname, userName, termConfig)")
return nil
}
return jsIPN.ssh(
args[0].String(),
args[1].String(),
args[2])
}),
"fetch": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 1 {
log.Printf("Usage: fetch(url)")
return nil
}
url := args[0].String()
return jsIPN.fetch(url)
}),
"dial": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 2 {
log.Printf("Usage: dial(network, addr)")
@@ -286,13 +270,6 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
}
return jsIPN.setExitNode(args[0].String())
}),
"setExitNodeEnabled": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 1 {
log.Printf("Usage: setExitNodeEnabled(enabled)")
return nil
}
return jsIPN.setExitNodeEnabled(args[0].Bool())
}),
"listFileTargets": js.FuncOf(func(this js.Value, args []js.Value) any {
return jsIPN.listFileTargets()
}),
@@ -380,6 +357,13 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
"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])")
@@ -392,6 +376,8 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
return jsIPN.localAPI(args[0].String(), args[1].String(), body)
}),
}
wireDriveJS(jsIPN, driveFS, m)
return m
}
type jsIPN struct {
@@ -497,6 +483,7 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
NodeKey: nm.NodeKey.String(),
MachineKey: nm.MachineKey.String(),
PeerAPIURL: selfPeerAPIURL,
Services: userServicesFromView(nm.SelfNode.Hostinfo().Services()),
},
MachineStatus: jsMachineStatus[nm.GetMachineStatus()],
},
@@ -512,32 +499,7 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
}
// Peer peerAPI URL from the peer's advertised Services.
peerURL := ""
var pp4, pp6 uint16
for _, s := range p.Hostinfo().Services().All() {
switch s.Proto {
case tailcfg.PeerAPI4:
pp4 = s.Port
case tailcfg.PeerAPI6:
pp6 = s.Port
}
}
if selfHave4 && pp4 != 0 {
for _, a := range p.Addresses().All() {
if a.IsSingleIP() && a.Addr().Is4() {
peerURL = fmt.Sprintf("http://%v", netip.AddrPortFrom(a.Addr(), pp4))
break
}
}
}
if peerURL == "" && selfHave6 && pp6 != 0 {
for _, a := range p.Addresses().All() {
if a.IsSingleIP() && a.Addr().Is6() {
peerURL = fmt.Sprintf("http://%v", netip.AddrPortFrom(a.Addr(), pp6))
break
}
}
}
peerURL := buildPeerAPIURL(p, selfHave4, selfHave6)
return jsNetMapPeerNode{
jsNetMapNode: jsNetMapNode{
@@ -546,6 +508,7 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
MachineKey: p.Machine().String(),
NodeKey: p.Key().String(),
PeerAPIURL: peerURL,
Services: userServicesFromView(p.Hostinfo().Services()),
},
Online: p.Online().Clone(),
TailscaleSSHEnabled: p.Hostinfo().TailscaleSSHEnabled(),
@@ -657,201 +620,18 @@ func (i *jsIPN) logout() {
func (i *jsIPN) shutdown() js.Value {
return makePromise(func() (any, error) {
i.shutdownOnce.Do(func() {
i.lb.Shutdown()
i.ln.Close()
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,
host: host,
username: username,
termConfig: termConfig,
}
go jsSSHSession.Run()
return map[string]any{
"close": js.FuncOf(func(this js.Value, args []js.Value) any {
return jsSSHSession.Close() != nil
}),
"resize": js.FuncOf(func(this js.Value, args []js.Value) any {
rows := args[0].Int()
cols := args[1].Int()
return jsSSHSession.Resize(rows, cols) != nil
}),
}
}
type jsSSHSession struct {
jsIPN *jsIPN
host string
username string
termConfig js.Value
session *ssh.Session
pendingResizeRows int
pendingResizeCols int
}
func (s *jsSSHSession) Run() {
writeFn := s.termConfig.Get("writeFn")
writeErrorFn := s.termConfig.Get("writeErrorFn")
setReadFn := s.termConfig.Get("setReadFn")
rows := s.termConfig.Get("rows").Int()
cols := s.termConfig.Get("cols").Int()
timeoutSeconds := 5.0
if jsTimeoutSeconds := s.termConfig.Get("timeoutSeconds"); jsTimeoutSeconds.Type() == js.TypeNumber {
timeoutSeconds = jsTimeoutSeconds.Float()
}
onConnectionProgress := s.termConfig.Get("onConnectionProgress")
onConnected := s.termConfig.Get("onConnected")
onDone := s.termConfig.Get("onDone")
defer onDone.Invoke()
writeError := func(label string, err error) {
writeErrorFn.Invoke(fmt.Sprintf("%s Error: %v\r\n", label, err))
}
reportProgress := func(message string) {
onConnectionProgress.Invoke(message)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSeconds*float64(time.Second)))
defer cancel()
reportProgress(fmt.Sprintf("Connecting to %s…", strings.Split(s.host, ".")[0]))
c, err := s.jsIPN.dialer.UserDial(ctx, "tcp", net.JoinHostPort(s.host, "22"))
if err != nil {
writeError("Dial", err)
return
}
defer c.Close()
config := &ssh.ClientConfig{
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
// Host keys are not used with Tailscale SSH, but we can use this
// callback to know that the connection has been established.
reportProgress("SSH connection established…")
return nil
},
User: s.username,
}
reportProgress("Starting SSH client…")
sshConn, _, _, err := ssh.NewClientConn(c, s.host, config)
if err != nil {
writeError("SSH Connection", err)
return
}
defer sshConn.Close()
sshClient := ssh.NewClient(sshConn, nil, nil)
defer sshClient.Close()
session, err := sshClient.NewSession()
if err != nil {
writeError("SSH Session", err)
return
}
s.session = session
defer session.Close()
stdin, err := session.StdinPipe()
if err != nil {
writeError("SSH Stdin", err)
return
}
session.Stdout = termWriter{writeFn}
session.Stderr = termWriter{writeFn}
setReadFn.Invoke(js.FuncOf(func(this js.Value, args []js.Value) any {
input := args[0].String()
_, err := stdin.Write([]byte(input))
if err != nil {
writeError("Write Input", err)
}
return nil
}))
// We might have gotten a resize notification since we started opening the
// session, pick up the latest size.
if s.pendingResizeRows != 0 {
rows = s.pendingResizeRows
}
if s.pendingResizeCols != 0 {
cols = s.pendingResizeCols
}
err = session.RequestPty("xterm", rows, cols, ssh.TerminalModes{})
if err != nil {
writeError("Pseudo Terminal", err)
return
}
err = session.Shell()
if err != nil {
writeError("Shell", err)
return
}
onConnected.Invoke()
err = session.Wait()
if err != nil {
writeError("Wait", err)
return
}
}
func (s *jsSSHSession) Close() error {
if s.session == nil {
// We never had a chance to open the session, ignore the close request.
return nil
}
return s.session.Close()
}
func (s *jsSSHSession) Resize(rows, cols int) error {
if s.session == nil {
s.pendingResizeRows = rows
s.pendingResizeCols = cols
return nil
}
return s.session.WindowChange(rows, cols)
}
func (i *jsIPN) fetch(url string) js.Value {
return makePromise(func() (any, error) {
c := &http.Client{
Transport: &http.Transport{
DialContext: i.dialer.UserDial,
},
}
res, err := c.Get(url)
if err != nil {
return nil, err
}
return map[string]any{
"status": res.StatusCode,
"statusText": res.Status,
"text": js.FuncOf(func(this js.Value, args []js.Value) any {
return makePromise(func() (any, error) {
defer res.Body.Close()
buf := new(bytes.Buffer)
if _, err := buf.ReadFrom(res.Body); err != nil {
return nil, err
}
return buf.String(), nil
})
}),
// TODO: populate a more complete JS Response object
}, nil
})
}
func (i *jsIPN) setExitNode(stableNodeID string) js.Value {
return makePromise(func() (any, error) {
mp := &ipn.MaskedPrefs{
@@ -863,13 +643,6 @@ func (i *jsIPN) setExitNode(stableNodeID string) js.Value {
})
}
func (i *jsIPN) setExitNodeEnabled(enabled bool) js.Value {
return makePromise(func() (any, error) {
_, err := i.lb.SetUseExitNodeEnabled(ipnauth.Self, enabled)
return nil, err
})
}
func (i *jsIPN) dial(network, addr string) js.Value {
return makePromise(func() (any, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
@@ -893,6 +666,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
@@ -910,6 +688,41 @@ func (i *jsIPN) listen(network, addr string) js.Value {
})
}
// tlsClientConfigFromJS builds a client tls.Config from optional JS options
// (serverName, insecureSkipVerify, caCerts). defaultServerName may be empty
// (STARTTLS upgrade case), in which case serverName must be provided unless
// insecureSkipVerify is set.
//
// On wasm there's no system root pool, so default to the baked-in
// LetsEncrypt roots (which is what `tailscale cert` uses for tailnet
// HTTPS endpoints). Callers can override with caCerts (PEM) or bypass
// entirely with insecureSkipVerify.
func tlsClientConfigFromJS(defaultServerName string, opts js.Value) (*tls.Config, error) {
cfg := &tls.Config{
ServerName: defaultServerName,
RootCAs: bakedroots.Get(),
}
if !opts.IsUndefined() && !opts.IsNull() {
if sn := opts.Get("serverName"); sn.Type() == js.TypeString {
cfg.ServerName = sn.String()
}
if iv := opts.Get("insecureSkipVerify"); iv.Type() == js.TypeBoolean {
cfg.InsecureSkipVerify = iv.Bool()
}
if ca := opts.Get("caCerts"); ca.Type() == js.TypeString {
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM([]byte(ca.String())) {
return nil, fmt.Errorf("caCerts: no valid PEM certificates found")
}
cfg.RootCAs = pool
}
}
if cfg.ServerName == "" && !cfg.InsecureSkipVerify {
return nil, fmt.Errorf("serverName is required unless insecureSkipVerify is set")
}
return cfg, nil
}
func (i *jsIPN) dialTLS(addr string, opts js.Value) js.Value {
return makePromise(func() (any, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
@@ -920,28 +733,9 @@ func (i *jsIPN) dialTLS(addr string, opts js.Value) js.Value {
return nil, fmt.Errorf("invalid address %q: %w", addr, err)
}
// On wasm there's no system root pool, so default to the
// baked-in LetsEncrypt roots (which is what `tailscale cert`
// uses for tailnet HTTPS endpoints). Callers can override with
// caCerts (PEM) or bypass entirely with insecureSkipVerify.
cfg := &tls.Config{
ServerName: host,
RootCAs: bakedroots.Get(),
}
if !opts.IsUndefined() && !opts.IsNull() {
if sn := opts.Get("serverName"); sn.Type() == js.TypeString {
cfg.ServerName = sn.String()
}
if iv := opts.Get("insecureSkipVerify"); iv.Type() == js.TypeBoolean {
cfg.InsecureSkipVerify = iv.Bool()
}
if ca := opts.Get("caCerts"); ca.Type() == js.TypeString {
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM([]byte(ca.String())) {
return nil, fmt.Errorf("caCerts: no valid PEM certificates found")
}
cfg.RootCAs = pool
}
cfg, err := tlsClientConfigFromJS(host, opts)
if err != nil {
return nil, err
}
rawConn, err := i.dialer.UserDial(ctx, "tcp", addr)
@@ -1320,10 +1114,10 @@ func (i *jsIPN) ping(ip string, pingType string, size int) js.Value {
return nil, fmt.Errorf("ping: invalid IP %q: %w", ip, err)
}
switch tailcfg.PingType(pingType) {
case tailcfg.PingDisco, tailcfg.PingTSMP, tailcfg.PingICMP, tailcfg.PingPeerAPI:
case 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)
return nil, fmt.Errorf("ping: unknown type %q, must be one of: TSMP, ICMP, peerapi", pingType)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
@@ -1373,6 +1167,39 @@ func (i *jsIPN) suggestExitNode() js.Value {
})
}
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{
@@ -1452,6 +1279,51 @@ func wrapConn(conn net.Conn) map[string]any {
"remoteAddr": js.FuncOf(func(this js.Value, args []js.Value) any {
return conn.RemoteAddr().String()
}),
// upgradeTLS wraps the conn in TLS in place (STARTTLS-style) and
// returns a new wrapped conn sharing the same underlying net.Conn;
// the old handle must not be used afterward. On any failure —
// configuration or handshake — the underlying conn is closed, so
// callers can treat every rejection as fatal to the connection.
// With isServer, certPem and keyPem are required; otherwise client
// options as in dialTLS apply.
"upgradeTLS": js.FuncOf(func(this js.Value, args []js.Value) any {
opts := js.Undefined()
if len(args) > 0 {
opts = args[0]
}
return makePromise(func() (any, error) {
var tlsConn *tls.Conn
hasOpts := !opts.IsUndefined() && !opts.IsNull()
if hasOpts && opts.Get("isServer").Type() == js.TypeBoolean && opts.Get("isServer").Bool() {
certPem := opts.Get("certPem")
keyPem := opts.Get("keyPem")
if certPem.Type() != js.TypeString || keyPem.Type() != js.TypeString {
conn.Close()
return nil, fmt.Errorf("upgradeTLS: certPem and keyPem are required when isServer is set")
}
cert, err := tls.X509KeyPair([]byte(certPem.String()), []byte(keyPem.String()))
if err != nil {
conn.Close()
return nil, fmt.Errorf("upgradeTLS: parsing cert/key: %w", err)
}
tlsConn = tls.Server(conn, &tls.Config{Certificates: []tls.Certificate{cert}})
} else {
cfg, err := tlsClientConfigFromJS("", opts)
if err != nil {
conn.Close()
return nil, err
}
tlsConn = tls.Client(conn, cfg)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := tlsConn.HandshakeContext(ctx); err != nil {
conn.Close()
return nil, err
}
return wrapConn(tlsConn), nil
})
}),
}
}
@@ -1570,16 +1442,6 @@ func resolveUDPAddr(s string) (*net.UDPAddr, error) {
return &net.UDPAddr{IP: ip, Port: port}, nil
}
type termWriter struct {
f js.Value
}
func (w termWriter) Write(p []byte) (n int, err error) {
r := bytes.Replace(p, []byte("\n"), []byte("\n\r"), -1)
w.f.Invoke(string(r))
return len(p), nil
}
// jsIncomingFile is the JSON representation of an in-progress inbound file
// transfer sent to the notifyIncomingFiles callback.
type jsIncomingFile struct {
@@ -1609,12 +1471,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"`
PeerAPIURL string `json:"peerAPIURL,omitempty"`
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 {
+27 -43
View File
@@ -36,49 +36,36 @@ var baseTags = []string{
"omitpemdecrypt",
}
// Keep is the set of feature/featuretags tags the cmd/tsconnect/wasm
// build needs LINKED. Every other feature in [featuretags.Features] is
// excluded via its ts_omit_ build tag (computed by [Tags]).
// Transitive dependencies of entries in Keep are pulled in
// automatically via [featuretags.Requires].
// Omit is the set of feature/featuretags tags excluded from the
// cmd/tsconnect/wasm build via their ts_omit_ build tag (computed by
// [Tags]). Everything else in [featuretags.Features] stays linked.
//
// Upstream uses the opposite polarity here — a small allow-list — because
// its wasm client is only an SSH/fetch-in-browser tool. This fork's JS
// bridge exposes Taildrop, Taildrive, Funnel/serve, ACME certs, exit node
// selection, service advertisement and the peerAPI, so an allow-list is
// the wrong default: a missing entry is not a compile error, it is a
// feature that silently stops working at runtime (an omitted extension
// simply never registers its hooks). Linking everything also matches how
// this build behaved before upstream introduced featuretags.
//
// Adding an entry here grows the wasm bundle. Removing one strips it.
// The init() below panics if any entry is unknown to feature/featuretags,
// so a rename / removal in that registry fails loudly here.
//
// Notably absent (server-only or otherwise meaningless in a browser):
// - "ssh": controls the SSH *server* (feature/ssh registers
// ssh/tailssh). The wasm acts as an SSH *client* using
// golang.org/x/crypto/ssh directly; no featuretag gates that.
// - "portmapper", "debugportmapper": js/wasm has no UDP sockets,
// can't speak NAT-PMP / PCP / UPnP.
// - "captiveportal": the browser handles captive portal detection
// in front of us.
// - "syspolicy": no MDM in a browser.
// - "drive", "taildrop", "peerapi*": no local filesystem.
// - "clientupdate": no binary self-update.
// - "dbus", "resolved", "networkmanager", "iptables", "linkspeed",
// "linuxdnsfight", "listenrawdisco", "osrouter", "synology",
// "systray", "tundevstats", "wakeonlan": OS integrations not
// applicable to a browser-hosted client.
// - "aws", "cloud", "kube", "bird", "appconnectors", "conn25",
// "relayserver", "serve", "acme", "tap", "tpm", "doctor",
// "advertiseroutes", "advertiseexitnode", "useroutes",
// "useexitnode": server-side or otherwise out of scope for the
// SSH-in-browser / fetch-in-browser use case.
var Keep = []featuretags.FeatureTag{
"c2n", // control-to-node mechanism the control client invokes
"dns", // MagicDNS resolution in-process
"health", // ipnstate/ipnlocal reference health warnables pervasively
"ipnbus", // notification bus for state/netmap callbacks
"logtail", // log upload (browser console + remote)
"netstack", // userspace networking; wasm has no kernel TUN
// Trimming the bundle by omitting more features is worthwhile but should
// be done with measurements and per-feature runtime verification, not by
// assuming a feature is unreachable from the browser.
var Omit = []featuretags.FeatureTag{
// feature/ace does not compile for GOOS=js: control/controlhttp only
// installs HookMakeACEDialer on non-js platforms, so feature/ace's
// reference to it is undefined here.
"ace",
}
func init() {
for _, ft := range Keep {
for _, ft := range Omit {
if _, ok := featuretags.Features[ft]; !ok {
panic(fmt.Sprintf("wasmbuild.Keep references unknown feature tag %q; "+
panic(fmt.Sprintf("wasmbuild.Omit references unknown feature tag %q; "+
"did feature/featuretags rename or remove it?", ft))
}
}
@@ -103,25 +90,22 @@ type BuildInfo struct {
}
// Tags returns the joined -tags value for the wasm build: [baseTags]
// plus a ts_omit_<feature> for every entry in [featuretags.Features]
// that is not transitively required by [Keep].
// plus a ts_omit_<feature> for every entry in [Omit].
//
// The result is sorted so that the same source tree always produces
// the same string (and therefore the same wasm bytes, given identical
// inputs to `go build`).
func Tags() string {
keep := map[featuretags.FeatureTag]bool{}
for _, ft := range Keep {
for dep := range featuretags.Requires(ft) {
keep[dep] = true
}
omit := map[featuretags.FeatureTag]bool{}
for _, ft := range Omit {
omit[ft] = true
}
tags := slices.Clone(baseTags)
for ft := range featuretags.Features {
if ft == "" || !ft.IsOmittable() {
continue
}
if !keep[ft] {
if omit[ft] {
tags = append(tags, ft.OmitTag())
}
}
+6
View File
@@ -304,6 +304,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)
+35 -1
View File
@@ -337,6 +337,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
@@ -1762,6 +1763,13 @@ func (b *LocalBackend) PeerCaps(src netip.Addr) tailcfg.PeerCapMap {
return b.currentNode().PeerCaps(src)
}
// PeerCapsIncludingUnsigned is like [LocalBackend.PeerCaps] but does not deny
// capabilities to peers with UnsignedPeerAPIOnly set. It exists only for the
// Funnel ingress path; see [nodeBackend.PeerCapsIncludingUnsigned].
func (b *LocalBackend) PeerCapsIncludingUnsigned(src netip.Addr) tailcfg.PeerCapMap {
return b.currentNode().PeerCapsIncludingUnsigned(src)
}
// PeerCapsForIP returns the capabilities that remote src IP has when
// talking to the given destination IP on this node.
func (b *LocalBackend) PeerCapsForIP(src, dst netip.Addr) tailcfg.PeerCapMap {
@@ -5681,6 +5689,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.
//
@@ -5725,7 +5757,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
+22
View File
@@ -432,10 +432,32 @@ func (nb *nodeBackend) srcIsUnsignedPeerLocked(src netip.Addr) bool {
return ok && n.UnsignedPeerAPIOnly()
}
// PeerCapsIncludingUnsigned is like [nodeBackend.PeerCaps] but does not deny
// capabilities to peers with UnsignedPeerAPIOnly set.
//
// Funnel ingress relays are delivered as UnsignedPeerAPIOnly nodes: per the
// docs on [tailcfg.Node.UnsignedPeerAPIOnly] they get no network access at all
// and exist solely to reach this node's peerapi. The ingress endpoint they need
// is gated on [tailcfg.PeerCapabilityIngress], so denying them capabilities
// wholesale — as peerCapsLocked does upstream as of 0eb38dc2e — makes Funnel
// impossible. Callers must therefore be limited to the ingress path.
//
// This is a fork-local patch; drop it once upstream restores Funnel.
// See webnet/tailscale#16.
func (nb *nodeBackend) PeerCapsIncludingUnsigned(src netip.Addr) tailcfg.PeerCapMap {
nb.mu.Lock()
defer nb.mu.Unlock()
return nb.peerCapsIgnoringSignatureLocked(src)
}
func (nb *nodeBackend) peerCapsLocked(src netip.Addr) tailcfg.PeerCapMap {
if nb.srcIsUnsignedPeerLocked(src) {
return nil
}
return nb.peerCapsIgnoringSignatureLocked(src)
}
func (nb *nodeBackend) peerCapsIgnoringSignatureLocked(src netip.Addr) tailcfg.PeerCapMap {
if nb.netMap == nil {
return nil
}
+7 -1
View File
@@ -592,8 +592,14 @@ func (h *peerAPIHandler) canDebug() bool {
var allowSelfIngress = envknob.RegisterBool("TS_ALLOW_SELF_INGRESS")
// canIngress reports whether h can send ingress requests to this node.
//
// The ingress cap is resolved without the unsigned-peer denial that
// [nodeBackend.PeerCaps] applies, because Funnel ingress relays are by design
// UnsignedPeerAPIOnly nodes whose only permitted action is this endpoint.
// See [nodeBackend.PeerCapsIncludingUnsigned].
func (h *peerAPIHandler) canIngress() bool {
return h.peerHasCap(tailcfg.PeerCapabilityIngress) || (allowSelfIngress() && h.isSelf)
caps := h.ps.b.PeerCapsIncludingUnsigned(h.remoteAddr.Addr())
return caps.HasCapability(tailcfg.PeerCapabilityIngress) || (allowSelfIngress() && h.isSelf)
}
func (h *peerAPIHandler) peerHasCap(wantCap tailcfg.PeerCapability) bool {
+8 -1
View File
@@ -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) {
Executable
BIN
View File
Binary file not shown.