Files
l-ui/internal/web/service/singbox.go
T
MuseandMuse Spark 741cd905ff ITEM6 B2: sing-box generation side (parallel path)
- SingboxService builds the full sing-box config from the new model:
  stored fragments re-projected against the identity layer (converter
  projections factored into internal/singbox/project.go, shared by
  converter and generator), agent singbox_outbounds/routing_rules
  override, forwarding compiled to sing-box outbounds+route rules,
  default private-CIDR reject rule (geoip:private replacement),
  tun dns landing in the config dns block (closes the B1 'B2' pending).
- trafficStatsSwitches wiring: experimental.v2ray_api.stats lists are
  generated per category only when both direction switches are on;
  all-off yields no experimental block (no backfill, per the switches'
  documented semantics).
- Agent /config endpoint additionally serves singbox_config +
  singbox_config_hash; xray payload untouched, sing-box build failures
  log-and-omit.
- SubService.GetLinkFromModel regenerates share links from identity +
  singbox_config/singbox_preserved only (legacy generators reused over
  rebuilt settings/stream views). Golden fixtures 26/26 byte-equal;
  production backup DB: 107/107 links equal (55 byte-equal, 52 equal
  modulo the legacy generator's own random Reality sni/sid pick),
  generated config passes sing-box check.
- Converter preservation amendments (panel-side, for blocked/discarded
  material link generation still needs): blockedTls for xhttp/kcp,
  verbatim streamNetwork/streamSecurity, vlessEncryption presence,
  ssPassword for removed ciphers, wireguardDns, socksAuth/socksAccounts
  provenance for exact mixed/socks user refresh.

Co-Authored-By: Muse Spark <noreply@muse.dev>
2026-10-22 21:42:40 +00:00

558 lines
18 KiB
Go

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
}