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.
455 lines
14 KiB
Go
455 lines
14 KiB
Go
package singbox
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net"
|
||
"strconv"
|
||
)
|
||
|
||
// ---------------------------------------------------------------- VLESS
|
||
|
||
func (c *conv) convertVLESS() error {
|
||
frag := c.base("vless")
|
||
for _, cl := range c.in.Clients {
|
||
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)
|
||
}
|
||
}
|
||
if users := ProjectVLESSUsers(c.in.Clients, c.in.DisableFlow); 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)
|
||
}
|
||
// B2 amendment: record settings.encryption whenever the key exists
|
||
// (any string value, including "none" and ""). The legacy link
|
||
// generator emits the encryption= parameter whenever the key is
|
||
// present, so byte-exact link regeneration from the new model
|
||
// needs the presence bit, not just the non-default values.
|
||
if enc, ok := c.set["encryption"].(string); ok {
|
||
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")
|
||
if users := ProjectVMessUsers(c.in.Clients); 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")
|
||
for _, cl := range c.in.Clients {
|
||
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)
|
||
}
|
||
}
|
||
if users := ProjectTrojanUsers(c.in.Clients); 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)
|
||
// B2 amendment: the fragment is discarded, but the share link
|
||
// for such an inbound is still generated today (the client
|
||
// password comes from the identity layer, the server password
|
||
// only exists here) — preserve it panel-side so link
|
||
// regeneration from the new model stays byte-exact.
|
||
if pw := gStr(c.set, "password"); pw != "" {
|
||
c.panel("ssPassword", pw)
|
||
}
|
||
c.res.Fragment = nil
|
||
return nil
|
||
}
|
||
frag := c.base("shadowsocks")
|
||
frag["method"] = method
|
||
if pw := gStr(c.set, "password"); pw != "" {
|
||
frag["password"] = pw
|
||
}
|
||
if users := ProjectShadowsocksUsers(c.in.Clients); 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
|
||
|
||
// socksAccountUsers projects the fixed settings.accounts entries —
|
||
// the part of a mixed/socks user list that does NOT come from the
|
||
// identity layer.
|
||
func (c *conv) socksAccountUsers() []any {
|
||
users := []any{}
|
||
for _, a := range gArr(c.set, "accounts") {
|
||
am, _ := a.(map[string]any)
|
||
if am == nil {
|
||
continue
|
||
}
|
||
user := gStr(am, "user")
|
||
if user == "" {
|
||
continue
|
||
}
|
||
users = append(users, map[string]any{"username": user, "password": gStr(am, "pass")})
|
||
}
|
||
return users
|
||
}
|
||
|
||
func (c *conv) socksUsers() []any {
|
||
users := []any{}
|
||
seen := map[string]bool{}
|
||
for _, u := range c.socksAccountUsers() {
|
||
um, _ := u.(map[string]any)
|
||
seen[gStr(um, "username")] = true
|
||
users = append(users, u)
|
||
}
|
||
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)
|
||
// B2 amendment: the auth mode is preserved panel-side. The stored
|
||
// fragment alone cannot distinguish "noauth with clients attached"
|
||
// from "password auth with currently zero users", and generation
|
||
// must not start demanding credentials on a noauth inbound (nor
|
||
// drop auth on a password inbound whose users are all disabled).
|
||
if a := gStr(c.set, "auth"); a != "" {
|
||
c.panel("socksAuth", a)
|
||
}
|
||
// B2 amendment: the fixed accounts are recorded panel-side as
|
||
// their own list. Inside the fragment's merged users list they
|
||
// are indistinguishable from identity clients, so generation
|
||
// could not otherwise tell a fixed account apart from a client
|
||
// that has since been detached (and would leak the detached
|
||
// client's stored entry forever).
|
||
if accounts := c.socksAccountUsers(); len(accounts) > 0 {
|
||
c.panel("socksAccounts", accounts)
|
||
}
|
||
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)
|
||
if users := ProjectHysteriaUsers(c.in.Clients, version); 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": c.fragmentTag(),
|
||
}
|
||
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
|
||
}
|
||
if peers := ProjectWireguardPeers(c.in.Clients); 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"))
|
||
}
|
||
// B2 amendment: settings.dns (a plain string) is read only by the
|
||
// share-link generator (dns= parameter); it has no endpoint-side
|
||
// counterpart, so it is preserved panel-side for link regeneration.
|
||
if dns, ok := c.set["dns"].(string); ok && dns != "" {
|
||
c.panel("wireguardDns", dns)
|
||
}
|
||
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": c.fragmentTag(),
|
||
}
|
||
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
|
||
}
|