da8f3a4fef
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>
174 lines
5.4 KiB
Go
174 lines
5.4 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
|
|
"github.com/wangjia/pangolin/server/internal/dpcred"
|
|
"github.com/wangjia/pangolin/server/internal/nodes"
|
|
)
|
|
|
|
// BuildClientConfig renders a complete sing-box CLIENT configuration JSON that
|
|
// the client app passes verbatim to the local tunnel kernel.
|
|
//
|
|
// Design rule (ARCHITECTURE.md §3.1): the Dart/Flutter client MUST NOT assemble
|
|
// or modify the config — it is rendered here, server-side, and returned raw.
|
|
//
|
|
// Parameters:
|
|
// - node: the target node row (provides endpoint, keys, ports)
|
|
// - dpUUID: the authenticated user's data-plane UUID (used as VLESS uuid)
|
|
// - deriveKey: shared HMAC key used by both server and agent to derive the
|
|
// Hysteria2 password from dp_uuid (must equal PANGOLIN_AGENT_DERIVE_KEY)
|
|
// - ttlSeconds: credential lifetime hint; not embedded in the config but can
|
|
// be used by callers to set a session timer
|
|
func BuildClientConfig(node *nodes.NodeRow, dpUUID, deriveKey string) ([]byte, error) {
|
|
// Parse host:port from endpoint; endpoint format is "host:port".
|
|
host, _ := splitHostPort(node.Endpoint)
|
|
if host == "" {
|
|
host = node.Endpoint
|
|
}
|
|
|
|
realityPublicKey := node.RealityPBK
|
|
realityShortID := node.RealityShortID
|
|
// Hysteria2 仅在节点确实配置了 hy2 端口时才下发。否则不出 hy2-out:
|
|
// 它会指向 REALITY 的 TCP 端口、而服务端又无 Hy2 监听,导致 urltest
|
|
// 一直探测一个死成员(connection reset by peer)。
|
|
hy2Enabled := node.Hy2Port.Valid && node.Hy2Port.Int32 > 0
|
|
hy2Password := dpcred.DeriveHy2Password(dpUUID, deriveKey)
|
|
|
|
// REALITY outbound (VLESS + REALITY TLS, TCP 443).
|
|
realityOut := map[string]any{
|
|
"type": "vless",
|
|
"tag": "reality-out",
|
|
"server": host,
|
|
"server_port": 11443, // REALITY always uses port from endpoint
|
|
"uuid": dpUUID,
|
|
"flow": dpcred.DefaultFlow,
|
|
"tls": map[string]any{
|
|
"enabled": true,
|
|
"server_name": node.RealitySNI,
|
|
"utls": map[string]any{
|
|
"enabled": true,
|
|
"fingerprint": "chrome",
|
|
},
|
|
"reality": map[string]any{
|
|
"enabled": true,
|
|
"public_key": realityPublicKey,
|
|
"short_id": realityShortID,
|
|
},
|
|
},
|
|
}
|
|
|
|
// Parse the REALITY listen port from endpoint.
|
|
if _, portStr := splitHostPort(node.Endpoint); portStr != "" {
|
|
port := 0
|
|
for _, ch := range portStr {
|
|
if ch >= '0' && ch <= '9' {
|
|
port = port*10 + int(ch-'0')
|
|
}
|
|
}
|
|
if port > 0 {
|
|
realityOut["server_port"] = port
|
|
}
|
|
}
|
|
|
|
// Hysteria2 outbound (UDP 443).
|
|
hy2Out := map[string]any{
|
|
"type": "hysteria2",
|
|
"tag": "hy2-out",
|
|
"server": host,
|
|
"server_port": node.Hy2Port.Int32,
|
|
"password": hy2Password,
|
|
"tls": map[string]any{
|
|
"enabled": true,
|
|
"alpn": []string{"h3"},
|
|
"server_name": node.RealitySNI,
|
|
// 节点 hy2 用自签证书(方案①);两端自有,跳过 CA 校验,
|
|
// 服务端鉴权靠 per-user 派生的 hy2 密码。
|
|
"insecure": true,
|
|
},
|
|
}
|
|
|
|
// TUN inbound with kill-switch (strict_route).
|
|
tunIn := map[string]any{
|
|
"type": "tun",
|
|
"tag": "tun-in",
|
|
"address": []string{"172.19.0.1/30"},
|
|
"mtu": 9000,
|
|
"auto_route": true,
|
|
"strict_route": true,
|
|
"stack": "system",
|
|
}
|
|
|
|
// 代理出站集合:REALITY 必有;Hy2 仅在启用时加入(否则不进配置/探测组)。
|
|
proxyTags := []string{"reality-out"}
|
|
proxyOutbounds := []any{realityOut}
|
|
if hy2Enabled {
|
|
proxyTags = append(proxyTags, "hy2-out")
|
|
proxyOutbounds = append(proxyOutbounds, hy2Out)
|
|
}
|
|
|
|
// urltest auto-select outbound.
|
|
autoBest := map[string]any{
|
|
"type": "urltest",
|
|
"tag": "auto",
|
|
"outbounds": proxyTags,
|
|
"url": "https://www.gstatic.com/generate_204",
|
|
"interval": "3m",
|
|
"tolerance": 50,
|
|
}
|
|
|
|
// Route: LAN direct, everything else via auto.
|
|
route := map[string]any{
|
|
"rules": []any{
|
|
map[string]any{
|
|
"ip_cidr": []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8"},
|
|
"outbound": "direct",
|
|
},
|
|
},
|
|
"final": "auto",
|
|
"auto_detect_interface": true,
|
|
// sing-box 1.12+ 要求显式声明出站域名用哪个 DNS 解析,缺失即 FATAL。
|
|
"default_domain_resolver": map[string]any{"server": "local"},
|
|
}
|
|
|
|
// DNS: remote over tunnel, local for domestic.
|
|
// sing-box 1.12+ DNS server format(type+server);旧的 address 串格式在
|
|
// 1.13 已 FATAL 拒绝(legacy DNS servers deprecated)。
|
|
dns := map[string]any{
|
|
"servers": []any{
|
|
map[string]any{"tag": "remote", "type": "tls", "server": "8.8.8.8", "detour": "auto"},
|
|
// local 不带 detour:sing-box 1.12 拒绝 DNS detour 到空 direct 出站
|
|
// (FATAL: detour to an empty direct outbound makes no sense)。
|
|
map[string]any{"tag": "local", "type": "udp", "server": "223.5.5.5"},
|
|
},
|
|
"final": "remote",
|
|
"strategy": "ipv4_only",
|
|
}
|
|
|
|
cfg := map[string]any{
|
|
// timestamp=false:客户端日志出口(logLine)已统一加时间戳,
|
|
// 关掉 sing-box 自带时间戳避免一行打印两个时间。
|
|
"log": map[string]any{"level": "warn", "timestamp": false},
|
|
"inbounds": []any{tunIn},
|
|
"outbounds": append(proxyOutbounds,
|
|
autoBest,
|
|
map[string]any{"type": "block", "tag": "block"},
|
|
map[string]any{"type": "direct", "tag": "direct"},
|
|
),
|
|
"route": route,
|
|
"dns": dns,
|
|
}
|
|
|
|
return json.Marshal(cfg)
|
|
}
|
|
|
|
// splitHostPort splits "host:port" into (host, port). Returns ("", "") on failure.
|
|
func splitHostPort(s string) (host, port string) {
|
|
i := strings.LastIndexByte(s, ':')
|
|
if i < 0 {
|
|
return s, ""
|
|
}
|
|
return s[:i], s[i+1:]
|
|
}
|