Add optional mTLS support for client & server

This commit is contained in:
shadow 2025-08-27 11:22:21 +08:00
parent 5f3c47e6c3
commit 6cb18ff5ad
10 changed files with 77 additions and 12 deletions

View file

@ -2,6 +2,7 @@ package cmd
import (
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"errors"
@ -96,10 +97,12 @@ type clientConfigObfs struct {
}
type clientConfigTLS struct {
SNI string `mapstructure:"sni"`
Insecure bool `mapstructure:"insecure"`
PinSHA256 string `mapstructure:"pinSHA256"`
CA string `mapstructure:"ca"`
SNI string `mapstructure:"sni"`
Insecure bool `mapstructure:"insecure"`
PinSHA256 string `mapstructure:"pinSHA256"`
CA string `mapstructure:"ca"`
ClientCertificate string `mapstructure:"clientCertificate"`
ClientKey string `mapstructure:"clientKey"`
}
type clientConfigQUIC struct {
@ -294,6 +297,31 @@ func (c *clientConfig) fillTLSConfig(hyConfig *client.Config) error {
}
hyConfig.TLSConfig.RootCAs = cPool
}
if c.TLS.ClientCertificate != "" && c.TLS.ClientKey != "" {
certLoader := &utils.LocalCertificateLoader{
CertFile: c.TLS.ClientCertificate,
KeyFile: c.TLS.ClientKey,
}
// Try loading the cert-key pair here to catch errors early
err := certLoader.InitializeCache()
if err != nil {
var pathErr *os.PathError
if errors.As(err, &pathErr) {
if pathErr.Path == c.TLS.ClientCertificate {
return configError{Field: "tls.clientCertificate", Err: pathErr}
}
if pathErr.Path == c.TLS.ClientKey {
return configError{Field: "tls.clientKey", Err: pathErr}
}
}
return configError{Field: "tls.clientCertificate", Err: err}
}
// Use GetClientCertificates so that users can update the cert without restarting the client.
hyConfig.TLSConfig.GetClientCertificate = func(*tls.CertificateRequestInfo) (*tls.Certificate, error) {
// For simplicity, always respond with the configured client certs, regardless of server requests.
return certLoader.GetCertificate(nil)
}
}
return nil
}

View file

@ -33,10 +33,12 @@ func TestClientConfig(t *testing.T) {
},
},
TLS: clientConfigTLS{
SNI: "another.example.com",
Insecure: true,
PinSHA256: "114515DEADBEEF",
CA: "custom_ca.crt",
SNI: "another.example.com",
Insecure: true,
PinSHA256: "114515DEADBEEF",
CA: "custom_ca.crt",
ClientCertificate: "client.crt",
ClientKey: "client.key",
},
QUIC: clientConfigQUIC{
InitStreamReceiveWindow: 1145141,

View file

@ -17,6 +17,8 @@ tls:
insecure: true
pinSHA256: 114515DEADBEEF
ca: custom_ca.crt
clientCertificate: client.crt
clientKey: client.key
quic:
initStreamReceiveWindow: 1145141

View file

@ -3,6 +3,7 @@ package cmd
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
@ -86,6 +87,7 @@ type serverConfigTLS struct {
Cert string `mapstructure:"cert"`
Key string `mapstructure:"key"`
SNIGuard string `mapstructure:"sniGuard"` // "disable", "dns-san", "strict"
ClientCA string `mapstructure:"clientCA"`
}
type serverConfigACME struct {
@ -333,6 +335,18 @@ func (c *serverConfig) fillTLSConfig(hyConfig *server.Config) error {
// Use GetCertificate instead of Certificates so that
// users can update the cert without restarting the server.
hyConfig.TLSConfig.GetCertificate = certLoader.GetCertificate
// Client CA
if c.TLS.ClientCA != "" {
ca, err := os.ReadFile(c.TLS.ClientCA)
if err != nil {
return configError{Field: "tls.clientCA", Err: err}
}
cPool := x509.NewCertPool()
if !cPool.AppendCertsFromPEM(ca) {
return configError{Field: "tls.clientCA", Err: errors.New("failed to parse client CA certificate")}
}
hyConfig.TLSConfig.ClientCAs = cPool
}
} else {
// ACME
dataDir := c.ACME.Dir

View file

@ -29,6 +29,7 @@ func TestServerConfig(t *testing.T) {
Cert: "some.crt",
Key: "some.key",
SNIGuard: "strict",
ClientCA: "some_ca.crt",
},
ACME: &serverConfigACME{
Domains: []string{

View file

@ -9,6 +9,7 @@ tls:
cert: some.crt
key: some.key
sniGuard: strict
clientCA: some_ca.crt
acme:
domains:

View file

@ -74,6 +74,7 @@ func (c *clientImpl) connect() (*HandshakeInfo, error) {
InsecureSkipVerify: c.config.TLSConfig.InsecureSkipVerify,
VerifyPeerCertificate: c.config.TLSConfig.VerifyPeerCertificate,
RootCAs: c.config.TLSConfig.RootCAs,
GetClientCertificate: c.config.TLSConfig.GetClientCertificate,
}
quicConfig := &quic.Config{
InitialStreamReceiveWindow: c.config.QUICConfig.InitialStreamReceiveWindow,

View file

@ -1,6 +1,7 @@
package client
import (
"crypto/tls"
"crypto/x509"
"net"
"time"
@ -92,6 +93,7 @@ type TLSConfig struct {
InsecureSkipVerify bool
VerifyPeerCertificate func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error
RootCAs *x509.CertPool
GetClientCertificate func(*tls.CertificateRequestInfo) (*tls.Certificate, error)
}
// QUICConfig contains the QUIC configuration fields that we want to expose to the user.

View file

@ -2,6 +2,7 @@ package server
import (
"crypto/tls"
"crypto/x509"
"net"
"net/http"
"sync/atomic"
@ -101,6 +102,7 @@ func (c *Config) fill() error {
type TLSConfig struct {
Certificates []tls.Certificate
GetCertificate func(info *tls.ClientHelloInfo) (*tls.Certificate, error)
ClientCAs *x509.CertPool
}
// QUICConfig contains the QUIC configuration fields that we want to expose to the user.

View file

@ -26,14 +26,26 @@ type Server interface {
Close() error
}
func convertToStdTLSConfig(config *Config) *tls.Config {
var clientAuth tls.ClientAuthType
if config.TLSConfig.ClientCAs != nil {
clientAuth = tls.RequireAndVerifyClientCert
} else {
clientAuth = tls.NoClientCert
}
return http3.ConfigureTLSConfig(&tls.Config{
Certificates: config.TLSConfig.Certificates,
GetCertificate: config.TLSConfig.GetCertificate,
ClientCAs: config.TLSConfig.ClientCAs,
ClientAuth: clientAuth,
})
}
func NewServer(config *Config) (Server, error) {
if err := config.fill(); err != nil {
return nil, err
}
tlsConfig := http3.ConfigureTLSConfig(&tls.Config{
Certificates: config.TLSConfig.Certificates,
GetCertificate: config.TLSConfig.GetCertificate,
})
tlsConfig := convertToStdTLSConfig(config)
quicConfig := &quic.Config{
InitialStreamReceiveWindow: config.QUICConfig.InitialStreamReceiveWindow,
MaxStreamReceiveWindow: config.QUICConfig.MaxStreamReceiveWindow,