mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-09 21:00:58 +00:00
feat(inbounds): per-proxy Pinned Peer Cert SHA-256 + labeled External Proxy form
Redesign the Add Inbound -> Stream External Proxy section into labeled per-entry cards (Force TLS / Host / Port / Remark and, under TLS, SNI / Fingerprint / ALPN) and add a Pinned Peer Cert SHA-256 field with a generate-random-hash button to each entry. The pin flows end to end into share links: pcs for vmess/vless/trojan/ss (stripped when a proxy forces security off) and the hex-normalized pinSHA256 for Hysteria. JSON and Clash subscriptions emit the native pinnedPeerCertSha256 / pin-sha256 via the cloned stream. Adds the forceTls label across all 13 locales plus frontend and Go tests.
This commit is contained in:
+79
-5
@@ -667,8 +667,11 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
|
||||
}
|
||||
epRemark, _ := ep["remark"].(string)
|
||||
|
||||
epParams := cloneStringMap(params)
|
||||
applyExternalProxyHysteriaParams(ep, epParams)
|
||||
|
||||
link := fmt.Sprintf("%s://%s@%s:%d", protocol, auth, dest, int(portF))
|
||||
links = append(links, buildLinkWithParams(link, params, s.genRemark(inbound, email, epRemark)))
|
||||
links = append(links, buildLinkWithParams(link, epParams, s.genRemark(inbound, email, epRemark)))
|
||||
}
|
||||
return strings.Join(links, "\n")
|
||||
}
|
||||
@@ -1017,7 +1020,7 @@ func buildVmessLink(obj map[string]any) string {
|
||||
func cloneVmessShareObj(baseObj map[string]any, newSecurity string) map[string]any {
|
||||
newObj := map[string]any{}
|
||||
for key, value := range baseObj {
|
||||
if !(newSecurity == "none" && (key == "alpn" || key == "sni" || key == "fp")) {
|
||||
if !(newSecurity == "none" && (key == "alpn" || key == "sni" || key == "fp" || key == "pcs")) {
|
||||
newObj[key] = value
|
||||
}
|
||||
}
|
||||
@@ -1037,6 +1040,9 @@ func applyExternalProxyTLSObj(ep map[string]any, obj map[string]any, security st
|
||||
if alpn, ok := externalProxyALPN(ep["alpn"]); ok {
|
||||
obj["alpn"] = alpn
|
||||
}
|
||||
if pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"]); ok {
|
||||
obj["pcs"] = joinAnyStrings(pins)
|
||||
}
|
||||
}
|
||||
|
||||
func applyExternalProxyTLSParams(ep map[string]any, params map[string]string, security string) {
|
||||
@@ -1052,6 +1058,29 @@ func applyExternalProxyTLSParams(ep map[string]any, params map[string]string, se
|
||||
if alpn, ok := externalProxyALPN(ep["alpn"]); ok {
|
||||
params["alpn"] = alpn
|
||||
}
|
||||
if pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"]); ok {
|
||||
params["pcs"] = joinAnyStrings(pins)
|
||||
}
|
||||
}
|
||||
|
||||
// applyExternalProxyHysteriaParams overrides the cert pin for a single
|
||||
// external-proxy entry on a Hysteria link. Hysteria carries the pin as a hex
|
||||
// `pinSHA256` (not the `pcs` the URL-param protocols use), so each entry is
|
||||
// coerced through hysteriaPinHex like the main pin. sni/fp/alpn are left as
|
||||
// the inbound's own — Hysteria external proxies are typically alternate
|
||||
// endpoints (port-hop / CDN) fronting the same certificate.
|
||||
func applyExternalProxyHysteriaParams(ep map[string]any, params map[string]string) {
|
||||
pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"])
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
hexPins := make([]string, 0, len(pins))
|
||||
for _, p := range pins {
|
||||
if s, ok := p.(string); ok {
|
||||
hexPins = append(hexPins, hysteriaPinHex(s))
|
||||
}
|
||||
}
|
||||
params["pinSHA256"] = strings.Join(hexPins, ",")
|
||||
}
|
||||
|
||||
// cloneStreamForExternalProxy returns a shallow clone of stream with
|
||||
@@ -1096,6 +1125,14 @@ func applyExternalProxyTLSToStream(ep map[string]any, stream map[string]any, sec
|
||||
if alpn, ok := externalProxyALPNList(ep["alpn"]); ok {
|
||||
tlsSettings["alpn"] = alpn
|
||||
}
|
||||
if pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"]); ok {
|
||||
settings, _ := tlsSettings["settings"].(map[string]any)
|
||||
if settings == nil {
|
||||
settings = map[string]any{}
|
||||
tlsSettings["settings"] = settings
|
||||
}
|
||||
settings["pinnedPeerCertSha256"] = pins
|
||||
}
|
||||
}
|
||||
|
||||
func externalProxySNI(ep map[string]any) (string, bool) {
|
||||
@@ -1165,6 +1202,43 @@ func externalProxyALPNList(value any) ([]any, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// externalProxyPins extracts an external-proxy entry's pinnedPeerCertSha256
|
||||
// as a []any of non-empty strings. The []any element type matches what the
|
||||
// JSON/Clash sub builders expect when reading the value back off the cloned
|
||||
// stream's tlsSettings.settings.
|
||||
func externalProxyPins(value any) ([]any, bool) {
|
||||
switch v := value.(type) {
|
||||
case []string:
|
||||
out := make([]any, 0, len(v))
|
||||
for _, item := range v {
|
||||
if item != "" {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out, len(out) > 0
|
||||
case []any:
|
||||
out := make([]any, 0, len(v))
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok && s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out, len(out) > 0
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func joinAnyStrings(items []any) string {
|
||||
parts := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if s, ok := item.(string); ok {
|
||||
parts = append(parts, s)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func (s *SubService) buildVmessExternalProxyLinks(externalProxies []any, baseObj map[string]any, inbound *model.Inbound, email string) string {
|
||||
var links strings.Builder
|
||||
for index, externalProxy := range externalProxies {
|
||||
@@ -1204,8 +1278,8 @@ func buildLinkWithParams(link string, params map[string]string, fragment string)
|
||||
|
||||
// buildLinkWithParamsAndSecurity is buildLinkWithParams plus an
|
||||
// external-proxy override: the `security` key in params is replaced with
|
||||
// the supplied value, and TLS hint fields (alpn/sni/fp) are stripped when
|
||||
// the override is `none`.
|
||||
// the supplied value, and TLS hint fields (alpn/sni/fp/pcs) are stripped
|
||||
// when the override is `none`.
|
||||
func buildLinkWithParamsAndSecurity(link string, params map[string]string, fragment, security string, omitTLSFields bool) string {
|
||||
return appendQueryAndFragment(link, params, fragment, security, omitTLSFields)
|
||||
}
|
||||
@@ -1220,7 +1294,7 @@ func appendQueryAndFragment(link string, params map[string]string, fragment, sec
|
||||
if securityOverride != "" && k == "security" {
|
||||
v = securityOverride
|
||||
}
|
||||
if omitTLSFields && (k == "alpn" || k == "sni" || k == "fp") {
|
||||
if omitTLSFields && (k == "alpn" || k == "sni" || k == "fp" || k == "pcs") {
|
||||
continue
|
||||
}
|
||||
q.Set(k, v)
|
||||
|
||||
@@ -617,6 +617,85 @@ func TestApplyExternalProxyTLSToStream_DoesNotLeakAcrossProxies(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExternalProxyTLSParams_SetsPinnedPeerCert(t *testing.T) {
|
||||
params := map[string]string{"security": "tls"}
|
||||
ep := map[string]any{
|
||||
"dest": "proxy.example.com",
|
||||
"pinnedPeerCertSha256": []any{"aa11", "bb22"},
|
||||
}
|
||||
|
||||
applyExternalProxyTLSParams(ep, params, "tls")
|
||||
|
||||
if params["pcs"] != "aa11,bb22" {
|
||||
t.Fatalf("pcs = %q, want aa11,bb22", params["pcs"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExternalProxyTLSObj_SetsPinnedPeerCert(t *testing.T) {
|
||||
obj := map[string]any{"tls": "tls"}
|
||||
ep := map[string]any{
|
||||
"dest": "proxy.example.com",
|
||||
"pinnedPeerCertSha256": []any{"aa11"},
|
||||
}
|
||||
|
||||
applyExternalProxyTLSObj(ep, obj, "tls")
|
||||
|
||||
if obj["pcs"] != "aa11" {
|
||||
t.Fatalf("pcs = %v, want aa11", obj["pcs"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExternalProxyTLSToStream_SetsPinnedPeerCert(t *testing.T) {
|
||||
stream := map[string]any{
|
||||
"security": "tls",
|
||||
"tlsSettings": map[string]any{"serverName": "upstream.example.com"},
|
||||
}
|
||||
ep := map[string]any{"dest": "edge.example.com", "pinnedPeerCertSha256": []any{"aa11", "bb22"}}
|
||||
|
||||
working := cloneStreamForExternalProxy(stream)
|
||||
applyExternalProxyTLSToStream(ep, working, "tls")
|
||||
|
||||
ts := working["tlsSettings"].(map[string]any)
|
||||
settings, _ := ts["settings"].(map[string]any)
|
||||
pins, ok := settings["pinnedPeerCertSha256"].([]any)
|
||||
if !ok || len(pins) != 2 || pins[0] != "aa11" || pins[1] != "bb22" {
|
||||
t.Fatalf("pinnedPeerCertSha256 = %v, want [aa11 bb22]", settings["pinnedPeerCertSha256"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExternalProxyHysteriaParams_PinIsHexNormalized(t *testing.T) {
|
||||
// base64 SHA-256 pin must come out as bare lowercase hex for Hysteria's
|
||||
// pinSHA256, which other (pcs) protocols leave untouched.
|
||||
params := map[string]string{"security": "tls", "sni": "server.example.com"}
|
||||
ep := map[string]any{
|
||||
"dest": "edge.example.com",
|
||||
"pinnedPeerCertSha256": []any{"yEfdI5XQl4wHgLggHEsomosoFZfUfCdfLXfT+W2N6cQ="},
|
||||
}
|
||||
|
||||
applyExternalProxyHysteriaParams(ep, params)
|
||||
|
||||
if params["pinSHA256"] != "c847dd2395d0978c0780b8201c4b289a8b281597d47c275f2d77d3f96d8de9c4" {
|
||||
t.Fatalf("pinSHA256 = %q, want hex-normalized pin", params["pinSHA256"])
|
||||
}
|
||||
if _, ok := params["pcs"]; ok {
|
||||
t.Fatalf("pcs must not be set for Hysteria, got %v", params)
|
||||
}
|
||||
if params["sni"] != "server.example.com" {
|
||||
t.Fatalf("sni = %q, want inbound sni preserved (no override for Hysteria)", params["sni"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExternalProxyHysteriaParams_NoPinLeavesMainPin(t *testing.T) {
|
||||
params := map[string]string{"security": "tls", "pinSHA256": "deadbeef"}
|
||||
ep := map[string]any{"dest": "edge.example.com"}
|
||||
|
||||
applyExternalProxyHysteriaParams(ep, params)
|
||||
|
||||
if params["pinSHA256"] != "deadbeef" {
|
||||
t.Fatalf("pinSHA256 = %q, want main pin preserved when proxy has none", params["pinSHA256"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExternalProxyTLSParams_DoesNotApplyForNone(t *testing.T) {
|
||||
params := map[string]string{
|
||||
"security": "none",
|
||||
|
||||
Reference in New Issue
Block a user