feat(tsconnect): add getCert, listenTLS, setFunnel + fix TLS cert for WASM #4
@@ -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
@@ -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,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user