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, }, }