// Package singbox converts the panel's legacy xray-shaped inbound model // into the sing-box fragment shapes defined by ITEM6-CONVERSION-SPEC // (~/workspace/xui-lite-work/ITEM6-CONVERSION-SPEC.md). // // The converter is a pure function over already-resolved inputs: the // caller (the B1 one-shot migrator in internal/database) resolves the // identity-layer clients for an inbound, reconciles them against the // legacy settings.clients mirror, and hands everything in. Anything in // the xray model that has no sing-box counterpart is NEVER dropped // silently: it is reported in Result.Unmapped (verbatim input preserved) // or in Result.Panel (panel-side material such as client-side TLS / // Reality parameters that subscription generation still needs). package singbox import ( "encoding/json" "fmt" "net" "strconv" "strings" "time" ) // Client is one identity-layer client resolved for a specific inbound. type Client struct { Email string UUID string // vless / vmess "id" Password string // trojan / shadowsocks / mixed / socks / http Auth string // hysteria auth credential Flow string // effective flow (flow_override already applied) Security string // vmess security AlterID int // vmess alterId (identity layer has no column; mirror-sourced) // VLESS reverse proxy settings, verbatim JSON when present. Reverse json.RawMessage // WireGuard peer material. PublicKey string PreSharedKey string AllowedIPs []string KeepAlive int // MTProto (external runtime; preserved only). Secret string AdTag string } // Inbound is the converter input for one inbound row. type Inbound struct { Listen string Port int Protocol string // Tag is the inbound row's panel tag; it becomes the fragment's // sing-box tag so core stats and agent traffic reports key // inbound totals by the panel's own identity (see fragmentTag). // Empty keeps the historical inbound- form. Tag string DisableFlow bool // xray DisableInboundFlow switch value for this inbound Settings json.RawMessage StreamSettings json.RawMessage Sniffing json.RawMessage // Fallbacks holds the resolved fallback entries for vless/vmess/trojan // masters (inbound_fallbacks table rows merged with any legacy // settings-embedded entries, deduplicated by the caller). Fallbacks []json.RawMessage Clients []Client } // Unmapped is one source construct with no sing-box counterpart. Input is // the verbatim source material so nothing is lost; Pending names the // decision that owns the item (Q1 user decisions, B2 base-config, ...). type Unmapped struct { Concept string `json:"concept"` Input json.RawMessage `json:"input,omitempty"` Reason string `json:"reason"` Pending string `json:"pending"` // Material reports whether this instance actually loses behaviour // (a non-default value with nowhere to go), as opposed to a // default-valued or merely relocated item. Drives Disposition. Material bool `json:"-"` } // Result is the conversion outcome for one inbound. type Result struct { // Disposition mirrors the B0 fixture vocabulary: direct, // partial-pending-q1, blocked-pending-q1, external-runtime. Disposition string // Fragment is the sing-box inbound (or endpoint, when Endpoint is // true) object. Nil when the inbound produces no core fragment // (blocked conversions and external-runtime protocols). Fragment map[string]any Endpoint bool Unmapped []Unmapped // Panel holds panel-side preserved material (client-side TLS / // Reality parameters, externalProxy, passthrough settings, ...). Panel map[string]any Notes []string } // Preserved is the stored shape of inbounds.singbox_preserved. type Preserved struct { Unmapped []Unmapped `json:"unmapped,omitempty"` Panel map[string]any `json:"panel,omitempty"` Notes []string `json:"notes,omitempty"` } // MarshalPreserved renders the preserved sidecar JSON ("" when empty). func (r *Result) MarshalPreserved() (string, error) { if len(r.Unmapped) == 0 && len(r.Panel) == 0 && len(r.Notes) == 0 { return "", nil } p := Preserved{Unmapped: r.Unmapped, Panel: r.Panel, Notes: r.Notes} b, err := json.Marshal(p) if err != nil { return "", err } return string(b), nil } // MarshalFragment renders the fragment JSON ("" when there is none). func (r *Result) MarshalFragment() (string, error) { if r.Fragment == nil { return "", nil } b, err := json.Marshal(r.Fragment) if err != nil { return "", err } return string(b), nil } // --------------------------------------------------------------------- // small JSON helpers (values are encoding/json generic: map[string]any, // []any, string, float64, bool, nil) func parseObj(raw json.RawMessage) (map[string]any, error) { if len(raw) == 0 { return map[string]any{}, nil } var m map[string]any if err := json.Unmarshal(raw, &m); err != nil { return nil, err } if m == nil { m = map[string]any{} } return m, nil } func rawOf(v any) json.RawMessage { if v == nil { return nil } if raw, ok := v.(json.RawMessage); ok { return raw } b, err := json.Marshal(v) if err != nil { return nil } return b } func gStr(m map[string]any, key string) string { if s, ok := m[key].(string); ok { return s } return "" } func gBool(m map[string]any, key string) bool { b, _ := m[key].(bool) return b } func gNum(m map[string]any, key string) (float64, bool) { switch v := m[key].(type) { case float64: return v, true case json.Number: f, err := v.Float64() return f, err == nil } return 0, false } func gInt(m map[string]any, key string) int { if f, ok := gNum(m, key); ok { return int(f) } return 0 } func gArr(m map[string]any, key string) []any { if a, ok := m[key].([]any); ok { return a } return nil } func gObj(m map[string]any, key string) map[string]any { if o, ok := m[key].(map[string]any); ok { return o } return nil } func has(m map[string]any, key string) bool { _, ok := m[key] return ok } func strArr(v any) []string { out := []string{} switch a := v.(type) { case []any: for _, e := range a { if s, ok := e.(string); ok { out = append(out, s) } } case string: if a != "" { out = append(out, a) } } return out } // normalizeFlow mirrors the generation-side flow normalisation. func normalizeFlow(flow string) string { if flow == "xtls-rprx-vision-udp443" { return "xtls-rprx-vision" } return flow } // removedShadowsocksCiphers mirrors model.IsRemovedShadowsocksMethod's // removed set (stream ciphers dropped by xray and absent from sing-box). func removedShadowsocksCipher(method string) bool { switch strings.ToLower(strings.TrimSpace(method)) { case "aes-128-cfb", "aes-192-cfb", "aes-256-cfb", "aes-128-ctr", "aes-192-ctr", "aes-256-ctr", "rc4", "rc4-md5", "bf-cfb", "cast5-cfb", "des-cfb", "idea-cfb", "seed-cfb", "camellia-128-cfb", "camellia-192-cfb", "camellia-256-cfb", "salsa20", "chacha20", "chacha20-ietf": return true } return false } type conv struct { in Inbound set map[string]any strm map[string]any res Result } func (c *conv) unmapped(concept string, input any, reason, pending string, material bool) { c.res.Unmapped = append(c.res.Unmapped, Unmapped{ Concept: concept, Input: rawOf(input), Reason: reason, Pending: pending, Material: material, }) } func (c *conv) note(format string, args ...any) { c.res.Notes = append(c.res.Notes, fmt.Sprintf(format, args...)) } func (c *conv) panel(key string, v any) { if c.res.Panel == nil { c.res.Panel = map[string]any{} } c.res.Panel[key] = v } // ConvertInbound converts one inbound. A non-nil error means the source // JSON itself is malformed — the migrator treats that as a hard failure // (refuse to start) rather than guessing. func ConvertInbound(in Inbound) (Result, error) { settings, err := parseObj(in.Settings) if err != nil { return Result{}, fmt.Errorf("settings: %w", err) } stream, err := parseObj(in.StreamSettings) if err != nil { return Result{}, fmt.Errorf("streamSettings: %w", err) } c := &conv{in: in, set: settings, strm: stream} switch in.Protocol { case "vless": err = c.convertVLESS() case "vmess": err = c.convertVMess() case "trojan": err = c.convertTrojan() case "shadowsocks": err = c.convertShadowsocks() case "mixed", "socks": err = c.convertMixedSocks() case "http": err = c.convertHTTP() case "hysteria": err = c.convertHysteria() case "wireguard": err = c.convertWireguard() case "tun": err = c.convertTun() case "tunnel": c.convertTunnel() case "mtproto", "amneziawg": c.convertExternal() default: return Result{}, fmt.Errorf("unsupported protocol %q", in.Protocol) } if err != nil { return Result{}, err } c.finishDisposition() return c.res, nil } func (c *conv) finishDisposition() { switch c.in.Protocol { case "mtproto", "amneziawg": c.res.Disposition = "external-runtime" return } if c.res.Fragment == nil { c.res.Disposition = "blocked-pending-q1" return } for _, u := range c.res.Unmapped { if u.Material { c.res.Disposition = "partial-pending-q1" return } } c.res.Disposition = "direct" } // fragmentTag is the fragment's sing-box tag: the inbound row's // panel tag, so core stats and the agent's traffic reports key // inbound totals by the same identity the panel stores (the // master's agent traffic handler matches reports by row tag — // with the historical inbound- form those lookups silently // never matched once panel tags moved to the n-in- scheme; // ITEM6 B5 rehearsal). Rows without a panel tag (bare converter // fixtures) keep the historical form. func (c *conv) fragmentTag() string { if c.in.Tag != "" { return c.in.Tag } return fmt.Sprintf("inbound-%d", c.in.Port) } // base builds the common fragment head. WireGuard endpoints and tun // inbounds do not take listen/listen_port and build their own head. func (c *conv) base(typ string) map[string]any { frag := map[string]any{ "type": typ, "tag": c.fragmentTag(), } if c.in.Listen != "" { frag["listen"] = c.in.Listen } if c.in.Port != 0 { frag["listen_port"] = c.in.Port } // streamSettings.sockopt listen-side options live at inbound level. c.applySockopt(frag) return frag } // applySockopt maps the mappable sockopt fields onto the fragment and // registers the rest. ip6Only handling follows the spec's B1 rule: when // true and the listen is a wildcard, the listen lands on the IPv6 // wildcard; a specific listen is left untouched (and noted). func (c *conv) applySockopt(frag map[string]any) { so := gObj(c.strm, "sockopt") if so == nil { return } rest := map[string]any{} for k, v := range so { switch k { case "mark": if n, ok := v.(float64); ok && n != 0 { frag["routing_mark"] = int(n) } case "tcpFastOpen": if b, ok := v.(bool); ok && b { frag["tcp_fast_open"] = true } case "tcpMptcp": if b, ok := v.(bool); ok && b { frag["tcp_multi_path"] = true } case "interface": if s, ok := v.(string); ok && s != "" { frag["bind_interface"] = s } case "V6Only", "ip6Only": if b, ok := v.(bool); ok && b { if c.in.Listen == "" || c.in.Listen == "0.0.0.0" { frag["listen"] = "::" c.note("sockopt.V6Only=true 且监听为通配:listen 落 IPv6 通配 ::") } else { c.note("sockopt.V6Only=true 但 listen 已指定为 %s,保持不动", c.in.Listen) } } default: // Every other sockopt key (tcpcongestion, domainStrategy, // happyEyeballs, tcpKeepAlive*, V6Only, tproxy, ...) has no // sing-box listen-side counterpart; it only counts when it // carries a non-zero value. if !isZeroValue(v) { rest[k] = v } } } if len(rest) > 0 { c.unmapped("streamSettings.sockopt 无对应字段组", rest, "sing-box 监听选项无对应字段;原值保留,待 Q1 汇总确认。", "Q1", true) } } func isZeroValue(v any) bool { switch t := v.(type) { case nil: return true case bool: return !t case float64: return t == 0 case string: return t == "" case []any: return len(t) == 0 case map[string]any: return len(t) == 0 } return false } // applyStream fills transport / tls / sniff / finalmask / externalProxy // for the stream-based protocols. Returns false when the transport is a // blocking form (xhttp / kcp): the fragment must be discarded. // // B2 amendment: for blocked transports the fragment goes away, but the // security side (TLS / Reality, including the client-side panel // material the builders register), the finalmask entries and // externalProxy are NOT fragment material — subscription generation // from the new model still needs them (the legacy link generators read // them out of streamSettings). They are converted and preserved // exactly as for direct transports; the built tls block, having no // fragment to live in, is preserved panel-side as "blockedTls". func (c *conv) applyStream(frag map[string]any, proto string) bool { network := gStr(c.strm, "network") security := gStr(c.strm, "security") // B2 amendment: the verbatim stream network/security strings are // preserved panel-side for the link-generating protocols. Share // links carry them verbatim (type=, the vmess "tls" field), and // the fragment intentionally cannot distinguish e.g. an absent // network from "tcp", or an empty security from "none". switch proto { case "vless", "vmess", "trojan", "shadowsocks", "hysteria": c.panel("streamNetwork", network) c.panel("streamSecurity", security) } blocked := false switch network { case "xhttp": c.unmapped("streamSettings.network=xhttp + xhttpSettings", gObj(c.strm, "xhttpSettings"), "sing-box 无 XHTTP 传输;带该传输的入站整体保留待 Q1(含第三方客户端兼容风险)。", "Q1", true) blocked = true case "kcp": c.unmapped("streamSettings.network=kcp + kcpSettings", gObj(c.strm, "kcpSettings"), "sing-box 无 mKCP 传输;带该传输的入站整体保留待 Q1。", "Q1", true) blocked = true } if !blocked { if t := c.buildTransport(network); t != nil { frag["transport"] = t } } tlsHome := frag if blocked { tlsHome = map[string]any{} } switch security { case "tls": if tls := c.buildTLS(gObj(c.strm, "tlsSettings"), false); tls != nil { tlsHome["tls"] = tls } case "reality": if tls := c.buildReality(gObj(c.strm, "realitySettings")); tls != nil { tlsHome["tls"] = tls } } if blocked { if tls, ok := tlsHome["tls"]; ok { c.panel("blockedTls", tls) } c.applyFinalmask(frag, proto) if ep := gArr(c.strm, "externalProxy"); len(ep) > 0 { c.panel("externalProxy", ep) c.note("streamSettings.externalProxy %d 条:订阅/分享侧字段,生成前被剥离,面板侧保留(对应 hosts 表行已由既有迁移生成)", len(ep)) } return false } c.applySniffing(frag, proto) c.applyFinalmask(frag, proto) if ep := gArr(c.strm, "externalProxy"); len(ep) > 0 { c.panel("externalProxy", ep) c.note("streamSettings.externalProxy %d 条:订阅/分享侧字段,生成前被剥离,面板侧保留(对应 hosts 表行已由既有迁移生成)", len(ep)) } return true } func (c *conv) buildTransport(network string) map[string]any { switch network { case "", "tcp", "raw": tcp := gObj(c.strm, "tcpSettings") if tcp == nil { return nil } if gBool(tcp, "acceptProxyProtocol") { c.unmapped("streamSettings.tcpSettings.acceptProxyProtocol", map[string]any{"acceptProxyProtocol": true}, "sing-box 监听侧无 PROXY protocol 开关对应,待 Q1。", "Q1", true) } hdr := gObj(tcp, "header") if hdr == nil || gStr(hdr, "type") != "http" { return nil } return c.buildHTTPHeaderTransport(hdr, true) case "ws": ws := gObj(c.strm, "wsSettings") if ws == nil { return nil } if gBool(ws, "acceptProxyProtocol") { c.unmapped("streamSettings.wsSettings.acceptProxyProtocol", map[string]any{"acceptProxyProtocol": true}, "sing-box 监听侧无 PROXY protocol 开关对应,待 Q1。", "Q1", true) } if hp := gInt(ws, "heartbeatPeriod"); hp > 0 { c.unmapped("streamSettings.wsSettings.heartbeatPeriod", hp, "sing-box ws 传输无心跳周期字段,小损登记待 Q1 汇总。", "Q1", true) } t := map[string]any{"type": "ws"} if p := gStr(ws, "path"); p != "" { t["path"] = p } if h := gObj(ws, "headers"); len(h) > 0 { t["headers"] = h } if med := gInt(ws, "maxEarlyData"); med > 0 { t["max_early_data"] = med if hn := gStr(ws, "earlyDataHeaderName"); hn != "" { t["early_data_header_name"] = hn } } return t case "grpc": g := gObj(c.strm, "grpcSettings") if g == nil { return nil } t := map[string]any{"type": "grpc"} if sn := gStr(g, "serviceName"); sn != "" { t["service_name"] = sn } if gBool(g, "multiMode") { c.unmapped("streamSettings.grpcSettings.multiMode", true, "sing-box grpc 传输无 multiMode 对应,小损登记待 Q1 汇总。", "Q1", true) } if a := gStr(g, "authority"); a != "" { c.unmapped("streamSettings.grpcSettings.authority", a, "sing-box grpc 服务端不承载 authority,小损登记待 Q1 汇总。", "Q1", true) } if n := gInt(g, "idle_timeout"); n > 0 { t["idle_timeout"] = (time.Duration(n) * time.Second).String() } if n := gInt(g, "idleTimeout"); n > 0 { t["idle_timeout"] = (time.Duration(n) * time.Second).String() } if n := gInt(g, "pingTimeout"); n > 0 { t["ping_timeout"] = (time.Duration(n) * time.Second).String() } return t case "httpupgrade": h := gObj(c.strm, "httpupgradeSettings") if h == nil { return nil } if gBool(h, "acceptProxyProtocol") { c.unmapped("streamSettings.httpupgradeSettings.acceptProxyProtocol", map[string]any{"acceptProxyProtocol": true}, "sing-box 监听侧无 PROXY protocol 开关对应,待 Q1。", "Q1", true) } t := map[string]any{"type": "httpupgrade"} if host := gStr(h, "host"); host != "" { t["host"] = host } if p := gStr(h, "path"); p != "" { t["path"] = p } return t case "hysteria": return nil // hysteria transports are protocol-internal } return nil } // buildHTTPHeaderTransport converts a tcp/ws-style http camouflage header. // For tcp the request Host is promoted to transport.host; the response // half has no sing-box server counterpart. func (c *conv) buildHTTPHeaderTransport(hdr map[string]any, isTCP bool) map[string]any { req := gObj(hdr, "request") t := map[string]any{"type": "http"} if req != nil { headers := map[string]any{} if h := gObj(req, "headers"); h != nil { for k, v := range h { if strings.EqualFold(k, "host") { if hosts := strArr(v); len(hosts) > 0 { t["host"] = hosts } continue } headers[k] = v } } if p := gArr(req, "path"); len(p) > 0 { t["path"] = p } else if ps := gStr(req, "path"); ps != "" { t["path"] = []string{ps} } if m := gStr(req, "method"); m != "" { t["method"] = m } if len(headers) > 0 { t["headers"] = headers } } if resp := gObj(hdr, "response"); len(resp) > 0 { c.unmapped("streamSettings.tcpSettings.header.response", resp, "sing-box 服务端不承载 http 伪装 response 半段,小损登记待 Q1 汇总。", "Q1", true) } return t } // buildTLS converts tlsSettings into the sing-box tls block. The // client-side sub-block (settings: fingerprint etc.) is panel material, // as is disableSystemRoot (client semantics). func (c *conv) buildTLS(tlsSettings map[string]any, reality bool) map[string]any { if tlsSettings == nil { return nil } tls := map[string]any{"enabled": true} if sn := gStr(tlsSettings, "serverName"); sn != "" { tls["server_name"] = sn } if v := gStr(tlsSettings, "minVersion"); v != "" { tls["min_version"] = v } if v := gStr(tlsSettings, "maxVersion"); v != "" { tls["max_version"] = v } if cs := gStr(tlsSettings, "cipherSuites"); cs != "" { parts := strings.FieldsFunc(cs, func(r rune) bool { return r == ':' || r == ',' }) suites := []string{} for _, p := range parts { if p = strings.TrimSpace(p); p != "" { suites = append(suites, p) } } if len(suites) > 0 { tls["cipher_suites"] = suites } } if alpn := gArr(tlsSettings, "alpn"); len(alpn) > 0 { tls["alpn"] = alpn } if ech := gArr(tlsSettings, "echConfigList"); len(ech) > 0 { c.unmapped("tlsSettings.echConfigList(服务端 ECH 配置列表)", ech, "xray 的 echConfigList 是客户端配置列表,sing-box 服务端 ECH 需私钥派生,查无等价转换,转无对应待 Q1。", "Q1", true) } if gBool(tlsSettings, "rejectUnknownSni") { c.unmapped("tlsSettings.rejectUnknownSni", true, "sing-box tls 无该开关对应,待 Q1。", "Q1", true) } if gBool(tlsSettings, "enableSessionResumption") { c.unmapped("tlsSettings.enableSessionResumption", true, "sing-box tls 无会话恢复开关对应,小损登记待 Q1 汇总。", "Q1", true) } certs := gArr(tlsSettings, "certificates") if len(certs) > 0 { first, _ := certs[0].(map[string]any) if first != nil { c.applyCertificate(tls, first) } if len(certs) > 1 { extra := certs[1:] c.panel("tlsExtraCertificates", extra) c.note("tlsSettings.certificates 共 %d 对,仅首对直映,其余 %d 对面板侧保留待 Q1", len(certs), len(extra)) } aux := map[string]any{} if v := gInt(first0(certs), "ocspStapling"); v != 0 { aux["ocspStapling"] = v } if gBool(first0(certs), "oneTimeLoading") { aux["oneTimeLoading"] = true } if u := gStr(first0(certs), "usage"); u != "" { aux["usage"] = u } if gBool(first0(certs), "buildChain") { aux["buildChain"] = true } if len(aux) > 0 { c.unmapped("tlsSettings.certificates 附属字段(ocspStapling/oneTimeLoading/usage/buildChain)", aux, "sing-box 证书加载无这些附属语义,小损登记待 Q1 汇总。", "Q1", true) } } if s := gObj(tlsSettings, "settings"); len(s) > 0 { c.panel("tlsClientSettings", s) } if gBool(tlsSettings, "disableSystemRoot") { c.panel("tlsDisableSystemRoot", true) } return tls } func first0(certs []any) map[string]any { if len(certs) == 0 { return nil } m, _ := certs[0].(map[string]any) return m } func (c *conv) applyCertificate(tls map[string]any, cert map[string]any) { cf, kf := gStr(cert, "certificateFile"), gStr(cert, "keyFile") if cf != "" || kf != "" { if cf != "" { tls["certificate_path"] = cf } if kf != "" { tls["key_path"] = kf } return } if certLines := strArr(cert["certificate"]); len(certLines) > 0 { tls["certificate"] = strings.Join(certLines, "\n") } if keyLines := strArr(cert["key"]); len(keyLines) > 0 { tls["key"] = strings.Join(keyLines, "\n") } } // buildReality converts realitySettings into tls{reality}. The nested // client-side settings block (publicKey/fingerprint/spiderX) is panel // material for subscription generation. func (c *conv) buildReality(rs map[string]any) map[string]any { if rs == nil { return nil } tls := map[string]any{"enabled": true} names := strArr(rs["serverNames"]) if len(names) > 0 { tls["server_name"] = names[0] if len(names) > 1 { c.panel("realityExtraServerNames", names[1:]) c.note("realitySettings.serverNames 共 %d 个,sing-box 服务端 server_name 只取首项,其余面板侧保留", len(names)) } } target := gStr(rs, "target") if target == "" { target = gStr(rs, "dest") // dest is target's historical alias } hs := map[string]any{} if host, port, err := net.SplitHostPort(target); err == nil { hs["server"] = host if p, err := strconv.Atoi(port); err == nil { hs["server_port"] = p } } else if target != "" { hs["server"] = target } reality := map[string]any{"enabled": true, "handshake": hs} if pk := gStr(rs, "privateKey"); pk != "" { reality["private_key"] = pk } if sids := strArr(rs["shortIds"]); len(sids) > 0 { reality["short_id"] = sids } if td, ok := gNum(rs, "maxTimediff"); ok && td > 0 { // xray counts maxTimediff in milliseconds; sing-box takes a // duration string. Approximate conversion (spec §4.3, N10 note). reality["max_time_difference"] = (time.Duration(td) * time.Millisecond).String() c.note("realitySettings.maxTimediff=%vms 按近似换算为 duration %s", td, reality["max_time_difference"]) } tls["reality"] = reality adv := map[string]any{} if gBool(rs, "show") { adv["show"] = true } if v := gInt(rs, "xver"); v != 0 { adv["xver"] = v } if v := gStr(rs, "minClientVer"); v != "" { adv["minClientVer"] = v } if v := gStr(rs, "maxClientVer"); v != "" { adv["maxClientVer"] = v } if v := gStr(rs, "mldsa65Seed"); v != "" { adv["mldsa65Seed"] = v } if v := gStr(rs, "masterKeyLog"); v != "" { adv["masterKeyLog"] = v } if len(adv) > 0 { c.unmapped("realitySettings 高级字段(N10)", adv, "sing-box reality 服务端无这些字段对应(mldsa65Seed 查无等价落点),原值保留待 Q1。", "Q1", true) } if s := gObj(rs, "settings"); len(s) > 0 { c.panel("realityClientSettings", s) } return tls } // applySniffing mirrors the observed generation-era semantics captured // by the B0 fixtures: sniff=true lands only for vless/vmess/trojan; // other protocols accept-and-drop it. destOverride/fakedns and // metadataOnly have no counterpart and are registered. func (c *conv) applySniffing(frag map[string]any, proto string) { var sn map[string]any if len(c.in.Sniffing) > 0 { var err error sn, err = parseObj(c.in.Sniffing) if err != nil { // Sniffing is stored as its own JSON text; a malformed blob // is surfaced as an unmapped note rather than failing the // whole conversion — generation treats unreadable sniffing // as disabled too. The verbatim text is preserved. c.panel("sniffingRaw", string(c.in.Sniffing)) c.note("sniffing JSON 损坏,原文保留在面板侧,转换按未启用处理") return } } // ipsExcluded/domainsExcluded (ITEM6 B4 disposition): sing-box // sniff is an inbound-level boolean with no per-destination // exclusion counterpart, and production stock is zero (B0/B2 // census). Rather than dropping the values at conversion, they // are preserved verbatim on the panel side — the same treatment // as the other no-counterpart materials — so the data survives // the migration and a future route-rule approximation has its // input. This runs before the enabled check: the exclusion lists // are user data even while sniffing is off. if sn != nil { if ips := strArr(sn["ipsExcluded"]); len(ips) > 0 { c.panel("sniffingIpsExcluded", ips) c.note("sniffing.ipsExcluded 无 sing-box 对应,原文保留在面板侧(B4 定版:保留不丢弃)") } if domains := strArr(sn["domainsExcluded"]); len(domains) > 0 { c.panel("sniffingDomainsExcluded", domains) c.note("sniffing.domainsExcluded 无 sing-box 对应,原文保留在面板侧(B4 定版:保留不丢弃)") } } if sn == nil || !gBool(sn, "enabled") { return } switch proto { case "vless", "vmess", "trojan": frag["sniff"] = true } over := strArr(sn["destOverride"]) for _, d := range over { if d == "fakedns" { c.unmapped("sniffing.destOverride 含 fakedns", sn["destOverride"], "sing-box sniff 无 fakedns 覆盖目标对应,待 Q1。", "Q1", true) } } if gBool(sn, "metadataOnly") { c.unmapped("sniffing.metadataOnly", true, "sing-box sniff 无 metadataOnly 对应,待 Q1。", "Q1", true) } if gBool(sn, "routeOnly") { c.note("sniffing.routeOnly=true:sing-box sniff 覆盖即参与路由,近似无损(记录备查)") } } // applyFinalmask handles streamSettings.finalmask. The one direct // mapping is hysteria2's salamander obfs (consumed by convertHysteria // before this is called — it sets frag["obfs"] and marks the entry // consumed); everything else is registered per entry. func (c *conv) applyFinalmask(frag map[string]any, proto string) { fm := gObj(c.strm, "finalmask") if fm == nil { return } for _, section := range []string{"tcp", "udp"} { for _, e := range gArr(fm, section) { em, _ := e.(map[string]any) if em == nil { continue } if proto == "hysteria" && section == "udp" && gStr(em, "type") == "salamander" && frag["obfs"] != nil { continue // consumed as obfs } c.unmapped(fmt.Sprintf("streamSettings.finalmask.%s[%s]", section, gStr(em, "type")), e, "sing-box 无 finalmask 机制;存量分享链接可能携带 mask 参数,删之属用户可见变化,待 Q1。", "Q1", true) } } if qp := gObj(fm, "quicParams"); len(qp) > 0 { c.unmapped("streamSettings.finalmask.quicParams", qp, "sing-box 无 finalmask quicParams 对应,待 Q1。", "Q1", true) } } // salamanderObfs extracts the hysteria2 obfs block from finalmask.udp. func (c *conv) salamanderObfs() map[string]any { fm := gObj(c.strm, "finalmask") if fm == nil { return nil } for _, e := range gArr(fm, "udp") { em, _ := e.(map[string]any) if em == nil || gStr(em, "type") != "salamander" { continue } obfs := map[string]any{"type": "salamander"} if st := gObj(em, "settings"); st != nil { if pw := gStr(st, "password"); pw != "" { obfs["password"] = pw } } return obfs } return nil } // registerFallbacks emits one unmapped entry per resolved fallback. func (c *conv) registerFallbacks() { for _, fb := range c.in.Fallbacks { c.unmapped("fallbacks(inbound_fallbacks 表行 → settings.fallbacks 注入形)", fb, "sing-box 无 fallback 概念;入站本体直映,回落关系(按 alpn/path 分流到 dest)无承载,候选承载待 Q1 拍板。", "Q1", true) } // limitFallback* throttles fallbacks only; without fallbacks they are // inert, with fallbacks they ride the same decision. rs := gObj(c.strm, "realitySettings") lim := map[string]any{} if v, ok := gNum(rs, "limitFallbackUpload"); ok && v != 0 { lim["limitFallbackUpload"] = v } if v, ok := gNum(rs, "limitFallbackDownload"); ok && v != 0 { lim["limitFallbackDownload"] = v } if len(lim) > 0 { c.unmapped("realitySettings.limitFallback*(回落限速,N1 附带)", lim, "随 fallbacks 一并无承载,待 Q1。", "Q1", true) } } // effectiveFlow applies the generation-side flow rules for one client. func (c *conv) effectiveFlow(cl Client) string { if c.in.DisableFlow { return "" } return normalizeFlow(cl.Flow) }