From 81e37f8812bdea0ccc3bbfcc4675925a2b94ade7 Mon Sep 17 00:00:00 2001 From: Codinget Date: Mon, 6 Jul 2026 09:00:19 +0000 Subject: [PATCH] feat(tsconnect/wasm): add upgradeTLS to wrapped conns Adds an upgradeTLS method to the conn objects returned by dial/accept, wrapping the existing net.Conn with crypto/tls in place (STARTTLS / FTPS AUTH TLS style). Client mode reuses the dialTLS options (serverName, insecureSkipVerify, caCerts), now factored into tlsClientConfigFromJS; server mode takes isServer with certPem/keyPem. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XfRgMke8p7QDxD5QM5thxH --- cmd/tsconnect/wasm/wasm_js.go | 100 ++++++++++++++++++++++++++-------- 1 file changed, 78 insertions(+), 22 deletions(-) diff --git a/cmd/tsconnect/wasm/wasm_js.go b/cmd/tsconnect/wasm/wasm_js.go index 96e0d1d70..eaff7836b 100644 --- a/cmd/tsconnect/wasm/wasm_js.go +++ b/cmd/tsconnect/wasm/wasm_js.go @@ -889,6 +889,41 @@ func (i *jsIPN) listen(network, addr string) js.Value { }) } +// tlsClientConfigFromJS builds a client tls.Config from optional JS options +// (serverName, insecureSkipVerify, caCerts). defaultServerName may be empty +// (STARTTLS upgrade case), in which case serverName must be provided unless +// insecureSkipVerify is set. +// +// On wasm there's no system root pool, so default to the baked-in +// LetsEncrypt roots (which is what `tailscale cert` uses for tailnet +// HTTPS endpoints). Callers can override with caCerts (PEM) or bypass +// entirely with insecureSkipVerify. +func tlsClientConfigFromJS(defaultServerName string, opts js.Value) (*tls.Config, error) { + cfg := &tls.Config{ + ServerName: defaultServerName, + RootCAs: bakedroots.Get(), + } + if !opts.IsUndefined() && !opts.IsNull() { + if sn := opts.Get("serverName"); sn.Type() == js.TypeString { + cfg.ServerName = sn.String() + } + if iv := opts.Get("insecureSkipVerify"); iv.Type() == js.TypeBoolean { + cfg.InsecureSkipVerify = iv.Bool() + } + if ca := opts.Get("caCerts"); ca.Type() == js.TypeString { + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM([]byte(ca.String())) { + return nil, fmt.Errorf("caCerts: no valid PEM certificates found") + } + cfg.RootCAs = pool + } + } + if cfg.ServerName == "" && !cfg.InsecureSkipVerify { + return nil, fmt.Errorf("serverName is required unless insecureSkipVerify is set") + } + return cfg, nil +} + func (i *jsIPN) dialTLS(addr string, opts js.Value) js.Value { return makePromise(func() (any, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) @@ -899,28 +934,9 @@ func (i *jsIPN) dialTLS(addr string, opts js.Value) js.Value { return nil, fmt.Errorf("invalid address %q: %w", addr, err) } - // On wasm there's no system root pool, so default to the - // baked-in LetsEncrypt roots (which is what `tailscale cert` - // uses for tailnet HTTPS endpoints). Callers can override with - // caCerts (PEM) or bypass entirely with insecureSkipVerify. - cfg := &tls.Config{ - ServerName: host, - RootCAs: bakedroots.Get(), - } - if !opts.IsUndefined() && !opts.IsNull() { - if sn := opts.Get("serverName"); sn.Type() == js.TypeString { - cfg.ServerName = sn.String() - } - if iv := opts.Get("insecureSkipVerify"); iv.Type() == js.TypeBoolean { - cfg.InsecureSkipVerify = iv.Bool() - } - if ca := opts.Get("caCerts"); ca.Type() == js.TypeString { - pool := x509.NewCertPool() - if !pool.AppendCertsFromPEM([]byte(ca.String())) { - return nil, fmt.Errorf("caCerts: no valid PEM certificates found") - } - cfg.RootCAs = pool - } + cfg, err := tlsClientConfigFromJS(host, opts) + if err != nil { + return nil, err } rawConn, err := i.dialer.UserDial(ctx, "tcp", addr) @@ -1464,6 +1480,46 @@ func wrapConn(conn net.Conn) map[string]any { "remoteAddr": js.FuncOf(func(this js.Value, args []js.Value) any { return conn.RemoteAddr().String() }), + // upgradeTLS wraps the conn in TLS in place (STARTTLS-style) and + // returns a new wrapped conn sharing the same underlying net.Conn; + // the old handle must not be used afterward. On handshake failure + // the underlying conn is closed. With isServer, certPem and keyPem + // are required; otherwise client options as in dialTLS apply. + "upgradeTLS": js.FuncOf(func(this js.Value, args []js.Value) any { + opts := js.Undefined() + if len(args) > 0 { + opts = args[0] + } + return makePromise(func() (any, error) { + var tlsConn *tls.Conn + hasOpts := !opts.IsUndefined() && !opts.IsNull() + if hasOpts && opts.Get("isServer").Type() == js.TypeBoolean && opts.Get("isServer").Bool() { + certPem := opts.Get("certPem") + keyPem := opts.Get("keyPem") + if certPem.Type() != js.TypeString || keyPem.Type() != js.TypeString { + return nil, fmt.Errorf("upgradeTLS: certPem and keyPem are required when isServer is set") + } + cert, err := tls.X509KeyPair([]byte(certPem.String()), []byte(keyPem.String())) + if err != nil { + return nil, fmt.Errorf("upgradeTLS: parsing cert/key: %w", err) + } + tlsConn = tls.Server(conn, &tls.Config{Certificates: []tls.Certificate{cert}}) + } else { + cfg, err := tlsClientConfigFromJS("", opts) + if err != nil { + return nil, err + } + tlsConn = tls.Client(conn, cfg) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := tlsConn.HandshakeContext(ctx); err != nil { + conn.Close() + return nil, err + } + return wrapConn(tlsConn), nil + }) + }), } }