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

412 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package sub
import (
"encoding/json"
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/singbox"
)
// GetLinkFromModel generates the (inbound, email) share link from the
// ITEM6 new model — identity-layer clients plus the stored
// inbounds.singbox_config fragment and singbox_preserved sidecar —
// instead of the legacy settings/streamSettings columns.
//
// The legacy generators consume settings/streamSettings maps; this
// entry point rebuilds those two views from the fragment + preserved
// material only (rebuildModelSettings / rebuildModelStream), primes
// the identity-layer clients, and then runs the SAME generators as
// GetLink on a shallow inbound copy carrying the rebuilt views. Link
// equivalence with the legacy path is therefore by construction
// wherever the rebuild is faithful, and the golden-fixture and
// production-database comparison tests prove the rebuild faithful.
func (s *SubService) GetLinkFromModel(inbound *model.Inbound, email string) string {
fragment := map[string]any{}
if strings.TrimSpace(inbound.SingboxConfig) != "" {
_ = json.Unmarshal([]byte(inbound.SingboxConfig), &fragment)
}
preserved := singbox.Preserved{}
if strings.TrimSpace(inbound.SingboxPreserved) != "" {
_ = json.Unmarshal([]byte(inbound.SingboxPreserved), &preserved)
}
settings := rebuildModelSettings(inbound, fragment, preserved)
stream := rebuildModelStream(inbound, fragment, preserved)
legacy := *inbound
if raw, err := json.Marshal(settings); err == nil {
legacy.Settings = string(raw)
}
if raw, err := json.Marshal(stream); err == nil {
legacy.StreamSettings = string(raw)
}
// Prime the identity-layer clients so clientForLink resolves from
// the identity layer (the new model's only client source) and
// seed the per-request settings cache with the rebuilt view
// (linkSettings would otherwise parse the legacy column).
if clients, err := s.inboundService.GetClients(inbound); err == nil {
s.primeLinkClients(inbound.Id, clients, true)
}
if inbound.Id > 0 {
if s.settingsByInbound == nil {
s.settingsByInbound = map[int]map[string]any{}
}
seed := make(map[string]any, len(settings))
for k, v := range settings {
if k == "clients" {
continue
}
seed[k] = v
}
s.settingsByInbound[inbound.Id] = seed
}
return s.GetLink(&legacy, email)
}
// unmappedInputs returns the preserved unmapped inputs for one
// concept, in recorded order.
func unmappedInputs(preserved singbox.Preserved, concept string) []json.RawMessage {
var out []json.RawMessage
for _, u := range preserved.Unmapped {
if u.Concept == concept {
out = append(out, u.Input)
}
}
return out
}
// unmappedSectionInputs returns the preserved unmapped inputs whose
// concept has the given prefix (the finalmask per-entry concepts
// embed the section and mask type: "streamSettings.finalmask.tcp[…]").
func unmappedSectionInputs(preserved singbox.Preserved, prefix string) []json.RawMessage {
var out []json.RawMessage
for _, u := range preserved.Unmapped {
if strings.HasPrefix(u.Concept, prefix) {
out = append(out, u.Input)
}
}
return out
}
func rawToAny(raw json.RawMessage) any {
if len(raw) == 0 {
return nil
}
var v any
if err := json.Unmarshal(raw, &v); err != nil {
return nil
}
return v
}
func rawToMap(raw json.RawMessage) map[string]any {
if m, ok := rawToAny(raw).(map[string]any); ok {
return m
}
return nil
}
// rebuildModelSettings rebuilds the legacy settings view (the fields
// the link generators read) from the fragment + preserved material.
func rebuildModelSettings(inbound *model.Inbound, fragment map[string]any, preserved singbox.Preserved) map[string]any {
panel := preserved.Panel
switch inbound.Protocol {
case model.MTProto, model.AmneziaWG:
// External-runtime protocols: the full settings object is
// preserved verbatim panel-side.
if pt, ok := panel["passthrough"].(map[string]any); ok {
return pt
}
return map[string]any{}
case model.VLESS:
settings := map[string]any{}
if enc, ok := panel["vlessEncryption"]; ok {
settings["encryption"] = enc
}
if inputs := unmappedInputs(preserved, "settings.decryption(vlessenc / ML-KEM 加密串)"); len(inputs) > 0 {
settings["decryption"] = rawToAny(inputs[0])
}
return settings
case model.Shadowsocks:
settings := map[string]any{}
if v, ok := fragment["method"]; ok {
settings["method"] = v
} else {
// Removed-cipher inbounds have no fragment; the method
// rides in the unmapped record's input.
for _, u := range preserved.Unmapped {
if strings.HasPrefix(u.Concept, "shadowsocks method=") {
if m := rawToMap(u.Input); m != nil {
if v, ok := m["method"]; ok {
settings["method"] = v
}
}
break
}
}
}
if v, ok := fragment["password"]; ok {
settings["password"] = v
} else if v, ok := panel["ssPassword"]; ok {
settings["password"] = v
}
return settings
case model.Hysteria:
version := float64(2)
if fragment["type"] == "hysteria" {
version = 1
}
return map[string]any{"version": version}
case model.WireGuard:
settings := map[string]any{}
if v, ok := fragment["private_key"]; ok {
settings["secretKey"] = v
}
if v, ok := fragment["mtu"]; ok {
settings["mtu"] = v
}
if v, ok := panel["wireguardDns"]; ok {
settings["dns"] = v
}
return settings
}
return map[string]any{}
}
// rebuildModelStream rebuilds the legacy streamSettings view (the
// fields the link generators read) from the fragment + preserved
// material: transport parameters from the sing-box transport (or, for
// blocked transports, from the preserved verbatim settings object),
// TLS/Reality parameters from the fragment tls block (or the
// panel-side blockedTls), finalmask reassembled from its preserved
// per-entry records, and externalProxy from panel material.
func rebuildModelStream(inbound *model.Inbound, fragment map[string]any, preserved singbox.Preserved) map[string]any {
panel := preserved.Panel
stream := map[string]any{}
// Network: the verbatim preserved string wins; the derivation
// below covers rows migrated before it was recorded.
network := ""
if v, ok := panel["streamNetwork"].(string); ok {
network = v
} else {
network = deriveModelNetwork(fragment, preserved)
}
stream["network"] = network
// The effective tls block: on the fragment for direct
// conversions, panel-side for blocked transports.
tlsBlock, _ := fragment["tls"].(map[string]any)
if tlsBlock == nil {
tlsBlock, _ = panel["blockedTls"].(map[string]any)
}
security := ""
if v, ok := panel["streamSecurity"].(string); ok {
security = v
} else if tlsBlock != nil {
if _, isReality := tlsBlock["reality"].(map[string]any); isReality {
security = "reality"
} else {
security = "tls"
}
} else {
security = "none"
}
stream["security"] = security
var transport map[string]any
if t, ok := fragment["transport"].(map[string]any); ok {
transport = t
}
switch network {
case "ws":
ws := map[string]any{}
if transport != nil {
if v, ok := transport["path"]; ok {
ws["path"] = v
}
if v, ok := transport["headers"]; ok {
ws["headers"] = v
}
if v, ok := transport["host"]; ok {
ws["host"] = v
}
}
stream["wsSettings"] = ws
case "grpc":
grpc := map[string]any{}
if transport != nil {
if v, ok := transport["service_name"]; ok {
grpc["serviceName"] = v
}
}
if inputs := unmappedInputs(preserved, "streamSettings.grpcSettings.authority"); len(inputs) > 0 {
grpc["authority"] = rawToAny(inputs[0])
}
if inputs := unmappedInputs(preserved, "streamSettings.grpcSettings.multiMode"); len(inputs) > 0 {
grpc["multiMode"] = rawToAny(inputs[0])
}
stream["grpcSettings"] = grpc
case "httpupgrade":
hu := map[string]any{}
if transport != nil {
if v, ok := transport["host"]; ok {
hu["host"] = v
}
if v, ok := transport["path"]; ok {
hu["path"] = v
}
}
stream["httpupgradeSettings"] = hu
case "tcp":
tcp := map[string]any{}
if transport != nil && transport["type"] == "http" {
header := map[string]any{"type": "http"}
request := map[string]any{}
if v, ok := transport["path"]; ok {
request["path"] = v
}
headers := map[string]any{}
if h, ok := transport["headers"].(map[string]any); ok {
for k, v := range h {
headers[k] = v
}
}
if v, ok := transport["host"]; ok {
headers["Host"] = v
}
if len(headers) > 0 {
request["headers"] = headers
}
header["request"] = request
if inputs := unmappedInputs(preserved, "streamSettings.tcpSettings.header.response"); len(inputs) > 0 {
if resp := rawToMap(inputs[0]); resp != nil {
header["response"] = resp
}
}
tcp["header"] = header
} else {
tcp["header"] = map[string]any{"type": "none"}
}
stream["tcpSettings"] = tcp
case "xhttp":
if inputs := unmappedInputs(preserved, "streamSettings.network=xhttp + xhttpSettings"); len(inputs) > 0 {
if m := rawToMap(inputs[0]); m != nil {
stream["xhttpSettings"] = m
}
}
case "kcp":
if inputs := unmappedInputs(preserved, "streamSettings.network=kcp + kcpSettings"); len(inputs) > 0 {
if m := rawToMap(inputs[0]); m != nil {
stream["kcpSettings"] = m
}
}
}
if tlsBlock != nil {
if reality, ok := tlsBlock["reality"].(map[string]any); ok {
rs := map[string]any{}
var names []any
if v, ok := tlsBlock["server_name"].(string); ok && v != "" {
names = append(names, v)
}
if extra, ok := panel["realityExtraServerNames"].([]any); ok {
names = append(names, extra...)
}
if len(names) > 0 {
rs["serverNames"] = names
}
if v, ok := reality["short_id"]; ok {
rs["shortIds"] = v
}
if v, ok := panel["realityClientSettings"]; ok {
rs["settings"] = v
}
stream["realitySettings"] = rs
} else {
ts := map[string]any{}
if v, ok := tlsBlock["server_name"]; ok {
ts["serverName"] = v
}
if v, ok := tlsBlock["alpn"]; ok {
ts["alpn"] = v
}
if v, ok := panel["tlsClientSettings"]; ok {
ts["settings"] = v
}
stream["tlsSettings"] = ts
}
}
// finalmask: reassemble the {tcp, udp, quicParams} object from
// its preserved per-entry records, in recorded order.
finalmask := map[string]any{}
for _, section := range []string{"tcp", "udp"} {
inputs := unmappedSectionInputs(preserved, "streamSettings.finalmask."+section+"[")
if len(inputs) == 0 {
continue
}
entries := make([]any, 0, len(inputs))
for _, raw := range inputs {
if v := rawToAny(raw); v != nil {
entries = append(entries, v)
}
}
if len(entries) > 0 {
finalmask[section] = entries
}
}
// The hysteria2 salamander entry is the one finalmask entry the
// converter consumes into the fragment (as obfs) instead of
// registering it unmapped; the legacy link generator reads it
// from finalmask, so it is synthesized back here.
if obfs, ok := fragment["obfs"].(map[string]any); ok && obfs["type"] == "salamander" {
entry := map[string]any{"type": "salamander"}
if pw, ok := obfs["password"]; ok {
entry["settings"] = map[string]any{"password": pw}
}
udp, _ := finalmask["udp"].([]any)
finalmask["udp"] = append([]any{entry}, udp...)
}
if inputs := unmappedInputs(preserved, "streamSettings.finalmask.quicParams"); len(inputs) > 0 {
if qp := rawToMap(inputs[0]); qp != nil {
finalmask["quicParams"] = qp
}
}
if len(finalmask) > 0 {
stream["finalmask"] = finalmask
}
if ep, ok := panel["externalProxy"]; ok {
stream["externalProxy"] = ep
}
return stream
}
// deriveModelNetwork recovers the stream network string for rows
// whose preserved material predates the verbatim streamNetwork
// record: blocked transports are identifiable by their preserved
// settings objects, a fragment transport maps back (the http header
// transport means tcp), and everything else is a raw tcp stream.
func deriveModelNetwork(fragment map[string]any, preserved singbox.Preserved) string {
if len(unmappedInputs(preserved, "streamSettings.network=xhttp + xhttpSettings")) > 0 {
return "xhttp"
}
if len(unmappedInputs(preserved, "streamSettings.network=kcp + kcpSettings")) > 0 {
return "kcp"
}
if t, ok := fragment["transport"].(map[string]any); ok {
if typ, _ := t["type"].(string); typ != "" {
if typ == "http" {
return "tcp"
}
return typ
}
}
return "tcp"
}