Files
3x-ui-agent/internal/web/service/singbox.go
T
Rogue 29de9761c8
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
Docs CI / build (push) Waiting to run
Docs Deploy (GitHub Pages) / build (push) Waiting to run
Docs Deploy (GitHub Pages) / deploy (push) Blocked by required conditions
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
V2.0.1: listen override fix, sing-box core fetch, probe settings page + fast report, spx removal, core mem env
- 0: sing-box gen overrides fragment listen/port from the inbound row
  (empty listen -> "::"), Update/Add recompute the stored fragment;
  non-loopback E2E against the real core binary.
- 1: agent fetches the sing-box core from the Gitea release when
  missing, self-checks it, and only then latches the switch.
- 2: node list shows the sing-box core version (heartbeat field,
  persisted on the node row), xray version as fallback.
- 3: public-page fallback returns 404 to anonymous visitors when the
  page is disabled.
- 4: settings restart hint bar removed, dead i18n keys cleaned.
- 5: two PUT calls missing JSON headers fixed (audit: only those two),
  batch probe-task creation endpoint + UI.
- 6: cf-probe retirement card and coverage pipeline removed.
- 7: generated vless links no longer carry spx (panel export + sub);
  parser still accepts spx; golden fixtures updated.
- probe batch: dedicated probe settings tab (public-page settings,
  tasks, per-node meta), retention_days editable, per-node traffic
  calibration + stat-interface pinning, runtime metrics split into a
  lightweight fast report (default 3s, 1-60s adjustable).
- 8: sing-box core processes start with GOMEMLIMIT=32MiB, GOGC=50.
- 9: dead /getTwoFactorEnable docs entries removed (frontend call and
  contract entry already gone at base; verified by grep).
2026-10-23 07:53:11 +00:00

606 lines
20 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
}
// V2.0.1 ⓪: the row's listen/port are authoritative at
// generation time. The stored fragment is a snapshot (taken
// at migration or at the last save's recompute) and drifts
// whenever the row was edited through a path that did not
// recompute it — V2.0.0's converter also omitted "listen"
// entirely for an empty row listen, which sing-box reads as
// loopback-only, silently unbinding migrated inbounds from
// the network. The row always wins here.
applyRowListenOverride(inbound, fragment)
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
}
// applyRowListenOverride stamps the inbound row's listen address and
// port onto a sing-box fragment, replacing whatever the snapshot
// carried (V2.0.1 ⓪). An empty row listen means "all interfaces" in
// the panel's model, which sing-box spells "::" (dual-stack
// wildcard); an absent listen field would default to loopback-only,
// so the wildcard is always written explicitly. A specific row
// listen (one IPv6 address, an internal address, ...) is carried
// verbatim — sing-box then binds exactly that address. WireGuard
// endpoints take no listen address, only the port.
func applyRowListenOverride(inbound *model.Inbound, fragment map[string]any) {
if fragment["type"] == "wireguard" {
if inbound.Port > 0 {
fragment["listen_port"] = inbound.Port
}
return
}
if inbound.Listen != "" {
fragment["listen"] = inbound.Listen
} else {
fragment["listen"] = "::"
}
if inbound.Port > 0 {
fragment["listen_port"] = inbound.Port
}
}
// 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: the block exists when at least one category gate is on
// (a category counts only when BOTH of its direction switches
// are on). All three name lists are always generated together:
// sing-box's v2ray_api stats manager registers NO counters at
// all unless the inbounds, users AND outbounds lists are all
// present — verified against the real core in the ITEM6 B5
// rehearsal (a config without "outbounds" answered QueryStats
// with zero entries while traffic flowed; adding the list
// produced full user/inbound/outbound counters). The per-
// category counting policy therefore cannot be enforced by
// omission here; the master enforces it at recording time
// (agent traffic report gate) instead. Outbound counters are
// registered but never reported by the agent, so their presence
// costs nothing.
inboundGate := opts.switches.InboundUplink && opts.switches.InboundDownlink
outboundGate := opts.switches.OutboundUplink && opts.switches.OutboundDownlink
if inboundGate || outboundGate {
outboundTags := []string{}
for _, o := range outbounds {
if om, ok := o.(map[string]any); ok {
if t, _ := om["tag"].(string); t != "" {
outboundTags = append(outboundTags, t)
}
}
}
if inboundTags == nil {
inboundTags = []string{}
}
if userNames == nil {
userNames = []string{}
}
stats := map[string]any{
"enabled": true,
"inbounds": inboundTags,
"users": userNames,
"outbounds": outboundTags,
}
cfg["experimental"] = map[string]any{
"v2ray_api": map[string]any{
"listen": singboxV2RayAPIListen,
"stats": stats,
},
}
}
return cfg
}