feat(share): node share (uri/qr/client_yaml) + Access Profile in URIs
This commit is contained in:
1 parent
bb875405b3
commit
dc62de00a3
3 files changed
+3
-1502
No files matched your search
@@ -1,715 +1 @@
|
||||
package converter
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/config"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/netutil"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/user"
|
||||
"golang.org/x/crypto/curve25519"
|
||||
"gopkg.in/yaml.v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func ResolveServerAddress(cfg *config.Config, req *http.Request) string {
|
||||
if envURL := os.Getenv("PUBLIC_URL"); envURL != "" {
|
||||
return cleanURLHost(envURL)
|
||||
}
|
||||
if cfg != nil && cfg.Server.PublicURL != "" {
|
||||
return cleanURLHost(cfg.Server.PublicURL)
|
||||
}
|
||||
if req != nil && req.Host != "" {
|
||||
return cleanURLHost(req.Host)
|
||||
}
|
||||
return "127.0.0.1"
|
||||
}
|
||||
|
||||
// ResolveListenerServer prefers per-listener PublicHost (IPv4/IPv6/domain),
|
||||
// then the global public URL / request host. Used for share links and client YAML.
|
||||
func ResolveListenerServer(cfg *config.Config, req *http.Request, l models.Listener) string {
|
||||
if h := netutil.NormalizeHost(l.PublicHost); h != "" && !netutil.IsUnspecifiedBind(h) {
|
||||
return h
|
||||
}
|
||||
return ResolveServerAddress(cfg, req)
|
||||
}
|
||||
|
||||
// ResolveListenerPort prefers PublicPort when set (NAT / dual-stack publish).
|
||||
func ResolveListenerPort(l models.Listener) string {
|
||||
if p := strings.TrimSpace(l.PublicPort); p != "" {
|
||||
return p
|
||||
}
|
||||
return strings.TrimSpace(l.Port)
|
||||
}
|
||||
|
||||
func cleanURLHost(raw string) string {
|
||||
u := netutil.NormalizeHost(raw)
|
||||
if u == "" || strings.ContainsAny(u, "\r\n/\\") {
|
||||
return "127.0.0.1"
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func GetSubscriptionURL(cfg *config.Config, req *http.Request, token string, target string) string {
|
||||
var base string
|
||||
if envURL := os.Getenv("PUBLIC_URL"); envURL != "" {
|
||||
base = strings.TrimSpace(envURL)
|
||||
} else if cfg != nil && cfg.Server.PublicURL != "" {
|
||||
base = strings.TrimSpace(cfg.Server.PublicURL)
|
||||
} else if req != nil && req.Host != "" {
|
||||
scheme := "http"
|
||||
if req.TLS != nil {
|
||||
scheme = "https"
|
||||
} else if proto := req.Header.Get("X-Forwarded-Proto"); strings.EqualFold(proto, "https") {
|
||||
scheme = "https"
|
||||
}
|
||||
// req.Host already includes [ipv6]:port when applicable.
|
||||
base = fmt.Sprintf("%s://%s", scheme, req.Host)
|
||||
} else {
|
||||
base = "http://127.0.0.1:8080"
|
||||
}
|
||||
base = strings.TrimSuffix(base, "/")
|
||||
pathToken := url.PathEscape(token)
|
||||
if target == "" {
|
||||
return fmt.Sprintf("%s/api/v1/client/sub/%s", base, pathToken)
|
||||
}
|
||||
return fmt.Sprintf("%s/api/v1/client/sub/%s?target=%s", base, pathToken, url.QueryEscape(strings.ToLower(strings.TrimSpace(target))))
|
||||
}
|
||||
|
||||
func GenerateRawConfig(db *gorm.DB, token models.AccessToken, req *http.Request) ([]byte, error) {
|
||||
if db == nil {
|
||||
return nil, fmt.Errorf("database is not initialized")
|
||||
}
|
||||
if token.ListenerID == 0 {
|
||||
return nil, fmt.Errorf("access token is not bound to a listener")
|
||||
}
|
||||
var listener models.Listener
|
||||
if err := db.First(&listener, token.ListenerID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, fmt.Errorf("listener not found")
|
||||
}
|
||||
return nil, fmt.Errorf("failed to fetch listener: %w", err)
|
||||
}
|
||||
if !listener.Enabled {
|
||||
return nil, fmt.Errorf("listener is disabled")
|
||||
}
|
||||
serverHost := ResolveListenerServer(config.GlobalConfig, req, listener)
|
||||
credentials := []user.Credential{}
|
||||
if byListener, err := user.NewService(db).ActiveCredentialsByListener(); err != nil {
|
||||
return nil, fmt.Errorf("failed to load listener credentials: %w", err)
|
||||
} else {
|
||||
credentials = byListener[listener.ID]
|
||||
}
|
||||
proxies, err := listenerToProxies(listener, serverHost, credentials)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(proxies) == 0 {
|
||||
return nil, fmt.Errorf("listener %q has no exportable client credentials", listener.Name)
|
||||
}
|
||||
names := make([]string, 0, len(proxies))
|
||||
for _, proxy := range proxies {
|
||||
if name, ok := proxy["name"].(string); ok {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
cfg := map[string]interface{}{
|
||||
"proxies": proxies,
|
||||
"proxy-groups": []interface{}{
|
||||
map[string]interface{}{"name": "PROXY", "type": "select", "proxies": names},
|
||||
},
|
||||
"rules": []string{"MATCH,PROXY"},
|
||||
}
|
||||
return yaml.Marshal(cfg)
|
||||
}
|
||||
|
||||
func listenerToProxies(l models.Listener, server string, credentials []user.Credential) ([]map[string]interface{}, error) {
|
||||
protocol := strings.ToLower(strings.TrimSpace(l.Protocol))
|
||||
if protocol == "" {
|
||||
protocol = strings.ToLower(strings.TrimSpace(l.Type))
|
||||
}
|
||||
if !config.IsMihomoListenerProtocol(protocol) {
|
||||
return nil, fmt.Errorf("unsupported listener protocol %q", protocol)
|
||||
}
|
||||
if protocol == "hysteria2-realm" {
|
||||
return nil, fmt.Errorf("hysteria2-realm is an auxiliary realm service and has no client proxy configuration")
|
||||
}
|
||||
opts, err := decodeOptions(l.Config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid listener config for %q: %w", l.Name, err)
|
||||
}
|
||||
portStr := ResolveListenerPort(l)
|
||||
var portVal interface{} = portStr
|
||||
if p, err := strconv.Atoi(portStr); err == nil {
|
||||
portVal = p
|
||||
}
|
||||
server = netutil.NormalizeHost(server)
|
||||
base := map[string]interface{}{"type": protocol, "server": server, "port": portVal}
|
||||
if l.UDP && clientSupportsUDP(protocol) {
|
||||
base["udp"] = true
|
||||
}
|
||||
copyClientTLS(base, opts)
|
||||
copyTransport(base, opts)
|
||||
makeProxy := func(suffix string) map[string]interface{} {
|
||||
p := cloneMap(base)
|
||||
name := l.Name
|
||||
if suffix != "" {
|
||||
name += "-" + suffix
|
||||
}
|
||||
p["name"] = name
|
||||
return p
|
||||
}
|
||||
result := make([]map[string]interface{}, 0, max(1, len(credentials)))
|
||||
switch protocol {
|
||||
case "shadowsocks":
|
||||
p := makeProxy(credentialSuffix(credentials, 0))
|
||||
copyOption(p, opts, "cipher")
|
||||
if len(credentials) > 1 {
|
||||
return nil, fmt.Errorf("listener %q: Shadowsocks can export only one password", l.Name)
|
||||
}
|
||||
if len(credentials) == 1 && credentials[0].Password != "" {
|
||||
p["password"] = credentials[0].Password
|
||||
} else if value, ok := opts["password"]; ok {
|
||||
p["password"] = value
|
||||
}
|
||||
if value, ok := opts["simple-obfs"].(map[string]interface{}); ok && boolValue(value["enable"]) {
|
||||
p["plugin"] = "simple-obfs"
|
||||
p["plugin-opts"] = value
|
||||
}
|
||||
applyClientWrappers(p, opts)
|
||||
result = append(result, p)
|
||||
case "snell":
|
||||
p := makeProxy("")
|
||||
copyOption(p, opts, "psk")
|
||||
copyOption(p, opts, "version")
|
||||
copyOption(p, opts, "udp")
|
||||
if value, ok := opts["obfs-opts"]; ok {
|
||||
p["obfs-opts"] = value
|
||||
}
|
||||
result = append(result, p)
|
||||
case "vmess", "vless":
|
||||
if len(credentials) == 0 {
|
||||
return nil, fmt.Errorf("listener %q requires at least one active user for %s client export", l.Name, protocol)
|
||||
}
|
||||
for i, cred := range credentials {
|
||||
if cred.UUID == "" {
|
||||
continue
|
||||
}
|
||||
suffix := ""
|
||||
if len(credentials) > 1 {
|
||||
suffix = credentialSuffix(credentials, i)
|
||||
if suffix == "" {
|
||||
suffix = fmt.Sprintf("%d", i+1)
|
||||
}
|
||||
}
|
||||
p := makeProxy(suffix)
|
||||
p["uuid"] = cred.UUID
|
||||
copyOption(p, opts, "cipher")
|
||||
copyOption(p, opts, "packet-encoding")
|
||||
copyOption(p, opts, "global-padding")
|
||||
copyOption(p, opts, "authenticated-length")
|
||||
// encryption (client) and decryption (server) are a generated pair from
|
||||
// `mihomo generate vless-x25519` / `vless-mlkem768` — never copy decryption→encryption.
|
||||
copyOption(p, opts, "encryption")
|
||||
if flow := userFieldFromOpts(opts, cred.UUID, "flow"); flow != nil {
|
||||
p["flow"] = flow
|
||||
} else {
|
||||
copyOption(p, opts, "flow")
|
||||
}
|
||||
if flow, ok := p["flow"].(string); ok && strings.Contains(strings.ToLower(flow), "vision") {
|
||||
p["tls"] = true
|
||||
}
|
||||
if alterID := userFieldFromOpts(opts, cred.UUID, "alterId"); alterID != nil {
|
||||
p["alterId"] = alterID
|
||||
} else {
|
||||
copyOption(p, opts, "alterId")
|
||||
}
|
||||
applyClientWrappers(p, opts)
|
||||
result = append(result, p)
|
||||
}
|
||||
case "trojan":
|
||||
if len(credentials) == 0 {
|
||||
return nil, fmt.Errorf("listener %q requires at least one active user for Trojan client export", l.Name)
|
||||
}
|
||||
for i, cred := range credentials {
|
||||
p := makeProxy(fmt.Sprintf("%d", i+1))
|
||||
p["password"] = cred.Password
|
||||
for _, key := range []string{"sni", "alpn", "fingerprint", "client-fingerprint", "skip-cert-verify", "name-cert-verify"} {
|
||||
copyOption(p, opts, key)
|
||||
}
|
||||
applyClientWrappers(p, opts)
|
||||
if value, ok := opts["ss-option"]; ok {
|
||||
p["ss-opts"] = value
|
||||
}
|
||||
result = append(result, p)
|
||||
}
|
||||
case "hysteria2":
|
||||
if len(credentials) == 0 {
|
||||
return nil, fmt.Errorf("listener %q requires at least one active user for Hysteria 2 client export", l.Name)
|
||||
}
|
||||
for i, cred := range credentials {
|
||||
p := makeProxy(fmt.Sprintf("%d", i+1))
|
||||
p["password"] = cred.Password
|
||||
for _, key := range []string{
|
||||
"up", "down", "obfs", "obfs-password", "masquerade", "bbr-profile",
|
||||
"realm-opts", "alpn", "sni", "servername", "skip-cert-verify", "name-cert-verify",
|
||||
"fingerprint", "ca", "ca-str",
|
||||
} {
|
||||
copyOption(p, opts, key)
|
||||
}
|
||||
if p["alpn"] == nil {
|
||||
p["alpn"] = []string{"h3"}
|
||||
}
|
||||
if p["sni"] == nil {
|
||||
if sn, ok := p["servername"].(string); ok && sn != "" {
|
||||
p["sni"] = sn
|
||||
}
|
||||
}
|
||||
if value, ok := opts["ech-opts"]; ok {
|
||||
p["ech-opts"] = value
|
||||
}
|
||||
result = append(result, p)
|
||||
}
|
||||
case "tuic":
|
||||
if token, ok := opts["token"]; ok {
|
||||
p := makeProxy("")
|
||||
p["token"] = token
|
||||
for _, key := range []string{"congestion-controller", "bbr-profile", "max-idle-time", "authentication-timeout", "alpn", "max-udp-relay-packet-size"} {
|
||||
copyOption(p, opts, key)
|
||||
}
|
||||
result = append(result, p)
|
||||
} else {
|
||||
if len(credentials) == 0 {
|
||||
return nil, fmt.Errorf("listener %q requires TUIC V5 users or a V4 token", l.Name)
|
||||
}
|
||||
for i, cred := range credentials {
|
||||
p := makeProxy(fmt.Sprintf("%d", i+1))
|
||||
p["uuid"] = cred.UUID
|
||||
p["password"] = cred.Password
|
||||
for _, key := range []string{"congestion-controller", "bbr-profile", "max-idle-time", "authentication-timeout", "alpn", "max-udp-relay-packet-size"} {
|
||||
copyOption(p, opts, key)
|
||||
}
|
||||
result = append(result, p)
|
||||
}
|
||||
}
|
||||
case "shadowquic":
|
||||
if len(credentials) == 0 {
|
||||
return nil, fmt.Errorf("listener %q requires at least one active user for ShadowQUIC client export", l.Name)
|
||||
}
|
||||
for i, cred := range credentials {
|
||||
p := makeProxy(fmt.Sprintf("%d", i+1))
|
||||
p["username"] = cred.Username
|
||||
p["password"] = cred.Password
|
||||
for _, key := range []string{
|
||||
"sni", "alpn", "quic-versions", "zero-rtt", "udp-over-stream",
|
||||
"keep-alive-interval", "congestion-controller", "up", "down", "cwnd",
|
||||
"bbr-profile", "max-datagram-frame-size", "max-open-streams",
|
||||
"recv-window-conn", "recv-window", "disable-mtu-discovery",
|
||||
} {
|
||||
copyOption(p, opts, key)
|
||||
}
|
||||
if p["sni"] == nil {
|
||||
if ju, ok := opts["jls-upstream"].(map[string]interface{}); ok {
|
||||
if sni, ok := ju["sni"].(string); ok && strings.TrimSpace(sni) != "" {
|
||||
p["sni"] = sni
|
||||
} else if addr, ok := ju["addr"].(string); ok && strings.TrimSpace(addr) != "" {
|
||||
host := addr
|
||||
if h, _, err := net.SplitHostPort(addr); err == nil {
|
||||
host = h
|
||||
}
|
||||
p["sni"] = host
|
||||
}
|
||||
}
|
||||
}
|
||||
if p["udp"] == nil {
|
||||
p["udp"] = true
|
||||
}
|
||||
if p["alpn"] == nil {
|
||||
p["alpn"] = []string{"h3"}
|
||||
}
|
||||
if p["quic-versions"] == nil {
|
||||
p["quic-versions"] = []string{"v2"}
|
||||
}
|
||||
if p["congestion-controller"] == nil {
|
||||
p["congestion-controller"] = "bbr"
|
||||
}
|
||||
if p["zero-rtt"] == nil {
|
||||
p["zero-rtt"] = true
|
||||
}
|
||||
result = append(result, p)
|
||||
}
|
||||
case "anytls":
|
||||
if len(credentials) == 0 {
|
||||
return nil, fmt.Errorf("listener %q requires at least one active user for AnyTLS client export", l.Name)
|
||||
}
|
||||
for i, cred := range credentials {
|
||||
p := makeProxy(fmt.Sprintf("%d", i+1))
|
||||
p["password"] = cred.Password
|
||||
for _, key := range []string{
|
||||
"client-fingerprint", "udp", "idle-session-check-interval",
|
||||
"idle-session-timeout", "min-idle-session", "sni", "alpn",
|
||||
"skip-cert-verify", "name-cert-verify",
|
||||
} {
|
||||
copyOption(p, opts, key)
|
||||
}
|
||||
applyClientWrappers(p, opts)
|
||||
result = append(result, p)
|
||||
}
|
||||
case "mieru":
|
||||
if len(credentials) == 0 {
|
||||
return nil, fmt.Errorf("listener %q requires at least one active user for Mieru client export", l.Name)
|
||||
}
|
||||
for i, cred := range credentials {
|
||||
p := makeProxy(fmt.Sprintf("%d", i+1))
|
||||
p["username"] = cred.Username
|
||||
p["password"] = cred.Password
|
||||
for _, key := range []string{"transport", "multiplexing", "handshake-mode", "traffic-pattern"} {
|
||||
copyOption(p, opts, key)
|
||||
}
|
||||
result = append(result, p)
|
||||
}
|
||||
case "sudoku":
|
||||
p := makeProxy("")
|
||||
for _, key := range []string{
|
||||
"key", "aead-method", "padding-min", "padding-max", "table-type",
|
||||
"custom-table", "custom-tables", "handshake-timeout",
|
||||
"enable-pure-downlink", "httpmask",
|
||||
} {
|
||||
copyOption(p, opts, key)
|
||||
}
|
||||
result = append(result, p)
|
||||
case "trusttunnel":
|
||||
if len(credentials) == 0 {
|
||||
return nil, fmt.Errorf("listener %q requires at least one active user for TrustTunnel client export", l.Name)
|
||||
}
|
||||
for i, cred := range credentials {
|
||||
p := makeProxy(fmt.Sprintf("%d", i+1))
|
||||
p["username"] = cred.Username
|
||||
p["password"] = cred.Password
|
||||
for _, key := range []string{
|
||||
"client-fingerprint", "health-check", "udp", "sni", "alpn",
|
||||
"skip-cert-verify", "name-cert-verify", "quic", "congestion-controller",
|
||||
"bbr-profile", "max-connections", "min-streams", "max-streams",
|
||||
} {
|
||||
copyOption(p, opts, key)
|
||||
}
|
||||
result = append(result, p)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func applyClientWrappers(p map[string]interface{}, opts map[string]interface{}) {
|
||||
if value := realityClientOptions(opts); value != nil {
|
||||
p["reality-opts"] = value
|
||||
if p["client-fingerprint"] == nil {
|
||||
p["client-fingerprint"] = "chrome"
|
||||
}
|
||||
if p["tls"] == nil {
|
||||
p["tls"] = true
|
||||
}
|
||||
if p["udp"] == nil {
|
||||
p["udp"] = true
|
||||
}
|
||||
if p["alpn"] == nil {
|
||||
p["alpn"] = []string{"h2", "http/1.1"}
|
||||
}
|
||||
if p["network"] == nil {
|
||||
p["network"] = "tcp"
|
||||
}
|
||||
}
|
||||
if value := shadowTLSClientOptions(opts); value != nil {
|
||||
p["shadow-tls-opts"] = value
|
||||
}
|
||||
if value := resTLSClientOptions(opts); value != nil {
|
||||
p["restls-opts"] = value
|
||||
}
|
||||
if value := jlsClientOptions(opts); value != nil {
|
||||
p["jls-opts"] = value
|
||||
}
|
||||
}
|
||||
|
||||
func decodeOptions(raw string) (map[string]interface{}, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return map[string]interface{}{}, nil
|
||||
}
|
||||
var options map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &options); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if options == nil {
|
||||
options = map[string]interface{}{}
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
func copyClientTLS(dst, src map[string]interface{}) {
|
||||
if _, ok := src["certificate"]; ok {
|
||||
dst["tls"] = true
|
||||
}
|
||||
if _, ok := src["reality-config"]; ok {
|
||||
dst["tls"] = true
|
||||
}
|
||||
if enabledWrapper(src, "shadow-tls") || enabledWrapper(src, "res-tls") || enabledWrapper(src, "jls-config") {
|
||||
dst["tls"] = true
|
||||
}
|
||||
for _, key := range []string{"sni", "servername", "alpn", "fingerprint", "client-fingerprint", "skip-cert-verify", "name-cert-verify"} {
|
||||
copyOption(dst, src, key)
|
||||
}
|
||||
if dst["sni"] == nil && dst["servername"] == nil {
|
||||
if reality, ok := src["reality-config"].(map[string]interface{}); ok {
|
||||
if sni, ok := firstStringValue(reality["server-names"]); ok {
|
||||
dst["servername"] = sni
|
||||
}
|
||||
}
|
||||
if dst["servername"] == nil {
|
||||
if jls, ok := src["jls-config"].(map[string]interface{}); ok {
|
||||
if sni, ok := jls["sni"].(string); ok && sni != "" {
|
||||
dst["servername"] = sni
|
||||
} else if dest, ok := jls["dest"].(string); ok && dest != "" {
|
||||
host := dest
|
||||
if h, _, err := net.SplitHostPort(dest); err == nil {
|
||||
host = h
|
||||
}
|
||||
dst["servername"] = host
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if dst["servername"] == nil {
|
||||
if sni, ok := dst["sni"].(string); ok && sni != "" {
|
||||
dst["servername"] = sni
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func enabledWrapper(src map[string]interface{}, key string) bool {
|
||||
raw, ok := src[key]
|
||||
if !ok || raw == nil {
|
||||
return false
|
||||
}
|
||||
if m, ok := raw.(map[string]interface{}); ok {
|
||||
if en, ok := m["enable"].(bool); ok {
|
||||
return en
|
||||
}
|
||||
return len(m) > 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func copyTransport(dst, src map[string]interface{}) {
|
||||
if path, ok := src["ws-path"].(string); ok && strings.TrimSpace(path) != "" {
|
||||
dst["network"] = "ws"
|
||||
wsOpts := map[string]interface{}{"path": path}
|
||||
if headers, ok := src["ws-headers"].(map[string]interface{}); ok && len(headers) > 0 {
|
||||
wsOpts["headers"] = headers
|
||||
}
|
||||
dst["ws-opts"] = wsOpts
|
||||
}
|
||||
if service, ok := src["grpc-service-name"].(string); ok && strings.TrimSpace(service) != "" {
|
||||
dst["network"] = "grpc"
|
||||
dst["grpc-opts"] = map[string]interface{}{"grpc-service-name": service}
|
||||
}
|
||||
if value, ok := src["xhttp-config"]; ok && value != nil {
|
||||
dst["network"] = "xhttp"
|
||||
dst["xhttp-opts"] = value
|
||||
}
|
||||
}
|
||||
|
||||
func realityClientOptions(src map[string]interface{}) map[string]interface{} {
|
||||
cfg, ok := src["reality-config"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
publicKey, err := deriveRealityPublicKey(cfg)
|
||||
if err != nil || publicKey == "" {
|
||||
return nil
|
||||
}
|
||||
result := map[string]interface{}{"public-key": publicKey}
|
||||
if ids, ok := cfg["short-id"].([]interface{}); ok && len(ids) > 0 {
|
||||
result["short-id"] = ids[0]
|
||||
} else if value, ok := cfg["short-id"]; ok {
|
||||
result["short-id"] = value
|
||||
}
|
||||
if v, ok := cfg["support-x25519mlkem768"]; ok {
|
||||
result["support-x25519mlkem768"] = v
|
||||
} else {
|
||||
result["support-x25519mlkem768"] = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func deriveRealityPublicKey(cfg map[string]interface{}) (string, error) {
|
||||
if public, ok := cfg["public-key"].(string); ok && strings.TrimSpace(public) != "" {
|
||||
return strings.TrimSpace(public), nil
|
||||
}
|
||||
private, ok := cfg["private-key"].(string)
|
||||
if !ok || strings.TrimSpace(private) == "" {
|
||||
return "", fmt.Errorf("reality-config missing public-key and private-key")
|
||||
}
|
||||
var raw []byte
|
||||
var err error
|
||||
for _, decode := range []func(string) ([]byte, error){
|
||||
base64.RawStdEncoding.DecodeString, base64.StdEncoding.DecodeString,
|
||||
base64.RawURLEncoding.DecodeString, base64.URLEncoding.DecodeString,
|
||||
} {
|
||||
raw, err = decode(strings.TrimSpace(private))
|
||||
if err == nil && len(raw) == 32 {
|
||||
break
|
||||
}
|
||||
raw = nil
|
||||
}
|
||||
if len(raw) != 32 {
|
||||
return "", fmt.Errorf("invalid Reality private key")
|
||||
}
|
||||
public, err := curve25519.X25519(raw, curve25519.Basepoint)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawStdEncoding.EncodeToString(public), nil
|
||||
}
|
||||
|
||||
func firstStringValue(v interface{}) (string, bool) {
|
||||
if s, ok := v.(string); ok {
|
||||
return s, s != ""
|
||||
}
|
||||
if a, ok := v.([]interface{}); ok && len(a) > 0 {
|
||||
s, _ := a[0].(string)
|
||||
return s, s != ""
|
||||
}
|
||||
if a, ok := v.([]string); ok && len(a) > 0 {
|
||||
return a[0], a[0] != ""
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func shadowTLSClientOptions(src map[string]interface{}) map[string]interface{} {
|
||||
cfg, ok := src["shadow-tls"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
result := map[string]interface{}{}
|
||||
for _, key := range []string{"version", "password"} {
|
||||
if value, ok := cfg[key]; ok {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func resTLSClientOptions(src map[string]interface{}) map[string]interface{} {
|
||||
cfg, ok := src["res-tls"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
result := map[string]interface{}{}
|
||||
if value, ok := cfg["password"]; ok {
|
||||
result["password"] = value
|
||||
}
|
||||
if value, ok := cfg["version-hint"]; ok {
|
||||
result["version-hint"] = value
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func jlsClientOptions(src map[string]interface{}) map[string]interface{} {
|
||||
cfg, ok := src["jls-config"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
result := map[string]interface{}{}
|
||||
if users, ok := cfg["users"].([]interface{}); ok && len(users) > 0 {
|
||||
if first, ok := users[0].(map[string]interface{}); ok {
|
||||
if value, ok := first["username"]; ok {
|
||||
result["username"] = value
|
||||
}
|
||||
if value, ok := first["password"]; ok {
|
||||
result["password"] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func cloneMap(src map[string]interface{}) map[string]interface{} {
|
||||
dst := make(map[string]interface{}, len(src))
|
||||
for key, value := range src {
|
||||
dst[key] = value
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func copyOption(dst, src map[string]interface{}, key string) {
|
||||
if value, ok := src[key]; ok {
|
||||
dst[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
func boolValue(v interface{}) bool {
|
||||
b, _ := v.(bool)
|
||||
return b
|
||||
}
|
||||
|
||||
func clientSupportsUDP(protocol string) bool {
|
||||
switch protocol {
|
||||
case "shadowsocks", "snell", "vmess", "vless", "trojan", "anytls", "trusttunnel":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func credentialSuffix(credentials []user.Credential, index int) string {
|
||||
if index >= 0 && index < len(credentials) && credentials[index].Username != "" {
|
||||
return credentials[index].Username
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func userFieldFromOpts(opts map[string]interface{}, uuid, field string) interface{} {
|
||||
uuid = strings.TrimSpace(uuid)
|
||||
if uuid == "" {
|
||||
return nil
|
||||
}
|
||||
list, ok := opts["users"].([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, item := range list {
|
||||
row, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(fmt.Sprint(row["uuid"])) != uuid {
|
||||
continue
|
||||
}
|
||||
if value, exists := row[field]; exists && value != nil && fmt.Sprint(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
PLACEHOLDER3
|
||||
@@ -1,301 +1 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/config"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/converter"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/netutil"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/protocol"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/user"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Handler serves node/listener HTTP endpoints with injected dependencies.
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
user *user.Service
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewHandler constructs a node HTTP handler.
|
||||
func NewHandler(svc *Service, userSvc *user.Service, db *gorm.DB) *Handler {
|
||||
return &Handler{svc: svc, user: userSvc, db: db}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers node routes on the provided group.
|
||||
func (h *Handler) RegisterRoutes(rg *gin.RouterGroup) {
|
||||
rg.GET("", h.ListNodes)
|
||||
rg.POST("", h.CreateNode)
|
||||
rg.GET("/:id", h.GetNode)
|
||||
rg.GET("/:id/uri", h.ExportNodeURI)
|
||||
rg.PUT("/:id", h.UpdateNode)
|
||||
rg.DELETE("/:id", h.DeleteNode)
|
||||
rg.POST("/:id/reload", h.ReloadNode)
|
||||
rg.POST("/:id/client-access", h.CreateClientAccess)
|
||||
}
|
||||
|
||||
// RegisterClientRoutes registers only routes that are unique to the node
|
||||
// handler. The listener handler already owns the shared CRUD/reload routes.
|
||||
func (h *Handler) RegisterClientRoutes(rg *gin.RouterGroup) {
|
||||
rg.GET("/:id/uri", h.ExportNodeURI)
|
||||
rg.POST("/:id/client-access", h.CreateClientAccess)
|
||||
}
|
||||
|
||||
func (h *Handler) ListNodes(c *gin.Context) {
|
||||
list, err := h.svc.GetAll()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateNode(c *gin.Context) {
|
||||
var l models.Listener
|
||||
if err := c.ShouldBindJSON(&l); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := ValidateNode(&l); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.svc.Create(&l); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, l)
|
||||
}
|
||||
|
||||
func (h *Handler) GetNode(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid node id"})
|
||||
return
|
||||
}
|
||||
l, err := h.svc.GetByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, l)
|
||||
}
|
||||
|
||||
// isTrustedHost validates that the provided host is acceptable for URI export.
|
||||
// It rejects path traversal, illegal characters, and hosts that do not match
|
||||
// the configured public_url (if set).
|
||||
func (h *Handler) isTrustedHost(host string) bool {
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
if strings.ContainsAny(host, "/\\?#@") || strings.Contains(host, "..") {
|
||||
return false
|
||||
}
|
||||
if config.GlobalConfig != nil && config.GlobalConfig.Server.PublicURL != "" {
|
||||
expected := config.GlobalConfig.Server.PublicURL
|
||||
if u, err := url.Parse(expected); err == nil && u.Host != "" {
|
||||
expected = u.Host
|
||||
}
|
||||
if eh, _, err := net.SplitHostPort(expected); err == nil {
|
||||
expected = eh
|
||||
}
|
||||
if !strings.EqualFold(host, expected) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ExportNodeURI returns one URI per active client credential. Public host
|
||||
// comes from public_url, request Host, or loopback as last resort.
|
||||
func (h *Handler) ExportNodeURI(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid node id"})
|
||||
return
|
||||
}
|
||||
listener, err := h.svc.GetByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "listener not found"})
|
||||
return
|
||||
}
|
||||
if !listener.Enabled {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "listener is disabled"})
|
||||
return
|
||||
}
|
||||
|
||||
publicURL := ""
|
||||
if config.GlobalConfig != nil {
|
||||
publicURL = strings.TrimSpace(config.GlobalConfig.Server.PublicURL)
|
||||
}
|
||||
// Priority: per-node Access Profile > global panel Access Profile > server.public_url
|
||||
if ap := protocol.LoadAccessProfile(h.db); ap.PublicHost != "" {
|
||||
publicURL = ap.PublicHost
|
||||
}
|
||||
if host := strings.TrimSpace(listener.PublicHost); host != "" {
|
||||
publicURL = host
|
||||
}
|
||||
host := c.GetHeader("X-Forwarded-Host")
|
||||
if host != "" && !h.isTrustedHost(host) {
|
||||
host = ""
|
||||
}
|
||||
if host == "" {
|
||||
host = c.Request.Host
|
||||
}
|
||||
host = netutil.NormalizeHost(host)
|
||||
host = normalizeExportHostPrefer(host, listener.BindAddress, listener.Listen, publicURL)
|
||||
host = netutil.NormalizeHost(host)
|
||||
|
||||
credentials := []user.Credential{}
|
||||
if h.user != nil {
|
||||
byListener, err := h.user.ActiveCredentialsByListener()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load active credentials: " + err.Error()})
|
||||
return
|
||||
}
|
||||
credentials = byListener[listener.ID]
|
||||
}
|
||||
|
||||
uris, err := ClientURIsWithCredentials(*listener, host, credentials)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if uris == nil {
|
||||
uris = []string{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"name": listener.Name,
|
||||
"protocol": listener.Protocol,
|
||||
"uris": uris,
|
||||
"hint": emptyURIHint(len(uris), len(credentials)),
|
||||
})
|
||||
}
|
||||
|
||||
func emptyURIHint(uriCount, credCount int) string {
|
||||
if uriCount > 0 {
|
||||
return ""
|
||||
}
|
||||
if credCount == 0 {
|
||||
return "no active users bound to this listener; bind users in User Management"
|
||||
}
|
||||
return "could not build share links for this protocol/config"
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateNode(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid node id"})
|
||||
return
|
||||
}
|
||||
var l models.Listener
|
||||
if err := c.ShouldBindJSON(&l); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
l.ID = uint(id)
|
||||
if err := ValidateNode(&l); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.svc.Update(&l); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, l)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteNode(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid node id"})
|
||||
return
|
||||
}
|
||||
if err := h.svc.Delete(uint(id)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "node deleted"})
|
||||
}
|
||||
|
||||
func (h *Handler) ReloadNode(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid node id"})
|
||||
return
|
||||
}
|
||||
if err := h.svc.TriggerReload(uint(id)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "node reloaded"})
|
||||
}
|
||||
|
||||
func (h *Handler) CreateClientAccess(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid listener id"})
|
||||
return
|
||||
}
|
||||
db := h.db
|
||||
if db == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database is not configured"})
|
||||
return
|
||||
}
|
||||
var listener models.Listener
|
||||
if err := db.First(&listener, uint(id)).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "listener not found"})
|
||||
return
|
||||
}
|
||||
if !listener.Enabled {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "listener is disabled"})
|
||||
return
|
||||
}
|
||||
var existing models.AccessToken
|
||||
if err := db.Where("listener_id = ? AND enabled = ?", listener.ID, true).First(&existing).Error; err == nil {
|
||||
if existing.ExpireAt == nil || existing.ExpireAt.After(time.Now()) {
|
||||
c.JSON(http.StatusOK, h.clientAccessResponse(c, existing))
|
||||
return
|
||||
}
|
||||
}
|
||||
buf := make([]byte, 16)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate secure token"})
|
||||
return
|
||||
}
|
||||
token := models.AccessToken{Name: listener.Name, Token: hex.EncodeToString(buf), Enabled: true, ListenerID: listener.ID}
|
||||
if err := db.Create(&token).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, h.clientAccessResponse(c, token))
|
||||
}
|
||||
|
||||
func (h *Handler) clientAccessResponse(c *gin.Context, token models.AccessToken) gin.H {
|
||||
cfg := config.GlobalConfig
|
||||
return gin.H{
|
||||
"id": token.ID,
|
||||
"name": token.Name,
|
||||
"token": token.Token,
|
||||
"type": "listener",
|
||||
"listener_id": token.ListenerID,
|
||||
"mihomo_link": converter.GetSubscriptionURL(cfg, c.Request, token.Token, "mihomo"),
|
||||
"clash_link": converter.GetSubscriptionURL(cfg, c.Request, token.Token, "clash"),
|
||||
"singbox_link": converter.GetSubscriptionURL(cfg, c.Request, token.Token, "singbox"),
|
||||
"shadowrocket_link": converter.GetSubscriptionURL(cfg, c.Request, token.Token, "shadowrocket"),
|
||||
}
|
||||
}
|
||||
PLACEHOLDER2
|
||||
@@ -1,486 +1 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
|
||||
"github.com/kazeyukiro/3m-ui/backend/internal/netutil"
|
||||
"golang.org/x/crypto/curve25519"
|
||||
)
|
||||
|
||||
// ClientURIs exports client share links from the same Listener schema used by
|
||||
// Mihomo config generation. Server-only fields are never copied blindly.
|
||||
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)
|
||||
}
|
||||
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":
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
return uri + "#" + url.QueryEscape(name)
|
||||
}
|
||||
|
||||
func tlsParams(cfg map[string]interface{}) map[string]string {
|
||||
params := map[string]string{}
|
||||
if enabled, ok := cfg["_listener-tls"].(bool); ok && enabled {
|
||||
params["security"] = "tls"
|
||||
}
|
||||
if certificate, ok := cfg["certificate"].(string); ok && strings.TrimSpace(certificate) != "" {
|
||||
params["security"] = "tls"
|
||||
}
|
||||
if v, ok := cfg["servername"].(string); ok && v != "" {
|
||||
params["sni"] = v
|
||||
}
|
||||
if v, ok := cfg["sni"].(string); ok && v != "" {
|
||||
params["sni"] = v
|
||||
}
|
||||
if v, ok := cfg["client-fingerprint"].(string); ok && v != "" {
|
||||
params["fp"] = v
|
||||
}
|
||||
if v, ok := cfg["fingerprint"].(string); ok && v != "" {
|
||||
params["fp"] = v
|
||||
}
|
||||
if b, ok := cfg["skip-cert-verify"].(bool); ok && b {
|
||||
params["allowInsecure"] = "1"
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func transportParams(cfg map[string]interface{}) map[string]string {
|
||||
params := map[string]string{}
|
||||
if v, ok := cfg["ws-path"].(string); ok && v != "" {
|
||||
params["type"] = "ws"
|
||||
params["path"] = v
|
||||
}
|
||||
if headers, ok := cfg["ws-headers"].(map[string]interface{}); ok {
|
||||
if host, ok := headers["Host"].(string); ok && host != "" {
|
||||
params["host"] = host
|
||||
}
|
||||
}
|
||||
if v, ok := cfg["grpc-service-name"].(string); ok && v != "" {
|
||||
params["type"] = "grpc"
|
||||
params["serviceName"] = v
|
||||
}
|
||||
if xhttp, ok := cfg["xhttp-config"].(map[string]interface{}); ok {
|
||||
params["type"] = "xhttp"
|
||||
if path, ok := xhttp["path"].(string); ok && path != "" {
|
||||
params["path"] = path
|
||||
}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func shadowsocksURIs(name, host, port string, cfg map[string]interface{}) ([]string, error) {
|
||||
cipher, _ := cfg["cipher"].(string)
|
||||
password, _ := cfg["password"].(string)
|
||||
if cipher == "" || password == "" {
|
||||
return nil, fmt.Errorf("shadowsocks listener requires cipher and password for URI export")
|
||||
}
|
||||
encoded := base64.RawStdEncoding.EncodeToString([]byte(cipher + ":" + password))
|
||||
return []string{addName("ss://"+encoded+"@"+netutil.JoinHostPort(host, port), name)}, nil
|
||||
}
|
||||
|
||||
func vlessURIs(name, host, port string, cfg map[string]interface{}) ([]string, error) {
|
||||
rows := userRows(cfg)
|
||||
if len(rows) == 0 {
|
||||
return nil, fmt.Errorf("vless listener requires at least one user for URI export")
|
||||
}
|
||||
result := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
uuid, _ := row["uuid"].(string)
|
||||
if uuid == "" {
|
||||
return nil, fmt.Errorf("vless user uuid is required")
|
||||
}
|
||||
params := tlsParams(cfg)
|
||||
params["type"] = "tcp"
|
||||
if flow, _ := row["flow"].(string); flow != "" {
|
||||
params["flow"] = flow
|
||||
}
|
||||
// Client URI uses encryption (not server-side decryption).
|
||||
if encryption, _ := cfg["encryption"].(string); encryption != "" {
|
||||
params["encryption"] = encryption
|
||||
} else {
|
||||
params["encryption"] = "none"
|
||||
}
|
||||
if reality, ok := cfg["reality-config"].(map[string]interface{}); ok {
|
||||
params["security"] = "reality"
|
||||
publicKey, err := realityPublicKey(reality)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
params["pbk"] = publicKey
|
||||
if sid := realityShortID(reality); sid != "" {
|
||||
params["sid"] = sid
|
||||
}
|
||||
if sni, ok := firstString(reality["server-names"]); ok {
|
||||
params["sni"] = sni
|
||||
}
|
||||
if params["fp"] == "" {
|
||||
params["fp"] = "chrome"
|
||||
}
|
||||
}
|
||||
for k, v := range transportParams(cfg) {
|
||||
params[k] = v
|
||||
}
|
||||
result = append(result, addName(query("vless://"+url.PathEscape(uuid)+"@"+netutil.JoinHostPort(host, port), params), name))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func vmessURIs(name, host, port string, cfg map[string]interface{}) ([]string, error) {
|
||||
rows := userRows(cfg)
|
||||
if len(rows) == 0 {
|
||||
return nil, fmt.Errorf("vmess listener requires at least one user for URI export")
|
||||
}
|
||||
result := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
uuid, _ := row["uuid"].(string)
|
||||
if uuid == "" {
|
||||
return nil, fmt.Errorf("vmess user uuid is required")
|
||||
}
|
||||
aid := stringValue(cfg["alterId"], "0")
|
||||
cipher := stringValue(cfg["cipher"], "auto")
|
||||
obj := map[string]string{"v": "2", "ps": name, "add": host, "port": port, "id": uuid, "aid": aid, "scy": cipher, "net": "tcp", "type": "none"}
|
||||
if tls, ok := tlsParams(cfg)["security"]; ok && tls == "tls" {
|
||||
obj["tls"] = "tls"
|
||||
}
|
||||
if sni := tlsParams(cfg)["sni"]; sni != "" {
|
||||
obj["sni"] = sni
|
||||
}
|
||||
if fp := tlsParams(cfg)["fp"]; fp != "" {
|
||||
obj["fp"] = fp
|
||||
}
|
||||
if ws, ok := cfg["ws-path"].(string); ok && ws != "" {
|
||||
obj["net"] = "ws"
|
||||
obj["path"] = ws
|
||||
}
|
||||
if grpc, ok := cfg["grpc-service-name"].(string); ok && grpc != "" {
|
||||
obj["net"] = "grpc"
|
||||
obj["path"] = grpc
|
||||
}
|
||||
if reality, ok := cfg["reality-config"].(map[string]interface{}); ok {
|
||||
// Share-link convention: mark Reality (not plain TLS).
|
||||
obj["tls"] = "reality"
|
||||
publicKey, err := realityPublicKey(reality)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
obj["pbk"] = publicKey
|
||||
if sid := realityShortID(reality); sid != "" {
|
||||
obj["sid"] = sid
|
||||
}
|
||||
if sni, ok := firstString(reality["server-names"]); ok {
|
||||
obj["sni"] = sni
|
||||
}
|
||||
if obj["fp"] == "" {
|
||||
obj["fp"] = "chrome"
|
||||
}
|
||||
}
|
||||
data, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, "vmess://"+base64.RawStdEncoding.EncodeToString(data))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func trojanURIs(name, host, port string, cfg map[string]interface{}) ([]string, error) {
|
||||
rows := userRows(cfg)
|
||||
if len(rows) == 0 {
|
||||
return nil, fmt.Errorf("trojan listener requires at least one user for URI export")
|
||||
}
|
||||
result := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
password, _ := row["password"].(string)
|
||||
if password == "" {
|
||||
return nil, fmt.Errorf("trojan user password is required")
|
||||
}
|
||||
params := tlsParams(cfg)
|
||||
for k, v := range transportParams(cfg) {
|
||||
params[k] = v
|
||||
}
|
||||
if reality, ok := cfg["reality-config"].(map[string]interface{}); ok {
|
||||
params["security"] = "reality"
|
||||
publicKey, err := realityPublicKey(reality)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
params["pbk"] = publicKey
|
||||
if sid := realityShortID(reality); sid != "" {
|
||||
params["sid"] = sid
|
||||
}
|
||||
if params["sni"] == "" {
|
||||
if sni, ok := firstString(reality["server-names"]); ok {
|
||||
params["sni"] = sni
|
||||
}
|
||||
}
|
||||
if params["fp"] == "" {
|
||||
params["fp"] = "chrome"
|
||||
}
|
||||
}
|
||||
result = append(result, addName(query("trojan://"+url.PathEscape(password)+"@"+netutil.JoinHostPort(host, port), params), name))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func hysteria2URIs(name, host, port string, cfg map[string]interface{}) ([]string, error) {
|
||||
users := userMap(cfg)
|
||||
if len(users) == 0 {
|
||||
return nil, fmt.Errorf("hysteria2 listener requires at least one user for URI export")
|
||||
}
|
||||
result := make([]string, 0, len(users))
|
||||
for username, raw := range users {
|
||||
password, ok := raw.(string)
|
||||
if !ok || password == "" {
|
||||
return nil, fmt.Errorf("hysteria2 user %q has empty password", username)
|
||||
}
|
||||
params := map[string]string{}
|
||||
if v, ok := cfg["sni"].(string); ok && v != "" {
|
||||
params["sni"] = v
|
||||
}
|
||||
if b, ok := cfg["skip-cert-verify"].(bool); ok && b {
|
||||
params["insecure"] = "1"
|
||||
}
|
||||
if v, ok := cfg["obfs"].(string); ok && v != "" {
|
||||
params["obfs"] = v
|
||||
}
|
||||
if v, ok := cfg["obfs-password"].(string); ok && v != "" {
|
||||
params["obfs-password"] = v
|
||||
}
|
||||
if v, ok := cfg["up"].(string); ok && v != "" {
|
||||
params["up"] = v
|
||||
}
|
||||
if v, ok := cfg["down"].(string); ok && v != "" {
|
||||
params["down"] = v
|
||||
}
|
||||
_ = username
|
||||
result = append(result, addName(query("hysteria2://"+url.PathEscape(password)+"@"+netutil.JoinHostPort(host, port), params), name))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func tuicURIs(name, host, port string, cfg map[string]interface{}) ([]string, error) {
|
||||
if token, ok := cfg["token"].(string); ok && strings.TrimSpace(token) != "" {
|
||||
return []string{addName("tuic://"+url.PathEscape(token)+"@"+netutil.JoinHostPort(host, port), name)}, nil
|
||||
}
|
||||
users := userMap(cfg)
|
||||
if len(users) == 0 {
|
||||
return nil, fmt.Errorf("tuic V5 listener requires at least one user for URI export")
|
||||
}
|
||||
result := make([]string, 0, len(users))
|
||||
for uuid, raw := range users {
|
||||
password, ok := raw.(string)
|
||||
if !ok || password == "" {
|
||||
return nil, fmt.Errorf("tuic user %q has empty password", uuid)
|
||||
}
|
||||
params := map[string]string{}
|
||||
for key, out := range map[string]string{"congestion-controller": "congestion_control", "bbr-profile": "bbr_profile", "udp-relay-mode": "udp_relay_mode", "max-udp-relay-packet-size": "max_udp_relay_packet_size"} {
|
||||
if v, ok := cfg[key].(string); ok && v != "" {
|
||||
params[out] = v
|
||||
}
|
||||
}
|
||||
if v, ok := firstString(cfg["alpn"]); ok {
|
||||
params["alpn"] = v
|
||||
}
|
||||
if v, ok := cfg["sni"].(string); ok && v != "" {
|
||||
params["sni"] = v
|
||||
}
|
||||
if b, ok := cfg["skip-cert-verify"].(bool); ok && b {
|
||||
params["allow_insecure"] = "1"
|
||||
}
|
||||
result = append(result, addName(query("tuic://"+url.PathEscape(uuid)+":"+url.PathEscape(password)+"@"+netutil.JoinHostPort(host, port), params), name))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func anytlsURIs(name, host, port string, cfg map[string]interface{}) ([]string, error) {
|
||||
users := userMap(cfg)
|
||||
if len(users) == 0 {
|
||||
return nil, fmt.Errorf("anytls listener requires at least one user for URI export")
|
||||
}
|
||||
result := make([]string, 0, len(users))
|
||||
for username, raw := range users {
|
||||
password, ok := raw.(string)
|
||||
if !ok || password == "" {
|
||||
return nil, fmt.Errorf("anytls user %q has empty password", username)
|
||||
}
|
||||
params := map[string]string{}
|
||||
if v, ok := cfg["sni"].(string); ok && v != "" {
|
||||
params["sni"] = v
|
||||
}
|
||||
if v, ok := cfg["client-fingerprint"].(string); ok && v != "" {
|
||||
params["fp"] = v
|
||||
}
|
||||
if b, ok := cfg["skip-cert-verify"].(bool); ok && b {
|
||||
params["insecure"] = "1"
|
||||
}
|
||||
if v, ok := cfg["idle-session-check-interval"].(string); ok && v != "" {
|
||||
params["idle_session_check_interval"] = v
|
||||
}
|
||||
if v, ok := cfg["idle-session-timeout"].(string); ok && v != "" {
|
||||
params["idle_session_timeout"] = v
|
||||
}
|
||||
if v, ok := cfg["min-idle-session"].(string); ok && v != "" {
|
||||
params["min_idle_session"] = v
|
||||
}
|
||||
_ = username
|
||||
result = append(result, addName(query("anytls://"+url.PathEscape(password)+"@"+netutil.JoinHostPort(host, port), params), name))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func realityPublicKey(cfg map[string]interface{}) (string, error) {
|
||||
if public, ok := cfg["public-key"].(string); ok && strings.TrimSpace(public) != "" {
|
||||
return public, nil
|
||||
}
|
||||
private, ok := cfg["private-key"].(string)
|
||||
if !ok || strings.TrimSpace(private) == "" {
|
||||
return "", fmt.Errorf("reality listener URI export requires reality-config.public-key or private-key")
|
||||
}
|
||||
var raw []byte
|
||||
var err error
|
||||
for _, decode := range []func(string) ([]byte, error){base64.RawStdEncoding.DecodeString, base64.StdEncoding.DecodeString, base64.RawURLEncoding.DecodeString, base64.URLEncoding.DecodeString} {
|
||||
raw, err = decode(strings.TrimSpace(private))
|
||||
if err == nil && len(raw) == 32 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(raw) != 32 {
|
||||
return "", fmt.Errorf("invalid Reality private key: expected 32 decoded bytes")
|
||||
}
|
||||
public, err := curve25519.X25519(raw, curve25519.Basepoint)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to derive Reality public key: %w", err)
|
||||
}
|
||||
return base64.RawStdEncoding.EncodeToString(public), nil
|
||||
}
|
||||
|
||||
// realityShortID returns the first short-id for client URI (official field is []string).
|
||||
func realityShortID(reality map[string]interface{}) string {
|
||||
if s, ok := firstString(reality["short-id"]); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstString(v interface{}) (string, bool) {
|
||||
if s, ok := v.(string); ok {
|
||||
return s, s != ""
|
||||
}
|
||||
if a, ok := v.([]interface{}); ok && len(a) > 0 {
|
||||
s, _ := a[0].(string)
|
||||
return s, s != ""
|
||||
}
|
||||
if a, ok := v.([]string); ok && len(a) > 0 {
|
||||
return a[0], a[0] != ""
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func stringValue(v interface{}, fallback string) string {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
PLACEHOLDER
|
||||
Reference in new issue
Block a user