From 143581c955340ed5a80877c24f1267da521fe2b3 Mon Sep 17 00:00:00 2001 From: Codinget Date: Wed, 6 May 2026 11:19:25 +0000 Subject: [PATCH] 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 --- cmd/tsconnect/wasm/wasm_js.go | 218 ++++++++++++++++++++++++++++++++-- ipn/ipnlocal/cert.go | 18 ++- ipn/ipnlocal/cert_disabled.go | 2 +- ipn/ipnlocal/local.go | 4 + ipn/ipnlocal/serve.go | 5 + 5 files changed, 238 insertions(+), 9 deletions(-) diff --git a/cmd/tsconnect/wasm/wasm_js.go b/cmd/tsconnect/wasm/wasm_js.go index b76f23de0..423fb8024 100644 --- a/cmd/tsconnect/wasm/wasm_js.go +++ b/cmd/tsconnect/wasm/wasm_js.go @@ -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{ diff --git a/ipn/ipnlocal/cert.go b/ipn/ipnlocal/cert.go index efab9db7a..ffdf609ee 100644 --- a/ipn/ipnlocal/cert.go +++ b/ipn/ipnlocal/cert.go @@ -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) diff --git a/ipn/ipnlocal/cert_disabled.go b/ipn/ipnlocal/cert_disabled.go index 0caab6bc3..380d39aec 100644 --- a/ipn/ipnlocal/cert_disabled.go +++ b/ipn/ipnlocal/cert_disabled.go @@ -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 diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 610d1d7b5..e8138aa50 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -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 diff --git a/ipn/ipnlocal/serve.go b/ipn/ipnlocal/serve.go index 9460896ad..fc10f2ecf 100644 --- a/ipn/ipnlocal/serve.go +++ b/ipn/ipnlocal/serve.go @@ -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 }