Fix TLS handshake: add X.509 keyUsage/EKU extensions to all certs

OpenVPN 2.7 + OpenSSL 3.6 strictly checks keyUsage extension.
Client log showed: 'Certificate does not have key usage extension'
→ 'VERIFY KU ERROR' → 'TLS handshake failed'.

Root cause: all certs generated via bare 'openssl req/x509 -req'
without any -extensions/-extfile, so no X.509 v3 extensions at all.

Fix: generate per-cert openssl ext config files:

CA cert (EnsureCA):
  basicConstraints = critical,CA:TRUE
  keyUsage = critical,keyCertSign,cRLSign
  subjectKeyIdentifier = hash
  authorityKeyIdentifier = keyid:always,issuer

Server cert (IssueCert, clientName=='server'):
  basicConstraints = critical,CA:FALSE
  keyUsage = critical,digitalSignature,keyEncipherment
  extendedKeyUsage = serverAuth

Client cert (IssueCert, otherwise):
  basicConstraints = critical,CA:FALSE
  keyUsage = critical,digitalSignature,keyEncipherment
  extendedKeyUsage = clientAuth

Also fix while here:
- .ovpn remote host: strip port from HTTP Host header with
  net.SplitHostPort; prefer ?host= query param from frontend
- Replace deprecated 'persist-key' (OpenVPN 2.7 warns) with just
  'persist-tun' in both server.conf and client .ovpn
- Replace 'cipher X' with 'data-ciphers X:AES-128-GCM' (OpenVPN 2.5+
  negotiation) in server.conf and client .ovpn

Verified: CA/Server/Client certs all show correct keyUsage + EKU via
openssl x509 -text; .ovpn remote shows correct host:port.
This commit is contained in:
cnbugs
2026-08-09 22:39:51 +08:00
parent ae02b60d67
commit 7c0f8cf4d8
2 changed files with 54 additions and 10 deletions
+6
View File
@@ -1,6 +1,7 @@
package api package api
import ( import (
"net"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -376,7 +377,12 @@ func (s *Server) deleteUser(c *gin.Context) {
func (s *Server) downloadOVPN(c *gin.Context) { func (s *Server) downloadOVPN(c *gin.Context) {
host := c.Query("host") host := c.Query("host")
if host == "" { if host == "" {
// 从 Host 头取 hostname(strip 端口号)
// 用户应在前端 ?host=vpn.example.com 传入真实地址
host = c.Request.Host host = c.Request.Host
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
} }
p, err := s.Svc.GenerateOVPN(c.Param("uid"), host) p, err := s.Svc.GenerateOVPN(c.Param("uid"), host)
if err != nil { if err != nil {
+48 -10
View File
@@ -57,11 +57,29 @@ func (m *Manager) EnsureCA() error {
if err := os.MkdirAll(m.pkiDir, 0o700); err != nil { if err := os.MkdirAll(m.pkiDir, 0o700); err != nil {
return err return err
} }
// 使用 openssl 直接生成自签 CA,避免依赖 easyrsa // 使用 openssl 直接生成自签 CA,避免依赖 easyrsa
// 必须带 X.509 扩展 (basicConstraints + keyUsage)
// 否则 OpenVPN 2.7 / OpenSSL 3.x 会报 "Certificate does not have key usage extension"。
caExt := filepath.Join(m.pkiDir, "ca-ext.cnf")
if err := os.WriteFile(caExt, []byte(`[req]
distinguished_name = req_dn
prompt = no
[req_dn]
CN = OpenVPN-Manager-CA
[v3_ca]
basicConstraints = critical,CA:TRUE
keyUsage = critical,keyCertSign,cRLSign
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer
`), 0o600); err != nil {
return fmt.Errorf("write ca ext: %w", err)
}
if err := runShell(`openssl req -x509 -newkey rsa:2048 -nodes \ if err := runShell(`openssl req -x509 -newkey rsa:2048 -nodes \
-keyout "`+m.pkiDir+`/ca.key" \ -keyout "`+m.pkiDir+`/ca.key" \
-out "`+m.pkiDir+`/ca.crt" \ -out "`+m.pkiDir+`/ca.crt" \
-days 3650 -subj "/CN=OpenVPN-Manager-CA" 2>/dev/null`); err != nil { -days 3650 -sha256 \
-subj "/CN=OpenVPN-Manager-CA" \
-extensions v3_ca -config "` + caExt + `" 2>/dev/null`); err != nil {
return fmt.Errorf("generate CA: %w", err) return fmt.Errorf("generate CA: %w", err)
} }
// DH 参数:用 "dh none" 走 ECDHE,不再需要 dh.pem 文件。 // DH 参数:用 "dh none" 走 ECDHE,不再需要 dh.pem 文件。
@@ -97,12 +115,12 @@ func (m *Manager) WriteServerConf(in *model.Instance, extraDir string) error {
conf.WriteString("server " + cidrToServerDirective(in.Subnet) + "\n") conf.WriteString("server " + cidrToServerDirective(in.Subnet) + "\n")
conf.WriteString("ifconfig-pool-persist " + filepath.Join(dir, "ipp.txt") + "\n") conf.WriteString("ifconfig-pool-persist " + filepath.Join(dir, "ipp.txt") + "\n")
conf.WriteString("keepalive 10 120\n") conf.WriteString("keepalive 10 120\n")
conf.WriteString("persist-key\npersist-tun\n") conf.WriteString("persist-tun\n")
conf.WriteString("data-ciphers " + orDefault(in.Cipher, "AES-256-GCM") + ":AES-128-GCM\n")
conf.WriteString("status " + filepath.Join(dir, "status.log") + " 10\n") conf.WriteString("status " + filepath.Join(dir, "status.log") + " 10\n")
conf.WriteString("status-version 3\n") conf.WriteString("status-version 3\n")
conf.WriteString("log " + filepath.Join(dir, "logs", "openvpn.log") + "\n") conf.WriteString("log " + filepath.Join(dir, "logs", "openvpn.log") + "\n")
conf.WriteString("verb 3\n") conf.WriteString("verb 3\n")
conf.WriteString("cipher " + orDefault(in.Cipher, "AES-256-GCM") + "\n")
conf.WriteString("auth " + orDefault(in.AuthDigest, "SHA256") + "\n") conf.WriteString("auth " + orDefault(in.AuthDigest, "SHA256") + "\n")
if in.PushDNS != "" { if in.PushDNS != "" {
for _, line := range strings.Split(in.PushDNS, "\n") { for _, line := range strings.Split(in.PushDNS, "\n") {
@@ -153,6 +171,8 @@ func (m *Manager) WriteServerConf(in *model.Instance, extraDir string) error {
// IssueCert 为客户端签发证书。clientName = CN。 // IssueCert 为客户端签发证书。clientName = CN。
// 返回 (certPath, keyPath, error)。 // 返回 (certPath, keyPath, error)。
// 当 clientName == "server" 时签发带 serverAuth EKU 的服务端证书,
// 否则签发带 clientAuth EKU 的客户端证书。
func (m *Manager) IssueCert(instanceName, clientName string) (string, string, error) { func (m *Manager) IssueCert(instanceName, clientName string) (string, string, error) {
pki := m.PKIPath(instanceName) pki := m.PKIPath(instanceName)
issuedDir := filepath.Join(pki, "issued") issuedDir := filepath.Join(pki, "issued")
@@ -183,11 +203,29 @@ func (m *Manager) IssueCert(instanceName, clientName string) (string, string, er
key, csr, clientName)); err != nil { key, csr, clientName)); err != nil {
return "", "", fmt.Errorf("gen csr: %w", err) return "", "", fmt.Errorf("gen csr: %w", err)
} }
// 用 CA 签发 // 写签发扩展配置(区分 server / client)
extFile := filepath.Join(pki, clientName+"-ext.cnf")
extContent := `[v3]
basicConstraints = critical,CA:FALSE
keyUsage = critical,digitalSignature,keyEncipherment
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer
`
if clientName == "server" {
extContent += "extendedKeyUsage = serverAuth\n"
} else {
extContent += "extendedKeyUsage = clientAuth\n"
}
if err := os.WriteFile(extFile, []byte(extContent), 0o600); err != nil {
return "", "", fmt.Errorf("write ext: %w", err)
}
// 用 CA 签发(带扩展)
caCrt := filepath.Join(m.pkiDir, "ca.crt") caCrt := filepath.Join(m.pkiDir, "ca.crt")
caKey := filepath.Join(m.pkiDir, "ca.key") caKey := filepath.Join(m.pkiDir, "ca.key")
if err := runShell(fmt.Sprintf(`openssl x509 -req -in "%s" -CA "%s" -CAkey "%s" -CAcreateserial \ if err := runShell(fmt.Sprintf(`openssl x509 -req -in "%s" -CA "%s" -CAkey "%s" -CAcreateserial \
-out "%s" -days 3650 -sha256 2>/dev/null`, csr, caCrt, caKey, crt)); err != nil { -out "%s" -days 3650 -sha256 \
-extfile "%s" -extensions v3 2>/dev/null`,
csr, caCrt, caKey, crt, extFile)); err != nil {
return "", "", fmt.Errorf("sign cert: %w", err) return "", "", fmt.Errorf("sign cert: %w", err)
} }
_ = os.Remove(csr) _ = os.Remove(csr)
@@ -240,8 +278,8 @@ func (m *Manager) GenerateClientOVPN(in *model.Instance, username, remoteHost st
b.WriteString("remote " + remoteHost + " " + strconv.Itoa(in.Port) + "\n") b.WriteString("remote " + remoteHost + " " + strconv.Itoa(in.Port) + "\n")
b.WriteString("resolv-retry infinite\n") b.WriteString("resolv-retry infinite\n")
b.WriteString("nobind\n") b.WriteString("nobind\n")
b.WriteString("persist-key\npersist-tun\n") b.WriteString("persist-tun\n")
b.WriteString("cipher " + orDefault(in.Cipher, "AES-256-GCM") + "\n") b.WriteString("data-ciphers " + orDefault(in.Cipher, "AES-256-GCM") + ":AES-128-GCM\n")
b.WriteString("auth " + orDefault(in.AuthDigest, "SHA256") + "\n") b.WriteString("auth " + orDefault(in.AuthDigest, "SHA256") + "\n")
b.WriteString("remote-cert-tls server\n") b.WriteString("remote-cert-tls server\n")
b.WriteString("verb 3\n") b.WriteString("verb 3\n")