From da8f3a4fefa07bbd827712da3e6c8c8606219d6f Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 19 Jun 2026 12:12:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20Hy2=20=E8=87=AA=E7=AD=BE=20TLS?= =?UTF-8?q?=20=E8=AF=81=E4=B9=A6(=E6=96=B9=E6=A1=88=E2=91=A0)=E2=80=94=20?= =?UTF-8?q?=E8=A1=A5=E5=85=A8=20hysteria2=20=E5=85=A5=E7=AB=99=E5=8F=AF?= =?UTF-8?q?=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/internal/agentd/hy2cert.go | 76 +++++++++++++++++++++++++ server/internal/agentd/hy2cert_test.go | 60 +++++++++++++++++++ server/internal/agentd/singbox.go | 38 ++++++++++++- server/internal/httpapi/clientconfig.go | 8 ++- 4 files changed, 177 insertions(+), 5 deletions(-) create mode 100644 server/internal/agentd/hy2cert.go create mode 100644 server/internal/agentd/hy2cert_test.go diff --git a/server/internal/agentd/hy2cert.go b/server/internal/agentd/hy2cert.go new file mode 100644 index 0000000..37c6a4f --- /dev/null +++ b/server/internal/agentd/hy2cert.go @@ -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() +} diff --git a/server/internal/agentd/hy2cert_test.go b/server/internal/agentd/hy2cert_test.go new file mode 100644 index 0000000..0c447de --- /dev/null +++ b/server/internal/agentd/hy2cert_test.go @@ -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") + } +} diff --git a/server/internal/agentd/singbox.go b/server/internal/agentd/singbox.go index 3d8d047..5e9b38b 100644 --- a/server/internal/agentd/singbox.go +++ b/server/internal/agentd/singbox.go @@ -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 { diff --git a/server/internal/httpapi/clientconfig.go b/server/internal/httpapi/clientconfig.go index df20e86..83b8b22 100644 --- a/server/internal/httpapi/clientconfig.go +++ b/server/internal/httpapi/clientconfig.go @@ -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, }, }