feat(server): Hy2 自签 TLS 证书(方案①)— 补全 hysteria2 入站可用
Hy2 跑在 QUIC 上、强制 TLS,需服务端证书(不像 REALITY 借大站证书)。此前
handler_grpc 只发 ListenPort、cert 路径为空 → hysteria2 入站起不来。改为节点自签:
- agentd/hy2cert.go: ensureSelfSignedCert 生成 ECDSA P-256 自签证书到
/etc/sing-box/hy2.{crt,key}(幂等,缺失才生成;key 0600;SAN=reality SNI)
- singbox.go: writeAndRestart 渲染前对启用 hy2 的节点 ensure 证书并把
CertPath/KeyPath 写回 hy2 配置
- httpapi/clientconfig.go: hy2 出站 TLS 加 insecure:true + server_name,收自签
(两端自有,服务端鉴权靠 per-user 派生的 hy2 密码)
验证:单元测试(证书可被 crypto/tls 加载/幂等/0600)+ sing-box check 接受自签 hy2 入站。
注:节点实际启用 hy2 还需配 Hy2Port + 放行 UDP(单独步骤)。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
package agentd
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ensureSelfSignedCert 保证 hysteria2 入站用的自签 TLS 证书存在(方案①)。
|
||||
//
|
||||
// 为什么自签:Hy2 跑在 QUIC 上,QUIC 强制 TLS 1.3 → 服务端必须出示证书。它不像
|
||||
// REALITY 借真实大站证书,得自备一张。两端都自有,客户端用 tls.insecure 收自签即可
|
||||
// (真正的服务端鉴权靠 per-user 派生的 hy2 密码)。
|
||||
//
|
||||
// 幂等:两个文件都在则直接返回;否则生成一张 ECDSA P-256、10 年有效的自签证书,
|
||||
// cert 0644 / key 0600 写盘。sni 写入 SAN(客户端 insecure 时仅作 ClientHello 用)。
|
||||
func ensureSelfSignedCert(certPath, keyPath, sni string) error {
|
||||
if fileExists(certPath) && fileExists(keyPath) {
|
||||
return nil
|
||||
}
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("agentd: hy2 cert keygen: %w", err)
|
||||
}
|
||||
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return fmt.Errorf("agentd: hy2 cert serial: %w", err)
|
||||
}
|
||||
|
||||
tmpl := x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: "pangolin-hy2"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().AddDate(10, 0, 0),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
if sni != "" {
|
||||
tmpl.DNSNames = []string{sni}
|
||||
}
|
||||
|
||||
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("agentd: hy2 cert create: %w", err)
|
||||
}
|
||||
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
if err := os.WriteFile(certPath, certPEM, 0o644); err != nil {
|
||||
return fmt.Errorf("agentd: write hy2 cert: %w", err)
|
||||
}
|
||||
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("agentd: marshal hy2 key: %w", err)
|
||||
}
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
|
||||
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
|
||||
return fmt.Errorf("agentd: write hy2 key: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fileExists(p string) bool {
|
||||
info, err := os.Stat(p)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package agentd
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnsureSelfSignedCert(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cert := filepath.Join(dir, "hy2.crt")
|
||||
key := filepath.Join(dir, "hy2.key")
|
||||
|
||||
if err := ensureSelfSignedCert(cert, key, "www.apple.com"); err != nil {
|
||||
t.Fatalf("generate: %v", err)
|
||||
}
|
||||
|
||||
// 必须是 sing-box(crypto/tls)能加载的有效 keypair。
|
||||
pair, err := tls.LoadX509KeyPair(cert, key)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadX509KeyPair: %v", err)
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(pair.Certificate[0])
|
||||
if err != nil {
|
||||
t.Fatalf("parse cert: %v", err)
|
||||
}
|
||||
if leaf.Subject.CommonName != "pangolin-hy2" {
|
||||
t.Errorf("CN = %q, want pangolin-hy2", leaf.Subject.CommonName)
|
||||
}
|
||||
if len(leaf.DNSNames) != 1 || leaf.DNSNames[0] != "www.apple.com" {
|
||||
t.Errorf("SAN = %v, want [www.apple.com]", leaf.DNSNames)
|
||||
}
|
||||
|
||||
// key 文件权限 0600。
|
||||
info, err := os.Stat(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||
t.Errorf("key perm = %o, want 600", perm)
|
||||
}
|
||||
|
||||
// 幂等:第二次调用不应重写(内容不变)。
|
||||
before, _ := os.ReadFile(cert)
|
||||
if err := ensureSelfSignedCert(cert, key, "www.apple.com"); err != nil {
|
||||
t.Fatalf("second call: %v", err)
|
||||
}
|
||||
after, _ := os.ReadFile(cert)
|
||||
if string(before) != string(after) {
|
||||
t.Error("cert regenerated on second call; expected idempotent")
|
||||
}
|
||||
|
||||
// cert 是 PEM CERTIFICATE 块。
|
||||
if blk, _ := pem.Decode(after); blk == nil || blk.Type != "CERTIFICATE" {
|
||||
t.Error("cert is not a PEM CERTIFICATE block")
|
||||
}
|
||||
}
|
||||
@@ -314,19 +314,51 @@ func (s *SingBox) RenderConfig() ([]byte, error) {
|
||||
|
||||
// writeAndRestart renders, writes the config file and restarts sing-box.
|
||||
func (s *SingBox) writeAndRestart(ctx context.Context) error {
|
||||
if err := os.MkdirAll(filepath.Dir(s.cfg.SingboxConfigPath), 0o755); err != nil {
|
||||
return fmt.Errorf("agentd: mkdir singbox cfg: %w", err)
|
||||
}
|
||||
// Hy2 启用时:渲染前确保自签 TLS 证书就位,并把路径写回 hy2 配置(方案①)。
|
||||
if err := s.ensureHy2Cert(); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := s.RenderConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(s.cfg.SingboxConfigPath), 0o755); err != nil {
|
||||
return fmt.Errorf("agentd: mkdir singbox cfg: %w", err)
|
||||
}
|
||||
if err := atomicWrite(s.cfg.SingboxConfigPath, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.restarter.Restart(ctx)
|
||||
}
|
||||
|
||||
// ensureHy2Cert 为启用 hy2 的节点准备自签证书(幂等),并把 CertPath/KeyPath 写回当前
|
||||
// hy2 配置,供 RenderConfig 渲染 hysteria2 入站的 certificate_path/key_path。
|
||||
func (s *SingBox) ensureHy2Cert() error {
|
||||
s.mu.Lock()
|
||||
enabled := s.hy2 != nil
|
||||
var sni string
|
||||
if s.reality != nil {
|
||||
sni = s.reality.ServerName
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if !enabled {
|
||||
return nil
|
||||
}
|
||||
dir := filepath.Dir(s.cfg.SingboxConfigPath)
|
||||
certPath := filepath.Join(dir, "hy2.crt")
|
||||
keyPath := filepath.Join(dir, "hy2.key")
|
||||
if err := ensureSelfSignedCert(certPath, keyPath, sni); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
if s.hy2 != nil {
|
||||
s.hy2.CertPath = certPath
|
||||
s.hy2.KeyPath = keyPath
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// markDirty signals the debounce loop that a render is pending (non-blocking).
|
||||
func (s *SingBox) markDirty() {
|
||||
select {
|
||||
|
||||
@@ -80,8 +80,12 @@ func BuildClientConfig(node *nodes.NodeRow, dpUUID, deriveKey string) ([]byte, e
|
||||
"server_port": node.Hy2Port.Int32,
|
||||
"password": hy2Password,
|
||||
"tls": map[string]any{
|
||||
"enabled": true,
|
||||
"alpn": []string{"h3"},
|
||||
"enabled": true,
|
||||
"alpn": []string{"h3"},
|
||||
"server_name": node.RealitySNI,
|
||||
// 节点 hy2 用自签证书(方案①);两端自有,跳过 CA 校验,
|
||||
// 服务端鉴权靠 per-user 派生的 hy2 密码。
|
||||
"insecure": true,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user