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
- 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).
606 lines
20 KiB
Go
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
|
|
}
|