From 7c0f8cf4d868f6e55c14a13d6c74da3af2a496c5 Mon Sep 17 00:00:00 2001 From: cnbugs Date: Sun, 9 Aug 2026 22:39:51 +0800 Subject: [PATCH] Fix TLS handshake: add X.509 keyUsage/EKU extensions to all certs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/internal/api/router.go | 6 ++++ backend/pkg/openvpn/manager.go | 58 ++++++++++++++++++++++++++++------ 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index 533f54d..51c6038 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -1,6 +1,7 @@ package api import ( + "net" "net/http" "os" "path/filepath" @@ -376,7 +377,12 @@ func (s *Server) deleteUser(c *gin.Context) { func (s *Server) downloadOVPN(c *gin.Context) { host := c.Query("host") if host == "" { + // 从 Host 头取 hostname(strip 端口号) + // 用户应在前端 ?host=vpn.example.com 传入真实地址 host = c.Request.Host + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } } p, err := s.Svc.GenerateOVPN(c.Param("uid"), host) if err != nil { diff --git a/backend/pkg/openvpn/manager.go b/backend/pkg/openvpn/manager.go index e1de918..1c217a5 100644 --- a/backend/pkg/openvpn/manager.go +++ b/backend/pkg/openvpn/manager.go @@ -57,11 +57,29 @@ func (m *Manager) EnsureCA() error { if err := os.MkdirAll(m.pkiDir, 0o700); err != nil { 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 \ - -keyout "`+m.pkiDir+`/ca.key" \ - -out "`+m.pkiDir+`/ca.crt" \ - -days 3650 -subj "/CN=OpenVPN-Manager-CA" 2>/dev/null`); err != nil { + -keyout "`+m.pkiDir+`/ca.key" \ + -out "`+m.pkiDir+`/ca.crt" \ + -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) } // 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("ifconfig-pool-persist " + filepath.Join(dir, "ipp.txt") + "\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-version 3\n") conf.WriteString("log " + filepath.Join(dir, "logs", "openvpn.log") + "\n") conf.WriteString("verb 3\n") - conf.WriteString("cipher " + orDefault(in.Cipher, "AES-256-GCM") + "\n") conf.WriteString("auth " + orDefault(in.AuthDigest, "SHA256") + "\n") if in.PushDNS != "" { 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。 // 返回 (certPath, keyPath, error)。 +// 当 clientName == "server" 时签发带 serverAuth EKU 的服务端证书, +// 否则签发带 clientAuth EKU 的客户端证书。 func (m *Manager) IssueCert(instanceName, clientName string) (string, string, error) { pki := m.PKIPath(instanceName) issuedDir := filepath.Join(pki, "issued") @@ -183,11 +203,29 @@ func (m *Manager) IssueCert(instanceName, clientName string) (string, string, er key, csr, clientName)); err != nil { 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") caKey := filepath.Join(m.pkiDir, "ca.key") 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) } _ = 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("resolv-retry infinite\n") b.WriteString("nobind\n") - b.WriteString("persist-key\npersist-tun\n") - b.WriteString("cipher " + orDefault(in.Cipher, "AES-256-GCM") + "\n") + b.WriteString("persist-tun\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("remote-cert-tls server\n") b.WriteString("verb 3\n")