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.
261 lines
6.8 KiB
Go
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, ""
|
|
}
|