Files
3x-ui-agent/internal/singbox/convert_protocols.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

456 lines
13 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
import (
"encoding/json"
"fmt"
"net"
"strconv"
)
// ---------------------------------------------------------------- VLESS
func (c *conv) convertVLESS() error {
frag := c.base("vless")
users := make([]any, 0, len(c.in.Clients))
for _, cl := range c.in.Clients {
u := map[string]any{"name": cl.Email, "uuid": cl.UUID}
if f := c.effectiveFlow(cl); f != "" {
u["flow"] = f
}
if len(cl.Reverse) > 0 {
c.unmapped("vless client reverse", map[string]any{
"email": cl.Email, "reverse": jsonRawString(cl.Reverse),
}, "sing-box vless 无 reverse 反向代理对应(N4),整客户其余字段照常直映。", "Q1", true)
}
users = append(users, u)
}
if len(users) > 0 {
frag["users"] = users
}
if dec := gStr(c.set, "decryption"); dec != "" && dec != "none" {
c.unmapped("settings.decryption(vlessenc / ML-KEM 加密串)", dec,
"sing-box vless 无对应加密层;带 vlessenc 的入站迁过去即明文 vless,行为与安全性变化,待 Q1。", "Q1", true)
}
if enc := gStr(c.set, "encryption"); enc != "" && enc != "none" {
c.panel("vlessEncryption", enc)
}
if !c.applyStream(frag, "vless") {
c.res.Fragment = nil
return nil
}
c.registerFallbacks()
c.res.Fragment = frag
return nil
}
// ---------------------------------------------------------------- VMess
func (c *conv) convertVMess() error {
frag := c.base("vmess")
users := make([]any, 0, len(c.in.Clients))
for _, cl := range c.in.Clients {
u := map[string]any{"name": cl.Email, "uuid": cl.UUID}
if cl.Security != "" {
u["security"] = cl.Security
}
u["alter_id"] = cl.AlterID
users = append(users, u)
}
if len(users) > 0 {
frag["users"] = users
}
if !c.applyStream(frag, "vmess") {
c.res.Fragment = nil
return nil
}
c.res.Fragment = frag
return nil
}
// --------------------------------------------------------------- Trojan
func (c *conv) convertTrojan() error {
frag := c.base("trojan")
users := make([]any, 0, len(c.in.Clients))
for _, cl := range c.in.Clients {
u := map[string]any{"name": cl.Email, "password": cl.Password}
if f := c.effectiveFlow(cl); f != "" {
// sing-box trojan users have no flow field; generation writes
// it into the xray user today, so this is a real (if small)
// behavioural item owned by Q1/B3.
c.unmapped("trojan client flow", f,
"sing-box trojan 用户无 flow 字段;flow 客户端(vision)实际协商行为待 Q1/B3 定。", "Q1/B3", true)
}
users = append(users, u)
}
if len(users) > 0 {
frag["users"] = users
}
if !c.applyStream(frag, "trojan") {
c.res.Fragment = nil
return nil
}
c.registerFallbacks()
c.res.Fragment = frag
return nil
}
// ---------------------------------------------------------- Shadowsocks
func (c *conv) convertShadowsocks() error {
method := gStr(c.set, "method")
if removedShadowsocksCipher(method) {
c.unmapped("shadowsocks method="+method, map[string]any{"method": method},
"sing-box 已移除 cfb/ctr 类 cipher,无承载;入站整体保留待 Q1。", "Q1", true)
c.res.Fragment = nil
return nil
}
frag := c.base("shadowsocks")
frag["method"] = method
if pw := gStr(c.set, "password"); pw != "" {
frag["password"] = pw
}
users := make([]any, 0, len(c.in.Clients))
for _, cl := range c.in.Clients {
users = append(users, map[string]any{"name": cl.Email, "password": cl.Password})
}
if len(users) > 0 {
frag["users"] = users
}
// "tcp,udp" is sing-box's default coverage and is omitted; a
// restricted network value is carried over.
switch n := gStr(c.set, "network"); n {
case "", "tcp,udp":
case "tcp", "udp":
frag["network"] = n
default:
c.note("shadowsocks settings.network=%q 非标准值,未落 network 字段,原文在旧列保留", n)
}
if has(c.set, "ivCheck") {
c.note("shadowsocks settings.ivCheck=%v:sing-box 无对应校验开关,登记备查(Q19 口径,待 Q1 汇总确认)", gBool(c.set, "ivCheck"))
}
if !c.applyStream(frag, "shadowsocks") {
c.res.Fragment = nil
return nil
}
c.res.Fragment = frag
return nil
}
// ------------------------------------------------------- mixed / socks
func (c *conv) socksUsers() []any {
users := []any{}
seen := map[string]bool{}
for _, a := range gArr(c.set, "accounts") {
am, _ := a.(map[string]any)
if am == nil {
continue
}
user := gStr(am, "user")
if user == "" {
continue
}
seen[user] = true
users = append(users, map[string]any{"username": user, "password": gStr(am, "pass")})
}
for _, cl := range c.in.Clients {
if cl.Email == "" || seen[cl.Email] {
continue
}
seen[cl.Email] = true
users = append(users, map[string]any{"username": cl.Email, "password": cl.Password})
}
return users
}
func (c *conv) convertMixedSocks() error {
typ := "mixed"
if c.in.Protocol == "socks" {
typ = "socks"
}
frag := c.base(typ)
if gStr(c.set, "auth") != "noauth" {
if users := c.socksUsers(); len(users) > 0 {
frag["users"] = users
}
}
if ip := gStr(c.set, "ip"); ip != "" && ip != "127.0.0.1" {
c.unmapped("settings.ip(UDP 回源地址)", ip,
"sing-box mixed/socks 无回源地址对应;默认值 127.0.0.1 无损,非默认登记待 Q1。", "Q1", true)
}
if has(c.set, "udp") && !gBool(c.set, "udp") {
c.note("settings.udp=false:sing-box mixed/socks 的 UDP 关联恒开,行为差异登记备查")
}
if !c.applyStream(frag, c.in.Protocol) {
c.res.Fragment = nil
return nil
}
c.res.Fragment = frag
return nil
}
// ----------------------------------------------------------------- HTTP
func (c *conv) convertHTTP() error {
frag := c.base("http")
users := []any{}
for _, a := range gArr(c.set, "accounts") {
am, _ := a.(map[string]any)
if am == nil {
continue
}
if user := gStr(am, "user"); user != "" {
users = append(users, map[string]any{"username": user, "password": gStr(am, "pass")})
}
}
if len(users) > 0 {
frag["users"] = users
}
if gBool(c.set, "allowTransparent") {
c.unmapped("settings.allowTransparent", true,
"sing-box http 入站无透明代理开关对应,待 Q1。", "Q1", true)
}
if !c.applyStream(frag, "http") {
c.res.Fragment = nil
return nil
}
c.res.Fragment = frag
return nil
}
// ------------------------------------------------------------- Hysteria
func (c *conv) convertHysteria() error {
version := gInt(c.set, "version")
if hs := gObj(c.strm, "hysteriaSettings"); hs != nil {
if sv := gInt(hs, "version"); sv != 0 && version != 0 && sv != version {
c.note("hysteria 版本真源冲突:settings.version=%d、streamSettings.hysteriaSettings.version=%d,按 settings 为准", version, sv)
}
}
if version == 0 {
version = 2
}
typ := "hysteria2"
if version == 1 {
typ = "hysteria"
}
frag := c.base(typ)
users := make([]any, 0, len(c.in.Clients))
for _, cl := range c.in.Clients {
if version == 1 {
users = append(users, map[string]any{"name": cl.Email, "auth": cl.Auth})
} else {
users = append(users, map[string]any{"name": cl.Email, "password": cl.Auth})
}
}
if len(users) > 0 {
frag["users"] = users
}
if version == 2 {
if obfs := c.salamanderObfs(); obfs != nil {
frag["obfs"] = obfs
}
if mz := gObj(c.set, "masquerade"); mz != nil {
if m := convertMasquerade(mz); m != nil {
frag["masquerade"] = m
}
}
}
if has(c.set, "udpIdleTimeout") && gInt(c.set, "udpIdleTimeout") != 0 {
c.note("hysteria settings.udpIdleTimeout=%d:sing-box 对应字段语义未对齐,小损备查(待 Q1 汇总)", gInt(c.set, "udpIdleTimeout"))
}
if !c.applyStream(frag, "hysteria") {
c.res.Fragment = nil
return nil
}
c.res.Fragment = frag
return nil
}
func convertMasquerade(mz map[string]any) map[string]any {
switch gStr(mz, "type") {
case "file":
out := map[string]any{"type": "file"}
if d := gStr(mz, "dir"); d != "" {
out["directory"] = d
}
return out
case "proxy":
out := map[string]any{"type": "proxy"}
if u := gStr(mz, "url"); u != "" {
out["url"] = u
}
if gBool(mz, "rewriteHost") {
out["rewrite_host"] = true
}
return out
case "string":
out := map[string]any{"type": "string"}
if sc := gInt(mz, "statusCode"); sc != 0 {
out["status_code"] = sc
}
if content := gStr(mz, "content"); content != "" {
out["content"] = content
}
return out
}
return nil
}
// ------------------------------------------------------------ WireGuard
func (c *conv) convertWireguard() error {
frag := map[string]any{
"type": "wireguard",
"tag": fmt.Sprintf("inbound-%d", c.in.Port),
}
c.res.Endpoint = true
if c.in.Port != 0 {
frag["listen_port"] = c.in.Port
}
if addr := deriveWireguardAddress(c.set); addr != "" {
frag["address"] = []string{addr}
} else {
c.note("wireguard settings 缺 subnetIp/subnetCidr,服务端 address 未推导,原文在旧列保留")
}
if sk := gStr(c.set, "secretKey"); sk != "" {
frag["private_key"] = sk
}
if mtu := gInt(c.set, "mtu"); mtu > 0 {
frag["mtu"] = mtu
}
peers := make([]any, 0, len(c.in.Clients))
for _, cl := range c.in.Clients {
p := map[string]any{"public_key": cl.PublicKey}
if len(cl.AllowedIPs) > 0 {
ips := make([]any, len(cl.AllowedIPs))
for i, s := range cl.AllowedIPs {
ips[i] = s
}
p["allowed_ips"] = ips
}
if cl.PreSharedKey != "" {
p["pre_shared_key"] = cl.PreSharedKey
}
if cl.KeepAlive > 0 {
p["persistent_keepalive_interval"] = cl.KeepAlive
}
peers = append(peers, p)
}
if len(peers) > 0 {
frag["peers"] = peers
}
if has(c.set, "noKernelTun") {
c.note("wireguard settings.noKernelTun=%v:sing-box wireguard endpoint 的系统/内核模式差异待 B3 真机验(N7)", gBool(c.set, "noKernelTun"))
}
if !c.applyStream(frag, "wireguard") {
c.res.Fragment = nil
return nil
}
c.res.Fragment = frag
return nil
}
// deriveWireguardAddress mirrors the panel's server-address derivation:
// the first host address of the configured subnet (10.0.0.0/24 ->
// 10.0.0.1/24).
func deriveWireguardAddress(settings map[string]any) string {
subnet := gStr(settings, "subnetIp")
cidr := gInt(settings, "subnetCidr")
if subnet == "" || cidr == 0 {
return ""
}
ip := net.ParseIP(subnet)
if ip == nil {
return ""
}
ip4 := ip.To4()
if ip4 == nil {
return ""
}
mask := net.CIDRMask(cidr, 32)
if mask == nil {
return ""
}
network := ip4.Mask(mask)
host := make(net.IP, 4)
copy(host, network)
host[3]++
return host.String() + "/" + strconv.Itoa(cidr)
}
// ------------------------------------------------------------------ TUN
func (c *conv) convertTun() error {
frag := map[string]any{
"type": "tun",
"tag": fmt.Sprintf("inbound-%d", c.in.Port),
}
if name := gStr(c.set, "name"); name != "" {
frag["interface_name"] = name
}
if gw := c.set["gateway"]; gw != nil {
switch v := gw.(type) {
case []any:
if len(v) > 0 {
frag["address"] = v
}
case string:
if v != "" {
frag["address"] = []string{v}
}
}
}
if mtu := gInt(c.set, "mtu"); mtu > 0 {
frag["mtu"] = mtu
}
if routes := gArr(c.set, "autoSystemRoutingTable"); len(routes) > 0 {
frag["auto_route"] = true
frag["route_address"] = routes
}
frag["stack"] = "system"
if dns := gArr(c.set, "dns"); len(dns) > 0 {
c.unmapped("settings.dns", dns,
"tun 入站本体无 dns 字段,由 sing-box 独立 dns 配置承载——属基础配置面,B2 定落点,此处登记防丢。", "B2", false)
}
if has(c.set, "userLevel") {
lvl := gInt(c.set, "userLevel")
c.unmapped("settings.userLevel", lvl,
"xray policy level 概念,sing-box 无对应;0 为默认无损,非 0 待 Q1。", "Q1", lvl != 0)
}
if aoi := gStr(c.set, "autoOutboundsInterface"); aoi != "" && aoi != "auto" {
c.note("tun settings.autoOutboundsInterface=%q 非 auto,sing-box tun 无直映字段,备查", aoi)
}
// TUN takes no stream/sniff handling in the current generation chain.
c.res.Fragment = frag
return nil
}
// --------------------------------------------------------------- Tunnel
func (c *conv) convertTunnel() {
c.unmapped("tunnel(dokodemo-door) settings", c.set,
"tun/tunnel 在 sing-box 侧的落点(tun 入站 + 转发模块)跨 B2/B3,本批整体保留待 Q1。", "Q1", true)
c.res.Fragment = nil
}
// ------------------------------------------- external-runtime protocols
func (c *conv) convertExternal() {
// MTProto and AmneziaWG run on external runtimes in the current
// architecture; the panel keeps their full settings verbatim.
c.panel("passthrough", c.set)
c.note("协议 %s 由外部运行时承载(现行架构),settings 原样保留在面板侧,本批不产核心片段", c.in.Protocol)
c.res.Fragment = nil
}
// jsonRawString unwraps a raw JSON value for embedding in another object.
func jsonRawString(raw []byte) any {
var v any
if err := json.Unmarshal(raw, &v); err != nil {
return string(raw)
}
return v
}