- 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>
412 lines
12 KiB
Go
412 lines
12 KiB
Go
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"
|
||
}
|