Files
3m-ui/backend/internal/node/uri.go
T
kazeyukiro 8507b8636c fix(sub): audit URI and YAML export for CDN access fields
Fill security=tls, sni, fp, and ws host from AccessSNI/PublicHost for
domain+WS share links and client YAML; default fingerprint chrome.
2026-09-30 08:28:16 +00:00

187 lines
5.3 KiB
Go

package node
import (
"encoding/json"
"fmt"
"net"
"net/url"
"strings"
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
"github.com/kazeyukiro/3m-ui/backend/internal/netutil"
)
func ClientURIs(listener models.Listener, host string) ([]string, error) {
host = netutil.NormalizeHost(host)
if host == "" {
host = netutil.NormalizeHost(listener.PublicHost)
}
if host == "" {
host = normalizeExportHost("", listener.BindAddress, listener.Listen)
}
if host == "" {
return nil, fmt.Errorf("cannot determine public host for listener; set public_host / server.public_url or access via a public hostname (IPv4/IPv6 supported)")
}
cfg, err := decodeURIConfig(listener.Config)
if err != nil {
return nil, err
}
cfg["_listener-tls"] = listener.TLS
cfg["_listener-udp"] = listener.UDP
port := strings.TrimSpace(listener.PublicPort)
if port == "" {
port = strings.TrimSpace(listener.Port)
}
applyURIAccessProfile(cfg, listener, host, port)
if strings.ContainsAny(port, ",-") {
return nil, fmt.Errorf("URI export requires a single listener port; ranges and port lists are not representable in a share URI")
}
switch strings.ToLower(listener.Protocol) {
case "shadowsocks":
return shadowsocksURIs(listener.Name, host, port, cfg)
case "snell":
return snellURIs(listener.Name, host, port, cfg)
case "vless":
return vlessURIs(listener.Name, host, port, cfg)
case "vmess":
return vmessURIs(listener.Name, host, port, cfg)
case "trojan":
return trojanURIs(listener.Name, host, port, cfg)
case "hysteria2":
return hysteria2URIs(listener.Name, host, port, cfg)
case "tuic", "tuic-v4", "tuic-v5":
return tuicURIs(listener.Name, host, port, cfg)
case "shadowquic":
return shadowQUICURIs(listener.Name, host, port, cfg)
case "anytls":
return anytlsURIs(listener.Name, host, port, cfg)
case "mieru":
return mieruURIs(listener.Name, host, port, cfg)
case "sudoku":
return sudokuURIs(listener.Name, host, port, cfg)
case "trusttunnel":
return trustTunnelURIs(listener.Name, host, port, cfg)
default:
return nil, fmt.Errorf("URI export is not supported for listener protocol %q", listener.Protocol)
}
}
// applyURIAccessProfile injects client-facing TLS/WS fields from the Listener
// row (AccessSNI, PublicHost, fingerprint) into the config map used by URI builders.
func applyURIAccessProfile(cfg map[string]interface{}, listener models.Listener, connectHost, port string) {
if cfg == nil {
return
}
hint := strings.TrimSpace(listener.AccessSNI)
if hint == "" {
hint = strings.TrimSpace(listener.PublicHost)
}
if hint == "" {
hint = strings.TrimSpace(connectHost)
}
hint = strings.Trim(hint, "[]")
domainHint := hint != "" && net.ParseIP(hint) == nil
sniEmpty := true
if v, ok := cfg["sni"].(string); ok && strings.TrimSpace(v) != "" {
sniEmpty = false
}
if v, ok := cfg["servername"].(string); ok && strings.TrimSpace(v) != "" {
sniEmpty = false
}
if sni := strings.TrimSpace(listener.AccessSNI); sni != "" {
cfg["sni"] = sni
cfg["servername"] = sni
} else if domainHint && sniEmpty {
cfg["sni"] = hint
cfg["servername"] = hint
}
if fp := strings.TrimSpace(listener.ClientFingerprint); fp != "" {
cfg["client-fingerprint"] = fp
cfg["fingerprint"] = fp
}
if alpn := strings.TrimSpace(listener.AccessALPN); alpn != "" {
cfg["alpn"] = alpn
}
hasWS := false
if wsPath, ok := cfg["ws-path"].(string); ok && strings.TrimSpace(wsPath) != "" {
hasWS = true
}
cdnPort := port == "443" || port == "8443" || port == "2053" || port == "2083" || port == "2087" || port == "2096"
if hasWS && domainHint && cdnPort {
cfg["_listener-tls"] = true
}
if cert, ok := cfg["certificate"].(string); ok && strings.TrimSpace(cert) != "" {
cfg["_listener-tls"] = true
}
if _, ok := cfg["reality-config"]; ok {
cfg["_listener-tls"] = true
}
if hasWS {
headers, _ := cfg["ws-headers"].(map[string]interface{})
if headers == nil {
headers = map[string]interface{}{}
}
if h, _ := headers["Host"].(string); strings.TrimSpace(h) == "" && domainHint {
headers["Host"] = hint
cfg["ws-headers"] = headers
}
}
}
func decodeURIConfig(raw string) (map[string]interface{}, error) {
if strings.TrimSpace(raw) == "" {
return map[string]interface{}{}, nil
}
var cfg map[string]interface{}
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
return nil, fmt.Errorf("invalid listener configuration: %w", err)
}
if cfg == nil {
return map[string]interface{}{}, nil
}
return cfg, nil
}
func userMap(cfg map[string]interface{}) map[string]interface{} {
if users, ok := cfg["users"].(map[string]interface{}); ok {
return users
}
return nil
}
func userRows(cfg map[string]interface{}) []map[string]interface{} {
users, _ := cfg["users"].([]interface{})
rows := make([]map[string]interface{}, 0, len(users))
for _, raw := range users {
if row, ok := raw.(map[string]interface{}); ok {
rows = append(rows, row)
}
}
return rows
}
func query(base string, values map[string]string) string {
q := url.Values{}
for k, v := range values {
if v != "" {
q.Set(k, v)
}
}
if encoded := q.Encode(); encoded != "" {
return base + "?" + encoded
}
return base
}
func addName(uri, name string) string {
if name == "" {
return uri
}
// Remark fragment; PathEscape matches common panel clients (incl. ).
return uri + "#" + url.PathEscape(name)
}