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

261 lines
6.8 KiB
Go

package singbox
import (
"encoding/json"
"fmt"
"strings"
)
// OverrideIssue records one agent-level outbound / routing entry that
// could not be mapped (spec §5.6). The entry stays untouched in the old
// column; nothing is silently dropped.
type OverrideIssue struct {
Index int `json:"index"`
Kind string `json:"kind"` // outbound protocol or "routing-rule"
Detail string `json:"detail"`
}
// ConvertAgentOutbounds maps a stored xray-shaped outbounds array (the
// agent_nodes.outbounds override) into sing-box outbounds. Entries that
// cannot be mapped are omitted from the output and reported as issues.
// Returns ("", nil, nil) for empty input.
func ConvertAgentOutbounds(raw string) (string, []OverrideIssue, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", nil, nil
}
var arr []map[string]any
if err := json.Unmarshal([]byte(raw), &arr); err != nil {
return "", nil, fmt.Errorf("outbounds: %w", err)
}
out := []any{}
var issues []OverrideIssue
for i, ob := range arr {
converted, issue := convertOneOutbound(ob)
if issue != "" {
issues = append(issues, OverrideIssue{Index: i, Kind: gStr(ob, "protocol"), Detail: issue})
continue
}
out = append(out, converted)
}
b, err := json.Marshal(out)
if err != nil {
return "", nil, err
}
return string(b), issues, nil
}
func convertOneOutbound(ob map[string]any) (map[string]any, string) {
proto := gStr(ob, "protocol")
tag := gStr(ob, "tag")
settings := gObj(ob, "settings")
switch proto {
case "freedom":
out := map[string]any{"type": "direct"}
if tag != "" {
out["tag"] = tag
}
return out, ""
case "blackhole":
out := map[string]any{"type": "block"}
if tag != "" {
out["tag"] = tag
}
return out, ""
case "socks", "http":
srv := firstServer(settings)
if srv == nil {
return nil, "no servers[0] to map"
}
out := map[string]any{"type": proto}
if tag != "" {
out["tag"] = tag
}
out["server"] = gStr(srv, "address")
if p := gInt(srv, "port"); p != 0 {
out["server_port"] = p
}
if users := gArr(srv, "users"); len(users) > 0 {
if u, _ := users[0].(map[string]any); u != nil {
if name := gStr(u, "user"); name != "" {
out["username"] = name
}
if pass := gStr(u, "pass"); pass != "" {
out["password"] = pass
}
}
}
return out, ""
case "shadowsocks":
srv := firstServer(settings)
if srv == nil {
return nil, "no servers[0] to map"
}
out := map[string]any{"type": "shadowsocks"}
if tag != "" {
out["tag"] = tag
}
out["server"] = gStr(srv, "address")
if p := gInt(srv, "port"); p != 0 {
out["server_port"] = p
}
method := gStr(srv, "method")
if method == "" {
method = gStr(settings, "method")
}
password := gStr(srv, "password")
if password == "" {
password = gStr(settings, "password")
}
if removedShadowsocksCipher(method) {
return nil, "shadowsocks method " + method + " removed in sing-box"
}
out["method"] = method
out["password"] = password
return out, ""
case "vless":
vnext := gArr(settings, "vnext")
if len(vnext) == 0 {
return nil, "no vnext[0] to map"
}
vn, _ := vnext[0].(map[string]any)
if vn == nil {
return nil, "vnext[0] malformed"
}
out := map[string]any{"type": "vless"}
if tag != "" {
out["tag"] = tag
}
out["server"] = gStr(vn, "address")
if p := gInt(vn, "port"); p != 0 {
out["server_port"] = p
}
if users := gArr(vn, "users"); len(users) > 0 {
if u, _ := users[0].(map[string]any); u != nil {
out["uuid"] = gStr(u, "id")
if f := gStr(u, "flow"); f != "" {
out["flow"] = normalizeFlow(f)
}
}
}
return out, ""
default:
return nil, "protocol " + proto + " has no outbound mapping in this batch"
}
}
func firstServer(settings map[string]any) map[string]any {
if settings == nil {
return nil
}
servers := gArr(settings, "servers")
if len(servers) == 0 {
return nil
}
srv, _ := servers[0].(map[string]any)
return srv
}
// ConvertAgentRoutingRules maps stored xray routing rules into sing-box
// route rules. Rules referencing geoip:/geosite: (fully excised in this
// product line) or using unmapped constructs are omitted and reported.
func ConvertAgentRoutingRules(raw string) (string, []OverrideIssue, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", nil, nil
}
var arr []map[string]any
if err := json.Unmarshal([]byte(raw), &arr); err != nil {
return "", nil, fmt.Errorf("routing rules: %w", err)
}
out := []any{}
var issues []OverrideIssue
for i, rule := range arr {
converted, issue := convertOneRoutingRule(rule)
if issue != "" {
issues = append(issues, OverrideIssue{Index: i, Kind: "routing-rule", Detail: issue})
continue
}
if converted != nil {
out = append(out, converted)
}
}
b, err := json.Marshal(out)
if err != nil {
return "", nil, err
}
return string(b), issues, nil
}
func convertOneRoutingRule(rule map[string]any) (map[string]any, string) {
if t := gStr(rule, "type"); t != "" && t != "field" {
return nil, "rule type " + t + " not mappable"
}
outbound := gStr(rule, "outboundTag")
if outbound == "" {
return nil, "rule has no outboundTag (balancer/other target)"
}
// Any geo reference anywhere in the rule defeats mapping.
raw, _ := json.Marshal(rule)
if strings.Contains(string(raw), "geoip:") || strings.Contains(string(raw), "geosite:") {
return nil, "rule references geoip:/geosite: (geo excised); kept in old column pending Q1"
}
out := map[string]any{"outbound": outbound}
if tags := strArr(rule["inboundTag"]); len(tags) > 0 {
out["inbound"] = tags
}
if domains := strArr(rule["domain"]); len(domains) > 0 {
var full, suffix, keyword, regex []string
for _, d := range domains {
switch {
case strings.HasPrefix(d, "full:"):
full = append(full, strings.TrimPrefix(d, "full:"))
case strings.HasPrefix(d, "keyword:"):
keyword = append(keyword, strings.TrimPrefix(d, "keyword:"))
case strings.HasPrefix(d, "regexp:"):
regex = append(regex, strings.TrimPrefix(d, "regexp:"))
default:
suffix = append(suffix, d)
}
}
if len(full) > 0 {
out["domain"] = full
}
if len(suffix) > 0 {
out["domain_suffix"] = suffix
}
if len(keyword) > 0 {
out["domain_keyword"] = keyword
}
if len(regex) > 0 {
out["domain_regex"] = regex
}
}
if ips := strArr(rule["ip"]); len(ips) > 0 {
out["ip_cidr"] = ips
}
switch p := rule["port"].(type) {
case float64:
out["port"] = int(p)
case string:
if p != "" {
out["port_range"] = p
}
}
if protos := strArr(rule["protocol"]); len(protos) > 0 {
out["protocol"] = protos
}
if n := gStr(rule, "network"); n != "" {
nets := []string{}
for _, part := range strings.Split(n, ",") {
if part = strings.TrimSpace(part); part != "" {
nets = append(nets, part)
}
}
if len(nets) > 0 {
out["network"] = nets
}
}
return out, ""
}