- 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>
174 lines
5.5 KiB
Go
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
|
|
}
|