mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-28 00:24:19 +00:00
7c8889466b
TLS: add verifyPeerCertByName (vcn) to inbound settings + emit in both share-link generators (frontend + Go sub) and outbound parser; the allowInsecure replacement xray removed after 2026-06-01. Add server-side curvePreferences, masterKeyLog, echSockopt (passthrough + form) at tlsSettings top-level so they survive the panel-only settings strip. REALITY: add limitFallbackUpload/Download (afterBytes/bytesPerSec/burstBytesPerSec) with per-field tooltips, plus masterKeyLog. Verified field names/semantics against pinned xray v1.260327.1 (bytesPerSec=0 disables). Hosts: fix verify_peer_cert_by_name column bool->string (xray expects comma-separated names) with an idempotent, history-gate-free migration (SQLite typeof blank; Postgres ALTER once); emit vcn for hosts/external proxies. Server: add getCertHash (local cert DER SHA-256) and getRemoteCertHash (xray tls ping) endpoints + api-docs; wire pinned-cert field buttons. Drop the meaningless random-hash button. Xray UI: metrics endpoint (listen/tag) config in Basics; import/export for routing rules and outbounds. Fallbacks card: compact empty state, header-aligned actions, responsive labeled grid rows. i18n: add all new keys to every locale; drop unused generateRandomPin.
321 lines
10 KiB
Go
321 lines
10 KiB
Go
package sub
|
|
|
|
import (
|
|
"encoding/json"
|
|
"maps"
|
|
"slices"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
)
|
|
|
|
// hostEndpoints loads an inbound's enabled hosts for the given subscription
|
|
// format ("raw"|"json"|"clash") and returns them as externalProxy-shaped maps so
|
|
// the existing per-format renderers can fan out one link/proxy per host. Returns
|
|
// nil when the inbound has no applicable host — the caller then uses the legacy
|
|
// inbound/externalProxy path, preserving byte-identical output for zero-host
|
|
// inbounds.
|
|
func (s *SubService) hostEndpoints(inbound *model.Inbound, format string) []map[string]any {
|
|
var hosts []*model.Host
|
|
if err := database.GetDB().
|
|
Where("inbound_id = ? AND is_disabled = ?", inbound.Id, false).
|
|
Order("sort_order asc, id asc").
|
|
Find(&hosts).Error; err != nil {
|
|
logger.Warning("SubService - hostEndpoints:", err)
|
|
return nil
|
|
}
|
|
if len(hosts) == 0 {
|
|
return nil
|
|
}
|
|
defaultDest := s.resolveInboundAddress(inbound)
|
|
eps := make([]map[string]any, 0, len(hosts))
|
|
for _, h := range hosts {
|
|
if slices.Contains(h.ExcludeFromSubTypes, format) {
|
|
continue
|
|
}
|
|
eps = append(eps, hostToExternalProxyMap(h, defaultDest, inbound.Port))
|
|
}
|
|
return eps
|
|
}
|
|
|
|
// hostToExternalProxyMap projects a Host onto the externalProxy entry shape the
|
|
// raw/json/clash renderers already consume. Address/port fall back to the
|
|
// inbound's own when the host leaves them blank (override-only host).
|
|
func hostToExternalProxyMap(h *model.Host, defaultDest string, defaultPort int) map[string]any {
|
|
dest := h.Address
|
|
if dest == "" {
|
|
dest = defaultDest
|
|
}
|
|
port := h.Port
|
|
if port == 0 {
|
|
port = defaultPort
|
|
}
|
|
ep := map[string]any{
|
|
"forceTls": hostSecurityToForceTls(h.Security),
|
|
"dest": dest,
|
|
"port": float64(port),
|
|
"remark": h.Remark,
|
|
// Marks this as a host (not a legacy externalProxy) entry so host-only
|
|
// behaviors (e.g. reality SNI/fp override) apply without touching the
|
|
// legacy externalProxy path. Not emitted into output.
|
|
"isHost": true,
|
|
}
|
|
sni := h.Sni
|
|
if h.OverrideSniFromAddress {
|
|
sni = dest
|
|
}
|
|
if !h.KeepSniBlank && sni != "" {
|
|
ep["sni"] = sni
|
|
}
|
|
if h.Fingerprint != "" {
|
|
ep["fingerprint"] = h.Fingerprint
|
|
}
|
|
if len(h.Alpn) > 0 {
|
|
ep["alpn"] = stringsToAnySlice(h.Alpn)
|
|
}
|
|
if len(h.PinnedPeerCertSha256) > 0 {
|
|
ep["pinnedPeerCertSha256"] = stringsToAnySlice(h.PinnedPeerCertSha256)
|
|
}
|
|
if h.EchConfigList != "" {
|
|
ep["echConfigList"] = h.EchConfigList
|
|
}
|
|
if h.VerifyPeerCertByName != "" {
|
|
ep["verifyPeerCertByName"] = h.VerifyPeerCertByName
|
|
}
|
|
if h.AllowInsecure {
|
|
ep["allowInsecure"] = true
|
|
}
|
|
if h.HostHeader != "" {
|
|
ep["hostHeader"] = h.HostHeader
|
|
}
|
|
if h.Path != "" {
|
|
ep["path"] = h.Path
|
|
}
|
|
if h.MihomoIpVersion != "" {
|
|
ep["mihomoIpVersion"] = h.MihomoIpVersion
|
|
}
|
|
if h.SockoptParams != "" {
|
|
ep["sockoptParams"] = h.SockoptParams
|
|
}
|
|
if h.MuxParams != "" {
|
|
ep["muxParams"] = h.MuxParams
|
|
}
|
|
if h.FinalMask != "" {
|
|
ep["finalMask"] = h.FinalMask
|
|
}
|
|
return ep
|
|
}
|
|
|
|
// hostMuxOverride returns a host's muxParams when it is valid JSON, else "".
|
|
// Used to override the JSON outbound's mux for that host.
|
|
func hostMuxOverride(ep map[string]any) string {
|
|
mp, ok := ep["muxParams"].(string)
|
|
if ok && mp != "" && json.Valid([]byte(mp)) {
|
|
return mp
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// applyHostStreamOverrides injects a host's free-JSON stream overrides into the
|
|
// per-host stream the JSON/Clash renderers build: sockoptParams (re-added since
|
|
// the base stream strips sockopt) and finalMask. No-op for legacy externalProxy
|
|
// entries (which never carry these keys), so existing output is unchanged.
|
|
func applyHostStreamOverrides(ep map[string]any, stream map[string]any) {
|
|
if sp, ok := ep["sockoptParams"].(string); ok && sp != "" {
|
|
var sockopt map[string]any
|
|
if json.Unmarshal([]byte(sp), &sockopt) == nil && len(sockopt) > 0 {
|
|
stream["sockopt"] = sockopt
|
|
}
|
|
}
|
|
// Host finalmask: merge the host's masks into the stream's finalmask (the
|
|
// JSON renderer consumes streamSettings["finalmask"]; clash ignores it).
|
|
if fm, ok := ep["finalMask"].(string); ok && fm != "" {
|
|
var masks map[string]any
|
|
if json.Unmarshal([]byte(fm), &masks) == nil && len(masks) > 0 {
|
|
merged := mergeFinalMask(stream["finalmask"], masks)
|
|
if len(merged) > 0 {
|
|
stream["finalmask"] = merged
|
|
}
|
|
}
|
|
}
|
|
// Reality SNI override (host only): JSON realityData reads serverNames and
|
|
// clash reads serverName, so set both forms.
|
|
if isHostEndpoint(ep) {
|
|
if sec, _ := stream["security"].(string); sec == "reality" {
|
|
if rs, ok := stream["realitySettings"].(map[string]any); ok && rs != nil {
|
|
if sni, ok := externalProxySNI(ep); ok {
|
|
rs["serverName"] = sni
|
|
rs["serverNames"] = []any{sni}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// hostSecurityToForceTls maps Host.Security onto the externalProxy forceTls
|
|
// vocabulary. "reality"/"same"/"" all keep the inbound's base security ("same")
|
|
// — reality parameters can only come from the inbound itself.
|
|
func hostSecurityToForceTls(security string) string {
|
|
switch security {
|
|
case "tls", "none":
|
|
return security
|
|
default:
|
|
return "same"
|
|
}
|
|
}
|
|
|
|
func stringsToAnySlice(in []string) []any {
|
|
out := make([]any, 0, len(in))
|
|
for _, s := range in {
|
|
if s != "" {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// injectExternalProxy rewrites the inbound's StreamSettings so its externalProxy
|
|
// array is exactly eps. Host endpoints win over any legacy externalProxy.
|
|
func injectExternalProxy(inbound *model.Inbound, eps []map[string]any) {
|
|
stream := unmarshalStreamSettings(inbound.StreamSettings)
|
|
if stream == nil {
|
|
stream = map[string]any{}
|
|
}
|
|
arr := make([]any, len(eps))
|
|
for i := range eps {
|
|
arr[i] = eps[i]
|
|
}
|
|
stream["externalProxy"] = arr
|
|
if b, err := json.Marshal(stream); err == nil {
|
|
inbound.StreamSettings = string(b)
|
|
}
|
|
}
|
|
|
|
// linkFromHosts renders a (possibly multi-line) raw link for one client using
|
|
// the given host endpoints. It renders ONLY the hosts: an empty eps yields ""
|
|
// (no legacy fallback) — the caller decides when to take the legacy path. That
|
|
// separation is what makes the zero-hosts fallback mutation-testable.
|
|
func (s *SubService) linkFromHosts(inbound *model.Inbound, client model.Client, eps []map[string]any) string {
|
|
if len(eps) == 0 {
|
|
return ""
|
|
}
|
|
stream := unmarshalStreamSettings(inbound.StreamSettings)
|
|
transport, _ := stream["network"].(string)
|
|
// Clone each ep before expanding its remark template: the eps slice is
|
|
// shared across all clients of this inbound, so the rendered (per-client)
|
|
// remark must not leak into the next client's links.
|
|
rendered := make([]map[string]any, len(eps))
|
|
for i, ep := range eps {
|
|
cp := maps.Clone(ep)
|
|
s.renderHostRemark(inbound, client, cp, transport)
|
|
rendered[i] = cp
|
|
}
|
|
clone := *inbound
|
|
injectExternalProxy(&clone, rendered)
|
|
return s.GetLink(&clone, client.Email)
|
|
}
|
|
|
|
// renderHostRemark expands a host endpoint's {{VAR}} remark template for one
|
|
// client in place and marks it final, so the downstream link/proxy/config
|
|
// renderers emit it verbatim (via endpointRemark) instead of re-composing it.
|
|
// No-op for non-host endpoints (legacy externalProxy / synthetic default), so
|
|
// their output stays byte-identical.
|
|
func (s *SubService) renderHostRemark(inbound *model.Inbound, client model.Client, ep map[string]any, transport string) {
|
|
if !isHostEndpoint(ep) {
|
|
return
|
|
}
|
|
tmpl, _ := ep["remark"].(string)
|
|
ep["remark"] = s.genHostRemark(inbound, client, tmpl, transport)
|
|
ep["remarkFinal"] = true
|
|
}
|
|
|
|
// endpointRemark returns the remark to stamp on an endpoint's link/proxy/config
|
|
// entry. A host endpoint whose template was pre-expanded by renderHostRemark
|
|
// carries remarkFinal and is used verbatim; every other entry flows through the
|
|
// standard genRemark composition unchanged.
|
|
func (s *SubService) endpointRemark(inbound *model.Inbound, email string, ep map[string]any, transport string) string {
|
|
if ep != nil {
|
|
if final, _ := ep["remarkFinal"].(bool); final {
|
|
r, _ := ep["remark"].(string)
|
|
return r
|
|
}
|
|
}
|
|
var extra string
|
|
if ep != nil {
|
|
extra, _ = ep["remark"].(string)
|
|
}
|
|
return s.genRemark(inbound, email, extra, transport)
|
|
}
|
|
|
|
// applyEndpointHostPath overrides the transport host header / path for a host
|
|
// endpoint. It is a no-op for legacy externalProxy entries (which never carry
|
|
// hostHeader/path) and only replaces keys the transport already emits, so it
|
|
// cannot add spurious params to e.g. a tcp link.
|
|
func applyEndpointHostPath(e ShareEndpoint, params map[string]string) {
|
|
if e.ep == nil {
|
|
return
|
|
}
|
|
if h, ok := e.ep["hostHeader"].(string); ok && h != "" {
|
|
if _, exists := params["host"]; exists {
|
|
params["host"] = h
|
|
}
|
|
}
|
|
if p, ok := e.ep["path"].(string); ok && p != "" {
|
|
if _, exists := params["path"]; exists {
|
|
params["path"] = p
|
|
}
|
|
}
|
|
}
|
|
|
|
// isHostEndpoint reports whether ep was synthesized from a Host (vs a legacy
|
|
// externalProxy entry), so host-only overrides stay off the legacy path.
|
|
func isHostEndpoint(ep map[string]any) bool {
|
|
v, _ := ep["isHost"].(bool)
|
|
return v
|
|
}
|
|
|
|
// applyEndpointRealityParams overrides a reality link's SNI + fingerprint from a
|
|
// host (reality's pbk/sid are inherited from the inbound, so they aren't touched).
|
|
// Host-only: legacy externalProxy reality links are unchanged.
|
|
func applyEndpointRealityParams(e ShareEndpoint, params map[string]string, security string) {
|
|
if security != "reality" || e.ep == nil || !isHostEndpoint(e.ep) {
|
|
return
|
|
}
|
|
if sni, ok := externalProxySNI(e.ep); ok {
|
|
params["sni"] = sni
|
|
}
|
|
if fp, ok := e.ep["fingerprint"].(string); ok && fp != "" {
|
|
params["fp"] = fp
|
|
}
|
|
}
|
|
|
|
// applyEndpointAllowInsecure adds allowInsecure=1 to a TLS/Reality link when the
|
|
// host opts into skipping cert verification. No-op for legacy externalProxy
|
|
// entries (which never carry the key) and for plaintext (none) endpoints.
|
|
func applyEndpointAllowInsecure(e ShareEndpoint, params map[string]string, security string) {
|
|
if e.ep == nil || security == "none" {
|
|
return
|
|
}
|
|
if ai, ok := e.ep["allowInsecure"].(bool); ok && ai {
|
|
params["allowInsecure"] = "1"
|
|
}
|
|
}
|
|
|
|
// applyEndpointHostPathObj is applyEndpointHostPath for the VMess object form.
|
|
func applyEndpointHostPathObj(e ShareEndpoint, obj map[string]any) {
|
|
if e.ep == nil {
|
|
return
|
|
}
|
|
if h, ok := e.ep["hostHeader"].(string); ok && h != "" {
|
|
if _, exists := obj["host"]; exists {
|
|
obj["host"] = h
|
|
}
|
|
}
|
|
if p, ok := e.ep["path"].(string); ok && p != "" {
|
|
if _, exists := obj["path"]; exists {
|
|
obj["path"] = p
|
|
}
|
|
}
|
|
}
|