30 Commits
Author SHA1 Message Date
codingetandClaude dbe32de290 fix(tsconnect): link fork features into the wasm build
Upstream's wasmbuild.Keep is scoped to its SSH-in-browser client and omits
acme, serve, taildrop, drive and the exit node features. The JS bridge in
this fork exposes all of them, so they have to stay linked in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 17:44:01 +00:00
codingetandClaude 86d4ec5c7c 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 17:42:13 +00:00
codingetandClaude 976e58a21a 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 17:42:13 +00:00
codingetandClaude d370fd4a73 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 17:42:13 +00:00
codingetandClaude bee799dffa 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 17:41:31 +00:00
codingetandClaude 5e0e9f688a 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 17:41:31 +00:00
codingetandClaude 6d272ab9a8 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 17:41:31 +00:00
codingetandClaude 34700154a4 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 17:40:39 +00:00
codingetandClaude e91dbcfbe5 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 17:40:39 +00:00
codingetandClaude e198818442 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 17:40:39 +00:00
codingetandClaude f1904a0f7d 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 17:40:39 +00:00
codingetandClaude c0acbfc399 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 17:40:39 +00:00
codingetandClaude 75965f86e3 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 17:40:39 +00:00
codingetandClaude 308312cbc6 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 17:40:06 +00:00
codingetandClaude 62368af810 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 17:40:06 +00:00
codingetandClaude a1aa13da24 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 17:40:06 +00:00
codingetandClaude f1708334a3 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 17:39:10 +00:00
codingetandClaude 90a32450ee 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 17:39:09 +00:00
codingetandClaude 4e00cfff66 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 17:35:39 +00:00
codingetandClaude 6c630c6abf 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 17:35:39 +00:00
codingetandClaude d36f64bea2 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 17:35:39 +00:00
codingetandClaude 10caf83326 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 17:35:39 +00:00
codingetandClaude d73f4278c7 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 17:35:39 +00:00
codingetandClaude 33289574ef 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 17:35:39 +00:00
codingetandClaude 0c6b23f427 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 17:31:43 +00:00
codingetandClaude 22e68fe5d9 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 17:31:43 +00:00
codingetandClaude 343b3fe583 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 17:31:43 +00:00
codinget 1a13f1cdb3 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 17:31:13 +00:00
codinget dec5041157 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 17:31:13 +00:00
codinget 434acfdf03 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 17:31:13 +00:00
11 changed files with 362 additions and 259 deletions
+13 -16
View File
@@ -5,7 +5,6 @@ import "../wasm_exec"
import wasmUrl from "./main.wasm"
import { sessionStateStorage } from "../lib/js-state-store"
import { renderApp } from "./app"
import { startIPN } from "../lib/start-ipn"
async function main() {
const app = await renderApp()
@@ -14,25 +13,23 @@ async function main() {
fetch(`./dist/${wasmUrl}`),
go.importObject
)
// The Go process should never exit, if it does then it's an unhandled panic.
go.run(wasmInstance.instance).then(() =>
app.handleGoPanic("Unexpected shutdown")
)
const params = new URLSearchParams(window.location.search)
const authKey = params.get("authkey") ?? undefined
// The Go process should never exit, if it does then it's an unhandled panic.
const ipn = await startIPN(
go,
wasmInstance.instance,
{
// Persist IPN state in sessionStorage in development, so that we don't
// need to re-authorize every time we reload the page.
stateStorage: DEBUG ? sessionStateStorage : undefined,
// authKey allows for an auth key to be
// specified as a url param which automatically
// authorizes the client for use.
authKey: DEBUG ? authKey : undefined,
},
(reason) => app.handleGoPanic(reason)
)
const ipn = newIPN({
// Persist IPN state in sessionStorage in development, so that we don't need
// to re-authorize every time we reload the page.
stateStorage: DEBUG ? sessionStateStorage : undefined,
// authKey allows for an auth key to be
// specified as a url param which automatically
// authorizes the client for use.
authKey: DEBUG ? authKey : undefined,
})
app.runWithIPN(ipn)
}
-87
View File
@@ -1,87 +0,0 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
/**
* Starts a Go runtime and returns the single IPN it owns.
*
* The runtime does not publish its bridge on a global. It reads the name of a
* callback from its environment and invokes it once the bridge is ready, so
* this resolves on an explicit signal rather than on the Go scheduler having
* run far enough. The name is generated per runtime, so several runtimes can
* start in one page without racing each other.
*
* The returned IPN replaces the bridge's shutdown() with one that resolves when
* the runtime has actually exited. The raw promise cannot be awaited: it races
* with the runtime tearing itself down, so it may never settle.
*/
export async function startIPN(
go: Go,
instance: WebAssembly.Instance,
config: IPNConfig,
onExit: (reason: string) => void
): Promise<IPN> {
const name = `__tsconnectInit_${Math.random().toString(36).slice(2)}`
const globals = globalThis as Record<string, unknown>
const ready = new Promise<[NewIPN, Terminate]>((resolve) => {
globals[name] = (newIPN: NewIPN, terminate: Terminate) => {
delete globals[name]
resolve([newIPN, terminate])
}
})
go.env[INIT_CALLBACK_ENV] = name
// Only an exit the caller did not ask for is worth reporting. Before the
// handover every exit is a startup failure and rejecting hands it back as an
// error; afterwards, only an exit that shutdown() did not cause is a panic.
let stopping = false
const exited: Promise<void> = go.run(instance).then(() => {
delete globals[name]
if (!stopping) onExit("Unexpected shutdown")
})
// Reject alongside it, so an exit during startup fails the awaits below
// instead of leaving them pending forever.
const failed: Promise<never> = exited.then(() => {
throw new Error("Go runtime exited before the IPN was ready")
})
const [newIPN, terminate] = await Promise.race([ready, failed])
let ipn: IPN
try {
// Keep racing the runtime: building the backend runs in a Go goroutine, and
// if the runtime dies partway that goroutine dies with it and its promise
// never settles.
ipn = await Promise.race([newIPN(config), failed])
} catch (err) {
// Nothing was built, so nothing can shut the runtime down. Exit it here and
// wait for it, or the page keeps a blocked runtime for a failed startup.
// Calling terminate on an already-exited runtime does nothing.
stopping = true
terminate()
await exited
throw err
}
// Replace shutdown in place rather than wrapping the object: the bridge hands
// back a plain map of Go-backed functions, and copying it would leave the
// caller with something that only looks like the IPN.
const rawShutdown = ipn.shutdown.bind(ipn)
ipn.shutdown = async () => {
stopping = true
try {
void rawShutdown()
} catch {
// The runtime may already be gone, in which case there is nothing to ask
// and the await below returns immediately.
}
await exited
}
return ipn
}
type NewIPN = (config: IPNConfig) => Promise<IPN>
type Terminate = () => void
/** Must match initCallbackEnv in wasm_js.go. */
const INIT_CALLBACK_ENV = "TSCONNECT_INIT_CALLBACK"
+5 -2
View File
@@ -7,7 +7,6 @@
/// <reference path="../types/wasm_js.d.ts" />
import "../wasm_exec"
import { startIPN } from "../lib/start-ipn"
import wasmURL from "./main.wasm"
/**
@@ -31,7 +30,11 @@ export async function createIPN(config: IPNPackageConfig): Promise<IPN> {
go.importObject
)
// The Go process should never exit, if it does then it's an unhandled panic.
return startIPN(go, wasmInstance.instance, config, config.panicHandler)
go.run(wasmInstance.instance).then(() =>
config.panicHandler("Unexpected shutdown")
)
return newIPN(config)
}
export { runSSHSession } from "../lib/ssh"
+2 -8
View File
@@ -7,18 +7,12 @@
*/
declare global {
function newIPN(config: IPNConfig): IPN
interface IPN {
run(callbacks: IPNCallbacks): void
login(): void
logout(): void
/**
* Tears down the backend and exits the Go runtime that owns this IPN.
*
* The promise the bridge returns races with the runtime exiting and may
* never settle; startIPN replaces it with one that resolves when the
* runtime has actually gone.
*/
shutdown(): Promise<void>
ssh(
host: string,
username: string,
+5 -15
View File
@@ -240,14 +240,10 @@ type jsDrivePeer struct {
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.
// 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() {
@@ -273,13 +269,6 @@ func (i *jsIPN) listDrivePeers() js.Value {
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
@@ -292,6 +281,7 @@ func (i *jsIPN) listDrivePeers() js.Value {
if !hasCap {
continue
}
peerURL := buildPeerAPIURL(p, selfHave4, selfHave6)
online := p.Online().Clone()
peers = append(peers, jsDrivePeer{
Name: p.DisplayName(false),
+275 -67
View File
@@ -6,10 +6,11 @@
//
// 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(), and logout().
// run(callbacks), login(), logout(), and ssh(...).
package main
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
@@ -24,14 +25,13 @@ import (
"net/http"
"net/http/httptest"
"net/netip"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"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,10 +40,10 @@ 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"
"tailscale.com/ipn/ipnserver"
"tailscale.com/ipn/localapi"
"tailscale.com/ipn/store/mem"
"tailscale.com/logpolicy"
@@ -52,6 +52,7 @@ import (
"tailscale.com/net/netns"
"tailscale.com/net/tsaddr"
"tailscale.com/net/tsdial"
"tailscale.com/safesocket"
"tailscale.com/tailcfg"
"tailscale.com/tsd"
"tailscale.com/types/logid"
@@ -64,62 +65,21 @@ import (
// ControlURL defines the URL to be used for connection to Control.
var ControlURL = ipn.DefaultControlURL
// initCallbackEnv names the JS global holding the callback that this runtime
// hands its bridge to. The loader generates a name, installs the callback under
// it, and passes the name in through go.env before starting the runtime.
//
// Publishing the bridge through a caller-supplied callback rather than a fixed
// global means the loader never has to guess when the Go scheduler has run far
// enough to expose it, and two runtimes in one JS realm cannot collide on the
// name.
const initCallbackEnv = "TSCONNECT_INIT_CALLBACK"
func main() {
name := os.Getenv(initCallbackEnv)
if name == "" {
log.Fatalf("%s is not set; this module must be loaded by @webnet/tsconnect", initCallbackEnv)
}
callback := js.Global().Get(name)
if callback.Type() != js.TypeFunction {
log.Fatalf("globalThis[%q] is not a function", name)
}
shutdownCh := make(chan struct{})
var terminateOnce sync.Once
terminate := func() { terminateOnce.Do(func() { close(shutdownCh) }) }
var claimed atomic.Bool
newIPNFn := js.FuncOf(func(this js.Value, args []js.Value) any {
return makePromise(func() (any, error) {
if len(args) != 1 {
return nil, errors.New("newIPN takes exactly one argument")
}
// One IPN per runtime: shutdown exits the shared Go runtime, so a
// second IPN here would be torn down by the first one's shutdown.
if !claimed.CompareAndSwap(false, true) {
return nil, errors.New("this WASM runtime already has an IPN; start another runtime instead")
}
return newIPN(args[0], terminate)
})
})
// A failed newIPN leaves the runtime blocked below with nothing to shut it
// down, so the loader gets a way to exit it. Leaving that to the loader
// keeps the ordering right: closing shutdownCh here would let main return
// before makePromise had delivered the rejection.
terminateFn := js.FuncOf(func(this js.Value, args []js.Value) any {
terminate()
return nil
})
callback.Invoke(newIPNFn, terminateFn)
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)
}))
// 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, terminate func()) (map[string]any, error) {
func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
netns.SetEnabled(false)
var store ipn.StateStore
@@ -174,13 +134,13 @@ func newIPN(jsConfig js.Value, terminate func()) (map[string]any, error) {
EventBus: sys.Bus.Get(),
})
if err != nil {
return nil, fmt.Errorf("wgengine.NewUserspaceEngine: %w", err)
log.Fatal(err)
}
sys.Set(eng)
ns, err := netstack.Create(logf, sys.Tun.Get(), eng, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper())
if err != nil {
return nil, fmt.Errorf("netstack.Create: %w", err)
log.Fatalf("netstack.Create: %v", err)
}
sys.Set(ns)
ns.ProcessLocalIPs = true
@@ -217,17 +177,20 @@ func newIPN(jsConfig js.Value, terminate func()) (map[string]any, error) {
// 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 {
return nil, fmt.Errorf("ipnlocal.NewLocalBackend: %w", err)
log.Fatalf("ipnlocal.NewLocalBackend: %v", err)
}
if err := ns.Start(lb); err != nil {
return nil, fmt.Errorf("starting netstack: %w", err)
log.Fatalf("failed to start netstack: %v", err)
}
wireTaildropFileOps(lb, jsConfig.Get("fileOps"))
srv.SetLocalBackend(lb)
jsIPN := &jsIPN{
dialer: dialer,
srv: srv,
lb: lb,
ns: ns,
controlURL: controlURL,
@@ -235,7 +198,7 @@ func newIPN(jsConfig js.Value, terminate func()) (map[string]any, error) {
hostname: hostname,
logID: logid,
funnelPorts: make(map[uint16]*funnelListenerEntry),
terminate: terminate,
shutdownCh: shutdownCh,
}
lb.SetTCPHandlerForFunnelFlow(jsIPN.handleFunnelTCP)
@@ -269,6 +232,25 @@ func newIPN(jsConfig js.Value, terminate func()) (map[string]any, error) {
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)")
@@ -308,6 +290,13 @@ func newIPN(jsConfig js.Value, terminate func()) (map[string]any, error) {
}
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()
}),
@@ -415,11 +404,12 @@ func newIPN(jsConfig js.Value, terminate func()) (map[string]any, error) {
}),
}
wireDriveJS(jsIPN, driveFS, m)
return m, nil
return m
}
type jsIPN struct {
dialer *tsdial.Dialer
srv *ipnserver.Server
lb *ipnlocal.LocalBackend
ns *netstack.Impl
controlURL string
@@ -430,7 +420,10 @@ type jsIPN struct {
funnelMu sync.Mutex
funnelPorts map[uint16]*funnelListenerEntry
terminate func() // unblocks main() so the Go runtime can exit
// 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
}
@@ -622,6 +615,18 @@ 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)
}
}()
}
func (i *jsIPN) login() {
@@ -639,22 +644,208 @@ func (i *jsIPN) logout() {
}()
}
// shutdown tears down the backend and lets main return, which exits the whole
// Go runtime. Callers should await the runtime's exit rather than the promise
// returned here: terminating races with makePromise resolving, so the promise
// may never settle.
func (i *jsIPN) shutdown() js.Value {
return makePromise(func() (any, error) {
i.shutdownOnce.Do(func() {
if i.lb != nil {
i.lb.Shutdown()
}
i.terminate()
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{
@@ -666,6 +857,13 @@ 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)
@@ -1137,10 +1335,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.PingTSMP, tailcfg.PingICMP, tailcfg.PingPeerAPI:
case tailcfg.PingDisco, tailcfg.PingTSMP, tailcfg.PingICMP, tailcfg.PingPeerAPI:
// valid
default:
return nil, fmt.Errorf("ping: unknown type %q, must be one of: TSMP, ICMP, peerapi", pingType)
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()
@@ -1465,6 +1663,16 @@ 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 {
+53 -27
View File
@@ -36,36 +36,59 @@ var baseTags = []string{
"omitpemdecrypt",
}
// 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.
// 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].
//
// 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.
//
// 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",
// 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.
// - "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", "tap", "tpm", "doctor", "advertiseroutes",
// "useroutes": server-side or otherwise out of scope.
//
// This fork keeps considerably more than upstream's SSH-in-browser
// build: the JS bridge exposes Taildrop, Taildrive, Funnel/serve, ACME
// certs and exit node selection, all of which need their feature linked
// in. The taildrop/drive FileOps are backed by JS callbacks rather than
// a real filesystem, so the usual "no local filesystem" objection to
// linking them into wasm doesn't apply here.
var Keep = []featuretags.FeatureTag{
"acme", // getCert / listenTLS need ACME cert issuance
"advertiseexitnode", // exit node advertisement via the JS bridge
"c2n", // control-to-node mechanism the control client invokes
"dns", // MagicDNS resolution in-process
"drive", // Taildrive WebDAV server exposed to JS
"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
"serve", // setFunnel / listenTLS serve config
"taildrop", // file send/receive over a JS-backed FileOps
"useexitnode", // exit node selection exposed to JS
}
func init() {
for _, ft := range Omit {
for _, ft := range Keep {
if _, ok := featuretags.Features[ft]; !ok {
panic(fmt.Sprintf("wasmbuild.Omit references unknown feature tag %q; "+
panic(fmt.Sprintf("wasmbuild.Keep references unknown feature tag %q; "+
"did feature/featuretags rename or remove it?", ft))
}
}
@@ -90,22 +113,25 @@ type BuildInfo struct {
}
// Tags returns the joined -tags value for the wasm build: [baseTags]
// plus a ts_omit_<feature> for every entry in [Omit].
// plus a ts_omit_<feature> for every entry in [featuretags.Features]
// that is not transitively required by [Keep].
//
// 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 {
omit := map[featuretags.FeatureTag]bool{}
for _, ft := range Omit {
omit[ft] = true
keep := map[featuretags.FeatureTag]bool{}
for _, ft := range Keep {
for dep := range featuretags.Requires(ft) {
keep[dep] = true
}
}
tags := slices.Clone(baseTags)
for ft := range featuretags.Features {
if ft == "" || !ft.IsOmittable() {
continue
}
if omit[ft] {
if !keep[ft] {
tags = append(tags, ft.OmitTag())
}
}
-7
View File
@@ -1763,13 +1763,6 @@ 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 {
-22
View File
@@ -432,32 +432,10 @@ 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
}
+1 -7
View File
@@ -592,14 +592,8 @@ 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 {
caps := h.ps.b.PeerCapsIncludingUnsigned(h.remoteAddr.Addr())
return caps.HasCapability(tailcfg.PeerCapabilityIngress) || (allowSelfIngress() && h.isSelf)
return h.peerHasCap(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) {