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>
This commit was merged in pull request #4.
This commit is contained in:
2026-05-06 11:19:25 +00:00
parent d9efc3bae2
commit 143581c955
5 changed files with 238 additions and 9 deletions
+211 -7
View File
@@ -25,6 +25,7 @@ import (
"net/netip"
"strconv"
"strings"
"sync"
"syscall/js"
"time"
@@ -164,14 +165,16 @@ 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,
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 +298,23 @@ 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())
}),
}
}
@@ -306,6 +326,15 @@ type jsIPN struct {
controlURL string
authKey string
hostname string
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{
@@ -797,6 +826,181 @@ 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, "")
})
}
// wrapConn exposes a net.Conn to JavaScript with binary (Uint8Array) I/O.
func wrapConn(conn net.Conn) map[string]any {
return map[string]any{
+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
+4
View File
@@ -412,6 +412,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
+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
}