mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-25 22:06:09 +00:00
feat(inbound): Advanced XHTTP and external TLS proxy settings (#4491)
* ✨ Introduce extended XHTTP and external proxy settings * ✨ Add custom SNI for proxy * ✨ Add previous changes into React version of app * fix(sub): isolate per-proxy tlsSettings during external-proxy iteration cloneMap (Clash) is shallow and `newStream := stream` (JSON) is an alias, so tlsSettings was shared across iterations. The new applyExternalProxyTLSToStream mutates it, leaking one proxy's serverName/fingerprint/alpn into the next (only overwritten when the next proxy explicitly sets the same field). Add cloneStreamForExternalProxy: shallow clones the top-level stream plus deep clones tlsSettings and tlsSettings.settings. Regression test locks in that proxy B does not inherit proxy A's fingerprint/alpn when B leaves them unset.
This commit is contained in:
+18
-2
@@ -122,7 +122,8 @@ func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client
|
||||
defaultDest = host
|
||||
}
|
||||
externalProxies, ok := stream["externalProxy"].([]any)
|
||||
if !ok || len(externalProxies) == 0 {
|
||||
hasExternalProxy := ok && len(externalProxies) > 0
|
||||
if !hasExternalProxy {
|
||||
externalProxies = []any{map[string]any{
|
||||
"forceTls": "same",
|
||||
"dest": defaultDest,
|
||||
@@ -138,7 +139,7 @@ func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client
|
||||
workingInbound := *inbound
|
||||
workingInbound.Listen = extPrxy["dest"].(string)
|
||||
workingInbound.Port = int(extPrxy["port"].(float64))
|
||||
workingStream := cloneMap(stream)
|
||||
workingStream := cloneStreamForExternalProxy(stream)
|
||||
|
||||
switch extPrxy["forceTls"].(string) {
|
||||
case "tls":
|
||||
@@ -153,6 +154,10 @@ func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client
|
||||
delete(workingStream, "realitySettings")
|
||||
}
|
||||
}
|
||||
security, _ := workingStream["security"].(string)
|
||||
if hasExternalProxy {
|
||||
applyExternalProxyTLSToStream(extPrxy, workingStream, security)
|
||||
}
|
||||
|
||||
proxy := s.buildProxy(&workingInbound, client, workingStream, extPrxy["remark"].(string))
|
||||
if len(proxy) > 0 {
|
||||
@@ -383,6 +388,17 @@ func (s *SubClashService) applySecurity(proxy map[string]any, security string, s
|
||||
if fingerprint, ok := tlsSettings["fingerprint"].(string); ok && fingerprint != "" {
|
||||
proxy["client-fingerprint"] = fingerprint
|
||||
}
|
||||
if alpn, ok := externalProxyALPNList(tlsSettings["alpn"]); ok {
|
||||
out := make([]string, 0, len(alpn))
|
||||
for _, item := range alpn {
|
||||
if s, ok := item.(string); ok && s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
proxy["alpn"] = out
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
case "reality":
|
||||
|
||||
@@ -174,7 +174,8 @@ func (s *SubJsonService) getConfig(inbound *model.Inbound, client model.Client,
|
||||
}
|
||||
|
||||
externalProxies, ok := stream["externalProxy"].([]any)
|
||||
if !ok || len(externalProxies) == 0 {
|
||||
hasExternalProxy := ok && len(externalProxies) > 0
|
||||
if !hasExternalProxy {
|
||||
externalProxies = []any{
|
||||
map[string]any{
|
||||
"forceTls": "same",
|
||||
@@ -191,7 +192,7 @@ func (s *SubJsonService) getConfig(inbound *model.Inbound, client model.Client,
|
||||
extPrxy := ep.(map[string]any)
|
||||
inbound.Listen = extPrxy["dest"].(string)
|
||||
inbound.Port = int(extPrxy["port"].(float64))
|
||||
newStream := stream
|
||||
newStream := cloneStreamForExternalProxy(stream)
|
||||
switch extPrxy["forceTls"].(string) {
|
||||
case "tls":
|
||||
if newStream["security"] != "tls" {
|
||||
@@ -204,6 +205,10 @@ func (s *SubJsonService) getConfig(inbound *model.Inbound, client model.Client,
|
||||
delete(newStream, "tlsSettings")
|
||||
}
|
||||
}
|
||||
security, _ := newStream["security"].(string)
|
||||
if hasExternalProxy {
|
||||
applyExternalProxyTLSToStream(extPrxy, newStream, security)
|
||||
}
|
||||
streamSettings, _ := json.MarshalIndent(newStream, "", " ")
|
||||
|
||||
var newOutbounds []json_util.RawMessage
|
||||
|
||||
+208
-10
@@ -849,11 +849,159 @@ func cloneVmessShareObj(baseObj map[string]any, newSecurity string) map[string]a
|
||||
return newObj
|
||||
}
|
||||
|
||||
func applyExternalProxyTLSObj(ep map[string]any, obj map[string]any, security string) {
|
||||
if security != "tls" {
|
||||
return
|
||||
}
|
||||
if sni, ok := externalProxySNI(ep); ok {
|
||||
obj["sni"] = sni
|
||||
}
|
||||
if fp, ok := ep["fingerprint"].(string); ok && fp != "" {
|
||||
obj["fp"] = fp
|
||||
}
|
||||
if alpn, ok := externalProxyALPN(ep["alpn"]); ok {
|
||||
obj["alpn"] = alpn
|
||||
}
|
||||
}
|
||||
|
||||
func applyExternalProxyTLSParams(ep map[string]any, params map[string]string, security string) {
|
||||
if security != "tls" {
|
||||
return
|
||||
}
|
||||
if sni, ok := externalProxySNI(ep); ok {
|
||||
params["sni"] = sni
|
||||
}
|
||||
if fp, ok := ep["fingerprint"].(string); ok && fp != "" {
|
||||
params["fp"] = fp
|
||||
}
|
||||
if alpn, ok := externalProxyALPN(ep["alpn"]); ok {
|
||||
params["alpn"] = alpn
|
||||
}
|
||||
}
|
||||
|
||||
// cloneStreamForExternalProxy returns a shallow clone of stream with
|
||||
// tlsSettings (and its nested settings map) deep-copied. The external
|
||||
// proxy loop mutates tlsSettings per iteration, so without isolating
|
||||
// those maps each proxy's SNI/fingerprint/ALPN would leak into the next.
|
||||
func cloneStreamForExternalProxy(stream map[string]any) map[string]any {
|
||||
out := cloneMap(stream)
|
||||
ts, ok := out["tlsSettings"].(map[string]any)
|
||||
if !ok || ts == nil {
|
||||
return out
|
||||
}
|
||||
clonedTs := cloneMap(ts)
|
||||
if inner, ok := clonedTs["settings"].(map[string]any); ok && inner != nil {
|
||||
clonedTs["settings"] = cloneMap(inner)
|
||||
}
|
||||
out["tlsSettings"] = clonedTs
|
||||
return out
|
||||
}
|
||||
|
||||
func applyExternalProxyTLSToStream(ep map[string]any, stream map[string]any, security string) {
|
||||
if security != "tls" {
|
||||
return
|
||||
}
|
||||
tlsSettings, _ := stream["tlsSettings"].(map[string]any)
|
||||
if tlsSettings == nil {
|
||||
tlsSettings = map[string]any{}
|
||||
stream["tlsSettings"] = tlsSettings
|
||||
}
|
||||
if sni, ok := externalProxySNI(ep); ok {
|
||||
tlsSettings["serverName"] = sni
|
||||
}
|
||||
if fp, ok := ep["fingerprint"].(string); ok && fp != "" {
|
||||
tlsSettings["fingerprint"] = fp
|
||||
settings, _ := tlsSettings["settings"].(map[string]any)
|
||||
if settings == nil {
|
||||
settings = map[string]any{}
|
||||
tlsSettings["settings"] = settings
|
||||
}
|
||||
settings["fingerprint"] = fp
|
||||
}
|
||||
if alpn, ok := externalProxyALPNList(ep["alpn"]); ok {
|
||||
tlsSettings["alpn"] = alpn
|
||||
}
|
||||
}
|
||||
|
||||
func externalProxySNI(ep map[string]any) (string, bool) {
|
||||
if sni, ok := ep["sni"].(string); ok && sni != "" {
|
||||
return sni, true
|
||||
}
|
||||
if dest, ok := ep["dest"].(string); ok && dest != "" {
|
||||
return dest, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func externalProxyALPN(value any) (string, bool) {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return v, v != ""
|
||||
case []string:
|
||||
if len(v) == 0 {
|
||||
return "", false
|
||||
}
|
||||
return strings.Join(v, ","), true
|
||||
case []any:
|
||||
alpn := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok && s != "" {
|
||||
alpn = append(alpn, s)
|
||||
}
|
||||
}
|
||||
if len(alpn) == 0 {
|
||||
return "", false
|
||||
}
|
||||
return strings.Join(alpn, ","), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func externalProxyALPNList(value any) ([]any, bool) {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
if v == "" {
|
||||
return nil, false
|
||||
}
|
||||
parts := strings.Split(v, ",")
|
||||
out := make([]any, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part = strings.TrimSpace(part); part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out, len(out) > 0
|
||||
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 (s *SubService) buildVmessExternalProxyLinks(externalProxies []any, baseObj map[string]any, inbound *model.Inbound, email string) string {
|
||||
var links strings.Builder
|
||||
for index, externalProxy := range externalProxies {
|
||||
ep, _ := externalProxy.(map[string]any)
|
||||
newSecurity, _ := ep["forceTls"].(string)
|
||||
securityToApply := baseObj["tls"].(string)
|
||||
if newSecurity != "same" {
|
||||
securityToApply = newSecurity
|
||||
}
|
||||
newObj := cloneVmessShareObj(baseObj, newSecurity)
|
||||
newObj["ps"] = s.genRemark(inbound, email, ep["remark"].(string))
|
||||
newObj["add"] = ep["dest"].(string)
|
||||
@@ -862,6 +1010,7 @@ func (s *SubService) buildVmessExternalProxyLinks(externalProxies []any, baseObj
|
||||
if newSecurity != "same" {
|
||||
newObj["tls"] = newSecurity
|
||||
}
|
||||
applyExternalProxyTLSObj(ep, newObj, securityToApply)
|
||||
if index > 0 {
|
||||
links.WriteString("\n")
|
||||
}
|
||||
@@ -917,11 +1066,14 @@ func (s *SubService) buildExternalProxyURLLinks(
|
||||
securityToApply = newSecurity
|
||||
}
|
||||
|
||||
nextParams := cloneStringMap(params)
|
||||
applyExternalProxyTLSParams(ep, nextParams, securityToApply)
|
||||
|
||||
links = append(
|
||||
links,
|
||||
buildLinkWithParamsAndSecurity(
|
||||
makeLink(dest, port),
|
||||
params,
|
||||
nextParams,
|
||||
makeRemark(ep),
|
||||
securityToApply,
|
||||
newSecurity == "none",
|
||||
@@ -1052,10 +1204,9 @@ func searchKey(data any, key string) (any, bool) {
|
||||
// - server-only (noSSEHeader, scMaxBufferedPosts, scStreamUpServerSecs,
|
||||
// serverMaxHeaderBytes) — client wouldn't read them, so emitting
|
||||
// them just bloats the URL.
|
||||
// - client-only (headers, uplinkHTTPMethod, uplinkChunkSize,
|
||||
// noGRPCHeader, scMinPostsIntervalMs, xmux, downloadSettings) — the
|
||||
// inbound config doesn't have them; the client configures them
|
||||
// locally.
|
||||
// - client-only values are included only when present in the inbound
|
||||
// JSON. Some deployments/imported configs carry them there, and the
|
||||
// subscription link is the only place clients can receive them.
|
||||
//
|
||||
// Truthy-only guards keep default inbounds emitting the same compact URL
|
||||
// they did before this helper grew.
|
||||
@@ -1077,15 +1228,12 @@ func buildXhttpExtra(xhttp map[string]any) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
if mode, ok := xhttp["mode"].(string); ok && len(mode) > 0 {
|
||||
extra["mode"] = mode
|
||||
}
|
||||
|
||||
stringFields := []string{
|
||||
"uplinkHTTPMethod",
|
||||
"sessionPlacement", "sessionKey",
|
||||
"seqPlacement", "seqKey",
|
||||
"uplinkDataPlacement", "uplinkDataKey",
|
||||
"scMaxEachPostBytes",
|
||||
"scMaxEachPostBytes", "scMinPostsIntervalMs",
|
||||
}
|
||||
for _, field := range stringFields {
|
||||
if v, ok := xhttp[field].(string); ok && len(v) > 0 {
|
||||
@@ -1093,6 +1241,24 @@ func buildXhttpExtra(xhttp map[string]any) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
for _, field := range []string{"uplinkChunkSize"} {
|
||||
if v, ok := nonZeroShareValue(xhttp[field]); ok {
|
||||
extra[field] = v
|
||||
}
|
||||
}
|
||||
|
||||
for _, field := range []string{"noGRPCHeader"} {
|
||||
if v, ok := xhttp[field].(bool); ok && v {
|
||||
extra[field] = v
|
||||
}
|
||||
}
|
||||
|
||||
for _, field := range []string{"xmux", "downloadSettings"} {
|
||||
if v, ok := nonEmptyShareObject(xhttp[field]); ok {
|
||||
extra[field] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Headers — emitted as the {name: value} map upstream's struct
|
||||
// expects. The server runtime ignores this field, but the client
|
||||
// (consuming the share link) honors it. Drop any "host" entry —
|
||||
@@ -1116,6 +1282,38 @@ func buildXhttpExtra(xhttp map[string]any) map[string]any {
|
||||
return extra
|
||||
}
|
||||
|
||||
func nonZeroShareValue(v any) (any, bool) {
|
||||
switch value := v.(type) {
|
||||
case string:
|
||||
return value, value != ""
|
||||
case int:
|
||||
return value, value != 0
|
||||
case int32:
|
||||
return value, value != 0
|
||||
case int64:
|
||||
return value, value != 0
|
||||
case float32:
|
||||
return value, value != 0
|
||||
case float64:
|
||||
return value, value != 0
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func nonEmptyShareObject(v any) (any, bool) {
|
||||
switch value := v.(type) {
|
||||
case map[string]any:
|
||||
return value, len(value) > 0
|
||||
case map[string]string:
|
||||
return value, len(value) > 0
|
||||
case []any:
|
||||
return value, len(value) > 0
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
// applyXhttpExtraParams emits the full xhttp config into the URL query
|
||||
// params of a vless:// / trojan:// / ss:// link. Sets path/host/mode at
|
||||
// top level (xray's Build() always lets these win over `extra`) and packs
|
||||
|
||||
@@ -151,6 +151,77 @@ func TestSearchKey_OnScalar(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildXhttpExtra_IncludesClientSideFieldsWhenPresent(t *testing.T) {
|
||||
extra := buildXhttpExtra(map[string]any{
|
||||
"path": "/xhttp",
|
||||
"host": "example.com",
|
||||
"mode": "packet-up",
|
||||
"xPaddingBytes": "100-1000",
|
||||
"uplinkHTTPMethod": "GET",
|
||||
"uplinkChunkSize": float64(4096),
|
||||
"noGRPCHeader": true,
|
||||
"scMinPostsIntervalMs": "20-40",
|
||||
"xmux": map[string]any{
|
||||
"maxConcurrency": "16-32",
|
||||
"hMaxRequestTimes": "600-900",
|
||||
"hMaxReusableSecs": "1800-3000",
|
||||
"hKeepAlivePeriod": float64(15),
|
||||
},
|
||||
"downloadSettings": map[string]any{
|
||||
"network": "xhttp",
|
||||
},
|
||||
"headers": map[string]any{
|
||||
"Host": "ignored.example.com",
|
||||
"X-Forwarded": "1",
|
||||
"X-Test-Empty": "",
|
||||
},
|
||||
})
|
||||
|
||||
if extra["path"] != nil || extra["host"] != nil {
|
||||
t.Fatalf("path/host should stay top-level, got extra %#v", extra)
|
||||
}
|
||||
for _, key := range []string{
|
||||
"xPaddingBytes",
|
||||
"uplinkHTTPMethod",
|
||||
"uplinkChunkSize",
|
||||
"noGRPCHeader",
|
||||
"scMinPostsIntervalMs",
|
||||
"xmux",
|
||||
"downloadSettings",
|
||||
} {
|
||||
if _, ok := extra[key]; !ok {
|
||||
t.Fatalf("extra missing %q: %#v", key, extra)
|
||||
}
|
||||
}
|
||||
if _, ok := extra["mode"]; ok {
|
||||
t.Fatalf("mode should stay as a top-level query parameter, got extra %#v", extra)
|
||||
}
|
||||
|
||||
headers, ok := extra["headers"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("headers = %#v, want map", extra["headers"])
|
||||
}
|
||||
if _, ok := headers["Host"]; ok {
|
||||
t.Fatalf("headers should not include Host: %#v", headers)
|
||||
}
|
||||
if headers["X-Forwarded"] != "1" {
|
||||
t.Fatalf("headers[X-Forwarded] = %#v, want 1", headers["X-Forwarded"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildXhttpExtra_LeavesDefaultClientSideFieldsOut(t *testing.T) {
|
||||
extra := buildXhttpExtra(map[string]any{
|
||||
"uplinkHTTPMethod": "",
|
||||
"uplinkChunkSize": float64(0),
|
||||
"noGRPCHeader": false,
|
||||
"xmux": map[string]any{},
|
||||
"downloadSettings": map[string]any{},
|
||||
})
|
||||
if extra != nil {
|
||||
t.Fatalf("default-only xhttp extra = %#v, want nil", extra)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneStringMap(t *testing.T) {
|
||||
src := map[string]string{"a": "1", "b": "2"}
|
||||
dst := cloneStringMap(src)
|
||||
@@ -369,6 +440,105 @@ func TestCloneVmessShareObj_NoneStripsTLSOnlyKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExternalProxyTLSParams_UsesProxyDomainAndOverrides(t *testing.T) {
|
||||
params := map[string]string{
|
||||
"security": "tls",
|
||||
"sni": "origin.example.com",
|
||||
"fp": "firefox",
|
||||
"alpn": "h2",
|
||||
}
|
||||
ep := map[string]any{
|
||||
"dest": "proxy.example.com",
|
||||
"sni": "tls.example.com",
|
||||
"fingerprint": "chrome",
|
||||
"alpn": []any{"h3", "h2"},
|
||||
}
|
||||
|
||||
applyExternalProxyTLSParams(ep, params, "tls")
|
||||
|
||||
if params["sni"] != "tls.example.com" {
|
||||
t.Fatalf("sni = %q, want tls.example.com", params["sni"])
|
||||
}
|
||||
if params["fp"] != "chrome" {
|
||||
t.Fatalf("fp = %q, want chrome", params["fp"])
|
||||
}
|
||||
if params["alpn"] != "h3,h2" {
|
||||
t.Fatalf("alpn = %q, want h3,h2", params["alpn"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExternalProxyTLSParams_FallsBackToDestSNI(t *testing.T) {
|
||||
params := map[string]string{"security": "tls"}
|
||||
ep := map[string]any{"dest": "proxy.example.com"}
|
||||
|
||||
applyExternalProxyTLSParams(ep, params, "tls")
|
||||
|
||||
if params["sni"] != "proxy.example.com" {
|
||||
t.Fatalf("sni = %q, want proxy.example.com", params["sni"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExternalProxyTLSToStream_DoesNotLeakAcrossProxies(t *testing.T) {
|
||||
stream := map[string]any{
|
||||
"security": "tls",
|
||||
"tlsSettings": map[string]any{},
|
||||
}
|
||||
proxies := []map[string]any{
|
||||
{"dest": "a.example.com", "fingerprint": "chrome", "alpn": []any{"h3"}},
|
||||
{"dest": "b.example.com"},
|
||||
}
|
||||
|
||||
results := make([]map[string]any, 0, len(proxies))
|
||||
for _, ep := range proxies {
|
||||
working := cloneStreamForExternalProxy(stream)
|
||||
applyExternalProxyTLSToStream(ep, working, "tls")
|
||||
ts := working["tlsSettings"].(map[string]any)
|
||||
snapshot := map[string]any{
|
||||
"serverName": ts["serverName"],
|
||||
"fingerprint": ts["fingerprint"],
|
||||
"alpn": ts["alpn"],
|
||||
}
|
||||
results = append(results, snapshot)
|
||||
}
|
||||
|
||||
if results[0]["serverName"] != "a.example.com" || results[0]["fingerprint"] != "chrome" {
|
||||
t.Fatalf("proxy A snapshot = %v", results[0])
|
||||
}
|
||||
if results[1]["serverName"] != "b.example.com" {
|
||||
t.Fatalf("proxy B serverName = %v, want b.example.com", results[1]["serverName"])
|
||||
}
|
||||
if results[1]["fingerprint"] != nil {
|
||||
t.Fatalf("proxy B should inherit no fingerprint, got %v (leaked from A)", results[1]["fingerprint"])
|
||||
}
|
||||
if results[1]["alpn"] != nil {
|
||||
t.Fatalf("proxy B should inherit no alpn, got %v (leaked from A)", results[1]["alpn"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExternalProxyTLSParams_DoesNotApplyForNone(t *testing.T) {
|
||||
params := map[string]string{
|
||||
"security": "none",
|
||||
"sni": "origin.example.com",
|
||||
}
|
||||
ep := map[string]any{
|
||||
"dest": "proxy.example.com",
|
||||
"fingerprint": "chrome",
|
||||
"alpn": []any{"h3"},
|
||||
}
|
||||
|
||||
applyExternalProxyTLSParams(ep, params, "none")
|
||||
|
||||
if params["sni"] != "origin.example.com" {
|
||||
t.Fatalf("sni should not change for security=none, got %q", params["sni"])
|
||||
}
|
||||
if _, ok := params["fp"]; ok {
|
||||
t.Fatalf("fp should not be set for security=none, got %v", params)
|
||||
}
|
||||
if _, ok := params["alpn"]; ok {
|
||||
t.Fatalf("alpn should not be set for security=none, got %v", params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractKcpShareFields_Defaults(t *testing.T) {
|
||||
stream := map[string]any{}
|
||||
got := extractKcpShareFields(stream)
|
||||
|
||||
Reference in New Issue
Block a user