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.
906 lines
26 KiB
Go
906 lines
26 KiB
Go
// 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)
|
||
}
|