Files
3x-ui-agent/internal/singbox/convert.go
T
Muse 39ad8b8a2e ITEM6 B1: sing-box data model + one-shot migration
New stored model (legacy columns untouched, read paths unchanged
until B2):
- inbounds.singbox_config (converted sing-box inbound/endpoint
  fragment JSON; empty = blocked or external-runtime) and
  inbounds.singbox_preserved (unmapped items verbatim + panel-side
  material + notes)
- agent_nodes.singbox_outbounds / singbox_routing_rules

Converter (internal/singbox, pure functions, ITEM6-CONVERSION-SPEC):
all panel protocols mapped; unmapped constructs are preserved and
registered, never dropped. B0 golden fixtures: 26/26 semantic match
(fragment + unmapped concept/pending sets + disposition).

One-shot migrator (internal/database, InitDB hook after initModels):
marker-gated and idempotent; SQLite file backup before any write
(failure refuses migration/startup); stats switches migrate out of
the template BEFORE the xrayTemplateConfig row is discarded
(SCOPE 4.3.1 order); identity layer is the only client truth with
spec 7-1 mirror reconciliation (adopt/fill/conflict all reported);
fallbacks merged from table + embedded settings, deduped; agent
overrides converted with per-entry issues; everything in one
transaction, marker written last; JSON report written next to the
DB. Malformed rows/templates are hard failures (rollback, old DB
untouched).

settings.trafficStatsSwitches added with factory defaults
(inbound on, outbound off) in defaultValueMap.

Validation: synthetic agent-era DB (mirror drift, fallbacks, xhttp,
agent geo rule, non-default stats) converts as specified; old
production backup (25 inbounds) migrates with credentials, Reality
pbk derivation, traffic counters and reset cycles byte-identical;
idempotent re-run is a no-op; touched packages show zero new test
failures vs the 9-red baseline.
2026-10-22 19:32:15 +00:00

906 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
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"
}
// 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": fmt.Sprintf("inbound-%d", c.in.Port),
}
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.
func (c *conv) applyStream(frag map[string]any, proto string) bool {
network := gStr(c.strm, "network")
security := gStr(c.strm, "security")
switch network {
case "xhttp":
c.unmapped("streamSettings.network=xhttp + xhttpSettings", gObj(c.strm, "xhttpSettings"),
"sing-box 无 XHTTP 传输;带该传输的入站整体保留待 Q1(含第三方客户端兼容风险)。", "Q1", true)
return false
case "kcp":
c.unmapped("streamSettings.network=kcp + kcpSettings", gObj(c.strm, "kcpSettings"),
"sing-box 无 mKCP 传输;带该传输的入站整体保留待 Q1。", "Q1", true)
return false
}
if t := c.buildTransport(network); t != nil {
frag["transport"] = t
}
switch security {
case "tls":
if tls := c.buildTLS(gObj(c.strm, "tlsSettings"), false); tls != nil {
frag["tls"] = tls
}
case "reality":
if tls := c.buildReality(gObj(c.strm, "realitySettings")); tls != nil {
frag["tls"] = tls
}
}
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
}
}
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)
}