Files
3x-ui-agent/internal/singbox/project.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

174 lines
5.5 KiB
Go

package singbox
// Identity-layer user projection: the pure per-protocol shapes the
// converter bakes into fragments at migration time, factored out so
// the B2 generation side re-projects the CURRENT identity layer into
// the stored fragment with the exact same shapes (one source of
// truth — the converter itself calls these). The caller filters the
// client list first (enabled / not traffic-disabled); projection is
// shape only.
// ProjectVLESSUsers renders vless fragment users: {name, uuid, flow?}
// with the generation-side flow rules applied (udp443 normalisation
// and the inbound DisableFlow switch).
func ProjectVLESSUsers(clients []Client, disableFlow bool) []any {
users := make([]any, 0, len(clients))
for _, cl := range clients {
u := map[string]any{"name": cl.Email, "uuid": cl.UUID}
flow := cl.Flow
if disableFlow {
flow = ""
} else {
flow = normalizeFlow(flow)
}
if flow != "" {
u["flow"] = flow
}
users = append(users, u)
}
return users
}
// ProjectVMessUsers renders vmess fragment users:
// {name, uuid, security?, alter_id}.
func ProjectVMessUsers(clients []Client) []any {
users := make([]any, 0, len(clients))
for _, cl := range clients {
u := map[string]any{"name": cl.Email, "uuid": cl.UUID}
if cl.Security != "" {
u["security"] = cl.Security
}
u["alter_id"] = cl.AlterID
users = append(users, u)
}
return users
}
// ProjectTrojanUsers renders trojan fragment users: {name, password}.
// (Client flow has no sing-box trojan counterpart; the converter
// registers it as unmapped at migration time.)
func ProjectTrojanUsers(clients []Client) []any {
users := make([]any, 0, len(clients))
for _, cl := range clients {
users = append(users, map[string]any{"name": cl.Email, "password": cl.Password})
}
return users
}
// ProjectShadowsocksUsers renders per-client shadowsocks users:
// {name, password}.
func ProjectShadowsocksUsers(clients []Client) []any {
users := make([]any, 0, len(clients))
for _, cl := range clients {
users = append(users, map[string]any{"name": cl.Email, "password": cl.Password})
}
return users
}
// ProjectHysteriaUsers renders hysteria fragment users: v1 takes
// {name, auth}, v2 takes {name, password} (both sourced from the
// identity client's Auth credential).
func ProjectHysteriaUsers(clients []Client, version int) []any {
users := make([]any, 0, len(clients))
for _, cl := range clients {
if version == 1 {
users = append(users, map[string]any{"name": cl.Email, "auth": cl.Auth})
} else {
users = append(users, map[string]any{"name": cl.Email, "password": cl.Auth})
}
}
return users
}
// ProjectWireguardPeers renders wireguard endpoint peers from the
// identity layer's per-client key material.
func ProjectWireguardPeers(clients []Client) []any {
peers := make([]any, 0, len(clients))
for _, cl := range clients {
p := map[string]any{"public_key": cl.PublicKey}
if len(cl.AllowedIPs) > 0 {
ips := make([]any, len(cl.AllowedIPs))
for i, s := range cl.AllowedIPs {
ips[i] = s
}
p["allowed_ips"] = ips
}
if cl.PreSharedKey != "" {
p["pre_shared_key"] = cl.PreSharedKey
}
if cl.KeepAlive > 0 {
p["persistent_keepalive_interval"] = cl.KeepAlive
}
peers = append(peers, p)
}
return peers
}
// ProjectSocksUsers merges the fixed settings.accounts entries
// (preserved panel-side at conversion time, verbatim) with the
// current identity clients: accounts first, first name wins — the
// converter's merge, reproduced exactly. Clients whose name collides
// with a fixed account are shadowed by the account, as in the
// converter and in the xray generation path.
func ProjectSocksUsers(accounts []any, clients []Client) []any {
out := make([]any, 0, len(accounts)+len(clients))
seen := map[string]bool{}
for _, u := range accounts {
um, _ := u.(map[string]any)
if um == nil {
continue
}
name, _ := um["username"].(string)
if name == "" || seen[name] {
continue
}
seen[name] = true
out = append(out, u)
}
for _, cl := range clients {
if cl.Email == "" || seen[cl.Email] {
continue
}
seen[cl.Email] = true
out = append(out, map[string]any{"username": cl.Email, "password": cl.Password})
}
return out
}
// RefreshSocksUsers re-projects a mixed/socks fragment's stored user
// list against the current identity layer. The stored list mixes two
// provenances the fragment does not distinguish: fixed
// settings.accounts entries (panel-side accounts, accounts-first at
// conversion time) and identity clients. The refresh keeps every
// stored entry whose username is NOT an attached identity client
// (those are the fixed accounts, order preserved), then appends the
// currently-enabled identity clients whose names are not already
// present — mirroring the converter's accounts-first, first-name-wins
// merge. A stored entry whose name belongs to an attached client is
// dropped and replaced by the fresh identity projection, so disabled
// or detached clients never leak through the stored list.
func RefreshSocksUsers(stored []any, attached map[string]bool, clients []Client) []any {
out := make([]any, 0, len(stored)+len(clients))
seen := map[string]bool{}
for _, u := range stored {
um, _ := u.(map[string]any)
if um == nil {
continue
}
name, _ := um["username"].(string)
if name == "" || attached[name] || seen[name] {
continue
}
seen[name] = true
out = append(out, u)
}
for _, cl := range clients {
if cl.Email == "" || seen[cl.Email] {
continue
}
seen[cl.Email] = true
out = append(out, map[string]any{"username": cl.Email, "password": cl.Password})
}
return out
}