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" }