The rehearsal's inbound-level totals never moved: the agent reports sing-box tags (inbound-<port>) while the master's agent traffic handler matches inbound rows by their panel tag — which the B1 model renamed to the n<node>-in- scheme, so the lookup silently matched nothing (per-user totals, keyed by email, were unaffected; production on V1 keeps the old tag form and is not hit until the V2 migration itself). The converter now takes the row's panel tag as the fragment tag (migrator passes it through), making one identity serve the DB row, the generated config, core stats and traffic reports. Bare fixtures without a panel tag keep the historical form.
984 lines
30 KiB
Go
984 lines
30 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
|
||
// 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-<port> 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-<port> form those lookups silently
|
||
// never matched once panel tags moved to the n<node>-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)
|
||
}
|