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.
456 lines
13 KiB
Go
456 lines
13 KiB
Go
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
|
||
}
|