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:
wangjia
2026-06-19 12:12:35 +08:00
parent b25c8bbc2c
commit da8f3a4fef
4 changed files with 177 additions and 5 deletions
+35 -3
View File
@@ -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 {