feat(tsconnect): add getCert, listenTLS, setFunnel + fix TLS cert for WASM #4
@@ -25,6 +25,7 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"syscall/js"
|
"syscall/js"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -164,14 +165,16 @@ func newIPN(jsConfig js.Value) map[string]any {
|
|||||||
srv.SetLocalBackend(lb)
|
srv.SetLocalBackend(lb)
|
||||||
|
|
||||||
jsIPN := &jsIPN{
|
jsIPN := &jsIPN{
|
||||||
dialer: dialer,
|
dialer: dialer,
|
||||||
srv: srv,
|
srv: srv,
|
||||||
lb: lb,
|
lb: lb,
|
||||||
ns: ns,
|
ns: ns,
|
||||||
controlURL: controlURL,
|
controlURL: controlURL,
|
||||||
authKey: authKey,
|
authKey: authKey,
|
||||||
hostname: hostname,
|
hostname: hostname,
|
||||||
|
funnelPorts: make(map[uint16]*funnelListenerEntry),
|
||||||
}
|
}
|
||||||
|
lb.SetTCPHandlerForFunnelFlow(jsIPN.handleFunnelTCP)
|
||||||
|
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"run": js.FuncOf(func(this js.Value, args []js.Value) 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())
|
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
|
controlURL string
|
||||||
authKey string
|
authKey string
|
||||||
hostname 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{
|
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.
|
// wrapConn exposes a net.Conn to JavaScript with binary (Uint8Array) I/O.
|
||||||
func wrapConn(conn net.Conn) map[string]any {
|
func wrapConn(conn net.Conn) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
|
|||||||
+17
-1
@@ -1,7 +1,7 @@
|
|||||||
// Copyright (c) Tailscale Inc & contributors
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
//go:build !js && !ts_omit_acme
|
//go:build !ts_omit_acme
|
||||||
|
|
||||||
package ipnlocal
|
package ipnlocal
|
||||||
|
|
||||||
@@ -302,6 +302,9 @@ var errCertExpired = errors.New("cert expired")
|
|||||||
var testX509Roots *x509.CertPool // set non-nil by tests
|
var testX509Roots *x509.CertPool // set non-nil by tests
|
||||||
|
|
||||||
func (b *LocalBackend) getCertStore() (certStore, error) {
|
func (b *LocalBackend) getCertStore() (certStore, error) {
|
||||||
|
if runtime.GOOS == "js" {
|
||||||
|
return certStateStore{StateStore: b.store}, nil
|
||||||
|
}
|
||||||
switch b.store.(type) {
|
switch b.store.(type) {
|
||||||
case *store.FileStore:
|
case *store.FileStore:
|
||||||
case *mem.Store:
|
case *mem.Store:
|
||||||
@@ -333,6 +336,16 @@ func (b *LocalBackend) ConfigureCertsForTest(getCert func(hostname string) (*TLS
|
|||||||
b.mu.Unlock()
|
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.
|
// certFileStore implements certStore by storing the cert & key files in the named directory.
|
||||||
type certFileStore struct {
|
type certFileStore struct {
|
||||||
dir string
|
dir string
|
||||||
@@ -550,6 +563,9 @@ var getCertPEM = func(ctx context.Context, b *LocalBackend, cs certStore, logf l
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
b.mu.Lock()
|
||||||
|
ac.HTTPClient = b.acmeHTTPClient
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
if !isDefaultDirectoryURL(ac.DirectoryURL) {
|
if !isDefaultDirectoryURL(ac.DirectoryURL) {
|
||||||
logf("acme: using Directory URL %q", ac.DirectoryURL)
|
logf("acme: using Directory URL %q", ac.DirectoryURL)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Copyright (c) Tailscale Inc & contributors
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
//go:build js || ts_omit_acme
|
//go:build ts_omit_acme
|
||||||
|
|
||||||
package ipnlocal
|
package ipnlocal
|
||||||
|
|
||||||
|
|||||||
@@ -412,6 +412,10 @@ type LocalBackend struct {
|
|||||||
// See [LocalBackend.ConfigureCertsForTest].
|
// See [LocalBackend.ConfigureCertsForTest].
|
||||||
getCertForTest func(hostname string) (*TLSCertKeyPair, error)
|
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
|
// existsPendingAuthReconfig tracks if a goroutine is waiting to
|
||||||
// acquire [LocalBackend]'s mutex inside of [LocalBackend.AuthReconfig].
|
// acquire [LocalBackend]'s mutex inside of [LocalBackend.AuthReconfig].
|
||||||
// It is used to prevent goroutines from piling up to do the same
|
// 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user