8 Commits

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:57:27 +00:00
codinget 0df765eb60 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-06-06 23:01:45 +00:00
codinget 52cae45f81 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-05-10 15:28:50 +00:00
codinget 7fd2507611 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-05-10 15:20:40 +00:00
codinget 8514045909 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-05-10 01:19:37 +00:00
codinget 7f5983eaab 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-05-09 21:55:58 +00:00
codinget 143581c955 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-05-06 11:19:25 +00:00
codinget d9efc3bae2 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-04-18 20:04:20 +00:00
7 changed files with 686 additions and 29 deletions
-12
View File
@@ -13,7 +13,6 @@ import (
"path"
"github.com/tailscale/hujson"
"tailscale.com/util/precompress"
"tailscale.com/version"
)
@@ -39,10 +38,6 @@ func runBuildPkg() {
runEsbuild(*buildOptions)
if err := precompressWasm(); err != nil {
log.Fatalf("Could not pre-recompress wasm: %v", err)
}
log.Printf("Generating types...\n")
if err := runYarn("pkg-types"); err != nil {
log.Fatalf("Type generation failed: %v", err)
@@ -59,13 +54,6 @@ func runBuildPkg() {
log.Printf("Built package version %s", version.Long())
}
func precompressWasm() error {
log.Printf("Pre-compressing main.wasm...\n")
return precompress.Precompress(path.Join(*pkgDir, "main.wasm"), precompress.Options{
FastCompression: *fastCompression,
})
}
func updateVersion() error {
packageJSONBytes, err := os.ReadFile("package.json.tmpl")
if err != nil {
+1 -1
View File
@@ -9,7 +9,7 @@
"sourceMap": true,
"jsx": "react-jsx",
"jsxImportSource": "preact",
"skipLibCheck": true
"types": ["golang-wasm-exec", "qrcode"]
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
+637 -13
View File
@@ -18,17 +18,21 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math/rand/v2"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"strconv"
"strings"
"sync"
"syscall/js"
"time"
"golang.org/x/crypto/ssh"
"golang.org/x/net/dns/dnsmessage"
"gvisor.dev/gvisor/pkg/tcpip"
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
"gvisor.dev/gvisor/pkg/tcpip/network/ipv4"
@@ -40,6 +44,7 @@ import (
"tailscale.com/ipn/ipnauth"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/ipnserver"
"tailscale.com/ipn/localapi"
"tailscale.com/ipn/store/mem"
"tailscale.com/logpolicy"
"tailscale.com/logtail"
@@ -50,6 +55,7 @@ import (
"tailscale.com/safesocket"
"tailscale.com/tailcfg"
"tailscale.com/tsd"
"tailscale.com/types/logid"
"tailscale.com/types/views"
"tailscale.com/wgengine"
"tailscale.com/wgengine/netstack"
@@ -164,14 +170,17 @@ func newIPN(jsConfig js.Value) map[string]any {
srv.SetLocalBackend(lb)
jsIPN := &jsIPN{
dialer: dialer,
srv: srv,
lb: lb,
ns: ns,
controlURL: controlURL,
authKey: authKey,
hostname: hostname,
dialer: dialer,
srv: srv,
lb: lb,
ns: ns,
controlURL: controlURL,
authKey: authKey,
hostname: hostname,
logID: logid,
funnelPorts: make(map[uint16]*funnelListenerEntry),
}
lb.SetTCPHandlerForFunnelFlow(jsIPN.handleFunnelTCP)
return map[string]any{
"run": js.FuncOf(func(this js.Value, args []js.Value) any {
@@ -295,6 +304,81 @@ func newIPN(jsConfig js.Value) map[string]any {
}
return jsIPN.deleteWaitingFile(args[0].String())
}),
"getCert": js.FuncOf(func(this js.Value, args []js.Value) any {
return jsIPN.getCert()
}),
"listenTLS": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 3 {
log.Printf("Usage: listenTLS(addr, certPEM, keyPEM)")
return nil
}
return jsIPN.listenTLS(args[0].String(), args[1].String(), args[2].String())
}),
"setFunnel": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 3 {
log.Printf("Usage: setFunnel(hostname, port, enabled)")
return nil
}
return jsIPN.setFunnel(args[0].String(), uint16(args[1].Int()), args[2].Bool())
}),
"whoIs": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) < 1 {
log.Printf("Usage: whoIs(addrPort[, proto])")
return nil
}
proto := ""
if len(args) >= 2 {
proto = args[1].String()
}
return jsIPN.whoIs(args[0].String(), proto)
}),
"queryDNS": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) < 1 {
log.Printf("Usage: queryDNS(name[, type])")
return nil
}
qtype := 1 // TypeA
if len(args) >= 2 {
qtype = args[1].Int()
}
return jsIPN.queryDNS(args[0].String(), qtype)
}),
"ping": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) < 1 {
log.Printf("Usage: ping(ip[, type[, size]])")
return nil
}
pingType := "TSMP"
if len(args) >= 2 {
pingType = args[1].String()
}
size := 0
if len(args) >= 3 {
size = args[2].Int()
}
return jsIPN.ping(args[0].String(), pingType, size)
}),
"suggestExitNode": js.FuncOf(func(this js.Value, args []js.Value) any {
return jsIPN.suggestExitNode()
}),
"setServices": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 1 {
log.Printf("Usage: setServices(services)")
return nil
}
return jsIPN.setServices(args[0])
}),
"localAPI": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) < 2 {
log.Printf("Usage: localAPI(method, path[, body])")
return nil
}
body := ""
if len(args) >= 3 {
body = args[2].String()
}
return jsIPN.localAPI(args[0].String(), args[1].String(), body)
}),
}
}
@@ -306,6 +390,16 @@ type jsIPN struct {
controlURL string
authKey string
hostname string
logID logid.PublicID
funnelMu sync.Mutex
funnelPorts map[uint16]*funnelListenerEntry
}
// funnelListenerEntry is the per-port state for routing Funnel connections to a listenTLS listener.
type funnelListenerEntry struct {
ch chan net.Conn
tlsCfg *tls.Config
}
var jsIPNState = map[ipn.State]string{
@@ -347,6 +441,31 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
notifyState(*n.State)
}
if nm := n.NetMap; nm != nil {
// Determine which address families we have, for peer peerAPI URL selection.
var selfHave4, selfHave6 bool
for _, a := range nm.GetAddresses().All() {
if !a.IsSingleIP() {
continue
}
if a.Addr().Is4() {
selfHave4 = true
} else if a.Addr().Is6() {
selfHave6 = true
}
}
// Self peerAPI URL: own port as reported by LocalBackend.
selfPeerAPIURL := ""
for _, a := range nm.GetAddresses().All() {
if !a.IsSingleIP() {
continue
}
if port, ok := i.lb.GetPeerAPIPort(a.Addr()); ok && port != 0 {
selfPeerAPIURL = fmt.Sprintf("http://%v", netip.AddrPortFrom(a.Addr(), port))
break
}
}
jsNetMap := jsNetMap{
Self: jsNetMapSelfNode{
jsNetMapNode: jsNetMapNode{
@@ -354,6 +473,8 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
Addresses: mapSliceView(nm.GetAddresses(), func(a netip.Prefix) string { return a.Addr().String() }),
NodeKey: nm.NodeKey.String(),
MachineKey: nm.MachineKey.String(),
PeerAPIURL: selfPeerAPIURL,
Services: userServicesFromView(nm.SelfNode.Hostinfo().Services()),
},
MachineStatus: jsMachineStatus[nm.GetMachineStatus()],
},
@@ -364,15 +485,46 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
name = p.Hostinfo().Hostname()
}
addrs := make([]string, p.Addresses().Len())
for i, ap := range p.Addresses().All() {
addrs[i] = ap.Addr().String()
for idx, ap := range p.Addresses().All() {
addrs[idx] = ap.Addr().String()
}
// Peer peerAPI URL from the peer's advertised Services.
peerURL := ""
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
}
}
}
return jsNetMapPeerNode{
jsNetMapNode: jsNetMapNode{
Name: name,
Addresses: addrs,
MachineKey: p.Machine().String(),
NodeKey: p.Key().String(),
PeerAPIURL: peerURL,
Services: userServicesFromView(p.Hostinfo().Services()),
},
Online: p.Online().Clone(),
TailscaleSSHEnabled: p.Hostinfo().TailscaleSSHEnabled(),
@@ -797,6 +949,470 @@ func (i *jsIPN) listenICMP(network string) js.Value {
})
}
func (i *jsIPN) getCert() js.Value {
return makePromise(func() (any, error) {
nm := i.lb.NetMap()
if nm == nil {
return nil, errors.New("getCert: no network map available")
}
certDomains := nm.DNS.CertDomains
if len(certDomains) == 0 {
return nil, errors.New("getCert: this tailnet does not support TLS certificates")
}
pair, err := i.lb.GetCertPEM(context.Background(), certDomains[0])
if err != nil {
return nil, err
}
return map[string]any{
"certPEM": string(pair.CertPEM),
"keyPEM": string(pair.KeyPEM),
}, nil
})
}
func (i *jsIPN) listenTLS(addr, certPEM, keyPEM string) js.Value {
return makePromise(func() (any, error) {
cert, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM))
if err != nil {
return nil, fmt.Errorf("listenTLS: parsing cert/key: %w", err)
}
tlsCfg := &tls.Config{Certificates: []tls.Certificate{cert}}
tcpLn, err := i.ns.ListenTCP("tcp4", addr)
if err != nil {
return nil, err
}
// Determine the actual port (handles ":0" ephemeral assignment).
// Use SplitHostPort rather than netip.ParseAddrPort because gVisor
// may return ":443" (empty host) which ParseAddrPort rejects.
_, portStr, err := net.SplitHostPort(tcpLn.Addr().String())
if err != nil {
tcpLn.Close()
return nil, fmt.Errorf("listenTLS: getting port from listener addr: %w", err)
}
portNum, err := strconv.ParseUint(portStr, 10, 16)
if err != nil {
tcpLn.Close()
return nil, fmt.Errorf("listenTLS: parsing port %q: %w", portStr, err)
}
port := uint16(portNum)
// Register a Funnel entry so handleFunnelTCP can route to this listener.
entry := &funnelListenerEntry{
ch: make(chan net.Conn, 8),
tlsCfg: tlsCfg,
}
i.funnelMu.Lock()
i.funnelPorts[port] = entry
i.funnelMu.Unlock()
ln := newCombinedTLSListener(tcpLn, tlsCfg, entry.ch, port, i)
return wrapTCPListener(ln), nil
})
}
// handleFunnelTCP is registered with LocalBackend.SetTCPHandlerForFunnelFlow.
// It routes incoming Funnel connections to the matching listenTLS listener.
func (i *jsIPN) handleFunnelTCP(src netip.AddrPort, dstPort uint16) func(net.Conn) {
i.funnelMu.Lock()
entry := i.funnelPorts[dstPort]
i.funnelMu.Unlock()
if entry == nil {
return nil
}
return func(conn net.Conn) {
tlsConn := tls.Server(conn, entry.tlsCfg)
select {
case entry.ch <- tlsConn:
default:
// Channel full; drop the connection rather than block.
conn.Close()
}
}
}
// combinedTLSListener merges TLS connections from the local netstack (direct
// tailnet access) and from Funnel ingress into a single net.Listener.
type combinedTLSListener struct {
tcpLn net.Listener
tlsCfg *tls.Config
funnelCh <-chan net.Conn
port uint16
ipn *jsIPN
netstackCh chan net.Conn
errCh chan error
done chan struct{}
closeOnce sync.Once
}
func newCombinedTLSListener(tcpLn net.Listener, tlsCfg *tls.Config, funnelCh <-chan net.Conn, port uint16, ipn *jsIPN) *combinedTLSListener {
l := &combinedTLSListener{
tcpLn: tcpLn,
tlsCfg: tlsCfg,
funnelCh: funnelCh,
port: port,
ipn: ipn,
netstackCh: make(chan net.Conn, 8),
errCh: make(chan error, 1),
done: make(chan struct{}),
}
go l.drainNetstack()
return l
}
func (l *combinedTLSListener) drainNetstack() {
for {
conn, err := l.tcpLn.Accept()
if err != nil {
select {
case l.errCh <- err:
default:
}
return
}
tlsConn := tls.Server(conn, l.tlsCfg)
select {
case l.netstackCh <- tlsConn:
case <-l.done:
conn.Close()
return
}
}
}
func (l *combinedTLSListener) Accept() (net.Conn, error) {
select {
case conn := <-l.funnelCh:
return conn, nil
case conn := <-l.netstackCh:
return conn, nil
case err := <-l.errCh:
return nil, err
case <-l.done:
return nil, net.ErrClosed
}
}
func (l *combinedTLSListener) Close() error {
l.closeOnce.Do(func() {
close(l.done)
l.tcpLn.Close()
l.ipn.funnelMu.Lock()
delete(l.ipn.funnelPorts, l.port)
l.ipn.funnelMu.Unlock()
})
return nil
}
func (l *combinedTLSListener) Addr() net.Addr {
return l.tcpLn.Addr()
}
func (i *jsIPN) setFunnel(hostname string, port uint16, enabled bool) js.Value {
return makePromise(func() (any, error) {
hp := ipn.HostPort(fmt.Sprintf("%s:%d", hostname, port))
var cfg *ipn.ServeConfig
if enabled {
cfg = &ipn.ServeConfig{
AllowFunnel: map[ipn.HostPort]bool{hp: true},
}
} else {
cfg = &ipn.ServeConfig{}
}
return nil, i.lb.SetServeConfig(cfg, "")
})
}
func (i *jsIPN) whoIs(addr string, proto string) js.Value {
return makePromise(func() (any, error) {
// Accept both "ip:port" and bare "ip" (port 0 still resolves by IP).
var ipp netip.AddrPort
if ap, err := netip.ParseAddrPort(addr); err == nil {
ipp = ap
} else if ip, err := netip.ParseAddr(addr); err == nil {
ipp = netip.AddrPortFrom(ip, 0)
} else {
return nil, fmt.Errorf("whoIs: invalid address %q (want ip:port or ip)", addr)
}
n, u, ok := i.lb.WhoIs(proto, ipp)
if !ok {
return nil, nil
}
addrs := make([]any, n.Addresses().Len())
for idx, ap := range n.Addresses().All() {
addrs[idx] = ap.Addr().String()
}
return map[string]any{
"node": map[string]any{
"id": string(n.StableID()),
"name": n.Name(),
"addresses": addrs,
},
"user": map[string]any{
"id": int64(u.ID),
"loginName": u.LoginName,
"displayName": u.DisplayName,
"profilePicURL": u.ProfilePicURL,
},
}, nil
})
}
func (i *jsIPN) queryDNS(name string, queryType int) js.Value {
return makePromise(func() (any, error) {
res, resolvers, err := i.lb.QueryDNS(name, dnsmessage.Type(queryType))
// Detect SERVFAIL with no upstream resolvers (common when an exit node is
// active but the DNS manager forwarder has no configured upstreams). Fall
// back to querying 8.8.8.8 via the dialer (which routes through the exit
// node), then as a last resort use the browser's default name resolver.
needsFallback := err != nil
if !needsFallback && len(resolvers) == 0 && len(res) > 0 {
var hdrParser dnsmessage.Parser
if hdr, hdrErr := hdrParser.Start(res); hdrErr == nil && hdr.RCode == dnsmessage.RCodeServerFailure {
needsFallback = true
}
}
if needsFallback {
qt := dnsmessage.Type(queryType)
if qt != dnsmessage.TypeA && qt != dnsmessage.TypeAAAA {
if err != nil {
return nil, fmt.Errorf("queryDNS: %w (no upstream resolver; only A/AAAA queries support fallback)", err)
}
return nil, fmt.Errorf("queryDNS: no upstream resolver available; only A/AAAA queries support fallback lookup")
}
ctx := context.Background()
d := i.dialer
r := &net.Resolver{
PreferGo: true,
Dial: func(rctx context.Context, network, address string) (net.Conn, error) {
return d.UserDial(rctx, "tcp", "8.8.8.8:53")
},
}
ips, rerr := r.LookupIPAddr(ctx, name)
if rerr != nil {
// Last resort: browser-native resolution (no exit-node routing).
ips, rerr = (&net.Resolver{PreferGo: false}).LookupIPAddr(ctx, name)
if rerr != nil {
return nil, fmt.Errorf("queryDNS: fallback resolution failed: %w", rerr)
}
}
var answers []any
for _, ia := range ips {
ip, ok := netip.AddrFromSlice(ia.IP)
if !ok {
continue
}
ip = ip.Unmap()
if qt == dnsmessage.TypeA && ip.Is4() {
answers = append(answers, ip.String())
} else if qt == dnsmessage.TypeAAAA && ip.Is6() {
answers = append(answers, ip.String())
}
}
return map[string]any{
"answers": answers,
"resolvers": []any{},
}, nil
}
var p dnsmessage.Parser
if _, err := p.Start(res); err != nil {
return nil, fmt.Errorf("queryDNS: parsing response: %w", err)
}
if err := p.SkipAllQuestions(); err != nil {
return nil, fmt.Errorf("queryDNS: skipping questions: %w", err)
}
var answers []any
for {
h, err := p.AnswerHeader()
if err == dnsmessage.ErrSectionDone {
break
}
if err != nil {
return nil, fmt.Errorf("queryDNS: reading answer: %w", err)
}
switch h.Type {
case dnsmessage.TypeA:
r, err := p.AResource()
if err != nil {
return nil, fmt.Errorf("queryDNS: reading A record: %w", err)
}
answers = append(answers, netip.AddrFrom4(r.A).String())
case dnsmessage.TypeAAAA:
r, err := p.AAAAResource()
if err != nil {
return nil, fmt.Errorf("queryDNS: reading AAAA record: %w", err)
}
answers = append(answers, netip.AddrFrom16(r.AAAA).String())
case dnsmessage.TypeCNAME:
r, err := p.CNAMEResource()
if err != nil {
return nil, fmt.Errorf("queryDNS: reading CNAME record: %w", err)
}
answers = append(answers, r.CNAME.String())
case dnsmessage.TypeTXT:
r, err := p.TXTResource()
if err != nil {
return nil, fmt.Errorf("queryDNS: reading TXT record: %w", err)
}
for _, s := range r.TXT {
answers = append(answers, s)
}
default:
if err := p.SkipAnswer(); err != nil {
return nil, fmt.Errorf("queryDNS: skipping unknown answer: %w", err)
}
}
}
resolverAddrs := make([]any, len(resolvers))
for idx, r := range resolvers {
resolverAddrs[idx] = r.Addr
}
return map[string]any{
"answers": answers,
"resolvers": resolverAddrs,
}, nil
})
}
func (i *jsIPN) ping(ip string, pingType string, size int) js.Value {
return makePromise(func() (any, error) {
addr, err := netip.ParseAddr(ip)
if err != nil {
return nil, fmt.Errorf("ping: invalid IP %q: %w", ip, err)
}
switch tailcfg.PingType(pingType) {
case tailcfg.PingDisco, tailcfg.PingTSMP, tailcfg.PingICMP, tailcfg.PingPeerAPI:
// valid
default:
return nil, fmt.Errorf("ping: unknown type %q, must be one of: disco, TSMP, ICMP, peerapi", pingType)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
pr, err := i.lb.Ping(ctx, addr, tailcfg.PingType(pingType), size)
if err != nil {
return nil, err
}
result := map[string]any{
"ip": pr.IP,
"nodeIP": pr.NodeIP,
"nodeName": pr.NodeName,
"latencySeconds": pr.LatencySeconds,
"endpoint": pr.Endpoint,
"derpRegionID": pr.DERPRegionID,
"derpRegionCode": pr.DERPRegionCode,
"peerAPIURL": pr.PeerAPIURL,
"isLocalIP": pr.IsLocalIP,
}
if pr.Err != "" {
result["err"] = pr.Err
}
return result, nil
})
}
func (i *jsIPN) suggestExitNode() js.Value {
return makePromise(func() (any, error) {
resp, err := i.lb.SuggestExitNode()
if err != nil {
return nil, err
}
result := map[string]any{
"id": string(resp.ID),
"name": resp.Name,
}
if l := resp.Location; l.Valid() {
result["location"] = map[string]any{
"country": l.Country(),
"countryCode": l.CountryCode(),
"city": l.City(),
"cityCode": l.CityCode(),
"latitude": l.Latitude(),
"longitude": l.Longitude(),
}
}
return result, nil
})
}
func (i *jsIPN) setServices(jsServices js.Value) js.Value {
return makePromise(func() (any, error) {
n := jsServices.Length()
svcs := make([]tailcfg.Service, 0, n)
for idx := range n {
s := jsServices.Index(idx)
proto := tailcfg.ServiceProto(s.Get("proto").String())
port := uint16(s.Get("port").Int())
var desc string
if d := s.Get("description"); d.Type() == js.TypeString {
desc = d.String()
}
svcs = append(svcs, tailcfg.Service{Proto: proto, Port: port, Description: desc})
}
i.lb.SetExplicitServices(svcs)
return nil, nil
})
}
// userServicesFromView converts a hostinfo services slice to jsService entries,
// filtering out internal peerapi protocol entries (already reflected in peerAPIURL).
func userServicesFromView(svcs views.Slice[tailcfg.Service]) []jsService {
var out []jsService
for _, s := range svcs.All() {
switch s.Proto {
case tailcfg.PeerAPI4, tailcfg.PeerAPI6, tailcfg.PeerAPIDNS:
continue
}
out = append(out, jsService{Proto: string(s.Proto), Port: s.Port, Description: s.Description})
}
return out
}
func (i *jsIPN) localAPI(method, path, body string) js.Value {
return makePromise(func() (any, error) {
h := localapi.NewHandler(localapi.HandlerConfig{
Actor: &ipnauth.TestActor{
Name: "wasm",
LocalAdmin: true,
},
Backend: i.lb,
Logf: log.Printf,
LogID: i.logID,
EventBus: i.lb.Sys().Bus.Get(),
})
h.PermitRead = true
h.PermitWrite = true
h.PermitCert = true
var bodyReader io.Reader
if body != "" {
bodyReader = strings.NewReader(body)
}
req, err := http.NewRequest(method, "http://local-tailscaled.sock"+path, bodyReader)
if err != nil {
return nil, fmt.Errorf("localAPI: %w", err)
}
// Empty Host passes the validHost check in the LocalAPI handler.
req.Host = ""
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
resp := w.Result()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("localAPI: reading response: %w", err)
}
return map[string]any{
"status": resp.StatusCode,
"body": string(respBody),
}, nil
})
}
// wrapConn exposes a net.Conn to JavaScript with binary (Uint8Array) I/O.
func wrapConn(conn net.Conn) map[string]any {
return map[string]any{
@@ -990,11 +1606,19 @@ type jsNetMap struct {
LockedOut bool `json:"lockedOut"`
}
type jsService struct {
Proto string `json:"proto"`
Port uint16 `json:"port"`
Description string `json:"description,omitempty"`
}
type jsNetMapNode struct {
Name string `json:"name"`
Addresses []string `json:"addresses"`
MachineKey string `json:"machineKey"`
NodeKey string `json:"nodeKey"`
Name string `json:"name"`
Addresses []string `json:"addresses"`
MachineKey string `json:"machineKey"`
NodeKey string `json:"nodeKey"`
PeerAPIURL string `json:"peerAPIURL,omitempty"`
Services []jsService `json:"services,omitempty"`
}
type jsNetMapSelfNode struct {
+17 -1
View File
@@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !js && !ts_omit_acme
//go:build !ts_omit_acme
package ipnlocal
@@ -302,6 +302,9 @@ var errCertExpired = errors.New("cert expired")
var testX509Roots *x509.CertPool // set non-nil by tests
func (b *LocalBackend) getCertStore() (certStore, error) {
if runtime.GOOS == "js" {
return certStateStore{StateStore: b.store}, nil
}
switch b.store.(type) {
case *store.FileStore:
case *mem.Store:
@@ -333,6 +336,16 @@ func (b *LocalBackend) ConfigureCertsForTest(getCert func(hostname string) (*TLS
b.mu.Unlock()
}
// SetACMEHTTPClient sets a custom HTTP client for ACME certificate operations.
// On js/wasm, this can be used to route requests through the Tailscale network
// stack to bypass browser CORS if Let's Encrypt endpoints fail preflight.
// A nil value (the default) uses the standard http.DefaultClient.
func (b *LocalBackend) SetACMEHTTPClient(c *http.Client) {
b.mu.Lock()
defer b.mu.Unlock()
b.acmeHTTPClient = c
}
// certFileStore implements certStore by storing the cert & key files in the named directory.
type certFileStore struct {
dir string
@@ -550,6 +563,9 @@ var getCertPEM = func(ctx context.Context, b *LocalBackend, cs certStore, logf l
if err != nil {
return nil, err
}
b.mu.Lock()
ac.HTTPClient = b.acmeHTTPClient
b.mu.Unlock()
if !isDefaultDirectoryURL(ac.DirectoryURL) {
logf("acme: using Directory URL %q", ac.DirectoryURL)
+1 -1
View File
@@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build js || ts_omit_acme
//go:build ts_omit_acme
package ipnlocal
+25 -1
View File
@@ -294,6 +294,7 @@ type LocalBackend struct {
capTailnetLock bool // whether netMap contains the tailnet lock capability
// hostinfo is mutated in-place while mu is held.
hostinfo *tailcfg.Hostinfo // TODO(nickkhyl): move to nodeBackend
explicitServices []tailcfg.Service // services set explicitly via SetExplicitServices; always uploaded
nmExpiryTimer tstime.TimerController // for updating netMap on node expiry; can be nil; TODO(nickkhyl): move to nodeBackend
activeLogin string // last logged LoginName from netMap; TODO(nickkhyl): move to nodeBackend (or remove? it's in [ipn.LoginProfile]).
engineStatus ipn.EngineStatus
@@ -412,6 +413,10 @@ type LocalBackend struct {
// See [LocalBackend.ConfigureCertsForTest].
getCertForTest func(hostname string) (*TLSCertKeyPair, error)
// acmeHTTPClient, if non-nil, is used for all ACME HTTP requests instead
// of http.DefaultClient. Set via SetACMEHTTPClient before first cert use.
acmeHTTPClient *http.Client
// existsPendingAuthReconfig tracks if a goroutine is waiting to
// acquire [LocalBackend]'s mutex inside of [LocalBackend.AuthReconfig].
// It is used to prevent goroutines from piling up to do the same
@@ -4963,6 +4968,23 @@ 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
b.mu.Unlock()
b.doSetHostinfoFilterServices()
}
// doSetHostinfoFilterServices calls SetHostinfo on the controlclient,
// possibly after mangling the given hostinfo.
//
@@ -5007,7 +5029,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
+5
View File
@@ -393,6 +393,11 @@ func (b *LocalBackend) setServeConfigLocked(config *ipn.ServeConfig, etag string
}
}
// Notify the control plane immediately so that changes to IngressEnabled /
// WireIngress (required for Funnel DNS provisioning) are not delayed until
// the next periodic heartbeat.
b.authReconfigLocked()
return nil
}