package service import ( "encoding/json" "fmt" "strings" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mhsanaei/3x-ui/v3/internal/singbox" ) // SingboxService builds sing-box configurations from the ITEM6 new // model (identity layer + inbounds.singbox_config fragments). It is // the B2 parallel generation path: the xray generation path and // production behaviour are untouched, and the agent config endpoint // serves this config alongside the xray one until the B5 switchover. type SingboxService struct { inboundService InboundService settingService SettingService nodeService NodeService } // trafficStatsSwitches mirrors the settings.trafficStatsSwitches // payload B1 migrated out of the xray template policy block. type trafficStatsSwitches struct { InboundUplink bool `json:"inboundUplink"` InboundDownlink bool `json:"inboundDownlink"` OutboundUplink bool `json:"outboundUplink"` OutboundDownlink bool `json:"outboundDownlink"` } // GetTrafficStatsSwitches reads the whole-network traffic statistics // switches. A missing or unreadable value falls back to the factory // defaults (inbound on, outbound off) — the same defaults B1's // migrator writes and the settings default map carries. func (s *SingboxService) GetTrafficStatsSwitches() trafficStatsSwitches { def := trafficStatsSwitches{InboundUplink: true, InboundDownlink: true} raw, err := s.settingService.getString("trafficStatsSwitches") if err != nil || strings.TrimSpace(raw) == "" { return def } var sw trafficStatsSwitches if err := json.Unmarshal([]byte(raw), &sw); err != nil { return def } return sw } // singboxV2RayAPIListen is where the agent's sing-box exposes the // v2ray_api stats service for the B3 runtime to poll locally. const singboxV2RayAPIListen = "127.0.0.1:10086" // privateCIDRs replaces the excised geoip:private SSRF guard // (ITEM6-SCOPE §5.2): loopback, RFC1918, link-local, CGNAT and IPv6 // unique-local/link-local ranges are rejected at route level by // default, with zero geo data involved. var privateCIDRs = []string{ "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", "169.254.0.0/16", "100.64.0.0/10", "::1/128", "fe80::/10", "fd00::/8", } // GetSingboxConfigForAgent builds the full sing-box configuration for // the node bound to an agent: stored fragments re-projected against // the current identity layer, the agent's sing-box egress override, // compiled forwarding, the default private-range reject rule, and the // v2ray_api stats block driven by settings.trafficStatsSwitches. func (s *SingboxService) GetSingboxConfigForAgent(nodeID int, singboxOutboundsJSON, singboxRoutingRulesJSON string) (map[string]any, error) { allInbounds, err := s.inboundService.GetAllInbounds() if err != nil { return nil, err } var items []*singboxInboundItem var forwarding []*model.Inbound for _, inbound := range allInbounds { if !inbound.Enable { continue } if inbound.NodeID == nil || *inbound.NodeID != nodeID { continue } // External-runtime protocols never enter the core config // (parity with the xray agent path). if inbound.Protocol == model.MTProto || inbound.Protocol == model.AmneziaWG { continue } if strings.TrimSpace(inbound.SingboxConfig) == "" { // No fragment: a blocked conversion (xhttp / kcp / // tunnel) or an unmigrated row. The preserved sidecar // explains which; the inbound stays out of the core // config exactly as B1 recorded it. continue } fragment := map[string]any{} if err := json.Unmarshal([]byte(inbound.SingboxConfig), &fragment); err != nil { return nil, fmt.Errorf("inbound %d: stored singbox_config is not valid JSON: %w", inbound.Id, err) } if len(fragment) == 0 { continue } preserved := singbox.Preserved{} if strings.TrimSpace(inbound.SingboxPreserved) != "" { if err := json.Unmarshal([]byte(inbound.SingboxPreserved), &preserved); err != nil { return nil, fmt.Errorf("inbound %d: stored singbox_preserved is not valid JSON: %w", inbound.Id, err) } } attached, err := s.inboundService.clientService.ListForInbound(nil, inbound.Id) if err != nil { return nil, err } enabled := filterEnabledClients(inbound, attached) refreshFragmentUsers(inbound, fragment, attached, enabled) items = append(items, &singboxInboundItem{ row: inbound, fragment: fragment, preserved: preserved, }) forwarding = append(forwarding, inbound) } fwdOutbounds, fwdRules := s.compileForwardingSingbox(forwarding, items) return assembleSingboxConfig(assembleSingboxOptions{ items: items, switches: s.GetTrafficStatsSwitches(), outboundsJSON: singboxOutboundsJSON, routingJSON: singboxRoutingRulesJSON, fwdOutbounds: fwdOutbounds, fwdRules: fwdRules, }), nil } // filterEnabledClients applies the xray generation path's client // gates, in the same order and with the same log line: a client whose // traffic row is disabled (expiration or traffic limit) is dropped, // and so is a client whose identity record is disabled. func filterEnabledClients(inbound *model.Inbound, attached []model.Client) []model.Client { enableMap := make(map[string]bool, len(inbound.ClientStats)) for _, clientTraffic := range inbound.ClientStats { enableMap[clientTraffic.Email] = clientTraffic.Enable } out := make([]model.Client, 0, len(attached)) for i := range attached { c := attached[i] if enable, exists := enableMap[c.Email]; exists && !enable { logger.Infof("Remove Inbound User %s due to expiration or traffic limit", c.Email) continue } if !c.Enable { continue } out = append(out, c) } return out } // toSingboxClients maps identity-layer clients onto the converter's // client shape for projection. func toSingboxClients(clients []model.Client) []singbox.Client { out := make([]singbox.Client, 0, len(clients)) for _, c := range clients { out = append(out, singbox.Client{ Email: c.Email, UUID: c.ID, Password: c.Password, Auth: c.Auth, Flow: c.Flow, Security: c.Security, PublicKey: c.PublicKey, PreSharedKey: c.PreSharedKey, AllowedIPs: c.AllowedIPs, KeepAlive: c.KeepAlive, }) } return out } // refreshFragmentUsers re-projects one fragment's users/peers from // the current identity layer. Protocols whose fragment material has // no identity projection (http accounts, tun, tunnel) keep the stored // fragment untouched. func refreshFragmentUsers(inbound *model.Inbound, fragment map[string]any, attached, enabled []model.Client) { clients := toSingboxClients(enabled) setOrDelete := func(key string, users []any) { if len(users) > 0 { fragment[key] = users } else { delete(fragment, key) } } switch inbound.Protocol { case model.VLESS: setOrDelete("users", singbox.ProjectVLESSUsers(clients, inbound.DisableFlow)) case model.VMESS: users := singbox.ProjectVMessUsers(clients) preserveVMessAlterIDs(fragment, users) setOrDelete("users", users) case model.Trojan: setOrDelete("users", singbox.ProjectTrojanUsers(clients)) case model.Shadowsocks: setOrDelete("users", singbox.ProjectShadowsocksUsers(clients)) case model.Hysteria: version := 2 if fragment["type"] == "hysteria" { version = 1 } setOrDelete("users", singbox.ProjectHysteriaUsers(clients, version)) case model.Mixed, model.Protocol("socks"): // Only password-auth inbounds carry users; the stored auth // mode decides (converter panel material). With no stored // users and noauth, identity clients stay out — the core // would otherwise start demanding credentials. stored, _ := fragment["users"].([]any) if len(stored) == 0 && !socksAuthEnabled(inbound) { return } // The fixed accounts ride panel-side (converter record): // with them, the refresh is exact — accounts verbatim plus // the current identity clients. Rows whose preserved // material predates that record fall back to the stored-list // heuristic (stored entries not owned by an attached client // are treated as fixed accounts). if accounts, ok := socksFixedAccounts(inbound); ok { setOrDelete("users", singbox.ProjectSocksUsers(accounts, clients)) return } attachedSet := make(map[string]bool, len(attached)) for _, c := range attached { attachedSet[c.Email] = true } setOrDelete("users", singbox.RefreshSocksUsers(stored, attachedSet, clients)) case model.WireGuard: setOrDelete("peers", singbox.ProjectWireguardPeers(clients)) } } // preservedPanel parses the row's preserved panel material once for // the generation-side readers. func preservedPanel(inbound *model.Inbound) map[string]any { if strings.TrimSpace(inbound.SingboxPreserved) == "" { return nil } var preserved singbox.Preserved if err := json.Unmarshal([]byte(inbound.SingboxPreserved), &preserved); err != nil { return nil } return preserved.Panel } // socksAuthEnabled reports whether a mixed/socks inbound runs // password auth, from the converter's preserved panel material. func socksAuthEnabled(inbound *model.Inbound) bool { auth, _ := preservedPanel(inbound)["socksAuth"].(string) return auth != "" && auth != "noauth" } // socksFixedAccounts returns the converter-recorded fixed // settings.accounts entries for a mixed/socks inbound. The second // result is false when the record is absent (rows preserved before // it existed) — including when accounts were genuinely empty, which // the heuristic fallback handles identically. func socksFixedAccounts(inbound *model.Inbound) ([]any, bool) { accounts, ok := preservedPanel(inbound)["socksAccounts"].([]any) return accounts, ok } // preserveVMessAlterIDs carries stored alter_id values over the fresh // projection: the identity layer has no alterId column (B1 sourced it // from the legacy settings mirror into the fragment), so a non-zero // stored value is the only surviving record and wins. func preserveVMessAlterIDs(fragment map[string]any, projected []any) { stored, _ := fragment["users"].([]any) if len(stored) == 0 || len(projected) == 0 { return } alterByName := map[string]any{} for _, u := range stored { um, _ := u.(map[string]any) if um == nil { continue } if name, _ := um["name"].(string); name != "" { if alt, ok := um["alter_id"]; ok { alterByName[name] = alt } } } for _, u := range projected { um, _ := u.(map[string]any) if um == nil { continue } name, _ := um["name"].(string) if alt, ok := alterByName[name]; ok { if f, isNum := alt.(float64); isNum && f != 0 { um["alter_id"] = int(f) } } } } // compileForwardingSingbox compiles the included inbounds' // forwarding specs into sing-box outbounds and route rules, with the // xray path's failure semantics: an unresolvable spec is skipped // with a warning, never fatal to the whole config. func (s *SingboxService) compileForwardingSingbox(inbounds []*model.Inbound, items []*singboxInboundItem) ([]any, []any) { tagByID := make(map[int]string, len(items)) for _, item := range items { if tag, _ := item.fragment["tag"].(string); tag != "" { tagByID[item.row.Id] = tag } } resolver := &XrayService{inboundService: s.inboundService, nodeService: s.nodeService} var outbounds []any var rules []any for _, inbound := range inbounds { spec, err := ParseForwardingSpec(inbound.Forwarding) if err != nil { logger.Warningf("Inbound %s: %v (forwarding ignored)", inbound.Tag, err) continue } if !spec.Enabled() { continue } if spec.Landing == "managed" && spec.TargetInboundId == inbound.Id { logger.Warningf("Inbound %s: forwarding target is itself, ignored", inbound.Tag) continue } fragTag := tagByID[inbound.Id] if fragTag == "" { continue } landing, err := resolver.resolveForwardingLanding(spec) if err != nil { logger.Warningf("Inbound %s: forwarding skipped: %v", inbound.Tag, err) continue } outbound, rule := singboxForwardingPair(inbound.Id, fragTag, landing) outbounds = append(outbounds, outbound) rules = append(rules, rule) } return outbounds, rules } // singboxForwardingPair renders one resolved landing as a sing-box // outbound plus its route rule (ITEM6-CONVERSION-SPEC §5.5). func singboxForwardingPair(inboundID int, fragTag string, landing *resolvedLanding) (map[string]any, map[string]any) { tag := fmt.Sprintf("fwd-%d", inboundID) outbound := map[string]any{ "type": landing.protocol, "tag": tag, "server": landing.address, "server_port": landing.port, } if landing.username != "" { outbound["username"] = landing.username outbound["password"] = landing.password } rule := map[string]any{ "inbound": []string{fragTag}, "action": "route", "outbound": tag, } return outbound, rule } // singboxInboundItem is one included inbound: the DB row, its stored // fragment (users already re-projected) and its preserved sidecar. type singboxInboundItem struct { row *model.Inbound fragment map[string]any preserved singbox.Preserved } type assembleSingboxOptions struct { items []*singboxInboundItem switches trafficStatsSwitches outboundsJSON string routingJSON string fwdOutbounds []any fwdRules []any } // assembleSingboxConfig is the pure assembly core: base skeleton, // fragments, egress override, forwarding, private-range reject and // the stats block. Database access stays in the service method so // this half is testable without a DB. func assembleSingboxConfig(opts assembleSingboxOptions) map[string]any { cfg := map[string]any{ "log": map[string]any{ "level": "info", "timestamp": true, }, } var inbounds []any var endpoints []any var userNames []string var inboundTags []string var dnsServers []any seenDNS := map[string]bool{} for _, item := range opts.items { frag := item.fragment tag, _ := frag["tag"].(string) if tag != "" { inboundTags = append(inboundTags, tag) } for _, key := range []string{"users"} { users, _ := frag[key].([]any) for _, u := range users { um, _ := u.(map[string]any) if um == nil { continue } if name, _ := um["name"].(string); name != "" { userNames = append(userNames, name) } else if username, _ := um["username"].(string); username != "" { userNames = append(userNames, username) } } } if frag["type"] == "wireguard" { endpoints = append(endpoints, frag) } else { inbounds = append(inbounds, frag) } // tun DNS (preserved pending B2 at conversion time) lands in // the config-level dns block: one udp server per entry. for _, u := range item.preserved.Unmapped { if u.Concept != "settings.dns" { continue } var addrs []string _ = json.Unmarshal(u.Input, &addrs) for _, addr := range addrs { if addr == "" || seenDNS[addr] { continue } seenDNS[addr] = true dnsServers = append(dnsServers, map[string]any{ "tag": fmt.Sprintf("tun-dns-%d", len(dnsServers)+1), "type": "udp", "server": addr, }) } } } if len(inbounds) > 0 { cfg["inbounds"] = inbounds } if len(endpoints) > 0 { cfg["endpoints"] = endpoints } if len(dnsServers) > 0 { cfg["dns"] = map[string]any{"servers": dnsServers} } // Outbounds: the agent's sing-box override replaces the defaults // wholesale (parity with the xray path's egress replacement); // compiled forwarding outbounds ride on top, deduplicated by tag. outbounds := []any{ map[string]any{"type": "direct", "tag": "direct"}, map[string]any{"type": "block", "tag": "block"}, } if strings.TrimSpace(opts.outboundsJSON) != "" { var override []any if err := json.Unmarshal([]byte(opts.outboundsJSON), &override); err == nil && len(override) > 0 { outbounds = override } else if err != nil { logger.Warningf("singbox outbounds override is not valid JSON, defaults kept: %v", err) } } present := map[string]bool{} for _, o := range outbounds { if om, ok := o.(map[string]any); ok { if t, _ := om["tag"].(string); t != "" { present[t] = true } } } for _, o := range opts.fwdOutbounds { om, _ := o.(map[string]any) if om == nil { continue } if t, _ := om["tag"].(string); t != "" && present[t] { continue } outbounds = append(outbounds, o) } cfg["outbounds"] = outbounds // Route rules: forwarding first (most specific intent), then the // agent's stored rules, then the private-range reject guardrail. rules := []any{} rules = append(rules, opts.fwdRules...) if strings.TrimSpace(opts.routingJSON) != "" { var stored []any if err := json.Unmarshal([]byte(opts.routingJSON), &stored); err == nil { rules = append(rules, stored...) } else { logger.Warningf("singbox routing rules override is not valid JSON, skipped: %v", err) } } rules = append(rules, map[string]any{ "ip_cidr": privateCIDRs, "action": "reject", }) cfg["route"] = map[string]any{ "rules": rules, "final": "direct", } // Stats: sing-box v2ray_api counters exist per category only, so // a category is generated only when BOTH of its direction // switches are on (a category with either direction off is not // generated at all — the off period's traffic is never counted, // matching the switches' documented no-backfill semantics). inboundGate := opts.switches.InboundUplink && opts.switches.InboundDownlink outboundGate := opts.switches.OutboundUplink && opts.switches.OutboundDownlink if inboundGate || outboundGate { stats := map[string]any{"enabled": true} if inboundGate { stats["inbounds"] = inboundTags stats["users"] = userNames } if outboundGate { var outboundTags []string for _, o := range outbounds { if om, ok := o.(map[string]any); ok { if t, _ := om["tag"].(string); t != "" { outboundTags = append(outboundTags, t) } } } stats["outbounds"] = outboundTags } cfg["experimental"] = map[string]any{ "v2ray_api": map[string]any{ "listen": singboxV2RayAPIListen, "stats": stats, }, } } return cfg }